Commit Graph
433 Commits
Author SHA1 Message Date
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 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 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 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 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 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 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 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 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 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 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 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 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