Author SHA1 Message Date
vulncheck 0f372e36a2 fix(mobile-eol): Apple models endoflife.date names differently
Intune reports Apple's marketing name, matched against endoflife.date's
release label after normalising. Apple names the first iPhone SE, iPad
Air, iPad mini and iPad Pro of each size without a generation ("iPad Pro
(9.7-inch)"), while endoflife adds "(1st generation)", so none of them
matched. Most of these models are already EOL (9.7"/12.9" Pro and SE
2025-03-31, 10.5" Pro 2026-09-17, Air 2023, mini 2019), so they raised no
finding at all. endoflife also files the 12.9" Pro 6th generation as
"iPad Pro (6th generation)" and the iPhone Air as "17 Air".

_APPLE_ALIASES maps these exact normalised names to the endoflife release,
resolved in _resolve_device like a Samsung row. Every Apple model name
from the iPhone 3G and iPad 2 on now finds its release in the live data;
names outside the table still go through the label match.
2026-11-03 10:35:11 +01:00
vulncheck 0a081faa19 fix(mobile-eol): Galaxy Tab A9+ (2025) is not the 2023 Tab A9+
An SM-X216R showed the Tab A9+ end-of-support finding (active support
ended 2026-10-23, security 2027-10-23). SM-X216R and its Wi-Fi twin
SM-X210R are the 2025 re-release, which endoflife.date tracks as its own
release galaxy-tab-a9+2025, supported into 2031. The 2023 codes
(SM-X216B/U, SM-X210) share the base code, so the prefix match took the
2025 tablet for the 2023 one. Suffixed rows now sit above the base rows.

The wrong finding had a way out only through the detectedApps reconcile,
which does not run with detected apps off, an empty app list or a
co-managed device. A phone or tablet is one model, so the four mobile
slugs join _SINGLE_RELEASE_SLUGS, and a resolved model that raises no
finding of its own now supersedes any other model's finding on the asset.
2026-11-03 10:27:50 +01:00
vulncheck dc17419824 fix(app-scan): KeePassXC CVE-2026-69150 on 2.7.x
Intune and Wazuh list "KeePassXC" 2.7.12 and CVE-2026-69150 never matched.
GitHub's CNA states it as "< 2.8.0-beta1" ("Prior to 2.8.0-beta1", a
use-after-free in the KeePass1 .kdb import every 2.7.x ships), and
_ranges_from_affected floored every prerelease bound at its own train, so
only the 2.8.0 betas were in range. That floor exists for Wazuh
CVE-2026-67307 ("< 5.0.0-beta3", written in beta1).

A bound at the train's first alpha or beta ("-beta1", "-beta.0", "-alpha")
cannot fix a flaw written in that train, so the floor stays open and the
bound becomes the release itself ("< 2.8.0"; _vcmp ranks 2.8.0 below
"2.8.0-beta1"). Later prereleases, "-alpha-3", commit suffixes and any rc
keep the floor. Over the current cvelistV5 ZIP 69 blocks read differently;
CVE-2026-69150 is the only one on a registry product. Index key bumped to
v63.
2026-11-02 14:55:37 +01:00
vulncheck 6da9032d38 fix(app-scan): keep Authenticator's platform-neutral NVD bounds on their own line
CVE-2024-21390 was open on an iPhone running Microsoft Authenticator 6.8.57,
the current App Store build. NVD files it as microsoft:authenticator with
target_sw * and versionEndExcluding 6.2401.0617. MSRC and cvelistV5 name no
platform either. The bound is an Android build (6.YYMM.NNNN), while iOS
numbers 6.N.N, so every iOS version compared below it and the finding could
never close.

authenticator_same_line drops a hit whose fix is on the other app's line,
judged by the asset's OS. Android builds before 6.2401.0617 keep the CVE.
The open iPhone finding closes on the next app scan through the normal
reconcile.
2026-11-02 13:38:47 +01:00
vulncheck 5dd26e7076 feat(app-scan): IntelliJ IDEA without a release in the name
A name like "IntelliJ IDEA" or "IntelliJ IDEA CE" (the macOS app names)
did not resolve, since the Windows version field is a build
(262.8665.337) that no advisory bounds. Such a name now resolves and
takes the version field when it is a release (field_ver_re,
^20\d{2}\.\d+); a build is asked of no source. A release in the name
still wins over the field.
2026-11-02 12:09:58 +01:00
vulncheck 33ba013160 feat(app-scan): IntelliJ IDEA via NVD and cvelistV5
Intune lists "IntelliJ IDEA 2026.2.0.1" 262.8665.337 and "IntelliJ IDEA
2026.1.5" 261.27258.48 (JetBrains s.r.o.), and nothing matched
CVE-2026-64812 (< 2026.2, CVSS 10) or the 2026.2.x batch. The version
field is the build; both sources bound the release, so it comes from the
name, and a name without one does not resolve. Community and Ultimate
share jetbrains:intellij_idea.

Most JetBrains records are "0 .. < 2026.2.1". jetbrains_affected reads
the rest per YYYY.N line: several fixes in one lessThan (CVE-2026-41882:
"2024.3.7.1, 2025.1.7.1, ..., 2026.1.1") and every line floored at the
first (CVE-2024-37051: 2023.1 .. < 2023.2.7 caught a patched 2023.2.8).
A bound that opens its line ("2026.2" in CVE-2026-59792), EAP bounds
and build numbers ("<173") are dropped.

NVD lists CVE-2026-41882's five fixes as exact affected versions, has
CVE-2024-37051 without its 2023.1 floor and CVE-2025-32054 as
2024.2.4 .. < 2024.3. Exact IntelliJ entries are ignored, and, as for
PostgreSQL, a CVE the intellij-idea index holds is the CNA's alone.
Index key bumped to v62.
2026-11-02 11:41:45 +01:00
vulncheck 5fe585f051 fix(assets): answer manager nodes in the software, coverage-gap and rescan buttons
4cc61d1b taught the nightly loops to skip manager nodes but missed the
on-demand endpoints behind the asset row. The software list and the
coverage gap still asked Wazuh for /syscollector/node:<name>/packages:
4.x answers 400, 5 "no software inventory", and the operator got that
as a popup. Rescan answered "successfully completed" for a sync that
returns before doing anything.

Both package endpoints now read through one helper, _wazuh_packages(),
which returns node_inventory() for a node: the server version, the same
one package the app scan checks. So the buttons show real data instead
of an error. Rescan has nothing to do on a node: the button is hidden
there and the endpoint refuses with a pointer to the App CVE re-scan.
2026-11-02 11:16:43 +01:00
vulncheck e0cbe64dd1 feat(app-scan): Intel Rapid Storage Technology via NVD and cvelistV5
Intune lists "Intel(R) Rapid Storage Technology" 17.5.9.1040 (Intel
Corporation), and nothing matched CVE-2022-43456. NVD has it as
intel:rapid_storage_technology with one range per release line
(17.0 .. < 17.11.3.1010.2). The CNA writes vendor "n/a", several product
spellings ("Intel(R) RST software", ...), and all lines in one sentence:
"before versions 16.8.5.1014.5, 17.11.3.1010.2, 18.7.6.1011.2 and
19.5.2.1049.5".

intel_rst_affected turns each bound into "0 .. < bound" and line_floor
starts it at its major, as NVD does; a single bound keeps no floor, also
as on NVD. Two bounds on one major are dropped: CVE-2021-0104's
"18.0.0.640 and 18.1.0.24" are where NVD's ranges start, not end.

RSTe, the RST Application (CVE-2025-24327, own numbering) and the
"installation software" record are not matched. Index key bumped to v61.
2026-11-02 11:05:25 +01:00
vulncheck d4fa3043c5 feat(app-scan): Dell SupportAssist via NVD and cvelistV5
Intune lists "Dell SupportAssist" 3.14.0.91 (Dell Inc.), and nothing
matched CVE-2024-52535 (symlink attack in the remediation component).
Both sources split it by edition: NVD as dell:supportassist_for_home_pcs
< 4.6.2 and dell:supportassist_for_business_pcs < 4.5.1, cvelistV5 as
"Dell / SupportAssist for Home PCs" and "... for Business PCs".

The editions share version numbers but not bounds, so the name decides:
plain "Dell SupportAssist" is Home, Business registers as "Dell
SupportAssist for Business PCs" (whole_name, so the companion rule keeps
it). Remediation, OS Recovery Plugin and SupportAssistAgent stay out.

Dell's newer records write the floor as "N/A". That reads as a version
which compares to nothing, so the cvelistV5 half never saw them. For the
two SupportAssist keys it is now read as 0, the way NVD reads the same
records; other keys keep the word-floor rule. Index key bumped to v60.
2026-11-02 10:48:44 +01:00
vulncheck c8ced4773a docs(netdisco): Testertext um Ubiquiti, Brocade und Migration 053 erweitert 2026-11-02 10:23:18 +01:00
vulncheck 9445b8d32e fix(app-scan): shorten the Data-jobs scan dialog and drop wrong sources
The confirm dialog listed Microsoft 365 Apps and endoflife.date as app-scan
sources. Neither runs in a full scan: M365 only runs on a single-asset scan
(the nightly chain runs its own M365 step first), and EOL has its own job.
The dialog now says so and leaves out the source list and the cvelistV5
download size, which changes over time.
2026-11-02 10:23:18 +01:00
vulncheck 8db4413cfd fix(netdisco): judge Aruba 2930F/M on a CX sysObjectID as ArubaOS-Switch
Newer Aruba-branded 2930F/2930M answer the AOS-CX class in SNMP but boot
ProVision "WC.16.11.x" images. aruba_family took the slug's word for it and
treated them as AOS-CX, where a 16.x version matches nothing, so these
switches got no firmware CVEs at all. The version now decides: a ProVision
major (15/16) on a CX slug is "aruba-switch".
2026-11-02 10:23:17 +01:00
vulncheck e9e603800a feat(mail): master switch for every outgoing mail
A new setting `mail_enabled` (Settings → Notifications, "Send emails",
default on) stops every mail TrueVuln sends: new-vulnerability
notifications, SLA breaches, KEV alerts and sync-failure alerts. send_email
refuses without it, and the alert jobs skip before they log a FAILED row per
recipient. Off drops the mail; nothing is queued for later. The SMTP test
mail (config_override) still goes out.
2026-11-02 10:23:17 +01:00
vulncheck 85a1c4c4cd feat(app-scan): Microsoft Authenticator for iOS via NVD and cvelistV5
Intune lists the iOS app by its home-screen name, "Authenticator", with no
vendor: 6.8.57 on an iPhone, 6.8.56 on an iPad. No row matched it.
CVE-2026-26123 (< 6.8.40) and CVE-2026-41615 (< 6.8.47) name both apps:
NVD as microsoft:authenticator with target_sw iphone_os and android,
cvelistV5 as "Microsoft Authenticator for IOS" and "... for Android",
with no `platforms`.

MSRC's "for iOS" is the one app on iPhones and iPads, so _platform_ok now
reads NVD's iphone_os as holding for iPadOS too, and an iPad is given the
iOS fix. This applies to every iOS app, not only Authenticator. Each NVD
entry keeps its own target_sw, so the Android bound (6.8.57 < 6.2511.7533)
never reaches an Apple device, and CVE-2026-80097 stays Android-only.

Both scans go through authenticator_on_own_os: the Android package id on
Android, the iOS names on iOS/iPadOS from version 6 on, and nothing that
another vendor signs. Google Authenticator also appears as "Authenticator"
on iOS, on its own 4.x line. Index key bumped to v59.
2026-11-02 10:04:17 +01:00
vulncheck d09208a337 fix(cvelistv5): build the index from CVE records only, close nothing without it
From 01.11. every cvelistV5 index build died with "'list' object has
no attribute 'get'". The walk read every *.json under cves/, which also
holds delta.json and deltaLog.json (a 35 MB list). The vendor
pre-filter skipped both until the Git for Windows row (31.10.) curated
the vendor "git", which every "githubLink" in them contains. One file
killed the whole build. The same release moved the setting key to v58,
so there was no older index to load either.

The app scan then skipped every cvelistV5 pass but still reconciled,
so the app-scan findings only cvelistV5 had found were closed as
patched.

The walk now reads cves/**/CVE-*.json only (against the live ZIP: of
417,810 files, only the two delta files drop out). A record it cannot
read or that no branch expects is skipped with a warning naming it,
instead of costing the index. build_product_index raises when it has
neither a ZIP nor a cache, or when the walk yields nothing, rather than
returning {}. The app scan treats a missing index as no verdict and
skips its reconcile for the run (resolve_skipped_cve5_unavailable).
2026-11-02 09:52:55 +01:00
vulncheck 4cc61d1b29 fix(wazuh): skip manager nodes in SCA, exposure, EOL, M365 and extensions
Master and worker nodes are assets with the pseudo agent id node:<name>.
After the vulnerability sync, the other per-agent reads still asked
Wazuh about them. On 5 each one raised "no software inventory" for
every node on every run: the SCA refresh, exposure, the EOL check (on
demand and nightly), the M365 check and the app scan's browser
extensions.

The extensions case did real harm. The failed fetch marked the node's
inventory incomplete, so its app-scan findings (the Wazuh version's
own CVEs) were never reconciled and could not close after an upgrade.

is_manager_node() now sits next to NODE_AGENT_PREFIX and
node_inventory(). Each of these loops skips a node before reading.
The app scan still checks the node's version as before.
2026-11-01 19:21:21 +01:00
vulncheck ac8addcd34 fix(wazuh): don't ask Wazuh for the vulnerabilities of a manager node
Master and worker nodes are assets with the pseudo agent id node:<name>,
so the app scan can check their own version. The autoscan, the nightly
scan and the asset rescan passed that id on to Wazuh like an agent's.

On 4.x the indexer answered with nothing, and the node counted as an
empty agent. On 5 the "no inventory means no data" check raised for
every node on every scan ("Agent node:master has no software
inventory ..."). A node runs no agent, so the sync now returns before
asking Wazuh: no error, and no empty agent for the reconcile.
2026-11-01 19:10:43 +01:00
vulncheck 4364d2f8e6 fix(wazuh): don't move a new host onto an old asset via a reused agent id
Agents that re-enroll on a fresh Wazuh 5 server get ids from 001 again.
The asset sync matched by agent id first, so the new 001 inherited the old
001's asset, findings and history.

The id now stops matching when the agent's name is a different host than
the asset's (case and FQDN vs short name tolerated) and its dateAdd is
later than the registration stored on the asset (new column
wazuh_registered_at, migration 066), or later than its last keepalive on
rows synced before 066. Either condition alone keeps the asset, so a
rename by another source and a migrated identity stay put. The old asset
is unlinked with an audit entry, and the agent goes through the hostname
stage. The vulnerability sync applies the same rule and skips such an
agent until the next asset sync.
2026-11-01 11:54:35 +01:00
vulncheck 1d413e58ba feat(wazuh): read vulnerabilities, inventory and SCA from Wazuh 5.0
Wazuh 5 is a new deployment, not an in-place upgrade, and it moved
everything the client read:
- /syscollector/*, /sca/* and /manager/* are gone from the API.
  Inventory and SCA now exist only in the indexer.
- States documents carry the agent as wazuh.agent.id. The mappings are
  dynamic: strict, so a term on agent.id matches nothing and raises
  nothing.
- The indices lost their cluster suffix. wazuh-states-vulnerabilities-*
  matched nothing on 5.
- There is no wazuh-alerts-* index.

The client finds the server version on its own, from GET / api_version.
If the manager API does not answer, it probes the indexer for the
unsuffixed 5 index (404 or 403 means 4.x), so the CVE sync keeps working
while the API is down, as it did on 4.x.

On 5 it queries the exact index names. The documented migration restores
4.x indices next to them as read-only history, and a pattern would read
that history too. Packages, ports and SCA are mapped back to the shapes
the 4.x API returned, so callers are unchanged. The SCA summary is
counted from the per-check documents.

A 5 server drops the inventory of every agent before 5.0, and a 5.x agent
has none until its first sync. For such an agent an empty answer is no
data, not a clean host, so it now raises per agent: its findings stay
open instead of being closed.

Also fixed, for 4.x as well:
- The fixed version was read from vulnerability.condition, which neither
  mapping has. It is now parsed from vulnerability.scanner.condition,
  and only "Package less than X" and "Fixed in X" name a fix.
- get_agent_by_id called GET /agents/{id}, which is in no API spec, so
  "Verify patch" never saw the rescan. It now uses
  GET /agents?agents_list=<id>.
- Removed get_vulnerability_summary and get_os_info. They had no callers
  and their endpoints no longer exist.

Checked against wazuh/wazuh@v5.0.0-rc2 (spec.yaml) and
wazuh/wazuh-indexer-plugins@v5.0.0-rc2 (templates/states). 5.0.0 is not
GA yet.
2026-11-01 11:42:08 +01:00
vulncheck a901efa3a3 feat(app-scan): match KeePassXC via NVD and cvelistV5
Intune lists "KeePassXC" 2.7.12 (KeePassXC Team), and nothing matched
CVE-2026-118673 (out-of-bounds write in browser integration, >= 2.3.0,
< 2.8.0). NVD has not analysed the 2026 records yet and carries no CPE
for them, so the cvelistV5 row is the path that sees them: GitHub's CNA
writes vendor "keepassxreboot", ZDI vendor "KeePassXC" with one exact
version (CVE-2026-4158, 2.7.11). The NVD row asks keepassxc:keepassxc
for the analysed ones (CVE-2023-35866, CVE-2024-33900). Index key bumped
to v58.

Windows only, since a distro "keepassxc" hides backports. The name is
anchored: KeePass, KeePassDX and KeePassXC-Browser stay out.
CVE-2026-69150 ("< 2.8.0-beta1") does not reach 2.7.x on cvelistV5,
because a prerelease bound is floored at its own train; NVD's CPE will
carry it once analysed.
2026-10-31 17:03:40 +01:00
vulncheck da89959aae feat(app-scan): match Git for Windows via NVD and cvelistV5
Intune lists Git for Windows as plain "Git" 2.38.1 (The Git Development
Community), and nothing matched: the old row asked NVD only for git:git,
and only for "Git for Windows" / "Git version control". NVD splits Git's
CVEs over three CPE names; at 2.38.1 git-scm:git holds 12 (CVE-2023-23946,
2.38.0 .. < 2.38.4), git:git 4 (CVE-2024-32002, < 2.39.4) and
git_for_windows_project:git_for_windows 5 (CVE-2023-29011, < 2.40.1).
The row now asks all three.

cvelistV5: GitHub's CNA writes one range per maintenance line into the
version field; core records are vendor "git", the Windows-only ones
"git-for-windows", both product "git". Index key bumped to v57.

_ranges_from_affected now reads GitHub's "= 2.45.0" as 2.45.0. Matching
was already exact (only digits are compared), but the "=" reached the
shown fix as ">= 2.45.0". This applies to every cvelistV5 product.

Windows only, since a distro "git" hides backports. The name is anchored:
GitHub Desktop, TortoiseGit, Git LFS and git-gui stay out.
2026-10-31 16:55:20 +01:00
vulncheck 4de54c1052 Merge pull request 'fix(msrc): rebuild the MSRC index right before the nightly app scan' (#4) from claude/admiring-swirles-ddde8e into dev
Reviewed-on: #4
2026-10-31 16:31:54 +01:00
vulncheck ac966cb7ed fix(app-scan): pair Blender's "Blender Foundation" spelling on cvelistV5
Field case: Blender 5.0.0 (Intune) and 5.0.1 (Wazuh) got CVE-2026-60103
but none of CVE-2026-119718..119722, published 2026-10-30.

The same CNA (VulnCheck) wrote the new records as vendor "Blender
Foundation" / product "Blender", while CVE-2026-60103 says "blender" /
"blender". Only the latter was paired, so the five records passed the byte
prefilter and were dropped at pairing. A fresher nightly index would not
have changed that. NVD has CVE-2026-119722 "Awaiting Analysis" with no
configuration, so cvelistV5 is still the only structured source.

Each record states two lines, "0" .. lessThan "4.5.15" and "5.0.0" ..
lessThan "5.2.3"; they are matched per line, so 5.0.x is shown the 5.2.3
fix and the 4.5 LTS line the 4.5.15 one. All five IDs go through the real
index build in the test. Index key bumped to v56.
2026-10-31 16:23:57 +01:00
vulncheck 49e7c55938 feat(nginx): harden the bundled nginx along the CIS NGINX Benchmark v3.1.0
The bundled nginx met about half of the benchmark's Level 1 items: it
answered for any Host, kept the 60-65 s default timeouts, allowed every
method and 100 MB bodies everywhere, served config files 644 and sent no
nosniff, Referrer-Policy or CSP on Next.js pages.

Now nginx answers only for the names on the certificate (TLS_SERVER_NAMES,
the DASHBOARD_URL host, localhost, 127.0.0.1, built by a new .envsh) and
closes the connection for any other Host. No ssl_reject_handshake, since
browsers send no SNI for an IP. TLS 1.3 only, with session tickets back on
(4.1.11) and HSTS at two years. Timeouts are 10 s, methods are limited to
what frontend and API use, bodies are 1 MB except 100 MB under /api/.
/etc/nginx is 750/640, there is an error page without nginx branding,
dot paths return 404 and X-Powered-By is hidden. The CSP is added only
where the upstream sent none, so backend responses keep theirs.

Clients without TLS 1.3 and Host names outside the list stop working.
Both cases are documented in README and TROUBLESHOOTING. README.DEV.md
maps every recommendation, including the Level 2 items not taken and why.
Verified on the built image with nginx -t, the benchmark's curl audits
and the login page under the CSP in a browser.
2026-10-31 16:13:12 +01:00
vulncheck 2d7c1e263e fix(msrc): rebuild the MSRC index right before the nightly app scan
CLA Assistant / cla (pull_request_target) Canceled after 0s
MSRC files Edge CVEs in the US afternoon (26.10.2026 22:5x-23:5x UTC, 37
CVEs; 27.09. 23:2x; 30.09. 20:2x). The crons run on local time, so under
CEST 23:50 UTC is 01:50. That is after the 01:30 index build, and the 03:00
chain reused that build (6h), so those Edge CVEs reached hosts a night late.

app_cve_scan_nightly now rebuilds the MSRC fixed-build index before it
decides. With the per-revision CVRF cache, this downloads only the documents
revised since 01:30, and a failed rebuild keeps the 01:30 index. The MSRC
step reuses the new build.

The 01:00-02:45 UTC batch that revises half the documents every night is
not the cause. It is Azure Linux (azl3) and touches no product in the index.

Also repairs tests/test_msrc_edge_chromium_cve.py, which broke with
ad2abc63: its fake response had no status_code. It also no longer writes
into the real /tmp CVRF cache.
2026-10-31 15:52:39 +01:00
vulncheck 66318ac901 docs(msrc): correct the CVRF cache saving and record why detection holds
Microsoft revised 9 of 18 documents on 31.10.2026, not one or two, so the
six-day cache saves about half the nightly downloads. Detection does not
depend on the cap: the newest CVE revision in each document equalled the
revision /updates listed for it, so a revised CVE always misses the cache.
2026-10-31 15:37:30 +01:00
vulncheck ef72de0e5e feat(app-scan): match Cisco Webex App and Webex Meetings via NVD and cvelistV5
Intune lists the Webex App as "Webex" 41.10.0.20213 (Cisco Systems, Inc),
Wazuh the Meetings desktop app as "Cisco Webex Meetings" 42.12.0 (Cisco
Webex LLC), and no row matched either. NVD files the Webex App under
cisco:webex_teams (CVE-2022-20863, < 42.7) and Meetings under
cisco:webex_meetings (CVE-2026-20233, 56 exact versions incl. 42.12.0).

cvelistV5: Cisco states Meetings versions in short form ("42.12"), read as
exactly that version, and the Webex App as "Cisco Webex Teams". Its record
for CVE-2022-20863 is "n/a", so NVD alone carries that one. CVE-2020-3155
is webex_meetings:* with no bound on NVD and stays out, as every unbounded
match does: no upgrade could close it. Index key bumped to v55.

Windows and macOS only, since Intune names the iOS app plain "Webex" too.
Names are anchored; Productivity Tools, Meetings Server and the VDI app
stay out.
2026-10-31 11:52:08 +01:00
vulncheck a336db4a9c feat(assets): sortable Open Vulns column in the asset table
The asset list already returned each asset's open-finding count (the
CSV export's "Open Vulnerabilities"), but the table never showed it.
It now has its own column between Status and Exposure, with a tooltip.

Sorting is server-side, so it spans every page: a correlated COUNT of
OPEN findings, tie-broken by hostname. The images endpoint used the
same subquery inline; both now share _open_vuln_count(). Measured at
1200 assets / 500k findings: ~18 ms per page when sorting by the
column, against ~7 ms by hostname. The default view is unchanged.
2026-10-31 11:26:31 +01:00
vulncheck 6aab200157 feat(app-scan): Microsoft Authenticator for Android via NVD and cvelistV5
Intune lists the Android app by its package id, "com.azure.authenticator"
6.2608.5658, and no row matched it. CVE-2026-80097 (improper
authentication, local EoP) is < 6.2606.4246 on both sources: NVD files
it under microsoft:authenticator with target_sw android, cvelistV5 as
"Microsoft / Microsoft Authenticator for Android" with no `platforms`.

Both scans skip the row on any asset whose OS is not Android, so NVD is
not asked for other hosts and the platform-less cvelistV5 record cannot
reach them. The name is anchored; the iOS app
(com.microsoft.azureauthenticator) stays out. Index key bumped to v54.
2026-10-30 16:43:43 +01:00
vulncheck 21398d34d6 fix(cvelistv5): drop the product name ZDI writes into the version
ZDI states CVE-2024-7565 as product "SoapUI", version "SoapUI 5.7.0". The
match was right, but the fix read ">SoapUI 5.7.0", and a product name with
digits would have been read as part of the version. A version that starts
with the record's own product name is now read without it.
2026-10-30 15:50:30 +01:00
vulncheck 619429480a feat(app-scan): match SoapUI via NVD and cvelistV5
Wazuh and Intune list "SoapUI 5.5.0" and "SoapUI 5.9.1" (SmartBear
Software), and no row matched it. NVD files open-source SoapUI under
smartbear:soapui with 4 CVEs; CVE-2019-12180 is "<= 5.5". NVD compares the
queried version itself and reads 5.5.0 as past 5.5, so a versioned
virtualMatchString answers nothing for 5.5.0. SoapUI is now asked for
without a version and _in_range decides, which also reads CVE-2024-7565's
typo ".5.7.0" as 5.7.0.

cvelistV5: MITRE's records are "n/a / n/a" (CVE-2019-12180 included), ZDI
states CVE-2024-7565 as "SoapUI 5.7.0", read as exactly 5.7.0. Index key
bumped to v53. Names are anchored, so ReadyAPI (formerly SoapUI Pro) stays
out.
2026-10-30 15:28:08 +01:00
vulncheck cf2aa4b84d fix(app-scan): the Nextcloud iOS app is not the desktop client
An iPhone got CVE-2021-22879 (desktop client < 3.1.3) for Intune's
"Nextcloud 0 (35.2.1)". Two causes together: the desktop row matches the
plain name "Nextcloud", which Intune also gives the iOS and Android apps,
and Intune states iOS app versions as "build (version)", so the scan read
build 0, which sits below every bound.

The desktop CVEs (NVD nextcloud:desktop and the nextcloud/security-advisories
feed) now apply on Windows and macOS only, as FileZilla's do. Intune versions
of the form "<integer> (<dotted version>)" are read as the version in
brackets. Open findings on mobile devices close on the next app scan.
2026-10-30 15:13:29 +01:00
vulncheck eed357d123 feat(app-scan): FileZilla Pro takes the FileZilla Client CVEs
NVD names only filezilla-project:filezilla_client. FileZilla Pro is that
client with more protocols: its own version history uses the same numbers
since 3.25.2 and lists the same fixes (Terrapin in 3.66.4, P-521 in 3.67.0).
The shared name pattern now also matches "FileZilla Pro" and "FileZilla Pro
3.66.4". The Pro CLI and FileZilla Server stay out.
2026-10-30 14:18:38 +01:00
vulncheck 90d3ad4898 fix(app-scan): FileZilla Client CVEs come from filezilla_client on NVD
Wazuh and Intune list "FileZilla Client 3.46.3" and "FileZilla Client
3.19.0" (Tim Kosse) with no finding. The registry asked NVD for
filezilla:filezilla, a CPE NVD has never used. Every client CVE sits under
filezilla-project:filezilla_client: CVE-2019-5429 (< 3.41.0),
CVE-2023-48795 (Terrapin, < 3.66.4) and CVE-2024-31497 (PuTTY P-521,
< 3.67.0). filezilla-project:filezilla holds none. cvelistV5 has no usable
range for them (MITRE "n/a"; CVE-2019-5429 names only the fix).

- The CPE row now queries filezilla_client.
- One anchored name pattern, shared with the cvelistV5 row, matches
  "FileZilla Client 3.46.3", "FileZilla 3.67.0" and bare "FileZilla".
  FileZilla Server (own CPE, own 0.9.x/1.x numbering) and FileZilla Pro no
  longer resolve to the client.
- Windows and macOS hosts only, on both paths: a distro package with a
  clean version would hide backported fixes.
2026-10-30 14:15:19 +01:00
vulncheck 70102b225a fix(app-scan): an OS-scoped CVE stays off hosts running another OS
NVD writes "Chrome on Android" as an AND of the app (target_sw *) and a
non-vulnerable OS node, and the parser read it flat, as platform-neutral.
cvelistV5 carries no platforms for those records, only the text. So
CVE-2026-11163 (Chrome on Android < 149.0.7827.53) landed on every
Windows and Mac Chrome below the bound, and "Chrome on Windows" CVEs on
Macs.

The NVD parser now takes the running-on OS as the match's platform; an
OS it cannot place keeps the match neutral, as before. Where neither
target_sw nor the configuration names a platform, both paths read
Google's "Google Chrome on <OS>" and Mozilla's "only affects Firefox
for/on <OS>" from the description, and nothing looser.
2026-10-30 14:02:19 +01:00
vulncheck 243cc7c86b fix(app-scan): TeamViewer.Client 1.0.0.0 is an MSIX package, not the client
Intune lists TeamViewer's shell-integration MSIX package "TeamViewer.Client
1.0.0.0" (publisher "CN=TeamViewer Germany GmbH, ...") beside the real
"TeamViewer 15.83.3.0". The unanchored name pattern took it for the client
and the 1.0.0.0 package version for a build below every fix, so the app-scan
reported CVE-2020-13699 on a patched host.

The shared inventory predicate (_is_citrix_shim, now _is_stub) also drops
any MSIX package at a placeholder version (1.0.0.0 and the other
teams_placeholder_version shapes). A package is recognised by its signer DN
as publisher (Intune) or its dotted identity name (Wazuh). Every scanner and
EOL already route through this predicate. A 1.0.0.0 on a plain ARP row
stays, because the number alone does not prove the row is fake. The
TeamViewer name patterns (NVD, cvelistV5, bulletins) also exclude
"TeamViewer.Client" whatever its version. The open finding closes on the
next app-CVE scan.
2026-10-30 13:56:36 +01:00
vulncheck 79a2c002a0 feat(intune): a setting keeps Intune/Defender inventory out of the app-CVE scan
An operator who wants only Defender TVM CVEs on Intune devices had no way to
get that. Intune devices reach the app-CVE scan three ways: the Intune sync's
pass over detectedApps, the Defender sync feeding its software inventory into
the same pipeline, and the App-Scan job (nightly, data job, asset button)
fetching detectedApps for every Intune-inventory asset. Only the first read a
setting (`detected_apps`); the other two had none, so unticking it still left
app-scan findings on those devices.

New Intune setting `app_scan_intune` (default on, behaviour unchanged) gates
all three. Off: no detectedApps fetch in the App-Scan job, no app-CVE pass in
either sync. EOL, M365 and the iOS OS passes keep running, and a host with a
live Wazuh agent is still scanned from syscollector. Each run retracts the
app-scan source from package findings on ACTIVE Intune-inventory assets, so
the findings it left behind can close; a finding with another source (e.g.
defender) stays open, and the audit reason names the setting.
2026-10-29 11:28:36 +01:00
vulncheck 0a73b8cee2 feat(app-scan): Veeam Agent for Microsoft Windows on both paths
KB4902 (CVE-2026-58068, CVE-2025-64391) sits at NVD "Awaiting Analysis",
so cvelistV5 is the only structured source; the older agent CVEs
(CVE-2025-48982, CVE-2024-29853) are NVD's under
veeam:veeam_agent_for_windows, the only CPE NVD has for this agent.

- New veeam-agent-windows key and CPE, anchored to the agent row. The
  "Redistributable" beside it is the installer payload Veeam keeps for
  deploying the agent, not the agent that runs.
- "Veeam Agent for ..." reads as a companion of Windows and was cut down
  to "Veeam", so the name never resolved. A whole_name flag on the entry
  lets a pattern that spells out the full name skip that rule.
- The agent records write "0 .. < 13.1" for "all earlier version 13 and
  6 builds"; the 0 floor becomes 6, the oldest line Veeam names. 13.1 is
  build 13.1.0.544, the first 13.1 build, so the bound needs no build.
- CVE-2026-32996 (KB4852) is an agent flaw filed under Backup and
  Replication and fixed by B&R 13.0.2.29 with no agent build; neither
  product takes it.
- cvelistV5 index bumped to v52.
2026-10-29 10:43:53 +01:00
vulncheck ad2abc63d4 fix(msrc): a 429 from the MSRC API no longer drops the nightly catch-up
On 29.10.2026 at 04:34 the remediation catch-up made one call to
/cvrf/v3.0/updates, got 429 Too Many Requests and aborted, although both
documents it needed were cached by the 01:30 index build. MSRC documents no
quota, offers no API key (v3.0 is anonymous) and sends neither Retry-After nor
rate-limit headers, so the throttling has to be absorbed on our side.

- Every CVRF call goes through _get. It retries 429, 5xx and network errors
  after 20/60/180 s (or Retry-After), and other 4xx raise at once.
  MsrcUnavailable stops both document walks instead of hammering on.
- A failed /updates falls back to the last listing that worked. Documents
  never leave /updates, so an index built from it cannot lose a month.
- CVRF documents stay cached per revision for 6 days instead of 4 h. The
  01:30 build downloaded all 18 documents (20-30 MB each, uncompressed) every
  night. Now it only downloads the revised ones.
- The on-demand Update Guide call no longer caches a 429/5xx as "CVE not in
  MSRC" until restart.
- Failed documents in the ingest and scan errors are logged as warnings;
  every caller dropped them from its log line.
2026-10-29 10:26:06 +01:00
vulncheck cfd48b8318 fix(reports): the technical CSV export is one query and shows progress
The Technical Vulnerability Report loaded every finding as a full ORM row
and lazy-loaded its asset, user and group one by one, then built the whole
file in memory before the first byte went out. On a large estate that took
minutes, and the Reports page gave no sign anything was happening.

The export now selects only the printed columns in one joined query and
streams the CSV in blocks of 1000 rows. Same scope and columns as before.
The Reports page shows a spinner, then the MB received, and locks the other
download buttons while one runs; the four copied download branches are one.
2026-10-28 23:34:52 +01:00
vulncheck 718d84cb5f fix(settings): the Intune "Detected apps" box says what it gates
The checkbox read "Detected apps (EOL/M365)", which suggested it decides
whether Intune assets get app-scanned at all. It only gates phase 2 of the
Intune sync (Sync now and the 02:10 nightly), and that phase also runs the
app-CVE pass, not just EOL and M365. The App-Scan data job and the nightly
app scan never read the flag: they still scan every asset, Wazuh agents via
syscollector and Intune-only devices via detectedApps. Label and tooltip now
say so; behaviour is unchanged.
2026-10-28 23:28:58 +01:00
vulncheck 4daa7eb6de feat(app-scan): Veeam Backup & Replication and every Veeam ONE component
KB4948 (CVE-2026-108140/-108141/-108142) and KB4951 (CVE-2026-107671)
sit at NVD "Awaiting Analysis", so cvelistV5 is the only structured
source until NVD enriches them; both paths now know the products.

- New veeam-br key and CPE veeam:veeam_backup_\&_replication, anchored to
  the server row "Veeam Backup & Replication". The Console has its own
  build line, and the Installer / Mount services run on every proxy and
  repository.
- veeam-one now matches every component (Client, Monitor and Reporter
  Server, ...) but the Agent: all carry the product build and take the
  same patch, so the build decides. CPE veeam:one added.
- The NVD query encodes "&" as %26. Raw, it ended the parameter and NVD
  answered 404, which was cached as "no CVEs".
- veeam_affected keeps only real build ranges from Veeam's records. The
  older ones write a release where a build is meant ("13.0.1 .. < 13.0.1",
  "0 .. <= 13.0.2"), which read literally hit every 12.x and miss the
  build the KB names. Those are left to NVD.
- cvelistV5 index bumped to v51.
2026-10-28 23:24:43 +01:00
vulncheck 7a0f981a91 fix(priority): priority ranks findings everywhere, CPR only breaks ties
CPR needs CVSS and EPSS. A freshly published critical CVE has no EPSS yet,
so every CPR-ordered view put it below scored mediums. Priority always
exists (severity floor without CVSS) and already weighs KEV/EUVD, exploits,
asset criticality and age.

- Vulnerabilities list: default sort is priority again; priority ties are
  broken by CPR instead of row id.
- Recent Critical widget: still selects the newest, ranks them by priority;
  View all opens the list sorted by priority.
- New-vuln digest {{rows}}: ranked by priority, new Prio column before CPR.
  Custom digest templates need a Prio header cell.
- SLA digest: Prio column, ranked by priority then hours overdue.
- Single new-vuln, manual-notify and SLA mails offer {{priority_score}} and
  {{cpr_score}}; settings help and preview list them.
- PDF reports tie-break by CPR before CVSS; JEV triage puts unscored last.
- README.DEV records the rule and the deliberate exceptions (KEV alert,
  Mobile widget, patch plan, URS).
2026-10-28 12:16:00 +01:00
vulncheck 7ab06158b0 fix(dockhand): an image gone from every host closes its findings
Dockhand exports only images still present on a Docker host, so a
deleted image simply dropped out of the export. The sync treated that
silence as "leave alone", and the findings stayed open until the
time-based reconcile set the image INACTIVE six weeks later. An image
updated to a build with no findings left its old CVEs open the same way.

The sync now also reads GET /api/images, which lists every tag on the
host. A Dockhand-reported image whose tag is on no host any more loses
its dockhand source and goes INACTIVE; it reopens when pulled again.
An image still on a host but absent from the export is either clean or
not scanned yet, so it closes only after a nightly rescan that covered
every environment without a failure. Nothing is retracted when an
environment lists no images (unreachable or empty) or an image failed
to import. The retraction loop moved into container_scan_service.retract
so ingest_image and the Dockhand sync share it.
2026-10-27 17:36:59 +01:00
vulncheck 663cd5feb4 build(db): PostgreSQL 18 instead of 15, with a migration script
The postgres:18 image keeps its data in /var/lib/postgresql/18/docker and
refuses to start on a volume mounted at /var/lib/postgresql/data, so
compose mounts a new volume postgres18_data at /var/lib/postgresql. The
15 volume postgres_data is no longer declared: it is the rollback copy,
and 'docker compose down -v' would otherwise delete it.

tools/migrate_postgres_15_to_18.sh moves an existing install over: it
stops the stack, reads the old volume with a temporary 15 container,
dumps with pg_dump 18, restores into the new volume and compares every
table's row count, the sequences, roles and alembic_version, and the
login with POSTGRES_PASSWORD from .env over the network. It never
touches the old volume. The likely real-world order (new compose pulled,
18 already started on an empty database) is handled: that database is
moved aside inside the new volume, not deleted. The cluster is built in
18/migrating and moved to 18/docker only after the checks pass, so an
aborted run leaves nothing 'compose up' would start. A marker with the
old cluster's pg_control hash makes a re-run a no-op, and refuses when
the old cluster was used again after a rollback. An md5 hash of the app
user (deprecated in 18) is rewritten as SCRAM.

Dumps go to backups/, which .dockerignore now excludes, since the
backend build copies the whole directory into the image.

The README gets the operator steps under "Upgrading from PostgreSQL 15".
2026-10-27 16:01:30 +01:00
vulncheck b2da0c4261 build(backend): Python 3.14 instead of 3.11
python:3.14-slim is the newest base the requirements install on with
--only-binary: 3.15 is out, but xmlsec (python3-saml) and pyyaml
(uvicorn[standard]) publish no cp315 wheels yet. The local test venv
already runs 3.14, so tests and image now match.

Pins without cp314 wheels moved: pydantic 2.10.5 -> 2.13.5 (pydantic-core
2.27 has none, 2.13.5 is what the tests run), psycopg2-binary 2.9.10 ->
2.9.13, sqlalchemy 2.0.36 -> 2.0.52 (2.0.36 fell back to the pure-Python
wheel, without the C extensions).

Python 3.13+ sets VERIFY_X509_STRICT in ssl.create_default_context(): an
internal CA without keyUsage, or a server certificate without Authority
Key Identifier, fails there although 3.11 accepted it. ca_trust_service
clears the flag for the process (create_default_context for httpx,
_create_default_https_context for pyVmomi and urllib), so integrations
against older internal PKIs keep working after the upgrade. The test
talks real TLS to a server whose legacy CA has neither extension.
2026-10-27 16:01:11 +01:00
vulncheck a3c019ede5 fix(vulnerabilities): a Docker host's findings include its images
The host link in Assets opens the findings list with asset_id=<host>,
and that filter matched only findings on the host asset itself. Dockhand
puts every finding on the image asset and links the image to its host
through container_placements, so the list named an image "on docker-01"
while the filter on docker-01 came back empty. The filter now also takes
the images placed on the host; the list and /by-package share it.
2026-10-27 15:26:18 +01:00
vulncheck 9ea052af0d fix(mfsa): Android advisories no longer set the desktop Firefox fix
CVE-2026-113470 stayed open on a Windows host with Firefox 158.0.1,
although desktop got the fix in 158.0.1 (mfsa2026-111). Mozilla's
Android advisory mfsa2026-116 writes its fix as plain "Firefox 158.0.2"
in fixed_in and says "for Android" only in the title. The merge across
advisories takes the highest mainline fix, so 158.0.2 became the
desktop bound. The same shape made the Android-only CVE-2026-18809
(mfsa2026-73) hit every desktop Firefox below 153.0.3.

The parser now rewrites fixed_in of an advisory titled "for Android"
to "Firefox for Android X", which, like the iOS entries, is not a
mainline desktop fix. Rebuilt over all 2025/2026 advisories, only
these two CVEs change; the other 975 desktop bounds are identical.
2026-10-27 15:13:46 +01:00
vulncheck dde12cbfe4 build(images): patch the CVEs Trivy and Grype can fix
Dockhand scans the TrueVuln images with Trivy and Grype. Both reported
critical findings that already had fixes, all in packages the images
control:

- PyJWT 2.13.0 -> 2.15.1, and pip is uninstalled after the requirements;
  its vendored urllib3, msgpack and pkg_resources were the rest of the
  backend's fixable findings and nothing runs pip at runtime.
- Next.js 16.3.1 -> 16.4.3, sharp 0.35.5, source-map-js 1.2.2. The
  frontend builds on node:24-trixie-slim: bookworm keeps zlib and glibc
  CVEs Debian will not fix there. The runner drops npm, npx and corepack
  (server.js needs only node) and takes the Debian security updates.
- nginx takes the Alpine updates for curl and brotli.

Rescanned images built from this tree: no critical and no fixable
finding left in backend, frontend or nginx, in either engine. What
remains are Debian packages without a fix yet, Grype's Python 3.11
binary matches (fixes listed only for 3.13+), and gosu inside the
upstream postgres:15-alpine image.
2026-10-26 15:44:06 +01:00
vulncheck c0e9e59729 build(frontend): base image on node:24 for the Brotli fix
Node 20 bundles the Brotli decoder hit by CVE-2026-111497 and is past
end of life, so no fixed release will come. Node 24.22.1 carries the
cherry-picked decoder fix.
2026-10-26 15:07:27 +01:00
vulncheck 3fe92bc5cb feat(app-scan): Greenshot via NVD and cvelistV5
CVE-2026-25792 was missed on Greenshot 1.3.312 (Intune) and 1.2.10.6
(Wazuh): neither registry knew the product. NVD files it under
getgreenshot:greenshot (<= 1.3.312), cvelistV5 as greenshot / greenshot
with the bound in the version field ("<= 1.3.312"). Name anchored so the
plugins stay out; Windows hosts only on both paths, since the flaw is in
the Windows build and the Mac app has its own numbering. cvelistV5 index
key bumped to v50 so the new key is indexed on the next scan.
2026-10-26 13:53:08 +01:00
vulncheck 7a1af29941 feat(vulnerabilities): sortable package groups with max CPR
The grouped view had one fixed order, so a product with a single high-CPR
CVE could not be brought to the top. The column headers now sort the groups
client-side (all groups are already loaded): package, findings, CVEs, hosts,
severity (criticals, then highs, ...), exploited, max priority and the new
max CPR. Groups without a score stay last either way. A third click restores
the default: exploited first, then summed priority.
2026-10-26 11:50:18 +01:00
vulncheck 0bf99857cc fix(sync-health): a reset integration counts as not configured
Resetting Wazuh or Nessus in Settings left the dashboard banner red with
"last run failed", and the scheduled sync kept running and failing.

The reset stores encrypt_value(""), which is non-empty ciphertext, and
configured_sources() read the raw column, so every reset source still
counted as configured. It now reads the decrypted value. That one function
drives the Sync Health state, the scheduler's skip for unconfigured
scanners and the failure-mail skip, so all three follow the reset again.

The existing tests covered the reset and the health state separately; the
new one resets through update_setting and then asks sync_health.
2026-10-26 11:42:05 +01:00
vulncheck 236c00ada7 feat(audit): every write route leaves an audit row
Groups, policies, scan schedules, finding assignment and suppression, and
every sync, scan, refresh and connection-test button wrote no audit row,
so "who did this, and what changed" had no answer for them.

Records that change now write one row naming each field old -> new
(audit_trail.record_change; a create passes before={}, a delete after={},
nothing changed writes nothing), in the same transaction as the change.
Defer, undefer and false-positive also record the date, reason and
suppression they set or clear; an admin's email change is logged.

Buttons take the route dependency audited("What"): one row once the
endpoint returned, before the response goes out, so a refused or raising
call writes none and a failing audit write is a 500 rather than a gap. It
keeps the query parameters and only the named body fields, because request
bodies carry passwords: the test-mail recipient and SMTP host, the syslog
target, the AI provider and base URL, the Nessus asset and scan.

A guard test fails for any new POST/PUT/PATCH/DELETE route that does not
audit itself. No new event types, so no migration: groups, policies and
schedules log as CONFIG_CHANGE, buttons as SCAN_TRIGGERED, told apart by
resource_type and the description.
2026-10-26 11:03:31 +01:00
vulncheck 69d9447de7 feat(audit): every user action names who, what changed and from where
A review for revisionssicher logging asked that the trail answer who, when,
what exactly and from which client IP. Edit asset logged only "Asset
updated", asset and finding actions carried no IP (only logins did), a
deleted user's rows turned into "System/Auto", a manual decommission was
logged as a system action, and a settings change left no row at all.

Every audit row with a user_id now gets the actor's username, client IP and
user agent stamped on insert (before_insert listener, fed by an app-wide
dependency that runs after ProxyHeadersMiddleware), so no writer can forget
them. The username is a snapshot column (migration 065, backfilled), so it
survives delete_user. Rows without a user (sync, scheduler) stay as before.

Asset edits, single and bulk, record each changed field old -> new; a manual
status change carries the user. A settings PUT writes CONFIG_CHANGE with the
changed field names and old/new values whose secret subfields are masked for
every key (ai_config is unprotected and holds api_token). An unchanged
re-save writes nothing. The syslog message carries user=.
2026-10-26 10:24:23 +01:00
vulncheck f7c7812fdd feat(vulnerabilities): group findings by package
The findings list shows one row per CVE, so a host with 40 log4j CVEs reads
as 40 problems when it is one update. A "Group by package" chip on the
Vulnerabilities page now shows one row per product with its findings, CVEs,
hosts, severities, exploited CVEs and installed/fixed versions, under the
list's own filters, so with one asset selected it is that asset's affected
products at a glance.

GET /vulnerabilities/by-package aggregates in one GROUP BY with the patch
plan's key (package rows, else package_name, else the CVE id). The list's
filter block moves into _apply_list_filters so both views count the same
rows. A row click opens the flat list with ?package=<key>, which matches the
group's findings by their package rows; the search's ILIKE on the summary
column missed a finding's second product.
2026-10-26 09:58:27 +01:00
vulncheck 626f7f7357 fix(https): make the reset to the auto certificate a real button
"Use auto-generated certificate" was a red text link next to the CA
download button and easy to miss. It now has the outline style of the
other reset buttons in Settings.

The confirmation also says that the reset replaces the uploaded
certificate and key, so going back to the PKI means uploading them again.
No copy is kept on purpose: a private key should not stay on disk once it
is no longer served.
2026-10-25 20:34:24 +01:00
vulncheck 7f063482d1 feat(app-scan): XenServer VM Tools CVEs from Citrix bulletins
CVE-2025-27462, -27463 and -27464 (XSA-468) are in the XenServer VM Tools
for Windows, but the record names only "Xen / Windows PV drivers / all"
and NVD left them Deferred with no configuration, so nothing bounded the
tools a Windows inventory lists ("XenServer VM Tools" / XenServer / 9.6.0).
Citrix's bulletin CTX692748 does: "All versions BEFORE 9.4.1", Linux guests
not affected.

The tools' "What's new" on docs.xenserver.com links each security release to
its bulletin (9.4.1 -> CTX692748), so it is the list, read the way XenCenter's
notes are; the fetch loop is now shared. A bulletin counts only if its VM
Tools bound is the release the notes link it from and its "Affected Versions"
names no other product; every CVE it lists is then the tools'. Severity comes
from the bulletin page's JSON, where Citrix now renders it.

Matched are "XenServer VM Tools" and "Citrix VM Tools" (before the 9.3.2
rebrand), whose version is the installer's, on Windows hosts only. The
Management Agent entries are not read: 9.4.2 ships agent 9.3.2.95. Findings
close when the tools update; a feed outage keeps the last parse. Known limit:
drivers updated through Windows Update alone leave the tools' entry old.
2026-10-24 14:35:04 +02:00
vulncheck d1811fd683 docs(https): a reverse proxy running as a container on the same host
The docs told an own proxy on this host to use 127.0.0.1:3003. For a proxy
in a Docker container (Nginx Proxy Manager, OpenResty, Traefik) that is
the container itself, and the host's LAN IP no longer answers on 3003
since the frontend is bound to 127.0.0.1. The proxy then returns 502 with
"Connection refused ... upstream: http://<lan-ip>:3003/".

README, .env.example, the Compose comments and TROUBLESHOOTING now cover
it: join the proxy container to <project>_vulnmanager-net, target
vulnmanager-frontend:3000 (the container port, which clashes with nothing
on the host), and make the network permanent in the proxy's Compose file
so a recreated container keeps it. Troubleshooting gains an entry for the
502 and the dead port 3003 after the update, with the fix for each setup.
2026-10-24 14:18:50 +02:00
vulncheck 0cd58a348e fix(auth): audit log and login rate limit see the client IP, not the frontend's
The backend never talks to the browser or the reverse proxy, only to the
Next.js container, and that proxy did not forward X-Forwarded-For.
FORWARDED_ALLOW_IPS defaulted to 127.0.0.1, which the frontend container
never is, so every audit row carried the container's Docker address and all
users shared one login rate-limit bucket. The limiter also parsed the header
a second time and trusted every private network, so an internal user could
pick a fresh rate-limit key per request with a forged header.

The Next.js proxy now forwards X-Forwarded-For. That was left out on purpose
while port 3003 was open, because Next.js keeps a client-supplied header.
Since the frontend is bound to 127.0.0.1, only nginx or the operator's proxy
reaches it, and those append the real peer.

The backend trusts the header from loopback and its own Docker subnet, read
from /proc/net/route, so no IP has to be configured and a recreated
container is still trusted. FORWARDED_ALLOW_IPS now adds proxies to that
(one on another host in front of nginx) instead of replacing it. uvicorn
takes the rightmost untrusted entry, so a forged entry in front never
counts. The rate limiter keys on that resolved address instead of parsing
the header itself. TRUST_PROXY_HEADERS defaults to true in Compose.
2026-10-24 14:01:55 +02:00
vulncheck de6d41cdd3 feat(https): serve the UI over TLS with a CA per installation
TrueVuln spoke plain HTTP only. HTTPS needed a foreign reverse proxy, the
hop behind it stayed unencrypted, and the frontend port 3003 was open on
every interface, so anyone on the network could read the vulnerability
data around the proxy.

A bundled nginx (Compose profile "tls", on by default via
COMPOSE_PROFILES) now terminates TLS 1.2/1.3 on 443: forward secrecy and
AEAD only, HSTS, port 80 only redirects. Frontend and backend are bound to
127.0.0.1. A reverse proxy on another host connects to nginx via HTTPS; one
on the same host may leave the profile out and use 127.0.0.1:3003.

On first start the backend creates a CA for the installation and issues
the server certificate from it for TLS_SERVER_NAMES, the DASHBOARD_URL host
and localhost, renewed in lifespan and nightly at 03:45. The CA carries
name constraints on exactly those names, because its key sits on the
vulnerability server and an unconstrained root in every browser would let
whoever steals it impersonate any site. A name it does not cover creates a
new CA, with an audit row. The CA lives in a backend-only volume; nginx
mounts only the certificate and key, and reloads itself when they change,
so nothing needs the Docker socket.

Admins can download the CA, or upload a certificate from the internal PKI
(PEM with chain, DER or PFX) under Settings > Security > HTTPS Certificate.
It is checked (key matches, chain complete, covers the DASHBOARD_URL host,
valid, not self-signed, RSA >= 2048 or ECDSA P-256/P-384, SHA-256+) and
served without a restart; renewal never overwrites it, a reset goes back to
the CA. The key stays in the volume (0600), never in the database or the
CONFIG_CHANGE audit rows.

The Next.js proxy now forwards request bodies as bytes: request.text()
mangled binary DER and PFX uploads.

Existing proxies on http://<host>:3003 from another machine must be
switched; README has the upgrade steps.
2026-10-24 13:29:10 +02:00
vulncheck bf4e1063ca fix(xenserver): reach a verified coordinator by its reverse-DNS name
A pool member names the coordinator by IP in HOST_IS_SLAVE, while the
certificate carries names, so Verify SSL failed with "IP address mismatch"
whenever a member was configured. With Verify SSL on, the client now looks
the IP up in reverse DNS and connects by that name, provided the name
resolves back to the same IP. No PTR record, or one pointing elsewhere,
fails that entry without sending anything to the bare IP, and the next
configured host is tried. Without Verify SSL nothing changes.
2026-10-24 12:53:29 +02:00
vulncheck bcf5b789af fix(app-scan): an NVD outage is asked about once per version, not per host
An outage is not cached, so every further host on the same version asked
NVD again, four attempts with back-off each. 80 Aruba switches on two
firmware versions made 320 identical requests and about 40 minutes of
sleep in one Netdisco sync, against an NVD that was not answering. The
same applied to IGEL and the app scan (one Chrome build on many hosts).

lookup_cves now remembers a failed (product, version) in memory for
NVD_RETRY_AFTER (15 min). Further hosts on it raise _TransientNVD at
once and get the no-verdict they got anyway, so nothing closes and no
detection changes. Other versions are asked as before; after the window
or a restart the failed one is asked again. The same 80 switches now
make 8 requests.
2026-10-23 23:42:11 +02:00
vulncheck 540f1f4cc9 feat(settings): trust internal-PKI CAs uploaded in the web UI
Integrations behind a certificate from an internal PKI only worked with
"Verify SSL" off. httpx verifies against certifi, not the OS store, so
update-ca-certificates in the container changed nothing. httpx, pyVmomi
and ldap3 do honour SSL_CERT_FILE.

Admins can now upload root/intermediate CAs under Settings > Security >
Trusted CA Certificates (PEM, chain or DER). ca_trust_service writes
certifi (or the SSL_CERT_FILE the process started with) plus the
uploaded CAs into one bundle and points SSL_CERT_FILE at it. The next
connection picks it up without a restart, and the public roots stay
trusted. Leaf certificates, expired CAs and files containing a private
key are rejected.

Admin only, reads included, at /api/v1/trust-store/ca-certificates.
Every add and remove writes a CONFIG_CHANGE audit row. The CAs are
stored as JSON in the custom_ca_certificates setting, and the generic
settings PUT is denied for that key so nothing bypasses validation or
the audit trail. The bundle is rebuilt in lifespan before the scheduler
starts.
2026-10-23 18:32:34 +02:00
vulncheck 77849f50d8 feat(xenserver): report skipped pool hosts as sync warnings
A configured pool host that did not answer was only logged and shown by the
connection test; the nightly sync passed over it silently and stayed green.
The run now lists each skipped entry with its reason as a warning. The run
still completes and sends no mail: the pool was reached. When no host
answers, the run fails and alerts as before.
2026-10-23 16:16:02 +02:00
vulncheck 91c26cd607 fix(app-scan): an NVD outage closes nothing
When NVD answered 503/429 past the retries, lookup_cves returned [] -
the same value as "NVD lists no CVE for this version". The reconcile
that followed read the outage as patched: the Netdisco/IGEL/appliance
passes closed every CVE only NVD carries (the 2023 ArubaOS-Switch trio
on every switch), the app-scan reconcile closed every NVD-backed desktop
finding on the host, and the next healthy run reopened them all.

lookup_cves now raises _TransientNVD. Its three callers put
NVD_NO_VERDICT into the asset's touched set, and both reconciles that
read it (_resolve_stale_appliance, the app-scan one) skip that asset for
the run. An answered "no CVE" still closes.
2026-10-23 15:46:25 +02:00
vulncheck a0ee65aafb feat(xenserver): try several configured pool hosts in order
Following HOST_IS_SLAVE needs the configured host to answer; when it is
the one that died (HA failover of the coordinator), nothing did. The host
field now takes several pool hosts, comma-separated, tried in order.

The next entry is tried only when no connection could be opened (refused,
connect timeout, TLS), the entry is not an address, or the coordinator a
member names cannot be reached (mid-failover a member can still name the
dead one). Once the login was sent, a read timeout or a refused login
stops the run: retrying would resend the credentials towards a lockout.
The connection test lists the entries it skipped as a warning.
2026-10-23 15:06:39 +02:00
vulncheck c40891e290 feat(xenserver): follow a pool member to the coordinator it names
The integration refused a pool member's address with "configure the pool
coordinator (X) instead", although HOST_IS_SLAVE already names X. The
coordinator role moves (HA failover, pool-designate-new-master), so a
fixed coordinator address breaks the test and the nightly sync after
every switch.

The client now follows HOST_IS_SLAVE to the named coordinator on every
login, once: a second HOST_IS_SLAVE means the pool is mid-transition and
the next sync retries. Any pool host works as the configured address. The
connection test reports the coordinator it reached. IPv6 coordinators are
bracketed.
2026-10-23 14:51:08 +02:00
vulncheck aedbf53cd8 fix(app-scan): re-read the Citrix Workspace build catalog every night
The catalog refreshed only once it was a week old. Citrix ships a release
and the bulletin naming it as the fix on the same day, and cvelistV5 has
the CVE the next night; until the catalog knows that release's build, the
bound cannot be resolved and the CVE is held, not decided — for up to a
week on every host.

The TTL is now 20 hours, so each nightly index build re-reads the download
pages, while a Data-jobs rebuild later the same day reuses them.
2026-10-23 14:38:09 +02:00
vulncheck 6d97db9a36 feat(app-scan): Azure Data Studio via NVD and cvelistV5
CVE-2024-26203 was missed on Azure Data Studio 1.47.1: neither registry
knew the product. Both sources state the same range (1.0.0 .. <1.48.0),
NVD under microsoft:azure_data_studio, cvelistV5 as Microsoft / Azure
Data Studio. Name anchored so extensions "for Azure Data Studio" stay
out. cvelistV5 index key bumped to v49 so the new key is indexed on the
next scan.
2026-10-23 14:23:00 +02:00
vulncheck f5d4519983 fix(eol): Windows client EOL by build and edition; edition from Intune
Windows 11 24H2 Home/Pro went out of support on 2026-10-13, Enterprise and
Education run to 2027-10-12, LTSC years longer. endoflife.date carries one
release per feature update and edition class (11-24h2-w, 11-24h2-e,
11-24h2-e-lts, 11-24h2-iot-lts) with the build in `latest`. The OS check
matched codename "11" by prefix and landed every Windows 11 on the first
release in the list, 26H2 Enterprise: no Windows 11 was ever EOL.

The build now picks the feature update and the edition picks the release.
Wazuh names the edition ("Microsoft Windows 11 Pro"). Intune did not: only
the beta managedDevice carries skuNumber, so the device list is read from
beta with skuNumber in $select. It is the same paged call, so the number of
Graph requests does not change. Only a 400 from beta (property refused)
falls back to v1.0 without the edition; 429, 5xx and auth errors fail as
before, with no second read.

No edition known: the latest date on that build, LTSC included, so EOL is
never reported early. A build endoflife.date does not list yet, and a bare
"Windows" (Defender-managed servers; 10.0.26100 is also Server 2025), get
no verdict. An edition-less Intune name does not overwrite Wazuh's.
2026-10-23 14:08:29 +02:00
vulncheck 1f6199663f feat(app-scan): XenCenter CVEs from Citrix bulletins
CVE-2026-42491 affects XenCenter, and neither NVD nor cvelistV5 knew the CVE
at all. Only Citrix's bulletin CTX696811 states it: "XenCenter versions
before 2026.4.0".

The bulletin list on support.citrix.com is a JavaScript app, but XenCenter's
static "What's new" page on docs.xenserver.com links every security release
to its bulletin, and the bulletin is served as HTML under
/external/article/<CTX>. A bulletin counts only if its bound is the release
the notes link it from and it names exactly one CVE: XenServer bulletins also
bundle host CVEs, and which of several is XenCenter's is prose.

The install reads "XenCenter 2026.4.1" with MSI version 26.4.1, read as
2026.4.1. A name and version naming different releases, and the Citrix
Hypervisor console (8.2.x), get no verdict: open findings are held, none is
opened. Windows hosts only. Merged into the cvelistV5 index under
"xencenter" on the nightly build, like Zoom's bulletins; findings are
app-scan findings and close when the host updates. A bulletin that is not
freshly parsed keeps its last parse.
2026-10-23 13:31:09 +02:00
vulncheck 5e09201b5a feat(xenserver): pool hosts as assets, CVEs from their pending security updates
A XenServer host runs no agent, so like ESXi it had no coverage. Unlike
vSphere, no public source can decide one: the Xen Project CNA writes
"consult Xen advisory XSA-517" with no version, XSA/NVD state upstream Xen
versions that XenServer's backported builds (4.17.7-4.xs8) do not follow,
Citrix bulletins name no fixed version, and software_version reads "8.4,
build stream" on every host, patched or not.

The pool coordinator does decide it. Its /updates lists per host the
updates synced but not applied, and every SecurityFix record names its CVEs
(verified against a live 8.4 pool). So a host carries a CVE exactly while
an update fixing it is pending on that host, and applying it closes the
finding. Only the "security fixes for the following issues" list counts,
not "upstream code changes for"; a list line off the format makes the
update unreadable rather than shorter.

Where the data cannot carry a verdict, none is given: a host with an
undescribed or unreadable pending update, or with no update data, keeps
its findings untouched; findings are not closed while XAPI still lists a
reboot or toolstack restart as pending (installed is not running); a stale
or never-synced pool is reported in the run's errors.

Findings carry their own source "xenserver", so the app scan's reconcile
(which closes every app-scan finding it did not re-detect, and a Dom0 with
a Wazuh agent has an inventory) cannot retract them. _upsert takes the
source as an optional keyword; every existing caller keeps "app-scan".

One connection per pool to the coordinator (XML-RPC + GET /updates, all
read-only), asset pin xen_host_uuid (migration 064), nightly at 02:55,
settings card, source filters, README.DEV section.
2026-10-23 12:12:13 +02:00
vulncheck 09b1869899 fix(app-scan): Citrix Workspace CTX697181 decided on LTSR 2507 hosts
A Windows host on 25.7.3000.3034 (LTSR 2507.1 CU3) logged CVE-2026-84419
as "held, not decided" and CVE-2026-84420 not at all. Four causes:

- CVE-2026-84420 writes vendor "Citirx" with product "Citrix Workspace
  app for Windows"; only "Citirx" + "Workspace app for Windows" was
  indexed, so the record never reached the index.
- The bulletin writes "LTSR 2507 CU3 Hotfix 1" for the line every
  download page calls 2507.1, and bound_release required the exact
  release. An LTSR bound without a minor now names the LTSR on its
  year.month; the one-build check still refuses two candidates.
- The "LTSR 2607 Hotfix 1" page (26.7.1.4) failed the build/title
  agreement check, which put a hotfix in the third field only on a CU.
- The seed predated CR 2603.20, LTSR 2507.1 CU3 Hotfix 1 and LTSR 2607
  Hotfix 1; they are added from the download pages.

CU3 now gets CVE-2026-84419 with fix 25.7.3001.2. CVE-2026-84420 is
correctly not affected there: its record names CU3 itself as the fix.
2026-10-23 10:46:04 +02:00
vulncheck aa9c1efb57 fix(app-scan): Apple CVEs fixed only in the next major match 26.x devices
An iPad on iOS 26.5 had CVE-2026-86918 in Defender TVM and nowhere else.
NVD has no configuration for it yet (Awaiting Analysis), and Apple's
cvelistV5 record is one range per product, "iOS and iPadOS" 0 .. < 27,
with no 26.x backport. _same_train only lets a bound on the host's own
major answer, so a fix that exists only in 27 was dropped for every 26.x
host. Timing was not the cause: the record was published 20.10. 02:50
local, after the 01:30 index build, and was in the index from the next
night on.

scan_asset_os_apple now keeps the own-train rule and, only when a CVE has
no range on the host's train, accepts the bound on the next major
(major + 1). Older trains (iOS 18, macOS 15) still get no verdict from a
26/27-only record. Against today's cvelistV5 snapshot this adds 60 CVEs to
an iOS/iPadOS 26.5 host and 74 to macOS 26.5, all fixed only in 27, and
changes nothing for iOS 18.7 or macOS 15.7.
2026-10-22 09:32:49 +02:00
vulncheck 3bdfe38a9d feat(app-scan): match UltraVNC
Wazuh and Intune list "UltraVnc" 1.3.8.1 and 1.2.2.2 (uvnc bvba), and no
row matched it. NVD files UltraVNC under uvnc:ultravnc, 37 CVEs, all ranges
but CVE-2026-3787 (= 1.6.4.0): the Kaspersky batch (CVE-2018-15361,
CVE-2019-8266, CVE-2019-8273, ...) < 1.2.2.3, CVE-2022-24750 < 1.3.8.1, and
the 2026 viewer/repeater batch (CVE-2026-7828 .. -7840) <= 1.8.2.2. Live,
NVD returns 35 of them for 1.2.2.2 and the 10 of the 2026 batch for 1.3.8.1.

No cvelistV5 row: the Kaspersky records name the fix, 1.2.2.3, as
"affected", and NVD has the uvnc records already. Names are anchored, so
Repeater, SC and the mirror driver stay out.
2026-10-21 13:43:21 +02:00
vulncheck c9605752ed fix(sync-alerts): no failure mail for an integration without config
The Reset dialog promises that the scheduled sync stops running. The
inventory jobs (Intune, vCenter, IGEL, Netdisco, Dockhand) skip on a missing
config, but an enabled Wazuh or Nessus ScanSchedule kept firing after a
reset or when the integration was never set up: "Wazuh configuration
missing" / "nessus_config not set", a failed run, and a "Sync failed" mail
every 24 hours for an integration nobody uses.

execute_scheduled_scan now skips a scanner whose config is not set, without
a run row. notify_sync_failure sends nothing for a source without config,
whichever job or button reported the failure; it is the rule Sync Health
already uses for "unconfigured". A configured source that fails still mails
as before.
2026-10-21 10:36:10 +02:00
vulncheck 16173f6880 fix(app-scan): backend starts again on Python 3.11
The container runs python:3.11-slim, and _query_nvd_cpe put
cpe.replace('\\', '%5C') inside an f-string. A backslash in an f-string
expression is a SyntaxError before Python 3.12, so importing
app_cve_scanner_service failed and uvicorn exited in a restart loop. The
local 3.14 venv accepted it, so the suite never saw it. The escape now runs
before the f-string; the query string is unchanged.
2026-10-20 19:56:44 +02:00
vulncheck 5dab89f9fd feat(app-scan): CVE-2026-2040 for PDF-XChange Editor from the vendor bulletin
NVD left CVE-2026-2040 (updater LPE, ZDI-26-122) "Deferred" with no
configuration, and the ZDI record names only the tested 10.7.2.400, so no
host below it got the finding. PDF-XChange's security bulletin names
10.7.2.400 affected and 10.7.3.401 the fix. A registry row now states it as
0 .. < 10.7.3.401, the way NVD read the analysed ZDI records of this product
(CVE-2025-0901: < 10.4.1.389).

_cpe takes "advisories", the vendor-bulletin rows the extension registry
already reads through _advisory_cves, and scan_asset_packages merges them
after the NVD lookup. Each row decides its CVE alone, also once NVD analyses
it.
2026-10-20 19:07:35 +02:00
vulncheck 22661199c7 feat(app-scan): match PDF-XChange Editor and PDF24 Creator
Intune lists "PDF-XChange Editor" 9.5.368.0 (Tracker Software Products
(Canada) Ltd.) and "PDF24 Creator 11.18.0" (geek software GmbH), and no row
matched either. NVD files the Editor under pdf-xchange:pdf-xchange_editor,
288 CVEs: most ZDI records as the one tested version (CVE-2023-40471:
9.5.368.0), the rest as ranges (CVE-2025-0901 < 10.4.1.389, CVE-2025-0903
< 10.4.2.390, CVE-2024-7352 <= 10.3.0.385). PDF24 Creator has one CVE,
CVE-2023-49147 (pdf24:pdf24_creator < 11.15.2), so 11.18.0 stays clean.

No cvelistV5 row: the 283 PDF-XChange records each name one tested version,
and NVD has all of them but CVE-2026-2040 (updater LPE, "Deferred", record
10.7.2.400 only; the vendor bulletin names the fix 10.7.3.401 and no floor).
Names are anchored, so Viewer, PRO, Lite, the printer drivers and
"Editor Plus" stay out.
2026-10-20 18:21:02 +02:00
vulncheck a42c5b2969 feat(app-scan): Firebird CNA records decide over NVD
NVD and the CNA disagree on Firebird. CVE-2025-65104 is "< 3.0.14" at NVD
and "< 4.0.0" at the CNA (every FB3 client, fixed by the FB4 client). A
fixed_version is never overwritten, so whichever scan ran first named the
fix. As for PostgreSQL, a CVE the cvelistV5 index holds is now the CNA's,
but only from its lowest floor up. Below it NVD still decides: the index
starts CVE-2026-28212's "< 3.0.14" at 3 (line_floor), while the CNA's text
and NVD both include 2.5. So 2.5 keeps it, and 3.x gets CVE-2025-24975
(ExtConnPool, 4.0 on) from neither source. Without a loaded index NVD is not
asked, so the Intune sync's scan leaves Firebird to the next full scan.
2026-10-20 17:29:21 +02:00
vulncheck 163e9e7a69 feat(app-scan): read ">= X, < Y" ranges, Firebird from cvelistV5
The GitHub CNA writes the whole range into the version field:
">= 4.0.0, < 4.0.7". _ranges_from_affected read that as the single version
(4,0,0,4,0,7), which no install equals, so every such line was lost: 60
Wazuh entries, 8 Vim, 1 Notepad++, 1 Wazuh dashboard and most of Firebird's.
It now reads ">= X, < Y", ">=X <Y" and ">= X, <= Y". An exclusive floor
("> X"), a floor with no ceiling or a bound that is no version ("5.0 beta1",
a commit hash) makes the whole entry unreadable. A partial answer would let
suppress_false_positives mark a finding on the dropped line as false.

Firebird gets a cvelistV5 row (FirebirdSQL / firebird) next to the NVD one,
sharing FIREBIRD_NAME_RE. The CNA also writes a bare "< X" per line:
CVE-2025-24975 says "< 6.0.0.609", "< 5.0.2.1610", "< 4.0.6.3183", and read
literally a patched 5.0.3 sits below 6.0.0.609. With "line_floor" such a
bound starts at its own major when the entry has more than one range. The
bounds carry the build counter, so this row compares the whole version.
Windows hosts only, and "4.0" names no build, as on the NVD row.
2026-10-20 17:16:08 +02:00
vulncheck 76bd150936 feat(app-scan): match Firebird on Windows
Intune lists "Firebird 2.0.7.13318 (win32)", Wazuh "Firebird 2.5.2.26540
(x64)", and a bundled install shows "Firebird-Server 4.0 x64 (<instance>)".
No row matched them. The CPE row firebirdsql:firebird brings NVD's per-line
ranges: CVE-2026-40342 and CVE-2026-28212 (< 3.0.14, 4.0.0 .. < 4.0.7,
5.0.0 .. < 5.0.4), CVE-2025-65104 (< 3.0.14) and CVE-2008-0467 (<= 2.0.3).
The fourth part of the version is the build counter, so only three parts
are compared (2.0.3.12981 is 2.0.3). A version of "4.0" names no build and
is skipped. Windows hosts only, like the TightVNC and PostgreSQL rows.

CVE-2008-0467 also has an entry limited to update "rc1" (<= 2.1), and read
as a plain range it caught the patched 2.0.7. _in_range now drops entries
whose update field is a prerelease (alpha/beta/rc/pre/preview) when the
installed version has no suffix. This applies to every product.

No cvelistV5 row: the GitHub CNA writes ">= 4.0.0, < 4.0.7" in the version
field, and _ranges_from_affected does not read that form.
2026-10-20 16:55:51 +02:00
vulncheck 24cfa413a3 feat(app-scan): match Intel PROSet/Wireless Software
Intune lists "Intel® PROSet/Wireless Software" 20.20.3 (Intel
Corporation), and no row matched it. NVD files the package under
intel:proset\/wireless_wifi, proset\/wireless (CVE-2018-12159, <= 20.50,
whose CNA record is "n/a / n/a"), proset\/wireless_software and
proset\/wireless_software_and_drivers. The CPE's backslash now goes to NVD
as %5C. Sent raw, NVD answers 400, and the product looked clean.

NVD left most 2023-2025 Intel records "Deferred" (CVE-2023-38417, -40536:
before 23.20), so a cvelistV5 key covers them. Intel writes a dozen
product spellings under vendor "n/a", so a pattern matches them, and only
names that say "software" and name no OS but Windows. Firmware, driver,
UEFI and Linux records are not the package. The bound is prose ("before
version 23.20"), and intel_proset_affected reads it as 0 .. < 23.20. Other
prose ("See references", "22.220 HF") is dropped, and NVD has those.
Installed names are anchored, so the Bluetooth package and Killer stay out.
2026-10-20 15:22:08 +02:00
vulncheck 4a04c9d5d9 feat(m365): Office 2021 / 2024 CVEs from their own build lines
The Microsoft 365 Apps security-updates page lists a build line per
perpetual edition ("Office 2024 Retail", "Office LTSC 2024 Volume
Licensed", same for 2021), but only "Microsoft 365 Apps" entries were
checked, so Office 2021 / 2024 hosts got no CVEs. A suite entry with
"LTSC" in its name is measured against the Volume Licensed line, any
other against Retail. The lines never share a major build, so a build
off its line is skipped instead of reading as behind on every section.

EOL: Office 2019 and 2021 (Retail and LTSC) already resolved as end of
support through endoflife.date; LTSC 2024 now lands on its own
"2024-ltsc" release instead of Retail 2024 (same date).
2026-10-20 15:07:22 +02:00
vulncheck ea79831532 feat(app-scan): match TightVNC, ADP ranges for opted-in keys
Wazuh and Intune list "TightVNC" 2.8.11.0 (GlavSoft LLC.), and no row
matched it. The CPE row tightvnc:tightvnc brings CVE-2021-42785
(<= 2.8.59) and CVE-2023-27830 (< 2.8.75) from NVD. CVE-2024-42049
(Server for Windows, < 2.8.84) has no NVD configuration and a CNA record
of "n/a / n/a"; only CISA's ADP container states it. A cvelistV5 row with
"adp": True now lets the index read ADP affected[] when no CNA entry of
the record names a curated product. Only tightvnc opts in, so no other
key changes. Windows hosts only on both paths: a distro "tightvnc" is the
Unix 1.3 line.
2026-10-20 14:52:31 +02:00
vulncheck 25c1fcefeb feat(app-scan): older PostgreSQL CVEs from NVD, CNA records decide
The PostgreSQL row asked cvelistV5 only, so a Windows server behind on
its minor release missed every CVE before 2024, when the PostgreSQL CNA
took over. NVD now adds those, under three rules. A CVE the cvelistV5
index holds for the key is the CNA's alone, because NVD keeps stale
ranges (CVE-2026-16238: 14.0 .. < 14.24 up to 18.0 .. < 18.5 against the
CNA's 18 .. < 18.6). An older hit counts only when its fix is on the
host's own major: CVE-2021-3393 is "< 11.11" with no floor, and the
advisory names 11, 12 and 13. Disputed records (CVE-2019-9193) are
dropped. Without a loaded index NVD is not asked for PostgreSQL, so the
Intune sync's scan leaves it to the next full scan.

The name also accepts the installer's "(x64)" / "x64" suffixes, and a
test pins that an 18.5 host's CVEs leave the touched set on 18.6-5.
2026-10-20 13:48:51 +02:00
vulncheck c97679c919 feat(app-scan): match PostgreSQL on Windows
Wazuh and Intune list "PostgreSQL 18" 18.6-5 (PostgreSQL Global
Development Group), the EDB installer, and PostgreSQL had no row. The
PostgreSQL CNA states each CVE per major: CVE-2026-16238 and -14676 are
"18" .. < "18.6", and versions before 18 are unaffected. NVD's
configuration for CVE-2026-16238 was not updated after the record was
corrected and still says 14.0 .. < 14.24 up to 18.0 .. < 18.5, so it
would flag every 14-17 server and clear an affected 18.5. PostgreSQL
therefore gets a cvelistV5 key only.

The installer build ("-5") is trimmed. Only lessThan ranges are indexed:
the Red Hat era records write prose or bare releases, and CVE-2019-10164
lists its fixes 10.9 and 11.4 as affected. The real index of 2026-10-20
holds 58 CVEs, all in the CNA's shape. Windows hosts only, since distro
packages hide backports. Names are anchored, so pgAdmin, the drivers and
EDB Postgres Advanced Server stay out.
2026-10-20 13:37:12 +02:00
vulncheck 3abc24ec4f fix(m365): Visio and Project CVEs go to hosts that have Visio / Project
The Microsoft 365 Apps security-updates page lists Visio and Project CVEs
in the same monthly section as the suite, under their own <h3>Visio</h3> /
<h3>Project</h3>, and MSRC files them under "Microsoft 365 Apps for
Enterprise". The check took every CVE of a section, so each host with
Microsoft 365 Apps got the Visio CVEs (CVE-2026-102855..102857 on
13.10.2026) whether Visio was installed or not, and a host with only Visio
or Project was never checked, because only "Microsoft 365 Apps" entries
started it.

Visio and Project (VisioProRetail, ProjectProRetail) are separate
Click-to-Run products that Microsoft requires on the suite's version and
update channel; selected, they appear as their own entries ("Microsoft
Visio - de-de"). The parser now records the CVEs listed only under Visio
or only under Project, and missing_for_packages gives such a CVE only to a
host with that app's entry, measured against its build on the suite's
channel. A CVE also listed under Word or "Office suite" stays with the
suite. MSI Visio 2016 and Visio/Project LTSC are not matched. The Wazuh and
the per-package path share the helper; stale Visio findings on hosts
without Visio close on the next run.
2026-10-20 13:19:05 +02:00
vulncheck 135532b1a0 feat(app-scan): match DBeaver and CloudBeaver
Wazuh and Intune list "DBeaver 26.2.2" (DBeaver Corp), and neither
product had a row. DBeaver gets dbeaver:dbeaver on NVD (CVE-2021-3836,
< 21.2.3) and a cvelistV5 key. CloudBeaver, the web server, gets its own
cvelistV5 key: CVE-2026-108742 and -108745 (0 .. <= 25.3.5) have no NVD
configuration. Both use one numbering, so each key pairs only its own
product and a desktop never gets a server CVE. Names are anchored to the
Community Edition.
2026-10-20 13:17:01 +02:00
vulncheck 82d4cdb073 fix(app-scan): ask a failed Intune device again at the end of the run
One device's detectedApps answered 500 four times on 19.10.2026 at 03:02,
each after about 15 s, and the app scan skipped the device for the night:
no cvelistV5, MSRC, TeamViewer or repo-advisory detection, no reconcile.
The probe the same morning got its 107 apps in 0.2 s, so this was a Graph
outage longer than the ~75 s its own retries span, not a broken device.

A full run now puts a device whose Intune fetch failed at the end of the
queue and asks once more, at least 60 s after the failure. If it answers,
the device is scanned and reconciled as usual; if not, it is counted and
logged as before and nothing closes. The single-asset scan does not wait,
since that request is synchronous, and Wazuh fetches are unchanged.
2026-10-19 15:44:00 +02:00
vulncheck 2df72a355a fix(app-scan): run the M365 check on a single-asset scan
M365 Apps findings carry their own source (microsoft365-apps), and under
the cross-source contract only the M365 detection retracts it. A host that
updated Office kept those findings open after "App scan" on its asset; they
closed only once the separate M365 Data job ran.

A run scoped to one asset now feeds the inventory it just read into
m365_service.run_m365_for_packages. The full scan skips that pass because the
nightly chain runs the M365 step right before it. The pass also skips a
partial inventory, for example the Intune fallback for a Wazuh host while the
Wazuh API is down, because it closes findings.
2026-10-19 14:13:54 +02:00
vulncheck ec69d649fe fix(intune): time every detectedApps read in the app-inventory probe
One device's detectedApps answered 500 four times in a row, each after
almost exactly 15 s (19.10.2026, 03:02). That is a server-side timeout on
that device's query, not a transient one, so retrying cannot help. Which
read still works for it decides the fix, and only a contrast shows that.

The probe now times v1.0 $expand, v1.0 and beta navigation, beta $expand,
$top=50 and $select for the device and for the most recently synced other
Windows device, and asks for the documented AppInvByDevice export filtered
to the device. A failing detectedApps no longer aborts the run.
2026-10-19 13:56:34 +02:00
vulncheck 24a0568af9 fix(intune): one serial is one device, the newest enrolment wins
A device re-imaged under a new name whose old Intune record nobody deleted
appears twice: two names, one serial. The re-enrolment dedupe keyed on
name + serial, so both records reached the asset's serial stage, each
pinned its own device id, Entra id and name, and the asset was renamed
back and forth up to twice per sync, ending on whichever record Graph
listed last, often the dead one.

The dedupe now keys on the serial (the name only when there is none) and
keeps the newest lastSyncDateTime. Defender had the same split: its
machine id pin is fill-only, so an asset that once pinned the old
install's machine kept it and that machine's CVEs kept findings open on
the live device. Machines behind one asset with different Entra device
ids are now different installs; only the most recently seen one counts,
and the pin follows it.
2026-10-19 11:28:28 +02:00
vulncheck d78baeaf93 fix(aruba): map "(AOS-10 & AOS-8)" to aruba-os, mark the Instant AP record reviewed
The 17.10 Netdisco run ended "1 ERR": the spelling guard from 89800c5c
found three HPE names no Aruba key knew. Two causes:

"HPE Aruba Networking Wireless Operating System (AOS-10 & AOS-8)"
(CVE-2026-23601, -23808, -23809) is the controller line, the same
product as CVE-2026-23812's "(AOS-8 & AOS-10)" with the generations in
the other order. The pattern only knew that order, so no controller
got these three CVEs. The aruba-os product_re now takes both.

"HPE/Hpe Aruba Networking InstantOS and Aruba Access Points running
ArubaOS 10" (CVE-2024-42393..42396) is Instant AP firmware, bounded at
8.10/8.12, which are controller build numbers too; HPE says controllers
are not affected. Mapping it would put findings on the wrong box, so it
goes into _ARUBA_KNOWN_UNMATCHED (lowercase covers both spellings).

The test builds these two records in an index of their own: their
ranges overlap CVE-2026-44857's builds that the shared fixture asserts on.
2026-10-17 11:59:15 +02:00
vulncheck 697360a093 fix(scheduler): run EOL, M365, app scan and MSRC as one chain after the Intune sync
The four post-inventory jobs were separate crons (03:00 EOL, 03:10 M365,
03:20 app-CVE scan, 03:50 MSRC), each waiting on the Intune sync's lock.
On a night the sync overran (16.10.2026: 07:38-12:17) all four started the
moment it ended, in no order. The MSRC job's remediation catch-up then ran
before the app scan whose Edge findings it exists for, so those stayed
without a KB until the next night, and app scan and MSRC wrote Windows
findings on the same assets at once (different job slots, no mutual
exclusion).

Cause: every waiter polled the same lock and none waited for its
predecessor; the clock order only held when the sync finished before 03:00.

post_inventory_scans_nightly (03:00) now waits once, then runs EOL -> M365
-> app scan -> MSRC in turn. Each step keeps its own job slot and Intune
wait (a no-op unless a sync starts mid-chain); a step that raises costs
only itself.

The schedule comments in scheduler.py and the job tables in ARCHITECTURE.md
and README.DEV.md carried times from before several reschedules and called
them UTC; the cron times are the container's local time (TZ from TIMEZONE).
Both are corrected.

Test: tests/test_nightly_post_inventory_chain.py fails on the old code (four
separate crons, no chain) and passes now; the schedule asserts in
test_nightly_index_order and test_msrc_edge_chromium_cve point at the chain.
The full suite fails on the same 13 order-dependent tests with and without
this change.
2026-10-17 11:30:28 +02:00
vulncheck 5f5e31a399 fix(scheduler): run overlapping jobs late instead of dropping them, one run per kind
APScheduler ran with its defaults: a 1 s misfire_grace_time, checked again
in the worker thread, on the event loop's default pool (min(32, cpus+4)
threads, shared with asyncio.to_thread). A job that queued for a free
thread, or came due while the loop was blocked for a second, was skipped
("Run time of job ... was missed by") and never caught up. On a night when
the Intune sync ran past noon and four jobs waited for it, the pool was
full and every job due after that was lost.

The scheduler now has its own 64-thread pool, with misfire_grace_time=None,
coalesce=True and max_instances=1. A late job runs late, and a backlog runs
once.

The nightly scans and the Data-jobs and sync buttons also ran the same code
with no lock between them, so two writers could hit the same findings
(deadlock, or a uq_vuln_cve_asset race aborting a run).
database.job_slot(name) is one advisory lock per kind, taken before the job
opens its Session. A second run waits for the first to finish and then
runs; after 6 h it starts anyway. It covers EOL, M365, app-CVE scan, MSRC
scan and refresh, exploit intel, Vulnrichment, the vuln-index rebuild, the
Wazuh vulnerability sync and Nessus. Different kinds still run in parallel.

Test: tests/test_job_concurrency.py fails on the old code (all 41
overlapping jobs dropped, 22 entry points without a lock) and passes now.
The full suite fails on the same 21 tests as before this change.
2026-10-17 10:12:19 +02:00
vulncheck 1b631af11e fix(scheduler): hold the post-inventory nightly jobs until the Intune sync ends
The 02:10 Intune sync (with Defender TVM) can outlive the 03:00 EOL,
03:10 M365, 03:20 app-CVE and 03:50 MSRC slots on a large estate (field
report 16.10.2026: 07:38-12:17 for 1639 assets). APScheduler ran them on
its thread pool alongside the sync: two writers on the same assets and
findings in different orders, and a scan of an inventory still being
refreshed.

Each of those jobs now waits until no sync holds the Intune advisory lock,
polling every 60 s for at most 2 h, then runs anyway with a warning so the
estate does not lose a night. The probe reads pg_locks instead of taking
the lock, so a sync starting at that moment is never turned away as
"already running". A GUI app scan does not wait; it logs a warning and
reports intune_sync_running in its stats.
2026-10-16 16:49:56 +02:00
vulncheck a4b5312f85 fix(defender): read TVM CVEs tenant-wide instead of one call per machine
The sync asked GET /machines/{id}/vulnerabilities once per machine. That
API allows 50 calls a minute and 1500 an hour, so a tenant with more
machines than that ran into 429 for every machine past the hourly budget.
The client gave up after its retries and those machines' CVEs were
neither written nor resolved, while the run took hours (field report:
1639 assets, Intune + Defender 07:38-12:17).

The (machine, CVE, product) pairs now come from the tenant-wide
/vulnerabilities/machinesVulnerabilities list, paged by $top/$skip so a
full page without a nextLink is not taken as the end. CVSS and
description, which that list lacks, are copied from existing findings of
the same CVE and looked up in the TVM catalogue only for CVEs new to the
estate (capped per run, fill-only on later runs). Every MDE call is paced
at one per 2.4 s, under both limits. The upsert reads an asset's findings
once per machine instead of once per CVE.

Simulated against the documented limits for 1639 machines: before, 62 min
of API time, 73 429s and 10 machines dropped; after, 4 API calls in steady
state, no 429, none dropped.
2026-10-16 16:33:09 +02:00
vulncheck 89800c5cb6 fix(aruba): read the Mobility prose records and flag unknown HPE spellings
CVE-2024-26304, -33511 and -33512 reached no controller. HPE files the
2022-2024 Mobility records under "Aruba Mobility Conductor (formerly
Mobility Master); Aruba Mobility Controllers; WLAN Gateways and SD-WAN
Gateways managed by Aruba Central" (and "ArubaOS Wi-Fi Controllers and
Campus/Remote Access Points") and states each branch as prose, "ArubaOS
10.5.x.x: 10.5.1.0 and below". Neither the name nor the shape was read,
and NVD has not analysed those three. Both names now map to aruba-os, and
blocks with prose go through aruba_switch_entries (branch as floor). The
shared extractor took the sentence as a version and made it its own
bound, which the scan backstop dropped. A lookbehind keeps the SD-WAN
compound build ("8.7.0.0-2.3.0.8 and below") from becoming a 2.3 branch.

The guard: every index build collects HPE product names that look like
Aruba firmware but match no key, logs them, and the Netdisco sync puts
them on its run errors (Sync Health amber, no mail). The five names
reviewed and left out on purpose (four Instant/AOS-10 AP lines, the
9200/9000 series record NVD covers) are in _ARUBA_KNOWN_UNMATCHED, so it
is silent on today's corpus. The list lives in memory and is empty after
a restart until the next build.
2026-10-16 16:12:33 +02:00
vulncheck c815d0e817 fix(app-scan): retract a dead Wazuh agent's own findings once Intune owns the host
Wazuh's vulnerability sync only asks active agents, so on a dual-enrolled
host whose agent died (field case asset #276, keepalive 2025-04-03) the
"wazuh" source was never retracted and those findings stayed open forever.
Since f528374e the app scan reads Intune for such a host; after it has read
Intune's whole list it now drops the "wazuh" source as well. A finding closes
only when no source is left, so CVEs that app-scan, MSRC, Defender or Nessus
still report stay open. Doing it in the same pass avoids closing findings
Intune still shows and then being blocked by Intune's 7-day reopen grace.
2026-10-16 13:21:49 +02:00
vulncheck 18744d9376 fix(aruba): match HPE's current product spellings and "<=" bounds
CVE-2026-76742 (AOS-S web-UI auth bypass, HPESBNW05156) was not detected
on ArubaOS-Switch 16.11.0016/0027/0029. HPE now names the product
"AOS-Switch (AOS-S)", which the anchored aruba-switch pattern rejected,
so the record never entered the cvelistV5 index; NVD still has it as
"Awaiting Analysis" with no CPE, so no source saw it. The same applied
to CVE-2026-76741..76749 and to other spellings: "ArubaOS (AOS)",
"HPE Aruba Networking AOS", "... Operating Systems (AOS-8 & AOS-10)",
"ArubaOS-Switch Devices" and a typo'd "HPE Aruba Networkign AOS-CX".

A second shape was dropped on purpose until now: lessThanOrEqual
"<=10.6.0.2" with the floor as prose in `version` (CVE-2024-42505,
CVE-2025-25042, -27080 and others, none with an NVD CPE).
aruba_affected() normalises it for the Aruba keys only; the shared
_clean_bound is unchanged, and the branch guard still confines an
entry whose floor cannot be read.

The NVD half now also queries arubanetworks:aos-cx and
aos-cx_firmware, under which NVD filed the 2021/2022 AOS-CX CVEs.

Checked against all 634 HPE cvelistV5 records: no non-switch or
non-controller product enters an Aruba key, and families never cross.
2026-10-16 13:05:08 +02:00
vulncheck f528374ef5 fix(app-scan): take Intune's software list when the Wazuh agent is dead
Field report 2026-10-16: asset #276 is still registered in Wazuh with a
last keepalive of 2025-04-03 while Intune reports it daily. Wazuh won the
inventory precedence whenever wazuh_agent_id was set, so the app-CVE scan
and the software view kept deciding from syscollector data frozen 18
months ago, and Intune's current detectedApps was never read. Findings
from that data could not close.

Each inventory now keeps its own stamp (assets.wazuh_last_seen,
intune_last_seen, migration 063). asset_inventory() hands a dual-enrolled
host to Intune when the keepalive is outside the grace window and Intune
checked in after it. A live agent keeps the precedence, so it does not
follow the order the syncs ran in. Rows without both stamps keep Wazuh.
run_app_cve_scan, the software view and the Intune sync's own retraction
use the same decision; a dead agent's browser extensions are not read.
2026-10-16 11:50:42 +02:00
vulncheck ee01abab12 fix(assets): keep a host Intune still sees active when its Wazuh agent died
Field report 2026-10-16: asset #276, whose Wazuh agent last kept alive on
2025-04-03 but which Intune reports daily, switched between ACTIVE and
INACTIVE with every sync. The Wazuh agent sync overwrote Intune's fresh
check-in in last_seen with the old keepalive and judged the status on it,
the nightly reconcile reads last_seen for Wazuh hosts and deactivated it
too, and the next Intune sync revived it.

last_seen now only moves forward (advance_last_seen), in the Wazuh agent
sync and in the Intune sync, so the newest proof of life from any source
decides. A Wazuh-only host past the grace window still ages out. The
Wazuh audit reason names the keepalive and who stamped last_seen.
2026-10-16 11:30:12 +02:00
vulncheck c90023121f feat(app-scan): detect Ghostscript CVEs on the Windows installer
"GPL Ghostscript" 10.05.1 (Artifex Software Inc.) got no CVE-2026-39919:
Ghostscript had no row on any path, and NVD has the record without a
configuration. cvelistV5 now pairs Artifex's three vendor spellings
(0 .. < 10.08.0), and NVD is asked for artifex:ghostscript.

Only the installer name is matched, so the distro package, the fonts
package, GhostPCL and GhostXPS stay out. Red Hat's records that write the
fixed release or prose into the version field are not read, and
gpl_ghostscript is not asked because its looser 2018 bounds would flag
9.24/9.25. On the full cvelistV5 ZIP the index gains 9 entries and every
other key is unchanged.
2026-10-16 11:01:38 +02:00
vulncheck 56afed055a fix(jev): commit each answer so the triage no longer deadlocks
The nightly Jev triage committed every 50 CVEs, so its row locks on
`vulnerabilities` (all rows of each answered CVE, across hosts) stayed held
across up to 50 HTTP calls. A job writing the same rows in another order
closed a lock cycle; Postgres killed the triage (2026-10-16 06:05), which
rolled back answers already paid for and skipped the audit row.

Each answer is now committed before the next call. A database error stops
the run and is recorded in the audit row as "stopped: database: ...".
2026-10-16 10:40:09 +02:00
vulncheck c9c955fc26 fix(zoom): read an MSI build as its release from Zoom's installer URLs
Field report 16.10.2026: Zoom Workplace (64-bit) 7.1.43453 got no finding
for CVE-2026-108184 (ZSB-26021, fixed in 7.1.9) or CVE-2026-108185
(ZSB-26022). The MSI writes major.minor.BUILD, and the release 7.1.5 sits
only in the ARP "Comments" ("7.1.5 (43453)"), which neither Wazuh nor
Intune reports. A build inside the fix's own minor cannot be placed, so
zoom_undecided gave it no verdict on purpose (the known MSI-build limit).

Zoom's download URLs name both: zoom.us/client/7.1.5.43453/,
zoom.us/download/vdi/7.0.13.27170/ (VDI Client and plugins share that
line). winget-pkgs keeps a manifest with that URL for every installer.
zoom_bulletin_service.build_builds reads them on the nightly cvelistV5
build into a stored build→release table (about 150 client and 40 VDI
builds), fetching only builds it does not know yet. Only a URL of the
manifest's own build counts: 2020's prod/5.2.42619.0804/ is build.date
and maps to unknown. zoom_release rewrites a Windows host's MSI version to
"7.1.5 (43453)", the EXE's spelling, before the NVD and cvelistV5 paths
compare, so the finding shows the release like the ARP comment.

No new false positives: a build the table does not know stays undecided
as before, and the table matches the field report's comments (7.1.43453 =
7.1.5, VDI 7.0.27050 = 7.0.11, 7.0.27170 = 7.0.13), rising with the build
in every minor. The field host's Universal Plugin 7.0.11 stays clear of
ZSB-26021 (the line names the VDI Client), and its VDI Client 7.0.13 is
the fix.
2026-10-16 10:34:40 +02:00
vulncheck 9c245e2984 fix(zoom): link the security bulletin a finding came from
A Zoom finding decided by a bulletin said "Source: Zoom Security Bulletin
ZSB-26020" in its description, but External References had no link to
the bulletin (field report, CVE-2026-53420). Cause: the cvelistV5 scan
built the finding without refs, though the bulletin entry carries its ZSB
id. It now passes the bulletin URL, which _upsert merges into references
like TeamViewer's and IGEL's, so existing findings get it on the next
scan. Record-only entries get no link, and matching is unchanged. The
detail page labels the link "Zoom Security Bulletin".

The description stays fill-only: a finding that NVD or the CVE record
created first keeps that text without the "Source:" suffix.
2026-10-16 10:12:26 +02:00
vulncheck 8df6524d06 feat(msrc): detect SQL Server 2017-2025 CVEs from the GDR/CU fixed builds
MSRC states each SQL Server fix per release and servicing path, e.g.
CVE-2026-70189: 16.0.1210.3 on "SQL Server 2022 (GDR)", 16.0.4305.5 on
"(CU27)". The app scan's MSRC package pass now reads the build from
"Microsoft SQL Server 20xx Setup (...)", raised by the GDR/Hotfix (KB...)
entries, and compares it only against fixes on its own path (GDR and CU
are told apart by the build's third part). Per CVE the oldest fix on the
path decides: MSRC adds newer builds to a revised CVE (CVE-2026-32176)
without dropping the first ones.

No false positives by construction: Browser, RsFx and VSS Writer stay at
RTM and are never read, a Setup entry without the instance entry
"Microsoft SQL Server 20xx (64-bit)" is a leftover and skipped, and a
build that fits no path gets no verdict. The reconcile compares a fix on
the host's own path only, and now also retracts MSRC from rows another
scanner created first. Index key bumped to msrc_product_index_v3.
2026-10-15 13:37:46 +02:00
vulncheck fb7bfad479 fix(euvd): never cache a catalog cut short by a failed page
A failed exploited-search page (or critical-list request) left a partial
map that was stored and stamped for 24h. Enrichment clears euvd_listed on
every CVE missing from the catalog, so CVEs ENISA still lists were
un-listed. The paginated fetch now reports whether it reached the last
page; an incomplete fetch keeps the last complete cache and backs off.
2026-10-15 13:17:18 +02:00
vulncheck 04774c8d19 fix(feeds): stop repeating downloads the app already made
Follow-up to the per-device vulnrichment fix: an audit of the backend found
more feeds pulled again by every caller.

cvelistV5 (~557 MB) had three downloaders: the index build, the CVSS
cascade's stage 3 and the date backfill. Callers arriving together each
pulled the whole ZIP, and after a failed download every later caller
retried at once. _ensure_zip is now the only one: a lock with a freshness
re-check after acquiring it, a zipfile check before the swap, and a 15 min
backoff after a failure (the on-disk ZIP still serves meanwhile).

Per-CVE lookups that keep missing are remembered for 6h: the M365 title
fetch on a cvelistV5 404 (MSRC publishes days before CVE.org), and NVD /
GHSA for CVEs they answered without a score. Rate limits and network
errors are not answers and are retried.

A failed or partial index build stores nothing, which keeps the cache
stale, so every per-asset scan and every per-sync enrichment rebuilt it
during an outage. The MFSA, repo-advisory and TeamViewer indexes and the
KEV / EUVD / VulnCheck refreshes now serve the stale copy for 1h after a
failure; the nightly and forced paths still rebuild. The app scan loads
the repo-advisory and TeamViewer indexes once per run, like the IGEL ISNs.
The repo-advisory index no longer falls back to {} when a build raises:
the reconcile would read every untouched finding as patched.

MSRC CVRF documents were downloaded by the 01:30 index build and again by
the 03:50 / Sunday ingest. Both now go through fetch_cvrf_doc, a disk
cache keyed by the revision date /updates lists (max 4h old), so a
revised document is still fetched again.
2026-10-15 13:12:21 +02:00
vulncheck 7a835fc797 fix(m365): stop re-downloading the vulnrichment ZIP once per device
Field report 14.10.2026: every Intune device with Microsoft 365 Apps logged
"vulnrichment: downloading ZIP snapshot for 131 CVEs", minutes apart, each
a full 334 MB pull, followed by the same NVD and CVE.org per-CVE lookups.
Two causes. _load_via_zip_snapshot had no cache: a fresh tempdir per call.
And run_m365_for_packages (Intune and Defender, once per device) re-ran the
whole CVSS cascade and the title fetch for every CVE the device has, not
just for its new findings; the upsert also overwrote the real CVE title
with the placeholder, so the text had to be fetched again on every run.

The vulnrichment ZIP is now disk-cached for 6h, swapped in only once it is
a valid ZIP, and a failed refresh falls back to the stale snapshot. A new
M365 finding inherits CVSS, severity, SSVC and the real CVE text from a
sibling on another host, as the msrc, nessus and container scanners
already do. Only what that leaves open goes to the feeds. Existing findings
keep their real text; the nightly Correct-CVSS run still re-checks them.

Also: _download_cvelistv5_zip wrote to a shared .part file, so concurrent
downloads could interleave; it now uses a per-writer name like the others.
2026-10-15 12:57:37 +02:00
vulncheck 5c55a4e9f3 fix(zoom): read "see references" CVEs from Zoom's security bulletins
Field report 14.10.2026: CVE-2026-108184 (ZSB-26021) and CVE-2026-108185
(ZSB-26022) were not found. Zoom's records on cvelistV5 state
"version": "see references" and link the bulletin; NVD had both at
"Awaiting Analysis" with no configuration (so were CVE-2026-53413, -53415
and -53420). Neither source had a bound, so no host could be flagged.

zoom_bulletin_service reads the overview (newest 20) and each bulletin's
"Affected Products" lines for Zoom Workplace and its VDI Client / VDI
Plugins. "starting on version 7.0.0 and before version 7.2.0 and 7.1.9 in
their respective branches" becomes [7.0.0, 7.1.9): each lower fix closes
its own x.y branch. Several branches without a start floor at the lowest
one's x.y.0; a single "before X" is everything below. Rooms, the SDKs,
Contact Center and the mobile apps are other version lines and skipped; a
line with no bound leaves its key to the record and NVD.

The nightly cvelistV5 build merges it under the zoom / zoom-vdi keys. The
bulletin decides each CVE it bounds, also over a record's own range
(CVE-2026-53416: record "< 7.0.11", ZSB-26018 adds the 6.6.15 branch fix).
Its lines name the platform and client or plugin, so these entries also
reach macOS and Linux and skip the CVE-text heuristic. The MSI-build limit
is unchanged: 7.1.43453 gets no verdict on a 7.1.9 fix.

A bulletin that is not freshly parsed (unreachable, parsed empty, rolled
off the overview) keeps its last parse, so its findings do not close as
patched while NVD still has no bound. Index v48.
2026-10-14 08:58:57 +02:00
vulncheck 6754b6b700 feat(app-scan): detect GnuPG CVEs via NVD
Field case: the inventory lists "GNU Privacy Guard" 2.4.8 (The GnuPG
Project); it got no CVE-2025-68972 (GnuPG through 2.4.8). GnuPG had no row
on any path.

New NVD row gnupg:gnupg. NVD has the record analysed and bounds each
branch; for 2.4.8 it returns CVE-2025-68972 and CVE-2025-68973.

No cvelistV5 row, although that was the first choice: MITRE's GnuPG
records write one range over all branches. CVE-2025-30258 is "0 .. < 2.5.5"
there (NVD: < 2.4.8 and 2.5.0 .. < 2.5.5) and the 2.5-only CVE-2026-24882
is "0 .. < 2.5.17" (NVD: 2.5.13 .. < 2.5.17); both would land on a 2.4.8
that is fixed or never affected. The name is anchored, so Gpg4win, which
carries its own version line, is not matched. Distro packages fail the
version cleaner and stay Wazuh's.
2026-10-13 14:10:27 +02:00
vulncheck fbc7575603 feat(app-scan): detect Blender CVEs via cvelistV5
Field case: Intune lists "Blender" 5.0.0 and Wazuh "Blender" 5.0.1
(Blender Foundation); neither got CVE-2026-60103 (3.0.0 through 5.1.2).
Blender had no row on any path.

NVD has the record "Deferred" with no configuration, and its
blender:blender rows stop at 2022, so cvelistV5 is the only structured
source: blender / blender, "3.0.0" .. lessThanOrEqual "5.1.2". The fix is
shown as ">5.1.2". The record's "unaffected" entry is a git commit and is
ignored.

Only the vendor's spelling is paired; the 2022 records name the vendor
"n/a" and write prose into the version field. The name is anchored
("Blender", "Blender 4.2 LTS"), so launchers and add-ons stay out.
Windows and macOS hosts only: a distro package ("4.3.2+dfsg-2") cleans to
4.3.2 and says nothing about backports. Index key bumped to v47.
2026-10-13 14:10:21 +02:00
vulncheck 3520ca6c45 fix(zoom): Workplace and VDI CVEs missed under the new CPE names
Field report 13.10.2026: CVE-2026-53412 (ZSB-26014) was not found on hosts
listing "Zoom Workplace (64-bit) 7.1.43453" and "Zoom VDI Universal
Plugin(64bit) 6.6.26830".

Cause 1, one CPE name. Every name containing "zoom" was asked as zoom:zoom,
the desktop client's old name. NVD has since filed it as zoom:meetings and
zoom:workplace_desktop, and the VDI client as vdi_windows_meeting_client(s),
virtual_desktop_infrastructure and workplace_virtual_desktop_infrastructure.
CVE-2026-53412 names only the new ones. Each row now asks every name. The
names are anchored, so Zoom Rooms, the Outlook plugin and the SDKs no longer
draw the client's CVEs.

Cause 2, the version hides the patch. Zoom's MSI writes major.minor.BUILD
(the plugin's 6.4.26620 is release 6.4.12); NVD's server-side match and
ours both read the build as a patch, so "6.6.0 .. < 6.6.15" never fired and
a floor like "> 5.14.14" passed every 5.14 build. NVD is now asked for x.y.0,
and a bound inside the install's own minor gets no verdict: no new finding,
but the CVE counts as touched so an open finding is not closed on no
evidence. Bounds in other minors still decide (6.6.22255 < 7.0.0). The EXE
form "6.0.11 (39959)" is unchanged.

Cause 3, client vs plugin. NVD files the VDI client and the VDI plugins
under one CPE. The CVE text decides: a plugin takes only CVEs that name a
VDI plugin, the client all but plugin-only ones.

cvelistV5: the old pairs used vendor "zoom", which no record does. Paired
now are the spellings with real bounds (CVE-2026-53416, "VDI Client and
Plugins" < 7.0.11, is still Awaiting Analysis at NVD); "see references",
"unspecified" floors and "before X" prose stay unpaired. Windows only.
2026-10-13 13:53:20 +02:00
vulncheck e73f277201 fix(wazuh): deleted agent on an Intune device kept Wazuh as inventory
The Wazuh sync only flipped the asset inactive; the stale wazuh_agent_id
kept the software view and the app-CVE scan asking Wazuh for an agent that
no longer exists, never Intune. A dual-registered asset whose agent is gone
from the agent list now loses the Wazuh link, so both fall through to
Intune; a returning agent is re-linked by hostname. Wazuh-only assets and
an empty agent list are left alone.
2026-10-12 15:42:39 +02:00
vulncheck 6e7a041f08 fix(wazuh): Intune "PC-LAB01" and agent "pc-lab01" became two assets
Field report 12.10.2026: a device known from Intune with an uppercase
name got a second asset as soon as its Wazuh agent reported the same name
in lowercase. Intune, Defender and Wazuh findings of one machine ended up
on two rows.

Cause: the Wazuh asset sync was the only source that compared hostnames
case-sensitively (Asset.hostname == name). Intune, Nessus, vCenter, IGEL,
Netdisco and Defender all match with ILIKE, which is why the other
direction (Wazuh first, Intune later) always merged.

The agent name stage and the cluster-node name stage now use the same
literal, case-insensitive compare (like_literal + ILIKE): every character
must match, only case is ignored. The cluster-node stage also goes through
pick_one, so several case variants are settled by the address or not at
all. Within Wazuh nothing changes: two agents that are both in the run
stay two assets, even when their names differ only in case. An agent
without a name skips the name stage.

Existing forks are not merged automatically. The lowercase row holds the
agent id; README.DEV describes how to heal it and how to find candidates.
2026-10-12 11:38:01 +02:00
vulncheck 99d0c51440 fix(cisco): NVD envelope range put CVE-2025-20363 on IOS XE 17.15.6
Field report 11.10.2026: a Netdisco router (C1121X-8PLTEP, IOS XE 17.15.6)
carried CVE-2025-20363. Cisco's Software Checker and Cisco's own cvelistV5
record agree that 17.15.6 is not affected; the record's 17.15 entries stop
at 17.15.3.

Cause: the hit came from the NVD half alone. NVD writes this CVE's whole
IOS XE configuration as one range, 3.2.0sg .. 17.17.1 inclusive, which is
just Cisco's first and last listed release with every train in between.
17.15.6 sits inside it. The cvelistV5 half never matched, but its reconcile
folds the NVD half's hits in, so the finding stayed open.

Dropping NVD ranges for Cisco is not the fix. Measured against all 560 IOS XE
CVEs at NVD: 89 carry ranges, and on most of them Cisco's record enumerates
no IOS XE releases at all, so the range is the only data. Where Cisco does
enumerate, the range only adds releases Cisco did not list (CVE-2025-20196:
102, also CVE-2023-20235, CVE-2023-20198/20273).

scan_asset_os now skips an NVD hit when cisco_unlisted says Cisco's record
enumerates that family for the CVE and leaves the installed release out. The
nightly app scan and the Netdisco sync pass the cvelistV5 index they already
load; without one the NVD half decides alone, as before. The open finding is
closed by scan_asset_cisco's existing reconcile on the next pass.
2026-10-12 11:28:23 +02:00
vulncheck f553e41c1a fix(msrc): Edge CVEs flapped open/patched; newest Edge build counts
Field reports 09./10.10.2026: on a Wazuh-only Server 2016 at Edge
152.0.4191.62 (fix 154.0.4258.37), 15 MSRC-only Edge CVEs went open ->
patched in the same second ("MSRC scan: ... is now at or past the fixed
build") and back to open on the next scan. Two Intune-only clients showed
the same flapping, plus a finding on a stale Edge build.

Cause 1: build_product_index skipped a CVRF document that failed to load
and stored the rest. The package reconcile reads "CVE not in the index" as
"host is past the fix", so it closed every CVE of the missing month; the
next complete build reopened them. A failed document now raises, which
keeps the cached index (ensure_index and the 01:30 refresh already handle
that). The reconcile also takes the inventory now and keeps a finding while
the product is still below the fixed build MSRC recorded on it, so a CVE
missing from the index for any other reason cannot close it either. An
update past the fix or an uninstall still closes it.

Cause 2: Intune lists Edge several times ("Microsoft Edge" 154.0.4258.62 +
152.0.4191.53, or 153.0.4234.32 next to the MSIX
"Microsoft.MicrosoftEdge.Stable" 154.0.4258.37). Edge Stable is one
machine-wide install and the extra entries are per-user registrations that
lag behind it, but each entry was scanned on its own and the stale one
flagged. Edge is now scanned once, at its newest build; the MSIX can raise
that build but never flags on its own.
2026-10-10 09:40:05 +02:00
vulncheck 25f706948a fix(nessus): an expired licence is a failed sync, not COMPLETED · 8 ERR
Field case: the Nessus licence expired and every scan answered
"Nessus 403 on /scans/157: {"error":"Expired license."}". Sync Health showed
an amber "8 ERR" card, the run was COMPLETED, and nobody got a mail, while
not one scan had been imported. The Wazuh API returning 500 goes red and
mails.

Cause: the Nessus sync never called fail_if_source_broken(); neither the
scheduler nor POST /nessus/sync asked whether the source itself was down.
Each scan error was caught and listed, and the run closed as completed. It
could not have caught it anyway: scans_processed was counted before
get_scan(), so it read 8 with 0 scans read, and "nothing synced + errors"
never matched.

scans_processed now counts scans actually read, and both callers pass it
to fail_if_source_broken(). Every scan failing is a failed run with the
usual mail (one per source per 24h); one unreadable scan among readable
ones stays completed with errors. The button returns 502 instead of
"Nessus sync completed"; HTTPException passes the catch-all so it is not
rewrapped as 500. The failure text says "scans" for Nessus.
2026-10-09 11:04:27 +02:00
vulncheck 81bafda04a fix(teams): skip the 1.0.0.0 placeholder, keep Teams Panel/Phone off Teams
Field case: an Intune host lists "MSTeams 1.0.0.0" (CN=Microsoft
Corporation). It drew CVE-2023-4863, -29328, -29330, CVE-2022-21965 and
CVE-2025-53783, plus the Teams classic EOL.

1.0.0.0 names no build. It is the new client's MSIX package (Intune's
"MSTeams", Wazuh's WindowsApps\MSTeams_1.0.0.0_...) with no version read;
no desktop Teams shipped as 1.0.0.0 and classic was never MSIX. Four parts
under 100, so it passed the scheme check and sat below every classic and
Android bound. teams_placeholder_version (1.0.0.0, 1.0, 1, 0.0.0.0) is now
skipped by the cvelistV5 and NVD scans and by _teams_classic. 0a73eafb had
read it as classic; "MSTeams" now counts as the new client for EOL, like
"Microsoft Teams". Real classic builds (1.5.00.31168) keep both.

CVE-2025-53783 came through teams_panels: the NVD filter matched the
vendor:product token as a substring, so microsoft:teams also took
teams_panels and teams_phones (1.0.9x device builds), and Teams for
Android (1.0.0.2025...) got their CVE and the Panel fix 1.0.97.2025102203.
Teams now takes its CPE exactly. Other products keep the prefix match;
Adobe relies on it (acrobat_dc). NVD cache prefix v5.
2026-10-08 13:15:00 +02:00
vulncheck 6ce578fcda fix(defender): a Chrome CVE on a patched client was labelled "Microsoft Windows 11"
Field report CVE-2026-106269 / CVE-2026-106211: first seen by Defender TVM,
the list showed package "Microsoft Windows 11" while the detail page showed
Google Chrome via app-scan.

Cause: d708a04 kept the OS fallback for machines with no row in the
SoftwareVulnerabilitiesByMachine export, meant for devices the export never
covers (iPhones). A fully patched Windows client has no rows either: nothing
was vulnerable at the last snapshot. When Chrome 155 shipped, the
per-machine call already listed its CVEs, the export did not, and the row got
the asset's OS. App-scan then cross-confirmed it, recorded Chrome as a
package row and left the parent name alone (fill-only).

The export covers every desktop OS, so a Windows, macOS or Linux asset never
falls back to its OS label; the package stays empty until the export names
it. iOS and Android keep the fallback.

Migration 062 repairs rows already written, with 057's rules. It also covers
old bare "Microsoft Windows" placeholders: since 2f6334e the asset reads
"Microsoft Windows 11"/"10", so neither 057 nor the sync's heal matched them.
2026-10-08 12:02:24 +02:00
vulncheck 2f6334edb9 fix(intune): scan Intune-only Windows clients for OS CVEs
Field case: clients moved to Intune without a Wazuh agent got no Windows
OS CVEs at all. Graph reports operatingSystem "Windows" and the build in
osVersion, never 10/11 or the edition. The sync stored a bare "Microsoft
Windows", and the cvelistV5 Windows pass skips a Windows it cannot place
in a family (_win_family), so these hosts were never scanned. The MSRC
OS pass covers Windows Server only.

_os_string now places the family from the build: 10.0.22000 and up is
Windows 11, 10.0.x below is Windows 10. The existing family-matched,
release-bounded pass does the rest and closes a finding once the build
reaches the fix. Edition does not matter for CVEs; Microsoft's records
are per release and architecture.

Only for devices whose managementAgent contains "mdm". Intune cannot
MDM-enrol Windows Server, but servers under Defender security settings
management are listed too, and 10.0.26100 is Server 2025 as well as
Windows 11 24H2. Those keep the bare label. managementAgent is now part
of the managedDevices $select.

Side effect: on hosts with both Wazuh and Intune, the 02:10 Intune sync
used to overwrite Wazuh's "Microsoft Windows 11 Pro" with the bare label,
which took the host out of the Windows OS pass until the next Wazuh sync.
It now writes "Microsoft Windows 11" and the host stays scannable.
2026-10-07 13:32:15 +02:00
vulncheck fa6e6af415 fix(app-scan): match classic Teams 1.x on cvelistV5 and NVD
Field case: an Intune host with "Microsoft Teams" 1.5.00.31168 and "Teams
Machine-Wide Installer" 1.5.0.31168 did not get CVE-2023-29328 / -29330
(Teams for Desktop, fixed 1.6.00.18681) from cvelistV5.

cvelistV5: teams is a strict-scheme key, so a bound in another build shape
is not compared. The check also ran over the floor, and Microsoft writes
the desktop floor as "1.0.0": three parts against 1.5.00.31168's four,
so every classic range was refused. It checks the upper bounds only now,
which also applies to adobe-acrobat (its HEAD tests still pass). The key
is skipped on a Mac: "Teams for Mac" is not paired, so it held only the
desktop's ranges.

NVD found both, but showed the macOS fix (1.6.00.17554, listed first).
_query_nvd_cpe now keeps the fix per target_sw, and scan_asset_packages
takes the host's own, else a platform-neutral entry's. That applies to
every NVD product. The Teams entries get the same upper-bound scheme check:
CVE-2023-24881 is "< 2.10.1" on NVD, Microsoft's record says 2.0.0 ..
< 2.10.1 and MSRC's only fix is 2.10.1, so it no longer lands on classic
1.x, where it could never close.

Names: Intune's "MSTeams" and a bare "Teams" match on both paths. The
Machine-Wide Installer stays out: it stages the client and keeps its own
version while the client updates itself; it keeps its EOL flag.

NVD cache prefix v4 (diagnose_scan follows) so the cached decisions are
redone on the next run.
2026-10-07 12:49:45 +02:00
vulncheck 3f2a577680 feat(app-scan): detect Oracle JRE and JDK CVEs on every record shape
Field case: an Intune host with "Java(TM) 6 Update 13" 6.0.130 and
"Java(TM) SE Development Kit 6 Update 13" 1.6.0.130 got nothing from the
app scan. Defender reports CVE-2013-1493 on the JDK. Not JDK-specific: the
JRE missed the same CVEs.

cvelistV5: the oracle-java key paired only "Oracle Java SE". Oracle filed
2018-2020 as "Java" ("Java SE: 6u201, 7u191, 8u181", lists split across
entries) and 2021-2024 as "Java SE JDK and JRE" ("Oracle Java SE:17.0.9"
next to GraalVM), so those years reached no Java install. Both are paired
now; _java_se_versions keeps the Java SE pieces (a label holds until the
next) and drops Embedded, JRockit and GraalVM. 9+ installs ("Development
Kit 17.0.9") and versions ("11.0.26", "24") now parse, for names that
start with "Java " only, so Temurin, Corretto, Microsoft's OpenJDK and
java-17-openjdk stay out. One finding per CVE (8u441 and 8u441-perf).

NVD: new rows for Java 6-8 (oracle:jdk + jre, and their sun: twins for
the Sun-era records). cvelistV5 has CVE-2013-1493 and CVE-2018-13785 as
"n/a". NVD writes the update in the CPE update field
(oracle:jdk:1.6.0:update13), so the release is the query and
_java_affected decides; "*:update41 up to 1.6.0" is 6u41 and below, an
entry without an update is the GA build. 9+ has no row: NVD names only the
newest affected update, which an exact query for an older one never
returns.

Both paths read Oracle's "newest affected update" as that update and every
earlier one of the same release, the rule the cvelistV5 path already used
(checked against Defender). This is the deliberate exception to "everything
below X is never a match": it stays within one release.

The NVD query now follows startIndex over every page. It read only the
first 200 records for every product; oracle:jdk:1.6.0 has 459.
2026-10-07 12:39:48 +02:00
vulncheck 09273d5640 feat(app-scan): detect NanaZip and NanaZip Preview CVEs
Field case: Wazuh lists "NanaZip" 6.0.1632.0 and "NanaZip Preview"
6.5.1742.0 (both MSIX, Kenji Mouri) on one host. Neither had a row.

Source: M2Team/NanaZip's own repo advisories (21, all with a CVE) on the
GitHub repo-advisory path. Only that feed states the fix for both lines.
NVD has the stable bound alone and no configuration from CVE-2026-54616
on; cvelistV5 drops the Preview line on 11 records. No NVD or cvelistV5
row: every NanaZip CVE there is also in this feed.

The version decides the line, not the package name: each fix names a build
on a major.minor line, and the build part only grows within it. The
maintainer writes prose ("5.0 Update 1 (5.0.1252.0) or later",
"Stable: >= 6.0.1698.0 / Preview: >= 6.5.1742.0"). _norm_ops now keeps the
bracketed build and reads "or later" as >=, and patched_floor splits on "/"
as well. 6.0.1632 gets 18 CVEs, Preview 6.5.1742 the four fixed in
6.5.1749, and 6.5.1750 and 7.0 get none.

This applies to every repo: the fixed version shown is now patched_floor's
fix for the install's own line, not only a bare build. Wazuh's ">= 4.14.4"
used to leave it empty. With no fix on the install's line, patched_floor
takes the nearest one above it instead of the highest.
2026-10-07 12:19:44 +02:00
vulncheck 3ad84f278b feat(app-scan): detect Lenovo System Update CVEs via cvelistV5
Field case: an Intune-only host on "Lenovo System Update" 5.08.03.59
(Lenovo) did not get CVE-2026-6387 (fixed 5.08.04.85). System Update had no
row on any path.

NVD has the record at "Awaiting Analysis" with no configuration, so
cvelistV5 is the only structured source: Lenovo / System Update,
"0" .. lessThan "5.08.04.85". New row on that pair. The zero-padded parts
compare as numbers.

The row matches that structured spelling only. Lenovo's records under
"Lenovo System Update" (CVE-2022-4568, CVE-2023-4632) write prose into the
version field ("Versions prior to 5.08.02.25"). The exact-version fallback
reads that as the fixed build itself, which would flag a patched host. The
name is anchored, so Vantage, System Interface Foundation and Update
Retriever are not matched. Index key bumped to v45.
2026-10-07 11:12:43 +02:00
vulncheck 113ec2aaf8 fix(app-scan): match Adobe's yearly-line Illustrator and Photoshop records
Since 2026 Adobe files Illustrator and Photoshop CVEs under one product
per yearly line: "Illustrator Desktop 2026" / "Illustrator Desktop 2025",
"Photoshop 2026" / "Photoshop 2025". The cvelistV5 rows only knew
"Illustrator" and "Photoshop Desktop", so those records (23 for
Illustrator, among them CVE-2026-71441) resolved to nothing. A pattern
entry with "20\d\d" now catches them, so next year's line needs no edit.

Each line is stated as "0 .. <= last broken build", the shape Media
Encoder uses. Read literally, the 2026 range covers a patched 2025
install (Illustrator 29.8.10 below 30.6), so both rows set major_floor.
That also floors the older one-line records ("Photoshop Desktop",
0 .. <= 26.1), the way NVD reads them (25.0 .. < 25.12.1, 26.0 .. < 26.2).
Earlier majors are left to NVD.

The Media Encoder test now also covers CVE-2026-34639, which states both
lines inside one affected block. Index key bumped to v44.
2026-10-07 10:52:50 +02:00
vulncheck 9c84804693 feat(app-scan): detect Adobe Media Encoder CVEs
Field case: an Intune-only host on "Adobe Media Encoder 2026" 26.2.2
(Adobe Inc.) got none of CVE-2026-48370, -48366, -47979, -47976 and
-47971. Media Encoder had no row on either path.

NVD: new row adobe:media_encoder. NVD has the batch analysed, "<= 25.6.5"
and "26.0 .. <= 26.2.2".

cvelistV5: new row on Adobe's "Adobe Media Encoder". Adobe states each
supported line as "0 .. lessThanOrEqual <last broken build>" and names
the patched build only as "unaffected" (26.3, 25.6.6). Read literally,
the 26 range also covers a 25.6.6 that is already patched. The new
registry flag major_floor gives such a range its bound's major as the
floor, so each range stays on its own line (Media Encoder 2025 = 25.x).
It applies to inclusive bounds only: a "< 26.0" floored at 26 would be
an empty range. Older lines get no finding from cvelistV5; NVD covers
them. Index key bumped to v43.
2026-10-07 10:29:04 +02:00
vulncheck b031e140f2 fix(wazuh): drop superseded MSIX registrations from the package list
A Citrix host listed 27 "Microsoft Teams" builds, every one under
WindowsApps\MSTeams_<ver>_x64__8wekyb3d8bbwe, while only the newest folder
existed on disk. Syscollector reads MSIX packages per user hive, and each
profile keeps the registration of the version it last had after Windows
removed the folder. The old builds drew Teams CVEs (CVE-2025-53783 on
25094.310.3616.953) that no update could ever close.

get_packages now keeps only the newest version per package family (name,
architecture, publisher id from the folder name), so the app scan, EOL,
M365 and the software tab all see the same cleaned list. Entries outside
WindowsApps are untouched, so classic Teams 1.x keeps its EOL finding.
2026-10-06 14:04:03 +02:00
vulncheck 3cb03b0955 fix(eol): flag Intune's "MSTeams" 1.x as classic Teams
0a73eaf pinned the 1.x classic-Teams rule to the exact normalised name
"teams", i.e. what Wazuh syscollector reports. Intune detectedApps lists
the same WindowsApps package by its AppX name "MSTeams", which normalises
to "msteams", so an Intune-only host with MSTeams 1.0.0.0 got no EOL
finding. The Intune path itself was fine: detectedApps feeds the same
run_eol_for_packages -> resolve_ms_lifecycle_eol chain.

Accept both names; the version still decides (1.x classic, 2xxxx new
client), so a current MSTeams stays clean.
2026-10-06 13:31:13 +02:00
vulncheck 161f242382 feat(app-scan): detect Nextcloud Desktop CVEs via NVD and GHSA
The sync client had no row on any path, so CVE-2025-66549 (fixed 3.16.5)
and CVE-2025-47792 (fixed 3.15) never reached a host. Intune and Wazuh
both list it as plain "Nextcloud" (3.15.3.20250107, 3.14.2.20241018).

NVD: new row nextcloud:desktop. NVD has both CVEs analysed, with ranges.
The name is anchored, so Talk, Files and the Office connectors (other
version lines) are not matched. The fourth version part is the MSI build
date; ver_parts=3 drops it, otherwise an exact-version CVE such as
CVE-2023-22472 (3.6.1 only) never equals 3.6.1.20221208.

GHSA: nextcloud/security-advisories joins the repo-advisory path.
Nextcloud publishes there before NVD analyses the record. The repo
covers the server, its apps and the mobile clients too, and Android's
3.x line overlaps the desktop's, so _PACKAGES keeps only the Desktop
package blocks (19 of 278 advisories in the live feed). The range
parser now reads "3.0.3 up to 3.2.4" (CVE-2021-37617) as a range rather
than as "anything below the fix".

No cvelistV5 row: those records name the product "security-advisories"
for every Nextcloud product, with prose bounds ("< 3.16.5"), so the
pair cannot tell the desktop from the server or Android.

Checked against the live feeds: 3.15.3.20250107 gets CVE-2025-66549
only; 3.14.2.20241018 gets that and CVE-2025-47792, and not
CVE-2024-52510, whose fix is 3.14.2. Both paths agree.

Known gap, documented in README.DEV: three old single-OS advisories
(fixed by 3.13.4 at the latest) are not platform-filtered on either
path.
2026-10-06 10:42:00 +02:00
vulncheck 9a8cceaeec feat(app-scan): detect LibreOffice CVEs via cvelistV5
Field case: an Intune host on "LibreOffice 26.2.4.2" (The Document
Foundation) got none of the 26.2.5 batch (CVE-2026-63266..63270, -63277
and siblings, "Addressed in LibreOffice 26.2.5/26.8.0").

Cause: LibreOffice had a row on the NVD-CPE path only, and NVD still has
these records at "Received" with no configuration. The cvelistV5 path had
no LibreOffice row, so resolve() returned None. It now has one, matched
on TDF's own vendor/product pair. Each record bounds one series
("26.2" .. lessThan "< 26.2.5"), so 25.8 and 26.8 stay out.

TDF writes the operator inside the bound. The compare reads only the
digits and survived that, but "< 26.2.5" would have become the finding's
fixed version for good. _clean_bound now strips a "<" inside a lessThan.
"<=" inside a lessThanOrEqual stays as it is: that is HPE's Aruba shape,
which scan_asset_aruba skips while its prose version field is unparsed.
Across the current cvelistV5 ZIP the change touches LibreOffice records
only.

The fourth digit is a build number and needs nothing special: the bounds
are three-part, so 26.2.4.2 sits below 26.2.5 and 26.2.5.1 does not.

Both rows are anchored and skip the Help Pack, Language Pack and SDK.
The Help Pack carries the suite's version and lags behind after an
update, which would keep the finding open. Index key bumped to v42.
2026-10-06 09:06:02 +02:00
vulncheck f439cc027b fix(intune): a 500 on detectedApps dropped the device from the run
Question from the field: when one Intune device answers detectedApps with
500 Internal Server Error, is it fetched again until it succeeds? It was
not, and the gap was silent.

Cause: GraphClient._get retried only timeouts and 429, so a 5xx raised at
once, and get_detected_apps caught that error and returned []. Every
caller (app scan, Intune sync, the asset's software view) read that as a
device with no software: nothing was detected on it that run, and nothing
was recorded. Nothing got closed either, since an empty inventory never
reconciles, but a device that kept failing stayed unscanned for good
without anyone seeing it.

500/502/503/504 are now retried with a short backoff. A failure that
persists raises out of get_detected_apps instead of returning []: the app
scan counts it as inventory_fetch_failed (the nightly log drops `errors`),
the Intune sync records it in its errors instead of a debug line, and the
software view returns a 502 instead of an empty list.

DefenderClient._get gets the same 5xx retry. A single 500 on the
tenant-wide SoftwareInventoryByMachine export emptied it for every machine.
2026-10-05 15:40:59 +02:00
vulncheck ad014268b3 fix(eol): a Citrix published-app stub gets no EOL finding
"Microsoft Visio 2016 / 1.0 / Delivered by Citrix" is a launcher for an app
that runs on the Citrix server, not on the host. The CVE scanners drop these
rows (filter_inventory); the EOL sweep kept them on purpose (f14eb1b) and
dated them by name through the MS-lifecycle listing, which put
EOL-MS-LIFECYCLE-MicrosoftVisio2016 on a host where Visio is not installed.

Cause: the stub guard in run_eol_for_packages only blocked the
version-based endoflife.date path; the name-only MS-lifecycle path still ran.
Stubs are now skipped in the loop for every source. They are skipped rather
than filtered up front so an all-stub inventory still counts as an inventory,
and _resolve_stale_ms_lifecycle closes the findings the old behaviour left
open on the next run.
2026-10-05 14:39:48 +02:00
vulncheck 605e271f49 feat(app-scan): the Data-jobs scan rebuilds the CVE indexes first
To get a CVE published today, the operator had to rebuild the cvelistV5
index on the host (docker compose exec ... build_product_index(force_fresh=
True)) before pressing the scan button, or wait for the 01:30 refresh. The
"App CVE Scan" button under Data jobs now does that itself: it calls
/app-cve-scan/start?rebuild_index=true, and the job runs the full nightly
refresh (fresh cvelistV5 ZIP + MFSA, GitHub advisories, TeamViewer, Citrix,
IGEL ISNs, MSRC) before it scans. The 01:30 job body moved into
app_cve_scanner_service.refresh_indexes so both run the same code.

Opt-in: the per-asset rescan and API clients keep reading the stored index,
no 557 MB download per click. The button's dialog says what it costs.

A failed rebuild keeps the cached index as before, but the button now says
so ("index rebuilds failed — scanned on the cached index"). That includes a
ZIP download that fell back to the old file on disk; otherwise "completed"
reads as "today's CVEs are in".

The button can now overlap the 01:30 job and the threat-intel cascade, and
all three streamed the ZIP into the same .part file. Each writer now uses
its own temp name; os.replace keeps the swap atomic.
2026-10-05 14:08:50 +02:00
vulncheck 93e748a70b feat(app-scan): detect .NET SDK and ASP.NET Core CVEs
.NET SDK: SDK-only CVEs (CVE-2026-58649, dotnet watch) carry every feature
band's fix in one field, lessThan "10.0.111, 10.0.400". Instead of being
dropped, those bounds now go to new dotnet-sdk-8/9/10 keys, one range per
band: [10.0.0, 10.0.111) and [10.0.200, 10.0.400). A band without its own
fix is out of support and stays vulnerable up to the next fix. Toolset,
Templates and the packs are parts of the SDK and are not matched.

NVD: runtime and SDK share microsoft:.net, so _in_range no longer compares
an SDK bound (patch 100+) with a runtime or the other way round. Without it
the runtime false positive would come back once NVD analyses the record.
.NET Framework (major < 5) is left alone.

ASP.NET Core was not checked at all. CVE-2025-55315 (CVSS 9.9),
CVE-2026-40372 and CVE-2026-26130 name only "ASP.NET Core x.0", never
".NET x.0". New aspnetcore-8/9/10 keys and the microsoft:asp.net_core CPE
match the shared framework and the runtime, not the targeting pack or the
hosting bundle options.

Checked against all 53 .NET CVE records since 06/2025: a fully patched
host with SDK 10.0.401 stays clean, runtime matches are unchanged.
2026-10-03 10:37:49 +02:00
vulncheck 14782c764d fix(app-scan): an SDK-only .NET CVE landed on every patched runtime
CVE-2026-58649 is in `dotnet watch` (SDK only), but Microsoft files it under
".NET 8.0/9.0/10.0" with the SDK feature-band fixes as bound, two in one
field: lessThan "8.0.130, 8.0.424". The dotnet-* keys compare the runtime
version from the package name, and every runtime (8.0.31) sits below 8.0.130,
so Host, FX Resolver, Runtime and Desktop Runtime were all flagged on fully
patched hosts.

A runtime patch never reaches 100 (one a month); an SDK band always does.
Bounds with patch >= 100 are now dropped for the dotnet-* keys at index time.
Of the 53 .NET CVEs since 06/2025 this is the only one with SDK bounds; real
runtime bounds (CVE-2025-55247, 8.0.21) still match.
2026-10-03 00:40:33 +02:00
vulncheck d83c1927ec fix(netdisco): read CTX697096's NetScaler records
CVE-2026-88771 .. 88778 (bulletin CTX697096, cvelistV5 2026-09-27) were
not reported on a 14.1-72.61 appliance, although the records were in
the ZIP before the nightly index build. NVD has them as Received with no
configuration, so cvelistV5 is the only source.

The CNA changed the record shape twice. The vendor is now "Citrix
NetScaler", which the anchored vendor pattern rejected, so the records
never reached the index. And the line moved from `version` into the
bound: version "0", lessThan "14.1-73.37", where scan_asset_netscaler
took only entries whose version equals the line.

The vendor pattern takes the third spelling. An entry with version "0"
applies when its bound is exactly line-dash-build of the appliance's
line; "14.1-73.37 FIPS" and "13.1-37.279 and NDcPP" are not builds and
still never reach a normal appliance.
2026-09-28 10:10:22 +02:00
vulncheck c71caf053b feat(scan): FortiPAM extension — 7.4 line, CNA cpe for NVD
cvelistV5 now carries CVE-2026-84388 (Fortinet as CNA, 2026-09-22) and
NVD lists it as Awaiting Analysis with no configuration. FG-IR-26-168
and the CNA description both now name "7.4 all versions" as affected
next to "8.0 all versions", so the entry gets a second row, 7.4.0 <= v
< 7.5, whose fix is on the 8.0 line (8.0.1.123). An advisory row takes
an `end` for that case.

The entry also carries the CNA's cpe (fortinet:fortipam_chrome_extension),
so NVD picks up the extension's later CVEs once it analyses them. For a
CVE an advisory row holds, the rows stay the verdict and NVD's result is
dropped: the CNA's structured data says only "8.0.1 affected", which
cannot tell 8.0.1.100 from the fixed 8.0.1.123 and misses 8.0.0 and
7.4. For the same reason cvelistV5 is not read for this extension.
2026-09-28 09:53:06 +02:00
vulncheck 96ab6a2b51 fix(app-scan): name the held Citrix CVEs in the scan log
"N CVE(s) with a bound not resolvable to a build — held, not decided"
gave a count only, so there was no way to tell which CVE or which
installed package was held. The line now lists the CVE IDs and the
package name and version.
2026-09-24 14:14:37 +02:00
vulncheck 62ac9363e6 fix(settings): show Users only to admins
/auth/users is RequireAdmin, but the Users section was marked
admin: false. Read-only and editor accounts saw an empty table and a
403 in the console. They now see Profile only, and the list is fetched
for admins only.
2026-09-24 14:14:37 +02:00
vulncheck 694bb0b6bd test(tomcat): pin the WebSocket CVEs that only cvelistV5 carries
A tester reported CVE-2026-76183 and CVE-2026-87022 as not detected on
"Apache Tomcat 9.0 Tomcat9 (remove only)" 9.0.107 on Windows. Both were
published on 23.09.2026 and NVD still has them at "Awaiting Analysis"
with no CPE, so cvelistV5 is the only structured source for now.

The real records, run through build_product_index and scan_asset, flag
the host, so the code is not the cause. The likely cause is timing: the
index is rebuilt at 01:30, so a scan on the day of publication could not
see them. The records are fixtures now, together with NVD's CPE ranges
for CVE-2026-73180. The test also covers the false-positive edges: the
fixed builds on each line (9.0.122, 10.1.60, 11.0.26) stay clean, and the
open "0 < 7.0.43" entry never reaches a 9.0 host.
2026-09-24 12:46:03 +02:00
vulncheck a8b5c1c695 feat(app-scan): Chrome CVEs from the Chrome Releases blog
Chrome 154 went stable on 22.09.2026 with 108 security fixes
(CVE-2026-95350 Critical, ...). Two days later none of them had a record
in cvelistV5 or NVD, so no host was flagged. Those were the only two
sources for Chrome. Every earlier release since July was on cvelistV5 by
then, so the gap is always the newest release.

The "Stable Channel Update for Desktop" posts name the fixed build per
platform ("154.0.8037.57 (Linux) 154.0.8037.57/.58 Windows/Mac"). The
lowest one is the lessThan that Google's CNA later writes into the
record (CVE-2026-93374: blog 153.0.8010.52 Linux, record lessThan
153.0.8010.52). _merge_chrome_releases adds these ranges to the chrome
key at the end of the index build, only for CVEs that no record has given
a Chrome range yet. Once the record lands, it decides with the same bound
and the finding continues. ChromeOS, Extended/Early Stable, iOS and posts
naming two majors are never read. When the blog is unreachable, the last
parse is served so findings do not close for a night. Index v41, so the
next App CVE Scan rebuilds it.

Checked against the live feed on 24.09.2026: 1265 CVEs from 17 posts,
all 108 of Chrome 154 with fix 154.0.8037.57.
2026-09-24 12:32:56 +02:00
vulncheck 2e03eea94c feat(netdisco): QNAP QTS / QuTS hero firmware CVEs
Netdisco reports a QNAP NAS as vendor "qnap", os "qts", version
"5.2.5.3145", or os "quts hero", version "h5.2.10.3577". It now becomes
asset OS "QNAP QTS" / "QNAP QuTS hero" and is scanned from NVD and
cvelistV5 like the other eight firmware lines. The leading "h" is hero's
spelling and QTS never has one, so a row whose os and "h" disagree gets no
verdict. The two OSes share one numbering and each has its own NVD CPE
(o:qnap:qts, o:qnap:quts_hero) and cvelistV5 key (index v40).

NVD answers a hero virtualMatchString only with the "h", so the NVD pass
keeps it. Older NVD records enumerate every build as an exact CPE with no
fix; the CNA pass backfills the fix.

The CNA states one range per branch, with floors written "5.2.x", "h5.2.0",
"5.x", "4.5.x, 4.4.x", "?" or "unspecified", and bounds like
"QuTS hero h5.2.0.2851 build 20240808". CVE-2025-66273 bounds hero h6.0 as
"?" < h6.0.0.3397 next to its h5.2 and h5.3 ranges; read literally, that
flags every patched h5.x box. An entry without a floor therefore only
applies on the bound's own branch (the Aruba rule).

Checked 23.09.2026 against the full cvelistV5 archive and every NVD
QTS/QuTS hero record: QTS 5.2.5.3145 gets 71 CVEs, 70 from both sources,
CVE-2025-66274 from the CNA only and CVE-2025-48725 from NVD only.
QuTS hero h5.2.10.3577 and QTS 5.2.9.3499 are clean. CVE-2025-59382 names
both OSes unaffected and gives no finding. QNAP's advisory pages attach
the same CNA records as JSON and are not ingested.
2026-09-23 10:40:18 +02:00
vulncheck 9f58016896 feat(triage): let a Jev run answer the whole backlog, not 500 CVEs
MAX_CVES_PER_RUN capped every nightly run at 500 unique CVEs. Nothing was
lost — only answered CVEs get stamped, so the rest came back the next night —
but a large backlog took weeks to drain for no reason: Jev handles the volume.

The run now asks every unanswered CVE in one pass. What kept the run safe is
unchanged: one question per unique CVE, a commit every 50 answers, and a stop
on a systemic failure (auth, quota, network) that leaves the rest unstamped
for the next night.
2026-09-21 10:35:55 +02:00
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
vulncheck 6c8cf7dd21 fix(igel): this UMS answers serverstatus blank, so the version can be told
The probe settled it: `/serverstatus` on this UMS returns HTTP 200 with EVERY
field empty — rmGuiServerVersion, activeMQVersion, derbyVersion, serverUUID
and server all "" — anonymously and logged in, on v1, v2 and v3 alike. Not a
renamed key, not a missing login, and the earlier guess that the component
versions were populated was wrong: the log only ever printed key names.

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

So the operator, who knows which UMS they installed, gets to say. New
optional ums_version / ums_build in the IGEL settings, used ONLY when IMI
reported none — a UMS that answers keeps answering for itself, and the Test
button says which of the two it is showing. Without this the one server that
manages the whole thin-client estate stays the only asset with no version to
compare a CVE range against.
2026-09-02 18:47:08 +02:00
vulncheck d92c686d80 tools(igel): probe for where this UMS keeps its own version
The login retry did not fill rmGuiServerVersion on the UMS in the field, so
the question is no longer "did we authenticate" but "does this UMS publish
its version at all, and where". Guessing another key name into the client
would be the third guess in a row.

Prints the raw serverstatus body before and after the login (so "the login
did not happen" and "the login changed nothing" stop looking alike), then
every neighbouring IMI route and web surface on the same port with any
version-shaped string found in it. Read-only.
2026-09-02 16:21:52 +02:00
vulncheck c93da7787e fix(igel): the UMS kept its own version until it was asked logged in
/serverstatus is the one IMI endpoint that answers without a login, which is
what makes it a reachability probe that can tell "cannot reach the UMS" from
"reached it, credentials rejected". Asked that way, UMS 12 answers 200 with
the whole envelope and its OWN version blank: activeMQVersion and
derbyVersion populated, rmGuiServerVersion "", no buildNumber at all. The
key was never renamed — the value simply is not handed to an anonymous
caller. So the UMS asset, the one server that manages the whole thin-client
estate, landed with no os_version and a description reading "IGEL UMS", and
nothing ever compared it against a CVE range.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 11:10:03 +02:00
vulncheck 978d4f3cc7 fix(firefox): the MFSA index was always a day behind the scan that used it
The nightly Firefox ranges were decided from yesterday's advisories, every
night, because the MFSA index refreshed itself ~10 minutes too late.

_merge_mozilla_mfsa asked get_index(), which honours its own 24h TTL. In the
nightly job the first caller to hit an expired cache is not the merge (03:20)
but the enrichment step that follows it (~03:30) — so the cache timestamp
drifted permanently to just after the build, and the merge read a ~23h50m old
index forever. Self-perpetuating: every night it re-stamped the same offset.

Measured on 02.09.2026, reproduced exactly offline against the advisory repo:

  03:21  cvelistv5-scan: MFSA decided 551 Firefox CVEs   <- 01.09. cache
  03:31  MFSA index built: 644 CVEs                      <- today's advisories

551 is the index as of mfsa2026-81; the truth that morning was 580 mainline
CVEs. The 29 in mfsa2026-82..85 (CVE-2026-84117..84145, published 01.09.
12:19 UTC) were not scannable for another full day.

force_fresh — which the nightly job already passes to re-download the
cvelistV5 ZIP — now also rebuilds the MFSA index before the merge. A rebuild
that fails still falls back to the cached index, so an offline or rate-limited
night is no worse than before, and the on-demand paths stay cache-only.

While there: a build cut short by the GitHub rate limit produced a PARTIAL
index and stored it, caching "Firefox has fewer CVEs than it does" for 24h.
It is now discarded in favour of the last complete one.
2026-09-02 10:55:56 +02:00
vulncheck 784caebd7c fix(assets): "seen again by a source sync" named the one thing nobody could look up
Three iPhones flipped inactive → active every day and the audit log's only
word on it was "(seen again by a source sync)" — the one line that cannot be
told apart from an Intune revive, a Defender TVM revive, or anything else.

Two defects behind the one symptom.

The nameless message came from the nightly TIME-based reconcile, which reacts
to `last_scan`/`last_seen` rather than to a sync run, and those stamps carried
no source, so no message could ever name one. Every sync that stamps them now
records itself in `last_seen_source` (migration 049) and the reactivation entry
quotes it: "seen again by the intune sync — time-based reconcile: last_scan=…,
last_seen=… is inside the 42d window". Rows stamped before the migration say
"unknown source (stamp predates source tracking)" — `assets.source` is the
source that CREATED the asset, not the one that last saw it, and a confident
wrong name in a revisionssicher log is worse than an honest gap. The
event-driven revives (Wazuh, Nessus, Intune, vCenter, IGEL) already named
themselves and are unchanged.

The flap itself: the phones were deleted in Intune, so the Intune reconcile
deactivated them on every sync, and hours later the nightly reconcile revived
them on the stamp the sync BEFORE the deletion had written. That stamp was
already there when the asset was deactivated, so it is not evidence of
anything — the reconcile now requires a stamp newer than the most recent
ASSET_DEACTIVATED entry, the same rule reconcile_missing_from_sync already
follows in the other direction. Fail-open: if that lookup errors the reconcile
behaves as before.

Also surfaced, read-only, in the asset edit dialog next to the VMware build:
Entra device ID, Intune device ID, Defender machine ID, Wazuh agent ID. The
Entra ID is the identifier that survives an Intune deletion while Defender TVM
keeps reporting the same device under another name — it was in the database and
in the CSV export all along, just nowhere on screen, which is exactly where an
operator needs it to exclude the right machine in Defender.

Not done: writing the ID into the Description field. Description is free-form
operator text and a sync overwriting it would clobber what someone typed there.
2026-08-31 21:37:15 +02:00
vulncheck 54ec6144cd feat(igel): thin clients were an inventory nobody was looking at
IGEL UMS as a fifth inventory source, configurable under Settings next to
vCenter. It registers the UMS server and every endpoint device it manages as
assets, then detects IGEL OS CVEs on each.

An IGEL thin client is the one endpoint class nothing here could see. It runs
no Wazuh agent — IGEL OS is a locked-down appliance, you do not install one —
Intune does not enrol it, and a Nessus scan gets an open port and no version.
An estate of several thousand devices showed up as zero assets and zero
findings, which reads as clean and only means unlooked-at.

UMS already holds what is needed, but it needs a join: the device record
carries a firmwareID ("21"), and the version lives in a separate firmware
table. One extra request answers for the whole estate — the alternative, two
calls per device, would be 6000 requests for 3000 thin clients.

CVE detection needs BOTH sources, and neither is redundant:

  * cvelistV5 carries IGEL's own CNA records. CVE-2026-82017 and -82018 (the
    DEF CON 34 thin-client research, ISN 2026-19 / 2026-20) sit at NVD with no
    configuration at all, so this is the only structured source for them.
  * NVD's CPE carries CVE-2025-47827, which MITRE filed with vendor "n/a" —
    there is no CNA data for cvelistV5 to index. It is also where the UMS
    server's own four CVEs live (CVE-2022-25804..25807: hardcoded DES keys,
    cleartext LDAP binds).

Two guards decide a finding. OS 11 and OS 12 are separate products with
separate fixes for the same flaw — CVE-2026-82017 is 11.11.150 on one line and
12.7.6 on the other — and taken naively an 11.11.149 device is also "less than
12.7.6". The installed major picks the line, and the product name ("IGEL OS
12") has to agree when the record states one.

The fix version survives an inclusive bound. CVE-2026-82018 says "affected up
to 12.8.2" and names 12.8.3 only in its `unaffected` entries — which is exactly
ISN 2026-20's Update Instructions, and the reason the finding exists. Without
recovering it the finding would call a device vulnerable and stay silent on
what fixes it.

Findings auto-close: an endpoint has no software inventory, so the app scan's
own reconcile never reaches it — the same hole the vSphere path had. The
CPE pass runs first and its hits are folded in, so the one CVE the cvelistV5
index structurally cannot confirm is not closed and reopened every night.

Address matching is deliberately off for devices. vCenter matches on it
because an ESXi host holds a static management address; a thin client takes a
DHCP lease, and a recycled lease would bind one device to another device's
finding history. The MAC-derived unit ID is the pin instead.

No EOL check: endoflife.date carries no IGEL product, and inventing lifecycle
dates for an estate whose real question is "OS 11 is end-of-maintenance" would
be worse than saying nothing.

The vendor security-bulletin page is planned, not built (docs/). The check
found the reason to wait: most ISNs are bundled-component notices — Chromium,
Firefox ESR — whose fix is stated per APP, and IMI v3 exposes no installed-app
resource, so those findings could not be attributed to a device.

Migration 048 (IGEL source + igel_unit_id). The cvelistV5 index key is bumped
to v31 so the cached index rebuilds with the new product.
2026-08-31 21:16:17 +02:00
vulncheck 2f742a7c4c fix(advisories): the KEV legend never made it to the feeds page
The dashboard widget explains its lock icon; the Security Advisory
Feeds header listed the same three catalogs but no legend, so 🔒 on
a row had nothing to decode it. Same source line, same suffix.

Also capitalised "Ransomware" in every user-visible string (legend
plus the three tooltips) — it was lowercase everywhere.
2026-08-29 18:06:53 +02:00
vulncheck fa5351a703 fix(kev): the alert mail was still a two-catalog feature
VulnCheck KEV shipped as a third exploited-catalog source in f77c741 and the
dashboard reads it, but the immediate-alert mail never did: db_init seeded
kev_alert_sources as "cisa,euvd" on the reasoning that a ~4x larger catalog
should be an opt-in, and there is no UI to opt in with. An exploited CVE that
only VulnCheck listed sat open on an active asset and produced no mail.

Catalog size is not what gates a mail — an OPEN finding on an ACTIVE asset is.
Whichever catalog names it, an exploited CVE on our own machine is worth the
alert, and VulnCheck usually lists days before CISA (lead_days is computed
from exactly that gap), which is the window an immediate alert exists for.

Seed is now all three. Migration 047 rewrites the value on installs still
carrying the untouched old default; anything an operator changed by hand is
left alone — a default correction, not a policy override.

The plumbing was already there: get_kev_catalog / _normalize_sources /
the {{sources}} label line all handle vulncheck. Only the seed was in the way.
Two stale docstrings naming just CISA and ENISA fixed with it.

First run after upgrade sends a backlog, not a storm: MAX_ALERTS_PER_MAIL
caps it at 25 and the job is hourly.
2026-08-29 18:03:15 +02:00
vulncheck 447e723eab fix(exchange): only Subscription Edition was on the CVE scanner's map
CVE-2026-62911 (auth bypass -> EoP, CVSS 8.0) was reported by Wazuh alone on
a host running "Microsoft Exchange Server 2016 Cumulative Update 23"
15.1.2507.61. Not a false positive: the record bounds 2016 CU23 at
15.01.0.0 .. < 15.01.2507.072, and .61 is below .72.

The app scan stayed quiet because the curated cvelistV5 registry knew exactly
one Exchange product — Subscription Edition. The CU row resolved to nothing,
so it was never compared against anything. The NVD-CPE path cannot cover these
at all: microsoft:exchange_server carries version "2016" and update
"cumulative_update_23", with no build to range-check (SE has its own CPE
product with real builds, which is why that one works there).

One key PER CU, not a shared "exchange" key: the record floors every CU at a
generic 15.0x.0.0, so a 2019 CU14 host (15.2.1544.x) would be compared against
the CU15 bound (1748.049) and reported affected while fully patched. The set is
closed — CU23 was the last 2016 CU, CU15 the last for 2019.

Anchored like the SE row, for the same reason: the same host also lists a plain
"Microsoft Exchange Server" frozen at the CU's RTM build (.6 against the CU
row's .61), plus language packs and KB hotfix rows with stub versions — each
would sit below every fix build forever.

Index setting bumped to v30 so the product index rebuilds.
2026-08-29 11:28:05 +02:00
vulncheck 7707a05bac fix(teamviewer): a 15.80.6.0 client reported clean against a 15.81.5 fix
CVE-2026-16444 (TV-2026-1008, CVSS 7.5) was invisible on every TeamViewer
install. NVD carries no configuration for it — the record sits at "Received"
with zero cpeMatch entries — so the CPE path was blind by construction, and
cvelistV5 was the only structured source left. There the record is filed under
ONE compound product string:

    vendor "TeamViewer"  product "Full Client, Host, QuickSupport & Portable"
    versions             15.0  lessThan  15.81.5

The curated pair list held ("teamviewer", "full client") and four siblings,
none of which equals that string, so the record never entered the index and
15.80.6.0 was never compared against 15.81.5. Across the 54 TeamViewer records
the same client appears under 25 spellings; the compound form is the one the
vendor has used since 2026, which is why coverage looked fine until now.

Replaced by a _PRODUCT_PATTERNS entry, the same mechanism Windows and Apple
releases use. The regex is anchored, enumerates the client component words and
excludes the mobile apps by name: DEX (32 records, versioned 9.x/24.x/25.x),
ONE, Frontline, IoT, Engage, Meeting and Patch & Asset Management all ship
their own version lines and must never be compared against a 15.x client.

CVE-2026-19042 is the control and is deliberately NOT a finding on Windows:
the record and the bulletin both scope it to Linux.

Also here, from the same audit:

- NVD files the client under a SECOND CPE name since the 2023 rename —
  28 records say teamviewer:teamviewer, 2 say teamviewer:remote. The registry
  queried one, so it lost the other's; added via `also`, the way Adobe's two
  Reader names already are.
- New source teamviewer_bulletin_service: the vendor's own security bulletins,
  for the same reason Firefox has MFSA and Notepad++ has repo advisories.
  5 of the 30 published bulletins carry NO CVE id at all — TV-2026-1006 is a
  real Full Client flaw (15.78–15.79.3) that no CVE-keyed source can see. The
  bulletin id stands in as the identifier, as GHSA- and NESSUS-PLUGIN- do.
  Rows are scoped per platform and per release line; bulletins that state no
  version ("All", "Server-side hot fix") are skipped rather than guessed at.
- _upsert learns `refs`, so a finding whose id has no public catalogue page
  carries the advisory URL as its only reference.
- An index that parses to empty is never stored: it would be stamped fresh for
  24h, leave every TeamViewer finding untouched, and the app-scan reconcile
  reads untouched as "no longer detected" → patched.

Verified against the live feeds: 30 bulletins, 17 with client version tables,
and on a Windows host at 15.80.6.0 exactly one finding — CVE-2026-16444,
fix 15.81.5.
2026-08-29 11:19:45 +02:00
vulncheck ed728cb593 fix(dashboard): the KEV widget called every VulnCheck row "CISA"
The advisories panel learned the third catalog; the dashboard widget did not.
Its badge cell was a two-way ternary — euvd or else CISA — so every row that
only VulnCheck listed got painted with CISA's name and CISA's red, and the
header underneath still read "CISA KEV + ENISA EUVD". Wrong on the facts, and
wrong on the licence: VulnCheck KEV is free to use only where it is named.

Cause was the duplicate definition. The advisories page carried its own badge
map and its own source line; the dashboard carried a copy that predated
VulnCheck. Both now read from frontend/lib/kevBadge.ts, and the widget takes
its attribution from the same response that carries the rows — so a fourth
catalog names itself in both places instead of silently borrowing CISA's.

Unknown sources now fall back to their own id, never to another catalog.
The lead-time badge (−6d) rides along, since that is the reason VulnCheck is
carried at all.

Per-vulnerability data is untouched: kev_listed is still CISA alone, so the
vulnerability tables show no VulnCheck column — there is no such field yet,
and adding one moves every URS score in the fleet.
2026-08-29 10:04:36 +02:00
vulncheck f77c7413b3 feat(kev): VulnCheck KEV as a third exploited-catalog source
The KEV panel knew CISA (~1400 CVEs) and ENISA EUVD. VulnCheck KEV carries
~5200 and typically lists them days before CISA does — every record ships
CISA's own date, so the lead time is measurable, not a claim, and each row
now shows it.

Opt-in and silent by default: without VULNCHECK_API_TOKEN the source returns
{} and nothing about the install changes. Alerting is deliberately NOT opted
in either — kev_alert_sources stays "cisa,euvd", because ~4x the CVEs is the
operator's decision, not a side effect of updating.

No SDK: two GETs against a documented JSON API. The backup endpoint hands out
the whole index as one signed ZIP; the paginated index is only the fallback
and is capped at 600 documents on the community tier, which the log says out
loud so a partial catalog is never mistaken for the full one.

Attribution is a licence term, not decoration: "VulnCheck KEV" is named in the
KEV panel header and badges every row the source contributed to. Noted in
LICENSING.md so nobody strips it later.

Per-vulnerability scoring is untouched — kev_listed still comes from CISA
alone. Feeding 5200 CVEs into the URS bonus would move every score in the
fleet and belongs in its own decision.
2026-08-29 09:42:03 +02:00
vulncheck 9e38554787 perf(dashboard): cut the load to a fifth of its queries
The dashboard fires fourteen requests and renders nothing until the slowest
returns. Measured against a seeded fleet (300 assets, 80k findings), the burst
took 0.76s — and the server runs them strictly one after another: every
vulnerabilities endpoint is `async def` holding a blocking Session, so six
parallel requests take exactly as long as six sequential ones (measured
speedup 0.90x). Wall clock is therefore the SUM of the endpoints, which makes
every wasted query on the page a direct hit on time-to-render.

Three sources of waste, all removed:

- /reports/dashboard ran eighteen COUNTs over the same scan — four severities,
  exploitable, avg, distinct assets, oldest row, seven one-day history counts,
  two totals, two asset counts. Now three statements: one aggregate with FILTER
  clauses, one grouped history query, one asset count. Response is byte-identical.
- The list endpoint lazily loaded asset, asset.groups and packages per row, so
  a twelve-row widget spent 36 round trips after its one real query. Eager-loaded:
  39 statements to 5 for that call, and the dashboard fires seven such widgets.
- With distinct_cve the unused `total` re-ran the whole window-function pass
  (80ms of a 160ms call) for a number no widget renders. New `with_total=false`,
  which the dashboard's seven widget calls now pass; default stays on, so the
  Vulnerabilities page is untouched.

Dashboard burst 0.76s → 0.48s locally. The N+1 removal counts for more than
that on a real deployment, where every round trip crosses the network.

Remaining top cost is /compliance/urs (185ms, ~40% of what is left): it pulls
every open finding into Python to compute CPR per row. The materialised
cpr_score column could aggregate that in SQL, but that changes URS numbers
wherever the column is stale, so it stays a separate decision.
2026-08-29 09:23:35 +02:00
vulncheck b14e83be92 fix(dashboard): link URS highest-risk assets to their compliance detail
The URS widget listed the five highest-risk assets as plain text — the only
list on the dashboard that was not clickable. KEV rows push to the
vulnerability view, compliance "worst offenders" linked out, only URS was a
dead end: the user read a hostname, then searched for it by hand on the
compliance page.

Each entry now links to /compliance?asset=<id>, and the compliance page
reads that parameter once its tables have loaded and opens the asset's
policy modal — the same modal a click on the URS table row opens. That makes
it a direct link to the affected asset, not just to the section.

Same fix for the compliance widget's "worst offenders", which linked to the
bare /compliance page and dropped the asset it named.

useSearchParams forces the page dynamic unless it sits under a Suspense
boundary, so CompliancePage is split into an inner component and a Suspense
wrapper, matching the vulnerabilities page. /compliance stays statically
prerendered.

The "Open >" link to the compliance page reported as missing from the URS
widget is present and unchanged (page.tsx:557).
2026-08-28 14:30:02 +02:00
vulncheck e7ad196d0e perf(urs): score the fleet in one pass instead of once per asset
Feldmeldung: Dashboard und Compliance-Seite brauchen seit dem Einspielen
der Mittwochs-Commits acht Sekunden statt drei. Die Logs zeigen, woran es
liegt, und es sind nicht die Mittwochs-Commits: von allen Calls beider
Seiten ist genau einer langsam, /api/v1/compliance/urs braucht 4,9 bis 6,0
Sekunden, alles andere zusammen unter 300 ms. Beide Seiten haengen ihr
gesamtes Promise.all daran, also wartet die ganze Seite mit.

list_urs hat pro Asset gescort: eine Asset-Abfrage, eine Vulnerability-
Abfrage, eine Result-Abfrage, dann pro Policy ein Lazy-Load der Checks
plus eine Impact-Abfrage. Nachgemessen mit einer 120-Host-Flotte, drei
Policies, 120 Checks: 961 Statements fuer einen Aufruf. Auf SQLite im
selben Prozess sind das 600 ms und faellt niemandem auf; gegen ein
Postgres ueber einen Socket sind es bei 3 ms Round-Trip 5,3 Sekunden —
exakt der gemessene Wert.

Deshalb war es vorher schnell und ist es jetzt nicht: der teure Zweig
zaehlt erst, seit SCA-Daten und Impact-Bewertungen geladen sind. Ohne
gecachte Checks kehrt _weighted_score_for_policy sofort zum gespeicherten
Score zurueck und die zwei Abfragen pro Policy entfallen. Wer URS und
Compliance anbindet, schaltet den Pfad scharf.

compute_urs_bulk() macht denselben Score in fuenf Abfragen, unabhaengig
von der Flottengroesse: Assets, offene Vulnerabilities als Spaltenabfrage,
Results, deren Checks, die Impact-Tabelle. Der Lesepfad schreibt dabei
nicht mehr — bisher hat compute_ass weighted_score auf den geladenen Zeilen
gesetzt, was ein GET per Autoflush zu UPDATEs gebracht hat. Persistiert
wird nur noch, wo es gewollt ist (recompute-all und der naechtliche Job).
compute_avs/compute_ass/compute_urs bleiben als duenne Wrapper ueber
denselben Code, damit Einzel- und Massenpfad nicht auseinanderlaufen
koennen. Die CPR-Formel steht jetzt als cpr_score() auf Modulebene, sodass
der Massenpfad sie ueber eine Spaltenabfrage laufen lassen kann, statt pro
Finding ein ORM-Objekt zu bauen — eine Quelle, keine zweite Kopie.

Dabei aufgefallen: /compliance/assets hat get_compliance_summary()
aufgerufen, das Ergebnis weggeworfen und dieselbe Aggregation nochmal
inline gemacht — beide Kopien mit einer Hostname-Abfrage pro Asset in der
Schleife. Zwei N+1-Schleifen fuer eine Liste. Jetzt eine gemeinsame
_asset_rollups(), zwei Abfragen.

Messung 265 Assets, 3 ms Round-Trip: 2121 Statements / 11,7 s → 6
Statements / 0,18 s, Ergebnis Zeile fuer Zeile identisch.

- tests/test_urs_bulk_scoring.py pinnt Statement-Zahl unabhaengig von der
  Flottengroesse, Gleichheit mit dem Einzelpfad in allen drei avs_modes,
  und dass ein Lesen nichts schreibt
- tests/test_compliance_rollup_queries.py pinnt dasselbe fuer das Rollup
2026-08-28 14:24:02 +02:00
vulncheck 0a61b33770 feat(assets): manual decommission — the state "Show decommissioned" filtered for
The "Show decommissioned" checkbox sent include_inactive=true and the backend
filter honoured it correctly, but no code path ever wrote status=DECOMMISSIONED:
the syncs only set INACTIVE/ACTIVE, the edit modal had no status field, and
Delete hard-removes the asset with its findings (CASCADE) instead of retiring
it. The filter had no state to filter for, so the checkbox looked dead.

The PUT endpoint did accept `status`, but wrote it with a bare setattr — the
transition reached the audit log as a generic ASSET_UPDATED instead of the
ASSET_DEACTIVATED / ASSET_REACTIVATED entry every sync-driven transition gets.

- Lifecycle Status select in the asset edit modal (edit only, editor+)
- PUT /assets/{id} routes status through apply_status() so the trail names the
  transition, with force=True: the operator-final guard is there to stop a sync
  from reviving a retired asset, not the operator who retired it
- tests/test_asset_decommission.py pins audit event, default filter and undo

No UI button for the lifecycle reconcile — it stays the nightly 04:15 job plus
POST /assets/reconcile-lifecycle (editor+). The tester text listing a
"Lifecycle abgleichen" button described a control that never existed.
2026-08-27 14:36:15 +02:00
vulncheck 643fee40a6 fix(policies): gate Create/Delete behind admin, Edit behind editor
Same class as 2e1b8e8, different role. The nav lets editors onto
/policies (requires: 'editor'), but POST /api/v1/policies and
DELETE /api/v1/policies/{id} are RequireAdmin — only PUT is
RequireEditor. So an editor saw a "Create Policy" button and a trash
icon that could only ever answer 403.

Fetch the role like the other pages do and gate accordingly: Create and
Delete admin-only, Edit editor-or-above (which also covers a read-only
user reaching the page by direct URL, since the nav item is hidden).

Audited the remaining pages while here — vulnerabilities/ is already
fully gated via currentUser?.role, and scans/, groups/ and admin/auth/
are coherent: every button on them needs exactly the role the nav
already requires to reach the page.
2026-08-27 10:30:08 +02:00
vulncheck 2e1b8e8024 fix(assets): hide Exposure button from read-only users
The "⚡ Exposure" toolbar button posts to /assets/refresh-exposure,
which is guarded by RequireEditor. Every other mutating control on the
page (Add Asset, rescan, edit, delete) is wrapped in `canEdit`; this one
was not, so a read-only user saw it, clicked it, and got
"Access denied. Required role: editor" as a failure alert.

Wrap it in the same `canEdit` gate. No backend change — the 403 was
correct, the button should never have been reachable.
2026-08-27 10:03:06 +02:00
vulncheck 9c84cf2939 fix(security): validate token purpose, verify Wazuh TLS, gate two asset routes
Aus dem Security-Review dieser Branch. Vier Findings, alle mit einem
gemeinsamen Muster: eine Kontrolle existiert, greift aber nicht.

MFA war mit dem Passwort allein umgehbar. _issue_mfa_challenge hat das
Challenge-Token mit create_access_token gebaut — ein regulaerer,
signierter Access-JWT. get_current_user prueft Signatur, Ablauf, JTI,
sub und is_active, aber nie den mfa_pending-Claim; den liest nur
/auth/mfa/verify. Wer das Passwort hat, ruft /auth/login, nimmt das
mfa_token aus der Antwort und schickt es als Bearer an jeden geschuetzten
Endpoint — mit der vollen Rolle aus der DB-Zeile, weil PermissionChecker
die Rolle nicht aus dem Token liest. Dasselbe galt fuer das
Forced-Setup-Token, das in den SSO-Pfaden zusaetzlich als URL-Parameter
uebergeben wird, und fuer Refresh-Tokens: /auth/refresh prueft
type == "refresh", die Gegenrichtung war offen, also war ein
Refresh-Token ein 7-Tage-Bearer und die Rotation umgehbar.

Alle Tokens tragen jetzt einen type-Claim, und get_current_user laesst
nur "access" durch — positiv allowlisten, damit ein neuer Token-Typ per
Default abgelehnt statt per Default gueltig ist. Nebenwirkung: bestehende
Access-Tokens ohne type-Claim werden ungueltig. Refresh-Tokens tragen den
Claim schon, Clients holen sich also im Hintergrund ein neues
Access-Token; wer keins hat, loggt sich einmal neu ein.

Wazuh lief durchgaengig mit verify=False. 15 Call-Sites bauten den
Client mit cfg.get("verify_ssl", False), und das war kein Edge Case: der
wazuhConfig-State im Frontend hatte gar kein verify_ssl-Feld, der Save
schrieb den Key also nie, und der Settings-PUT ersetzt den ganzen Wert.
Der Fallback war der einzig erreichbare Zustand. Ueber diese Verbindung
gehen Manager- und Indexer-Credentials als HTTP Basic. Jetzt
bool(cfg.get("verify_ssl", True)) wie bei Nessus/vCenter/Intune, plus
eine Verify-SSL-Checkbox im Wazuh-Formular, damit ein self-signed Zert
bewusst statt stillschweigend akzeptiert wird.

setup.sh hat einen JWT-Key generiert und ihn nirgends hingeschrieben:
das sed suchte einen Platzhalter, den .env.example seit Laengerem nicht
mehr enthaelt, traf nichts, exit 0, und die naechste Zeile meldete
Erfolg. Die Instanz signierte also mit dem oeffentlich bekannten
CHANGE-ME-String — offline faelschbare Admin-Tokens. Das sed ersetzt
jetzt die ganze Zeile und verifiziert danach; wichtiger ist der
App-seitige Guard in jwt_handler, der Platzhalter und Keys unter 32
Zeichen beim Start ablehnt, so wie totp.py es fuer
AUTH_PROVIDER_CRYPTO_KEY schon tut. Damit ist auch der Operator
abgefangen, der .env.example kopiert und Schritt 2 vergisst.

Beide Setup-Skripte legten ausserdem einen Admin mit hardcodiertem
"changeme" an und umgingen damit die Staerke-Pruefung in db_init. Der
Block ist raus, create_initial_data ist der einzige Bootstrap-Pfad.

Dazu RequireEditor auf /assets/reconcile-lifecycle und
/assets/refresh-exposure — beide hatten nur get_current_user, waehrend
jede andere mutierende Route derselben Datei RequireEditor verlangt.

Nicht gefixt, bewusst: JIT-Provisioning verlinkt eine SSO-Identitaet per
unverifizierter E-Mail auf ein bestehendes lokales Konto
(jit_provisioner.py:60). Kein Privilege Gain, weil _refresh_role die
Rolle neu berechnet, aber ein Lockout des legitimen Admins ist moeglich.
Braucht eine Design-Entscheidung, kein Einzeiler.

tests/test_token_purpose.py deckt die Typ-Logik ab. 92 Tests gruen.
2026-08-26 16:12:34 +02:00
vulncheck 74c0312b5c docs(readme): document AUTH_PROVIDER_CRYPTO_KEY in first-time setup
The setup steps never mentioned it, so a fresh install keeps the shipped
CHANGE-ME-FERNET-KEY placeholder and the first MFA enrolment fails with a
bare HTTP 500 ("AUTH_PROVIDER_CRYPTO_KEY is not a valid Fernet key").
Nothing else is affected, which is exactly why it stays hidden until
someone turns on MFA.

Also corrects the reverse-proxy section, which still named the compose
fallback ports 3000/8000 instead of the 3003/8022 that .env.example ships.

Docs only — nothing to re-test.
2026-08-26 15:48:37 +02:00
vulncheck 21f85e4b89 fix(alembic): unbreak fresh installs at migration 028
A fresh `docker compose up` never came up. Migration 028 imported the
live ORM (`from app.models.asset import Asset`) and ran `db.query(Asset)`
against a database that is only at revision 028, so the SELECT asked for
`assets.intune_device_id` — a column revision 033 adds. Alembic aborted
with UndefinedColumn, the whole migration chain rolled back (one
transaction for all revisions), and the backend crash-looped against an
empty database.

028 is now raw SQL: one CTE that flips the legacy Nessus rows and writes
the audit entries, comparing `source::text` / `status::text` so it no
longer depends on today's enum labels either.

env.py switches to transaction_per_migration. 027 adds the
ASSET_DEACTIVATED enum label that 028 inserts, and PostgreSQL refuses to
use a new enum value in the transaction that added it ("unsafe use of new
value"). Per-migration transactions also make a failed upgrade resumable
instead of all-or-nothing.

Verified end to end from a clean `git archive` checkout: migrations run
027 -> 046, default admin is created, backend reports healthy, login
through the frontend proxy returns a session cookie.

tests/test_migrations_no_orm_imports.py locks the bug class down: no
migration may import app code, because the ORM mapping is always newer
than the schema of the revision it runs at.
2026-08-26 15:48:37 +02:00
vulncheck ab6d4c6152 fix(kev-alert): separate source badges with a real space
Badges were emitted back-to-back (`</span><span>`) and relied only on
`.src { margin-right: 3px }` for the gap. Mail clients that drop margins on
inline elements rendered them merged: "CISA KEVENISA EUVD",
"CISA KEVENISA EUVDRANSOMWARE".

Join the labels with a literal space and add `white-space: nowrap` so a badge
never wraps mid-label. Regression test covers both.
2026-08-26 15:34:05 +02:00
vulncheck 36ecf7edaf docs(readme): stop listing AI models the app never hardcoded
The provider table named specific models — GPT-4.1 and o3, Claude Opus 4.5
and Sonnet 4.5, Gemini 3 Pro and 2.5 Pro, DeepSeek V3 and R1. Every one of
those is now a generation or more behind, and two are already retired
upstream: OpenAI sunsets o3 today, and Gemini 2.0 shut down on 1 June.

The list was wrong in a way it could not stay right, because the app never
read it. get_ai_models() in app/routers/settings.py queries each provider's
own /models endpoint when the key is entered, so the dropdown already shows
whatever that key can reach — a model released tomorrow appears without a
code change. Documenting a snapshot of that alongside it only created
something to go stale.

Replaced with the provider list, their default endpoints, and the note that
discovery is live. Same six providers as before; nothing in their support
changed.

Documentation only. Nothing to test.
2026-08-26 15:34:05 +02:00
vulncheck 0b71453c48 chore(release): drop personal attribution from comments and docstrings
Comments across the codebase credited one individual by role and, in places,
described that person's own machines: which SQL Server versions a host ran,
which devices were enrolled, what a particular dashboard showed, how many
findings sat open on which server. In a public repository that reads as a
profile of someone's unpatched estate.

The observations are why the code looks the way it does, so they stay. Every
CVE id, version, build number, count and date is preserved, as are the
verbatim quotes that motivated specific sort and filter rules — only the
attribution changes, to "field report", "observed", "a host". A local
variable in tests/test_autodesk_year.py was renamed for the same reason; its
value and every assertion around it are byte-identical.

PROJECT_OVERVIEW.md additionally loses a subtitle naming the kind of
organisation this was built for, and a support section pointing at an
internal team, both replaced with neutral wording.

Comments, docstrings and markdown prose only: 74 files, 200 lines, one-for-one
swaps. detect_changes reports 104 touched symbols and zero affected execution
flows, and all 55 test scripts pass. Nothing here needs re-testing.
2026-08-26 15:34:05 +02:00
vulncheck 262cd46652 chore(release): use neutral placeholders in examples and fixtures
Ahead of the public release, example values now use generic placeholders
instead of values carried over from a live environment: the connection
placeholders in the settings form, the sample data behind the mail-template
preview, the hostnames in the EOL fixtures, and the device identifiers in the
Intune re-enrolment fixtures. The README points at GitHub rather than the
internal Git host.

Comments, docstrings, HTML placeholder attributes and fixture strings only.
No logic changed and the assertions pin the same behaviour as before, so
there is nothing here to re-test.

.gitignore additionally covers every .env variant (only literal .env and
.env.local matched before, so a .env.production would have been picked up by
git add .), the SAML and LDAPS key material .env.example tells operators to
generate in the repo root, AI-tooling state, root-level node artifacts,
generated report exports, and the untracked third-party checkout sitting in
the working tree, which git add . would otherwise commit as a broken gitlink.

Also untracks the local editor-assistant config and an empty root
package-lock.json stub that had no package.json beside it.
2026-08-26 15:34:05 +02:00
vulncheck 6740b44edb docs(licensing): add CLA, open-core boundary, contributing and security policy
Publishing under AGPL without a CLA closes the door on ever offering
commercial terms: every merged PR would be copyright we cannot relicense,
and asking dozens of contributors for permission after the fact does not
work. CLA.md section 2 grants the maintainer a license "under any license
terms, including proprietary" — that clause is the whole point.

LICENSING.md states the open-core boundary up front rather than carving
features out later, which is the move that has burned trust elsewhere. The
entire detection and enrichment engine stays AGPL; Enterprise is limited to
SSO, audit trail, compliance reporting, SIEM forwarding and multi-tenancy,
under a stated two-part rule for what may ever go there.

SECURITY.md gives a private disclosure path — a vulnerability scanner whose
own bugs land as public issues is a poor advertisement.

The CLA bot runs as a GitHub Action writing signatures to a cla-signatures
branch, so there is no third-party service to sign up for.

Placeholders {{LICENSOR}}, {{CONTACT}}, {{GH_OWNER}} and {{GH_REPO}} are
filled in once the legal entity and the GitHub location are settled.
2026-08-26 14:40:50 +02:00
vulncheck 1da02f96d9 chore(release): scrub internal identifiers before the repo goes public
The tree carried our own infrastructure in places a public reader could
reconstruct it from:

- frontend settings placeholders showed the real Wazuh manager and indexer
  on 10.10.66.0/24, next to .local placeholders everywhere else in the file
- test fixtures used the live "ise-" hostname convention (dc, mail hybrid,
  Exchange, SQL, SAP, Citrix VDI), and one shipped source comment in
  sync_agent_vulnerabilities named a domain controller outright
- the Intune re-enrolment fixtures and the _run_intune_sync_locked docstring
  carried a real Samsung serial and the two Graph device ids it was captured
  from

Replaced with neutral placeholders; the assertions still pin the same
behaviour. README now points at GitHub instead of the internal Gitea host.

.gitignore gained the patterns that only held by luck: every .env variant
(only literal .env and .env.local were covered, so a .env.production would
have sailed into git add .), the SAML/LDAPS key material our own .env.example
tells operators to generate in the repo root, AI-tooling state (.gitnexus was
ignored via .git/info/exclude, which is never cloned), root-level node
artifacts, generated report exports, and the untracked third-party
wazuh-risk-insight checkout that git add . would have committed as a broken
gitlink.

Untracked .serena/ config and the empty root package-lock.json stub.
2026-08-26 14:40:50 +02:00
vulncheck bda905fcb9 fix(mobile-eol): the Intune sync closed the device-EOL finding it just raised
Tester report: EOL-SAMSUNG-GALAXY-TAB-galaxy-tab-a8 and
EOL-SAMSUNG-MOBILE-galaxy-xcover5 flipping patched -> open -> patched in the
audit log, seconds apart, every sync.

Both events come from one run over one device. check_device raises the
device-model EOL finding through the normal EOL upsert, so it is stored with
first_detected_by=eol_check like any other. The detectedApps sweep in the same
loop iteration then reconciles the asset's EOL findings against the app
inventory it just read — and a phone model is in no app inventory, so the
finding reads as "no longer installed" and is closed again. The device ended
every sync with its EOL hidden, which is the false negative behind the report.

reconcile_eol_findings already has the channel for this (seen_ids, used for
the OS-level EOL row); the mobile check simply never fed it. It does now.
Its docstring claimed mobile findings were out of reach of the reconcile,
which is what kept the bug invisible — corrected.

Detection itself was right both times, and stays right: Tab A8 (SM-X205) is
EOL since 2026-02-21 (high), XCover5 (SM-G525F) is only past active support
since 2024-03-12 and still gets security patches, so it is the low
"end-of-active-support" finding, not EOL.

Existing rows heal on the next sync (reopen_if_patched).
2026-08-21 13:18:58 +02:00
vulncheck 4fce4a7306 fix(msrc): the ingest sorted the monthly index by revision date, not month
CVE-2026-62727 landed on the tester's Server 2025 host on 20.08. via the
fixed-build scan and still had no Remediation block afterwards — the same
symptom 0bf3252 was supposed to end.

_list_recent_docs sorted the /updates index by CurrentReleaseDate before
taking the tail. That field is the last REVISION date, not the month the
document is: Microsoft restamps ancient documents in bulk (19.08.2026 put
2020-May, 2021-Nov, 2023-Feb, 2024-Oct on one timestamp; 1999-Sep "Mariner
Release Notes" sits at 2025-10). So the tail was "most recently touched",
not "most recent months". build_product_index takes 18 of that order and
still saw 2026-Aug — which is why the CVE was detected — while the 2-month
catch-up ingest took 2 and could miss it entirely. Detection without
remediation is exactly the shape the tester reported. Sort on the month
parsed from the ID instead; the 18-month backfill gets real coverage too,
which it never had.

Second half, the latency: even correct, the ingest is a whole-month batch
that runs at 03:50. A finding created out of band — the tester's was
detected 16:15 — waited until the next morning. The Update Guide (sug) API
answers per CVE in ~50 KB with the same payload (KB, fixedBuildNumber,
update type, download link), so the detail view now fetches that one CVE
when a Windows finding has no MSRC rows yet, mirroring the Linux/OSV
on-demand path next to it. Rows come out in parse_vulnerability's shape, so
the existing host-branch narrowing applies unchanged: at installed
10.0.26100.33138 it picks KB5120233 (build .33296) and the hotpatch
KB5120228 (.33222).

The old test fixture pretended CurrentReleaseDate tracked the month, which
is what hid this; it now carries restamped entries and fails on the old sort.
2026-08-21 08:46:52 +02:00
vulncheck 891979ee28 fix(wazuh): detect the manager, and stop the agent inheriting its CVEs
Four separate defects made the same product invisible on the one host it
runs on. The tester's two reports are the visible tip of it.

1. GHSA index kept vulnerabilities[0] only. An advisory carries one block
   per affected package, each with its own range. GHSA-mvh4-g699-984j
   (CVE-2026-54085) lists wazuh-agent ">= 4.2.0" and wazuh-manager
   "> 4.2.0" — only the agent was ever indexed, so the manager side was
   never seen. 7 of wazuh/wazuh's 74 advisories are shaped this way.

2. The range parser understood only a "< x" upper bound, and read
   patched_versions with a bare _vt(). Wazuh writes ">= 4.0.0,
   <= 5.0.0-beta2" (CVE-2026-74039), ">= 4.14.4", "≥ 4.8.0",
   "4.10.4, 4.14.5" and "from 3.5.0 to 4.3.10." — every one of those
   returned False. A ">= x" range has no upper bound of its own and falls
   back on the patched value, so an unreadable patched value silenced the
   whole advisory: 24 of the 74 were invisible. A 4.14.1 manager goes from
   23 findings to 39, with nothing lost.

3. cvelistV5 states the component too — CVE-2026-74039 is filed under
   product "wazuh-manager" — but every wazuh-* package resolves to the one
   key "wazuh", so the record landed on the agents. The only guard was a
   prose heuristic, and that description names neither side. That is the
   reported false positive: an agent carrying a manager-only API DoS.

4. All three scan paths dedup on (product key, version). A manager host
   runs wazuh-manager AND wazuh-agent at the identical version, so
   whichever syscollector listed first took the key and the other was
   never scanned. With the agent first, the manager guard in _upsert then
   dropped the manager-side CVEs as well.

Hypothesis that held: the component is stated fact in both feeds, and
every path was inferring it from prose instead of reading it.

Verified against the live wazuh/wazuh feed (74 advisories) and the
cvelistV5 record for CVE-2026-74039. GHSA index cache key bumped to v3 to
force a rebuild; the stale agent finding closes itself through the
existing app-scan reconcile on the next scan.
2026-08-21 08:38:59 +02:00
vulncheck 6e449dba12 fix(vcenter): auto-close patched ESXi/vCenter CVEs, and stop leaking the sync lock
Two bugs, one visible symptom: a hypervisor patched during the day kept every
CVE its old build had, and the manual sync answered "another sync was already
running" forever.

1. The vSphere pass never reconciled. The app scan's auto-resolve sits behind
   `if packages:` — a hypervisor runs no agent, has no package inventory, and
   so never reached it. Nothing else ever revisited these findings: the new
   version and build were written onto the asset (correct in the GUI) while
   the findings from the old build stayed open, run after run. The reconcile
   now lives in scan_asset_vmware, where the verdict is made, so both callers
   (vCenter sync + nightly app scan) get it. Same contract as every other
   reconcile: retract our own source, mark patched only once no scanner still
   claims it, and close nothing at all when this pass had no verdict — no
   build on the asset, no index, or a bound the catalog cannot resolve.
   It commits itself: the nightly scan commits an asset only when the scan
   FOUND something, and a host whose findings were all just closed found
   nothing — its cleanup would have been rolled back and redone every night.

2. The advisory lock leaked across the connection pool. pg_try_advisory_lock
   is SESSION scoped — it belongs to the postgres backend, i.e. to the pooled
   connection — but both syncs took it on their ORM Session and then commit
   once per host, and a committed Session hands its connection back to the
   pool. Any concurrent request could take it, after which the sync ran on a
   different backend: the closing pg_advisory_unlock found nothing, returned
   false, and the lock stayed held for the life of that pooled connection.
   Reproduced against postgres: run 1 released it by luck, run 2 unlocked the
   wrong backend, run 3 was skipped, one lock held. From then on EVERY vCenter
   sync — manual and nightly — was skipped, so nothing rescanned the hosts and
   nothing could ever be closed. The lock now gets a connection of its own
   (database.advisory_lock), and a failed unlock invalidates that connection
   instead of returning it to the pool still locked. Intune/Defender had the
   same pattern and the same fix.
2026-08-21 08:28:05 +02:00
vulncheck a099fef3b8 fix(firefox): take the CVE list from Mozilla's MFSA, not cvelistV5 ranges
Of the 23 CVEs in mfsa2026-74 (announced 18.08.) TrueVuln had found two on
20.08. Not a cache problem — the yml was fetched 19.08. 15:39 — the MFSA index
was only ever used to fill in severity and description, never to find a CVE.

Detection came from cvelistV5, which states Firefox INVERSELY: no affected
range, only one "unaffected" floor per maintained train (115.39 / 140.14 /
153.1 / 154). Nothing in the record marks which floor is the release train and
which are ESR, so _ranges_from_affected only inverts the single-floor shape —
anything else is skipped rather than flag a regular Firefox 121 for an ESR-only
advisory. CVE-2026-74975 and -74989 have one floor; the other 21 have two to
four and produced no range at all. Whether such a CVE was found then came down
to NVD having published a CPE for it, which is why two assets on the same
Firefox build reported different CVE sets.

Mozilla's own advisory says it in one line — fixed_in: [Firefox 154] — so the
MFSA index now decides the Firefox ranges and cvelistV5 keeps everything else:

- mozilla_advisory_service: derive ff_fix (mainline, non-ESR/iOS/Thunderbird)
  and esr_fix per CVE; merge fixed_in across advisories instead of letting the
  first file listed win, so the regular-vs-ESR verdict no longer depends on
  alphabetical order; parse the indented "  - Firefox 137.0.2" form (5 of 178
  advisories, previously an empty fixed_in); never overwrite a good index with
  an empty one and serve it stale when a rebuild fails.
- cvelistv5_scan_service: _merge_mozilla_mfsa replaces the Firefox entries for
  every CVE Mozilla has ruled on, drops the ESR/iOS/Thunderbird-only ones, and
  spares an install sitting at or above its own ESR train's fix. Index setting
  bumped to v28 so the cache rebuilds.

Verified against all 178 announce/2025+2026 advisories and the live cvelistV5
records: all 23 mfsa2026-74 CVEs now resolve to "below 154", identically for
153.0.4 and 147.0.4. Regression test: tests/test_firefox_mfsa_source.py
2026-08-21 08:01:53 +02:00
vulncheck 1b1ff11ca1 fix(cvelistv5): force a fresh ZIP for the nightly index build
The 15 Chrome CVEs from stable 151.0.7922.169 were committed to cvelistV5 on
2026-08-18 at 20:43 UTC but only surfaced in TrueVuln two nights later.

Confirmed cause: the nightly index build (03:20 UTC) shares
/tmp/truevuln-cvelistv5-cache.zip with the threat-intel refresh, whose
IntervalTrigger(24h) is anchored to app startup rather than the clock. On a
container started in the late afternoon that job re-downloads the ZIP around
17:00-20:00, so at 03:20 the file is only ~10h old — inside the 12h TTL. The
index build therefore never downloaded anything: it rebuilt, night after
night, from an afternoon snapshot taken before Google published. A simulation
of the real cron times and TTLs puts worst-case detection latency at 34.2h.

The matching logic was never at fault — the real CVE-2026-76044 record flags
150.0.7871.125 and 151.0.7922.139 and clears .169/.170. Only the index was
blind.

Nightly build now passes force_fresh=True, bypassing the TTL. On-demand
callers (manual scan, FP suppression) keep the 12h cache so the GUI never
triggers a 557 MB download. A forced refresh that fails falls back to the
on-disk ZIP instead of skipping the walk.

Worst-case latency drops 34.2h -> 23.8h, at a cost of up to 2 ZIP downloads
per day instead of ~1.4. No job timing changed.
2026-08-21 07:43:28 +02:00
vulncheck 0a73eafb8f fix(eol): flag "Microsoft Teams" 1.x as classic Teams
Wazuh syscollector reports the classic client under its unrenamed name
"Microsoft Teams" (WindowsApps MSTeams_1.0.0.0_x64__8wekyb3d8bbwe), which
is exactly the name the current client uses. The name-only guard
`_normalise(pname) == "teams" -> return None` therefore swallowed it and
the host showed no EOL finding.

Version decides: every shipping new-Teams build is 2xxxx.x.x.x, never 1.x
— the same split _teams_classic already uses for the machine-wide
installer. Pinned to the exact "teams" name so "Microsoft Teams Meeting
Add-in for Microsoft Office" (live, at 1.24.x) stays clean.
2026-08-19 20:23:31 +02:00
vulncheck 0089ecbc54 fix(advisories): show every feed item, not the first 15
The feed panel counted f.items.length in its header but rendered
f.items.slice(0, 15). With the "Items per feed" limit at 20 the heise
Security Alerts feed therefore announced "20 items" and listed 15 —
the five newest-but-sixteenth-onward advisories were fetched, cached
and silently dropped at render time.

The slice is older than the configurable limit: it shipped with the
original page (64204f4), back when 15 was the only cap there was. Once
advisory_feeds_max_items became the operator's knob, the hardcoded 15
turned into a second, invisible ceiling that no setting could raise.

Root cause confirmed: one hardcoded display cap contradicting the
server-side limit that the same header reports. Removed it — how many
items a feed keeps is now decided in exactly one place (the setting,
clamped 1-500 in the service), and the header count and the list can
no longer disagree.
2026-08-19 20:18:17 +02:00
vulncheck 1b74882584 fix(mobile): clear Android bulletin findings off non-Android devices
An iOS asset carried Android Security Bulletin and Samsung SMR CVEs. They got
there while a matching bug let one asset stand for several physical devices,
but nothing could ever remove them again: the ASB/SMR pass runs for Android
devices only, so on an iOS asset those findings had no scanner left that could
close them. They would have stayed open forever, with the operator's only
option being to delete the asset.

The mobile check now closes them as soon as it sees a device that is not
Android — an ASB CVE on an iPhone is wrong regardless of how it got there.
Same closing contract as everywhere else: drop our own source, close only when
no other scanner still reports the CVE, one audit row per transition. The
patch-level pseudo-finding carries the sync's own source, so it is recognised
by its stable id instead.
2026-08-19 20:14:58 +02:00
vulncheck 623e638a59 fix(intune): stop merging devices onto one asset via a placeholder Entra id
Graph reports an all-zero azureADDeviceId for every enrolment that has no
Entra device object of its own — personally-owned and userless mobile devices,
mostly. The sync stored it in aad_device_id and then matched on it, so every
such device found the first asset carrying the placeholder and pinned its own
enrolment id onto it.

One asset therefore stood for a whole class of devices. On every pass it was
re-pinned to whichever device Graph listed next — logged as a re-enrolment,
because the id had genuinely changed — adopted that device's name, and
collected its findings. The devices that lost the race were missing from the
inventory entirely: they never reached the auto-create branch, since the match
had already succeeded.

Two guards, because the constant is not the point: the documented placeholder
is never treated as an id, and neither is any value two devices in the same
sync both report. The second needs no knowledge of which constants Microsoft
considers special, so it also covers the next one.

Defender's machine match reads the same field and gets the same guard.

Migration 046 clears the placeholder out of aad_device_id, so the collision
stops on existing data and the next sync re-creates the assets that were
swallowed. The findings still sitting on the surviving asset are handled
separately.
2026-08-19 20:14:50 +02:00
vulncheck f614a0a086 fix(intune): pin a re-enrolled asset to the LIVE enrolment, not the dead one
A wiped-and-redeployed device stays in Intune twice: the dead entry under its
old device id next to the live one, same deviceName. Both matched the same
asset (correct — it is one asset) and both were processed, each pinning its
own device id onto it. The last one in Graph's arbitrary order won:

    ... re-enrolled — device id b89b… -> a603…
    ... re-enrolled — device id a603… -> b89b…

One sync, flipping the asset back onto the dead enrolment. Every inventory read
after that went to the old device — this sync's detectedApps, and the app-CVE
scan, which fetches by asset.intune_device_id — so software long gone from the
live device kept being reported as installed and closed findings reopened. The
stale app list never changes again, so the false positives had no way out.

Duplicates are now collapsed before anything touches an asset: newest check-in
wins (lastSyncDateTime, enrolledDateTime breaks ties), the rest are dropped for
the sync and logged. 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; nameless records are never merged.

And when Intune is the asset's only inventory source (no Wazuh agent, no
Defender machine), the app list now REPLACES the stored inventory instead of
adding to it: run_eol_for_packages reconciles, so what the live device no
longer has is retracted rather than left open forever. Co-managed hosts keep
the old behaviour — there "not in my list" is not "uninstalled". The OS-EOL
finding is seeded into the seen set so the reconcile cannot close it.
2026-08-18 15:25:20 +02:00
vulncheck 6e9fc6b610 fix(assets): stop treating "listed by Wazuh" as proof the host is alive
The Wazuh sync judged every agent by its own keepalive against the six-week
grace window and deactivated the ones that had aged out — then
`reconcile_missing_from_sync` ran and revived every INACTIVE asset whose agent
id appeared in `seen_ids`. Wazuh lists disconnected agents too (fetching
active-only was itself a data-loss bug), so the seen-set contained exactly the
hosts the keepalive check had just ruled dead. Deactivate and reactivate for the
same host, in the same sync, in the same second — visible as an
ASSET_DEACTIVATED immediately followed by an ASSET_REACTIVATED "(event-driven
revive)" in the audit log, and as zero INACTIVE Wazuh assets in the inventory
while Wazuh showed 30 disconnected agents.

The revive branch was redundant as well as wrong: every id that reaches
`seen_ids` has already passed through `apply_status` in the agent loop (or the
cluster-node loop), where the keepalive decides. Dropping it leaves the
reconcile with the one job it can actually judge — an agent id that is no longer
in the list at all. A returning host still comes back, through its keepalive.

`reactivated` stays in the returned dict so the sync response keeps its shape.

Sole caller is the Wazuh sync; Nessus, Intune and vCenter use their own
`*_by_seen_ids` reconcile functions and are untouched.
2026-08-18 10:36:19 +02:00
vulncheck 37ac04e34b feat(defender): read installed software from MDE instead of waiting on Intune
Intune's detectedApps refreshes every 7 days per device, so the app-CVE scan
ran against week-old version strings — a browser patched this morning still
read last week's version, and the finding stayed open until the next
Discovered-apps cycle.

Intune's replacement, Enhanced App Inventory, collects multiple times per day
but has no API: `deviceInventories` is absent from the Graph beta $metadata
(no entity, no navigation property on managedDevice), its doc pages 404, and
the official export-report list has no app-inventory reportName —
AppInv{RawData,Aggregate,ByDevice} are all Discovered apps. Microsoft says
Graph support is planned for later in 2026. Until then the data is
portal-only.

Defender for Endpoint already exposes the same thing app-only and documented:
/machines/SoftwareInventoryByMachine, one tenant-wide paginated call, keyed by
the machine ids the Defender sync already matches to assets. It follows device
telemetry rather than a weekly cycle. Feed it into _run_app_inventory — the
same EOL / M365 / app→CVE pipeline detectedApps feeds — so the fresher list
wins wherever Defender covers the device, and detectedApps stays for the rest.

The only translation is the name: Defender reports CPE-style tokens (vendor
`microsoft`, product `visual_studio_code`) while the product registry matches
installer names. _pkg_name joins and un-escapes them, dropping the vendor when
the product already carries it.

Best-effort throughout: a tenant without Software.Read.All or without Defender
Vulnerability Management gets an empty inventory and the previous behaviour.

Also extends tools/probe_intune_app_inventory.py to walk the whole matrix —
URL shapes, the documented config resource, and every plausible export report
name — so the Intune-side verdict can be re-checked against a live tenant when
Microsoft ships the Graph route.
2026-08-18 10:26:29 +02:00
vulncheck 0425887231 fix(assets): stop counting a Wazuh agent's last_scan as a sign of life
The CVE sync stamps `last_scan` on every run — it reads the indexer's
stored documents, so a disconnected agent gets a fresh stamp while nothing
was fetched from the host at all. The nightly reconcile took that stamp as
evidence the host still exists, while the Wazuh sync judged the same host
by its own keepalive. Once the six-week window closed, the two disagreed
permanently: sync deactivates on the stale keepalive, reconcile revives on
the fresh scan stamp, twice a day, forever. Yesterday's audit fix would
have written every one of those flips into the log.

`_liveness_stamp()` now decides: a Wazuh asset with an agent id is judged
by its keepalive alone. Everything without a keepalive of its own (Nessus,
vCenter, Intune, manual) keeps both stamps, and so does a legacy Wazuh row
that has no `last_seen` yet — a missing stamp must not deactivate a host.
2026-08-18 10:12:40 +02:00
vulncheck 442a7ad147 fix(assets): name the CSV export like the audit-log export
The assets CSV download ignored the server's Content-Disposition and built
its own filename from `new Date().toISOString()` — always UTC, so an export
started at 09:06:39 CEST landed as `assets_2026-08-18-07-06-39.csv`, two
hours off and in a different format than `audit_log_20260818_090842.csv`.

The audit-log export is a plain `<a href>`, so the browser honours the
server's header and gets server-local time. The assets export is an XHR
(auth headers + filter params), so it now reads the same header instead of
inventing a name: both exports get `<prefix>_YYYYMMDD_HHMMSS.csv` in server
time from one source of truth (the router).

Content-Disposition is added to the CORS expose_headers so the header is
readable when the frontend runs on a different origin than the API.
2026-08-18 09:57:55 +02:00
vulncheck 2ddac354d3 fix(assets): audit-log the status change every Wazuh sync makes
The audit log's last word on an asset was "active → inactive" (17.8.,
06:44), while the inventory kept showing it ACTIVE. The status was right:
the grace window shipped that same morning (0845fbe) revives a Wazuh agent
that is merely disconnected but whose keepalive is inside the six weeks.
The audit entry was missing, because the agent sync wrote `asset.status`
directly instead of going through the audited path — so a transition the
sync itself caused never reached a trail that has to be revisionssicher.

Every sync status write now goes through `apply_status()`, which logs the
transition and is a no-op when nothing changes (no audit spam per run).
It also refuses to revive a DECOMMISSIONED asset — operator-final, the
rule both reconcile functions already followed and the direct assignment
quietly broke on every sync.

tests/test_asset_status_audit.py drives the real sync against an in-memory
DB with the tester's agent 105.
2026-08-18 09:54:04 +02:00
vulncheck 5a0d37cded feat(assets): export full inventory as CSV with all fields
Adds GET /api/v1/assets/export - a streaming, unpaginated CSV export of
the whole (optionally filtered) asset inventory with every DB column plus
denormalised policy/assignee/groups and open-vulnerability counts.

- Extract the list endpoint's filter logic into _apply_asset_filters so
  list + export share one source of truth.
- Use selectinload (yield_per-safe) for policy/assignee/groups to avoid
  N+1 lazy loads on large exports.
- Add an "Export CSV" toolbar button that downloads the export, honouring
  the current search / source / "show decommissioned" filters.
- Add a regression test for the CSV JSON column formatters.

Also drops the auto-generated AGENTS.md/CLAUDE.md (gitnexus tooling files)
from tracking and gitignores them.
2026-08-17 15:56:51 +02:00
vulncheck 7be3a4ba10 feat(assets): show VMware build number in OS column 2026-08-17 15:03:44 +02:00
vulncheck 7078f5e024 feat(vsphere): read vCenter + every ESXi host, and detect their CVEs
A hypervisor runs no agent. Wazuh cannot reach it, Intune does not know it,
and Nessus only sees it if someone scoped a credentialed scan at it — so the
machines whose compromise takes every VM on them down were the ones with no
vulnerability coverage at all.

The connector reads the vCenter appliance and every ESXi host in its
inventory over the vSphere SOAP API (pyVmomi): hostname, management IP,
hardware model, product version and BUILD. One PropertyCollector pass, so a
500-host estate is one round trip. Read-only rights are enough.

The build is the point. NVD carries nothing usable — CVE-2026-47876 and
CVE-2026-59310 both sit there with no configuration — and the bounds
cvelistV5 does state are build identifiers, not versions:

    ESX      8.0       lessThan "ESXi80U3k-25595708"
    vCenter  8.0       lessThan "8.0 U3k"

_is_version rejects both, so _ranges_from_affected dropped the entries and
every vSphere CVE was invisible. They are now indexed verbatim and resolved
to build numbers: ESXi bounds carry one inline, vCenter bounds name a release
whose build comes from Broadcom KB 326316 (seeded in full, re-read weekly,
merged never replaced). A bound that resolves to no build produces no
verdict, and neither does a host with no build recorded.

Comparing builds alone is wrong in both directions. CVE-2025-22224 names two
fixes for the 8.0 line at once — U3d (24585383) for 8.0.3 and U2d (24585300)
for 8.0.2 — so the U2d host is patched despite the higher number existing.
And vCenter 8.0 U2f shipped four days AFTER the U3k fix on the older update
line, with a higher build, because Broadcom ships async patches there. So the
decision is scoped to the update line first, then still checked against the
build.

What must never resolve is asserted in the tests: the same advisories file
"VMware Cloud Foundation (vCenter Server)" and "vSphere Foundation" with the
SUITE's version numbers, and matching those would compare a vCenter 8.0.3
appliance against a VCF 5.x range.

EOL comes from endoflife.date (esxi / vcenter), keyed by major line — which
is also the honest granularity, one end-of-support date per line. 7.0 ended
2025-10-02, 6.7/6.5 in 2022. Both slugs are single-release, so an upgrade
retires the old finding. The OS patterns are shared with the CVE pass so a
machine cannot be a hypervisor for its CVEs and something else for its dates.

Checked against all 189 vSphere CVE records currently in cvelistV5: 64 bounds
indexed, none unresolvable, and every current release comes out clean while
each one behind gets its own line's fix named.

Migration 045 adds AssetSource.VCENTER, assets.vmware_uuid (the pin) and
assets.vmware_build.
2026-08-17 11:54:51 +02:00
vulncheck 0845fbe5db fix(wazuh): give offline hosts a six-week grace window before their CVEs vanish
The asset sync fetched active agents only, and the sync-driven reconcile
inactivates every agent id the sync does not report. Since INACTIVE assets are
hidden from every CVE view, a host that was merely switched off lost all its
findings at the next sync — while Wazuh itself kept listing the agent and its
vulnerabilities. Holiday, sick leave or a spare laptop in a drawer looked
exactly like a decommissioned machine.

Root cause confirmed: not the 30-day soft-inactive window (which never got a
chance to apply here), but the connection-state filter feeding seen_ids.

- sync fetches ALL agents, paginated; disconnected ones stay in seen_ids
- last_seen now carries Wazuh's lastKeepAlive instead of "when we synced",
  so there is a real stamp to measure against
- per-agent status comes from status_for_last_seen(): ACTIVE inside the
  window, INACTIVE outside — replaces the binary connected/disconnected test
- reconcile_asset_lifecycle uses max(last_scan, last_seen): a host that keeps
  checking in but cannot be scanned while offline no longer ages out early
- window default 30 -> 42 days, one setting for both paths; migration 044
  rewrites only rows still holding the old default
- agents registered but never connected are skipped instead of imported

tests/test_asset_grace_window.py covers the window edges, UTC keepalive
parsing, the newest-stamp reconcile, and guards the sync against a
status filter being reintroduced.
2026-08-17 11:07:30 +02:00
vulncheck 630f34f450 fix(scan): keep beta-only and CI-only Wazuh flaws off installed agents
Two Wazuh CVEs landed on every host running the agent, a Windows agent on
4.14.5 among them, and neither describes anything installed there.

CVE-2026-67308 is shell injection in Wazuh's GitHub Actions workflows —
the CI, not the product — bounded by the fixing commit. The commit-hash
guard already rejected that bound on the affected entry, but the record
states the same commit twice, and the "unaffected" half feeds the
inverse-record rule (Mozilla's "X and up are fixed"), which never checked
its bound. The hash walked back in there and "44" outranked every 4.x.

CVE-2026-67307 lets an enrolled agent spoof cluster attribution in
inventory-sync messages. Wazuh's own advisory names 5.0.0-beta1, fixed in
5.0.0-beta3; the CNA record says "0 → < 5.0.0-beta3", which read literally
covers everything ever shipped. A prerelease upper bound names the
prerelease train, so an open floor becomes that train's start: 4.x below
it and the finished 5.0.0 above it are out, the betas between stay in.

Both are auto-closed on the next app-scan run.
2026-08-17 10:36:23 +02:00
vulncheck 4df65352ba feat(eol): flag Microsoft Teams classic as EOL since 2024-07-01
The lifecycle export does carry the classic client ("Microsoft Teams" /
release "Classic Teams Client", ends 2024-07-01), but Teams follows the
Modern policy: that date lives in "Release End Date" while the parser
reads the Extended/Retirement columns, which are empty on every Modern
row. So the row is dropped and a host still running classic Teams
produced no finding at all.

Reading Release End Date instead is not an option — for a supported
product it marks the end of a superseded release (every Windows 10
version row has one), which would report supported products as EOL.

So the date is fixed in code and matched on the two inventory names that
mean classic: "Microsoft Teams classic", and "Teams Machine-Wide
Installer" only at version 1.x, since the new client's bootstrapper
writes an entry of the same name with a 5-digit build.

The new client lists as plain "Microsoft Teams" and is now refused by
the export matcher outright: that string is the listing name for BOTH
releases, so the day Microsoft fills a retirement date on the classic
row, a current Teams would have read as CRITICAL EOL.
2026-08-17 10:24:40 +02:00
vulncheck 04d59d31e6 feat(euvd): link every CVE to its ENISA EUVD record
The EUVD link on the detail page only appeared when enrichment had
stored an euvd_id, which only happens for rows the EUVD KEV feed
covers. Every other CVE showed no EU record at all.

The EUVD resolves a CVE id to the same page as its own EUVD-ID
(/vulnerability/CVE-2026-68820 == /vulnerability/EUVD-2026-56468), so
the link needs no id mapping: use the EUVD-ID when we know it, the CVE
id otherwise. Label keeps the EUVD-ID in parentheses when present.
2026-08-17 10:16:42 +02:00
vulncheck 4daf28113e fix(kev): give EUVD-only exploited CVEs their product name back
An "Actively Exploited" row that only ENISA lists rendered as a bare CVE
id with no text: the EUVD parser kept the date, the exploited flag and the
EUVD-ID and threw away everything that says WHAT is affected. CISA rows
looked fine because CISA ships vendor/product/name — so the gap only showed
on the EU-only entries, exactly the ones nobody can recognise by number.

ENISA does carry it, nested: enisaIdProduct[].product.name (+ its vendor)
and a free-text description. Parse those, merge them like the other fields,
and let the KEV catalog fall back to them when CISA has nothing — CISA
still wins where both list a CVE, because its short vuln NAME beats a
paragraph on a one-line row. No CVE is dropped or filtered by any of this.

Caches written before these fields are refetched once, same as the pre-
`exploited` ones: an entry with no product would keep the blank rows for
another 24h. Descriptions are clipped to 300 chars — the cache is a single
JSON blob in a Setting row, and ~1700 full ENISA texts add ~0.5 MB to
every read for text no row ever shows.

Detail view now links the ENISA EUVD record under External References
whenever we know the EUVD-ID, so the EU-authoritative page and the
corresponding EUVD-ID are one click away instead of copy-paste.
2026-08-15 16:57:48 +02:00
vulncheck b469b5eca3 fix(deps): upgrade the auth stack, and free bcrypt from passlib
starlette was never pinned here — fastapi 0.115.6 held it below 0.42,
which is exactly where its 14 advisories live. So the starlette fix is a
fastapi upgrade (0.141.1), not a starlette one; pinning starlette
directly would only have fought the resolver.

passlib 1.7.4 has been unmaintained since 2020 and cannot initialise
against bcrypt 5 at all — it raises before it hashes anything. bcrypt is
now called directly, which drops the last reason to hold a crypto
primitive three majors back. The 72-byte truncation passlib did silently
is done explicitly, otherwise every existing hash stops verifying and
every long password turns a login into a 500.

lxml goes to 6.1.1 for its own sake: it parses Microsoft HTML in
m365_service, not the advisory feeds — those run through stdlib
ElementTree.

The CSV upload read the whole body into memory with no cap, while feed
downloads have had one for a while. Same 16 MB limit, same shape.

OSV against the pinned set: 62 advisories → 0.
2026-08-15 09:47:30 +02:00
vulncheck 9e81436391 chore(frontend): drop next-auth, lift next/axios out of their advisories
next-auth was never imported — the backend issues the JWTs itself — so a
package carrying a critical advisory sat in the tree doing nothing.
Removing it beats upgrading it.

next 16.1.5 → 16.3.1 also clears postcss, sharp, nanoid, form-data,
follow-redirects and uuid, which came in transitively. eslint-config-next
moves in lockstep with next, otherwise it pins the old toolchain.

npm audit --omit=dev: 9 findings (1 critical) → 0.
2026-08-15 09:47:19 +02:00
vulncheck 57ed0b1534 feat(kev): alert on exploited CVEs that are open in our own environment
Actively-exploited CVEs were shown from CISA KEV only, on a dashboard
somebody had to be looking at. ENISA's EUVD exploited catalog was already
being fetched for enrichment but never surfaced as a KEV source.

- kev_service merges CISA KEV + ENISA EUVD (exploited only — an EU-Critical
  entry is a priority list, not an exploitation claim) into one entry per
  CVE carrying both sources.
- Inventory impact is counted honestly: active assets only (matching the
  vulnerabilities list), open findings apart from remediated ones, and an
  asset with both an open and a closed row counted once, as open. That gap
  is what made the badge say 64 where the filtered view listed 62.
- kev_alert_service mails the CVEs with open findings here — hostnames, IPs,
  counts, sources — once per CVE, again when more systems are affected.
  Hourly, plus straight after each threat-intel refresh.
- Dashboard pins what is open here above the newest listings; Advisories
  gains source filters, an alert panel and its config.

EUVD entries now carry an explicit `exploited` flag; caches predating it
cannot answer the question for an entry that is also EU-Critical, so they
are treated as stale and refetched once.
2026-08-15 09:37:16 +02:00
vulncheck 0bf32528e3 fix(msrc): give an MSRC-only finding its real text, and its KB next morning
Two defects on the same pair of findings — CVE-2026-6727 (MSRC only) and
CVE-2026-70304 (cross-confirmed), both detected 12.08. and both still wrong
on 14.08.

No Remediation block: the CVRF ingest ran weekly (Sun 04:40) while the scans
that create the findings run nightly, and the ingest only stores remediations
for CVE ids already in the vulnerabilities table. A Patch-Tuesday CVE
therefore waited up to seven days for its KB. The nightly MSRC scan now
chains a two-month catch-up ingest after itself, so the CVEs it (and the
app scan before it) just created get their fixes the same night; Sunday keeps
doing the full 18-month backfill.

Wrong description: msrc_scan_service creates its rows with a placeholder
("MSRC reports ... is fixed in build ...") because the fixed-build index
carries no flaw text, and the app-scan backfill only fills an EMPTY
description — so for a CVE no other scanner ever saw, the placeholder was
permanent. The monthly document does carry the text, in the Note titled
"Description"; the ingest now writes it over the placeholder, and only over
the placeholder. Existing rows heal on the next run, no migration.

While there: _list_recent_docs now sorts the /updates index by
CurrentReleaseDate before taking the tail. At 18 months a misplaced entry was
survivable; at two it is not. build_product_index reuses it instead of
slicing the raw index, which also stops non-monthly entries getting through.
2026-08-15 09:18:01 +02:00
vulncheck 36744f193e fix(scan): match Python the interpreter, not python-* modules
The python entry in both registries matched any name containing the word,
so PyPI/distro modules resolved as CPython: "python-dotenv 1.1.1" was
compared against CPython's ranges and flagged with every CVE up to 2.7.15
(CVE-2017-1000158, CVSS 9.8). Same for python-dateutil, python3-pip,
pythonnet.

The name now has to END at the interpreter, optionally with its release
("python3", "python3.11", "Python 3.13.7 (64-bit)"); a trailing "-" or
letter means a different product on a different version line.
2026-08-15 09:12:01 +02:00
vulncheck 4bb7bdeb62 feat(scan): detect Apache Tomcat, and its EOL, on both paths
Tomcat had no entry in either registry, so neither path ever looked at it:
resolve() returned None on the cvelistV5 side and there was no CPE row on the
NVD side. A Windows install ("Apache Tomcat 9.0 Tomcat9 (remove only)",
9.0.107) was invisible to both — every Tomcat CVE, on every host, had no
source at all.

The reference case is CVE-2026-34486, a regression in the fix for
CVE-2026-29146 that lets the EncryptInterceptor be bypassed. The ASF names
three EXACT versions there (11.0.20, 10.1.53, 9.0.116) and nothing else, which
_ranges_from_affected already reads as three closed [v, v] ranges — so 9.0.107
is correctly NOT flagged for it, while the older ranges it genuinely sits
inside (CVE-2026-29146: 9.0.0.M1 .. <=9.0.115) do match.

The floors are what keep the release lines apart. Every ASF record is stated
per line, and each floor is that line's first milestone, written either
"9.0.0.M1" or "11.0.0-M1" — _vtuple reads both as 9.0.0.1 / 11.0.0.1, so a 9.0
host can never fall inside the 11.0.18 bound. Vendor appears both as "Apache
Software Foundation" and as plain "Apache" (CVE-2020-1938), so both are paired.

Kept narrow: the name is anchored and the satellites are excluded. Tomcat
Connectors (mod_jk), Tomcat Native and TomEE are separate products with their
own version lines, and comparing an mod_jk 1.2.x against a 9.0.x range would
be nonsense.

EOL: the slug had to go ABOVE "apache" in the map. The resolver walks it in
insertion order and takes the first anchored hit, so "Apache Tomcat 9.0 …"
starts with "apache" and every Tomcat was being checked against the Apache
HTTP Server lifecycle — a different product on a 2.4.x version line that no
Tomcat build can ever match. endoflife.date keys the releases by line, which
the reported version prefixes directly: 9.0 is supported until 2027-03-31 (so
the host above gets no EOL finding), 8.5 died 2024-03-31, 8.0 in 2018, 7 in
2021.

Index key bumped to v26 so the cached index is rebuilt with the new product
instead of serving the old one for another 26 hours.
2026-08-15 09:05:40 +02:00
vulncheck 65f21e5470 fix(eol): a Microsoft update keeps the EOL product it names after "for"
The tester's host runs SQL Server 2008 R2 and SQL Server 2012, both years past
end of life, and every EOL finding on it was retracted overnight as "MS
lifecycle no longer matches this installed product (re-evaluated — not
end-of-life)". Nine critical findings to zero, with the software still
installed.

Two causes, both of them a name the sweep refused to look at:

1. The listing had to PREFIX the inventory name (the SAP-GUI fix), and none of
   this host's evidence is shaped that way. A Microsoft update names its parent
   product AFTER the connector: "Service Pack 1 for SQL Server 2008 R2
   (KB2528583)", "Hotfix 7493 für SQL Server 2012", "SQL Server Browser for SQL
   Server 2012". Every component that names the product as a prefix is an
   add-on the sweep already refuses (Native Client, RsFx Driver, Setup), so the
   updates were the only evidence left, and they matched nothing.

   What follows "for" may now claim a listing — but only when Microsoft is the
   vendor. That is what tells the two cases apart: SAP's client is vendor "SAP
   SE", a service pack for SQL Server is "Microsoft Corporation". An unknown
   vendor changes nothing, the strict prefix rule stands, and an operating-
   system listing is never claimed by a package (an app "for Windows 10" is not
   Windows; the OS has its own EOL path).

2. A package with no version was skipped before any source saw it — and the
   engine's own entry, "Microsoft SQL Server 2012", carries no version at all.
   The name-only sources need none, so a versionless entry now reaches them;
   endoflife.date, which resolves the release FROM the version, still does not
   get a vote on one.

The vendor is stored on the finding, because the nightly revalidation re-asks
the resolver from the ROW, not from the inventory. Without it every finding
would be re-judged under a stricter rule than the one that created it and close
the same night it appeared — the open→patched flap, with nothing changed on the
host. Findings written before this change carry no vendor, so revalidation
leaves those names alone rather than closing them on an answer it cannot have.

Verified against the live export: all nine SQL Server findings return with
Microsoft's own dates (2019-07-09 and 2022-07-12), and the SAP GUI, Edge,
ODBC/OLE DB drivers, Teams, Store for Business, Skype for Business and Windows
Admin Center controls stay clean.
2026-08-15 08:55:30 +02:00
vulncheck f14eb1b5b2 fix(eol): a Citrix stub keeps the release in its name
Intune lists published apps as "Microsoft Access 2010 / 1.0 / Delivered by
Citrix". The CVE scanner drops those rows because the version is a
placeholder, and the EOL sweep reused that same filter — so two products
years past end-of-support produced no finding at all.

The version is the untrustworthy half, not the name. A stub is now kept and
restricted to the name-only sources (the Microsoft lifecycle export and the
hardcoded exotics); the endoflife.date path, which derives the release from
the installed version, is skipped for it. A stub named just "Firefox" still
matches nothing, which is what the filter was written for.
2026-08-15 08:45:00 +02:00
vulncheck a1a94d93bd fix(scan): detect Vim, VS Code and CPython from cvelistV5
None of the three had a cvelistV5 registry entry, so resolve() returned
None and the path never looked at them — while NVD carries no CPE and no
affected block for any of the reported CVEs, so the CPE path could not see
them either. Fifteen CVEs on live hosts had no source at all.

  Vim      CVE-2026-73070/-73072/-73074/-73076/-73077/-73078 on 9.0.0969.
           The bound is stated inside the version field ("< 9.2.0842"),
           the operator form the range parser already reads.
  VS Code  CVE-2026-47285/-58650/-59113/-69278/-69306/-69320/-70335/-70336
           on 1.132.0, all stated as 1.0.0 .. <1.132.1.
  CPython  CVE-2025-15366. The Windows installer reports an MSI build
           (3.13.7150.0) while the real version sits in the name — read as
           a build it is ABOVE every 3.13.x bound, so no range ever
           matched. name_ver takes the version from the name, which is
           what the CPE registry entry already does for Python.

Kept narrow so nothing new is over-matched: the names are anchored
(Neovim, Visual Studio Community 2022 and the Python launcher do not
resolve) and the pairs are exact, so Microsoft's VS Code EXTENSIONS —
their own products, with their own version lines — stay out.

Both paths get the products: the cvelistV5 entries cover what NVD has not
enriched, and the two new CPE registry rows cover the older records NVD
did enrich (every pre-2020 Vim record names vendor "n/a" and is not
matchable from the CNA block) and cross-confirm the fresh ones later.

Index key bumped to v25 so the cached index is rebuilt with the new
products instead of serving the old one for another 26 hours.
2026-08-13 16:28:03 +02:00
vulncheck 974de08b3c fix(autodesk): match AutoCAD by release, not by release year
Two halves of the same blind spot, both on a stock AutoCAD LT 2026
(build 25.1.60.0):

MISSED. The cvelistV5 registry never carried year_ver, so that path
compared the MSI build 25.1.60.0 against "2026.0.0 lessThan 2026.1.2"
and matched nothing. It is the only path that can see these CVEs at
all — CVE-2026-7405, -7406 (ADSK-SA-2026-0012) and -17550 sit at NVD
with vulnStatus "Received" and zero cpeMatch entries, so the CPE path
is blind to them by construction. Four confirmed CVEs were invisible.

NEVER CLEARS. The year came from the product name and the build was
thrown away, so every 2026 install scanned as a bare "2026" — below
every 2026.x fix bound forever. A host patched to 2026.1.2 keeps all
seven of its autocad_lt findings with no version it could reach to
drop them. Three of those seven carry a bare-year CPE at NVD with no
range at all.

Both are fixed by resolving the build to the release Autodesk actually
bounds by: 25.1.60.0 -> 2026.0.0, 25.1.172.0 -> 2026.1.2. The table is
keyed by the build's major.minor, which has to agree with the year in
the name — that one check keeps it off products numbered some other
way, so Navisworks Freedom 2025 (22.0.1411.23) still falls back to the
bare year instead of landing on an AutoCAD row.

Hypothesis that held: the year was doing two jobs it cannot do at
once — pick the release line, and say where inside it the host sits.
2026-08-13 14:04:46 +02:00
vulncheck 8684263c13 fix(eol): a lifecycle listing must prefix the product name
"SAP GUI for Windows 8.00 64bit (Patch 17)" was reported CRITICAL, EOL for
3866 days, security support ended 2016-01-12 — Windows 8's date, on a client
SAP shipped in 2023 and supports until 2027.

Cause: the MS-lifecycle resolver matched a listing anywhere inside the product
name. Normalisation turns the name into "sap gui for windows 8 00 64bit patch
17", which contains the "Windows 8" listing verbatim (the dot in "8.00" becomes
a space, so the version starts with the token "8"). Every "<vendor> for
<Microsoft product>" install could inherit a Microsoft end-of-life date the
same way. The listing must now be a token-aligned prefix of the product name;
verified against the live export, this drops the SAP row and keeps every true
finding (Exchange 2016, SQL Server 2014/2019, Lync 2013, SharePoint 2016,
Windows Server 2016, System Center 2012 R2, Report Viewer 2012).

The same finding also cited the wrong source: description and source badge read
"endoflife.date" while the id read EOL-MS-LIFECYCLE-…, so the row named a
source that had never seen the product. Both now come from the slug.

Open false positives close themselves on the next EOL check —
revalidate_ms_lifecycle_findings re-asks the resolver per finding.
2026-08-13 13:34:41 +02:00
vulncheck 9ef1db5e9b fix(scheduler): sync Wazuh assets before the scheduled agent loop
The scheduled Wazuh scan read the asset list straight from the database
and asked Wazuh about whatever wazuh_agent_id each row already carried.
It never called get_agents(), so it could not learn about an agent id
that had changed.

A host that dropped off and re-registered comes back with a NEW agent
id. The row keeps the old one forever: every vulnerability lookup and
the nightly EOL sweep query an agent that no longer exists, so the asset
shows no CVEs and no EOL entries, and it never flips back from INACTIVE
to ACTIVE either — reconcile_asset_lifecycle only revives on a fresh
last_scan, and the failing lookup never writes one.

Pressing "Sync Data (Wazuh)" fixed it because the button calls
/assets/sync_wazuh first, which matches by hostname when the agent id
misses, rewrites the id and sets the status. The scheduled job now makes
the same call before it reads the asset list. A failing asset sync is
logged and does not abort the vulnerability scan.
2026-08-13 13:26:21 +02:00
vulncheck 042e5aa6c7 feat(advisories): make the per-feed item cap configurable
30 items per feed was a constant. It is now the setting
`advisory_feeds_max_items`, editable in the feed configuration panel, applied
to every feed and taking effect on the next refresh.

The range is 1–500 rather than unbounded, and that ceiling is load-bearing:
every item lands in the cached JSON of ONE settings row that the page reads
in full, so "no limit" means a row that grows forever and a page that stops
rendering. 500 per feed is a few hundred KB per source — a quarter of the BSI
advisories in one sitting, and still a page that loads.

The value is written through the generic settings API, which stores whatever
a text field produced, so the read path treats it as untrusted: "", "abc",
"50 ", '"120"', 0, -5, 999999, "inf", true, None and an unreachable database
all resolve to something usable — junk falls back to 30, out-of-range is
clamped — and it never raises, because a feed run must not fail over its own
limit. The editor rejects the same values up front with the bounds named,
rather than saving something that quietly becomes a different number.

Also bounds the feed download at 16 MB. The item limit is the operator's to
raise; the size of a stranger's response is not.
2026-08-13 12:14:40 +02:00
vulncheck c2cb0a5a3c refactor(eol): one sweep implementation instead of three
The EOL package loop existed three times — the eol-check endpoint, the
nightly job, and run_eol_for_packages — and had drifted the way copies do:
only the endpoint filtered published-app stubs out of the inventory, so the
nightly job could raise findings from placeholder versions the button would
never produce; the flap guard added yesterday had to be threaded into each
copy by hand; and the regression test could only cover a re-implementation
of the loop, not the loop itself.

Now all three callers go through run_eol_for_packages, which returns its
counters, and the test drives the same function production does. What the
callers legitimately differ in became two arguments:

  reconcile — retract findings this sweep did not re-confirm. Only for a
  caller holding the WHOLE inventory. Intune's detectedApps is partial, so
  on a co-managed host it would retract the Wazuh sweep's findings and the
  next Wazuh run would reopen them — the same flap, one layer up.

  seen_ids — findings the caller produced itself (the OS-level EOL row),
  which the package list can never re-confirm. The test asserts both
  directions, so a caller that forgets to seed it fails rather than quietly
  reopening every OS finding each night.

Inventory filtering moved into the sweep, where no caller can forget it.
2026-08-13 11:56:02 +02:00
vulncheck 42702d0891 test(eol): lock down the sweep fixed point
Replays a real Exchange inventory through the actual state machine — upsert,
supersede, reconcile, against SQLite — three times, and asserts what the
tester asked for: the Subscription-Edition host carries no Exchange finding,
the 2016 host carries exactly one, and sweeps 2 and 3 write no status change
at all.

Verified to have teeth: it fails on the build the flapping audit log came
from (companion packages claim releases, findings supersede each other 14×
per run) and on yesterday's, where the add-on token list still let
"Exchange Server 2010 MAPI Client and CDO" through — 4 flips per run.

Offline and pinned: the endoflife.date catalogue is inlined, so the test
asserts our behaviour rather than what the feed says this week.
2026-08-13 11:49:02 +02:00
vulncheck 544d6c6eec fix(eol): only the product itself may claim its release year
Office, SharePoint and Exchange take their release from the year in the
inventory NAME, so every companion package that happens to carry a year
claimed that release was installed: "Microsoft Office 2007 Primary Interop
Assemblies", "Microsoft Exchange Server 2010 MAPI Client and CDO",
"Microsoft SharePoint 2013 Client Components SDK". Each produced a CRITICAL
EOL finding for a product the host does not run — the tester's Exchange SE
host, fully supported, carried an EOL-MSEXCHANGE-2007.

Naming the add-ons one at a time is a race nobody wins; there is always
another rollup. What separates the product from its companions is what
FOLLOWS the year: the product name ends there, or continues with an edition
or update qualifier ("2016 Cumulative Update 23", "Professional Plus 2016 -
de-de"). Any other word after the year belongs to a different product that
merely names this one. Both resolvers consult the same rule, so a companion
is refused whichever source would have matched it.

Second half of the same report: the status loop. These slugs are
single-release, so each companion superseded the others' finding once per
sweep — 2007 closes 2016, 2016 reopens and closes 2007, every run, with
nothing changing on the host. Supersede now leaves alone any finding the
same sweep already confirmed: the sweep saw both products in one inventory,
so neither replaced the other. Dropping the companions removes the cause;
this removes the mechanism.

The real entries are untouched — "Microsoft Exchange Server 2016 Cumulative
Update 23" and the plain "Microsoft Exchange Server" both still resolve to
release 2016 (EOL 2025-10-14), so the host's genuine finding stands.
2026-08-13 11:42:38 +02:00
vulncheck 610a745147 fix(eol): detect .NET and .NET Framework at all
".NET" normalises to "net" — _normalise_name strips the dot — so the
curated "dotnet" / "dotnetframework" slug keys could never match a real
inventory entry ("Microsoft .NET Runtime - 8.0.29 (x64)" normalises to
"microsoftnetruntime8029x64"). Every .NET and .NET Framework install was
skipped: no finding for the dead releases, none for the live ones either.

A prefix key cannot repair it — "microsoftnet" would also swallow
"Microsoft Network Monitor" — so both products are matched by regex, kept
tight enough that "Microsoft Visual Studio .NET 2003" and "Microsoft .NET
Micro Framework Porting Kit" keep falling through to the Microsoft export.

Second defect behind the first: modern .NET reports an MSI build in the
version field (9.0.18 ships as 72.72.55158), so no release prefix-matches
it even with the slug fixed. The release comes from the display name, the
same way app_cve_scanner_service already handles these products.

Developer-side packs (Targeting / Developer Pack, Reference Assemblies)
are excluded: they carry the runtime's name with an old version number and
are build inputs, not an installed runtime.

And the source rule: for these two products endoflife.date decides alone.
Microsoft's export carries no .NET row today and its lifecycle page agrees
with endoflife.date on .NET 9 (2026-11-11), but a future export row must
not be able to retire a supported release early.

Verified against the reported inventory: .NET 8/9 -> EOL SOON (2026-11-10),
.NET 10 -> supported to 2028-11-14, Desktop Runtime 6.0.36 -> EOL.
2026-08-13 11:29:47 +02:00
vulncheck b851b1c3c4 fix(intune): make the app-inventory probe tell 403 causes apart
The probe reported every failure as "app registration lacks the
permission", but detectedApps and managedDevices succeed on the same
token, so that permission is demonstrably granted. The 403 body is the
Intune service envelope, not an AAD denial — and _get truncates it at
200 chars, cutting off the part that names the cause.

Print the untruncated body, the token's roles claim, and a control
request against a route that certainly does not exist. Intune answers
unknown segments with the same generic 403, so only the contrast
between the three shows whether deviceInventories('ApplicationProperties')
is an ungranted route, a gated feature, or no route at all.
2026-08-12 14:24:16 +02:00
vulncheck b8be1c4af8 fix(intune): record the device check-in, not our sync time
asset.last_scan was stamped with datetime.now() on every sync, so the
dashboard reported fresh data while the underlying detectedApps snapshot
could be seven days old (Discovered apps refreshes weekly per device,
Win32/IME every 24h). An Edge reading 151.0.4129.59 here was already .78 on
the device, and the app-CVE scan ran on the stale version without a warning.

Store lastSyncDateTime — already selected, never used — in last_seen and
count devices whose check-in is >= 7 days old in the sync stats.

Add tools/probe_intune_app_inventory.py: Intune's App inventory replaces
Discovered apps and collects several times a day, but its Graph surface is
not in the beta reference yet, so the payload shape has to come from a real
tenant before any mapping is written.
2026-08-12 14:13:34 +02:00
vulncheck 1d7f647ec3 fix(gui): make URS rows open the asset like the compliance table
The hostname was styled as a link (blue, bold) but carried no handler, so
clicking it did nothing — the identical table below already routes through
openAsset.
2026-08-12 14:13:34 +02:00
vulncheck d87d781a64 fix(gui): recover the audit Asset column for rows written without a host
The column reads new_value.hostname. Writers fill it since c81d030, but
every row written before that stays empty forever — which is exactly
what the tester still sees on the EOL new-finding events — and any
writer that stores a blank hostname produces the same empty cell.

One extra query per page maps vulnerability id → hostname for the rows
that name no host, and merges it into the payload on the way out. No
migration, no stored rows touched, and a future writer that forgets the
field is covered too.
2026-08-12 13:05:35 +02:00
vulncheck d01b5867fa fix(scan): add-on packages must not produce EOL findings of their own
A host running Exchange Server Subscription Edition — fully supported —
still lists "Microsoft Exchange Server 2007 Standard Anti-Spam Filter
Updates", "Microsoft Exchange 2007 Enterprise Rules Updates", a dozen
language packs and a Lync bootstrapper in its inventory. Every one of
those matched a product: the Exchange ones resolved to the msexchange
slug with the year pulled straight out of the name, so the tester's SE
host carried CRITICAL EOL-MSEXCHANGE-2007 findings for a product that is
not installed, and "Microsoft Lync Server 2013, Bootstrapper
Prerequisites Installer Package" — which ships with an Exchange install
— was read as a Lync 2013 server.

The flapping followed from that. msexchange is a single-release slug, so
the 2007, 2010 and 2016 rows superseded each other in turn, one status
flip per add-on per run, filling the change history with alternating
open/patched entries while nothing on the host changed.

is_component_package() is consulted by BOTH resolvers, so a component is
refused whichever source would have matched it. The real product entries
sit in the same inventory ("Microsoft Exchange Server", "… 2016
Cumulative Update 23"), so the true 2016 finding survives — it is now
attributed to the server instead of to a Greek language pack. Findings
already open close themselves on the next sweep: the package is gone
from the inventory, which the reconcile reads as uninstalled.

Second bug in the same area: MS-lifecycle findings were keyed
EOL-MS-LIFECYCLE-{release}, and that export says "Original Release" for
almost everything. Every MS-lifecycle product on a host therefore shared
ONE row, whose title, package and description were overwritten by
whichever package the sweep touched last — the tester's Lync 2013
finding kept turning into Visual C++ 2012 and back, with a status change
logged each time. Keyed per product now; rows written under the old
shared id are closed by the revalidate pass with their own reason rather
than left as duplicates.
2026-08-12 13:05:28 +02:00
vulncheck b634e0658f feat(gui): link GitHub advisories from the CVE detail view
NVD and cvelistV5 have no record for a lot of the ids this scanner
produces, and the GLOBAL GitHub advisory database does not have them
either: github.com/advisories/GHSA-8c6v-7g3w-prrq is a 404 while
wazuh/wazuh's own advisory page serves it. Repo-published advisories
only exist on the repo.

So every CVE now offers a GitHub chip. When the affected product maps to
a repo that publishes its own advisories (the existing _REPO_MAP), the
link points at that repo — the detail endpoint upgrades it to the
advisory itself when the cached repo index knows the GHSA id, and falls
back to the repo's advisory search otherwise. Everything else gets the
global advisory search. All of them are search pages or verified
records, so none can 404.

Two things fixed in passing:

- The GHSA branch of the External References block was dead code: the
  block was guarded on /^CVE-/, so a GHSA-only finding (the ones added
  in 6304b66) showed no references at all.
- The `references` column has been written by the Nessus sync since it
  landed and was never rendered anywhere. Those vendor advisories are
  often the only page describing the flaw, and for a NESSUS-PLUGIN- row
  they are the only reference it can have — the block now renders for
  those rows too, with the catalogue links still restricted to real CVE
  ids so no NVD link is built from a plugin id.
2026-08-12 13:05:14 +02:00
vulncheck 6304b661f9 feat(scan): findings for advisories that carry no CVE number
4 of Notepad++'s 18 advisories and 10 of Wazuh's 60 have only a GHSA id. They
were skipped, on the reasoning that the pipeline keys on CVE numbers — which
made them invisible to every source we have, since NVD and cvelistV5 are keyed
on CVE numbers by definition. A published flaw with a version range is a
finding whether or not a number was ever assigned.

The GHSA id stands in as the identifier, the way NESSUS-PLUGIN- and EOL- ids
already do in this schema, and it fits the column with room to spare.

What such a finding does not get is threat intel. KEV, EPSS and the CVSS
enrichment all look up a CVE number and skip anything else on their own
(enrichment_service filters on the prefix), so it simply carries none — honest,
since none exists for it. The detail page swaps the NVD and CVE.org links,
which would 404, for the advisory itself.

Measured against the versions in the test environment, this adds nothing
today: at Wazuh 4.14.1 and Notepad++ 8.9.5 every matching advisory happens to
have a CVE id. The CVE-less ones are on other release lines (5.0.0-beta). So
this is coverage for the case rather than a fix for a current gap.

The same run shows the component filter doing its job: at 4.14.1 the agent
gets 1 finding and the manager 13, from the same 60 advisories.
2026-08-12 10:11:23 +02:00
vulncheck 7f36f9b6fb fix(scan): fix floor on the NVD path, advisory summaries, subsystem fallback
Three things from one screenshot, and the second one explains the first
report.

THE FIX FLOOR ONLY WORKED ON ONE PATH

2a831e4 derived ">26.5.2.2" from a lessThanOrEqual bound, but only in
cvelistV5. Node.js comes from there and reads "not announced — need newer than
24.14.0"; the Acrobat extension comes from NVD, where the same fact is spelled
versionEndIncluding, and read a bare "not announced". Derived on both paths
now, with the same rules: a real fix version wins, a commit hash is neither.

THE ADVISORIES NEVER PASSED THEIR SUMMARY ON

The GitHub summary was cached and then dropped, so those findings displayed
only the title we generate ("Wazuh Agent 4.14.2 — CVE-2026-44901") and nothing
about the flaw. Worse than cosmetic: the manager/agent prose check in _upsert
reads that text, and with nothing to read it could not do its job.

A SUBSYSTEM FALLBACK FOR ADVISORIES WITHOUT A PACKAGE

11 of wazuh/wazuh's 60 name no usable package ("Wazuh v4.14.1"), leaving the
component filter nothing to work with. The title usually says it anyway: the
cluster and its DAPI are how manager nodes talk to each other, an agent has
neither, while syscheckd and logcollector run on the endpoint. Used only when
the package field is silent, and only when exactly one side is named — a title
mentioning both is left to the version ranges.

Verified live: CVE-2026-44901 (manager) is dropped on agents and kept on
managers, CVE-2026-49392 (agent) the other way round. Notepad++ is untouched —
its advisories name no component, so nothing is filtered there.

Still open, and worth a decision: 4 of Notepad++'s 18 advisories and 10 of
Wazuh's 60 carry no CVE id at all. They are skipped, because the rest of the
pipeline keys on CVE numbers for enrichment, KEV, EPSS and cross-scanner
dedup. Covering them means giving a finding a GHSA id instead.
2026-08-12 10:03:40 +02:00
vulncheck 7088c552e2 fix(scan): a rate-limited advisory build must store nothing
The pagination guard stopped the fetch on a 403 but not the build: execution
fell through to _store, which wrote the truncated index and a fresh timestamp,
so a partial answer was served as authoritative for the full 24h TTL —
the same failure the guard was written against, one level up.

Nothing is stored now, and the last complete index is returned. Returning an
empty one would be worse than stale: every repo-advisory finding would go
untouched, which the reconcile reads as resolved. _stored() is the raw read,
load_index() keeps the TTL check on top of it.

Two more from the same review:

Commit hashes could still reach a fix version. _load_via_nvd harvests the same
NVD bounds as the scanners into FixCandidates and had no is_version check, so
"before 44bf114d2f49" survived on that path after both scanners rejected it.
It imports the scanner's rule rather than carrying a third copy.

A repo that answers with no advisories is recorded as such again; one that
never answered is not. The earlier `if not payload: continue` collapsed both
into "never checked".

The non-manager package rule is now NON_MANAGER_PKG_RE, one constant that
_upsert and the test share — the test compiled its own copy of the pattern and
would have stayed green if the production one changed. Covered by a new check
that stubs a 403 and asserts nothing is written.
2026-08-12 08:58:08 +02:00
vulncheck b968407f58 fix(scan): manager-side CVEs off every non-manager package, not just the agent
The guard named wazuh-agent only, so CVE-2026-25769 — cluster-protocol code
execution between master and worker — moved to the next package along and sat
on a dashboard host instead. A dashboard takes part in that protocol no more
than an agent does, and neither does the indexer.

The rule now covers agent, dashboard and indexer; manager and server keep
everything, since that is what the CVEs are about.

This is the fallback for NVD and cvelistV5, which have no component field. The
GitHub advisories state it outright and are matched on that — but this
particular CVE names its package "Wazuh v4.14.1" there, which identifies no
component, so the prose check is what has to catch it.
2026-08-12 08:39:49 +02:00
vulncheck be3c111a93 fix(gui): match the audit-log pager to Vulnerabilities and Assets
Audit logs had its own pagination bar: a "Rows per page:" label, 25/50/100/250
steps and full-word First/Prev/Next/Last buttons. The Vulnerabilities and Assets
tables use "Per page" with 50/100/250/500/1000 and compact «/‹ Prev/Next ›/»
controls. Same bar everywhere now; 1000 is the cap on /audit/logs.
2026-08-11 22:37:54 +02:00
vulncheck 41f5a422d4 feat(scan): use the component GitHub states, instead of inferring it
The tester pointed at the right field. NVD files agent, manager and dashboard
under one CPE and cannot express which is meant, so a manager flaw landed on
every endpoint. The GitHub advisories say it outright, in
vulnerabilities[].package.name: 41 of wazuh/wazuh's entries name
"wazuh-manager", 13 name the agent, one the dashboard, and two describe the
project's CI rather than any product.

That is stated fact, and it beats the prose heuristic on both counts.

It covers the direction the heuristic could not: CVE-2026-49392 is agent-only
and stayed open on the manager, because reading "this is only about the agent"
out of prose is far harder than spotting manager wording. The component field
answers both directions at once.

And it removes CVE-2026-67308 properly. That one is about GitHub Actions
workflows, so no amount of manager/agent wording applies — its component is
"wazuh/wazuh (CI workflows)", which corresponds to nothing installed anywhere
and now lands on no host.

Deliberately one-sided in the safe direction: an advisory that names no
component, or a package shape we do not recognise, keeps its finding. Only a
stated mismatch drops one.

The prose heuristic stays for NVD and cvelistV5, which have no such field.
Cache key bumped so the component is available immediately rather than after
the 24h TTL.
2026-08-11 14:03:41 +02:00
vulncheck 4927a1e2ac fix(scan): reject commit-hash bounds on the CPE path too
The hash rule went into cvelistV5 only, so the same CVE could still arrive
through NVD after the other path had stopped producing it — the tester still
saw CVE-2026-67308 on a Wazuh agent, bounded by "before 44bf114d2f49" and
advising an upgrade to an upstream commit.

The rule now lives in the CPE scanner, where both paths use it, and applies to
every NVD bound as well as to the fix version. cvelistV5 delegates rather than
keeping its own copy, so the two cannot drift apart and let a CVE back in
through whichever is laxer. Pinned by a test asserting both agree.

Note for the tester's case: this particular CVE is about Wazuh's GitHub
Actions workflows, so the manager-only filter does NOT catch it — that one
keys on cluster and manager wording, and this text mentions neither. It is the
hash rule that removes it, on every host.
2026-08-11 08:56:50 +02:00
vulncheck eef8f5d2d0 fix(scan): follow pagination on repo advisories
One page of 100 was fetched and the Link header ignored. wazuh/wazuh sits at
60 advisories and notepad-plus-plus at 18, so nothing is missing today — but a
repo crossing 100 would have lost everything past the first page without a
word, which is the failure mode this whole day was made of: a source that
answers 200 and quietly returns part of the answer.

Pages are followed via rel="next", capped at 20 so a malformed header cannot
loop. A multi-page fetch is logged, and hitting the rate limit stops the whole
build rather than storing a partial index as if it were complete.

Checked while answering where these advisories come from: the per-repo
endpoint carries the full history (2023 through 2026, no second page), while
the global advisory database returns exactly one Wazuh entry — it is organised
by package ecosystem and Wazuh is not a package in any of them. The per-repo
endpoint is the right source; the global one would add nothing.
2026-08-11 08:51:30 +02:00
vulncheck 9ea5e76d19 fix(scan): keep manager-side Wazuh CVEs off agent-only hosts
Wazuh files agent, manager and indexer under one CPE, so every server-side CVE
landed on every endpoint running the agent. CVE-2026-25769 is the clearest
case: code execution "on the master node", reached "from a worker node" over
the cluster protocol. An agent speaks 1514/1515 to a manager and is not part
of that protocol at all — the finding sat on a client workstation.

Findings whose description names the server side and nothing else are dropped
when the package is wazuh-agent. The check is deliberately timid: any text
that also mentions the agent is kept, because a flaw in how the manager
handles agent input is a real risk on both sides, and missing a genuine agent
CVE is the more expensive error.

It lives in _upsert, where all three sources converge, so NVD, cvelistV5 and
the GitHub advisories are covered by one rule. Findings already stored close
themselves on the next scan: the CVE is no longer re-detected, so the existing
reconcile marks it patched.
2026-08-11 08:50:00 +02:00
vulncheck b7fbeb7598 fix(gui): fill the audit Asset column for lifecycle events too
Third and last place this was missing. ASSET_DEACTIVATED and
ASSET_REACTIVATED wrote the bare new status ("inactive") into new_value, which
is not parseable, so the Asset column stayed empty on exactly the rows that
are ABOUT an asset — while the hostname sat in the description right beside
it.

Same JSON shape as the vulnerability events now, with the reason alongside the
host, so the column fills for all three kinds: findings detected, findings
changed, and assets deactivated or revived.
2026-08-11 08:13:04 +02:00
vulncheck 6382c6daa5 fix(sync): put an address in the IP column, not a hostname
The all-in-one fallback took the host straight out of the configured API URL,
so an installation addressed by name ended up with an FQDN in a column called
IP ADDRESS — which sorts and filters as text and is not what anything reading
that field expects.

It is resolved now, and passed through untouched when the URL already contains
an address. If DNS has no answer the field stays empty: better nothing there
than a value of the wrong kind.
2026-08-10 19:40:31 +02:00
vulncheck 991db2bc96 fix(scan): a commit hash is not a version bound
CVE-2026-67308 is a shell-injection flaw in Wazuh's GitHub Actions workflows —
their CI, not the product — and the record bounds it with the commit that
fixed it: "before 44bf114d2f49". Read as digits that becomes (44, 114, 2, 49),
which every installed 4.x sits below, so it landed on every Wazuh host as a
critical finding about a pull-request workflow nobody runs. The fix field then
advised upgrading to an upstream commit.

Bounds that are hashes are dropped where ranges are built, so an entry left
without a usable bound is skipped entirely rather than matching everything
below a number that was never a version.

The test for "is this a version" has to be narrow, because plenty of real ones
carry letters: a hash is hex, seven characters or more, with no separator.
Checkmk's 2.4.0p12 and 5.0.0-beta1 have separators; Teams' 25060212643 is all
digits. Only things like 44bf114d2f49 and deadbeef fail it.

Verified against the pair the tester reported: CVE-2026-67308 now yields no
range at all, while CVE-2026-25770 (3.9.0 up to 4.14.3, a genuine privilege
escalation) still matches his 4.14.1 and stops at 4.14.3. Index key to v24.
2026-08-10 19:04:56 +02:00
vulncheck 901f6fc088 fix(scan): repo advisories — real versions only, and see rpm-packaged installs
Three defects the tester found within minutes of the GitHub source going live.

A COMMIT HASH IS NOT A FIX VERSION

Wazuh answers some advisories with "44bf114d2f49" instead of a release, and it
was passed through verbatim: the finding told him to upgrade to an upstream
commit. Only a dotted-numeric build is stored now. A fixed_version is written
once and never revised, so a wrong one outlives the finding — empty is
recoverable, wrong is not.

THE ADVISORIES NEVER REACHED A LINUX AGENT

This path read the raw version field. Fine for Notepad++, wrong for anything
shipped as an rpm/deb: a Wazuh agent reports "4.14.1-1" and comparing that
against "4.14.6" matched nothing. The advisories therefore only ever landed on
the cluster node, whose version is already clean, and quietly skipped every
agent — which is most of the estate. It now resolves the version the same way
the other scanners do.

NO IP ON AN ALL-IN-ONE NODE

A single-node install reports no address, because there is no cluster to
address, so the asset had an empty IP column. The configured API URL points at
exactly that machine and is used as the fallback — only when there is one node,
where the inference actually holds.
2026-08-10 18:58:19 +02:00
vulncheck c81d0309a5 fix(gui): fill the audit Asset column for new-finding events too
Status changes carry the host in new_value and render correctly; the
VULNERABILITY_DETECTED rows wrote no new_value at all, so the new Asset column
was empty for exactly the events there are most of. The hostname was already
in the description text, which is the part that gets truncated.

Those rows now carry the same JSON as the status changes — host, CVE, source
and severity — so the column fills for both kinds and the data is there for
filtering later.
2026-08-10 18:43:59 +02:00
vulncheck ff298ec23f feat(scan): read Wazuh advisories from GitHub, ahead of the CVE feeds
The tester was right that NVD alone leaves a hole, and it is bigger than
expected. Checked live against the repo: Wazuh 4.14.5 — the newest release NVD
knows any bound for — is affected by 10 GitHub advisories, three of them
critical, all fixed in 4.14.6. A host on 4.14.5 looks clean to NVD and to
cvelistV5 and is not. Several advisories carry no CVE id at all, so nothing
keyed on CVE numbers can ever see them.

No new service: github_repo_advisory_service already does exactly this for
Notepad++, so Wazuh is one line in its repo map, covering the agent, manager,
server, indexer and dashboard packages plus the synthesised "wazuh-server"
name from the cluster-node scan. The API needs no token for public advisories.

Its range parser did need one addition. It was written against Notepad++'s
free-text ("<= v8.9.6.4", "old versions - 8.8.1") and fell through to the
"below patched" rule for GitHub's own ">= 4.3.0" form. That happened to give
the right answer for 4.x, but it ignores the floor — a 3.x install would have
inherited an advisory that starts at 4.0.0. The form is now parsed properly,
with the optional upper bound.
2026-08-10 18:43:05 +02:00
vulncheck 5689365a60 fix(scan): strip the v from a node version before scanning
The cluster API reports "v4.14.1" on some builds. The sync strips the prefix,
but an asset synced before that still carries it — and _clean_version rejects
the entire string over that one letter, so the node resolved fine and was then
never actually scanned. Stripped at the point of use as well, so a stored
value that is not clean no longer costs the check.
2026-08-10 16:23:13 +02:00
vulncheck ee337dcfe2 fix: widen wazuh_agent_id — node ids did not fit, sync returned 500
assets.wazuh_agent_id was String(10), sized for a numeric agent id ("042").
Manager nodes are stored in the same column as "node:<name>", which is 11
characters for "node:master" alone and free-form beyond that, so the insert
was rejected on length and the whole Wazuh sync answered 500. Widened to 64
and the id is truncated to fit.

The failure also showed the block was not as contained as intended: the
guard around the node sync caught nothing, because the offending insert was
only validated at the agent loop's commit further down, outside it. It now
flushes inside the guard and rolls back on failure, so a problem with a node
costs the nodes and not the entire asset sync.
2026-08-10 16:20:06 +02:00
vulncheck adfc2cf898 feat(scan): sync Wazuh manager nodes as assets and scan their version
Detecting wazuh-agent covered the hosts that run one. Master and worker nodes
run none — they inventory other machines and nobody inventories them — so they
were never assets at all, and the heaviest Wazuh CVEs are precisely the ones
on the server. The tester has master and worker on 4.14.1 and saw nothing.

The cluster API reports its own version without an agent being involved, over
the session that already fetches agents, so no new credentials and no new
integration: /cluster/nodes during the Wazuh sync, falling back to
/manager/info on a standalone install where the cluster endpoint is off.

Each node becomes an asset with a "node:<name>" id. The scan reads that prefix
and, instead of asking syscollector for a package list that cannot exist,
takes the node's own version as its inventory — which then runs through the
same registry, ranges and reconcile as everything else. A node with no version
reported contributes nothing rather than a guess.

Dashboard and indexer are unaffected: those do carry agents and were already
covered by the wazuh-agent detection.
2026-08-10 16:00:27 +02:00
vulncheck c31e5de70d feat(scan): detect Wazuh's own components
The scanner watches every other product on these hosts and was blind to the
one doing the watching. NVD carries 54 CVEs for Wazuh, several of them remote
code execution on the manager, and neither detection path resolved the name at
all — a Wazuh 4.14.5 server reported nothing about itself.

Two things had to line up.

The CPE names: NVD files 41 of the 54 under wazuh:wazuh, while the dashboard
and the older Kibana app have their own names and their own CVEs, so the
dashboard entry queries all three. cvelistV5 names the product per component
and states ranges as ">= 4.2.0, < 4.7.2".

The version: these ship as rpm/deb, so syscollector reports "4.14.5-1".
_clean_version rejects that outright — deliberately, to keep distro packages
out of the scanner — which is what made the components invisible even once the
names matched. A pkg_ver flag trims the packaging release suffix, leaving the
upstream version the advisories talk about. Both paths share
_effective_version, so both registries carry the flag; without it on the
cvelistV5 side that path still dropped the version.

Helper packages are excluded by name (wazuh-agent-config-helper and friends
are not the agent). Index key to v23; diagnose_scan gains a check for the
trimmed version, so the container can be verified directly.
2026-08-10 15:46:26 +02:00
vulncheck 5436745d0e fix: give the remaining asset foreign keys a delete rule
Migration 040 fixed the two constraints pointing at vulnerabilities, which was
only half the chain — I checked what referenced findings and not what
referenced assets. So the delete got one step further and failed on the next
constraint instead:

    ForeignKeyViolation: update or delete on table "assets" violates foreign
    key constraint "notification_logs_asset_id_fkey"

This time every foreign key into the delete path was enumerated. Three had no
rule, each gets the one that fits: the notification log keeps its row with the
reference cleared (it records that a mail WAS sent, which stays true), while
scans and findings belong to their asset, are NOT NULL, and go with it. The
asset_groups association is included even though SQLAlchemy clears it itself —
the database should not depend on the application getting the order right.

Verified no foreign key into assets, vulnerabilities or scans is left without
a rule.
2026-08-10 15:37:03 +02:00
vulncheck a7b601001c feat(scan): report disabled browser extensions too
A disabled extension was skipped before any lookup, on the reasoning that code
which does not execute is not a live risk. Wrong call: the vulnerable code is
still in the user's profile, and a click, a policy push or a profile sync puts
it back in the browser. Latent, not absent.

It also made the tool inconsistent with itself. Wazuh reports every installed
Linux kernel, including ones the machine has never booted, and nobody argues
those should be hidden for not running right now — same situation, same answer.

The finding carries the state in its name ("… [disabled]") so whoever picks it
up can weigh it, and enabled/disabled are tracked separately, so an extension
present in one profile and switched off in another shows both.
2026-08-10 15:20:28 +02:00
vulncheck 9558129bc1 feat(gui): show the affected asset as its own audit-log column
Naming the host inside the description was not enough: that column truncates
at max-w-xs, and these rows are long — "Vulnerability EOL-CHROME-150 status
changed: open → patched (Superseded — asset moved to a newer chrome release
(EOL-CH…" cuts off before it. So the machine was still only reachable by
exporting the CSV and reading new_value by hand.

It has been in new_value's JSON all along, so the column reads it from there
and no backend change is needed. Rows written before the hostname fix show a
dash rather than a guess.
2026-08-10 14:39:57 +02:00
vulncheck e8e86396cb fix(eol): cap the severity of rapid-release "end of life"
Firefox and Chrome ship every four weeks, and the previous version stops
receiving patches the day the next one lands. By endoflife.date's definition
every install except the very newest is end-of-life — the tester's host moved
Firefox 150 to 152 and the finding came straight back as EOL-FIREFOX-152 at
CVSS 9.0, on its way to critical (9.8) as the day counter climbed.

That put "one release behind on a browser" above Exchange Server 2016, which
really is abandoned. Both were HIGH, and the browser was overtaking it purely
by age.

The finding stays — an unpatched browser is worth knowing about — but it is
capped at medium for these two, and the days-past-EOL escalation no longer
applies to them: on a four-week cycle that counter measures release cadence,
not growing risk. The text says so too ("pending update, not an abandoned
product") and names the current release. For these products the CVE scan is
the precise signal anyway; it says which hole is open rather than only that
the version is stale.

Scoped to a named set, so anything on a real support lifecycle — Windows,
Exchange, Office, SQL Server, Node, Java — keeps the full weight.
2026-08-10 14:22:32 +02:00
vulncheck 93120f5dd6 fix(scan): a companion product is not the product it names
"Veeam Explorer for Microsoft Teams" 13.3.2.3 collected four Teams CVEs whose
bounds are 25122.x — Veeam's own software, matched purely because its name
contains "Microsoft Teams". Checking the rest of the registry against the same
shape found four more: Chrome findings on "Citrix Workspace app for Google
Chrome", Firefox findings on "Kaspersky Plugin for Mozilla Firefox", and so on.
Backup tools, plugins and connectors are all named this way.

The product being described stands FIRST, so a match that lands only in the
"…for <product>" tail is dropped. Matching itself still runs on the full name,
which matters: the Firefox entry rejects "Firefox for iOS" itself (WebKit, not
Gecko, different CVEs), and trimming the name before matching hid that from it
— caught by test_mobile_browsers.

Teams is anchored to the start of the name as well, so a product that merely
mentions it later cannot match even without a "for". Both scanner paths share
the rule, so they cannot disagree about the same install.
2026-08-10 14:18:56 +02:00
vulncheck 591e27bbe9 feat: keep the vendor's own severity visible, and name the host in the audit log
VENDOR RATING NO LONGER DISAPPEARS

Chrome and Firefox CVEs ship with no CVSS at all, so the vendor's own rating
is the only signal at first: Google states it in the record ("Chromium
security severity: Critical"), Mozilla in its advisories. Once a real score
arrives the severity column is recalculated from it and that rating was gone —
CVE-2026-19137 shows HIGH because CVSS 8.3 is High, while Google calls it
Critical.

Neither is wrong. They answer different questions: Chromium "Critical" means
escape from the sandbox, by Google's own criteria; 8.3 is High on the FIRST
scale. So the vendor's rating now sits next to ours instead of being replaced,
and only when the two differ. Sorting and priority stay on CVSS.

No schema change — Google already writes it into the description, and Mozilla's
is appended there the same way, so the badge reads both from text that is
already stored.

AUDIT ROWS NAME THE MACHINE

log_vulnerability_change has taken a hostname from the start, but the automated
paths (EOL supersede and reconcile, app-scan auto-resolve) never passed one, so
their rows said which CVE changed and not on which system — the tester could
only get at it by exporting the CSV and reading new_value. It is now looked up
when the caller does not supply it, rather than threaded through every call
site: these are single-row writes and the id is indexed.
2026-08-10 14:13:49 +02:00
vulncheck 732dd75637 fix(eol): the Microsoft lifecycle export never arrived, and never parsed
Two independent failures, both silent, so the second EOL source has been
contributing nothing for a while.

THE WORKBOOK WAS NEVER FOUND

The link pattern required the file to be named eos-product-listing-<month>-
<year>.xlsx. Microsoft renamed it to product-lifecycle-data-new.xlsx, so the
match failed and fetch returned no rows. The export page still answers 200 and
the log shows a successful request, which is why nothing looked wrong. Only
the host, path shape and extension are pinned now — that page carries exactly
one download.microsoft.com xlsx, so the next rename costs nothing.

AND IT WOULD NOT HAVE PARSED ANYWAY

The column lookup asked for "listingname" and "enddate". The workbook calls
them "Product Listing Name" and "Extended End Date", so both fell through to
their positional defaults — and default 3 is "Azure Feature", whose value is
the string "Fixed". Not a date, so every row was dropped as unusable. Columns
are now matched on their real names (punctuation-insensitive), preferring
extended support over retirement, and a workbook with no recognisable date
column says so in the log instead of quietly returning nothing.

Both failures presented identically to a supported product: no lifecycle data
found, move on. Exchange 2016, the Visual C++ redistributables and Lync 2013
are all in this export.

tools/check_ms_lifecycle.py walks the whole chain — link, download, parse,
name matching against real inventory strings — and exits non-zero at whichever
step breaks next.
2026-08-10 14:07:42 +02:00
vulncheck 2a831e4678 feat(scan): report a fix floor when the advisory names no patched build
Adobe writes CVE-2026-48294 as "lessThanOrEqual 26.5.2.2", so no fixed build
exists to show and the finding read "not announced" — which hid something the
tester rightly pointed out twice: if 26.5.2.2 and older are affected, anything
above it is not. That is a valid statement, and an admin can act on "newer
than 26.5.2.2" even when the vendor named no build.

Stored as ">26.5.2.2" and rendered as "not announced — need newer than
26.5.2.2". The prefix carries both facts at once, which is why it is not
written as a bare version: fixed_version also drives the "patch available"
badge, and lessThanOrEqual means the CNA named no patched release, often
because none has shipped. has_fix reads the prefix and does not claim one
exists. A later record with a real lessThan replaces the floor.

No schema change: the existing column carries it, and nothing compares
fixed_version numerically (only equality, in has_fix).
2026-08-10 14:02:24 +02:00
vulncheck 153809a3f1 fix(eol): detect Exchange 2016, and close findings when software is uninstalled
EXCHANGE SERVER 2016 STILL LOOKED SUPPORTED

Fixing the slug was necessary but not sufficient. endoflife.date names the
msexchange releases 2016 / 2019 / subscription while syscollector reports
15.1.2507.6, so prefix-matching a version against a year can never hit — the
same shape as Office and SharePoint, which is why those are already year-keyed.
Exchange is now too.

That alone still misses the plain "Microsoft Exchange Server" entry sitting
next to "…2016 Cumulative Update 23" in the same inventory: same install, no
year in the name to key on. So a build number is also matched against the
major.minor of each release's latest build — 15.1 IS 2016, 15.0 is 2013.
Deliberately returns nothing when two releases share it: 2019 and Subscription
Edition are both 15.2, and guessing would put a supported SE host on a release
that went end-of-support in 2025.

UNINSTALLED SOFTWARE KEPT ITS EOL FINDING

Supersede closes the old release when a newer one appears, which is why
Chrome 150 -> 151 has always worked. It cannot cover an uninstall: nothing
newer shows up, so nothing supersedes the finding. Since supersede was
restricted to single-release products, side-by-side software had no route out
at all.

The inventory answers it — a product the scan did not see is not installed —
but only when there IS one. An empty package list is a failed or not-yet-
populated fetch, never proof that a machine runs no software; closing on it is
exactly the mistake the app scan made with a re-registered Wazuh agent, so the
same precondition applies here.

Only findings this scan owns are retracted, leaving mobile/Intune EOL findings
alone. Wired into both the GUI check and the nightly job so the two agree,
counted as eol_uninstalled_closed.
2026-08-10 13:59:31 +02:00
vulncheck 9fac0ae92f feat(gui): per-page selector on the vulnerabilities table
The page size was hard-coded at 100, so 59k findings could only be walked 591
pages at a time while the assets table has offered 50/100/250/500/1000 all
along. Same control, same choices, same default, so both tables behave alike.

The pagination bar now appears whenever there are results at all. It used to
be gated on totalCount > pageSize, which would have hidden the selector as
soon as the chosen size covered everything — leaving no way back to a smaller
one. Changing the size returns to page 1, otherwise page 5 of 1000-per-page
lands past the end of a 50-per-page list.

The API already accepted up to 1000 (limit le=1000); nothing changed there.
2026-08-10 11:20:06 +02:00
vulncheck e0a56b9053 fix(eol): stop the supersede/reopen fight on side-by-side products
Supersede assumed "a product runs exactly one release per asset". True for a
browser or an OS, false for most Windows components: the tester's host carries
Visual C++ 2008, 2010, 2012, 2013, 2015 and 2022 redistributables at the same
time, all genuinely installed and several genuinely EOL.

So the two halves of the EOL check fought each other inside a single scan.
Processing the 2008 entry closed the 2013 finding as "superseded — asset moved
to a newer release"; processing 2013 reopened it and closed 2008; and so on
for every pair, every night. The change history filled with 50 alternating
rows, the audit log with hundreds, and the "newer release" a 2013 product was
supposedly superseded by was 2008 — five years older. Nothing on the host had
changed at any point.

Supersede now applies only to products a device runs one of (browsers, the OS,
Exchange, SharePoint). For anything installed side by side, an older release
still being present is the normal state, not a leftover, and its finding stays
open — which is also the correct answer, because that software really is
installed and really is end-of-life.

Not addressed here: nothing closes an EOL finding when a side-by-side product
is actually uninstalled. That needs the EOL check to reconcile against the
inventory the way the app scan does, rather than inferring removal from the
presence of a different release.
2026-08-10 09:51:30 +02:00
vulncheck fdd1019fbd fix(eol): correct the endoflife.date slugs — seven pointed at nothing
The product mapping was hand-written and never checked against the actual
catalogue, so a quarter of it was wrong. Every EOL run asked for ms-office,
exchange-server, java, visual-cpp, adobe-acrobat, apache and iis, and got 404
back each time — visible in the log as "not in catalog", invisible everywhere
else. The scan simply found no lifecycle data and moved on, which is why
Microsoft Exchange Server 2016 came out looking supported.

Checked against the live list (462 products) rather than guessed:

  exchange-server -> msexchange           (the reported Exchange 2016 case)
  ms-office       -> office
  apache          -> apache-http-server
  java            -> oracle-jdk

The other three do not exist there at all and never will: endoflife.date has
no record for Adobe Acrobat, the Visual C++ redistributables, or IIS. Their 18
mapping entries are removed rather than left to 404 on every run — IIS ships
with Windows Server and is covered by that entry, the other two depend on the
Microsoft export or a vendor page. Same reasoning as the existing Edge note.

tools/check_eol_slugs.py verifies the whole mapping against the live catalogue
and exits non-zero when a slug disappears, so this cannot rot again unnoticed.
2026-08-10 09:34:48 +02:00
vulncheck d90b8455f2 fix(scan): never store a release line as the fixed version
CVE-2026-21710 lists Node 20.20.1, 22.22.1, 24.14.0 and 25.8.1 as concrete
entries and then adds "4.0 lessThan 4.*" and "5.0 lessThan 5.*" for the
ancient lines. The wildcard was carried through as a fix target, so a host on
24.13.1 was advised to upgrade to "4.*" — a release line, not a build anyone
can install.

It sticks, too: a fixed_version is filled in once and never overwritten
(vuln_override_service), so a wrong one outlives the finding. Empty is
recoverable, wrong is not — so wildcards are dropped at the source, and the
ones already written are cleared on the next scan and counted as
fix_versions_cleared.
2026-08-10 08:59:27 +02:00
vulncheck 949708745a fix(scan): apply the version-shape check to Teams as well
why_match on the tester's host reported 0 matching NVD rules for
CVE-2025-49731, yet the finding kept reappearing — so it was not the CPE path
producing it, it was cvelistV5, which had no shape check.

The record names three builds: 1.0.0.2025112902 (Android), 7.10.1 (iOS) and
the MSRC build 25060212643. A host on the current 26183.1903.4892.4448
compares below every one of them, so a fully patched machine kept a Teams CVE
open. The MSRC scanner already refused to compare across build shapes (that is
what the "Teams build shapes are not compared" check covers); this path did
not, and it re-created the finding on every scan, which is why it survived the
reconcile — a CVE detected this run is never stale.

Same rule as Adobe, so the strict list is now a named set rather than a prefix
test on one key. The old Teams numbering (1.6.00.4472) shares its shape with
the mobile bounds and still compares normally, so genuinely outdated classic
clients are unaffected.

Index key bumped to v22; diagnose_scan gains a check for this exact host.
2026-08-10 08:07:42 +02:00
vulncheck 20b7686aea fix(scan): see Firefox on Android; mark extensions as per-user
Android reports the app by its store id, org.mozilla.firefox. The cvelistV5
registry matched "mozilla firefox" with a space, so that path missed Firefox
on every Android device while the CPE path saw it fine — the two sources
disagreed on the same install. Anchored to the start so it stays a package id
match rather than a substring hit; Chrome already had com.android.chrome.

Browser extensions now carry "(User)" the way Windows ARP marks a per-user
install ("Microsoft Visual Studio Code (User)"). An extension lives in the
browser profile, so it is updated or removed per user and will not be found in
Programs and Features — worth saying in the finding itself.
2026-08-09 19:39:43 +02:00
vulncheck d61b7a86af fix: never auto-resolve from an empty inventory; unblock asset deletion
Three findings from the tester, all reproducible.

EMPTY INVENTORY CLOSED EVERYTHING

The auto-resolve rule was documented as "only safe when we had a real
inventory this run (packages non-empty)", but the code only ever checked the
asset status. A Wazuh agent that re-registers under a NEW id keeps its old
id's syscollector data until it rescans, so /syscollector/<new-id>/packages
answers 200 with an empty list — a live, healthy asset reporting no software.
Every app-scan-only finding on it then counted as "no longer detected" and was
closed, only to be reopened by the next scan that saw the inventory again.
That is the flapping behind several of the "was closed, now open again"
reports.

An asset that HAS an inventory source and returns nothing from it is a fetch
we cannot trust, not a host running no software. Its findings are now left
alone and the skip is logged and counted (resolve_skipped_no_inventory). The
asset matching itself was fine — the sync falls back to the hostname and
carries the new agent id over.

ASSETS WITH NOTIFICATIONS COULD NOT BE DELETED

notification_logs and ai_analyses referenced vulnerabilities with no ON DELETE
rule, so deleting an asset failed with a ForeignKeyViolation once anything had
been mailed about one of its findings — which is why some assets deleted fine
and others returned 500. Migration 040 gives each the rule that fits: the
notification log keeps its row with the reference cleared (it records that a
mail WAS sent, which stays true), the AI analysis goes with the finding it
describes.

DELL RVTOOLS AND VEEAM ONE

Both are cvelistV5-only — DSA-2026-325 (CVE-2026-64993) and the six CVEs in
Veeam KB4892 are all still awaiting NVD enrichment, so the CPE path is blind
to them. Veeam's advisory covers the SERVER only ("Veeam ONE 13.0.2.6723 and
all earlier version 13 builds", fixed in 13.1.0.7034), so the name pattern
excludes the client and console — otherwise every workstation with the console
installed would be flagged.

Dell states its bound as "4.8.1 or later", which is prose, not a version. The
comparison survived it by accident (only digits are read) but the sentence was
carried through and shown as the fixed version, so _clean_bound trims exactly
that shape and leaves anything else verbatim.

Index key bumped to v21.
2026-08-09 19:38:27 +02:00
vulncheck 2474d2816f tools: add why_match.py — which bound made this CVE match
why_open.py explains why a finding stays open. This explains the step before
it: which NVD cpeMatch rule accepted the installed version, printed next to the
parsed version tuples. Every false positive reported so far came down to a
bound written in a different version scheme than the install (Adobe's three
products, Teams' build numbering), and finding that meant reading the NVD page
by hand each time.
2026-08-06 13:42:55 +02:00
vulncheck c042e9d549 feat(gui): report every counter a scan returns, not a hand-picked few
Each scan button carried its own list of which counters to display, so a
counter the backend reported but the list did not name was invisible: the log
said "11 FP-suppressed" and the button said "0 findings (0 new)". Adding a
counter to a scan service meant editing several buttons, and it got forgotten
every time. The MSRC button was worse — it sliced the result to the first four
entries and printed the rest as raw backend field names.

lib/scanStats.ts formats whatever the backend actually returned. Known keys
get a readable label, anything else renders from its own name, so a new
counter shows up in the GUI with no frontend change. Zero counters are
dropped, since "0 auto-resolved" is noise.

Applied to the app scan, the per-asset re-scan, the EOL check, the MSRC
enrich and the M365 check.
2026-08-06 13:42:09 +02:00
vulncheck faedd83ae9 fix(scan): tell Adobe's three acrobat products apart by version shape
Adobe files three different things under the same acrobat / acrobat_reader
CPEs, and nothing in the record separates them except how the version is
written:

  desktop Acrobat/Reader     26.001.21771     3 parts
  Acrobat Reader T5 in Edge  127.0.2651.105   4 parts, Chromium numbering
  browser extension          26.7.1.0         4 parts, its own numbering

CVE-2024-41879 says "Acrobat Reader versions 127.0.2651.105 and earlier" and
carries cpe:microsoft:edge as its second configuration — it is the PDF engine
built into Edge, not the desktop application. Compared as plain numbers
26 < 127, so it was reported on every desktop Acrobat AND on the extension.
CVE-2024-20721, -20709 and -39379 (bound 120.0.2210.91) did the same, as did
the CVE-2026-479xx block, which is why the extension work looked like it had
regressed: those findings were never extension CVEs to begin with.

A bound only counts now when it is written in the same shape as the install:
same number of parts, and the leading number on the same side of 100 (which
is what separates Chromium's three digits from the extension's two). Both
sides must agree, otherwise the bound describes a different product and the
two are not comparable — every caller already drops a match it cannot compare.

Scoped to Adobe on both paths (NVD reads it off the CPE criteria, cvelistV5
off the curated key), because part counts differ legitimately elsewhere:
"8.6" against "8.6.1" must keep matching.

The genuine extension finding CVE-2026-48294 (up to 26.5.2.2) is unaffected,
and so are real desktop CVEs — both pinned in tests/test_adobe_product_split.py.
2026-08-06 13:39:38 +02:00
vulncheck 816a684bf1 fix(gui): end the scan poll only on an explicit terminal status
The app-scan and EOL pollers had two branches — running, or finished — so
every answer that was not a readable status object fell into "finished". The
empty result then rendered as "0 assets, 0 findings (0 new)" and the button
went idle while the backend scan carried on and logged the real numbers.

The tester hit this after ~15-30 minutes with several tabs open, which is when
the access token expires mid-scan: the poll 401s, the interceptor refreshes and
replays it, and anything that comes back other than a clean status object —
a login-page body from a redirect, a second 401 — landed in the finished
branch. The 401/refresh/200 sequence in the log is the healthy path, not the
bug; the poller's reading of it was.

Now only status "completed" or "failed" stops the poll. Anything else keeps
polling, with a ~2 min counter of unusable answers so a backend restart
mid-scan (the job lives in process memory) ends it with a clear message
instead of spinning forever. Same counter on the catch branch, which could
poll a dead backend indefinitely.

Also surfaces fp_suppressed in the result line, now that suppression runs
inside the scan.
2026-08-06 10:47:33 +02:00
vulncheck 837f2fbc8a fix(scan): run FP-suppression in the scan, not beside it
All three ways of starting an app scan — the nightly job, the GUI button, and
a single asset — call run_app_cve_scan and share every check inside it. The
cvelistV5 false-positive suppression was the one step that did not: it hung
off the nightly job, so the same host came out differently depending on how
the scan was started. A manual run left Wazuh's loose-CPE false positives
standing until the next night.

Moved into run_app_cve_scan, scoped to the same asset_id, so a single-asset
run stays a single-asset run. Reported as fp_suppressed in the scan stats,
which the GUI already renders alongside the other counters.

Index freshness stays deliberately different: the nightly job rebuilds the
cvelistV5 index (a ~557 MB ZIP walk), a manual scan reuses the cached one and
only builds when none exists.
2026-08-06 10:36:54 +02:00
vulncheck 81f7bbdc60 chore: refresh GitNexus index counts 2026-08-06 10:09:28 +02:00
vulncheck fc0359acef fix(scan): let Node.js reach the cvelistV5 path
The July-2026 Node releases have no NVD configuration at all — CVE-2026-56846
and -56848 sit there with no CPE — so the CPE path is structurally blind to
them and a host on 24.13.1 was reported clean. cvelistV5 carries both records
in full, but the scanner had no registry entry for Node, so it never looked.
The bound logic was already right (both were in diagnose_scan since the
inclusive-bound fix); nothing ever fed it.

vendor "nodejs" / product "node", one entry per release line with
version == lessThanOrEqual, so each line keeps its major as the floor and
"up to 24.18.0" cannot swallow an already-patched 22.x host.

Index key bumped to v20 (new entry → rebuild). diagnose_scan gains four checks:
both Adobe schemes, Autodesk years untouched, and Node reaching this path.
2026-08-06 10:09:17 +02:00
vulncheck 47d588d734 fix(scan): read Adobe's old and new version schemes as the same scale
Acrobat/Reader DC shipped as 2019.010.20098 until the 2020 release and as
20.001.30005 / 26.001.21771 after it — the same build, spelled two ways. The
sources disagree on which to use: NVD states the pre-2020 bounds without the
century (19.010.20098), cvelistV5 with it, so the two forms meet in every
comparison. Read as plain numbers 26 < 2019, and every current Acrobat fell
inside every pre-2020 Adobe CVE: CVE-2019-7819 was open on 18 hosts, including
an Acrobat Reader DC that had already been patched past the fix.

_vtuple now folds the four-digit form onto the short one. The pattern is narrow
enough to identify itself — three parts, a 2000-2099 leader, a three-digit
track, a four/five-digit build — so Autodesk's 2026 and 2026.0.0 are untouched.
Folding preserves order inside the old scheme and runs on both sides of every
comparison, so it can only change the outcome where the two schemes meet.

Also moves a finding's package_name/version columns onto a surviving product
when the row they named is pruned: they were written once at creation and never
revisited, so the list view kept showing "Asian Language And Spelling
Dictionaries Support For Adobe Acrobat Reader" while the detail page listed
only "Adobe Acrobat (64-bit) 26.001.21662" for the same finding.

Reported-by: wazuh/wazuh#29960 (vendor version schema change)
2026-08-06 10:09:07 +02:00
vulncheckandClaude Opus 5 1b5028fea6 fix(scan): commit the cleanup even when the scan found nothing
This is why false positives survived scan after scan. The reconcile ran, set
the stale findings to patched — and then the transaction was never committed,
because the commit fired only when the scan had FOUND something:

    if touched:          # touched = a finding was created or re-detected
        db.commit()

An asset whose only outcome was closing stale findings rolled its own work
back, every single time. The tester's why_open output made it unmistakable:
two assets with all four preconditions satisfied — ACTIVE, Wazuh inventory,
scanned an hour earlier, package no longer resolving, app-scan the only
source — and eleven findings each still sitting open.

It also explains why the earlier fixes appeared to work selectively: a host
with a fresh finding got its cleanup committed alongside that finding, a
quiet host did not. Exactly backwards, since a host with nothing new to
report is the one whose cleanup matters.

Cleaning up now counts as a change, so it is written like any other.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 17:27:44 +02:00
vulncheckandClaude Opus 5 396f47363f feat(scan): detect browser extension CVEs from Wazuh IT Hygiene
Browser extensions were a complete blind spot, and an awkward one: nothing
inventories them, because syscollector lists applications, not what runs
inside a browser. The Acrobat extension ships its own CVEs — CVE-2026-48294,
Chrome, up to 26.5.2.2 — which went nowhere, or worse, landed on the desktop
application whose CPE NVD shares with it (fixed in c88eb45).

IT Hygiene (Wazuh 4.14+) writes them to the indexer, which we already read for
vulnerabilities, so the client just needed a second index. Missing index or
unconfigured indexer returns an empty list — the feature is optional and must
not take a scan down with it.

Three things this gets right, all learned the hard way:

Extensions have their OWN registry, keyed by STORE ID rather than name. The
displayed name is localised and follows marketing ("Adobe Acrobat: PDF edit,
convert, sign tools"), while the id is what the browser installs under. Chrome
and Edge are separate keys because their version schemes are unrelated —
25.5.4.1 against 26.7.1.0 means nothing across stores.

target_sw is matched against the BROWSER, not the OS. That single field is
what tells the extension's CVEs apart from the application's, which is exactly
what went wrong when it was read from the wrong CPE position.

A DISABLED extension is skipped. The code sits on disk but does not execute,
so reporting it would misstate the risk — the tester's estate has one, on
Chrome, at 23.8.1.0.

On that estate this finds the Chrome install at 25.5.4.1 as affected by
CVE-2026-48294. Edge at 26.7.1.0 is not: no CVE names the Edge extension at
all — every "Acrobat for Edge" record carries browser versions and means the
engine built into Edge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 16:05:41 +02:00
vulncheckandClaude Opus 5 9b55d5e7af chore: explain why a finding is still open
The self-check confirmed the container is current, so the remaining question
is why specific findings survive the reconcile — and that has several
preconditions which fail silently. Nothing in the log says "skipped because
the asset is inactive" or "another scanner still claims it".

    docker compose exec backend python tools/why_open.py Dictionaries

Walks the same four conditions the reconcile applies — asset ACTIVE, an
inventory source exists, app-scan is the finding's only source, and the
package no longer resolves under current code — and marks whichever one
fails. The last check is the important one: if the package still resolves,
the CVE is re-detected on every scan and can never close, which points at the
matching rules rather than the reconcile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 16:02:24 +02:00
vulncheckandClaude Opus 5 978437282e chore: add a scanner self-check that survives copy-paste
Diagnosing "the fix did not take effect" kept failing before it started: the
one-liners I sent over chat picked up a non-breaking space and died with
SyntaxError, three times in a row, so we never learned whether the container
was running the new code at all.

    docker compose exec backend python tools/diagnose_scan.py

Seven checks, each printing what it expects: the Adobe add-on exclusion, the
Teams build-shape guard, both halves of the Node inclusive-bound fix, the
target_sw field, the cvelistV5 index key and the NVD cache prefix. A failing
line means the container is behind, and every other symptom is downstream of
that — which is the question worth answering first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 13:57:16 +02:00
vulncheckandClaude Opus 5 3384a1e918 fix(scan): an inclusive bound names one release line, not everything below
A record whose `version` equals its upper bound was treated the same way
regardless of WHICH bound it is, and the two mean different things.

version == lessThan is a zero-width, impossible range. Chrome emits it, NVD
reads it as an open floor, and that is right: a Chrome CVE fixed in
151.0.7922.72 does affect 150.x. Unchanged.

version == lessThanOrEqual is inclusive, so the entry names ONE release line.
Node states CVE-2026-56846 as two entries — 24.18.0 and 22.23.1 — and its
description says "affects Node.js 24.x and 22.x". Opening the floor made "up
to 24.18.0" swallow everything older: a host on 22.23.2, already patched and
covered by the OTHER entry, matched through the 24.x range, and so did 20.x,
which is not affected at all. The major version is now kept as the floor,
confining each entry to its own line.

This also closes the false negative that surfaced it: Node 24.13.1 was
reported unaffected because the entry was read as the single version 24.18.0
rather than the line it describes.

Index key bumped to v19 — cached ranges carry the old floors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 13:12:40 +02:00
vulncheckandClaude Opus 5 3fd27d46e5 fix(scan): carry the CVE description, and keep iOS browsers out
App-scan findings showed only the title we generate — "Adobe Acrobat (64-bit)
26.001.21691 — CVE-2026-48294" — which says what MATCHED, not what the
vulnerability is. The CNA's own text was sitting unused in both sources. It is
now taken from the cvelistV5 record and from NVD's descriptions block,
whichever path found the CVE, and backfilled onto existing rows that have none
(fill-only, so a source with better text keeps the last word).

Second: iOS browser builds were resolving to the desktop CPEs.
org.mozilla.ios.Firefox matched mozilla:firefox, which is wrong for the same
reason iOS Chrome was already excluded — Apple mandates WebKit there, so the
Gecko and Blink CVEs behind those ranges do not apply to it. Android is
unaffected by this: org.mozilla.firefox and com.android.chrome share engine,
version numbers and fixes with desktop, and NVD keeps them under one CPE with
a neutral target_sw, so they stay mapped as before. Verified: no separate
firefox_for_android CPE exists (0 CVEs).

Firefox ESR keeps its existing exclusion — NVD tracks it as its own
firefox_esr CPE.

Index key bumped to v18; cached entries carry no description.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 09:54:32 +02:00
vulncheckandClaude Opus 5 c88eb45733 fix(scan): read target_sw from the right CPE field
The platform filter read field 9 of the CPE — sw_edition — instead of field
10, target_sw. Field 9 is almost always "*", so every match looked
platform-neutral, the filter accepted everything, and it has had no effect
since it was written. The Firefox-for-iOS case its docstring describes was
never actually caught.

What this let through is the false-positive wave the tester reported. NVD
files the Acrobat BROWSER EXTENSIONS under the same product name as the
desktop application, distinguished only by target_sw:

    cpe:2.3:a:adobe:acrobat:*:*:*:*:*:edge:*:*     up to 126.0.2592.81
    cpe:2.3:a:adobe:acrobat:*:*:*:*:*:chrome:*:*

and their versions are BROWSER versions. Desktop Acrobat 26.001.21771
compares below 126.0.2592.81, so every host with Acrobat installed collected
the extension's CVEs — CVE-2026-48294, CVE-2024-39379, CVE-2024-20721,
CVE-2024-20709. These are real vulnerabilities in a real product; they were
simply attributed to the wrong artefact, one we do not inventory at all.

Cache prefix bumped to v3: rows cached under the broken filter contain those
matches and would keep serving them.

Detecting the extensions properly is a separate feature — it needs a browser
extension inventory, which syscollector does not provide today.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 14:46:22 +02:00
vulncheckandClaude Opus 5 9dc774e629 feat(scan): add the Autodesk viewers and wire up cvelistV5 for the family
DWG TrueView and Navisworks Freedom were blind spots — viewers, installed far
more widely than the authoring tools and just as exposed, since both parse
untrusted CAD files (CVE-2025-1658/-1659/-1660 are DWFX parsing bugs).

cvelistV5 is not optional for Autodesk: the three CVEs from ADSK-SA-2026-0009
(CVE-2026-16463, -16465, -17550) carry no NVD data at all, so the CPE path
cannot see them at any point. Both paths are wired up for all four products.

Two version notations occur and both compare correctly as numbers: older
records say "2026 .. <2026.1", newer ones "2027.0.0 .. <2027.1.0". AutoCAD and
AutoCAD LT are listed separately in the records but carry IDENTICAL ranges,
verified across CVE-2025-5046, -8894 and CVE-2026-17550; kept apart in case
they ever diverge.

Language packs are the trap here. They ship as separate inventory entries with
the SAME version as the application — "AutoCAD LT 2022 Language Pack" at
24.1.51.0, and Navisworks Freedom 2025 has twelve of them across Deutsch,
Italiano, Français, Português, Español, Korean and more. Each would have
collected the full CVE set of its product, exactly like Adobe's dictionary
pack. Excluded, along with Material Library and Single Sign On Component.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 12:09:31 +02:00
vulncheckandClaude Opus 5 1224224516 feat(scan): detect AutoCAD and AutoCAD LT via release year
A complete blind spot, and the reason is a units mismatch rather than a
missing entry. Autodesk versions its products by release YEAR and NVD lists
them that way too (autodesk:autocad:2018), but the inventory reports the
internal build — AutoCAD LT 2026 installs as 25.1.60.0. Comparing a build
number against a year matches nothing, so adding the product alone would have
changed nothing.

The year is therefore read from the NAME ("AutoCAD LT 2026 - Deutsch
(German)"), the same idea as name_ver for .NET and Python, with a year pattern
instead of a dotted version. Where the name carries no year — "AutoCAD LT
Private" — nothing is scanned: guessing one is worse than missing the product.

LT and full AutoCAD are separate CPE products and stay apart. The companions
are excluded by anchoring, each having its own unrelated version: Autodesk
Access, CER, Genuine Service, Identity Manager, and "AutoCAD Open in Desktop".

cvelistV5 pairs are deliberately not added yet — NVD's AutoCAD coverage looks
complete on inspection, and an unverified second path is a false-positive risk
rather than a gain. Worth revisiting if a CVE turns up that NVD has not
enriched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 10:40:22 +02:00
vulncheckandClaude Opus 5 f64902417e feat(scan): detect the Adobe Creative Cloud desktop apps
Illustrator, Photoshop, InDesign and Bridge were blind spots — nothing looked
at them at all, on software that ships dozens of code-execution CVEs per
patch day (APSB26-42, -51, -40, -32, -39, -89).

Both sources carry them well, so both paths are wired up. cvelistV5 names two
of the four with a "Desktop" suffix ("Photoshop Desktop", "InDesign Desktop")
and two without ("Illustrator", "Bridge"), which is not a pattern — each name
is listed. Verified against CVE-2026-34661, -27289, -27283 and -34630.

The inventory names carry a YEAR the CVE records never mention ("Adobe
Illustrator 2026" at version 30.1), so the year is ignored rather than parsed
— the version field is what gets compared. Two InDesign generations can sit on
one host and both resolve to the same product, each judged on its own version.

Anchored, and the helper components that ship alongside are excluded:
AdobeNotificationClient, Adobe Refresh Manager and AdobeAcrobatDCCoreApp are
not the products and carry unrelated version numbers — the same trap the
dictionary pack sprang last commit.

Index key bumped to v16.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 10:06:50 +02:00
vulncheckandClaude Opus 5 264d54c150 feat(scan): detect VMware Tools, and read wildcard version floors
VMware Tools is the clearest case for running both sources. NVD enriched
CVE-2025-41244 with a CPE and marked CVE-2025-41246, -41239, -22247 and
-22230 "NOT SCHEDULED" — the CPE path can never see those four, on software
that sits on every virtualised Windows endpoint. cvelistV5 has all of them,
under three spellings: VMware/Tools, VMware/VMware tools, and n/a/VMware
Tools. All three are mapped; both paths are wired up.

Adding the product alone would not have worked, because VMware writes its
affected versions as placeholders — "13.x.x.x", and "12.x.x, 11.x.x" naming
TWO release lines in a single field. Parsed as digits the second becomes
(12, 11), which is not a version: a 12.4 host compares BELOW that floor and
drops out of its own range, and so does an 11.3 host. Both would have been
reported unaffected while the record says the opposite.

_wildcard_floor now reads the concrete part in front of each `x` and takes the
lowest — the floor of the whole statement. Versions without a wildcard are
untouched, so nothing else changes. This is general, not VMware-specific;
any CNA writing "9.x" gets read correctly now.

Index key bumped to v15.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 08:35:22 +02:00
vulncheckandClaude Opus 5 f0ae234b85 fix(adobe): match the product, not the add-ons that carry its name
The Adobe entries added in 2dc60be and 3e6c15c matched the product name
anywhere in the string, so they also matched every add-on that mentions it:
"Asian Language And Spelling Dictionaries Support For Adobe Acrobat Reader",
language packs, font packs. Those ship their own version numbers that look
exactly like old Reader builds — the dictionary pack is 23.008.20421 — so each
one collected the full set of Reader CVEs. The tester saw CVE-2026-48373
listed eight times on one host, and not once for the Reader itself.

The name must now START with the product, which drops the "Support For …"
add-ons, plus an explicit exclusion for the packs that do lead with it
(language pack, dictionaries, spelling, font pack). Same pattern as the
Exchange language packs and the Teams add-in — a vendor putting its product
name inside an accessory's name is the recurring shape here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 18:08:38 +02:00
vulncheckandClaude Opus 5 3e6c15cef6 feat(scan): cover Adobe Acrobat via cvelistV5, and stop Defender flapping
Two findings from the same report.

Adobe had no cvelistV5 entry at all — only the CPE path, which meant current
versions depended entirely on NVD having published a CPE yet. cvelistV5 names
the product "Adobe" / "Acrobat Reader": no DC suffix, and no Reader-vs-Acrobat
split, because Adobe stopped shipping them apart — APSB26-63 covers both and
links the same release notes for either. NVD meanwhile keeps the older _dc
spellings alive in parallel, which is why 2dc60be made the CPE path query both
names. Verified against CVE-2026-47965 / -47911 / -47961: affected up to and
including 26.001.21651, and the bound resolves correctly against that build.
Wazuh only detects ancient Reader builds (wazuh/wazuh#29960), so these two
paths are the entire coverage for current versions.

Separately, Defender findings flapped open and closed within one sync. The
auto-resolve ran per MACHINE, but several Defender machines can map to one
asset — a re-imaged or dual-registered device keeps its old machine entry. The
machine that no longer lists a CVE closed the finding; the one that still
lists it reopened it a minute later; next sync the same again (tester:
CVE-2026-66313, patched 13:41, open 13:42, patched 15:00). The CVE sets are
now unioned per asset and resolved once, after every machine has been asked.

Index key bumped to v14.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 15:53:03 +02:00
vulncheckandClaude Opus 5 2dc60becd6 feat(scan): query both CPE names Adobe products are filed under
NVD describes one Acrobat install under TWO product names — acrobat_reader_dc
and acrobat_reader (the "_dc" suffix is the older spelling, kept in use after
Adobe dropped it) — and each name carries DIFFERENT CVEs. Verified against a
current Reader build: 25 CVEs under one name, 6 entirely different ones under
the other. We only ever asked the first, so those six were invisible. Same
split for Acrobat itself.

A registry entry can now name alternate CPEs; their results are merged, first
entry per CVE kept, so a CVE listed under both names stays one finding.

This path carries the weight for Adobe: Wazuh only detects ancient Reader
versions, which is what made the gap easy to miss — findings existed, just
never the current ones.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 14:25:32 +02:00
vulncheckandClaude Opus 5 5d01854130 fix(ui): don't report a sync result we never saw
The Intune sync button threw away the POST's answer. That answer says either
"started" or "already_running" — and in the second case we are watching
somebody else's run (the nightly job, or a second click on a long sync), whose
stats may well be gone by the time we poll. The status endpoint then returns
no result and no error, and the button fell into a branch that simply said
"Sync completed." — announcing a success it had not witnessed, with none of
the numbers.

Attaching to a running sync is now stated up front, and a missing result is
reported as a warning that points at the log instead of a green success.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 13:57:57 +02:00
vulncheckandClaude Opus 5 93139610f6 fix(status): stop slow inventories from reopening what fast ones closed
The three inventories do not see the same moment in time. Wazuh syscollector
is close to live, Intune depends on how aggressively the tenant pushes an
inventory refresh, and Defender TVM's software list trails by days. Any source
could reopen a patched finding immediately, so the normal sequence after
patching a host was: the fast source stops reporting the CVE and the finding
closes — then the slow source runs, still holding its old picture, and reopens
it. Next night the same again. The finding flaps and its change history
describes our polling rather than the host.

A source may now only reopen a finding once its own lag has had time to pass:
three days for Defender, one for the Graph-based ones. Sources that read a
live inventory themselves — wazuh_sync, app_scan, msrc, nessus — keep
reopening immediately, because when they say it is back, it is back.

The grace is a window, not a veto: past its lag the slow source is believed
again, so a host that genuinely regressed still reopens.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 13:47:42 +02:00
vulncheckandClaude Opus 5 29dcbaca5d fix(intune): adopt the current device id when a device is re-enrolled
Wipe an Intune device and deploy it again and Intune issues a NEW device id.
The asset kept the dead one, because both ids were written fill-only — set
once, never corrected. Nothing looked broken: the hostname match still found
the asset, the sync reported it as matched. But every inventory fetch went to
an id that no longer exists and came back empty.

That is worse than it sounds, because an asset with no inventory is skipped by
the entire scan chain — the whole package block, the MSRC pass, the reconcile
and the prune all sit behind `if packages:`. Its findings therefore stay open
forever, and no amount of rescanning can clear them. It also explains why the
last two reconcile fixes appeared to do nothing on such a host: the code they
repaired was never reached.

We are holding the id Intune reports for this device right now, so it is by
definition the current one. A change is logged, since a re-enrolment is worth
seeing in the log.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 11:37:03 +02:00
vulncheckandClaude Opus 5 c8c0ffa4ab fix(msrc): reconcile findings whose parent name is another vendor's product
e02d303 made the reconcile resolve the parent package_name instead of
matching it literally, which fixed findings the app scan had named "Microsoft
Edge". It did not fix the case one step further out: a CVE that hits Chrome
AND Edge gets its parent name from whichever scanner saw it first, and that is
usually "Google Chrome" — which resolves to no MSRC product at all, since
Chrome is not one. The finding was skipped again, and stayed open with Edge
long past its fix (tester: CVE-2026-16807).

The per-package rows do carry the product this pass knows about, so they are
consulted when the parent name leads nowhere. A finding is still only touched
when something on it belongs to a product this pass evaluated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 11:01:54 +02:00
vulncheckandClaude Opus 5 45248001ec fix(ui): report what the Intune sync did, same as the app scan
The backend log carried OS-EOL and mobile-EOL findings, reactivations, and
Defender's resolved count; the UI showed none of them. A sync that closed 241
Defender CVEs and surfaced 44 mobile EOL issues read as '144 devices, 144
matched, 0 created, 380 app findings' — nothing about the work it had done.

Non-zero counts are appended now, zeros stay hidden so a quiet sync stays
short. Defender gets its own group: new, resolved, unmatched devices.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 11:00:50 +02:00
vulncheckandClaude Opus 5 119e64fc05 fix(ui): report what the scan did, not only what it found
The button said "160 assets, 0 findings (0 new)." for a run that had just
auto-resolved 345 stale findings and pruned hundreds of package rows. That
reads as "nothing happened" — indistinguishable from a scan that genuinely
did nothing, and it is exactly the run an operator wants confirmation of after
patching an estate.

The numbers were in the backend log the whole time; the button simply never
showed them. Now it appends whatever is non-zero: auto-resolved findings,
pruned package rows, MSRC findings. A quiet run stays quiet — nothing is
appended when all three are zero.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 17:42:21 +02:00
vulncheckandClaude Opus 5 42a3831abb fix(scheduler): run enrichment after the scans, not before it
The nightly chain was out of order in a way that cost a full day of accuracy.
CISA Vulnrichment — which corrects CVSS and SSVC — ran at 03:00, while the
jobs that CREATE findings ran after it: EOL at 03:15, M365 at 03:20, the app
scan at 03:25 and the MSRC scan at 05:10. Everything those four produced
therefore waited until the NEXT night to be corrected, so a fresh critical
spent 24h at whatever placeholder score it was born with.

URS had the same problem one level up: it recomputed at 04:00 from a picture
that was missing the MSRC findings still an hour away.

Reordered into the dependency chain it always implied — inventory (02:00-02:30),
findings (03:00-03:50), enrichment (04:30-04:50), aggregates (05:10-05:25),
housekeeping (05:40). Same jobs, same spacing, nothing added.

Documented in the README, because the ordering is a constraint rather than a
preference: a new job that produces findings has to sit before 04:30.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 11:30:59 +02:00
vulncheckandClaude Opus 5 fdec6a7446 chore(eol): drop the dead Microsoft Edge mapping
endoflife.date carries no record for Edge — the 'microsoft-edge' slug 404s,
and no entry in their catalogue matches 'edge' at all — so every EOL check
spent a request to find that out.

Nothing is lost: Edge follows the Modern Lifecycle Policy and has no
end-of-life date while it stays current, which makes 'is this version too old'
a patch question. The CVE scan already answers that one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 21:16:01 +02:00
vulncheckandClaude Opus 5 b2431ddcea fix(scan): drop package rows the scan no longer confirms
A finding is one row per (cve, asset) but lists every affected product, and
two products hit by the same CVE patch on entirely separate schedules.
CVE-2026-17733 hits Chrome and Edge: the tester patched Chrome to
151.0.7922.72 while Edge stayed on 150.0.4078.105, and the finding kept
listing Chrome at its OLD version as still affected. Reading it, you cannot
tell which half is actually outstanding.

The cause is that a package row is only ever written while the product is
detected. Once it is patched, nothing touches the row again, so it keeps
whatever it had — the stale version included. last_seen_at was added for
exactly this and the comment promised a pass that reads it; that pass was
never built. This is it.

Only rows on findings this run re-examined are pruned, and never the last one:
a finding with no packages reads as "we know nothing" rather than "this
product is fixed" — and if it really was the last, the finding itself is stale
and the existing reconcile closes it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 21:14:16 +02:00
vulncheckandClaude Opus 5 ad85ccd98d fix(ui): wrap long package names instead of clipping them
'Microsoft Exchange Server Subscription Edition' lost its distinguishing half
to an ellipsis — and that half is exactly what separates it from plain
'Microsoft Exchange Server', which sits on the very same host with a
different build. The name now wraps, and carries a title attribute for the
hover.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 19:26:32 +02:00
vulncheckandClaude Opus 5 3f6a7dc3e5 fix(scoring): score CVEs the vendor never gave a CVSS
Both scores multiply the CVSS in, so a CVE without one came out at zero:
priority 0, CPR "—". A Critical Chrome CVE therefore sorted below a medium
that happened to carry a score, in every priority-ordered list and in the
digest mail. Google states "Chromium security severity: Critical" in prose and
NVD frequently never scores those records, so this is not a rare corner.

The severity band's FLOOR now stands in when no score exists — critical 9.0,
high 7.0, medium 4.0, low 0.1. The floor, not the middle: the estimate can
only ever understate a real score, never inflate one. An unscored critical
lands at CPR 74 against 78.8 for a real 9.8, and above a scored medium, which
is the ordering that was wrong. A real CVSS always wins over the estimate.

Also silences the Settings page for read-only users: every
/api/v1/settings/<key> is admin-only and the page requests a dozen on open,
one 403 each. Same central gate as /auth/users.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 15:59:07 +02:00
vulncheckandClaude Opus 5 ddf0a373df feat(scan): detect Exchange Server Subscription Edition
A complete blind spot: Wazuh does not detect it (wazuh/wazuh#36200), so an
Exchange SE host reported nothing at all. Both of our sources carry the data —
cvelistV5 states "15.02.0.0 .. lessThan 15.02.2562.043" for CVE-2026-42897, and
NVD has the matching CPE — so this only needed a registry entry on each path.

The leading zeros that make this awkward elsewhere are irrelevant here: Wazuh
reports 15.2.2562.27 and the record says 15.02.2562.043, and both sides are
parsed as numbers rather than compared as strings. That difference is exactly
what the Wazuh issue describes as the cause on their side.

The name regex is ANCHORED and exact, which is the whole risk of this change.
One Exchange host lists five kinds of row — the product itself, "Microsoft
Exchange Server", a dozen language packs (all still on the RTM build), and
"Hotfix Update for Exchange Server Subscription Edition (KB5066373)" whose
version field is the literal "1". Only the product entry carries the real
build; the KB row would compare below every fix ever published and flag the
host permanently.

Index key bumped to v13. tests/test_exchange_se.py pins all five row types
plus the version boundary.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 14:09:45 +02:00
vulncheckandClaude Opus 5 4879610dea fix(ui): stop asking for data a read-only user may not have
Opening Assets or Vulnerabilities as a read-only user fired /auth/users on
every page load, which the backend correctly refused — a steady stream of
403s in the log for a call whose result that role can never use. The
assignment dropdowns it feeds are already hidden for readonly.

Gated centrally in the API client rather than by rearranging three pages:
the role is picked up from whichever /auth/me response passes through the
response interceptor — AppShell issues one on startup — and a request
interceptor drops the call before it is sent. No page changes, no extra
round-trip, and until the first /auth/me lands the role is unknown and
everything behaves exactly as before.

The rejection carries no HTTP response, so it cannot trip the 401
refresh-retry path, and all four callers already fall back to an empty list —
which is the correct answer for this role.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 14:06:11 +02:00
vulncheckandClaude Opus 5 793e840665 feat(scan): index CVEs that name a single exact version
A record stating one affected version and no bound at all was dropped
entirely, so the CVE never entered the index and nothing but Defender could
report it. CVE-2026-14266 is exactly that shape: "7-Zip 20.01, status
affected", nothing else.

They were dropped to avoid over-matching, which is a real risk — an unbounded
floor swallows every version above it. A single exact version has no such
problem when it is read as the closed range [v, v]: it matches that one
version and nothing else, which is precisely what the record claims. 20.01
hits, 20.01.0 hits (same version, written differently), 19.00 and 26.01 do
not.

Index key bumped to v12; the cached index has none of these entries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 13:52:09 +02:00
vulncheckandClaude Opus 5 099bce723e fix(exposure): count a service once, not once per socket
Reading ESTABLISHED sockets as evidence (943569f) exposed a dedup key that
was too narrow: it included the protocol, which was harmless while only
listeners counted, but a busy host has many established sockets on the same
service port, and tcp vs tcp6 already made a single listener look like two
services.

The tester's domain controller listed "RDP :3389" four times and LDAP four
times. Since each additional entry adds 40% of its weight, the exposure score
inflated to 100 for what is one RDP and one LDAP service. Deduplicating by
port alone fixes both: one service, one entry, one weight.

Also feeds Recent Critical from four narrow server-side queries (critical,
high, KEV, EUVD) instead of filtering the 300 newest rows in the browser. A
batch of low-severity CVEs — Chrome publishes dozens at once — fills that
window completely and empties the widget, which no amount of extra depth
fixes; only filtering before the limit does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 13:49:42 +02:00
vulncheckandClaude Opus 5 e02d3035bb fix(msrc): reconcile findings the app scan named differently
The Edge false positive survived every scan because its own reconcile could
not see it. Whichever scanner creates a finding owns package_name, and the app
scan writes the inventory's wording — "Microsoft Edge" — while the MSRC
reconcile filtered on the exact curated label, "Microsoft Edge
(Chromium-based)". The row matched nothing, so it was never even considered
for closing: the tester's host ran .105, long past the .99 fix, and the
finding stayed open through repeated app scans and MSRC runs.

Findings are now matched to a product the way the SCAN matches them — through
resolve_package / resolve_os — instead of by exact label. The exact-name path
stays for rows this scanner wrote itself, and the OS pass keeps working
because resolve_os is tried too; without that, switching to package matching
alone would have stopped every Windows OS finding from closing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 18:23:59 +02:00
vulncheckandClaude Opus 5 943569f12f fix: compare only comparable builds, and read inbound sockets as evidence
Two unrelated defects, both found on the same test estate.

MSRC states the Teams fix as 25060212043 — one eleven-digit stamp — while
Teams itself ships 26183.1003.4002.4460. Python compares those tuples element
by element, so 26183 < 25060212043 came out True and a current Teams was
reported vulnerable (CVE-2025-49731). Builds with different segment counts are
not the same numbering scheme and no ordering between them means anything, so
they are no longer compared at all. The product name filters were NOT at
fault here: "Microsoft Teams Meeting Add-in for Microsoft Office" and
"Microsoft Teams VDI Dim-plugin" both resolve to nothing, as intended.

Separately, both port-based scorers required a LISTENING socket, and Wazuh's
syscollector does not always report one. On a Windows Server 2025 domain
controller netstat showed 3389 ESTABLISHED while syscollector returned no
listening entry at all, so RDP scored zero exposure and the DC role went
undetected on a live domain controller. An ESTABLISHED socket whose LOCAL
port is a known service port is an inbound connection, which proves the
service is running just as well. Outbound connections carry an ephemeral
local port, so they cannot be mistaken for one — and only ports that are
actually asked about qualify, never an arbitrary high port.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 18:22:43 +02:00
vulncheckandClaude Opus 5 5da1efd3fb fix(ai): stop truncating the answer, and ask for JSON properly
max_tokens was 2000 for every call. Ten prioritised findings with a
justification each, plus the strategy paragraph, does not fit — the reply was
cut off mid-JSON, the parser failed, and the audit reported "no
recommendations" with nothing indicating the answer had simply run out of
room. Reasoning models never stood a chance: deepseek-reasoner spends that
same budget thinking before writing a character. Raised to 8000.

Truncation is also no longer silent. finish_reason "length" (stop_reason
"max_tokens" on Anthropic) is logged with the provider, model and limit, so
the next occurrence names itself instead of looking like an empty answer.

And the JSON is now requested through response_format on the providers that
honour it (OpenAI, DeepSeek, OpenRouter, Groq, Mistral) rather than hoping
the prompt talks the model into valid syntax. Providers that would reject the
field are not sent it.

Both call paths also stopped assuming the response shape: an empty choices or
content array raised IndexError inside the client, surfacing as a generic
500 rather than as the empty answer it was.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 12:24:01 +02:00
vulncheckandClaude Opus 5 77317a5435 fix(ai): show what the model actually replied when parsing fails
Running an AI Audit that produced no recommendations looked exactly like
never having run one — "No AI recommendations available yet. Try running an
AI Audit above." — which is the opposite of what happened and leaves nothing
to act on.

The backend already handles a model that ignores the JSON schema: it puts the
raw reply in global_strategy and returns an empty list. The empty-list branch
ran first and swallowed it. That reply is now shown, labelled as what it is,
with the hint that weaker models fail this way.

The error hint was stale too. It told everyone to check Infomaniak settings
and set the model to 'llama3' — wrong advice on DeepSeek, OpenAI or
Anthropic, pointing at a setting that was already correct. It now points at
the AI Integration screen and at verifying the model still exists.

Also dropped the window.location.reload() that sat on the empty state: it
threw away the result the user had just waited for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 12:19:09 +02:00
vulncheckandClaude Opus 5 378d5ccda9 fix(wazuh): apply the empty-agent reconcile to the collective sync too
01cf91f fixed the wrong entry point. There are two Wazuh sync
implementations, and the one behind the "Sync Data" button —
run_wazuh_vulnerability_sync — carries its own copy of the same guard, which
I left untouched. So the fix changed nothing for the operator: the tester
stopped the agent, restarted it, ran a sync, and all 348 Windows OS findings
stayed open with wazuh as their only source.

The collective sync now defers empty agents the same way and reconciles them
once the run is over, when the total CVE count proves whether the API was
answering at all. Findings closed this way are counted in vulns_patched, so
the result message reports them instead of silently showing "Patched: 0".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 12:16:14 +02:00
vulncheckandClaude Opus 5 3bf483a70a fix(ai): rank the audit input correctly and tell the model what matters
The ordering was inverted at the top. `ORDER BY cvss_score DESC` puts NULLs
first in Postgres, so every finding WITHOUT a score led the list — and fresh
Chrome CVEs carry no CVSS at all. The model was handed those as the most
urgent items, ahead of a scored 9.8, which is exactly the "wrong order"
showing up in the audit. It now ranks by the scores the product computes for
this question (priority, then CPR, then CVSS), nulls last.

Scope was wrong too: the query hit the table raw, so it ranked findings on
decommissioned assets and treated EOL- pseudo-CVEs as vulnerabilities. It now
shares the scope rule the reports use.

The model also could not do the job it was asked to do. It received CVE id,
hostname, CVSS and package name — less than the dashboard shows — with no way
to see known exploitation (KEV/EUVD), exploit probability (EPSS), how long a
finding has been open, or whether a fix even exists. Those are what make a 7.5
outrank a 9.8, so they are in the payload now, and the prompt says how to
weigh them instead of leaving it implied.

Two smaller mismatches: the prompt asked for "the top 10" while limit
defaulted to 20 — so the model was told to discard half its input, and at
limit<10 it was asked for more items than it was given. And the same CVE
across 30 hosts filled the list with one problem thirty times; entries are now
deduplicated by CVE, with over-fetch so the dedup does not starve the list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 12:13:01 +02:00
vulncheckandClaude Opus 5 8257110764 feat(ai): load the model list from the provider instead of hardcoding it
The model dropdown was a hand-written list per provider, so it was wrong the
day after every release: DeepSeek showed V3 while V3.2 was current, Anthropic
still seeded claude-3-5-sonnet-20240620, Gemini seeded 1.5-pro. Worse, picking
a model the provider had since retired failed only later, at generation time,
with an error that pointed nowhere near this screen.

A "Load models" button next to the dropdown now asks the provider what it
serves right now — OpenAI, DeepSeek, OpenRouter, Groq, Mistral (all
OpenAI-shaped /models), Anthropic, Gemini, and Ollama's local /api/tags. The
live list replaces the static options once loaded; the static ones remain as
the fallback for providers with no listing endpoint and for before the first
fetch, and the per-provider defaults are now clearly seeds, not choices.

The key is sent in the request body, never a query string — URLs end up in
proxy and access logs. An empty key falls back to the saved one so the list
loads without retyping it, and provider errors are surfaced verbatim (401
reads as "rejected the API key", not a generic failure). Switching providers
clears the list so one provider's models are never offered under another.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 11:49:22 +02:00
vulncheckandClaude Opus 5 1d1caf973b fix(reports): correct the numbers and rank what claims to be ranked
Every export queried the vulnerabilities table raw, so all four disagreed
with the dashboard they were exported from, in the same three ways:

- Findings on decommissioned and inactive assets were counted. Those hosts
  are deliberately hidden on screen, and the gap grows with every machine
  retired.
- EOL- and NESSUS-PLUGIN- pseudo-CVEs were counted as CVEs. They are real
  work items but they are not vulnerabilities, and they have their own
  surface in the UI.
- The executive summary counted severities across ALL statuses, so a
  remediated estate still reported hundreds of "Critical Severity" — the
  patched ones. A reader takes those as outstanding work. They are now
  explicitly labelled and scoped to open findings.

"Top Priority Risks" was the worst of it: a LIMIT 5 with no ORDER BY, so the
database returned any five critical rows it liked under a heading promising
the five that matter most. It now ranks by priority score, then CVSS.

Two exports could also take the server down on a large estate. Patching
Progress put every patched finding of the last 30 days in the table — one
reconcile here closed 14703 at once — and the ISO report loaded every
non-compliant finding into memory just to call len() on it, while printing
20. Both now count in the database and list a bounded, ordered page. The CSV
streams in batches instead of materialising the whole file first.

The scope rule lives in app/services/report_scope.py so the reports cannot
drift apart again, and so it can be tested without the web stack —
tests/test_report_scope.py asserts it against the emitted SQL.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 11:42:43 +02:00
vulncheckandClaude Opus 5 eb595b9e7b fix(dashboard): let Recent Critical see CVEs that carry no CVSS
The widget judged criticality on the score alone, so a CVE without a CVSS
could never qualify no matter how severe. Chrome CVEs are exactly that case —
Google ships no metrics block and states "Chromium security severity:
Critical" in prose, and NVD frequently never scores them at all. The tester's
dashboard read "No data" under Recent Critical while a batch of fresh Chrome
CVEs sat right next to it in Newly Published.

Where a CVSS exists it still decides, and severity is kept in sync with it.
Only when there is no score does the vendor's own severity get a say. Reading
the severity out of Chrome's prose landed in 6b3744e; this is the half that
makes it visible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 11:35:21 +02:00
vulncheckandClaude Opus 5 eca15f09be chore(ui): tagline reads Vulnerability Intelligence
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 11:33:42 +02:00
vulncheckandClaude Opus 5 b5ade33835 chore(ui): name the product what it does in the sidebar
'Enterprise Security' says nothing; 'Vulnerability Management' says what the
tool is. The full 'Enterprise Vulnerability Management' overflows the 288px
sidebar at this tracking, so the tagline drops the marketing word rather than
wrapping to two lines.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 11:33:02 +02:00
vulncheckandClaude Opus 5 ac9700b32c fix(scan): bump the cvelistV5 index key for the Checkmk pairs
New pairs change what the index CONTAINS, and a cached index built by the
previous version has no checkmk bucket at all — so the scan would have kept
serving a stale index and found nothing until the key changed anyway.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 11:24:27 +02:00
vulncheckandClaude Opus 5 8ef4a5ce0d feat(scan): detect Checkmk agent CVEs
The Checkmk agent was invisible to every path, and the reason was one
character: it numbers patches inside the version string (2.4.0p12), which the
dotted-numeric rule in _clean_version rejected outright, so the version was
discarded before any source was consulted. Wazuh does not detect it either
(wazuh/wazuh#35646), which left it a complete blind spot.

Nothing else had to change — _vtuple already reads 2.4.0p12 as (2,4,0,12), so
the comparisons were correct all along. Both sources state their bounds in the
same notation (lessThan "2.4.0p13"), and both are now wired up: the cvelistV5
pairs for the branch-bounded ranges, the NVD CPE registry entry as the second
route.

The exclusion that made the rule strict stays intact: Linux distro versions
(1:3.2, 4.6.5-3.el8, 2.43.0.windows.1) are still rejected — they belong to
Wazuh and pushing them at NVD produces noise. tests/test_checkmk_version.py
covers both halves, including that an older release branch answers only for
itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 11:24:05 +02:00
vulncheckandClaude Opus 5 01cf91f764 fix(wazuh): close findings on hosts Wazuh reports as clean
A host that Wazuh no longer reports any CVE for kept every finding it ever
had. The tester's ise-dc01 showed 0 Critical / 0 High / 0 Medium in Wazuh
while 348 findings sat open here, all "first detected by WAZUH" — the app
scan could not help, since those CVEs are Wazuh's alone.

The cause was a guard doing its job too bluntly. An empty answer means one of
two opposite things — the host really is clean, or Wazuh could not answer —
and the per-agent sync cannot tell them apart, so it refused to close
anything rather than risk mass-patching an estate from a transient blank.

The distinction exists one level up: a run in which OTHER agents returned
CVEs proves the API is answering, which makes this agent's emptiness real.
So the empty agents are now collected during the run and reconciled at the
end, once that is known. If EVERY agent comes back empty, that is an outage
pattern, not an estate that patched itself overnight — nothing is closed, and
it is logged as such.

Findings another scanner still reports keep their other sources and stay
open; only Wazuh's own claim is withdrawn. The single-asset sync keeps the
old careful behaviour: one agent alone says nothing about the API's health.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 10:26:21 +02:00
vulncheckandClaude Opus 5 6b3744e569 fix(severity): read the Chromium severity Google states in prose
Chrome CVE records carry no metrics block whatsoever — Google puts the
severity in the description instead: "… (Chromium security severity:
Critical)". Every fresh Chrome finding therefore landed on the neutral
'medium' placeholder, so a Critical sandbox escape sorted level with a Low UI
glitch, both in the queue and in the daily digest mail. For a team that
triages by severity that is worse than no data.

The severity word is now read from the description when no metrics block
exists. No score is invented — only what the vendor stated, and a real CVSS
always wins over the prose. Once NVD publishes a score, the existing
CVSS-sync takes over as before.

Existing rows heal on the next scan: the upsert already lifts a finding off
the medium placeholder when a source reports a better severity.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 09:06:15 +02:00
vulncheckandClaude Opus 5 3f290ebbb4 fix(scan): filter published-app stubs once, at the inventory source
Citrix published-app delivery leaves a registry stub for software that is NOT
installed on the box: a self-chosen product name and a placeholder version
("SAP Business Client 1.0", vendor "Delivered by Citrix"). A 1.0 reads as
ancient against any CVE data, so whatever matches it reports decade-old CVEs.

The filter existed, but each scan path applied it for itself — so every path
added later started out unfiltered. Three had it, four did not, including the
SAP path added yesterday, which is how a published-app stub turned into a
finding again.

Filtering the fetched inventory once, before any path sees it, is the only
version of this that stays true as paths are added. The EOL check fetches its
own inventory and now filters it the same way; a placeholder 1.0 reads as
end-of-life there too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 09:04:35 +02:00
vulncheckandClaude Opus 5 72932a8f3e fix(defender): retry on 429 instead of failing the sync
MDE allows roughly 100 calls a minute and the vulnerability walk makes one
call per machine, so any sync of real size runs into 429 — the more so when
an app scan is running alongside it, which is exactly what the tester did
before seeing a wall of "429 Too Many Requests".

The client treated 429 as a hard error, so one throttled call threw away
everything the sync had left to do. A 429 is "come back shortly", not a
failure: it now retries, waiting as long as Microsoft's Retry-After header
asks and backing off geometrically (capped at 60s) when the header is missing
or unparseable.

The Graph client already did this for Intune; only Defender was missing it,
which matches the log — every throttled call came from
api.securitycenter.microsoft.com.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 16:06:10 +02:00
vulncheckandClaude Opus 5 06715d2b15 feat(sap): match SAP from cvelistV5 by release + patch-level range
NVD enumerates one CPE per SAP patch level, which can only ever describe the
levels that existed when the record was written — a host on a newer level
falls through. cvelistV5 states the bound instead ("7.70 PL0" ..
"7.70 PL11"), so it keeps answering as SAP ships more levels.

The records contradict themselves, and the resolution is the whole point of
this change: CVE-2023-32113 carries BOTH "<= 7.70" (the entire release) and
"7.70 PL0".."7.70 PL11". A host on 7.70 PL26 is affected by the first
statement and patched by the second. The patch level is the precise one, so
it decides for its own release, and the release-wide bound only answers for
releases that have no patch-level entry at all. Entries are therefore weighed
per CVE, not row by row.

Both vendor spellings are matched — records days apart say "SAP SE" and
"SAP_SE".

Index key bumped to v10 (new sap bucket). tests/test_sap_cvelistv5.py pins
the contradiction, the fallback, and the unreadable-level case against the
verbatim version objects.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 15:01:03 +02:00
vulncheckandClaude Opus 5 d1badf70ed fix(sap): require the release to match, not just the patch level
With the real syscollector rows in hand the level-only match turns out to be
a false-positive generator. Every SAP release carries the same patch level
NUMBERS — NVD lists gui_for_windows 7.70:patch_level17 right next to
8.0:patch_level1 — so the tester's host (SAP GUI for Windows 8.00 64bit,
Patch 17) matched every 7.70 CVE that happens to have a patch_level17 entry.

The release is now compared as well, and numerically: Wazuh reports "8.00"
where NVD writes "8.0", so a string compare would have rejected the host's
own release.

The parser is confirmed against the verbatim inventory strings, which differ
per product — Business Client states the level in the version field
("8.00 PL26") while SAP GUI states a compilation there and puts the level in
the NAME ("SAP GUI for Windows 8.00 64bit  (Patch 17)"). Both are pinned in
the test, along with "Compilation" not reading as a level despite containing
the letters p-l.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 14:57:17 +02:00
vulncheckandClaude Opus 5 86da96fc06 fix(sap): drop Analysis for Microsoft Office from the scanned products
It was never in scope — it came in from the Wazuh issue's product list — and
it does not fit the patch-level model the other two SAP products use: AfO
ships 2.8.x builds and states no patch level anywhere, so the match could
never fire for it.

Worse is what NVD says about it. CVE-2021-38175 lists
`cpe:2.3:a:sap:analysis_for_microsoft_office:2.8:*` with a wildcard update
field and no range whatsoever, so any 2.8 build would count as affected
forever, no matter how many patches it has. cvelistV5 carries the honest
bound ("< 2.8") but the cvelistV5 path has no SAP pairs yet, so there is no
correct source to scan this product with today.

A test now pins that a wildcard update field never matches, for any patch
level — the guard that keeps this class of CPE from becoming a false positive
if another SAP product is added later.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 14:55:25 +02:00
vulncheckandClaude Opus 5 1f0c141411 feat(scan): detect SAP desktop CVEs by patch level
SAP GUI, Business Client and Analysis for Office were a complete blind spot:
cvelistV5 carries no CPE for them, and Wazuh misses them too
(wazuh/wazuh#30334), so nothing in the pipeline ever looked at them.

NVD has the data, but not as a range — SAP enumerates ONE CPE per patch
level in the update field (cpe:2.3:a:sap:gui_for_windows:7.70:patch_level4),
verified against CVE-2023-32113 with 15 entries and CVE-2021-38150 with 68.
So "affected" is set membership, not a comparison, and the existing range
check cannot express it: it compares the base release, which is identical for
a fully patched host and an unpatched one.

The level is read from either inventory string ("7.70 PL 12" in the version
field, "Patch 4" in the product name — both spellings appear in the Wazuh
issue). When no level can be read the product is SKIPPED, not scanned: the
base release alone matches every CVE ever filed against it, so one unreadable
string would otherwise produce a page of false positives. The patch level is
part of the cache key, so PL 12 can't be served PL 3's answer.

tests/test_sap_patch_level.py pins both halves against the real criteria
strings, the unknown-level case included.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 13:10:09 +02:00
vulncheckandClaude Opus 5 8ce476cd17 fix(defender): fill package and installed version on OS-level findings
Defender TVM reports the CVE, never the build it sits on, and for an
OS-level CVE it omits the software label entirely. Those findings rendered
with an empty package and an empty "Installed" — reading as "we know
nothing" while the asset record had carried the OS and its version all
along (tester: CVE-2026-64726 across the iPhones).

Fall back to the asset's own OS and os_version when Defender names no
software. Existing rows are filled only where the column is still empty, so
a scanner that actually inspected the software keeps the last word.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 13:07:36 +02:00
vulncheckandClaude Opus 5 b0254bb497 fix(scan): run the app CVE scan as a background job
Same failure the EOL check had before dabec89: the scan ran inside the
request, so a full estate — plus the cvelistV5 and MSRC index builds on first
use — outlived the browser's HTTP patience. The GUI showed "Backend
connection failed" while the backend ran happily to completion, with no way
to tell whether anything had happened.

POST /app-cve-scan/start spawns the run on its own DB session and returns 202;
GET /app-cve-scan/status reports running/result/error, and the button polls it.
Starting a second run while one is active attaches to the running job instead
of queueing a duplicate. The synchronous POST /app-cve-scan stays for API
clients.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 13:06:34 +02:00
vulncheckandClaude Opus 5 f26f7baf94 feat(scan): detect Apple OS CVEs from cvelistV5
Apple OS findings could only ever come from NVD, and NVD's Apple enrichment
lags: CVE-2026-28911 sat at "UNDERGOING ENRICHMENT" with no configuration at
all, so the CPE path had nothing to match and the cvelistV5 path refused
every non-Windows asset outright. Both iPhones and Macs therefore showed
these CVEs only where Defender happened to report them — with no package or
installed version, since Defender carries neither.

cvelistV5 has the data on day one (Apple / macOS / lessThan 14.8.8), so the
OS scan now reads it there too.

Apple states each parallel release train as its own ZERO-floor range on the
same CVE — CVE-2026-64721 carries lessThan 14.8.8, 15.7.8 AND 26.6 — so a
Sonoma 14.7 host matches all three at face value and the finding would claim
"fixed in 26.6", an upgrade that host will never receive. The major version
picks the train, the same role _win_family plays for Windows builds.
tests/test_apple_os_train.py pins that behaviour.

Index key bumped to v9; the cached index has no apple-os bucket.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 13:04:07 +02:00
vulncheckandClaude Opus 5 e983b18d38 fix(msrc): false positive from a Chromium fix pinned on an Edge finding
CVE-2026-15120 is a Chromium CVE that Edge ingests, so the CPE path wrote
Chromium's fixed build (150.0.7871.114) onto the finding first. MSRC — the
only source that knows the Edge build that actually ships the fix
(150.0.4078.65) — could never correct it: fixed_version was written
fill-only.

A host on Edge 150.0.4078.83 was therefore patched weeks ago and still showed
OPEN, because .83 compares below .7871.114. Verified against the CVRF: no
MSRC document carries a 150.0.7871.* FixedBuild for any Edge product, so the
value could only have come from the Chromium side.

MSRC is authoritative for its own products' fixed build, so it now corrects
the row — the same reasoning that already made the installed version a
refresh instead of a fill-only write, one field over. The reconcile closes
these findings on the next scan.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 12:59:51 +02:00
vulncheckandClaude Opus 5 786071386f fix(ui): show detection provenance on every affected package
The "via …" line under an affected package was missing or wrong in three
constellations, so a finding's provenance could not be read off the detail
page:

- A package confirmed by two scanners kept only the one that wrote first —
  record_affected_package never merged `source` on an existing row.
- Findings that only carry the parent summary (OS-level rows, pseudo-CVEs,
  data written before per-package tracking) fell into a fallback block that
  renders no source at all.
- Rows with a NULL source rendered nothing instead of falling back to the
  finding's own sources.

Package sources are now cumulative ("app-scan,msrc", widened to VARCHAR(60)
in migration 039), the API synthesises a package entry from the parent
summary when no child rows exist, and the UI renders every source as a chip
— never blank.

Also makes HOST / PACKAGE / ASSIGNED TO sortable; the assignee sorts by
username, not by id.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 16:21:07 +02:00
vulncheck 8c28167ebd fix(msrc): OS reconcile missed the Wazuh spelling of package_name
Same host, two findings, opposite outcomes — the tester's screenshot:
  CVE-2026-50518  first seen by APP-SCAN  package "…Server 2016 Datacenter"        -> patched
  CVE-2026-49798  first seen by WAZUH     package "…Server 2016 Datacenter 10.0.14393.9234" -> still open

Three writers produce three spellings of package_name for the SAME OS
finding: the MSRC catalogue name, the asset's OS string (app scan), and the
asset's OS string with the build appended (Wazuh). The previous fix covered
the first two by exact match, so Wazuh-created rows still never matched and
kept msrc as an unreachable last claimant.

resolve_stale_os now walks the asset's open msrc rows itself: exact match on
the labels PLUS a prefix match on the asset's own OS string, which catches
anything appended to it. The prefix is safe because it uses the FULL OS
string — "…Server 2012 Standard" cannot prefix "…Server 2012 R2 Standard" —
while the MSRC catalogue label is deliberately not used as a prefix, since
"Microsoft Windows Server 2012" would. Non-OS products on the same host
(SharePoint, Chrome) are unaffected. Closing is audited with host and build.
2026-07-28 11:29:12 +02:00
vulncheck 206c97f14a fix(msrc): OS findings never closed — reconcile matched only MSRC's own label
Root cause, visible in the tester's screenshots: the asset inventory shows
Server 2025 on 10.0.26100.33158 and Server 2016 on 10.0.14393.9339 — both
exactly the fixed build — while the findings still read installed .32995 /
.9234 and stayed OPEN with msrc as the only remaining source.

_resolve_stale matches package_name EXACTLY against the MSRC catalogue
label (Microsoft Windows Server 2025). But an OS finding first seen by the
app scan or Wazuh carries the ASSET's OS string instead (Microsoft Windows
Server 2025 Standard, ... 2016 Datacenter), because cvelistv5.scan_asset_os
labels rows with asset.operating_system. So the query never returned those
rows: the other scanner retracted its source once the host was patched,
msrc was left holding a claim it could not reach, and the finding stayed
open forever.

_os_labels() now considers both spellings — the MSRC label AND the asset's
own OS string. Deliberately exact strings, not a prefix match, since
Microsoft Windows Server 2012 prefixes ... 2012 R2 and a prefix rule would
close a different release's findings.
2026-07-28 09:47:35 +02:00
vulncheck d0c555d5ff fix(msrc): run the OS reconcile from the app scan; refresh the installed build
1) MSRC OS findings never closed after a host was patched. The OS scan AND
   its reconcile both live in run_msrc_scan (its own nightly job / button),
   while the app scan only ever called the PACKAGE pair — so pressing
   'App CVE Scan' after patching left every MSRC OS finding open and looked
   like nothing had happened. The app scan now runs the OS scan plus the new
   resolve_stale_os(), scoped to the asset's own OS product label so it
   cannot touch SharePoint/Edge findings it never evaluated.

2) MSRC wrote package_version fill-only, so a finding kept the build seen at
   first detection and never showed the host's current one — the same defect
   the app scan had with Firefox ('Installed 150.0.3'). Now refreshed on
   every re-detection, like every other scanner.
2026-07-27 14:54:29 +02:00
vulncheck 5df35f43cf feat(scan): detect Oracle Java CVEs from cvelistV5
Java was in no scanner registry at all — a complete blind spot. NVD leaves
these CVEs 'Awaiting Enrichment' (no CPEs, no affected block), so cvelistV5
is the only structured source, confirmed across CVE-2026-60526 / -21925 /
-47057 / -62574.

Three things Java needs that no other product did:

- Identity via BOTH routes the records use: the vendor/product block
  ('Oracle Corporation' / 'Oracle Java SE') and the CPE product
  (oracle:java_se, plus the older oracle:jre / oracle:jdk — Java SE IS the
  JRE, and the same CVE can be filed either way).
- Version from the NAME, not the field: Wazuh/Intune report 'Java 8 Update
  441' with an ARP build of 8.0.4410.7, which maps to nothing. Parsed to
  (feature, update); '1.8.0_471' and '8u491' spellings both handled.
- Bare affected versions get indexed. Oracle states 'version: 8u491,
  status: affected' with no range, so the normal range extractor yielded
  nothing for these records.

Matching rule is CUMULATIVE — installed <= affected, within the same
feature release. Oracle only names the current supported update, but older
ones carry the same flaw; validated against Defender TVM, which reports
CVE-2026-62574 (affected 8u491) on hosts running 8u102 and 8u191. Java 11
is never matched by an 8u CVE.

Verified against the live CVE-2026-62574 record: 8u102/8u191/8u441/8u491
all flagged, newer safe. Index bumped to v8 so the new key is picked up.
2026-07-27 14:48:08 +02:00
vulncheck 639fd00d30 fix(severity): keep severity in sync with the CVSS score
Tester: MSRC-only Edge findings sat at MEDIUM while showing CVSS 9.6 /
8.8 / 8.3. Sources without a score seed a neutral placeholder (MSRC
publishes a CVSS for only a minority of its CVEs), and whatever filled
the score afterwards — the correction cascade, sibling inheritance —
never revisited the severity. App-scan-detected rows looked right because
they arrive with a score in the first place.

Vulnerability.sync_severity_with_cvss() derives severity from the score
and is called by refresh_scores(), which every sync/enrich/override path
already invokes, so the fix applies everywhere rather than in one
scanner. Rows whose severity an operator pinned manually are left alone.
2026-07-27 14:43:52 +02:00
vulncheck 03233d6ed1 docs: bring both READMEs up to the current implementation
Neither README mentioned anything added in this round. README.md now
covers the CVSS-correction cascade (Vulnrichment → NVD → cvelistV5 →
GHSA) and NVD_API_KEY, Mozilla MFSA severity, sibling metric inheritance,
EOL severity scaling by days-past-EOL plus the compliance mapping and the
background job, Microsoft Edge via MSRC (and why Chrome data must never
score it), GitHub repository advisories, multi-product findings, the
vendor column, silent token refresh and the two-way audit trail.

README.DEV.md gains a developer section on the three non-NVD sources, the
one-finding-many-products model and why it is not two rows, the
cross-source contract with the four false-negative classes that got the
inventory override removed, the ACTIVE-asset guard, the background-job
endpoints, the github_pat setting, and the theme layer's un-layered CSS
trick including the opacity/arbitrary-value gotcha.
2026-07-27 14:15:54 +02:00
vulncheck dabec89021 feat(eol): run the EOL check as a background job with live progress
The EOL check ran synchronously, so on a full estate it outlived the
browser's HTTP patience — the tester saw 'Backend connection failed'
while the backend was still happily working, with no way to tell whether
anything had happened.

Now POST /eol-check/start spawns the run on its own DB session and returns
202 immediately; GET /eol-check/status reports running/stage/done/total
plus the final stats or the error. The per-asset loop updates the progress
counters, so the button shows 'N/M assets — hostname' while it works and
the final summary (including how many stale MS-lifecycle findings were
closed) when it finishes. Starting a second run while one is active
attaches to the running job instead of queueing a duplicate.

The synchronous POST /eol-check stays for API clients and small installs.
2026-07-27 12:28:58 +02:00
vulncheck da325bca77 fix(eol): close stale MS-lifecycle FPs from any path; stop the export refetch storm
1) The Edge→'Azure Stack Edge' false positive survived every nightly run.
   c3cd62c added a reconcile, but it lives in run_eol_for_packages while
   the EOL-check button has its own loop and never called it — so a
   finding produced by a since-corrected name match stayed open forever.
   New revalidate_ms_lifecycle_findings() re-asks the resolver for every
   OPEN EOL-MS-LIFECYCLE finding and closes the ones that no longer
   match, independent of which path ran. Called at the end of the EOL
   check; audited with the host name.

2) The endless GET .../lifecycle/products/export/ run: the in-process
   memo was only set on SUCCESS, so once the fetch or the xlsx-link
   lookup failed, every one of the thousands of scanned packages
   re-fetched the page — which is also why the UI eventually gave up with
   'Backend connection failed' while the backend kept going. Failures are
   now memoised too (negative cache), so a broken source costs one
   request per hour instead of one per package.

3) EOL dialog text was stale: it named only endoflife.date and said
   nothing about the MS-lifecycle export, the built-in exotics, the OS
   check, auto-closing, or that Intune-only devices are covered elsewhere.

4) CVSS-correction dialog: one numbered source per line, as requested.
2026-07-27 12:10:31 +02:00
vulncheck 3ad51a48d8 fix(scan): inherit CVE metrics from a sibling finding when the source has none
Tester: CVE-2026-16423 showed CVSS 8.8 on the Google Chrome row but '-'
and priority 0 on the Microsoft Edge row of the same CVE. CVSS/EPSS/KEV
are properties of the CVE, not of one host, so the two must agree.

Cause: only 49 of 436 Edge CVEs carry a CVSSScoreSet in the CVRF, and
Edge CVEs are absent from NVD/cvelistV5, so an MSRC-only finding often
has no score to enrich from — while the same CVE on another asset already
had one. apply_canonical_from_siblings() exists for exactly this and was
wired into the Wazuh and Nessus syncs only.

Call it on newly created findings that have no score, in both the MSRC
and app-scan create paths (which also covers the GitHub repo-advisory
findings, since those upsert through app-scan).
2026-07-27 10:48:47 +02:00
vulncheck e4ae0df483 fix(msrc): NOT NULL severity crashed every new finding; carry MSRC CVSS
The index rebuild worked (log: 18 docs, 10 products, 11154 fix entries)
but the very first Edge INSERT died on NotNullViolation — severity is NOT
NULL and the MSRC create path never set it. Until Edge joined, MSRC
practically always hit the existing-row branch (Windows/SharePoint
findings were already there from other scanners), so the create path
rarely ran and the missing column went unnoticed. One failed flush poisons
the session, so the whole scan 502'd.

Fixing it with a neutral placeholder alone would have been wrong here:
Edge CVEs are in NEITHER NVD NOR cvelistV5, so the enrichment cascade can
never fill the score in later — every Edge finding would sit at
'medium / no score' forever. MSRC does publish both, so the index now
carries CVSS base score, vector and Microsoft's own severity word
(Critical/Important/Moderate/Low → critical/high/medium/low, falling back
to the score, then to medium). Applied on create and backfilled onto
existing rows that still lack a score.

Also imports VulnerabilitySeverity in _upsert, which was not in scope.
2026-07-26 23:40:11 +02:00
vulncheck 4dec4f70ee fix(msrc): retire the pre-Edge index cache and rebuild it on demand
Tester added Edge support, ran App-CVE-scan + MSRC refresh, and still saw
CVE-2026-16417 attributed to Google Chrome only — with the MSRC Edge fix
build (150.0.4078.96) already showing in the Remediation panel, so the
data was there but no Edge FINDING existed.

Cause: the product index is cached under a fixed setting key and served
with allow_stale=True. Adding Edge to _PRODUCTS changes what the index
CONTAINS, but the cache built by the previous version has no edge key at
all — and load_index happily returned it. Worse, when no index exists the
app scan just skipped every MSRC product instead of building one.

Bump the key to msrc_product_index_v2 (retires the stale cache on deploy)
and build the index on demand in the app scan when it is missing, matching
what the cvelistV5 path already does.
2026-07-26 14:41:32 +02:00
vulncheck 767c109816 fix(scan): per-package record must never abort the scan; refresh scan dialog text
1) The per-package helper added in 24eebd3 crashed the whole App CVE scan
   with a 502: a plain query does not see rows added earlier in the SAME
   unflushed transaction, and one run legitimately records the same
   (finding, product) twice — the tester's log shows vulnerability 68148
   getting 'Microsoft Edge' twice in one batch — so the second INSERT hit
   uq_vulnpkg_vuln_package and the IntegrityError took the request down.

   Now: also scan db.new for a pending row (update it instead of adding a
   second), wrap the insert in a savepoint so a lost race rolls back only
   that statement, and swallow any remaining error — this is display data
   and must never break a scan.

2) The App-CVE-scan confirm dialog still described only OSV / NVD-CPE.
   It now names what actually runs (OSV/NVD, cvelistV5 incl. Windows OS
   builds, MSRC fixed-builds incl. Edge, GitHub repo advisories, M365,
   endoflife.date) and answers the tester's question explicitly: every
   asset with Wazuh OR Intune inventory is scanned, not just Wazuh ones.
2026-07-26 12:14:08 +02:00
vulncheck 24eebd3113 feat(scan): show every affected product when one CVE hits two of them
Tester: CVE-2026-16417 lists only google:chrome CPEs at NVD/cvelistV5, but
MSRC documents Microsoft Edge as affected too (fixed build 150.0.4078.96).
On a host running BOTH browsers unpatched, two different products are
genuinely affected by the same CVE — and their build schemes are unrelated,
so neither version can stand in for the other.

A finding is unique per (cve_id, asset_id) — index uq_vuln_cve_asset — so
the answer is one finding carrying BOTH products, not two rows: splitting
would break dedup, source reconciliation and every count. The per-package
table (vulnerability_packages) exists for exactly this and was only ever
written by the Wazuh sync, so app-scan and MSRC findings showed whichever
product happened to be written first.

New audit_events.record_affected_package(); wired into both upsert paths
of app_cve_scanner_service and msrc_scan_service (create + existing).
Idempotent per (finding, package name) — re-detection refreshes version,
fix and last_seen instead of appending duplicates. The API already returns
these as 'packages' and the CVE detail page already renders one card per
entry, so Chrome and Edge now appear side by side with their own installed
and fixed versions.
2026-07-26 11:58:33 +02:00
vulncheck fbe107e28b feat(msrc): detect Microsoft Edge CVEs — MSRC is the only source for them
Tester verified that Edge CVEs (CVE-2026-57989/-57990/-57978, published
2026-07-24) exist in NEITHER NVD ('CVE ID Not Found') NOR cvelistV5, while
MSRC carries CVSS, severity and the fixed build. The July CVRF alone lists
436 Edge CVEs with a FixedBuild, so no other scanner path could ever see
them.

Add Edge to the curated MSRC product registry; the existing package scan
and reconcile pick it up unchanged. branch=False because Edge servicing is
cumulative (plain installed < fixed); a newer major compares greater and
drops out on its own.

Two guards the tester's screenshots motivated:
- Edge is never scored off Chromium/Chrome data — the builds diverge
  entirely (Edge 150.0.4078.99 rides on Chromium 150.0.7871.187), so a
  google:chrome range says nothing about an Edge build.
- WebView2 excluded from the match: it ships as its own package with its
  own version (that host had Edge .83 beside WebView2 .99).

Verified against the live CVRF: Edge 150.0.4078.83 → 18 findings (the ones
genuinely still open), current .99 → 0, a future 151.x → 0.
2026-07-26 11:51:46 +02:00
vulncheck d81a993bbe fix(scan): never auto-resolve on inactive assets; show real sync counters
A disconnected Wazuh agent still serves its LAST STORED syscollector data,
so the app scan 'succeeds' on data that can be months old and then closes
findings from it. Wazuh's own vuln sync only walks active agents, so
nothing ever reopens them — the tester's disconnected host ended up with
zero CVEs in TrueVuln while Wazuh still listed 31 for it. The app-scan
reconcile now runs only when the asset is ACTIVE; skipped ones are counted.

Sync result message now reports what actually happened (CVE reports seen,
created / updated / reopened / confirmed / patched) instead of three
counters that were mostly structurally zero.
2026-07-25 21:23:17 +02:00
vulncheck 6ff2076164 fix(scan): remove the inventory override; Python name_ver; honest sync counters
1) REMOVED the inventory-based override that closed findings over a lone
   Wazuh claim. It produced false negatives in four distinct ways, one
   surfacing per test round: multi-stream fixes (MySQL 8.0.23 vs stored
   7.6.34), 2.x minor servicing lines (Git 2.49.0 vs 2.41.7), MSI build
   numbers in the version field (Python 3.13.7150.0 vs fix 3.13.10), and
   stale inventory from disconnected agents. Each guard fixed one class
   and the next round found another — the premise is wrong: a single
   stored fixed_version cannot validate an arbitrary inventory version
   string. Back to the strict contract: every scanner retracts only its
   OWN source, and a finding closes when no source is left. False
   negatives are worse than a finding that lingers.

2) Python now uses name_ver: Windows reports the MSI build (3.13.7150.0)
   in the version field while the semantic version (3.13.7) is in the
   display name, so real Python CVEs never matched NVD's ranges.

3) Wazuh sync counters were misleading — 'Updated' counted ONLY a CVSS
   score change, so a run that reopened findings and refreshed versions
   still said 'Created: 0, Updated: 0, Patched: 0'. Now any real field
   change counts as updated, reopens get their own counter, and
   unchanged-but-confirmed rows are counted too.
2026-07-25 21:21:55 +02:00
vulncheck 75a9a5659f fix(scan): branch guard on major.MINOR; name the host in status-change audit rows
1) The major-only branch guard shipped in a74c229 was too weak for 2.x
   projects, where the servicing line lives in the MINOR: Git 2.49.0 vs
   the stored fix 2.41.7 share major 2, so the finding was still closed
   wrongly (2.49.0 is inside '>= 2.49.0-rc0, < 2.49.1' — the real fix for
   that branch is 2.49.1). Guard now requires the same major.minor.
   Re-checked against every case seen so far: Git/MySQL/Suricata stay
   open, Edge/Notepad++/Chrome single-stream cases still close.

2) Status-change audit rows named the CVE but not the system, so a reopen
   couldn't be attributed or found by host. log_vulnerability_change takes
   an optional hostname, puts it in the description ('… → open on HOST')
   and in the JSON payload; reopen_if_patched resolves it from the
   finding's asset. The description is what the audit-log search matches,
   so host search now works for these rows.
2026-07-25 18:58:47 +02:00
vulncheck a74c229264 fix(scan): stop closing multi-stream CVEs across branches; audit every reopen
1) FALSE NEGATIVE (regression from the Wazuh-override I added earlier):
   _inventory_confirms_fixed compared installed vs fixed_version without
   checking they belong to the same servicing branch. Multi-stream CVEs
   store only ONE stream's fix, so the compare was meaningless and closed
   still-live findings — tester: MySQL 8.0.23 vs stored fix 7.6.34 (8.0.23
   sits inside the separate 8.0.0–8.0.42 affected range), Suricata 8.0.0 vs
   stored fix 7.0.12 (inside 8.0.0–8.0.1). Both were auto-resolved wrongly,
   then reopened by the next Wazuh sync. Now the majors must match — same
   lesson as the Windows build-line guard: only compare within one release
   line. Edge/Notepad++ single-stream cases still close as before.

2) patched → open was never audited. Every scanner reopened findings
   inline, so only the positive direction (open → patched) appeared in the
   audit log and per-CVE Change History — the tester watched CVEs silently
   flip back to open with no record. New audit_events.reopen_if_patched()
   does the transition AND writes the status-change row; all 11 reopen
   sites (wazuh, nessus x2, defender, app-scan, msrc, m365, eol,
   mobile-eol, android) now go through it. Verified the resulting status is
   identical to the old branch logic for every status value — the only
   change is that the row now gets written.

3) Silence the pydantic v2 import warning (orm_mode → from_attributes).
2026-07-25 17:32:10 +02:00
vulncheck bf42e28b15 feat(scan): detect CVEs from GitHub REPOSITORY security advisories
Closes a real gap the tester chased down: Notepad++ CVEs (CVE-2026-57233,
-54758, -52886, …) are in neither NVD nor cvelistV5, so no scanner path
could see them. They exist only on the project's own GitHub advisories.

My earlier assessment ('GHSA carries no version data') was based on the
GLOBAL advisory API, whose unreviewed entries are bare NVD mirrors. The
PER-REPO endpoint is a different source: the maintainer publishes
vulnerable_version_range + patched_versions there. Verified live against
notepad-plus-plus: 18 advisories, all 18 with a range, 12 with a CVE id,
9 with CVSS.

New github_repo_advisory_service: product→repo registry (one line per
product), 24h cached index, optional github_pat. Range parser handles the
maintainer free-text seen in the live feed ('<= v8.9.6.4', '< v8.9.6.4',
'<=8.9.1', 'old versions - 8.8.1', 'v8.9.4 & v8.9.5', bare versions) with
patched_versions as a hard safety net — at/past the patched release is
never flagged. Explicit version lists match exactly, never 'everything
below'. Findings are written through the app-scan upsert, so the existing
reconcile auto-resolves them on update; no new source to maintain.

End-to-end against the live feed: 8.9.6.4 → 2 findings, 8.9.6 → 6,
8.5.0 → 9, patched 8.9.7 → 0.
2026-07-25 14:45:55 +02:00
vulncheck 7851b31af7 fix(dashboard): Recent Critical widget starved to 2 rows — distinct_cve + deeper feed
Published-date-desc feeder returned the newest 300 per-asset rows, mostly
low/medium; after the CVSS≥8/KEV/EUVD filter only 2 criticals survived.
Same starving Newly Published had — same cure: distinct_cve=true collapses
per-asset duplicates server-side and the deeper window (300) reliably
fills the 10 slots.
2026-07-24 15:35:31 +02:00
vulncheck 1476318624 fix(wazuh): refresh asset OS build in the vuln sync; recency for Recent Critical widget
1) Windows-OS CVEs stuck open: only the separate ASSET sync refreshed
   asset.operating_system/os_version; the Wazuh VULN sync (the flow that
   actually runs regularly) never did. So scan_asset_os kept judging OS
   CVEs against a stale build — tester: Server 2016 host already on
   .9339 (the fix build per Wazuh dashboard) while findings showed
   installed .9140 and stayed open. The vuln sync now refreshes OS
   name/build from the agent record; the existing stale-reconcile then
   closes the findings on the next app scan.

2) Recent Critical CVEs widget pinned the same old 2021 KEV heavyweights
   (priority-sorted, recency ignored). Feeder now sorts by CVE published
   date desc (filter unchanged: CVSS ≥ 8 or KEV or EUVD), same-day ties
   broken by CPR desc. Subtitle + View-All link match.
2026-07-24 14:32:44 +02:00
vulncheck f7f627ee5c fix(ui): dark-theme remaps for opacity/arbitrary-value classes
Tester (dark theme): dashboard widget headers ('Recent Critical CVEs' …)
and the sticky vulnerabilities list header stayed light-gray under light
text. Cause: opacity and arbitrary-value utilities are their OWN class
names — bg-gray-50/50, bg-[#F3F4F6]/95, bg-white/50 — which the plain
.bg-gray-50/.bg-white remaps never touch.

Add escaped-selector remaps for those, kill the pastel indigo gradient on
the AI widgets (background-image none + dark surface) with matching
indigo text/border/badge tints, bg-base-200, and the group-hover blue
tint on the sticky actions column. Dark-theme autofill now carries
!important + caret-color so it actually beats the light-scheme autofill
rule (which is !important). Verified via computed styles in the browser:
all previously-light classes resolve to dark surfaces in mid/dark.
2026-07-24 12:02:33 +02:00
vulncheck 7941d0403f fix(scan): never match Firefox ESR installs against release-train ranges
Tester: CVE-2026-16395 flagged on 'Mozilla Firefox 52.2.1 ESR' — the
cvelistV5 record carries only 'unaffected 153, lte *' (release train),
and per mfsa2026-69/-70 no ESR branch is affected at all. Both matchers
(cvelistV5 registry + curated CPE registry) resolved the ESR package to
the plain firefox key, so ESR builds were judged against release-train
ranges — structurally wrong (NVD tracks ESR as its own firefox_esr CPE;
Mozilla ships separate ESR advisories).

Guard both resolve() paths: a package whose name carries the ESR word
never resolves to the firefox key. ESR patch state would need the MFSA
fixed_in data (already stored in the mozilla_advisory index) — until
that's wired, no match beats a false positive. Existing FP rows
auto-close on the next app-scan via the stale reconcile.
2026-07-24 11:51:48 +02:00
vulncheck 8d50547cbb fix(app-scan): close findings Wazuh keeps reporting past the fix
Tester: Edge CVE-2026-58525 stuck open showing installed 150.0.4078.48 /
fix .50 while the live inventory already had .83. Chain: app-scan
correctly dropped its source on re-scan, but Wazuh's VD states index
re-evaluates lazily and kept claiming the CVE (with the OLD version), so
the wazuh-only row stayed open indefinitely.

Inventory-verified override in the app-scan reconcile: when a stale row's
only remaining source is wazuh AND the CURRENT inventory shows the
finding's package at/past its fixed_version, close it over Wazuh's stale
claim (audit reason says so). Conservative: needs a parseable fix AND a
name-matching package with a parseable version; Linux rpm/deb versions
never parse → those stay Wazuh's call. Reconcile query now also selects
wazuh-only rows (previously filtered on app-scan source, so a row that
lost app-scan in an earlier run was never revisited). Name→version map
built once per asset.
2026-07-24 11:29:59 +02:00
vulncheck 3af68fcddc fix(ui): put the theme switcher where it's actually visible — the sidebar
The previous commit added the theme button to shell/Header.tsx, which is
imported NOWHERE (dead component — the app shell renders its own top bar,
desktop has none at all), so the tester couldn't find any button.

New ThemeSwitcher: segmented Light / Mid / Dark control at the bottom of
the sidebar (desktop sidebar + mobile drawer, above the user box) —
always visible on every page. Dead Header.tsx deleted so the next change
doesn't land there again.
2026-07-24 11:17:55 +02:00
vulncheck 5233a65103 feat(ui): three-way theme — light / mid (soft dark) / dark (full dark)
The old theme toggle only flipped daisyui variables, which the hardcoded
light utility classes (bg-white, text-gray-900, …) never react to — so
it visibly did nothing. Instead of rewriting every page with dark:
variants, add an UN-layered override block in globals.css that remaps
the light-surface utilities (plus tinted badges, borders, inputs,
scrollbars) under html[data-theme='mid'|'dark'] via CSS variables —
un-layered author CSS beats Tailwind's @layer utilities without
!important. Sidebar (bg-gray-800/900) and brand colors intentionally
untouched.

useTheme now cycles light → mid → dark (legacy 'corporate'/'dark'
values migrate), the Header button shows the active theme (sun / moon /
solid moon + label), and an inline pre-paint script in layout.tsx
applies the stored theme before hydration so dark users get no white
flash. Verified all three themes render correctly in the browser.
2026-07-24 11:05:09 +02:00
vulncheck 4ebb381db0 fix(vendor): fill vendor for Wazuh vuln-detector findings too
The Vendor column showed for app-scan/Intune/Defender findings but not
for Wazuh's own vulnerability-detector rows (the main Wazuh source),
which set package_name/version but never package_vendor — inhomogeneous.

Thread package.vendor from the Wazuh states-vulnerabilities index
through get_vulnerabilities into the sync: take the first non-empty
vendor per CVE, set it on create and backfill existing rows. Wazuh
populates vendor for most Windows/RPM packages, so those now match the
other sources; where the source has no vendor it stays blank (same limit
as syscollector).
2026-07-24 10:55:15 +02:00
vulncheck 86159f44b1 feat(audit): search, sortable columns, real pagination + page size
The audit log only did fixed 100-row 'load more' with no total, search or
sort. Backend GET /audit/logs now accepts search (free-text over
description, event type, resource, IP, and username via outerjoin), sort
+ order over a whitelisted column set, and sets X-Total-Count (already
CORS-exposed) so the UI can page. Frontend rewritten to match the CVE
view: debounced search box, clickable sort headers, rows-per-page
selector (25/50/100/250), and First/Prev/Next/Last with 'X-Y of N'.
2026-07-24 10:27:43 +02:00
vulncheck c3cd62c461 fix(eol): resolve stale MS-lifecycle findings the rescan no longer matches
run_eol_for_packages only ever ADDED findings; _supersede_old_eol fires
only when a replacement is created. So after the Edge→Azure-Stack-Edge
name-match fix, the wrong 'Microsoft Edge = EOL' finding stayed open
forever (no replacement is produced for a now-unmatched product).

Add a reconcile scoped to the EOL-MS-LIFECYCLE- prefix (package path
only; OS-level endoflife.date findings use other slugs and are
untouched): resolve open MS-lifecycle findings the current run didn't
re-produce. Guarded on a non-empty package list so a failed inventory
read can't mass-close.
2026-07-24 10:10:36 +02:00
vulncheck 6f9f2ae10b fix(mfa,ms-lifecycle,vendor): 500 on expired MFA, export refetch storm, Intune vendor
- MFA verify: decode_token returns None on an expired 5-min challenge
  (it doesn't raise), so the exception-only guard let None.get() throw →
  500 'Internal Server Error'. Guard None → clean 401 'MFA session
  expired'. Login page routes that message back to the credentials step
  instead of stranding the user on a dead MFA form.
- MS lifecycle: the 24h DB cache still let every synced device re-download
  the export before the first commit landed (tester saw dozens of
  identical GET .../lifecycle/products/export/). Add an in-process memo so
  one sync fetches at most once.
- Intune live software inventory (/assets/{id}/software) hardcoded
  vendor='' — now uses detectedApps.publisher, so the Vendor column fills
  like Wazuh's.
2026-07-24 10:08:15 +02:00
vulncheck 9007cdc03a feat(eol): escalate severity by days-past-EOL + tag compliance frameworks
Adopts the endoflife.date/Wazuh EOL-risk model: a product that has been
past end-of-life for 1000+ days is now CRITICAL (cvss 9.8) rather than a
flat HIGH — the longer it runs unpatched, the higher the standing risk.
Titles carry the age ('EOL 1500d'). EOL findings also cite the control
failure (PCI-DSS 6.3.3, NIST 800-53 CM-8, HIPAA 164.312(a)(1)) so audit
reports can reference it. EOL-SOON / EOAS tiers unchanged.
2026-07-24 08:49:02 +02:00
vulncheck 56c0335839 fix(auth): silent token refresh so active sessions aren't logged out
The 30-min access token had no client-side renewal: on any 401 the SPA
redirected straight to /login, so an actively-used session was bounced
every 30 minutes even though a valid 7-day refresh cookie existed.

- /auth/refresh now falls back to the HttpOnly refresh_token COOKIE when
  no body is sent — the browser can't read that cookie to put it in the
  body, so the cookie path is what makes SPA silent refresh possible.
- axios interceptor: on 401 (non-auth endpoints) try one /auth/refresh
  then replay the original request; redirect only when refresh itself
  fails (refresh expired/revoked = genuinely inactive). Single-flight
  guard shares one refresh across concurrent 401s, since the backend
  rotates (revokes) the refresh token on use.

Net: active users stay logged in up to the 7-day refresh window (all
roles); only real inactivity logs out.
2026-07-23 22:29:48 +02:00
vulncheck 32c064e3f2 fix(cvss-cascade): CVE-only input, wire NVD key, key-aware cap, honest UI
- Only real CVE-IDs enter the correction cascade now (was: everything
  except NESSUS-). Drops GHSA-only/pseudo ids that can never resolve —
  spares wasted lookups and shrinks the missing set that gates NVD.
- _load_via_nvd now uses the NVD_API_KEY env (apiKey header, same as the
  enrichment service) and throttles ~45/1s instead of 5/1s with a key.
- Stage-2 NVD cap scales with the key: 1500 (key) vs 100 (unauth), so a
  key actually gets used instead of always falling through to cvelistV5.
- Correct-CVSS button label/confirm/tooltip now describe the real
  cascade (Vulnrichment → NVD → cvelistV5 → GHSA, each filling only what
  the previous left empty, CVE-IDs only) instead of 'CISA Vulnrichment'.
2026-07-23 22:29:36 +02:00
vulncheck 97562abed0 feat(vendor): capture + show software vendor from Intune/Defender/Wazuh
Answers 'is vendor readable via the Intune/Defender API?' — yes:
- Intune detectedApps.publisher was being dropped in _map_apps; now kept
  as the package vendor.
- Defender softwareVendor was folded into the package label; now stored
  separately.
- Wazuh syscollector already carries vendor; now threaded through.

New vulnerabilities.package_vendor column (migration 038, idempotent),
populated at the package-finding chokepoints (app_cve_scanner._upsert +
cvelistv5 scan_asset via pkg vendor, defender _upsert_cve) and shown in
the Affected Package card on the CVE detail page.

Scope: single-package findings. Per-package (vulnerability_packages) and
Nessus/m365 vendor left as follow-up — Nessus rolls vendor into the
plugin name and m365/OS vendor is implicit (Microsoft).
2026-07-23 14:06:05 +02:00
vulncheck 8db635effa fix(scan): tighten Firefox match to require the Mozilla vendor word
Drop the bare-"firefox" alternative from the product key regex so a
stray "…Firefox…" inside another product's name can't resolve to the
Firefox CVE key. Windows ARP / Wazuh inventory always carry the "Mozilla"
prefix (incl. "Mozilla Firefox ESR"), so no real install is lost.
2026-07-23 13:59:38 +02:00
vulncheck 0ba07131cb feat(enrich): Mozilla MFSA severity source for fresh Firefox CVEs
Mozilla publishes a per-CVE impact rating (critical/high/moderate/low)
in foundation-security-advisories before NVD/cvelistV5 have a score — the
gap on brand-new Firefox CVEs. New mozilla_advisory_service builds a
{cve: severity/title/fixed_in/esr_only} index from the MFSA repo (line
parser, no PyYAML dep; 24h cache; github_pat lifts the rate limit) and
apply_mozilla_severity fills the placeholder severity in enrich_vulnerabilities
when the vuln has no CVSS-derived value. Mozilla carries no numeric CVSS,
so it only sets severity/description, never clobbers a real score.

fixed_in / esr_only (e.g. only 'Firefox ESR 115.13' → regular Firefox
unaffected) is the authoritative ESR discriminator, stored for the
upcoming Firefox-scan ESR exclusion.
2026-07-23 13:57:57 +02:00
vulncheck cab46fd819 feat(enrich): add GitHub Advisories (GHSA) as CVSS/severity fallback
Stage 4 of the score cascade (vulnrichment → NVD → cvelistV5 → GHSA).
GHSA carries advisories for CVEs still missing from NVD and cvelistV5
when very fresh — the gap the tester hit on new Firefox/Notepad++ CVEs.
Fills CVSS/severity/description/references only; GHSA 'unreviewed'
advisories carry no affected-version range (verified against the live
API), so no fix/detection data is derived — this is enrichment, not
new-CVE detection.

Optional github_pat setting (encrypted at rest, admin-only Settings
card) lifts the GitHub rate limit 60 → 5000 req/h; the loop stops
cleanly when the limit is hit.
2026-07-23 13:50:32 +02:00
vulncheck acec27367f fix(m365): auto-resolve findings once the host's Office build catches up
The M365 check only ever ADDED rows: upsert refreshes package_version
while a CVE is still missing, but when a host updated Office and the CVE
left the missing set, detect_missing_cves returned affected=False, the
loop skipped it, and the finding stayed OPEN forever with a stale
installed build (e.g. CVE-2026-45460 shown as installed 16.0.19929.20172
on a host already on 20131.20154, past the 20026.20166 fix).

Add a reconcile mirroring Defender/app-scan: after scanning an asset
where an M365 install was actually seen, drop the microsoft365-apps
source from open findings whose CVE is no longer missing, and mark
patched when no other source still reports them. Wired into both
run_m365_check and run_m365_for_packages.
2026-07-23 13:46:28 +02:00
vulncheck 7a220511b8 fix(m365): don't attribute Windows-OS CVEs to Microsoft 365 Apps
The MS 365 Apps security-updates page lists OS-level components (GDI,
MSXML, …) under its 'Office suite' heading — CVEs that are really
Windows-OS bugs Office bundles (e.g. CVE-2026-50387, a Windows GDI vuln
whose real fix is a Win10/Server2016/2025 OS build). The M365 check swept
those in and, worse, upsert overwrote the correct scan_asset_os finding
on the same (cve, asset) row with an M365 product + Office build.

Skip any CVE the cvelistV5 Windows-OS registry already owns, in both
entry points (run_m365_check + run_m365_for_packages). scan_asset_os
keeps ownership with the correct build + MSRC KB.
2026-07-23 13:37:39 +02:00
vulncheck 304900da1b fix(wazuh): scrub stale cross-build-line Windows OS fix on re-sync
Fill-only backfill never corrected fixed_version values stored by
pre-sanitize syncs, so an old cross-line build (e.g. 6.2.9200.x
Server-2012) stayed on a 10.0.26100 Server-2025 host even after the
sanitize guard shipped. Re-sanitize the stored value each sync and clear
it when it belongs to a different build line; MSRC panel supplies the
correct per-branch KB.
2026-07-23 13:34:16 +02:00
vulncheck 3bb480c39f fix(scan): drop ESR false positive; correct Cisco PSIRT feed URL
- cvelistv5: inverse-unaffected fix bound now only for records with
  exactly ONE unaffected entry. Firefox ESR CVEs carry two (e.g.
  CVE-2026-16361: 115.38 lte 115.* + 140.13 lte *), and Mozilla writes
  the ESR floor as an unbounded 'lte *', so a 'below X' rule wrongly
  flagged regular Firefox 121/152. Multi-train records now skipped.
- advisory feeds: DEFAULT cisco-psirt URL corrected to
  psirtrss20/CiscoSecurityAdvisory.xml (old rss.x?i=44 is DTD-refused);
  saved configs auto-migrate the stale URL on load.
2026-07-23 13:27:20 +02:00
vulncheck bf945f838f fix(users): delete user no longer 500s (clear all FK references)
Deleting a user cleared only Asset/Vulnerability assignments, but users.id is
referenced by four more tables — notification_logs, ai_reports, user_groups,
audit_logs — so Postgres RESTRICT blocked the delete with a 500.

Clear every FK first. Historical rows are NULLed, not deleted, so a removed
user's notifications, AI reports and — critically — AUDIT LOG entries survive
(a user deletion must not erase who-did-what). Group memberships are removed
outright. Then expire the cached relationship state so the User.audit_logs
delete-orphan cascade doesn't re-delete the rows we just detached.
2026-07-22 09:23:37 +02:00
vulncheck d55ebdbec4 docs: correct the MSRC section — NVD/cvelistV5 DO carry MS fix builds
The 'no fixed build for Microsoft products' claim was disproved in this branch
(modern records carry numeric ranges; the old claim came from sampling ancient
CVEs). Describe what actually shipped: family-matched Windows OS detection via
cvelistV5 bounded ranges (Server and Client), per-release SharePoint/.NET keys,
and MSRC as the second authoritative source.
2026-07-21 15:04:08 +02:00
vulncheck 64204f41b7 feat(advisories): Security Advisory Feeds page (ZDI/CERT-EU/BSI/Cisco + custom)
New early-warning surface the tester asked for: the newest 7-Zip advisory was
on ZDI before NVD or cvelistV5 had it. A dedicated Advisories page now shows
the CISA-KEV latest additions plus configurable RSS/Atom sources.

- advisory_feed_service: fetch + parse (RSS and Atom), cached in a setting so
  the page serves instantly; per-feed errors recorded, never fail the run.
  Default feeds verified LIVE before shipping (ZDI published/upcoming, CERT-EU,
  BSI/CERT-Bund WID, Cisco PSIRT); Cisco emits junk after the XML root, so a
  lenient per-item fallback parses it anyway. Security: any DOCTYPE/ENTITY
  declaration is refused outright (XXE / billion-laughs) — feeds never need
  DTDs, no defusedxml dependency required.
- GET /api/v1/advisories/feeds (cache; first call fetches),
  POST /feeds/refresh (editor). Config = advisory_feeds_config setting,
  editable from the page (admin): enable/disable, rename, custom URLs.
- Scheduler refresh every 6h; 'Advisories' nav item for all roles (read-only
  surface; Refresh for editor+, Configure admin-only).

Parser self-checked against rss/atom/cisco-junk/DTD-refusal fixtures.
2026-07-21 15:03:44 +02:00
vulncheck c6118d3d52 fix(wazuh): drop cross-release fix builds from Wazuh CTI on Windows OS findings
Wazuh sometimes attaches a fix build from a different Windows release to an OS
finding (tester: 'Fixed in 6.2.9200.26079' — a Server 2012 build — on a Server
2025 host at 10.0.26100.x). A fix on another build line says nothing about the
host; showing it is misinformation. Sanitize at both sync ingest points: for
'Microsoft Windows*' packages, when installed and fix parse as 4-part builds on
different build lines, the fix is dropped (the MSRC remediation panel supplies
the correct per-branch KB anyway). Non-OS packages and unparseable versions are
untouched.
2026-07-21 14:45:29 +02:00
vulncheck 54edb38b3a feat(app-scan): modern .NET (8/9/10) CVE detection via cvelistV5
The July .NET CVEs (CVE-2026-47304/50649/50525/50659) were invisible to the
app scan because cvelistV5's registry had no .NET entry. Added per-release keys
(dotnet-8/9/10 -> '.NET 8.0'/'9.0'/'10.0'), same reasoning as SharePoint: the
record floors are generic release floors (8.0.0 .. 8.0.29), so a shared key
would cross-match releases. The real semantic version lives in the ARP NAME
('Microsoft .NET Runtime - 8.0.16 (x64)') and bumps with every monthly patch;
the version field is an MSI build -> the scan now supports name_ver entries via
the existing _effective_version mechanic. Matches Runtime/Host/Desktop-Runtime;
SDKs excluded (8.0.1xx numbering never falls in the runtime range, runtime is
installed alongside anyway).

.NET FRAMEWORK stays out deliberately, with data this time: its ARP version
(4.8.04084) is STATIC across monthly patches — only file versions change — so
comparing it to fix builds like 4.8.4803.0 would flag every install forever.
Framework patch state is file/KB-based; Defender TVM covers it and those
findings already flow in via the Defender sync. The record's ampersand ranges
('2.0.50727.9069 & 3.0...') parse to no-match, verified harmless.

Index bumped to v7. Verified against the live records: 8.0.16 -> affected by
8.0.0..8.0.29; 8.0.29 -> clean; SDK/Framework/Targeting-Pack names don't
resolve.
2026-07-21 14:41:36 +02:00
vulncheck 948589d07f fix(scan): cross-source auto-resolve, stale installed version, Citrix stubs
Three tester-reported issues:

1. Cross-confirmed findings never auto-resolved. Each reconcile (app-scan /
   defender / msrc) skipped findings any OTHER scanner also reported, so a
   Chrome CVE seen by app-scan AND Defender was closed by neither — a patched
   host (150.0.7871.125 installed, fix .115) kept an open finding forever.
   All three reconciles now use the Nessus-backfill contract instead: drop
   YOUR OWN source when no longer detected, mark patched (audit-logged) only
   once no source is left. A finding another scanner still reports stays open
   under that scanner. Verified both directions in a self-check.

2. Installed version went stale. The app-scan upsert only filled
   package_version when empty, so the row kept its first-ever version
   (tester: Firefox showed 'Installed: 150.0.3' while 152.0.5 was on the
   box). Re-detection now refreshes the version.

3. Citrix published-app registry stubs ('Firefox 1.0', vendor 'Delivered by
   Citrix') describe software NOT installed on the box and produced
   ancient-CVE false positives (CVE-2008-2798 against Firefox 1.0). Packages
   whose vendor names Citrix are skipped in both scanners; the stub vendor
   field is the discriminator, real installs keep their real vendor.
2026-07-21 14:22:50 +02:00
vulncheck 8fd09d1f81 fix(msrc): host-aware KB pick on shared build lines + refresh status polling
1. Wrong KB suggested: a Win11 24H2 host at 10.0.26100.8655 was shown KB5099536
   build 10.0.26100.33158 — the Windows Server 2025 update. The remediation
   display collapsed each build branch to its NEWEST revision before knowing the
   host, but one branch can carry several revision sequences (26100 = 24H2 at
   ~8xxx AND Server 2025 at ~33xxx), so the Server KB always won. Now all
   candidates per branch are kept and, once the host is known, the pick per
   update-type is the SMALLEST fix revision still above the installed one —
   the host's own servicing sequence (cumulative updates make smallest-above
   the fixing one). Host past everything -> newest as reference.
   Verified: 26100.8655 -> KB5087539 (.8875); 26100.32690 -> KB5099536
   (.33158, .8875 correctly skipped as below installed); 26200 line untouched.

2. MSRC Enrich button was stuck on 'Started in background' forever. Track the
   refresh state module-side, expose GET /msrc/refresh/status, and let the
   button poll every 4s until it can show the real outcome (stats or error) —
   same pattern as the Intune sync status.
2026-07-21 14:14:32 +02:00
vulncheck 56454dff2a feat(notifications): aggregate the digest to one row per CVE
A CVE on 200 hosts was 200 rows and the counters counted each occurrence
(tester saw 'Neue Schwachstellen 11848' with 'Kritisch 467, Hoch 11381' —
per-(CVE,asset), not distinct). Collapse the digest to one row per CVE:

- {{rows}} is now one line per CVE, sorted by CPR desc, columns CVE | Severity |
  CVSS | CPR | #Systems | Package. The CVE links to ?cve_id= (no asset_id), i.e.
  the vulnerability view listing every affected asset; #Systems says how many.
- Severity counts and {{total}} are per distinct CVE, so 'Neue Schwachstellen'
  and the tiles no longer multiply by host count. {{affected_assets_count}}
  stays distinct assets across the digest.
- default digest template + preview sample + settings hint updated (Host column
  dropped — meaningless once aggregated). Notification-history subject/body show
  distinct-CVE count.

Verified on the tester's shape: 47× CVE-2026-50387 + 2× another -> 2 rows,
counters {critical:1, high:1}, #Systems 47.
2026-07-16 09:58:35 +02:00
vulncheck b4d0caf8ad fix(app-scan): find newest Firefox CVEs + SharePoint 2013 (both were my bad calls)
The tester proved two of my 'structurally impossible' claims wrong by patching
around them locally. Both were wrong, and for the same reason as before —
checking too narrow a slice of the data.

1. Newest Firefox CVEs were never indexed. Mozilla states the INVERSE: no
   affected range at all, only 'version 152.0.6, lessThanOrEqual *,
   status: unaffected'. _ranges_from_affected skipped every non-affected entry,
   so the CVE vanished. 'X and up are fixed' == 'below X is affected', so a
   lone unaffected floor now yields a fix bound. Guarded: only when the entry
   declared no affected range of its own AND there is exactly one floor —
   several floors mean several branches (release vs ESR) and picking one would
   over- or under-report.

2. SharePoint 2013 does have real fix ranges. I concluded otherwise from six
   RECENT MSRC docs — of course a product EOL since 2023-04-11 is absent there.
   The CVE records from its supported years carry real builds
   (CVE-2023-23395: 15.0.0 .. 15.0.5537.1000) and an unpatched farm is behind
   all of them. That is exactly what the tester's Nessus reports.

Verified against the live records, both directions:
  Firefox 147.0.4.0 / 152.0.5 -> affected;  152.0.6 / 153.0 -> clean
  SharePoint Foundation 2013 @ 15.0.4569.1506 -> flagged by CVE-2023-23395
                                                 and CVE-2022-35823
  regression: WS2016 @ 14393.5000 -> flagged, @ 14393.9234 -> clean

Index bumped to v6 so it rebuilds once.
2026-07-16 09:38:25 +02:00
vulncheck d304463996 feat(app-scan): Windows OS CVEs, family-matched (Server AND Client)
Re-enables the OS scan that c4abbaa turned off, with the guard that was missing.
Two independent checks, each one learned from a real false positive:

1. FAMILY — the entry's product name must belong to the asset's Windows family.
   Windows 11 24H2 and Windows Server 2025 share build line 10.0.26100 but keep
   separate revision sequences, so CVE-2026-41089 (Server 2025 only, fix .32860)
   matched a fully-patched 24H2 client at .8655. The index now keeps the product
   name to make this possible.
2. RELEASE-BOUNDED — the range's floor and fix must sit on one build line. Older
   records use a generic 10.0.0 floor that swallows every lower build
   (CVE-2021-26432 put a 17763 fix on a 14393 host).

Together the range only answers 'patched or not' within the host's own release.

Verified end-to-end against the real records, both directions:
  Win11 24H2 @ 26100.8655   -> EMPTY  (the reported FP)
  WS2016    @ 14393.9234    -> EMPTY  (patched; also rejects the 2021 record)
  WS2025    @ 26100.32690   -> CVE-2026-41089 (.32860), CVE-2026-47291 (.32995)
  Win11 23H2@ 22631.5000    -> CVE-2026-47291 (.22631.7219) only
  WS2016    @ 14393.5000    -> CVE-2026-47291 (.14393.9234) only

Index bumped to v5 (entries carry the product name) so it rebuilds once.
2026-07-16 09:32:42 +02:00
vulncheck 2519427b41 feat(app-scan): SharePoint 2016/2019/Subscription CVE detection via cvelistV5
SharePoint was simply absent from the curated registry — the app scan never
looked for it, which is why Nessus found those CVEs and we did not. cvelistV5
carries usable numeric ranges for it, verified across 82 recent CVEs rather
than one sample:

  Microsoft SharePoint Enterprise Server 2016       16.0.0 .. 16.0.5556.1005
  Microsoft SharePoint Server 2019                  16.0.0 .. 16.0.10417.20153
  Microsoft SharePoint Server Subscription Edition  16.0.0 .. 16.0.19725.20384

One key PER RELEASE, not a shared 'sharepoint' key: every range uses a generic
16.0.0 floor and all three releases report 16.0.x, so a shared key would let a
2016 install (16.0.5456) fall inside the 2019 range — the same cross-release
false positive the Windows OS scan just produced. The release is decided by the
product NAME; the range then only answers 'patched or not' within it.

2013 stays out: Microsoft publishes no fixes for it, so no range exists. Its
EOL finding is the signal there — this changes nothing for a 2013-only host.
2026-07-16 09:29:24 +02:00
vulncheck c4abbaa1c1 revert(app-scan): turn the Windows OS scan back off — cross-release FPs
Identifying a Windows release by its build LINE is not sound, so the whole
premise of 5703dfa fails. Windows 11 24H2 and Windows Server 2025 both sit on
10.0.26100 but keep separate revision sequences:

  CVE-2026-41089 affects Windows Server 2025 ONLY, fix 10.0.26100.32860
  a fully-patched 24H2 client reports 10.0.26100.8655
  -> 8655 < 32860, so the client matched a Server-only CVE

The same-line floor/fix guard from e19c8d9 was necessary but not sufficient: it
only rejects the old generic-floor records, not two releases sharing a line.

Disabling rather than attempting a third in-place fix: the FPs are in the
tester's data now, and I have already been wrong three times here by
generalising from too few records. Windows products are no longer indexed at
all, so the findings self-heal via the app-scan auto-resolve on the next run.
Package scanning is untouched.

Re-enabling needs the entry's product NAME in the index, matched against the
asset's OS family, verified against CVE-2026-41089 (client must not match) and
CVE-2026-47291 (each must match its own line). Written up in scan_asset_os.
2026-07-16 09:26:29 +02:00
vulncheck e19c8d9da7 fix(app-scan): stop cross-release Windows false positives
Regression from 5703dfa. A Server 2016 host (10.0.14393.9234) was flagged with
CVE-2021-26432 'fixed in 10.0.17763.2114' — a Server 2019 build. My claim that
cvelistV5 ranges are release-bounded came from checking ONE modern record, and
older ones are not:

  CVE-2021-26432  version=10.0.0        lessThan=10.0.17763.2114   (generic floor)
  CVE-2026-47291  version=10.0.14393.0  lessThan=10.0.14393.9234   (release-bounded)

A '10.0.0' floor makes the range swallow every lower build, so a 14393 host fell
inside a 17763 fix. The OS scan now requires the floor and the fix to sit on the
same build line and skips the rest — such an entry carries no release info at
all, and the OS string alone can't supply it. Costs nothing in practice: Windows
servicing is cumulative, so a host behind on an old CVE is already flagged by
that line's newer ones. Existing false positives self-heal on the next scan via
the app-scan auto-resolve.

Also:
- scheduler job renamed to '(Windows OS)' — it covers client too, not just Server
- dashboard: AI Audit History hidden from read-only (AI endpoints are editor+)
- dashboard: 'Export Report' had no onClick at all (dead for every role) — now
  links to the reports page
- compliance: the Impact-CSV upload card is admin-only (the import endpoint is
  RequireAdmin), so it no longer 403s silently for other roles
2026-07-14 15:36:10 +02:00
vulncheck 5703dfa806 feat(app-scan): Windows OS CVEs via cvelistV5 bounded ranges (Server AND Client)
Corrects bf529ed, which claimed Microsoft products cannot use the CPE/cvelistV5
path. That was wrong, and the reasoning behind it was a sampling error: the
'no ranges' check looked at CVE-2013-3900 and the 'lessThan: publication' check
at CVE-2021-24072 — both ancient. Modern MS records carry real numeric ranges.
Re-verified on the CVE the tester cited:

  CVE-2026-47291 cvelistV5: 20/20 affected entries have a numeric lessThan,
                            0 'publication'
                 NVD:       22/24 microsoft cpeMatch have versionEndExcluding
  CVE-2026-45467 (SharePoint, current): lessThan 16.0.5556.1005 etc.

So there is no Server/Client split and no reason to special-case Microsoft.

Implementation reads cvelistV5, not NVD, for one concrete reason: NVD flattens
these to an END bound only, so a 1607 host (14393.x) sits inside the 22H2 range
(endExcluding 19045.7417) and false-positives. cvelistV5 keeps the range bounded
(10.0.22631.0 .. 10.0.22631.7219), and those bounds select the host's release by
themselves — no build->release table, Server and Client fall out for free.

- pattern products in the index (one key for the whole 'Windows 10|11|Server'
  family) so a new release doesn't need a registry row; anchored so SharePoint,
  Teams and '.NET Framework on Windows Server 2016' can't be swallowed
- scan_asset_os() flags straight from asset.os_version; runs for every Windows
  asset, no software inventory required
- index bumped to v4 (entries changed) so it rebuilds once
2026-07-14 13:01:25 +02:00
vulncheck d4ac987c03 feat(msrc): SharePoint CVE detection via FixedBuild (2016/2019/SubEd)
Extends the MSRC scan from OS to installed MS software, hooked into the app-CVE
scan which already holds the inventory. Verified against live data first:

- Syscollector DOES report the serviced build, not RTM ('Microsoft SharePoint
  Foundation 2013' -> 15.0.4569.1506), so the FixedBuild compare is sound — the
  false-positive worry that blocked this doesn't apply.
- Branch semantics are per-product, not universal: Windows Server 2016 (14393)
  and SharePoint 2019 (10417) keep a stable 3rd segment, but SharePoint 2016
  moved 5535->5539->5543->5552->5556 in six months and SubEd 19127->19725.
  Prefix-matching those would silently match NOTHING, so products now carry a
   flag; SharePoint identifies by name (2016/2019/SubEd all report
  16.0.x) and compares plain installed < fixed, which is right because MS
  servicing is cumulative.
- SharePoint 2013 deliberately unsupported here: 0 products across six MSRC
  docs — Microsoft ships no fixes for it, so no FixedBuild can exist. Its EOL
  finding is the signal instead.

Reconcile is scoped to the product labels each pass actually evaluated —
otherwise the OS pass would auto-resolve every SharePoint finding it never
looked at, and vice versa.
2026-07-14 12:21:27 +02:00
vulncheck f5c2c58ab9 fix(eol): detect SharePoint end-of-life (Foundation/Server 2013-2019)
Neither the MS-lifecycle export nor the slug lookup knew SharePoint, so
Foundation/Server installs never produced an EOL finding — tester hit this on a
SharePoint Foundation 2013 box (EOL since 2023-04-11) with 2016/2019 going EOL
2026-07-14. endoflife.date does track it as 'sharepoint'; two gaps had to close:

- add the slug aliases (Server / Enterprise Server / Foundation / Designer)
- mark it year-keyed: releases are years ('2016') while the install reports a
  build ('16.0.5556.1005'), so prefix-matching the version against a cycle can
  never hit — the year comes from the product name (same shape as Office, and
  worse: 2016, 2019 and Subscription Edition ALL report 16.0.x, so the version
  alone cannot even tell the releases apart)

Subscription Edition is still supported and is skipped by the existing
wrapper-token guard, so it can't produce a false EOL. Dates verified against
the live endoflife.date API.
2026-07-14 12:04:40 +02:00
vulncheck e0b3e531b2 feat(msrc): patch-level Windows Server CVE detection from MSRC FixedBuild
Closes the Wazuh-CTI delay for Microsoft OS CVEs, which NVD/cvelistV5 provably
cannot: they carry no fixed build for MS products (rangeless CPEs /
lessThan:'publication'), so they can't distinguish a patched host from an
unpatched one. MSRC's CVRF does — each Type-2 remediation pairs a FixedBuild
with the ProductIDs it covers, e.g. CVE-2026-33834 -> 10.0.14393.9140 for
'Windows Server 2016' (+ Server Core), KB5087537.

- msrc_scan_service: builds a curated product->(cve, fixed_build, kb) index from
  the monthly CVRF docs (cached in a Setting, nightly rebuild), then flags a host
  when its build is behind the newest fix ON ITS OWN servicing branch (a 2016
  fix says nothing about a 2019 host). Auto-resolves + audit-logs once the host
  catches up; never touches findings another scanner reports.
- Product mapping is anchored so 'Microsoft .NET Framework 4.8 on Windows Server
  2016' can't be mistaken for the OS family. Windows client (10/11) is out of
  scope until there's a build->release table.
- Nightly job at 05:10 + POST /api/v1/vulnerabilities/msrc-scan (editor).

Verified against live MSRC data: a host at exactly 10.0.14393.9140 is correctly
NOT flagged; an older build is; a different branch is ignored.
2026-07-14 11:59:46 +02:00
vulncheck bf529ed421 docs(app-scan): record why Microsoft products can't use the CPE/cvelistV5 path
Investigated adding Windows Server (and SharePoint) to the app-CVE scanner to
beat the Wazuh CTI delay. Verified against live data that the premise doesn't
hold: NVD gives MS OS entries as rangeless CPEs
(cpe:2.3:o:microsoft:windows_server_2016:-:* with versionEndExcluding=null) and
cvelistV5 MS records use lessThan:'publication' — neither carries the fixed
build, so neither distinguishes a patched host from an unpatched one. _in_range
already rejects those wildcard/'-' versions, so registry entries would match
nothing while spending NVD quota. Leave a note where the next person will look
instead of shipping dead entries; MSRC (KB + FixedBuild) is the viable source.
2026-07-14 11:40:53 +02:00
vulncheck 7e15d6552a feat(notifications): keep lifecycle/EOL findings out of CVE mails
New notification_lifecycle_mode setting (default 'exclude'): pseudo-findings
that aren't real CVEs — EOL-* (endoflife.date), ANDROID-PATCH-* (patch-level
staleness), NESSUS-PLUGIN-* — no longer mix into the vulnerability mails. The
rule is 'anything not CVE-*' so future pseudo prefixes are covered without a
code change. Applies to both delivery modes (single/digest) and both schedules
(per-sync/nightly); 'include' restores the legacy mixed behaviour. Excluded
findings stay fully visible in the dashboard — only the mail routing changes.
2026-07-14 11:34:02 +02:00
vulncheck 1609d3edbc fix(wazuh): scroll the states index (fixes 400 + lost findings on big agents)
Paging used from/size, which OpenSearch hard-caps at index.max_result_window
(default 10000). Agents with more findings 400'd at from=10000, the whole agent
then read as '0 vulns' and its backfill was skipped — so hosts with 24k/27k
findings silently contributed nothing. Switch full syncs to the scroll API (no
window ceiling, no unique-sort requirement), with a fallback to windowed paging
if the indexer refuses scroll; explicit-offset callers keep from/size but stop
at the window instead of erroring.

Also hide the dashboard AI Audit controls + recommendations panel from
read-only users (the audit endpoint is editor-gated and threw 'Access denied').
2026-07-14 11:30:06 +02:00
vulncheck 904a3ec1ca fix(notifications): provide {{affected_assets_count}} in the digest template
The digest only exposed the per-CVE count (inside {{rows}}), so a top-level
{{affected_assets_count}} in the digest body stayed literal in sent mail (the
preview substituted it from sample data, masking the gap). Add it to the digest
variables = distinct assets across the whole digest ('affected systems' tile).
Docs + template hint updated.
2026-07-13 14:28:00 +02:00
vulncheck bf07b90330 docs(env): make DASHBOARD_URL prominent — it drives all email links
Email links (dashboard button, cve/asset deep links, the CVE links in the
digest table) are built from DASHBOARD_URL; unset → they fall back to
http://localhost:3000 and break. Uncomment it with the right default port and a
clear 'set this to how users reach the frontend' note.
2026-07-13 14:05:17 +02:00
vulncheck 93e2b1912c fix(ui): readonly AI card, clipboard fallback, template preview vars
- Hide the AI Remediation section for read-only users (Generate hits an
  editor-only endpoint; output isn't persisted, nothing to show them).
- Copy buttons: navigator.clipboard is undefined over plain http://<ip>
  (non-secure context) so copies silently failed — add a textarea+execCommand
  fallback.
- Email template Preview: add the new sample variables (cpr_score,
  affected_assets_count, cve/asset deep links) and a sample {{rows}} HTML table
  so previews render real values instead of literal {{...}} placeholders.
2026-07-13 13:15:48 +02:00
vulncheck 65d6c62561 fix(rbac): show assignee as text for read-only (not a dead 'Unassigned' select)
Read-only users can't fetch /auth/users (admin-only), so the disabled assign
<select> resolved to 'Unassigned' even when the finding/asset WAS assigned — the
head icon showed assigned but the name was hidden. Render the assignee name
straight from the payload (assigned_user_name / group name) as read-only text
instead. Applied on both the vulnerabilities and assets lists. Also fix a
pre-existing invalid title= prop on the Nessus <svg> (use a <title> child).
2026-07-13 13:09:00 +02:00
vulncheck 867498c3d3 fix(vulns): hide the Actions column entirely for read-only users
Read-only saw an empty Actions column with just a '—' placeholder. Gate the
header, the per-row cell, and the empty-state colSpan on role so the column
disappears for read-only instead of hanging around blank.
2026-07-13 10:39:12 +02:00
vulncheck e0fb38e344 docs: account lockout (temp/permanent + admin unlock) & syslog forwarding 2026-07-13 10:37:05 +02:00
vulncheck f9d9919615 feat(audit): forward audit-log events to syslog/SIEM (UDP/TCP, RFC-5424)
Every audit_logs insert is mirrored as an RFC-5424 syslog message with a
per-event severity (login failures/lockouts/access-denied → warning, security
alerts → alert, config/deletes → notice, else info). A SQLAlchemy after_insert
hook enqueues onto a bounded queue drained by one daemon worker (never blocks
the request/flush; bursty syncs drain sequentially). Config cached 30s; TCP
keeps a persistent socket with reconnect. Disabled by default → no-op until
enabled. Admin-only config card (host/port/UDP-TCP/facility) with a Test button;
POST /api/v1/settings/syslog/test sends a probe. No TLS yet (UDP+TCP).
2026-07-13 10:33:29 +02:00
vulncheck a289c07ccb feat(auth): permanent account lockout + admin unlock
Opt-in via the auth_lockout_permanent setting: after 5 failed logins an account
is locked until an admin clears it (post-incident review), instead of the
temporary 15-min auto-unlock. Safeguard: the last active admin is NEVER
permanently locked (falls back to the temporary window) so a brute-force on the
admin login can't lock everyone out for good.

- migration 037: users.locked + locked_at
- local.py: permanent-lock check + threshold escalation with last-admin guard
- POST /auth/users/{id}/unlock (admin, audit-logged); list_users exposes locked
- Settings UI: 'Permanent account lockout' toggle + per-user Unlock button/badge
2026-07-13 10:30:05 +02:00
vulncheck 4bb5138e8e docs: notifications (all sources, nightly, rate limit, CPR/vars) + RBAC scope
Bring the READMEs in line with this session's changes:
- New-CVE mails now fire from all four scanner sources (was Wazuh/Nessus only)
- Document notification_schedule (per_sync/nightly roundup), nightly hour, the
  rate-limit settings, and the windowing setting
- Digest table: CPR-desc sort, #Systems count, deep links; document the single-
  and digest-mode template variable sets and the editable digest template
- RBAC table: notification history is own-only for non-admins (privacy),
  reports viewable by all; broaden the 'no mail' troubleshooting row
2026-07-13 10:16:08 +02:00
vulncheck d698726621 feat(notifications): sort digest CVE table by CPR descending
The aggregated digest rows now render highest-CPR (most urgent) first, rows
without a CPR last — risk-led ordering the tester asked for. Per-CVE
affected-systems count was already surfaced (the #Systems column).
2026-07-13 10:10:05 +02:00
vulncheck 5e85a5cf25 fix(rbac): hide vuln write-actions from read-only + scope notification log
- Vulnerabilities list: the per-row Change-Status / Suppress / Mark-False-
  Positive buttons and the assign dropdown were shown to read-only users and
  threw 'Access denied. Required role: editor'. Gate them (editor+); assign
  select disabled for read-only.
- CVE detail page: Dismiss/Reactivate now shown only to editor+ (fetches
  /auth/me for the role).
- Notification log (/notifications): GET /log returned EVERY sent mail —
  recipient emails + which CVE went to whom — to any authenticated user (privacy
  leak). Now admins see all; everyone else sees only notifications addressed to
  them (user_id == own).
2026-07-13 09:41:21 +02:00
vulncheck 4a25a5d1c0 feat(notifications): add CPR, affected-systems count & deep-link template vars
Both single and digest new-CVE emails now expose risk/priority context and
ready-made links so operators don't hand-build URLs:
- cpr_score: the contextual priority rating (risk-led triage)
- affected_assets_count: distinct assets carrying this CVE (blast radius),
  computed once per batch via a grouped query
- cve_link / asset_link / cve_on_asset_link: prebuilt deep links into the
  vulnerabilities view (?cve_id=, ?asset_id=, both)
Digest rows gain CPR + #Systems columns and a clickable CVE; the single-mode
default template shows CPR, affected count and two action buttons. Settings
variable hints updated.
2026-07-13 09:37:25 +02:00
vulncheck 702dfcbfb3 feat(notifications): editable digest template + nightly roundup + rate limit
#2 The digest template (email_template_new_vuln_digest, actually sent in the
default digest mode) was not editable in the UI — only the single-mode template
was — so a customized template appeared unused. Add a digest-template editor in
Settings (mode-aware Active/Inactive badges clarify which applies).

#3 Add a nightly aggregated roundup: notification_schedule='nightly' suppresses
per-sync mails and a self-gated hourly scheduler job sends ONE digest per
recipient of all CVEs since the last run (windowed via
notification_nightly_last_run), at notification_nightly_hour. Plus GUI-config
send rate limiting (email_rate_delay_seconds, email_max_per_run) applied in the
dispatch loop against provider anti-spam.
2026-07-13 07:53:25 +02:00
vulncheck 440e96f965 fix(notifications): send new-CVE emails for app-scan & Defender sources
dispatch_new_vuln_notifications was only called by the Wazuh and Nessus syncs,
so brand-new findings from the app-CVE scanner and Defender TVM never triggered
a notification (tester: 'only Wazuh mails arrive'). Call the same digest
dispatch after each of those syncs enriches its new findings.
2026-07-13 07:40:53 +02:00
vulncheck 01e766dace fix(vulns): hide Refresh Threat Intel from read-only users
Every other admin toolbar button on the vulnerabilities page is hidden for
read-only (role !== 'readonly'), but Refresh Threat Intel was only disabled —
so it showed greyed-out and dead. Hide it like the rest for consistency.
2026-07-11 10:46:05 +02:00
vulncheck 7bb396a606 fix(eol/app-scan): supersede stale EOL releases + detect Android Chrome
Two Chrome-on-mobile issues:

1. Duplicate EOL findings (EOL-CHROME-149 AND -150 on one iPad running only
   150). A product runs one release per asset, so upsert_eol_vulnerability now
   supersedes any other open EOL-<slug>-* on the same asset (marks it patched +
   audit-logs it). Generic — also cleans up OS major upgrades (Server 2016->2019
   etc.). Fixes the tester's 'why two EOL' question.

2. Android Chrome went undetected: Intune reports it by package id
   'com.android.chrome', which didn't match the 'google chrome' registry regex.
   Add it to both scanners (curated NVD-CPE + cvelistV5). Android shares Chrome's
   version numbers and security fixes with Desktop (per Google), so the same
   google:chrome ranges apply. Bare 'Chrome' (iOS, WebKit-backed) stays
   unmatched by design.
2026-07-10 15:32:52 +02:00
vulncheck 1c7a0c42d1 fix(assets): serialize intune_device_id so MDM assets get software/app-scan actions
AssetResponse omitted intune_device_id, so the frontend's software-inventory
and app-rescan buttons (gated on wazuh_agent_id || intune_device_id) never
appeared on Intune-managed iOS/macOS/Android devices — only Wazuh hosts. Add
intune_device_id (+ defender_machine_id) to the response; the software endpoint
already handles the Intune detectedApps path.
2026-07-10 14:36:40 +02:00
vulncheck 96e6072a41 fix(assets): hide write-actions from read-only users (no more 403 alerts)
A read-only user saw every row action (Wazuh/Nessus/app rescan, edit, delete,
assign) and Add Asset, but the backend gates those (RequireEditor/RequireAdmin)
so clicking threw 'Access denied. Required role: editor'. Fetch the current
role and show mutating actions only to editor+ (delete to admin); read actions
(installed software, coverage gap, exposure) stay for everyone.
2026-07-10 14:07:31 +02:00
vulncheck 4aac1bfd06 fix(settings): hide OpenRouter/Intune config from non-admin users
Every other card in the System-config column is already gated behind
userRole==='admin', but the OpenRouter and Intune cards rendered
unconditionally, so a read-only user saw empty admin config with dead
Save/Test buttons (backend already 403s them via RequireAdmin, so no data
leak — but confusing). Gate both like the rest.
2026-07-10 14:02:59 +02:00
vulncheck ae52cfe293 feat(assets): on-demand installed-software view per asset
New GET /assets/{id}/software pulls the live software inventory (Wazuh
syscollector packages, else Intune detectedApps) — the same inventory the
app-CVE scanner consumes — without persisting it. A list-icon button on each
Wazuh/Intune-backed asset row opens a modal with name/version/vendor.
Read-only, deduped, sorted. No DB schema (ponytail: on-demand until a
searchable/reporting inventory is actually needed).
2026-07-10 13:37:47 +02:00
vulncheck c3b9bb6cbf fix(app-scan): exclude OpenSSL FIPS builds (FP) + per-asset app re-scan button
- OpenSSL FIPS provider builds (e.g. Veeam's 'OpenSSL v3.0.0 FIPS') were
  matched against OpenSSL CVEs, but the advisories explicitly carve the FIPS
  modules OUT (vulnerable code is outside the FIPS boundary) and they carry a
  separate 4-part build version — pure false positive (CVE-2025-15467). Exclude
  via negative lookahead 'openssl(?!.*fips)'. Existing FP self-heals: next scan
  no longer detects it -> auto-resolve marks it patched (now audit-logged).
- Add an 'App CVE re-scan' action button on asset rows (Wazuh- or Intune-backed
  assets) that hits the existing POST /vulnerabilities/app-cve-scan?asset_id=,
  analogous to the Wazuh rescan button.
2026-07-10 13:33:25 +02:00
vulncheck ebea13f740 fix(audit): log auto-resolve status changes for app-scan/defender/verify
Automated patch transitions were silently flipping status to 'patched' with no
audit trail — no global audit-log row and no per-CVE Change History entry, so
the remediation was invisible (Revisionssicherheit gap the tester flagged on
CVE-2026-15131). log_vulnerability_change already writes one AuditLog row that
feeds BOTH surfaces; Wazuh/Nessus sync already called it, three paths did not:

- app-scan auto-resolve (_resolve_stale_app_findings): now logs source=app_scan
- Defender TVM had NO auto-resolve at all — add one (defender-only, guarded to
  non-empty machine responses) that logs source=defender_sync
- verify_patch_with_rescan flipped patched/patch_failed without logging — now
  records the outcome (source=verify_patch_rescan)

All automated (user_id=None) with a WHO/WHEN/WHY reason string.
2026-07-10 08:03:33 +02:00
vulncheck 572b6fe217 fix(settings): handle skipped/empty sync result + clearer status colors
The sync-status poll showed 'Sync done — undefined devices…' in green when a
run was skipped by the advisory lock (result = {skipped}). Branch on skipped
(amber 'already running'), guard the stat fields with ?? 0, and give states
distinct colors: loading=blue pulse, warning=amber, success=green, error=red.
2026-07-09 15:16:54 +02:00
vulncheck 18080a31e4 fix(app-scan): auto-resolve patched findings + import CVSS from cvelistV5
Two gaps surfaced by a Chrome finding (CVE-2026-15131):

1. App-scan was additive-only: after Chrome was updated past the vulnerable
   version, the CVE simply stopped being re-detected but the open finding
   lingered forever. Add a per-asset reconcile — an app-scan-ONLY open finding
   not re-detected this run (and not claimed by Wazuh/Nessus/Defender) is marked
   patched. Guarded to runs that actually had a package inventory so a transient
   fetch failure can't false-close everything.

2. The cvelistV5 scanner hardcoded cvss=None, so findings it created showed CVSS
   N/A even though the record carries a 3.1 vector. Extract baseScore/severity
   (_cvss_from_record, CNA>ADP, 3.1>3.0>4.0), store per index entry (index bumped
   to v3), and backfill cvss_score/fixed_version/severity onto existing findings
   when better data arrives. Self-check for the parser under __main__.
2026-07-09 15:16:54 +02:00
vulncheck 381c21034c fix(dashboard): show 10 distinct CVEs in Newly-Published/EOL widgets
The widgets fetched a per-asset-duplicated list and deduped by cve_id
client-side. When a CVE sits on many assets, a handful of CVEs x N assets fill
the whole fetch window, so dedup starved and the widget showed ~4 of 10. With
131 assets no limit can guarantee 10 distinct (10x131 > 1000 cap).

Add a distinct_cve query param to the vulnerability list: a row_number() window
partitioned by cve_id keeps one representative row per CVE (newest published,
then most recent) server-side, composing with every sort_by. Newly-Published
and EOL widgets pass distinct_cve=true (Mobile stays per-asset by design).
2026-07-09 14:02:49 +02:00
vulncheck e78b4fef80 feat(intune): sync status endpoint + GUI polls for completion
The manual sync is fire-and-forget (202), so the GUI got stuck on 'Sync started
in background' forever. Track last/current sync state in the router and expose
GET /sync/status; the settings page now polls every 3s and shows the real
result (devices/matched/created/app-findings + Defender new CVEs) or the error.
2026-07-09 13:59:00 +02:00
vulncheck ee4e1d9c8b fix(intune): serialise sync with a Postgres advisory lock (deadlock fix)
Two overlapping Intune/Defender syncs updated the same asset rows in different
orders -> 'deadlock detected' during flush. The router's _INTUNE_SYNC_RUNNING
flag is per-worker (Gunicorn has several) and the nightly scheduler runs in its
own context, so neither guards cross-process. Wrap the whole sync in
pg_try_advisory_lock: a second concurrent caller is now cleanly skipped instead
of deadlocking. Lock is released (rollback-safe) in finally; the nested Defender
sync shares the same locked session.
2026-07-09 13:40:50 +02:00
vulncheck e9ed5133a4 docs(api): add external REST-API usage guide with curl + Python examples
Login → Bearer-token flow, filtered vulnerability list, asset inventory pull,
token refresh, and a Python integration snippet. Documents query params,
response shapes (bare array vs {items,total}), and the ENABLE_API_DOCS toggle.
2026-07-09 13:28:52 +02:00
vulncheck 099c1d8af0 feat(api): rebrand OpenAPI title to TrueVuln + ENABLE_API_DOCS opt-in
Expose Swagger /docs + ReDoc /redoc on a production instance without full
development mode (ENABLE_API_DOCS=true) for ITSM/CMDB integrators. Raw
/openapi.json spec is served regardless. OpenAPI title/description rebranded
VulnManager -> TrueVuln.
2026-07-09 13:26:19 +02:00
vulncheck 937251fc24 feat(intune/defender): AAD-id cross-merge + readable device names, rename-safe
Tester hit an asset named "<enrollment-GUID>_IPad_6/16/2026_9:58 AM" — Intune's
management-name blob that Graph puts in `deviceName` for supervised/userless/
ABM iOS devices. Two improvements:

1. Readable, stable hostname (_clean_device_name in intune_service): when
   deviceName is a management-name GUID blob, compose "<model>-<serial>"
   (e.g. iPad13,4-DMPXK7MP9Y) from fields we already fetch; real names pass
   through unchanged. Existing ugly assets self-heal on the next sync — the
   asset is matched by its stable id and its hostname refreshed (rename
   tracking, which also covers the autodeploy renames the tester asked about).

2. Cross-source merge on the Entra/AAD device id. The same physical device has
   different per-service ids (intune_device_id ≠ defender_machine_id) and only
   merged by hostname before — which fails when Intune's deviceName and
   Defender's computerDnsName differ (both often the management name). Now both
   syncs store + match on the AAD device id (Intune `azureADDeviceId` ==
   Defender `aadDeviceId`): match cascade id → aad_device_id → hostname, and
   each sync backfills both ids so one physical device becomes one asset.

Migration 036 adds assets.aad_device_id (indexed, nullable, idempotent).
Verified the name-cleaning against the exact blob + real-name + Windows cases.
2026-07-09 13:21:03 +02:00
vulncheck ce1765fb42 fix(remediation): show the right MSRC fix for multi-product CVEs (.NET vs VS)
A CVE that affects .NET AND Visual Studio (and SQL, etc.) carries one MSRC
fix build per product. The remediation view showed the wrong one — a .NET
Host 8.0.12 finding displayed "build 18.6.3 → Visual Studio 2026 18.6"
instead of the .NET update.

Two causes in the MSRC display logic (get_vuln_remediations):
1. The dedup keyed on (Windows-build-branch, update-type). Non-Windows
   product builds (.NET/VS/SQL) have no 4-part Windows build → branch None,
   so DIFFERENT products collapsed into one entry (last-wins = VS). Now
   branch-None fixes are keyed on the build itself, so each product's fix
   survives.
2. No product scoping. Added a filter: for a non-Windows, non-M365 finding,
   narrow the shown fixes to the build line whose major matches the installed
   package version (8.0.12 → major 8 → keep .NET 8.0.28, drop VS 18.6.3).
   Falls back to the full set when nothing matches, so a fix is never hidden.

Migration-free (display-only; the DB already stores every product's fix as a
separate cve_remediations row). Verified the dedup keeps both builds and the
filter picks 8.0.28 for a .NET 8.0.12 finding.
2026-07-09 10:07:42 +02:00
vulncheck 340e70ce67 perf(api): run heavy endpoints in the threadpool (stop freezing the web GUI)
Blocking operations (SMTP send loops, Wazuh/Nessus/Graph HTTP, bulk
re-scoring) were declared `async def` but do synchronous work inline, so they
ran ON the event loop and froze the entire web GUI until they finished
(tester: "Notify All Critical/High" locks TrueVuln the whole time, and the
same at many other places).

Converted the offending endpoints to plain `def` so Starlette runs them in a
worker threadpool off the event loop — the GUI stays responsive while they
run. Verified each has no `await` in its body before converting (mechanical,
behaviour-preserving):

- notifications: notify-critical (the reported one), test email
- assets: coverage-gap, refresh-exposure, sync-wazuh-assets, rescan-asset
- scans: trigger-autoscan
- vulnerabilities: enrich-single, refresh-kev-catalog, wazuh sync,
  override-from-nessus, recompute-priority-scores
- nessus: test-connection, list-scans, scan-host

(Several other heavy endpoints — m365-check, eol-check, exploit-intel,
bulk-enrich, nessus /sync, app-cve-scan, suppress-fp — were already sync def
from earlier in this work.)
2026-07-09 10:03:11 +02:00
vulncheck d54a821054 docs: explain what asset/finding assignment does + owner-assignment runbook note
Tester asked what assignment actually does and whether owner-assignment must
be mandatory in the runbook. Documented the real behaviour rather than
changing code (per decision, SLA stays assignment-only):

- New README.DEV.md subsection "Asset / Finding assignment — what it actually
  does": the recipient cascade, what assignment controls (new-CVE digest with
  admin/default fallback vs. SLA-breach digest with NO fallback, the
  Assigned-To column, auto-cleanup) and what it explicitly does NOT do
  (scanning/scoring/SLA-calc/reports/visibility — all role-based). Runbook
  recommendation: assign each asset to a system owner, since that's the only
  way SLA-breach mails reach anyone and to route new-CVE mails to the real
  owner instead of blanket-to-admins.
- Documented the new notification_default_recipients setting in the settings
  table; refreshed the "no mail after sync" troubleshooting row (the fallback
  changed when it's truly empty) and the fresh-install step.
- One-line notes in README.md Notifications + Asset Management sections.
2026-07-09 09:58:40 +02:00
vulncheck d6d2c3209b fix(ui): rebrand left ~115 truevuln-blue classes with no matching CSS var
The rebrand renamed many Tailwind color classes vulncheck-blue → truevuln-blue
but left the theme token as --color-vulncheck-blue, so those 115 classes
referenced an undefined color → transparent backgrounds / unstyled text
(tester: the "Save Template" button was invisible until hover; also affected
buttons, links, focus rings, sort arrows app-wide).

Unified everything on truevuln-blue: renamed the @theme token to
--color-truevuln-blue and the remaining 156 vulncheck-blue class usages to
truevuln-blue. Now all 271 usages resolve to one defined token; 0 vulncheck-blue
left.
2026-07-09 08:21:07 +02:00
vulncheck 405b4bc541 fix(notifications): fall back to default recipients / admins for unassigned findings
New-vulnerability emails only went to a finding's assigned user/group (or its
asset's). The thousands of scanner findings are assigned to nobody, so the
nightly digest and the bulk "Notify All Critical/High" resolved zero
recipients — "Sent: 0, Failed: 0" — even with SMTP configured and an admin
email set.

_resolve_recipients_for_vuln now falls back, when the assignment cascade is
empty, to get_default_recipients(): the configured
`notification_default_recipients` setting (comma/semicolon/space-separated
emails), or — if unset — every active admin user, so "the admin gets
everything" works out of the box. The bulk-notify endpoint was rewritten to
use the same cascade (it previously only checked asset assignment, with no
fallback). Added a "Default Recipient(s)" field to the notification settings UI.
2026-07-09 08:21:07 +02:00
vulncheck b35c2c95f7 fix(app-cve-scan): .NET version-from-name, Teams add-in FPs, cvelistV5 platform filter
Three tester-reported issues:

1. .NET Runtime/Desktop Runtime/SDK/Host not detected. Wazuh syscollector
   reports the MSI build number (48.x/94.x) in the version FIELD while the
   real semantic version (6.0.16) is only in the display NAME — so the scan
   queried a bogus version that never matched NVD's 6.0.x/8.0.x ranges.
   Registry entries can now set name_ver=True; _effective_version pulls the
   version from the name for those. The finding's package_version now shows
   the semantic version too. Developer reference packs (Framework X.Y
   Targeting Pack / Multi-Targeting Pack / SDK / Client Profile) are excluded
   — they're build-time assemblies, not the installed runtime, and would
   false-positive against runtime CVE ranges. The standalone .NET SDK still
   matches (it's a real product with its own CVEs).

2. Teams false positives: "Microsoft Teams Meeting Add-in for Microsoft
   Office" and "Microsoft Teams VDI Citrix plugin" were matched as Teams and
   picked up Teams-app CVEs (CVE-2023-29330, CVE-2025-49737). They're
   separate products with their own versioning. The Teams name-regex (both
   the NVD-CPE and cvelistV5 registries) now excludes add-in/plugin/vdi/
   citrix/machine-wide.

3. cvelistV5 scanner had no OS-platform check, so a macOS-only Teams CVE
   (CVE-2025-49737) could land on a Windows host. The index now stores each
   affected[] entry's platforms; scan filters by the asset's OS family — but
   only when the record lists a real OS (Windows/macOS/Linux/Android/iOS).
   Microsoft also uses platforms for CPU arch ("x64-based Systems"), so
   arch-only records are kept (can't judge the OS → never drop on arch).
   Also dropped the "Teams for Mac" product pair (belt-and-suspenders, since
   not every CNA fills platforms). Index cache bumped to v2 to force a
   rebuild with the new platforms field.

All resolution/version/platform logic verified against the real installed-
name strings and the actual CVE CPE data.
2026-07-09 08:21:07 +02:00
vulncheck 064daaf8ed Delete directory 'patches' 2026-07-07 17:03:26 +02:00
vulncheck 10692c67d4 fix(docs): use relative path for dashboard screenshot, not a main-pinned URL
The image was hardcoded to /raw/branch/main/..., so viewing README.md on the
dev branch still rendered main's (stale) screenshot regardless of what dev
actually had. A relative path resolves against whatever ref/branch is being
viewed (Gitea/GitHub both do this), so dev shows dev's image and main shows
main's once merged.
2026-07-07 17:02:20 +02:00
vulncheck 4417dc18ae chore(docs): remove stray docs/dashboard.png/ duplicate upload
Accidental duplicate from the Gitea web-upload UI (a folder named
dashboard.png containing a file of the same name); the correct,
referenced screenshot lives at docs/screenshots/dashboard.png.
2026-07-07 17:00:30 +02:00
vulncheck 2aa8b60b5b Upload files to "docs/dashboard.png" 2026-07-07 16:56:28 +02:00
vulncheck 1f305b5be3 Upload files to "docs/screenshots" 2026-07-07 16:55:36 +02:00
vulncheck c90a7bc0e5 docs: document this session's features, simplify frontend README
README.md: the "Features" section was silent on everything built this
session — added a subsection for the built-in App→CVE scanner (OSV/NVD-CPE/
cvelistV5), Wazuh false-positive suppression, and mobile device security
(EOL/EOS, Android patch-level, per-CVE Android via Samsung SMR/Google ASB);
extended the KEV bullet to mention the new advisory dashboard widget; added
the new /api/v1/advisories endpoint group to the API table.

README.DEV.md: added a 6th feature-group entry to "What this branch adds"
and a full new section (matching the doc's existing per-feature style —
what/why, module map, design decisions, out-of-scope) covering the
App→CVE engine, FP suppression, mobile device security, and the KEV
advisory feed, so this session's work has the same documentation trail as
every earlier feature drop in this doc.

frontend/README.md: was still the untouched create-next-app boilerplate,
including "Deploy on Vercel" instructions that don't apply (this app ships
via Docker Compose). Replaced with a short pointer to the root README/
README.DEV for setup and deployment.

Not touched: .env.example DEFAULT_ADMIN_EMAIL (admin@vulnmanager.local) —
verified consistent with README's own reference to it, no drift.
2026-07-07 16:46:43 +02:00
vulncheck a4b8c85d06 fix(rebrand): catch the split-span brand name the string rename missed
Nav/Drawer render the name as two separate JSX text nodes ("Vuln" + a
<span> for the color-accented "Check") for the two-tone styling — the
literal string "VulnCheck" never appears in the source, so the earlier
exact-string rename pass didn't touch it. Logo/Nav still showed "VulnCheck"
after that commit (tester screenshot). Now "True" + accented "Vuln",
matching the rest of the rebrand. logo.svg itself is just the shield+check
glyph with no embedded text — nothing to change there.
2026-07-07 16:39:18 +02:00
vulncheck dbad9a365e chore(rebrand): VulnCheck → TrueVuln
Renames the product name in every user-visible surface and internal
self-reference: page title, nav/shell, login/MFA pages, email templates and
subject prefixes ([VULNCHECK] → [TRUEVULN]), TOTP issuer label, report/PDF
headers, notification previews, outbound User-Agent/HTTP-Referer headers we
set ourselves, docs (README, ARCHITECTURE, PROJECT_OVERVIEW, DATABASE_SCHEMA,
README.DEV, TROUBLESHOOTING is untouched — see below), and .env.example
placeholder config (LDAP/OIDC/SAML example domains and paths).

Also renamed the on-disk cache file paths (/tmp/vulncheck-*.zip|csv|json →
/tmp/truevuln-*), kept consistent across the two files that share the
cvelistV5 ZIP cache path — first run after deploy re-downloads that ~557 MB
cache once (harmless, disposable).

Deliberately LEFT UNCHANGED (not branding — real external references or
infra identifiers; renaming the text without renaming the underlying thing
would just break/mislead):
- The actual Gitea repo URL/path (gitea.isuit.ch/vulncheck/vulncheck) and the
  README lines derived from it (git clone target dir, tree listing) — a real
  repo rename is a manual Gitea-side step (Settings → repository name) the
  user would need to do themselves, and existing clones would need
  `git remote set-url` after.
- The real support mailbox (support-vulncheck.sq9vd@passmail.net, in both
  README and TROUBLESHOOTING) and the Buy Me A Coffee link — both point to
  accounts that still exist under the old name; renaming the text alone
  wouldn't create new ones.
- GitNexus MCP resource URIs in CLAUDE.md/AGENTS.md (gitnexus://repo/
  vulncheck/...) — tied to GitNexus's own index name for this repo, not our
  branding; those files are untracked in this repo anyway.
- docker-compose.yml container/network/Postgres user+db names
  (vulnmanager-*) — explicit user decision: infra naming carries real
  deploy/data risk on an already-running instance and isn't part of the
  product-branding ask.
- The Tailwind color token class `vulncheck-blue` (frontend/app/globals.css)
  — invisible internal CSS variable name, renaming it would touch ~270
  className occurrences for zero user-visible benefit.

Verified: backend py_compile clean on every touched .py file; frontend tsc
clean (two pre-existing, unrelated errors remain: assets/page.tsx SVG title
prop, mfa-setup missing qrcode.react types). All diffs are exact-string
renames — no other changes riding along.
2026-07-07 16:34:40 +02:00
vulncheck a75d2d87be feat(app-cve-scan): detect .NET Runtime/SDK and Microsoft Teams CVEs
Tester-reported false negatives (Wazuh's own detector misses these; our
app-scan is independent — it works off Wazuh's raw package inventory, not
Wazuh's CVE engine, so it can catch what Wazuh's detector can't).

- Microsoft .NET Runtime/Desktop Runtime/SDK/Host, and .NET Framework: NVD
  files ALL of them (6/7/8/9 and Framework 2.0–4.8.1) under the SAME CPE
  (cpe:2.3:a:microsoft:.net), each major version scoped by its own cpeMatch
  range — one registry row covers both families via the existing NVD-CPE
  scanner. .NET Framework itself typically isn't a Windows "installed
  program" (it's a Windows feature, not an MSI package), so it's usually
  invisible to Wazuh syscollector's package list regardless of this
  registry row — documented as a Wazuh-agent data-source limit, not
  something curation can fix. "Teams Machine-Wide Installer" excluded
  (placeholder entry, not a real Teams binary).
- Microsoft Teams (new + classic, both use cpe:2.3:a:microsoft:teams):
  added to both the NVD-CPE registry and the cvelistV5 registry (defense in
  depth — a fresh Teams CVE from Microsoft's own CNA data can predate NVD's
  CPE processing, as seen with Edge CVE-2026-58523 today).

Verified resolution against realistic installed-program name strings
(Desktop Runtime/SDK/Framework variants, Teams, Teams Machine-Wide).

Deferred (separate, larger pieces — not in this change):
- SAP Business Client/GUI/Analysis for MS Office: NVD's CPE for these covers
  a whole minor version (e.g. all of 2.8) with no patch-level (PL/SP)
  granularity, so a naive match would false-positive on already-patched
  installs (tester's own example: 2.8 SP29 flagged when only earlier SPs are
  vulnerable). Needs a dedicated PL/SP-aware matcher — parse the SP/PL
  number out of the installed version/display string and compare against a
  curated per-CVE fixed-SP table sourced from SAP's own advisories, similar
  in shape to the Samsung SMR-vs-ASB precision work. Real scope, own turn.
- Microsoft Teams classic EOL flagging: no endoflife.date product exists for
  Teams; would need a hardcoded EOL/retirement-date entry (same shape as the
  existing ms_lifecycle_service exotics for Silverlight etc). Small add-on,
  can do on request.
2026-07-07 13:40:22 +02:00
vulncheck 41947920dc fix(app-cve-scan): NVD cache TTL, zero-width ranges, Edge, Graph 429 pacing
1. NVD/OSV per-(product,version) cache TTL 7d → 24h. A version queried
   BEFORE a new CVE for that exact version is published stays cached empty
   for the whole window, hiding the CVE from every host on that version
   until it expires — exactly the tester's CVE-2026-14152 (undetected) vs
   CVE-2026-13778 (detected, same day) discrepancy. 24h still collapses
   most redundant traffic (many hosts share a version).

2. cvelistV5 range parser: some CNA records emit "version" == "lessThan"
   (e.g. a few Chrome entries) — read literally that's a zero-width,
   impossible range, so the CVE was silently unmatchable. NVD treats these
   as an open floor (no lower bound); we now do the same instead of
   dropping the CVE. Verified against the exact malformed record.

3. Added Microsoft Edge (Chromium-based) to the cvelistV5 registry — it was
   only in the NVD-CPE registry, so a fresh Edge CVE with no NVD-CPE data
   yet (verified: CVE-2026-58523 has none) was invisible to cvelistV5
   detection too, despite the CNA (Microsoft) publishing its own
   affected[]/cpeApplicability directly in the record.

4. Graph get_detected_apps: paced with a 300ms minimum gap between calls on
   one client. A device-by-device sync loop was sustaining 429s from Graph
   (single-call retry wasn't enough against back-to-back device calls).

(No change: the "400 then 200" on the first device of the beta fallback is
expected — _v1_detected_apps_ok is per GraphClient instance, and a fresh
client is built each sync run, so the first device in a run always probes
v1.0 once before flipping to beta for the rest of that run.)
2026-07-07 13:37:07 +02:00
vulncheck 7425d79453 feat(android): Samsung SMR as primary source, ASB as fallback; fix stale cache
1. Samsung SMR precision (tester's point 1): security.samsungmobile.com's
   yearly page (?year=YYYY) serves ALL ~12 months' content server-side in the
   raw HTML — the accordion is pure CSS/JS, it doesn't gate what's delivered
   — so it IS reliably parseable per month, contrary to what I assumed
   earlier. New samsung_smr_service parses each SMR-MMM-YYYY block: Google
   Critical/High CVEs MINUS the "Not applicable to Samsung devices" list,
   plus Samsung Semiconductor Critical/High fixes. Cached per year (24h).

   android_cve_service.check_android_cves now takes `manufacturer`: Samsung
   devices try the SMR month first (source 'samsung-smr'), falling back to
   raw ASB (source 'android-asb') only when SMR doesn't cover that month or
   the fetch fails. Non-Samsung Android stays on ASB. This directly fixes
   the false positive the tester hit: CVE-2025-59604 (Qualcomm-only) is
   explicitly "Not applicable to Samsung devices" on Samsung's own page and
   is now excluded — verified against the live page (absent from the parsed
   June-2026 set).

2. Cache bug (tester's point 2): a device stuck showing only the newest
   month's CVEs despite older months (verified live: 2025-09 had 59, 2025-12
   had 57 AOSP critical/high CVEs) turned out to be stale cache entries from
   before today's URL-format and SoC-section-filter fixes — the "non-empty is
   immutable" cache rule kept serving pre-fix (empty or wrong) data forever.
   Bumped _CACHE_PREFIX (asb_month_v2_) so every month is refetched under the
   current, correct logic.

Both parsers verified against live data (JS + Python) before shipping:
19 months of Samsung SMR (2025-01–2026-07) parsed without error with
plausible per-month counts; ASB severity counts confirmed non-zero and
variable across all 12 lookback months.
2026-07-07 13:30:55 +02:00
vulncheck 5d2669df21 feat(advisories): CISA KEV "actively exploited" awareness feed
New security-advisory section, independent of asset findings: a rolling view
of what's being actively exploited in the wild so operators see 0-days/
exploited CVEs even before a scanner flags an affected asset.

- Reuses the KEV catalog enrichment already fetches + caches (24h); added
  vendor/product/name to the cached entry (enrichment ignores the extras).
- advisory_service.get_recent_kev: newest KEV entries, annotated with whether
  the CVE is already in inventory (+ asset count) via one grouped query.
- GET /api/v1/advisories/kev-recent (new advisories router).
- Dashboard widget "Actively Exploited · CISA KEV": CVE, vendor/product,
  date added, 🔒 ransomware flag, and an "In inventory · N" badge (vs
  "not seen"). Rows link to our CVE detail; View All → the CISA catalog.

Backend py_compile + frontend tsc clean.
2026-07-05 17:17:55 +02:00
vulncheck f645cadc7b fix(android-asb): only import AOSP CVEs, skip chipset/SoC vendor sections
Raw ASB import created false positives on Samsung (and any) devices: ASB
lists chipset-vendor CVEs (Qualcomm/MediaTek/Unisoc/Imagination/Arm) that
only affect devices with that SoC — Samsung's own SMR marks many "Not
applicable to Samsung devices". The parser now walks section headers + rows
in order and keeps only the AOSP sections (Framework/System/Kernel/Runtime/
Media/Google Play/Widevine) that apply to any Android device at that patch
level; SoC/vendor sections are dropped.

Verified on 2025-09: 115 total → 59 AOSP kept, ~49 SoC dropped; the Framework
KEV CVE-2025-48543 is retained.

(Samsung's own page can't scope per-device either without JS — it loads
month bodies via AJAX — so AOSP-section filtering is the robust fix. Samsung-
proprietary SVE CVEs remain out of scope.)
2026-07-05 17:00:08 +02:00
vulncheck 4cd39b163a fix(enrichment): pass db to VulnOverrideService in cvelistV5 date pass
VulnOverrideService.__init__ requires db, but the bulk cvelistV5 ZIP date
pass called it with no args → "missing 1 required positional argument: 'db'"
and fell back to slow per-CVE HTTP lookups. db is in scope; pass it.
2026-07-05 17:00:08 +02:00
vulncheck 101445109f fix: ASB 2026 URL format, cvelistV5 fixed_version, macOS OS-CVE scan
- android-asb: from 2026 Google nests the bulletin under a year segment
  (/bulletin/2026/2026-01-01); older months stay flat (/bulletin/2025-10-01).
  fetch_asb_month now tries the year-nested URL first, then flat. Verified:
  2026-01 → nested 200/flat 404; 2025-xx → nested 404/flat 200.
- cvelistV5 scan: stop using lessThanOrEqual as fixed_version — that bound
  means the version is still affected (no published fix), so leave
  fixed_version empty (correct "no patch available", e.g. 7-Zip CVE-2026-58052
  ≤26.02). Only lessThan is a real fix target.
- app-cve OS scan: add macOS (cpe:2.3:o:apple:macos) alongside iOS/iPadOS so
  Mac assets get OS CVEs (e.g. Apple CVE-2026-43700, previously only Defender
  saw it).
2026-07-05 16:54:50 +02:00
vulncheck 59e4afbeb3 fix(android-asb): negative-cache empty/404 months to stop the refetch storm
Future ASB months Google hasn't published yet return 404. fetch_asb_month
only cached successful months, so every Android device re-fetched the same
404 months on every sync (the tester saw 2026-01…06 hammered dozens of times).

Now empty/404 results are cached too, with a 3-day TTL: months with CVEs stay
immutable/cached forever; empty months are skipped for 3 days, then retried
so a newly-published month is picked up. Legacy list-format cache entries
still read correctly.

(The 404s are expected — those months aren't published on source.android.com
yet; the 2025 months that exist already produced android-asb findings.)
2026-07-05 16:51:53 +02:00
vulncheck ed78810ad8 feat(intune): per-CVE Android detection from Google ASB
For Intune-managed Android devices, raise real-CVE findings for the months
the device is behind on patches. Device patch level (androidSecurityPatchLevel,
e.g. 2025-03-01) → every monthly Android Security Bulletin published after it
lists CVEs the device hasn't received. Fetched from source.android.com
(stable, static, per-month) and parsed (CVE + severity, severity carried
across rowspan rows); cached per month in a Setting.

Source 'android-asb' (real CVE ids → EPSS/KEV/CVSS enrichment applies). Wired
into the Intune sync alongside the patch-level-staleness headline finding.

Why not Samsung's SMR page: securityUpdate.smsb ignores the year/month query
param and loads the month via JS — a plain fetch returns the same latest month
regardless (verified: 2025-03/-04/-09 all identical). ASB is the upstream
source for the Google CVEs Samsung ships (the security-critical bulk).
Samsung-proprietary SVE CVEs aren't covered (their page is unscrapeable).

Volume guards: Critical + High only, last 12 months (tunable via _MAX_MONTHS
/ _WANT_SEV). Verified month math + the ASB parser against the live bulletins
(2025-09 → 109 CVEs, per-month distinct).
2026-06-30 10:48:06 +02:00
vulncheck c1fc992285 fix(app-cve-scan): build cvelistV5 index on manual scan + faster/robust build
Why the tester saw nothing: the manual "App CVE Scan" only LOADED the cached
index, it never built it — only the nightly did, and that build likely timed
out on the 557 MB download (120 s). So 7-Zip CVE-2026-58052 / Notepad++
CVE-2026-52885 (both in-registry, in-range) were never matched.

- Manual scan now builds the index when it's missing (same result as the
  nightly), then caches it.
- ZIP download timeout 120 s → 600 s.
- Index build pre-filters on raw bytes (only JSON-parse files mentioning a
  curated vendor) → ~99% fewer json.loads, build drops from minutes to ~a
  minute.

Verified the two CVEs' vendor/product (7-Zip/7-Zip, notepad-plus-plus) match
the registry and the installed versions (26.01 ≤ 26.02, 8.9.5 < 8.9.6.4) fall
in range — so they will now be detected once the index exists.
2026-06-30 10:40:06 +02:00
vulncheck 46594c153d feat(app-cve-scan): auto-suppress loose-CPE false positives via cvelistV5
Item 3 — the inverse of detection. Wazuh's CPE match over-reports CVEs across
product editions (a SQL Server 2019 / 15.x host carrying a CVE that only
affects 16.x/17.x). Now: for each open Wazuh finding, read the CVE's
cvelistV5 affected[] ranges scoped to the matched product, and if the
installed version is provably OUTSIDE every clean range → mark
status=false_positive (reversible via unmark, defer_reason explains why).

Conservative guards (never hide a real finding):
- Wazuh source only (the over-matcher); Nessus/app-scan/EOL untouched.
- Product match needs ≥2 shared significant tokens → only multi-word
  products (SQL Server, Visual Studio…) are ever scoped; single-token apps
  are left alone.
- If any relevant affected entry has no clean numeric range → abort (keep).
- If installed is inside ANY affected range → keep (it's real).

Nightly job runs it after the scan; manual POST /suppress-false-positives.
Verified on the tester's exact CVEs (installed 15.0.4013.40): CVE-2026-26116
(17.x-only) + CVE-2026-33120 (16.x-only) suppress; CVE-2024-29047 (covers
15.x) correctly kept.
2026-06-29 20:21:45 +02:00
vulncheck cd65dd0076 fix(assets): UnboundLocalError on list_assets without source filter
The source-filter branch did `from app.models.vulnerability import
Vulnerability` locally, which made Vulnerability a function-local everywhere —
so the later vuln_count query (line 465) raised UnboundLocalError whenever no
source filter was passed. Vulnerability is already imported at module level;
dropped the redundant local import.
2026-06-29 15:30:02 +02:00
vulncheck 61ce7497ff feat(app-cve-scan): cvelistV5 range-based detection for installed software
Catches CVEs the NVD-CPE scanner misses: fresh CVEs NVD hasn't CPE'd yet, or
ones filed under a different CPE product string than we curated (TeamViewer
lives under teamviewer:remote, not teamviewer:teamviewer). Matches directly
against cvelistV5 affected[].vendor/product + version ranges — the
authoritative MITRE feed we already cache as a ZIP.

- Curated product registry (name-regex → cvelistV5 vendor/product pairs):
  TeamViewer, Notepad++, Devolutions RDM, 7-Zip, Firefox, Chrome, VLC,
  PuTTY, WinSCP, Wireshark, FileZilla, Zoom. Unknown software ignored.
- build_product_index: one walk over the cached ZIP → {product_key:
  [{cve,start,lt,lte}]} for curated products only; cached in a Setting,
  rebuilt by the nightly job (the 557 MB walk happens once, not per scan).
- scan_asset: resolve installed software → indexed CVEs → version-range
  check → upsert (source 'app-scan', shared badge/cross-confirm/enrichment).
- Wired into run_app_cve_scan (loads cached index; skipped+logged if not
  built yet) and the nightly job (builds index first).

Verified against the real CVE JSON: TeamViewer CVE-2026-23572 (<15.74.5),
Notepad++ CVE-2026-52885 (<8.9.6.4), Devolutions CVE-2026-13372
(2026.2.5–2026.2.11) all detect at affected versions and correctly do NOT
match patched versions.
2026-06-29 15:05:20 +02:00
vulncheck ade5f435d9 fix(assets): Nessus filter via vuln-source; fix(ui): AI remediation render
assets: the previous filter keyed Nessus on nessus_host_uuid, but imports
whose host had no uuid (matched by hostname) carry it nowhere — they fell
into MANUAL. Now each scanner filter also matches assets with ≥1 finding
whose `sources` list contains that scanner ("nessus"/"wazuh"/...), which is
the maintained multi-source truth. MANUAL = no linkage AND no scanner vuln.

ui: the no-dependency AI-remediation renderer showed GFM tables as raw pipes,
literal "\n" inside code blocks, and "<br>" as text. Now: unescape literal
\n/\t, render <br> as a line break inline (without splitting the line, which
would corrupt table rows), and render GFM tables (header/sep/body).
2026-06-29 14:13:31 +02:00
vulncheck d14ed835e4 fix(mobile-eol): clean device EOL text + correct Samsung A11/Active5 Pro
Description bugs (tester screenshot):
1. lowercase vendor ("samsung Galaxy Tab A8") — Intune reports manufacturer
   lowercase. Use a fixed vendor label (Apple iPhone / Apple / Samsung).
2. doubled model ("Samsung Galaxy Tab A8 Galaxy Tab A8 — EOL") —
   upsert_eol_vulnerability appends the release label itself, so product_name
   must be the VENDOR only. Now title reads "Samsung Galaxy Tab A8 — EOL" and
   the full device name is written back into the package column.

Mapping fixes from the tester (he resolved the two I left open, and caught a
wrong one):
- SM-X236 was mapped to Tab A9+ → it's Tab A11+ (5G). Corrected.
- SM-X230 → Tab A11+ (Wi-Fi), SM-X130/X135 → Tab A11, SM-X356 → Tab
  Active5 Pro. Added. All names verified against the live API.
2026-06-25 13:12:37 +02:00
vulncheck 428c3f2737 feat(mobile-eol): add older Samsung Tab A / Tab Active / XCover3-4 models
From the tester's missing-model list: Tab A 7.0/8.0 (2015/16), Tab A9+ 5G
(SM-X236), Tab Active / Active2 / Active3 / Active5, XCover3/4/4s. All 13
new release names verified against the live endoflife.date API; SM-G556 /
G525 / G736 were already mapped.

Two codes left out pending the marketing name (avoid a wrong-EOL mapping):
SM-X356 and SM-X230 — not confidently identifiable.
2026-06-25 11:16:57 +02:00
vulncheck 35940de51e feat(dashboard): Mobile Security widget (EOL/EOS + Android patch level)
Separate dashboard widget for phones & tablets, kept apart from the
desktop-software EOL widget (which now excludes mobile findings).

Reuses existing data — the findings are already vuln rows. New
finding_type=mobile filter on the vulns endpoint ORs the mobile cve_ids
(ANDROID-PATCH-%, EOL-IPHONE/IPAD/SAMSUNG-MOBILE/SAMSUNG-GALAXY-TAB-%) and
bypasses the pseudo-CVE exclusion. The widget feeds from it (top 10, cvss
desc → vendor-EOL 9 > patch ≥1y 8 > EOL-soon 5.5 > end-of-active-support 3),
deduped by cve_id+asset so the same EOL stream on N devices stays N rows.
"View All" deep-links to /vulnerabilities?finding_type=mobile, which the
list page now reads from the URL.

ponytail: one combined widget, not two — severity sort already surfaces the
worst (vendor-EOL reached) above the milder patch-staleness rows, and each
row's title says which it is. Split into two later if the volume warrants.
2026-06-25 09:59:33 +02:00
vulncheck d8c838fa09 fix(assets): source filter by scanner linkage; expand Samsung EOL table
assets: the source filter used the creation-time `source` enum, so an asset
first created by Wazuh/Manual and later matched by Nessus stayed off the
"Nessus" filter. Now filter by the actual per-scanner id columns
(wazuh_agent_id / nessus_host_uuid / intune_device_id|defender_machine_id),
so a merged asset shows under every scanner that sees it. MANUAL = no linkage.

mobile-eol: expand the Samsung SM-code table from ~30 to ~100 models —
S20→S25, Note10/20, Z Fold/Flip 2→7, full A-series, XCover, Tab S6→S10 +
Tab A. All 100 release names verified against the live endoflife.date API.
Apple was never table-bound (it fuzzy-matches the full live list). Unmapped
Samsung models still skip (no false-positive). Graduated severity (eoas→low,
eol-soon→medium, eol→high) already comes from eol_service.
2026-06-25 09:53:38 +02:00
vulncheck 98bd366ddf feat(intune): mobile device EOL/EOS + Android patch-level staleness
For Intune-managed phones/tablets, runs during the Intune sync (device dict
already in hand — no extra fetch):

1. Device-model EOL/EOS via endoflife.date, reusing eol_service
   (fetch/cache/EOLStatus/upsert → EOL- pseudo-CVE). Model→release mapping:
   - Apple: Intune reports the marketing name → fuzzy-match the endoflife
     release label/name (iPhone 15 Pro Max → 15-pro-max, iPad Air (5th
     generation) → air-5). Bare identifiers (iPhone15,3) are skipped.
   - Samsung: SM-code has no textual overlap with endoflife → curated
     SM-prefix table (S21–S25, A-series 5G, XCover, Tab S9/S10), matched by
     prefix so region suffixes are ignored. Unmapped models are skipped (no
     false-positive). All release names verified against the live API.

2. Android security-patch-level staleness from Intune's
   androidSecurityPatchLevel: age >=90/180/365d → low/medium/high pseudo-
   finding (ANDROID-PATCH-LEVEL-STALE, one per asset). The control instance
   that flags "patches not actually applied" without scraping any vendor
   bulletin. is_pseudo_cve recognises the new prefix.

Added androidSecurityPatchLevel to the managedDevices $select.
Deferred (not built): per-CVE Android bulletin scraping (Google ASB /
Samsung SMR) — brittle, no API.
2026-06-24 10:32:37 +02:00
vulncheck 02cfb39486 feat(assets): filter inventory by sync source
Dropdown on the Assets page (All / Wazuh / Nessus / Intune+Defender /
Manual). Backend already accepts ?source=; this just wires the UI.
2026-06-23 10:01:37 +02:00
vulncheck d460364590 fix(app-cve-scan): platform check kills cross-platform false-positives
Desktop Firefox on a Windows host was matching the Firefox-for-iOS CVE
(cpe:2.3:a:mozilla:firefox:*:...:iphone_os:*, target_sw=iphone_os). We
matched on vendor:product only and ignored the CPE platform field.

Now record each matching cpeMatch's target_sw (CPE index 9) and keep a CVE
for an asset only when target_sw is platform-neutral (*) or names the asset's
OS family (_os_family). Applied to both package and OS scans.

Cache key bumped to v2: → pre-fix rows (without target_sw) are ignored so the
stale FPs aren't served from cache; they re-fetch with the platform data.

Also: 503 backoff raised to >=3s × attempt over 4 tries (NVD 2.0 503s under
load even WITH a key — it's server-side, not auth), and the scan logs whether
NVD_API_KEY is present so a missing key is obvious in the logs.
2026-06-23 10:00:02 +02:00
vulncheck 3123263cf0 feat(app-cve-scan): iOS/iPadOS OS-level CVEs
Apple ships the precise OS version (e.g. 18.1.2) and NVD carries proper
version ranges for cpe:2.3:o:apple:iphone_os / :ipados, so it's the same
clean CPE-range check the desktop apps already use — no new machinery.

Reads asset.operating_system + asset.os_version (already synced from Intune),
so it covers Intune-only iPhones/iPads that have no syscollector packages.
Runs per asset regardless of package inventory; cached per (cpe, version) so
N devices on the same iOS build = one NVD query.

Android intentionally omitted: NVD only lists the base version (13/14/15)
without ranges → needs the Intune security-patch level + Android bulletin
parsing, a separate feature.
2026-06-22 22:19:05 +02:00
vulncheck 36a560d508 fix(app-cve-scan): kill NVD noise/503 storm + Graph 400/429 hammering
Three bugs from the first app-scan run:

1. Linux rpm/deb packages flooded NVD with junk queries
   (python:python:4.6.5-3.el8, epoch 1:3.2, 2.43.0.windows.1, ...) →
   503 storm + false-positives. Those packages are Wazuh's domain. Add
   _clean_version: only clean dotted-numeric versions reach the scanner
   ("7.0.2 (34567)" → "7.0.2"); epoch/release-tag versions are skipped.

2. A transient NVD 503/429 cached an EMPTY result for 7 days → real CVEs
   missed until TTL. _query_nvd_cpe now retries (3x backoff) and raises
   _TransientNVD on persistent 429/502/503/504; lookup_cves returns []
   WITHOUT caching so the next run retries. Permanent 4xx still cache empty.

3. v1.0 $expand=detectedApps 400s on this tenant → it 400'd once per
   device, every run. Flip _v1_detected_apps_ok off after the first 400
   and go straight to beta. _get now retries 429 honouring Retry-After.

NVD_API_KEY strongly recommended — keyless NVD is the main 503 source.
2026-06-22 22:15:34 +02:00
vulncheck 36976c6647 feat(app-cve-scan): built-in software→CVE scanner (OSV + NVD-CPE)
Maps installed software (Wazuh syscollector packages + Intune detectedApps)
to real CVEs via OSV and NVD-CPE with an own version-range check. Closes the
coverage gap for Intune-only / mobile devices that have no real scanner
(Intune managedDevices add every device but carry no CVE data; Defender TVM
only covers MDE-onboarded hosts with findings).

Design: curated + precise (low false-positives).
- Curated product registry (~24 common apps) name-regex → CPE/OSV; unknown
  app names are ignored (no CPE auto-guessing → no FP storm).
- NVD-CPE: query per product CPE, then verify the installed version actually
  falls inside each CVE's cpeMatch range ourselves (start/end incl/excl,
  exact-version equal, wildcard skipped); versionEndExcluding → fixed_version.
- OSV: precise server-side version match for language ecosystems.
- Results cached per (product_key, version) in app_cve_cache (TTL 7d) so the
  same version across N hosts = one query (and stays under NVD's rate limit).

Findings upsert as source 'app-scan' with real CVE ids → the normal
EPSS/KEV/CVSS enrichment + multi-source remediation apply, and they
cross-confirm with Wazuh/Nessus/Defender on the same (cve, asset).

Wiring:
- Migration 035 + AppCveCache model.
- Piggyback in intune_service._run_app_inventory (detectedApps already fetched).
- POST /api/v1/vulnerabilities/app-cve-scan (sync def → threadpool).
- Nightly scheduler job app_cve_scan_nightly (03:25 UTC).
- Frontend: app-scan source badge + filter + "App CVE Scan" button.

NVD_API_KEY recommended for scale (rate limit). Run alembic upgrade head.
2026-06-21 12:16:44 +02:00
vulncheck cc9954595c fix(ui): Exposure/Risk sort NULLs-last + Affected-Asset links to its findings
Two tester bugs:
- Assets list: sorting Exposure or Risk descending surfaced the empty
  ("—") rows first instead of the real high scores. The generic numeric
  sort branch didn't use nulls_last; now NULLs always sort last in both
  directions.
- CVE detail "Affected Asset" linked to /assets?id= (just the asset list).
  Now links to /vulnerabilities?asset_id=<id> → all findings on that asset.
2026-06-21 11:36:16 +02:00
vulncheck 7890391283 fix(defender): populate affected-software (Package) for Defender CVEs
Tester: Defender-sourced CVEs showed an empty Package/affected-software
column, although Defender's own UI lists the affected software (e.g.
"OpenSSL 1.1.1.0").

Cause: /api/machines/{id}/vulnerabilities returns CVE definitions only —
no per-device software. Now run_defender_sync additionally pulls the
tenant-wide SoftwareVulnerabilitiesByMachine assessment (one paginated
export), builds a (machineId, CVE) → "vendor name version" map, and sets
package_name on each Defender finding (and backfills it on an existing row
whose package was empty). Best-effort: if the tenant/plan doesn't expose
the export, behaviour is unchanged.
2026-06-16 13:29:43 +02:00
vulncheck c3a75e7a77 feat(risk): Asset Risk Dimensions — high-value-target scoring + exposure rebalance + URS
Tester: the port-based exposure score put nearly every Windows host at 100
(no separation), and the thing that actually matters — whether a host runs
a crown-jewel role enabling lateral movement / domain takeover — wasn't
captured.

- Migration 034 + model: assets.high_value_score (0-100) + risk_dimensions
  (JSON roles) + _updated_at.
- app/services/risk_dimensions_service.py: detect_risk_dimensions(ports,
  packages) → roles from syscollector ports (port + process) and installed
  packages: Domain Controller, ADCS/CA, backup servers, SW-distribution,
  Exchange, WSUS, MSSQL, DNS, DHCP, WinRM. Score = max(weight) + 0.3·rest
  (cap 100). risk_factor() maps it to a URS band (>=90→1.5 … else 1.0).
- exposure_service: rebalanced port weights — baseline Windows
  (SMB/MSRPC/NetBIOS/WinRM) now LOW; real remote-control/cleartext
  exposures (Telnet/VNC/RDP/FTP) stay HIGH. Risk detection runs in the same
  pass (reuses fetched ports + one get_packages call).
- urs_service: URS uses max(operator criticality factor, role factor) — a
  DC/ADCS host rises to critical weighting even at criticality=normal;
  operator can still set higher. criticality field untouched.
- assets API: high_value_score + risk_dimensions in the response + sortable;
  Assets page gets a "Risk" column with score + role badges.

Verified detection: DC(88+389)→100, SQL pkg+WinRM→79, plain Win→0,
Exchange+Veeam→100. Migration 034 required: alembic upgrade head.
Roles need Wazuh syscollector (ports+packages); Nessus/Intune-only → v2.
2026-06-16 13:23:16 +02:00
vulncheck d0d27b2c99 fix(assets): paginate the Assets list (was silently capped at 100)
Tester: the Assets page showed at most 100 entries with no paging and no
page-size control.

- Backend list_assets now returns the pre-pagination total in an
  X-Total-Count header (body stays a plain array — the asset dropdowns on
  the scans/vulnerabilities pages still consume a list). limit/offset were
  already supported.
- Assets page: page + page-size (50/100/250/500/1000) controls, prev/next/
  first/last, "X–Y of N", debounced search, page resets to 1 on any
  filter/search/page-size change.
- The asset dropdowns on the scans + vulnerabilities pages now request
  limit=1000 so large estates aren't silently truncated there either.
2026-06-16 10:49:48 +02:00
vulncheck d90044e696 feat(msrc): newest KB per build-branch+type; sensible M365-Apps handling
Tester-approved: show the newest MSRC KB per (build-branch + update-type)
— normally one entry, two on hotpatch hosts (Security Update + Security
Hotpatch Update); drop the superseded rest.

- msrc_service now stores the MSRC update type (SubType) in the fix row's
  `detail`, so the detail view can keep Security-Update vs Security-Hotpatch
  separately. (Re-run MSRC Enrich to populate it on existing rows.)
- get_vuln_remediations: dedup fixes to newest per (branch, type); for a
  Windows-OS finding narrow to the host's own build branch (1–2 entries).

Special case — Microsoft 365 Apps findings: the MSRC KBs are perpetual/MSI
Office builds that never match the installed Click-to-Run channel build, so
branch-filtering is skipped (it would hide everything). Instead the deduped
per-Office-version set + the aka.ms pointer are shown, and a clear
"Update via Office channel to build <channel build>" hint is prepended —
the actually-actionable fix for C2R installs.

Verified: Windows 10.0.26100.32690 → only the 26100 SU + 26100 Hotpatch;
M365 example 7 KBs → 4 (newest per Office line + aka.ms).
2026-06-15 15:08:06 +02:00
vulncheck 4d6ccb71d2 fix: heavy manual endpoints run off the event loop (no more GUI freeze)
Tester: clicking "M365 CVEs" froze the whole web GUI until the cvelistV5
comparison finished — same class of bug as the earlier date-backfill case.

Cause: these endpoints were `async def` but call fully-synchronous,
blocking services inline (httpx + DB), starving the single asyncio event
loop. Fix: declare them as plain `def` — FastAPI/Starlette then runs them
in a worker threadpool, off the event loop, so the GUI stays responsive
(responses/counts unchanged). Converted: m365-check, eol-check,
exploit-intel/refresh, bulk enrich (Refresh Threat Intel),
override/vulnrichment (Correct CVSS), and the Nessus /sync trigger.

Also:
- Intune detectedApps: v1.0 `$expand=detectedApps` returns HTTP 400 on the
  managedDevice; now falls back to the beta /detectedApps navigation
  collection (paginated). Still best-effort.
- Defender 403 ("Missing application roles"): /api/machines needs
  Machine.Read.All in addition to Vulnerability.Read.All — documented in
  the Intune settings tooltip + .env.example. (Sync stays non-fatal.)
2026-06-15 15:04:00 +02:00
vulncheck 2087b484d9 docs(readme): update to current feature set, remove stale roadmap
The README still described VulnCheck as Wazuh-only with AI — out of date.
Updated to reflect what's actually shipped:

- Tagline/overview: multi-source (Wazuh + Nessus + Microsoft Intune /
  Defender), enrichment, EOL/M365, multi-provider auth.
- Features: new "Scanner & Inventory Integrations" (Wazuh/Nessus/Intune/
  Defender + cross-confirm + soft-inactive lifecycle), Threat-Intel
  Enrichment (EPSS/KEV/EUVD/exploit catalogs/cvelistV5/Vulnrichment/NVD),
  EOL & Microsoft 365 detection, Multi-Source Remediation (scanner/MSRC/
  Ubuntu/RHEL/OSV), AI on-demand remediation.
- Configure-integrations + Integrations note + RBAC settings row + the
  architecture diagram updated for the new sources.
- Removed the Roadmap section (its items — LDAP/AD auth, more SIEM
  integrations, scanning without Wazuh — are now shipped).
2026-06-15 09:57:54 +02:00
vulncheck 8e8cfee19a feat(msrc): host-matched KB + MSRC update-guide link in remediation block
Tester, two MSRC-detail changes:

1) The MSRC remediation listed a KB for every Windows version (noise).
   Now the block shows only the KB(s) matching the affected host's Windows
   build branch (e.g. installed 10.0.26100.32690 → only the 26100-line
   KBs, newest first); falls back to the full list when nothing matches or
   the host build is unknown. Filtering is server-side in
   GET /vulnerabilities/{id}/remediations using the asset os_version.

2) Prepend the deterministic MSRC update-guide link for the CVE
   (https://msrc.microsoft.com/update-guide/vulnerability/<CVE>) to the
   MSRC block. Advisory items now render as clickable links in the UI
   (also surfaces OSV/Ubuntu advisory URLs that were previously hidden).

No migration / no new fetch — pure response shaping over cached MSRC rows.
2026-06-15 08:30:57 +02:00
vulncheck 355adad829 feat(m365): fill real CVE title + description from cvelistV5
Tester: M365 findings showed the synthetic placeholder description; the
CVSS-correction cascade (by design, for efficiency) never touches
title/description. Fill them separately from the authoritative source.

apply_real_cve_metadata(db, cve_ids): fetches the real CVE title + English
description from CVE.org cvelistV5 raw and writes them onto the M365
findings (first_detected_by='m365_check'), keeping a trailing note that
the finding originated from the Microsoft 365 Apps source (so the
provenance stays clear). Runs after CVSS-correction + enrichment in both
run_m365_check and run_m365_for_packages.

Verified live: CVE-2026-45456 → title "Microsoft Outlook and Word Remote
Code Execution Vulnerability" + the real type-confusion description.
2026-06-13 13:22:16 +02:00
vulncheck c2cad0088d feat(intune): Phase 3 — Defender for Endpoint TVM real CVEs
Optional per-device CVE feed from Microsoft Defender for Endpoint (TVM),
toggled by `defender_tvm` in the Intune settings card. Reuses the same
Entra app (tenant/client/secret) but a separate API + scope.

- app/integrations/defender_client.py: client-credentials token for scope
  https://api.securitycenter.microsoft.com/.default; get_machines() +
  get_machine_vulnerabilities() (paginated); test_connection().
- app/services/defender_service.py: run_defender_sync — match Defender
  machine → asset by computerDnsName (pin defender_machine_id), upsert
  REAL Vulnerability rows (source='defender', cross-confirm via add_source
  with wazuh/nessus/intune), then VULNERABILITY_DETECTED audit + EPSS/KEV/
  date enrichment. Real CVE ids → cvelistV5/MSRC/OSV remediation applies.
- intune_service.run_intune_sync runs the Defender pass when enabled.
- Settings card: "Defender TVM CVEs" toggle (needs Vulnerability.Read.All
  on WindowsDefenderATP + Defender licensing).

Needs Application permission Vulnerability.Read.All (WindowsDefenderATP) +
admin consent. No migration (defender_machine_id added in 033).
2026-06-13 10:40:57 +02:00
vulncheck 922341b378 feat(intune): Phase 2 — detectedApps feed EOL + M365 detection
Intune managed devices often run without a Wazuh agent, so their installed
software was invisible to EOL/M365 detection. Now the Intune sync reads
each device's detectedApps (Graph $expand=detectedApps) and runs them
through the existing detection.

- eol_service.run_eol_for_packages(db, asset, packages): source-agnostic
  per-package EOL (endoflife.date → MS-lifecycle/exotics fallback), same
  precedence as the eol-check endpoint.
- m365_service.run_m365_for_packages(db, asset, packages): source-agnostic
  M365-Apps CVE detection (build-vs-channel) + real-metric correction.
- intune_service: detectedApps enrichment now on by default (toggle
  "Detected apps (EOL/M365)" in the Intune settings card).

Findings are CVE-level, so OSV/MSRC/Ubuntu remediation enrichment and the
normal EPSS/KEV/date enrichment apply automatically. No migration.
2026-06-13 10:37:22 +02:00
vulncheck 5418d6a9d3 feat(intune): Microsoft Intune/Graph inventory source — Phase 1 (devices + OS-EOL)
Third inventory source next to Wazuh/Nessus: pulls Intune managed devices
via Microsoft Graph (app-only client-credentials) → assets + OS-level EOL.

- Migration 033: INTUNE assetsource label + assets.intune_device_id /
  defender_machine_id (+ indexes). Model updated.
- app/integrations/graph_client.py: client-credentials token cache (mirrors
  wazuh_client), paginated managedDevices, get_detected_apps (phase 2),
  test_connection. Plain httpx, no msal dependency.
- app/services/intune_service.py: run_intune_sync — find-or-create asset
  (intune_device_id → hostname → auto-create) mirroring nessus_sync,
  source=INTUNE, refresh OS/version, OS-EOL via eol_service.check_os_eol,
  id-keyed lifecycle reconcile.
- asset_lifecycle.reconcile_intune_by_seen_ids (mirrors the Nessus one).
- app/routers/intune.py: POST /api/v1/integrations/intune/test (admin),
  /sync (editor, fire-and-forget 202). Registered in main.py.
- scheduler: intune_sync_nightly (sync def) at 02:10.
- Settings: encrypted intune_config (PROTECTED_SETTING_KEYS); settings PUT
  now MERGES secret subfields (blank/"***set***" keeps the stored secret)
  so JSON configs can be edited without re-typing secrets; client_secret
  added to redaction subfields.
- Frontend: "Microsoft Intune (Graph API)" settings card (tenant/client/
  secret + Test/Sync), intune/defender source badges, Asset type field.
- .env.example documents the Entra app registration + permissions.

Migration 033 required: alembic upgrade head. detectedApps→EOL/M365 is
phase 2 (toggle present, off by default until the per-package helpers land).
2026-06-13 10:34:51 +02:00
vulncheck 9714fb72a2 feat(m365): real CVSS/metrics + richer description at CVE check-in
Tester (follow-up to Plan P 45524f7): M365 Apps CVEs were created with a
placeholder severity=medium / cvss=None and a thin description. Pull the
real metrics straight away at check-in and clarify the description.

- After an M365 check creates/updates findings, run the CVSS-correction
  cascade (vulnrichment → cvelistV5 → NVD) for the touched CVE ids, then
  enrich_vulnerabilities (EPSS/KEV/EUVD + NVD dates). These are real CVE
  ids, so they resolve like any other. The override path never touches
  `description`, so the M365 source note is preserved.
- Description rewritten: states the affected product, installed vs fixed
  build + "update via the Office channel", explains the detection source
  (Wazuh syscollector build vs MS365 release notes — not in NVD/Wazuh),
  and notes metrics come from MSRC + Vulnrichment/cvelistV5 with a pointer
  to the MSRC remediation section.

No migration. CVSS/severity now populated on the next M365 Enrich run.
2026-06-13 10:05:09 +02:00
vulncheck 933a47a10e feat(osv): add OSV.dev as an additional CVE remediation source
Tester idea: use the OSV.dev aggregator to broaden Linux/cross-ecosystem
coverage. Design question (fallback vs overwrite) → neither clobbers:
OSV is added as its OWN source (own UI block, source='osv'), never
overwriting vendor rows, and it also fills gaps for distros/ecosystems the
vendor providers don't cover (Debian, SUSE, Alpine, Rocky, AlmaLinux, npm,
PyPI, Go, ...).

- linux_remediation_service.fetch_osv(): GET api.osv.dev/v1/vulns/<CVE> →
  per-ecosystem fixed versions (skips commit-hash "fixes") + filtered
  advisory references (USN/RHSA/DSA/GHSA/errata/SUSE/Alma...).
- enrich_cve_linux() now stores the matching vendor provider AND OSV as
  separate sources per CVE.
- GET /vulnerabilities/{id}/remediations triggers on-demand enrichment for
  ANY non-Windows host (was Ubuntu/RHEL only), so OSV covers Debian/SUSE/
  etc.; cached in cve_remediations, off the event loop.
- UI labels the block "via OSV.dev (aggregator)".

Verified live: CVE-2023-48795 → OSV yields Debian DSA / GHSA / FreeBSD
advisory links the vendor providers don't. No migration (reuses 032).
2026-06-13 10:01:45 +02:00
vulncheck ac16f99be7 feat(linux): Ubuntu USN + RHEL/CentOS errata remediation enrichment
Tester feature (step 2 of multi-source enrichment): add Linux distro fixes
alongside the Nessus scanner solution and MSRC, reusing cve_remediations.

Unlike MSRC (monthly bulk doc), the Linux trackers are queryable per-CVE,
free and unauthenticated — so we fetch on demand when the CVE detail opens
for a Linux host, then cache into cve_remediations (repeat views instant,
re-fetch refreshes).

app/services/linux_remediation_service.py:
  - provider_for_os(): Ubuntu -> ubuntu; CentOS/RHEL/Rocky/Alma/Oracle/
    Fedora -> redhat (CentOS/Alma rebuild RHEL, so the RHSA + fixed NVR is
    the actionable fix).
  - fetch_ubuntu(): ubuntu.com/security/cves/<CVE>.json → per-release fixed
    package versions (kind=fix), USN advisories, mitigation.
  - fetch_redhat(): access.redhat.com securitydata → RHSA advisory + fixed
    package NVR + errata link, mitigation/statement.
  - enrich_cve_linux(): pick provider by host OS, replace cached rows.

GET /vulnerabilities/{id}/remediations now triggers this enrichment
(off the event loop via asyncio.to_thread) on first view for a Linux host.
UI groups it under "via Ubuntu USN" / "via Red Hat / CentOS / Alma".

Verified live: CVE-2024-6387 (regreSSHion) returns per-release openssh
fixed versions + USN-6859-1 + the LoginGraceTime mitigation.

No migration (reuses cve_remediations from 032).
2026-06-10 13:31:39 +02:00
vulncheck 29f828678b feat(msrc): Microsoft (MSRC) per-CVE remediation enrichment
Tester feature (step 1 of multi-source enrichment): augment the Nessus-
only scanner remediation with authoritative Microsoft data, for Windows
OS *and* MS products (Office/365, .NET, SQL, Exchange, ...).

MSRC's per-CVE endpoint 404s, so we ingest the monthly CVRF documents
(api.msrc.microsoft.com/cvrf/v3.0/cvrf/{YYYY-Mon}, ~4 MB each) and extract
per-CVE remediations:
  - fixes:   KB number + FixedBuild + download URL (Remediations Type 2/3)
  - workarounds / mitigations (containment): Notes "Workarounds" /
    "Mitigations", HTML stripped to text — covers the "no KB yet, only
    containment" case the tester called out.

- Migration 032 + model: cve_remediations (CVE-level, source-tagged).
- app/services/msrc_service.py: refresh_msrc() pulls the last N monthly
  docs (default 18, setting msrc_months_back), stores rows only for CVE
  ids already in the DB (keeps it relevant). Re-parse replaces a CVE's
  rows so MS revisions (containment-only -> KB later) self-update.
- Endpoints: GET /vulnerabilities/{id}/remediations (scanner + external,
  grouped by source) and POST /vulnerabilities/msrc/refresh (fire-and-
  forget background thread). Weekly scheduler job (Sun 04:40).
- UI: CVE detail now renders a Remediation block per source ("via scanner"
  / "via Microsoft (MSRC)") with KB + download links, workarounds, and
  mitigation/containment. "🛡️ MSRC Enrich" button on the vuln list.

Verified parse against the live 2026-May CVRF doc (KB+build+catalog link
per Windows build). Migration 032 required: alembic upgrade head.

Step 2 (Linux: Ubuntu USN / CentOS errata) reuses cve_remediations next.
2026-06-10 13:29:25 +02:00
vulncheck eea9f5aa94 feat(ai): EOL-aware remediation prompt (upgrade plan, not "apply patch")
Tester: AI remediation for EOL/EOS findings was generic and sometimes
hallucinated a patch that doesn't exist (the product is out of support).

For pseudo-CVE findings (cve_id starts with EOL- / NESSUS-PLUGIN-), the
prompt now switches to an end-of-life system prompt: state the EOL/EOS
risk, name the supported target release + timeline, give OS-specific
upgrade/replace commands and download location, list interim compensating
controls / containment while the migration is pending, and a verification
step — explicitly told NOT to suggest applying a non-existent patch.
Real-CVE findings keep the existing patch-focused prompt.
2026-06-10 13:19:26 +02:00
vulncheck 00d22fc318 feat(audit): initial VULNERABILITY_DETECTED event for sync-created findings
Tester: a CVE newly created by a sync appeared in the vuln list but left
NO initial audit entry ("new CVE detected on asset X at ...") — the audit
trail only began with the first status change (open -> patched etc.).
Confirmed: not intentional, simply never built; fails the revisionssicher
requirement.

- Migration 031: ALTER TYPE auditeventtype ADD VALUE
  'VULNERABILITY_DETECTED' (idempotent, autocommit block).
- New app/services/audit_events.py: audit_new_vulnerabilities() writes one
  System/Auto event per newly created finding:
  "New finding detected: CVE-X on <hostname> (severity=..., source=...)".
- Wired into every creation path:
    * Wazuh full sync (run_wazuh_vulnerability_sync)  -> source=wazuh
    * Wazuh per-agent sync (sync_agent_vulnerabilities, covers the
      scheduler loop + per-asset rescan)              -> source=wazuh
    * Nessus sync (incl. Nessus EOL pseudo-vulns via
      newly_created_vuln_ids)                         -> source=nessus
    * endoflife.date upsert                           -> source=eol_check
    * M365 Apps upsert                                -> source=m365_check
  Full-sync and per-agent paths are independent (no double events).
- Best-effort: audit failure never breaks a sync.

Migration 031 required: alembic upgrade head.
2026-06-10 08:58:38 +02:00
vulncheck 3e382a489d fix(eol): stop short product names matching unrelated lifecycle listings
Tester false-positive: the evergreen Microsoft Edge browser (148.x/149.x)
was flagged EOL as "Azure Stack Edge" (end 2024-03-31).

Cause: the lifecycle-export matcher was bidirectional (rn in target OR
target in rn). "Microsoft Edge" normalises to just "edge" ("microsoft" is
stripped), and "edge" is a substring of the unrelated listing
"azure stack edge" — so the reverse direction matched.

Fix: one direction only — the LISTING name must be contained in the
product name. Verified against the live April-2026 export:
  Microsoft Edge / Edge WebView2  -> no match (was Azure Stack Edge)
  SQL Server 2014 Management Objects -> SQL Server 2014 (kept)
  Exchange Server 2016               -> kept
  a real "Azure Stack Edge" device   -> still matches

Existing wrong EOL-MS-LIFECYCLE-Azure_Stack_Edge rows on Edge hosts won't
be recreated; dismiss them once via the detail-page Dismiss button.
2026-06-10 08:52:14 +02:00
vulncheck e2625e036e feat(ui): render AI remediation as markdown with copy-able code blocks
Tester: the AI remediation output showed raw markdown (**bold**, ``` fences)
verbatim.

Added a dependency-free mini-markdown renderer for the AI Remediation
section: fenced code blocks become dark, horizontally-scrolling <pre>
blocks with a language label and a Copy button; **bold**, `inline code`,
headings, and bullet/numbered lists are formatted. Other text passes
through.

Also added an "EOL check done: …" summary log line (incl. the
ms_lifecycle/exotics finding count) so it's verifiable whether the
Visual C++ / Silverlight MS-lifecycle fallback fired.
2026-06-08 14:20:30 +02:00
vulncheck 65e2c222ce fix(nessus): specific remediation beats generic on cross-confirmed CVEs
Tester: a cross-confirmed (Wazuh + Nessus) CVE showed the generic
remediation "Install the patches listed below." instead of the specific
Nessus plugin solution ("Upgrade to Paessler PRTG ... 18.2.40.1683 or
later", plugin 277614).

A CVE can match several Nessus plugins on one host; a catch-all plugin's
generic solution was overwriting / blocking the real plugin's fix because
the precedence relied on a title-based specificity heuristic.

Now remediation precedence is text-based: a non-generic solution always
replaces an empty/generic one, and a generic solution never overwrites a
specific one. _is_generic_remediation() flags catch-all phrases
("install the patches listed below", "apply the appropriate patch",
"n/a", "no known fix", ...). Existing wrong rows self-heal on the next
Nessus sync.
2026-06-08 10:42:29 +02:00
vulncheck 95750458d2 feat(ai): configure OpenRouter key via the Settings GUI
Tester: let the OpenRouter API key be set in the UI like the other
integrations, not only via env.

- openrouter_api_key added to PROTECTED_SETTING_KEYS → encrypted at rest
  (same Fernet key as Wazuh/SMTP/Nessus configs). ai_service already reads
  env first, then this setting, so no service change needed.
- Settings page: new "AI Remediation (OpenRouter)" card — API key
  (password, shows configured/not-set), model (default openrouter/free),
  optional comma-separated fallbacks, Save + Remove Key. Admin-only (PUT
  /settings is RequireAdmin).
- settings GET redaction now treats an empty decrypted protected value as
  unset, so "Remove Key" reflects correctly after reload.

Env OPENROUTER_API_KEY still overrides the stored value when present.
2026-06-06 11:09:08 +02:00
vulncheck efee1d6e61 feat(ui): EOL asset column + hide dead External-References on pseudo-CVEs
Tester, two EOL-detail/widget polish items:

1) CVE detail: the External References block (NVD / CVE.org / Exploit-DB
   links) is now hidden for EOL- / NESSUS-PLUGIN- pseudo-CVEs — those have
   no real CVE record, so the links led nowhere. Shown only when cve_id
   matches /^CVE-/.

2) Dashboard "Newly EOL / EOS" widget: replaced the (usually empty) CPR
   column with the affected asset's hostname, linked to
   /vulnerabilities?asset_id=<id>&eol=1 so a click shows ALL of that host's
   EOL findings. renderVulnWidget gains an assetColumn option; the two CVE
   widgets keep CPR.
2026-06-06 11:05:10 +02:00
vulncheck 7bc7f996a0 feat(ui): Dismiss / Reactivate control on the CVE detail page
Tester: EOL findings (and any vuln) could only be dismissed from the list,
not the detail page — which only showed a status badge.

Added a Dismiss button next to the status badge: marks the finding
false-positive with an audit-logged reason (hides it from the default
active list). When already false-positive it flips to Reactivate. Reuses
the existing /false-positive and /unmark-false-positive endpoints. Works
for EOL pseudo-CVE rows too — they're ordinary vulnerability records.
2026-06-06 10:17:01 +02:00
vulncheck ba7f2069e2 feat(ui): EOL dashboard widget shows product name, not pseudo-CVE id
Tester: the "Newly EOL / EOS" widget's first column showed the synthetic
EOL pseudo-CVE id (EOL-CHROME-148) which is noise. renderVulnWidget now
takes optional labelField/firstColHeader; the EOL widget uses
package_name with a "Product" header (tooltip still shows the id). The two
CVE widgets are unchanged.
2026-06-06 09:50:58 +02:00
vulncheck 9021943f38 feat(ai): on-demand OpenRouter AI remediation on the CVE detail page
Tester feature request: generate OS-aware fix guidance per CVE via
OpenRouter (OpenAI-compatible).

- app/services/ai_service.py: calls OpenRouter /chat/completions via httpx
  (no new SDK dep). Config from env first, then settings table:
  OPENROUTER_API_KEY, OPENROUTER_MODEL (default openrouter/free),
  OPENROUTER_FALLBACKS (route=fallback). Builds an OS-aware prompt from the
  CVE + host (package, installed/fixed version, OS, scanner remediation)
  and asks for concrete commands + verification + mitigation. Maps 401/402
  to clear errors.
- POST /vulnerabilities/{id}/ai-remediation runs it via asyncio.to_thread
  (off the event loop). GET /ai-remediation/status reports whether a key
  is set so the UI hides the button when unconfigured.
- CVE detail: "🤖 AI Remediation" section with Generate/Regenerate button,
  shown only when configured.
- .env.example documents the OpenRouter keys.

Keyless by default = feature hidden; no behaviour change unless a key is set.
2026-06-06 09:48:51 +02:00
vulncheck 36cc29f69d feat(nessus): surface scanner remediation in its own CVE-detail section
Tester: Nessus already provides a per-finding remediation ("solution");
show it instead of burying it in the description blob.

- Migration 030: add vulnerabilities.remediation (TEXT).
- Nessus sync stores the plugin solution in the new remediation column
  (was appended to description); description now holds the synopsis only.
- API exposes remediation; CVE detail renders a "🛠️ Remediation (via
  scanner)" section below Affected Package when present.

Migration 030 required: alembic upgrade head.
2026-06-06 09:45:54 +02:00
vulncheck baefbf2b96 fix(eol): MS-lifecycle fallback runs whenever endoflife.date has no result
Tester: Visual C++ Redistributables (and other MS products) stayed in the
coverage gap, never flagged EOL.

Root cause: the MS-lifecycle fallback only ran when resolve_product_slug
returned None. But VC++ redistributables map to the 'visual-cpp' slug, so
they took the endoflife.date path — which has no data matching
redistributable build numbers → no finding — and the fallback (which has
the hardcoded VC++ 2008-2013 EOL dates) never ran.

Restructured both EOL loops (manual endpoint + nightly job): try
endoflife.date first, and run the MS-lifecycle export / hardcoded-exotics
fallback whenever endoflife.date yields nothing actionable — not only when
the name is unmapped. Now VC++ 2008-2013 → EOL, 2015-2022 → supported
(not flagged), Silverlight → EOL, and any MS product endoflife.date can't
resolve a release for gets a second chance against the lifecycle export.
2026-06-06 09:42:13 +02:00
vulncheck 67c201497e feat(ui): default the vulnerabilities list to CPR sort (desc)
Tester: CPR is the best single risk-based metric, so the main list should
lead with it. Changed the default sort from priority to cpr (descending).
Users can still click any column header to re-sort.
2026-06-06 09:32:57 +02:00
vulncheck 3b541780f8 fix(enrich): date backfill is fire-and-forget — stop 503 proxy timeout
The /dates/backfill endpoint awaited the whole job before responding, so a
ZIP walk over thousands of CVEs outran the reverse-proxy request timeout →
503.

Now it launches the date fill in a detached daemon thread (own DB session)
and returns 202 immediately. A module-level guard prevents a double-click
from stacking concurrent 600 MB ZIP walks. Progress is in the backend logs
("CVE dates:" / "Date backfill done"). Button shows "started" and refreshes
the list a few seconds later.
2026-06-03 16:12:14 +02:00
vulncheck 3adf02c6cb feat(enrich): on-demand "Backfill Dates" button (non-blocking, keyless)
Tester confusion: "Refresh Threat Intel" never set published dates
(nvd_dates_set=0) — by design it skips the date pass to stay fast/non-
blocking — so dates only filled on the nightly job, leaving the operator
waiting and unsure.

New POST /vulnerabilities/dates/backfill: date-only enrichment (no
EPSS/KEV) over every CVE row missing a published_date, via the cvelistV5
ZIP/raw cascade with NVD fallback. Runs in a worker thread
(asyncio.to_thread) with its own DB session, so the GUI stays responsive.
No NVD API key needed — cvelistV5 is the primary, keyless source.

Adds a "Backfill Dates" button on /vulnerabilities so the operator can
trigger it explicitly and see the result, instead of waiting for the
nightly scheduler.
2026-06-03 16:06:08 +02:00
vulncheck fbfa1d8e97 perf(enrich): ZIP date pass covers ALL missing CVEs in one run
The per-run cap (4000) was applied before the cvelistV5 ZIP pass, so a DB
with more undated CVEs than the cap stayed partially dated for several
nightly runs. During that partial state the "Newly Published" widget
showed a misleading subset — newest-published rows that had been dated
floated above genuinely-recent CVEs still sitting at NULL.

A local ZIP walk is cheap, so it should not be capped. Now the ZIP pass
runs over the entire missing set (dates a fresh DB completely in one run);
the per-run cap applies only to the slow per-CVE raw/NVD fallback for
whatever the ZIP didn't contain.
2026-06-03 15:46:43 +02:00
vulncheck 8a614ae5e5 fix(ui): render kernel "Fixed in" git-commit hash readably
Tester: the "Fixed in" field showed a raw 40-char git commit hash
(7713bd320ed4fc3d08a22...) that overflowed into the FIX badge and looked
broken.

Linux-kernel CVEs report their fix as an upstream commit hash, not a
Debian package version. Added formatFixedVersion(): a hex string (12-64
chars, no version separators) is shown as "upstream commit <short>" with
the full hash on hover; real versions render unchanged. Added break-words
so nothing overflows the cell.
2026-06-03 15:42:37 +02:00
vulncheck 97cb7915c6 perf(enrich): bulk CVE-date backfill via cvelistV5 ZIP (shared cache)
Tester suggestion: do the date backfill like the CVSS-correction cascade —
download/parse the cvelistV5 ZIP snapshot instead of thousands of per-CVE
fetches when many CVEs need dating at once.

Hybrid with a threshold, reusing the CVSS cascade's SHARED disk cache
(/tmp/vulncheck-cvelistv5-cache.zip, 12h TTL):
  - missing > 200  -> one ZIP snapshot + local walk (cveMetadata
    datePublished/dateUpdated). Download-free when CVSS-correction already
    pulled the ZIP. Fresh-DB bulk fill drops from ~33 min to seconds.
  - missing <= 200 -> per-CVE raw GitHub (no 557 MB download for a handful
    of new CVEs on nightly runs).
Anything the ZIP didn't contain falls through to the per-CVE path, then
NVD as last resort.

Adds VulnOverrideService.load_cve_dates_via_zip() + a shared
_download_cvelistv5_zip() helper. Persistent date cache unchanged.
2026-06-03 15:33:24 +02:00
vulncheck 033f39f0bb fix(ui): action-button result toast wraps instead of overflowing viewport
Tester: the result toast under the EOL Check / Exploit Intel / M365 CVEs
buttons ("30 assets, 4203 packages checked …") was cut off the left edge
at 100% browser zoom (only visible at 75%).

It was absolute right-0 + whitespace-nowrap, so the long single line grew
leftward past the viewport edge when the button sits on the left of the
toolbar. Switched to whitespace-normal + fixed w-60 so it wraps to a few
lines and stays on-screen.
2026-06-03 15:31:51 +02:00
vulncheck 1fb49e4336 fix(assets): show INACTIVE assets by default — that's the point of the status column
Tester: a Nessus sync correctly soft-inactivated asset #36 (verified in
DB + audit ASSET_DEACTIVATED), but it then vanished from the Assets view
entirely — not even visible with an INACTIVE badge.

Cause: the asset list defaulted to ACTIVE-only and hid INACTIVE behind the
"Show inactive" toggle. But the whole purpose of the Status column is to
surface soft-inactive hosts inline (amber INACTIVE badge), not bury them.

Fix: default list now shows ACTIVE + INACTIVE (hiding only
DECOMMISSIONED, which is operator-final). The toggle is repurposed to
"Show decommissioned" (include_inactive=true reveals those). Status-filter
and audit/history behaviour unchanged.
2026-06-03 15:00:30 +02:00
vulncheck df3d98ba68 perf(enrich): source CVE dates from cvelistV5 (no rate limit) before NVD
Tester suggestion: reuse the CVSS-correction cascade's authoritative
source (CVE.org cvelistV5) for the published/updated dates instead of
crawling the rate-limited NVD API one CVE at a time.

The NVD-only backfill needed a 6.5s sleep between requests without an API
key, so dating thousands of CVEs took hours (and, before the scheduler
thread-pool fix, froze the GUI the whole time).

Now the date backfill hits the official cvelistV5 raw JSON on GitHub first
(cveMetadata.datePublished / .dateUpdated — present for every published
CVE, GitHub raw has no aggressive rate limit, no sleep needed), and only
falls back to the NVD API per-CVE when cvelistV5 has no record. Per-run
cap raised to 4000 since the fast path no longer sleeps. Persistent cache
unchanged — each CVE still fetched once ever.

This gives both published_date and last_modified_date (the tester
specifically wanted dateUpdated) and drains a fresh DB in ~1-2 nightly
runs instead of weeks.

Verified live: cvelistV5 returns datePublished + dateUpdated for
CVE-2024-3094, CVE-2021-44228, CVE-2014-0160.
2026-06-03 14:29:08 +02:00
vulncheck af9bb89d7f fix(scheduler): run jobs in thread pool — stop freezing the web GUI
Tester: while the post-startup enrichment (EPSS + NVD date backfill) ran,
VulnCheck was completely unusable — only the logo, no login — for the
whole duration.

Root cause: the scheduler is an AsyncIOScheduler and every job was
declared `async def`, so APScheduler ran them as coroutines ON the single
asyncio event loop. But the job bodies are 100% synchronous blocking I/O
(sync httpx, time.sleep for NVD rate-limiting, heavy DB work) — there is
not one `await` in the whole file. A blocking coroutine starves the event
loop, so the ASGI server can't serve any request -> frozen UI.

Fix: declare all 15 jobs as plain `def`. APScheduler's AsyncIOExecutor
runs non-coroutine jobs via loop.run_in_executor(), i.e. in a worker
thread, off the event loop. The enrichment/sync jobs can now take as long
as they need without blocking logins or page loads.

No behavioural change to the jobs themselves — they were already sync.
2026-06-03 14:27:09 +02:00
vulncheck d22ddb73b9 fix: dashboard full width, EOL-NESSUS homogenisation, published sort fallback
Tester screenshots — four issues:

1) Dashboard/assets wasted huge left/right gutters. Dropped the
   max-w-[1800px] cap on both pages -> content uses full available width
   (minus AppShell padding).

2) After a Nessus re-sync, a finding still showed BOTH the legacy
   EOL-NESSUS-{plugin_id} row AND the new slug-named row (e.g. Adobe
   Reader -> EOL-ADOBE-ACROBAT-...). The slug alias already resolves, but
   the old plugin-id row was never removed. _upsert_nessus_eol now deletes
   the legacy EOL-NESSUS-{plugin_id} row for the asset whenever the plugin
   resolves to a real product slug. Re-sync homogenises existing data.

3) "Newly Published" still looked unsorted: until the NVD published_date
   backfill drains, most rows have published_date NULL and were ordered by
   id (meaningless). Added a secondary sort on the CVE's own year+sequence
   so "newest CVE number first" holds even before backfill. Bumped the
   no-key NVD backfill cap 60 -> 150/run so dates fill faster.

4) Assets "Assigned To" select truncated to "Unas..." — widened
   maxWidth 140 -> 200px (minWidth 120).
2026-06-03 13:53:05 +02:00
vulncheck 74a49be3eb fix(ui): dashboard widgets size to content — no empty space under short lists
Tester: "Newly Published CVEs" card has too much empty space.

Cause: the 3-widget grid used items-stretch + h-full on each card, so all
cards were forced to the height of the tallest (Recent Critical, 10 rows).
A shorter list (Newly Published / Newly EOL) left a big blank gap below its
last row.

Fix: items-start on the grid + drop h-full on the card, so each widget is
exactly as tall as its own content.
2026-06-03 13:46:39 +02:00
vulncheck 3ae4ac9e1e chore(compose): drop obsolete top-level version attribute 2026-06-03 13:43:47 +02:00
vulncheck dc8ef025f5 feat(eol): Microsoft lifecycle export as EOL fallback for MS exotics (Plan O)
Tester: endoflife.date misses Microsoft "exotics" (and many server SKUs),
and Microsoft has no lifecycle API. The only machine-readable primary
source is the monthly Excel export linked from
learn.microsoft.com/lifecycle/products/export — whose download URL (GUID
+ month) changes every month.

New app/services/ms_lifecycle_service.py:
  - fetch_lifecycle_data(): scrape the export page for the current
    eos-product-listing .xlsx link, download it, parse
    ListingName/Release/EndDate via openpyxl, cache 24h in settings.
  - resolve_ms_lifecycle_eol(): match a syscollector product name to a
    listing; pick the latest NON-ESU end date (paid Extended Security
    Updates are an add-on most hosts lack, so a product is treated EOL
    when standard extended support ends). Returns a reusable EOLStatus.
  - Hardcoded exotics NOT in the export (tester-named): Silverlight and
    the old Visual C++ 2008-2013 redistributables, with fixed EOL dates.

Wired as a *fallback*: the EOL check (manual button + nightly job)
consults endoflife.date first and only falls back to MS-lifecycle for
names endoflife.date can't map. Findings upsert through the existing
EOL pseudo-CVE path (cve_id "EOL-MS-LIFECYCLE-...").

Verified against the live April-2026 export: SQL Server 2014 -> EOL
(2024-07-09), SQL Server 2019 -> not EOL (2030), Exchange 2016 -> EOL
(2025-10-14). Adds openpyxl==3.1.5.

No migration — uses existing vulnerabilities + settings tables.
2026-06-03 13:40:26 +02:00
vulncheck 45524f7f8a feat(m365): detect Microsoft 365 Apps CVEs not in NVD/Wazuh (Plan P)
Tester: M365 Apps security fixes never reach NVD and are invisible to
Wazuh's vulnerability detector — they only live on the Microsoft Learn
"Microsoft 365 Apps security updates" page. No Microsoft API exists.

New app/services/m365_service.py:
  - fetch_security_data(): parse that page into monthly releases
    (channel->build map + CVE list), cached 24h in settings.
  - parse_build("16.0.19929.20172") -> (19929, 20172); compares the last
    two dotted build segments numerically.
  - channel_for_product(): tester's rule — name contains "enterprise" ->
    Monthly Enterprise Channel, else Current Channel.
  - detect_missing_cves(): installed >= newest channel build -> UNAFFECTED;
    otherwise union the CVEs of every monthly section the host is behind.
  - upsert_m365_vulnerability(): real-CVE rows (enrichable like any CVE),
    placeholder severity refined by nightly enrichment / Correct-CVSS.
  - run_m365_check(): walk Wazuh-linked assets, collapse per-language
    duplicates, upsert.

Verified against the live page + tester's example: installed
16.0.19929.20172 vs MEC 19929.20162 -> UNAFFECTED (0 CVEs); an older
build -> the month's 15 CVEs. 92 releases parsed cleanly.

Wired up:
  - POST /api/v1/vulnerabilities/m365-check (synchronous, RequireEditor).
  - Nightly job m365_check_nightly at 03:20 UTC.
  - "M365 CVEs" button on the vulnerabilities page.

No migration — uses existing vulnerabilities + settings tables.
2026-06-03 13:34:42 +02:00
vulncheck 57e457e92f perf(enrich): confine rate-limited NVD date backfill to the nightly job
Follow-up to the published_date backfill. The NVD CVE API sleeps up to
6.5s per request without an API key, so running the backfill inline on
interactive / sync paths could hang them for minutes.

- NVD backfill now only runs in the nightly enrich_all_open_vulnerabilities
  job (and single-vuln enrich, <=1 lookup). Disabled on the "Refresh
  Threat Intel" button, the Wazuh full + per-agent syncs, and the Nessus
  sync — all pass use_nvd_dates=False to stay fast.
- Per-run lookup cap is now key-aware: 60/run without a key (~6.5 min),
  1500/run with a key (~18 min) — avoids a no-key install stalling the
  nightly job for the better part of an hour.
- Document NVD_API_KEY in .env.example (free key → ~10x faster fill,
  picked up automatically via env_file).
2026-06-03 12:28:51 +02:00
vulncheck 89ba5b2efc fix(ui): dashboard header toolbar wraps instead of overflowing at 100% zoom
Tester: action buttons cut off / out of viewport at 100% browser zoom,
only visible at 75%.

The dashboard header right-group (audit filter + AI Audit + history +
Export Report) was a non-wrapping flex row. On any sub-1800px viewport
at 100% zoom it overflowed past max-w-[1800px] and got clipped. Add
flex-wrap + justify-end so it drops to a second line instead of running
off-screen. (The /vulnerabilities action bar already wraps.)
2026-06-03 12:09:26 +02:00
vulncheck 0cf0e5313f fix(enrich): backfill CVE published_date from NVD — fixes Newly-Published sort
Tester: "Newly Published" still wrong even in VIEW ALL.

Real root cause (not the sort SQL — that was already nulls-last desc):
published_date was NEVER populated. No ingest path wrote it — Nessus and
Wazuh imports only set detected_at, and enrichment only touched
EPSS/KEV/EUVD. So the column was 100% NULL and the sort had nothing to
order by → arbitrary order.

Fix: backfill published_date (and lastModified) from the NVD CVE API
inside the enrichment job.
  - fetch_nvd_cve_dates(): single-CVE GETs against services.nvd.nist.gov,
    persistent settings cache (dates are immutable → fetch each CVE once
    ever), capped NVD_MAX_LOOKUPS_PER_RUN=400 so a fresh DB drains the
    backlog over nightly runs instead of one rate-limit storm.
  - Honors NVD_API_KEY env for the 50/30s limit (else 5/30s, 6.5s sleep).
  - Only looks up vulns where published_date IS NULL — already-dated rows
    cost zero calls.
  - Toggle enrichment_nvd_dates_enabled (default on).

Also adds last_modified_date column (migration 029) + exposes both in the
vulnerabilities API response, per tester ("published date und updated
date"). Index on published_date for the sort.

Migration 029 required: alembic upgrade head.
2026-06-03 12:07:54 +02:00
vulncheck ee3b0f18bb fix(lifecycle): id-keyed Nessus reconcile — fixes inactive-never-set
Tester on alembic 028 + rebuilt, asset INACTIVE still never set after
a reduced-scope Nessus sync.

Root cause: reconcile keyed on nessus_host_uuid. The single host in the
reduced scan had no host_uuid in its Nessus host_info, so the seen-uuid
set came back EMPTY → the fail-open guard ("empty set = upstream maybe
failed, don't deactivate anything") skipped the whole reconcile → the
dropped hosts from the previous scan stayed ACTIVE.

Fix: track the matched asset.id of every host touched this sync
(seen_asset_ids) and reconcile on that instead of the uuid set. A
uuid-less host still contributes its id, so the seen-set is non-empty
and the dropped assets get inactivated.

New reconcile_nessus_by_seen_ids():
  candidate = ACTIVE AND (source==NESSUS OR nessus_host_uuid NOT NULL)
              AND id NOT IN seen_asset_ids  → INACTIVE
  revive    = same set, INACTIVE, id IN seen_asset_ids → ACTIVE
  fail-open only when seen_asset_ids is truly empty (no host matched).
Logs "Nessus sync reconcile: N seen, M inactivated, K candidates" so
the next report is self-diagnosing.

Old uuid-keyed reconcile_missing_from_sync kept for the Wazuh path /
back-compat.
2026-06-03 12:02:21 +02:00
vulncheck 3693cfce24 fix: Newly-Published null-date flood + Adobe Reader EOL slug
Tester batch 2026-06-03 (screenshot).

#2 "Newly Published" table jumbled after a Nessus sync. Cause: the
published_date sort used COALESCE(published_date, detected_at).
Nessus imports old CVEs (2014-2023) with published_date NULL +
detected_at=today, so COALESCE made every freshly-imported old CVE
rank as "published today" and flood the top in arbitrary id order.
Now sorts by real published_date with nulls-LAST — null-date rows
sink instead of masquerading as newest.

#1 Adobe Reader EOL finding showed as EOL-NESSUS-56213 instead of a
product slug. Nessus plugin 56213 names the product "Adobe Reader"
(no "Acrobat"), so the acrobat-prefixed slug keys never matched.
Added adobereader / acrobatreader → adobe-acrobat aliases.

#3 (asset INACTIVE not set after a reduced-scope Nessus sync) — the
reconcile_missing_from_sync path + AssetSource.NESSUS re-tagging +
migration 028 already handle this in current dev; tester deploy is
likely behind. Needs: deploy latest + alembic upgrade, then re-test.
If still broken, the "asset sync-reconcile (nessus): N ACTIVE assets
have no nessus_host_uuid pinned" log line distinguishes upstream
(unpinned) from logic.
2026-06-03 11:09:10 +02:00
vulncheck 45e2be8d13 fix(ui+audit): revisionssicher asset-delete log + wider page layout
From tester screenshot feedback (batch 2026-06-03):

#3 Asset deletion audit was thin ("Asset deleted: <truncated>") with
no clear WHO/impact. Now logs analog to the CVE status-change
entries: event_description names the host + IP + deleting user +
cascade-removed vuln count, and old_value carries a JSON snapshot
(hostname, ip, os, source, status, wazuh_agent_id, vuln count,
deleted_by). Audit committed BEFORE the delete so it survives even
if the cascade fails.

#2 + #4 Dashboard + Assets wasted large left/right gutters on wide
monitors and the assets table got squished/clipped (max-w-7xl =
1280px). Bumped dashboard, assets and scans pages to max-w-[1800px]
so wide screens use the space and the assets columns (incl. the new
Exposure col) stop clipping.

Still open (needs the tester's TXT, unreadable from Downloads
sandbox): EOL/EOS MS-product handling + remaining widget-fill polish.
2026-06-03 11:04:43 +02:00
vulncheck 638f6de0a5 docs(runbook): stamp-or-upgrade one-liner + psql tip
Tested on the staging instance: the bare `alembic upgrade head` flow
errors out with "0 found when updating 027 to 028" when the
`alembic_version` table is empty (a state the pre-feedback DB on
docker-01 was in). Replace the single command with stamp-then-upgrade
so the same runbook works whether the version row is present or not.

Also add a tip on the right way to inspect alembic_version: Postgres
runs in its own container, `docker compose exec backend psql` hits
the wrong socket.
2026-06-02 14:51:01 +02:00
vulncheck 6f7c4a8c54 chore(migration): alembic 028 reconcile legacy Nessus assets
One-shot data migration that flips ACTIVE NESSUS-sourced assets with
nessus_host_uuid IS NULL to INACTIVE, with audit log entries. Closes
the backlog that the runtime path cannot reach (it only matches by
pinned UUID). Idempotent — clean DBs are a no-op. Downgrade is a
no-op (operator decision to revive).

Apply with:
  docker compose exec backend alembic upgrade head
2026-06-02 14:34:52 +02:00
vulncheck bab0092940 fix(lifecycle): legacy Nessus asset reconcile + diagnostic logging
Asset rows created before nessus_host_uuid was pinned (matched by IP
or hostname in older syncs) are silently skipped by
reconcile_missing_from_sync because id_field.isnot(None) excludes
them. After a reduced scan they stay ACTIVE forever, contradicting
the "sync-driven INACTIVE" promise in feedback 808246f.

- asset_lifecycle.reconcile_missing_from_sync: log a count of legacy
  unpinned ACTIVE assets so the next tester reproduction surfaces
  the root cause immediately.
- nessus_sync.reconcile_legacy_nessus_assets: one-shot helper that
  flips these rows to INACTIVE with an audit-log entry, safe to run
  multiple times.

Fixes feedback 2026-06-02 #3 (INACTIVE not flipping on reduced scan).
2026-06-02 14:34:52 +02:00
vulncheck 9385ca4e0d fix(eol): add VC++ Redistributable slugs + reject sub-component matches
- _PRODUCT_SLUGS extended with visual-cpp aliases (vcredist,
  microsoftvisualc..., msvcr, msvcp) so 2005/2008/2010 etc. flow
  through to endoflife.date's visual-cpp product.
- _WRAPPER_TOKENS adds nativeclient, setupsupportfiles, setup,
  premium, clicktorun, subscription — these name a tracked product
  but have a different lifecycle, so they were producing false EOL
  matches (e.g. SQL Server 2008 R2 Native Client inheriting SQL
  Server's EOL date).

Fixes feedback 2026-06-02 #6 (Coverage Gap missing EOL entries).
2026-06-02 14:34:52 +02:00
vulncheck 0d51457a10 fix(vulns): exclude EOL / Nessus-plugin pseudo-CVEs from published_date view
COALESCE(published_date, detected_at) made every EOL row ride the
detected_at fallback to the top of the "Newly Published" list — a
tester-reported false chronological order. Skip rows whose cve_id
starts with EOL- or NESSUS-PLUGIN- when sort_by=published_date and the
caller did not explicitly search=EOL-.

Fixes feedback 2026-06-02 #5a.
2026-06-02 14:34:52 +02:00
vulncheck 703728c69d fix(eol): slug-based pseudo-CVE id for Nessus EOL findings
New helper _slug_pseudo_cve resolves the plugin_name to an
endoflife.date slug via eol_service.resolve_product_slug, then emits
EOL-{SLUG}-{VERSION|P####} instead of EOL-NESSUS-{plugin_id}. Office
variants keep the year-based EOL-MS-OFFICE-YYYY id for the language-
pack dedup. Legacy _office_pseudo_cve retained as a fallback when slug
resolution fails.

Detection Sources panel (nessus + plugin_id) is untouched — the
plugin id remains queryable from the vuln.nessus_plugin_id column.

Fixes feedback 2026-06-02 #2b, #4. Existing rows still need a one-off
backfill (out of scope here).
2026-06-02 14:34:52 +02:00
vulncheck d450b961aa fix(dashboard): card layout equal-height + truncate + flags column visible
- card root gets flex flex-col h-full so cards in the 3-col grid are
  equal height (no whitespace below short lists)
- table gains explicit colgroup with table-fixed so the Flags column
  cannot be clipped by overflow-x-auto at lg:1/3 width
- CVE cell now truncates with a tooltip instead of overflowing
- grid wrapper uses items-stretch for symmetric row heights

Fixes feedback 2026-06-02 #2a, #2c, #5b.
2026-06-02 14:34:52 +02:00
vulncheck ac0c611125 fix(assets): case-insensitive hostname sort + null-safe last_scan
Hostnames now sort by LOWER(hostname) so 'alpine' < 'Webserver'.
last_scan ORDER BY applies nulls_last on both asc and desc (was
implicit on desc only). Fixes feedback 2026-06-02 #1.
2026-06-02 14:34:52 +02:00
446 changed files with 84289 additions and 3754 deletions
+3
View File
@@ -26,3 +26,6 @@ coverage.xml
frontend/ frontend/
unicis-platform-ce/ unicis-platform-ce/
static/ static/
# Database dumps (tools/migrate_postgres_15_to_18.sh): never into the image
backups/
+107 -19
View File
@@ -1,11 +1,12 @@
# ============================================================================= # =============================================================================
# VulnCheck Environment Configuration # TrueVuln Environment Configuration
# ============================================================================= # =============================================================================
# Copy this file to .env and adjust values before starting: # Copy this file to .env and adjust values before starting:
# cp .env.example .env # cp .env.example .env
# #
# Designed to run behind a reverse proxy (Nginx Proxy Manager, Traefik, etc.) # HTTPS out of the box: the bundled nginx serves the UI with a certificate
# with HTTPS termination at the proxy level. # from a CA created for this installation (or one you upload in Settings >
# Security). An existing reverse proxy can sit in front and connect via HTTPS.
# ============================================================================= # =============================================================================
# --- Database --- # --- Database ---
@@ -15,10 +16,30 @@ POSTGRES_DB=vulnmanager
POSTGRES_PORT=5432 POSTGRES_PORT=5432
# --- Application Ports --- # --- Application Ports ---
# These are the internal Docker ports. Map them in your reverse proxy. # Plain HTTP, bound to 127.0.0.1 only. Users connect to nginx (HTTPS below).
# Your own reverse proxy on this host instead: installed on the host, it uses
# 127.0.0.1:${FRONTEND_PORT}; running as a Docker container (Nginx Proxy
# Manager, OpenResty, ...), it joins <project>_vulnmanager-net and uses
# vulnmanager-frontend:3000 (the container port, not FRONTEND_PORT).
BACKEND_PORT=8022 BACKEND_PORT=8022
FRONTEND_PORT=3003 FRONTEND_PORT=3003
# --- HTTPS (bundled nginx) ---
# The "tls" profile starts nginx. Remove it only when your own reverse proxy
# runs on this host (see Application Ports above). A proxy on
# another host connects to https://<this host>:${HTTPS_PORT} and trusts the
# TrueVuln CA (download in Settings > Security).
COMPOSE_PROFILES=tls
HTTPS_PORT=443
HTTP_PORT=80 # redirects to HTTPS only
# Names and IPs users type to reach TrueVuln, comma-separated. They go into
# the auto-generated certificate together with the DASHBOARD_URL host and
# localhost. The CA may only issue for these names; adding one later creates
# a new CA that has to be distributed again. nginx answers only for these
# names; any other Host gets the connection closed.
# e.g. TLS_SERVER_NAMES=truevuln.corp.example,10.0.0.5
TLS_SERVER_NAMES=
# --- Security (REQUIRED) --- # --- Security (REQUIRED) ---
# Generate with: openssl rand -hex 32 # Generate with: openssl rand -hex 32
JWT_SECRET_KEY=CHANGE-ME-GENERATE-WITH-openssl-rand-hex-32 JWT_SECRET_KEY=CHANGE-ME-GENERATE-WITH-openssl-rand-hex-32
@@ -27,16 +48,25 @@ JWT_SECRET_KEY=CHANGE-ME-GENERATE-WITH-openssl-rand-hex-32
# 'production' disables Swagger docs and enables security headers. # 'production' disables Swagger docs and enables security headers.
# 'development' enables Swagger UI at /docs and relaxes some checks. # 'development' enables Swagger UI at /docs and relaxes some checks.
ENV=production ENV=production
# Expose Swagger UI (/docs) + ReDoc (/redoc) on a production instance without
# switching to development — handy for ITSM/CMDB API integrators. Default off.
# The raw OpenAPI spec (/openapi.json) is always served regardless.
ENABLE_API_DOCS=false
# --- Cookies --- # --- Cookies ---
# Set to true when using HTTPS (recommended, required behind HTTPS proxy) # true: the session cookie never travels over plain HTTP. Keep it with the
# bundled nginx or any HTTPS proxy.
AUTH_COOKIE_SECURE=true AUTH_COOKIE_SECURE=true
AUTH_COOKIE_SAMESITE=lax AUTH_COOKIE_SAMESITE=lax
# --- Reverse Proxy --- # --- Reverse Proxy ---
# Enable if running behind a reverse proxy to trust X-Forwarded-For headers # Trust X-Forwarded-For so audit log and login rate limit see the client IP,
# for correct client IP in rate limiting and audit logs. # not the frontend container's. Trusted peers are loopback and the backend's
# own Docker subnet (found automatically). A reverse proxy on ANOTHER host in
# front of nginx goes into FORWARDED_ALLOW_IPS (IPs or CIDRs, added to that).
# Your own proxy must set X-Forwarded-For with $proxy_add_x_forwarded_for.
TRUST_PROXY_HEADERS=true TRUST_PROXY_HEADERS=true
# FORWARDED_ALLOW_IPS=10.0.0.20
# --- Default Admin Account --- # --- Default Admin Account ---
# Created on first start only (if no admin exists in the database). # Created on first start only (if no admin exists in the database).
@@ -59,9 +89,67 @@ DEFAULT_ADMIN_EMAIL=admin@vulnmanager.local
TIMEZONE=Europe/Zurich TIMEZONE=Europe/Zurich
# --- Dashboard URL --- # --- Dashboard URL ---
# Used in email notifications for links back to the dashboard. # Base URL used to build ALL links in email notifications (dashboard button,
# Set this to your external URL (behind reverse proxy). # per-CVE / per-asset deep links, the CVE links inside the digest table).
# DASHBOARD_URL=https://vuln.example.com # The backend can't know how your browser reaches the frontend, so you MUST set
# this to exactly how users open TrueVuln — otherwise links point at the
# fallback http://localhost:3000 and won't work.
# Its host also goes into the HTTPS certificate, and an uploaded certificate
# must cover it.
# e.g. https://truevuln.corp.example or https://10.0.0.5
# Set it, then restart the backend (docker compose up -d).
DASHBOARD_URL=https://localhost
# --- Microsoft Intune / Graph (optional) ---
# Configured in the UI: Settings → "Microsoft Intune (Graph API)" card
# (stored encrypted as the `intune_config` setting), NOT via env. Pulls
# Intune managed devices → assets + OS-EOL.
# App-only (client-credentials): register an Entra app, add the Application
# permission DeviceManagementManagedDevices.Read.All, grant admin consent,
# create a client secret. (Defender TVM phase: BOTH Machine.Read.All AND
# Vulnerability.Read.All on WindowsDefenderATP — the /api/machines call
# requires Machine.Read.All.) No env vars required.
# --- NVD API key (optional, recommended) ---
# Used to backfill each CVE's official published / lastModified date from
# the NVD CVE API (drives the "Newly Published" dashboard widget sort).
# Without a key NVD allows 5 requests / 30s; with one, 50 / 30s — so a
# fresh database fills in published dates ~10x faster. Free, request at:
# https://nvd.nist.gov/developers/request-an-api-key
# NVD_API_KEY=your-nvd-api-key
# --- VulnCheck KEV (optional) ---
# Third "actively exploited" catalog next to CISA KEV and ENISA EUVD. Carries
# ~5200 CVEs against CISA's ~1400 and usually lists them earlier; the KEV panel
# shows the lead time per CVE. Free community account -> API token at
# https://console.vulncheck.com/
# Free for commercial use as long as "VulnCheck KEV" stays attributed in the
# UI (the Advisories page does that automatically).
# Without a token the source stays silent and nothing changes.
# VULNCHECK_API_TOKEN=vulncheck_...
#
# To also alert on it, add "vulncheck" to the kev_alert_sources setting
# (default "cisa,euvd" — opting in is deliberate, it is ~4x the CVEs).
# --- OpenRouter AI remediation (optional) ---
# Enables the "AI Remediation" button on the CVE detail page — generates
# OS-aware fix steps/commands on demand. OpenAI-compatible; get a key at
# https://openrouter.ai/keys (free tier ~10 req/day across free models).
# Without a key the feature stays hidden.
# OPENROUTER_API_KEY=sk-or-...
# Model slug (OpenRouter uses dots, e.g. anthropic/claude-sonnet-4.6).
# Default openrouter/free auto-routes across available free models.
# OPENROUTER_MODEL=openrouter/free
# 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).
# ============================================================================= # =============================================================================
@@ -108,7 +196,7 @@ LDAP_CA_CERT_PATH=/etc/ssl/certs/company-ca.pem
LDAP_VALIDATE_CERT=true # NEVER set to false in production LDAP_VALIDATE_CERT=true # NEVER set to false in production
# Service account for the search-then-bind flow. # Service account for the search-then-bind flow.
LDAP_BIND_DN=cn=svc-vulncheck,ou=ServiceAccounts,dc=company,dc=local LDAP_BIND_DN=cn=svc-truevuln,ou=ServiceAccounts,dc=company,dc=local
# Bootstrap password — loaded once, encrypted, stored in DB. # Bootstrap password — loaded once, encrypted, stored in DB.
# Remove from env AFTER the first successful start. # Remove from env AFTER the first successful start.
LDAP_BIND_PASSWORD_BOOTSTRAP= LDAP_BIND_PASSWORD_BOOTSTRAP=
@@ -136,7 +224,7 @@ OIDC_PROVIDER_NAME=Entra ID
OIDC_DISCOVERY_URL= OIDC_DISCOVERY_URL=
OIDC_CLIENT_ID= OIDC_CLIENT_ID=
OIDC_CLIENT_SECRET= OIDC_CLIENT_SECRET=
OIDC_REDIRECT_URI=https://vulncheck.company.com/auth/oidc/callback OIDC_REDIRECT_URI=https://truevuln.company.com/auth/oidc/callback
OIDC_SCOPES=openid profile email groups OIDC_SCOPES=openid profile email groups
# Claim names — defaults work for Entra ID / Keycloak. Adjust per IdP. # Claim names — defaults work for Entra ID / Keycloak. Adjust per IdP.
@@ -151,20 +239,20 @@ OIDC_CLAIM_SUBJECT=sub
# SAML 2.0 # SAML 2.0
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
SAML_PROVIDER_NAME=Single Sign-On SAML_PROVIDER_NAME=Single Sign-On
SAML_SP_ENTITY_ID=https://vulncheck.company.com/auth/saml/metadata SAML_SP_ENTITY_ID=https://truevuln.company.com/auth/saml/metadata
SAML_SP_ACS_URL=https://vulncheck.company.com/auth/saml/acs SAML_SP_ACS_URL=https://truevuln.company.com/auth/saml/acs
SAML_SP_SLO_URL=https://vulncheck.company.com/auth/saml/slo SAML_SP_SLO_URL=https://truevuln.company.com/auth/saml/slo
# SP cert + key — generate a keypair specifically for this SP: # SP cert + key — generate a keypair specifically for this SP:
# openssl req -x509 -newkey rsa:2048 -nodes \ # openssl req -x509 -newkey rsa:2048 -nodes \
# -keyout sp.key -out sp.crt -days 730 \ # -keyout sp.key -out sp.crt -days 730 \
# -subj "/CN=vulncheck.company.com" # -subj "/CN=truevuln.company.com"
SAML_SP_CERT_PATH=/etc/vulncheck/saml/sp.crt SAML_SP_CERT_PATH=/etc/truevuln/saml/sp.crt
SAML_SP_PRIVATE_KEY_PATH=/etc/vulncheck/saml/sp.key SAML_SP_PRIVATE_KEY_PATH=/etc/truevuln/saml/sp.key
# IdP metadata source — exactly one of: # IdP metadata source — exactly one of:
SAML_IDP_METADATA_URL= SAML_IDP_METADATA_URL=
# SAML_IDP_METADATA_PATH=/etc/vulncheck/saml/idp-metadata.xml # SAML_IDP_METADATA_PATH=/etc/truevuln/saml/idp-metadata.xml
# Attribute mapping (defaults work for most IdPs) # Attribute mapping (defaults work for most IdPs)
SAML_ATTR_USERNAME=urn:oid:0.9.2342.19200300.100.1.1 SAML_ATTR_USERNAME=urn:oid:0.9.2342.19200300.100.1.1
+41
View File
@@ -0,0 +1,41 @@
name: CLA Assistant
on:
issue_comment:
types: [created]
pull_request_target:
types: [opened, closed, synchronize]
permissions:
actions: write
contents: write
pull-requests: write
statuses: write
jobs:
cla:
runs-on: ubuntu-latest
steps:
- name: Check CLA signature
if: (github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target'
uses: contributor-assistant/github-action@v2.6.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Fine-grained PAT with Contents: read/write on this repo.
# Needed so the bot can write signatures to the cla-signatures branch.
PERSONAL_ACCESS_TOKEN: ${{ secrets.CLA_SIGNATURES_TOKEN }}
with:
path-to-signatures: 'signatures/version1/cla.json'
path-to-document: 'https://github.com/{{GH_OWNER}}/{{GH_REPO}}/blob/main/CLA.md'
branch: 'cla-signatures'
allowlist: 'dependabot[bot],renovate[bot],{{GH_OWNER}}'
custom-notsigned-prcomment: |
Thanks for your pull request! Before we can merge it, please read and sign the
[Contributor License Agreement](https://github.com/{{GH_OWNER}}/{{GH_REPO}}/blob/main/CLA.md).
**You keep the copyright to your work.** The CLA lets us distribute TrueVuln under
both AGPL-3.0 and commercial terms — see [LICENSING.md](https://github.com/{{GH_OWNER}}/{{GH_REPO}}/blob/main/LICENSING.md).
To sign, post exactly this as a comment on this pull request:
custom-pr-sign-comment: 'I have read the CLA Document and I hereby sign the CLA'
custom-allsigned-prcomment: 'All contributors have signed the CLA. Thanks!'
+66
View File
@@ -68,3 +68,69 @@ backups/
# Local Training Doc # Local Training Doc
CODEBASE_KNOWLEDGE_BASE.md CODEBASE_KNOWLEDGE_BASE.md
.claude/ .claude/
# Local agent instructions (auto-generated by gitnexus setup/analyze)
AGENTS.md
CLAUDE.md
# --- Secrets: alle .env-Varianten, Zertifikate, Keys ---
.env.*
!.env.example
*.pem
*.key
*.crt
*.cer
*.p12
*.pfx
sp.key
sp.crt
idp-metadata.xml
secrets/
# --- AI-Tooling-State (nie in einem public Repo) ---
.serena/
.gitnexus/
.aider*
.cursor/
.windsurf/
.continue/
.gemini/
# --- Python-Luecken ---
.venv/
.mypy_cache/
.ruff_cache/
.tox/
.hypothesis/
coverage.xml
.coverage.*
# --- Node/Next auf Root-Ebene (frontend/.gitignore deckt nur frontend/) ---
node_modules/
.next/
out/
.turbo/
*.tsbuildinfo
npm-debug.log*
yarn-error.log*
# --- OS/Editor-Luecken ---
._*
.Spotlight-V100
desktop.ini
.zed/
.fleet/
.history/
# --- Generierte Exporte mit echten Scan-Daten ---
vulnerabilities_report_*.csv
executive_summary_*.pdf
audit_log_*.csv
exports/
uploads/
reports/
*.sqlite
*.dump
# --- Eingebetteter Fremd-Checkout (eigenes Repo, kein Submodul) ---
wazuh-risk-insight/
+56 -14
View File
@@ -1,4 +1,4 @@
# VulnCheck — Architecture Overview # TrueVuln — Architecture Overview
> Stand: Mai 2026 (dev-Branch, post-Migration 022). Diese Doku spiegelt > Stand: Mai 2026 (dev-Branch, post-Migration 022). Diese Doku spiegelt
> den `dev`-Branch wider. Stable `main` ist eine Teilmenge — siehe > den `dev`-Branch wider. Stable `main` ist eine Teilmenge — siehe
@@ -16,7 +16,7 @@
│ HTTPS/JSON + JWT (Cookie+Bearer) │ HTTPS/JSON + JWT (Cookie+Bearer)
▼ ▼
┌──────────────────────────────────────────────────────────────────────────┐ ┌──────────────────────────────────────────────────────────────────────────┐
│ Backend — FastAPI (Python 3.12) │ │ Backend — FastAPI (Python 3.14) │
│ │ │ │
│ ┌──────────────┐ ┌─────────────────┐ ┌─────────────────────────┐ │ │ ┌──────────────┐ ┌─────────────────┐ ┌─────────────────────────┐ │
│ │ Routers │ │ Services │ │ Integrations (clients) │ │ │ │ Routers │ │ Services │ │ Integrations (clients) │ │
@@ -30,9 +30,9 @@
│ │ - sync_schedules (every 60s, picks up DB changes) │ │ │ │ - sync_schedules (every 60s, picks up DB changes) │ │
│ │ - SLA breach checker (hourly) │ │ │ │ - SLA breach checker (hourly) │ │
│ │ - Daily threat-intel refresh (EPSS + KEV) │ │ │ │ - Daily threat-intel refresh (EPSS + KEV) │ │
│ │ - Nightly compliance SCA refresh (02:00 UTC) │ │ │ │ - Nightly compliance SCA refresh (02:00) │ │
│ │ - Nightly Vulnrichment cascade (03:00 UTC) │ │ │ │ - Nightly Vulnrichment cascade (04:30) │ │
│ │ - Nightly URS recompute + snapshot prune (04:00 UTC) │ │ │ │ - Nightly URS recompute + snapshot prune (05:10) │ │
│ │ - User-defined scan schedules (Wazuh or Nessus, cron/interval) │ │ │ │ - User-defined scan schedules (Wazuh or Nessus, cron/interval) │ │
│ └──────────────────────────────────────────────────────────────────┘ │ │ └──────────────────────────────────────────────────────────────────┘ │
└────────────────────┬──────────────────────────────┬──────────────────────┘ └────────────────────┬──────────────────────────────┬──────────────────────┘
@@ -40,7 +40,7 @@
SQLAlchemy 2.x httpx clients SQLAlchemy 2.x httpx clients
▼ ▼ ▼ ▼
┌────────────────────────┐ ┌────────────────────────┐ ┌────────────────────────┐ ┌────────────────────────┐
│ PostgreSQL 16 │ │ External Sources │ │ PostgreSQL 18 │ │ External Sources │
│ - alembic-managed │ │ - Wazuh Indexer/API │ │ - alembic-managed │ │ - Wazuh Indexer/API │
│ - 22 migrations live │ │ - Nessus REST API │ │ - 22 migrations live │ │ - Nessus REST API │
│ - JSON columns: │ │ - NVD REST API │ │ - JSON columns: │ │ - NVD REST API │
@@ -61,7 +61,7 @@
| Frontend | Next.js 14 App Router, React 18, TypeScript, Tailwind CSS, Heroicons | | Frontend | Next.js 14 App Router, React 18, TypeScript, Tailwind CSS, Heroicons |
| Backend | FastAPI 0.115, Pydantic v2, Uvicorn | | Backend | FastAPI 0.115, Pydantic v2, Uvicorn |
| ORM | SQLAlchemy 2.x + Alembic | | ORM | SQLAlchemy 2.x + Alembic |
| Database | PostgreSQL 16 | | Database | PostgreSQL 18 |
| Scheduler | APScheduler 3.x (interval + cron triggers) | | Scheduler | APScheduler 3.x (interval + cron triggers) |
| Auth | Local (bcrypt + TOTP), LDAPS (`ldap3`), OIDC (`authlib` + JWKS), SAML 2.0 (`python3-saml`) | | Auth | Local (bcrypt + TOTP), LDAPS (`ldap3`), OIDC (`authlib` + JWKS), SAML 2.0 (`python3-saml`) |
| AI | Infomaniak AI proxy (configurable via env) | | AI | Infomaniak AI proxy (configurable via env) |
@@ -72,7 +72,7 @@
## 2. Security Architecture (OWASP Top 10 Mitigation) ## 2. Security Architecture (OWASP Top 10 Mitigation)
| OWASP risk | Defence in VulnCheck | | OWASP risk | Defence in TrueVuln |
|---|---| |---|---|
| **A01 Broken Access Control** | RBAC enforced via `require_role()` dependency on every router; admin / editor / viewer scopes. Asset-ownership filter on vuln listings. | | **A01 Broken Access Control** | RBAC enforced via `require_role()` dependency on every router; admin / editor / viewer scopes. Asset-ownership filter on vuln listings. |
| **A02 Cryptographic Failures** | TOTP secrets encrypted at rest with `AUTH_PROVIDER_CRYPTO_KEY` (Fernet). LDAP bind password same. bcrypt cost-12 for local passwords. JWT secret rotated per deployment. | | **A02 Cryptographic Failures** | TOTP secrets encrypted at rest with `AUTH_PROVIDER_CRYPTO_KEY` (Fernet). LDAP bind password same. bcrypt cost-12 for local passwords. JWT secret rotated per deployment. |
@@ -101,6 +101,10 @@ Each router groups a logical resource. All require JWT except `/auth/*` login en
| `auth_saml.py` | SAML 2.0 SSO | `/auth/saml/login`, `/auth/saml/acs`, `/auth/saml/metadata` | | `auth_saml.py` | SAML 2.0 SSO | `/auth/saml/login`, `/auth/saml/acs`, `/auth/saml/metadata` |
| `vulnerabilities.py` | Vulns, enrichment, overrides, sync | `/vulnerabilities`, `/vulnerabilities/sync/wazuh`, `/vulnerabilities/enrich/*`, `/vulnerabilities/override/*` | | `vulnerabilities.py` | Vulns, enrichment, overrides, sync | `/vulnerabilities`, `/vulnerabilities/sync/wazuh`, `/vulnerabilities/enrich/*`, `/vulnerabilities/override/*` |
| `nessus.py` | Tenable Nessus | `/vulnerabilities/nessus/{test,scans,sync,scan-host,cleanup-pseudo-cves}` | | `nessus.py` | Tenable Nessus | `/vulnerabilities/nessus/{test,scans,sync,scan-host,cleanup-pseudo-cves}` |
| `intune.py` | Microsoft Intune / Graph | `/integrations/intune/{test,sync,sync/status}` |
| `vcenter.py` | VMware vCenter / ESXi | `/integrations/vcenter/{test,sync,sync/status,catalog/refresh}` |
| `igel.py` | IGEL UMS (IMI API v3) | `/integrations/igel/{test,sync,sync/status}` |
| `xenserver.py` | XenServer pool (XAPI) | `/integrations/xenserver/{test,sync,sync/status}` |
| `assets.py` | Asset CRUD + assignment | `/assets`, `/assets/{id}`, `/assets/bulk-update`, `/assets/{id}/assign` | | `assets.py` | Asset CRUD + assignment | `/assets`, `/assets/{id}`, `/assets/bulk-update`, `/assets/{id}/assign` |
| `scans.py` | Scan history + schedules | `/scans`, `/scans/summary`, `/scans/clear`, `/scans/autoscan`, `/scans/schedules` | | `scans.py` | Scan history + schedules | `/scans`, `/scans/summary`, `/scans/clear`, `/scans/autoscan`, `/scans/schedules` |
| `compliance.py` | SCA + URS | `/compliance/summary`, `/compliance/assets`, `/compliance/{asset_id}`, `/compliance/refresh`, `/compliance/impacts/{import,stats}`, `/compliance/urs[/...]` | | `compliance.py` | SCA + URS | `/compliance/summary`, `/compliance/assets`, `/compliance/{asset_id}`, `/compliance/refresh`, `/compliance/impacts/{import,stats}`, `/compliance/urs[/...]` |
@@ -118,6 +122,8 @@ Pure business logic, no FastAPI imports. Reusable from scheduler + routers.
| Service | Role | | Service | Role |
|---|---| |---|---|
| `enrichment_service.py` | EPSS / KEV / EUVD enrichment per CVE, with 24h cache in `settings` | | `enrichment_service.py` | EPSS / KEV / EUVD enrichment per CVE, with 24h cache in `settings` |
| `kev_service.py` | Merges the KEV sources (CISA KEV + ENISA EUVD exploited + VulnCheck KEV) into one catalog and annotates it with inventory impact (open vs remediated, active assets only) |
| `kev_alert_service.py` | Immediate mail when an actively-exploited CVE has open findings here; idempotent per CVE |
| `vuln_override_service.py` | 3-stage CVSS / SSVC / fixed_version cascade (Vulnrichment → NVD → cvelistV5) | | `vuln_override_service.py` | 3-stage CVSS / SSVC / fixed_version cascade (Vulnrichment → NVD → cvelistV5) |
| `override_jobs.py` | Async job tracker for the long-running "Correct CVSS" button | | `override_jobs.py` | Async job tracker for the long-running "Correct CVSS" button |
| `nessus_sync.py` | Per-scan import, asset matching cascade, pseudo-CVE handling | | `nessus_sync.py` | Per-scan import, asset matching cascade, pseudo-CVE handling |
@@ -125,6 +131,10 @@ Pure business logic, no FastAPI imports. Reusable from scheduler + routers.
| `compliance_impact_import.py` | CIS-Benchmark CSV importer with loose-header matching | | `compliance_impact_import.py` | CIS-Benchmark CSV importer with loose-header matching |
| `urs_service.py` | AVS + ASS → URS formula, snapshot writes, daily prune | | `urs_service.py` | AVS + ASS → URS formula, snapshot writes, daily prune |
| `email_service.py` | SMTP send, per-recipient templates, digest + single modes | | `email_service.py` | SMTP send, per-recipient templates, digest + single modes |
| `vcenter_service.py` | vCenter + ESXi inventory sync → assets, EOL and vSphere CVEs |
| `igel_service.py` | IGEL UMS server + endpoint-device inventory sync → assets and IGEL OS CVEs |
| `xenserver_service.py` | XenServer pool sync → host assets, CVEs from the security updates pending per host |
| `vmware_release_service.py` | vSphere release identity: build numbers, update lines, Broadcom KB 326316 catalog |
### 3.3 Integrations (`app/integrations/`) ### 3.3 Integrations (`app/integrations/`)
@@ -136,6 +146,10 @@ Thin HTTP clients, no DB writes.
| `nessus_client.py` | REST API + plugin output parsing (`fixed_version`, VPR, host UUID) | | `nessus_client.py` | REST API + plugin output parsing (`fixed_version`, VPR, host UUID) |
| `ai_client.py` | LLM dispatcher (currently routes to Infomaniak) | | `ai_client.py` | LLM dispatcher (currently routes to Infomaniak) |
| `infomaniak_ai_client.py` | Infomaniak chat-completion proxy | | `infomaniak_ai_client.py` | Infomaniak chat-completion proxy |
| `graph_client.py` | Microsoft Graph (app-only) — Intune managed devices + detected apps |
| `vcenter_client.py` | vSphere SOAP (pyVmomi) — `about` + one PropertyCollector pass over every `HostSystem` |
| `igel_client.py` | IGEL IMI REST v3 — `serverstatus`, the firmware table, and every device in one `?facets=details` call |
| `xenserver_client.py` | XAPI on the pool coordinator — XML-RPC pool/host records + `GET /updates` |
### 3.4 Auth Stack — Strategy Pattern ### 3.4 Auth Stack — Strategy Pattern
@@ -173,10 +187,28 @@ SSO/LDAP login. `RoleMapper` re-evaluates role on every login from
|---|---|---| |---|---|---|
| `scheduler_sync` | every 60 s | Picks up DB changes to `scan_schedules` and re-registers jobs | | `scheduler_sync` | every 60 s | Picks up DB changes to `scan_schedules` and re-registers jobs |
| `sla_breach_check` | every 1 h | SLA-overdue scan; honors `sla_breach_enabled` toggle + `PolicyStatus.DISABLED` skip; digest or single mode | | `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 | | `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 |
| `compliance_sca_nightly` | 02:00 UTC | Wazuh SCA pull for every linked asset | | `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` |
| `vulnrichment_nightly` | 03:00 UTC | 3-stage CVSS/SSVC/fixed_version cascade | | `advisory_feeds_refresh` | every 6 h, :20 | ZDI/CERT-EU/BSI/... RSS feeds into the advisories cache |
| `urs_nightly` | 04:00 UTC | URS recompute + asset_risk_snapshots prune (>90d) | | `https_certificate_check` | 03:45 | Renews the auto-generated HTTPS certificate (< 30 days left, names changed); logs a warning for an uploaded one about to expire. Also runs in `lifespan` |
| `new_vuln_digest_nightly` | hourly, :05 | Sends the new-CVE roundup at `notification_nightly_hour` (default 06) when `notification_schedule` is nightly |
| `vuln_index_refresh_nightly` | 01:30 | Rebuilds the cvelistV5 (+MFSA), GitHub-advisory, TeamViewer, IGEL-ISN and MSRC fixed-build indexes before any sync or scan reads them |
| `compliance_sca_nightly` | 02:00 | Wazuh SCA pull for every linked asset |
| `intune_sync_nightly` | 02:10 | Intune managed devices → assets + OS-EOL + per-device app-CVE scan, Defender TVM CVEs when enabled; holds an advisory lock, can run for hours |
| `vcenter_sync_nightly` | 02:20 | vCenter + ESXi hosts → assets, EOL, vSphere CVEs (refreshes the build catalog first) |
| `igel_sync_nightly` | 02:30 | IGEL UMS server + endpoint devices → assets, IGEL OS CVEs |
| `exposure_nightly` | 02:30 | Wazuh syscollector ports → network-exposure score |
| `netdisco_sync_nightly` | 02:40 | Netdisco switches/routers → assets, firmware CVEs |
| `dockhand_sync_nightly` | 02:50 | Dockhand container-image findings (rescan first) |
| `xenserver_sync_nightly` | 02:55 | XenServer pool hosts → assets, CVEs of the security updates pending per host |
| `post_inventory_scans_nightly` | 03:00 | Waits for the Intune sync, then in turn: EOL check → M365 Apps CVEs → app-CVE scan → MSRC fixed-build scan + remediation catch-up |
| `vulnrichment_nightly` | 04:30 | 3-stage CVSS/SSVC/fixed_version cascade |
| `msrc_refresh_weekly` | Sun 04:40 | MSRC CVRF remediations, 18 months back |
| `exploit_intel_nightly` | 04:50 | Exploit-DB / PoC-in-GitHub / Metasploit counts |
| `urs_nightly` | 05:10 | URS recompute + asset_risk_snapshots prune (>90d) |
| `asset_lifecycle_nightly` | 05:25 | Inactivates assets no source reported within the window, revives seen ones |
| `audit_log_prune_nightly` | 05:40 | Audit-log retention prune (`audit_log_retention_days`) |
| `jev_triage_nightly` | 06:00 | Jev config hint for new CVEs (off unless enabled) |
| user-defined | per schedule | Per-`scan_schedules` row, routes to `run_wazuh_vulnerability_sync` or `run_nessus_sync` based on `scanner_type` | | user-defined | per schedule | Per-`scan_schedules` row, routes to `run_wazuh_vulnerability_sync` or `run_nessus_sync` based on `scanner_type` |
--- ---
@@ -247,7 +279,10 @@ Query parameters of note on `GET /api/v1/vulnerabilities`:
docker-compose.yml docker-compose.yml
├── postgres (volume: pgdata) ├── postgres (volume: pgdata)
├── backend (FastAPI + APScheduler) ── alembic upgrade head on start ├── backend (FastAPI + APScheduler) ── alembic upgrade head on start
└── frontend (Next.js standalone build, served via node) │ writes CA + server certificate to volume tls_data
├── frontend (Next.js standalone build, served via node; 127.0.0.1 only)
└── nginx (profile "tls": TLS 1.3 on 443, 80 → 301, CIS-hardened; reads tls_data,
reloads itself when the certificate changes)
``` ```
Environment configuration via `.env` (gitignored). Required keys: Environment configuration via `.env` (gitignored). Required keys:
@@ -278,7 +313,14 @@ Optional outbound HTTPS endpoints (firewall whitelist if locked-down):
- `https://raw.githubusercontent.com` (Vulnrichment per-CVE + ZIPs) - `https://raw.githubusercontent.com` (Vulnrichment per-CVE + ZIPs)
- `https://github.com` (cvelistV5 ZIP) - `https://github.com` (cvelistV5 ZIP)
- `https://services.nvd.nist.gov` (NVD REST) - `https://services.nvd.nist.gov` (NVD REST)
- `https://api.github.com` (GitHub repo security advisories — Notepad++, Wazuh)
- `https://www.teamviewer.com` (TeamViewer security bulletins)
- `https://nessus.your-internal/...` (Nessus) - `https://nessus.your-internal/...` (Nessus)
- `https://graph.microsoft.com` (Intune / Defender TVM)
- `https://endoflife.date` (EOL/EOS lifecycle)
- `https://knowledge.broadcom.com` (vCenter release → build catalog, KB 326316)
- `https://vcenter.your-internal/sdk` (vSphere SOAP, port 443)
- `https://ums.your-internal:8443/umsapi/v3` (IGEL IMI REST)
### Upgrade flow ### Upgrade flow
@@ -296,7 +338,7 @@ docker compose exec backend alembic current # expect: 022 (head)
- Wazuh-Indexer pagination beyond the OpenSearch 10 000-hit cap: search-after / scroll in `WazuhClient.get_vulnerabilities`. - Wazuh-Indexer pagination beyond the OpenSearch 10 000-hit cap: search-after / scroll in `WazuhClient.get_vulnerabilities`.
- Vulnrichment cascade auto-routes to ZIP snapshot when > 25 CVEs requested — avoids 25 × N raw fetches. - Vulnrichment cascade auto-routes to ZIP snapshot when > 25 CVEs requested — avoids 25 × N raw fetches.
- cvelistV5 ZIP (~557 MB) is on-disk-cached 12 h at `/tmp/vulncheck-cvelistv5-cache.zip`. - cvelistV5 ZIP (~557 MB) is on-disk-cached 12 h at `/tmp/truevuln-cvelistv5-cache.zip`.
- NVD stage caps at 100 CVEs per run (rate limit). >100 missing → falls through to cvelistV5 directly. - NVD stage caps at 100 CVEs per run (rate limit). >100 missing → falls through to cvelistV5 directly.
- `sources` / `enrichment_sources` JSON columns avoid table-join chatter for per-vuln source filtering. - `sources` / `enrichment_sources` JSON columns avoid table-join chatter for per-vuln source filtering.
- Indexed columns: `cve_id`, `asset_id`, `cvss_score`, `severity`, `epss_score`, `kev_listed`, `euvd_listed`, `exploitation_status`, `ssvc_technical_impact`, `ssvc_automatable`, `nessus_vpr_score`, `nessus_plugin_id`, `package_name`, `detected_at`. - Indexed columns: `cve_id`, `asset_id`, `cvss_score`, `severity`, `epss_score`, `kev_listed`, `euvd_listed`, `exploitation_status`, `ssvc_technical_impact`, `ssvc_automatable`, `nessus_vpr_score`, `nessus_plugin_id`, `package_name`, `detected_at`.
+102
View File
@@ -0,0 +1,102 @@
# TrueVuln Contributor License Agreement
**Version 1.0**
Thank you for your interest in contributing to TrueVuln (the "Project"), maintained by
{{LICENSOR}} (the "Maintainer").
This Contributor License Agreement ("Agreement") clarifies the intellectual property
license granted with Contributions from any person or entity. It protects You, the
Maintainer, and every user of the Project. **You keep the copyright to your work.**
This Agreement only grants the Maintainer the additional permissions needed to
distribute the Project sustainably.
By submitting a Contribution to the Project, You accept and agree to the following
terms for Your present and future Contributions.
## 1. Definitions
**"You"** means the copyright owner, or the legal entity authorized by the copyright
owner, that is entering into this Agreement.
**"Contribution"** means any original work of authorship, including any modification
of or addition to an existing work, that is intentionally submitted by You to the
Maintainer for inclusion in the Project. "Submitted" means any form of electronic,
verbal, or written communication sent to the Maintainer or its representatives,
including but not limited to pull requests, patches, issues, and electronic mailing
lists, excluding communication conspicuously marked in writing by You as
"Not a Contribution".
## 2. Copyright License
You hereby grant to the Maintainer a perpetual, worldwide, non-exclusive, no-charge,
royalty-free, irrevocable copyright license to reproduce, prepare derivative works of,
publicly display, publicly perform, sublicense, and distribute Your Contributions and
such derivative works, **under any license terms, including the GNU Affero General
Public License v3.0, any later version thereof, and proprietary or commercial license
terms.**
This grant is what allows the Maintainer to offer the Project both as free and open
source software and under separate commercial terms. See `LICENSING.md`.
## 3. Patent License
You hereby grant to the Maintainer and to recipients of software distributed by the
Maintainer a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made, use, offer to
sell, sell, import, and otherwise transfer the Project, where such license applies only
to those patent claims licensable by You that are necessarily infringed by Your
Contribution alone or by combination of Your Contribution with the Project.
If any entity institutes patent litigation against You or any other entity alleging
that Your Contribution, or the Project to which You contributed, constitutes direct or
contributory patent infringement, then any patent licenses granted to that entity under
this Agreement terminate as of the date such litigation is filed.
## 4. You Retain Your Rights
You retain all right, title, and interest in and to Your Contributions. Nothing in this
Agreement restricts Your ability to use, license, or distribute Your Contributions
elsewhere, under any terms You choose.
## 5. Your Representations
You represent that:
1. You are legally entitled to grant the above licenses.
2. Each of Your Contributions is Your original creation, or You have the necessary
rights to submit it.
3. If Your employer has rights to intellectual property You create, You have either
received permission to make Contributions on behalf of that employer, or Your
employer has waived such rights for Your Contributions.
4. Your Contributions do not knowingly include third-party code that would subject the
Project to license terms incompatible with Section 2. If You submit any work that is
not Your original creation, You will identify it clearly, along with its source and
license.
## 6. No Obligation, No Warranty
You are not expected to provide support for Your Contributions. The Maintainer is under
no obligation to accept or merge any Contribution.
Unless required by applicable law or agreed to in writing, You provide Your
Contributions **"AS IS", WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND**, either express
or implied, including any warranty of merchantability, fitness for a particular purpose,
title, or non-infringement.
## 7. Notification
You agree to notify the Maintainer of any facts or circumstances of which You become
aware that would make these representations inaccurate in any respect.
---
## How to sign
You do not need to send anything by mail. When you open your first pull request, an
automated bot will ask you to confirm this Agreement by posting a comment on the pull
request. Your signature is recorded once and applies to all your future Contributions.
If you prefer not to sign the CLA, you are still very welcome to help: bug reports,
reproduction steps, documentation feedback, and feature discussion in issues do not
require a CLA.
+92
View File
@@ -0,0 +1,92 @@
# Contributing to TrueVuln
Thanks for wanting to help. TrueVuln aggregates and enriches vulnerability data from a
lot of moving upstream sources, so almost every kind of contribution is useful --
especially the boring ones.
## Contributions that need no CLA
Open an issue. No agreement, no bot, no friction:
- **Bug reports** -- include the scanner source (Wazuh / Nessus / Intune / Defender),
the CVE or asset involved, and what you expected instead
- **Upstream data problems** -- a CVE with a wrong score, an EOL product not detected,
a false positive from a specific scanner
- **Feature requests and discussion**
- **Documentation feedback**
## Contributions that need a CLA
Code and documentation changes submitted as pull requests require the
[Contributor License Agreement](CLA.md).
**Why:** TrueVuln is open core (see [LICENSING.md](LICENSING.md)). The CLA grants the
maintainer permission to distribute contributed code under both AGPL-3.0 and commercial
terms. **You keep the copyright to your work** and can still use, license, or distribute
it anywhere else however you like.
**How:** Open your pull request. A bot comments asking you to confirm the CLA. You reply
with the sentence it gives you. That is it -- once, ever, for all future contributions.
If you do not want to sign, that is fine. Open an issue describing the fix instead; a
maintainer can implement it.
## Before you open a pull request
1. **Open an issue first** for anything non-trivial. Saves you writing code that goes in
a direction the project is not taking.
2. **One concern per pull request.** A bug fix and a refactor in the same diff is two
pull requests.
3. **Add a test.** Anything with a branch, a parser, a version comparison, or a scoring
rule needs a test that fails without your change. `tests/` shows the existing style.
4. **Do not commit secrets.** No API keys, tokens, real hostnames, real asset data, or
customer names -- not in code, not in tests, not in fixtures. Use the placeholder
style from `.env.example`.
5. **Sanitise fixtures.** If you add scanner output as a test fixture, strip hostnames,
IP addresses, serial numbers, and user names first.
## Development setup
```bash
cp .env.example .env # fill in your own values
docker compose up -d
```
Or without Docker, see `README.DEV.md`.
Run the tests:
```bash
pytest
```
## What makes a good contribution here
TrueVuln's value is accuracy. A detection that fires on the wrong host is worse than no
detection, because it burns the operator's trust. So:
- **Version and build comparisons must be exact.** Family-matched, range-bounded. A
Windows 11 host must never match a Server-only fix, even on a shared build line.
- **Prefer authoritative sources.** Vendor advisories over aggregators, aggregators over
heuristics.
- **Findings must be able to close themselves.** If a detection can fire, it needs a path
to auto-resolve when the condition no longer holds. Stale findings are noise.
- **Fail loud in ingest, fail quiet in the UI.** A broken upstream feed should be visible
in logs and status, not silently produce empty results.
## Commit messages
Conventional Commits, scoped to the area:
```
fix(msrc): the ingest sorted the monthly index by revision date, not month
feat(nessus): import plugin output as remediation text
```
Describe what actually changed and why. The subject line should make sense to someone
reading `git log` two years from now.
## Security issues
**Do not open a public issue for a security vulnerability in TrueVuln itself.**
See [SECURITY.md](SECURITY.md) for private reporting.
+15 -4
View File
@@ -1,7 +1,7 @@
# VulnCheck — Database Schema # TrueVuln — Database Schema
> Stand: Mai 2026, alembic head = `022_backfill_ssvc_only_exploitation_source`. > Stand: Mai 2026, alembic head = `022_backfill_ssvc_only_exploitation_source`.
> PostgreSQL 16. Times in UTC. `TimestampMixin` adds `created_at` + `updated_at` > PostgreSQL 18. Times in UTC. `TimestampMixin` adds `created_at` + `updated_at`
> to every audited table unless noted. > to every audited table unless noted.
--- ---
@@ -91,12 +91,19 @@
| ip_address | VARCHAR(45) | INDEX | IPv4/IPv6 | | ip_address | VARCHAR(45) | INDEX | IPv4/IPv6 |
| wazuh_agent_id | VARCHAR(10) | UNIQUE, INDEX | Zero-padded ("006") | | wazuh_agent_id | VARCHAR(10) | UNIQUE, INDEX | Zero-padded ("006") |
| nessus_host_uuid | VARCHAR(64) | INDEX | Pinned after first Nessus match | | nessus_host_uuid | VARCHAR(64) | INDEX | Pinned after first Nessus match |
| intune_device_id | VARCHAR(64) | INDEX | Pinned after first Graph match |
| defender_machine_id | VARCHAR(64) | INDEX | Defender for Endpoint machine |
| aad_device_id | VARCHAR(64) | INDEX | Entra device id — shared Intune/Defender anchor |
| vmware_uuid | VARCHAR(64) | INDEX | ESXi hardware UUID / vCenter instanceUuid (migration 045) |
| vmware_build | VARCHAR(32) | NULLABLE | VMkernel / vpxd build — VMware states its fixes as builds, so this is what decides a vSphere CVE |
| igel_unit_id | VARCHAR(64) | INDEX | IGEL UMS unit ID / UMS `serverUUID` (migration 048) |
| xen_host_uuid | VARCHAR(64) | INDEX | XenServer XAPI host UUID (migration 064) |
| operating_system | VARCHAR(255) | NULLABLE | OS family | | operating_system | VARCHAR(255) | NULLABLE | OS family |
| os_version | VARCHAR(100) | NULLABLE | | | os_version | VARCHAR(100) | NULLABLE | |
| kernel_version | VARCHAR(100) | NULLABLE | | | kernel_version | VARCHAR(100) | NULLABLE | |
| criticality | VARCHAR(16) | NOT NULL DEFAULT 'normal' | `low` / `normal` / `high` / `critical` — URS multiplier | | criticality | VARCHAR(16) | NOT NULL DEFAULT 'normal' | `low` / `normal` / `high` / `critical` — URS multiplier |
| compliance_frameworks | TEXT | NULLABLE | JSON array of mandatory framework slugs | | compliance_frameworks | TEXT | NULLABLE | JSON array of mandatory framework slugs |
| source | ENUM `assetsource` | NOT NULL | `wazuh` / `manual` / `nessus` / etc. | | source | ENUM `assetsource` | NOT NULL | `WAZUH` / `NESSUS` / `INTUNE` / `VCENTER` / `IGEL` / `MANUAL` |
| status | ENUM `assetstatus` | NOT NULL | `active` / `inactive` | | status | ENUM `assetstatus` | NOT NULL | `active` / `inactive` |
| last_scan | TIMESTAMP | NULLABLE | | | last_scan | TIMESTAMP | NULLABLE | |
| last_seen | TIMESTAMP | NULLABLE | | | last_seen | TIMESTAMP | NULLABLE | |
@@ -221,7 +228,7 @@ shows the human-friendly source string.
| vulnerability_id | INTEGER | FK, INDEX | | | vulnerability_id | INTEGER | FK, INDEX | |
| asset_id | INTEGER | FK, INDEX | | | asset_id | INTEGER | FK, INDEX | |
| user_id | INTEGER | FK, INDEX | Recipient user | | user_id | INTEGER | FK, INDEX | Recipient user |
| notification_type | ENUM `notificationtype` | NOT NULL, INDEX | `NEW_VULN` / `SLA_BREACH` / `STATUS_CHANGE` / etc. | | notification_type | ENUM `notificationtype` | NOT NULL, INDEX | `SLA_BREACH` / `ASSIGNMENT` / `NEW_VULNERABILITY` / `KEV_ALERT` / `MANUAL` |
| sent_at | TIMESTAMP | NOT NULL, INDEX | | | sent_at | TIMESTAMP | NOT NULL, INDEX | |
| subject | VARCHAR(500) | NULLABLE | | | subject | VARCHAR(500) | NULLABLE | |
| recipient_email | VARCHAR(255) | NULLABLE | | | recipient_email | VARCHAR(255) | NULLABLE | |
@@ -269,6 +276,10 @@ Key-value store. Stable keys:
| `email_template_*` | HTML/Jinja-light | Override default mail templates | | `email_template_*` | HTML/Jinja-light | Override default mail templates |
| `kev_cache_json` | JSON | CISA KEV catalog cache (24h TTL) | | `kev_cache_json` | JSON | CISA KEV catalog cache (24h TTL) |
| `euvd_cache_json` | JSON | ENISA EUVD catalog cache (24h TTL) | | `euvd_cache_json` | JSON | ENISA EUVD catalog cache (24h TTL) |
| `kev_alert_enabled` | `true` \| `false` | Master toggle for the hourly KEV alert mail (default on) |
| `kev_alert_sources` | `cisa,euvd` | Which KEV sources feed the alert; empty = all |
| `kev_alert_recipients` | string | Comma/semicolon list; empty = `notification_default_recipients`, else active admins |
| `kev_alert_state` | JSON | `{cve: {at, n}}` — what was already mailed, so a run is idempotent |
### `compliance_results` ### `compliance_results`
+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.
+18 -17
View File
@@ -1,5 +1,5 @@
# VulnManager Dockerfile # VulnManager Dockerfile
FROM python:3.11-slim FROM python:3.14-slim
# Security: Run as non-root user # Security: Run as non-root user
RUN useradd -m -u 1000 vulnmanager RUN useradd -m -u 1000 vulnmanager
@@ -7,29 +7,27 @@ RUN useradd -m -u 1000 vulnmanager
WORKDIR /app WORKDIR /app
# Install system dependencies. # Install system dependencies.
# - postgresql-client: alembic / psql tooling # - postgresql-client: pg_isready in the entrypoint
# - dos2unix: entrypoint line-endings # - dos2unix: entrypoint line-endings
# - libxml2-dev, libxmlsec1-dev, libxmlsec1-openssl, pkg-config: # No -dev packages: every requirement ships a wheel (xmlsec and lxml bundle
# required by python3-saml (XML signature validation) # their own libxml2), and the headers only added CVE findings to the image
# - libsasl2-dev, libldap2-dev, libssl-dev: # (linux-libc-dev, libunbound, libxml2). upgrade: the base image lags behind
# required by python-ldap if used (ldap3 is pure-python so optional, # Debian security updates.
# kept here for future flexibility) RUN apt-get update && apt-get upgrade -y \
RUN apt-get update && apt-get install -y --no-install-recommends \ && apt-get install -y --no-install-recommends \
postgresql-client \ postgresql-client \
dos2unix \ dos2unix \
libxml2-dev \
libxmlsec1-dev \
libxmlsec1-openssl \
pkg-config \
libsasl2-dev \
libldap2-dev \
libssl-dev \
ca-certificates \ ca-certificates \
&& rm -rf /var/lib/apt/lists/* && 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.
# pip goes afterwards: nothing runs it at runtime, and its vendored urllib3,
# msgpack and pkg_resources only added CVE findings to the image.
COPY requirements.txt . 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 \
&& pip uninstall -y pip
# Copy application code (and entrypoint) # Copy application code (and entrypoint)
COPY . . COPY . .
@@ -37,6 +35,9 @@ COPY . .
# Set permissions for entrypoint # Set permissions for entrypoint
RUN dos2unix /app/entrypoint.sh RUN dos2unix /app/entrypoint.sh
RUN chmod +x /app/entrypoint.sh RUN chmod +x /app/entrypoint.sh
# Mount points of the tls_data and tls_ca volumes: a fresh volume takes the
# directory's owner, so the backend can write the HTTPS certificate there.
RUN mkdir -p /app/tls /app/tls-ca
RUN chown -R vulnmanager:vulnmanager /app RUN chown -R vulnmanager:vulnmanager /app
# Switch to non-root user # Switch to non-root user
+126
View File
@@ -0,0 +1,126 @@
# Licensing
TrueVuln is **open core**. This document is the authoritative statement of what is
free and open source software and what is not. It is published alongside the first
public release so the boundary is known up front and does not move under you.
Copyright (C) 2026 {{LICENSOR}}
## The short version
| | |
|---|---|
| **Everything in this repository** | Free and open source under **AGPL-3.0-or-later**. Self-host it, modify it, run it in production, commercially, at no cost. |
| **TrueVuln Enterprise** | A separate, commercially licensed add-on. Not in this repository. Not required to run TrueVuln. |
There is no "crippled community edition". The entire detection and enrichment
engine -- the part that actually does the work -- is AGPL and stays AGPL.
## What is AGPL-3.0 (this repository)
The complete, production-usable vulnerability management platform:
**Scanner and inventory integrations**
- Wazuh (agent discovery, vulnerability sync, Syscollector triggers, patch verification)
- Tenable Nessus (scan import, launch/poll, VPR, exploit maturity)
- Microsoft Intune / Graph API (managed devices, detectedApps)
- Microsoft Defender for Endpoint TVM (per-device CVEs, software inventory)
- VMware vCenter / ESXi
**Threat-intelligence enrichment**
- EPSS, CISA KEV, ENISA EUVD, VulnCheck KEV (community, optional)
- Exploit-DB, PoC-in-GitHub, Metasploit module index
- cvelistV5, CISA Vulnrichment, NVD, GitHub Advisories (GHSA)
- Mozilla MFSA, MSRC, Ubuntu USN, Red Hat errata, OSV.dev
**Detection engines**
- End-of-life / end-of-support detection (endoflife.date + Microsoft product lifecycle)
- Microsoft 365 Apps CVE detection
- Windows OS CVE detection (client and server, build-range matched)
- SharePoint and modern .NET detection
- MSRC fixed-build scanning
- Mobile device security (Samsung SMR, Google ASB, Android patch-level staleness)
- App-to-CVE scanner for software with no dedicated scanner
- Security advisory feeds (CISA KEV, RSS sources)
**Platform**
- Priority and CPR risk scoring, SLA tracking and enforcement, email alerts
- Automated patch verification, deferral and false-positive workflows
- Full web UI and dashboard
- Local authentication with TOTP multi-factor authentication
- Role-based access control
- REST API
- CSV export
This is a complete product. A company can run it, rely on it, and never pay anything.
## What is TrueVuln Enterprise (commercial)
Distributed separately under a commercial license, aimed at organisations with a
regulatory driver and a compliance budget:
| Capability | Why it is here |
|---|---|
| **Enterprise SSO** -- LDAP / Active Directory, OIDC, SAML with JIT provisioning and role mapping | Needed only once identity is centrally governed |
| **Revision-proof audit trail** -- tamper-evident, exportable, retention-managed | Driven by audit and certification requirements |
| **Compliance reporting** -- PCI-DSS, ISO 27001, NIST 800-53, HIPAA control mapping, scoped PDF reports | Produced for auditors, not for operators |
| **SIEM forwarding** -- syslog / CEF export to external SIEM | Enterprise integration surface |
| **Multi-tenancy** -- mandant separation for MSPs and holding structures | Only meaningful above single-organisation scale |
| **Priority support with SLA** | Commercial obligation, not code |
### The rule we hold ourselves to
A feature belongs in Enterprise only if **both** are true:
1. It is not needed to detect, prioritise, or remediate a vulnerability.
2. The organisations that need it have a compliance or scale driver, and therefore a budget.
Anything that makes TrueVuln better at its actual job -- finding real vulnerabilities on
real assets -- goes into the AGPL core. We will not move an existing AGPL feature into
Enterprise. New Enterprise features are additive.
## Why AGPL-3.0
AGPL is a strong copyleft licence with a network clause. Anyone who modifies TrueVuln
and offers it to third parties over a network must publish their modifications.
For you as a user, this means nothing changes: **self-hosting TrueVuln inside your own
organisation, including commercially, triggers no obligation to publish anything.** The
network clause applies to offering the software as a service to others.
For us, it means a cloud provider cannot take this work, run it as a paid service, and
give nothing back.
## Commercial licence exemption
If AGPL-3.0 is incompatible with your intended use -- typically because you want to embed
TrueVuln in a proprietary product or offer it as a hosted service without publishing your
modifications -- a commercial licence is available. Contact: {{CONTACT}}
## Contributing
Contributions to this repository are accepted under the
[Contributor License Agreement](CLA.md). The CLA lets us offer TrueVuln under both AGPL
and commercial terms. You keep the copyright to your work. See
[CONTRIBUTING.md](CONTRIBUTING.md).
## Trademark
"TrueVuln" and the TrueVuln logo are trademarks of {{LICENSOR}}. The AGPL grants rights to
the *code*, not to the name. You may state that your product is built on or derived from
TrueVuln. You may not use the name or logo in a way that suggests your fork is the
official distribution.
## SPDX
SPDX-License-Identifier: AGPL-3.0-or-later
## Data source attribution
**VulnCheck KEV** — when the VulnCheck KEV source is enabled (optional, off
without `VULNCHECK_API_TOKEN`), its data is free to use in commercial and
open-source products on one condition: prominent attribution. The Advisories
page therefore names "VulnCheck KEV" in the KEV panel header and badges every
row the source contributed to. Do not remove that labelling while the source
is enabled — it is the licence term, not decoration.
<https://docs.vulncheck.com/community/vulncheck-kev/attribution>
+8 -8
View File
@@ -1,6 +1,6 @@
# VulnCheck — Project Overview # TrueVuln — Project Overview
> Vulnerability Management Dashboard for a Swiss-school IT-Security setup. > Self-hosted vulnerability management for small and mid-sized IT teams.
> Wazuh agents + Nessus scanner + CISA/ENISA/EPSS threat intel + CIS-Benchmark > Wazuh agents + Nessus scanner + CISA/ENISA/EPSS threat intel + CIS-Benchmark
> compliance + Unified Risk Score, all behind a single FastAPI + Next.js stack. > compliance + Unified Risk Score, all behind a single FastAPI + Next.js stack.
> >
@@ -10,7 +10,7 @@
## 🎯 Summary ## 🎯 Summary
VulnCheck collects vulnerability findings from multiple scanners (Wazuh, TrueVuln collects vulnerability findings from multiple scanners (Wazuh,
Nessus), enriches them with several free public threat-intel sources, scores Nessus), enriches them with several free public threat-intel sources, scores
the result on a unified 0-100 risk scale, and surfaces them in a role-based the result on a unified 0-100 risk scale, and surfaces them in a role-based
web UI with SLA-tracking, mail notifications, compliance evidence (Wazuh SCA web UI with SLA-tracking, mail notifications, compliance evidence (Wazuh SCA
@@ -180,7 +180,7 @@ AUTH_PROVIDER_CRYPTO_KEY=<fernet-key>
# Wazuh # Wazuh
WAZUH_API_URL=https://wazuh-manager:55000 WAZUH_API_URL=https://wazuh-manager:55000
WAZUH_INDEXER_URL=https://wazuh-indexer:9200 WAZUH_INDEXER_URL=https://wazuh-indexer:9200
WAZUH_API_USER=vulncheck-readonly WAZUH_API_USER=truevuln-readonly
WAZUH_API_PASS=<pw> WAZUH_API_PASS=<pw>
# Optional # Optional
@@ -283,7 +283,7 @@ UPDATE settings SET value='true' WHERE key='sla_breach_enabled';
- **`sla_breach_enabled` toggle has no UI yet** — DB-only (Settings page improvement on backlog) - **`sla_breach_enabled` toggle has no UI yet** — DB-only (Settings page improvement on backlog)
- **CIRCL aggregator** not used — we hit CISA + ENISA directly - **CIRCL aggregator** not used — we hit CISA + ENISA directly
- **No real-time KEV push** — we poll the CISA feed (24 h cache) - **No real-time KEV push** — we poll the CISA feed (24 h cache)
- **No two-way Nessus FP sync** — marking false-positive in VulnCheck doesn't update Nessus - **No two-way Nessus FP sync** — marking false-positive in TrueVuln doesn't update Nessus
- **Wazuh-side fix-version field doesn't exist** in the indexer schema — relying entirely on Vulnrichment/NVD/cvelistV5 cascade for PATCH AVAILABLE - **Wazuh-side fix-version field doesn't exist** in the indexer schema — relying entirely on Vulnrichment/NVD/cvelistV5 cascade for PATCH AVAILABLE
- **Per-framework URS breakdown** schema-ready but UI hidden - **Per-framework URS breakdown** schema-ready but UI hidden
- **No WebAuthn / FIDO2** — TOTP is the only second factor - **No WebAuthn / FIDO2** — TOTP is the only second factor
@@ -299,8 +299,8 @@ docker compose exec backend pytest tests/
``` ```
Smoke test covers auth, RBAC, vuln CRUD, sync mocking. Coverage is partial Smoke test covers auth, RBAC, vuln CRUD, sync mocking. Coverage is partial
— integration paths (Wazuh, Nessus, enrichment HTTP) rely on manual + tester — integration paths (Wazuh, Nessus, enrichment HTTP) rely on manual
verification. verification against a live environment.
### Add a new vuln column ### Add a new vuln column
@@ -374,4 +374,4 @@ LDAP bind decryption failures, missing SLA mails, etc.).
## 📧 Support ## 📧 Support
Internal IT-Security team. See `README.md` for project owner contact. Open an issue on GitHub. For security vulnerabilities in TrueVuln itself, see `SECURITY.md`.
+2932 -36
View File
File diff suppressed because it is too large Load Diff
+351 -101
View File
@@ -1,19 +1,19 @@
<p align="center"> <p align="center">
<img src="frontend/public/logo.png" alt="VulnCheck Logo" width="120" /> <img src="frontend/public/logo.png" alt="TrueVuln Logo" width="120" />
</p> </p>
<h1 align="center">VulnCheck Dashboard</h1> <h1 align="center">TrueVuln Dashboard</h1>
<p align="center"> <p align="center">
<strong>Open Source Vulnerability Management for Wazuh</strong><br> <strong>Open Source Vulnerability Management — Wazuh · Nessus · Microsoft Intune</strong><br>
Prioritize, verify, and resolve vulnerabilities with automated workflows and AI-powered analysis. Aggregate findings from multiple scanners, enrich them from authoritative sources, and prioritize, verify, and resolve them with automated workflows and AI-powered analysis.
</p> </p>
<p align="center"> <p align="center">
<a href="#-quick-start"><img src="https://img.shields.io/badge/Docker-Ready-2496ED?logo=docker&logoColor=white" alt="Docker Ready" /></a> <a href="#-quick-start"><img src="https://img.shields.io/badge/Docker-Ready-2496ED?logo=docker&logoColor=white" alt="Docker Ready" /></a>
<img src="https://img.shields.io/badge/License-AGPLv3-blue.svg" alt="License" /> <img src="https://img.shields.io/badge/License-AGPLv3-blue.svg" alt="License" />
<img src="https://img.shields.io/badge/Status-Beta-orange.svg" alt="Status" /> <img src="https://img.shields.io/badge/Status-Beta-orange.svg" alt="Status" />
<img src="https://img.shields.io/badge/Python-3.11+-3776AB?logo=python&logoColor=white" alt="Python" /> <img src="https://img.shields.io/badge/Python-3.14-3776AB?logo=python&logoColor=white" alt="Python" />
<img src="https://img.shields.io/badge/Next.js-16-black?logo=next.js" alt="Next.js" /> <img src="https://img.shields.io/badge/Next.js-16-black?logo=next.js" alt="Next.js" />
</p> </p>
@@ -21,16 +21,22 @@
## Overview ## Overview
VulnCheck is a self-hosted vulnerability management dashboard that integrates with [Wazuh](https://wazuh.com/) to provide a centralized view of your infrastructure's security posture. It automates vulnerability discovery, SLA tracking, patch verification, and reporting -- backed by AI analysis from multiple providers. TrueVuln is a self-hosted vulnerability management dashboard that aggregates findings from [Wazuh](https://wazuh.com/), Tenable Nessus, and Microsoft Intune/Defender into a single, prioritized view of your infrastructure's security posture. It enriches every CVE from authoritative open sources, detects end-of-life software and Microsoft 365 Apps gaps that scanners miss, and automates SLA tracking, patch verification, and reporting -- backed by AI analysis from multiple providers.
**Key capabilities:** **Key capabilities:**
- Sync vulnerabilities and assets from Wazuh automatically - Multi-source inventory & findings: Wazuh agents, Nessus scans, Microsoft Intune (MDM/UEM) + Defender for Endpoint TVM
- AI-powered CVE analysis with remediation recommendations - Cross-confirmation when several scanners report the same CVE on the same host
- SLA policy enforcement with automated email alerts - Threat-intel enrichment: EPSS, CISA KEV, ENISA EUVD, VulnCheck KEV (optional), public-exploit catalogs (Exploit-DB / PoC-in-GitHub / Metasploit), and authoritative CVE dates/CVSS (cvelistV5 / Vulnrichment / NVD)
- Automated patch verification via Wazuh Syscollector rescans - Multi-source remediation: scanner solution + Microsoft (MSRC KBs), Ubuntu USN, Red Hat/CentOS errata, and the OSV.dev aggregator
- Role-based access with full audit trail - End-of-life / end-of-support detection (endoflife.date + the Microsoft product-lifecycle export) and Microsoft 365 Apps CVE detection (not in NVD)
- PDF/CSV reporting for compliance workflows - Built-in App→CVE scanner (OSV / NVD-CPE / cvelistV5) for software with no real vulnerability scanner, plus Wazuh false-positive suppression
- Mobile device security for Intune-managed phones/tablets: EOL/EOS (Samsung/Apple), Android patch-level staleness, and per-CVE Android detection (Samsung SMR / Google ASB)
- **Security Advisory Feeds** page: CISA KEV "actively exploited" plus configurable RSS sources (ZDI, CERT-EU, BSI/CERT-Bund, Cisco PSIRT, custom URLs) — early-warning sources that often publish before NVD/cvelistV5
- AI-powered CVE analysis + on-demand, OS-aware remediation generation
- Priority + CPR risk scoring, SLA enforcement with email alerts, automated patch verification
- Multi-provider authentication (local, LDAP, OIDC, SAML) with TOTP MFA
- Role-based access with a full revision-proof audit trail, PDF/CSV reporting
--- ---
@@ -47,44 +53,117 @@ VulnCheck is a self-hosted vulnerability management dashboard that integrates wi
- Status tracking: Open, Patched, Pending Verification, Patch Failed, Accepted Risk, False Positive, Deferred - Status tracking: Open, Patched, Pending Verification, Patch Failed, Accepted Risk, False Positive, Deferred
- Per-vulnerability notification suppression - Per-vulnerability notification suppression
### Wazuh Integration ### Scanner & Inventory Integrations
- Auto-discover agents and sync vulnerability data - **Wazuh** -- auto-discover agents, sync vulnerability data, trigger Syscollector scans from the UI, automated patch verification (mark patched → rescan → confirm)
- Trigger Syscollector scans directly from the UI - **Tenable Nessus** -- import findings from configured scans (X-ApiKeys), launch/poll/import scans, VPR score, exploit availability/maturity, scanner remediation text
- Automated patch verification: mark as patched, VulnCheck rescans and confirms - **Microsoft Intune (Graph API)** -- app-only (client-credentials) sync of managed devices → assets + OS-EOL, and `detectedApps` → EOL/M365 detection (no Wazuh agent required)
- Deduplication of CVEs per asset - **Microsoft Defender for Endpoint (TVM)** -- optional real per-device CVEs via the Defender API, plus the tenant's **installed-software inventory** (`Software.Read.All`), which follows device telemetry instead of Intune's 7-day Discovered-apps cycle and feeds the same EOL / M365 / app→CVE detection
- One CVE per asset (deduplicated); multiple scanners on the same finding are merged and flagged **cross-confirmed**; assets are soft-inactivated (not deleted) when a source stops reporting them, with audit trail
### Threat-Intelligence Enrichment
- **EPSS** (FIRST.org) exploitation probability, **CISA KEV** (known-exploited, plus a dedicated "actively exploited" advisory dashboard widget independent of asset findings), **ENISA EUVD** (EU exploited/critical), **VulnCheck KEV** (optional, free community token — ~5200 exploited CVEs vs CISA's ~1400, usually listed days earlier; each row shows the lead time over CISA. Requires prominent "VulnCheck KEV" attribution, which the Advisories page renders)
- **Public-exploit catalogs** -- Exploit-DB, PoC-in-GitHub, Metasploit module index
- **Authoritative CVE metadata** -- published/last-modified dates, CVSS, and descriptions from CVE.org **cvelistV5**, CISA **Vulnrichment**, and the **NVD** API
- **CVSS-correction cascade** (for placeholder/missing scores) -- CISA Vulnrichment → NVD → cvelistV5 → **GitHub Advisories (GHSA)**, each source only for the CVEs the previous one left empty; real CVE-IDs only. An `NVD_API_KEY` raises the NVD stage from 5 to 50 req/30s, an optional GitHub PAT lifts GHSA from 60 to 5000 req/h
- **Mozilla MFSA** -- per-CVE impact rating (critical/high/moderate/low) straight from Mozilla's advisory repo, for fresh Firefox CVEs that have no score in NVD/cvelistV5 yet
- **Metrics are CVE-global** -- a finding created without a score inherits CVSS/EPSS/KEV from the same CVE on another asset, so the same CVE never shows two different scores
### End-of-Life & Microsoft 365 Detection
- **EOL/EOS detection** for installed software and OS via [endoflife.date](https://endoflife.date) plus the Microsoft product-lifecycle export (covers exotics like SQL Server, Visual C++ Redistributables, Silverlight)
- **Severity scales with age** -- a product 1000+ days past end-of-life is rated CRITICAL rather than a flat HIGH, and EOL findings cite the control failure (PCI-DSS 6.3.3, NIST 800-53 CM-8, HIPAA 164.312(a)(1)) for audit reports
- **Self-correcting** -- findings whose product match no longer holds are re-evaluated and closed automatically; the check runs as a background job with live progress instead of blocking the browser
- **Microsoft 365 Apps CVE detection** -- compares the installed Office build against the Microsoft 365 Apps security-update channels for CVEs that never reach NVD or Wazuh
### Windows OS & Microsoft Product CVE Detection
- **Windows OS CVEs (Server AND Client)** via cvelistV5's bounded build ranges, family-matched (a Win11 24H2 host never matches a Server-2025-only fix even though both live on build line 26100) — patch-level accurate, available on Patch Tuesday, well ahead of the Wazuh CTI feed
- **SharePoint 2013/2016/2019/Subscription** and **modern .NET (8/9/10)** detection with one registry key per release, so generic version floors can't cross-match releases (.NET Framework is excluded by design: its ARP version is static across monthly patches — Defender TVM covers it file/KB-based)
- **MSRC fixed-build scan** as a second, authoritative source (KB numbers per servicing branch); cross-confirms, auto-resolves once a host catches up, nightly job + manual trigger (`POST /api/v1/vulnerabilities/msrc-scan`)
- **Microsoft Edge CVEs** -- MSRC is the *only* machine-readable source for these (they appear in neither NVD nor cvelistV5); matched against Edge's own fixed build, never against Chromium/Chrome ranges, since the two build schemes diverge entirely (Edge 150.0.4078.99 rides on Chromium 150.0.7871.187)
### Built-in App→CVE Scanner & Mobile Device Security
- **App→CVE scanner** -- maps installed software (Wazuh syscollector packages, Intune `detectedApps`) to real CVEs via a curated OSV / NVD-CPE registry plus a direct cvelistV5 range match (catches fresh CVEs NVD hasn't CPE'd yet, or ones filed under a CPE product string that wasn't curated); results cross-confirm and enrich like any other source
- **False-positive suppression** -- flags a Wazuh finding as false-positive when the installed version is provably outside every clean cvelistV5 version range for the matched product (e.g. a SQL Server 2019 host wrongly flagged with a 2022/2025-only CVE)
- **GitHub repository advisories** -- CVEs a project publishes only on its own GitHub advisory page, reaching neither NVD nor cvelistV5 (e.g. Notepad++); version ranges come straight from the maintainer, with the published patched version as a safety net
- **One finding, every affected product** -- when a single CVE hits two products on the same host (e.g. Chrome *and* Edge), the finding lists both with their own installed and fixed versions instead of naming only whichever scanner ran first
- **Vendor / publisher** captured from every inventory source (Wazuh `vendor`, Intune `publisher`, Defender `softwareVendor`) and shown on the finding
- **Mobile device EOL/EOS** (Samsung, Apple) and **Android security-patch-level staleness** for Intune-managed phones/tablets, with a dedicated dashboard widget
- **Per-CVE Android detection** -- Samsung's own SMR bulletin (precise, excludes chipset CVEs that don't apply to the device) with Google's ASB as a fallback
### Multi-Source Remediation
The CVE detail page shows remediation from every available source side by side:
- Scanner solution (Nessus), **Microsoft (MSRC)** KB + fixed build + download link (filtered to the host's build), **Ubuntu USN**, **Red Hat / CentOS / Alma** errata, and the **OSV.dev** aggregator (Debian, SUSE, Alpine, Rocky, language ecosystems)
- Workarounds / mitigations / containment when no patch exists yet; the MSRC update-guide link per CVE
### AI-Powered Analysis ### AI-Powered Analysis
Supports multiple AI providers for CVE analysis, threat assessment, and remediation guidance: Supports multiple AI providers for CVE analysis, threat assessment, and on-demand OS-aware remediation generation (including an OpenRouter integration for the per-CVE "Generate fix steps" button, EOL-aware for end-of-life findings):
| Provider | Models | **Model lists are fetched live** from each provider's own `/models` endpoint the moment you enter an API key -- the dropdown shows exactly what your key can reach, including models released after this README was written. There is no hardcoded model list to keep in sync.
|---|---|
| OpenAI | GPT-5.2, GPT-4.1, o3, o3-mini |
| Anthropic | Claude Opus 4.5, Sonnet 4.5, Haiku 4.5 |
| Google Gemini | Gemini 3 Pro, 3 Flash, 2.5 Pro |
| DeepSeek | V3, R1 (Reasoner) |
| Ollama (local) | Llama 3.3, Mistral, CodeLlama, Phi-4 |
| Infomaniak | Llama 3, Mistral 3, Mixtral, Granite, Qwen 3, Gemma 3n |
> **Ollama in Docker:** Use `http://host.docker.internal:11434/v1` as the Base URL when running VulnCheck in Docker with Ollama on the host. | Provider | Default endpoint | Notes |
|---|---|---|
| OpenAI | `api.openai.com/v1` | |
| Anthropic | `api.anthropic.com/v1` | |
| Google Gemini | `generativelanguage.googleapis.com/v1beta` | key travels in a header, never in the URL |
| DeepSeek | `api.deepseek.com` | OpenAI-compatible wire format |
| Ollama (local) | `localhost:11434` | no API key, no data leaves the host |
| Infomaniak | Swiss-hosted | provider-side web search is enabled for CVE lookups |
> **Ollama in Docker:** Use `http://host.docker.internal:11434/v1` as the Base URL when running TrueVuln in Docker with Ollama on the host.
### Automated Workflows ### Automated Workflows
- **Scheduled scans** with configurable intervals (hourly, daily, weekly) or cron expressions - **Scheduled scans** with configurable intervals (hourly, daily, weekly) or cron expressions
- **SLA breach monitoring** runs hourly, sends email alerts to assigned users/groups - **SLA breach monitoring** runs hourly, sends email alerts to assigned users/groups
- **Patch verification** triggers a Wazuh rescan and updates status automatically - **Patch verification** triggers a Wazuh rescan and updates status automatically
### Notifications #### Nightly job order (UTC)
- Customizable HTML email templates for SLA breaches and new vulnerability alerts
- Live preview of email templates with sample data
- SMTP configuration with test email functionality
- Full notification history with status tracking (Sent, Failed, Suppressed)
> **Performance Note:** Avoid sending large batches of emails simultaneously. Most SMTP providers (especially Proton, Gmail, Outlook) enforce strict rate limits (~10-20 emails per minute). Exceeding these limits may result in "too many connections" errors or temporary blocks. SLA breach notifications are throttled to 1 email per vulnerability per 24 hours by default. The order is not arbitrary — each stage depends on the one above it. Inventory
is collected first, findings are produced from it, enrichment corrects those
findings, and only then are the aggregates computed. Anything that creates
findings therefore has to run **before** the enrichment, or its scores would
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 |
| 03:00 | endoflife.date EOL detection | findings |
| 03:10 | Microsoft 365 Apps CVE detection | findings |
| 03:20 | Built-in App→CVE scan (cvelistV5 + NVD-CPE) | findings |
| 03:50 | MSRC fixed-build scan (Windows OS, Edge, SharePoint) | findings |
| 04:30 | CISA Vulnrichment CVSS / SSVC correction | enrichment |
| 04:40 | MSRC remediation enrichment (weekly) | enrichment |
| 04:50 | Public-exploit catalogue refresh | enrichment |
| 05:10 | URS recompute + snapshot prune | aggregate |
| 05:25 | Asset lifecycle reconcile | aggregate |
| 05:40 | Audit-log retention prune | housekeeping |
Independent of the chain: security advisory feeds every 6h at :20, the
new-vulnerability digest mail at the configured hour, and the hourly SLA check.
Wazuh's own vulnerability sync is a user-defined schedule (Scans → Schedules),
not part of this chain.
### Notifications
- New-CVE email alerts from **every** scanner source (Wazuh, Nessus, App→CVE scanner, Defender TVM) — not just Wazuh
- Customizable HTML email templates for SLA breaches, single new-CVE alerts, **and** the aggregated digest — all editable in the UI with live preview
- Digest table sorted by **CPR** (priority) descending, with per-CVE affected-systems count and ready-made deep links; template variables include `{{cpr_score}}`, `{{affected_assets_count}}`, `{{cve_link}}`, `{{asset_link}}`
- **Delivery schedule:** per-sync (immediate) or a **nightly roundup** — one aggregated mail per recipient of the day's new CVEs
- **Send rate limiting** (delay between mails + max per run) to respect SMTP-provider anti-spam limits
- Recipient routing by asset/finding assignment (user or group), with a configurable **Default Recipient(s)** fallback — or all admins — for unassigned new-CVE mails
- Notification history with status tracking (Sent, Failed, Suppressed) — admins see all; other roles see only notifications addressed to them
> **Performance Note:** Most SMTP providers (Proton, Gmail, Outlook) enforce strict rate limits (~10-20 emails/minute). Use **nightly roundup** mode and/or the send rate limit to stay under them. SLA-breach notifications are throttled to 1 email per vulnerability per 24 hours by default.
### Asset Management ### Asset Management
- Auto-discovery from Wazuh agents or manual creation - Auto-discovery from Wazuh agents or manual creation
- Bulk assignment to users, groups, and SLA policies - Bulk assignment to users, groups, and SLA policies
- Track OS, location, owner, and scan history per asset - Track OS, location, owner, and scan history per asset
> **Asset ownership:** Assigning an asset (or an individual finding) to a user/group is a **notification/ownership tag** — it routes new-CVE and SLA-breach emails to that owner. It does **not** affect scanning, scoring, or visibility (all view-permitted users see every CVE). New-CVE mails fall back to the default recipients / admins when unassigned; **SLA-breach mails have no fallback and require an assignee**, so assign each asset to a system owner. See README.DEV for details.
### SLA Policies ### SLA Policies
- Define severity-based remediation windows (e.g., Critical: 2 days, High: 7 days) - Define severity-based remediation windows (e.g., Critical: 2 days, High: 7 days)
- Assign policies to assets - Assign policies to assets
@@ -97,8 +176,11 @@ Supports multiple AI providers for CVE analysis, threat assessment, and remediat
### Security ### Security
- JWT authentication with role-based access control (Admin, Editor, Readonly) - JWT authentication with role-based access control (Admin, Editor, Readonly)
- Bcrypt password hashing with account lockout - 30-minute access token with **silent refresh** against a 7-day refresh cookie -- an actively used session stays signed in; only real inactivity logs you out
- Comprehensive audit logging (who changed what, when) - **Revisionssicher audit trail** in both directions: a finding going open → patched *and* patched → open is recorded, naming the CVE, the host and the scanner that caused it
- Audit log with full-text search (description, event, resource, IP, user), sortable columns and real pagination
- Bcrypt password hashing; login rate-limit + **account lockout** after 5 failed attempts — temporary 15-min auto-unlock, or opt-in **permanent lockout** cleared only by an admin (the last active admin is never permanently locked)
- Comprehensive audit logging (who changed what, when), optionally **forwarded to a syslog/SIEM** server (UDP/TCP, RFC-5424, per-event severity) for central alerting
- Security headers (CSP, HSTS, X-Frame-Options) - Security headers (CSP, HSTS, X-Frame-Options)
- Rate limiting on API endpoints - Rate limiting on API endpoints
@@ -109,7 +191,11 @@ Three roles with hierarchical permissions: **Admin > Editor > Readonly**
| Action | Readonly | Editor | Admin | | Action | Readonly | Editor | Admin |
|---|:---:|:---:|:---:| |---|:---:|:---:|:---:|
| **View** vulnerabilities, assets, reports, dashboards | ✅ | ✅ | ✅ | | **View** vulnerabilities, assets, reports, dashboards | ✅ | ✅ | ✅ |
| **View** scan history, notification history | ✅ | ✅ | ✅ | | **View/download** reports (Executive, Technical CSV, ISO 27001, Patching) | ✅ | ✅ | ✅ |
| **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 | ✅ | ✅ | ✅ | | **View** AI analysis history | ✅ | ✅ | ✅ |
| **Edit** vulnerabilities (status, assign, defer, reopen) | ❌ | ✅ | ✅ | | **Edit** vulnerabilities (status, assign, defer, reopen) | ❌ | ✅ | ✅ |
| **Trigger** Wazuh sync and scans | ❌ | ✅ | ✅ | | **Trigger** Wazuh sync and scans | ❌ | ✅ | ✅ |
@@ -122,7 +208,7 @@ Three roles with hierarchical permissions: **Admin > Editor > Readonly**
| **Create/Delete** SLA policies | ❌ | ❌ | ✅ | | **Create/Delete** SLA policies | ❌ | ❌ | ✅ |
| **Manage** users (create, edit, delete, reset password) | ❌ | ❌ | ✅ | | **Manage** users (create, edit, delete, reset password) | ❌ | ❌ | ✅ |
| **Manage** groups | ❌ | ❌ | ✅ | | **Manage** groups | ❌ | ❌ | ✅ |
| **Configure** settings (Wazuh, AI, SMTP) | ❌ | ❌ | ✅ | | **Configure** settings (Wazuh, Nessus, Intune, AI, SMTP) | ❌ | ❌ | ✅ |
| **Send** test/critical notifications | ❌ | ❌ | ✅ | | **Send** test/critical notifications | ❌ | ❌ | ✅ |
| **View** audit logs | ❌ | ❌ | ✅ | | **View** audit logs | ❌ | ❌ | ✅ |
@@ -131,7 +217,7 @@ Three roles with hierarchical permissions: **Admin > Editor > Readonly**
## Screenshots ## Screenshots
### Dashboard ### Dashboard
![VulnCheck Dashboard](https://gitea.isuit.ch/vulncheck/vulncheck/raw/branch/main/docs/screenshots/dashboard.png) ![TrueVuln Dashboard](docs/screenshots/dashboard.png)
--- ---
@@ -149,12 +235,12 @@ Three roles with hierarchical permissions: **Admin > Editor > Readonly**
| RAM | 2 GB | 4 GB | | RAM | 2 GB | 4 GB |
| Disk | 10 GB | 20 GB | | Disk | 10 GB | 20 GB |
> **Note:** These requirements are for the VulnCheck application only. If running Wazuh on the same VM, add its requirements accordingly. > **Note:** These requirements are for the TrueVuln application only. If running Wazuh on the same VM, add its requirements accordingly.
### 1. Clone the repository ### 1. Clone the repository
```bash ```bash
git clone https://gitea.isuit.ch/vulncheck/vulncheck.git git clone https://github.com/{{GH_OWNER}}/{{GH_REPO}}.git
cd vulncheck cd vulncheck
``` ```
@@ -179,14 +265,19 @@ Then edit `.env` and set at minimum:
Paste the output as `JWT_SECRET_KEY` in `.env`. Paste the output as `JWT_SECRET_KEY` in `.env`.
3. **`DEFAULT_ADMIN_PASSWORD`** — must satisfy strength rules: ≥8 chars with uppercase, lowercase, digit, and special character. The default `ChangeMe123!` passes; weaker values (like `changeme`) cause the admin **not** to be created and login will fail. 3. **`DEFAULT_ADMIN_PASSWORD`** — must satisfy strength rules: ≥8 chars with uppercase, lowercase, digit, and special character. The default `ChangeMe123!` passes; weaker values (like `changeme`) cause the admin **not** to be created and login will fail.
4. **`AUTH_COOKIE_SECURE`** — `true` only when serving the UI over **HTTPS** (e.g. behind a reverse proxy with TLS). If you are accessing the app over plain **HTTP** (e.g. `http://server:3003` during testing), set this to `false`, otherwise the browser silently drops the session cookie and login appears to do nothing — the page refreshes but the user stays logged out. 4. **`AUTH_PROVIDER_CRYPTO_KEY`** — Fernet key used to encrypt TOTP secrets and the LDAP bind password at rest. Generate with:
```bash ```bash
# Plain HTTP / testing without reverse proxy python3 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
sed -i 's/^AUTH_COOKIE_SECURE=.*/AUTH_COOKIE_SECURE=false/' .env
``` ```
The `.env.example` ships with production-ready defaults for running behind a reverse proxy with HTTPS. Also review `TIMEZONE` and `DASHBOARD_URL`. Leaving the shipped `CHANGE-ME-FERNET-KEY` placeholder makes **MFA enrolment fail with HTTP 500** (`AUTH_PROVIDER_CRYPTO_KEY is not a valid Fernet key`) and blocks LDAP bind-password storage. Everything else still works, so the failure only shows up the first time someone turns on MFA.
5. **`DASHBOARD_URL`** and **`TLS_SERVER_NAMES`** — the address users open TrueVuln at (`https://truevuln.corp.example`) and any further names or IPs they type. Both go into the HTTPS certificate the installation's own CA issues on first start. `setup.sh` fills them with this host's name and IP.
Keep **`AUTH_COOKIE_SECURE=true`**: TrueVuln is served over HTTPS, so the session cookie never needs plain HTTP.
The `.env.example` ships with production-ready defaults (HTTPS via the bundled nginx). Also review `TIMEZONE`.
Verify the file exists and `POSTGRES_PASSWORD` is set before continuing: Verify the file exists and `POSTGRES_PASSWORD` is set before continuing:
@@ -206,29 +297,78 @@ docker compose up -d --build
> **Wait 2-3 minutes after deploy** before opening the UI. The first start has to pull images, run database migrations, build the Next.js frontend, and warm up the backend. Hitting the login page too early can show a blank screen, a 502, or login that "looks" broken because the frontend bundle is still compiling. Watch `docker compose ps` until all three services report `Up (healthy)` / `Up`, and `docker compose logs frontend` shows `✓ Ready in …`. > **Wait 2-3 minutes after deploy** before opening the UI. The first start has to pull images, run database migrations, build the Next.js frontend, and warm up the backend. Hitting the login page too early can show a blank screen, a 502, or login that "looks" broken because the frontend bundle is still compiling. Watch `docker compose ps` until all three services report `Up (healthy)` / `Up`, and `docker compose logs frontend` shows `✓ Ready in …`.
This starts three containers: This starts four containers:
| Service | URL | Description | | Service | URL | Description |
|---|---|---| |---|---|---|
| Frontend | `http://localhost:3003` | Web UI | | nginx | `https://<host>` (`HTTPS_PORT`, 443); port 80 redirects | Web UI over TLS 1.2/1.3 |
| Backend | `http://localhost:8022` | API (bound to localhost only) | | Frontend | `http://127.0.0.1:3003` | Web UI, plain HTTP, this host only |
| PostgreSQL | Port 5432 | Database | | Backend | `http://127.0.0.1:8022` | API, this host only |
| PostgreSQL | `127.0.0.1:5432` | Database |
### Reverse Proxy (recommended) ### HTTPS
VulnCheck is designed to run behind a reverse proxy (Nginx Proxy Manager, Traefik, Caddy, etc.): The bundled nginx (Compose profile `tls`, on via `COMPOSE_PROFILES=tls` in `.env`) is the only service reachable from the network. On first start the backend creates a **CA for this installation** and issues the server certificate from it for `TLS_SERVER_NAMES`, the `DASHBOARD_URL` host and localhost; it renews itself nightly.
1. Point your reverse proxy to the **frontend** port (default `3000`) - **Make browsers trust it:** *Settings → Security → HTTPS Certificate → Download TrueVuln CA*, then distribute `truevuln-ca.pem` as a trusted root (GPO/MDM). The CA carries name constraints and can issue for those names only, so a stolen CA key is no good for any other site. Adding a name later creates a new CA, which has to be distributed again.
2. The frontend proxies all API calls to the backend internally via Docker networking - **Certificate from your internal PKI:** upload it on the same card (PEM with chain, DER or PFX/P12, key separately or inside). It is checked (key matches, chain complete, covers the `DASHBOARD_URL` host, not expired, not self-signed, RSA ≥ 2048 / ECDSA P-256 or P-384, SHA-256+) and served within 30 seconds, without a restart. The key stays on the `tls_data` volume, never in the database. The CA key lives in a separate volume (`tls_ca`) that only the backend mounts.
3. You do **not** need to expose the backend port (8000) externally - **TLS:** TLS 1.3 only, HSTS (two years). `nginx:stable-alpine` currently ships OpenSSL 3.5, which also offers the hybrid post-quantum key exchange X25519MLKEM768 to browsers that support it. Clients without TLS 1.3 (Windows Server 2019 and older via SChannel, Java 8 before u261) cannot connect.
- **Hardening:** nginx follows the CIS NGINX Benchmark v3.1.0 (mapping in `README.DEV.md`). It answers only for the names on the certificate (`TLS_SERVER_NAMES`, the `DASHBOARD_URL` host, localhost, 127.0.0.1) and closes the connection for any other `Host`; a name users type must be in that list.
**Required proxy settings:** ### With your own reverse proxy
- Forward `X-Forwarded-For` and `X-Real-IP` headers (for rate limiting and audit logs)
- Enable WebSocket passthrough if needed
**Required `.env` settings** (already set in `.env.example`): - **Proxy on another host:** keep the bundled nginx and point the proxy at `https://<truevuln-host>:443`, trusting the TrueVuln CA (nginx: `proxy_ssl_trusted_certificate truevuln-ca.pem; proxy_ssl_verify on;`). The whole path stays encrypted.
- `TRUST_PROXY_HEADERS=true` - **Proxy on this host, installed directly (systemd nginx/Apache/Caddy):** remove `tls` from `COMPOSE_PROFILES` and point the proxy at `http://127.0.0.1:3003`. Traffic never leaves the host.
- `AUTH_COOKIE_SECURE=true` (when using HTTPS) - **Proxy on this host as a Docker container (Nginx Proxy Manager, OpenResty, Traefik, …):** remove `tls` from `COMPOSE_PROFILES`. `127.0.0.1` inside that container is the container itself, and the host's LAN IP no longer answers on 3003, so neither works as a target. Put the proxy container into TrueVuln's network and address the frontend by name:
```bash
docker network ls | grep vulnmanager-net # e.g. vulncheck_vulnmanager-net (prefix = Compose project)
docker network connect vulncheck_vulnmanager-net <proxy-container>
docker exec <proxy-container> curl -sSI http://vulnmanager-frontend:3000 # expect HTTP/1.1 200
```
Target in the proxy: `http://vulnmanager-frontend:3000` (Nginx Proxy Manager: Scheme `http`, Forward Hostname `vulnmanager-frontend`, Forward Port `3000`). Port 3000 is the port *inside* the frontend container; it does not clash with anything on the host using 3000, and 3003 is only the host-side mapping. `docker network connect` is lost when the proxy container is recreated (e.g. on an update), so make it permanent in the proxy's own Compose file:
```yaml
services:
app: # the proxy service
networks: [default, truevuln]
networks:
truevuln:
external: true
name: vulncheck_vulnmanager-net
```
The proxy is then in the backend's Docker subnet and trusted for `X-Forwarded-For` without configuration.
Either way the proxy must set `X-Forwarded-For` (nginx/OpenResty: `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;`), and `TRUST_PROXY_HEADERS=true` stays on. The backend trusts the header from its own Docker subnet without configuration. A proxy on **another host** goes into `FORWARDED_ALLOW_IPS` (its IP), otherwise audit log and rate limit see the proxy instead of the client. Older guides told you to put Docker gateway IPs (`172.18.0.1`, …) there; that is not needed any more and can go.
### Upgrading from a version without the bundled nginx
The frontend port is now bound to `127.0.0.1`, so a proxy that reached `http://<host>:3003` from another machine stops working. Add to `.env`:
```bash
COMPOSE_PROFILES=tls
HTTPS_PORT=443
HTTP_PORT=80
TLS_SERVER_NAMES=truevuln.corp.example,10.0.0.5
```
set `DASHBOARD_URL` to the `https://` address and `AUTH_COOKIE_SECURE=true` (older instructions said `false` for plain HTTP), run `docker compose up -d --build`, and either switch the proxy to HTTPS (above) or let users open TrueVuln directly.
Already running your own proxy on this host (Nginx Proxy Manager and the like)? Then leave `COMPOSE_PROFILES` without `tls` and re-point the proxy as described in [With your own reverse proxy](#with-your-own-reverse-proxy). Symptom of a proxy still pointing at the old target: **502 Bad Gateway**, and in the proxy's error log `connect() failed (111: Connection refused) … upstream: "http://<lan-ip>:3003/"`.
### Upgrading from PostgreSQL 15
The database runs on PostgreSQL 18 (`postgres:18-alpine`), the backend on Python 3.14. The 18 image keeps its data in a new volume `postgres18_data` mounted at `/var/lib/postgresql`; started against the old `postgres_data` volume it refuses to start. Migrate **before** the first `up` of the new version:
1. Back up as usual (see [Database Backup & Restore](#database-backup--restore)); the script also keeps its own dumps under `backups/`.
2. `git pull`. Already ran `docker compose up -d` and saw a crash-looping or empty database? No data is lost; carry on.
3. `./tools/migrate_postgres_15_to_18.sh` — stops the stack, dumps the 15 cluster with `pg_dump` 18 into the new volume, compares every table's row count and the login with `POSTGRES_PASSWORD` from `.env`, and leaves `postgres_data` untouched. An empty database the 18 image created on a premature start is moved inside the new volume to `pre-migration-<time>/`, not deleted.
4. `docker compose up -d --build`, log in, check assets and findings.
5. The script warns about other roles with md5 passwords (deprecated in 18): set them again with `ALTER ROLE <name> PASSWORD '…'`. Own changes to `postgresql.conf`, `pg_hba.conf` or `ALTER SYSTEM` are not carried over.
6. Rollback: `docker compose down`, put `image: postgres:15-alpine` and `postgres_data:/var/lib/postgresql/data` back into `docker-compose.yml` (and declare `postgres_data` under `volumes:`), `docker compose up -d`. Anything written since the migration is lost. Migrating again later: `docker volume rm <project>_postgres18_data` first; the script refuses to reuse a copy whose source has been used since.
7. Once it has run cleanly for a few days: `docker volume rm <project>_postgres_data` and `rm -rf backups/pg15-to-18-*`. Until then avoid `docker volume prune -a`: the old volume is no longer declared in compose and counts as unused.
### 4. First login ### 4. First login
@@ -295,11 +435,13 @@ curl -X POST https://your-domain.tld/auth/setup-admin \
### 5. Configure integrations ### 5. Configure integrations
In **Settings**, configure: In **Settings**, configure the integrations you need (all optional, all stored encrypted at rest):
1. **Wazuh SIEM** -- API URL, credentials, and Indexer connection 1. **Wazuh SIEM** -- API URL, credentials, and Indexer connection
2. **AI Provider** -- Choose your provider and enter API credentials 2. **Tenable Nessus** -- base URL + API keys, default scan IDs
3. **SMTP Email** -- For SLA breach and vulnerability notifications 3. **Microsoft Intune (Graph API)** -- tenant/client ID + client secret (app-only); optional Defender TVM
4. **AI Provider / OpenRouter** -- choose your provider and enter API credentials
5. **SMTP Email** -- for SLA breach and vulnerability notifications
--- ---
@@ -314,18 +456,20 @@ In **Settings**, configure:
┌─────────────┼─────────────┐ ┌─────────────┼─────────────┐
│ │ │ │ │ │
┌─────▼────┐ ┌────▼─────┐ ┌───▼────┐ ┌─────▼────┐ ┌────▼─────┐ ┌───▼────┐
│PostgreSQL │ │ Wazuh │ │ AI │ │PostgreSQL │ │ Scanners │ │ AI │
│ 15 │ │ SIEM │ │Provider│ │ 15 │ │ & feeds │ │Provider│
└──────────┘ └──────────┘ └────────┘ └──────────┘ └──────────┘ └────────┘
``` ```
**External sources:** Wazuh, Tenable Nessus, Microsoft Graph (Intune) + Defender TVM, and read-only enrichment feeds — EPSS/KEV/EUVD, cvelistV5/Vulnrichment/NVD, endoflife.date + MS lifecycle export, MSRC, Ubuntu/Red Hat advisories, OSV.dev, public-exploit catalogs.
**Tech Stack:** **Tech Stack:**
| Layer | Technology | | Layer | Technology |
|---|---| |---|---|
| Frontend | Next.js 16, React 19, Tailwind CSS 4, DaisyUI, TypeScript | | Frontend | Next.js 16, React 19, Tailwind CSS 4, DaisyUI, TypeScript |
| Backend | FastAPI, SQLAlchemy 2.0, Pydantic 2, APScheduler | | Backend | FastAPI, SQLAlchemy 2.0, Pydantic 2, APScheduler |
| Database | PostgreSQL 15 | | Database | PostgreSQL 18 |
| Auth | JWT (python-jose), bcrypt | | Auth | JWT (python-jose), bcrypt |
| Reports | ReportLab (PDF), CSV export | | Reports | ReportLab (PDF), CSV export |
| Deployment | Docker Compose, multi-stage builds, non-root containers | | Deployment | Docker Compose, multi-stage builds, non-root containers |
@@ -345,20 +489,25 @@ All configuration is done via the `.env` file:
| `POSTGRES_DB` | `vulnmanager` | Database name | | `POSTGRES_DB` | `vulnmanager` | Database name |
| `POSTGRES_PORT` | `5432` | Database port | | `POSTGRES_PORT` | `5432` | Database port |
| `BACKEND_PORT` | `8022` | Backend API port | | `BACKEND_PORT` | `8022` | Backend API port |
| `FRONTEND_PORT` | `3003` | Frontend UI port | | `FRONTEND_PORT` | `3003` | Frontend UI port (plain HTTP, `127.0.0.1` only) |
| `COMPOSE_PROFILES` | `tls` | `tls` starts the bundled nginx |
| `HTTPS_PORT` / `HTTP_PORT` | `443` / `80` | nginx ports; HTTP only redirects |
| `TLS_SERVER_NAMES` | -- | Extra names/IPs for the auto-generated certificate (the `DASHBOARD_URL` host and localhost are always in it) |
| `JWT_SECRET_KEY` | -- | **Required.** JWT signing key (`openssl rand -hex 32`) | | `JWT_SECRET_KEY` | -- | **Required.** JWT signing key (`openssl rand -hex 32`) |
| `ENV` | `production` | `development` or `production` | | `ENV` | `production` | `development` or `production` |
| `TIMEZONE` | `UTC` | Server timezone (e.g., `Europe/Zurich`) | | `TIMEZONE` | `UTC` | Server timezone (e.g., `Europe/Zurich`) |
| `NVD_API_KEY` | -- | Optional. Raises the NVD stage of the CVSS cascade from 5 to 50 req/30s and its batch cap from 100 to 1500 CVEs |
| `AUTH_COOKIE_SECURE` | auto | `true` for HTTPS, `false` for HTTP. Auto-detected from `ENV` if not set | | `AUTH_COOKIE_SECURE` | auto | `true` for HTTPS, `false` for HTTP. Auto-detected from `ENV` if not set |
| `AUTH_COOKIE_SAMESITE` | `lax` | Cookie SameSite policy | | `AUTH_COOKIE_SAMESITE` | `lax` | Cookie SameSite policy |
| `TRUST_PROXY_HEADERS` | `false` | Trust `X-Forwarded-For` for client IP (enable behind reverse proxy) | | `TRUST_PROXY_HEADERS` | `true` (Compose) | Trust `X-Forwarded-For` for client IP in audit log and rate limit |
| `FORWARDED_ALLOW_IPS` | -- | Extra trusted proxies (IPs/CIDRs), e.g. a reverse proxy on another host. Loopback and the backend's Docker subnet are always trusted |
| `DEFAULT_ADMIN_USERNAME` | `admin` | Default admin username (first run only) | | `DEFAULT_ADMIN_USERNAME` | `admin` | Default admin username (first run only) |
| `DEFAULT_ADMIN_PASSWORD` | `changeme` | Default admin password (first run only) | | `DEFAULT_ADMIN_PASSWORD` | `changeme` | Default admin password (first run only) |
| `DASHBOARD_URL` | -- | External URL for email notification links | | `DASHBOARD_URL` | -- | External URL for email notification links; its host goes into the HTTPS certificate |
### Integrations ### Integrations
All integrations (Wazuh, AI, SMTP) are configured through the **Settings** page in the UI. No additional environment variables are needed for these. All integrations (Wazuh, Nessus, Intune/Defender, AI/OpenRouter, SMTP) are configured through the **Settings** page in the UI and stored encrypted at rest. No additional environment variables are required. Optional env keys exist for headless/CI use (e.g. `NVD_API_KEY`, `OPENROUTER_API_KEY`) — see `.env.example`. Multi-provider auth (LDAP/OIDC/SAML/MFA) is configured via env + the auth admin UI; see `.env.example`.
--- ---
@@ -366,25 +515,24 @@ All integrations (Wazuh, AI, SMTP) are configured through the **Settings** page
### Reverse Proxy Setup ### Reverse Proxy Setup
VulnCheck is designed to run behind a reverse proxy. Only the **frontend port** needs to be reachable by the proxy -- the frontend handles all API routing internally. TrueVuln serves HTTPS itself (see [HTTPS](#https)). A reverse proxy in front is optional:
``` ```
Internet → Reverse Proxy (443/HTTPS) → Frontend (3003) → Backend (8022, internal) Browser → (your proxy, 443) → nginx (443, TLS) → Frontend (3003, 127.0.0.1) → Backend (8022, internal)
``` ```
**Nginx Proxy Manager example:** **Proxy on another host, re-encrypting to TrueVuln:** the `Host` it forwards (here `vuln.example.com`) must be the `DASHBOARD_URL` host or in `TLS_SERVER_NAMES`; TrueVuln's nginx closes the connection for any other name.
1. Add a new proxy host pointing to `http://<docker-host>:3003`
2. Enable SSL with Let's Encrypt
3. No custom location blocks needed -- the frontend proxies everything
**Manual Nginx example:**
```nginx ```nginx
server { server {
listen 443 ssl; listen 443 ssl;
server_name vuln.example.com; server_name vuln.example.com;
location / { location / {
proxy_pass http://127.0.0.1:3003; proxy_pass https://truevuln-host:443;
proxy_ssl_trusted_certificate /etc/nginx/truevuln-ca.pem;
proxy_ssl_verify on;
proxy_ssl_name truevuln-host; # a name in TLS_SERVER_NAMES
proxy_ssl_server_name on;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -393,7 +541,9 @@ server {
} }
``` ```
The backend port (8022) is bound to `127.0.0.1` by default in `docker-compose.yml` and does not need to be exposed. **Proxy on the same host:** remove `tls` from `COMPOSE_PROFILES`. Installed directly on the host: `proxy_pass http://127.0.0.1:3003;`. Running as a Docker container: join it to `<project>_vulnmanager-net` and use `proxy_pass http://vulnmanager-frontend:3000;` (see [With your own reverse proxy](#with-your-own-reverse-proxy)).
Frontend (3003) and backend (8022) are bound to `127.0.0.1` in `docker-compose.yml`.
### Security Checklist ### Security Checklist
@@ -401,11 +551,11 @@ The backend port (8022) is bound to `127.0.0.1` by default in `docker-compose.ym
- [ ] Set a strong `POSTGRES_PASSWORD` - [ ] Set a strong `POSTGRES_PASSWORD`
- [ ] Change the default admin password after first login - [ ] Change the default admin password after first login
- [ ] Set `ENV=production` - [ ] Set `ENV=production`
- [ ] Set `AUTH_COOKIE_SECURE=true` (HTTPS) - [ ] Keep `AUTH_COOKIE_SECURE=true` (HTTPS)
- [ ] Set `TRUST_PROXY_HEADERS=true` (behind reverse proxy) - [ ] Keep `TRUST_PROXY_HEADERS=true`; a reverse proxy on another host goes into `FORWARDED_ALLOW_IPS`
- [ ] Configure TLS via reverse proxy - [ ] Set `DASHBOARD_URL` / `TLS_SERVER_NAMES`, distribute the TrueVuln CA or upload a PKI certificate
- [ ] Restrict database port (5432) to internal networks - [ ] Restrict database port (5432) to internal networks
- [ ] Block external access to ports 3003 and 8022 via firewall - [ ] Ports 3003 and 8022 stay on `127.0.0.1`; only 443 (and 80 for the redirect) open to users
--- ---
@@ -416,7 +566,7 @@ The backend port (8022) is bound to `127.0.0.1` by default in `docker-compose.ym
**Backend:** **Backend:**
```bash ```bash
# Python 3.11+ # Python 3.14 (same as the backend image)
pip install -r requirements.txt pip install -r requirements.txt
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
``` ```
@@ -439,7 +589,7 @@ docker run -d --name vulncheck-db \
-e POSTGRES_PASSWORD=changeme \ -e POSTGRES_PASSWORD=changeme \
-e POSTGRES_DB=vulnmanager \ -e POSTGRES_DB=vulnmanager \
-p 5432:5432 \ -p 5432:5432 \
postgres:15-alpine postgres:18-alpine
``` ```
### Database Migrations ### Database Migrations
@@ -487,8 +637,9 @@ The backend exposes a REST API at `/api/v1/`. All endpoints require JWT authenti
| Endpoint Group | Base Path | Description | | Endpoint Group | Base Path | Description |
|---|---|---| |---|---|---|
| Authentication | `/auth` | Login, logout, user management | | Authentication | `/auth` | Login, logout, user management |
| Vulnerabilities | `/api/v1/vulnerabilities` | CRUD, AI analysis, Wazuh sync, bulk ops | | Vulnerabilities | `/api/v1/vulnerabilities` | CRUD, AI analysis, Wazuh sync, App→CVE scan, FP suppression, bulk ops |
| Assets | `/api/v1/assets` | Inventory, assignment, bulk ops | | Assets | `/api/v1/assets` | Inventory, assignment, sync-source filter, bulk ops |
| Advisories | `/api/v1/advisories` | CISA KEV feed + configurable RSS advisory feeds (`/feeds`, `/feeds/refresh`) |
| Scans | `/api/v1/scans` | Scan jobs, schedules | | Scans | `/api/v1/scans` | Scan jobs, schedules |
| Policies | `/api/v1/policies` | SLA policy management | | Policies | `/api/v1/policies` | SLA policy management |
| Groups | `/api/v1/groups` | User group management | | Groups | `/api/v1/groups` | User group management |
@@ -498,7 +649,117 @@ The backend exposes a REST API at `/api/v1/`. All endpoints require JWT authenti
| Audit | `/audit` | Audit trail (admin only) | | Audit | `/audit` | Audit trail (admin only) |
| Health | `/health` | Health check (no auth) | | Health | `/health` | Health check (no auth) |
Interactive API docs are available at `http://localhost:8022/docs` (Swagger UI). **Interactive docs (Swagger UI):** `http://<host>:8022/docs`, ReDoc at `/redoc`.
Enabled automatically in development (`ENV=development`). On a production
instance set `ENABLE_API_DOCS=true` in `.env` to turn them on. The raw OpenAPI
spec is **always** served at `/openapi.json` — import it into Postman/Insomnia
or generate a client, regardless of the docs toggle.
### Calling the API from an external system
The API is standard REST + JSON, secured with a JWT **Bearer** token. Flow:
**(1)** log in once to get a token, **(2)** send it as `Authorization: Bearer …`
on every request. Access tokens expire after **30 min**; use the refresh token
or just log in again for a fresh one.
> Use a dedicated service user (role `viewer` is enough for read-only ITSM/CMDB
> pulls) and **disable MFA** on it — otherwise `/auth/login` returns
> `mfa_required` and expects a second `/auth/mfa/verify` step. Replace
> `https://truevuln.example.com` with your host.
**1 — Log in, get a token**
```bash
TOKEN=$(curl -s -X POST https://truevuln.example.com/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"svc-itsm","password":"••••••"}' \
| jq -r .access_token)
```
Response body:
```json
{
"access_token": "eyJhbGci…",
"refresh_token": "eyJhbGci…",
"token_type": "bearer",
"user": { "id": 7, "username": "svc-itsm", "role": "viewer" },
"mfa_required": false
}
```
**2 — List all vulnerabilities (with the token)**
```bash
curl -s https://truevuln.example.com/api/v1/vulnerabilities \
-H "Authorization: Bearer $TOKEN"
```
**Filter** — the list endpoint takes query params (combine freely):
```bash
# Only actively-exploited (CISA KEV), critical, open findings — top 500
curl -s "https://truevuln.example.com/api/v1/vulnerabilities?kev_only=true&severity=critical&status=open&limit=500" \
-H "Authorization: Bearer $TOKEN"
# Everything on one specific asset
curl -s "https://truevuln.example.com/api/v1/vulnerabilities?asset_id=42" \
-H "Authorization: Bearer $TOKEN"
# Only Nessus-sourced findings, sorted by CVSS
curl -s "https://truevuln.example.com/api/v1/vulnerabilities?source=nessus&sort_by=cvss&sort_order=desc" \
-H "Authorization: Bearer $TOKEN"
```
Common query params: `severity` (critical/high/medium/low), `status`,
`kev_only`, `exploitable`, `epss_min` (0.0–1.0), `source` (wazuh/nessus/manual),
`asset_id`, `search` (CVE-ID/package/title), `sort_by` (priority/cvss/detected_at),
`sort_order`, `limit` (≤1000). Full list + response schema in `/docs`.
**3 — Pull the asset inventory (ITAM/CMDB sync)**
```bash
curl -s https://truevuln.example.com/api/v1/assets \
-H "Authorization: Bearer $TOKEN"
```
**4 — Refresh an expired token** (no re-login needed within refresh-token life)
```bash
curl -s -X POST https://truevuln.example.com/auth/refresh \
-H 'Content-Type: application/json' \
-d "{\"refresh_token\":\"$REFRESH_TOKEN\"}"
```
**Python example** (e.g. an ITSM integration script):
```python
import requests
BASE = "https://truevuln.example.com"
tok = requests.post(f"{BASE}/auth/login",
json={"username": "svc-itsm", "password": "••••••"}).json()["access_token"]
hdr = {"Authorization": f"Bearer {tok}"}
vulns = requests.get(f"{BASE}/api/v1/vulnerabilities",
params={"kev_only": True, "status": "open", "limit": 1000},
headers=hdr).json()
for v in vulns:
print(v["cve_id"], v["severity"], v["asset_hostname"])
```
Each finding is a flat JSON object — key fields: `cve_id`, `asset_id`,
`asset_hostname`, `cvss_score`, `severity`, `status`, `epss_score`,
`kev_listed`, `exploit_available`, `priority_score`, `sources`, `detected_at`.
> **Response shape:** with the default `sort_by=priority` the endpoint returns a
> bare JSON **array**. With `sort_by=cvss` or `sort_by=detected_at` it returns a
> paged object `{"items": [...], "total": N}` instead — read `.items` in that
> case.
> **Note:** tokens are short-lived user JWTs (30 min). A long-lived API key /
> service token and outbound webhooks are not built yet — say so if you need
> them.
--- ---
@@ -517,17 +778,6 @@ Please open an issue first for larger changes to discuss the approach.
--- ---
## Roadmap
- [ ] Webhook notifications (Slack, Teams, generic)
- [ ] LDAP/Active Directory authentication
- [ ] Multi-tenant support
- [ ] Additional SIEM integrations
- [ ] Vulnerability scanning without Wazuh (standalone agent)
- [ ] Dark mode
---
## License ## License
This project is licensed under the **GNU Affero General Public License v3.0 (AGPLv3)**. See [LICENSE](LICENSE) for details. This project is licensed under the **GNU Affero General Public License v3.0 (AGPLv3)**. See [LICENSE](LICENSE) for details.
@@ -642,7 +892,7 @@ docker exec vulnmanager-db pg_isready -U vulnmanager
## Support ## Support
If you encounter issues or have questions, please [open an issue](https://gitea.isuit.ch/vulncheck/vulncheck/issues) in this repository. If you encounter issues or have questions, please [open an issue](https://github.com/{{GH_OWNER}}/{{GH_REPO}}/issues) in this repository.
**Email Support:** [support-vulncheck.sq9vd@passmail.net](mailto:support-vulncheck.sq9vd@passmail.net) **Email Support:** [support-vulncheck.sq9vd@passmail.net](mailto:support-vulncheck.sq9vd@passmail.net)
@@ -650,7 +900,7 @@ If you encounter issues or have questions, please [open an issue](https://gitea.
## Support the Project ## Support the Project
If VulnCheck is useful to you, consider buying me a coffee! ☕ If TrueVuln is useful to you, consider buying me a coffee! ☕
<a href="https://buymeacoffee.com/vulncheck" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" height="50"></a> <a href="https://buymeacoffee.com/vulncheck" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" height="50"></a>
+54
View File
@@ -0,0 +1,54 @@
# Security Policy
## Reporting a vulnerability
**Do not open a public GitHub issue for a security vulnerability in TrueVuln.**
Report it privately through GitHub's
[private vulnerability reporting](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability)
(Security tab → Report a vulnerability), or by email to {{CONTACT}}.
Please include:
- Affected version or commit
- What an attacker can do with it
- Reproduction steps or a proof of concept
- Whether it is already public anywhere
## What to expect
| | |
|---|---|
| Acknowledgement | within 72 hours |
| Initial assessment | within 7 days |
| Fix or mitigation for confirmed high/critical issues | target 30 days |
| Credit | in the advisory, unless you prefer not to be named |
This project is maintained by a small team. Timelines are targets made in good faith,
not a contractual commitment.
## Scope
**In scope:** authentication and authorisation bypass, privilege escalation between
roles, injection (SQL, command, template), SSRF via configurable feed or scanner URLs,
credential exposure in logs, API responses, or reports, secrets handling in
`app/auth/setting_crypto.py`, tampering with the audit trail, XSS in the frontend.
**Out of scope:** vulnerabilities in third-party dependencies without a demonstrated
exploit path through TrueVuln (report those upstream), issues that require an already
compromised host or an existing admin session, missing security headers with no
demonstrated impact, findings from automated scanners with no working proof of concept,
denial of service through deliberate resource exhaustion on a self-hosted instance,
and social engineering.
## Supported versions
TrueVuln is in beta. Security fixes are applied to the latest release on `main`. There
are no backports to older releases yet.
## A note on deployment
TrueVuln holds credentials for Wazuh, Nessus, Microsoft Graph, and your mail server, and
a complete map of every unpatched vulnerability in your estate. **Treat the instance as a
tier-0 asset.** Do not expose it directly to the internet, restrict access to the
administrative network, and enable MFA.
+748
View File
@@ -0,0 +1,748 @@
# TrueVuln — Update auf `dev` (Netdisco-Connector: Switche, Router, Access Points & FC-Switche als Assets, Firmware-CVEs fuer Aruba, Cisco, Extreme, Ubiquiti und Brocade)
```bash
git pull
docker compose build
docker compose up -d
docker compose exec backend alembic current # erwartet: 053 (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.
**Keiner der Firmware-Teile bringt eine neue Migration mit** — fuer Ubiquiti
und Brocade reicht bauen und neu starten. **Neu ist 053**: eine Spalte fuer die
Seriennummer, damit ein Geraet mit neuer IP kein zweites Asset mehr anlegt
(Nachtrag ganz unten). Auch die laeuft beim Containerstart mit.
Backend und Frontend.
> **Wer die vorherige Fassung schon getestet hat**, braucht nur fuenf
> Abschnitte: *Ubiquiti UniFi Access Points*, *Brocade Fabric OS*, *Nachtrag:
> CVE-2019-12660*, *Nachtrag: Host-Filter, Ladezeiten und ein Messinstrument*
> und *Nachtrag: ein Geraet mit neuer IP*. Die zugehoerigen Testpunkte sind
> **19 bis 28**. Der Rest steht unveraendert und dient als Nachschlagewerk.
---
## 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 acht Firmware-Linien
werden daraus CVE-Findings: die drei HPE-Aruba-Linien, Cisco IOS XE / IOS XR,
Extreme Networks EXOS (Switch Engine) und **neu Ubiquiti UniFi Access Points
sowie Brocade Fabric OS (FC-Switche)**.
Beide neuen Linien **ausschliesslich ueber NVD/CPE und CVEListv5** — keine
Hersteller-Bulletins, weder Ubiquitis Community-Advisories noch Broadcoms
KB-Artikel. Wie das jeweils aufgeht, steht unten.
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-, Extreme-, UniFi-AP- und
> Brocade-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. (Und genau die hat sich als False Positive herausgestellt, siehe unten.)
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.
## Ubiquiti UniFi Access Points — die Modell-Spalte entscheidet, zweimal
Hier ist zum ersten Mal der **OS-Slug wertlos**. Im Screenshot antwortet
dieselbe Flotte ihn dreimal verschieden — `U6-LR`, `U7-Outdoor` und einmal
schlicht `UniFi` —, je nachdem was im sysDescr stand. Massgeblich ist, wie im
Screenshot richtig vermutet, die **Modell-Spalte**. Und die entscheidet zwei
verschiedene Dinge:
**Erstens: ob die Zeile ueberhaupt ein Access Point ist.** Ubiquiti nummeriert
AP-, Switch- und Gateway-Firmware in **einem** Schema. CVE-2023-38034 ist das
Musterbeispiel — derselbe Record, ein Fix je Produktlinie:
| Produkt im Record | behoben in |
|---|---|
| `UniFi Access Points` | 6.5.53 |
| `UniFi Switches` | 6.5.32 |
Ein USW-24 auf 6.5.40 ist damit gepatcht — gegen die AP-Grenze gehalten sieht
er aus wie ein ungepatchter AP. Deshalb: nur Modelle der AP-Reihe (`UAP-*`,
`U6-*`, `U7-*`, `UBB`, `UWB-XG`, und per `U<Ziffer>` auch kuenftige
Generationen) bekommen ein Verdikt. Alles andere unter `ubiquiti` — `USW-*`,
`US-*`, `UDM`, `UXG`, Kameras — bekommt Asset und OS-String und **kein**
CVE-Verdikt.
**Zweitens: welche Records gelten.** Ungefaehr die Haelfte von Ubiquitis
Records nennt ein **einzelnes Modell**:
| CVE | Produkt im Record | behoben in |
|---|---|---|
| CVE-2024-37380 (Bulletin 041) | `UniFi U6+ Access Point` | 6.6.74 |
| CVE-2024-22054 | `UniFi Access Points` | 6.6.55 |
| CVE-2023-38034 (Bulletin 035) | `UniFi Access Points` | <= 6.5.53 |
CVE-2024-37380 betrifft **nur den U6+**. Ein U6-LR auf 6.5.x liegt unter dieser
Grenze, war aber nie betroffen — nach Version allein gematcht bekaeme die
gesamte AP-Flotte das Finding. Deshalb steht das Modell im Asset-OS-String
(`UniFi AP U6-LR`), und ein Modell-Record wird nur auf genau dieses Modell
angewandt. Nennt ein Record kein Modell, gilt er fuer alle APs — so meint
Ubiquiti das auch.
> Wenn ein Record ein Modell nennt und das Asset keins fuehrt, wird er
> **uebersprungen**. "Laesst sich nicht entscheiden" ist nicht "betroffen".
**Beide Quellen, und hier traegt CVEListv5 die Hauptlast.** Auf der NVD-Seite
haengt an `cpe:2.3:o:ui:unifi_uap_firmware` (geprueft am 09.09.2026) genau
**zwei** CVEs: CVE-2023-35085 und CVE-2023-38034. Die aktuelle,
CVE-2024-37380, steht dort seit Veroeffentlichung auf *"Deferred"* mit **null**
CPE-Eintraegen — strukturiert existiert sie nur in Ubiquitis eigenem Record.
`cpe:2.3:o:ui:unifi_switch_firmware` wird bewusst **nicht** mit abgefragt: das
ist ein anderes Produkt mit eigenen Fixes auf denselben CVEs (siehe Tabelle
oben), kein Alternativname.
> **Erwartung fuer die Flotte aus dem Screenshot: keine Findings.** 6.7.58,
> 6.9.1 und 8.8.7 liegen alle **ueber** allen drei Grenzen. Das ist das richtige
> Ergebnis, kein Fehler — interessant wird es erst bei einem AP auf 6.6.54 oder
> aelter. Wer das aktiv sehen will: ein Geraet mit alter Firmware, oder die
> Regeln offline gegen `tests/test_ubiquiti_brocade_firmware.py`.
## Brocade Fabric OS — die einzige Linie nur ueber NVD, und die Buchstaben zaehlen
Brocade ist der Fall, in dem die CNA-Haelfte **nichts** hergibt, und zwar nicht
aus Nachlaessigkeit, sondern weil die Versionsangabe ein **Satz** ist. Wortlaut
aus den Records:
| CVE | `version` im CVEListv5-Record |
|---|---|
| CVE-2025-9711 | `before 9.2.1c3, and 9.2.2 though 9.2.2b` |
| CVE-2026-0383 | `before 9.2.1c2, 9.2.2 through 9.2.2a and 10.0.0` |
| CVE-2024-29954 | `before v9.2.1, v9.2.0b, v9.1.1d, and v8.2.3e` |
("though" ist Brocades Tippfehler, nicht meiner.) Daraus laesst sich keine
Range lesen, ohne zu raten. NVDs Analysten machen aus **demselben** Text saubere
`cpeMatch`-Ranges — deshalb ist hier die NVD-Haelfte die tragende, und es gibt
bewusst keinen CVEListv5-Pass fuer Fabric OS.
Dazu die Eigenheit, die tatsaechlich ein Bug war: **der Rebuild-Buchstabe ist
Teil des Release**. Fabric OS nummeriert `9.0.1e1`, `9.1.1d`, `9.2.1c3` — und
NVD schreibt die Grenzen genauso. Zwei Konsequenzen:
* Die strenge Versionslesung haelt `9.0.1e1` fuer **gar keine Version** — die
FC-Switche waeren nie gescannt worden. Sie werden jetzt mit dem Lettered-Leser
eingelesen (wie Cisco).
* Die Buchstaben werden **sortiert** statt weggeworfen. Vorher las
`9.2.1c3` sich als `(9,2,1,3)` und `9.2.1d` als `(9,2,1)` — ein auf 9.2.1d
gepatchter Switch lag damit unter der Grenze "vor 9.2.1c3" und haette die CVE
eingesammelt. Jetzt gilt 9.2.1b < 9.2.1c2 < 9.2.1c3 < 9.2.1d, wie es sich
gehoert.
> **Erwartung fuer `fcswitch3` / `fcswitch4` auf `9.0.1e1`:** Asset-OS steht als
> **`Brocade Fabric OS`** da, Version `9.0.1e1`, und es kommen Findings — u. a.
> CVE-2024-29954 (Range 9.0.1 bis unter 9.1.1d), CVE-2025-9711 (unter 9.2.1c3)
> und CVE-2026-0383 (unter 9.2.1c2). Alle **mit** Fix-Version.
**Brocades IP-Switche (ICX/FastIron, NetIron) sind nicht dabei** und muessen es
auch nicht sein: gleiche Marke, gleiche Versionsform (`08.0.95hT211`), voellig
andere Firmware. Getrennt werden sie ueber den OS-Slug — SNMP::Info fuehrt die
FC-Switche als `Layer2::Brocade` (OS-Slug `brocade`), die IP-Switche unter den
Foundry-Klassen (`ironware`/`fastiron`/`foundry`). Meldet euer Netdisco fuer die
FC-Switche etwas anderes als `brocade`, sagt es der Test sofort (siehe unten).
## 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.
* **Keine Ubiquiti-Community-Advisories und keine Broadcom-KB-Artikel.** War so
gewuenscht, und es geht auch auf: Ubiquitis Bulletins 035 und 041 sind
dieselben CVEs, die im CNA-Record strukturiert stehen, und Broadcoms
Advisory-Seiten dieselben, die NVD als Ranges fuehrt. Eine dritte Quelle mit
eigenen Ausfallarten braucht es dafuer nicht.
* **UniFi Switches, Gateways (UDM/UXG) und Kameras.** Die Daten gaebe es —
NVD fuehrt `unifi_switch_firmware`, Ubiquiti das Produkt `UniFi Switches`.
Aber im Testbestand war kein solches Geraet, und ein Schluessel, den nichts
fuellt, ist ein Schluessel, den nichts prueft. Die Modell-Pruefung trennt sie
bereits sauber ab; sagt Bescheid, wenn ihr welche im Netdisco habt, dann sind
es ein paar Zeilen.
* **Brocade ICX/FastIron und NetIron** (siehe oben) — andere Firmware unter
derselben Marke, per OS-Slug aussortiert.
* **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.
## Nachtrag: CVE-2019-12660 auf jedem IOS-XE-Router war ein False Positive
Gemeldet mit Screenshot und Detailanalyse — die Analyse war richtig, und die
Ursache liegt genau dort, wo sie vermutet wurde.
NVDs **komplette** Konfiguration fuer diese CVE besteht aus **einem** Eintrag
(nachgeprueft am 09.09.2026):
```
cpe:2.3:o:cisco:ios_xe:* versionStartIncluding: 16.1.1
(keine Obergrenze, kein versionEnd irgendeiner Art)
```
Woertlich gelesen heisst das "jedes IOS-XE-Release ab 16.1.1, fuer immer" —
also auch jedes, das Cisco erst 2024 oder 2026 veroeffentlicht hat. Cisco hat
den Fehler 2019 behoben und formuliert im Advisory ausdruecklich *"at the time
of publication"*; der 17.15-Zweig existierte damals noch gar nicht. Am Finding
stand deshalb **"fixed in: n/a"** — und genau das ist der Befund: **ein
Eintrag ohne Obergrenze nennt keinen Fix, also kann sich das Finding nie
schliessen.** Ein Finding, aus dem kein Patch herausfuehrt, ist kein Verdikt,
sondern Rauschen.
Behoben, und zwar an der Wurzel statt fuer diese eine CVE: ein NVD-Eintrag mit
Untergrenze und **ohne** Obergrenze zaehlt ab sofort nicht mehr als Treffer.
Das ist dieselbe Regel, die die CVEListv5-Haelfte **schon immer** anwendet
(*"only ranges with a real upper bound"*) und die auch fuer vSphere gilt
(*"affected, fix unknown" laesst sich nicht in ein Host-Verdikt uebersetzen*) —
die NVD-Haelfte war der Ausreisser, nicht der Fix.
Wichtig fuer die Erwartung beim Nachtesten:
* Es wirkt **pro Eintrag, nicht pro CVE**. Ein Record, der daneben eine
ordentliche Range fuehrt, meldet weiterhin ueber die.
* Auf dem Testgeraet (C8000V, 17.15.4c) trifft es exakt **eine** von neun
CVEs — nachgemessen: die anderen acht haengen an exakten Versionen
(CVE-2026-20267…20273) oder an einer beidseitig begrenzten Range
(CVE-2025-20363). Es faellt also nichts Echtes mit weg.
* **Kein manuelles Aufraeumen noetig.** Die offenen Findings muessen *nicht*
von Hand als False Positive markiert werden: der naechste Netdisco-Sync
meldet die CVE nicht mehr, und die Cisco-Reconcile schliesst sie dann von
selbst (Change History: die Quelle zieht sich zurueck). Das ist derselbe Weg,
den ein echtes Firmware-Update nehmen wuerde.
**Und warum nicht der Cisco IOS Software Checker?** Der waere fuer diesen Fall
tatsaechlich die genaueste Quelle, und er kann etwas, was beide angebundenen
Quellen nicht koennen: die **Fix-Version** je Release-Zweig nennen. Trotzdem
bleibt er vorerst draussen, aus denselben Gruenden, die schon fuer die
HPE-Bulletins gelten — er ist eine dritte Quelle mit eigenen Ausfallarten
(`sec.cloudapps.cisco.com`, Captcha auf der Checker-Seite, kein
dokumentiertes API, HTML das sich aendert), und er wuerde hier nichts
**erkennen**, was die anderen beiden nicht schon erkennen, sondern nur etwas
**wegnehmen**. Genau das Wegnehmen macht die Regel oben jetzt ohne dritte
Quelle. Wenn die fehlende Fix-Version bei Cisco-Findings konkret stoert, ist
das der Punkt, an dem sich die Anbindung lohnt — dann aber als eigenes Thema,
mit eigener Fehlerbehandlung und einem Weg, kaputt zu gehen, ohne den Scan
mitzunehmen. Sagt Bescheid.
## Nachtrag: Host-Filter, Ladezeiten und ein Messinstrument
Drei Meldungen, die zusammengehoeren — zwei davon haben dieselbe Ursache.
**Der Host-Filter endete beim Buchstaben "M".** Die Auswahlliste holte ihre
Hosts aus dem Asset-Listen-Endpoint mit `limit=1000` — dessen hartem Maximum.
Bei 1194 Assets brach sie 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 nicht aus wie "zu
viele", es sieht aus wie "gibt es nicht".
Der Picker braucht zwei Spalten, keine ganzen Assets. Neu ist deshalb
`GET /api/v1/assets/names` — `id` und `hostname`, eine Query, **kein Limit**,
sortiert wie die Tabelle (case-insensitiv, damit `alpine` nicht hinter
`Webserver` rutscht). Auch fuer Readonly-Nutzer offen, die filtern ja genauso.
**Und dabei kam die Ladezeit heraus.** Der Listen-Endpoint hat pro Zeile ein
eigenes `SELECT count(*) FROM vulnerabilities WHERE asset_id = ?` abgesetzt.
Bei `limit=1000` sind das tausend Roundtrips in **einem** Request — genau der
Aufruf, der im geschickten Backend-Log den Seitenaufbau dominiert hat. Jetzt
ein gruppiertes COUNT: drei Statements fuer 100 Assets, dieselbe Zahl wie fuer
10. Das betrifft die Asset-Tabelle genauso wie den alten Picker.
Dazu, ersatzlos raus: die Scan-Jobs-Seite hat bei **jedem** Laden dieselben
1000 Assets geholt und den State nie gelesen. Ein kompletter Request weniger
pro Seitenaufbau.
**Dashboard: erst die Zahlen, dann der Rest.** Das Dashboard feuert dreizehn
Requests gleichzeitig ab und wartete auf **alle** — bis zum langsamsten stand
nur "Loading Dashboard...". Jetzt haengt die Seite nur noch am
Statistik-Aufruf: sobald der da ist, stehen die Karten, und die einzelnen
Widgets fuellen sich nach. Solange ein Widget noch wartet, zeigt es einen
Spinner und **nicht** "No data." — das ist der Unterschied, auf den es hier
ankommt: "noch nicht da" darf nie aussehen wie "nichts gefunden". Und der
Ladebildschirm selbst ist jetzt ein Spinner statt einer nackten Zeile Text.
> **Ob das reicht, sagt euer Log.** Bisher stand in der Antwortzeile nur
> `⬅️ Response: 200` — ohne Pfad und ohne Dauer. Bei dreizehn parallelen
> Requests laesst sich damit nicht sagen, welcher gebremst hat; genau deshalb
> war "das Dashboard laedt langsam" bisher nicht nachmessbar. Ab sofort steht
> dort Methode, Pfad und Dauer, und alles ab **1000 ms** zusaetzlich als
> `🐢 SLOW`:
>
> ```
> docker compose logs backend | grep "🐢 SLOW"
> ```
>
> Wenn nach dem Update noch etwas haengt: einmal das Dashboard laden und die
> SLOW-Zeilen schicken. Dann muss niemand mehr raten.
## Nachtrag: ein Geraet mit neuer IP legte ein zweites Asset an
Gemeldet mit Screenshot: ein UniFi AP U6-Pro, Firmware von 6.9.1 auf 8.8.7
aktualisiert und dabei eine neue IP bekommen. Gleicher Hostname, **gleiche
Seriennummer** — trotzdem zwei Zeilen im Inventar, die neue ACTIVE, die alte
INACTIVE.
Dazu die Frage, und sie ist berechtigt: *"Kann das nicht automatisch erkannt
werden? Denn sonst muesste man so immer manuell bereinigen."*
**Ja, und ab jetzt wird es das.** In der Runde davor stand dieser Fall als
bewusster Nebeneffekt drin — lieber eine sichtbare Dublette als zwei Geraete
auf einer `asset_id`. Das war als Zwischenstand richtig und als Endzustand
falsch: bei einem Switch mit statischer Management-Adresse passiert es nie, bei
einem Access Point am DHCP-Lease bei jedem Firmware-Update.
**Warum die Adresse nicht gereicht hat.** Netdisco haengt ein Geraet an seiner
kanonischen IP auf — und der Hostname-Fallback dahinter ebenfalls, ueber
dieselbe Spalte. Das alte Asset traegt die ALTE Adresse und fiel damit aus
genau der Abfrage heraus, die es haette finden muessen. Alle drei Stufen der
Kette haengen an derselben Adresse, also half keine, und am Ende stand ein
Neuanlegen.
**Was jetzt zuerst entscheidet: die Seriennummer.** Sie ist der einzige Wert,
der sowohl eine Umbenennung als auch einen Adresswechsel ueberlebt, und
Netdisco meldet sie ohnehin pro Geraet — sie stand bisher nur in der
Beschreibung. Neue Reihenfolge: **Seriennummer → kanonische IP → Hostname →
IP** (Migration 053).
Drei Dinge bleiben ausdruecklich, wie sie waren:
- **Stacks bleiben ein Asset.** Der Pin wird **einmal** gesetzt und nie
ueberschrieben. VSF/IRF-Mitglieder melden einen Namen und eine Adresse, aber
je eine Seriennummer — das Asset behaelt die erste, die uebrigen Chassis
matchen weiter ueber die gemeinsame Adresse. In der Beschreibung stehen
weiterhin alle (`serials SG01TQ, SG02HY, SG03J6`).
- **Namensvettern bleiben zwei Assets.** Zwei `RackPDU` mit verschiedenen
Seriennummern werden nicht zusammengefuehrt — der Fix aus der letzten Runde
gilt unveraendert.
- **Platzhalter-Seriennummern zaehlen nicht.** `N/A`, lauter Nullen, alles
unter vier Zeichen: darauf zu matchen wuerde jedes Geraet, das dieselbe
Nicht-Antwort gibt, auf eine Zeile werfen. Ein Fund am falschen Host ist
schlimmer als eine Dublette, also identifizieren die nichts und es bleibt bei
Adresse und Name.
**Nichts nachzutragen, aber einmal aufzuraeumen.** Ein Backfill ist nicht
noetig: der erste Sync nach der Migration findet jedes Geraet ueber seine — in
dem Moment unveraenderte — Adresse und schreibt die Seriennummer dabei ans
Asset. Ab dann greift die neue Stufe.
Die **bereits entstandene Dublette loest sich aber nicht von selbst auf.** Am
INACTIVE-Asset haengen Findings und Audit-Trail, und die fuehren wir nicht
automatisch zusammen — das ist eine Entscheidung, kein Abgleich. Konkret: die
alte Zeile aus dem Screenshot (U6-Pro, 6.9.1, die `.99`) unter Assets loeschen,
die ACTIVE-Zeile bleibt. Falls an so einer Zeile echte Historie haengt, die du
behalten willst: nicht loeschen, gib mir die ID.
## Vor dem Testen: einmal den CVE-Index bauen lassen
Der cvelistV5-Index hat eine neue Version (v35, wegen der Ubiquiti-Produkte —
vorher v34 fuer Extreme). 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, die aelteren EXOS-Findings und der
komplette Brocade-Teil kommen sofort** (NVD hat die Daten), **IOS-XR, die
EXOS-CVEs von 2026 und CVE-2024-37380 auf einem U6+ 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) und **CVE-2025-20363**. Ohne Fix-Version — das
ist so gewollt, siehe oben. **CVE-2019-12660 darf NICHT mehr dabei sein**
(war ein False Positive, siehe unten) — falls doch, ist der Index noch der
alte, oder das Finding ist noch offen und schliesst sich beim naechsten
Sync.
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 # enthaelt den 12660-Fall
venv/bin/python tests/test_extreme_firmware.py
venv/bin/python tests/test_ubiquiti_brocade_firmware.py
venv/bin/python tests/test_netdisco_inventory.py
venv/bin/python tests/test_netdisco_nameless_device.py
venv/bin/python tests/test_asset_list_queries.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.
19. **Die UniFi Access Points — zuerst wieder: kommt die Zeile richtig an?**
Einen AP im Inventar aufmachen. Erwartung: Asset-OS steht als
**`UniFi AP <Modell>`** da (also `UniFi AP U6-LR`, `UniFi AP UAP-SHD`,
`UniFi AP U7-Outdoor`) und die Version als blanke Zahl (`6.7.58`). Steht
dort nur `ubiquiti UniFi` oder aehnliches, meldet euer Netdisco die
Modell-Spalte anders als angenommen — dann bitte die `deviceinventory`-Zeile
schicken (`vendor`, `model`, `os`, `version`).
20. **Und dann: dass dort NICHTS steht.** Die Flotte aus dem Screenshot (6.7.58,
6.9.1, 8.8.7) ist ueber allen bekannten Grenzen — **keine Findings ist hier
das richtige Ergebnis**. Falls ein AP mit aelterer Firmware auftreibbar ist
(<= 6.6.54): der bekommt CVE-2024-22054, unter 6.5.54 zusaetzlich
CVE-2023-38034 und CVE-2023-35085.
21. **Die Modell-Pruefung, der eigentliche Punkt bei Ubiquiti.** Falls ein
**U6+** im Bestand ist: nur der darf CVE-2024-37380 bekommen (unter 6.6.74),
und **kein** U6-LR oder U7 daneben — auch dann nicht, wenn dessen Version
niedriger ist. Und andersherum: unter Assets mit einem UniFi-**Switch**
(`USW-*`/`US-*`) oder einer UDM/UXG darf ueberhaupt kein Ubiquiti-Finding
stehen; die bekommen Asset und OS-String, mehr nicht.
22. **Die Brocade-FC-Switche.** `fcswitch3` und `fcswitch4`: Asset-OS
**`Brocade Fabric OS`**, Version `9.0.1e1`. Erwartete Findings u. a.
**CVE-2024-29954**, **CVE-2025-9711** und **CVE-2026-0383**, jeweils mit
Fix-Version. Falls ein zweiter FC-Switch auf einem neueren Stand laeuft
(9.2.1d oder hoeher): der muss bei CVE-2025-9711 **sauber** bleiben — das
ist genau der Fall, der vorher am Buchstaben gescheitert waere.
23. **Ein Brocade-ICX/FastIron**, falls vorhanden: Asset ja, OS-String der rohe
Slug (`brocade ironware` o. ae.), **keine** Findings. Wichtig ist vor allem,
dass dort keine Fabric-OS-CVE auftaucht.
24. **Der Host-Filter.** Vulnerabilities → "Select Host...": die Liste muss bis
`Z` durchlaufen, alle 1194 Hosts. Einen Host **hinter** dem Buchstaben M
auswaehlen und pruefen, dass gefiltert wird.
25. **Ladezeit.** Inventar und Dashboard einmal aufrufen und mit vorher
vergleichen. Dann:
```bash
docker compose logs --since 5m backend | grep "🐢 SLOW"
```
Erwartung: `GET /api/v1/assets` taucht dort nicht mehr auf. Was sonst noch
steht, bitte schicken — das ist die naechste Baustelle, mit Zahl statt
Vermutung.
26. **Das Dashboard baut sich schrittweise auf.** Erst der Spinner, dann die
vier Kennzahlen-Karten, dann fuellen sich die Widgets. Wichtig dabei: ein
noch ladendes Widget zeigt "Loading…", **nicht** "No data.". Steht irgendwo
dauerhaft "Loading…", ist der Request dahinter haengengeblieben — dann
wieder die SLOW-Zeilen.
27. **Das Geraet mit der neuen IP.** Netdisco-Sync laufen lassen und den AP aus
deinem Screenshot suchen: es darf **keine** dritte Zeile entstehen. Dann,
wenn im Bestand machbar, den Fall wirklich nachstellen — einem Geraet eine
neue Adresse geben, Namen und Seriennummer gleich lassen, zweimal syncen.
Erwartung: **eine** Zeile, mit der neuen IP und der neuen Firmware, Status
**ACTIVE**, und keine INACTIVE-Leiche daneben. Geht das nicht, reicht auch
ein Geraet, das nachweislich schon einmal die IP gewechselt hat.
28. **Und dass dabei nichts zusammengefallen ist.** Im selben Lauf gegenpruefen:
die beiden `RackPDU` stehen weiterhin als zwei Zeilen da, und ein Stack
steht weiterhin als eine — mit allen Seriennummern in der Beschreibung.
Das ist der Teil, der beim Umbau haette kippen koennen.
+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.
+9 -1
View File
@@ -66,7 +66,15 @@ def run_migrations_online() -> None:
with connectable.connect() as connection: with connectable.connect() as connection:
context.configure( context.configure(
connection=connection, connection=connection,
target_metadata=target_metadata target_metadata=target_metadata,
# One transaction PER migration, not one for the whole chain.
# PostgreSQL refuses to *use* an enum label in the same
# transaction that added it ("unsafe use of new value ... of
# enum type"), so any `ALTER TYPE ... ADD VALUE` in revision N
# followed by an INSERT of that label in revision N+1 fails on
# a fresh install. Per-migration transactions also make a
# failed upgrade resumable instead of all-or-nothing.
transaction_per_migration=True,
) )
with context.begin_transaction(): with context.begin_transaction():
+1 -1
View File
@@ -8,7 +8,7 @@ Adds ``vulnerabilities.nessus_vpr_score`` — Tenable's proprietary
Vulnerability Priority Rating (0–10) from the Nessus plugin payload. Vulnerability Priority Rating (0–10) from the Nessus plugin payload.
Stored alongside our own ``priority_score`` so users can compare: Stored alongside our own ``priority_score`` so users can compare:
- VulnCheck's contextual priority (CVSS + KEV + EUVD + asset policy + age) - TrueVuln's contextual priority (CVSS + KEV + EUVD + asset policy + age)
- Tenable's commercial VPR ranking - Tenable's commercial VPR ranking
Idempotent — skips the column add if it already exists. Idempotent — skips the column add if it already exists.
@@ -12,8 +12,8 @@ discarded by the override service:
Background: CISA Vulnrichment scores every CVE on three SSVC Background: CISA Vulnrichment scores every CVE on three SSVC
dimensions — Exploitation (already stored as dimensions — Exploitation (already stored as
``exploitation_status``), Technical Impact and Automatable. Tester ``exploitation_status``), Technical Impact and Automatable. Field
asked us to surface the latter two so the operator can prioritise feedback asked to surface the latter two so the operator can prioritise
"total + automatable + active" CVEs (i.e. mass-exploitable, full "total + automatable + active" CVEs (i.e. mass-exploitable, full
takeover) over "partial + no + none". takeover) over "partial + no + none".
@@ -7,7 +7,7 @@ Create Date: 2026-05-19 16:00:00.000000
Background: when Base.metadata.create_all() runs at app startup before Background: when Base.metadata.create_all() runs at app startup before
alembic upgrade (or after a failed partial migration), Postgres ends alembic upgrade (or after a failed partial migration), Postgres ends
up with tables whose id column has no DEFAULT nextval(...). Inserts up with tables whose id column has no DEFAULT nextval(...). Inserts
then fail with NotNullViolation. Tester has hit this on every deploy then fail with NotNullViolation. This was hit on every deploy
involving the 4 new Plan-D/E tables. involving the 4 new Plan-D/E tables.
This migration is idempotent and runs every time `alembic upgrade This migration is idempotent and runs every time `alembic upgrade
@@ -11,7 +11,7 @@ changed.
Result: operator filter Result: operator filter
WHERE exploitation_source='vulnrichment' AND exploitation_status != 'none' WHERE exploitation_source='vulnrichment' AND exploitation_status != 'none'
under-reported by orders of magnitude (4 vs ~840 in tester's DB). under-reported by orders of magnitude (4 vs ~840 in a live DB).
Stamp those rows as vulnrichment-sourced so the filter works. Only Stamp those rows as vulnrichment-sourced so the filter works. Only
touch rows that have at least one Vulnrichment-supplied SSVC field touch rows that have at least one Vulnrichment-supplied SSVC field
@@ -10,7 +10,7 @@ paginated FIRST in SQL then re-sorted only the current page by the
computed score — so "PRIO desc" looked correct row-by-row but pages 2+ computed score — so "PRIO desc" looked correct row-by-row but pages 2+
were not in global order. were not in global order.
Tester reported: "Spaltensortierung gilt nur teilweise für die Field report: "Spaltensortierung gilt nur teilweise für die
aktuell angezeigten CVES." aktuell angezeigten CVES."
Add two indexed columns. Forward-write paths (sync / enrich / override Add two indexed columns. Forward-write paths (sync / enrich / override
+1 -1
View File
@@ -4,7 +4,7 @@ Revision ID: 026
Revises: 025 Revises: 025
Create Date: 2026-05-31 16:00:00.000000 Create Date: 2026-05-31 16:00:00.000000
Tester request: use Wazuh IT-hygiene (open listeners / ports) as an Field request: use Wazuh IT-hygiene (open listeners / ports) as an
extra risk dimension — a host with an exposed VNC/RDP/Telnet listener extra risk dimension — a host with an exposed VNC/RDP/Telnet listener
is network-vulnerabler regardless of its CVE count. is network-vulnerabler regardless of its CVE count.
@@ -4,7 +4,7 @@ Revision ID: 027
Revises: 026 Revises: 026
Create Date: 2026-06-02 00:00:00.000000 Create Date: 2026-06-02 00:00:00.000000
Tester feedback round 2026-06-01: Field feedback round 2026-06-01:
- Sync-driven reconciliation needs to distinguish Nessus-sourced - Sync-driven reconciliation needs to distinguish Nessus-sourced
assets from Wazuh-sourced ones, so `AssetSource.NESSUS` is added. assets from Wazuh-sourced ones, so `AssetSource.NESSUS` is added.
- Audit log gains ASSET_DEACTIVATED + ASSET_REACTIVATED event types - Audit log gains ASSET_DEACTIVATED + ASSET_REACTIVATED event types
@@ -0,0 +1,92 @@
"""Reconcile legacy Nessus-sourced assets without a pinned nessus_host_uuid
Revision ID: 028
Revises: 027
Create Date: 2026-06-02 14:00:00.000000
Field feedback round 2026-06-02 (#3 INACTIVE not flipping on reduced
Nessus scan): the event-driven reconciliation in
`app.services.asset_lifecycle.reconcile_missing_from_sync` only inactivates
assets whose `nessus_host_uuid` is in `seen_ids` of a recent sync. Assets
created by older Nessus syncs that matched by IP or hostname (before the
UUID-backfill path was added) have `nessus_host_uuid IS NULL` and are
silently skipped. After a reduced scan they stay ACTIVE forever, which
contradicts the "sync-driven INACTIVE" promise.
This migration is the one-shot cleanup for the existing backlog (33 rows
in the test instance). New rows created after the 0006 commit (which
adds the diagnostic log + the `reconcile_legacy_nessus_assets` runtime
helper) are handled in code.
Idempotent: a row already INACTIVE matches the filter only when the
status check is omitted, so the body re-checks status before flipping.
Audit-logged via the same `_audit_asset_status` helper as the runtime
path so the audit trail is consistent.
Downgrade is a no-op — restoring a row to ACTIVE would require operator
intent, not a migration reversal.
"""
from alembic import op
revision = "028"
down_revision = "027"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Raw SQL on purpose: importing app.models here binds the *current*
# ORM mapping to a database that is only at revision 028, so a fresh
# install crashes on columns added by later revisions (033 added
# assets.intune_device_id). A migration may only touch the schema as
# it exists at its own revision.
#
# `source::text` / `status::text` instead of enum literals keeps this
# independent of whichever labels the enum types carry at this point.
op.execute(
"""
WITH flipped AS (
UPDATE assets
SET status = 'INACTIVE'
WHERE source::text = 'NESSUS'
AND status::text = 'ACTIVE'
AND nessus_host_uuid IS NULL
RETURNING id, hostname
)
INSERT INTO audit_logs (
user_id, event_type, event_description,
resource_type, resource_id, old_value, new_value, timestamp
)
SELECT
NULL,
'ASSET_DEACTIVATED',
left(
'Asset ' || hostname || ': status active \u2192 inactive '
|| '(legacy Nessus-sourced asset without pinned '
|| 'nessus_host_uuid \u2014 flipped by alembic migration 028 '
|| '(reconcile_legacy_nessus_assets))',
500
),
'asset',
id::text,
'active',
json_build_object(
'status', 'inactive',
'hostname', hostname,
'reason', 'legacy Nessus-sourced asset without pinned '
|| 'nessus_host_uuid \u2014 flipped by alembic '
|| 'migration 028 (reconcile_legacy_nessus_assets)'
)::text,
now()
FROM flipped;
"""
)
def downgrade() -> None:
# No-op. Restoring INACTIVE -> ACTIVE is an operator decision, not a
# migration reversal. The legacy rows can be re-activated by a fresh
# Nessus sync that reports them (event-driven revive in
# reconcile_missing_from_sync).
pass
@@ -0,0 +1,46 @@
"""Add last_modified_date to vulnerabilities (NVD lastModified)
Revision ID: 029
Revises: 028
Create Date: 2026-06-03 10:00:00.000000
Field feedback: "Newly Published" widget sorted wrong even in VIEW ALL.
Root cause — published_date was NEVER populated by any ingest path
(Nessus/Wazuh import only set detected_at), so the column was all-NULL
and the nulls-last sort produced arbitrary order.
Fix is two-part:
1) Backfill published_date from the NVD CVE API (enrichment_service).
2) Also persist the CVE's lastModified date so the UI can show
"published vs updated" — both dates were explicitly requested
("Die Infos aus den CVEs published date und updated date").
This migration only adds the new column; published_date already exists.
Idempotent — ADD COLUMN IF NOT EXISTS.
"""
from alembic import op
revision = "029"
down_revision = "028"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("""
ALTER TABLE vulnerabilities
ADD COLUMN IF NOT EXISTS last_modified_date TIMESTAMP;
""")
op.execute("""
CREATE INDEX IF NOT EXISTS ix_vulnerabilities_published_date
ON vulnerabilities (published_date);
""")
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_vulnerabilities_published_date;")
op.execute("""
ALTER TABLE vulnerabilities
DROP COLUMN IF EXISTS last_modified_date;
""")
+34
View File
@@ -0,0 +1,34 @@
"""Add remediation column to vulnerabilities (Nessus solution text)
Revision ID: 030
Revises: 029
Create Date: 2026-06-04 09:00:00.000000
Field request: Nessus scan results already carry a per-finding
remediation ("solution") text. It was only being appended into the
description blob — surface it in its own column so the CVE detail page can
render a dedicated "Remediation" section below the affected package.
Idempotent — ADD COLUMN IF NOT EXISTS.
"""
from alembic import op
revision = "030"
down_revision = "029"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("""
ALTER TABLE vulnerabilities
ADD COLUMN IF NOT EXISTS remediation TEXT;
""")
def downgrade() -> None:
op.execute("""
ALTER TABLE vulnerabilities
DROP COLUMN IF EXISTS remediation;
""")
@@ -0,0 +1,36 @@
"""Add VULNERABILITY_DETECTED audit event type
Revision ID: 031
Revises: 030
Create Date: 2026-06-09 10:00:00.000000
Reported: a CVE newly created by a Wazuh/Nessus sync appeared in the vuln
list but had NO initial audit event ("new CVE detected on asset X") — the
audit trail started with the first status change. Not revisionssicher.
This adds the enum value; the sync paths now write one
VULNERABILITY_DETECTED event per newly created finding.
ALTER TYPE ... ADD VALUE cannot run inside a transaction block on older
Postgres; alembic's autocommit_block handles that.
Idempotent — IF NOT EXISTS.
"""
from alembic import op
revision = "031"
down_revision = "030"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.get_context().autocommit_block():
op.execute(
"ALTER TYPE auditeventtype ADD VALUE IF NOT EXISTS 'VULNERABILITY_DETECTED'"
)
def downgrade() -> None:
# Postgres cannot remove enum values; harmless to leave in place.
pass
@@ -0,0 +1,48 @@
"""Add cve_remediations table (multi-source enrichment)
Revision ID: 032
Revises: 031
Create Date: 2026-06-10 09:00:00.000000
Field request: enrich remediation coverage beyond the Nessus scanner
solution. External primary sources (MSRC CVRF for Windows + MS products,
later Ubuntu USN / CentOS errata for Linux) provide per-CVE fixes (KB +
fixed build + download URL), workarounds, and mitigations/containment for
cases where no patch/KB exists yet.
These are CVE-level (not per-asset-row), so they live in their own table
keyed by cve_id; the existing vulnerabilities.remediation column (the
scanner solution) is unchanged and shown alongside.
Idempotent — CREATE TABLE / INDEX IF NOT EXISTS.
"""
from alembic import op
revision = "032"
down_revision = "031"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("""
CREATE TABLE IF NOT EXISTS cve_remediations (
id SERIAL PRIMARY KEY,
cve_id VARCHAR(50) NOT NULL,
source VARCHAR(32) NOT NULL, -- msrc | ubuntu | centos | ...
kind VARCHAR(24) NOT NULL, -- fix | workaround | mitigation | advisory
title VARCHAR(300),
detail TEXT,
kb VARCHAR(64),
fixed_build VARCHAR(120),
url TEXT,
fetched_at TIMESTAMP NOT NULL DEFAULT now()
);
""")
op.execute("CREATE INDEX IF NOT EXISTS ix_cve_remediations_cve_id ON cve_remediations (cve_id);")
op.execute("CREATE INDEX IF NOT EXISTS ix_cve_remediations_cve_src ON cve_remediations (cve_id, source);")
def downgrade() -> None:
op.execute("DROP TABLE IF EXISTS cve_remediations;")
+42
View File
@@ -0,0 +1,42 @@
"""Add INTUNE asset source + intune_device_id / defender_machine_id
Revision ID: 033
Revises: 032
Create Date: 2026-06-14 09:00:00.000000
Microsoft Intune (MDM/UEM) as a third inventory source next to Wazuh and
Nessus. Adds the enum label + the pin columns used to reconnect an asset to
its Intune managedDevice and (phase 3) its Defender for Endpoint machine.
ALTER TYPE ... ADD VALUE cannot run inside a transaction block on older
Postgres → autocommit_block. Idempotent (IF NOT EXISTS).
"""
from alembic import op
revision = "033"
down_revision = "032"
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 'INTUNE'")
op.execute("""
ALTER TABLE assets
ADD COLUMN IF NOT EXISTS intune_device_id VARCHAR(64),
ADD COLUMN IF NOT EXISTS defender_machine_id VARCHAR(64);
""")
op.execute("CREATE INDEX IF NOT EXISTS ix_assets_intune_device_id ON assets (intune_device_id);")
op.execute("CREATE INDEX IF NOT EXISTS ix_assets_defender_machine_id ON assets (defender_machine_id);")
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_assets_intune_device_id;")
op.execute("DROP INDEX IF EXISTS ix_assets_defender_machine_id;")
op.execute("""
ALTER TABLE assets
DROP COLUMN IF EXISTS intune_device_id,
DROP COLUMN IF EXISTS defender_machine_id;
""")
@@ -0,0 +1,43 @@
"""Add asset risk-dimension (high-value-target) columns
Revision ID: 034
Revises: 033
Create Date: 2026-06-16 09:00:00.000000
"Risk Dimensions": crown-jewel role detection (Domain Controller, ADCS,
SQL, Exchange, WSUS, backup, ...) computed alongside network exposure and
fed into the URS. Stores a 0-100 high_value_score + the detected roles.
Idempotent — ADD COLUMN IF NOT EXISTS.
"""
from alembic import op
revision = "034"
down_revision = "033"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("""
ALTER TABLE assets
ADD COLUMN IF NOT EXISTS high_value_score DOUBLE PRECISION,
ADD COLUMN IF NOT EXISTS risk_dimensions TEXT,
ADD COLUMN IF NOT EXISTS risk_dimensions_updated_at TIMESTAMP;
""")
op.execute("""
CREATE INDEX IF NOT EXISTS ix_assets_high_value_score
ON assets (high_value_score)
WHERE high_value_score > 0;
""")
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_assets_high_value_score;")
op.execute("""
ALTER TABLE assets
DROP COLUMN IF EXISTS high_value_score,
DROP COLUMN IF EXISTS risk_dimensions,
DROP COLUMN IF EXISTS risk_dimensions_updated_at;
""")
+40
View File
@@ -0,0 +1,40 @@
"""Add app_cve_cache for the built-in app→CVE scanner
Revision ID: 035
Revises: 034
Create Date: 2026-06-19 09:00:00.000000
Caches (product, version) → CVE lookups from OSV / NVD-CPE so the built-in
scanner doesn't re-query the same Chrome/Firefox/... version for every host
(and stays under NVD's rate limit). CVE↔version is stable; refreshed on a
TTL.
Idempotent.
"""
from alembic import op
revision = "035"
down_revision = "034"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("""
CREATE TABLE IF NOT EXISTS app_cve_cache (
id SERIAL PRIMARY KEY,
product_key VARCHAR(160) NOT NULL, -- cpe:a:google:chrome | osv:npm:lodash
version VARCHAR(120) NOT NULL,
cves TEXT, -- JSON [{cve,cvss,severity,fixed}]
fetched_at TIMESTAMP NOT NULL DEFAULT now()
);
""")
op.execute("""
CREATE UNIQUE INDEX IF NOT EXISTS uq_app_cve_cache_key_ver
ON app_cve_cache (product_key, version);
""")
def downgrade() -> None:
op.execute("DROP TABLE IF EXISTS app_cve_cache;")
+32
View File
@@ -0,0 +1,32 @@
"""Add assets.aad_device_id for cross-source (Intune ↔ Defender) merge
Revision ID: 036
Revises: 035
Create Date: 2026-07-09 10:00:00.000000
The same physical device has different per-service ids (intune_device_id vs
defender_machine_id), so Intune and Defender could only merge by hostname —
which fails when the names differ (e.g. Defender's computerDnsName is the
Intune management name). The Entra/AAD device id is the stable cross-service
anchor (Intune `azureADDeviceId` == Defender `aadDeviceId`); store + match on
it so one physical device is one asset regardless of name/rename.
Idempotent.
"""
from alembic import op
revision = "036"
down_revision = "035"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("ALTER TABLE assets ADD COLUMN IF NOT EXISTS aad_device_id VARCHAR(64);")
op.execute("CREATE INDEX IF NOT EXISTS ix_assets_aad_device_id ON assets (aad_device_id);")
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_assets_aad_device_id;")
op.execute("ALTER TABLE assets DROP COLUMN IF EXISTS aad_device_id;")
+31
View File
@@ -0,0 +1,31 @@
"""Add users.locked + locked_at for permanent account lockout
Revision ID: 037
Revises: 036
Create Date: 2026-07-13 12:00:00.000000
Permanent-lockout mode (opt-in via the auth_lockout_permanent setting): after
the failed-attempt threshold an account is locked until an admin clears it
(post-incident), instead of the temporary 15-minute auto-unlock. The last
active admin is never permanently locked (recoverable via the temporary path)
so the system can't be fully locked out.
Idempotent.
"""
from alembic import op
revision = "037"
down_revision = "036"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("ALTER TABLE users ADD COLUMN IF NOT EXISTS locked BOOLEAN NOT NULL DEFAULT FALSE;")
op.execute("ALTER TABLE users ADD COLUMN IF NOT EXISTS locked_at TIMESTAMP NULL;")
def downgrade() -> None:
op.execute("ALTER TABLE users DROP COLUMN IF EXISTS locked_at;")
op.execute("ALTER TABLE users DROP COLUMN IF EXISTS locked;")
@@ -0,0 +1,28 @@
"""Add vulnerabilities.package_vendor
Revision ID: 038
Revises: 037
Create Date: 2026-07-23 12:00:00.000000
Vendor/publisher of the affected software, now that it is actually read from
the sources that carry it: Wazuh syscollector (vendor), Intune detectedApps
(publisher), Defender TVM (softwareVendor). Display-only; helps disambiguate
same-named products from different vendors.
Idempotent.
"""
from alembic import op
revision = "038"
down_revision = "037"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("ALTER TABLE vulnerabilities ADD COLUMN IF NOT EXISTS package_vendor VARCHAR(255) NULL;")
def downgrade() -> None:
op.execute("ALTER TABLE vulnerabilities DROP COLUMN IF EXISTS package_vendor;")
@@ -0,0 +1,26 @@
"""Widen vulnerability_packages.source for multi-scanner provenance
Revision ID: 039
Revises: 038
Create Date: 2026-07-28 09:00:00.000000
A package row now records EVERY scanner that confirmed it ("app-scan,msrc"),
not just the one that wrote first. VARCHAR(20) could not hold three names.
Idempotent.
"""
from alembic import op
revision = "039"
down_revision = "038"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("ALTER TABLE vulnerability_packages ALTER COLUMN source TYPE VARCHAR(60);")
def downgrade() -> None:
op.execute("ALTER TABLE vulnerability_packages ALTER COLUMN source TYPE VARCHAR(20);")
+48
View File
@@ -0,0 +1,48 @@
"""Give the two remaining vulnerability FKs a delete rule.
Deleting an asset cascades into its findings, but notification_logs and
ai_analyses pointed at vulnerabilities with no ON DELETE rule, so PostgreSQL
refused the whole delete:
ForeignKeyViolation: update or delete on table "vulnerabilities" violates
foreign key constraint "notification_logs_vulnerability_id_fkey"
An asset was therefore deletable only until something had been mailed about
one of its findings — which is why some deleted fine and others returned 500.
The two get different rules on purpose. A notification log records that a mail
WAS sent; that stays true after the finding is gone, so the reference is
cleared and the row kept (the column is already nullable). An AI analysis of a
finding that no longer exists says nothing, and its column is NOT NULL, so it
goes with it.
Revision ID: 040
Revises: 039
"""
from alembic import op
revision = "040"
down_revision = "039"
branch_labels = None
depends_on = None
_NOTIF = "notification_logs_vulnerability_id_fkey"
_AI = "ai_analyses_vulnerability_id_fkey"
def upgrade():
op.drop_constraint(_NOTIF, "notification_logs", type_="foreignkey")
op.create_foreign_key(_NOTIF, "notification_logs", "vulnerabilities",
["vulnerability_id"], ["id"], ondelete="SET NULL")
op.drop_constraint(_AI, "ai_analyses", type_="foreignkey")
op.create_foreign_key(_AI, "ai_analyses", "vulnerabilities",
["vulnerability_id"], ["id"], ondelete="CASCADE")
def downgrade():
op.drop_constraint(_NOTIF, "notification_logs", type_="foreignkey")
op.create_foreign_key(_NOTIF, "notification_logs", "vulnerabilities",
["vulnerability_id"], ["id"])
op.drop_constraint(_AI, "ai_analyses", type_="foreignkey")
op.create_foreign_key(_AI, "ai_analyses", "vulnerabilities",
["vulnerability_id"], ["id"])
+54
View File
@@ -0,0 +1,54 @@
"""Give the remaining asset FKs a delete rule.
Migration 040 fixed the two constraints pointing at vulnerabilities, which was
only half the chain. Deleting an asset then got one step further and failed on
the next one:
ForeignKeyViolation: update or delete on table "assets" violates foreign
key constraint "notification_logs_asset_id_fkey"
Three tables reference assets with no ON DELETE rule. Each gets the rule that
fits it: a notification log records that a mail WAS sent and stays (reference
cleared, column is already nullable); a scan and a finding belong to their
asset, cannot outlive it, and are NOT NULL, so they go with it.
Revision ID: 041
Revises: 040
"""
from alembic import op
revision = "041"
down_revision = "040"
branch_labels = None
depends_on = None
# (constraint, table, ondelete)
_FKS = [
("notification_logs_asset_id_fkey", "notification_logs", "SET NULL"),
("scans_asset_id_fkey", "scans", "CASCADE"),
("vulnerabilities_asset_id_fkey", "vulnerabilities", "CASCADE"),
# SQLAlchemy clears the association rows itself, so this one never failed —
# included so the database does not rely on it.
("asset_groups_asset_id_fkey", "asset_groups", "CASCADE"),
]
def upgrade():
for name, table, rule in _FKS:
# The constraint name is the PostgreSQL default; tolerate a database
# where it was created differently rather than aborting the upgrade.
try:
op.drop_constraint(name, table, type_="foreignkey")
except Exception:
continue
op.create_foreign_key(name, table, "assets", ["asset_id"], ["id"],
ondelete=rule)
def downgrade():
for name, table, _rule in _FKS:
try:
op.drop_constraint(name, table, type_="foreignkey")
except Exception:
continue
op.create_foreign_key(name, table, "assets", ["asset_id"], ["id"])
@@ -0,0 +1,32 @@
"""Widen assets.wazuh_agent_id for manager-node ids.
The column held a numeric agent id ("042") and was sized accordingly. Manager
nodes have no agent id, so they are stored as "node:<name>" — already 11
characters for "node:master", and node names are free-form. Inserting one
failed the length check and the whole Wazuh sync returned 500.
Revision ID: 042
Revises: 041
"""
from alembic import op
import sqlalchemy as sa
revision = "042"
down_revision = "041"
branch_labels = None
depends_on = None
def upgrade():
op.alter_column("assets", "wazuh_agent_id",
existing_type=sa.String(10), type_=sa.String(64),
existing_nullable=True)
def downgrade():
# Node rows would not fit back into 10 characters; drop them first so the
# narrowing cannot fail on data this migration itself introduced.
op.execute("DELETE FROM assets WHERE wazuh_agent_id LIKE 'node:%'")
op.alter_column("assets", "wazuh_agent_id",
existing_type=sa.String(64), type_=sa.String(10),
existing_nullable=True)
@@ -0,0 +1,33 @@
"""Add KEV_ALERT notification type
Revision ID: 043
Revises: 042
Create Date: 2026-08-15 10:00:00.000000
Immediate alerting for actively-exploited CVEs (CISA KEV / ENISA EUVD) that
have open findings on our own active assets. Its own notification type so the
notification log can be filtered for the alerts that meant "act now" rather
than mixing them into the nightly new-vulnerability stream.
ALTER TYPE ... ADD VALUE cannot run inside a transaction block on older
Postgres → autocommit_block. Idempotent (IF NOT EXISTS).
"""
from alembic import op
revision = "043"
down_revision = "042"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.get_context().autocommit_block():
op.execute("ALTER TYPE notificationtype ADD VALUE IF NOT EXISTS 'KEV_ALERT'")
def downgrade() -> None:
# Postgres cannot drop an enum label. Rows carrying it would have to be
# rewritten first, and losing the record that an alert was sent is worse
# than an unused label — so this is deliberately a no-op.
pass
@@ -0,0 +1,36 @@
"""Widen the asset inactivity window from 30 to 42 days (six weeks)
Revision ID: 044
Revises: 043
Create Date: 2026-08-17 10:00:00.000000
The window now governs the Wazuh sync too: a disconnected agent keeps its
findings visible until the window closes, instead of losing them at the next
sync. Four weeks is too tight for a three-week absence plus a weekend, so the
default moves to six.
Only rows still holding the old default '30' are rewritten — an operator who
deliberately set another value keeps it. Assets whose stored settings row does
not exist at all pick up the new code default (asset_lifecycle._DEFAULT_DAYS).
"""
from alembic import op
revision = "044"
down_revision = "043"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute(
"UPDATE settings SET value = '42' "
"WHERE key = 'asset_inactive_after_days' AND trim(value) = '30'"
)
def downgrade() -> None:
op.execute(
"UPDATE settings SET value = '30' "
"WHERE key = 'asset_inactive_after_days' AND trim(value) = '42'"
)
@@ -0,0 +1,46 @@
"""Add VCENTER asset source + vmware_uuid / vmware_build
Revision ID: 045
Revises: 044
Create Date: 2026-08-17 10:00:00.000000
VMware vCenter as a fourth inventory source next to Wazuh, Nessus and Intune.
It registers the vCenter appliance itself AND every ESXi host it manages —
central infrastructure that carried no CVE detection at all until now.
`vmware_uuid` pins the asset to its ESXi hardware UUID (or the vCenter
instanceUuid) so renames and re-adds reconnect deterministically.
`vmware_build` stores the VMkernel / vpxd build, which is the only quantity
VMware's advisories and a live host state comparably (see
vmware_release_service).
ALTER TYPE ... ADD VALUE cannot run inside a transaction block on older
Postgres → autocommit_block. Idempotent (IF NOT EXISTS).
"""
from alembic import op
revision = "045"
down_revision = "044"
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 'VCENTER'")
op.execute("""
ALTER TABLE assets
ADD COLUMN IF NOT EXISTS vmware_uuid VARCHAR(64),
ADD COLUMN IF NOT EXISTS vmware_build VARCHAR(32);
""")
op.execute("CREATE INDEX IF NOT EXISTS ix_assets_vmware_uuid ON assets (vmware_uuid);")
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_assets_vmware_uuid;")
op.execute("""
ALTER TABLE assets
DROP COLUMN IF EXISTS vmware_uuid,
DROP COLUMN IF EXISTS vmware_build;
""")
@@ -0,0 +1,39 @@
"""Clear the placeholder Entra device id off assets
Revision ID: 046
Revises: 045
Create Date: 2026-08-19 10:00:00.000000
Graph reports an all-zero azureADDeviceId for every enrolment with no Entra
device object of its own — personally-owned / userless mobile devices. It is a
placeholder, not an id, and the Intune sync stored it in `aad_device_id` and
then matched on it: every such device found the first asset carrying the
placeholder, so a whole class of devices collapsed onto ONE asset record. The
sync re-pinned that record to a different device on every pass (logged as a
re-enrolment), and the devices that lost the race were missing from the
inventory entirely.
The sync no longer stores or matches on it. This clears what is already in the
table, so the next sync stops colliding and re-creates the missing assets.
"""
from alembic import op
revision = "046"
down_revision = "045"
branch_labels = None
depends_on = None
_PLACEHOLDER = "00000000-0000-0000-0000-000000000000"
def upgrade() -> None:
op.execute(
f"UPDATE assets SET aad_device_id = NULL "
f"WHERE lower(aad_device_id) = '{_PLACEHOLDER}'"
)
def downgrade() -> None:
# The placeholder carried no information — nothing to restore.
pass
@@ -0,0 +1,52 @@
"""Add VulnCheck KEV to the immediate-alert sources
Revision ID: 047
Revises: 046
Create Date: 2026-08-29 12:00:00.000000
VulnCheck KEV shipped as a third exploited-catalog source (f77c741) and the
dashboard reads it, but the alert mail did not: `kev_alert_sources` was seeded
"cisa,euvd" on the reasoning that a ~4x larger catalog should be an opt-in.
That reasoning does not survive contact with what the alert actually gates on
— an OPEN finding on an ACTIVE asset. Catalog size does not decide a mail; our
own inventory does. And VulnCheck typically lists a CVE days before CISA (we
already compute `lead_days` from it), which is exactly the window an immediate
alert exists for.
The seed is now all three. This rewrites the value on installs that still
carry the untouched old default. A value an operator changed to anything else
is left alone — this is a default correction, not a policy override.
Note for whoever reads this after the first run: the mails that follow are a
backlog, not a storm. They are capped at MAX_ALERTS_PER_MAIL (25) per run and
the job is hourly, so a large first batch drains over a few hours.
"""
from alembic import op
revision = "047"
down_revision = "046"
branch_labels = None
depends_on = None
_OLD = "cisa,euvd"
_NEW = "cisa,euvd,vulncheck"
def upgrade() -> None:
# Tolerate the spacing a hand-edited value may carry ("cisa, euvd").
op.execute(
"UPDATE settings SET value = '%s' "
"WHERE key = 'kev_alert_sources' "
"AND replace(replace(lower(value), ' ', ''), '\"', '') = '%s'"
% (_NEW, _OLD)
)
def downgrade() -> None:
op.execute(
"UPDATE settings SET value = '%s' "
"WHERE key = 'kev_alert_sources' "
"AND replace(replace(lower(value), ' ', ''), '\"', '') = '%s'"
% (_OLD, _NEW)
)
+45
View File
@@ -0,0 +1,45 @@
"""Add IGEL asset source + igel_unit_id
Revision ID: 048
Revises: 047
Create Date: 2026-08-31 12:00:00.000000
IGEL UMS as a fifth inventory source next to Wazuh, Nessus, Intune and
vCenter. It registers the UMS server itself AND every endpoint device it
manages — a thin-client estate that carried no CVE detection at all until now:
an IGEL OS device runs no Wazuh agent, Intune does not know it, and Nessus
sees only an open port.
`igel_unit_id` pins the asset to the device's UMS unit ID (the MAC-derived
identifier UMS keys everything on, stable across renames, re-registrations and
even a UMS restore) or, for the server row, to its `serverUUID`.
The firmware version needs no new column: it IS the OS version, so it lands in
`os_version` like every other OS, and the IGEL CVE bounds are ordinary version
ranges (12.0.0 < 12.7.6) — no build-number detour as with vSphere.
ALTER TYPE ... ADD VALUE cannot run inside a transaction block on older
Postgres → autocommit_block. Idempotent (IF NOT EXISTS).
"""
from alembic import op
revision = "048"
down_revision = "047"
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 'IGEL'")
op.execute("""
ALTER TABLE assets
ADD COLUMN IF NOT EXISTS igel_unit_id VARCHAR(64);
""")
op.execute("CREATE INDEX IF NOT EXISTS ix_assets_igel_unit_id ON assets (igel_unit_id);")
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_assets_igel_unit_id;")
op.execute("ALTER TABLE assets DROP COLUMN IF EXISTS igel_unit_id;")
@@ -0,0 +1,37 @@
"""Add assets.last_seen_source — who stamped last_scan/last_seen
Revision ID: 049
Revises: 048
Create Date: 2026-08-31 15:00:00.000000
The nightly time-based reconcile revives an INACTIVE asset whose stamps are
back inside the grace window, and could only report that as "seen again by a
source sync". For a device that lives in two Microsoft sources at once —
deleted in Intune, still reported by Defender TVM under a different name —
that line names nothing an operator can act on (field report 2026-08-30).
Every sync that stamps `last_scan`/`last_seen` now also records itself here,
and the reactivation audit entry quotes it. NULL on rows stamped before this
migration; the audit reason says "unknown source" rather than guessing.
No backfill: `assets.source` is the CREATING source, not the stamping one —
copying it would put a confident wrong name in a revisionssicher log.
"""
from alembic import op
revision = "049"
down_revision = "048"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("""
ALTER TABLE assets
ADD COLUMN IF NOT EXISTS last_seen_source VARCHAR(32);
""")
def downgrade() -> None:
op.execute("ALTER TABLE assets DROP COLUMN IF EXISTS last_seen_source;")
+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 053–055.
"""
from alembic import op
revision = "056"
down_revision = "055"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("ALTER TABLE assets ADD COLUMN IF NOT EXISTS vmware_serial VARCHAR(64);")
def downgrade() -> None:
op.execute("ALTER TABLE assets DROP COLUMN IF EXISTS vmware_serial;")
@@ -0,0 +1,73 @@
"""Repair Defender's OS placeholder on application CVEs
Revision ID: 057
Revises: 056
Create Date: 2026-09-15 12:00:00.000000
Until d708a04 Defender labelled a CVE with the asset's OS and build whenever
its software export had not caught up yet, so Chrome and Edge CVEs read
"Microsoft Windows" / 10.0.26100.x (CVE-2026-87474). The sync now replaces
that on rows Defender still reports; this repairs the rest, meaning rows
Defender dropped, rows MSRC took over, and closed rows.
Only rows Defender created whose package is still the asset's OS string:
1. A package row another scanner recorded names the real product → take the
earliest one's name and version.
2. Otherwise, if the fix is a dotted version whose major differs from the
placeholder build (153.x against 10.0.x), the placeholder cannot be this
CVE's product. Clear name and version: unknown rather than wrong.
A Windows CVE fixed in 10.0.x or an iOS CVE at 18.x keeps its label.
Data only, not reversible. The downgrade is a no-op.
"""
import re
from alembic import op
from sqlalchemy import text
revision = "057"
down_revision = "056"
branch_labels = None
depends_on = None
_DOTTED = re.compile(r"^\d+(\.\d+)+$")
def _major(v):
return v.split(".")[0] if v and _DOTTED.match(v) else None
def repair(conn) -> int:
rows = conn.execute(text("""
SELECT v.id, v.package_version, v.fixed_version
FROM vulnerabilities v JOIN assets a ON a.id = v.asset_id
WHERE v.first_detected_by = 'defender'
AND a.operating_system IS NOT NULL AND a.operating_system <> ''
AND v.package_name = a.operating_system
""")).fetchall()
fixed = 0
for vid, pkg_ver, fix_ver in rows:
child = conn.execute(text("""
SELECT package_name, package_version FROM vulnerability_packages
WHERE vulnerability_id = :id ORDER BY first_detected_at, id LIMIT 1
"""), {"id": vid}).fetchone()
if child:
conn.execute(text("UPDATE vulnerabilities SET package_name = :n, "
"package_version = :ver WHERE id = :id"),
{"n": child[0], "ver": child[1], "id": vid})
elif _major(fix_ver) and _major(pkg_ver) and _major(fix_ver) != _major(pkg_ver):
conn.execute(text("UPDATE vulnerabilities SET package_name = NULL, "
"package_version = NULL WHERE id = :id"), {"id": vid})
else:
continue
fixed += 1
return fixed
def upgrade() -> None:
repair(op.get_bind())
def downgrade() -> None:
pass
@@ -0,0 +1,29 @@
"""Add vulnerabilities.vendor_severity — the vendors' own ratings
Revision ID: 058
Revises: 057
Create Date: 2026-09-15 10:00:00.000000
JSON {"Microsoft": "Critical", "TeamViewer": "Important", ...}. Vendors rate
by their own scale, which can differ from the CVSS band the severity column
follows: CVE-2026-85921 is CVSS 8.2 (High), Critical at MSRC. The detail view
shows a rating only where it disagrees.
No backfill: the next MSRC ingest (nightly 2 months, Sunday 18 months), app
scan and mobile check stamp it. Idempotent (IF NOT EXISTS), same shape as 053–056.
"""
from alembic import op
revision = "058"
down_revision = "057"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("ALTER TABLE vulnerabilities ADD COLUMN IF NOT EXISTS vendor_severity TEXT;")
def downgrade() -> None:
op.execute("ALTER TABLE vulnerabilities DROP COLUMN IF EXISTS vendor_severity;")
@@ -0,0 +1,36 @@
"""Add CONTAINER asset source + vulnerabilities.baseline
Revision ID: 059
Revises: 058
Create Date: 2026-09-17 09:00:00.000000
Container images scanned by Trivy or Grype on a Docker host and pushed to
POST /api/v1/vulnerabilities/sync/container. One asset per image (repo:tag),
see container_scan_service. The image name is the identity, so no pin column.
`vulnerabilities.baseline` marks findings from a repository's first report,
which send no new-finding mail. Its own column, not notification_suppressed:
that is the operator's mute and would also silence SLA mails for good.
ALTER TYPE ... ADD VALUE cannot run inside a transaction block on older
Postgres → autocommit_block. Idempotent (IF NOT EXISTS). Postgres cannot drop
an enum value, so downgrade only drops the column.
"""
from alembic import op
revision = "059"
down_revision = "058"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.get_context().autocommit_block():
op.execute("ALTER TYPE assetsource ADD VALUE IF NOT EXISTS 'CONTAINER'")
op.execute("ALTER TABLE vulnerabilities "
"ADD COLUMN IF NOT EXISTS baseline BOOLEAN NOT NULL DEFAULT false;")
def downgrade() -> None:
op.execute("ALTER TABLE vulnerabilities DROP COLUMN IF EXISTS baseline;")
@@ -0,0 +1,40 @@
"""Container image → Docker host asset
Revision ID: 060
Revises: 059
Create Date: 2026-09-18 12:00:00.000000
The Dockhand sync keeps one CONTAINER asset per image; which host runs it was
only text in the description. This table links an image to the host asset
(the Wazuh / Nessus / vCenter record of the Docker host) whose hostname the
Docker host reports, so the host shows its images and the image its host.
Rewritten by every Dockhand sync; both sides cascade on asset delete.
"""
from alembic import op
import sqlalchemy as sa
revision = "060"
down_revision = "059"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"container_placements",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("image_asset_id", sa.Integer(),
sa.ForeignKey("assets.id", ondelete="CASCADE"), nullable=False),
sa.Column("host_asset_id", sa.Integer(),
sa.ForeignKey("assets.id", ondelete="CASCADE"), nullable=False),
sa.Column("containers", sa.Text(), nullable=True),
)
op.create_index("ix_container_placements_image_asset_id", "container_placements",
["image_asset_id"])
op.create_index("ix_container_placements_host_asset_id", "container_placements",
["host_asset_id"])
def downgrade() -> None:
op.drop_table("container_placements")
@@ -0,0 +1,28 @@
"""Add vulnerabilities.jev_config_dependent + jev_checked_at
Revision ID: 061
Revises: 060
Create Date: 2026-09-19 10:00:00.000000
Jev's answer to "only exploitable with a non-default configuration?", per CVE
text. An operator hint on the patch plan; it never closes a finding and never
enters priority_score. No backfill: the nightly Jev job fills it when enabled.
Idempotent (IF NOT EXISTS), same shape as 053–058.
"""
from alembic import op
revision = "061"
down_revision = "060"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("ALTER TABLE vulnerabilities ADD COLUMN IF NOT EXISTS jev_config_dependent DOUBLE PRECISION;")
op.execute("ALTER TABLE vulnerabilities ADD COLUMN IF NOT EXISTS jev_checked_at TIMESTAMP;")
def downgrade() -> None:
op.execute("ALTER TABLE vulnerabilities DROP COLUMN IF EXISTS jev_checked_at;")
op.execute("ALTER TABLE vulnerabilities DROP COLUMN IF EXISTS jev_config_dependent;")
@@ -0,0 +1,71 @@
"""Repair Defender's OS placeholder once more
Revision ID: 062
Revises: 061
Create Date: 2026-10-08 12:00:00.000000
057 repaired the rows d708a04 left behind. Two kinds arrived since:
- A Windows client with no row in the software export yet read as "not
covered", and its Chrome CVEs got the asset's OS, now "Microsoft Windows 11"
(CVE-2026-106269). The sync no longer does that; this repairs those rows.
- Since 2f6334e the Intune sync writes "Microsoft Windows 11"/"10" to the
asset, so an old "Microsoft Windows" placeholder no longer equals the
asset's OS and neither 057 nor the sync's heal reaches it.
Same rules as 057: a package row from another scanner wins, otherwise a fix
whose major differs from the placeholder build clears the label. Data only;
the downgrade is a no-op.
"""
import re
from alembic import op
from sqlalchemy import text
revision = "062"
down_revision = "061"
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
OR (v.package_name = 'Microsoft Windows'
AND a.operating_system LIKE 'Microsoft Windows %'))
""")).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,28 @@
"""Add assets.wazuh_last_seen + intune_last_seen
Revision ID: 063
Revises: 062
Create Date: 2026-10-16 10:00:00.000000
Per-inventory freshness for the software-inventory precedence of a host in both
Wazuh and Intune (audit_events.asset_inventory). No backfill: the next Wazuh and
Intune syncs fill them, and a row without both keeps the Wazuh precedence.
Idempotent (IF NOT EXISTS), same shape as 053–061.
"""
from alembic import op
revision = "063"
down_revision = "062"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("ALTER TABLE assets ADD COLUMN IF NOT EXISTS wazuh_last_seen TIMESTAMP;")
op.execute("ALTER TABLE assets ADD COLUMN IF NOT EXISTS intune_last_seen TIMESTAMP;")
def downgrade() -> None:
op.execute("ALTER TABLE assets DROP COLUMN IF EXISTS intune_last_seen;")
op.execute("ALTER TABLE assets DROP COLUMN IF EXISTS wazuh_last_seen;")
@@ -0,0 +1,33 @@
"""Add XENSERVER asset source + xen_host_uuid
Revision ID: 064
Revises: 063
Create Date: 2026-10-23 10:00:00.000000
XenServer pools as an inventory source: the pool coordinator's XAPI lists every
host, and its `/updates` endpoint lists, per host, the updates still pending —
whose security entries name their CVEs. `xen_host_uuid` pins the asset to the
host UUID XAPI keys on, which a rename in XenCenter leaves alone.
ALTER TYPE ... ADD VALUE cannot run inside a transaction block on older
Postgres → autocommit_block. Idempotent (IF NOT EXISTS).
"""
from alembic import op
revision = "064"
down_revision = "063"
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 'XENSERVER'")
op.execute("ALTER TABLE assets ADD COLUMN IF NOT EXISTS xen_host_uuid VARCHAR(64);")
op.execute("CREATE INDEX IF NOT EXISTS ix_assets_xen_host_uuid ON assets (xen_host_uuid);")
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_assets_xen_host_uuid;")
op.execute("ALTER TABLE assets DROP COLUMN IF EXISTS xen_host_uuid;")
@@ -0,0 +1,31 @@
"""Add audit_logs.username
Revision ID: 065
Revises: 064
Create Date: 2026-10-26 10:00:00.000000
delete_user nulls audit_logs.user_id so the trail survives, but that turned
every row the user wrote into "System/Auto". The actor's name is now stamped
on insert (audit_log._stamp_actor); the backfill names every row whose user
still exists. Rows of users deleted before this revision stay unnamed.
Idempotent (IF NOT EXISTS), same shape as 053–064.
"""
from alembic import op
revision = "065"
down_revision = "064"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("ALTER TABLE audit_logs ADD COLUMN IF NOT EXISTS username VARCHAR(50);")
op.execute(
"UPDATE audit_logs SET username = users.username FROM users "
"WHERE audit_logs.user_id = users.id AND audit_logs.username IS NULL;"
)
def downgrade() -> None:
op.execute("ALTER TABLE audit_logs DROP COLUMN IF EXISTS username;")
@@ -0,0 +1,27 @@
"""Add assets.wazuh_registered_at
Revision ID: 066
Revises: 065
Create Date: 2026-11-01 10:00:00.000000
The Wazuh agent's `dateAdd`. Agents that re-enroll on a fresh Wazuh server
get ids from 001 again; without the registration date the asset sync cannot
tell the new 001 from the old one (asset_lifecycle.unlink_reused_wazuh_id).
Filled by the next Wazuh sync. Idempotent (IF NOT EXISTS), same shape as
053–065.
"""
from alembic import op
revision = "066"
down_revision = "065"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("ALTER TABLE assets ADD COLUMN IF NOT EXISTS wazuh_registered_at TIMESTAMP;")
def downgrade() -> None:
op.execute("ALTER TABLE assets DROP COLUMN IF EXISTS wazuh_registered_at;")
+59 -3
View File
@@ -10,14 +10,14 @@ from sqlalchemy.orm import Session
from app.database import get_db from app.database import get_db
from app.models.user import User, UserRole from app.models.user import User, UserRole
from app.auth.jwt_handler import decode_token from app.auth.jwt_handler import decode_token, TOKEN_TYPE_ACCESS
from app.auth.token_revocation import is_revoked from app.auth.token_revocation import is_revoked
# Security Scheme für Swagger UI # Security Scheme für Swagger UI
security = HTTPBearer(auto_error=False) security = HTTPBearer(auto_error=False)
async def get_current_user( def get_current_user(
request: Request, request: Request,
credentials: HTTPAuthorizationCredentials = Depends(security), credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db) db: Session = Depends(get_db)
@@ -55,6 +55,14 @@ async def get_current_user(
if payload is None: if payload is None:
raise credentials_exception raise credentials_exception
# Alle Tokens tragen dieselbe Signatur — ohne diese Prüfung ist ein
# Refresh-Token (7 Tage) oder ein MFA-Challenge-Token (ausgestellt VOR dem
# zweiten Faktor) eine vollwertige Session. Positiv allowlisten, nicht die
# Challenge-Claims blocklisten: ein neuer Token-Typ ist dann per Default
# abgelehnt statt per Default gültig.
if payload.get("type") != TOKEN_TYPE_ACCESS:
raise credentials_exception
jti = payload.get("jti") jti = payload.get("jti")
if jti and is_revoked(jti): if jti and is_revoked(jti):
raise credentials_exception raise credentials_exception
@@ -79,7 +87,7 @@ async def get_current_user(
return user return user
async def get_current_active_user( def get_current_active_user(
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
) -> User: ) -> User:
""" """
@@ -148,3 +156,51 @@ class PermissionChecker:
RequireAdmin = PermissionChecker(UserRole.ADMIN) RequireAdmin = PermissionChecker(UserRole.ADMIN)
RequireEditor = PermissionChecker(UserRole.EDITOR) RequireEditor = PermissionChecker(UserRole.EDITOR)
RequireReadonly = PermissionChecker(UserRole.READONLY) RequireReadonly = PermissionChecker(UserRole.READONLY)
async def capture_request_origin(request: Request) -> None:
"""App-wide dependency feeding the audit log's client IP and user agent.
Async on purpose: it sets the var in the request's own context, which the
threadpool running a sync endpoint copies. Runs after ProxyHeadersMiddleware,
so request.client is the real client."""
from app.models.audit_log import request_origin
request_origin.set((request.client.host if request.client else None,
request.headers.get("user-agent")))
def audited(what: str, event_type=None, resource_type: Optional[str] = None,
body_fields: tuple = ()):
"""Route dependency for an action button that changes no single record
(sync, scan, refresh, connection test): one audit row naming who started
`what`, written once the endpoint returned — a refused call or one that
raises writes none; a test that answers {"ok": false} counts as attempted. The query parameters go into the row, and of a JSON body only
`body_fields` ("a" or "a.b"), never the rest: bodies carry passwords.
With `resource_type`, the route's path id is the resource.
Returns the Depends itself: scope="function" writes the row before the
response goes out, so a failing write is a 500, not a silent gap."""
from starlette.concurrency import run_in_threadpool
from app.models.audit_log import AuditEventType
from app.services.audit_trail import record_action
async def _audit(request: Request, db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)):
yield
q = request.query_params
details = {k: q.getlist(k) if len(q.getlist(k)) > 1 else q[k] for k in q.keys()}
if body_fields:
body = await request.json() # cached from the endpoint's own parse
for path in body_fields:
value = body
for part in path.split("."):
value = value.get(part) if isinstance(value, dict) else None
if value is not None:
details[path] = value
resource_id = next(iter(request.path_params.values()), None) if resource_type else None
def _write():
record_action(db, current_user.id, event_type or AuditEventType.SCAN_TRIGGERED, what,
resource_type=resource_type, resource_id=resource_id, details=details)
db.commit()
await run_in_threadpool(_write)
return Depends(_audit, scope="function")
+49 -6
View File
@@ -7,9 +7,9 @@ import os
import uuid import uuid
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Optional, Dict, Any from typing import Optional, Dict, Any
import bcrypt
import jwt import jwt
from jwt import PyJWTError from jwt import PyJWTError
from passlib.context import CryptContext
# JWT Configuration # JWT Configuration
JWT_SECRET_KEY = os.getenv("JWT_SECRET_KEY") JWT_SECRET_KEY = os.getenv("JWT_SECRET_KEY")
@@ -18,12 +18,43 @@ if not JWT_SECRET_KEY:
"JWT_SECRET_KEY environment variable is not set. " "JWT_SECRET_KEY environment variable is not set. "
"Application cannot start without a secure secret key." "Application cannot start without a secure secret key."
) )
# Ein nicht ersetzter Platzhalter ist so gut wie kein Schlüssel: der Wert steht
# im öffentlichen Repo, also kann jeder ein Admin-Token signieren. Genauso
# fail-loud behandeln wie AUTH_PROVIDER_CRYPTO_KEY in app/auth/totp.py.
if JWT_SECRET_KEY.startswith("CHANGE-ME") or JWT_SECRET_KEY.startswith("your-super-secret"):
raise RuntimeError(
"JWT_SECRET_KEY is still the shipped placeholder. Generate a real key "
"with `openssl rand -hex 32` and set it in .env."
)
if len(JWT_SECRET_KEY) < 32:
raise RuntimeError(
"JWT_SECRET_KEY is shorter than 32 characters. Generate one with "
"`openssl rand -hex 32`."
)
JWT_ALGORITHM = "HS256" JWT_ALGORITHM = "HS256"
JWT_ACCESS_TOKEN_EXPIRE_MINUTES = 30 JWT_ACCESS_TOKEN_EXPIRE_MINUTES = 30
JWT_REFRESH_TOKEN_EXPIRE_DAYS = 7 JWT_REFRESH_TOKEN_EXPIRE_DAYS = 7
# Token-Zwecke. Alle Tokens werden mit demselben Schlüssel signiert, der
# `type`-Claim ist also das Einzige, was eine Session von einem Refresh- oder
# MFA-Challenge-Token unterscheidet. get_current_user lässt nur ACCESS durch.
TOKEN_TYPE_ACCESS = "access"
TOKEN_TYPE_REFRESH = "refresh"
TOKEN_TYPE_MFA_CHALLENGE = "mfa_challenge"
TOKEN_TYPE_MFA_SETUP = "mfa_setup"
# Password Hashing (bcrypt mit 12 Rounds) # Password Hashing (bcrypt mit 12 Rounds)
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto", bcrypt__rounds=12) BCRYPT_ROUNDS = 12
def _pw_bytes(password: str) -> bytes:
"""
bcrypt liest nur die ersten 72 Bytes. passlib hat still abgeschnitten,
die bcrypt-Bibliothek wirft dagegen einen ValueError — die bestehenden
Hashes stammen also aus abgeschnittener Eingabe und werden nur dann
weiterhin verifiziert, wenn hier gleich abgeschnitten wird.
"""
return password.encode("utf-8")[:72]
def hash_password(password: str) -> str: def hash_password(password: str) -> str:
@@ -36,7 +67,8 @@ def hash_password(password: str) -> str:
Returns: Returns:
bcrypt-Hash bcrypt-Hash
""" """
return pwd_context.hash(password) return bcrypt.hashpw(_pw_bytes(password),
bcrypt.gensalt(rounds=BCRYPT_ROUNDS)).decode("ascii")
def verify_password(plain_password: str, hashed_password: str) -> bool: def verify_password(plain_password: str, hashed_password: str) -> bool:
@@ -50,12 +82,18 @@ def verify_password(plain_password: str, hashed_password: str) -> bool:
Returns: Returns:
True wenn Passwort korrekt True wenn Passwort korrekt
""" """
return pwd_context.verify(plain_password, hashed_password) try:
return bcrypt.checkpw(_pw_bytes(plain_password),
hashed_password.encode("utf-8"))
except ValueError:
# Kein gültiger bcrypt-Hash in der DB — nie als Treffer werten.
return False
def create_access_token( def create_access_token(
data: Dict[str, Any], data: Dict[str, Any],
expires_delta: Optional[timedelta] = None expires_delta: Optional[timedelta] = None,
token_type: str = TOKEN_TYPE_ACCESS,
) -> str: ) -> str:
""" """
Erstellt JWT Access Token Erstellt JWT Access Token
@@ -63,6 +101,9 @@ def create_access_token(
Args: Args:
data: Payload-Daten (z.B. {"sub": "username", "role": "admin"}) data: Payload-Daten (z.B. {"sub": "username", "role": "admin"})
expires_delta: Custom Expiry-Zeit (default: 30 Min) expires_delta: Custom Expiry-Zeit (default: 30 Min)
token_type: Zweck des Tokens. Nur TOKEN_TYPE_ACCESS ist eine Session;
Challenge-Tokens (MFA) müssen ihren eigenen Typ setzen, sonst sind
sie von einem vollwertigen Access-Token nicht unterscheidbar.
Returns: Returns:
JWT-Token als String JWT-Token als String
@@ -70,6 +111,7 @@ def create_access_token(
Token-Claims: Token-Claims:
- sub: Subject (Username) - sub: Subject (Username)
- role: User-Rolle - role: User-Rolle
- type: Token-Zweck (access | mfa_challenge | mfa_setup)
- exp: Expiry-Timestamp - exp: Expiry-Timestamp
- iat: Issued-At-Timestamp - iat: Issued-At-Timestamp
""" """
@@ -84,6 +126,7 @@ def create_access_token(
to_encode.update({ to_encode.update({
"exp": expire, "exp": expire,
"iat": now, "iat": now,
"type": token_type,
"jti": uuid.uuid4().hex, "jti": uuid.uuid4().hex,
}) })
@@ -108,7 +151,7 @@ def create_refresh_token(data: Dict[str, Any]) -> str:
to_encode.update({ to_encode.update({
"exp": expire, "exp": expire,
"iat": now, "iat": now,
"type": "refresh", # Token-Type für Unterscheidung "type": TOKEN_TYPE_REFRESH, # Token-Type für Unterscheidung
"jti": uuid.uuid4().hex, "jti": uuid.uuid4().hex,
}) })
+7 -4
View File
@@ -63,6 +63,7 @@ class AuthOrchestrator:
*, *,
actor_ip: Optional[str] = None, actor_ip: Optional[str] = None,
actor_user_agent: Optional[str] = None, actor_user_agent: Optional[str] = None,
channel: Optional[str] = None,
) -> AuthResult: ) -> AuthResult:
""" """
Try each credential-capable strategy in lookup_order. Returns Try each credential-capable strategy in lookup_order. Returns
@@ -111,7 +112,7 @@ class AuthOrchestrator:
# IdP and never reach this code path. # IdP and never reach this code path.
mfa_required = bool(user.totp_enabled and user.totp_secret) 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( return AuthResult(
user=user, user=user,
provider=prov, provider=prov,
@@ -122,7 +123,7 @@ class AuthOrchestrator:
) )
# All strategies failed # 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) # Generic safe message (anti-enumeration)
raise AuthError( raise AuthError(
detail=f"all providers failed for '{username}': {[p for p in provider_tried]}", 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) provisioner = JITProvisioner(self.db, default_role=self.default_role)
return provisioner.provision(identity, actor_ip=ip) 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 evt = AuditEventType.LOGIN_SUCCESS
if provider == AuthProvider.LDAP: if provider == AuthProvider.LDAP:
evt = AuditEventType.LOGIN_LDAP_SUCCESS evt = AuditEventType.LOGIN_LDAP_SUCCESS
@@ -215,7 +216,7 @@ class AuthOrchestrator:
AuditLog( AuditLog(
user_id=user.id, user_id=user.id,
event_type=evt, 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, ip_address=ip,
user_agent=ua, user_agent=ua,
resource_type="user", resource_type="user",
@@ -232,6 +233,7 @@ class AuthOrchestrator:
error: Optional[AuthError], error: Optional[AuthError],
ip, ip,
ua, ua,
channel=None,
): ):
self.db.add( self.db.add(
AuditLog( AuditLog(
@@ -241,6 +243,7 @@ class AuthOrchestrator:
f"login failed for '{username}' " f"login failed for '{username}' "
f"(tried: {', '.join(providers_tried) or 'none'}; " f"(tried: {', '.join(providers_tried) or 'none'}; "
f"reason: {error.detail if error else 'no providers configured'})" f"reason: {error.detail if error else 'no providers configured'})"
+ (f" [{channel}]" if channel else "")
), ),
ip_address=ip, ip_address=ip,
user_agent=ua, user_agent=ua,
+3 -3
View File
@@ -6,11 +6,11 @@ Configuration lives in the `settings` table under key `auth_role_mappings`
{ {
"ldap": [ "ldap": [
{"pattern": "CN=VulnCheck-Admins,*", "role": "admin"}, {"pattern": "CN=TrueVuln-Admins,*", "role": "admin"},
{"pattern": "CN=VulnCheck-Editors,*", "role": "editor"} {"pattern": "CN=TrueVuln-Editors,*", "role": "editor"}
], ],
"oidc": [ "oidc": [
{"pattern": "vulncheck-admins", "role": "admin"} {"pattern": "truevuln-admins", "role": "admin"}
], ],
"saml": [...] "saml": [...]
} }
+9
View File
@@ -28,6 +28,15 @@ PROTECTED_SETTING_KEYS: frozenset[str] = frozenset({
"wazuh_config", "wazuh_config",
"smtp_config", "smtp_config",
"nessus_config", "nessus_config",
"openrouter_api_key",
"typesafe_api_key",
"intune_config",
"vcenter_config",
"igel_config",
"netdisco_config",
"dockhand_config",
"xenserver_config",
"github_pat",
}) })
+46 -2
View File
@@ -15,13 +15,39 @@ from sqlalchemy.orm import Session
from app.auth.jwt_handler import hash_password, verify_password from app.auth.jwt_handler import hash_password, verify_password
from app.auth.strategies.base import AuthError, AuthStrategy, ExternalIdentity from app.auth.strategies.base import AuthError, AuthStrategy, ExternalIdentity
from app.models.user import AuthProvider, User from app.models.user import AuthProvider, User, UserRole
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
ACCOUNT_LOCKOUT_THRESHOLD = 5 ACCOUNT_LOCKOUT_THRESHOLD = 5
ACCOUNT_LOCKOUT_DURATION_MIN = 15 ACCOUNT_LOCKOUT_DURATION_MIN = 15
def _lockout_is_permanent(db: Session) -> bool:
"""Opt-in via the `auth_lockout_permanent` setting. Off → the classic
temporary 15-min auto-unlock."""
from app.models.setting import Setting
try:
s = db.query(Setting).filter(Setting.key == "auth_lockout_permanent").first()
return bool(s and str(s.value).strip().lower() in ("1", "true", "yes"))
except Exception:
return False
def _is_last_active_admin(db: Session, user: User) -> bool:
"""True if `user` is the only admin that could still log in. Used to refuse
PERMANENTLY locking the last recoverable admin — otherwise a brute-force on
the admin login would lock everyone out of the system for good."""
if user.role != UserRole.ADMIN:
return False
others = (
db.query(User)
.filter(User.role == UserRole.ADMIN, User.is_active.is_(True),
User.locked.is_(False), User.id != user.id)
.count()
)
return others == 0
# Pre-computed valid bcrypt hash used in the user-not-found branch so that # Pre-computed valid bcrypt hash used in the user-not-found branch so that
# verify cost is constant-time regardless of whether the username exists. # verify cost is constant-time regardless of whether the username exists.
# Generated once at module import. The plaintext is irrelevant — it is never # Generated once at module import. The plaintext is irrelevant — it is never
@@ -61,7 +87,15 @@ class LocalAuthStrategy(AuthStrategy):
if not user.is_active: if not user.is_active:
raise AuthError(detail=f"inactive user '{username}'") raise AuthError(detail=f"inactive user '{username}'")
# Account lockout # Permanent lock (admin must clear it) — takes precedence over the
# temporary window and can only be lifted via the admin unlock endpoint.
if getattr(user, "locked", False):
raise AuthError(
detail=f"user '{username}' is permanently locked (admin unlock required)",
safe_message="Account locked. Contact an administrator.",
)
# Temporary lockout window
if user.failed_login_attempts >= ACCOUNT_LOCKOUT_THRESHOLD: if user.failed_login_attempts >= ACCOUNT_LOCKOUT_THRESHOLD:
# If updated_at is older than lockout window, auto-reset. # If updated_at is older than lockout window, auto-reset.
unlock_after = (user.updated_at or user.created_at) + timedelta( unlock_after = (user.updated_at or user.created_at) + timedelta(
@@ -77,6 +111,16 @@ class LocalAuthStrategy(AuthStrategy):
# Password check # Password check
if not user.password_hash or not verify_password(password, user.password_hash): if not user.password_hash or not verify_password(password, user.password_hash):
user.failed_login_attempts += 1 user.failed_login_attempts += 1
# Escalate to a PERMANENT lock at the threshold when enabled — but
# never permalock the last recoverable admin (avoid total lockout;
# they fall back to the temporary window instead).
if (user.failed_login_attempts >= ACCOUNT_LOCKOUT_THRESHOLD
and _lockout_is_permanent(self.db)
and not _is_last_active_admin(self.db, user)):
user.locked = True
user.locked_at = datetime.now()
logger.warning("Account '%s' permanently locked after %d failed attempts",
username, user.failed_login_attempts)
self.db.commit() self.db.commit()
raise AuthError(detail=f"bad password for '{username}'") raise AuthError(detail=f"bad password for '{username}'")
+2 -2
View File
@@ -27,7 +27,7 @@ from cryptography.fernet import Fernet, InvalidToken
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
ISSUER = "VulnCheck" ISSUER = "TrueVuln"
TOTP_DIGITS = 6 TOTP_DIGITS = 6
TOTP_INTERVAL = 30 TOTP_INTERVAL = 30
TOTP_VALID_WINDOW = 1 # accept current ± 1 step to tolerate clock skew TOTP_VALID_WINDOW = 1 # accept current ± 1 step to tolerate clock skew
@@ -138,7 +138,7 @@ def decrypt_secret(stored: str) -> str:
the AUTH_PROVIDER_CRYPTO_KEY requirement landed would see "Invalid the AUTH_PROVIDER_CRYPTO_KEY requirement landed would see "Invalid
MFA Code" on the next login and have to reset enrolment, even MFA Code" on the next login and have to reset enrolment, even
though their authenticator app + DB row still hold the correct though their authenticator app + DB row still hold the correct
secret. This is the bug the tester hit during the v0.12 upgrade. secret. This is the bug that surfaced during the v0.12 upgrade.
""" """
if not stored: if not stored:
raise InvalidToken("empty stored secret") raise InvalidToken("empty stored secret")
+123 -3
View File
@@ -1,12 +1,16 @@
""" """
Database Connection und Session Management Database Connection und Session Management
""" """
import logging
import os import os
import time
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, Session from sqlalchemy.orm import sessionmaker, Session
from contextlib import contextmanager from contextlib import ExitStack, contextmanager
from typing import Generator from typing import Generator
logger = logging.getLogger(__name__)
# Database URL aus Environment Variable # Database URL aus Environment Variable
DATABASE_URL = os.getenv( DATABASE_URL = os.getenv(
"DATABASE_URL", "DATABASE_URL",
@@ -14,11 +18,21 @@ DATABASE_URL = os.getenv(
) )
# Engine mit Security-Optimierungen # 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( engine = create_engine(
DATABASE_URL, DATABASE_URL,
pool_pre_ping=True, # Prüft Connection vor Nutzung pool_pre_ping=True, # Prüft Connection vor Nutzung
pool_size=10, # Connection Pool Größe pool_size=POOL_SIZE,
max_overflow=20, # Maximale zusätzliche Connections max_overflow=MAX_OVERFLOW,
echo=False, # SQL-Logging (in Production deaktivieren) echo=False, # SQL-Logging (in Production deaktivieren)
) )
@@ -71,3 +85,109 @@ def init_db():
""" """
from app.models.base import Base from app.models.base import Base
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
@contextmanager
def advisory_lock(key: int):
"""Postgres advisory lock held on a connection of its own.
`pg_try_advisory_lock` is SESSION scoped: the lock lives on the postgres
backend — i.e. on the pooled connection — not on the transaction. A
SQLAlchemy Session hands its connection back to the pool on every commit,
and a sync commits per host, so the lock ends up on a connection the sync
no longer owns: the closing `pg_advisory_unlock` runs on a different
backend, returns false, and the lock stays held for the life of that
pooled connection. Every later sync then answers "another sync already
running" — for good, until the process restarts. That is what stalled the
vCenter sync (and with it the auto-close of patched findings) for a day.
So the lock gets a connection nobody else can check out. Yields True when
it was granted, False when someone else holds it.
"""
conn = engine.connect().execution_options(isolation_level="AUTOCOMMIT")
got = False
try:
got = bool(conn.exec_driver_sql(
"SELECT pg_try_advisory_lock(%(k)s)", {"k": key}).scalar())
yield got
finally:
try:
if got:
conn.exec_driver_sql("SELECT pg_advisory_unlock(%(k)s)", {"k": key})
except Exception:
# Unlock failed → throw the backend away rather than return a
# still-locked connection to the pool; postgres drops session
# locks when the backend closes.
conn.invalidate()
finally:
conn.close()
# One key per job kind that the nightly scheduler AND a GUI button both start.
# 0x545601xx are the integration-sync locks (intune/vcenter/igel/netdisco/dockhand).
JOB_SLOT_KEYS = {
"wazuh_vulns": 0x54560201,
"nessus": 0x54560202,
"eol": 0x54560203,
"m365": 0x54560204,
"app_cve_scan": 0x54560205,
"msrc_scan": 0x54560206,
"msrc_refresh": 0x54560207,
"exploit_intel": 0x54560208,
"vuln_index": 0x54560209,
"vulnrichment": 0x5456020A,
}
@contextmanager
def _one_slot(name: str, timeout: float, poll: float):
key = JOB_SLOT_KEYS[name]
deadline = time.monotonic() + timeout
waited = False
while True:
entered = False
try:
with advisory_lock(key) as got:
entered = True
if got or time.monotonic() >= deadline:
if not got:
logger.warning("job %s: the other run still holds it after %d min "
"— starting alongside it", name, timeout // 60)
elif waited:
logger.info("job %s: the other run finished — starting", name)
yield
return
except Exception as e:
if entered:
raise # the job's own error, not the lock's
logger.warning("job %s: lock unavailable, running unserialised: %s", name, e)
break
if not waited:
logger.warning("job %s: another run (nightly or GUI) is in progress — "
"waiting for it, up to %d min", name, timeout // 60)
waited = True
time.sleep(poll)
yield
@contextmanager
def job_slot(*names: str, timeout: float = 6 * 3600, poll: float = 15):
"""Run one job of a kind at a time, whoever starts it.
The nightly scheduler and the GUI buttons start the same scans. Two runs of
one scan write the same findings in different orders: a deadlock, or the
loser of the uq_vuln_cve_asset race aborting its run. A second run waits
for the first instead and then runs — queued, never skipped. After
`timeout` it starts anyway with a warning (as intune_service.wait_for_sync
does): a hung run must not cost every later one its turn.
Usable as `with job_slot("eol"):` or as a decorator. Take it before the
job opens its Session, so a waiting run holds no rows. Several names are
taken in a fixed order, so two jobs that share one cannot deadlock.
No postgres (tests) → runs unserialised; the job then fails on its own DB
access if the database is really gone.
"""
with ExitStack() as stack:
for name in sorted(names):
stack.enter_context(_one_slot(name, timeout, poll))
yield
+19
View File
@@ -42,6 +42,25 @@ def create_initial_data(db: Session):
"true", "true",
"Enable ENISA EUVD enrichment (EU exploited/critical vulnerabilities)" "Enable ENISA EUVD enrichment (EU exploited/critical vulnerabilities)"
) )
_ensure_default_setting(
db,
"kev_alert_enabled",
"true",
"Email immediately when an actively exploited CVE (CISA KEV / "
"ENISA EUVD / VulnCheck KEV) has open findings on an active asset"
)
_ensure_default_setting(
db,
"kev_alert_sources",
"cisa,euvd,vulncheck",
"KEV sources used for immediate alerting (cisa, euvd, vulncheck). "
"All three by default: catalog size is not what decides a mail — "
"an OPEN finding on an ACTIVE asset is, and an exploited CVE "
"sitting on our own machine is worth the mail whichever catalog "
"listed it. VulnCheck usually lists days ahead of CISA, which is "
"the whole point of an immediate alert. Set to a shorter list to "
"narrow it."
)
db.commit() db.commit()
except Exception as e: except Exception as e:
logger.warning(f"Could not seed enrichment settings: {e}") logger.warning(f"Could not seed enrichment settings: {e}")
+38 -7
View File
@@ -134,12 +134,29 @@ Structure your response as JSON ONLY:
}} }}
""" """
def _call_llm(self, prompt: str, enable_web_search: bool = False, temperature: float = 0.3) -> str: # A structured answer costs far more than 2000 tokens: ten prioritised
# findings with a justification each, plus the strategy paragraph, runs
# past that and the reply is cut off MID-JSON. The parser then fails, the
# caller reports "no recommendations", and nothing says the answer was
# simply truncated. Reasoning models make it worse — deepseek-reasoner
# spends this same budget thinking before it writes a single character.
MAX_TOKENS = 8000
# Providers that honour OpenAI's response_format. Asking these for JSON
# makes a parse failure structurally impossible, rather than something we
# hope a prompt talks the model into. Others ignore the field or reject
# the request outright, so they are not sent it.
_JSON_MODE_PROVIDERS = ("openai", "deepseek", "openrouter", "groq", "mistral")
def _call_llm(self, prompt: str, enable_web_search: bool = False,
temperature: float = 0.3, json_mode: bool = False) -> str:
if self.provider == "anthropic": if self.provider == "anthropic":
return self._call_anthropic(prompt, temperature) return self._call_anthropic(prompt, temperature)
return self._call_openai_compatible(prompt, enable_web_search, temperature) return self._call_openai_compatible(prompt, enable_web_search, temperature,
json_mode=json_mode)
def _call_openai_compatible(self, prompt: str, enable_web_search: bool, temperature: float) -> str: def _call_openai_compatible(self, prompt: str, enable_web_search: bool,
temperature: float, json_mode: bool = False) -> str:
headers = { headers = {
"Authorization": f"Bearer {self.api_key}", "Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json" "Content-Type": "application/json"
@@ -152,11 +169,13 @@ Structure your response as JSON ONLY:
{"role": "user", "content": prompt} {"role": "user", "content": prompt}
], ],
"temperature": temperature, "temperature": temperature,
"max_tokens": 2000 "max_tokens": self.MAX_TOKENS
} }
if enable_web_search and self.provider == "infomaniak": if enable_web_search and self.provider == "infomaniak":
payload["tools"] = [{"type": "web_search"}] payload["tools"] = [{"type": "web_search"}]
if json_mode and self.provider in self._JSON_MODE_PROVIDERS:
payload["response_format"] = {"type": "json_object"}
logger.info(f"Calling {self.provider} API with model: {self.model}") logger.info(f"Calling {self.provider} API with model: {self.model}")
@@ -167,7 +186,15 @@ Structure your response as JSON ONLY:
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
return data["choices"][0]["message"]["content"] choice = (data.get("choices") or [{}])[0]
# Say so when the answer was cut short. Silently returning half a JSON
# document is what made this look like "the AI returned nothing".
if choice.get("finish_reason") == "length":
logger.warning(
"%s/%s hit the %d token limit — the reply is truncated and will "
"not parse. Use a model with more headroom or ask for less.",
self.provider, self.model, self.MAX_TOKENS)
return (choice.get("message") or {}).get("content") or ""
def _call_anthropic(self, prompt: str, temperature: float) -> str: def _call_anthropic(self, prompt: str, temperature: float) -> str:
headers = { headers = {
@@ -178,13 +205,17 @@ Structure your response as JSON ONLY:
payload = { payload = {
"model": self.model, "model": self.model,
"messages": [{"role": "user", "content": prompt}], "messages": [{"role": "user", "content": prompt}],
"max_tokens": 2000, "max_tokens": self.MAX_TOKENS,
"temperature": temperature "temperature": temperature
} }
response = self.client.post(self.base_url, headers=headers, json=payload) response = self.client.post(self.base_url, headers=headers, json=payload)
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
return data["content"][0]["text"] if data.get("stop_reason") == "max_tokens":
logger.warning("%s/%s hit the %d token limit — reply truncated.",
self.provider, self.model, self.MAX_TOKENS)
blocks = data.get("content") or []
return blocks[0].get("text", "") if blocks else ""
def _parse_analysis_response(self, response: str) -> Dict[str, Any]: def _parse_analysis_response(self, response: str) -> Dict[str, Any]:
try: try:
+230
View File
@@ -0,0 +1,230 @@
"""
Microsoft Defender for Endpoint (TVM) API client — app-only.
Separate from Microsoft Graph: the threat & vulnerability management data
lives on api.securitycenter.microsoft.com with its own token resource
scope and app permission (Vulnerability.Read.All on WindowsDefenderATP).
Reuses the same Entra app (tenant/client/secret) as the Intune/Graph
integration; only the requested scope differs.
Returns REAL per-device CVEs (not pseudo) → they enrich like any CVE.
"""
import logging
import time
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional
import httpx
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
logger = logging.getLogger(__name__)
MDE_BASE = "https://api.securitycenter.microsoft.com/api"
LOGIN_BASE = "https://login.microsoftonline.com"
MDE_SCOPE = "https://api.securitycenter.microsoft.com/.default"
# The vulnerability APIs allow 50 calls per minute and 1500 per hour
# (learn.microsoft.com, "Get discovered vulnerabilities"). Spacing every call
# by an hour's share keeps a run under both, however long it is: the hourly
# budget is the one a large tenant used to exhaust.
MIN_CALL_INTERVAL = 3600 / 1500
# $top maximum of the tenant-wide vulnerability list.
MACHINE_VULNS_PAGE = 10000
class DefenderAPIError(Exception):
pass
class DefenderAuthError(DefenderAPIError):
pass
class DefenderClient:
def __init__(self, tenant_id: str, client_id: str, client_secret: str, verify_ssl: bool = True):
if not all([tenant_id, client_id, client_secret]):
raise ValueError("Defender TVM requires tenant_id, client_id and client_secret.")
self.tenant_id = tenant_id
self.client_id = client_id
self.client_secret = client_secret
self._token: Optional[str] = None
self._token_expires_at: Optional[datetime] = None
self._next_call = 0.0
self.client = httpx.Client(
verify=verify_ssl,
timeout=httpx.Timeout(30.0, connect=8.0),
limits=httpx.Limits(max_connections=10, max_keepalive_connections=5),
)
def _authenticate(self) -> str:
url = f"{LOGIN_BASE}/{self.tenant_id}/oauth2/v2.0/token"
data = {
"grant_type": "client_credentials",
"client_id": self.client_id,
"client_secret": self.client_secret,
"scope": MDE_SCOPE,
}
try:
r = self.client.post(url, data=data)
if r.status_code in (400, 401):
detail = ""
try:
detail = r.json().get("error_description", "")[:200]
except Exception:
detail = r.text[:200]
raise DefenderAuthError(f"token rejected ({r.status_code}): {detail}")
r.raise_for_status()
payload = r.json()
except httpx.HTTPError as e:
raise DefenderAuthError(f"token request failed: {e}") from e
token = payload.get("access_token")
if not token:
raise DefenderAuthError("no access_token in token response")
self._token = token
self._token_expires_at = datetime.now() + timedelta(seconds=max(60, int(payload.get("expires_in", 3600)) - 120))
return token
def _ensure_token(self) -> str:
if not self._token or not self._token_expires_at or datetime.now() >= self._token_expires_at:
return self._authenticate()
return self._token
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8),
retry=retry_if_exception_type((httpx.ReadTimeout, httpx.WriteTimeout)))
def _get(self, url: str, params: Optional[Dict[str, Any]] = None,
_tries: int = 4) -> Dict[str, Any]:
# Calls are paced under the rate limit (MIN_CALL_INTERVAL), but a 429
# can still come — another client of the same app registration shares
# the budget. Treating that as a hard error threw away everything the
# sync had left to do; 429 is a "come back shortly", not a failure.
# Microsoft states the wait in Retry-After, so honour it.
token = self._ensure_token()
for attempt in range(_tries):
wait = self._next_call - time.monotonic()
if wait > 0:
time.sleep(wait)
self._next_call = time.monotonic() + MIN_CALL_INTERVAL
r = self.client.get(url, headers={"Authorization": f"Bearer {token}",
"Accept": "application/json"},
params=params)
if r.status_code in (500, 502, 503, 504) and attempt < _tries - 1:
# Transient on MDE as on Graph; a hard error here dropped the
# machine (or the whole tenant-wide export) from the run.
logger.info("Defender %s on %s, retrying (attempt %d/%d)",
r.status_code, url, attempt + 1, _tries)
time.sleep(2 * (attempt + 1))
continue
if r.status_code != 429:
break
if attempt == _tries - 1:
raise DefenderAPIError("Defender rate limit (429) — gave up after "
f"{_tries} attempts")
try:
wait = float(r.headers.get("Retry-After", ""))
except ValueError:
wait = 0.0
# No/!unusable header → back off geometrically instead of hammering.
wait = min(max(wait, 2.0 * (2 ** attempt)), 60.0)
logger.info("Defender 429 — retrying in %.0fs (attempt %d/%d)",
wait, attempt + 1, _tries)
time.sleep(wait)
if r.status_code >= 400:
raise DefenderAPIError(f"Defender GET {url} -> {r.status_code}: {r.text[:200]}")
return r.json()
def _get_all(self, path: str, max_pages: int = 200) -> List[dict]:
url = f"{MDE_BASE}{path}"
out: List[dict] = []
pages = 0
while url and pages < max_pages:
data = self._get(url)
out.extend(data.get("value", []) or [])
url = data.get("@odata.nextLink")
pages += 1
if url:
# A cut-off list reads as "these CVEs are gone" to the resolver.
raise DefenderAPIError(f"{path}: more than {max_pages} pages, list incomplete")
return out
def get_machines(self) -> List[dict]:
return self._get_all("/machines")
def get_all_machine_vulnerabilities(self) -> List[dict]:
"""Every (machine, CVE, product) of the tenant — one row each, with
machineId/cveId/severity and productVendor/productName/productVersion.
Replaces the walk over `/machines/{id}/vulnerabilities`: one call per
machine cannot fit the 1500-calls-per-hour budget once the tenant has
more machines than that, and past it every machine came back 429 and
was dropped from the run. This asks once per 10,000 rows.
Carries no CVSS or description; see get_vulnerability. Raises on
failure — a partial list must not be resolved against, so a full page
without a nextLink is followed by $skip rather than taken as the end."""
base = f"{MDE_BASE}/vulnerabilities/machinesVulnerabilities"
out: List[dict] = []
url, params = base, {"$top": MACHINE_VULNS_PAGE}
for _ in range(2000):
data = self._get(url, params=params)
page = data.get("value", []) or []
out.extend(page)
if data.get("@odata.nextLink"):
url, params = data["@odata.nextLink"], None
elif len(page) >= MACHINE_VULNS_PAGE:
url, params = base, {"$top": MACHINE_VULNS_PAGE, "$skip": len(out)}
else:
return out
raise DefenderAPIError("machinesVulnerabilities: more than 2000 pages, list incomplete")
def get_vulnerability(self, cve_id: str) -> dict:
"""One CVE from the TVM catalogue: name, description, severity, cvssV3."""
return self._get(f"{MDE_BASE}/vulnerabilities/{cve_id}")
def get_software_vulnerabilities_by_machine(self) -> List[dict]:
"""Tenant-wide (device, CVE, software) assessment — the JSON-response
export. One paginated call maps every machine+CVE to its affected
software (the per-machine /vulnerabilities endpoint omits software).
Rows carry deviceId/cveId + softwareVendor/softwareName/softwareVersion.
Best-effort: returns [] if the tenant/plan doesn't expose it."""
try:
return self._get_all("/machines/SoftwareVulnerabilitiesByMachine")
except DefenderAPIError as e:
logger.warning("Defender software-vuln export unavailable: %s", e)
return []
def get_software_inventory_by_machine(self) -> List[dict]:
"""Tenant-wide installed-software inventory — one row per
(device, vendor, product, version).
This is the fresh replacement for Intune's `detectedApps`, which only
refreshes every 7 days per device. MDE re-evaluates on device
telemetry, so a browser that updated this morning reads its new
version here while Discovered apps still shows last week's.
Rows carry deviceId/deviceName + softwareVendor/softwareName/
softwareVersion. Needs the app permission Software.Read.All on
WindowsDefenderATP — best-effort: a tenant without it (or without
Defender Vulnerability Management) gets [] and keeps detectedApps.
"""
try:
return self._get_all("/machines/SoftwareInventoryByMachine")
except DefenderAPIError as e:
logger.warning("Defender software inventory unavailable: %s", e)
return []
def test_connection(self) -> dict:
try:
self._ensure_token()
except DefenderAuthError as e:
return {"ok": False, "step": "auth", "error": str(e)}
try:
data = self._get(f"{MDE_BASE}/machines", params={"$top": 1})
return {"ok": True, "machine_sample": len(data.get("value", []) or [])}
except DefenderAPIError as e:
return {"ok": False, "step": "machines", "error": str(e)}
def close(self) -> None:
try:
self.client.close()
except Exception:
pass
+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 10–60 s, a Grype DB update on the first scan longer. Bounded
# so a hung Dockhand cannot hold the nightly job (and its lock) forever.
SCAN_ALL_READ_TIMEOUT = 3 * 3600.0
class DockhandError(RuntimeError):
"""Connection / authentication / API failure, with Dockhand's message kept."""
class DockhandClient:
def __init__(self, base_url: str, token: str = "", verify_ssl: bool = True):
headers = {"Accept": "application/json"}
if token:
headers["Authorization"] = f"Bearer {token.strip()}"
# Short connect, generous read: the export flattens every scan of a
# host in one response and is slow on a big one.
self._client = httpx.Client(
base_url=base_url.rstrip("/"), verify=verify_ssl, headers=headers,
timeout=httpx.Timeout(connect=8.0, read=120.0, write=30.0, pool=5.0),
)
def _request(self, method: str, path: str, timeout=None, **params) -> object:
try:
r = self._client.request(method, path, params=params,
timeout=timeout or self._client.timeout)
except httpx.HTTPError as e:
raise DockhandError(f"Dockhand unreachable: {e}") from e
if r.status_code in (401, 403):
raise DockhandError(f"Dockhand refused the token (HTTP {r.status_code}) — it needs "
"images:view and environments:view (images:inspect to rescan).")
if r.status_code >= 400:
raise DockhandError(f"Dockhand {path} failed (HTTP {r.status_code}): {r.text[:300]}")
try:
return r.json()
except ValueError as e:
# A reverse proxy's login page answers 200 with HTML.
raise DockhandError(f"Dockhand {path} returned no JSON — wrong URL?") from e
def _get(self, path: str, **params) -> object:
return self._request("GET", path, **params)
def environments(self) -> List[dict]:
got = self._get("/api/environments")
if not isinstance(got, list):
raise DockhandError("Dockhand /api/environments returned no list")
return got
def images(self, env_id: int) -> List[dict]:
"""The host's images. Dockhand answers `[]` — not an error — when it
cannot reach the Docker host."""
got = self._get("/api/images", env=env_id)
if not isinstance(got, list):
raise DockhandError(f"Dockhand /api/images for environment {env_id} returned no list")
return got
def host(self, env_id: int) -> dict:
"""The Docker daemon's host: `hostname` is `docker info` Name, the real
host's name even behind a socket or a Hawser agent."""
got = self._get("/api/host", env=env_id)
return got if isinstance(got, dict) else {}
def findings(self, env_id: int) -> List[dict]:
got = self._get("/api/vulnerabilities/export", env=env_id, format="json")
if not isinstance(got, dict) or not isinstance(got.get("findings"), list):
raise DockhandError(f"Dockhand export for environment {env_id} has no findings list "
"— Dockhand older than 1.0.37?")
return got["findings"]
def scan_all(self, env_id: int) -> dict:
"""Rescan every image of the host and wait for it. `Accept: application/json`
makes Dockhand answer synchronously with the summary
`{scanned, failed, total, cancelled}` (or `{success: false, error}`)
instead of a job id."""
got = self._request("POST", "/api/vulnerabilities/scan-all", env=env_id,
timeout=httpx.Timeout(connect=8.0, read=SCAN_ALL_READ_TIMEOUT,
write=30.0, pool=5.0))
if not isinstance(got, dict):
raise DockhandError(f"Dockhand scan-all for environment {env_id} returned no object")
return got
def close(self) -> None:
self._client.close()
+271
View File
@@ -0,0 +1,271 @@
"""
Microsoft Graph API client (app-only / client-credentials).
Used to pull Intune (MDM/UEM) inventory — managed devices and their
detected apps — as a third asset/software source next to Wazuh and Nessus.
Auth: client-credentials. A bearer token is fetched from
login.microsoftonline.com and cached until shortly before expiry, mirroring
the token-cache pattern in app/integrations/wazuh_client.py. No msal /
azure-identity dependency — plain httpx.
Required Entra app (Application) permissions + admin consent:
DeviceManagementManagedDevices.Read.All (devices + detected apps)
"""
import logging
import re
import time
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional
import httpx
from tenacity import (
retry, stop_after_attempt, wait_exponential, retry_if_exception_type,
)
logger = logging.getLogger(__name__)
GRAPH_BASE = "https://graph.microsoft.com/v1.0"
GRAPH_BETA = "https://graph.microsoft.com/beta"
LOGIN_BASE = "https://login.microsoftonline.com"
_DETECTED_APPS_MIN_GAP = 0.3 # seconds between get_detected_apps calls on one client
GRAPH_SCOPE = "https://graph.microsoft.com/.default"
_TRANSIENT_5XX = (500, 502, 503, 504)
class GraphAPIError(Exception):
"""Base exception for Microsoft Graph errors. `status` is the HTTP code
when Graph answered with one."""
def __init__(self, message: str = "", status: Optional[int] = None):
super().__init__(message)
self.status = status
class GraphAuthError(GraphAPIError):
"""Token acquisition / authentication failure."""
class GraphClient:
def __init__(
self,
tenant_id: str,
client_id: str,
client_secret: str,
verify_ssl: bool = True,
):
if not all([tenant_id, client_id, client_secret]):
raise ValueError("Intune/Graph requires tenant_id, client_id and client_secret.")
self.tenant_id = tenant_id
self.client_id = client_id
self.client_secret = client_secret
self.verify_ssl = verify_ssl
self._token: Optional[str] = None
self._token_expires_at: Optional[datetime] = None
self._v1_detected_apps_ok = True # flips off after the first v1.0 400
self._last_detected_apps_call = 0.0 # paces get_detected_apps across a device loop
# connect fails fast (unreachable), read generous for paged calls.
self.client = httpx.Client(
verify=verify_ssl,
timeout=httpx.Timeout(30.0, connect=8.0),
limits=httpx.Limits(max_connections=10, max_keepalive_connections=5),
)
# ---- auth ----------------------------------------------------------
def _authenticate(self) -> str:
url = f"{LOGIN_BASE}/{self.tenant_id}/oauth2/v2.0/token"
data = {
"grant_type": "client_credentials",
"client_id": self.client_id,
"client_secret": self.client_secret,
"scope": GRAPH_SCOPE,
}
try:
r = self.client.post(url, data=data)
if r.status_code in (400, 401):
# AAD returns error_description with the real cause.
detail = ""
try:
detail = r.json().get("error_description", "")[:200]
except Exception:
detail = r.text[:200]
raise GraphAuthError(f"token request rejected ({r.status_code}): {detail}")
r.raise_for_status()
payload = r.json()
except httpx.HTTPError as e:
raise GraphAuthError(f"token request failed: {e}") from e
token = payload.get("access_token")
if not token:
raise GraphAuthError("no access_token in token response")
expires_in = int(payload.get("expires_in", 3600))
self._token = token
# refresh 2 min before expiry
self._token_expires_at = datetime.now() + timedelta(seconds=max(60, expires_in - 120))
return token
def _ensure_token(self) -> str:
if not self._token or not self._token_expires_at or datetime.now() >= self._token_expires_at:
return self._authenticate()
return self._token
# ---- requests ------------------------------------------------------
@retry(
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, min=1, max=8),
retry=retry_if_exception_type((httpx.ReadTimeout, httpx.WriteTimeout)),
)
def _get(self, url: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
token = self._ensure_token()
for attempt in range(4):
r = self.client.get(url, headers={"Authorization": f"Bearer {token}",
"Accept": "application/json"}, params=params)
if r.status_code == 429:
# Honour Retry-After (Graph sets it); cap so a bad header can't hang us.
wait = r.headers.get("Retry-After")
delay = min(int(wait), 60) if (wait or "").isdigit() else (attempt + 1) * 5
logger.debug("Graph 429, waiting %ss (try %d)", delay, attempt + 1)
time.sleep(delay)
continue
if r.status_code in _TRANSIENT_5XX and attempt < 3:
# A per-device detectedApps call answers 500/503 now and then
# and the same call succeeds seconds later. Not retrying it
# dropped that device out of the whole run.
# ponytail: no circuit breaker — a full Graph outage costs ~12s
# per device; add one if a tenant's device count makes that hurt.
logger.info("Graph %s on %s, retrying (try %d)", r.status_code, url, attempt + 1)
time.sleep(2 * (attempt + 1))
continue
break
if r.status_code == 429:
raise GraphAPIError("Graph rate limit (429) after retries", status=429)
if r.status_code >= 400:
raise GraphAPIError(f"Graph GET {url} -> {r.status_code}: {r.text[:200]}",
status=r.status_code)
return r.json()
def _get_all(self, path: str, params: Optional[Dict[str, Any]] = None,
max_pages: int = 200, base: str = GRAPH_BASE) -> List[dict]:
"""Follow @odata.nextLink pagination, return the flattened value list."""
url = f"{base}{path}"
out: List[dict] = []
pages = 0
while url and pages < max_pages:
data = self._get(url, params=params)
out.extend(data.get("value", []) or [])
url = data.get("@odata.nextLink")
params = None # nextLink already carries the query
pages += 1
return out
# ---- API surface ---------------------------------------------------
def get_managed_devices(self) -> List[dict]:
"""All Intune managed devices.
Read from beta: only beta carries skuNumber (the Windows edition, which
decides the end of support — 11 24H2 Pro ended a year before
Enterprise). It is a list property, so it costs no extra call. Should
beta refuse the query (400: property gone or renamed), the same list
from v1.0 keeps the sync running without the edition — one extra call,
only then. Throttling, 5xx and auth errors are not retried on v1.0: a
second full read under quota pressure is the thing to avoid.
"""
fields = ("id,deviceName,operatingSystem,osVersion,complianceState,"
"lastSyncDateTime,enrolledDateTime,manufacturer,model,serialNumber,"
"managedDeviceOwnerType,azureADDeviceId,androidSecurityPatchLevel,"
"managementAgent")
try:
return self._get_all("/deviceManagement/managedDevices",
params={"$select": fields + ",skuNumber"},
base=GRAPH_BETA)
except GraphAPIError as e:
if e.status != 400:
raise
logger.warning("Graph beta managedDevices refused the query, reading "
"v1.0 without the Windows edition: %s", e)
return self._get_all("/deviceManagement/managedDevices",
params={"$select": fields})
def get_detected_apps(self, device_id: str) -> List[dict]:
"""Detected (installed) apps for one managed device → {name, version}.
Phase 2: feeds the existing EOL / M365 per-package detection.
detectedApps is not expandable on managedDevices in the v1.0 Graph
(returns HTTP 400). The reliable path is the dedicated navigation
property on the beta endpoint; we try v1.0 $expand once, then fall
back to beta's /detectedApps collection — and once v1.0 has 400'd we
skip it for the rest of this client's life (no 400 per device).
Paced: a device-by-device loop calling this in a tight sequence was
sustaining 429s from Graph (no per-call delay was enough on its own).
Enforce a minimum gap since the last call on this client.
"""
gap = time.monotonic() - self._last_detected_apps_call
if gap < _DETECTED_APPS_MIN_GAP:
time.sleep(_DETECTED_APPS_MIN_GAP - gap)
self._last_detected_apps_call = time.monotonic()
# 1) v1.0 $expand (works on some tenants) — only until it 400s once.
if self._v1_detected_apps_ok:
try:
data = self._get(
f"{GRAPH_BASE}/deviceManagement/managedDevices/{device_id}",
params={"$expand": "detectedApps"},
)
apps = data.get("detectedApps")
if apps is not None:
return self._map_apps(apps)
except GraphAPIError as e:
self._v1_detected_apps_ok = False
logger.debug("v1.0 detectedApps $expand unsupported, using beta: %s", e)
# 2) beta dedicated navigation collection (paginated). A failure
# raises: [] would read as "this device has no software", and every
# caller would skip the device without recording why.
out: List[dict] = []
url = f"{GRAPH_BETA}/deviceManagement/managedDevices/{device_id}/detectedApps"
pages = 0
while url and pages < 50:
data = self._get(url)
out.extend(self._map_apps(data.get("value", []) or []))
url = data.get("@odata.nextLink")
pages += 1
return out
@staticmethod
def _map_apps(apps: list) -> List[dict]:
out = []
for a in apps or []:
name = (a.get("displayName") or "").strip()
if name:
ver = (a.get("version") or "").strip()
# iOS apps come as "build (version)" — "0 (35.2.1)", "5771 (3.7.1)".
# The bare build is no version; read as one it sat below every bound.
m = re.fullmatch(r"\d+ \((\d+(?:\.\d+)+)\)", ver)
out.append({"name": name, "version": m.group(1) if m else ver,
"vendor": (a.get("publisher") or "").strip() or None})
return out
def test_connection(self) -> dict:
"""Acquire a token + read one device → connectivity check."""
try:
self._ensure_token()
except GraphAuthError as e:
return {"ok": False, "step": "auth", "error": str(e)}
try:
data = self._get(f"{GRAPH_BASE}/deviceManagement/managedDevices",
params={"$top": 1, "$select": "id,deviceName"})
# $count needs ConsistencyLevel; cheap proxy: did we get a page.
sample = len(data.get("value", []) or [])
return {"ok": True, "tenant": self.tenant_id, "device_sample": sample}
except GraphAPIError as e:
return {"ok": False, "step": "devices", "error": str(e)}
def close(self) -> None:
try:
self.client.close()
except Exception:
pass
+283
View File
@@ -0,0 +1,283 @@
"""IGEL UMS client (IGEL Management Interface, IMI API v3).
Thin httpx wrapper, no DB writes — same contract as the other integration
clients. Three things are read and nothing else:
* `GET /v3/serverstatus` — the UMS server's own identity (`rmGuiServerVersion`,
`buildNumber`, `serverUUID`). The only endpoint that answers without a
login, which is what makes it a usable reachability probe.
* `GET /v3/firmwares` — every firmware registered with this UMS, as
{id: product, version, firmwareType}. Small list (one row per firmware
ever imported), and the ONLY place the version string lives.
* `GET /v3/thinclients?facets=details` — every endpoint device in one call,
with its `firmwareID`, unit ID, MAC, last IP and hardware details.
Why the firmware table is fetched separately: the device record carries a
`firmwareID`, not a version — "21", not "11.09.100.01". Joining the two is
what turns an inventory into something a CVE range can be compared against,
and it costs exactly one extra request for the whole estate. The alternative
(`/v3/thinclients/{id}` per device, then `/v3/firmwares/{fwId}` per device) is
two calls per thin client; a 3000-device estate would make 6000.
Auth: HTTP Basic on POST /v3/login, which returns a JSESSIONID cookie valid
for 30 minutes, refreshed by every request. httpx.Client keeps the cookie jar,
so the login is a single call and everything after it just works.
Read-only throughout: a UMS account with the built-in read-only permissions is
enough — this client never POSTs a command to a device. IMI has to be enabled
and licensed on the UMS server first (UMS Console → Network → IMI).
API reference: https://kb.igel.com/en/igel-management-interface/current
"""
from __future__ import annotations
import logging
from typing import Dict, List, Optional
import httpx
logger = logging.getLogger(__name__)
class IgelError(RuntimeError):
"""Connection / authentication failure, with the IMI message kept."""
class IgelClient:
def __init__(self, host: str, username: str, password: str,
port: int = 8443, verify_ssl: bool = True):
self.host = (host or "").strip().replace("https://", "").replace("http://", "").rstrip("/")
self.username = username
self.password = password
self.port = int(port or 8443)
self.verify_ssl = verify_ssl
self.base_url = f"https://{self.host}:{self.port}/umsapi/v3"
# Short connect timeout, generous read: the same reasoning as the
# Nessus client — an unreachable UMS must fail fast instead of holding
# a worker, while `?facets=details` over a large 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=60.0, write=60.0, pool=5.0),
headers={"Accept": "application/json"},
)
self._logged_in = False
# ---------- connection ----------
def login(self) -> None:
if self._logged_in:
return
try:
r = self._client.post("/login", auth=(self.username, self.password))
except httpx.HTTPError as e:
raise IgelError(f"IMI login failed for {self.host}: {e}") from e
if r.status_code == 401:
raise IgelError("IMI login rejected the credentials (HTTP 401). "
"Check the UMS account, and that it is allowed to "
"use IMI (UMS Console → Network → IMI).")
if r.status_code >= 400:
raise IgelError(f"IMI login failed (HTTP {r.status_code}): {_imi_error(r)}")
self._logged_in = True
def close(self) -> None:
if self._logged_in:
try:
self._client.post("/logout")
except Exception:
pass
self._logged_in = False
self._client.close()
def _get(self, path: str) -> object:
self.login()
try:
r = self._client.get(path)
except httpx.HTTPError as e:
raise IgelError(f"IMI GET {path} failed: {e}") from e
if r.status_code >= 400:
raise IgelError(f"IMI GET {path} failed (HTTP {r.status_code}): {_imi_error(r)}")
try:
return r.json()
except ValueError as e:
raise IgelError(f"IMI GET {path} returned no JSON") from e
# ---------- reads ----------
def get_server_status(self) -> dict:
"""The UMS server's own identity. No login required (by design).
Asked anonymously, UMS 12 answers 200 with the whole envelope but
leaves its OWN version blank — `rmGuiServerVersion: ""` next to a
populated `activeMQVersion` and `derbyVersion`. The component versions
come from the running components, the server version from a property
an unauthenticated caller does not get to read. So: ask once without a
login (that is what makes this a reachability probe that can tell
"unreachable" from "credentials rejected"), and if the version came
back blank, log in and ask the same question again.
"""
d = self._serverstatus_payload()
out = _status_fields(d)
if not out["version"] and not self._logged_in:
try:
self.login()
except IgelError:
# Kept quiet on purpose: test_connection() calls login()
# itself right after this and reports the credential problem
# as its own step. Raising here would relabel a rejected
# password as an unreachable server.
pass
else:
d = self._serverstatus_payload()
authed = _status_fields(d)
if authed["version"]:
out = authed
if not out["version"]:
# Said out loud, with the keys the server actually sent AND the
# ones that were there but empty — the difference between "UMS
# renamed the field" (fix: add the new name below) and "UMS sent
# the field blank" (fix: something above did not authenticate) is
# exactly what this log line has to answer without a second round
# trip to the customer.
logger.warning(
"IMI serverstatus carried no version%s; keys were: %s; "
"known version keys present but empty: %s",
" (even after login)" if self._logged_in else "",
sorted(d) if isinstance(d, dict) else type(d).__name__,
[k for k in _STATUS_VERSION_KEYS
if isinstance(d, dict) and k in d
and not str(d.get(k) or "").strip()] or "none")
return out
def _serverstatus_payload(self) -> object:
"""The raw GET, without _get()'s implicit login."""
try:
r = self._client.get("/serverstatus")
except httpx.HTTPError as e:
raise IgelError(f"IMI unreachable at {self.base_url}: {e}") from e
if r.status_code >= 400:
raise IgelError(f"IMI serverstatus failed (HTTP {r.status_code}): {_imi_error(r)}")
return r.json() if r.content else {}
def get_firmwares(self) -> Dict[str, dict]:
"""{firmware id: {product, version, type}}.
Returned verbatim — deciding which firmware IS an IGEL OS is the
service's job (igel_service.os_from_firmware), not this client's.
UMS wraps the list in `FwResource`; a UMS that returns a bare list is
accepted too, because the same endpoint is documented both ways across
IMI versions and an unwrapped answer must not read as "no firmwares".
"""
# No trailing slash. UMS 12 routes "/firmwares/" to nothing and answers
# HTTP 404 "No endpoint GET /umsapi/v3/firmwares/" — the documented URL
# is /v3/firmwares, and the slash was silently costing the whole
# firmware table, which is the only place a version string lives.
data = self._get("/firmwares")
rows = data.get("FwResource", []) if isinstance(data, dict) else (data or [])
out: Dict[str, dict] = {}
for row in rows:
if not isinstance(row, dict) or not row.get("id"):
continue
out[str(row["id"])] = {
"product": (row.get("product") or "").strip(),
"version": (row.get("version") or "").strip(),
"type": (row.get("firmwareType") or "").strip(),
}
return out
def get_devices(self) -> List[dict]:
"""Every endpoint device, one call, with details.
Recycle-bin entries (`movedToBin`) are dropped here rather than by the
caller: UMS keeps a deleted device in the listing indefinitely, and a
device somebody deleted is exactly the one that should stop counting as
inventory.
"""
data = self._get("/thinclients?facets=details")
rows = data if isinstance(data, list) else (data or {}).get("TcResource", [])
return [self._device_dict(r) for r in rows
if isinstance(r, dict) and not r.get("movedToBin")]
def test_connection(self) -> dict:
"""Credential + reachability probe for the settings UI.
Server status first because it needs no login: that separates "cannot
reach the IMI service" from "reached it, credentials rejected", which
are two very different things to put in front of an operator.
"""
try:
status = self.get_server_status()
except IgelError as e:
return {"ok": False, "step": "connect", "error": str(e)}
try:
self.login()
except IgelError as e:
return {"ok": False, "step": "login", "error": str(e), "ums": status}
try:
devices = self.get_devices()
firmwares = self.get_firmwares()
except IgelError as e:
return {"ok": True, "step": "inventory", "error": str(e),
"ums": status, "device_count": None}
return {"ok": True, "ums": status, "device_count": len(devices),
"firmware_count": len(firmwares)}
@staticmethod
def _device_dict(r: dict) -> dict:
# Only what the sync writes onto an asset. `?facets=details` returns
# forty-odd fields per device (monitors, BIOS dates, uptime counters);
# carrying the ones nothing reads would be forty columns of estate
# 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(),
"ip_address": (r.get("lastIP") or "").strip() or None,
"device_type": (r.get("deviceType") or "").strip(),
"product_id": (r.get("productId") or "").strip(),
"os_type": (r.get("osType") or "").strip(),
}
# Documented IMI v3 spelling first, then the ones UMS 12 has been seen to use.
# https://kb.igel.com/en/igel-management-interface/current/get-v3-serverstatus
_STATUS_VERSION_KEYS = ("rmGuiServerVersion", "umsVersion", "serverVersion",
"version")
_STATUS_BUILD_KEYS = ("buildNumber", "build", "buildNo", "buildnumber")
def _status_fields(d: object) -> dict:
"""The four things the rest of the app wants out of a serverstatus body."""
return {
"version": _first(d, _STATUS_VERSION_KEYS),
"build": _first(d, _STATUS_BUILD_KEYS),
"server_uuid": _first(d, ("serverUUID", "serverUuid", "uuid")),
"server": d.get("server") if isinstance(d, dict) else None,
}
def _first(d: object, keys) -> Optional[str]:
"""First key present with a non-empty value, stringified. Empty is missing:
an UMS that answers "" for its version must not read as a version of ""."""
if not isinstance(d, dict):
return None
for k in keys:
v = d.get(k)
if v is not None and str(v).strip():
return str(v).strip()
return None
def _imi_error(r: httpx.Response) -> str:
"""The IMI error message, which lives in the body, not the status line."""
try:
body = r.json()
if isinstance(body, dict):
return str(body.get("message") or body.get("error") or body)[:300]
except Exception:
pass
return (r.text or "")[:300]
+60 -7
View File
@@ -85,30 +85,83 @@ class AIAnalysisService:
return ai_analysis return ai_analysis
def get_priority_recommendations(self, limit: int = 20, severity: Optional[str] = None) -> Dict[str, Any]: def get_priority_recommendations(self, limit: int = 20, severity: Optional[str] = None) -> Dict[str, Any]:
# Implementation similar to original but using universal client # Same scope the reports and the dashboard use: real CVEs on assets
query = self.db.query(Vulnerability).filter(Vulnerability.status == "open") # that still exist. Without it the audit ranked findings on
# decommissioned hosts and treated EOL- pseudo-CVEs as vulnerabilities.
from app.services.report_scope import scoped
query = scoped(self.db).filter(Vulnerability.status == "open")
if severity: if severity:
query = query.filter(Vulnerability.severity == severity) query = query.filter(Vulnerability.severity == severity)
top_vulns = query.order_by(desc(Vulnerability.cvss_score)).limit(limit).all() # ORDER BY cvss_score DESC put the WORST candidates first: Postgres
# sorts NULLs before everything on DESC, so every finding without a
# score — fresh Chrome CVEs carry none at all — was handed to the model
# as the top of the list, ahead of a scored 9.8. Rank by the scores the
# product computes for exactly this question, and put nulls last.
top_vulns = (query.order_by(
Vulnerability.priority_score.desc().nullslast(),
Vulnerability.cpr_score.desc().nullslast(),
Vulnerability.cvss_score.desc().nullslast(),
# Over-fetch, because the dedup below happens after the LIMIT: one CVE
# spread over 30 hosts would otherwise leave the model a handful of
# distinct findings instead of `limit` of them.
).limit(limit * 5).all())
if not top_vulns: if not top_vulns:
return {"recommendations": [], "global_strategy": "No open vulnerabilities found."} return {"recommendations": [], "global_strategy": "No open vulnerabilities found."}
# One entry per CVE. The same CVE on 30 hosts used to fill the whole
# list, so the model ranked one problem thirty times and never saw the
# other 29.
vuln_data = [] vuln_data = []
seen_cves = set()
for v in top_vulns: for v in top_vulns:
if len(vuln_data) >= limit:
break
if v.cve_id in seen_cves:
continue
seen_cves.add(v.cve_id)
# The model was given CVE id, host, CVSS and package — less than the
# dashboard shows. It could not weigh what actually decides urgency:
# known exploitation (KEV/EUVD), exploit probability (EPSS), and the
# asset's own criticality. Those are the fields that make an 7.5
# outrank a 9.8.
vuln_data.append({ vuln_data.append({
"cve_id": v.cve_id, "cve_id": v.cve_id,
"hostname": v.asset.hostname if v.asset else "Unknown", "hostname": v.asset.hostname if v.asset else "Unknown",
"cvss_score": v.cvss_score, "cvss_score": v.cvss_score,
"package_name": v.package_name "package_name": v.package_name,
"severity": v.severity.value if v.severity else None,
"priority_score": v.priority_score,
"epss_percentile": v.epss_percentile,
"known_exploited_cisa_kev": bool(v.kev_listed),
"listed_enisa_euvd": bool(v.euvd_listed),
"fixed_version": v.fixed_version,
"days_open": ((datetime.now() - v.detected_at).days
if v.detected_at else None),
}) })
# Prompt for structured prioritization # Prompt for structured prioritization
prompt = f"""You are a Vulnerability Management Expert. Analyze the following list of vulnerabilites and prioritize the top 10 that need immediate attention. # "top 10" was hardcoded while `limit` defaulted to 20 — the model was
# told to discard half its input for no stated reason, and at limit<10
# it was asked for more items than it had been given.
want = min(10, len(vuln_data))
prompt = f"""You are a vulnerability management expert. Rank the {want} findings below that need attention first.
Vulnerabilities: Rank by exploitability and blast radius, not by CVSS alone:
- known_exploited_cisa_kev = true outranks a higher CVSS that is not exploited.
- listed_enisa_euvd = true is the EU equivalent signal.
- epss_percentile is the probability of exploitation in the next 30 days (0-1).
- days_open shows how long the finding has been unaddressed.
- fixed_version = null means no patch exists yet, so advise mitigation instead.
- cvss_score may be null when the vendor published no score; judge such a
finding on its severity and the signals above rather than skipping it.
Findings ({len(vuln_data)} distinct CVEs):
{json.dumps(vuln_data)} {json.dumps(vuln_data)}
Return exactly {want} recommendations, most urgent first, priority numbered
from 1. Use only cve_id values from the list above — never invent one.
You MUST respond with a valid JSON object ONLY, following this exact structure: You MUST respond with a valid JSON object ONLY, following this exact structure:
{{ {{
"global_strategy": "Brief strategic summary of the risk landscape and focus areas.", "global_strategy": "Brief strategic summary of the risk landscape and focus areas.",
@@ -124,7 +177,7 @@ You MUST respond with a valid JSON object ONLY, following this exact structure:
}} }}
""" """
try: try:
response_text = self.ai_client._call_llm(prompt) response_text = self.ai_client._call_llm(prompt, json_mode=True)
# Clean DeepSeek <think> tags if present # Clean DeepSeek <think> tags if present
if "<think>" in response_text: if "<think>" in response_text:
+1 -1
View File
@@ -301,7 +301,7 @@ class NessusClient:
"Nessus Essentials — targeted host scans via the API " "Nessus Essentials — targeted host scans via the API "
"aren't supported by the free edition. Workarounds: " "aren't supported by the free edition. Workarounds: "
"(1) launch the scan manually from the Nessus UI, then " "(1) launch the scan manually from the Nessus UI, then "
"click \"Sync Data (Nessus)\" in VulnCheck to import " "click \"Sync Data (Nessus)\" in TrueVuln to import "
"results, or (2) upgrade to Nessus Professional / " "results, or (2) upgrade to Nessus Professional / "
"Tenable.io which support API-driven launches." "Tenable.io which support API-driven launches."
) from e ) from e
+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]
+216
View File
@@ -0,0 +1,216 @@
"""VMware vCenter Server (vSphere SOAP API) client.
Thin pyVmomi wrapper, no DB writes — same contract as the other integration
clients. Two things are read and nothing else:
* the vCenter appliance itself — `content.about` gives name, version, build
and instanceUuid;
* every ESXi host in the inventory — one PropertyCollector pass over
`HostSystem` for name, product version + build, management IP, hardware
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
is the one quantity a vSphere CVE decision needs (see vmware_release_service),
and only the SOAP inventory carries it per host.
The PropertyCollector matters at scale: a `RetrievePropertiesEx` with an
explicit property set answers for 500 hosts in one round trip, where touching
`host.config.product` per object would be 500 SOAP calls.
Read-only throughout. A vSphere role with `System.View` / `System.Read` (the
built-in **Read-only** role, propagated from the root folder) is enough — this
client never calls a method that changes anything.
"""
from __future__ import annotations
import logging
import ssl
from typing import List, Optional
logger = logging.getLogger(__name__)
# Properties fetched per ESXi host. Keep this list tight: every entry is
# serialised for every host in the estate.
_HOST_PROPS = [
"name",
"config.product.version",
"config.product.build",
"config.product.fullName",
"config.network.vnic",
# 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",
]
class VCenterError(RuntimeError):
"""Connection / authentication failure, with the vSphere fault text kept."""
class VCenterClient:
def __init__(self, host: str, username: str, password: str,
port: int = 443, verify_ssl: bool = True):
self.host = (host or "").strip().replace("https://", "").replace("http://", "").rstrip("/")
self.username = username
self.password = password
self.port = int(port or 443)
self.verify_ssl = verify_ssl
self._si = None
# ---------- connection ----------
def _ssl_context(self):
if self.verify_ssl:
return None # pyVmomi builds a verifying context itself
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
return ctx
def connect(self):
if self._si is not None:
return self._si
try:
from pyVim.connect import SmartConnect
except ImportError as e: # pragma: no cover - deployment error
raise VCenterError(
"pyvmomi is not installed — add it to requirements.txt") from e
try:
self._si = SmartConnect(
host=self.host, user=self.username, pwd=self.password,
port=self.port, sslContext=self._ssl_context(),
)
except Exception as e:
raise VCenterError(f"vCenter connect failed for {self.host}: {e}") from e
return self._si
def close(self) -> None:
if self._si is None:
return
try:
from pyVim.connect import Disconnect
Disconnect(self._si)
except Exception:
pass
self._si = None
# ---------- reads ----------
def get_about(self) -> dict:
"""The vCenter appliance's own identity."""
about = self.connect().content.about
return {
"name": getattr(about, "name", None),
"full_name": getattr(about, "fullName", None),
"version": getattr(about, "version", None),
"build": getattr(about, "build", None),
"api_version": getattr(about, "apiVersion", None),
"instance_uuid": getattr(about, "instanceUuid", None),
"os_type": getattr(about, "osType", None),
}
def test_connection(self) -> dict:
"""Credential + reachability probe for the settings UI."""
try:
about = self.get_about()
except VCenterError as e:
return {"ok": False, "step": "connect", "error": str(e)}
try:
hosts = len(self.get_hosts())
except Exception as e:
return {"ok": True, "step": "hosts", "error": str(e),
"vcenter": about, "host_count": None}
return {"ok": True, "vcenter": about, "host_count": hosts}
def get_hosts(self) -> List[dict]:
"""Every ESXi host in the inventory, one PropertyCollector pass."""
from pyVmomi import vim, vmodl
si = self.connect()
content = si.content
view = content.viewManager.CreateContainerView(
content.rootFolder, [vim.HostSystem], True)
try:
spec = vmodl.query.PropertyCollector.FilterSpec(
objectSet=[vmodl.query.PropertyCollector.ObjectSpec(
obj=view,
skip=False,
selectSet=[vmodl.query.PropertyCollector.TraversalSpec(
name="traverseEntities", path="view", skip=False,
type=vim.view.ContainerView)],
)],
propSet=[vmodl.query.PropertyCollector.PropertySpec(
type=vim.HostSystem, pathSet=_HOST_PROPS)],
)
result = content.propertyCollector.RetrieveContents([spec])
finally:
try:
view.Destroy()
except Exception:
pass
return [self._host_dict(o) for o in (result or [])]
@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": 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.
vmk0 is the management interface on a stock install, but not on every one —
an operator can move management to vmk1 and delete vmk0. So vmk0 is
preferred and the first adapter with an address is the fallback; an IPv6
literal is accepted as-is (the column takes 45 chars for exactly that).
"""
if not vnics:
return None
best = None
for vnic in vnics:
ip = getattr(getattr(getattr(vnic, "spec", None), "ip", None), "ipAddress", None)
if not ip:
continue
if getattr(vnic, "device", "") == "vmk0":
return str(ip)
best = best or str(ip)
return best
+433 -102
View File
@@ -16,6 +16,7 @@ https://documentation.wazuh.com/current/user-manual/api/reference.html
""" """
import os import os
import logging import logging
import re
import time import time
from typing import Optional, Dict, List, Any from typing import Optional, Dict, List, Any
from datetime import datetime, timedelta from datetime import datetime, timedelta
@@ -30,6 +31,61 @@ from tenacity import (
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# WindowsApps\<Name>_<Version>_<Arch>_<ResourceId>_<PublisherId> — the MSIX
# PackageFullName, which is the folder name.
_MSIX_DIR = re.compile(
r"\\WindowsApps\\([^\\_]+)_(\d+(?:\.\d+){3})_([^\\_]*)_[^\\_]*_([^\\_]+)\\?$",
re.IGNORECASE)
def drop_superseded_msix(packages: list) -> list:
"""Keep only the newest version of each MSIX package family.
Syscollector reads MSIX/AppX packages from every user hive, and a profile
keeps the registration of the version it last had after Windows removed
that folder. A Citrix host listed 27 "Microsoft Teams" builds while only
MSTeams_26225.1806.5074.1452 existed on disk; the rest drew Teams CVEs
for code that can no longer run, and no update could ever close them.
Windows does keep an older build on disk while a user still runs it; that
case is dropped too — accepted, since the app updates itself at that
user's next logon, while a stale registration never goes away.
"""
def family(p):
m = _MSIX_DIR.search((p.get("location") or "").strip())
if m:
return ((m.group(1).lower(), m.group(3).lower(), m.group(4).lower()),
tuple(int(x) for x in m.group(2).split(".")))
return None, None
newest: Dict[tuple, tuple] = {}
for p in packages:
fam, ver = family(p)
if fam:
newest[fam] = max(newest.get(fam, ver), ver)
keep = []
for p in packages:
fam, ver = family(p)
if fam is None or ver == newest[fam]:
keep.append(p)
if len(keep) < len(packages):
logger.debug("inventory: dropped %d superseded MSIX registration(s)",
len(packages) - len(keep))
return keep
# vulnerability.scanner.condition. Only these two wordings name a fixed
# version: "Package less than X" (4.x, 5.0 up to rc1) and "Fixed in X" (5.0
# rc2 on). "… or equal to X", "Package equal to X", "Affected up to X; no fixed
# version yet" and the like name the last AFFECTED version, so anything else
# is no fix at all, never a guess.
_FIX_CONDITION = re.compile(r"^\s*(?:Package less than|Fixed in)\s+(\S+)\s*$", re.IGNORECASE)
def fixed_version_from_condition(condition) -> Optional[str]:
m = _FIX_CONDITION.match(condition) if isinstance(condition, str) else None
return m.group(1) if m else None
class WazuhAPIError(Exception): class WazuhAPIError(Exception):
"""Base exception for Wazuh API errors""" """Base exception for Wazuh API errors"""
pass pass
@@ -64,7 +120,7 @@ class WazuhClient:
indexer_url: Optional[str] = None, indexer_url: Optional[str] = None,
indexer_username: Optional[str] = None, indexer_username: Optional[str] = None,
indexer_password: Optional[str] = None, indexer_password: Optional[str] = None,
verify_ssl: bool = True verify_ssl: Optional[bool] = None
): ):
""" """
Initializes the Wazuh API Client Initializes the Wazuh API Client
@@ -93,6 +149,7 @@ class WazuhClient:
self._token: Optional[str] = None self._token: Optional[str] = None
self._token_expires_at: Optional[datetime] = None self._token_expires_at: Optional[datetime] = None
self._major: Optional[int] = None # server major version, read once
# HTTP Client mit Timeouts und Connection-Pooling # HTTP Client mit Timeouts und Connection-Pooling
self.client = httpx.Client( self.client = httpx.Client(
@@ -142,6 +199,12 @@ class WazuhClient:
logger.error(f"Wazuh Auth error: {e}") logger.error(f"Wazuh Auth error: {e}")
raise WazuhAuthenticationError(f"Authentication failed: {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: def _ensure_authenticated(self) -> str:
""" """
Stellt sicher, dass ein gültiger Token vorhanden ist Stellt sicher, dass ein gültiger Token vorhanden ist
@@ -234,6 +297,85 @@ class WazuhClient:
logger.error(f"Wazuh Indexer Request Error: {e}") logger.error(f"Wazuh Indexer Request Error: {e}")
raise WazuhAPIError(f"Indexer Request failed: {e}") raise WazuhAPIError(f"Indexer Request failed: {e}")
# ============================================
# Wazuh 4.x vs 5.x
# ============================================
#
# Wazuh 5 removed /syscollector/*, /sca/* and /manager/* from the API:
# inventory and SCA live only in the indexer. Its states indices carry the
# agent as wazuh.agent.id (4.x: agent.id) and have no cluster suffix
# (wazuh-states-vulnerabilities, 4.x: wazuh-states-vulnerabilities-<cluster>).
@property
def is_v5(self) -> bool:
if self._major is None:
try:
ver = str((self._request("GET", "/").get("data") or {}).get("api_version") or "")
except WazuhAPIError:
# The CVE sync reads only the indexer and kept running while
# the manager API was down; it must still. 5 creates the
# unsuffixed index at setup, 4.x never has one.
if not self.indexer_url:
raise
try:
self._indexer_request("POST", "/wazuh-states-vulnerabilities/_search",
json_data={"size": 0})
ver = "5"
except WazuhAPIError as e:
# 404: no such index. 403: a 4.x role that reads only
# wazuh-states-vulnerabilities-* may not even look. Read
# wrongly as 4 on a 5 indexer, the suffixed pattern finds
# nothing for anyone, and an all-empty run closes nothing.
if not re.search(r"'40[34] |index_not_found|security_exception", str(e)):
raise
ver = "4"
m = re.match(r"v?(\d+)", ver)
if not m:
raise WazuhAPIError(f"Unrecognised Wazuh API version {ver!r}")
self._major = int(m.group(1))
return self._major >= 5
def _states_index(self, kind: str) -> str:
"""The states index for `kind` ("vulnerabilities", "inventory-packages").
Exact on 5: the documented migration restores 4.x indices next to the
new ones as read-only history, and a pattern would read those too —
findings that host patched long ago would never close."""
return f"/wazuh-states-{kind}" if self.is_v5 else f"/wazuh-states-{kind}-*"
def _agent_term(self, agent_id: str) -> Dict[str, Any]:
# The mappings are dynamic: strict — the wrong field matches nothing
# and raises nothing, so this must be the right one.
return {"term": {("wazuh.agent.id" if self.is_v5 else "agent.id"): str(agent_id)}}
def _require_inventory(self, agent_id: str) -> None:
"""On 5, an empty answer for an agent without a software inventory is
no data, not a clean host. A 5 server drops the sync of every agent
before 5.0 (migration-to-5x/wazuh-agents) — mid-migration that is
most of the estate — and a 5.x agent has none until its first sync;
either way nothing was scanned, and an empty host gets its findings
closed. Raise instead: every caller handles a per-agent failure.
Worded so source_failure() does not take it for an outage."""
n = self._indexer_request(
"POST", f"{self._states_index('inventory-packages')}/_count",
json_data={"query": self._agent_term(agent_id)}).get("count")
if not n:
raise WazuhAPIError(
f"Agent {agent_id} has no software inventory in the Wazuh 5 indexer: "
f"a 5 server keeps none for agents before 5.0, and a 5.x agent has "
f"none until its first inventory sync. Nothing was read for it")
def _states_docs(self, kind: str, agent_id: str, extra: Optional[List[dict]] = None,
source: Optional[List[str]] = None) -> List[Dict[str, Any]]:
"""Every _source of one agent in a 5 states index (scrolled). None at
all raises unless the agent has an inventory (_require_inventory)."""
query = {"bool": {"must": [self._agent_term(agent_id)] + (extra or [])}}
hits, _ = self._scroll_hits(agent_id, query, 5000, 50_000,
index=self._states_index(kind), source=source)
if not hits:
self._require_inventory(agent_id)
return [h.get("_source") or {} for h in hits]
# ============================================ # ============================================
# Agent Management # Agent Management
# ============================================ # ============================================
@@ -272,7 +414,8 @@ class WazuhClient:
Returns: Returns:
Agent-Details als Dictionary Agent-Details als Dictionary
""" """
response = self._request("GET", f"/agents/{agent_id}") # There is no GET /agents/{id} in the 4.x or the 5 API spec.
response = self._request("GET", "/agents", params={"agents_list": str(agent_id)})
items = response.get("data", {}).get("affected_items", []) items = response.get("data", {}).get("affected_items", [])
if not items: if not items:
raise WazuhAPIError(f"Agent {agent_id} not found") raise WazuhAPIError(f"Agent {agent_id} not found")
@@ -282,6 +425,86 @@ class WazuhClient:
# Vulnerability Management # Vulnerability Management
# ============================================ # ============================================
def _paged_hits(self, agent_id, query, page_size, offset, max_total, index):
"""Classic from/size paging. OpenSearch rejects from+size beyond
index.max_result_window (default 10000) with a 400, so this path stops
at that ceiling instead of blowing up. Used only when an explicit
offset is requested; full syncs scroll instead."""
RESULT_WINDOW = 10_000
hits: List[Dict[str, Any]] = []
total_hits = 0
page_offset = offset
while True:
body = {"size": page_size, "from": page_offset, "query": query,
"track_total_hits": True}
response = self._indexer_request("POST", f"{index}/_search", json_data=body)
page_hits = response.get("hits", {}).get("hits", []) or []
total_hits = response.get("hits", {}).get("total", {}).get("value", 0)
hits.extend(page_hits)
if not page_hits or len(hits) >= total_hits or len(hits) >= max_total:
break
page_offset += page_size
if page_offset + page_size > RESULT_WINDOW:
logger.warning(
f"Agent {agent_id}: stopping at the {RESULT_WINDOW}-doc result window "
f"({total_hits - len(hits)} left); use offset=0 to scroll them all"
)
break
return hits, total_hits
def _scroll_hits(self, agent_id, query, page_size, max_total, index, source=None):
"""Scroll the full result set — no result-window ceiling. Falls back to
from/size (window-bounded) if the indexer refuses scroll."""
hits: List[Dict[str, Any]] = []
total_hits = 0
scroll_id = None
try:
body = {"size": page_size, "query": query, "track_total_hits": True}
if source:
body["_source"] = source
response = self._indexer_request(
"POST", f"{index}/_search?scroll=2m", json_data=body
)
except WazuhAPIError as e:
logger.warning(
f"Agent {agent_id}: scroll unavailable ({e}); falling back to windowed paging"
)
return self._paged_hits(agent_id, query, page_size, 0, max_total, index)
try:
while True:
scroll_id = response.get("_scroll_id") or scroll_id
page_hits = response.get("hits", {}).get("hits", []) or []
total_hits = response.get("hits", {}).get("total", {}).get("value", total_hits)
hits.extend(page_hits)
logger.info(
f"Agent {agent_id}: scroll page returned {len(page_hits)} hits "
f"(total: {total_hits}, accumulated: {len(hits)})"
)
if not page_hits or len(hits) >= total_hits:
break
if len(hits) >= max_total:
logger.warning(
f"Agent {agent_id}: hit MAX_TOTAL cap of {max_total}; "
f"{total_hits - len(hits)} documents of {index} skipped"
)
break
if not scroll_id:
break
response = self._indexer_request(
"POST", "/_search/scroll",
json_data={"scroll": "2m", "scroll_id": scroll_id},
)
finally:
if scroll_id:
try:
self._indexer_request(
"DELETE", "/_search/scroll", json_data={"scroll_id": [scroll_id]}
)
except Exception:
pass # context expires on its own
return hits, total_hits
def get_vulnerabilities( def get_vulnerabilities(
self, self,
agent_id: str, agent_id: str,
@@ -329,22 +552,8 @@ class WazuhClient:
response = self._request("GET", f"/vulnerability/{agent_id}", params=params) response = self._request("GET", f"/vulnerability/{agent_id}", params=params)
return response.get("data", {}).get("affected_items", []) return response.get("data", {}).get("affected_items", [])
def query_vulnerabilities_from_indexer( def _solved_cves_from_alerts(self, agent_id: str) -> set:
self, """4.x: CVEs whose LATEST alert status is "Solved"."""
agent_id: str,
limit: int = 5000,
offset: int = 0
) -> List[Dict[str, Any]]:
"""
Holt Vulnerabilities aus dem Wazuh Indexer (OpenSearch)
Erforderlich ab Wazuh 4.8.
Filtert CVEs deren LETZTER Alert-Status "Solved" ist.
Wichtig: Der alte Code hat alle CVEs gefiltert, die IRGENDWANN mal
"Solved" waren. Das ist falsch, weil eine CVE erneut auftreten kann.
Jetzt wird per top_hits nur der aktuellste Status pro CVE geprueft.
"""
# Step 1: Get CVEs whose LATEST alert status is "Solved"
solved_cves = set() solved_cves = set()
try: try:
solved_query = { solved_query = {
@@ -388,53 +597,57 @@ class WazuhClient:
logger.info(f"Agent {agent_id}: Found {len(solved_cves)} CVEs with latest status 'Solved' to exclude") logger.info(f"Agent {agent_id}: Found {len(solved_cves)} CVEs with latest status 'Solved' to exclude")
except Exception as e: except Exception as e:
logger.warning(f"Could not query solved CVEs from alerts: {e}") logger.warning(f"Could not query solved CVEs from alerts: {e}")
return solved_cves
def query_vulnerabilities_from_indexer(
self,
agent_id: str,
limit: int = 5000,
offset: int = 0
) -> List[Dict[str, Any]]:
"""
Holt Vulnerabilities aus dem Wazuh Indexer (OpenSearch)
Erforderlich ab Wazuh 4.8.
Filtert CVEs deren LETZTER Alert-Status "Solved" ist.
Wichtig: Der alte Code hat alle CVEs gefiltert, die IRGENDWANN mal
"Solved" waren. Das ist falsch, weil eine CVE erneut auftreten kann.
Jetzt wird per top_hits nur der aktuellste Status pro CVE geprueft.
"""
# Wazuh 5 has no alerts index, and needs none: a solved detection is
# deleted from the states index ("deletes mark detections as solved").
v5 = self.is_v5
solved_cves = set() if v5 else self._solved_cves_from_alerts(agent_id)
# Step 2: Query active vulnerabilities from states index. # Step 2: Query active vulnerabilities from states index.
# Pagination: OpenSearch caps from+size at index.max_result_window # Paged via scroll (see _scroll_hits) — from/size is hard-capped by
# (default 10000), so we loop in pages of `limit` (default 5000) # OpenSearch's index.max_result_window (default 10000) and 400s beyond
# until total_hits is drained or we hit MAX_TOTAL safety cap. # it, which made big agents look like "0 vulns" and skip their backfill.
# Prior behaviour was a single 5000-hit fetch — silently dropped # MAX_TOTAL is a memory guard, not a protocol limit: 50k covers every
# everything above 5000 on agents with very large finding sets # real Wazuh fleet without unbounded growth.
# (tester saw 10000 hits returning only 5000). 50k cap covers
# every real Wazuh fleet without unbounded memory growth.
MAX_TOTAL = 50_000 MAX_TOTAL = 50_000
PAGE_SIZE = max(1, min(limit, 5000)) PAGE_SIZE = max(1, min(limit, 5000))
hits: List[Dict[str, Any]] = [] hits: List[Dict[str, Any]] = []
total_hits = 0 total_hits = 0
page_offset = offset base_query = {"bool": {"must": [self._agent_term(agent_id)]}}
index = self._states_index("vulnerabilities")
try: try:
while True: if offset:
query = { # Explicit offset requested → keep the classic from/size path,
"size": PAGE_SIZE, # bounded below the result window (see _paged_hits).
"from": page_offset, hits, total_hits = self._paged_hits(
"query": { agent_id, base_query, PAGE_SIZE, offset, MAX_TOTAL, index
"bool": {
"must": [{"term": {"agent.id": agent_id}}]
}
},
"track_total_hits": True,
}
response = self._indexer_request(
"POST",
"/wazuh-states-vulnerabilities-*/_search",
json_data=query,
) )
page_hits = response.get("hits", {}).get("hits", []) or [] else:
total_hits = response.get("hits", {}).get("total", {}).get("value", 0) # Full sync: scroll. from/size is capped by OpenSearch's
hits.extend(page_hits) # index.max_result_window (default 10000) — agents with more
logger.info( # findings 400'd at from=10000, the whole agent then looked like
f"Agent {agent_id}: page from={page_offset} returned " # "0 vulns" and its backfill was skipped (observed: agents with
f"{len(page_hits)} hits (total: {total_hits}, accumulated: {len(hits)})" # 24k/27k findings). Scroll has no such ceiling and needs no
# unique sort field.
hits, total_hits = self._scroll_hits(
agent_id, base_query, PAGE_SIZE, MAX_TOTAL, index
) )
if not page_hits or len(hits) >= total_hits:
break
if len(hits) >= MAX_TOTAL:
logger.warning(
f"Agent {agent_id}: hit MAX_TOTAL cap of {MAX_TOTAL}; "
f"{total_hits - len(hits)} vulnerabilities skipped"
)
break
page_offset += PAGE_SIZE
results = [] results = []
skipped_no_cve = 0 skipped_no_cve = 0
@@ -461,7 +674,7 @@ class WazuhClient:
# Wazuh-Indexer emits -1 / out-of-range placeholders for # Wazuh-Indexer emits -1 / out-of-range placeholders for
# packages it could not score. Clamp anything outside the # packages it could not score. Clamp anything outside the
# CVSSv3 spec range [0.0, 10.0] back to None so downstream # CVSSv3 spec range [0.0, 10.0] back to None so downstream
# CPR maths cannot produce negative results (tester saw # CPR maths cannot produce negative results (observed:
# CPR=-9.3 driven by cvss_score=-1). # CPR=-9.3 driven by cvss_score=-1).
base_score = None base_score = None
try: try:
@@ -472,23 +685,11 @@ class WazuhClient:
except (TypeError, ValueError): except (TypeError, ValueError):
base_score = None base_score = None
# Fixed-version extraction. Wazuh 4.x writes this in a # 4.x and 5 alike write it only into the condition text
# few different fields depending on the feed; try them # (vulnerability.scanner.condition; both mappings are
# in order, then fall back to regex-parsing the # dynamic: strict, there is no fix field).
# condition string ("Package less than X.Y.Z"). fixed_version = fixed_version_from_condition(
fixed_version = ( (vuln.get("scanner") or {}).get("condition"))
vuln.get("fix")
or vuln.get("fixed_version")
or (pkg.get("fix") if isinstance(pkg, dict) else None)
)
if not fixed_version:
cond = vuln.get("condition") or (pkg.get("condition") if isinstance(pkg, dict) else None)
if isinstance(cond, str):
# "Package less than 1.2.3", "less than 4.5", etc.
import re as _re
m = _re.search(r"less than\s+([0-9][\w\.\-:+~]*)", cond, _re.IGNORECASE)
if m:
fixed_version = m.group(1)
# Mapping auf das interne Format # Mapping auf das interne Format
results.append({ results.append({
@@ -500,12 +701,16 @@ class WazuhClient:
}, },
"name": pkg.get("name"), "name": pkg.get("name"),
"version": pkg.get("version"), "version": pkg.get("version"),
"vendor": pkg.get("vendor"),
"fixed_version": fixed_version, "fixed_version": fixed_version,
"severity": vuln.get("severity"), "severity": vuln.get("severity"),
"title": vuln.get("title") or vuln.get("description"), "title": vuln.get("title") or vuln.get("description"),
"detected_at": vuln.get("detected_at") or source.get("@timestamp") "detected_at": vuln.get("detected_at") or source.get("@timestamp")
}) })
if v5 and not hits:
self._require_inventory(agent_id)
if skipped_no_cve: if skipped_no_cve:
logger.warning(f"Agent {agent_id}: Skipped {skipped_no_cve} entries without CVE ID") logger.warning(f"Agent {agent_id}: Skipped {skipped_no_cve} entries without CVE ID")
if skipped_solved: if skipped_solved:
@@ -513,23 +718,16 @@ class WazuhClient:
logger.info(f"Agent {agent_id}: Returning {len(results)} vulnerabilities from indexer") logger.info(f"Agent {agent_id}: Returning {len(results)} vulnerabilities from indexer")
return results return results
except WazuhAPIError:
raise
except Exception as e: 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}") 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]:
"""
Holt Vulnerability-Zusammenfassung für einen Agent
Args:
agent_id: Wazuh Agent ID
Returns:
Dictionary mit Counts pro Severity-Level
Beispiel: {"critical": 5, "high": 12, "medium": 30, "low": 45}
"""
response = self._request("GET", f"/vulnerability/{agent_id}/summary")
return response.get("data", {}).get("affected_items", [{}])[0]
# ============================================ # ============================================
# Syscollector (System Inventory) # Syscollector (System Inventory)
@@ -570,8 +768,54 @@ class WazuhClient:
Returns: Returns:
Liste von Package-Dictionaries Liste von Package-Dictionaries
""" """
if self.is_v5:
# The API's item shape, so every consumer reads both alike.
items = [{"name": p.get("name"), "version": p.get("version"),
"vendor": p.get("vendor"), "architecture": p.get("architecture"),
"location": p.get("path"), "format": p.get("type"),
"description": p.get("description"), "size": p.get("size"),
"install_time": p.get("installed"), "source": p.get("source"),
"priority": p.get("priority"), "multiarch": p.get("multiarch")}
for p in (d.get("package") or {}
for d in self._states_docs("inventory-packages", agent_id))]
else:
response = self._request("GET", f"/syscollector/{agent_id}/packages") response = self._request("GET", f"/syscollector/{agent_id}/packages")
return response.get("data", {}).get("affected_items", []) items = response.get("data", {}).get("affected_items", [])
return drop_superseded_msix(items)
def get_cluster_nodes(self) -> List[Dict[str, Any]]:
"""The manager nodes themselves: [{name, type, version, ip}].
Master and worker nodes normally run no agent — they inventory other
hosts and nobody inventories them — so they never appeared as assets
and their own CVEs went unseen. The API knows its own version without
an agent being involved. Falls back to /manager/info on a standalone
install, where the cluster endpoint is disabled.
"""
nodes: List[Dict[str, Any]] = []
try:
resp = self._request("GET", "/cluster/nodes")
for n in resp.get("data", {}).get("affected_items", []) or []:
if n.get("name"):
nodes.append({"name": n.get("name"), "type": n.get("type") or "node",
"version": (n.get("version") or "").lstrip("v"),
"ip": n.get("ip")})
except Exception as e:
logger.debug("cluster/nodes unavailable (standalone?): %s", e)
if nodes:
return nodes
try:
info = self._request("GET", "/manager/info").get("data", {})
items = info.get("affected_items") or [info]
for i in items:
if i.get("name") or i.get("version"):
nodes.append({"name": i.get("name") or "wazuh-manager",
"type": i.get("type") or "master",
"version": (i.get("version") or "").lstrip("v"),
"ip": None})
except Exception as e:
logger.warning("wazuh: could not read manager version: %s", e)
return nodes
def get_ports(self, agent_id: str) -> List[Dict[str, Any]]: def get_ports(self, agent_id: str) -> List[Dict[str, Any]]:
"""Open ports / listeners from syscollector. """Open ports / listeners from syscollector.
@@ -580,22 +824,79 @@ class WazuhClient:
pid, ...}. We use the listening sockets to gauge network pid, ...}. We use the listening sockets to gauge network
exposure (Plan: risk-exposure indicator). exposure (Plan: risk-exposure indicator).
""" """
if not self.is_v5:
response = self._request("GET", f"/syscollector/{agent_id}/ports") response = self._request("GET", f"/syscollector/{agent_id}/ports")
return response.get("data", {}).get("affected_items", []) return response.get("data", {}).get("affected_items", [])
ports = []
for d in self._states_docs("inventory-ports", agent_id):
src, dst, proc = (d.get(k) or {} for k in ("source", "destination", "process"))
ports.append({"local": {"ip": src.get("ip"), "port": src.get("port")},
"remote": {"ip": dst.get("ip"), "port": dst.get("port")},
"protocol": (d.get("network") or {}).get("transport"),
"state": (d.get("interface") or {}).get("state"),
"process": proc.get("name"), "pid": proc.get("pid")})
return ports
def get_os_info(self, agent_id: str) -> Dict[str, Any]: def get_browser_extensions(self, agent_id: str) -> List[Dict[str, Any]]:
"""Installed browser extensions for one agent, from IT Hygiene.
These live in the INDEXER, not the manager API — syscollector has no
endpoint for them. Without this the browser extensions are a blind
spot: the Acrobat extension for Chrome ships its own CVEs
(CVE-2026-48294) that no other inventory can reach.
Returns the package block enriched with the browser, e.g.
{name, version, id, enabled, browser, profile}. Empty list when the
indexer is not configured — the caller then simply scans nothing.
""" """
Holt OS-Informationen eines Agents if not self.indexer_url:
return []
Args: body = {
agent_id: Wazuh Agent ID "size": 1000,
"query": self._agent_term(agent_id),
Returns: "_source": ["browser.name", "browser.profile.name",
OS-Details (Name, Version, Kernel, etc.) "package.name", "package.version", "package.id",
""" "package.enabled", "package.vendor",
response = self._request("GET", f"/syscollector/{agent_id}/os") "package.from_webstore"],
items = response.get("data", {}).get("affected_items", []) }
return items[0] if items else {} try:
resp = self._indexer_request(
"POST", f"{self._states_index('inventory-browser-extensions')}/_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, 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 {}
pkg = src.get("package") or {}
if not pkg.get("name"):
continue
out.append({
"name": pkg.get("name"),
"version": pkg.get("version"),
"id": pkg.get("id"),
"enabled": pkg.get("enabled"),
"vendor": pkg.get("vendor"),
"browser": ((src.get("browser") or {}).get("name") or "").lower(),
"profile": (((src.get("browser") or {}).get("profile") or {})
.get("name")),
})
if not out and self.is_v5:
self._require_inventory(agent_id)
return out
# ============================================ # ============================================
# SCA — Security Configuration Assessment # SCA — Security Configuration Assessment
@@ -616,8 +917,30 @@ class WazuhClient:
"score": 78, "end_scan": "2026-05-19T01:00:00Z", ... "score": 78, "end_scan": "2026-05-19T01:00:00Z", ...
} }
""" """
if not self.is_v5:
response = self._request("GET", f"/sca/{agent_id}") response = self._request("GET", f"/sca/{agent_id}")
return response.get("data", {}).get("affected_items", []) or [] return response.get("data", {}).get("affected_items", []) or []
# Wazuh 5 stores one document per check and no summary: count them.
# pass/fail as 4.x; everything else ("Not applicable", "Not run") is
# what 4.x called invalid. end_scan: the newest check update.
policies: Dict[str, Dict[str, Any]] = {}
for d in self._states_docs("sca", agent_id, source=[
"policy.id", "policy.name", "policy.description",
"check.result", "state.modified_at"]):
pol = d.get("policy") or {}
if not pol.get("id"):
continue
p = policies.setdefault(pol["id"], {
"policy_id": pol["id"], "name": pol.get("name"),
"description": pol.get("description"),
"pass": 0, "fail": 0, "invalid": 0, "total_checks": 0, "end_scan": None})
result = str((d.get("check") or {}).get("result") or "").lower()
p["pass" if result == "passed" else "fail" if result == "failed" else "invalid"] += 1
p["total_checks"] += 1
modified = (d.get("state") or {}).get("modified_at")
if modified and (p["end_scan"] is None or modified > p["end_scan"]):
p["end_scan"] = modified
return list(policies.values())
def get_sca_checks( def get_sca_checks(
self, self,
@@ -634,12 +957,20 @@ class WazuhClient:
summaries. Returns up to ``limit`` checks; the default 500 is summaries. Returns up to ``limit`` checks; the default 500 is
enough for the largest CIS benchmarks (~250-400 checks). enough for the largest CIS benchmarks (~250-400 checks).
""" """
if not self.is_v5:
response = self._request( response = self._request(
"GET", "GET",
f"/sca/{agent_id}/checks/{policy_id}", f"/sca/{agent_id}/checks/{policy_id}",
params={"limit": limit}, params={"limit": limit},
) )
return response.get("data", {}).get("affected_items", []) or [] return response.get("data", {}).get("affected_items", []) or []
# 4.x API shape; result in its lower-case spelling ("Passed" → "passed").
return [{"id": c.get("id"), "title": c.get("name"),
"description": c.get("description"), "rationale": c.get("rationale"),
"remediation": c.get("remediation"),
"result": str(c.get("result") or "").lower() or None}
for c in (d.get("check") or {} for d in self._states_docs(
"sca", agent_id, extra=[{"term": {"policy.id": str(policy_id)}}]))][:limit]
# ============================================ # ============================================
# Helper-Funktionen # Helper-Funktionen
+288
View File
@@ -0,0 +1,288 @@
"""XenServer client (XAPI on the pool coordinator).
Thin httpx wrapper, no DB writes — same contract as the other integration
clients. Read-only: three calls and one HTTP GET, nothing that changes state.
* XML-RPC `session.login_with_password`, `pool.get_all_records`,
`host.get_all_records` — the pool's hosts with UUID, name, management
address and `software_version` (product version, Xen version).
* `GET /updates` — per host, the ids of the updates the pool has synced but
the host has not applied, and the update records themselves. A security
record lists its CVEs in the description. This is the endpoint XenCenter's
Updates view is drawn from, and it exists from XenServer 8 on (the
repository-based update model); Citrix Hypervisor 8.2 answers it with 404.
Why the coordinator and only the coordinator: XAPI on a pool member refuses
the login with HOST_IS_SLAVE and names the coordinator, and the coordinator
answers for every host in the pool — one connection per pool, not per host.
The role moves (HA failover, `pool-designate-new-master`), so the configured
address may be any pool host: on HOST_IS_SLAVE the client follows to the
coordinator the member named, on every connect (by name when the
certificate is verified, see _verifiable_name). Several pool hosts may be
configured, comma-separated: the configured host can be the one that died, so
the next one is tried — but only when no connection could be opened (refused,
connect timeout, TLS) or the entry is not an address. Once the login was sent,
a timeout or a refused login stops the run: retrying it elsewhere would resend
the credentials, and failed logins count towards a lockout.
XML-RPC is spoken over the same httpx client instead of xmlrpc.client's own
transport: that one has no timeout, and an unreachable coordinator must fail
fast instead of holding a worker.
`/updates` authenticates with HTTP basic auth, the form XenServer's own docs
show and the one verified against a live 8.4 pool (2026-10-23).
"""
from __future__ import annotations
import ipaddress
import logging
import re
import socket
import xmlrpc.client
from datetime import datetime
from typing import List, Optional
import httpx
logger = logging.getLogger(__name__)
API_VERSION = "2.21"
class XenServerError(RuntimeError):
"""Connection / authentication / XAPI failure, with XAPI's message kept."""
class HostUnreachable(XenServerError):
"""No connection to the host (refused, connect timeout, TLS); nothing was sent."""
class HostIsSlave(XenServerError):
"""HOST_IS_SLAVE: the host asked is a pool member; XAPI names the coordinator."""
def __init__(self, member: str, coordinator: str):
self.coordinator = coordinator
super().__init__(f"{member} is a pool member and names "
+ (f"{coordinator} as pool coordinator" if coordinator
else "no pool coordinator"))
def _origin(host: str, port: int) -> str:
try:
v6 = ipaddress.ip_address(host).version == 6
except ValueError:
v6 = False
return f"https://[{host}]:{port}" if v6 else f"https://{host}:{port}"
def _clean_host(host: str) -> str:
h = (host or "").strip()
for prefix in ("https://", "http://"):
if h.lower().startswith(prefix):
h = h[len(prefix):]
return h.rstrip("/")
def _verifiable_name(address: str) -> str:
"""The name to reach a coordinator by when its certificate is verified.
HOST_IS_SLAVE names the coordinator by its management IP, and a pool
certificate carries names. The name comes from reverse DNS and must
resolve back to that IP, so the connection still goes to the host the
member named; the certificate is then checked against the name. No PTR
record, or one that resolves elsewhere: no fallback to the bare IP.
"""
try:
ip = ipaddress.ip_address(address)
except ValueError:
return address
try:
name = socket.gethostbyaddr(address)[0]
forward = {ipaddress.ip_address(ai[4][0].split("%")[0])
for ai in socket.getaddrinfo(name, None)}
except (OSError, ValueError) as e:
raise HostUnreachable(
f"Verify SSL needs a name for {address}, and reverse DNS has none ({e}) — "
"add a PTR record, or configure the coordinator by name as a further pool host") from e
if ip not in forward:
raise HostUnreachable(
f"reverse DNS names {address} {name}, but {name} does not resolve back to it")
return name
def xapi_datetime(value) -> Optional[datetime]:
"""XAPI's dateTime.iso8601 ("20261022T10:59:07Z", UTC) → naive UTC.
XAPI writes the epoch for "never", so that reads as None too.
"""
raw = str(getattr(value, "value", value) or "").strip().rstrip("Z")
try:
dt = datetime.strptime(raw, "%Y%m%dT%H:%M:%S")
except ValueError:
return None
return None if dt.year <= 1970 else dt
class XenServerClient:
def __init__(self, host: str, username: str, password: str,
port: int = 443, verify_ssl: bool = True):
self.hosts = list(dict.fromkeys(
h for h in (_clean_host(x) for x in re.split(r"[,\s]+", host or "")) if h))
# The configured entry in use: the first until it does not answer (see login).
self.host = self.hosts[0] if self.hosts else ""
# Entries passed over on the last login, "<host>: <why>" — reported by
# test_connection so a dead or mistyped entry does not go unnoticed.
self.skipped: List[str] = []
self.username = (username or "").strip()
self.password = password or ""
self.port = int(port or 443)
self.verify_ssl = verify_ssl
# Short connect, generous read: /updates is computed on request from
# the synced repository and takes seconds on a large pool.
self._client = httpx.Client(
verify=verify_ssl,
timeout=httpx.Timeout(connect=8.0, read=180.0, write=30.0, pool=5.0))
# The address actually spoken to: the configured host until a member
# hands over to the coordinator it names (see login).
self.coordinator = self.host
self.base_url = ""
try:
self._point_at(self.host)
except XenServerError:
pass # login reports it with the other entries
self._session: Optional[str] = None
# ---------- XML-RPC ----------
def _call(self, method: str, *params):
body = xmlrpc.client.dumps(params, method, allow_none=True)
try:
r = self._client.post("/", content=body, headers={"Content-Type": "text/xml"})
r.raise_for_status()
(res,), _ = xmlrpc.client.loads(r.content)
except (httpx.ConnectError, httpx.ConnectTimeout) as e:
raise HostUnreachable(f"{method}: {e}") from e
except (httpx.HTTPError, xmlrpc.client.Error) as e:
raise XenServerError(f"{method}: {e}") from e
if not isinstance(res, dict) or res.get("Status") != "Success":
err = list((res or {}).get("ErrorDescription") or ["unknown error"])
if err[0] == "HOST_IS_SLAVE":
raise HostIsSlave(self.coordinator, str(err[1]) if len(err) > 1 else "")
raise XenServerError(f"{method}: {' '.join(str(x) for x in err)}")
return res.get("Value")
def _point_at(self, address: str) -> None:
url = _origin(address, self.port)
try:
self._client.base_url = url
except httpx.InvalidURL as e:
raise HostUnreachable(f"not a usable address ({e})") from e
self.coordinator, self.base_url = address, url
def login(self) -> None:
if self._session:
return
if not self.hosts:
raise XenServerError("no pool host configured")
self.skipped = []
for host in self.hosts:
self.host = host
try:
self._point_at(host)
self._login_here()
return
except HostUnreachable as e:
logger.warning("XenServer %s: %s — trying the next configured host", host, e)
self.skipped.append(f"{host}: {e}")
raise XenServerError("no configured pool host answered — " + "; ".join(self.skipped))
def _login_here(self) -> None:
args = (self.username, self.password, API_VERSION, "TrueVuln")
try:
self._session = self._call("session.login_with_password", *args)
except HostIsSlave as e:
if not e.coordinator:
raise
# Once only: a member naming another member means the pool is
# mid-transition, and the next sync will find the new coordinator.
# An unreachable coordinator moves on to the next configured
# host: mid-failover a member can still name the dead one.
logger.info("XenServer %s is a pool member; following to coordinator %s",
self.host, e.coordinator)
try:
self._point_at(_verifiable_name(e.coordinator) if self.verify_ssl
else e.coordinator)
self._session = self._call("session.login_with_password", *args)
except XenServerError as e2:
kind = HostUnreachable if isinstance(e2, HostUnreachable) else XenServerError
raise kind(f"{self.host} is a pool member; following it to coordinator "
f"{e.coordinator} failed: {e2}") from e2
def get_pool(self) -> dict:
self.login()
pools = self._call("pool.get_all_records", self._session) or {}
p = next(iter(pools.values()), {}) if pools else {}
return {"name": p.get("name_label") or "",
# Absent before the repository-based update model: None, and
# the caller reports that it cannot tell how fresh /updates is.
"last_update_sync": xapi_datetime(p.get("last_update_sync"))}
def get_hosts(self) -> List[dict]:
self.login()
records = self._call("host.get_all_records", self._session) or {}
return [{"ref": ref, "uuid": h.get("uuid") or "",
"name": h.get("name_label") or h.get("hostname") or "",
"address": h.get("address") or "",
"software_version": dict(h.get("software_version") or {}),
# What the host must still do for installed updates to run
# ("reboot_host", "restart_toolstack"); XenServer 8 and later.
"pending_guidances": list(h.get("pending_guidances") or [])
+ list(h.get("pending_guidances_recommended") or [])}
for ref, h in records.items()]
# ---------- HTTP ----------
def get_updates(self) -> dict:
try:
r = self._client.get("/updates", auth=(self.username, self.password))
except httpx.HTTPError as e:
raise XenServerError(f"GET /updates: {e}") from e
if r.status_code != 200:
hint = (" — the pool needs XenServer 8 or later with an update repository enabled"
if r.status_code == 404 else "")
raise XenServerError(f"GET /updates: HTTP {r.status_code} {r.text[:200]!r}{hint}")
try:
data = r.json()
except ValueError as e:
raise XenServerError(f"GET /updates: not JSON ({e})") from e
if not isinstance(data, dict) or not isinstance(data.get("hosts"), list) \
or not isinstance(data.get("updates"), list):
raise XenServerError("GET /updates: unexpected shape (no hosts/updates list)")
return data
def test_connection(self) -> dict:
"""Login, pool, hosts, /updates — the same four reads a sync does."""
step = "login"
try:
self.login()
step = "pool"
pool = self.get_pool()
step = "hosts"
hosts = self.get_hosts()
step = "updates"
updates = self.get_updates()
except XenServerError as e:
return {"ok": False, "step": step, "error": str(e)}
last = pool.get("last_update_sync")
return {"ok": True, "pool": pool.get("name"), "coordinator": self.coordinator,
"skipped": self.skipped,
"host_count": len(hosts),
"pending_updates": len(updates.get("updates") or []),
"last_update_sync": last.isoformat() if last else None}
def close(self) -> None:
if self._session:
try:
self._call("session.logout", self._session)
except XenServerError as e:
logger.debug("XenServer logout failed: %s", e)
self._session = None
self._client.close()
+73 -16
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 from app.routers import auth, auth_admin, vulnerabilities, assets, policies, scans, settings, notifications, groups, reports, audit, nessus, compliance, intune, vcenter, igel, netdisco, dockhand, xenserver, advisories, trust_store, https_certificate
try: try:
from app.routers import auth_oidc from app.routers import auth_oidc
_HAS_OIDC = True _HAS_OIDC = True
@@ -44,11 +44,12 @@ except Exception:
_HAS_SAML = False _HAS_SAML = False
from app.database import engine, SessionLocal from app.database import engine, SessionLocal
from app.models.base import Base from app.models.base import Base
from app.auth.dependencies import capture_request_origin
from app.models.user import User, UserRole from app.models.user import User, UserRole
from app.auth.jwt_handler import hash_password from app.auth.jwt_handler import hash_password
from app.scheduler import start_scheduler, stop_scheduler from app.scheduler import start_scheduler, stop_scheduler
from app.db_init import create_initial_data from app.db_init import create_initial_data
from app.rate_limiter import limiter, TRUST_PROXY_HEADERS from app.rate_limiter import limiter, TRUST_PROXY_HEADERS, forwarded_allow_ips
# Logging Configuration # Logging Configuration
logging.basicConfig( logging.basicConfig(
@@ -60,6 +61,9 @@ logger = logging.getLogger(__name__)
# Environment # Environment
ENV = os.getenv("ENV", "production") ENV = os.getenv("ENV", "production")
DEBUG = ENV == "development" DEBUG = ENV == "development"
# Opt-in: expose Swagger/ReDoc on a production instance (for ITSM/CMDB
# integrators) without full debug mode. Default off.
API_DOCS = DEBUG or os.getenv("ENABLE_API_DOCS", "false").lower() in ("1", "true", "yes")
# Rate Limiter (OWASP A04: Insecure Design) # Rate Limiter (OWASP A04: Insecure Design)
@@ -98,6 +102,26 @@ async def lifespan(app: FastAPI):
except Exception as e: except Exception as e:
logger.error(f"Error creating initial data: {e}") logger.error(f"Error creating initial data: {e}")
# Custom CAs before the scheduler: the first sync must already trust them.
try:
from app.services.ca_trust_service import load_trust_bundle
db = SessionLocal()
load_trust_bundle(db)
db.close()
except Exception as e:
logger.error(f"Custom CA trust bundle could not be applied: {e}")
# The bundled nginx waits for these files before it serves HTTPS.
try:
from app.services.https_certificate_service import ensure_certificates
db = SessionLocal()
try:
ensure_certificates(db)
finally:
db.close()
except Exception as e:
logger.error(f"HTTPS certificate could not be created: {e}")
# Background-Scheduler für automatische Scans starten # Background-Scheduler für automatische Scans starten
try: try:
start_scheduler() start_scheduler()
@@ -105,6 +129,13 @@ async def lifespan(app: FastAPI):
except Exception as e: except Exception as e:
logger.warning(f"Scheduler could not be started: {e}") logger.warning(f"Scheduler could not be started: {e}")
# Audit-log → syslog forwarder (SIEM). No-op until enabled in settings.
try:
from app.services.syslog_service import register_audit_listener
register_audit_listener()
except Exception as e:
logger.warning(f"Syslog forwarder could not be registered: {e}")
yield yield
stop_scheduler() stop_scheduler()
@@ -113,12 +144,14 @@ async def lifespan(app: FastAPI):
# FastAPI App # FastAPI App
app = FastAPI( app = FastAPI(
title="VulnManager API", title="TrueVuln API",
description="Vulnerability Management Dashboard mit Wazuh & KI-Integration", description="TrueVuln — Vulnerability Management Dashboard mit Wazuh & KI-Integration",
version="1.0.0", version="1.0.0",
docs_url="/docs" if DEBUG else None, # Swagger UI nur in Development docs_url="/docs" if API_DOCS else None, # Swagger UI: Dev, oder ENABLE_API_DOCS=true
redoc_url="/redoc" if DEBUG else None, redoc_url="/redoc" if API_DOCS else None,
lifespan=lifespan lifespan=lifespan,
# Client IP + user agent for every audit row a user causes (audit_log.py).
dependencies=[Depends(capture_request_origin)],
) )
# Rate Limiter State # Rate Limiter State
@@ -152,10 +185,10 @@ allowed_origins = os.getenv("CORS_ORIGINS", "http://localhost:3000,http://localh
if TRUST_PROXY_HEADERS: if TRUST_PROXY_HEADERS:
# Narrow the set of peers allowed to set forwarded-for headers. Wildcard # Narrow the set of peers allowed to set forwarded-for headers. Wildcard
# ("*") lets any upstream rewrite request.client.host, which defeats # ("*") lets any upstream rewrite request.client.host, which defeats
# rate-limit IP keying. Operators must list reverse-proxy IPs via # rate-limit IP keying. Loopback + the own Docker subnet, plus whatever
# FORWARDED_ALLOW_IPS (uvicorn convention). # FORWARDED_ALLOW_IPS lists (IPs or CIDRs).
_trusted_hosts = os.getenv("FORWARDED_ALLOW_IPS", "127.0.0.1").split(",") _trusted_hosts = forwarded_allow_ips()
_trusted_hosts = [h.strip() for h in _trusted_hosts if h.strip()] logger.info("Trusting X-Forwarded-For from %s", ", ".join(_trusted_hosts))
app.add_middleware(ProxyHeadersMiddleware, trusted_hosts=_trusted_hosts) app.add_middleware(ProxyHeadersMiddleware, trusted_hosts=_trusted_hosts)
# Query parameters whose value must be redacted before being logged. # Query parameters whose value must be redacted before being logged.
@@ -181,16 +214,31 @@ def _safe_query_string(query: str) -> str:
return "?" + "&".join(parts) 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") @app.middleware("http")
async def log_requests(request: Request, call_next): async def log_requests(request: Request, call_next):
safe_target = f"{request.url.path}{_safe_query_string(request.url.query)}" safe_target = f"{request.url.path}{_safe_query_string(request.url.query)}"
logger.info(f"➡️ Incoming Request: {request.method} {safe_target}") logger.info(f"➡️ Incoming Request: {request.method} {safe_target}")
started = time.perf_counter()
try: try:
response = await call_next(request) 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 return response
except Exception as e: 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 raise
@app.middleware("http") @app.middleware("http")
@@ -239,7 +287,7 @@ app.add_middleware(
allow_credentials=True, allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE"], allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
allow_headers=["Authorization", "Content-Type", "Accept"], allow_headers=["Authorization", "Content-Type", "Accept"],
expose_headers=["X-Total-Count"] # Für Pagination expose_headers=["X-Total-Count", "Content-Disposition"] # Pagination + CSV-Download-Dateiname
) )
# Trusted Host Middleware (Host-Header-Validation) # Trusted Host Middleware (Host-Header-Validation)
@@ -283,7 +331,7 @@ async def global_exception_handler(request: Request, exc: Exception):
# ============================================ # ============================================
@app.get("/health", tags=["Health"]) @app.get("/health", tags=["Health"])
async def health_check(): def health_check():
""" """
Health-Check-Endpoint für Load-Balancer & Monitoring. Health-Check-Endpoint für Load-Balancer & Monitoring.
@@ -296,7 +344,7 @@ async def health_check():
@app.get("/", tags=["Root"]) @app.get("/", tags=["Root"])
@limiter.limit("10/minute") @limiter.limit("10/minute")
async def root(request: Request): def root(request: Request):
""" """
Root-Endpoint mit API-Info Root-Endpoint mit API-Info
""" """
@@ -320,10 +368,19 @@ if _HAS_SAML and auth_saml is not None:
app.include_router(auth_saml.router) app.include_router(auth_saml.router)
app.include_router(vulnerabilities.router) app.include_router(vulnerabilities.router)
app.include_router(nessus.router) 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(xenserver.router)
app.include_router(advisories.router)
app.include_router(assets.router) app.include_router(assets.router)
app.include_router(policies.router) app.include_router(policies.router)
app.include_router(scans.router) app.include_router(scans.router)
app.include_router(settings.router) app.include_router(settings.router)
app.include_router(trust_store.router)
app.include_router(https_certificate.router)
app.include_router(notifications.router) app.include_router(notifications.router)
app.include_router(groups.router) app.include_router(groups.router)
app.include_router(reports.router) app.include_router(reports.router)
+6
View File
@@ -13,7 +13,10 @@ from app.models.policy import Policy
from app.models.scan_schedule import ScanSchedule from app.models.scan_schedule import ScanSchedule
from app.models.notification_log import NotificationLog from app.models.notification_log import NotificationLog
from app.models.setting import Setting from app.models.setting import Setting
from app.models.sync_run import SyncRun
from app.models.ai_report import AIReport from app.models.ai_report import AIReport
from app.models.cve_remediation import CveRemediation
from app.models.app_cve_cache import AppCveCache
from app.models.compliance import ( from app.models.compliance import (
ComplianceResult, ComplianceCheck, ComplianceImpact, AssetRiskSnapshot, ComplianceResult, ComplianceCheck, ComplianceImpact, AssetRiskSnapshot,
) )
@@ -31,7 +34,10 @@ __all__ = [
"ScanSchedule", "ScanSchedule",
"NotificationLog", "NotificationLog",
"Setting", "Setting",
"SyncRun",
"AIReport", "AIReport",
"CveRemediation",
"AppCveCache",
"ComplianceResult", "ComplianceResult",
"ComplianceCheck", "ComplianceCheck",
"ComplianceImpact", "ComplianceImpact",
+4 -1
View File
@@ -17,7 +17,10 @@ class AIAnalysis(Base, TimestampMixin):
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
vulnerability_id = Column( vulnerability_id = Column(
Integer, Integer,
ForeignKey("vulnerabilities.id"), # CASCADE: unlike the notification log, an analysis of a finding that
# no longer exists says nothing — and nullable=False leaves no other
# option. Without a rule it blocked asset deletion the same way.
ForeignKey("vulnerabilities.id", ondelete="CASCADE"),
unique=True, unique=True,
nullable=False, nullable=False,
index=True index=True
+18
View File
@@ -0,0 +1,18 @@
"""
Cache for the built-in app→CVE scanner: (product_key, version) → CVE list.
Keeps OSV / NVD-CPE lookups bounded (same software version across many hosts
= one query) and under NVD's rate limit. Refreshed on a TTL.
"""
from sqlalchemy import Column, Integer, String, Text, DateTime, func
from app.models.base import Base
class AppCveCache(Base):
__tablename__ = "app_cve_cache"
id = Column(Integer, primary_key=True, index=True)
product_key = Column(String(160), nullable=False, index=True) # cpe:a:vendor:product | osv:eco:name
version = Column(String(120), nullable=False)
cves = Column(Text, nullable=True) # JSON [{cve, cvss, severity, fixed}]
fetched_at = Column(DateTime, nullable=False, server_default=func.now())
+98 -1
View File
@@ -20,6 +20,12 @@ class AssetSource(str, Enum):
WAZUH = "WAZUH" WAZUH = "WAZUH"
NESSUS = "NESSUS" NESSUS = "NESSUS"
MANUAL = "MANUAL" MANUAL = "MANUAL"
INTUNE = "INTUNE"
VCENTER = "VCENTER"
IGEL = "IGEL"
NETDISCO = "NETDISCO"
CONTAINER = "CONTAINER" # one asset per image, Trivy/Grype push (059)
XENSERVER = "XENSERVER" # pool coordinator via XAPI (064)
class AssetStatus(str, Enum): class AssetStatus(str, Enum):
@@ -44,10 +50,65 @@ class Asset(Base, TimestampMixin):
# Identifikation # Identifikation
hostname = Column(String(255), nullable=False, index=True) hostname = Column(String(255), nullable=False, index=True)
ip_address = Column(String(45), nullable=True, index=True) # IPv6 unterstützt ip_address = Column(String(45), nullable=True, index=True) # IPv6 unterstützt
wazuh_agent_id = Column(String(10), unique=True, nullable=True, index=True) # 10 was enough for a numeric agent id ("042"), but manager nodes are
# stored here too as "node:<name>" and node names are free-form — the
# sync died on the very first one with a 500 (value too long).
wazuh_agent_id = Column(String(64), unique=True, nullable=True, index=True)
# Nessus host UUID — pinned after first hostname/IP match so future Nessus # Nessus host UUID — pinned after first hostname/IP match so future Nessus
# syncs reconnect deterministically even if hostname or IP changes. # syncs reconnect deterministically even if hostname or IP changes.
nessus_host_uuid = Column(String(64), nullable=True, index=True) nessus_host_uuid = Column(String(64), nullable=True, index=True)
# Microsoft Intune managedDevice id — pinned after first match so future
# 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.
aad_device_id = Column(String(64), nullable=True, index=True)
# vSphere. `vmware_uuid` is the hardware UUID of an ESXi host (stable
# across renames, re-adds and even a vCenter rebuild) or the vCenter's own
# instanceUuid — the pin that reconnects the asset on every sync.
# `vmware_build` is the VMkernel / vpxd BUILD number, and it is not a
# nice-to-have: VMware states its fixes as builds ("ESXi80U3k-25595708"),
# so without it no ESXi or vCenter CVE can be decided precisely. See
# 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)
# XAPI host UUID of a XenServer host — what the pool itself keys a host on,
# unchanged by a rename in XenCenter. The pin that reconnects the asset on
# every sync (064).
xen_host_uuid = Column(String(64), 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 # System-Information
operating_system = Column(String(255), nullable=True) operating_system = Column(String(255), nullable=True)
@@ -69,6 +130,12 @@ class Asset(Base, TimestampMixin):
exposed_services = Column(Text, nullable=True) # JSON [{port, proto, service, risk}] exposed_services = Column(Text, nullable=True) # JSON [{port, proto, service, risk}]
exposure_updated_at = Column(DateTime, nullable=True) exposure_updated_at = Column(DateTime, nullable=True)
# Risk Dimensions — crown-jewel role scoring (DC, ADCS, SQL, Exchange,
# WSUS, backup, ...). Feeds the URS via risk_dimensions_service.risk_factor.
high_value_score = Column(Float, nullable=True, index=True) # 0-100
risk_dimensions = Column(Text, nullable=True) # JSON [{role, label, weight}]
risk_dimensions_updated_at = Column(DateTime, nullable=True)
# Metadata # Metadata
source = Column( source = Column(
SQLEnum(AssetSource), SQLEnum(AssetSource),
@@ -86,6 +153,21 @@ class Asset(Base, TimestampMixin):
# Scanning # Scanning
last_scan = Column(DateTime, nullable=True) last_scan = Column(DateTime, nullable=True)
last_seen = Column(DateTime, nullable=True) last_seen = Column(DateTime, nullable=True)
# Which sync wrote the stamps above ("wazuh" | "nessus" | "intune" |
# "defender" | "vcenter" | "igel" | "netdisco" | "xenserver" | "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
# itself reported again (field report 2026-08-30).
last_seen_source = Column(String(32), nullable=True)
# Each inventory's own clock (agent keepalive / Intune lastSyncDateTime).
# `last_seen` is the newest of all sources, so it cannot say which of the
# two software lists is current — `audit_events.asset_inventory` needs that.
wazuh_last_seen = Column(DateTime, nullable=True)
intune_last_seen = Column(DateTime, nullable=True)
# The agent's `dateAdd`: tells a reused agent id (fresh Wazuh server,
# ids from 001 again) from the agent that held it (066).
wazuh_registered_at = Column(DateTime, nullable=True)
# Zusätzliche Informationen # Zusätzliche Informationen
description = Column(Text, nullable=True) description = Column(Text, nullable=True)
@@ -117,3 +199,18 @@ class Asset(Base, TimestampMixin):
def __repr__(self): def __repr__(self):
return f"<Asset(hostname='{self.hostname}', ip='{self.ip_address}')>" 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
+30 -1
View File
@@ -1,8 +1,11 @@
""" """
Audit Log Model for security events Audit Log Model for security events
""" """
from contextvars import ContextVar
from enum import Enum from enum import Enum
from sqlalchemy import Column, Integer, String, Text, ForeignKey, Enum as SQLEnum, DateTime from typing import Optional, Tuple
from sqlalchemy import Column, Integer, String, Text, ForeignKey, Enum as SQLEnum, DateTime, event, text
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from app.models.base import Base from app.models.base import Base
@@ -35,6 +38,7 @@ class AuditEventType(str, Enum):
USER_DELETED = "USER_DELETED" USER_DELETED = "USER_DELETED"
# Data Operations # Data Operations
VULNERABILITY_DETECTED = "VULNERABILITY_DETECTED" # sync created a new finding
VULNERABILITY_UPDATED = "VULNERABILITY_UPDATED" VULNERABILITY_UPDATED = "VULNERABILITY_UPDATED"
ASSET_CREATED = "ASSET_CREATED" ASSET_CREATED = "ASSET_CREATED"
ASSET_UPDATED = "ASSET_UPDATED" ASSET_UPDATED = "ASSET_UPDATED"
@@ -64,6 +68,9 @@ class AuditLog(Base):
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True) user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True)
# Snapshot of the actor's name: delete_user nulls user_id, and the trail
# must still say who it was.
username = Column(String(50), nullable=True)
# Event Details # Event Details
event_type = Column( event_type = Column(
@@ -91,3 +98,25 @@ class AuditLog(Base):
def __repr__(self): def __repr__(self):
return f"<AuditLog(event='{self.event_type}', user_id={self.user_id})>" return f"<AuditLog(event='{self.event_type}', user_id={self.user_id})>"
# (client IP, user agent) of the request being served. Set by
# app.auth.dependencies.capture_request_origin; unset in scheduler and sync threads.
request_origin: ContextVar[Optional[Tuple[Optional[str], Optional[str]]]] = \
ContextVar("audit_request_origin", default=None)
@event.listens_for(AuditLog, "before_insert")
def _stamp_actor(mapper, connection, target): # noqa: ARG001
"""Every row a user caused names the user and where they came from, so no
writer can forget it. System rows (no user_id) stay as they are."""
if target.user_id is None:
return
if target.username is None:
target.username = connection.execute(
text("SELECT username FROM users WHERE id = :id"), {"id": target.user_id}
).scalar()
origin = request_origin.get()
if origin:
target.ip_address = target.ip_address or origin[0]
target.user_agent = target.user_agent or (origin[1] or "")[:500] or None
+1 -1
View File
@@ -52,7 +52,7 @@ class ComplianceResult(Base, TimestampMixin):
# When Wazuh last evaluated this policy (from the SCA endpoint) # When Wazuh last evaluated this policy (from the SCA endpoint)
end_scan = Column(DateTime, nullable=True) end_scan = Column(DateTime, nullable=True)
# When VulnCheck last refreshed from Wazuh # When TrueVuln last refreshed from Wazuh
last_synced = Column(DateTime, nullable=False, default=datetime.utcnow) last_synced = Column(DateTime, nullable=False, default=datetime.utcnow)
# ORM. cascade + passive_deletes so DELETE on Asset propagates via # ORM. cascade + passive_deletes so DELETE on Asset propagates via
+29
View File
@@ -0,0 +1,29 @@
"""
CVE-level remediation enrichment from external primary sources.
Separate from the per-asset Vulnerability.remediation column (the Nessus
scanner solution): these rows are keyed by cve_id and come from MSRC CVRF
(Windows + MS products) and, later, Linux distro advisories. The CVE detail
page shows them alongside the scanner remediation, grouped by source.
"""
from sqlalchemy import Column, Integer, String, Text, DateTime, func
from app.models.base import Base
class CveRemediation(Base):
__tablename__ = "cve_remediations"
id = Column(Integer, primary_key=True, index=True)
cve_id = Column(String(50), nullable=False, index=True)
source = Column(String(32), nullable=False) # msrc | ubuntu | centos | ...
kind = Column(String(24), nullable=False) # fix | workaround | mitigation | advisory
title = Column(String(300), nullable=True)
detail = Column(Text, nullable=True)
kb = Column(String(64), nullable=True)
fixed_build = Column(String(120), nullable=True)
url = Column(Text, nullable=True)
fetched_at = Column(DateTime, nullable=False, server_default=func.now())
def __repr__(self):
return f"<CveRemediation(cve_id='{self.cve_id}', source='{self.source}', kind='{self.kind}')>"
+4 -1
View File
@@ -17,7 +17,10 @@ user_groups = Table(
asset_groups = Table( asset_groups = Table(
'asset_groups', 'asset_groups',
Base.metadata, Base.metadata,
Column('asset_id', Integer, ForeignKey('assets.id'), primary_key=True), # CASCADE: SQLAlchemy clears these rows itself on delete, but the database
# should not depend on the application doing it in the right order.
Column('asset_id', Integer, ForeignKey('assets.id', ondelete='CASCADE'),
primary_key=True),
Column('group_id', Integer, ForeignKey('groups.id'), primary_key=True) Column('group_id', Integer, ForeignKey('groups.id'), primary_key=True)
) )
+18 -2
View File
@@ -14,6 +14,11 @@ class NotificationType(str, Enum):
SLA_BREACH = "sla_breach" SLA_BREACH = "sla_breach"
ASSIGNMENT = "assignment" ASSIGNMENT = "assignment"
NEW_VULNERABILITY = "new_vulnerability" NEW_VULNERABILITY = "new_vulnerability"
# 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" MANUAL = "manual"
@@ -27,8 +32,19 @@ class NotificationLog(Base):
__tablename__ = "notification_logs" __tablename__ = "notification_logs"
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
vulnerability_id = Column(Integer, ForeignKey("vulnerabilities.id"), nullable=True, index=True) # SET NULL, not CASCADE: the log is the record that a notification WAS
asset_id = Column(Integer, ForeignKey("assets.id"), nullable=True, index=True) # sent, and that stays true after the finding is gone. Without a rule the
# database refused the delete outright — an Intune device removed upstream
# could not be deleted at all once anything had been mailed about it
# (ForeignKeyViolation on notification_logs_vulnerability_id_fkey).
vulnerability_id = Column(Integer, ForeignKey("vulnerabilities.id", ondelete="SET NULL"),
nullable=True, index=True)
# SET NULL for the same reason as vulnerability_id above: the log records
# that a notification WAS sent, which stays true after the asset is gone.
# Without a rule this blocked asset deletion outright — the vulnerability_id
# fix in migration 040 only moved the failure one constraint along.
asset_id = Column(Integer, ForeignKey("assets.id", ondelete="SET NULL"),
nullable=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True) user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True)
notification_type = Column(SQLEnum(NotificationType), nullable=False, index=True) notification_type = Column(SQLEnum(NotificationType), nullable=False, index=True)
sent_at = Column(DateTime, nullable=False, index=True) sent_at = Column(DateTime, nullable=False, index=True)
+3 -1
View File
@@ -32,7 +32,9 @@ class Scan(Base, TimestampMixin):
__tablename__ = "scans" __tablename__ = "scans"
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
asset_id = Column(Integer, ForeignKey("assets.id"), nullable=False, index=True) # CASCADE: a scan belongs to its asset and cannot outlive it (NOT NULL).
asset_id = Column(Integer, ForeignKey("assets.id", ondelete="CASCADE"),
nullable=False, index=True)
# Scan-Details # Scan-Details
scan_type = Column( scan_type = Column(
+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}')>"
+4
View File
@@ -84,6 +84,10 @@ class User(Base, TimestampMixin):
is_active = Column(Boolean, default=True, nullable=False) is_active = Column(Boolean, default=True, nullable=False)
is_verified = Column(Boolean, default=False, nullable=False) is_verified = Column(Boolean, default=False, nullable=False)
failed_login_attempts = Column(Integer, default=0, nullable=False) failed_login_attempts = Column(Integer, default=0, nullable=False)
# Permanent lockout (auth_lockout_permanent mode): stays locked until an
# admin clears it, independent of the temporary 15-min auto-unlock window.
locked = Column(Boolean, default=False, nullable=False)
locked_at = Column(DateTime, nullable=True)
# Relationships # Relationships
audit_logs = relationship("AuditLog", back_populates="user", cascade="all, delete-orphan") audit_logs = relationship("AuditLog", back_populates="user", cascade="all, delete-orphan")
+166 -19
View File
@@ -30,6 +30,42 @@ class VulnerabilityStatus(str, Enum):
deferred = "deferred" deferred = "deferred"
# Lower bound of each CVSS band. Used only when no score exists at all —
# some vendors publish none: Chrome states "Chromium security severity:
# Critical" in prose and NVD frequently never scores those CVEs. Both
# priority and CPR multiply the CVSS in, so a missing one drove them to zero
# 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}
def cpr_score(cvss_score, severity, epss_score, epss_percentile):
"""CPR from raw column values — see Vulnerability.calculate_cpr_score.
Module-level so bulk scorers can run it over a lightweight column
query instead of hydrating one ORM object per finding.
"""
base = cvss_score
if base is None:
sev = getattr(severity, "value", severity)
base = _SEVERITY_FLOOR.get(str(sev or "").lower())
if base is None or epss_score is None:
return None
cvss_pct = max(0.0, min(base, 10.0)) * 10.0
if epss_percentile is not None:
epss_pct = max(0.0, min(epss_percentile, 1.0)) * 100.0
else:
# Fallback when only raw EPSS probability is stored — not a
# true percentile but the best we have. Older data only.
epss_pct = max(0.0, min(epss_score, 1.0)) * 100.0
cpr = (cvss_pct * 0.6) + (epss_pct * 0.4)
return round(min(cpr, 100.0), 2)
class Vulnerability(Base, TimestampMixin): class Vulnerability(Base, TimestampMixin):
""" """
Vulnerability-Tabelle für CVE-Tracking Vulnerability-Tabelle für CVE-Tracking
@@ -47,11 +83,20 @@ class Vulnerability(Base, TimestampMixin):
# non-CVE findings (EOL, compliance, weak ciphers) are imported with a # non-CVE findings (EOL, compliance, weak ciphers) are imported with a
# pseudo-CVE id of the form 'NESSUS-PLUGIN-{plugin_id}'. # pseudo-CVE id of the form 'NESSUS-PLUGIN-{plugin_id}'.
cve_id = Column(String(50), nullable=False, index=True) # CVE-2024-1234 or NESSUS-PLUGIN-12345 cve_id = Column(String(50), nullable=False, index=True) # CVE-2024-1234 or NESSUS-PLUGIN-12345
asset_id = Column(Integer, ForeignKey("assets.id"), nullable=False, index=True) # CASCADE: the ORM already cascades, but the database should not depend on
# the application getting the order right.
asset_id = Column(Integer, ForeignKey("assets.id", ondelete="CASCADE"),
nullable=False, index=True)
# CVSS-Bewertung # CVSS-Bewertung
cvss_score = Column(Float, nullable=True, index=True) cvss_score = Column(Float, nullable=True, index=True)
cvss_vector = Column(String(100), nullable=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( severity = Column(
SQLEnum(VulnerabilitySeverity), SQLEnum(VulnerabilitySeverity),
nullable=False, nullable=False,
@@ -72,6 +117,7 @@ class Vulnerability(Base, TimestampMixin):
# Betroffene Software # Betroffene Software
package_name = Column(String(255), nullable=True, index=True) package_name = Column(String(255), nullable=True, index=True)
package_vendor = Column(String(255), nullable=True) # Wazuh/Intune/Defender vendor|publisher
package_version = Column(String(100), nullable=True) package_version = Column(String(100), nullable=True)
fixed_version = Column(String(100), nullable=True) fixed_version = Column(String(100), nullable=True)
@@ -88,12 +134,14 @@ class Vulnerability(Base, TimestampMixin):
ssvc_automatable = Column(String(8), nullable=True, index=True) # yes | no ssvc_automatable = Column(String(8), nullable=True, index=True) # yes | no
# Zeitstempel # Zeitstempel
published_date = Column(DateTime, nullable=True) published_date = Column(DateTime, nullable=True, index=True) # NVD `published`
last_modified_date = Column(DateTime, nullable=True) # NVD `lastModified`
detected_at = Column(DateTime, nullable=False, index=True) detected_at = Column(DateTime, nullable=False, index=True)
patched_at = Column(DateTime, nullable=True) patched_at = Column(DateTime, nullable=True)
# Zusätzliche Informationen # Zusätzliche Informationen
references = Column(Text, nullable=True) # JSON-Array mit URLs references = Column(Text, nullable=True) # JSON-Array mit URLs
remediation = Column(Text, nullable=True) # Nessus solution / fix guidance
cwe_id = Column(String(20), nullable=True) # Common Weakness Enumeration cwe_id = Column(String(20), nullable=True) # Common Weakness Enumeration
# Assignment # Assignment
@@ -106,6 +154,10 @@ class Vulnerability(Base, TimestampMixin):
# Notification Control # Notification Control
notification_suppressed = Column(Boolean, default=False, nullable=False) 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) # 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 epss_score = Column(Float, nullable=True, index=True) # 0.0 - 1.0 probability of exploitation in next 30 days
@@ -164,6 +216,13 @@ class Vulnerability(Base, TimestampMixin):
metasploit_module_count = Column(Integer, nullable=False, default=0, server_default="0") metasploit_module_count = Column(Integer, nullable=False, default=0, server_default="0")
exploit_intel_updated_at = Column(DateTime, nullable=True) 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 # Relationships
asset = relationship("Asset", back_populates="vulnerabilities") asset = relationship("Asset", back_populates="vulnerabilities")
assigned_user = relationship("User", foreign_keys=[assigned_user_id]) assigned_user = relationship("User", foreign_keys=[assigned_user_id])
@@ -201,9 +260,15 @@ class Vulnerability(Base, TimestampMixin):
@property @property
def is_pseudo_cve(self) -> bool: def is_pseudo_cve(self) -> bool:
"""True for non-CVE findings rendered as rows in the vulns list. """True for non-CVE findings rendered as rows in the vulns list.
Two flavours: NESSUS-PLUGIN-* (compliance/cipher/EOL detected NESSUS-PLUGIN-* (compliance/cipher/EOL detected by Nessus), EOL-*
by Nessus) and EOL-* (endoflife.date scan).""" (endoflife.date scan), ANDROID-PATCH-* (patch-level gap) and TV-*
return self.cve_id.startswith("NESSUS-PLUGIN-") or self.cve_id.startswith("EOL-") (a TeamViewer bulletin the vendor published without requesting a CVE —
5 of their 30). They stay in the reports, because they are real work
items; the badge says only that the id is not a CVE number."""
return (self.cve_id.startswith("NESSUS-PLUGIN-")
or self.cve_id.startswith("EOL-")
or self.cve_id.startswith("ANDROID-PATCH-")
or self.cve_id.startswith("TV-"))
@property @property
def is_eol_finding(self) -> bool: def is_eol_finding(self) -> bool:
@@ -221,6 +286,39 @@ class Vulnerability(Base, TimestampMixin):
self.sources = json.dumps(current) self.sources = json.dumps(current)
return True 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: def remove_source(self, scanner: str) -> bool:
"""Drop `scanner` from `sources`. Returns True if list changed. """Drop `scanner` from `sources`. Returns True if list changed.
Caller is responsible for committing.""" Caller is responsible for committing."""
@@ -232,6 +330,55 @@ class Vulnerability(Base, TimestampMixin):
self.sources = json.dumps(current) self.sources = json.dumps(current)
return True 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`.
Sources that carry no score seed a neutral placeholder (MSRC only
publishes a CVSS for a minority of its CVEs), and whatever fills the
score later — the correction cascade, sibling inheritance — used to
leave the placeholder behind: Edge findings were seen sitting at
MEDIUM with a CVSS of 9.6. Never touches a row whose severity an
operator pinned via the override path (exploitation_source set).
"""
if self.cvss_score is None:
return False
if getattr(self, "exploitation_source", None) == "manual":
return False
s = float(self.cvss_score)
want = (VulnerabilitySeverity.critical if s >= 9.0 else
VulnerabilitySeverity.high if s >= 7.0 else
VulnerabilitySeverity.medium if s >= 4.0 else
VulnerabilitySeverity.low if s > 0 else
VulnerabilitySeverity.none)
if self.severity != want:
self.severity = want
return True
return False
def refresh_scores(self) -> None: def refresh_scores(self) -> None:
"""Recompute + persist priority_score + cpr_score on the row. """Recompute + persist priority_score + cpr_score on the row.
@@ -241,6 +388,7 @@ class Vulnerability(Base, TimestampMixin):
not per-page. not per-page.
""" """
try: try:
self.sync_severity_with_cvss()
self.priority_score = self.calculate_priority_breakdown().get("total") self.priority_score = self.calculate_priority_breakdown().get("total")
except Exception: except Exception:
self.priority_score = None self.priority_score = None
@@ -262,6 +410,10 @@ class Vulnerability(Base, TimestampMixin):
""" """
return self.calculate_priority_breakdown()["total"] return self.calculate_priority_breakdown()["total"]
def _severity_floor(self):
sev = getattr(self.severity, "value", self.severity)
return _SEVERITY_FLOOR.get(str(sev or "").lower())
def calculate_cpr_score(self): def calculate_cpr_score(self):
""" """
Cybersecurity Priority Risk — weighted blend of CVSS + EPSS Cybersecurity Priority Risk — weighted blend of CVSS + EPSS
@@ -281,23 +433,15 @@ class Vulnerability(Base, TimestampMixin):
Previous implementation was a pure multiplicative Previous implementation was a pure multiplicative
``CVSS × EPSS × 10`` which collapsed to near-zero for the 99% of ``CVSS × EPSS × 10`` which collapsed to near-zero for the 99% of
CVEs that have EPSS < 1%, making the score useless for triage CVEs that have EPSS < 1%, making the score useless for triage
(tester reported CVE-2026-8390 with CVSS 9.8 + EPSS 0.04% scored (field report: CVE-2026-8390 with CVSS 9.8 + EPSS 0.04% scored
0.04 — a 'critical-CVSS, low-real-world-risk' finding now scores 0.04 — a 'critical-CVSS, low-real-world-risk' finding now scores
~60 instead, which actually reflects its triage position). ~60 instead, which actually reflects its triage position).
Returns: float 0-100, oder None wenn CVSS oder EPSS fehlt. Returns: float 0-100, oder None wenn CVSS oder EPSS fehlt.
""" """
if self.cvss_score is None or self.epss_score is None: return cpr_score(
return None self.cvss_score, self.severity, self.epss_score, self.epss_percentile
cvss_pct = max(0.0, min(self.cvss_score, 10.0)) * 10.0 )
if self.epss_percentile is not None:
epss_pct = max(0.0, min(self.epss_percentile, 1.0)) * 100.0
else:
# Fallback when only raw EPSS probability is stored — not a
# true percentile but the best we have. Older data only.
epss_pct = max(0.0, min(self.epss_score, 1.0)) * 100.0
cpr = (cvss_pct * 0.6) + (epss_pct * 0.4)
return round(min(cpr, 100.0), 2)
def calculate_priority_breakdown(self) -> dict: def calculate_priority_breakdown(self) -> dict:
""" """
@@ -306,8 +450,11 @@ class Vulnerability(Base, TimestampMixin):
""" """
from datetime import datetime, timezone from datetime import datetime, timezone
# 1. Technical Severity (0-10) # 1. Technical Severity (0-10). Falls back to the severity band's floor
technical_severity = self.cvss_score or 0.0 # when the vendor published no CVSS — see _SEVERITY_FLOOR. Without it a
# Critical Chrome CVE scored 0 here and sorted below every medium.
technical_severity = (self.cvss_score if self.cvss_score is not None
else (self._severity_floor() or 0.0))
# 2. Exploit-Signal: max aus KEV/EUVD / EPSS / Wazuh / SSVC / maturity # 2. Exploit-Signal: max aus KEV/EUVD / EPSS / Wazuh / SSVC / maturity
# KEV ODER EUVD bedeuten "real-world exploitation bestätigt" # KEV ODER EUVD bedeuten "real-world exploitation bestätigt"
+11 -4
View File
@@ -41,10 +41,10 @@ class VulnerabilityPackage(Base, TimestampMixin):
package_version = Column(String(100), nullable=True) package_version = Column(String(100), nullable=True)
fixed_version = Column(String(100), nullable=True) fixed_version = Column(String(100), nullable=True)
# Source tag — which scanner reported this package row. # Source tag(s) — every scanner that reported this package row, comma
# ("wazuh" / "nessus" / "manual"). Multi-scanner deployments # joined ("app-scan,msrc"). One scanner alone owning the field made a
# can show per-source confirmation in the per-package view. # cross-confirmed package look single-source in the UI.
source = Column(String(20), nullable=True) source = Column(String(60), nullable=True)
first_detected_at = Column(DateTime, nullable=False, default=datetime.utcnow) first_detected_at = Column(DateTime, nullable=False, default=datetime.utcnow)
last_seen_at = Column(DateTime, nullable=False, default=datetime.utcnow) last_seen_at = Column(DateTime, nullable=False, default=datetime.utcnow)
@@ -81,6 +81,13 @@ class VulnerabilityPackage(Base, TimestampMixin):
is the affected version itself.""" is the affected version itself."""
if not self.fixed_version: if not self.fixed_version:
return False return False
# ">26.5.2.2" is a floor, not a build: it comes from a lessThanOrEqual
# bound, which says "this and older are affected" and names no patched
# release — often because none has shipped. The value is worth showing
# (an admin can act on "newer than 26.5.2.2"), but claiming a patch is
# available would send them looking for something that may not exist.
if self.fixed_version.startswith(">"):
return False
if not self.package_version: if not self.package_version:
return True # fix known, install unknown — best to show "patch available" return True # fix known, install unknown — best to show "patch available"
return self.fixed_version != self.package_version return self.fixed_version != self.package_version
+43 -39
View File
@@ -1,6 +1,9 @@
import ipaddress import ipaddress
import logging import logging
import os import os
import socket
import struct
from typing import List
from fastapi import Request from fastapi import Request
from slowapi import Limiter from slowapi import Limiter
@@ -8,53 +11,54 @@ logger = logging.getLogger(__name__)
TRUST_PROXY_HEADERS = os.getenv("TRUST_PROXY_HEADERS", "false").lower() == "true" TRUST_PROXY_HEADERS = os.getenv("TRUST_PROXY_HEADERS", "false").lower() == "true"
# Comma-separated list of CIDR ranges. Only requests whose direct peer is
# inside one of these ranges are allowed to override the client IP via def _read_route_table() -> str:
# X-Forwarded-For / X-Real-IP. Defaults to RFC1918 + loopback for safety — try:
# operators behind public-internet load balancers must set this explicitly. with open("/proc/net/route") as f:
_DEFAULT_TRUSTED_PROXIES = "127.0.0.1/32,::1/128,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16" return f.read()
_trusted_proxy_cidrs: list = [] except OSError:
for raw in os.getenv("TRUSTED_PROXY_CIDRS", _DEFAULT_TRUSTED_PROXIES).split(","): return "" # not Linux (local dev): loopback only
raw = raw.strip()
if not raw:
def local_subnets(route_table: str) -> List[str]:
"""The subnets this host sits in directly (routes without a gateway).
In the container that is the Compose network: the frontend that calls
the backend, and nginx, live there; nothing else can reach the backend."""
subnets = []
for line in route_table.splitlines()[1:]:
f = line.split()
if len(f) < 8 or f[2] != "00000000" or f[1] == "00000000":
continue continue
try: dest, mask = (socket.inet_ntoa(struct.pack("<I", int(x, 16))) for x in (f[1], f[7]))
_trusted_proxy_cidrs.append(ipaddress.ip_network(raw, strict=False)) subnets.append(str(ipaddress.ip_network(f"{dest}/{mask}", strict=False)))
except ValueError: return subnets
logger.warning("Ignoring invalid TRUSTED_PROXY_CIDRS entry: %s", raw)
def _peer_is_trusted_proxy(peer_ip: str) -> bool: def forwarded_allow_ips() -> List[str]:
try: """Peers whose X-Forwarded-For uvicorn's ProxyHeadersMiddleware honours.
addr = ipaddress.ip_address(peer_ip)
except ValueError: Always loopback + the own Docker subnet: the backend's peer is the
return False Next.js container, whose IP changes with every recreate, so a fixed IP
return any(addr in net for net in _trusted_proxy_cidrs) never matched and every request was logged with the frontend's address.
FORWARDED_ALLOW_IPS adds to that (a reverse proxy on another host in
front of nginx). Not the RFC1918 ranges: internal users live there too,
and a trusted client could put any address into the header."""
trusted = ["127.0.0.1"] + local_subnets(_read_route_table())
for h in os.getenv("FORWARDED_ALLOW_IPS", "").split(","):
if h.strip() and h.strip() not in trusted:
trusted.append(h.strip())
return trusted
def get_client_ip(request: Request) -> str: def get_client_ip(request: Request) -> str:
""" """
Resolve client IP. Honour reverse-proxy headers only when the direct The client address after ProxyHeadersMiddleware (main.py) resolved
peer is itself a trusted proxy — otherwise the client could spoof X-Forwarded-For against forwarded_allow_ips(): the same IP the audit log
X-Forwarded-For to bypass rate limiting on /auth/login etc. records. Parsing the header again here trusted every private network, so
an internal user could pick a new rate-limit key per request.
""" """
peer = request.client.host if request.client else None return request.client.host if request.client else "unknown"
if TRUST_PROXY_HEADERS and peer and _peer_is_trusted_proxy(peer):
xff = request.headers.get("x-forwarded-for")
if xff:
# Rightmost untrusted hop = leftmost entry the trusted proxy
# received. Take the first non-trusted IP walking from the right.
for candidate in reversed([h.strip() for h in xff.split(",") if h.strip()]):
if not _peer_is_trusted_proxy(candidate):
return candidate
# All hops were trusted proxies — fall back to leftmost.
return xff.split(",")[0].strip()
xri = request.headers.get("x-real-ip")
if xri:
return xri.strip()
return peer or "unknown"
limiter = Limiter(key_func=get_client_ip) limiter = Limiter(key_func=get_client_ip)

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