Author SHA1 Message Date
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
144 changed files with 17905 additions and 1055 deletions
+52 -13
View File
@@ -1,5 +1,5 @@
# =============================================================================
# VulnCheck Environment Configuration
# TrueVuln Environment Configuration
# =============================================================================
# Copy this file to .env and adjust values before starting:
# cp .env.example .env
@@ -27,6 +27,10 @@ JWT_SECRET_KEY=CHANGE-ME-GENERATE-WITH-openssl-rand-hex-32
# 'production' disables Swagger docs and enables security headers.
# 'development' enables Swagger UI at /docs and relaxes some checks.
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 ---
# Set to true when using HTTPS (recommended, required behind HTTPS proxy)
@@ -59,9 +63,44 @@ DEFAULT_ADMIN_EMAIL=admin@vulnmanager.local
TIMEZONE=Europe/Zurich
# --- Dashboard URL ---
# Used in email notifications for links back to the dashboard.
# Set this to your external URL (behind reverse proxy).
# DASHBOARD_URL=https://vuln.example.com
# Base URL used to build ALL links in email notifications (dashboard button,
# per-CVE / per-asset deep links, the CVE links inside the digest table).
# 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.
# e.g. http://<host-or-ip>:${FRONTEND_PORT} or https://vuln.example.com behind a proxy
# Set it, then restart the backend (docker compose up -d).
DASHBOARD_URL=http://localhost:3003
# --- 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
# --- 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
# =============================================================================
@@ -108,7 +147,7 @@ LDAP_CA_CERT_PATH=/etc/ssl/certs/company-ca.pem
LDAP_VALIDATE_CERT=true # NEVER set to false in production
# 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.
# Remove from env AFTER the first successful start.
LDAP_BIND_PASSWORD_BOOTSTRAP=
@@ -136,7 +175,7 @@ OIDC_PROVIDER_NAME=Entra ID
OIDC_DISCOVERY_URL=
OIDC_CLIENT_ID=
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
# Claim names — defaults work for Entra ID / Keycloak. Adjust per IdP.
@@ -151,20 +190,20 @@ OIDC_CLAIM_SUBJECT=sub
# SAML 2.0
# -----------------------------------------------------------------------------
SAML_PROVIDER_NAME=Single Sign-On
SAML_SP_ENTITY_ID=https://vulncheck.company.com/auth/saml/metadata
SAML_SP_ACS_URL=https://vulncheck.company.com/auth/saml/acs
SAML_SP_SLO_URL=https://vulncheck.company.com/auth/saml/slo
SAML_SP_ENTITY_ID=https://truevuln.company.com/auth/saml/metadata
SAML_SP_ACS_URL=https://truevuln.company.com/auth/saml/acs
SAML_SP_SLO_URL=https://truevuln.company.com/auth/saml/slo
# SP cert + key — generate a keypair specifically for this SP:
# openssl req -x509 -newkey rsa:2048 -nodes \
# -keyout sp.key -out sp.crt -days 730 \
# -subj "/CN=vulncheck.company.com"
SAML_SP_CERT_PATH=/etc/vulncheck/saml/sp.crt
SAML_SP_PRIVATE_KEY_PATH=/etc/vulncheck/saml/sp.key
# -subj "/CN=truevuln.company.com"
SAML_SP_CERT_PATH=/etc/truevuln/saml/sp.crt
SAML_SP_PRIVATE_KEY_PATH=/etc/truevuln/saml/sp.key
# IdP metadata source — exactly one of:
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)
SAML_ATTR_USERNAME=urn:oid:0.9.2342.19200300.100.1.1
+2
View File
@@ -0,0 +1,2 @@
/cache
/project.local.yml
+167
View File
@@ -0,0 +1,167 @@
# the name by which the project can be referenced within Serena/when chatting with the LLM.
project_name: "vulnerability-dashboard"
# the encoding used by text files in the project
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
encoding: "utf-8"
# line ending convention to use when writing source files.
# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
line_ending:
# The language backend to use for this project.
# If not set, the global setting from serena_config.yml is used.
# Valid values: LSP, JetBrains
# Note: the backend is fixed at startup. If a project with a different backend
# is activated post-init, an error will be returned.
language_backend:
# whether to use project's .gitignore files to ignore files
ignore_all_files_in_gitignore: true
# advanced configuration option allowing to configure language server-specific options.
# Maps the language key to the options.
# The settings are considered only if the project is trusted (see global configuration to define trusted projects).
# See https://oraios.github.io/serena/02-usage/050_configuration.html#language-server-specific-settings
ls_specific_settings: {}
# list of additional paths to ignore in this project.
# Same syntax as gitignore, so you can use * and **.
# Important: quote patterns that start with `*`, otherwise YAML treats them as aliases.
# Example:
# ignored_paths:
# - "examples/**"
# - ".worktrees/**"
# - "**/bin/**"
# - "**/obj/**"
# Note: global ignored_paths from serena_config.yml are also applied additively.
ignored_paths: []
# whether the project is in read-only mode
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
# Added on 2025-04-18
read_only: false
# list of tool names to exclude.
# This extends the existing exclusions (e.g. from the global configuration)
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
excluded_tools: []
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default).
# This extends the existing inclusions (e.g. from the global configuration).
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
included_optional_tools: []
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
fixed_tools: []
# list of mode names that are to be activated by default, overriding the setting in the global configuration.
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply
# for this project.
# This setting can, in turn, be overridden by CLI parameters (--mode).
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
default_modes:
# list of mode names to be activated additionally for this project, e.g. ["query-projects"]
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
added_modes:
# initial prompt for the project. It will always be given to the LLM upon activating the project
# (contrary to the memories, which are loaded on demand).
initial_prompt: ""
# time budget (seconds) per tool call for the retrieval of additional symbol information
# such as docstrings or parameter information.
# This overrides the corresponding setting in the global configuration; see the documentation there.
# If null or missing, use the setting from the global configuration.
symbol_info_budget:
# list of regex patterns which, when matched, mark a memory entry as readonly.
# Extends the list from the global configuration, merging the two lists.
read_only_memory_patterns: []
# list of regex patterns for memories to completely ignore.
# Matching memories will not appear in list_memories or activate_project output
# and cannot be accessed via read_memory or write_memory.
# To access ignored memory files, use the read_file tool on the raw file path.
# Extends the list from the global configuration, merging the two lists.
# Example: ["_archive/.*", "_episodes/.*"]
ignored_memory_patterns: []
# optional shell command to run before the language backend (LSP or JetBrains) is initialised.
# the command runs in the project root directory and is only executed if the project is trusted
# (see trusted_project_path_patterns in the global configuration).
# serena waits for the command to exit: a non-zero exit code is logged as an error but does not
# abort activation. a per-project timeout (activation_command_timeout, default 180s) is the safety
# backstop for non-terminating commands; on expiry the process is killed and activation continues.
# example: activation_command: "npx nx run-many -t build"
activation_command:
# maximum time in seconds to wait for activation_command to complete before killing it (default 180s).
# must be a positive number.
activation_command_timeout: 180.0
# list of additional workspace folder paths for cross-package reference support.
# Paths can be absolute or relative to the project root.
# Each folder is registered as an LSP workspace folder, enabling language servers to discover
# symbols and references across package boundaries, but these folders are not indexed by Serena,
# i.e. the respective symbols will not be found using Serena's symbol search tools.
# Example:
# additional_workspace_folders:
# - ../sibling-package
# - ../shared-lib
ls_additional_workspace_folders: []
# list of workspace folder paths (LSP backend only).
# These folders will be used to build up Serena's symbol index.
# Paths must be within the project root and should thus be relative to the project root.
# Furthermore, the paths should not be filtered by ignore settings.
# Default setting: The entire project root folder (".") is considered.
# In (large) monorepos, this can be used to index only subfolders of the project root, e.g.
# ls_workspace_folders:
# - "./subproject1"
# - "./subproject2"
ls_workspace_folders:
- .
# list of language servers to start when using the LSP backend; choose from:
# ada al angular ansible bash
# bsl clojure cpp cpp_ccls crystal
# csharp csharp_omnisharp cue dart elixir
# elm erlang fortran fsharp gdscript
# go groovy haskell haxe hlsl
# html java json julia kotlin
# latex lean4 lua luau markdown
# matlab msl nix ocaml pascal
# perl php php_phpactor php_phpantom powershell
# python python_jedi python_pyrefly python_ty r
# rego ruby ruby_solargraph rust scala
# scss solidity svelte swift systemverilog
# terraform toml typescript typescript_vts vue
# yaml zig
# (This list may be outdated; generated with scripts/print_language_list.py;
# For the current list, see values of Language enum here:
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py)
# For some languages, there are several alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
# Note:
# - For C, use cpp
# - For JavaScript, use typescript
# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root)
# - For Svelte projects, use svelte (subsumes typescript/javascript for .svelte projects; requires npm)
# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three)
# - For Free Pascal/Lazarus, use pascal
# Special requirements:
# Some language servers require additional setup/installations.
# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
# When using multiple language servers, the first language server that supports a given file will be used for that file.
# The first language server is the default language and the respective language server will be used as a fallback.
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
language_servers:
- python
+44
View File
@@ -0,0 +1,44 @@
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **vulncheck** (4279 symbols, 12312 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> Index stale? Run `node .gitnexus/run.cjs analyze` from the project root — it auto-selects an available runner. No `.gitnexus/run.cjs` yet? `npx gitnexus analyze` (npm 11 crash → `npm i -g gitnexus`; #1939).
## Always Do
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
- **MUST run `detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows. For regression review, compare against the default branch: `detect_changes({scope: "compare", base_ref: "main"})`.
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
- When exploring unfamiliar code, use `query({search_query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `context({name: "symbolName"})`.
- For security review, `explain({target: "fileOrSymbol"})` lists taint findings (source→sink flows; needs `analyze --pdg`).
## Never Do
- NEVER edit a function, class, or method without first running `impact` on it.
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
- NEVER rename symbols with find-and-replace — use `rename` which understands the call graph.
- NEVER commit changes without running `detect_changes()` to check affected scope.
## Resources
| Resource | Use for |
|----------|---------|
| `gitnexus://repo/vulncheck/context` | Codebase overview, check index freshness |
| `gitnexus://repo/vulncheck/clusters` | All functional areas |
| `gitnexus://repo/vulncheck/processes` | All execution flows |
| `gitnexus://repo/vulncheck/process/{name}` | Step-by-step execution trace |
## CLI
| Task | Read this skill file |
|------|---------------------|
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
<!-- gitnexus:end -->
+3 -3
View File
@@ -1,4 +1,4 @@
# VulnCheck — Architecture Overview
# TrueVuln — Architecture Overview
> Stand: Mai 2026 (dev-Branch, post-Migration 022). Diese Doku spiegelt
> den `dev`-Branch wider. Stable `main` ist eine Teilmenge — siehe
@@ -72,7 +72,7 @@
## 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. |
| **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. |
@@ -296,7 +296,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`.
- 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.
- `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`.
+44
View File
@@ -0,0 +1,44 @@
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **vulncheck** (4279 symbols, 12312 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> Index stale? Run `node .gitnexus/run.cjs analyze` from the project root — it auto-selects an available runner. No `.gitnexus/run.cjs` yet? `npx gitnexus analyze` (npm 11 crash → `npm i -g gitnexus`; #1939).
## Always Do
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
- **MUST run `detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows. For regression review, compare against the default branch: `detect_changes({scope: "compare", base_ref: "main"})`.
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
- When exploring unfamiliar code, use `query({search_query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `context({name: "symbolName"})`.
- For security review, `explain({target: "fileOrSymbol"})` lists taint findings (source→sink flows; needs `analyze --pdg`).
## Never Do
- NEVER edit a function, class, or method without first running `impact` on it.
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
- NEVER rename symbols with find-and-replace — use `rename` which understands the call graph.
- NEVER commit changes without running `detect_changes()` to check affected scope.
## Resources
| Resource | Use for |
|----------|---------|
| `gitnexus://repo/vulncheck/context` | Codebase overview, check index freshness |
| `gitnexus://repo/vulncheck/clusters` | All functional areas |
| `gitnexus://repo/vulncheck/processes` | All execution flows |
| `gitnexus://repo/vulncheck/process/{name}` | Step-by-step execution trace |
## CLI
| Task | Read this skill file |
|------|---------------------|
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
<!-- gitnexus:end -->
+1 -1
View File
@@ -1,4 +1,4 @@
# VulnCheck — Database Schema
# TrueVuln — Database Schema
> Stand: Mai 2026, alembic head = `022_backfill_ssvc_only_exploitation_source`.
> PostgreSQL 16. Times in UTC. `TimestampMixin` adds `created_at` + `updated_at`
+4 -4
View File
@@ -1,4 +1,4 @@
# VulnCheck — Project Overview
# TrueVuln — Project Overview
> Vulnerability Management Dashboard for a Swiss-school IT-Security setup.
> Wazuh agents + Nessus scanner + CISA/ENISA/EPSS threat intel + CIS-Benchmark
@@ -10,7 +10,7 @@
## 🎯 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
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
@@ -180,7 +180,7 @@ AUTH_PROVIDER_CRYPTO_KEY=<fernet-key>
# Wazuh
WAZUH_API_URL=https://wazuh-manager:55000
WAZUH_INDEXER_URL=https://wazuh-indexer:9200
WAZUH_API_USER=vulncheck-readonly
WAZUH_API_USER=truevuln-readonly
WAZUH_API_PASS=<pw>
# 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)
- **CIRCL aggregator** not used — we hit CISA + ENISA directly
- **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
- **Per-framework URS breakdown** schema-ready but UI hidden
- **No WebAuthn / FIDO2** — TOTP is the only second factor
+335 -30
View File
@@ -1,8 +1,8 @@
<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>
<h1 align="center">VulnCheck — Dev Branch Features</h1>
<h1 align="center">TrueVuln — Dev Branch Features</h1>
<p align="center">
<strong>Threat-Intel-Enrichment, Risk-Based Prioritization &amp; Multi-Provider Auth</strong><br>
@@ -18,7 +18,7 @@
## What this branch adds
Five feature groups on top of stable `main`:
Six feature groups on top of stable `main`:
**1. Threat-intel enrichment** — extends the priority score beyond CVSS + Wazuh exploit flags with multiple independent threat-intelligence sources, plus a separate CPR (Cybersecurity Priority Risk) score.
@@ -43,6 +43,8 @@ All sources are **free**, no API key required.
**5. Unified Risk Score (URS)** — single 0-100 figure per asset combining Asset Vulnerability Score (AVS, from CPR) and Asset Security Score (ASS, from impact-weighted SCA), multiplied by per-asset criticality. Daily snapshots feed a trend arrow. CSV-importable CIS-Benchmark impact weights drive the weighting.
**6. Built-in App→CVE detection + Mobile Device Security** — a curated OSV/NVD-CPE/cvelistV5 scanner maps installed software to real CVEs (and suppresses Wazuh's loose-CPE false positives) for hosts with no real vulnerability scanner; a parallel track handles mobile devices — EOL/EOS for Samsung/Apple models, Android patch-level staleness, and per-CVE Android detection via Samsung's own SMR page (preferred, precise) or Google's ASB (fallback). Plus a CISA-KEV "actively exploited" advisory feed, independent of asset findings.
---
## Priority Score (current formula)
@@ -410,14 +412,14 @@ curl -X PUT https://<host>/api/v1/auth-config/role-mappings \
-d '{
"mappings": {
"ldap": [
{"pattern": "CN=VulnCheck-Admins,*", "role": "admin"},
{"pattern": "CN=VulnCheck-Editors,*", "role": "editor"}
{"pattern": "CN=TrueVuln-Admins,*", "role": "admin"},
{"pattern": "CN=TrueVuln-Editors,*", "role": "editor"}
],
"oidc": [
{"pattern": "<azure-group-uuid-admin>", "role": "admin"}
],
"saml": [
{"pattern": "VulnCheck-Admins", "role": "admin"}
{"pattern": "TrueVuln-Admins", "role": "admin"}
]
}
}'
@@ -492,6 +494,32 @@ AUTH_JIT_DEFAULT_ROLE=readonly
AUTH_PROVIDER_CRYPTO_KEY=<fernet-key> # python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
```
### Account lockout
Login is rate-limited (`5/min`/IP) and locks a local account after
`ACCOUNT_LOCKOUT_THRESHOLD` (5) failed attempts. Two modes:
- **Temporary** (default) — auto-unlocks after `ACCOUNT_LOCKOUT_DURATION_MIN`
(15 min). No admin action, no self-lockout risk.
- **Permanent** — set the `auth_lockout_permanent` setting (Settings → General)
to `true`. The account stays locked until an admin clears it via
`POST /auth/users/{id}/unlock` (Unlock button in User Management). Safeguard:
`_is_last_active_admin()` prevents permanently locking the last recoverable
admin (it falls back to the temporary window) so a brute-force on the admin
login can't lock the whole system out. Columns: `users.locked`, `locked_at`
(migration 037).
### Audit-log → syslog / SIEM forwarding
`syslog_service.py` mirrors every `audit_logs` insert to an external syslog
server (RFC-5424, UDP or TCP) when the `syslog_config` setting is enabled
(`{enabled, host, port, protocol, facility}`; admin card under Settings). A
SQLAlchemy `after_insert` hook enqueues onto a bounded queue drained by one
daemon worker — never blocks the request/flush, bursts drain sequentially.
Per-event severity: FAILED/DENIED/LOCK → warning, SECURITY_ALERT/escalation →
alert, deletes/config-change → notice, else info. Disabled = cheap no-op.
No TLS yet (UDP/TCP). Test probe: `POST /api/v1/settings/syslog/test`.
See [`.env.example`](.env.example) for full LDAP / OIDC / SAML blocks with worked
examples for Entra ID, Okta, Keycloak, Google, and Active Directory.
@@ -587,7 +615,7 @@ logging in via the new paths.
| MFA setup returns 404 HTML with `x-nextjs-cache: HIT` | Next.js had no proxy route for `/auth/mfa/*` so it served them as page routes | Catch-all `frontend/app/auth/[...path]/route.ts` is now in place. Frontend rebuild required. |
| MFA card not visible in Settings | Frontend container has the pre-MFA build | `docker compose build frontend && docker compose up -d frontend`. Hard reload (Ctrl+Shift+R). |
| LDAP test bind fails immediately | Bootstrap password loaded once and encrypted in DB; subsequent decrypts fail if you rotated `AUTH_PROVIDER_CRYPTO_KEY` | Clear the cached bind: `DELETE FROM settings WHERE key='ldap_bind_password_encrypted';` then restart. The next start re-bootstraps from `LDAP_BIND_PASSWORD_BOOTSTRAP`. |
| No mail after a Wazuh sync that found new CVEs | Recipient cascade returned empty (vuln/asset not assigned to any user or group with an email) OR severity below `notification_min_severity` threshold OR notifications suppressed on the vuln | Assign asset/vuln, lower threshold in Settings, or un-suppress via the bell icon. Verify with `docker compose logs backend | grep 'Wazuh sync notifications'`. |
| No mail after a sync (Wazuh/Nessus/app-scan/Defender) that found new CVEs | Recipient cascade empty **and** the fallback also empty (finding/asset unassigned, `notification_default_recipients` unset, **and** no active admin has an email) OR severity below `notification_min_severity` OR notifications suppressed on the vuln OR `notification_schedule=nightly` (per-sync sends are deferred to the nightly roundup) | Give an admin an email, set `notification_default_recipients`, assign the asset, lower the threshold, un-suppress via the bell icon, or check the delivery schedule. Verify with `docker compose logs backend \| grep -iE 'digest\|notifications'`. (SLA-breach mails have **no** fallback — they still require an assignee; see *Asset / Finding assignment*.) |
---
@@ -600,52 +628,95 @@ relevant to them. No SMTP rate-limit problems with large syncs.
## How it triggers
Both sync paths route through the same dispatcher:
**Every** sync source routes new findings through the same dispatcher
(`dispatch_new_vuln_notifications()`): **Wazuh, Nessus, the App→CVE scanner,
and Defender TVM**. (Earlier only Wazuh/Nessus did — app-scan and Defender
findings were silently skipped; fixed.) Each source calls the dispatcher after
it enriches its freshly-created findings.
- **Manual:** `POST /api/v1/vulnerabilities/sync/wazuh` (UI button or curl)
- **Scheduled:** the APScheduler-driven sync jobs (Settings → Scan Schedules)
After a sync completes:
After the sync completes:
1. Backend collects `newly_created_vuln_ids` during the sync
1. Backend collects the sync's newly-created vuln ids
2. Loads them, applies `notification_min_severity` filter
3. Resolves recipient per CVE via the existing cascade
3. Resolves recipient per CVE via the cascade
(`vuln.assigned_user``vuln.assigned_group``asset.assigned_user`
`asset.assigned_groups`)
`asset.assigned_groups`), else the default-recipients/admins fallback
4. Groups CVEs by recipient email
5. Sends one digest mail per recipient via `dispatch_new_vuln_notifications()`
5. Sends one digest mail per recipient (rows sorted by CPR desc)
6. Writes one `NotificationLog` per recipient (anchor vuln + total count)
If `vulns_created = 0` (Wazuh reported only updates), no mails fire. Designed
this way to avoid noise on routine syncs.
If a sync created 0 new findings, no mails fire (no noise on routine syncs).
### Delivery schedule (per-sync vs nightly roundup)
`notification_schedule` controls *when* mail goes out:
- **`per_sync`** (default) — dispatch sends at the end of each sync run.
- **`nightly`** — per-sync sends are suppressed (`dispatch_new_vuln_notifications`
returns early when `respect_schedule=True`). A scheduler job
(`new_vuln_digest_nightly`, registered hourly, self-gates on
`notification_nightly_hour` + the schedule flag) then calls
`send_nightly_new_vuln_digest()` which aggregates **all** active findings
detected since the last run — windowed via the `notification_nightly_last_run`
setting so nothing double-sends or is missed — into ONE mail per recipient.
Fewer mails → friendlier to provider anti-spam.
### Send rate limiting
The dispatch send-loop paces mail per `get_email_rate_limit()`:
`email_rate_delay_seconds` (sleep between mails) and `email_max_per_run`
(hard cap per dispatch; 0 = unlimited). Guards against provider bursting.
## Settings
| Setting key | Default | Purpose |
|---|---|---|
| `notification_mode` | `digest` | `digest` (one mail per recipient) or `single` (legacy, one mail per CVE) |
| `notification_schedule` | `per_sync` | `per_sync` (send after each sync) or `nightly` (suppress per-sync, one roundup) |
| `notification_nightly_hour` | `6` | Hour (023, server time) the nightly roundup fires |
| `notification_nightly_last_run` | — | Internal — ISO timestamp of the last nightly roundup (window anchor) |
| `notification_min_severity` | `critical` | Skip CVEs below this severity (`critical` / `high` / `medium` / `low`) |
| `notification_lifecycle_mode` | `exclude` | `exclude` = pseudo-findings that aren't real CVEs (`EOL-*`, `ANDROID-PATCH-*`, `NESSUS-PLUGIN-*`) never trigger the CVE mails; `include` = legacy mixed behaviour. Rule is "anything not `CVE-*`" (`is_lifecycle_finding()`), so new pseudo prefixes are covered automatically |
| `notification_default_recipients` | — | Fallback recipients (comma-separated) for **new-CVE** mails when a finding has no assignee. Empty → all active admins. |
| `email_rate_delay_seconds` | `0` | Seconds to sleep between mails in a dispatch (anti-spam pacing) |
| `email_max_per_run` | `0` | Max mails per dispatch run (0 = unlimited) |
| `smtp_config` | — | Required. Without SMTP no mails go out. |
All three editable via Settings UI (no env-var needed).
All editable via Settings → Notifications (no env-var needed).
## Mail content
Subject: `[VULNCHECK] N new vulnerabilities detected`
Subject: `[TRUEVULN] N new vulnerabilities detected`
Body: severity-count badges (Critical / High / Medium / Low), table of CVE +
Severity + CVSS + Host + Package, "Open in dashboard" button. Template lives
in `app/services/email_service.py:DEFAULT_DIGEST_TEMPLATE` and is overridable
via the `email_template_new_vuln_digest` setting (custom HTML/Jinja-light
variables `{{total}}`, `{{count_critical}}`, `{{count_high}}`, `{{rows}}`,
`{{detected_at}}`, `{{recipient_name}}`, `{{dashboard_url}}`).
Body: severity-count badges, then a table **sorted by CPR descending** with
columns **CVE (deep link) · Severity · CVSS · CPR · Host · #Systems · Package**,
and an "Open in dashboard" button. `#Systems` = distinct assets affected by that
CVE across the whole inventory (`_affected_counts()`). Default template lives in
`email_service.py:DEFAULT_DIGEST_TEMPLATE`, editable in **Settings →
Notifications → New Vulnerability Digest Template** (the `email_template_new_vuln_digest`
setting). The `email_template_new_vuln` (single-mode) template is separate and
only used when `notification_mode='single'` — the UI badges show which is active.
Digest top-level variables: `{{total}}`, `{{affected_assets_count}}`
(distinct systems across the digest), `{{rows}}` (the pre-rendered table,
CPR/#Systems/links baked in), `{{count_critical|high|medium|low}}`,
`{{recipient_name}}`, `{{detected_at}}`, `{{dashboard_url}}`.
Single-mode variables (per CVE): `{{cve_id}}`, `{{severity_upper}}`,
`{{cvss_score}}`, `{{cpr_score}}`, `{{affected_assets_count}}`,
`{{asset_hostname}}`, `{{package_name}}`, `{{description}}`, `{{detected_at}}`,
`{{dashboard_url}}`, and ready-made deep links `{{cve_link}}`, `{{asset_link}}`,
`{{cve_on_asset_link}}`.
## Fresh-install behaviour
Out-of-box flow for a clean deployment:
1. SMTP configured in Settings → Email
2. Asset assigned to a user or group with a populated `users.email`
2. A recipient exists: either the asset/finding is assigned to a user/group
with a populated `users.email`, **or** a fallback applies (see
*Asset / Finding assignment* below) — an active admin email, or the
configured `notification_default_recipients`
3. `notification_min_severity` set (default `critical` works for most)
4. First Wazuh sync runs (manual or scheduled)
5. New CVEs found → digest mail goes out automatically, no extra config
@@ -668,6 +739,34 @@ Wazuh sync notifications: 3 sent, 0 failed, 3 recipients
`/notifications` UI shows the resulting log row(s) per recipient.
## Asset / Finding assignment — what it actually does
Assignment (a **user or group**, set on an **asset** or on an individual
**finding**) is purely a **responsibility / notification tag**. It does **not**
gate scanning, scoring, the SLA calculation itself, report contents, or
visibility — RBAC is role-based, so anyone with view rights sees every CVE
regardless of who it's assigned to.
Recipient **cascade** (both mail paths, first match wins):
`finding.assigned_user``finding.assigned_group``asset.assigned_user`
`asset.assigned_groups`.
What assignment controls:
| Effect | Behaviour |
|---|---|
| **New-CVE digest** (`email_service._resolve_recipients_for_vuln`) | Goes to the assignee via the cascade. **Fallback when nothing is assigned:** `notification_default_recipients`, or — if that's empty — every active admin. So "the admin gets everything" works without any assignment. |
| **SLA-breach digest** (`scheduler.check_sla_breaches`) | Goes to the assignee via the cascade **only****no fallback**. An unassigned SLA breach sends **no mail** to anyone. |
| **"Assigned To" column** (Assets & Vulnerabilities pages) | Display + sort only. |
| **Cleanup** | Deleting a user/group nulls their assignments automatically. |
**Runbook recommendation:** assign each asset to a system owner (a user or
group with a populated email). It's the only way to (a) route new-CVE mails to
the real owner instead of blanket-to-admins, and (b) make **SLA-breach mails
reach anyone at all** — the admin fallback does *not* apply to SLA breaches.
For a catch-all on new-CVE mails without per-asset ownership, set
Settings → Notifications → **Default Recipient(s)** instead.
---
# Nessus Integration (multi-scanner unified view)
@@ -735,7 +834,7 @@ Per Nessus host, the sync tries in order:
Unmatched hosts are returned in the sync response under
`unmatched_hosts` so admins can spot drift between Nessus targets and
the VulnCheck inventory.
the TrueVuln inventory.
## Merge logic per `(cve_id, asset_id)`
@@ -796,7 +895,7 @@ bounded even for full-DB corrections.
┌─ Stage 3 — cvelistV5 (MITRE/CVE.org) ──────────────────────────┐
│ github.com/CVEProject/cvelistV5 │
│ • 557 MB ZIP, disk-cached 12h at │
│ /tmp/vulncheck-cvelistv5-cache.zip │
│ /tmp/truevuln-cvelistv5-cache.zip │
│ • exhaustive — every published CVE (~250k+) │
│ • same CVE-5 JSON shape as Vulnrichment, parser reused │
└─────────────────────────────────────────────────────────────────┘
@@ -943,7 +1042,7 @@ UI smoke test:
## Nessus — out of scope for v1
- Triggering Nessus scans from VulnCheck (we only import existing scans)
- Triggering Nessus scans from TrueVuln (we only import existing scans)
- Two-way sync of false-positive status back to Nessus
- Network discovery / SNMP-based asset auto-creation beyond the
hostname/IP-match step
@@ -1184,6 +1283,119 @@ GROUP BY severity ORDER BY severity;"
---
# App→CVE Detection Engine & Mobile Device Security
Closes the coverage gap where a device has no real vulnerability scanner
(Intune-only endpoints, mobile devices) or where the scanner's own CPE
matching is too loose (false positives) or too narrow (false negatives).
## Built-in App CVE Scanner
Maps installed software (Wazuh syscollector packages, Intune `detectedApps`)
to real CVEs via two independent, complementary sources:
| Source | Module | Strength |
|---|---|---|
| **OSV.dev** | `app_cve_scanner_service.py` | Precise server-side version matching for language ecosystems (npm, PyPI, ...) |
| **NVD-CPE** | `app_cve_scanner_service.py` | Broad desktop-app coverage via curated CPE registry + own version-range check (cpeMatch start/end incl/excl) |
| **cvelistV5 range match** | `cvelistv5_scan_service.py` | Catches CVEs NVD hasn't CPE'd yet, or filed under a CPE product string we didn't curate (e.g. a TeamViewer CVE under `teamviewer:remote`, not `teamviewer:teamviewer`) — matches directly against the CNA's own `affected[].vendor/product` + version ranges |
Both registries are curated (name-regex → vendor/product), not fuzzy —
unknown software is skipped rather than guessed, to keep false-positives
near zero. Findings upsert as `source='app-scan'` with real CVE ids, so the
normal EPSS/KEV/CVSS enrichment and multi-source cross-confirm apply.
The cvelistV5 path needs a **reverse index** (`{vendor,product} → CVE
ranges`) built by walking the ~557 MB cvelistV5 ZIP once; cached in a
Setting, rebuilt by the nightly job. A manual "App CVE Scan" run builds it
on demand if missing (slower on first run, then cached).
**False-positive suppression** (same cvelistV5 data, inverse direction):
Wazuh's own CPE matching sometimes over-reports across product editions
(e.g. flags a SQL Server 2019 host with a CVE that only affects 2022/2025).
`cvelistv5_scan_service.suppress_false_positives` marks a Wazuh finding
`false_positive` only when the installed version is provably outside
**every** clean cvelistV5 range for the matched product — conservative by
design (Wazuh-sourced only, ≥2 shared significant tokens required to scope
a product, any unbounded/ambiguous range aborts the check).
Endpoints: `POST /api/v1/vulnerabilities/app-cve-scan`,
`POST /api/v1/vulnerabilities/suppress-false-positives` (both scoped to
`asset_id` optionally). Nightly job runs the scan, rebuilds the cvelistV5
index, then runs suppression.
## Mobile Device Security (Intune)
Runs inline during the Intune sync — no extra Graph calls beyond the
device dict and `detectedApps` already fetched.
- **Device EOL/EOS** (`mobile_eol_service.py`) — Apple (iPhone/iPad) fuzzy-
matches endoflife.date's full release list by marketing name; Samsung
needs a curated SM-code → release-name table (no textual bridge exists
between Intune's model code and endoflife's marketing name in either
dataset) — ~120 models, all verified against the live endoflife API.
- **OS-level CVEs** — iOS/iPadOS/macOS via NVD-CPE (`app_cve_scanner_service`),
with a platform check (CPE `target_sw` token) so e.g. a Firefox-for-iOS
CVE can't match a desktop Firefox install.
- **Android patch-level staleness** — Intune's `androidSecurityPatchLevel`
vs. today; graduated severity (≥90/180/365 days → low/medium/high).
- **Android per-CVE detection** — two sources, Samsung preferred:
- **Samsung SMR** (`samsung_smr_service.py`) — `securityUpdate.smsb?year=YYYY`
serves the full year's ~12 monthly sections server-side (the accordion
UI is pure CSS/JS, doesn't gate content); parses each `SMR-MMM-YYYY`
block's Google Critical/High list **minus** "Not applicable to Samsung
devices" (chipset-specific CVEs Samsung's own page excludes) plus
Samsung Semiconductor fixes. Precise per-device applicability.
- **Google ASB** (`android_cve_service.py`) — fallback for non-Samsung
Android or months SMR doesn't cover. Section-aware parser keeps only
AOSP sections (Framework/System/Kernel/...), drops SoC/vendor sections
(Qualcomm/MediaTek/...) that only apply to that specific chipset.
URL format changed in 2026 (`/bulletin/{year}/{month}` vs. the older
flat `/bulletin/{month}`) — both tried.
- Both cache per-month/year in a Setting; empty/404 months are
negatively cached (short TTL) so a not-yet-published month doesn't
trigger a re-fetch on every device sync.
Dashboard: a dedicated **"Mobile Security · EOL & Patch Level"** widget,
kept separate from the desktop-software EOL widget, sorted so a reached
vendor-EOL outranks patch-level staleness. `GET /api/v1/vulnerabilities?
finding_type=mobile` backs both the widget and its "View All".
## Advisory Awareness Feed
Independent of asset findings — a rolling view of what's actively
exploited in the wild (CISA KEV), so 0-days are visible before any scanner
flags an affected asset. Reuses the KEV catalog enrichment already
fetches/caches (24h). `GET /api/v1/advisories/kev-recent` → dashboard
widget with an "in inventory / not seen" badge per CVE (one grouped query,
no per-CVE lookup).
## Assets: filter by sync source
`GET /api/v1/assets?source=...` now filters by the **actual scanner
linkage** (`wazuh_agent_id` / `nessus_host_uuid` / `intune_device_id` /
`defender_machine_id` / a vuln row whose `sources` names that scanner) —
not the creation-time `source` enum, which never updates after an asset is
matched by a second scanner post-creation.
## Out of scope (future ideas)
- Samsung-proprietary SVE CVEs (no ASB equivalent; would need
`security.samsungmobile.com`'s per-device JS-loaded detail view, not
scrapeable without a browser)
- Android bulletin OEM coverage beyond Samsung (Google Pixel / others) —
ASB-only fallback already covers them, just without a vendor-specific
applicability filter
- SAP (Business Client / GUI / Analysis for MS Office) CVE detection needs
a patch-level (SP/PL) aware matcher — NVD's CPE covers a whole minor
version with no SP granularity, so a naive match false-positives on
already-patched installs. Needs a curated per-CVE fixed-SP table.
- Microsoft Teams classic EOL flag — no endoflife.date product exists;
would need a hardcoded retirement-date exotic (same shape as the existing
Silverlight/VC++ redistributable entries)
---
## Out of scope (future ideas)
- LDAP password change flow (currently read-only — users change pw in AD)
@@ -1193,3 +1405,96 @@ GROUP BY severity ORDER BY severity;"
- LDAP referral chasing across multiple forests
- Encrypted SAML assertions (currently only signed)
- OIDC back-channel logout
---
# Data sources added for CVEs that never reach NVD / cvelistV5
Three upstreams publish CVEs that the usual feeds do not carry. Each is
version-range checked like any other source, so a patched host is never flagged.
| Source | Covers | Why it is needed |
|---|---|---|
| **MSRC CVRF** (`msrc_scan_service`) | Windows Server, SharePoint, **Microsoft Edge** | Microsoft ships no version ranges; the CVRF's `FixedBuild` is the only machine-readable "which build carries the fix". Edge CVEs are absent from NVD *and* cvelistV5 entirely. |
| **GitHub repository advisories** (`github_repo_advisory_service`) | projects that self-publish, e.g. Notepad++ | The per-repo endpoint (unlike the global `/advisories`) carries `vulnerable_version_range` + `patched_versions`. |
| **Mozilla MFSA** (`mozilla_advisory_service`) | Firefox severity | Mozilla rates impact before NVD/cvelistV5 have a score. |
Notes:
- **Edge is never scored off Chrome data.** Edge 150.0.4078.99 rides on Chromium
150.0.7871.187 — the schemes are unrelated, so a `google:chrome` range says
nothing about an Edge build. WebView2 is excluded for the same reason (own
package, own version).
- The MSRC index is cached under `msrc_product_index_v2`. **Bump that key
whenever `_PRODUCTS` changes** — a cache built by an older version simply has
no entry for the new product, and `load_index(allow_stale=True)` will serve it
anyway. The app scan rebuilds the index on demand when it is missing.
- Repo-advisory version ranges are maintainer free-text (`<= v8.9.6.4`,
`old versions - 8.8.1`, `v8.9.4 & v8.9.5`, bare versions). `patched_versions`
is the hard safety net: at/past the patched release is never flagged, and an
explicit version list matches exactly, never "everything below".
## One finding, several affected products
A finding is unique per `(cve_id, asset_id)` (index `uq_vuln_cve_asset`), so a
CVE hitting two products on one host — CVE-2026-16417 affects Chrome **and**
Edge — is ONE row carrying BOTH products, not two rows (splitting would break
dedup, source reconciliation and every count). The per-product detail lives in
`vulnerability_packages` and is written via
`audit_events.record_affected_package()`; the API returns it as `packages` and
the CVE detail page renders one card per entry.
The helper is idempotent per `(finding, package name)` and must never break a
scan: a plain query does not see rows added earlier in the same unflushed
transaction, so it also scans `db.new`, inserts inside a savepoint, and swallows
what is left.
## Cross-source contract (do not weaken it)
Every scanner retracts **only its own source**; a finding closes when no source
is left. An inventory-based override that closed over a lone Wazuh claim was
removed after producing false negatives four different ways: multi-stream fixes
(MySQL 8.0.23 vs a 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. A single stored
`fixed_version` cannot validate an arbitrary inventory version string.
Related guards:
- The app-scan reconcile only runs on **ACTIVE** assets. A disconnected Wazuh
agent still serves its last stored syscollector data, so "no longer detected"
proves nothing — and Wazuh's own vuln sync skips inactive agents, so nothing
would reopen the findings.
- Products whose ARP version is an MSI build (Python, .NET) are marked
`name_ver=True` so the semantic version is read from the display name.
## Background jobs (GUI)
Long scans return immediately and are polled, so the browser never times out:
| Start | Status |
|---|---|
| `POST /api/v1/vulnerabilities/eol-check/start` | `GET /api/v1/vulnerabilities/eol-check/status` |
| `POST /api/v1/vulnerabilities/override/vulnrichment/start` | `GET .../vulnrichment/status/{job_id}` |
| `POST /api/v1/vulnerabilities/msrc/refresh` | `GET /api/v1/vulnerabilities/msrc/refresh/status` |
Job state lives in process memory — a backend restart clears it (and aborts the
run). The synchronous `POST /eol-check` remains for API clients.
## Settings (encrypted at rest)
| Key | Purpose |
|---|---|
| `github_pat` | Optional GitHub token. Lifts GHSA **and** the MFSA/repo-advisory fetches from 60 to 5000 req/h. Fine-grained, no scopes needed (public data). |
## UI
- **Themes**: light / mid (soft dark) / dark, switchable at the bottom of the
sidebar, stored in `localStorage('theme')` and applied to
`<html data-theme>` by a pre-paint script in `app/layout.tsx` (no white
flash). Pages use hardcoded light utilities, so `globals.css` remaps them
under `html[data-theme="mid"|"dark"]` in an **un-layered** block — un-layered
author CSS beats Tailwind's `@layer utilities` without `!important`. Opacity
and arbitrary-value variants (`bg-gray-50/50`, `bg-[#F3F4F6]/95`) are their
own class names and need their own remap.
- **Audit log**: `GET /audit/logs` takes `search`, `sort`, `order` and sets
`X-Total-Count` (already CORS-exposed) for real pagination.
+239 -53
View File
@@ -1,12 +1,12 @@
<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>
<h1 align="center">VulnCheck Dashboard</h1>
<h1 align="center">TrueVuln Dashboard</h1>
<p align="center">
<strong>Open Source Vulnerability Management for Wazuh</strong><br>
Prioritize, verify, and resolve vulnerabilities with automated workflows and AI-powered analysis.
<strong>Open Source Vulnerability Management Wazuh · Nessus · Microsoft Intune</strong><br>
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 align="center">
@@ -21,16 +21,22 @@
## 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:**
- Sync vulnerabilities and assets from Wazuh automatically
- AI-powered CVE analysis with remediation recommendations
- SLA policy enforcement with automated email alerts
- Automated patch verification via Wazuh Syscollector rescans
- Role-based access with full audit trail
- PDF/CSV reporting for compliance workflows
- Multi-source inventory & findings: Wazuh agents, Nessus scans, Microsoft Intune (MDM/UEM) + Defender for Endpoint TVM
- Cross-confirmation when several scanners report the same CVE on the same host
- Threat-intel enrichment: EPSS, CISA KEV, ENISA EUVD, public-exploit catalogs (Exploit-DB / PoC-in-GitHub / Metasploit), and authoritative CVE dates/CVSS (cvelistV5 / Vulnrichment / NVD)
- Multi-source remediation: scanner solution + Microsoft (MSRC KBs), Ubuntu USN, Red Hat/CentOS errata, and the OSV.dev aggregator
- End-of-life / end-of-support detection (endoflife.date + the Microsoft product-lifecycle export) and Microsoft 365 Apps CVE detection (not in NVD)
- 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,14 +53,49 @@ VulnCheck is a self-hosted vulnerability management dashboard that integrates wi
- Status tracking: Open, Patched, Pending Verification, Patch Failed, Accepted Risk, False Positive, Deferred
- Per-vulnerability notification suppression
### Wazuh Integration
- Auto-discover agents and sync vulnerability data
- Trigger Syscollector scans directly from the UI
- Automated patch verification: mark as patched, VulnCheck rescans and confirms
- Deduplication of CVEs per asset
### Scanner & Inventory Integrations
- **Wazuh** -- auto-discover agents, sync vulnerability data, trigger Syscollector scans from the UI, automated patch verification (mark patched → rescan → confirm)
- **Tenable Nessus** -- import findings from configured scans (X-ApiKeys), launch/poll/import scans, VPR score, exploit availability/maturity, scanner remediation text
- **Microsoft Intune (Graph API)** -- app-only (client-credentials) sync of managed devices → assets + OS-EOL, and `detectedApps` → EOL/M365 detection (no Wazuh agent required)
- **Microsoft Defender for Endpoint (TVM)** -- optional real per-device CVEs via the Defender API
- 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)
- **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
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 |
|---|---|
@@ -65,26 +106,61 @@ Supports multiple AI providers for CVE analysis, threat assessment, and remediat
| 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.
> **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
- **Scheduled scans** with configurable intervals (hourly, daily, weekly) or cron expressions
- **SLA breach monitoring** runs hourly, sends email alerts to assigned users/groups
- **Patch verification** triggers a Wazuh rescan and updates status automatically
### Notifications
- 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)
#### Nightly job order (UTC)
> **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 |
|------|-----|-------|
| 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
- Auto-discovery from Wazuh agents or manual creation
- Bulk assignment to users, groups, and SLA policies
- 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
- Define severity-based remediation windows (e.g., Critical: 2 days, High: 7 days)
- Assign policies to assets
@@ -97,8 +173,11 @@ Supports multiple AI providers for CVE analysis, threat assessment, and remediat
### Security
- JWT authentication with role-based access control (Admin, Editor, Readonly)
- Bcrypt password hashing with account lockout
- Comprehensive audit logging (who changed what, when)
- 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
- **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)
- Rate limiting on API endpoints
@@ -109,7 +188,9 @@ Three roles with hierarchical permissions: **Admin > Editor > Readonly**
| Action | Readonly | Editor | Admin |
|---|:---:|:---:|:---:|
| **View** vulnerabilities, assets, reports, dashboards | ✅ | ✅ | ✅ |
| **View** scan history, notification history | ✅ | ✅ | ✅ |
| **View/download** reports (Executive, Technical CSV, ISO 27001, Patching) | ✅ | ✅ | ✅ |
| **View** scan history | ✅ | ✅ | ✅ |
| **View** notification history | own only | own only | ✅ all |
| **View** AI analysis history | ✅ | ✅ | ✅ |
| **Edit** vulnerabilities (status, assign, defer, reopen) | ❌ | ✅ | ✅ |
| **Trigger** Wazuh sync and scans | ❌ | ✅ | ✅ |
@@ -122,7 +203,7 @@ Three roles with hierarchical permissions: **Admin > Editor > Readonly**
| **Create/Delete** SLA policies | ❌ | ❌ | ✅ |
| **Manage** users (create, edit, delete, reset password) | ❌ | ❌ | ✅ |
| **Manage** groups | ❌ | ❌ | ✅ |
| **Configure** settings (Wazuh, AI, SMTP) | ❌ | ❌ | ✅ |
| **Configure** settings (Wazuh, Nessus, Intune, AI, SMTP) | ❌ | ❌ | ✅ |
| **Send** test/critical notifications | ❌ | ❌ | ✅ |
| **View** audit logs | ❌ | ❌ | ✅ |
@@ -131,7 +212,7 @@ Three roles with hierarchical permissions: **Admin > Editor > Readonly**
## Screenshots
### Dashboard
![VulnCheck Dashboard](https://gitea.isuit.ch/vulncheck/vulncheck/raw/branch/main/docs/screenshots/dashboard.png)
![TrueVuln Dashboard](docs/screenshots/dashboard.png)
---
@@ -149,7 +230,7 @@ Three roles with hierarchical permissions: **Admin > Editor > Readonly**
| RAM | 2 GB | 4 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
@@ -216,7 +297,7 @@ This starts three containers:
### Reverse Proxy (recommended)
VulnCheck is designed to run behind a reverse proxy (Nginx Proxy Manager, Traefik, Caddy, etc.):
TrueVuln is designed to run behind a reverse proxy (Nginx Proxy Manager, Traefik, Caddy, etc.):
1. Point your reverse proxy to the **frontend** port (default `3000`)
2. The frontend proxies all API calls to the backend internally via Docker networking
@@ -295,11 +376,13 @@ curl -X POST https://your-domain.tld/auth/setup-admin \
### 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
2. **AI Provider** -- Choose your provider and enter API credentials
3. **SMTP Email** -- For SLA breach and vulnerability notifications
2. **Tenable Nessus** -- base URL + API keys, default scan IDs
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,11 +397,13 @@ In **Settings**, configure:
┌─────────────┼─────────────┐
│ │ │
┌─────▼────┐ ┌────▼─────┐ ┌───▼────┐
│PostgreSQL │ │ Wazuh │ │ AI │
│ 15 │ │ SIEM │ │Provider│
│PostgreSQL │ │ Scanners │ │ AI │
│ 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:**
| Layer | Technology |
@@ -349,6 +434,7 @@ All configuration is done via the `.env` file:
| `JWT_SECRET_KEY` | -- | **Required.** JWT signing key (`openssl rand -hex 32`) |
| `ENV` | `production` | `development` or `production` |
| `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_SAMESITE` | `lax` | Cookie SameSite policy |
| `TRUST_PROXY_HEADERS` | `false` | Trust `X-Forwarded-For` for client IP (enable behind reverse proxy) |
@@ -358,7 +444,7 @@ All configuration is done via the `.env` file:
### 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,7 +452,7 @@ All integrations (Wazuh, AI, SMTP) are configured through the **Settings** page
### 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 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.
```
Internet → Reverse Proxy (443/HTTPS) → Frontend (3003) → Backend (8022, internal)
@@ -487,8 +573,9 @@ The backend exposes a REST API at `/api/v1/`. All endpoints require JWT authenti
| Endpoint Group | Base Path | Description |
|---|---|---|
| Authentication | `/auth` | Login, logout, user management |
| Vulnerabilities | `/api/v1/vulnerabilities` | CRUD, AI analysis, Wazuh sync, bulk ops |
| Assets | `/api/v1/assets` | Inventory, assignment, bulk ops |
| Vulnerabilities | `/api/v1/vulnerabilities` | CRUD, AI analysis, Wazuh sync, App→CVE scan, FP suppression, 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 |
| Policies | `/api/v1/policies` | SLA policy management |
| Groups | `/api/v1/groups` | User group management |
@@ -498,7 +585,117 @@ The backend exposes a REST API at `/api/v1/`. All endpoints require JWT authenti
| Audit | `/audit` | Audit trail (admin only) |
| 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.01.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 +714,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
This project is licensed under the **GNU Affero General Public License v3.0 (AGPLv3)**. See [LICENSE](LICENSE) for details.
@@ -650,7 +836,7 @@ If you encounter issues or have questions, please [open an issue](https://gitea.
## 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>
+1 -1
View File
@@ -8,7 +8,7 @@ Adds ``vulnerabilities.nessus_vpr_score`` — Tenable's proprietary
Vulnerability Priority Rating (010) from the Nessus plugin payload.
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
Idempotent — skips the column add if it already exists.
@@ -0,0 +1,81 @@
"""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
Tester 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:
# Use raw SQL through the migration's session_bind so the connection
# is the same one alembic manages — no extra pool, no second engine.
bind = op.get_bind()
# Re-import the model in the migration context. Alembic env has
# already imported Base.metadata via app.models.base; this import
# pulls in the Asset / AuditLog / AssetSource / AssetStatus enums
# we need for the audit insert.
from app.models.asset import Asset, AssetSource, AssetStatus
from app.services.asset_lifecycle import _audit_asset_status
from sqlalchemy.orm import Session
with Session(bind=bind) as db:
legacy = (
db.query(Asset)
.filter(
Asset.source == AssetSource.NESSUS,
Asset.status == AssetStatus.ACTIVE,
Asset.nessus_host_uuid.is_(None),
)
.all()
)
if not legacy:
# Nothing to do — migration is a no-op on already-clean DBs.
return
for a in legacy:
a.status = AssetStatus.INACTIVE
_audit_asset_status(
db,
a,
"active",
"inactive",
"legacy Nessus-sourced asset without pinned nessus_host_uuid — "
"flipped by alembic migration 028 (reconcile_legacy_nessus_assets)",
)
db.commit()
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
Tester 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" — the tester explicitly wanted both
("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
Tester feature: 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
Tester: 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
Tester feature: 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);")
+3 -3
View File
@@ -6,11 +6,11 @@ Configuration lives in the `settings` table under key `auth_role_mappings`
{
"ldap": [
{"pattern": "CN=VulnCheck-Admins,*", "role": "admin"},
{"pattern": "CN=VulnCheck-Editors,*", "role": "editor"}
{"pattern": "CN=TrueVuln-Admins,*", "role": "admin"},
{"pattern": "CN=TrueVuln-Editors,*", "role": "editor"}
],
"oidc": [
{"pattern": "vulncheck-admins", "role": "admin"}
{"pattern": "truevuln-admins", "role": "admin"}
],
"saml": [...]
}
+3
View File
@@ -28,6 +28,9 @@ PROTECTED_SETTING_KEYS: frozenset[str] = frozenset({
"wazuh_config",
"smtp_config",
"nessus_config",
"openrouter_api_key",
"intune_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.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__)
ACCOUNT_LOCKOUT_THRESHOLD = 5
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
# verify cost is constant-time regardless of whether the username exists.
# Generated once at module import. The plaintext is irrelevant — it is never
@@ -61,7 +87,15 @@ class LocalAuthStrategy(AuthStrategy):
if not user.is_active:
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 updated_at is older than lockout window, auto-reset.
unlock_after = (user.updated_at or user.created_at) + timedelta(
@@ -77,6 +111,16 @@ class LocalAuthStrategy(AuthStrategy):
# Password check
if not user.password_hash or not verify_password(password, user.password_hash):
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()
raise AuthError(detail=f"bad password for '{username}'")
+1 -1
View File
@@ -27,7 +27,7 @@ from cryptography.fernet import Fernet, InvalidToken
logger = logging.getLogger(__name__)
ISSUER = "VulnCheck"
ISSUER = "TrueVuln"
TOTP_DIGITS = 6
TOTP_INTERVAL = 30
TOTP_VALID_WINDOW = 1 # accept current ± 1 step to tolerate clock skew
+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":
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 = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json"
@@ -152,11 +169,13 @@ Structure your response as JSON ONLY:
{"role": "user", "content": prompt}
],
"temperature": temperature,
"max_tokens": 2000
"max_tokens": self.MAX_TOKENS
}
if enable_web_search and self.provider == "infomaniak":
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}")
@@ -167,7 +186,15 @@ Structure your response as JSON ONLY:
response.raise_for_status()
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:
headers = {
@@ -178,13 +205,17 @@ Structure your response as JSON ONLY:
payload = {
"model": self.model,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": 2000,
"max_tokens": self.MAX_TOKENS,
"temperature": temperature
}
response = self.client.post(self.base_url, headers=headers, json=payload)
response.raise_for_status()
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]:
try:
+160
View File
@@ -0,0 +1,160 @@
"""
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"
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.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]:
# MDE allows ~100 calls/minute, and the per-machine vulnerability walk
# is one call per machine — so a sync of any size hits 429, more so when
# an app scan runs alongside it (tester: a wall of "429 Too Many
# Requests"). 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):
r = self.client.get(url, headers={"Authorization": f"Bearer {token}",
"Accept": "application/json"},
params=params)
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
return out
def get_machines(self) -> List[dict]:
return self._get_all("/machines")
def get_machine_vulnerabilities(self, machine_id: str) -> List[dict]:
return self._get_all(f"/machines/{machine_id}/vulnerabilities")
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 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
+233
View File
@@ -0,0 +1,233 @@
"""
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 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"
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"
class GraphAPIError(Exception):
"""Base exception for Microsoft Graph errors."""
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
break
if r.status_code == 429:
raise GraphAPIError("Graph rate limit (429) after retries")
if r.status_code >= 400:
raise GraphAPIError(f"Graph GET {url} -> {r.status_code}: {r.text[:200]}")
return r.json()
def _get_all(self, path: str, params: Optional[Dict[str, Any]] = None,
max_pages: int = 200) -> List[dict]:
"""Follow @odata.nextLink pagination, return the flattened value list."""
url = f"{GRAPH_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."""
fields = ("id,deviceName,operatingSystem,osVersion,complianceState,"
"lastSyncDateTime,manufacturer,model,serialNumber,"
"managedDeviceOwnerType,azureADDeviceId,androidSecurityPatchLevel")
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)
try:
out: List[dict] = []
url = (f"https://graph.microsoft.com/beta/deviceManagement/"
f"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
except GraphAPIError as e:
logger.debug("beta detectedApps failed for %s: %s", device_id, e)
return []
@staticmethod
def _map_apps(apps: list) -> List[dict]:
out = []
for a in apps or []:
name = (a.get("displayName") or "").strip()
if name:
out.append({"name": name, "version": (a.get("version") or "").strip(),
"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
+60 -7
View File
@@ -85,30 +85,83 @@ class AIAnalysisService:
return ai_analysis
def get_priority_recommendations(self, limit: int = 20, severity: Optional[str] = None) -> Dict[str, Any]:
# Implementation similar to original but using universal client
query = self.db.query(Vulnerability).filter(Vulnerability.status == "open")
# Same scope the reports and the dashboard use: real CVEs on assets
# 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:
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:
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 = []
seen_cves = set()
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({
"cve_id": v.cve_id,
"hostname": v.asset.hostname if v.asset else "Unknown",
"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 = 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)}
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:
{{
"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:
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
if "<think>" in response_text:
+1 -1
View File
@@ -301,7 +301,7 @@ class NessusClient:
"Nessus Essentials — targeted host scans via the API "
"aren't supported by the free edition. Workarounds: "
"(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 / "
"Tenable.io which support API-driven launches."
) from e
+151 -38
View File
@@ -282,6 +282,86 @@ class WazuhClient:
# Vulnerability Management
# ============================================
_STATES_INDEX = "/wazuh-states-vulnerabilities-*"
def _paged_hits(self, agent_id, query, page_size, offset, max_total):
"""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"{self._STATES_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):
"""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}
response = self._indexer_request(
"POST", f"{self._STATES_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)
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)} vulnerabilities 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(
self,
agent_id: str,
@@ -390,51 +470,33 @@ class WazuhClient:
logger.warning(f"Could not query solved CVEs from alerts: {e}")
# Step 2: Query active vulnerabilities from states index.
# Pagination: OpenSearch caps from+size at index.max_result_window
# (default 10000), so we loop in pages of `limit` (default 5000)
# until total_hits is drained or we hit MAX_TOTAL safety cap.
# Prior behaviour was a single 5000-hit fetch — silently dropped
# everything above 5000 on agents with very large finding sets
# (tester saw 10000 hits returning only 5000). 50k cap covers
# every real Wazuh fleet without unbounded memory growth.
# Paged via scroll (see _scroll_hits) — from/size is hard-capped by
# OpenSearch's index.max_result_window (default 10000) and 400s beyond
# it, which made big agents look like "0 vulns" and skip their backfill.
# MAX_TOTAL is a memory guard, not a protocol limit: 50k covers every
# real Wazuh fleet without unbounded growth.
MAX_TOTAL = 50_000
PAGE_SIZE = max(1, min(limit, 5000))
hits: List[Dict[str, Any]] = []
total_hits = 0
page_offset = offset
base_query = {"bool": {"must": [{"term": {"agent.id": agent_id}}]}}
try:
while True:
query = {
"size": PAGE_SIZE,
"from": page_offset,
"query": {
"bool": {
"must": [{"term": {"agent.id": agent_id}}]
}
},
"track_total_hits": True,
}
response = self._indexer_request(
"POST",
"/wazuh-states-vulnerabilities-*/_search",
json_data=query,
if offset:
# Explicit offset requested → keep the classic from/size path,
# bounded below the result window (see _paged_hits).
hits, total_hits = self._paged_hits(
agent_id, base_query, PAGE_SIZE, offset, MAX_TOTAL
)
page_hits = response.get("hits", {}).get("hits", []) or []
total_hits = response.get("hits", {}).get("total", {}).get("value", 0)
hits.extend(page_hits)
logger.info(
f"Agent {agent_id}: page from={page_offset} returned "
f"{len(page_hits)} hits (total: {total_hits}, accumulated: {len(hits)})"
else:
# Full sync: scroll. from/size is capped by OpenSearch's
# index.max_result_window (default 10000) — agents with more
# findings 400'd at from=10000, the whole agent then looked like
# "0 vulns" and its backfill was skipped (tester: agents with
# 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
)
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 = []
skipped_no_cve = 0
@@ -500,6 +562,7 @@ class WazuhClient:
},
"name": pkg.get("name"),
"version": pkg.get("version"),
"vendor": pkg.get("vendor"),
"fixed_version": fixed_version,
"severity": vuln.get("severity"),
"title": vuln.get("title") or vuln.get("description"),
@@ -583,6 +646,56 @@ class WazuhClient:
response = self._request("GET", f"/syscollector/{agent_id}/ports")
return response.get("data", {}).get("affected_items", [])
_EXT_INDEX = "/wazuh-states-inventory-browser-extensions-*"
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.
"""
if not self.indexer_url:
return []
body = {
"size": 1000,
"query": {"term": {"agent.id": str(agent_id)}},
"_source": ["agent.id", "browser.name", "browser.profile.name",
"package.name", "package.version", "package.id",
"package.enabled", "package.vendor",
"package.from_webstore"],
}
try:
resp = self._indexer_request(
"POST", f"{self._EXT_INDEX}/_search", json_data=body)
except WazuhAPIError as e:
# IT Hygiene is optional and only exists from 4.14 — a missing
# index must not take the whole scan down with it.
logger.debug("browser extensions unavailable for %s: %s", agent_id, e)
return []
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")),
})
return out
def get_os_info(self, agent_id: str) -> Dict[str, Any]:
"""
Holt OS-Informationen eines Agents
+17 -5
View File
@@ -29,7 +29,7 @@ from slowapi.errors import RateLimitExceeded
from app.routers import auth, auth_admin, vulnerabilities, assets, policies, scans, settings, notifications, groups, reports, audit, nessus, compliance
from app.routers import auth, auth_admin, vulnerabilities, assets, policies, scans, settings, notifications, groups, reports, audit, nessus, compliance, intune, advisories
try:
from app.routers import auth_oidc
_HAS_OIDC = True
@@ -60,6 +60,9 @@ logger = logging.getLogger(__name__)
# Environment
ENV = os.getenv("ENV", "production")
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)
@@ -105,6 +108,13 @@ async def lifespan(app: FastAPI):
except Exception as 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
stop_scheduler()
@@ -113,11 +123,11 @@ async def lifespan(app: FastAPI):
# FastAPI App
app = FastAPI(
title="VulnManager API",
description="Vulnerability Management Dashboard mit Wazuh & KI-Integration",
title="TrueVuln API",
description="TrueVuln — Vulnerability Management Dashboard mit Wazuh & KI-Integration",
version="1.0.0",
docs_url="/docs" if DEBUG else None, # Swagger UI nur in Development
redoc_url="/redoc" if DEBUG else None,
docs_url="/docs" if API_DOCS else None, # Swagger UI: Dev, oder ENABLE_API_DOCS=true
redoc_url="/redoc" if API_DOCS else None,
lifespan=lifespan
)
@@ -320,6 +330,8 @@ if _HAS_SAML and auth_saml is not None:
app.include_router(auth_saml.router)
app.include_router(vulnerabilities.router)
app.include_router(nessus.router)
app.include_router(intune.router)
app.include_router(advisories.router)
app.include_router(assets.router)
app.include_router(policies.router)
app.include_router(scans.router)
+4
View File
@@ -14,6 +14,8 @@ from app.models.scan_schedule import ScanSchedule
from app.models.notification_log import NotificationLog
from app.models.setting import Setting
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 (
ComplianceResult, ComplianceCheck, ComplianceImpact, AssetRiskSnapshot,
)
@@ -32,6 +34,8 @@ __all__ = [
"NotificationLog",
"Setting",
"AIReport",
"CveRemediation",
"AppCveCache",
"ComplianceResult",
"ComplianceCheck",
"ComplianceImpact",
+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())
+15
View File
@@ -20,6 +20,7 @@ class AssetSource(str, Enum):
WAZUH = "WAZUH"
NESSUS = "NESSUS"
MANUAL = "MANUAL"
INTUNE = "INTUNE"
class AssetStatus(str, Enum):
@@ -48,6 +49,14 @@ class Asset(Base, TimestampMixin):
# Nessus host UUID — pinned after first hostname/IP match so future Nessus
# syncs reconnect deterministically even if hostname or IP changes.
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)
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)
# System-Information
operating_system = Column(String(255), nullable=True)
@@ -69,6 +78,12 @@ class Asset(Base, TimestampMixin):
exposed_services = Column(Text, nullable=True) # JSON [{port, proto, service, risk}]
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
source = Column(
SQLEnum(AssetSource),
+1
View File
@@ -35,6 +35,7 @@ class AuditEventType(str, Enum):
USER_DELETED = "USER_DELETED"
# Data Operations
VULNERABILITY_DETECTED = "VULNERABILITY_DETECTED" # sync created a new finding
VULNERABILITY_UPDATED = "VULNERABILITY_UPDATED"
ASSET_CREATED = "ASSET_CREATED"
ASSET_UPDATED = "ASSET_UPDATED"
+1 -1
View File
@@ -52,7 +52,7 @@ class ComplianceResult(Base, TimestampMixin):
# When Wazuh last evaluated this policy (from the SCA endpoint)
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)
# 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
View File
@@ -84,6 +84,10 @@ class User(Base, TimestampMixin):
is_active = Column(Boolean, default=True, nullable=False)
is_verified = Column(Boolean, default=False, 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
audit_logs = relationship("AuditLog", back_populates="user", cascade="all, delete-orphan")
+54 -6
View File
@@ -72,6 +72,7 @@ class Vulnerability(Base, TimestampMixin):
# Betroffene Software
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)
fixed_version = Column(String(100), nullable=True)
@@ -88,12 +89,14 @@ class Vulnerability(Base, TimestampMixin):
ssvc_automatable = Column(String(8), nullable=True, index=True) # yes | no
# 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)
patched_at = Column(DateTime, nullable=True)
# Zusätzliche Informationen
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
# Assignment
@@ -203,7 +206,9 @@ class Vulnerability(Base, TimestampMixin):
"""True for non-CVE findings rendered as rows in the vulns list.
Two flavours: NESSUS-PLUGIN-* (compliance/cipher/EOL detected
by Nessus) and EOL-* (endoflife.date scan)."""
return self.cve_id.startswith("NESSUS-PLUGIN-") or self.cve_id.startswith("EOL-")
return (self.cve_id.startswith("NESSUS-PLUGIN-")
or self.cve_id.startswith("EOL-")
or self.cve_id.startswith("ANDROID-PATCH-"))
@property
def is_eol_finding(self) -> bool:
@@ -232,6 +237,31 @@ class Vulnerability(Base, TimestampMixin):
self.sources = json.dumps(current)
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: the tester saw Edge findings 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:
"""Recompute + persist priority_score + cpr_score on the row.
@@ -241,6 +271,7 @@ class Vulnerability(Base, TimestampMixin):
not per-page.
"""
try:
self.sync_severity_with_cvss()
self.priority_score = self.calculate_priority_breakdown().get("total")
except Exception:
self.priority_score = None
@@ -262,6 +293,19 @@ class Vulnerability(Base, TimestampMixin):
"""
return self.calculate_priority_breakdown()["total"]
# 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
# scores below multiply the CVSS in, so a missing one drove priority and
# CPR 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.
_SEVERITY_FLOOR = {"critical": 9.0, "high": 7.0, "medium": 4.0, "low": 0.1}
def _severity_floor(self):
sev = getattr(self.severity, "value", self.severity)
return self._SEVERITY_FLOOR.get(str(sev or "").lower())
def calculate_cpr_score(self):
"""
Cybersecurity Priority Risk — weighted blend of CVSS + EPSS
@@ -287,9 +331,10 @@ class Vulnerability(Base, TimestampMixin):
Returns: float 0-100, oder None wenn CVSS oder EPSS fehlt.
"""
if self.cvss_score is None or self.epss_score is None:
base = self.cvss_score if self.cvss_score is not None else self._severity_floor()
if base is None or self.epss_score is None:
return None
cvss_pct = max(0.0, min(self.cvss_score, 10.0)) * 10.0
cvss_pct = max(0.0, min(base, 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:
@@ -306,8 +351,11 @@ class Vulnerability(Base, TimestampMixin):
"""
from datetime import datetime, timezone
# 1. Technical Severity (0-10)
technical_severity = self.cvss_score or 0.0
# 1. Technical Severity (0-10). Falls back to the severity band's floor
# 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
# KEV ODER EUVD bedeuten "real-world exploitation bestätigt"
+4 -4
View File
@@ -41,10 +41,10 @@ class VulnerabilityPackage(Base, TimestampMixin):
package_version = Column(String(100), nullable=True)
fixed_version = Column(String(100), nullable=True)
# Source tag — which scanner reported this package row.
# ("wazuh" / "nessus" / "manual"). Multi-scanner deployments
# can show per-source confirmation in the per-package view.
source = Column(String(20), nullable=True)
# Source tag(s) — every scanner that reported this package row, comma
# joined ("app-scan,msrc"). One scanner alone owning the field made a
# cross-confirmed package look single-source in the UI.
source = Column(String(60), nullable=True)
first_detected_at = Column(DateTime, nullable=False, default=datetime.utcnow)
last_seen_at = Column(DateTime, nullable=False, default=datetime.utcnow)
+47
View File
@@ -0,0 +1,47 @@
"""Security-advisory awareness feeds (CISA KEV + configurable RSS sources)."""
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.user import User
from app.auth.dependencies import get_current_user, RequireEditor
router = APIRouter(prefix="/api/v1/advisories", tags=["Advisories"])
@router.get("/feeds")
def advisory_feeds(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Cached security-advisory RSS feeds (ZDI / CERT-EU / BSI / ...). Served
from the cache the scheduler fills; first call ever triggers a fetch."""
from app.services.advisory_feed_service import get_cached_feeds, refresh_feeds
cached = get_cached_feeds(db)
if cached is None:
refresh_feeds(db)
cached = get_cached_feeds(db) or {"fetched_at": None, "feeds": []}
return cached
@router.post("/feeds/refresh")
# Sync def → worker threadpool; N feed fetches are blocking I/O.
def refresh_advisory_feeds(
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor),
):
"""Re-fetch every enabled advisory feed now."""
from app.services.advisory_feed_service import refresh_feeds
return refresh_feeds(db)
@router.get("/kev-recent")
def kev_recent(
limit: int = Query(20, le=100, description="How many recent KEV entries to return"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Most recently added CISA KEV (actively-exploited) CVEs, newest first,
annotated with whether we already have that CVE in inventory."""
from app.services.advisory_service import get_recent_kev
return {"items": get_recent_kev(db, limit=limit)}
+178 -19
View File
@@ -5,9 +5,9 @@ Endpoints for asset management (Wazuh Agents + manual systems).
"""
from typing import Optional, List, Any
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status, Query
from fastapi import APIRouter, Depends, HTTPException, status, Query, Response
from sqlalchemy.orm import Session
from sqlalchemy import asc, desc, nulls_last
from sqlalchemy import asc, desc, func, nulls_last
from pydantic import BaseModel, field_validator
from app.database import get_db
@@ -34,6 +34,10 @@ class AssetResponse(BaseModel):
ip_address: Optional[str]
wazuh_agent_id: Optional[str]
nessus_host_uuid: Optional[str] = None
# Intune/Defender ids — surfaced so the UI can offer the software/app-scan
# actions on MDM-managed (iOS/macOS/Android) assets, not just Wazuh hosts.
intune_device_id: Optional[str] = None
defender_machine_id: Optional[str] = None
operating_system: Optional[str]
os_version: Optional[str]
source: AssetSource
@@ -55,10 +59,13 @@ class AssetResponse(BaseModel):
network_exposure_score: Optional[float] = None
exposed_services: Optional[List[dict]] = None
exposure_updated_at: Optional[datetime] = None
# Risk Dimensions (crown-jewel roles)
high_value_score: Optional[float] = None
risk_dimensions: Optional[List[dict]] = None
@field_validator('exposed_services', mode='before')
@field_validator('exposed_services', 'risk_dimensions', mode='before')
@classmethod
def parse_exposed_services(cls, v: Any) -> Optional[List[dict]]:
def parse_json_list(cls, v: Any) -> Optional[List[dict]]:
if v is None or isinstance(v, list):
return v
if isinstance(v, str):
@@ -199,7 +206,7 @@ async def reconcile_lifecycle(
@router.get("/{asset_id}/coverage-gap")
async def asset_coverage_gap(
def asset_coverage_gap(
asset_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
@@ -297,8 +304,86 @@ async def asset_coverage_gap(
}
@router.get("/{asset_id}/software")
def asset_software(
asset_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""On-demand installed-software inventory for one asset.
Pulled LIVE (not persisted) from whichever source backs the asset:
Wazuh syscollector packages, else Intune detectedApps. This is the same
inventory the app-CVE scanner consumes surfaced read-only so the operator
can see what's installed without a DB table.
"""
import json as _json
from app.auth.setting_crypto import read_setting_value
asset = db.query(Asset).filter(Asset.id == asset_id).first()
if not asset:
raise HTTPException(404, "Asset not found")
software: list[dict] = []
source = None
if asset.wazuh_agent_id:
from app.integrations.wazuh_client import WazuhClient
raw = read_setting_value(db, "wazuh_config")
if not raw:
raise HTTPException(400, "Wazuh is not configured")
cfg = _json.loads(raw)
wazuh = WazuhClient(
base_url=cfg.get("api_url"), username=cfg.get("username"),
password=cfg.get("password"), indexer_url=cfg.get("indexer_url"),
indexer_username=cfg.get("indexer_username"),
indexer_password=cfg.get("indexer_password"),
verify_ssl=cfg.get("verify_ssl", False),
)
try:
pkgs = wazuh.get_packages(asset.wazuh_agent_id) or []
except Exception as e:
raise HTTPException(502, f"Could not fetch packages from Wazuh: {e}")
source = "wazuh"
software = [{"name": (p.get("name") or "").strip(),
"version": (p.get("version") or "").strip(),
"vendor": (p.get("vendor") or p.get("format") or "").strip()} for p in pkgs]
elif asset.intune_device_id:
from app.services.intune_service import load_intune_config, _build_client
icfg = load_intune_config(db)
if not icfg:
raise HTTPException(400, "Intune is not configured")
client = _build_client(icfg)
try:
apps = client.get_detected_apps(asset.intune_device_id) or []
except Exception as e:
raise HTTPException(502, f"Could not fetch detectedApps from Intune: {e}")
finally:
client.close()
source = "intune"
software = [{"name": (a.get("name") or "").strip(),
"version": (a.get("version") or "").strip(),
"vendor": (a.get("vendor") or "").strip()} for a in apps]
else:
raise HTTPException(422, "Asset has no Wazuh agent or Intune device — no live software inventory")
# Dedup (name, version) + drop nameless, sort by name.
seen: set = set()
out: list[dict] = []
for s in software:
if not s["name"]:
continue
key = (s["name"].lower(), s["version"])
if key in seen:
continue
seen.add(key)
out.append(s)
out.sort(key=lambda s: s["name"].lower())
return {"asset_id": asset_id, "hostname": asset.hostname, "source": source,
"count": len(out), "software": out}
@router.post("/refresh-exposure")
async def refresh_exposure(
def refresh_exposure(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
@@ -339,19 +424,23 @@ async def list_assets(
status: Optional[AssetStatus] = Query(None),
source: Optional[AssetSource] = Query(None),
search: Optional[str] = Query(None, description="Suche in Hostname, IP"),
include_inactive: bool = Query(False, description="Include soft-inactive + decommissioned assets"),
include_inactive: bool = Query(False, description="Also include DECOMMISSIONED assets (INACTIVE are shown by default)"),
sort_by: str = Query("hostname", description="hostname, ip_address, operating_system, status, last_scan, network_exposure_score, policy_name, assigned_user_name"),
sort_order: str = Query("asc", description="asc, desc"),
limit: int = Query(100, le=1000),
offset: int = Query(0),
response: Response = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Liste aller Assets mit Filterung.
Default zeigt nur ACTIVE soft-inactive assets (kein source-sync
innerhalb asset_inactive_after_days) sind ausgeblendet bis
include_inactive=true. Vuln-historie + audit bleiben erhalten.
Default zeigt ACTIVE **und** INACTIVE der Sinn der Status-Spalte ist
ja, soft-inactive Assets (kein source-sync innerhalb
asset_inactive_after_days) mit Badge sichtbar zu halten, nicht sie zu
verstecken. Nur DECOMMISSIONED (operator-final) ist per default
ausgeblendet; include_inactive=true zeigt auch die.
Vuln-historie + audit bleiben erhalten.
"""
query = db.query(Asset)
@@ -361,10 +450,42 @@ async def list_assets(
# everything (active + inactive + decommissioned)
pass
else:
query = query.filter(Asset.status == AssetStatus.ACTIVE)
# active + inactive visible; hide only operator-retired
query = query.filter(Asset.status != AssetStatus.DECOMMISSIONED)
if source:
query = query.filter(Asset.source == source)
# Filter by the actual sync linkage, not the creation-time `source`
# enum: an asset first created by Wazuh and later matched by Nessus
# keeps source=WAZUH but carries a nessus_host_uuid. The per-scanner
# id columns are the multi-source truth, so a merged asset correctly
# appears under every scanner that sees it.
def _has_vuln_source(tag: str):
# Asset has ≥1 finding reported by `tag` (the vuln-level `sources`
# list is the maintained multi-source truth). Catches Nessus
# imports whose host had no nessus_host_uuid (matched by hostname).
return Asset.id.in_(
db.query(Vulnerability.asset_id)
.filter(Vulnerability.sources.contains(f'"{tag}"'))
)
if source == AssetSource.WAZUH:
query = query.filter(Asset.wazuh_agent_id.isnot(None) | _has_vuln_source("wazuh"))
elif source == AssetSource.NESSUS:
query = query.filter(Asset.nessus_host_uuid.isnot(None) | _has_vuln_source("nessus"))
elif source == AssetSource.INTUNE:
# Intune devices often carry no findings → id columns are the signal.
query = query.filter(
Asset.intune_device_id.isnot(None) | Asset.defender_machine_id.isnot(None)
| _has_vuln_source("intune") | _has_vuln_source("defender"))
elif source == AssetSource.MANUAL:
# genuinely manual = no scanner linkage AND no scanner-sourced vuln
query = query.filter(
Asset.wazuh_agent_id.is_(None), Asset.nessus_host_uuid.is_(None),
Asset.intune_device_id.is_(None), Asset.defender_machine_id.is_(None),
~_has_vuln_source("wazuh"), ~_has_vuln_source("nessus"),
~_has_vuln_source("intune"), ~_has_vuln_source("defender"))
else:
query = query.filter(Asset.source == source)
if search:
search_pattern = f"%{search}%"
@@ -383,15 +504,25 @@ async def list_assets(
"status": Asset.status,
"last_scan": Asset.last_scan,
"network_exposure_score": Asset.network_exposure_score,
"high_value_score": Asset.high_value_score,
}
sort_dir = desc if sort_order == "desc" else asc
if sort_by in _SORT_MAP_DIRECT:
col = _SORT_MAP_DIRECT[sort_by]
# last_scan can be NULL (never-synced) — keep them at the bottom.
if sort_by == "last_scan":
query = query.order_by(sort_dir(nulls_last(col)))
# NULL-safe on BOTH directions — never-scanned assets land at
# the bottom regardless of asc/desc (PG: NULLS LAST is independent
# of the primary direction).
query = query.order_by(nulls_last(sort_dir(col)))
elif sort_by == "hostname":
# Case-insensitive alpha sort — a host called "alpine" must
# not outrank "Webserver" because the literal `A` > `W`.
query = query.order_by(nulls_last(sort_dir(func.lower(col))))
else:
query = query.order_by(sort_dir(col))
# NULLs always last (both directions) — sorting Exposure/Risk
# desc must surface the real high scores first, not the empty
# ("—") rows. (Tester: desc showed blanks before real values.)
query = query.order_by(nulls_last(sort_dir(col)))
elif sort_by == "policy_name":
query = query.outerjoin(Policy, Asset.policy_id == Policy.id).order_by(sort_dir(Policy.name))
elif sort_by == "assigned_user_name":
@@ -400,6 +531,12 @@ async def list_assets(
# Unknown / unsupported sort_by — fall back to hostname asc.
query = query.order_by(asc(Asset.hostname))
# Total (pre-pagination) so the UI can page; returned as a header to
# keep the response body a plain array (other consumers expect a list).
total_count = query.order_by(None).count()
response.headers["X-Total-Count"] = str(total_count)
response.headers["Access-Control-Expose-Headers"] = "X-Total-Count"
assets = query.offset(offset).limit(limit).all()
# Vulnerability-Counts + assigned user info
@@ -570,16 +707,38 @@ async def delete_asset(
detail="Asset not found"
)
# Audit-Log vor Löschung
# Revisionssicher audit — snapshot the full asset + cascade impact
# BEFORE deletion so the trail says WHO deleted WHAT (and how much
# history it took with it), analog to the CVE status-change entries.
import json as _json
vuln_count = db.query(Vulnerability).filter(
Vulnerability.asset_id == asset.id
).count()
snapshot = {
"hostname": asset.hostname,
"ip_address": asset.ip_address,
"operating_system": asset.operating_system,
"source": asset.source.value if hasattr(asset.source, "value") else str(asset.source),
"status": asset.status.value if hasattr(asset.status, "value") else str(asset.status),
"wazuh_agent_id": asset.wazuh_agent_id,
"vulnerabilities_cascade_deleted": vuln_count,
"deleted_by": current_user.username,
}
audit_log = AuditLog(
user_id=current_user.id,
event_type=AuditEventType.ASSET_DELETED,
event_description=f"Asset deleted: {asset.hostname}",
event_description=(
f"Asset '{asset.hostname}' ({asset.ip_address or 'no-ip'}) deleted by "
f"{current_user.username}{vuln_count} vulnerabilities cascade-removed"
)[:500],
resource_type="asset",
resource_id=str(asset.id),
old_value=_json.dumps(snapshot),
new_value="deleted",
timestamp=datetime.now()
)
db.add(audit_log)
db.commit() # persist audit first so it survives even if delete fails
db.delete(asset)
db.commit()
@@ -663,7 +822,7 @@ from app.integrations.wazuh_client import WazuhClient, WazuhAPIError
from app.models.setting import Setting
@router.post("/sync_wazuh", response_model=dict)
async def sync_wazuh_assets(
def sync_wazuh_assets(
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor)
):
@@ -794,7 +953,7 @@ async def sync_wazuh_assets(
@router.post("/{asset_id}/rescan", response_model=dict)
async def rescan_asset(
def rescan_asset(
asset_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor)
+40 -5
View File
@@ -1,10 +1,10 @@
import csv
import io
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from sqlalchemy import desc
from sqlalchemy import asc, cast, desc, or_, String
from app.database import get_db
from app.models.user import User, UserRole
@@ -33,20 +33,38 @@ class AuditLogResponse(BaseModel):
timestamp: datetime
class Config:
orm_mode = True
# pydantic v2 name (v1's `orm_mode` still works but warns on import)
from_attributes = True
# Whitelist of sortable columns → SQL column. Anything else falls back to
# timestamp, so the client can't inject an arbitrary order_by.
_SORT_COLUMNS = {
"timestamp": AuditLog.timestamp,
"event_type": AuditLog.event_type,
"resource_type": AuditLog.resource_type,
"resource_id": AuditLog.resource_id,
"user_id": AuditLog.user_id,
"ip_address": AuditLog.ip_address,
}
@router.get("/logs", response_model=List[AuditLogResponse])
async def get_audit_logs(
response: Response,
skip: int = 0,
limit: int = Query(100, le=1000, description="Max rows per page (cap 1000)"),
user_id: Optional[int] = None,
resource_type: Optional[str] = None,
resource_id: Optional[str] = None,
search: Optional[str] = Query(None, description="Free-text over description, event, resource, IP, user"),
sort: str = Query("timestamp", description="Sort column"),
order: str = Query("desc", description="asc | desc"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Get audit logs.
Get audit logs. Sets `X-Total-Count` (matched rows before pagination) so
the UI can render page controls.
Admins see everything. Editors can scope the query to a single
resource (e.g. resource_type='vulnerability', resource_id='42')
@@ -69,8 +87,25 @@ async def get_audit_logs(
query = query.filter(AuditLog.resource_type == resource_type)
if resource_id:
query = query.filter(AuditLog.resource_id == resource_id)
if search and search.strip():
term = f"%{search.strip()}%"
# username lives on the related User; outerjoin so system/auto rows
# (user_id NULL) still match on the other columns.
query = query.outerjoin(User, AuditLog.user_id == User.id).filter(or_(
AuditLog.event_description.ilike(term),
cast(AuditLog.event_type, String).ilike(term),
AuditLog.resource_type.ilike(term),
AuditLog.resource_id.ilike(term),
AuditLog.ip_address.ilike(term),
User.username.ilike(term),
))
logs = query.order_by(desc(AuditLog.timestamp)).offset(skip).limit(limit).all()
total = query.count()
response.headers["X-Total-Count"] = str(total)
col = _SORT_COLUMNS.get(sort, AuditLog.timestamp)
direction = asc if order == "asc" else desc
logs = query.order_by(direction(col)).offset(skip).limit(limit).all()
response = []
for log in logs:
+77 -9
View File
@@ -364,9 +364,15 @@ async def mfa_verify(
try:
decoded = decode_token(payload.mfa_token)
except Exception:
raise HTTPException(status_code=401, detail="Invalid or expired MFA token")
if not decoded.get("mfa_pending"):
raise HTTPException(status_code=401, detail="Invalid MFA token")
decoded = None
# decode_token returns None on an EXPIRED/invalid token (it does not raise).
# Guarding only the exception path let `None.get(...)` throw below → 500
# when a user sat on the MFA screen past the 5-min challenge window.
if not decoded or not decoded.get("mfa_pending"):
raise HTTPException(
status_code=401,
detail="MFA session expired — please log in again.",
)
user = db.query(User).filter(User.username == decoded.get("sub")).first()
if not user or not user.is_active:
@@ -674,17 +680,30 @@ async def logout(
@router.post("/refresh", response_model=LoginResponse)
async def refresh_token(
refresh_request: RefreshTokenRequest,
request: Request,
refresh_request: Optional[RefreshTokenRequest] = None,
db: Session = Depends(get_db)
):
"""
Renew access token with refresh token
Renew access token with refresh token.
The token is taken from the request body when present, else from the
HttpOnly `refresh_token` cookie the browser can't read that cookie to
put it in the body, so the cookie fallback is what makes silent refresh
(access-token renewal without a re-login) work from the SPA.
Security:
- Checks token type (must be "refresh")
- Validates token signature and expiry
"""
payload = decode_token(refresh_request.refresh_token)
presented = (refresh_request.refresh_token if refresh_request else None) \
or request.cookies.get("refresh_token")
if not presented:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing refresh token",
)
payload = decode_token(presented)
if not payload or payload.get("type") != "refresh":
raise HTTPException(
@@ -1034,12 +1053,44 @@ async def list_users(
"email": u.email,
"role": u.role.value,
"is_active": u.is_active,
"failed_login_attempts": u.failed_login_attempts
"failed_login_attempts": u.failed_login_attempts,
"locked": getattr(u, "locked", False),
"locked_at": getattr(u, "locked_at", None),
}
for u in users
]
@router.post("/users/{user_id}/unlock")
@limiter.limit("10/minute")
async def unlock_user(
request: Request,
user_id: int,
current_user: User = Depends(RequireAdmin),
db: Session = Depends(get_db),
):
"""Clear a permanent lockout (and reset the failed-attempt counter) after an
admin has reviewed the incident. Admin only; audit-logged."""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
was_locked = bool(getattr(user, "locked", False)) or user.failed_login_attempts > 0
user.locked = False
user.locked_at = None
user.failed_login_attempts = 0
db.commit()
log_audit_event(
db,
AuditEventType.SECURITY_ALERT,
f"Account unlocked by admin: {user.username}",
user_id=current_user.id,
request=request,
)
return {"message": f"User {user.username} unlocked", "was_locked": was_locked}
class UserUpdateRequest(BaseModel):
role: Optional[UserRole] = None
email: Optional[EmailStr] = None
@@ -1139,11 +1190,28 @@ async def delete_user(
username = user.username
# Clear user assignments before deletion to avoid FK constraint errors
# Every FK into `users` must be cleared first, or Postgres blocks the
# delete (→ 500). Historical rows are NULLed, not deleted: notifications,
# AI reports and especially the AUDIT LOG must survive so a deleted user's
# actions stay on record (revision-proof). Group memberships are removed
# outright (the assoc row has no meaning without the user).
from app.models.asset import Asset
from app.models.vulnerability import Vulnerability
from app.models.notification_log import NotificationLog
from app.models.ai_report import AIReport
from app.models.audit_log import AuditLog
from app.models.group import user_groups
db.query(Asset).filter(Asset.assigned_user_id == user_id).update({"assigned_user_id": None})
db.query(Vulnerability).filter(Vulnerability.assigned_user_id == user_id).update({"assigned_user_id": None})
db.query(NotificationLog).filter(NotificationLog.user_id == user_id).update({"user_id": None})
db.query(AIReport).filter(AIReport.created_by_id == user_id).update({"created_by_id": None})
db.query(AuditLog).filter(AuditLog.user_id == user_id).update({"user_id": None})
db.execute(user_groups.delete().where(user_groups.c.user_id == user_id))
db.flush()
# Drop cached relationship state so the User.audit_logs delete-orphan
# cascade doesn't re-delete the rows we just detached above.
db.expire(user)
db.delete(user)
db.commit()
@@ -1151,7 +1219,7 @@ async def delete_user(
log_audit_event(
db,
AuditEventType.USER_DELETED,
f"User deleted: {username}",
f"User deleted: {username} (id {user_id})",
user_id=current_user.id,
request=request
)
+99
View File
@@ -0,0 +1,99 @@
"""
Microsoft Intune / Graph integration HTTP endpoints.
- POST /api/v1/integrations/intune/test auth/connectivity probe
- POST /api/v1/integrations/intune/sync trigger device+inventory sync
Config lives in settings table key `intune_config` (encrypted JSON:
tenant_id, client_id, client_secret, verify_ssl, auto_create_assets,
detected_apps).
"""
from __future__ import annotations
import logging
import threading
import time
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.auth.dependencies import RequireAdmin, RequireEditor
from app.database import get_db, SessionLocal
from app.models.user import User
from app.services.intune_service import load_intune_config, run_intune_sync, _build_client
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/integrations/intune", tags=["Intune"])
# Last/current manual-sync state so the GUI can poll for completion (the sync
# itself is fire-and-forget 202). ponytail: module-level state assumes the
# single uvicorn worker we ship with; if you add --workers, move this to the DB.
_INTUNE_SYNC: dict = {"running": False, "result": None, "error": None,
"started_at": None, "finished_at": None}
@router.post("/test")
async def test_intune(
db: Session = Depends(get_db),
current_user: User = Depends(RequireAdmin),
):
"""Verify the Intune/Graph credentials: acquire a token + read 1 device."""
cfg = load_intune_config(db)
if not cfg:
raise HTTPException(400, "Intune is not configured (tenant_id/client_id/client_secret missing).")
client = _build_client(cfg)
try:
return client.test_connection()
finally:
client.close()
def _run_intune_sync_threaded() -> None:
db = SessionLocal()
try:
stats = run_intune_sync(db)
result = {k: v for k, v in stats.items() if k != "errors"}
_INTUNE_SYNC["result"] = result
_INTUNE_SYNC["error"] = None
logger.info("Intune sync (manual) done: %s", result)
except Exception as e:
_INTUNE_SYNC["result"] = None
_INTUNE_SYNC["error"] = str(e)
logger.error("Intune sync (manual) failed: %s", e)
finally:
db.close()
_INTUNE_SYNC["running"] = False
_INTUNE_SYNC["finished_at"] = time.time()
@router.post("/sync", status_code=202)
async def sync_intune(
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor),
):
"""Kick off an Intune device/inventory sync in the background (202).
Fire-and-forget so a large tenant can't trip the reverse-proxy request
timeout. Poll GET /sync/status for completion."""
if not load_intune_config(db):
raise HTTPException(400, "Intune is not configured.")
if _INTUNE_SYNC["running"]:
return {"status": "already_running", "detail": "An Intune sync is already in progress."}
_INTUNE_SYNC.update({"running": True, "result": None, "error": None,
"started_at": time.time(), "finished_at": None})
threading.Thread(target=_run_intune_sync_threaded, daemon=True).start()
return {"status": "started", "detail": "Intune sync started in the background."}
@router.get("/sync/status")
async def sync_intune_status(current_user: User = Depends(RequireEditor)):
"""Poll target for the GUI: current/last manual-sync state + result stats."""
s = _INTUNE_SYNC
state = ("running" if s["running"]
else "error" if s["error"]
else "done" if s["result"]
else "idle")
return {"state": state, "running": s["running"], "result": s["result"],
"error": s["error"], "finished_at": s["finished_at"]}
+9 -7
View File
@@ -42,7 +42,7 @@ class NessusSyncRequest(BaseModel):
class NessusScanHostRequest(BaseModel):
asset_id: int = Field(..., description="VulnCheck asset ID to rescan")
asset_id: int = Field(..., description="TrueVuln asset ID to rescan")
scan_id: Optional[int] = Field(
default=None,
description=(
@@ -65,7 +65,7 @@ class NessusScanImportRequest(BaseModel):
# ---------- endpoints ----------
@router.post("/test")
async def test_nessus_connection(
def test_nessus_connection(
db: Session = Depends(get_db),
current_user: User = Depends(RequireAdmin),
):
@@ -103,7 +103,7 @@ async def test_nessus_connection(
@router.get("/scans")
async def list_nessus_scans(
def list_nessus_scans(
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor),
):
@@ -138,7 +138,9 @@ async def list_nessus_scans(
@router.post("/sync")
async def trigger_nessus_sync(
# Sync def → worker threadpool (off the event loop) so a long Nessus
# import doesn't freeze the web GUI.
def trigger_nessus_sync(
payload: NessusSyncRequest,
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor),
@@ -162,7 +164,7 @@ async def trigger_nessus_sync(
raise HTTPException(
502,
"Nessus server unreachable from the backend. Check the "
"configured base_url is reachable from the VulnCheck container "
"configured base_url is reachable from the TrueVuln container "
f"(not localhost/127.0.0.1) and the host/port/firewall. ({e})",
)
except _httpx.TimeoutException as e:
@@ -175,7 +177,7 @@ async def trigger_nessus_sync(
@router.post("/scan-host")
async def nessus_scan_host(
def nessus_scan_host(
payload: NessusScanHostRequest,
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor),
@@ -184,7 +186,7 @@ async def nessus_scan_host(
Launch a targeted Nessus scan for a single asset.
Nessus ``POST /scans/{id}/launch`` supports ``alt_targets`` which overrides
the scan's configured scope. VulnCheck resolves the asset's IP and passes
the scan's configured scope. TrueVuln resolves the asset's IP and passes
it as the only target useful for post-patch rescans without waiting for
the next full scheduled scan.
+24 -22
View File
@@ -54,9 +54,18 @@ async def list_notification_logs(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""List notification history"""
"""List notification history.
Privacy: the log holds recipient emails + which CVE/asset went to whom.
Admins see everything (operational overview); everyone else sees only the
notifications addressed to them (user_id == their own id).
"""
from app.models.user import UserRole
query = db.query(NotificationLog).order_by(desc(NotificationLog.sent_at))
if current_user.role != UserRole.ADMIN:
query = query.filter(NotificationLog.user_id == current_user.id)
if notification_type:
try:
nt = NotificationType(notification_type)
@@ -100,7 +109,8 @@ async def list_notification_logs(
@router.post("/test")
async def send_test_email(
# Sync def → threadpool; the blocking SMTP send won't stall the event loop.
def send_test_email(
test_data: TestEmailRequest,
db: Session = Depends(get_db),
current_user: User = Depends(RequireAdmin)
@@ -111,12 +121,12 @@ async def send_test_email(
if not config:
raise HTTPException(status_code=400, detail="SMTP not configured")
subject = "VulnCheck Dashboard - Test Email"
subject = "TrueVuln Dashboard - Test Email"
body = """
<html>
<body style="font-family: Arial, sans-serif; padding: 20px;">
<h2>SMTP Test Successful</h2>
<p>This is a test email from VulnCheck Dashboard.</p>
<p>This is a test email from TrueVuln Dashboard.</p>
<p>Your SMTP configuration is working correctly.</p>
<hr>
<p style="color: #888; font-size: 12px;">Sent at: {}</p>
@@ -133,7 +143,9 @@ async def send_test_email(
@router.post("/notify-critical")
async def notify_unnotified_critical_vulnerabilities(
# Sync def → Starlette runs it in a worker threadpool (off the event loop), so
# the blocking SMTP send loop over thousands of CVEs doesn't freeze the web GUI.
def notify_unnotified_critical_vulnerabilities(
db: Session = Depends(get_db),
current_user: User = Depends(RequireAdmin)
):
@@ -143,7 +155,7 @@ async def notify_unnotified_critical_vulnerabilities(
"""
try:
from app.models.vulnerability import VulnerabilityStatus, VulnerabilitySeverity
from app.services.email_service import send_new_vulnerability_notification
from app.services.email_service import send_new_vulnerability_notification, _resolve_recipients_for_vuln
from app.models.group import Group
smtp_config = get_smtp_config(db)
@@ -168,21 +180,11 @@ async def notify_unnotified_critical_vulnerabilities(
if not asset:
continue
# Get recipients from asset assignment
recipients = []
if asset.assigned_user_id:
user = db.query(User).filter(User.id == asset.assigned_user_id).first()
if user and user.email:
recipients.append((user.id, user.email, user.username))
elif asset.groups:
# Asset uses M2M groups (no single assigned_group_id column)
for group in asset.groups:
for u in group.users:
if u.email:
recipients.append((u.id, u.email, u.username))
# Recipients via the full cascade (vuln/asset assignment), with a
# fallback to the configured default recipients / active admins so
# unassigned findings still notify someone.
recipients = _resolve_recipients_for_vuln(db, vuln)
if not recipients:
# No recipients for this vulnerability's asset
continue
for r_uid, r_email, r_username in recipients:
@@ -197,7 +199,7 @@ async def notify_unnotified_critical_vulnerabilities(
"title": vuln.title or "No description",
"description": vuln.title or "No description",
"detected_at": vuln.detected_at.strftime("%Y-%m-%d %H:%M UTC") if vuln.detected_at else "Unknown",
"dashboard_url": "https://your-vulncheck-instance.com/vulnerabilities",
"dashboard_url": "https://your-truevuln-instance.com/vulnerabilities",
"recipient_name": r_username,
"recipient_email": r_email
}
@@ -211,7 +213,7 @@ async def notify_unnotified_critical_vulnerabilities(
user_id=r_uid,
notification_type=NotificationType.MANUAL,
sent_at=datetime.now(),
subject=f"[VULNCHECK] {vuln.severity.value.upper()} Vulnerability: {vuln.cve_id}",
subject=f"[TRUEVULN] {vuln.severity.value.upper()} Vulnerability: {vuln.cve_id}",
recipient_email=r_email,
status=NotificationStatus.SENT if success else NotificationStatus.FAILED,
message_body=f"{vuln.severity.value} vulnerability {vuln.cve_id} on {asset.hostname}",
+77 -36
View File
@@ -17,6 +17,10 @@ from datetime import timedelta
router = APIRouter(prefix="/api/v1/reports", tags=["Reports"])
# Shared scope rule — see app/services/report_scope.py.
from app.services.report_scope import scoped as _scoped
@router.get("/vulnerabilities/csv")
async def export_vulnerabilities_csv(
db: Session = Depends(get_db),
@@ -25,7 +29,10 @@ async def export_vulnerabilities_csv(
"""
Exports all vulnerabilities to a CSV file.
"""
vulns = db.query(Vulnerability).all()
# Same scope as the PDFs so the two exports agree, and streamed in batches:
# .all() on ~50k findings materialised every ORM object plus the whole CSV
# in memory before the first byte reached the client.
vulns = _scoped(db).order_by(Vulnerability.id).yield_per(500)
output = io.StringIO()
writer = csv.writer(output)
@@ -83,27 +90,35 @@ async def export_executive_summary_pdf(
story.append(Paragraph(f"Date: {datetime.now().strftime('%Y-%m-%d')}", styles['Normal']))
story.append(Spacer(1, 12))
# Stats
total_vulns = db.query(Vulnerability).count()
critical = db.query(Vulnerability).filter(Vulnerability.severity == VulnerabilitySeverity.critical).count()
high = db.query(Vulnerability).filter(Vulnerability.severity == VulnerabilitySeverity.high).count()
medium = db.query(Vulnerability).filter(Vulnerability.severity == VulnerabilitySeverity.medium).count()
low = db.query(Vulnerability).filter(Vulnerability.severity == VulnerabilitySeverity.low).count()
# Stats. The severity counts are scoped to OPEN findings on purpose: the
# old report counted every row regardless of status, so a fully remediated
# estate still reported hundreds of "Critical Severity" — the patched ones.
# A reader takes those numbers as outstanding work.
total_vulns = _scoped(db).count()
def _open_by_sev(sev):
return (_scoped(db)
.filter(Vulnerability.status == VulnerabilityStatus.open,
Vulnerability.severity == sev).count())
open_vulns = db.query(Vulnerability).filter(Vulnerability.status == VulnerabilityStatus.open).count()
patched = db.query(Vulnerability).filter(Vulnerability.status == VulnerabilityStatus.patched).count()
critical = _open_by_sev(VulnerabilitySeverity.critical)
high = _open_by_sev(VulnerabilitySeverity.high)
medium = _open_by_sev(VulnerabilitySeverity.medium)
low = _open_by_sev(VulnerabilitySeverity.low)
open_vulns = _scoped(db).filter(Vulnerability.status == VulnerabilityStatus.open).count()
patched = _scoped(db).filter(Vulnerability.status == VulnerabilityStatus.patched).count()
story.append(Paragraph("Vulnerability Overview", styles['Heading2']))
data = [
['Metric', 'Count'],
['Total Vulnerabilities', str(total_vulns)],
['Critical Severity', str(critical)],
['High Severity', str(high)],
['Medium Severity', str(medium)],
['Low Severity', str(low)],
['Open Status', str(open_vulns)],
['Patched Status', str(patched)],
['Total Findings (all statuses)', str(total_vulns)],
['Open — Critical', str(critical)],
['Open — High', str(high)],
['Open — Medium', str(medium)],
['Open — Low', str(low)],
['Open (total)', str(open_vulns)],
['Patched', str(patched)],
]
t = Table(data)
@@ -122,10 +137,17 @@ async def export_executive_summary_pdf(
# Top Risks (Critical & Open)
story.append(Paragraph("Top Priority Risks (Critical & Open)", styles['Heading2']))
top_risks = db.query(Vulnerability).filter(
Vulnerability.severity == VulnerabilitySeverity.critical,
Vulnerability.status == VulnerabilityStatus.open
).limit(5).all()
# "Top" has to mean something. This had a limit and no ORDER BY, so the
# database was free to return any five critical rows it liked — whatever
# the scan happened to insert first — under a heading promising the worst
# five. Rank by the score the product already computes for exactly this.
top_risks = (_scoped(db)
.filter(Vulnerability.severity == VulnerabilitySeverity.critical,
Vulnerability.status == VulnerabilityStatus.open)
.order_by(Vulnerability.priority_score.desc().nullslast(),
Vulnerability.cvss_score.desc().nullslast(),
Vulnerability.detected_at.desc())
.limit(5).all())
if top_risks:
risk_data = [['CVE ID', 'Description', 'Asset']]
@@ -175,13 +197,24 @@ async def export_patching_progress_pdf(
# Query Patched in last 30 days
last_30_days = datetime.now() - timedelta(days=30)
patched_vulns = db.query(Vulnerability).filter(
_q = _scoped(db).filter(
Vulnerability.status == VulnerabilityStatus.patched,
Vulnerability.patched_at >= last_30_days
).all()
Vulnerability.patched_at >= last_30_days,
)
# Count in the database, list only a page of it. A single reconcile can
# close thousands of findings at once (one Wazuh sync closed 14703 here),
# and the old version put every one of them in the table — a PDF nobody
# can open, built from a result set held entirely in memory.
total_patched = _q.count()
_ROW_CAP = 100
patched_vulns = (_q.order_by(Vulnerability.patched_at.desc())
.limit(_ROW_CAP).all())
story.append(Paragraph(f"Remediation Summary (Last 30 Days)", styles['Heading2']))
story.append(Paragraph(f"Total Vulnerabilities Patched: {len(patched_vulns)}", styles['Normal']))
story.append(Paragraph(f"Total Vulnerabilities Patched: {total_patched}", styles['Normal']))
if total_patched > _ROW_CAP:
story.append(Paragraph(
f"Listing the {_ROW_CAP} most recent below.", styles['Normal']))
story.append(Spacer(1, 12))
if patched_vulns:
@@ -190,7 +223,7 @@ async def export_patching_progress_pdf(
data.append([
v.cve_id,
v.asset.hostname if v.asset else "Unknown",
v.severity.value,
v.severity.value if v.severity else "",
v.patched_at.strftime('%Y-%m-%d') if v.patched_at else "Unknown"
])
@@ -239,12 +272,20 @@ async def export_compliance_audit_pdf(
story.append(Spacer(1, 12))
# Audit Logic: Check for Open Critical/High vulnerabilities
non_compliant_vulns = db.query(Vulnerability).filter(
_nc = _scoped(db).filter(
Vulnerability.status == VulnerabilityStatus.open,
Vulnerability.severity.in_([VulnerabilitySeverity.critical, VulnerabilitySeverity.high])
).all()
Vulnerability.severity.in_([VulnerabilitySeverity.critical,
VulnerabilitySeverity.high]),
)
# Only 20 rows are ever printed, so loading every non-compliant finding
# just to call len() on it pulled tens of thousands of ORM objects into
# memory for a number the database can return on its own.
non_compliant_count = _nc.count()
non_compliant_vulns = (_nc.order_by(
Vulnerability.priority_score.desc().nullslast(),
Vulnerability.cvss_score.desc().nullslast()).limit(20).all())
is_compliant = len(non_compliant_vulns) == 0
is_compliant = non_compliant_count == 0
# Status Banner
status_text = "COMPLIANT" if is_compliant else "NON-COMPLIANT"
@@ -264,16 +305,16 @@ async def export_compliance_audit_pdf(
story.append(Spacer(1, 12))
if not is_compliant:
story.append(Paragraph(f"Findings: {len(non_compliant_vulns)} Critical/High vulnerabilities detected which violate timely remediation requirements.", styles['BodyText']))
story.append(Paragraph(f"Findings: {non_compliant_count} Critical/High vulnerabilities detected which violate timely remediation requirements.", styles['BodyText']))
story.append(Spacer(1, 6))
data = [['CVE ID', 'Severity', 'Asset', 'Detected At']]
for v in non_compliant_vulns[:20]: # Limit to top 20 to avoid confusing PDF overflow
for v in non_compliant_vulns: # already capped + ranked by the query
data.append([
v.cve_id,
v.severity.value,
v.severity.value if v.severity else "",
v.asset.hostname if v.asset else "Unknown",
v.detected_at.strftime('%Y-%m-%d %H:%M')
v.detected_at.strftime('%Y-%m-%d %H:%M') if v.detected_at else ""
])
t = Table(data, colWidths=[2*inch, 1*inch, 2.5*inch, 1.5*inch])
@@ -286,8 +327,8 @@ async def export_compliance_audit_pdf(
]))
story.append(t)
if len(non_compliant_vulns) > 20:
story.append(Paragraph(f"...and {len(non_compliant_vulns) - 20} more items.", styles['Normal']))
if non_compliant_count > len(non_compliant_vulns):
story.append(Paragraph(f"...and {non_compliant_count - len(non_compliant_vulns)} more items.", styles['Normal']))
else:
story.append(Paragraph("No critical or high vulnerabilities found. System management appears to be in line with control requirements.", styles['Normal']))
+14 -4
View File
@@ -14,7 +14,9 @@ from app.models.scan_schedule import ScanSchedule, ScheduleInterval
from app.models.asset import Asset
from app.models.user import User
from app.auth.dependencies import get_current_user, RequireEditor
from app.routers.vulnerabilities import sync_agent_vulnerabilities
from app.routers.vulnerabilities import (
sync_agent_vulnerabilities, reconcile_empty_agents,
)
router = APIRouter(prefix="/api/v1/scans", tags=["Scans"])
@@ -227,7 +229,7 @@ from app.integrations.wazuh_client import WazuhClient, WazuhAPIError
from app.models.setting import Setting
@router.post("/autoscan", response_model=dict)
async def trigger_autoscan(
def trigger_autoscan(
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor)
):
@@ -262,6 +264,9 @@ async def trigger_autoscan(
# 3. Initialize Wazuh Client
triggered_count = 0
# Shared across the run — an agent that returns nothing is only
# trustworthy once another agent has proven the API answers.
run_stats: dict = {}
errors = []
try:
@@ -286,7 +291,8 @@ async def trigger_autoscan(
db.add(scan)
# Sync vulnerabilities from Wazuh
sync_agent_vulnerabilities(db, client, asset.wazuh_agent_id, asset)
sync_agent_vulnerabilities(db, client, asset.wazuh_agent_id,
asset, run_stats=run_stats)
scan.status = ScanStatus.COMPLETED
scan.completed_at = datetime.now()
@@ -297,8 +303,12 @@ async def trigger_autoscan(
scan.completed_at = datetime.now()
scan.error_message = str(e)
errors.append(f"{asset.hostname}: {str(e)}")
db.commit()
try:
reconcile_empty_agents(db, run_stats)
except Exception as e:
errors.append(f"empty-agent reconcile: {e}")
except Exception as e:
# Mark all pending scans as FAILED since Wazuh connection failed
+151 -15
View File
@@ -20,6 +20,24 @@ from app.auth.setting_crypto import (
router = APIRouter(prefix="/api/v1/settings", tags=["Settings"])
class SyslogTestRequest(BaseModel):
host: str
port: int = 514
protocol: str = "udp"
facility: int = 16
@router.post("/syslog/test")
async def test_syslog(
payload: SyslogTestRequest,
current_user: User = Depends(RequireAdmin),
):
"""Send a one-off test message to the given syslog target. Admin only."""
from app.services.syslog_service import send_test
ok, detail = send_test(payload.model_dump())
return {"ok": ok, "detail": detail}
# Keys that may never be written through this API — owned by env vars or
# managed by the auth subsystem. Overwriting them would lock users out of
# MFA or compromise key isolation.
@@ -31,14 +49,19 @@ _DENYLISTED_KEYS = {"auth_provider_crypto_key"}
_SECRET_SUBFIELDS = {
"password", "secret", "secret_key", "access_key",
"api_key", "indexer_password", "bind_password",
"token", "smtp_password",
"token", "smtp_password", "client_secret",
}
def _redact_protected_value(key: str, stored: Optional[str]) -> Optional[str]:
"""Decrypt + redact secret subfields for safe GET responses."""
if stored is None:
if not stored:
return None
try:
if not decrypt_value(stored):
return None # empty after decrypt = treated as unset
except Exception:
pass
try:
plaintext = decrypt_value(stored)
except Exception:
@@ -118,22 +141,30 @@ async def update_setting(
value_to_store = update_data.value
if is_protected(key):
# If admin re-submitted a redacted value, refuse — they probably
# didn't intend to overwrite secrets with the literal "***set***".
# For protected JSON configs, MERGE secret subfields: if the admin
# left a secret blank or re-submitted the redaction placeholder
# ("***set***"), keep the previously-stored secret instead of
# wiping it. Lets the operator edit non-secret fields (host, ids,
# toggles) without re-typing the password/secret every time.
try:
parsed = json.loads(update_data.value)
if isinstance(parsed, dict):
for f in parsed:
if f.lower() in _SECRET_SUBFIELDS and parsed[f] == "***set***":
raise HTTPException(
status_code=400,
detail=(
f"Refusing to store redaction placeholder in '{f}'. "
"Re-submit the actual secret value."
),
)
except json.JSONDecodeError:
pass
parsed = None
if isinstance(parsed, dict):
existing_row = db.query(Setting).filter(Setting.key == key).first()
existing_cfg = {}
if existing_row and existing_row.value:
try:
existing_cfg = json.loads(decrypt_value(existing_row.value))
except Exception:
existing_cfg = {}
for f in list(parsed.keys()):
if f.lower() in _SECRET_SUBFIELDS and (parsed[f] in ("", "***set***", None)):
if existing_cfg.get(f):
parsed[f] = existing_cfg[f] # preserve stored secret
else:
parsed.pop(f, None) # nothing to keep → drop
update_data.value = json.dumps(parsed)
value_to_store = encrypt_value(update_data.value)
setting = db.query(Setting).filter(Setting.key == key).first()
@@ -146,3 +177,108 @@ async def update_setting(
db.commit()
db.refresh(setting)
return _serialize(setting)
# ---------- live model discovery ----------
#
# The model dropdown used to be a hand-written list per provider, so it was
# wrong the day after every release — DeepSeek-V3 sat there while V3.2 shipped,
# and picking a model the provider had retired failed only at generation time.
# Ask the provider instead: every one of these exposes a model list.
#
# The key travels in the request BODY, never a query string — URLs end up in
# proxy and access logs.
class AIModelsRequest(BaseModel):
provider: str
api_token: Optional[str] = None # omit to use the saved one
base_url: Optional[str] = None
@router.post("/ai/models")
async def list_ai_models(
body: AIModelsRequest,
db: Session = Depends(get_db),
current_user: User = Depends(RequireAdmin),
):
"""Fetch the models the configured provider currently offers."""
import json as _json
import httpx
provider = (body.provider or "").strip().lower()
token = (body.api_token or "").strip()
if not token:
# Fall back to what is stored, so the dropdown works without retyping.
row = db.query(Setting).filter(Setting.key == "ai_config").first()
if row and row.value:
try:
token = (_json.loads(row.value) or {}).get("api_token") or ""
except (ValueError, TypeError):
token = ""
base = (body.base_url or "").strip().rstrip("/")
# (url, headers, json-path to the list, key holding the model id)
if provider in ("openai", "deepseek", "openrouter", "groq", "mistral"):
default_base = {
"openai": "https://api.openai.com/v1",
"deepseek": "https://api.deepseek.com",
"openrouter": "https://openrouter.ai/api/v1",
"groq": "https://api.groq.com/openai/v1",
"mistral": "https://api.mistral.ai/v1",
}[provider]
url = f"{base or default_base}/models"
headers = {"Authorization": f"Bearer {token}"} if token else {}
path, id_key = ("data",), "id"
elif provider == "anthropic":
url = f"{base or 'https://api.anthropic.com/v1'}/models"
headers = {"x-api-key": token, "anthropic-version": "2023-06-01"}
path, id_key = ("data",), "id"
elif provider == "gemini":
# Google takes the key in a header too — keeps it out of the URL.
url = f"{base or 'https://generativelanguage.googleapis.com/v1beta'}/models"
headers = {"x-goog-api-key": token}
path, id_key = ("models",), "name"
elif provider == "ollama":
# Local daemon, no key. Its list lives outside the OpenAI-shaped API.
root = (base or "http://localhost:11434").replace("/v1/chat/completions", "")
url = f"{root.rstrip('/')}/api/tags"
headers = {}
path, id_key = ("models",), "name"
else:
raise HTTPException(400, f"Live model listing is not supported for '{provider}'")
if not token and provider not in ("ollama", "openrouter"):
raise HTTPException(400, "Enter the API key first, then load the models")
try:
async with httpx.AsyncClient(timeout=20.0) as client:
r = await client.get(url, headers=headers)
except httpx.HTTPError as e:
raise HTTPException(502, f"Could not reach {provider}: {e}")
if r.status_code == 401 or r.status_code == 403:
raise HTTPException(401, f"{provider} rejected the API key")
if r.status_code >= 400:
raise HTTPException(502, f"{provider} returned {r.status_code}: {r.text[:200]}")
try:
payload = r.json()
except ValueError:
raise HTTPException(502, f"{provider} returned a non-JSON response")
items = payload
for step in path:
items = (items or {}).get(step) if isinstance(items, dict) else None
if not isinstance(items, list):
raise HTTPException(502, f"Unexpected model list shape from {provider}")
models = []
for it in items:
if not isinstance(it, dict):
continue
mid = it.get(id_key)
if not mid:
continue
# Gemini returns "models/gemini-3-pro" — the API wants the bare id.
mid = str(mid).split("/")[-1] if provider == "gemini" else str(mid)
models.append({"id": mid, "label": it.get("display_name") or it.get("name") or mid})
# Newest first where the provider says so; otherwise stable alphabetical.
models.sort(key=lambda m: m["id"])
return {"provider": provider, "models": models, "count": len(models)}
File diff suppressed because it is too large Load Diff
+309 -27
View File
@@ -41,7 +41,7 @@ INTERVAL_MAP = {
}
async def execute_scheduled_scan(schedule_id: int):
def execute_scheduled_scan(schedule_id: int):
"""Executes a scheduled scan. Routes by ScanSchedule.scanner_type
to either the Wazuh agent loop or the Nessus sync service."""
db = SessionLocal()
@@ -115,7 +115,12 @@ async def execute_scheduled_scan(schedule_id: int):
verify_ssl=config.get("verify_ssl", False)
) as client:
# Sync vulnerabilities for each agent
from app.routers.vulnerabilities import sync_agent_vulnerabilities
from app.routers.vulnerabilities import (
sync_agent_vulnerabilities, reconcile_empty_agents,
)
# Shared across the run so an agent that returns nothing can be
# judged against the run as a whole (see reconcile_empty_agents).
run_stats: dict = {}
for asset in assets:
try:
scan = Scan(
@@ -126,7 +131,8 @@ async def execute_scheduled_scan(schedule_id: int):
)
db.add(scan)
sync_agent_vulnerabilities(db, client, asset.wazuh_agent_id, asset)
sync_agent_vulnerabilities(db, client, asset.wazuh_agent_id,
asset, run_stats=run_stats)
scan.status = ScanStatus.COMPLETED
scan.completed_at = datetime.now()
@@ -137,6 +143,13 @@ async def execute_scheduled_scan(schedule_id: int):
errors.append(f"{asset.hostname}: {e}")
logger.error(f"Scheduled scan error for {asset.hostname}: {e}")
# Now that the run is done, agents that returned nothing can be
# judged: real emptiness if the API answered for anyone else.
try:
reconcile_empty_agents(db, run_stats)
except Exception as e:
logger.error(f"Wazuh empty-agent reconcile failed: {e}")
except Exception as e:
logger.error(f"Wazuh connection error during scheduled scan: {e}")
@@ -153,7 +166,7 @@ async def execute_scheduled_scan(schedule_id: int):
db.close()
async def check_sla_breaches():
def check_sla_breaches():
"""
Hourly SLA-breach check. Honors notification_mode setting:
- 'digest' (default): one summary mail per recipient
@@ -347,7 +360,7 @@ async def check_sla_breaches():
user_id=user_id,
notification_type=NotificationType.SLA_BREACH,
sent_at=now,
subject=f"[VULNCHECK] {len(items)} SLA-breached vulnerabilities require action",
subject=f"[TRUEVULN] {len(items)} SLA-breached vulnerabilities require action",
recipient_email=email,
status=NotificationStatus.SENT if success else NotificationStatus.FAILED,
message_body=f"SLA digest: {it['vuln'].cve_id} on {it['asset'].hostname} ({it['hours_overdue']}h overdue)",
@@ -410,7 +423,7 @@ async def check_sla_breaches():
db.close()
async def refresh_threat_intel_enrichment():
def refresh_threat_intel_enrichment():
"""Daily refresh of EPSS scores + CISA KEV catalog for all open vulnerabilities."""
from app.services.enrichment_service import enrich_all_open_vulnerabilities
@@ -428,7 +441,7 @@ async def refresh_threat_intel_enrichment():
db.close()
async def exploit_intel_nightly():
def exploit_intel_nightly():
"""Refresh public-exploit catalogs (Exploit-DB / PoC-in-GitHub /
Metasploit). Runs 03:45 UTC after Vulnrichment (03:00) and the
audit prune (03:30), before URS recompute (04:00) so the new
@@ -449,7 +462,7 @@ async def exploit_intel_nightly():
db.close()
async def eol_check_nightly():
def eol_check_nightly():
"""Run endoflife.date EOL detection for every Wazuh-linked asset.
Pseudo-CVE rows (cve_id starts with EOL-) get upserted so the next
@@ -524,13 +537,26 @@ async def eol_check_nightly():
if key in seen:
continue
seen.add(key)
if not eol_service.resolve_product_slug(name):
continue
try:
status = eol_service.check_eol(db, name, version)
except Exception:
continue
if not status or not (status.is_eol or status.is_eol_soon or status.is_eoas):
status = None
if eol_service.resolve_product_slug(name):
try:
status = eol_service.check_eol(db, name, version)
except Exception:
status = None
actionable = status and (status.is_eol or status.is_eol_soon or status.is_eoas)
# MS-lifecycle fallback whenever endoflife.date had nothing
# actionable (covers VC++ Redistributables, exotics, and MS
# products endoflife.date can't resolve a release for).
if not actionable:
try:
from app.services import ms_lifecycle_service
ms_status = ms_lifecycle_service.resolve_ms_lifecycle_eol(db, name, version)
if ms_status and (ms_status.is_eol or ms_status.is_eol_soon):
status = ms_status
actionable = True
except Exception as e:
logger.debug("MS-lifecycle nightly fallback failed for %s: %s", name, e)
if not actionable:
continue
try:
eol_service.upsert_eol_vulnerability(
@@ -552,7 +578,148 @@ async def eol_check_nightly():
db.close()
async def prune_audit_logs_nightly():
def intune_sync_nightly():
"""Sync Microsoft Intune managed devices → assets + OS-EOL (Graph API).
Skipped when intune_config is not set. Slotted at 02:10 UTC, before the
other inventory-derived jobs."""
from app.services.intune_service import load_intune_config, run_intune_sync
db = SessionLocal()
try:
if not load_intune_config(db):
logger.info("Intune sync skipped — intune_config not set")
return
stats = run_intune_sync(db)
logger.info("Intune nightly: %s", {k: v for k, v in stats.items() if k != "errors"})
except Exception as e:
logger.error("Intune nightly failed: %s", e)
db.rollback()
finally:
db.close()
def m365_check_nightly():
"""Detect Microsoft 365 Apps CVEs (Plan P) for every Wazuh-linked asset.
M365 Apps security fixes never reach NVD and are invisible to Wazuh's
vulnerability detector. This parses the MS365 Apps security-updates
page (cached 24h), compares the installed build per channel, and
upserts real-CVE rows for the months each host is behind on.
Slotted at 03:20 UTC right after the EOL nightly (03:15), before the
audit prune (03:30).
"""
from app.integrations.wazuh_client import WazuhClient
from app.services import m365_service
from app.auth.setting_crypto import read_setting_value
import json as _json
db = SessionLocal()
try:
raw = read_setting_value(db, "wazuh_config")
if not raw:
logger.info("M365 check skipped — wazuh_config not set")
return
try:
cfg = _json.loads(raw)
except _json.JSONDecodeError:
logger.warning("M365 check skipped — invalid wazuh_config JSON")
return
if not all([cfg.get("api_url"), cfg.get("username"), cfg.get("password")]):
logger.info("M365 check skipped — wazuh_config incomplete")
return
wazuh = WazuhClient(
base_url=cfg.get("api_url"),
username=cfg.get("username"),
password=cfg.get("password"),
indexer_url=cfg.get("indexer_url"),
indexer_username=cfg.get("indexer_username"),
indexer_password=cfg.get("indexer_password"),
verify_ssl=cfg.get("verify_ssl", False),
)
stats = m365_service.run_m365_check(db, wazuh)
logger.info("M365 nightly: %s", stats)
except m365_service.M365Error as e:
logger.warning("M365 nightly skipped — %s", e)
except Exception as e:
logger.error("M365 nightly failed: %s", e)
db.rollback()
finally:
db.close()
def app_cve_scan_nightly():
"""Built-in app→CVE scanner for every asset with software inventory.
Maps installed software (Wazuh packages + Intune detectedApps) to real
CVEs via OSV / NVD-CPE (curated + precise, own version-range check)
closes the coverage gap for Intune-only / mobile devices that have no
real scanner. Source 'app-scan'; cross-confirms with the other scanners.
Slotted at 03:25 UTC after M365 (03:20), before the audit prune (03:30).
Cache (TTL 7d) keeps OSV/NVD load bounded; NVD_API_KEY recommended.
"""
from app.services import app_cve_scanner_service, cvelistv5_scan_service
db = SessionLocal()
try:
# Rebuild the cvelistV5 reverse index first (one ~557 MB ZIP walk) so
# the scan below has fresh product→CVE ranges for curated software.
try:
cvelistv5_scan_service.build_product_index(db)
except Exception as e:
logger.warning("cvelistV5 index build failed (non-fatal): %s", e)
# FP-suppression is part of run_app_cve_scan itself now, so every way of
# starting a scan — nightly, GUI, single asset — produces the same
# result. It used to hang off this job alone.
stats = app_cve_scanner_service.run_app_cve_scan(db)
logger.info("App CVE scan nightly: %s", {k: v for k, v in stats.items() if k != "errors"})
except Exception as e:
logger.error("App CVE scan nightly failed: %s", e)
db.rollback()
finally:
db.close()
def msrc_refresh_weekly():
"""Ingest the recent monthly MSRC CVRF documents into cve_remediations.
Microsoft revises advisories (containment-only first, KBs later), so a
weekly pull keeps the per-CVE fixes/workarounds/mitigations current.
"""
from app.services import msrc_service
db = SessionLocal()
try:
stats = msrc_service.refresh_msrc(db)
logger.info("MSRC weekly: %s", {k: v for k, v in stats.items() if k != "errors"})
except Exception as e:
logger.error("MSRC weekly refresh failed: %s", e)
db.rollback()
finally:
db.close()
def msrc_scan_nightly():
"""Rebuild the MSRC fixed-build index and flag Windows-Server OS CVEs whose
FixedBuild is ahead of the host's build.
MSRC publishes on Patch Tuesday, well before the CVE reaches the Wazuh CTI
feed this closes that gap, and it is patch-level accurate (NVD/cvelistV5
carry no fixed build for MS products, see msrc_scan_service).
"""
from app.services import msrc_scan_service
db = SessionLocal()
try:
msrc_scan_service.build_product_index(db)
stats = msrc_scan_service.run_msrc_scan(db)
logger.info("MSRC scan nightly: %s", {k: v for k, v in stats.items() if k != "errors"})
except Exception as e:
logger.error("MSRC scan nightly failed: %s", e)
db.rollback()
finally:
db.close()
def prune_audit_logs_nightly():
"""Prune audit_logs older than `audit_log_retention_days` setting.
Default 1825 days ( 5 years) so ISO 27001 / SOX / DSGVO Art.5
@@ -591,7 +758,7 @@ async def prune_audit_logs_nightly():
db.close()
async def reconcile_assets_nightly():
def reconcile_assets_nightly():
"""Soft-inactivate assets no source has reported within the window;
revive recently-seen inactive ones. Runs 04:15 UTC, after URS."""
from app.services.asset_lifecycle import reconcile_asset_lifecycle
@@ -606,7 +773,7 @@ async def reconcile_assets_nightly():
db.close()
async def refresh_exposure_nightly():
def refresh_exposure_nightly():
"""Refresh network-exposure scores from Wazuh syscollector ports.
Runs 02:30 UTC, after SCA (02:00)."""
from app.auth.setting_crypto import read_setting_value
@@ -640,7 +807,7 @@ async def refresh_exposure_nightly():
db.close()
async def recompute_urs_nightly():
def recompute_urs_nightly():
"""Recompute Unified Risk Score (URS) for every asset and snapshot.
Runs 04:00 UTC after SCA refresh (02:00) and Vulnrichment
@@ -674,7 +841,7 @@ async def recompute_urs_nightly():
db.close()
async def refresh_compliance_sca():
def refresh_compliance_sca():
"""Nightly Wazuh SCA refresh — pulls /sca/{agent_id} for every
asset that has a wazuh_agent_id and upserts compliance_results.
@@ -696,7 +863,7 @@ async def refresh_compliance_sca():
db.close()
async def refresh_cisa_vulnrichment():
def refresh_cisa_vulnrichment():
"""Nightly job — pull CISA Vulnrichment ZIP snapshot + correct CVSS / SSVC.
CISA commits to cisagov/vulnrichment several times per day. Running
@@ -799,6 +966,48 @@ def sync_schedules():
db.close()
def new_vuln_digest_nightly():
"""Nightly roundup of all new CVEs → one aggregated mail per recipient.
Registered hourly and self-gates on the configured hour, so both the
on/off (notification_schedule) and the send hour (notification_nightly_hour)
are GUI-configurable without re-registering the job.
ponytail: 24 cheap no-op checks/day beats re-registration plumbing.
"""
from datetime import datetime as _dt
db = SessionLocal()
try:
from app.services.email_service import (
get_notification_schedule, get_notification_nightly_hour,
send_nightly_new_vuln_digest,
)
if get_notification_schedule(db) != "nightly":
return
if _dt.now().hour != get_notification_nightly_hour(db):
return
stats = send_nightly_new_vuln_digest(db)
logger.info("Nightly new-vuln digest sent: %s", stats)
except Exception as e:
logger.error("Nightly new-vuln digest failed: %s", e)
finally:
db.close()
def advisory_feeds_refresh():
"""Refresh the security-advisory RSS feeds (ZDI/CERT-EU/BSI/...) so the
advisories page serves from cache. Every 6h these sources publish ahead
of NVD/cvelistV5, that's their whole value."""
from app.services.advisory_feed_service import refresh_feeds
db = SessionLocal()
try:
stats = refresh_feeds(db)
logger.info("Advisory feeds refresh: %s", stats)
except Exception as e:
logger.error("Advisory feeds refresh failed: %s", e)
finally:
db.close()
def start_scheduler():
"""Starts the background scheduler"""
if not HAS_APSCHEDULER or scheduler is None:
@@ -843,7 +1052,7 @@ def start_scheduler():
# without spamming the GitHub raw API per-CVE.
scheduler.add_job(
refresh_cisa_vulnrichment,
trigger=CronTrigger(hour=3, minute=0),
trigger=CronTrigger(hour=4, minute=30),
id="vulnrichment_nightly",
name="Nightly CISA Vulnrichment CVSS / SSVC Correction",
replace_existing=True,
@@ -865,7 +1074,7 @@ def start_scheduler():
# Also prunes asset_risk_snapshots older than 90 days.
scheduler.add_job(
recompute_urs_nightly,
trigger=CronTrigger(hour=4, minute=0),
trigger=CronTrigger(hour=5, minute=10),
id="urs_nightly",
name="Nightly URS Recompute + Snapshot Prune",
replace_existing=True,
@@ -876,7 +1085,7 @@ def start_scheduler():
# (default 30), revive recently-seen ones. Runs after URS.
scheduler.add_job(
reconcile_assets_nightly,
trigger=CronTrigger(hour=4, minute=15),
trigger=CronTrigger(hour=5, minute=25),
id="asset_lifecycle_nightly",
name="Nightly Asset Lifecycle Reconcile",
replace_existing=True,
@@ -892,13 +1101,32 @@ def start_scheduler():
replace_existing=True,
)
# Advisory RSS feeds (ZDI/CERT-EU/BSI/...) every 6h at :20.
scheduler.add_job(
advisory_feeds_refresh,
trigger=CronTrigger(hour="*/6", minute=20),
id="advisory_feeds_refresh",
name="Security Advisory Feeds Refresh",
replace_existing=True,
)
# New-vuln nightly roundup — runs hourly at :05, self-gates on the
# configured hour + notification_schedule=='nightly' (see the function).
scheduler.add_job(
new_vuln_digest_nightly,
trigger=CronTrigger(minute=5),
id="new_vuln_digest_nightly",
name="Nightly New-Vulnerability Roundup",
replace_existing=True,
)
# Nightly audit-log prune at 03:30 — between Vulnrichment (03:00)
# and URS (04:00). Retention configurable via setting
# `audit_log_retention_days` (default 1825 = ~5 years; 0 = keep
# forever). Covers ISO 27001 / SOX / DSGVO Art.5 windows.
scheduler.add_job(
prune_audit_logs_nightly,
trigger=CronTrigger(hour=3, minute=30),
trigger=CronTrigger(hour=5, minute=40),
id="audit_log_prune_nightly",
name="Nightly Audit-Log Retention Prune",
replace_existing=True,
@@ -909,23 +1137,77 @@ def start_scheduler():
# detection). Creates pseudo-CVEs (cve_id starts with "EOL-").
scheduler.add_job(
eol_check_nightly,
trigger=CronTrigger(hour=3, minute=15),
trigger=CronTrigger(hour=3, minute=0),
id="eol_check_nightly",
name="Nightly endoflife.date EOL Detection",
replace_existing=True,
)
# Nightly Microsoft 365 Apps CVE detection (Plan P) at 03:20 — parses
# the MS365 Apps security-updates page and creates real-CVE rows for
# builds behind the latest channel patch. Closes the gap where M365
# fixes never reach NVD / Wazuh.
scheduler.add_job(
m365_check_nightly,
trigger=CronTrigger(hour=3, minute=10),
id="m365_check_nightly",
name="Nightly Microsoft 365 Apps CVE Detection",
replace_existing=True,
)
# Nightly exploit-intel refresh (Plan M) at 03:45 — pulls
# Exploit-DB CSV + PoC-in-GitHub + Metasploit module index, writes
# per-vuln counts + ref lists.
scheduler.add_job(
exploit_intel_nightly,
trigger=CronTrigger(hour=3, minute=45),
trigger=CronTrigger(hour=4, minute=50),
id="exploit_intel_nightly",
name="Nightly Public-Exploit Catalog Refresh",
replace_existing=True,
)
# Nightly Microsoft Intune device/inventory sync (02:10 UTC).
scheduler.add_job(
intune_sync_nightly,
trigger=CronTrigger(hour=2, minute=10),
id="intune_sync_nightly",
name="Nightly Microsoft Intune Inventory Sync",
replace_existing=True,
)
# Nightly built-in app→CVE scan (03:25 UTC) — maps installed software
# (Wazuh packages + Intune detectedApps) to real CVEs via OSV/NVD-CPE;
# closes the coverage gap for Intune-only / mobile devices.
scheduler.add_job(
app_cve_scan_nightly,
trigger=CronTrigger(hour=3, minute=20),
id="app_cve_scan_nightly",
name="Nightly Built-in App CVE Scan",
replace_existing=True,
)
# Weekly MSRC CVRF ingest (Sun 04:40 UTC) — per-CVE Microsoft fixes
# (KB + build + link), workarounds, and mitigations into
# cve_remediations for the Windows/MS-product findings.
scheduler.add_job(
msrc_refresh_weekly,
trigger=CronTrigger(day_of_week="sun", hour=4, minute=40),
id="msrc_refresh_weekly",
name="Weekly MSRC Remediation Enrichment",
replace_existing=True,
)
# Nightly MSRC fixed-build scan (05:10 UTC) — Windows-Server OS CVEs whose
# FixedBuild is ahead of the host's build. Daily (not weekly) so a Patch
# Tuesday lands the next morning instead of days later.
scheduler.add_job(
msrc_scan_nightly,
trigger=CronTrigger(hour=3, minute=50),
id="msrc_scan_nightly",
name="Nightly MSRC Fixed-Build Scan (Windows OS)",
replace_existing=True,
)
scheduler.start()
logger.info(
"Background scheduler started (SLA Breach Checker + Threat Intel Refresh + Vulnrichment Nightly + Compliance SCA Nightly + URS Nightly + Audit-Log Prune)"
+166
View File
@@ -0,0 +1,166 @@
"""
Security advisory RSS/Atom feeds central awareness page.
Complements the CISA-KEV feed with vendor/CERT advisories that often precede
NVD/cvelistV5 publication (tester: newest 7-Zip advisory was on ZDI before
either). Feeds are configurable (setting `advisory_feeds_config`); the parsed
items are cached in a setting so the page renders instantly and the fetch cost
is paid by the scheduler, not the request.
Default feeds were verified live before shipping:
zdi-published / zdi-upcoming Zero Day Initiative
cert-eu CERT-EU security advisories
bsi-wid BSI / CERT-Bund WID advisories
cisco-psirt Cisco PSIRT (feed emits trailing junk after
the XML root lenient per-item fallback)
"""
from __future__ import annotations
import json
import logging
import re
import urllib.request
import xml.etree.ElementTree as ET
from datetime import datetime
from typing import List, Optional
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
CONFIG_SETTING = "advisory_feeds_config"
CACHE_SETTING = "advisory_feeds_cache"
MAX_ITEMS_PER_FEED = 30
HTTP_TIMEOUT = 30
DEFAULT_FEEDS: List[dict] = [
{"id": "zdi-published", "name": "Zero Day Initiative — Published",
"url": "https://www.zerodayinitiative.com/rss/published/", "enabled": True},
{"id": "zdi-upcoming", "name": "Zero Day Initiative — Upcoming",
"url": "https://www.zerodayinitiative.com/rss/upcoming/", "enabled": False},
{"id": "cert-eu", "name": "CERT-EU Security Advisories",
"url": "https://cert.europa.eu/publications/security-advisories-rss", "enabled": True},
{"id": "bsi-wid", "name": "BSI / CERT-Bund (WID)",
"url": "https://wid.cert-bund.de/content/public/securityAdvisory/rss", "enabled": True},
{"id": "cisco-psirt", "name": "Cisco PSIRT Advisories",
"url": "https://sec.cloudapps.cisco.com/security/center/psirtrss20/CiscoSecurityAdvisory.xml", "enabled": False},
]
_ATOM = "{http://www.w3.org/2005/Atom}"
_TAG_RE = re.compile(r"<[^>]+>")
_ITEM_RE = re.compile(r"<item[\s>].*?</item>", re.S | re.I)
def get_feed_config(db: Session) -> List[dict]:
"""Configured feeds; defaults when the setting is unset/broken."""
from app.models.setting import Setting
try:
row = db.query(Setting).filter(Setting.key == CONFIG_SETTING).first()
if row and row.value:
cfg = json.loads(row.value)
if isinstance(cfg, list) and cfg:
for f in cfg: # migrate stale Cisco RSS URL (rss.x?i=44 → DTD-refused)
if isinstance(f.get("url"), str) and "rss.x?i=44" in f["url"]:
f["url"] = "https://sec.cloudapps.cisco.com/security/center/psirtrss20/CiscoSecurityAdvisory.xml"
return [f for f in cfg if f.get("url")]
except Exception as e:
logger.warning("advisory-feeds: config unreadable, using defaults: %s", e)
return DEFAULT_FEEDS
def _text(el) -> str:
return "" if el is None or el.text is None else el.text.strip()
def _first(node, *tags):
for t in tags:
el = node.find(t)
if el is not None:
return el
return None
_DTD_RE = re.compile(rb"<!(?:DOCTYPE|ENTITY)", re.I)
def _parse_items(raw: bytes) -> List[dict]:
"""RSS <item> / Atom <entry> → dicts. Falls back to per-item regex slicing
for feeds that emit junk after the XML root (Cisco).
Security: any DOCTYPE/ENTITY declaration is rejected outright legitimate
feeds never need DTDs, and refusing them shuts down XXE and billion-laughs
entity-expansion attacks without pulling in defusedxml."""
if _DTD_RE.search(raw):
raise ValueError("feed contains DOCTYPE/ENTITY declarations — refused")
try:
root = ET.fromstring(raw)
nodes = root.findall(".//item") or root.findall(f".//{_ATOM}entry")
except ET.ParseError:
text = raw.decode("utf-8", "replace")
nodes = []
for m in _ITEM_RE.findall(text):
try:
nodes.append(ET.fromstring(m))
except ET.ParseError:
continue
items = []
for n in nodes[: MAX_ITEMS_PER_FEED * 2]:
title = _text(_first(n, "title", f"{_ATOM}title"))
link_el = _first(n, "link", f"{_ATOM}link")
link = _text(link_el)
if not link and link_el is not None: # Atom: href attribute
link = (link_el.get("href") or "").strip()
date = _text(_first(n, "pubDate", f"{_ATOM}updated", f"{_ATOM}published", "dc:date"))
summary = _TAG_RE.sub(" ", _text(_first(n, "description", f"{_ATOM}summary")))[:400].strip()
if title:
items.append({"title": title[:300], "link": link[:1000],
"date": date[:64], "summary": summary})
if len(items) >= MAX_ITEMS_PER_FEED:
break
return items
def refresh_feeds(db: Session) -> dict:
"""Fetch every ENABLED feed, cache the parsed items. Per-feed errors are
recorded on the feed (page shows them) and never fail the run."""
from app.models.setting import Setting
out = {"fetched_at": datetime.now().isoformat(), "feeds": []}
ok = failed = 0
for f in get_feed_config(db):
entry = {"id": f.get("id"), "name": f.get("name") or f.get("id"),
"url": f["url"], "enabled": bool(f.get("enabled")),
"items": [], "error": None}
if entry["enabled"]:
try:
raw = urllib.request.urlopen(
urllib.request.Request(f["url"], headers={"User-Agent": "truevuln-feed/1.0"}),
timeout=HTTP_TIMEOUT).read()
entry["items"] = _parse_items(raw)
ok += 1
except Exception as e:
entry["error"] = f"{type(e).__name__}: {e}"[:200]
failed += 1
logger.warning("advisory-feeds: %s failed: %s", f.get("id"), e)
out["feeds"].append(entry)
payload = json.dumps(out)
row = db.query(Setting).filter(Setting.key == CACHE_SETTING).first()
if row:
row.value = payload
else:
db.add(Setting(key=CACHE_SETTING, value=payload,
description="Cached security-advisory feed items"))
db.commit()
logger.info("advisory-feeds: refreshed (%d ok, %d failed)", ok, failed)
return {"ok": ok, "failed": failed, "feeds": len(out["feeds"])}
def get_cached_feeds(db: Session) -> Optional[dict]:
from app.models.setting import Setting
row = db.query(Setting).filter(Setting.key == CACHE_SETTING).first()
if not row or not row.value:
return None
try:
return json.loads(row.value)
except Exception:
return None
+66
View File
@@ -0,0 +1,66 @@
"""
Security-advisory awareness feed.
Independent of asset findings: a rolling view of what's being actively
exploited in the wild (CISA KEV), so operators see 0-days/exploited CVEs even
when no scanner has flagged an affected asset yet. Each entry is annotated
with whether we already have that CVE in inventory (and on how many assets).
Reuses the KEV catalog enrichment already fetches + caches (24h).
"""
from __future__ import annotations
import logging
from datetime import datetime
from typing import List, Optional
from sqlalchemy import func
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
def _parse_date(s) -> Optional[datetime]:
try:
return datetime.strptime(str(s)[:10], "%Y-%m-%d")
except (ValueError, TypeError):
return None
def get_recent_kev(db: Session, limit: int = 20) -> List[dict]:
"""Most recently added CISA KEV entries, newest first, annotated with our
inventory status. Returns [] on fetch failure (awareness is best-effort)."""
from app.services.enrichment_service import fetch_kev_catalog
try:
kev = fetch_kev_catalog(db)
except Exception as e:
logger.warning("advisory: KEV fetch failed: %s", e)
return []
rows = []
for cve, e in kev.items():
rows.append({
"cve_id": cve,
"vendor": e.get("vendor"),
"product": e.get("product"),
"name": e.get("name"),
"date_added": e.get("date_added"),
"ransomware": bool(e.get("ransomware_use")),
"description": e.get("short_description"),
})
rows.sort(key=lambda r: (_parse_date(r["date_added"]) or datetime.min), reverse=True)
rows = rows[:limit]
# Annotate with inventory presence in one query.
from app.models.vulnerability import Vulnerability
cves = [r["cve_id"] for r in rows]
counts = {}
if cves:
q = (db.query(Vulnerability.cve_id, func.count(func.distinct(Vulnerability.asset_id)))
.filter(Vulnerability.cve_id.in_(cves))
.group_by(Vulnerability.cve_id))
counts = {cve: n for cve, n in q.all()}
for r in rows:
r["asset_count"] = int(counts.get(r["cve_id"], 0))
r["in_inventory"] = r["asset_count"] > 0
return rows
+193
View File
@@ -0,0 +1,193 @@
"""
AI remediation service (OpenRouter, OpenAI-compatible).
On-demand generator that turns a vulnerability + its host context into
concrete, OS-aware remediation steps. Uses OpenRouter's OpenAI-compatible
REST API directly via httpx (no extra SDK dependency).
Config (env first, then settings table, so it works headless or via UI):
OPENROUTER_API_KEY required to enable the feature
OPENROUTER_MODEL default "openrouter/free" (free auto-router)
OPENROUTER_FALLBACKS optional comma list for route=fallback
Free tier: ~10 requests/day across free models fine for on-demand use.
"""
import logging
import os
from typing import List, Optional
import httpx
from sqlalchemy.orm import Session
from app.models.setting import Setting
logger = logging.getLogger(__name__)
OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
DEFAULT_MODEL = "openrouter/free"
HTTP_TIMEOUT = 60.0
SETTING_KEY = "openrouter_api_key"
SETTING_MODEL = "openrouter_model"
SETTING_ENABLED = "ai_remediation_enabled"
class AIServiceError(Exception):
"""Raised when AI remediation cannot be produced."""
def _cfg(db: Session, env_name: str, setting_key: str, default: str = "") -> str:
val = os.getenv(env_name, "").strip()
if val:
return val
try:
from app.auth.setting_crypto import read_setting_value
sv = read_setting_value(db, setting_key)
if sv:
return str(sv).strip()
except Exception:
s = db.query(Setting).filter(Setting.key == setting_key).first()
if s and s.value:
return str(s.value).strip()
return default
def is_enabled(db: Session) -> bool:
"""True when an API key is configured (env or settings)."""
return bool(_cfg(db, "OPENROUTER_API_KEY", SETTING_KEY))
def _build_messages(*, cve_id, title, description, package, installed,
fixed, os_name, scanner_remediation) -> List[dict]:
sys = (
"You are a senior security engineer. Given a vulnerability and the "
"affected host, produce concise, ACTIONABLE remediation guidance for "
"the specific operating system. Prefer concrete commands in fenced "
"code blocks (apt/dnf/zypper for Linux distros, PowerShell/winget/MSI "
"for Windows). Include: 1) the fix (upgrade/patch/config), 2) exact "
"commands for THIS OS, 3) a verification step, 4) a mitigation if no "
"patch is available. Be brief — no preamble, no marketing."
)
# EOL/EOS pseudo-findings (cve_id starts with EOL- / NESSUS-PLUGIN-) are
# not patchable CVEs — there is no fix, the product is out of support.
# Give the model EOL-specific instructions so the answer is an upgrade/
# replacement plan, not a "apply the patch" hallucination.
is_eol = bool(cve_id) and (
cve_id.upper().startswith("EOL-") or cve_id.upper().startswith("NESSUS-PLUGIN-")
)
if is_eol:
sys = (
"You are a senior IT-security engineer advising on END-OF-LIFE / "
"END-OF-SUPPORT (EOL/EOS) software. The product below no longer "
"receives security patches — there is NO CVE patch to apply, so do "
"NOT suggest 'apply the update'. Produce a concise upgrade/migration "
"plan for THIS operating system:\n"
"1) State the EOL/EOS situation and the risk of staying on it.\n"
"2) The supported target release/edition to move to (name the "
"current supported version and its support timeline if known).\n"
"3) Concrete upgrade/replace commands for THIS OS (apt/dnf/zypper "
"dist-upgrade or repo swap on Linux; winget/MSI/installer or OS "
"in-place upgrade on Windows), plus where to download the supported "
"build.\n"
"4) Interim COMPENSATING CONTROLS / containment while the upgrade is "
"pending (network isolation/segmentation, restrict exposure, disable "
"the component, WAF/firewall rules, increased monitoring).\n"
"5) A verification step. Be brief, no preamble."
)
lines = [
("EOL/EOS finding ID: " if is_eol else "CVE / ID: ") + str(cve_id),
f"Title: {title or ''}",
f"Affected OS: {os_name or 'unknown'}",
f"Product / package: {package or ''}",
f"Installed version: {installed or ''}",
(f"Latest supported version: {fixed or ''}" if is_eol
else f"Fixed version: {fixed or ''}"),
]
if scanner_remediation:
lines.append(f"Scanner-suggested remediation: {scanner_remediation}")
if description:
lines.append(f"\nDescription:\n{description[:1500]}")
if is_eol:
lines.append(
"\nThis is an end-of-life / out-of-support product, NOT a patchable "
"CVE. Give the upgrade/migration plan + interim compensating controls."
)
else:
lines.append("\nGive the remediation now.")
return [
{"role": "system", "content": sys},
{"role": "user", "content": "\n".join(lines)},
]
def generate_remediation(
db: Session,
*,
cve_id: str,
title: Optional[str] = None,
description: Optional[str] = None,
package: Optional[str] = None,
installed: Optional[str] = None,
fixed: Optional[str] = None,
os_name: Optional[str] = None,
scanner_remediation: Optional[str] = None,
) -> dict:
"""Call OpenRouter and return {"content": str, "model": str}.
Synchronous + blocking the caller must run it OFF the event loop
(asyncio.to_thread) so it never freezes the GUI.
"""
api_key = _cfg(db, "OPENROUTER_API_KEY", SETTING_KEY)
if not api_key:
raise AIServiceError(
"OpenRouter not configured — set OPENROUTER_API_KEY (env or Settings)."
)
model = _cfg(db, "OPENROUTER_MODEL", SETTING_MODEL, DEFAULT_MODEL)
fallbacks = _cfg(db, "OPENROUTER_FALLBACKS", "openrouter_fallbacks", "")
body = {
"model": model,
"messages": _build_messages(
cve_id=cve_id, title=title, description=description, package=package,
installed=installed, fixed=fixed, os_name=os_name,
scanner_remediation=scanner_remediation,
),
}
if fallbacks:
models = [model] + [m.strip() for m in fallbacks.split(",") if m.strip()]
body["models"] = models
body["route"] = "fallback"
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
# OpenRouter attribution headers (optional but recommended).
"HTTP-Referer": "https://truevuln.local",
"X-Title": "TrueVuln",
}
try:
with httpx.Client(timeout=HTTP_TIMEOUT) as client:
resp = client.post(OPENROUTER_URL, headers=headers, json=body)
except httpx.HTTPError as e:
raise AIServiceError(f"OpenRouter request failed: {e}") from e
if resp.status_code == 401:
raise AIServiceError("OpenRouter rejected the API key (401).")
if resp.status_code == 402:
raise AIServiceError(
"OpenRouter quota/credits exhausted (402) — free-tier daily cap hit "
"or a paid model needs credits."
)
if resp.status_code >= 400:
raise AIServiceError(f"OpenRouter error {resp.status_code}: {resp.text[:300]}")
try:
data = resp.json()
content = data["choices"][0]["message"]["content"]
used_model = data.get("model", model)
except (KeyError, IndexError, ValueError) as e:
raise AIServiceError(f"Unexpected OpenRouter response shape: {e}") from e
if not content or not content.strip():
raise AIServiceError("OpenRouter returned an empty response.")
return {"content": content.strip(), "model": used_model}
+264
View File
@@ -0,0 +1,264 @@
"""
Android per-CVE detection from the Google Android Security Bulletin (ASB).
For an Intune-managed Android device we know its security patch level
(androidSecurityPatchLevel, e.g. "2025-03-01"). Every monthly ASB published
AFTER that level lists CVEs the device has NOT yet received. We fetch those
months from source.android.com (stable, static, per-month URLs), extract the
CVEs + severity, and raise real-CVE findings (source 'android-asb').
Why ASB and not Samsung's SMR page: Samsung's securityUpdate.smsb ignores the
year/month query param and loads the month via JS, so a plain fetch can't get
a historical month. ASB is the upstream source for the Google CVEs Samsung
ships (the security-critical bulk) and is cleanly scrapeable. Samsung-
proprietary SVE CVEs are not covered (their page is unscrapeable).
Scope guards (volume): Critical + High only, last _MAX_MONTHS months.
"""
from __future__ import annotations
import json
import logging
import re
from datetime import date, datetime
from typing import List, Optional, Tuple
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
_ASB_BASE = "https://source.android.com/docs/security/bulletin"
def _asb_urls(month: str) -> List[str]:
"""Candidate ASB URLs for a month slug. From 2026 Google nests the page
under a year segment (/bulletin/2026/2026-01-01); older months are flat
(/bulletin/2025-10-01). Try the year-nested form first, then flat, so we
survive whichever format applies (and future shifts)."""
year = month[:4]
return [f"{_ASB_BASE}/{year}/{month}", f"{_ASB_BASE}/{month}"]
_CACHE_PREFIX = "asb_month_v2_" # v2: bumped when the URL/section-filter
# logic changed, so stale cache entries
# from before those fixes are ignored
# and every month is refetched fresh.
_MAX_MONTHS = 12 # cap lookback so a very stale device can't flood
_WANT_SEV = {"critical", "high"} # actionable severities only
_CVE_RE = re.compile(r"CVE-\d{4}-\d{4,7}")
_SEV_RE = re.compile(r">(Critical|High|Moderate|Low)<")
# Section headers + rows, in document order, so each CVE is scoped to its
# ASB section.
_TOKEN_RE = re.compile(r"<h[23][^>]*>(.*?)</h[23]>|<tr[^>]*>(.*?)</tr>", re.S)
# SoC / third-party vendor sections — those CVEs only affect devices with that
# chipset (Qualcomm/MediaTek/etc.), so importing them onto every Android device
# produces false positives (Samsung's own SMR lists many as "Not applicable").
# We keep only the AOSP sections (Framework/System/Kernel/Runtime/Media/Play/
# Widevine) that apply to any Android device at that patch level.
_SOC_SECTION = re.compile(
r"qualcomm|mediatek|unisoc|spreadtrum|imagination|arm component|"
r"broadcom|nvidia|marvell|kryo|adreno", re.I)
def _parse_patch_month(raw) -> Optional[Tuple[int, int]]:
"""androidSecurityPatchLevel 'YYYY-MM-DD' → (year, month)."""
m = re.match(r"(\d{4})-(\d{2})", str(raw or ""))
if not m:
return None
return int(m.group(1)), int(m.group(2))
def _months_after(year: int, month: int, today: date) -> List[str]:
"""ASB month slugs ('YYYY-MM-01') strictly after (year,month) up to today,
newest first, capped at _MAX_MONTHS."""
out = []
y, mo = year, month
while True:
mo += 1
if mo > 12:
mo = 1
y += 1
if (y, mo) > (today.year, today.month):
break
out.append(f"{y:04d}-{mo:02d}-01")
return out[-_MAX_MONTHS:][::-1]
def _parse_asb_html(html: str) -> List[Tuple[str, str]]:
"""→ [(cve, severity)] from one ASB page, walking section headers + rows in
order. CVEs under SoC/vendor sections (chipset-specific) are skipped so we
don't false-positive them onto devices with a different SoC. Severity
carries forward across rowspan rows (Android merges the severity cell)."""
out: List[Tuple[str, str]] = []
seen: set = set()
last_sev = "High"
skip = False
for m in _TOKEN_RE.finditer(html):
if m.group(1) is not None: # section header
last_sev = "High"
skip = bool(_SOC_SECTION.search(re.sub(r"<[^>]+>", "", m.group(1))))
continue
cell = m.group(2)
cm = _CVE_RE.search(cell)
if not cm:
continue
sm = _SEV_RE.search(cell)
if sm:
last_sev = sm.group(1)
if skip:
continue # SoC/vendor section → chipset-specific, not universal
cve = cm.group(0).upper()
if cve in seen:
continue
seen.add(cve)
out.append((cve, last_sev.lower()))
return out
# A month with CVEs is immutable → cache forever. An empty/404 month (a
# future month Google hasn't published yet) is negatively cached for this long
# so we don't re-fetch it on every device sync, but still pick it up once it
# goes live.
_NEG_TTL_DAYS = 3
def _cache_read(db: Session, key: str):
"""→ (pairs, is_fresh). pairs may be []; is_fresh False means refetch."""
from app.models.setting import Setting
row = db.query(Setting).filter(Setting.key == key).first()
if not row or not row.value:
return None, False
try:
blob = json.loads(row.value)
except Exception:
return None, False
if isinstance(blob, list): # legacy positive entry
return [tuple(x) for x in blob], True
pairs = [tuple(x) for x in (blob.get("pairs") or [])]
if pairs:
return pairs, True # non-empty is immutable
try:
ts = datetime.fromisoformat(blob.get("ts"))
except Exception:
return [], False
return [], (datetime.now() - ts).days < _NEG_TTL_DAYS
def _cache_write(db: Session, key: str, month: str, pairs: list) -> None:
from app.models.setting import Setting
payload = json.dumps({"ts": datetime.now().isoformat(), "pairs": pairs})
row = db.query(Setting).filter(Setting.key == key).first()
if row:
row.value = payload
else:
db.add(Setting(key=key, value=payload,
description=f"Android Security Bulletin {month} (cve,severity)"))
db.commit()
def fetch_asb_month(db: Session, month: str) -> List[Tuple[str, str]]:
"""Cached fetch+parse of one ASB month. Empty/404 months are negatively
cached (short TTL) so a future month Google hasn't published yet doesn't
trigger a re-fetch on every device sync."""
key = _CACHE_PREFIX + month
pairs, fresh = _cache_read(db, key)
if fresh:
return pairs
import httpx
pairs = []
try:
with httpx.Client(timeout=30.0, follow_redirects=True,
headers={"User-Agent": "TrueVuln/1.0"}) as c:
for url in _asb_urls(month):
r = c.get(url)
if r.status_code == 200:
pairs = _parse_asb_html(r.text)
break
except Exception as e:
logger.debug("ASB fetch failed for %s: %s", month, e)
pairs = []
_cache_write(db, key, month, pairs) # cache empties too (negative cache)
return pairs
def _upsert(db: Session, asset, month: str, cve: str, sev: str, new_ids: list,
source: str = "android-asb", label: str = "ASB") -> None:
from app.models.vulnerability import Vulnerability, VulnerabilitySeverity, VulnerabilityStatus
cve_id = cve.upper()
existing = (db.query(Vulnerability)
.filter(Vulnerability.cve_id == cve_id, Vulnerability.asset_id == asset.id)
.first())
if existing:
existing.add_source(source)
if not existing.package_name:
existing.package_name = f"Android ({label} {month[:7]})"[:255]
from app.services.audit_events import reopen_if_patched
reopen_if_patched(db, existing, reason="Android patch-level check reports this finding again", source="android_cve")
try:
existing.refresh_scores()
except Exception:
pass
return
sevmap = {"critical": VulnerabilitySeverity.critical, "high": VulnerabilitySeverity.high,
"moderate": VulnerabilitySeverity.medium, "low": VulnerabilitySeverity.low}
row = Vulnerability(
cve_id=cve_id, asset_id=asset.id, severity=sevmap.get(sev, VulnerabilitySeverity.high),
status=VulnerabilityStatus.open,
title=f"Android {month[:7]} security patch — {cve_id}"[:500],
package_name=f"Android ({label} {month[:7]})"[:255],
package_version=(asset.os_version or "")[:100] or None,
detected_at=datetime.now(),
sources=json.dumps([source]), first_detected_by=source,
)
db.add(row)
db.flush()
try:
row.refresh_scores()
except Exception:
pass
new_ids.append(row.id)
def check_android_cves(db: Session, asset, patch_level, manufacturer: str = "",
new_ids: Optional[list] = None) -> int:
"""Raise findings for the months the device is behind on. Critical+High
only, last _MAX_MONTHS months.
Samsung devices: prefer security.samsungmobile.com's own SMR page per
month (excludes chipset CVEs Samsung says don't apply — avoids false
positives the raw ASB would produce, e.g. a Qualcomm-only CVE on a
Samsung device with a different SoC). Falls back to raw ASB (source
'android-asb') for any month the SMR page doesn't cover or fails to
fetch, and for all non-Samsung Android devices.
"""
if new_ids is None:
new_ids = []
ym = _parse_patch_month(patch_level)
if not ym:
return 0
months = _months_after(ym[0], ym[1], date.today())
is_samsung = "samsung" in (manufacturer or "").lower()
count = 0
for month in months:
pairs = None
if is_samsung:
try:
from app.services import samsung_smr_service
y, m = int(month[:4]), int(month[5:7])
pairs = samsung_smr_service.get_smr_month(db, y, m)
except Exception as e:
logger.debug("Samsung SMR lookup failed for %s: %s", month, e)
pairs = None
if pairs is not None:
source, label = "samsung-smr", "SMR"
else:
pairs = fetch_asb_month(db, month)
source, label = "android-asb", "ASB"
for cve, sev in pairs:
if sev not in _WANT_SEV:
continue
before = len(new_ids)
try:
_upsert(db, asset, month, cve, sev, new_ids, source=source, label=label)
count += 1 if len(new_ids) > before else 0
except Exception as e:
logger.debug("%s upsert failed (%s on %s): %s", source, cve, asset.id, e)
return count
File diff suppressed because it is too large Load Diff
+164 -1
View File
@@ -3,7 +3,7 @@ Asset lifecycle reconciliation.
Tester request: when an asset is decommissioned in the source systems
(removed from Wazuh + Nessus via the org's "system no longer exists"
process), VulnCheck should not keep a data corpse. But hard-deleting
process), TrueVuln should not keep a data corpse. But hard-deleting
loses the vulnerability history + breaks the revisionssicher audit
trail the operator also asked for.
@@ -203,6 +203,28 @@ def reconcile_missing_from_sync(
for a in stale:
a.source = AssetSource.NESSUS
# Diagnostic: log how many candidates we're about to evaluate so
# a tester reporting "INACTIVE never flips" can paste this line
# in the bug report — it tells us if the issue is upstream (no
# nessus_host_uuid pinned) or downstream (reconcile logic).
legacy_unpinned = (
db.query(Asset)
.filter(
Asset.source == source,
Asset.status == AssetStatus.ACTIVE,
id_field.is_(None),
)
.count()
)
if legacy_unpinned:
logger.info(
"asset sync-reconcile (%s): %d ACTIVE assets have no %s pinned "
"— they will be skipped by the per-id reconcile. Consider a "
"host-name backfill job to set nessus_host_uuid for legacy rows.",
source.value if hasattr(source, "value") else source,
legacy_unpinned, id_field.key,
)
active_q = (
db.query(Asset)
.filter(
@@ -245,3 +267,144 @@ def reconcile_missing_from_sync(
stats["inactivated"], stats["reactivated"],
)
return stats
def reconcile_nessus_by_seen_ids(
db: Session,
*,
seen_asset_ids: set,
reason: str,
) -> dict:
"""Robust id-keyed Nessus reconcile — supersedes the uuid-keyed path.
Why id-keyed: the previous reconcile keyed on `nessus_host_uuid`. If
the only host in a reduced-scope scan had no `host_uuid` in its
Nessus host_info, `seen_uuids` came back EMPTY the fail-open guard
skipped everything the dropped hosts stayed ACTIVE (tester bug).
Tracking the matched `asset.id` of every host actually touched this
sync avoids that: even a uuid-less host still contributes its id, so
the seen-set is non-empty and the dropped assets get inactivated.
Candidate set = ACTIVE assets Nessus knows about, i.e.
source == NESSUS OR nessus_host_uuid IS NOT NULL
minus the ids seen this run.
Fail-open: empty seen set skip (can't tell "scan saw nothing" from
"upstream failed"). DECOMMISSIONED is operator-final, never touched.
Caller commits.
"""
from sqlalchemy import or_ as _or
stats = {"inactivated": 0, "reactivated": 0, "candidates": 0}
if not seen_asset_ids:
logger.warning(
"nessus reconcile (id-keyed): skipped — seen_asset_ids empty "
"(scan returned no matched hosts? not deactivating anything)"
)
return stats
nessus_known = _or(
Asset.source == AssetSource.NESSUS,
Asset.nessus_host_uuid.isnot(None),
)
# Inactivate: Nessus-known, ACTIVE, not seen this run.
candidates = (
db.query(Asset)
.filter(
nessus_known,
Asset.status == AssetStatus.ACTIVE,
~Asset.id.in_(seen_asset_ids),
)
.all()
)
stats["candidates"] = len(candidates)
for asset in candidates:
old = asset.status.value if hasattr(asset.status, "value") else str(asset.status)
asset.status = AssetStatus.INACTIVE
_audit_asset_status(db, asset, old, "inactive", reason)
stats["inactivated"] += 1
# Reactivate: Nessus-known, INACTIVE, seen again this run.
revived = (
db.query(Asset)
.filter(
nessus_known,
Asset.status == AssetStatus.INACTIVE,
Asset.id.in_(seen_asset_ids),
)
.all()
)
for asset in revived:
asset.status = AssetStatus.ACTIVE
_audit_asset_status(
db, asset, "inactive", "active",
"seen again by a Nessus sync (event-driven revive)",
)
stats["reactivated"] += 1
return stats
def reconcile_intune_by_seen_ids(
db: Session,
*,
seen_asset_ids: set,
reason: str,
) -> dict:
"""Id-keyed Intune reconcile — same robust pattern as the Nessus one.
Candidate set = ACTIVE Intune-known assets (source == INTUNE OR
intune_device_id IS NOT NULL) minus the ids seen this Graph sync
INACTIVE; INACTIVE ones seen again ACTIVE. Fail-open on empty seen
set. DECOMMISSIONED untouched. Caller commits.
"""
from sqlalchemy import or_ as _or
stats = {"inactivated": 0, "reactivated": 0, "candidates": 0}
if not seen_asset_ids:
logger.warning(
"intune reconcile: skipped — seen_asset_ids empty "
"(sync returned no matched devices? not deactivating anything)"
)
return stats
intune_known = _or(
Asset.source == AssetSource.INTUNE,
Asset.intune_device_id.isnot(None),
)
candidates = (
db.query(Asset)
.filter(
intune_known,
Asset.status == AssetStatus.ACTIVE,
~Asset.id.in_(seen_asset_ids),
)
.all()
)
stats["candidates"] = len(candidates)
for asset in candidates:
old = asset.status.value if hasattr(asset.status, "value") else str(asset.status)
asset.status = AssetStatus.INACTIVE
_audit_asset_status(db, asset, old, "inactive", reason)
stats["inactivated"] += 1
revived = (
db.query(Asset)
.filter(
intune_known,
Asset.status == AssetStatus.INACTIVE,
Asset.id.in_(seen_asset_ids),
)
.all()
)
for asset in revived:
asset.status = AssetStatus.ACTIVE
_audit_asset_status(
db, asset, "inactive", "active",
"seen again by an Intune sync (event-driven revive)",
)
stats["reactivated"] += 1
return stats
+204
View File
@@ -0,0 +1,204 @@
"""
Shared audit-event writers for sync-driven changes.
Tester requirement (revisionssicher): a finding newly created by a
Wazuh/Nessus sync must leave an initial "VULNERABILITY_DETECTED" trail
previously the audit history only began with the first status change.
"""
import logging
from datetime import datetime, timedelta
from typing import Iterable
from sqlalchemy.orm import Session
from app.models.audit_log import AuditLog, AuditEventType
from app.models.vulnerability import Vulnerability
logger = logging.getLogger(__name__)
def audit_new_vulnerabilities(
db: Session,
vuln_ids: Iterable[int],
*,
source: str,
) -> int:
"""Write one VULNERABILITY_DETECTED audit row per newly created finding.
`source` names the detector ("wazuh", "nessus", "eol_check", ...).
user_id stays NULL the audit UI renders it as System/Auto, matching
the asset-lifecycle events. Caller commits. Returns rows written.
"""
ids = [i for i in vuln_ids if i]
if not ids:
return 0
rows = (
db.query(Vulnerability)
.filter(Vulnerability.id.in_(ids))
.all()
)
written = 0
now = datetime.now()
for v in rows:
hostname = v.asset.hostname if v.asset else f"asset #{v.asset_id}"
desc = (
f"New finding detected: {v.cve_id} on {hostname} "
f"(severity={v.severity.value if hasattr(v.severity, 'value') else v.severity}, "
f"source={source})"
)
db.add(AuditLog(
user_id=None, # System/Auto
event_type=AuditEventType.VULNERABILITY_DETECTED,
event_description=desc[:500],
resource_type="vulnerability",
resource_id=str(v.id),
timestamp=now,
))
written += 1
if written:
logger.info("audit: %d VULNERABILITY_DETECTED events (%s)", written, source)
return written
# How far behind reality each source's inventory can be, and therefore how
# long after a close its "still vulnerable" claim is not trustworthy.
#
# Measured against how the data actually arrives, not against the scan
# schedule: Wazuh syscollector is close to live, Intune depends on the
# tenant's inventory-refresh policy, and Defender TVM's software list is the
# slowest of the three by a wide margin. Sources that read a live inventory
# themselves (wazuh_sync, app_scan, msrc, nessus) get no grace — when they say
# it is back, it is back.
_REOPEN_GRACE = {
"defender": timedelta(days=3),
"intune": timedelta(days=1),
"m365_check": timedelta(days=1), # same Graph inventory as Intune
"mobile_eol": timedelta(days=1),
"android_cve": timedelta(days=1),
}
def reopen_if_patched(db: Session, vuln, *, reason: str, source: str) -> bool:
"""Flip a patched finding back to OPEN and AUDIT the transition.
Every scanner reopens findings it sees again after they were closed, but
each one did it inline without writing a status-change row so only the
positive direction (open patched) ever appeared in the audit log and the
per-CVE Change History. A finding could silently go patched open, which
is exactly the transition an auditor most wants to see (tester flagged it).
Returns True when a reopen actually happened.
"""
from app.models.vulnerability import VulnerabilityStatus
if vuln.status != VulnerabilityStatus.patched:
return False
# Don't let a slow source undo a fast source's conclusion.
#
# The three inventories do not see the same moment in time. Wazuh
# syscollector is close to live, Intune depends on how aggressively the
# tenant's policies push an inventory refresh, and Defender TVM's software
# list trails by days. So the normal sequence after patching a host is:
# 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 thing again. The finding flaps, and its history fills with
# transitions that describe our polling, not the host.
#
# A source may therefore only reopen a finding once its own lag has had
# time to pass. Below that, its claim is about a state the other source has
# already superseded.
grace = _REOPEN_GRACE.get((source or "").lower())
if grace and vuln.patched_at and (datetime.now() - vuln.patched_at) < grace:
logger.debug(
"reopen from %s ignored for %s — patched %s ago, within its %s lag",
source, vuln.cve_id, datetime.now() - vuln.patched_at, grace)
return False
old_status = vuln.status
vuln.status = VulnerabilityStatus.open
vuln.patched_at = None
try:
from app.routers.vulnerabilities import log_vulnerability_change
# Name the host so the row is attributable and searchable. Lazy-loads
# the asset, which is fine: reopens are rare (unlike bulk resolves).
hostname = None
try:
hostname = vuln.asset.hostname if vuln.asset else None
except Exception:
hostname = None
log_vulnerability_change(
db, None, vuln.id, old_status, vuln.status,
reason=reason, cve_id=vuln.cve_id, source=source, hostname=hostname,
)
except Exception as e:
logger.warning("audit log for reopen failed (vuln_id=%s): %s", vuln.id, e)
return True
def record_affected_package(db: Session, vuln, *, name: str, version: str = None,
fixed_version: str = None, source: str = None) -> None:
"""Record ONE affected product on a finding, in vulnerability_packages.
A finding is unique per (cve_id, asset_id), so when two DIFFERENT products
on the same host are hit by the same CVE e.g. CVE-2026-16417 affects both
Google Chrome and Microsoft Edge, which carry completely different build
schemes the single row's package_name can only name one of them. The
per-package table is what keeps both visible (the CVE detail page already
renders every entry, and the API returns them as `packages`).
Idempotent per (vulnerability, package name): re-detection refreshes the
version/fix and last_seen_at instead of appending duplicates.
"""
from app.models.vulnerability_package import VulnerabilityPackage
if not name:
return
name = name[:255]
now = datetime.now()
try:
row = (db.query(VulnerabilityPackage)
.filter(VulnerabilityPackage.vulnerability_id == vuln.id,
VulnerabilityPackage.package_name == name)
.first())
if row is None:
# A plain query does NOT see rows added earlier in this same
# (unflushed) transaction, and one scan run legitimately hits the
# same (finding, product) twice — e.g. several inventory entries
# carrying the same product name, or two CVE entries for one
# package. Without this the second add hit uq_vulnpkg_vuln_package
# and the IntegrityError aborted the WHOLE app-scan with a 502.
for pending in db.new:
if (isinstance(pending, VulnerabilityPackage)
and pending.vulnerability_id == vuln.id
and pending.package_name == name):
row = pending
break
if row is not None:
if version:
row.package_version = version[:100]
if fixed_version:
row.fixed_version = fixed_version[:100]
# Provenance is per-package and cumulative: a product confirmed by
# app-scan AND MSRC must show BOTH, otherwise whichever scanner
# wrote first owns the "via …" line forever (tester: an app-scan
# detection kept showing 'via MSRC' after the MSRC run).
if source:
have = [s for s in (row.source or "").split(",") if s]
if source not in have:
have.append(source)
row.source = ",".join(have)[:60]
row.last_seen_at = now
return
# Savepoint so a lost race (parallel scan inserting the same pair)
# rolls back only this insert, never the caller's transaction.
with db.begin_nested():
db.add(VulnerabilityPackage(
vulnerability_id=vuln.id,
package_name=name,
package_version=(version or None) and version[:100],
fixed_version=(fixed_version or None) and fixed_version[:100],
source=source,
first_detected_at=now,
last_seen_at=now,
))
except Exception as e:
# Never let per-package bookkeeping break a scan — it is display data.
logger.warning("per-package record skipped (vuln_id=%s, %s): %s",
vuln.id, name, e)
File diff suppressed because it is too large Load Diff
+291
View File
@@ -0,0 +1,291 @@
"""
Microsoft Defender for Endpoint (TVM) real per-device CVEs.
Phase 3 of the Intune integration. Reuses the Entra app from intune_config
(toggle `defender_tvm`) but talks to the Defender API. Maps each Defender
machine to an existing asset (by computerDnsName) and upserts REAL CVE
rows (source='defender'), which then enrich via the normal EPSS/KEV/
cvelistV5 + multi-source-remediation paths.
"""
import json
import logging
from datetime import datetime
from typing import Optional
from sqlalchemy.orm import Session
from app.models.asset import Asset
logger = logging.getLogger(__name__)
SOURCE_NAME = "defender"
_SEV_MAP = {
"critical": "critical", "high": "high", "medium": "medium",
"low": "low", "informational": "none", "none": "none",
}
def _severity(raw: Optional[str]):
from app.models.vulnerability import VulnerabilitySeverity
return {
"critical": VulnerabilitySeverity.critical,
"high": VulnerabilitySeverity.high,
"medium": VulnerabilitySeverity.medium,
"low": VulnerabilitySeverity.low,
"none": VulnerabilitySeverity.none,
}.get(_SEV_MAP.get((raw or "").lower(), "medium"), VulnerabilitySeverity.medium)
def _match_asset(db: Session, machine: dict):
"""Match a Defender machine to an asset by stable id
(defender_machine_id aad_device_id computerDnsName). Matching on the
Entra/AAD device id first merges the machine onto the SAME asset the Intune
sync created (usually the cleaner name), instead of forking a second asset
off Defender's management-name computerDnsName."""
mid = (machine.get("id") or "").strip() or None
aad_id = (machine.get("aadDeviceId") or "").strip() or None
dns = (machine.get("computerDnsName") or "").strip()
def _pin(a):
if mid and not a.defender_machine_id:
a.defender_machine_id = mid
if aad_id and not a.aad_device_id:
a.aad_device_id = aad_id
if mid:
a = db.query(Asset).filter(Asset.defender_machine_id == mid).first()
if a:
_pin(a)
return a
if aad_id:
a = db.query(Asset).filter(Asset.aad_device_id == aad_id).first()
if a:
_pin(a)
return a
short = dns.split(".")[0] if dns else ""
for cand in [c for c in (dns, short) if c]:
a = db.query(Asset).filter(Asset.hostname.ilike(cand)).first()
if a:
_pin(a)
return a
if short:
a = db.query(Asset).filter(Asset.hostname.ilike(f"{short}.%")).first()
if a:
_pin(a)
return a
return None
def _upsert_cve(db: Session, asset, vuln: dict, new_ids: list, software: Optional[str] = None,
vendor: Optional[str] = None) -> None:
from app.models.vulnerability import Vulnerability, VulnerabilityStatus
cve_id = (vuln.get("id") or "").strip().upper()
if not cve_id.startswith("CVE-"):
return
cvss = vuln.get("cvssV3")
try:
cvss = float(cvss) if cvss is not None else None
except (TypeError, ValueError):
cvss = None
sev = _severity(vuln.get("severity"))
existing = (
db.query(Vulnerability)
.filter(Vulnerability.cve_id == cve_id, Vulnerability.asset_id == asset.id)
.first()
)
# Defender reports the CVE, not the build it sits on: the installed version
# is never in the payload, and for an OS-level CVE the software label is
# missing entirely — those rows showed an empty package and an empty
# "Installed", which reads as "we know nothing" when the asset record has
# had the OS and its version all along (tester, CVE-2026-64726 on iPhones).
label = software or (asset.operating_system or None)
installed = asset.os_version if not software else None
if existing:
existing.add_source(SOURCE_NAME)
# Fill the affected-software/package column if it was empty.
if label and not existing.package_name:
existing.package_name = label[:255]
if installed and not existing.package_version:
existing.package_version = installed[:100]
if vendor and not existing.package_vendor:
existing.package_vendor = vendor[:255]
from app.services.audit_events import reopen_if_patched
reopen_if_patched(db, existing, reason="Defender TVM reports this CVE on the device again", source="defender")
try:
existing.refresh_scores()
except Exception:
pass
return
row = Vulnerability(
cve_id=cve_id,
asset_id=asset.id,
cvss_score=cvss,
severity=sev,
status=VulnerabilityStatus.open,
title=(vuln.get("name") or cve_id)[:500],
description=(vuln.get("description") or None),
package_name=(label[:255] if label else None),
package_version=(installed[:100] if installed else None),
package_vendor=(vendor[:255] if vendor else None),
detected_at=datetime.now(),
sources=json.dumps([SOURCE_NAME]),
first_detected_by=SOURCE_NAME,
)
db.add(row)
db.flush()
try:
row.refresh_scores()
except Exception:
pass
new_ids.append(row.id)
def _resolve_stale(db: Session, asset, seen_cves: set) -> int:
"""Mark defender-only OPEN findings on this asset patched when Defender no
longer reports them (device remediated). Leaves findings any other scanner
still reports. Writes a revisionssicher status-change row per resolve."""
from app.models.vulnerability import Vulnerability, VulnerabilityStatus
rows = (db.query(Vulnerability)
.filter(Vulnerability.asset_id == asset.id,
Vulnerability.status == VulnerabilityStatus.open,
Vulnerability.sources.contains('"defender"'))
.all())
resolved = 0
for v in rows:
if v.cve_id in seen_cves:
continue
# Drop OUR source; close only when nobody else still reports it (same
# contract as the Nessus backfill — the old skip-if-cross-confirmed
# rule deadlocked with the app-scan reconcile and left patched hosts
# with permanently open cross-confirmed findings).
v.remove_source(SOURCE_NAME)
if v.source_list:
continue
old_status = v.status
v.status = VulnerabilityStatus.patched
v.patched_at = datetime.now()
resolved += 1
try:
from app.routers.vulnerabilities import log_vulnerability_change
log_vulnerability_change(
db, None, v.id, old_status, v.status,
reason=f"Defender TVM no longer reports this CVE on {asset.hostname} (device remediated)",
cve_id=v.cve_id, source="defender_sync",
)
except Exception as e:
logger.warning("audit log for defender auto-resolve failed (vuln_id=%s): %s", v.id, e)
return resolved
def run_defender_sync(db: Session) -> dict:
"""Pull Defender TVM CVEs and upsert per matched asset. Returns stats."""
from app.services.intune_service import load_intune_config
from app.integrations.defender_client import DefenderClient
cfg = load_intune_config(db)
if not cfg or not cfg.get("defender_tvm"):
return {"skipped": "defender_tvm disabled"}
client = DefenderClient(cfg["tenant_id"], cfg["client_id"], cfg["client_secret"],
verify_ssl=cfg.get("verify_ssl", True))
stats = {"machines": 0, "matched": 0, "unmatched": 0, "cve_rows": 0, "new": 0, "errors": []}
new_ids: list = []
try:
machines = client.get_machines()
except Exception as e:
client.close()
raise RuntimeError(f"Defender machines fetch failed: {e}") from e
# (machineId, CVE) → affected software string. One tenant-wide export
# call; the per-machine /vulnerabilities endpoint omits software.
sw_map: dict = {}
try:
for r in client.get_software_vulnerabilities_by_machine():
mid = (r.get("deviceId") or r.get("machineId") or "").strip()
cve = (r.get("cveId") or "").strip().upper()
if not mid or not cve:
continue
vendor = (r.get("softwareVendor") or r.get("productVendor") or "").strip()
name = (r.get("softwareName") or r.get("productName") or "").strip()
ver = (r.get("softwareVersion") or r.get("productVersion") or "").strip()
label = " ".join(x for x in (vendor, name, ver) if x).strip()
if label and (mid, cve) not in sw_map:
sw_map[(mid, cve)] = {"label": label, "vendor": vendor or None}
except Exception as e:
logger.debug("defender software map build failed: %s", e)
# asset id → the union of CVEs every machine behind it reported.
seen_by_asset: dict = {}
for m in machines:
stats["machines"] += 1
asset = _match_asset(db, m)
if not asset:
stats["unmatched"] += 1
continue
stats["matched"] += 1
seen_cves: set = set()
try:
vulns = client.get_machine_vulnerabilities(m["id"])
for v in vulns:
cve = (v.get("id") or "").strip().upper()
if cve:
seen_cves.add(cve)
sw = sw_map.get((m.get("id", ""), cve)) or {}
_upsert_cve(db, asset, v, new_ids,
software=sw.get("label"), vendor=sw.get("vendor"))
stats["cve_rows"] += 1
# Collect, resolve later. Several Defender machines can map to ONE
# asset — a re-imaged or dual-registered device keeps its old
# machine entry — and resolving per machine made them fight: the
# machine that no longer lists the CVE closes the finding, the one
# that still lists it reopens it a minute later, every sync
# (tester: CVE-2026-66313, patched 13:41, open 13:42, patched
# 15:00). A finding may only be closed once EVERY machine behind
# the asset has been asked.
seen_by_asset.setdefault(asset.id, {"asset": asset, "cves": set(),
"any": False})
seen_by_asset[asset.id]["cves"] |= seen_cves
if seen_cves:
seen_by_asset[asset.id]["any"] = True
except Exception as e:
stats["errors"].append(f"machine {m.get('computerDnsName')}: {e}")
db.commit()
# Every machine has been asked, so each asset's CVE union is complete now.
# Guarded to non-empty responses so a transient or clean read can't
# mass-close (same safety as the Nessus and app-scan backfills).
for entry in seen_by_asset.values():
if entry["any"]:
stats["resolved"] = stats.get("resolved", 0) + _resolve_stale(
db, entry["asset"], entry["cves"])
db.commit()
client.close()
stats["new"] = len(new_ids)
# Initial detected-audit + metric/date enrichment for the new CVEs.
if new_ids:
try:
from app.services.audit_events import audit_new_vulnerabilities
audit_new_vulnerabilities(db, new_ids, source="defender")
db.commit()
except Exception as e:
logger.debug("defender detected-audit failed: %s", e)
try:
from app.models.vulnerability import Vulnerability
from app.services.enrichment_service import enrich_vulnerabilities
fresh = db.query(Vulnerability).filter(Vulnerability.id.in_(new_ids)).all()
if fresh:
enrich_vulnerabilities(db, fresh)
# New-CVE email notifications (was Wazuh/Nessus-only). Same path.
from app.services.email_service import dispatch_new_vuln_notifications
stats["notifications"] = dispatch_new_vuln_notifications(db, fresh)
except Exception as e:
logger.debug("defender enrichment/notify failed: %s", e)
logger.info("Defender TVM sync: %s", {k: v for k, v in stats.items() if k != "errors"})
return stats
+302 -27
View File
@@ -8,6 +8,7 @@ import os
import re
import smtplib
from datetime import datetime
from urllib.parse import quote
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from typing import Optional
@@ -94,7 +95,7 @@ DEFAULT_SLA_BREACH_TEMPLATE = """<!DOCTYPE html>
<a href="{{dashboard_url}}" class="btn">REMEDIATE NOW</a>
</div>
<div class="footer">
<strong>VulnCheck Security Operations Center</strong><br>
<strong>TrueVuln Security Operations Center</strong><br>
Automated compliance monitoring system. Do not reply to this email.<br>
<span style="font-size: 10px; opacity: 0.7;">Sent to: {{recipient_name}} ({{recipient_email}})</span>
</div>
@@ -147,6 +148,14 @@ DEFAULT_NEW_VULN_TEMPLATE = """<!DOCTYPE html>
<span class="detail-label">Package</span>
<span class="detail-value">{{package_name}}</span>
</div>
<div class="detail-item">
<span class="detail-label">CPR (priority)</span>
<span class="detail-value">{{cpr_score}}</span>
</div>
<div class="detail-item">
<span class="detail-label">Affected systems</span>
<span class="detail-value">{{affected_assets_count}}</span>
</div>
<div class="detail-item">
<span class="detail-label">Detected At</span>
<span class="detail-value">{{detected_at}}</span>
@@ -162,10 +171,11 @@ DEFAULT_NEW_VULN_TEMPLATE = """<!DOCTYPE html>
{{description}}
</div>
<a href="{{dashboard_url}}" class="action-btn">View Details & Remediate</a>
<a href="{{cve_on_asset_link}}" class="action-btn">View this CVE on {{asset_hostname}}</a>
<a href="{{asset_link}}" class="action-btn" style="background:#495057;margin-top:10px;">View all findings on this asset</a>
</div>
<div class="footer">
Generated by VulnCheck Dashboard {{detected_at}}
Generated by TrueVuln Dashboard {{detected_at}}
</div>
</div>
</body>
@@ -178,7 +188,7 @@ DEFAULT_NEW_VULN_SUBJECT = "ALERT: New {{severity_upper}} Vulnerability ({{cve_i
# Digest variant — one mail per recipient summarising N new CVEs
# instead of one mail per CVE. Selected by setting `notification_mode`.
# ---------------------------------------------------------------
DEFAULT_DIGEST_SUBJECT = "[VULNCHECK] {{total}} new vulnerabilities detected"
DEFAULT_DIGEST_SUBJECT = "[TRUEVULN] {{total}} new vulnerabilities detected"
DEFAULT_DIGEST_TEMPLATE = """<!DOCTYPE html>
<html><head><meta charset="utf-8"><style>
body{font-family:Arial,sans-serif;color:#1f2937;max-width:760px;margin:24px auto;padding:0 16px}
@@ -211,41 +221,76 @@ td{padding:7px 6px;border-bottom:1px solid #f3f4f6}
<div class="low">LOW<br>{{count_low}}</div>
</div>
<table>
<thead><tr><th>CVE</th><th>Severity</th><th>CVSS</th><th>Host</th><th>Package</th></tr></thead>
<thead><tr><th>CVE</th><th>Severity</th><th>CVSS</th><th>CPR</th><th>Systems</th><th>Package</th></tr></thead>
<tbody>{{rows}}</tbody>
</table>
<a class="btn" href="{{dashboard_url}}">Open in dashboard</a>
<div class="foot">
You receive this because the affected asset or vulnerability is assigned to you or one of your groups.
Manage assignments and suppression in the VulnCheck UI.
Manage assignments and suppression in the TrueVuln UI.
</div>
</div></body></html>"""
def render_digest_rows(items: list) -> str:
"""Render the <tr> rows for the digest table. Items: list of dicts with
cve_id, severity, cvss_score, asset_hostname, package_name. All dynamic
values HTML-escaped to prevent injection from compromised scanner data."""
rows = []
def aggregate_by_cve(items: list) -> list:
"""Collapse per-(CVE, asset) items to ONE entry per CVE. The representative
is the highest-CPR occurrence; `systems` is how many distinct assets in this
batch carry the CVE. Sorted by CPR descending (unscored last)."""
groups: dict[str, dict] = {}
for it in items:
cve = str(it.get("cve_id") or "")
g = groups.get(cve)
if g is None:
g = groups[cve] = {"rep": it, "assets": set()}
if it.get("asset_id") is not None:
g["assets"].add(it["asset_id"])
if (it.get("cpr_score") or -1) > (g["rep"].get("cpr_score") or -1):
g["rep"] = it
out = []
for cve, g in groups.items():
rep = dict(g["rep"])
rep["systems"] = len(g["assets"]) or 1
out.append(rep)
out.sort(key=lambda it: (it.get("cpr_score") is None, -(it.get("cpr_score") or 0.0)))
return out
def render_digest_rows(items: list, base_url: str = "") -> str:
"""Render the digest table — ONE row per CVE, not per (CVE, asset). A CVE on
200 hosts is one line: the CVE links to the filtered vulnerability view
(?cve_id=) that lists every affected asset, and #Systems says how many.
Columns: CVE | Sev | CVSS | CPR | #Systems | Package, CPR-descending. All
dynamic values HTML-escaped against compromised scanner data."""
rows = []
for it in aggregate_by_cve(items):
raw_sev = (it.get("severity") or "none").lower()
# whitelist severity for CSS class — anything else falls back to 'none'
sev = raw_sev if raw_sev in {"critical", "high", "medium", "low", "none"} else "none"
cvss = it.get("cvss_score")
cvss_str = html.escape(str(cvss)) if cvss is not None else "-"
cpr = it.get("cpr_score")
cpr_str = html.escape(f"{cpr:.1f}") if isinstance(cpr, (int, float)) else "-"
systems = it.get("systems") or 1
cve = str(it.get("cve_id", ""))
cve_esc = html.escape(cve)
if base_url and cve:
link = f"{base_url}?cve_id={quote(cve)}" # no asset_id → shows ALL affected assets
cve_cell = f"<a href='{html.escape(link)}'>{cve_esc}</a>"
else:
cve_cell = f"<strong>{cve_esc}</strong>"
rows.append(
f"<tr>"
f"<td><strong>{html.escape(str(it.get('cve_id', '')))}</strong></td>"
f"<td>{cve_cell}</td>"
f"<td><span class='sev sev-{sev}'>{sev.upper()}</span></td>"
f"<td>{cvss_str}</td>"
f"<td>{html.escape(str(it.get('asset_hostname', '')))}</td>"
f"<td>{cpr_str}</td>"
f"<td>{html.escape(str(systems))}</td>"
f"<td>{html.escape(str(it.get('package_name') or '')[:60])}</td>"
f"</tr>"
)
return "".join(rows)
DEFAULT_SLA_DIGEST_SUBJECT = "[VULNCHECK] {{total}} SLA-breached vulnerabilities require action"
DEFAULT_SLA_DIGEST_SUBJECT = "[TRUEVULN] {{total}} SLA-breached vulnerabilities require action"
DEFAULT_SLA_DIGEST_TEMPLATE = """<!DOCTYPE html>
<html><head><meta charset="utf-8"><style>
body{font-family:Arial,sans-serif;color:#1f2937;max-width:780px;margin:24px auto;padding:0 16px}
@@ -371,14 +416,25 @@ def send_new_vulnerability_digest(
if not items:
return False, "no items"
# Counts are per DISTINCT CVE, matching the one-row-per-CVE table — a CVE on
# 200 hosts counts once, not 200×. `total` = distinct CVEs; the top-level
# affected-systems tile stays distinct assets across the whole digest.
counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "none": 0}
_assets: set = set()
for it in items:
if it.get("asset_id") is not None:
_assets.add(it["asset_id"])
unique = aggregate_by_cve(items)
for it in unique:
sev = (it.get("severity") or "none").lower()
if sev in counts:
counts[sev] += 1
variables = {
"total": str(len(items)),
"total": str(len(unique)),
# Distinct assets across this digest (top-level "affected systems" tile).
# Per-CVE spread is the #Systems column inside {{rows}}.
"affected_assets_count": str(len(_assets)),
"count_critical": str(counts["critical"]),
"count_high": str(counts["high"]),
"count_medium": str(counts["medium"]),
@@ -387,7 +443,7 @@ def send_new_vulnerability_digest(
"recipient_name": recipient_name,
"recipient_email": to_email,
"dashboard_url": dashboard_url,
"rows": render_digest_rows(items),
"rows": render_digest_rows(items, base_url=dashboard_url),
}
subject_template, body_template = get_email_template(db, "email_template_new_vuln_digest")
@@ -545,11 +601,115 @@ def get_notification_mode(db: Session) -> str:
return "digest"
def _get_setting_str(db: Session, key: str) -> Optional[str]:
from app.models.setting import Setting
try:
s = db.query(Setting).filter(Setting.key == key).first()
return s.value.strip() if s and s.value else None
except Exception:
return None
def is_lifecycle_finding(cve_id: Optional[str]) -> bool:
"""True for pseudo-findings that aren't real CVEs — endoflife.date rows
(EOL-*), Android patch-level staleness (ANDROID-PATCH-*), Nessus plugin
rows (NESSUS-PLUGIN-*), etc. Deliberately 'anything not CVE-*' so a new
pseudo prefix is covered without touching this."""
return not (cve_id or "").upper().startswith("CVE-")
def get_notification_lifecycle_mode(db: Session) -> str:
"""`notification_lifecycle_mode`: 'exclude' (default) or 'include'.
exclude = lifecycle/EOL pseudo-findings never trigger the CVE mails, so
vulnerability reporting stays unmixed with lifecycle hygiene.
include = legacy behaviour, everything in one mail."""
val = (_get_setting_str(db, "notification_lifecycle_mode") or "").lower()
return val if val in ("exclude", "include") else "exclude"
def get_notification_schedule(db: Session) -> str:
"""`notification_schedule`: 'per_sync' (default) or 'nightly'.
per_sync = notify at the end of each sync run (immediate).
nightly = per-sync sends are suppressed; one roundup job at night sends
ALL of the day's new CVEs in a single aggregated mail per
recipient (fewer mails friendlier to provider anti-spam)."""
val = (_get_setting_str(db, "notification_schedule") or "").lower()
return val if val in ("per_sync", "nightly") else "per_sync"
def get_notification_nightly_hour(db: Session) -> int:
"""Hour (0-23, server local time) the nightly roundup fires. Default 6."""
try:
h = int(_get_setting_str(db, "notification_nightly_hour") or "6")
return h if 0 <= h <= 23 else 6
except (TypeError, ValueError):
return 6
def get_email_rate_limit(db: Session) -> tuple[float, int]:
"""(delay_seconds_between_mails, max_mails_per_run). 0 = unlimited/no delay.
Throttles the send loop so a big batch doesn't trip provider rate limits."""
try:
delay = float(_get_setting_str(db, "email_rate_delay_seconds") or "0")
except (TypeError, ValueError):
delay = 0.0
try:
cap = int(_get_setting_str(db, "email_max_per_run") or "0")
except (TypeError, ValueError):
cap = 0
return max(0.0, delay), max(0, cap)
def get_default_recipients(db: Session) -> list[tuple]:
"""Fallback recipients for findings with NO assignee anywhere in the
cascade. The `notification_default_recipients` setting (comma/semicolon/
space-separated emails) wins; if it's unset/empty, every active admin
user so 'the admin gets everything' works out of the box, which is
what operators expect after configuring SMTP + an admin email but never
assigning the thousands of scanner findings to anyone.
Returns (user_id_or_None, email, display_name).
"""
from app.models.setting import Setting
from app.models.user import User, UserRole
out: list[tuple] = []
seen: set[str] = set()
raw = None
try:
s = db.query(Setting).filter(Setting.key == "notification_default_recipients").first()
raw = s.value if s else None
except Exception:
raw = None
if raw and raw.strip():
for em in (e.strip() for e in re.split(r"[,;\s]+", raw) if e.strip()):
if em in seen:
continue
u = db.query(User).filter(User.email == em).first()
out.append(((u.id if u else None), em, (u.username if u else em)))
seen.add(em)
return out
# No configured default → all active admins.
try:
admins = db.query(User).filter(
User.role == UserRole.ADMIN, User.is_active.is_(True)).all()
except Exception:
admins = []
for u in admins:
if u.email and u.email not in seen:
out.append((u.id, u.email, u.username))
seen.add(u.email)
return out
def _resolve_recipients_for_vuln(db: Session, vuln) -> list[tuple[int, str, str]]:
"""
Returns list of (user_id, email, username) for a vulnerability following
the cascade: vuln.assigned_user > vuln.assigned_group > asset.assigned_user
> asset.assigned_group. Empty list when nothing matches.
> asset.assigned_group. When the cascade is empty, falls back to the
configured default recipients / active admins (get_default_recipients).
"""
from app.models.group import Group
from app.models.user import User
@@ -575,16 +735,44 @@ def _resolve_recipients_for_vuln(db: Session, vuln) -> list[tuple[int, str, str]
for g in vuln.asset.groups:
for u in g.users:
_push(u)
if not recipients:
for uid, email, uname in get_default_recipients(db):
if email and email not in seen_emails:
recipients.append((uid, email, uname))
seen_emails.add(email)
return recipients
def dispatch_new_vuln_notifications(db: Session, new_vulns: list) -> dict:
def _affected_counts(db: Session, cve_ids: set) -> dict:
"""{cve_id: number of distinct assets with this CVE in an active state}.
One grouped query for the whole batch."""
if not cve_ids:
return {}
from sqlalchemy import func
from app.models.vulnerability import Vulnerability, VulnerabilityStatus
active = (VulnerabilityStatus.open, VulnerabilityStatus.pending_verification,
VulnerabilityStatus.patch_failed)
rows = (
db.query(Vulnerability.cve_id, func.count(func.distinct(Vulnerability.asset_id)))
.filter(Vulnerability.cve_id.in_(list(cve_ids)), Vulnerability.status.in_(active))
.group_by(Vulnerability.cve_id)
.all()
)
return {cve: cnt for cve, cnt in rows}
def dispatch_new_vuln_notifications(db: Session, new_vulns: list, respect_schedule: bool = True) -> dict:
"""
Entry point for the Wazuh sync. Groups new vulns by recipient,
applies the severity threshold, and dispatches either:
Shared entry point for every sync (Wazuh/Nessus/app-scan/Defender). Groups
new vulns by recipient, applies the severity threshold, and dispatches either:
- one digest email per recipient (mode='digest', default), or
- one email per CVE per recipient (mode='single', legacy).
respect_schedule: when True (sync callers) and notification_schedule is
'nightly', sending is skipped here the nightly roundup job sends instead.
The nightly job calls with respect_schedule=False to actually send.
Returns a stats dict for logging.
"""
from app.models.notification_log import NotificationLog, NotificationType, NotificationStatus
@@ -593,15 +781,35 @@ def dispatch_new_vuln_notifications(db: Session, new_vulns: list) -> dict:
if not new_vulns:
return stats
# Keep CVE reporting unmixed with lifecycle/EOL hygiene findings unless the
# operator opts back in. Applies to both delivery modes and both schedules.
if get_notification_lifecycle_mode(db) == "exclude":
kept = [v for v in new_vulns if not is_lifecycle_finding(v.cve_id)]
skipped = len(new_vulns) - len(kept)
if skipped:
stats["lifecycle_skipped"] = skipped
new_vulns = kept
if not new_vulns:
return stats
if respect_schedule and get_notification_schedule(db) == "nightly":
stats["deferred_to_nightly"] = len(new_vulns)
return stats
smtp = get_smtp_config(db)
if not smtp:
logger.info("SMTP not configured — skipping new-vuln notifications")
return stats
rate_delay, rate_cap = get_email_rate_limit(db)
mode = get_notification_mode(db)
dashboard_url = os.getenv("DASHBOARD_URL", "http://localhost:3000").rstrip("/") + "/vulnerabilities"
detected_at_str = datetime.now().strftime("%Y-%m-%d %H:%M UTC")
# How many distinct assets each CVE affects (whole inventory, not just this
# batch) — lets the template convey blast radius / spread.
affected = _affected_counts(db, {v.cve_id for v in new_vulns if v.cve_id})
# Bucket vulns per recipient email (only those above the severity threshold).
buckets: dict[str, dict] = {} # email -> {user_id, username, items: [vuln_summary]}
notif_log_anchors: dict[str, list] = {} # email -> list of (vuln_id, asset_id) for logging
@@ -615,6 +823,8 @@ def dispatch_new_vuln_notifications(db: Session, new_vulns: list) -> dict:
"cve_id": vuln.cve_id,
"severity": vuln.severity.value if vuln.severity else "none",
"cvss_score": vuln.cvss_score,
"cpr_score": vuln.cpr_score,
"affected_count": affected.get(vuln.cve_id, 1),
"asset_hostname": vuln.asset.hostname if vuln.asset else "Unknown",
"package_name": vuln.package_name,
"vuln_id": vuln.id,
@@ -624,7 +834,16 @@ def dispatch_new_vuln_notifications(db: Session, new_vulns: list) -> dict:
stats["recipients"] = len(buckets)
import time as _time
_sent_this_run = 0
for email, bucket in buckets.items():
# Rate-limit: cap per run + pace between mails (provider anti-spam).
if rate_cap and _sent_this_run >= rate_cap:
stats["rate_capped"] = stats.get("rate_capped", 0) + 1
continue
if rate_delay and _sent_this_run > 0:
_time.sleep(rate_delay)
_sent_this_run += 1
items = bucket["items"]
username = bucket["username"]
user_id = bucket["user_id"]
@@ -640,37 +859,47 @@ def dispatch_new_vuln_notifications(db: Session, new_vulns: list) -> dict:
)
anchor_vuln_id = items[0]["vuln_id"]
anchor_asset_id = items[0]["asset_id"]
_distinct = len({it.get("cve_id") for it in items})
db.add(NotificationLog(
vulnerability_id=anchor_vuln_id, # anchor — full list is in body
asset_id=anchor_asset_id,
user_id=user_id,
notification_type=NotificationType.NEW_VULNERABILITY,
sent_at=datetime.now(),
subject=f"[VULNCHECK] {len(items)} new vulnerabilities detected",
subject=f"[TRUEVULN] {_distinct} new vulnerabilities detected",
recipient_email=email,
status=NotificationStatus.SENT if success else NotificationStatus.FAILED,
message_body=f"Digest of {len(items)} new vulnerabilities",
message_body=f"Digest of {_distinct} distinct CVEs across {len(items)} findings",
error_message=None if success else err,
))
if success:
stats["emails_sent"] += 1
logger.info(f"Digest email sent to {email}: {len(items)} CVEs")
logger.info(f"Digest email sent to {email}: {_distinct} distinct CVEs ({len(items)} findings)")
else:
stats["emails_failed"] += 1
logger.warning(f"Digest email FAILED to {email}: {err}")
else:
# Legacy single mode — one mail per CVE per recipient
for item in items:
_cve = item["cve_id"]
_aid = item.get("asset_id")
_cpr = item.get("cpr_score")
variables = {
"cve_id": item["cve_id"],
"cve_id": _cve,
"severity": item["severity"],
"severity_upper": item["severity"].upper(),
"cvss_score": str(item.get("cvss_score") or "N/A"),
"cpr_score": (f"{_cpr:.1f}" if isinstance(_cpr, (int, float)) else "N/A"),
"affected_assets_count": str(item.get("affected_count") or 1),
"asset_hostname": item["asset_hostname"],
"package_name": item.get("package_name") or "",
"title": item["cve_id"],
"title": _cve,
"detected_at": detected_at_str,
"dashboard_url": dashboard_url,
# Ready-made deep links (no manual ?cve_id= assembly needed):
"cve_link": f"{dashboard_url}?cve_id={quote(_cve)}",
"asset_link": (f"{dashboard_url}?asset_id={_aid}" if _aid else dashboard_url),
"cve_on_asset_link": (f"{dashboard_url}?asset_id={_aid}&cve_id={quote(_cve)}" if _aid else f"{dashboard_url}?cve_id={quote(_cve)}"),
"recipient_name": username,
"recipient_email": email,
}
@@ -681,7 +910,7 @@ def dispatch_new_vuln_notifications(db: Session, new_vulns: list) -> dict:
user_id=user_id,
notification_type=NotificationType.NEW_VULNERABILITY,
sent_at=datetime.now(),
subject=f"[VULNCHECK] New {item['severity'].upper()} Vulnerability: {item['cve_id']}",
subject=f"[TRUEVULN] New {item['severity'].upper()} Vulnerability: {item['cve_id']}",
recipient_email=email,
status=NotificationStatus.SENT if success else NotificationStatus.FAILED,
message_body=f"New {item['severity']} vulnerability {item['cve_id']} on {item['asset_hostname']}",
@@ -696,6 +925,52 @@ def dispatch_new_vuln_notifications(db: Session, new_vulns: list) -> dict:
return stats
def _set_setting_str(db: Session, key: str, value: str) -> None:
from app.models.setting import Setting
s = db.query(Setting).filter(Setting.key == key).first()
if s:
s.value = value
else:
db.add(Setting(key=key, value=value))
db.commit()
def send_nightly_new_vuln_digest(db: Session) -> dict:
"""Nightly roundup: one aggregated mail per recipient with ALL new CVEs
since the last run. Only active when notification_schedule == 'nightly'
(per-sync sends are suppressed in that mode). Window is tracked in the
`notification_nightly_last_run` setting so nothing is sent twice and
nothing is missed between runs."""
from datetime import timedelta
from app.models.vulnerability import Vulnerability, VulnerabilityStatus
if get_notification_schedule(db) != "nightly":
return {"skipped": "notification_schedule != nightly"}
last_raw = _get_setting_str(db, "notification_nightly_last_run")
since = None
if last_raw:
try:
since = datetime.fromisoformat(last_raw)
except ValueError:
since = None
if since is None:
since = datetime.now() - timedelta(hours=24)
active = (VulnerabilityStatus.open, VulnerabilityStatus.pending_verification,
VulnerabilityStatus.patch_failed)
vulns = (
db.query(Vulnerability)
.filter(Vulnerability.status.in_(active),
Vulnerability.detected_at >= since)
.all()
)
stats = dispatch_new_vuln_notifications(db, vulns, respect_schedule=False)
_set_setting_str(db, "notification_nightly_last_run", datetime.now().isoformat())
logger.info("Nightly new-vuln digest: %s new since %s%s", len(vulns), since, stats)
return stats
def should_notify_for_severity(db: Session, severity) -> bool:
"""
Return True if the configured notification threshold lets this severity
+253 -1
View File
@@ -10,7 +10,9 @@ Alle Quellen sind kostenlos und benötigen keinen API-Key.
"""
import json
import logging
import os
import re
import time
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Iterable
@@ -44,10 +46,43 @@ EUVD_CACHE_KEY = "enrichment_euvd_cache"
EUVD_CACHE_TS_KEY = "enrichment_euvd_cache_updated_at"
EUVD_TTL_HOURS = 24
# ---------- NVD CVE dates (published / lastModified) ----------
# NVD is the only authoritative source for a CVE's official publish date.
# Nessus/Wazuh imports never carried it, so published_date was all-NULL
# and the "Newly Published" sort was meaningless. We backfill it here.
NVD_CVE_API_URL = "https://services.nvd.nist.gov/rest/json/cves/2.0"
# Official CVE.org cvelistV5 raw JSON — primary, non-rate-limited date
# source (cveMetadata.datePublished / .dateUpdated for every CVE).
CVELISTV5_RAW_BASE = "https://raw.githubusercontent.com/CVEProject/cvelistV5/main/cves"
# Per-run cap on date lookups. cvelistV5 has no aggressive rate limit, so
# this can be generous; a fresh DB still drains over a couple nightly runs.
CVE_DATE_MAX_LOOKUPS_PER_RUN = 4000
# Above this many missing CVEs, one cvelistV5 ZIP snapshot (reusing the
# CVSS cascade's shared 12h disk cache) beats thousands of per-CVE HTTP
# round-trips. Below it, per-CVE raw fetches avoid a 557 MB download for a
# handful of new CVEs.
CVE_DATE_ZIP_THRESHOLD = 200
# Persistent cache — a CVE's published date is immutable, lastModified
# changes rarely. No TTL: once cached we never refetch (keeps us well
# under NVD's rate limit).
NVD_DATE_CACHE_KEY = "enrichment_nvd_date_cache"
# Per-run lookup cap so a fresh DB with thousands of CVEs doesn't hammer
# NVD in one go — the nightly job catches up incrementally over days.
# Key-aware: without a key each request must sleep 6.5s, so a big cap
# would stall the whole enrichment job for the better part of an hour.
# With a key the floor is 0.7s, so we can drain a much larger batch.
NVD_MAX_LOOKUPS_NO_KEY = 150 # ~16 min/run
NVD_MAX_LOOKUPS_WITH_KEY = 1500 # ~18 min/run
# NVD rate limit: 5 req / 30s without key, 50 req / 30s with key.
# Sleep just over the floor to stay safe.
NVD_SLEEP_NO_KEY = 6.5
NVD_SLEEP_WITH_KEY = 0.7
# ---------- Toggles ----------
SETTING_EPSS_ENABLED = "enrichment_epss_enabled"
SETTING_KEV_ENABLED = "enrichment_kev_enabled"
SETTING_EUVD_ENABLED = "enrichment_euvd_enabled"
SETTING_NVD_DATES_ENABLED = "enrichment_nvd_dates_enabled"
# ---------- HTTP ----------
HTTP_TIMEOUT = 30.0
@@ -194,6 +229,10 @@ def fetch_kev_catalog(db: Session, force_refresh: bool = False) -> Dict[str, dic
"date_added": entry.get("dateAdded"),
"ransomware_use": (entry.get("knownRansomwareCampaignUse") or "").lower() == "known",
"short_description": entry.get("shortDescription"),
# Extra fields for the advisory/awareness feed (enrichment ignores them).
"vendor": entry.get("vendorProject"),
"product": entry.get("product"),
"name": entry.get("vulnerabilityName"),
}
_store_kev_cache(db, kev_map)
@@ -454,12 +493,177 @@ def fetch_euvd_catalogs(db: Session, force_refresh: bool = False) -> Dict[str, d
# Apply to DB
# ============================================================
# ============================================================
# NVD CVE dates (published / lastModified)
# ============================================================
def _parse_nvd_dt(raw) -> Optional[str]:
"""NVD timestamps look like '2024-01-31T17:15:34.123'. Keep ISO str."""
if not raw or not isinstance(raw, str):
return None
return raw.strip() or None
def _load_nvd_date_cache(db: Session) -> Dict[str, dict]:
s = db.query(Setting).filter(Setting.key == NVD_DATE_CACHE_KEY).first()
if not s or not s.value:
return {}
try:
return json.loads(s.value)
except json.JSONDecodeError:
return {}
def _cvelistv5_raw_url(cve_id: str) -> Optional[str]:
"""Per-CVE raw URL in the official CVE.org cvelistV5 GitHub repo.
.../cves/2026/9xxx/CVE-2026-9988.json
"""
m = re.fullmatch(r"CVE-(\d{4})-(\d+)", cve_id.upper())
if not m:
return None
year, num = m.group(1), m.group(2)
bucket = f"{int(num) // 1000}xxx"
return f"{CVELISTV5_RAW_BASE}/{year}/{bucket}/{cve_id.upper()}.json"
def _dates_from_cvelistv5(payload: dict) -> dict:
"""Extract published/lastModified from a cvelistV5 record.
cveMetadata.datePublished / .dateUpdated are the authoritative MITRE
timestamps and exist for every published CVE.
"""
meta = payload.get("cveMetadata") or {}
return {
"published": _parse_nvd_dt(meta.get("datePublished")),
"last_modified": _parse_nvd_dt(meta.get("dateUpdated")),
}
def _dates_from_nvd(client: "httpx.Client", cve: str) -> Optional[dict]:
"""NVD fallback for a single CVE (rate-limited; only when cvelistV5 misses)."""
api_key = os.getenv("NVD_API_KEY", "").strip()
headers = {"apiKey": api_key} if api_key else None
resp = client.get(NVD_CVE_API_URL, params={"cveId": cve}, headers=headers)
if resp.status_code == 404:
return {}
resp.raise_for_status()
items = resp.json().get("vulnerabilities") or []
if not items:
return {}
obj = items[0].get("cve") or {}
return {
"published": _parse_nvd_dt(obj.get("published")),
"last_modified": _parse_nvd_dt(obj.get("lastModified")),
}
def fetch_nvd_cve_dates(
db: Session,
cve_ids: Iterable[str],
max_lookups: Optional[int] = None,
) -> Dict[str, dict]:
"""Resolve {cve_id: {"published": iso, "last_modified": iso}}.
Primary source is the official CVE.org **cvelistV5** raw JSON on GitHub
(cveMetadata.datePublished / .dateUpdated) it has dates for every
published CVE and, unlike the NVD API, is not aggressively rate-limited,
so we can fill thousands of CVEs quickly without the per-request sleep
that used to make this crawl take hours. NVD is kept only as a per-CVE
fallback when cvelistV5 has no record (and honours NVD_API_KEY).
Persistent settings cache (dates are effectively immutable) so each CVE
is fetched once ever; only cache-missing CVEs are looked up, capped at
`max_lookups` per run so a fresh DB drains over a few nightly runs.
NOTE: must run OFF the asyncio loop (scheduler jobs are sync executed
in a worker thread). Never call this inline on a request handler.
"""
cache = _load_nvd_date_cache(db)
cve_list = [c for c in cve_ids if c and CVE_REGEX.fullmatch(c)]
missing = [c for c in cve_list if c not in cache]
if not missing:
return cache
if max_lookups is None:
max_lookups = CVE_DATE_MAX_LOOKUPS_PER_RUN
# Bulk path: many missing at once → one cvelistV5 ZIP snapshot over the
# WHOLE missing set (a local zip walk is cheap, so it is NOT subject to
# the per-run cap — this dates a fresh DB completely in a single run so
# the "Newly Published" widget isn't stuck showing a partial subset).
# Reuses the CVSS cascade's shared 12h disk cache (download-free when
# CVSS-correction already pulled it).
if len(missing) > CVE_DATE_ZIP_THRESHOLD:
try:
from app.services.vuln_override_service import VulnOverrideService
zip_dates = VulnOverrideService(db).load_cve_dates_via_zip(missing)
for cve, d in zip_dates.items():
cache[cve] = {
"published": _parse_nvd_dt(d.get("published")),
"last_modified": _parse_nvd_dt(d.get("last_modified")),
}
missing = [c for c in missing if c not in cache]
logger.info(
"CVE dates: ZIP filled %d, %d remain for per-CVE fallback",
len(zip_dates), len(missing),
)
except Exception as e:
logger.warning("cvelistV5 ZIP date pass failed (%s) — per-CVE fallback", e)
# Per-CVE path (raw cvelistV5 → NVD) only for whatever the ZIP missed,
# capped so a huge unresolved remainder doesn't run forever.
to_fetch = missing[:max_lookups]
from_cvelist = 0
from_nvd = 0
not_found = 0
with httpx.Client(timeout=HTTP_TIMEOUT, follow_redirects=True,
headers={"User-Agent": "TrueVuln/1.0"}) as client:
for cve in to_fetch:
url = _cvelistv5_raw_url(cve)
got = None
if url:
try:
r = client.get(url)
if r.status_code == 200:
got = _dates_from_cvelistv5(r.json())
from_cvelist += 1
elif r.status_code != 404:
r.raise_for_status()
except (httpx.HTTPError, ValueError) as e:
logger.debug("cvelistV5 date fetch failed for %s: %s", cve, e)
# Fallback to NVD only when cvelistV5 had no record.
if got is None:
try:
got = _dates_from_nvd(client, cve)
if got:
from_nvd += 1
# NVD courtesy delay (no key = 5 req/30s).
time.sleep(NVD_SLEEP_WITH_KEY if os.getenv("NVD_API_KEY", "").strip() else NVD_SLEEP_NO_KEY)
except httpx.HTTPError as e:
logger.debug("NVD date fallback failed for %s: %s", cve, e)
continue # don't cache failure — retry next run
if got is None:
continue
if not got.get("published") and not got.get("last_modified"):
not_found += 1
cache[cve] = got
_set_setting(db, NVD_DATE_CACHE_KEY, json.dumps(cache),
"CVE published/lastModified cache (cvelistV5 + NVD, persistent)")
logger.info(
"CVE dates: %d from cvelistV5, %d from NVD, %d empty, %d cached total, %d still missing",
from_cvelist, from_nvd, not_found, len(cache), max(0, len(missing) - len(to_fetch)),
)
return cache
def enrich_vulnerabilities(
db: Session,
vulns: List[Vulnerability],
use_epss: Optional[bool] = None,
use_kev: Optional[bool] = None,
use_euvd: Optional[bool] = None,
use_nvd_dates: Optional[bool] = None,
) -> dict:
"""
Reichert eine Liste von Vulnerabilities in-place an und commited.
@@ -472,6 +676,7 @@ def enrich_vulnerabilities(
"epss_updated": 0,
"kev_marked": 0, "kev_cleared": 0,
"euvd_marked": 0, "euvd_cleared": 0,
"nvd_dates_set": 0,
"total": 0,
}
@@ -481,11 +686,14 @@ def enrich_vulnerabilities(
use_kev = _setting_bool(db, SETTING_KEV_ENABLED, default=True)
if use_euvd is None:
use_euvd = _setting_bool(db, SETTING_EUVD_ENABLED, default=True)
if use_nvd_dates is None:
use_nvd_dates = _setting_bool(db, SETTING_NVD_DATES_ENABLED, default=True)
stats = {
"epss_updated": 0,
"kev_marked": 0, "kev_cleared": 0,
"euvd_marked": 0, "euvd_cleared": 0,
"nvd_dates_set": 0,
"total": len(vulns),
}
@@ -517,6 +725,21 @@ def enrich_vulnerabilities(
except Exception as e:
logger.error(f"EUVD enrichment failed unexpectedly: {e}")
# NVD published/lastModified backfill. Only look up CVEs that still
# lack a published_date — that's the entire point (sort was broken
# because the column was NULL). Saves NVD calls on already-dated rows.
nvd_dates: Dict[str, dict] = {}
if use_nvd_dates:
need_dates = sorted({
v.cve_id for v in vulns
if v.cve_id and v.published_date is None and CVE_REGEX.fullmatch(v.cve_id)
})
if need_dates:
try:
nvd_dates = fetch_nvd_cve_dates(db, need_dates)
except Exception as e:
logger.error(f"NVD date backfill failed unexpectedly: {e}")
now = datetime.now()
for vuln in vulns:
@@ -580,6 +803,23 @@ def enrich_vulnerabilities(
vuln.euvd_id = None
stats["euvd_cleared"] += 1
if use_nvd_dates and vuln.cve_id in nvd_dates:
entry = nvd_dates[vuln.cve_id]
pub = entry.get("published")
mod = entry.get("last_modified")
if pub and vuln.published_date is None:
try:
vuln.published_date = datetime.fromisoformat(pub.replace("Z", "+00:00"))
stats["nvd_dates_set"] += 1
sources_used.append("nvd")
except (ValueError, AttributeError):
pass
if mod and vuln.last_modified_date is None:
try:
vuln.last_modified_date = datetime.fromisoformat(mod.replace("Z", "+00:00"))
except (ValueError, AttributeError):
pass
if sources_used:
# Merge with existing sources
existing = []
@@ -596,11 +836,23 @@ def enrich_vulnerabilities(
vuln.refresh_scores()
db.commit()
# Mozilla MFSA severity for fresh Firefox CVEs — Mozilla's authoritative
# `impact` fills the placeholder severity when NVD/cvelistV5 have no score
# yet (the gap the tester hit on brand-new Firefox CVEs). Best-effort.
try:
from app.services import mozilla_advisory_service
stats["mozilla_severity"] = mozilla_advisory_service.apply_mozilla_severity(db, cve_ids)
except Exception as e:
logger.debug("Mozilla MFSA enrichment skipped: %s", e)
logger.info(
f"Enrichment done: {stats['total']} vulns, "
f"epss_updated={stats['epss_updated']}, "
f"kev_marked={stats['kev_marked']}, kev_cleared={stats['kev_cleared']}, "
f"euvd_marked={stats['euvd_marked']}, euvd_cleared={stats['euvd_cleared']}"
f"euvd_marked={stats['euvd_marked']}, euvd_cleared={stats['euvd_cleared']}, "
f"nvd_dates_set={stats['nvd_dates_set']}, "
f"mozilla_severity={stats.get('mozilla_severity', 0)}"
)
return stats
+264 -10
View File
@@ -73,12 +73,28 @@ _PRODUCT_SLUGS: dict[str, str] = {
"exchangeserver": "exchange-server",
"microsoftoffice": "ms-office",
"msoffice": "ms-office",
# SharePoint — endoflife.date tracks it as `sharepoint` (2013 EOL
# 2023-04-11, 2016/2019 EOL 2026-07-14, Subscription Edition still
# supported). Neither the MS-lifecycle export nor the plain slug lookup
# caught it, so Foundation/Server installs never got an EOL finding.
# Covers the Server, Enterprise Server and Foundation flavours generically.
"sharepoint": "sharepoint",
"microsoftsharepoint": "sharepoint",
"microsoftsharepointserver": "sharepoint",
"microsoftsharepointfoundation": "sharepoint",
"microsoftsharepointenterpriseserver": "sharepoint",
"microsoftsharepointdesigner": "sharepoint",
"microsoftofficeproofing": "ms-office",
"microsoftofficeosxmui": "ms-office",
"microsoftofficeosxmuigerman": "ms-office",
"microsoftofficeformac": "ms-office",
"office": "ms-office",
"microsoftedge": "microsoft-edge",
# Microsoft Edge is deliberately NOT mapped. endoflife.date carries no
# record for it at all — the "microsoft-edge" slug 404s, so every EOL check
# spent a request finding that out. Edge follows the Modern Lifecycle
# Policy: it has no end-of-life date as long as it stays current, so "is
# this version too old" is a patch question, which the CVE scan already
# answers. Nothing is lost by leaving it out.
"powershell": "powershell",
"dotnet": "dotnet",
"dotnetframework": "dotnetfx",
@@ -108,11 +124,30 @@ _PRODUCT_SLUGS: dict[str, str] = {
"postgres": "postgresql",
"mongodb": "mongodb",
"redis": "redis",
# Microsoft Visual C++ Redistributable (all flavours — 2005/2008/2010/2012/2013/2015-2022).
# endoflife.date exposes the product as `visual-cpp`; map any sane
# spelling here. Versions are matched by endoflife.date.
"visualc": "visual-cpp",
"visualcppredistributable": "visual-cpp",
"microsoftvisualc": "visual-cpp",
"microsoftvisualcp": "visual-cpp",
"microsoftvisualcppr": "visual-cpp",
"microsoftvisualcpprdistributable": "visual-cpp",
"microsoftvisualcplusplus": "visual-cpp",
"vcredist": "visual-cpp",
"vcruntime": "visual-cpp",
"msvcr": "visual-cpp",
"msvcp": "visual-cpp",
# Adobe
"adobeacrobat": "adobe-acrobat",
"adobeacrobatreader": "adobe-acrobat",
"adobeacrobatreaderdc": "adobe-acrobat",
"adobeacrobatdc": "adobe-acrobat",
# Nessus plugin 56213 reports "Adobe Reader" (no "Acrobat"), so the
# acrobat-prefixed keys above never substring-matched → fell back to
# EOL-NESSUS-56213. These aliases fix the slug resolution.
"adobereader": "adobe-acrobat",
"acrobatreader": "adobe-acrobat",
# Linux distros
"ubuntu": "ubuntu",
"debian": "debian",
@@ -139,6 +174,10 @@ _WRAPPER_TOKENS = (
"veeam", "explorerfor", "backup", "connector", "odbc", "jdbc",
"driver", "clientfor", "agentfor", "pluginfor", "extensionfor",
"providerfor", "managementpack", "monitoringfor",
# Sub-components of a tracked product that have their own (different)
# lifecycle — matching the parent would give a false EOL signal.
"nativeclient", "setupsupportfiles", "setupsql", "setup",
"premium", "clicktorun", "subscription",
)
@@ -314,7 +353,12 @@ def _pick_release(releases: List[dict], installed: str) -> Optional[dict]:
# the year lives in the PRODUCT NAME ("Microsoft Office ... 2016"), so we
# match on the year token, not the numeric version prefix. Mirrors the
# OS path (resolve_os_to_eol), which also keys on name not version.
_YEAR_KEYED_SLUGS = {"office", "ms-office"}
# Release is a YEAR ("2016") while the installed version is a build number
# ("16.0.5556.1005") — prefix-matching the version against the cycle can never
# hit, so the year comes from the product name instead. SharePoint is the same
# shape as Office, and worse: 2016, 2019 AND Subscription Edition all report
# 16.0.x, so the version alone can't even tell the releases apart.
_YEAR_KEYED_SLUGS = {"office", "ms-office", "sharepoint"}
def _extract_year(text: Optional[str]) -> Optional[str]:
@@ -562,6 +606,39 @@ def _pseudo_cve_id(slug: str, release_name: str) -> str:
return f"EOL-{slug.upper()}-{safe_rel}"[:50]
def _supersede_old_eol(db: "Session", asset_id: int, slug: Optional[str], keep_cve_id: str) -> None:
"""A product runs exactly ONE release per asset. When we upsert the EOL
finding for the current release, any OTHER open EOL finding for the same
product/asset is stale (the device moved to a new major, e.g. Chrome
149150) resolve it so the old release doesn't linger as a duplicate."""
from app.models.vulnerability import Vulnerability, VulnerabilityStatus
if not slug or slug == "unknown":
return
prefix = f"EOL-{slug.upper()}-"
stale = (
db.query(Vulnerability)
.filter(Vulnerability.asset_id == asset_id,
Vulnerability.status == VulnerabilityStatus.open,
Vulnerability.first_detected_by == "eol_check",
Vulnerability.cve_id.like(f"{prefix}%"),
Vulnerability.cve_id != keep_cve_id)
.all()
)
for v in stale:
old_status = v.status
v.status = VulnerabilityStatus.patched
v.patched_at = datetime.now()
try:
from app.routers.vulnerabilities import log_vulnerability_change
log_vulnerability_change(
db, None, v.id, old_status, v.status,
reason=f"Superseded — asset moved to a newer {slug} release ({keep_cve_id})",
cve_id=v.cve_id, source="eol_supersede",
)
except Exception as e:
logger.warning("audit log for EOL supersede failed (vuln_id=%s): %s", v.id, e)
def upsert_eol_vulnerability(
db: Session,
*,
@@ -578,15 +655,31 @@ def upsert_eol_vulnerability(
cve_id = _pseudo_cve_id(status.product_slug or "unknown", status.release_name or "unknown")
# Three-tier severity:
# Severity tiers:
# EOL 1000+ days → CRITICAL, cvss 9.8, title "EOL 1000d+"
# EOL (security ended) → HIGH, cvss 9.0, title "EOL"
# EOL SOON (≤90d) → MEDIUM, cvss 5.5, title "EOL SOON"
# EOAS only (still patched)→ LOW, cvss 3.0, title "end-of-active-support"
# The days-past-EOL escalation mirrors the endoflife.date/Wazuh EOL model:
# the longer a product has been unpatched, the higher the standing risk.
if status.is_eol:
severity = VulnerabilitySeverity.high
cvss = 9.0
state_label = "EOL"
state_desc = "EOL (no further security patches)."
# days_to_eol is signed (negative = past); guard the bool-true case
# (endoflife eolFrom=true, no date → days unknown).
days_past = (-status.days_to_eol
if status.days_to_eol is not None and status.days_to_eol < 0
else None)
if days_past is not None and days_past >= 1000:
severity = VulnerabilitySeverity.critical
cvss = 9.8
state_label = f"EOL {days_past}d"
state_desc = f"EOL for {days_past} days (no security patches — critical exposure)."
else:
severity = VulnerabilitySeverity.high
cvss = 9.0
state_label = f"EOL {days_past}d" if days_past is not None else "EOL"
state_desc = (f"EOL for {days_past} days (no further security patches)."
if days_past is not None
else "EOL (no further security patches).")
elif status.is_eol_soon:
severity = VulnerabilitySeverity.medium
cvss = 5.5
@@ -618,6 +711,13 @@ def upsert_eol_vulnerability(
if status.latest_version:
desc_lines.append(f"Latest supported release: {status.latest_version} ({status.latest_date or 'date unknown'}).")
desc_lines.append(f"Installed on this host: {installed_version}.")
if status.is_eol:
# Running EOL software is an explicit control failure in the major
# frameworks — surface the mapping so audits/reports can cite it.
desc_lines.append(
"Compliance: running end-of-life software violates PCI-DSS 6.3.3, "
"NIST 800-53 CM-8, and HIPAA 164.312(a)(1)."
)
description = "\n".join(desc_lines)
if existing:
@@ -626,9 +726,8 @@ def upsert_eol_vulnerability(
existing.description = description
existing.package_version = installed_version[:100]
existing.fixed_version = (status.latest_version or None)
if existing.status == VulnerabilityStatus.patched:
existing.status = VulnerabilityStatus.open
existing.patched_at = None
from app.services.audit_events import reopen_if_patched
reopen_if_patched(db, existing, reason="endoflife.date check reports this product as EOL again", source="eol_check")
# Resync bumps detected_at so the Newly EOL/EOS widget ranks the
# freshest finding first.
existing.detected_at = datetime.now()
@@ -636,6 +735,7 @@ def upsert_eol_vulnerability(
existing.refresh_scores()
except Exception:
pass
_supersede_old_eol(db, asset_id, status.product_slug, cve_id)
return existing.id, False
vuln = Vulnerability(
@@ -659,4 +759,158 @@ def upsert_eol_vulnerability(
vuln.refresh_scores()
except Exception:
pass
# Revisionssicher: initial detected-event for the new EOL finding.
try:
from app.services.audit_events import audit_new_vulnerabilities
audit_new_vulnerabilities(db, [vuln.id], source="eol_check")
except Exception:
pass
_supersede_old_eol(db, asset_id, status.product_slug, cve_id)
return vuln.id, True
def run_eol_for_packages(db: "Session", asset, packages: list) -> int:
"""Source-agnostic per-package EOL detection for one asset.
`packages` = list of {name, version}. endoflife.date first, then the
MS-lifecycle export / hardcoded-exotics fallback when endoflife has
nothing actionable (same precedence as the eol-check endpoint). Used by
both the Wazuh eol-check and the Intune detectedApps inventory. Returns
the number of EOL findings upserted. Caller commits.
"""
count = 0
seen: set = set()
kept_msl: set = set() # MS-lifecycle EOL cve_ids still valid this run
for pkg in packages or []:
name = (pkg.get("name") or "").strip()
version = (pkg.get("version") or "").strip()
if not name or not version:
continue
key = (name.lower(), version)
if key in seen:
continue
seen.add(key)
status = None
if resolve_product_slug(name):
try:
status = check_eol(db, name, version)
except Exception:
status = None
actionable = status and (status.is_eol or status.is_eol_soon or status.is_eoas)
if not actionable:
try:
from app.services import ms_lifecycle_service
ms = ms_lifecycle_service.resolve_ms_lifecycle_eol(db, name, version)
if ms and (ms.is_eol or ms.is_eol_soon):
status = ms
actionable = True
except Exception:
pass
if not actionable:
continue
if status and status.product_slug == "ms-lifecycle":
kept_msl.add(_pseudo_cve_id("ms-lifecycle", status.release_name or "unknown"))
try:
upsert_eol_vulnerability(
db, asset_id=asset.id, product_name=name,
installed_version=version, status=status,
)
count += 1
except Exception as e:
logger.warning("EOL-for-packages upsert failed (%s on asset %s): %s", name, asset.id, e)
# Guard on a non-empty inventory: an empty list is a transient/failed read,
# not proof the products are gone (same safety as the other reconciles).
if packages:
_resolve_stale_ms_lifecycle(db, asset.id, kept_msl)
return count
def _resolve_stale_ms_lifecycle(db: "Session", asset_id: int, kept: set) -> None:
"""Resolve open MS-lifecycle EOL findings the current run no longer produced.
Without this, fixing a bad nameproduct match (tester: 'Microsoft Edge'
browser mis-mapped to the 'Azure Stack Edge' listing) left the wrong finding
open forever, since _supersede_old_eol only fires when a REPLACEMENT is
created. Scoped to the EOL-MS-LIFECYCLE- prefix, which only the package path
produces OS-level endoflife.date findings use other slugs and are
untouched. Only reconciles when at least one package was inventoried (empty
package list = nothing to conclude)."""
from app.models.vulnerability import Vulnerability, VulnerabilityStatus
stale = (
db.query(Vulnerability)
.filter(Vulnerability.asset_id == asset_id,
Vulnerability.status == VulnerabilityStatus.open,
Vulnerability.cve_id.like("EOL-MS-LIFECYCLE-%"))
.all()
)
for v in stale:
if v.cve_id in kept:
continue
old_status = v.status
v.status = VulnerabilityStatus.patched
v.patched_at = datetime.now()
try:
from app.routers.vulnerabilities import log_vulnerability_change
log_vulnerability_change(
db, None, v.id, old_status, v.status,
reason="MS lifecycle no longer matches this installed product "
"(re-evaluated — not end-of-life)",
cve_id=v.cve_id, source="eol_reconcile",
)
except Exception as e:
logger.warning("audit log for MS-lifecycle reconcile failed (vuln_id=%s): %s", v.id, e)
def revalidate_ms_lifecycle_findings(db: "Session") -> int:
"""Re-check every OPEN MS-lifecycle EOL finding and close the ones that no
longer match. Returns how many were closed.
Path-independent on purpose. _resolve_stale_ms_lifecycle only runs inside
run_eol_for_packages, but the EOL-check endpoint (the button) has its own
loop and never called it so a finding produced by a since-fixed name match
stayed open forever (tester: 'Microsoft Edge' the browser matched the
'Azure Stack Edge' listing; the match was fixed, the finding was not).
Re-asking the resolver per finding is cheap: the lifecycle rows are memoised
in-process, so this costs one fetch at most.
"""
from app.models.vulnerability import Vulnerability, VulnerabilityStatus
from app.services import ms_lifecycle_service
rows = (db.query(Vulnerability)
.filter(Vulnerability.status == VulnerabilityStatus.open,
Vulnerability.cve_id.like("EOL-MS-LIFECYCLE-%"))
.all())
closed = 0
for v in rows:
name = (v.package_name or "").strip()
if not name:
continue
try:
st = ms_lifecycle_service.resolve_ms_lifecycle_eol(
db, name, v.package_version or "")
except Exception as e:
logger.debug("MS-lifecycle revalidate failed for %s: %s", name, e)
continue # unreachable source → leave the finding alone
if st and (st.is_eol or st.is_eol_soon):
continue # still EOL → keep
old_status = v.status
v.status = VulnerabilityStatus.patched
v.patched_at = datetime.now()
closed += 1
try:
from app.routers.vulnerabilities import log_vulnerability_change
log_vulnerability_change(
db, None, v.id, old_status, v.status,
reason=f"MS lifecycle no longer reports '{name}' as end-of-life "
f"(re-evaluated — earlier match was wrong)",
cve_id=v.cve_id, source="eol_revalidate",
hostname=(v.asset.hostname if v.asset else None),
)
except Exception as e:
logger.warning("audit log for MS-lifecycle revalidate failed (%s): %s", v.id, e)
if closed:
db.commit()
logger.info("MS-lifecycle revalidate: closed %d stale finding(s)", closed)
return closed
+3 -3
View File
@@ -57,7 +57,7 @@ logger = logging.getLogger(__name__)
# ----------------------------------------------------------------------
_EDB_CSV_URL = "https://gitlab.com/exploit-database/exploitdb/-/raw/main/files_exploits.csv"
_EDB_CACHE_PATH = "/tmp/vulncheck-exploit-db.csv"
_EDB_CACHE_PATH = "/tmp/truevuln-exploit-db.csv"
_EDB_CACHE_TTL = 24 * 3600
# Lower-case header → index lookup (the GitLab CSV occasionally
@@ -133,7 +133,7 @@ def fetch_exploit_db_cve_map() -> Dict[str, List[str]]:
# Direct raw URL — heavy but cacheable. Pattern: one folder per year
# under the repo, each holding {CVE-ID}.json.
_POC_RAW_BASE = "https://raw.githubusercontent.com/nomi-sec/PoC-in-GitHub/master"
_POC_CACHE_PATH = "/tmp/vulncheck-pocs-github.json"
_POC_CACHE_PATH = "/tmp/truevuln-pocs-github.json"
_POC_CACHE_TTL = 24 * 3600
@@ -257,7 +257,7 @@ _MSF_META_URL = (
"https://raw.githubusercontent.com/rapid7/metasploit-framework/master/"
"db/modules_metadata_base.json"
)
_MSF_CACHE_PATH = "/tmp/vulncheck-msf-modules.json"
_MSF_CACHE_PATH = "/tmp/truevuln-msf-modules.json"
_MSF_CACHE_TTL = 24 * 3600
+60 -19
View File
@@ -27,26 +27,31 @@ logger = logging.getLogger(__name__)
# port → (service label, base risk weight 0-40). Higher = worse to expose.
# Remote-control + cleartext-admin protocols rank highest.
#
# Weights rebalanced (tester: almost every Windows host hit 100 because
# baseline Windows services — SMB/MSRPC/NetBIOS/WinRM — were weighted like
# real exposures). Baseline Windows services are now LOW; genuine remote-
# control / cleartext-admin exposures stay HIGH. Crown-jewel ROLES (DC,
# ADCS, SQL, Exchange, …) are scored separately by risk_dimensions_service.
_RISKY_PORTS: Dict[int, tuple] = {
23: ("Telnet (cleartext)", 40),
3389: ("RDP", 35),
3389: ("RDP", 30),
5900: ("VNC", 35), 5901: ("VNC", 35), 5902: ("VNC", 30),
5903: ("VNC", 30), 5904: ("VNC", 30), 5905: ("VNC", 30),
445: ("SMB", 30),
139: ("NetBIOS", 25),
21: ("FTP (cleartext)", 28),
21: ("FTP (cleartext)", 25),
512: ("rexec", 30), 513: ("rlogin", 30), 514: ("rsh", 30),
1433: ("MSSQL", 22), 3306: ("MySQL", 22), 5432: ("PostgreSQL", 22),
27017: ("MongoDB", 24), 6379: ("Redis", 26), 9200: ("Elasticsearch", 22),
27017: ("MongoDB", 24), 6379: ("Redis", 26), 9200: ("Elasticsearch", 20),
11211: ("Memcached", 24),
135: ("MSRPC", 20),
161: ("SNMP", 20),
389: ("LDAP (cleartext)", 18),
5985: ("WinRM-HTTP", 22), 5986: ("WinRM-HTTPS", 14),
2049: ("NFS", 20),
8834: ("Nessus", 10),
# SSH is normal admin but still an exposure datapoint.
22: ("SSH", 8),
1433: ("MSSQL", 18), 3306: ("MySQL", 18), 5432: ("PostgreSQL", 18),
161: ("SNMP", 14),
2049: ("NFS", 16),
5985: ("WinRM-HTTP", 12), 5986: ("WinRM-HTTPS", 8),
445: ("SMB", 10),
389: ("LDAP (cleartext)", 8),
135: ("MSRPC", 6),
139: ("NetBIOS", 6),
22: ("SSH", 6),
8834: ("Nessus", 8),
}
# Listeners bound to these local IPs are NOT network-exposed.
@@ -77,22 +82,40 @@ def analyze_ports(ports: List[dict]) -> tuple:
# Only listening sockets count as exposure.
state = str(p.get("state") or "").lower()
proto = str(p.get("protocol") or p.get("proto") or "").lower()
# A listener is the normal proof that a service is running. But Wazuh
# does not always report one: on a Windows Server 2025 DC the tester saw
# 3389 ESTABLISHED in netstat and no listening entry from syscollector
# at all, so RDP scored zero exposure on a box serving live RDP
# sessions. An ESTABLISHED socket whose LOCAL port is the well-known
# one is an inbound connection, which proves the service is there just
# as well. (Outbound connections carry an ephemeral local port, so they
# cannot be mistaken for this.)
if proto == "tcp" and state and state != "listening":
continue
if state != "established":
continue
try:
port = int(p.get("local_port") or p.get("local", {}).get("port") or 0)
except (ValueError, TypeError):
continue
# An ESTABLISHED socket only counts when its LOCAL port is one of the
# known service ports — otherwise it is the ephemeral end of an
# outbound connection and proves nothing about this host.
if port <= 0 or port not in _RISKY_PORTS:
continue
local_ip = p.get("local_ip") or (p.get("local") or {}).get("ip") or ""
if not _is_externally_bound(local_ip):
continue
label, weight = _RISKY_PORTS[port]
key = (port, proto)
if key in seen:
# Deduplicate by PORT alone. It used to include 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 made
# even the listeners look like two services. The tester's DC listed
# "RDP :3389" four times and LDAP four times, and since every extra
# entry adds 40% of its weight, the exposure score inflated to 100 on
# what is really one RDP and one LDAP service.
if port in seen:
continue
seen.add(key)
seen.add(port)
risky.append({
"port": port,
"proto": proto or "tcp",
@@ -130,7 +153,25 @@ def refresh_asset_exposure(db: Session, wazuh, asset: Asset) -> Optional[dict]:
asset.network_exposure_score = score
asset.exposed_services = json.dumps(services) if services else None
asset.exposure_updated_at = datetime.now()
return {"score": score, "count": len(services)}
# Risk Dimensions (crown-jewel roles) — reuse the ports already fetched
# plus the package list; no extra Wazuh round-trip beyond get_packages.
hv_score = 0.0
try:
from app.services.risk_dimensions_service import detect_risk_dimensions
try:
packages = wazuh.get_packages(asset.wazuh_agent_id) or []
except Exception:
packages = []
rd = detect_risk_dimensions(ports, packages)
hv_score = rd["score"]
asset.high_value_score = rd["score"]
asset.risk_dimensions = json.dumps(rd["dimensions"]) if rd["dimensions"] else None
asset.risk_dimensions_updated_at = datetime.now()
except Exception as e:
logger.warning("risk-dimensions failed for %s: %s", asset.hostname, e)
return {"score": score, "count": len(services), "high_value_score": hv_score}
def refresh_all_exposure(db: Session, wazuh) -> dict:
@@ -0,0 +1,273 @@
"""
GitHub REPOSITORY security advisories CVEs for desktop software.
Different source from the GLOBAL advisory API (api.github.com/advisories):
that one mirrors NVD and its 'unreviewed' entries carry no version data at
all. The per-repo endpoint
https://api.github.com/repos/{owner}/{repo}/security-advisories
is what a project maintainer publishes themselves, and it DOES carry
`vulnerable_version_range` + `patched_versions` (verified live against
notepad-plus-plus: 18 advisories, all 18 with a range, 12 with a CVE id).
That closes a real gap: Notepad++ CVEs (CVE-2026-57233, -54758, -52886, )
appear in neither NVD nor cvelistV5, so no other scanner path can see them.
Scope: products in `_REPO_MAP` whose upstream publishes advisories this way.
Findings are written through the app-scan upsert with the app-scan source, so
the existing app-scan reconcile auto-resolves them once the host updates
no separate source/reconcile pair to maintain.
"""
import json
import logging
import re
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Tuple
from sqlalchemy.orm import Session
from app.models.setting import Setting
logger = logging.getLogger(__name__)
INDEX_SETTING = "github_repo_advisory_cache"
INDEX_TS_SETTING = "github_repo_advisory_cache_ts"
TTL_HOURS = 24
_API = "https://api.github.com/repos/{repo}/security-advisories?per_page=100"
# Installed-software name → GitHub repo publishing advisories for it.
# Add a line per product; everything else is untouched.
_REPO_MAP: List[Tuple[re.Pattern, str]] = [
(re.compile(r"notepad\+\+", re.I), "notepad-plus-plus/notepad-plus-plus"),
]
_SEV = {"critical": "critical", "high": "high", "moderate": "medium",
"medium": "medium", "low": "low"}
def resolve_repo(product_name: str) -> Optional[str]:
n = (product_name or "").strip()
for rx, repo in _REPO_MAP:
if rx.search(n):
return repo
return None
def _vt(s: Optional[str]) -> Optional[tuple]:
"""'v8.9.6.4' → (8,9,6,4). None when not dotted-numeric."""
s = (s or "").strip().lstrip("vV").strip()
if not re.fullmatch(r"\d+(\.\d+)*", s):
return None
return tuple(int(x) for x in s.split("."))
def is_affected(installed: str, rng: Optional[str], patched: Optional[str]) -> bool:
"""Is `installed` inside the advisory's vulnerable range?
`vulnerable_version_range` is maintainer free-text the real Notepad++
feed uses '<= v8.9.6.4', '< v8.9.6.4', '<=8.9.1', 'old versions - 8.8.1',
'v8.9.4 & v8.9.5' and bare single versions. `patched_versions` is always a
clean single literal, so it doubles as the safety net: at/past the patched
release is never affected, whatever the range text says.
"""
it = _vt(installed)
if not it:
return False
pt = _vt(patched)
if pt and it >= pt:
return False # patched — hard stop, no FP possible
r = (rng or "").strip()
m = re.match(r"^<=\s*v?([\d.]+)$", r, re.I)
if m:
b = _vt(m.group(1))
return bool(b and it <= b)
m = re.match(r"^<\s*v?([\d.]+)$", r, re.I)
if m:
b = _vt(m.group(1))
return bool(b and it < b)
m = re.match(r"^old versions\s*-\s*v?([\d.]+)$", r, re.I)
if m:
b = _vt(m.group(1))
return bool(b and it <= b)
parts = [p for p in re.split(r"[&,]", r) if p.strip()]
if len(parts) > 1: # explicit list — exact matches only,
return any(_vt(p) == it for p in parts) # older builds are NOT affected
if len(parts) == 1 and _vt(parts[0]):
return _vt(parts[0]) == it # single exact version
return bool(pt and it < pt) # unparseable range → below-patched rule
def _cvss_of(adv: dict) -> Optional[float]:
score = (adv.get("cvss") or {}).get("score")
if not score:
sev = adv.get("cvss_severities") or {}
for k in ("cvss_v4", "cvss_v3"):
s = (sev.get(k) or {}).get("score")
if s:
score = s
break
try:
return float(score) if score else None
except (TypeError, ValueError):
return None
def build_index(db: Session) -> Dict[str, list]:
"""{repo: [{cve, cvss, sev, range, patched, summary}, …]}, cached 24h."""
import httpx
from app.auth.setting_crypto import read_setting_value
headers = {"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28"}
try:
pat = (read_setting_value(db, "github_pat") or "").strip()
if pat:
headers["Authorization"] = f"Bearer {pat}"
except Exception:
pass
index: Dict[str, list] = {}
with httpx.Client(timeout=20.0, follow_redirects=True, headers=headers) as client:
for _, repo in _REPO_MAP:
if repo in index:
continue
try:
r = client.get(_API.format(repo=repo))
if r.status_code == 403 and r.headers.get("x-ratelimit-remaining") == "0":
logger.warning("repo-advisories: GitHub rate limit hit — "
"set github_pat for 5000 req/h")
break
if r.status_code != 200:
logger.debug("repo-advisories: %s → HTTP %s", repo, r.status_code)
continue
entries = []
for adv in r.json() or []:
cve = (adv.get("cve_id") or "").strip().upper()
# CVE-less advisories (GHSA id only) are skipped: the CVE id
# is what the rest of the pipeline (enrichment, KEV/EPSS,
# dedup across scanners) keys on.
if not cve.startswith("CVE-"):
continue
if adv.get("withdrawn_at"):
continue
vulns = adv.get("vulnerabilities") or []
v0 = vulns[0] if vulns else {}
entries.append({
"cve": cve,
"cvss": _cvss_of(adv),
"sev": _SEV.get((adv.get("severity") or "").lower()),
"range": v0.get("vulnerable_version_range"),
"patched": v0.get("patched_versions"),
"summary": (adv.get("summary") or "")[:400],
})
index[repo] = entries
logger.info("repo-advisories: %s%d CVE advisories", repo, len(entries))
except Exception as e:
logger.debug("repo-advisories: fetch %s failed: %s", repo, e)
_store(db, index)
return index
def _store(db: Session, index: Dict[str, list]) -> None:
for key, val in ((INDEX_SETTING, json.dumps(index)),
(INDEX_TS_SETTING, datetime.now().isoformat())):
row = db.query(Setting).filter(Setting.key == key).first()
if row:
row.value = val
else:
db.add(Setting(key=key, value=val))
db.commit()
def load_index(db: Session) -> Optional[Dict[str, list]]:
ts = db.query(Setting).filter(Setting.key == INDEX_TS_SETTING).first()
row = db.query(Setting).filter(Setting.key == INDEX_SETTING).first()
if not ts or not row or not row.value:
return None
try:
if datetime.now() - datetime.fromisoformat(ts.value) > timedelta(hours=TTL_HOURS):
return None
return json.loads(row.value)
except (ValueError, json.JSONDecodeError):
return None
def get_index(db: Session) -> Dict[str, list]:
idx = load_index(db)
if idx is not None:
return idx
try:
return build_index(db)
except Exception as e:
logger.warning("repo-advisory index build failed: %s", e)
return {}
def scan_asset(db: Session, asset, packages: list, new_ids: Optional[list] = None,
touched: Optional[set] = None) -> int:
"""Match installed software against repo-published advisories.
Findings go through the app-scan upsert (source 'app-scan'), so the
existing app-scan reconcile closes them when the host updates.
Returns findings upserted. Caller commits."""
from app.services import app_cve_scanner_service as cpe
index = get_index(db)
if not index:
return 0
if new_ids is None:
new_ids = []
count = 0
seen: set = set()
for pkg in packages or []:
name = (pkg.get("name") or "").strip()
version = (pkg.get("version") or "").strip()
if not name or not version:
continue
if cpe._is_citrix_shim(pkg):
continue
repo = resolve_repo(name)
if not repo or repo not in index:
continue
key = (repo, version)
if key in seen:
continue
seen.add(key)
for e in index[repo]:
if not is_affected(version, e.get("range"), e.get("patched")):
continue
c = {"cve": e["cve"], "cvss": e.get("cvss"), "severity": e.get("sev"),
"fixed": (e.get("patched") or "").lstrip("vV") or None}
try:
before = len(new_ids)
cpe._upsert(db, asset, name, version, c, new_ids, touched=touched,
vendor=(pkg.get("vendor") or None))
count += 1 if len(new_ids) > before else 0
except Exception as ex:
logger.debug("repo-advisory upsert failed (%s on %s): %s",
e["cve"], asset.id, ex)
return count
if __name__ == "__main__":
# ponytail: one self-check for the range parser — the only non-trivial
# logic. Covers every format seen in the live Notepad++ feed.
# Run: python -m app.services.github_repo_advisory_service
assert is_affected("8.9.6.4", "<= v8.9.6.4", "v8.9.7") is True # tester's CVE-2026-57233
assert is_affected("8.9.7", "<= v8.9.6.4", "v8.9.7") is False # patched
assert is_affected("8.9.6", "<= v8.9.6.4", "v8.9.7") is True # older affected
assert is_affected("8.9.6.4", "< v8.9.6.4", "v8.9.7") is False # exclusive bound
assert is_affected("8.9.0", "<=8.9.1", "8.9.2") is True # no space, no 'v'
assert is_affected("8.8.0", "old versions - 8.8.1", "8.8.2") is True
assert is_affected("8.9.3", "v8.9.4 & v8.9.5", "v8.9.6") is False # list ≠ range
assert is_affected("8.9.4", "v8.9.4 & v8.9.5", "v8.9.6") is True
assert is_affected("8.9.6.1", "v8.9.6.1", "v8.9.6.2") is True # single exact
assert is_affected("8.9.5", "v8.9.6.1", "v8.9.6.2") is False
assert is_affected("9.0", "old versions - 8.8.1", "8.8.2") is False # safety net
assert is_affected("8.9.6.4", None, "v8.9.7") is True # no range → below-patched
assert is_affected("1:8.9-1", "<= v8.9.6.4", "v8.9.7") is False # rpm-style → skip
assert resolve_repo("Notepad++ (64-bit x64)") == "notepad-plus-plus/notepad-plus-plus"
assert resolve_repo("Google Chrome") is None
print("github_repo_advisory_service self-check OK")
+329
View File
@@ -0,0 +1,329 @@
"""
Microsoft Intune (MDM/UEM) inventory sync.
Pulls Intune managed devices via Microsoft Graph (app-only) and registers
them as assets (source=INTUNE), then runs OS-level EOL detection on each
the same find-or-create + lifecycle-reconcile pattern as the Nessus sync.
Phase 2 (detectedApps EOL/M365 per installed app) hooks in here too.
"""
import json
import logging
import re
from datetime import datetime
from typing import Optional
from sqlalchemy.orm import Session
from app.models.asset import Asset, AssetSource, AssetStatus
logger = logging.getLogger(__name__)
SOURCE_NAME = "intune"
SETTING_KEY = "intune_config"
def load_intune_config(db: Session) -> Optional[dict]:
"""Decrypt + parse intune_config, or None when not configured."""
from app.auth.setting_crypto import read_setting_value
raw = read_setting_value(db, SETTING_KEY)
if not raw:
return None
try:
cfg = json.loads(raw)
except json.JSONDecodeError:
logger.warning("intune_config is not valid JSON")
return None
if not all([cfg.get("tenant_id"), cfg.get("client_id"), cfg.get("client_secret")]):
return None
return cfg
def _build_client(cfg: dict):
from app.integrations.graph_client import GraphClient
return GraphClient(
tenant_id=cfg["tenant_id"],
client_id=cfg["client_id"],
client_secret=cfg["client_secret"],
verify_ssl=cfg.get("verify_ssl", True),
)
# Intune's `deviceName` is the Entra/management name for supervised / userless
# / ABM iOS devices — a "<enrollment-GUID>_<Model>_<M/D/YYYY>_<time>" blob.
# Detect it so we can show a readable, stable name instead.
_MGMT_NAME_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}_", re.I)
def _clean_device_name(device: dict) -> str:
"""Readable hostname. Graph fills `deviceName` with the management-name
GUID blob for supervised/userless iOS; compose a stable name from
model + serial instead. Falls back to whatever's there."""
name = (device.get("deviceName") or "").strip()
if name and not _MGMT_NAME_RE.match(name):
return name
model = (device.get("model") or "").strip()
serial = (device.get("serialNumber") or "").strip()
if model and serial:
return f"{model}-{serial}"[:255]
if model and device.get("id"):
return f"{model}-{device['id'][:8]}"[:255]
return name # nothing better available
def _find_or_create_asset(db: Session, device: dict, auto_create: bool):
"""Match an Intune device to an asset by stable id
(intune_device_id aad_device_id hostname), else auto-create. Matching
on ids first makes device renames (very common in autodeployments) a no-op
the asset is found by id and its hostname refreshed."""
device_id = (device.get("id") or "").strip() or None
aad_id = (device.get("azureADDeviceId") or "").strip() or None
hostname = _clean_device_name(device)
def _pin(a):
# Adopt the ids Intune just gave us, so future syncs (and the Defender
# sync) converge on this one asset.
#
# These used to be fill-only, which broke re-enrolment: wipe a device
# and deploy it again and Intune issues a NEW device id, but the asset
# kept the dead one. The hostname match still found the asset, so
# nothing looked wrong — while every inventory fetch went to an id that
# no longer exists and came back empty. An asset with no inventory is
# skipped by the whole scan chain (no MSRC pass, no reconcile, no
# prune), so its findings stay open forever with no way to clear them.
# We are holding the id Intune reports for this device right now; it is
# by definition the current one.
if device_id and a.intune_device_id != device_id:
if a.intune_device_id:
logger.info("Intune sync: %s re-enrolled — device id %s%s",
a.hostname, a.intune_device_id, device_id)
a.intune_device_id = device_id
if aad_id and a.aad_device_id != aad_id:
a.aad_device_id = aad_id
if device_id:
a = db.query(Asset).filter(Asset.intune_device_id == device_id).first()
if a:
_pin(a)
return a, "intune_id"
if aad_id:
a = db.query(Asset).filter(Asset.aad_device_id == aad_id).first()
if a:
_pin(a)
return a, "aad_id"
short = hostname.split(".")[0] if hostname else ""
for candidate in [c for c in (hostname, short) if c]:
a = db.query(Asset).filter(Asset.hostname.ilike(candidate)).first()
if a:
_pin(a)
return a, "hostname"
if short:
a = db.query(Asset).filter(Asset.hostname.ilike(f"{short}.%")).first()
if a:
_pin(a)
return a, "hostname-fqdn-prefix"
if auto_create and hostname:
a = Asset(
hostname=short or hostname,
intune_device_id=device_id,
aad_device_id=aad_id,
source=AssetSource.INTUNE,
status=AssetStatus.ACTIVE,
)
db.add(a)
db.flush()
logger.info("Intune sync: auto-created asset %s", a.hostname)
return a, "created"
return None, "skipped"
def _os_string(device: dict) -> str:
"""Intune operatingSystem is short ('Windows'/'macOS'); prefix so the
EOL OS resolver recognises it like a Wazuh OS string."""
os_name = (device.get("operatingSystem") or "").strip()
if os_name.lower() == "windows":
return "Microsoft Windows"
return os_name
# Cross-process guard: only one Intune/Defender sync may touch the asset rows
# at a time. The router's module-level flag is per-worker, and the nightly
# scheduler runs in yet another context — two overlapping syncs update the same
# assets in different orders → Postgres deadlock. A Postgres advisory lock is
# global to the DB, so it serialises every caller.
_SYNC_ADVISORY_LOCK_KEY = 0x54560101 # arbitrary constant ("TV" + 01)
def _try_sync_lock(db: Session) -> bool:
from sqlalchemy import text
return bool(db.execute(text("SELECT pg_try_advisory_lock(:k)"),
{"k": _SYNC_ADVISORY_LOCK_KEY}).scalar())
def _release_sync_lock(db: Session) -> None:
from sqlalchemy import text
try:
db.rollback() # unlock must run outside a failed (deadlocked) txn
except Exception:
pass
try:
db.execute(text("SELECT pg_advisory_unlock(:k)"), {"k": _SYNC_ADVISORY_LOCK_KEY})
db.commit()
except Exception:
pass
def run_intune_sync(db: Session, asset_id: Optional[int] = None) -> dict:
"""Sync Intune managed devices → assets + OS-EOL. Returns stats."""
cfg = load_intune_config(db)
if not cfg:
raise RuntimeError("Intune is not configured (settings.intune_config missing/incomplete).")
if not _try_sync_lock(db):
logger.warning("Intune sync skipped — another Intune/Defender sync holds the lock")
return {"skipped": "another sync already running"}
try:
return _run_intune_sync_locked(db, cfg)
finally:
_release_sync_lock(db)
def _run_intune_sync_locked(db: Session, cfg: dict) -> dict:
from app.services import eol_service
from app.services.asset_lifecycle import reconcile_intune_by_seen_ids
auto_create = bool(cfg.get("auto_create_assets", True))
detected_apps_enabled = bool(cfg.get("detected_apps", True))
client = _build_client(cfg)
stats = {
"devices": 0, "assets_matched": 0, "assets_created": 0,
"os_eol_findings": 0, "app_findings": 0,
"assets_inactivated": 0, "assets_reactivated": 0, "errors": [],
}
seen_asset_ids: set = set()
try:
devices = client.get_managed_devices()
except Exception as e:
raise RuntimeError(f"Graph managedDevices fetch failed: {e}") from e
for device in devices:
stats["devices"] += 1
try:
asset, how = _find_or_create_asset(db, device, auto_create)
if not asset:
continue
if how == "created":
stats["assets_created"] += 1
else:
stats["assets_matched"] += 1
# Rename tracking: matched by a stable id → adopt the current
# (cleaned) device name so autodeploy renames propagate.
new_name = _clean_device_name(device)
if how in ("intune_id", "aad_id") and new_name and asset.hostname != new_name:
asset.hostname = new_name.split(".")[0] or new_name
# refresh inventory fields
os_name = _os_string(device)
if os_name:
asset.operating_system = os_name[:255]
if device.get("osVersion"):
asset.os_version = str(device["osVersion"])[:100]
asset.last_scan = datetime.now()
db.flush()
if asset.id:
seen_asset_ids.add(asset.id)
# OS-level EOL
try:
os_status = eol_service.check_os_eol(db, asset.operating_system or "", asset.os_version or "")
if os_status and (os_status.is_eol or os_status.is_eol_soon or os_status.is_eoas):
eol_service.upsert_eol_vulnerability(
db, asset_id=asset.id,
product_name=(asset.operating_system or "Operating System").strip(),
installed_version=(asset.os_version or os_status.release_name or "unknown"),
status=os_status,
)
stats["os_eol_findings"] += 1
except Exception as e:
logger.warning("Intune OS-EOL failed for %s: %s", asset.hostname, e)
# Mobile device EOL/EOS (model) + Android patch-level staleness.
try:
from app.services import mobile_eol_service
stats["mobile_eol_findings"] = (
stats.get("mobile_eol_findings", 0)
+ mobile_eol_service.check_device(db, asset, device)
)
except Exception as e:
logger.debug("Intune mobile-EOL failed for %s: %s", asset.hostname, e)
# Phase 2 — detected apps → existing EOL/M365 per-package detection
if detected_apps_enabled and device.get("id"):
try:
pkgs = client.get_detected_apps(device["id"])
if pkgs:
stats["app_findings"] += _run_app_inventory(db, asset, pkgs)
except Exception as e:
logger.debug("Intune detectedApps failed for %s: %s", asset.hostname, e)
except Exception as e:
stats["errors"].append(f"device {device.get('deviceName')}: {e}")
db.commit()
# Lifecycle reconcile (event-driven, id-keyed).
try:
recon = reconcile_intune_by_seen_ids(
db, seen_asset_ids=seen_asset_ids,
reason="not reported by the latest Intune sync",
)
stats["assets_inactivated"] = recon["inactivated"]
stats["assets_reactivated"] = recon["reactivated"]
db.commit()
except Exception as e:
logger.warning("Intune reconcile failed: %s", e)
client.close()
# Phase 3 — Defender for Endpoint TVM real CVEs (opt-in, separate API).
if cfg.get("defender_tvm"):
try:
from app.services.defender_service import run_defender_sync
dstats = run_defender_sync(db)
stats["defender"] = {k: v for k, v in dstats.items() if k != "errors"}
except Exception as e:
logger.warning("Defender TVM sync failed (non-fatal): %s", e)
logger.info(
"Intune sync done: %d devices, %d matched, %d created, %d OS-EOL, "
"%d app findings, %d inactivated, %d reactivated",
stats["devices"], stats["assets_matched"], stats["assets_created"],
stats["os_eol_findings"], stats["app_findings"],
stats["assets_inactivated"], stats["assets_reactivated"],
)
return stats
def _run_app_inventory(db: Session, asset, packages: list) -> int:
"""Feed Intune detectedApps into the existing EOL + M365 detection.
Returns number of findings upserted (best-effort)."""
count = 0
try:
from app.services import eol_service
count += eol_service.run_eol_for_packages(db, asset, packages)
except Exception as e:
logger.debug("Intune EOL-for-packages failed on %s: %s", asset.hostname, e)
try:
from app.services import m365_service
count += m365_service.run_m365_for_packages(db, asset, packages)
except Exception as e:
logger.debug("Intune M365-for-packages failed on %s: %s", asset.hostname, e)
try:
from app.services import app_cve_scanner_service
count += app_cve_scanner_service.scan_asset_packages(db, asset, packages)
except Exception as e:
logger.debug("Intune app-cve scan failed on %s: %s", asset.hostname, e)
return count
+257
View File
@@ -0,0 +1,257 @@
"""
Linux distro CVE remediation enrichment (step 2 of multi-source enrichment).
Unlike MSRC (monthly bulk doc), the Linux security trackers are queryable
PER CVE, cheaply and without auth so we fetch on demand when a CVE detail
is opened for a Linux host, then cache into cve_remediations so repeat
views are instant. Same table + UI as the MSRC path.
Providers:
ubuntu https://ubuntu.com/security/cves/<CVE>.json
packages[].statuses[] (release_codename, status, description=
fixed version), notices_ids (USN), mitigation.
redhat https://access.redhat.com/hydra/rest/securitydata/cve/<CVE>.json
(CentOS / AlmaLinux / Rocky / Oracle rebuild RHEL, so the RHSA
+ fixed package NVR is the actionable fix). affected_release[],
mitigation/statement.
"""
import logging
import re
from datetime import datetime
from typing import List, Optional
import httpx
from sqlalchemy.orm import Session
from app.models.cve_remediation import CveRemediation
logger = logging.getLogger(__name__)
HTTP_TIMEOUT = 20.0
UA = {"User-Agent": "TrueVuln/1.0", "Accept": "application/json"}
UBUNTU_URL = "https://ubuntu.com/security/cves/{cve}.json"
REDHAT_URL = "https://access.redhat.com/hydra/rest/securitydata/cve/{cve}.json"
def provider_for_os(os_name: Optional[str]) -> Optional[str]:
"""Map an asset OS string to a Linux security provider, or None."""
n = (os_name or "").lower()
if "ubuntu" in n:
return "ubuntu"
if any(k in n for k in ("centos", "red hat", "redhat", "rhel", "rocky",
"alma", "oracle linux", "fedora")):
return "redhat"
return None
# ============================================================
# providers
# ============================================================
def fetch_ubuntu(cve_id: str) -> List[dict]:
try:
r = httpx.get(UBUNTU_URL.format(cve=cve_id.upper()), headers=UA,
timeout=HTTP_TIMEOUT, follow_redirects=True)
if r.status_code == 404:
return []
r.raise_for_status()
d = r.json()
except (httpx.HTTPError, ValueError) as e:
logger.debug("ubuntu fetch failed for %s: %s", cve_id, e)
return []
out: List[dict] = []
for pkg in d.get("packages", []) or []:
name = pkg.get("name")
for s in pkg.get("statuses", []) or []:
if s.get("status") != "released":
continue
codename = s.get("release_codename") or s.get("release") or ""
fixed = (s.get("description") or "").strip()
if not fixed:
continue
out.append({
"kind": "fix",
"title": f"{name} ({codename})"[:300],
"detail": None,
"kb": None,
"fixed_build": fixed[:120],
"url": None,
})
# USN advisories
for usn in (d.get("notices_ids") or []):
out.append({
"kind": "advisory",
"title": str(usn)[:300],
"detail": None,
"kb": None,
"fixed_build": None,
"url": f"https://ubuntu.com/security/notices/{usn}",
})
mit = (d.get("mitigation") or "").strip()
if mit:
out.append({"kind": "mitigation", "title": "Mitigation",
"detail": _clean(mit)[:4000], "kb": None,
"fixed_build": None, "url": None})
return out
def fetch_redhat(cve_id: str) -> List[dict]:
try:
r = httpx.get(REDHAT_URL.format(cve=cve_id.upper()), headers=UA,
timeout=HTTP_TIMEOUT, follow_redirects=True)
if r.status_code == 404:
return []
r.raise_for_status()
d = r.json()
except (httpx.HTTPError, ValueError) as e:
logger.debug("redhat fetch failed for %s: %s", cve_id, e)
return []
out: List[dict] = []
seen = set()
for a in d.get("affected_release", []) or []:
adv = a.get("advisory")
pkg = a.get("package")
if not adv:
continue
key = (adv, pkg)
if key in seen:
continue
seen.add(key)
out.append({
"kind": "fix",
"title": f"{adv}: {pkg}"[:300] if pkg else str(adv)[:300],
"detail": (a.get("product_name") or None),
"kb": adv,
"fixed_build": (pkg or None),
"url": f"https://access.redhat.com/errata/{adv}",
})
for field, label in (("mitigation", "Mitigation"), ("statement", "Statement")):
val = (d.get(field) or "").strip()
if val:
out.append({"kind": "mitigation" if field == "mitigation" else "advisory",
"title": label, "detail": _clean(val)[:4000],
"kb": None, "fixed_build": None, "url": None})
return out
_TAG_RE = re.compile(r"<[^>]+>")
def _clean(text: str) -> str:
return re.sub(r"[ \t]{2,}", " ", _TAG_RE.sub(" ", text)).strip()
# ============================================================
# orchestration
# ============================================================
OSV_URL = "https://api.osv.dev/v1/vulns/{cve}"
# Reference URLs worth surfacing as advisories (distro errata / advisories
# across ecosystems). OSV aggregates many sources, so this is the gap-filler.
_ADVISORY_HINTS = ("errata", "/security/notices", "usn-", "rhsa", "dsa-",
"dla-", "ghsa", "/advisories/", "suse.com/security",
"alas", "rocky", "almalinux", "debian.org/security")
_HEX40 = re.compile(r"^[0-9a-f]{16,}$", re.I)
def fetch_osv(cve_id: str) -> List[dict]:
"""OSV.dev aggregator (Debian, Alpine, Rocky, Alma, SUSE, language
ecosystems, ...). Augments the vendor providers + fills gaps for
distros they don't cover. Stored under source='osv'."""
try:
r = httpx.get(OSV_URL.format(cve=cve_id.upper()), headers=UA,
timeout=HTTP_TIMEOUT, follow_redirects=True)
if r.status_code == 404:
return []
r.raise_for_status()
d = r.json()
except (httpx.HTTPError, ValueError) as e:
logger.debug("osv fetch failed for %s: %s", cve_id, e)
return []
out: List[dict] = []
seen_fix = set()
for a in d.get("affected", []) or []:
pkg = a.get("package", {}) or {}
eco = pkg.get("ecosystem")
name = pkg.get("name")
if not eco or not name:
continue # git-only / upstream entry → no actionable distro fix
for rg in a.get("ranges", []) or []:
for ev in rg.get("events", []) or []:
fixed = ev.get("fixed")
if not fixed or _HEX40.match(str(fixed)):
continue # skip commit-hash "fixes"
key = (eco, name, fixed)
if key in seen_fix:
continue
seen_fix.add(key)
out.append({
"kind": "fix",
"title": f"{name} ({eco})"[:300],
"detail": None, "kb": None,
"fixed_build": str(fixed)[:120],
"url": None,
})
# advisory references (deduped, capped)
adv_seen = set()
for ref in d.get("references", []) or []:
url = (ref.get("url") or "").strip()
if not url:
continue
low = url.lower()
if not any(h in low for h in _ADVISORY_HINTS):
continue
if url in adv_seen:
continue
adv_seen.add(url)
out.append({"kind": "advisory", "title": url.split("/")[2][:300] if "//" in url else "advisory",
"detail": None, "kb": None, "fixed_build": None, "url": url})
if len(adv_seen) >= 8:
break
return out
def enrich_cve_linux(db: Session, cve_id: str, os_name: Optional[str]) -> int:
"""Fetch + cache CVE remediations for a non-Windows host: the matching
vendor provider (Ubuntu USN / RHEL-family errata) when the OS is known,
PLUS OSV.dev as an additional source (own block, never overwrites
vendor). Returns total rows stored. Caller commits.
"""
cve = cve_id.upper()
if not cve.startswith("CVE-"):
return 0
now = datetime.now()
total = 0
jobs = [] # (source, rows)
provider = provider_for_os(os_name)
if provider:
jobs.append((provider, fetch_ubuntu(cve) if provider == "ubuntu" else fetch_redhat(cve)))
jobs.append(("osv", fetch_osv(cve)))
for source, rows in jobs:
# Replace existing rows for this (cve, source) — keeps it current.
db.query(CveRemediation).filter(
CveRemediation.cve_id == cve, CveRemediation.source == source,
).delete(synchronize_session=False)
for r in rows:
db.add(CveRemediation(
cve_id=cve, source=source, kind=r["kind"],
title=r.get("title"), detail=r.get("detail"), kb=r.get("kb"),
fixed_build=r.get("fixed_build"), url=r.get("url"), fetched_at=now,
))
total += len(rows)
return total
def has_cached(db: Session, cve_id: str, provider: str) -> bool:
return db.query(
db.query(CveRemediation).filter(
CveRemediation.cve_id == cve_id.upper(),
CveRemediation.source == provider,
).exists()
).scalar()
+686
View File
@@ -0,0 +1,686 @@
"""
Microsoft 365 Apps CVE detection (Plan P).
Microsoft 365 Apps (formerly Office 365 ProPlus) security fixes are NOT
published to NVD and are NOT detected by Wazuh's vulnerability detector —
they only live on one human-readable Microsoft Learn page:
https://learn.microsoft.com/en-us/officeupdates/microsoft365-apps-security-updates
There is no Microsoft API. So we parse that page, learn the latest patched
build per update channel, compare it against the build Wazuh's syscollector
reports as installed, and create real-CVE vulnerability rows for every
monthly update the host is behind on.
Build logic (verified against the tester's example):
installed 16.0.19929.20172 vs Monthly Enterprise Channel 19929.20162
-> 20172 >= 20162 -> UNAFFECTED (no CVEs)
installed < a section's channel build -> AFFECTED -> attach that
section's CVEs (union across every section the host is behind on).
Channel mapping (tester's rule): the deployed channel isn't in the
syscollector name, so we approximate it from the product name
"...enterprise..." -> Monthly Enterprise Channel, else Current Channel.
"""
import json
import logging
import re
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Tuple
import httpx
import lxml.html
from sqlalchemy.orm import Session
from app.models.setting import Setting
logger = logging.getLogger(__name__)
M365_SECURITY_URL = (
"https://learn.microsoft.com/en-us/officeupdates/"
"microsoft365-apps-security-updates"
)
# ---------- cache (settings table) ----------
M365_CACHE_KEY = "m365_security_cache"
M365_CACHE_TS_KEY = "m365_security_cache_updated_at"
M365_TTL_HOURS = 24
# ---------- toggle ----------
SETTING_M365_ENABLED = "m365_detection_enabled"
HTTP_TIMEOUT = 30.0
# Build line: "Monthly Enterprise Channel: Version 2604 (Build 19929.20162)"
_BUILD_RE = re.compile(
r"([A-Za-z0-9()/ \-]+?):\s*Version\s+(\d{3,4})\s*\(\s*Build\s+(\d+\.\d+)\s*\)"
)
# Month-day-year heading that delimits each monthly section.
_DATE_RE = re.compile(
r"\b(January|February|March|April|May|June|July|August|September|"
r"October|November|December)\s+(\d{1,2}),\s+(\d{4})\b"
)
_CVE_RE = re.compile(r"CVE-\d{4}-\d{4,}", re.IGNORECASE)
# Product-name -> channel approximation.
CHANNEL_MONTHLY_ENTERPRISE = "Monthly Enterprise Channel"
CHANNEL_CURRENT = "Current Channel"
class M365Error(Exception):
"""Raised when the M365 security page cannot be fetched/parsed."""
# ============================================================
# build helpers
# ============================================================
def parse_build(version: str) -> Optional[Tuple[int, int]]:
"""'16.0.19929.20172' or '19929.20172' -> (19929, 20172).
Microsoft 365 build numbers are the last two dotted segments
(BBBBB.RRRRR). The leading '16.0.' is the Office major and is
constant, so we ignore it.
"""
if not version:
return None
nums = re.findall(r"\d+", version)
if len(nums) < 2:
return None
try:
return int(nums[-2]), int(nums[-1])
except ValueError:
return None
def channel_for_product(product_name: str) -> str:
"""Tester's rule: name contains 'enterprise' -> MEC, else Current."""
return (
CHANNEL_MONTHLY_ENTERPRISE
if "enterprise" in (product_name or "").lower()
else CHANNEL_CURRENT
)
def is_m365_apps(product_name: str) -> bool:
"""True for syscollector entries like 'Microsoft 365 Apps for enterprise'."""
n = (product_name or "").lower()
return "microsoft 365 apps" in n or "office 365 proplus" in n
# ============================================================
# page fetch + parse
# ============================================================
def _parse_security_page(html: str) -> List[dict]:
"""Parse the MS365 security page into a list of monthly releases.
Each release: {
"date": "May 12, 2026",
"channel_builds": {channel_name: [(major, rev), ...]}, # max = newest
"cves": ["CVE-2026-40361", ...], # every CVE in the section
}
Releases are returned in page order (newest first).
"""
# Flatten to text in document order. The page is a linear sequence of
# date headings -> channel/build lines -> product headings -> CVE
# bullets, so segmenting the flattened text by date heading is robust
# against markup churn.
doc = lxml.html.fromstring(html)
for bad in doc.xpath("//script | //style | //nav | //header | //footer"):
bad.getparent().remove(bad)
body = doc.xpath("//main") or [doc]
text = body[0].text_content()
# Find date-heading anchors and slice between them.
matches = list(_DATE_RE.finditer(text))
releases: List[dict] = []
for i, m in enumerate(matches):
start = m.end()
end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
section = text[start:end]
date_label = f"{m.group(1)} {m.group(2)}, {m.group(3)}"
channel_builds: Dict[str, List[Tuple[int, int]]] = {}
for bm in _BUILD_RE.finditer(section):
channel = bm.group(1).strip()
build = parse_build(bm.group(3))
if build:
channel_builds.setdefault(channel, []).append(build)
if not channel_builds:
# Not a real release section (e.g. intro paragraph mentioning
# a date) — skip.
continue
cves = sorted({c.upper() for c in _CVE_RE.findall(section)})
if not cves:
continue
releases.append({
"date": date_label,
"channel_builds": channel_builds,
"cves": cves,
})
return releases
def _load_cache(db: Session) -> Optional[List[dict]]:
ts = db.query(Setting).filter(Setting.key == M365_CACHE_TS_KEY).first()
cache = db.query(Setting).filter(Setting.key == M365_CACHE_KEY).first()
if not ts or not cache or not cache.value:
return None
try:
if datetime.now() - datetime.fromisoformat(ts.value) > timedelta(hours=M365_TTL_HOURS):
return None
return json.loads(cache.value)
except (ValueError, json.JSONDecodeError):
return None
def _store_cache(db: Session, releases: List[dict]) -> None:
s = db.query(Setting).filter(Setting.key == M365_CACHE_KEY).first()
if s:
s.value = json.dumps(releases)
else:
db.add(Setting(key=M365_CACHE_KEY, value=json.dumps(releases),
description="MS365 Apps security-updates parse cache (24h)"))
ts = db.query(Setting).filter(Setting.key == M365_CACHE_TS_KEY).first()
if ts:
ts.value = datetime.now().isoformat()
else:
db.add(Setting(key=M365_CACHE_TS_KEY, value=datetime.now().isoformat(),
description="Timestamp of last MS365 page parse"))
db.commit()
def fetch_security_data(db: Session, force_refresh: bool = False) -> List[dict]:
"""Return parsed monthly releases, cached 24h in the settings table."""
if not force_refresh:
cached = _load_cache(db)
if cached is not None:
return cached
try:
with httpx.Client(timeout=HTTP_TIMEOUT, follow_redirects=True,
headers={"User-Agent": "TrueVuln/1.0"}) as client:
resp = client.get(M365_SECURITY_URL)
resp.raise_for_status()
html = resp.text
except httpx.HTTPError as e:
raise M365Error(f"could not fetch MS365 security page: {e}") from e
releases = _parse_security_page(html)
if not releases:
raise M365Error("MS365 page parsed to zero releases — layout changed?")
_store_cache(db, releases)
logger.info("MS365: parsed %d monthly releases", len(releases))
return releases
# ============================================================
# detection
# ============================================================
def _channel_max(release: dict, channel: str) -> Optional[Tuple[int, int]]:
"""Newest (max) build for `channel` in a release, as a tuple."""
builds = release.get("channel_builds", {}).get(channel)
if not builds:
return None
# builds may be lists from JSON -> normalise to tuples
return max(tuple(b) for b in builds)
def _os_owned_cve_ids(db: Session) -> set:
"""CVE ids the cvelistV5 Windows-OS registry owns. The MS 365 Apps page
dumps OS-level components (GDI, MSXML, ) under its "Office suite" heading
CVEs that are really Windows-OS bugs Office just bundles (e.g.
CVE-2026-50387, a Windows GDI vuln). Those belong to scan_asset_os, which
knows the correct build + MSRC KB; attributing them to M365 is wrong AND
upsert clobbers the correct OS finding on the same (cve, asset) row."""
try:
from app.services import cvelistv5_scan_service
idx = cvelistv5_scan_service.load_index(db) or {}
return {(e.get("cve") or "").upper()
for e in (idx.get("windows") or []) if e.get("cve")}
except Exception as e:
logger.debug("M365: OS-CVE dedup index unavailable: %s", e)
return set()
def detect_missing_cves(
releases: List[dict],
*,
installed_version: str,
channel: str,
) -> dict:
"""Compare an installed M365 build against the parsed releases.
Returns {
"affected": bool,
"installed_build": "19929.20172" or None,
"latest_build": "19929.20162" or None, # newest patched, this channel
"missing_cves": [ ... ], # union, deduped
"behind_releases": [ "May 12, 2026", ... ],
}
"""
out = {
"affected": False,
"installed_build": None,
"latest_build": None,
"missing_cves": [],
"behind_releases": [],
}
installed = parse_build(installed_version)
if not installed:
return out
out["installed_build"] = f"{installed[0]}.{installed[1]}"
# Newest patched build for this channel across the whole page.
channel_builds = [b for r in releases if (b := _channel_max(r, channel))]
if not channel_builds:
return out
latest = max(channel_builds)
out["latest_build"] = f"{latest[0]}.{latest[1]}"
if installed >= latest:
return out # fully patched -> unaffected
# Behind: union CVEs from every section whose channel build the host
# has not reached.
out["affected"] = True
cve_set: set = set()
for r in releases:
b = _channel_max(r, channel)
if b and installed < b:
cve_set.update(r.get("cves", []))
out["behind_releases"].append(r.get("date"))
out["missing_cves"] = sorted(cve_set)
return out
def upsert_m365_vulnerability(
db: Session,
*,
asset_id: int,
cve_id: str,
product_name: str,
installed_version: str,
fixed_build: Optional[str],
) -> Tuple[Optional[int], bool]:
"""Create/refresh a real-CVE M365 vuln row. Returns (id, was_created).
CVSS/severity are left as a neutral placeholder; the nightly
enrichment (EPSS/KEV/NVD dates) and the Correct-CVSS job refine them.
These are real CVE ids, so they enrich like any other CVE.
"""
from app.models.vulnerability import (
Vulnerability, VulnerabilitySeverity, VulnerabilityStatus,
)
cve_id = cve_id.upper()
existing = (
db.query(Vulnerability)
.filter(Vulnerability.cve_id == cve_id, Vulnerability.asset_id == asset_id)
.first()
)
title = f"{product_name}{cve_id} (Microsoft 365 Apps security update)"
desc = (
f"{cve_id} affects {product_name} and is fixed by a Microsoft 365 "
f"Apps security update not yet applied on this host.\n"
f"Installed build: {installed_version}. Fixed in build: "
f"{fixed_build or 'unknown'} or later — update via the configured "
f"Office update channel.\n\n"
f"Detection source: this finding comes from the host's installed "
f"Microsoft 365 Apps build (Wazuh syscollector inventory) compared "
f"against the Microsoft 365 Apps security-updates release notes — "
f"M365 Apps fixes are NOT published to NVD and are NOT seen by "
f"Wazuh's vulnerability detector.\n"
f"Severity / CVSS / dates are enriched from MSRC + CISA Vulnrichment "
f"/ cvelistV5 for this real CVE id (see the Remediation section for "
f"the Microsoft (MSRC) KB / advisory details)."
)
if existing:
existing.title = title[:500]
existing.description = desc
existing.package_name = product_name[:255]
existing.package_version = installed_version[:100]
existing.fixed_version = (fixed_build or None)
from app.services.audit_events import reopen_if_patched
reopen_if_patched(db, existing, reason="Microsoft 365 Apps check reports this CVE again", source="m365_check")
existing.detected_at = datetime.now()
try:
existing.refresh_scores()
except Exception:
pass
return existing.id, False
vuln = Vulnerability(
cve_id=cve_id,
asset_id=asset_id,
cvss_score=None,
severity=VulnerabilitySeverity.medium, # placeholder; enrichment refines
status=VulnerabilityStatus.open,
title=title[:500],
description=desc,
package_name=product_name[:255],
package_version=installed_version[:100],
fixed_version=(fixed_build or None),
detected_at=datetime.now(),
sources='["microsoft365-apps"]',
first_detected_by="m365_check",
)
db.add(vuln)
db.flush()
try:
vuln.refresh_scores()
except Exception:
pass
# Revisionssicher: initial detected-event for the new M365 finding.
try:
from app.services.audit_events import audit_new_vulnerabilities
audit_new_vulnerabilities(db, [vuln.id], source="m365_check")
except Exception:
pass
return vuln.id, True
# ============================================================
# orchestration (shared by the endpoint and the nightly job)
# ============================================================
_M365_SOURCE = "microsoft365-apps"
def _resolve_stale_m365(db: Session, asset, still_affected: set) -> int:
"""Mark M365-only OPEN findings patched when the host's current build has
caught up (CVE no longer in the missing set). Without this the check only
ever ADDED rows: a host that updated Office kept the old finding open with a
stale installed build forever (upsert refreshes package_version only while
the CVE is still missing). Mirrors the Defender/app-scan reconcile: drop OUR
source, close only when nobody else still reports it. Caller guarantees an
M365 install was actually seen on this asset (else 'patched' is unprovable).
"""
from app.models.vulnerability import Vulnerability, VulnerabilityStatus
rows = (db.query(Vulnerability)
.filter(Vulnerability.asset_id == asset.id,
Vulnerability.status == VulnerabilityStatus.open,
Vulnerability.sources.contains(f'"{_M365_SOURCE}"'))
.all())
resolved = 0
for v in rows:
if v.cve_id and v.cve_id.upper() in still_affected:
continue
v.remove_source(_M365_SOURCE)
if v.source_list:
continue
old_status = v.status
v.status = VulnerabilityStatus.patched
v.patched_at = datetime.now()
resolved += 1
try:
from app.routers.vulnerabilities import log_vulnerability_change
log_vulnerability_change(
db, None, v.id, old_status, v.status,
reason=f"Installed Microsoft 365 Apps build on {asset.hostname} "
f"now includes the fix for this CVE",
cve_id=v.cve_id, source="m365_check",
)
except Exception as e:
logger.warning("audit log for M365 auto-resolve failed (vuln_id=%s): %s", v.id, e)
return resolved
def run_m365_check(db: Session, wazuh, asset_id: Optional[int] = None) -> dict:
"""Walk Wazuh-linked assets, detect M365-Apps CVE exposure, upsert rows.
`wazuh` is an already-configured WazuhClient (the caller owns its
lifecycle, matching the eol-check pattern).
"""
from app.models.asset import Asset
releases = fetch_security_data(db)
os_cve_ids = _os_owned_cve_ids(db)
q = db.query(Asset).filter(Asset.wazuh_agent_id.isnot(None))
if asset_id is not None:
q = q.filter(Asset.id == asset_id)
assets = q.all()
stats = {
"assets_scanned": 0,
"m365_installs": 0,
"assets_affected": 0,
"cve_findings_total": 0,
"cve_findings_new": 0,
"releases_parsed": len(releases),
"errors": [],
}
touched_cves: set = set()
for asset in assets:
try:
pkgs = wazuh.get_packages(asset.wazuh_agent_id) or []
except Exception as e:
stats["errors"].append(f"asset {asset.id} ({asset.hostname}): {e}")
continue
stats["assets_scanned"] += 1
# An asset can list the same product per language pack (de-de,
# en-us, .proof, ...) — collapse to one detection per build.
seen_builds: set = set()
asset_affected = False
m365_install_seen = False
keep_open: set = set() # CVEs still missing on the current build
for pkg in pkgs:
name = (pkg.get("name") or "").strip()
version = (pkg.get("version") or "").strip()
if not name or not version or not is_m365_apps(name):
continue
stats["m365_installs"] += 1
m365_install_seen = True
channel = channel_for_product(name)
key = (channel, version)
if key in seen_builds:
continue
seen_builds.add(key)
result = detect_missing_cves(
releases, installed_version=version, channel=channel
)
if not result["affected"]:
continue
asset_affected = True
for cve_id in result["missing_cves"]:
if cve_id.upper() in os_cve_ids:
continue # Windows-OS CVE — owned by scan_asset_os, not M365
try:
_, created = upsert_m365_vulnerability(
db,
asset_id=asset.id,
cve_id=cve_id,
product_name=name,
installed_version=version,
fixed_build=result["latest_build"],
)
stats["cve_findings_total"] += 1
touched_cves.add(cve_id.upper())
keep_open.add(cve_id.upper())
if created:
stats["cve_findings_new"] += 1
except Exception as e:
logger.warning(
"M365 upsert failed (%s on asset %s): %s",
cve_id, asset.id, e,
)
if asset_affected:
stats["assets_affected"] += 1
# Resolve findings the host has since patched — only when we actually
# saw an M365 install (else 'patched' is unprovable, same guard as the
# Defender/app-scan reconcile against an empty read).
if m365_install_seen:
stats["resolved"] = stats.get("resolved", 0) + _resolve_stale_m365(
db, asset, keep_open)
db.commit()
# Pull real metrics for the M365 CVEs right at check-in: these are real
# CVE ids absent from Wazuh/NVD, so the CVSS/severity placeholder is
# corrected from the vulnrichment → cvelistV5 → NVD cascade, and dates
# via the enrichment service. The custom source note in `description`
# is preserved (the override path never touches description).
if touched_cves:
cve_list = sorted(touched_cves)
try:
from app.services.vuln_override_service import correct_vulnerability_scores
cstats = correct_vulnerability_scores(db, cve_ids=cve_list)
stats["cvss_corrected"] = cstats.get("updated", 0)
except Exception as e:
logger.warning("M365: CVSS correction failed (non-fatal): %s", e)
try:
from app.models.vulnerability import Vulnerability
from app.services.enrichment_service import enrich_vulnerabilities
fresh = db.query(Vulnerability).filter(Vulnerability.cve_id.in_(cve_list)).all()
if fresh:
enrich_vulnerabilities(db, fresh) # EPSS/KEV/EUVD + NVD dates
except Exception as e:
logger.warning("M365: enrichment failed (non-fatal): %s", e)
try:
stats["meta_filled"] = apply_real_cve_metadata(db, cve_list)
except Exception as e:
logger.warning("M365: real CVE metadata fill failed (non-fatal): %s", e)
logger.info(
"M365 check: %d assets scanned, %d installs, %d affected, "
"%d CVE rows (%d new), %d cvss-corrected",
stats["assets_scanned"], stats["m365_installs"],
stats["assets_affected"], stats["cve_findings_total"],
stats["cve_findings_new"], stats.get("cvss_corrected", 0),
)
return stats
_M365_SOURCE_NOTE = (
"\n\n— Detected via the Microsoft 365 Apps security-updates page "
"(installed build vs. patched channel build; not published to NVD and "
"not seen by Wazuh). Title/description from CVE.org cvelistV5; "
"severity/CVSS/dates via MSRC + CISA Vulnrichment."
)
def _fetch_cve_title_desc(cve_id: str) -> Tuple[Optional[str], Optional[str]]:
"""Real CVE title + English description from CVE.org cvelistV5 raw."""
m = re.fullmatch(r"CVE-(\d{4})-(\d+)", cve_id.upper())
if not m:
return None, None
year, num = m.group(1), m.group(2)
url = (f"https://raw.githubusercontent.com/CVEProject/cvelistV5/main/"
f"cves/{year}/{int(num) // 1000}xxx/{cve_id.upper()}.json")
try:
with httpx.Client(timeout=15.0, follow_redirects=True,
headers={"User-Agent": "TrueVuln/1.0"}) as c:
r = c.get(url)
if r.status_code != 200:
return None, None
cna = (r.json().get("containers") or {}).get("cna") or {}
except (httpx.HTTPError, ValueError):
return None, None
title = cna.get("title")
desc = None
for d in cna.get("descriptions") or []:
if (d.get("lang") or "").lower().startswith("en"):
desc = d.get("value")
break
return title, desc
def apply_real_cve_metadata(db: Session, cve_ids: list) -> int:
"""Fill the REAL CVE title + description (cvelistV5) on M365 findings,
keeping a trailing note that the finding originated from the M365 Apps
source. CVSS-correction deliberately leaves title/description alone, so
this runs separately. Returns rows updated. Caller commits."""
from app.models.vulnerability import Vulnerability
updated = 0
for cve_id in sorted({c.upper() for c in cve_ids if c}):
title, desc = _fetch_cve_title_desc(cve_id)
if not title and not desc:
continue
rows = (
db.query(Vulnerability)
.filter(
Vulnerability.cve_id == cve_id,
Vulnerability.first_detected_by == "m365_check",
)
.all()
)
for v in rows:
if title:
v.title = title[:500]
if desc:
v.description = desc.strip() + _M365_SOURCE_NOTE
updated += 1
if updated:
db.commit()
return updated
def run_m365_for_packages(db: Session, asset, packages: list) -> int:
"""Source-agnostic M365-Apps CVE detection for one asset's installed
apps (e.g. Intune detectedApps). Same build-vs-channel logic as
run_m365_check; pulls real metrics for new CVEs. Returns findings
upserted. Caller commits."""
try:
releases = fetch_security_data(db)
except M365Error as e:
logger.debug("M365-for-packages: security data unavailable: %s", e)
return 0
os_cve_ids = _os_owned_cve_ids(db)
count = 0
touched: set = set()
seen: set = set()
m365_install_seen = False
keep_open: set = set()
for pkg in packages or []:
name = (pkg.get("name") or "").strip()
version = (pkg.get("version") or "").strip()
if not name or not version or not is_m365_apps(name):
continue
m365_install_seen = True
channel = channel_for_product(name)
key = (channel, version)
if key in seen:
continue
seen.add(key)
result = detect_missing_cves(releases, installed_version=version, channel=channel)
if not result["affected"]:
continue
for cve_id in result["missing_cves"]:
if cve_id.upper() in os_cve_ids:
continue # Windows-OS CVE — owned by scan_asset_os, not M365
try:
upsert_m365_vulnerability(
db, asset_id=asset.id, cve_id=cve_id, product_name=name,
installed_version=version, fixed_build=result["latest_build"],
)
count += 1
touched.add(cve_id.upper())
keep_open.add(cve_id.upper())
except Exception as e:
logger.warning("M365-for-packages upsert failed (%s on asset %s): %s", cve_id, asset.id, e)
# Resolve findings the host has since patched (see run_m365_check).
if m365_install_seen:
_resolve_stale_m365(db, asset, keep_open)
if touched:
try:
from app.services.vuln_override_service import correct_vulnerability_scores
correct_vulnerability_scores(db, cve_ids=sorted(touched))
except Exception as e:
logger.debug("M365-for-packages CVSS correction failed: %s", e)
try:
apply_real_cve_metadata(db, sorted(touched))
except Exception as e:
logger.debug("M365-for-packages metadata fill failed: %s", e)
return count
+247
View File
@@ -0,0 +1,247 @@
"""
EOL/EOS + Android patch-level checks for MDM (Intune) mobile devices.
Reuses eol_service (endoflife.date fetch/cache/EOLStatus/upsert). The only
NEW work is mapping an Intune device model endoflife slug + release, since
endoflife keys phones by marketing name (Galaxy S25 Ultra, iPhone 15 Pro)
while Intune reports model identifiers:
- Apple reports the marketing name ("iPhone 15 Pro") fuzzy-match the
endoflife release label/name.
- Samsung reports an SM-code ("SM-S938B") with NO textual overlap a
curated SM-prefix endoflife-release table (extend as inventory grows).
Plus a cheap "Android security patch level is N months stale" finding from
Intune's androidSecurityPatchLevel — the control instance that checks whether
patches were actually applied, without scraping any vendor bulletin.
"""
from __future__ import annotations
import logging
import re
from datetime import date, datetime
from typing import Optional, Tuple
from sqlalchemy.orm import Session
from app.services import eol_service
logger = logging.getLogger(__name__)
# Curated Samsung SM-base-code → (endoflife slug, release `name`). Matched by
# prefix so the region/variant suffix (B/N/U/0/F…) is ignored: "SM-S938B"
# starts with "SM-S938". Names verified against endoflife.date.
# ponytail: curated; add a row when a new model shows up in inventory —
# unknown models are skipped (no false-positive), not guessed.
_PHONE = "samsung-mobile"
_TAB = "samsung-galaxy-tab"
# SM-base-code (4 digits) → (endoflife slug, release `name`). Codes are
# unique per model, so no prefix collides with another. Slug per row routes
# tablets (SM-X/T/P) to samsung-galaxy-tab. Names verified against the live
# endoflife.date API.
_SAMSUNG: list[Tuple[str, str, str]] = [
# --- Galaxy S25 / S24 / S23 / S22 (base / + / Ultra / FE) ---
("SM-S938", _PHONE, "galaxy-s25-ultra"), ("SM-S936", _PHONE, "galaxy-s25+"), ("SM-S931", _PHONE, "galaxy-s25"), ("SM-S731", _PHONE, "galaxy-s25-fe"),
("SM-S928", _PHONE, "galaxy-s24-ultra"), ("SM-S926", _PHONE, "galaxy-s24+"), ("SM-S921", _PHONE, "galaxy-s24"), ("SM-S721", _PHONE, "galaxy-s24-fe"),
("SM-S918", _PHONE, "galaxy-s23-ultra"), ("SM-S916", _PHONE, "galaxy-s23+"), ("SM-S911", _PHONE, "galaxy-s23"), ("SM-S711", _PHONE, "galaxy-s23-fe"),
("SM-S908", _PHONE, "galaxy-s22-ultra"), ("SM-S906", _PHONE, "galaxy-s22+"), ("SM-S901", _PHONE, "galaxy-s22"),
# --- Galaxy S21 / S20 (note the -5g suffix on S21) ---
("SM-G998", _PHONE, "galaxy-s21-ultra-5g"), ("SM-G996", _PHONE, "galaxy-s21+-5g"), ("SM-G991", _PHONE, "galaxy-s21-5g"), ("SM-G990", _PHONE, "galaxy-s21-fe-5g"),
("SM-G988", _PHONE, "galaxy-s20-ultra-5g"), ("SM-G986", _PHONE, "galaxy-s20+-5g"), ("SM-G985", _PHONE, "galaxy-s20+"),
("SM-G981", _PHONE, "galaxy-s20-5g"), ("SM-G980", _PHONE, "galaxy-s20"), ("SM-G781", _PHONE, "galaxy-s20-fe-5g"), ("SM-G780", _PHONE, "galaxy-s20-fe"),
# --- Galaxy Note 20 / 10 ---
("SM-N986", _PHONE, "galaxy-note20-ultra-5g"), ("SM-N985", _PHONE, "galaxy-note20-ultra"), ("SM-N981", _PHONE, "galaxy-note20-5g"), ("SM-N980", _PHONE, "galaxy-note20"),
("SM-N976", _PHONE, "galaxy-note10+-5g"), ("SM-N975", _PHONE, "galaxy-note10+"), ("SM-N971", _PHONE, "galaxy-note10-5g"), ("SM-N970", _PHONE, "galaxy-note10"), ("SM-N770", _PHONE, "galaxy-note10-lite"),
# --- Galaxy Z Fold / Flip ---
("SM-F966", _PHONE, "galaxy-z-fold7"), ("SM-F956", _PHONE, "galaxy-z-fold6"), ("SM-F946", _PHONE, "galaxy-z-fold5"), ("SM-F936", _PHONE, "galaxy-z-fold4"), ("SM-F926", _PHONE, "galaxy-z-fold3-5g"), ("SM-F916", _PHONE, "galaxy-z-fold2-5g"),
("SM-F766", _PHONE, "galaxy-z-flip7"), ("SM-F741", _PHONE, "galaxy-z-flip6"), ("SM-F731", _PHONE, "galaxy-z-flip5"), ("SM-F721", _PHONE, "galaxy-z-flip4"), ("SM-F711", _PHONE, "galaxy-z-flip3-5g"),
# --- Galaxy A-series (5G + LTE) ---
("SM-A576", _PHONE, "galaxy-a57-5g"), ("SM-A566", _PHONE, "galaxy-a56-5g"), ("SM-A556", _PHONE, "galaxy-a55-5g"), ("SM-A546", _PHONE, "galaxy-a54-5g"), ("SM-A536", _PHONE, "galaxy-a53-5g"),
("SM-A376", _PHONE, "galaxy-a37-5g"), ("SM-A366", _PHONE, "galaxy-a36-5g"), ("SM-A356", _PHONE, "galaxy-a35-5g"), ("SM-A346", _PHONE, "galaxy-a34-5g"), ("SM-A336", _PHONE, "galaxy-a33-5g"),
("SM-A266", _PHONE, "galaxy-a26-5g"), ("SM-A256", _PHONE, "galaxy-a25-5g"), ("SM-A166", _PHONE, "galaxy-a16-5g"), ("SM-A165", _PHONE, "galaxy-a16"),
("SM-A156", _PHONE, "galaxy-a15-5g"), ("SM-A155", _PHONE, "galaxy-a15"), ("SM-A146", _PHONE, "galaxy-a14-5g"), ("SM-A145", _PHONE, "galaxy-a14"),
# --- Galaxy XCover (rugged business) ---
("SM-G556", _PHONE, "galaxy-xcover7"), ("SM-G736", _PHONE, "galaxy-xcover6-pro"), ("SM-G525", _PHONE, "galaxy-xcover5"), ("SM-G715", _PHONE, "galaxy-xcover-pro"),
("SM-G398", _PHONE, "galaxy-xcover-4s"), ("SM-G390", _PHONE, "galaxy-xcover-4"), ("SM-G389", _PHONE, "galaxy-xcover3-g389f"), ("SM-G388", _PHONE, "galaxy-xcover-3"),
# --- Galaxy Tab S10 / S9 / S8 / S7 / S6 (SM-X newer, SM-T/P older) ---
("SM-X926", _TAB, "galaxy-tab-s10-ultra"), ("SM-X826", _TAB, "galaxy-tab-s10+"),
("SM-X916", _TAB, "galaxy-tab-s9-ultra"), ("SM-X816", _TAB, "galaxy-tab-s9+"), ("SM-X716", _TAB, "galaxy-tab-s9"), ("SM-X710", _TAB, "galaxy-tab-s9"),
("SM-X616", _TAB, "galaxy-tab-s9-fe+"), ("SM-X610", _TAB, "galaxy-tab-s9-fe+"), ("SM-X516", _TAB, "galaxy-tab-s9-fe"), ("SM-X510", _TAB, "galaxy-tab-s9-fe"),
("SM-X906", _TAB, "galaxy-tab-s8-ultra"), ("SM-X806", _TAB, "galaxy-tab-s8+"), ("SM-X706", _TAB, "galaxy-tab-s8"), ("SM-X700", _TAB, "galaxy-tab-s8"),
("SM-T976", _TAB, "galaxy-tab-s7+"), ("SM-T970", _TAB, "galaxy-tab-s7+"), ("SM-T875", _TAB, "galaxy-tab-s7"), ("SM-T870", _TAB, "galaxy-tab-s7"), ("SM-T736", _TAB, "galaxy-tab-s7-fe"), ("SM-T730", _TAB, "galaxy-tab-s7-fe"),
("SM-T866", _TAB, "galaxy-tab-s6"), ("SM-T860", _TAB, "galaxy-tab-s6"), ("SM-P625", _TAB, "galaxy-tab-s6-lite-2024"), ("SM-P620", _TAB, "galaxy-tab-s6-lite-2024"), ("SM-P619", _TAB, "galaxy-tab-s6-lite"), ("SM-P613", _TAB, "galaxy-tab-s6-lite"), ("SM-P615", _TAB, "galaxy-tab-s6-lite-2020"), ("SM-P610", _TAB, "galaxy-tab-s6-lite-2020"),
# --- Galaxy Tab A (budget) ---
("SM-X236", _TAB, "galaxy-tab-a11+"), ("SM-X230", _TAB, "galaxy-tab-a11+"), ("SM-X135", _TAB, "galaxy-tab-a11"), ("SM-X130", _TAB, "galaxy-tab-a11"),
("SM-X216", _TAB, "galaxy-tab-a9+"), ("SM-X210", _TAB, "galaxy-tab-a9+"), ("SM-X116", _TAB, "galaxy-tab-a9"), ("SM-X110", _TAB, "galaxy-tab-a9"), ("SM-X205", _TAB, "galaxy-tab-a8"), ("SM-X200", _TAB, "galaxy-tab-a8"),
("SM-T350", _TAB, "galaxy-tab-a-8.0-2015"), ("SM-T280", _TAB, "galaxy-tab-a-7.0-2016"),
# --- Galaxy Tab Active (rugged tablets) ---
("SM-X356", _TAB, "galaxy-tab-active5-pro"), ("SM-X306", _TAB, "galaxy-tab-active5"), ("SM-X300", _TAB, "galaxy-tab-active5"), ("SM-T575", _TAB, "galaxy-tab-active3"),
("SM-T395", _TAB, "galaxy-tab-active2"), ("SM-T365", _TAB, "galaxy-tab-active-lte"), ("SM-T360", _TAB, "galaxy-tab-active"),
]
_STALE_CVE_ID = "ANDROID-PATCH-LEVEL-STALE"
def _norm(s: Optional[str]) -> str:
return re.sub(r"[^a-z0-9]+", "-", (s or "").lower()).strip("-")
def _resolve_device(manuf: str, model: str, os_name: str):
"""→ (slug, matcher) or None. matcher = {'kind':'name','name':...} for the
Samsung table, or {'kind':'apple','model':...} for Apple fuzzy match."""
m = model or ""
mu = (manuf or "").lower()
ml = m.lower()
osl = (os_name or "").lower()
if "apple" in mu or ml.startswith("ipad") or ml.startswith("iphone") or "ios" in osl:
slug = "ipad" if ("ipad" in ml or "ipados" in osl) else "iphone"
return slug, {"kind": "apple", "model": m}
if "samsung" in mu or re.match(r"sm-[a-z]\d", ml):
up = m.upper()
for prefix, slug, name in _SAMSUNG:
if up.startswith(prefix):
return slug, {"kind": "name", "name": name}
logger.debug("mobile-eol: unmapped Samsung model %s", m)
return None
def _find_release(data: dict, matcher: dict) -> Optional[dict]:
rels = ((data.get("result") or {}).get("releases") or []) if isinstance(data, dict) else []
if matcher["kind"] == "name":
return next((r for r in rels if r.get("name") == matcher["name"]), None)
target = _norm(matcher["model"]) # "iphone-15-pro-max" / "ipad-air-5th-generation"
for r in rels:
label = r.get("label") or ""
cands = {_norm(r.get("name")), _norm(label),
_norm("iphone " + label), _norm("ipad " + label)}
if target in cands:
return r
return None
def _parse_patch_date(raw) -> Optional[date]:
s = str(raw or "").strip()[:10]
if not s:
return None
try:
return datetime.strptime(s, "%Y-%m-%d").date()
except ValueError:
return None
def _upsert_android_stale(db: Session, asset, patch_level: str, age_days: int) -> bool:
"""Pseudo-finding: the device's Android security patch level is stale.
Stable cve_id per asset idempotent. Returns True on create."""
from app.models.vulnerability import Vulnerability, VulnerabilitySeverity, VulnerabilityStatus
months = age_days // 30
if age_days >= 365:
sev, cvss = VulnerabilitySeverity.high, 8.0
elif age_days >= 180:
sev, cvss = VulnerabilitySeverity.medium, 5.5
else:
sev, cvss = VulnerabilitySeverity.low, 3.0
title = f"Android security patch level {months} months behind ({patch_level})"
desc = (f"Intune reports this device's Android security patch level as {patch_level} "
f"{age_days} days ({months} months) old. Monthly Android/OEM security "
f"patches since then have not been applied, so any CVE fixed in those "
f"bulletins remains open regardless of MDM patch policy.")
existing = (db.query(Vulnerability)
.filter(Vulnerability.cve_id == _STALE_CVE_ID, Vulnerability.asset_id == asset.id)
.first())
if existing:
existing.severity = sev
existing.cvss_score = cvss
existing.title = title[:500]
existing.description = desc
existing.package_version = str(patch_level)[:100]
existing.detected_at = datetime.now()
from app.services.audit_events import reopen_if_patched
reopen_if_patched(db, existing, reason="Mobile EOL/patch-level check reports this finding again", source="mobile_eol")
try:
existing.refresh_scores()
except Exception:
pass
return False
row = Vulnerability(
cve_id=_STALE_CVE_ID, asset_id=asset.id, cvss_score=cvss, severity=sev,
status=VulnerabilityStatus.open, title=title[:500], description=desc,
package_name="Android Security Patch Level", package_version=str(patch_level)[:100],
detected_at=datetime.now(), sources='["intune"]', first_detected_by="intune",
)
db.add(row)
db.flush()
try:
row.refresh_scores()
except Exception:
pass
return True
def check_device(db: Session, asset, device: dict) -> int:
"""EOL/EOS for the device model + Android patch-level staleness.
Returns findings upserted. Caller commits."""
count = 0
manuf = (device.get("manufacturer") or "").strip()
model = (device.get("model") or "").strip()
os_name = (device.get("operatingSystem") or "").strip()
os_version = (device.get("osVersion") or "").strip()
# 1) Device-model EOL/EOS via endoflife.date.
try:
res = _resolve_device(manuf, model, os_name)
if res:
slug, matcher = res
data = eol_service.fetch_product(db, slug)
rel = _find_release(data, matcher) if data else None
if rel:
status = eol_service._build_eol_status(rel, slug)
if status.is_eol or status.is_eol_soon or status.is_eoas:
# product_name is the VENDOR only — upsert_eol_vulnerability
# appends the release label itself, so passing "Samsung
# Galaxy Tab A8" would double it ("…A8 Galaxy Tab A8").
# endoflife labels: iPhone lacks the "iPhone" prefix, iPad
# and Samsung labels already carry it.
vendor = {"iphone": "Apple iPhone", "ipad": "Apple",
"samsung-mobile": "Samsung",
"samsung-galaxy-tab": "Samsung"}.get(slug, (manuf or "Device").title())
vid, _ = eol_service.upsert_eol_vulnerability(
db, asset_id=asset.id, product_name=vendor,
installed_version=(f"{os_name} {os_version}".strip() or "unknown"),
status=status,
)
# Keep the full device name in the package column.
if vid:
from app.models.vulnerability import Vulnerability
row = db.query(Vulnerability).filter(Vulnerability.id == vid).first()
if row:
row.package_name = f"{vendor} {rel.get('label') or model}".strip()[:255]
count += 1
except Exception as e:
logger.debug("mobile-eol device check failed for %s: %s", asset.hostname, e)
# 2) Android security-patch-level staleness + per-CVE detail from ASB.
if "android" in os_name.lower():
patch = device.get("androidSecurityPatchLevel")
d = _parse_patch_date(patch)
if d:
age = (date.today() - d).days
if age >= 90: # <90d = within a normal monthly-patch window
try:
_upsert_android_stale(db, asset, str(patch)[:10], age)
count += 1
except Exception as e:
logger.debug("mobile-eol android-patch failed for %s: %s", asset.hostname, e)
# Per-CVE findings for the months the device is behind (Google ASB).
try:
from app.services import android_cve_service
count += android_cve_service.check_android_cves(db, asset, patch, manufacturer=manuf)
except Exception as e:
logger.debug("android-asb CVEs failed for %s: %s", asset.hostname, e)
return count
+266
View File
@@ -0,0 +1,266 @@
"""
Mozilla Foundation Security Advisories (MFSA) per-CVE severity + fix train.
Source: github.com/mozilla/foundation-security-advisories (announce/YYYY/*.yml).
Each MFSA yml maps CVE {impact, title} plus an advisory-level `fixed_in`
(e.g. ["Firefox 128", "Firefox ESR 115.13"]). Mozilla publishes an `impact`
rating (critical/high/moderate/low) but NO numeric CVSS so this is a
SEVERITY + description source, not a CVSS source. It fills the gap where fresh
Firefox CVEs have no score yet in NVD/cvelistV5.
`fixed_in` is the AUTHORITATIVE regular-vs-ESR discriminator (an advisory whose
fixed_in lists only "Firefox ESR …" does not affect regular Firefox) stored
here for the Firefox-scan ESR exclusion, cf. [[ghsa-unreviewed-no-version-range]]
sibling reference on why cvelistV5 alone can't tell them apart.
The parser is deliberately line-based (no PyYAML dependency): the MFSA schema is
regular top-level `fixed_in:` list, then an `advisories:` map of
` CVE-:` ` impact:` / ` title:`.
"""
import json
import logging
import re
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Tuple
from sqlalchemy.orm import Session
from app.models.setting import Setting
logger = logging.getLogger(__name__)
INDEX_SETTING = "mozilla_mfsa_index"
INDEX_TS_SETTING = "mozilla_mfsa_index_ts"
TTL_HOURS = 24
_API = "https://api.github.com/repos/mozilla/foundation-security-advisories"
_IMPACT_TO_SEV = {
"critical": "critical",
"high": "high",
"moderate": "medium",
"low": "low",
"none": "none",
}
_CVE_KEY = re.compile(r"^ (CVE-\d{4}-\d+):\s*$")
_IMPACT = re.compile(r"^ impact:\s*([A-Za-z]+)")
_TITLE = re.compile(r"^ title:\s*(.+?)\s*$")
_LIST_ITEM = re.compile(r"^-\s*(.+?)\s*$")
def _gh_headers(db: Session) -> dict:
h = {"Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28"}
try:
from app.auth.setting_crypto import read_setting_value
pat = (read_setting_value(db, "github_pat") or "").strip()
if pat:
h["Authorization"] = f"Bearer {pat}"
except Exception:
pass
return h
def _is_esr_only(fixed_in: List[str]) -> bool:
"""True when every Firefox entry is an ESR build — the advisory does not
affect regular Firefox. 'Firefox ESR 115.13' ESR; 'Firefox 128' regular.
Thunderbird/other entries are ignored for the Firefox decision."""
ff = [f for f in fixed_in if "firefox" in f.lower()]
if not ff:
return False
return all("esr" in f.lower() for f in ff)
def _parse_yml(text: str) -> Tuple[List[str], Dict[str, dict]]:
"""Line-parse one MFSA yml → (fixed_in list, {cve: {impact, title}})."""
fixed_in: List[str] = []
cves: Dict[str, dict] = {}
section = None # "fixed_in" | "advisories" | None
cur = None
for line in text.splitlines():
if line.startswith("fixed_in:"):
section = "fixed_in"
continue
if line.startswith("advisories:"):
section = "advisories"
cur = None
continue
# A non-indented, non-list line ends the current top-level block.
if line and not line[0].isspace() and not line.startswith("-"):
section = None
cur = None
if section == "fixed_in":
m = _LIST_ITEM.match(line)
if m:
fixed_in.append(m.group(1))
elif section == "advisories":
mc = _CVE_KEY.match(line)
if mc:
cur = mc.group(1).upper()
cves[cur] = {}
continue
if cur:
mi = _IMPACT.match(line)
if mi:
cves[cur]["impact"] = mi.group(1).lower()
continue
mt = _TITLE.match(line)
if mt and "title" not in cves[cur]:
cves[cur]["title"] = mt.group(1)
return fixed_in, cves
def build_index(db: Session, years: Optional[List[int]] = None) -> Dict[str, dict]:
"""Walk the MFSA repo for the given years, build {cve: {sev, title,
fixed_in, esr_only}}, cache it. Defaults to current + previous year (the
window where CVEs are fresh enough that NVD may still lag)."""
import httpx
if years is None:
y = datetime.now().year
years = [y, y - 1]
index: Dict[str, dict] = {}
headers = _gh_headers(db)
with httpx.Client(timeout=20.0, follow_redirects=True, headers=headers) as client:
for year in years:
try:
r = client.get(f"{_API}/contents/announce/{year}")
if r.status_code == 403 and r.headers.get("x-ratelimit-remaining") == "0":
logger.warning("MFSA: GitHub rate limit hit — set github_pat for 5000/h")
break
if r.status_code != 200:
continue
files = [f for f in (r.json() or [])
if isinstance(f, dict) and str(f.get("name", "")).endswith(".yml")]
except Exception as e:
logger.debug("MFSA: listing %s failed: %s", year, e)
continue
for f in files:
url = f.get("download_url")
if not url:
continue
try:
rr = client.get(url)
if rr.status_code != 200:
continue
fixed_in, cves = _parse_yml(rr.text)
esr_only = _is_esr_only(fixed_in)
for cve_id, data in cves.items():
sev = _IMPACT_TO_SEV.get(data.get("impact") or "")
# First writer wins per CVE (a CVE can appear in several
# MFSAs for different products; the Firefox one is fine).
if cve_id not in index:
index[cve_id] = {
"sev": sev,
"title": data.get("title"),
"fixed_in": fixed_in,
"esr_only": esr_only,
}
except Exception as e:
logger.debug("MFSA: parse %s failed: %s", f.get("name"), e)
_store(db, index)
logger.info("MFSA index built: %d CVEs across years %s", len(index), years)
return index
def _store(db: Session, index: Dict[str, dict]) -> None:
for key, val in ((INDEX_SETTING, json.dumps(index)),
(INDEX_TS_SETTING, datetime.now().isoformat())):
row = db.query(Setting).filter(Setting.key == key).first()
if row:
row.value = val
else:
db.add(Setting(key=key, value=val))
db.commit()
def load_index(db: Session) -> Optional[Dict[str, dict]]:
ts = db.query(Setting).filter(Setting.key == INDEX_TS_SETTING).first()
row = db.query(Setting).filter(Setting.key == INDEX_SETTING).first()
if not ts or not row or not row.value:
return None
try:
if datetime.now() - datetime.fromisoformat(ts.value) > timedelta(hours=TTL_HOURS):
return None
return json.loads(row.value)
except (ValueError, json.JSONDecodeError):
return None
def get_index(db: Session) -> Dict[str, dict]:
"""Cached index, lazily (re)built when absent/stale. Build failure → {}."""
idx = load_index(db)
if idx is not None:
return idx
try:
return build_index(db)
except Exception as e:
logger.warning("MFSA index build failed: %s", e)
return {}
def apply_mozilla_severity(db: Session, cve_ids: List[str]) -> int:
"""Fill severity + description for Firefox CVEs from Mozilla's authoritative
impact rating. Only overrides severity when the vuln has NO CVSS-derived
value (cvss_score is None severity is a default placeholder); Mozilla has
no CVSS number so it must not clobber a real score-derived severity."""
from app.models.vulnerability import Vulnerability, VulnerabilitySeverity
wanted = [c.upper() for c in cve_ids if c]
if not wanted:
return 0
idx = get_index(db)
if not idx:
return 0
hits = [c for c in wanted if c in idx]
if not hits:
return 0
updated = 0
rows = db.query(Vulnerability).filter(Vulnerability.cve_id.in_(hits)).all()
for v in rows:
data = idx.get((v.cve_id or "").upper())
if not data:
continue
sev = data.get("sev")
if sev and v.cvss_score is None:
new_sev = getattr(VulnerabilitySeverity, sev, None)
if new_sev is not None and v.severity != new_sev:
v.severity = new_sev
updated += 1
if not v.description and data.get("title"):
v.description = data["title"]
if updated:
db.commit()
return updated
if __name__ == "__main__":
# ponytail: one self-check for the line parser + ESR discriminator — the two
# non-trivial bits. Run: python -m app.services.mozilla_advisory_service
sample = """announced: July 9th, 2024
impact: high
fixed_in:
- Firefox 128
- Firefox ESR 115.13
title: Security Vulnerabilities fixed in Firefox 128
advisories:
CVE-2024-6601:
title: Race condition in permission assignment
impact: moderate
reporter: Andreas Farre
CVE-2024-6602:
title: Memory corruption in NSS
impact: critical
"""
fixed_in, cves = _parse_yml(sample)
assert fixed_in == ["Firefox 128", "Firefox ESR 115.13"], fixed_in
assert cves["CVE-2024-6601"] == {"title": "Race condition in permission assignment",
"impact": "moderate"}, cves["CVE-2024-6601"]
assert cves["CVE-2024-6602"]["impact"] == "critical"
assert _is_esr_only(["Firefox 128", "Firefox ESR 115.13"]) is False # has regular
assert _is_esr_only(["Firefox ESR 115.13"]) is True # ESR only
assert _is_esr_only(["Thunderbird 128"]) is False # no firefox
assert _IMPACT_TO_SEV["moderate"] == "medium"
print("mozilla_advisory_service self-check OK")
+314
View File
@@ -0,0 +1,314 @@
"""
Microsoft product-lifecycle EOL detection (Plan O).
endoflife.date covers the popular products well, but Microsoft "exotics"
(and many server/SKU variants) are not there. Microsoft has no lifecycle
API. The only machine-readable primary source is the monthly Excel export
linked from:
https://learn.microsoft.com/en-us/lifecycle/products/export/
That page links a file like
https://download.microsoft.com/download/<guid>/eos-product-listing-<month>-<year>.xlsx
whose GUID + filename change every month so we scrape the page for the
current link, download the .xlsx, and parse it.
Sheet columns: ListingName | Release | AzureFeature | EndDate
e.g. ("Microsoft SQL Server 2014", "Service Pack 3", None, 2024-07-09)
A few true exotics are NOT in the export at all (the tester called these
out): Silverlight and the Visual C++ Redistributables have their own
single pages. Those are hardcoded below their EOL dates are fixed and
never change.
This is a *fallback* EOL source: the EOL check consults endoflife.date
first and only falls back here for names endoflife.date can't map.
"""
import json
import logging
import re
from datetime import datetime, date, timedelta
from typing import Dict, List, Optional
import httpx
from sqlalchemy.orm import Session
from app.models.setting import Setting
from app.services.eol_service import EOLStatus, EOL_SOON_DAYS, _days_until
logger = logging.getLogger(__name__)
EXPORT_PAGE_URL = "https://learn.microsoft.com/en-us/lifecycle/products/export/"
# The xlsx link on that page. GUID + month change monthly.
_XLSX_RE = re.compile(
r"https://download\.microsoft\.com/download/[^\s\"'>]+?"
r"eos-product-listing-[^\s\"'>]+?\.xlsx",
re.IGNORECASE,
)
# ---------- cache (settings table) ----------
MSL_CACHE_KEY = "ms_lifecycle_cache"
MSL_CACHE_TS_KEY = "ms_lifecycle_cache_updated_at"
MSL_TTL_HOURS = 24
# In-process memo. resolve_ms_lifecycle_eol() runs once PER PACKAGE across every
# synced device; without this, a fresh 24h DB cache still let each concurrent
# device re-download the export before the first _store_cache commit landed —
# the tester saw dozens of identical GET .../lifecycle/products/export/ per sync.
# This holds the parsed rows in the worker for a short window so one sync fetches
# at most once. ponytail: module-global memo, fine for a read-only reference list.
_MEM_ROWS: Optional[List[dict]] = None
_MEM_TS: float = 0.0
_MEM_TTL_SEC = 3600
# ---------- toggle ----------
SETTING_MSL_ENABLED = "ms_lifecycle_enabled"
HTTP_TIMEOUT = 60.0
class MSLifecycleError(Exception):
"""Raised when the MS lifecycle export cannot be fetched/parsed."""
# ============================================================
# hardcoded exotics (not in the export file)
# ============================================================
# {match_substring: (display_name, eol_date_iso)}. Matched case-insensitive
# against the syscollector product name. EOL dates are fixed/announced.
_HARDCODED_EOL: Dict[str, tuple] = {
# https://learn.microsoft.com/lifecycle/announcements/silverlight-end-of-support
"silverlight": ("Microsoft Silverlight", "2021-10-12"),
# Visual C++ Redistributables track their Visual Studio lifecycle. The
# runtimes from EOL Visual Studio versions are themselves out of
# support. (Latest 2015-2022 redist stays supported — not listed.)
# https://learn.microsoft.com/cpp/windows/latest-supported-vc-redist
"visual c++ 2013": ("Visual C++ 2013 Redistributable", "2024-04-09"),
"visual c++ 2012": ("Visual C++ 2012 Redistributable", "2023-01-10"),
"visual c++ 2010": ("Visual C++ 2010 Redistributable", "2020-07-14"),
"visual c++ 2008": ("Visual C++ 2008 Redistributable", "2018-04-10"),
}
def _normalise(name: str) -> str:
n = (name or "").lower()
n = n.replace("microsoft", " ").replace("(r)", " ").replace("®", " ")
n = re.sub(r"[^a-z0-9]+", " ", n)
return re.sub(r"\s+", " ", n).strip()
# ============================================================
# fetch + parse
# ============================================================
def _find_xlsx_url(html: str) -> Optional[str]:
m = _XLSX_RE.search(html)
return m.group(0) if m else None
def _parse_xlsx(content: bytes) -> List[dict]:
"""Parse the eos-product-listing workbook into row dicts."""
import io
import openpyxl
wb = openpyxl.load_workbook(io.BytesIO(content), read_only=True, data_only=True)
ws = wb[wb.sheetnames[0]]
rows = ws.iter_rows(values_only=True)
header = next(rows, None)
if not header:
return []
# Tolerate column reordering by mapping header names.
idx = {str(h).strip().lower(): i for i, h in enumerate(header) if h}
i_name = idx.get("listingname", 0)
i_rel = idx.get("release", 1)
i_end = idx.get("enddate", 3)
out: List[dict] = []
for r in rows:
if not r or len(r) <= i_end:
continue
name = r[i_name]
end = r[i_end]
if not name or end is None:
continue
if isinstance(end, (datetime, date)):
end_iso = end.strftime("%Y-%m-%d")
else:
# occasionally a string date — keep first 10 chars if ISO-ish
s = str(end).strip()
end_iso = s[:10] if re.match(r"\d{4}-\d{2}-\d{2}", s) else None
if not end_iso:
continue
out.append({
"name": str(name).strip(),
"release": str(r[i_rel]).strip() if (len(r) > i_rel and r[i_rel]) else "",
"end_date": end_iso,
})
return out
def _load_cache(db: Session) -> Optional[List[dict]]:
ts = db.query(Setting).filter(Setting.key == MSL_CACHE_TS_KEY).first()
cache = db.query(Setting).filter(Setting.key == MSL_CACHE_KEY).first()
if not ts or not cache or not cache.value:
return None
try:
if datetime.now() - datetime.fromisoformat(ts.value) > timedelta(hours=MSL_TTL_HOURS):
return None
return json.loads(cache.value)
except (ValueError, json.JSONDecodeError):
return None
def _store_cache(db: Session, rows: List[dict]) -> None:
c = db.query(Setting).filter(Setting.key == MSL_CACHE_KEY).first()
if c:
c.value = json.dumps(rows)
else:
db.add(Setting(key=MSL_CACHE_KEY, value=json.dumps(rows),
description="MS lifecycle EOL export cache (24h)"))
ts = db.query(Setting).filter(Setting.key == MSL_CACHE_TS_KEY).first()
if ts:
ts.value = datetime.now().isoformat()
else:
db.add(Setting(key=MSL_CACHE_TS_KEY, value=datetime.now().isoformat(),
description="Timestamp of last MS lifecycle export parse"))
db.commit()
def fetch_lifecycle_data(db: Session, force_refresh: bool = False) -> List[dict]:
"""Return parsed lifecycle rows, cached 24h in the settings table."""
global _MEM_ROWS, _MEM_TS
import time as _t
if not force_refresh:
# In-process memo first — collapses the per-package/per-device burst.
if _MEM_ROWS is not None and (_t.time() - _MEM_TS) < _MEM_TTL_SEC:
return _MEM_ROWS
cached = _load_cache(db)
if cached is not None:
_MEM_ROWS, _MEM_TS = cached, _t.time()
return cached
try:
with httpx.Client(timeout=HTTP_TIMEOUT, follow_redirects=True,
headers={"User-Agent": "Mozilla/5.0 TrueVuln/1.0"}) as client:
page = client.get(EXPORT_PAGE_URL)
page.raise_for_status()
xlsx_url = _find_xlsx_url(page.text)
if not xlsx_url:
_MEM_ROWS, _MEM_TS = [], _t.time()
raise MSLifecycleError("export xlsx link not found on the page (layout changed?)")
xlsx = client.get(xlsx_url)
xlsx.raise_for_status()
rows = _parse_xlsx(xlsx.content)
except httpx.HTTPError as e:
# Remember the FAILURE too. Without this every one of the thousands of
# scanned packages re-fetched the export page (tester saw an endless
# run of GET .../lifecycle/products/export/ and the UI timing out),
# because the memo was only ever set on success.
_MEM_ROWS, _MEM_TS = [], _t.time()
raise MSLifecycleError(f"could not fetch MS lifecycle export: {e}") from e
if not rows:
_MEM_ROWS, _MEM_TS = [], _t.time()
raise MSLifecycleError("MS lifecycle export parsed to zero rows")
_store_cache(db, rows)
_MEM_ROWS, _MEM_TS = rows, _t.time()
logger.info("MS lifecycle: parsed %d product rows", len(rows))
return rows
# ============================================================
# resolver
# ============================================================
def _status_from_end_date(
*, display_name: str, release: str, end_iso: str, installed_version: str
) -> EOLStatus:
days = _days_until(end_iso)
is_eol = days is not None and days < 0
is_soon = days is not None and 0 <= days <= EOL_SOON_DAYS
slug = "ms-lifecycle"
return EOLStatus(
is_eol=is_eol,
is_eoas=False,
is_maintained=not is_eol,
is_eol_soon=is_soon,
days_to_eol=days,
release_label=release or display_name,
release_name=(release or display_name)[:40],
eol_date=end_iso,
product_slug=slug,
latest_version=None,
)
def resolve_ms_lifecycle_eol(
db: Session, product_name: str, installed_version: str = ""
) -> Optional[EOLStatus]:
"""Resolve an EOL status for an MS product via hardcoded exotics first,
then the lifecycle export. Returns None when no match or no EOL signal.
"""
pname = product_name or ""
# 1) hardcoded exotics (Silverlight, old VC++ redists)
low = pname.lower()
for needle, (disp, end_iso) in _HARDCODED_EOL.items():
if needle in low:
st = _status_from_end_date(
display_name=disp, release="", end_iso=end_iso,
installed_version=installed_version,
)
if st.is_eol or st.is_eol_soon:
return st
return None
# 2) lifecycle export — match listing by normalised name, then take the
# LATEST end date among matching rows (the last service pack defines
# when security support truly ends).
try:
rows = fetch_lifecycle_data(db)
except MSLifecycleError as e:
logger.debug("MS lifecycle unavailable: %s", e)
return None
target = _normalise(pname)
if not target or len(target) < 4:
return None
matches = []
for row in rows:
rn = _normalise(row["name"])
if not rn:
continue
# Require the LISTING name to be contained in the product name —
# one direction only. The old bidirectional check also matched
# `target in rn`, which let a short product name like "Edge"
# (normalised from "Microsoft Edge", "microsoft" stripped) match
# the unrelated listing "Azure Stack Edge" → false EOL (tester
# screenshot: evergreen Edge browser flagged EOL 2024-03-31).
# "rn in target" keeps the legit cases: listing "SQL Server 2014"
# ⊂ product "Microsoft SQL Server 2014 Management Objects"; an
# exact-equal name is also covered (rn == target ⇒ rn in target).
if rn in target:
matches.append(row)
if not matches:
return None
# Prefer the latest NON-ESU end date: paid Extended Security Updates are
# an add-on most hosts don't have, so security-conservatively a product
# is EOL when its standard (extended) support ends, not when the
# purchasable ESU window closes. Fall back to the overall max only if
# every matching row is an ESU row.
def _is_esu(r: dict) -> bool:
rel = (r.get("release") or "").lower()
return "extended security update" in rel or "esu" in rel
non_esu = [r for r in matches if not _is_esu(r)]
best = max(non_esu or matches, key=lambda r: r["end_date"])
st = _status_from_end_date(
display_name=best["name"], release=best["release"],
end_iso=best["end_date"], installed_version=installed_version,
)
if st.is_eol or st.is_eol_soon:
return st
return None
+709
View File
@@ -0,0 +1,709 @@
"""
MSRC-driven OS CVE detection patch-level accurate, ahead of the Wazuh CTI feed.
Why this exists (and why NVD/cvelistV5 can't do it): Microsoft does not express
fixes as version ranges. NVD lists MS OS entries as rangeless CPEs
(`cpe:2.3:o:microsoft:windows_server_2016:-:*`, versionEndExcluding=null) and
cvelistV5 MS records use `lessThan: "publication"` neither says which BUILD
carries the fix, so neither can tell a patched host from an unpatched one.
MSRC's CVRF does: each Type-2 remediation carries a `FixedBuild` plus the
`ProductID`s it applies to, e.g.
CVE-2026-33834 FixedBuild 10.0.14393.9140 ProductID ['10816','10855'] KB5087537
ProductTree: 10816 -> "Windows Server 2016"
10855 -> "Windows Server 2016 (Server Core installation)"
So: index (product [cve, fixed_build]) from the monthly CVRF docs, then compare
an asset's installed OS build against the fixed build of its own servicing
branch. installed < fixed affected. Same curated-and-precise contract as the
other scanners: only products we can map to an asset are indexed, nothing is
guessed, and a host that is patched is never flagged.
Scope: Windows Server only for now its OS string names the product outright.
Client Windows (10/11) needs a buildrelease table before it can join.
"""
from __future__ import annotations
import json
import logging
import re
from datetime import datetime, timedelta
from typing import Dict, List, Optional
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
SOURCE_NAME = "msrc"
# v2: adding Microsoft Edge to _PRODUCTS changes what the index CONTAINS, and
# a cache built by the previous version has no edge key at all. Bumping the
# setting name retires that cache on deploy instead of serving it for another
# night (tester ran app-scan + MSRC refresh and still saw no Edge findings).
_INDEX_SETTING = "msrc_product_index_v2"
_INDEX_TTL = timedelta(hours=26) # rebuilt nightly; a missed night still serves
# Curated map: asset side (`match_re`) → MSRC ProductTree name (`msrc_re`).
#
# `kind` os = matched against asset.operating_system + asset.os_version
# pkg = matched against an installed-software name + its version
# `branch` True → a fix only speaks to hosts sharing its build prefix, because
# the 3rd segment IS the release (Windows Server 2016 = 14393;
# SharePoint 2019 = 10417 — verified stable across releases).
# False → the 3rd segment moves with every CU, so prefix-matching would
# silently never hit; identity comes from the NAME instead and
# the compare is a plain installed < fixed. Verified: SharePoint
# 2016 shows 5535/5539/5543/5552/5556 over six months.
#
# `msrc_re` is anchored so "Microsoft .NET Framework 4.8 on Windows Server 2016"
# (a *different* product that merely mentions the OS) can't match the OS family.
# Order matters — R2 must precede its base year.
#
# SharePoint 2013 is deliberately absent: it is EOL (2023-04-11) and Microsoft
# publishes no fixes for it, so there is no FixedBuild to compare — no amount of
# fix data can flag it. The EOL finding is the signal there (see eol_service).
_PRODUCTS: List[dict] = [
{"key": "ws2012r2", "kind": "os", "branch": True, "match_re": r"windows server\s*2012\s*r2",
"msrc_re": r"^windows server 2012 r2\b", "label": "Microsoft Windows Server 2012 R2"},
{"key": "ws2012", "kind": "os", "branch": True, "match_re": r"windows server\s*2012(?!\s*r2)",
"msrc_re": r"^windows server 2012\b(?!\s*r2)", "label": "Microsoft Windows Server 2012"},
{"key": "ws2016", "kind": "os", "branch": True, "match_re": r"windows server\s*2016",
"msrc_re": r"^windows server 2016\b", "label": "Microsoft Windows Server 2016"},
{"key": "ws2019", "kind": "os", "branch": True, "match_re": r"windows server\s*2019",
"msrc_re": r"^windows server 2019\b", "label": "Microsoft Windows Server 2019"},
{"key": "ws2022", "kind": "os", "branch": True, "match_re": r"windows server\s*2022",
"msrc_re": r"^windows server 2022\b", "label": "Microsoft Windows Server 2022"},
{"key": "ws2025", "kind": "os", "branch": True, "match_re": r"windows server\s*2025",
"msrc_re": r"^windows server 2025\b", "label": "Microsoft Windows Server 2025"},
# SharePoint — 2016, 2019 and Subscription Edition ALL report 16.0.x, so the
# year in the name is the only thing that tells the releases apart.
{"key": "sp2016", "kind": "pkg", "branch": False, "match_re": r"sharepoint.*\b2016\b",
"msrc_re": r"^microsoft sharepoint (enterprise )?server 2016\b",
"label": "Microsoft SharePoint Server 2016"},
{"key": "sp2019", "kind": "pkg", "branch": False, "match_re": r"sharepoint.*\b2019\b",
"msrc_re": r"^microsoft sharepoint server 2019\b",
"label": "Microsoft SharePoint Server 2019"},
{"key": "spse", "kind": "pkg", "branch": False, "match_re": r"sharepoint.*subscription",
"msrc_re": r"^microsoft sharepoint server subscription edition\b",
"label": "Microsoft SharePoint Server Subscription Edition"},
# Microsoft Edge — MSRC is the ONLY machine-readable source for these.
# Edge CVEs are absent from NVD and cvelistV5 (tester checked
# CVE-2026-57989/-57990/-57978: "CVE ID Not Found" at NVD, no match in
# cvelistV5), while MSRC carries CVSS, severity AND the fixed build. The
# July CVRF alone lists 436 Edge CVEs with a FixedBuild.
#
# Edge must NEVER be scored off Chromium/Chrome data: the builds diverge
# completely (Edge 150.0.4078.99 rides on Chromium 150.0.7871.187), so a
# google:chrome range says nothing about an Edge build.
#
# branch=False: Edge servicing is cumulative and the 3rd segment moves with
# every release, so identity comes from the name and the test is a plain
# installed < fixed. A newer major (151.x) compares greater than any 150.x
# fix, so it correctly drops out.
#
# WebView2 is excluded — it ships as its own package with its own version
# (the tester's host had Edge .83 next to WebView2 .99), so folding it in
# here would compare one product's build against the other's fix.
{"key": "edge", "kind": "pkg", "branch": False,
"match_re": r"microsoft edge(?!.*webview)",
"msrc_re": r"^microsoft edge \(chromium-based\)$",
"label": "Microsoft Edge (Chromium-based)"},
]
_OS_COMPILED = [(re.compile(p["match_re"], re.I), p) for p in _PRODUCTS if p["kind"] == "os"]
_PKG_COMPILED = [(re.compile(p["match_re"], re.I), p) for p in _PRODUCTS if p["kind"] == "pkg"]
_MSRC_COMPILED = [(re.compile(p["msrc_re"], re.I), p) for p in _PRODUCTS]
_BUILD_RE = re.compile(r"^\d+(\.\d+)+$")
# MSRC uses its own severity vocabulary in the CVRF Threats block.
_MS_SEVERITY = {
"critical": "critical",
"important": "high",
"moderate": "medium",
"low": "low",
}
def _severity_from(hit: dict):
"""MSRC severity word, else derive from the CVSS base score, else medium."""
from app.models.vulnerability import VulnerabilitySeverity
name = _MS_SEVERITY.get((hit.get("sev") or "").strip().lower())
if not name:
score = hit.get("cvss")
if isinstance(score, (int, float)):
name = ("critical" if score >= 9.0 else "high" if score >= 7.0
else "medium" if score >= 4.0 else "low")
return getattr(VulnerabilitySeverity, name or "medium", VulnerabilitySeverity.medium)
def resolve_os(os_name: str) -> Optional[dict]:
"""Asset OS string → curated product entry (None = not ours to scan)."""
n = (os_name or "").strip().lower()
if not n:
return None
for rx, p in _OS_COMPILED:
if rx.search(n):
return p
return None
def _resolve_msrc_product(name: str) -> Optional[dict]:
n = (name or "").strip().lower()
for rx, p in _MSRC_COMPILED:
if rx.search(n):
return p
return None
def _btuple(b: str) -> Optional[tuple]:
if not b or not _BUILD_RE.match(b):
return None
try:
return tuple(int(x) for x in b.split("."))
except ValueError:
return None
def _branch(b: str) -> Optional[tuple]:
"""Servicing branch = build minus its revision, e.g.
10.0.14393.9140 (10, 0, 14393). A fix only speaks to hosts on its own
branch: Server 2016 (14393) says nothing about Server 2019 (17763)."""
t = _btuple(b)
return t[:3] if t and len(t) >= 3 else None
# ---------- index ----------
def build_product_index(db: Session, months_back: Optional[int] = None) -> dict:
"""Walk the recent monthly CVRF docs → {product_key: [{cve, build, kb}]}."""
import httpx
from app.services.msrc_service import (
MSRC_BASE, HTTP_TIMEOUT, DEFAULT_MONTHS_BACK, SETTING_MONTHS_BACK, _setting_int,
)
months = months_back or _setting_int(db, SETTING_MONTHS_BACK, DEFAULT_MONTHS_BACK)
index: Dict[str, List[dict]] = {}
seen: set = set()
docs_done = 0
with httpx.Client(timeout=HTTP_TIMEOUT, headers={"Accept": "application/json"}) as client:
r = client.get(f"{MSRC_BASE}/updates")
r.raise_for_status()
doc_ids = [v["ID"] for v in (r.json().get("value") or [])][-months:]
for doc_id in doc_ids:
try:
resp = client.get(f"{MSRC_BASE}/cvrf/{doc_id}")
resp.raise_for_status()
doc = resp.json()
except Exception as e:
logger.warning("msrc-scan: doc %s failed: %s", doc_id, e)
continue
# ProductID → curated product key (only the ones we can map).
pid_key: Dict[str, str] = {}
for fp in (doc.get("ProductTree", {}) or {}).get("FullProductName", []) or []:
p = _resolve_msrc_product(fp.get("Value") or "")
if p and fp.get("ProductID"):
pid_key[str(fp["ProductID"])] = p["key"]
if not pid_key:
continue
for v in doc.get("Vulnerability", []) or []:
cve = (v.get("CVE") or "").strip().upper()
# MSRC ships the score and its own severity word in the CVRF.
# Carry them: Edge CVEs exist in NEITHER NVD NOR cvelistV5, so
# the enrichment cascade can never fill them in later — without
# this every Edge finding would sit at the neutral placeholder
# forever.
_sets = v.get("CVSSScoreSets") or []
_cvss = None
_vector = None
if _sets:
try:
_cvss = float(_sets[0].get("BaseScore"))
except (TypeError, ValueError):
_cvss = None
_vector = (_sets[0].get("Vector") or "").strip() or None
_sev = None
for _thr in v.get("Threats") or []:
if _thr.get("Type") == 3:
_sev = ((_thr.get("Description") or {}).get("Value") or "").strip().lower()
break
if not cve.startswith("CVE-"):
continue
for rem in v.get("Remediations", []) or []:
if rem.get("Type") != 2:
continue
build = (rem.get("FixedBuild") or "").strip()
if not _btuple(build):
continue # no usable build → tells us nothing about patch state
desc = str((rem.get("Description") or {}).get("Value", "") or "").strip()
kb = desc if desc.isdigit() else None
for pid in rem.get("ProductID") or []:
key = pid_key.get(str(pid))
if not key:
continue
sig = (key, cve, build)
if sig in seen:
continue
seen.add(sig)
index.setdefault(key, []).append({
"cve": cve, "build": build, "kb": kb,
"cvss": _cvss, "vector": _vector, "sev": _sev})
docs_done += 1
_store_index(db, index)
logger.info("msrc-scan: index built (%d docs) → %d products, %d fix entries",
docs_done, len(index), sum(len(v) for v in index.values()))
return index
def _store_index(db: Session, index: dict) -> None:
from app.models.setting import Setting
payload = json.dumps({"built_at": datetime.now().isoformat(), "index": index})
row = db.query(Setting).filter(Setting.key == _INDEX_SETTING).first()
if row:
row.value = payload
else:
db.add(Setting(key=_INDEX_SETTING, value=payload,
description="MSRC product→(CVE, FixedBuild) index (curated)"))
db.commit()
def load_index(db: Session, allow_stale: bool = True) -> Optional[dict]:
from app.models.setting import Setting
row = db.query(Setting).filter(Setting.key == _INDEX_SETTING).first()
if not row or not row.value:
return None
try:
blob = json.loads(row.value)
built = datetime.fromisoformat(blob.get("built_at"))
except Exception:
return None
if not allow_stale and datetime.now() - built > _INDEX_TTL:
return None
return blob.get("index") or {}
# ---------- scan ----------
def affected_cves(entries: List[dict], installed: str, branch_match: bool = True) -> List[dict]:
"""CVEs whose fix is newer than the installed build. Per CVE the NEWEST
fixed build wins that's the one that actually has to be on the box.
branch_match: see _PRODUCTS. True only fixes on the host's own build
prefix count (the prefix is the release). False the prefix moves with
every CU, so identity already came from the product name and every fix for
that product applies (MS servicing is cumulative, so installed < fixed is
exactly the right test)."""
inst_t = _btuple(installed)
if not inst_t:
return []
inst_b = _branch(installed)
if branch_match and not inst_b:
return []
newest: Dict[str, dict] = {}
for e in entries:
bt = _btuple(e.get("build") or "")
if not bt:
continue
# Two builds are only comparable when they are the same KIND of number.
# Teams ships 26183.1003.4002.4460 while MSRC states its fix as
# 25060212043 — a single eleven-digit stamp. Python compares those
# tuples element by element, so 26183 < 25060212043 came out True and
# a current Teams was reported vulnerable (tester, CVE-2025-49731).
# Differing segment counts mean the two sides are not the same scheme,
# and no ordering between them carries meaning.
if len(bt) != len(inst_t):
logger.debug(
"msrc: skipping %s — build %r and installed %r use different "
"version schemes", e.get("cve"), e.get("build"), installed)
continue
if branch_match and _branch(e["build"]) != inst_b:
continue # different servicing branch → says nothing about this host
cur = newest.get(e["cve"])
if cur is None or bt > _btuple(cur["build"]):
newest[e["cve"]] = e
return [e for e in newest.values() if inst_t < _btuple(e["build"])]
def resolve_package(name: str) -> Optional[dict]:
"""Installed-software name → curated MSRC product (None = not ours)."""
n = (name or "").strip().lower()
if not n:
return None
for rx, p in _PKG_COMPILED:
if rx.search(n):
return p
return None
def scan_asset(db: Session, asset, index: dict, new_ids: list,
touched: Optional[set] = None) -> int:
"""Flag MS OS CVEs whose FixedBuild is ahead of this host's build."""
if not index:
return 0
prod = resolve_os(asset.operating_system or "")
if not prod:
return 0
entries = index.get(prod["key"]) or []
if not entries:
return 0
installed = (asset.os_version or "").strip()
return _flag(db, asset, prod, installed, entries, new_ids, touched)
def scan_asset_packages(db: Session, asset, packages: list, index: dict,
new_ids: list, touched: Optional[set] = None) -> int:
"""Same fixed-build compare for installed MS software (SharePoint today).
Called from the app-CVE scan, which already has the inventory in hand."""
if not index:
return 0
count = 0
seen: set = set()
for pkg in packages or []:
name = (pkg.get("name") or "").strip()
version = (pkg.get("version") or "").strip()
if not name or not version:
continue
prod = resolve_package(name)
if not prod:
continue
# One product reports several components (Core / Lang Pack / SQL
# Express) all carrying the same build — scan the product once.
dedup = (prod["key"], version)
if dedup in seen:
continue
seen.add(dedup)
entries = index.get(prod["key"]) or []
if entries:
count += _flag(db, asset, prod, version, entries, new_ids, touched)
return count
def _flag(db: Session, asset, prod: dict, installed: str, entries: List[dict],
new_ids: list, touched: Optional[set]) -> int:
hits = affected_cves(entries, installed, branch_match=prod.get("branch", True))
count = 0
for h in hits:
if touched is not None:
touched.add(h["cve"])
try:
if _upsert(db, asset, prod["label"], installed, h, new_ids):
count += 1
except Exception as e:
logger.debug("msrc-scan upsert failed (%s on %s): %s", h["cve"], asset.id, e)
return count
def _upsert(db: Session, asset, product: str, installed: str, hit: dict, new_ids: list) -> bool:
from app.models.vulnerability import (
Vulnerability, VulnerabilityStatus, VulnerabilitySeverity,
)
cve_id = hit["cve"]
fixed = hit["build"]
kb = hit.get("kb")
existing = (db.query(Vulnerability)
.filter(Vulnerability.cve_id == cve_id, Vulnerability.asset_id == asset.id)
.first())
if existing:
existing.add_source(SOURCE_NAME)
# Record the product separately so a CVE affecting TWO products on one
# host (Chrome via app-scan + Edge via MSRC — CVE-2026-16417) shows both.
from app.services.audit_events import record_affected_package
record_affected_package(db, existing, name=product, version=installed,
fixed_version=fixed, source=SOURCE_NAME)
if not existing.package_name:
existing.package_name = product[:255]
# Refresh, don't fill-only: the host's build moves with every patch, so
# a fill-only write froze the finding at the version first seen (same
# bug the app-scan had with Firefox showing 'Installed 150.0.3').
if installed:
existing.package_version = installed[:100]
# Same fill-only trap, one field over — and this one produces FALSE
# POSITIVES, not just a stale display. CVE-2026-15120 is a Chromium CVE
# that Edge ingests, so the CPE path writes Chromium's fix
# (150.0.7871.114) onto the finding first. MSRC knows the only build
# that matters for Edge (150.0.4078.65), but fill-only never let it
# through — the host at 150.0.4078.83 was long patched and still showed
# OPEN, because .83 < .7871.114. MSRC is authoritative for its own
# products' fixed build; let it correct the row.
if fixed and existing.fixed_version != fixed:
existing.fixed_version = fixed
if existing.cvss_score is None and hit.get("cvss") is not None:
existing.cvss_score = hit["cvss"]
existing.cvss_vector = hit.get("vector") or existing.cvss_vector
existing.severity = _severity_from(hit)
from app.services.audit_events import reopen_if_patched
reopen_if_patched(db, existing, reason="MSRC fixed-build scan reports this CVE again", source="msrc")
try:
existing.refresh_scores()
except Exception:
pass
return False
title = f"{product}{cve_id}" + (f" (KB{kb})" if kb else "")
row = Vulnerability(
cve_id=cve_id, asset_id=asset.id,
# severity is NOT NULL. The CVRF index carries only (cve, build, kb) —
# no score — so seed the same neutral placeholder the other
# synthetic-finding source (m365) uses; the enrichment cascade
# (vulnrichment → NVD → cvelistV5 → GHSA) refines it right after.
# Until Edge joined, MSRC almost always hit the existing-row branch, so
# this create path rarely ran and the missing column went unnoticed —
# then every new Edge finding hit a NotNullViolation and aborted the
# whole scan.
severity=_severity_from(hit),
cvss_score=hit.get("cvss"),
cvss_vector=hit.get("vector"),
status=VulnerabilityStatus.open,
title=title[:500],
description=(f"MSRC reports {product} is fixed in build {fixed}"
+ (f" via KB{kb}" if kb else "")
+ f"; this host reports {installed}."),
package_name=product[:255], package_version=installed[:100],
fixed_version=fixed,
detected_at=datetime.now(),
sources=json.dumps([SOURCE_NAME]), first_detected_by=SOURCE_NAME,
)
db.add(row)
db.flush()
from app.services.audit_events import record_affected_package
record_affected_package(db, row, name=product, version=installed,
fixed_version=fixed, source=SOURCE_NAME)
# CVE metrics (CVSS/EPSS/KEV) are properties of the CVE, not of one host.
# Only 49 of 436 Edge CVEs carry a CVSSScoreSet in the CVRF, so an MSRC-only
# finding often has no score of its own while the SAME CVE on another asset
# already does (tester: CVE-2026-16423 showed 8.8 on the Chrome row and
# '-' / priority 0 on the Edge row). Inherit from a sibling before scoring.
try:
if row.cvss_score is None:
from app.services.vuln_override_service import apply_canonical_from_siblings
apply_canonical_from_siblings(db, row)
except Exception as e:
logger.debug("sibling metric inherit failed (%s): %s", row.cve_id, e)
try:
row.refresh_scores()
except Exception:
pass
new_ids.append(row.id)
return True
def _resolve_stale(db: Session, asset, touched: set, considered: set) -> int:
"""A msrc-only finding no longer reported means the host caught up past the
FixedBuild patched. Same contract as the app-scan reconcile: never touch
findings another scanner also reports.
`considered` = the product labels THIS pass actually evaluated. Without it
the OS pass would close every SharePoint finding it never looked at (and
vice versa), since neither pass touches the other's CVEs."""
from app.models.vulnerability import Vulnerability, VulnerabilityStatus
if not considered:
return 0
rows = (db.query(Vulnerability)
.filter(Vulnerability.asset_id == asset.id,
Vulnerability.status == VulnerabilityStatus.open,
Vulnerability.sources.contains('"msrc"'))
.all())
resolved = 0
for v in rows:
# Match the finding to a product the way the SCAN does, not by exact
# label. Whichever scanner creates the row first owns package_name, and
# the app scan writes the inventory's own wording — "Microsoft Edge" —
# while this pass only ever looked for "Microsoft Edge
# (Chromium-based)". The row was therefore invisible to its own
# reconcile and stayed open forever: the tester's host sat on Edge
# .105, long past the .99 fix, with the finding still open after
# repeated scans.
# This same function serves the OS pass, whose labels resolve through
# resolve_os instead — so try both, and keep the exact-name match for
# rows this scanner wrote itself.
name = v.package_name or ""
if name not in considered:
prod = resolve_package(name) or resolve_os(name)
if not prod or prod["label"] not in considered:
# The parent name is whatever the FIRST scanner called it, and
# for a CVE that hits Chrome and Edge alike that is often
# "Google Chrome" — which resolves to no MSRC product at all,
# so the finding was skipped and never closed even though MSRC
# tracks its Edge half (tester: CVE-2026-16807, Edge long past
# the fix, still open). The per-package rows carry the product
# this pass actually knows about, so ask them too.
pkg_names = [p.package_name for p in (v.packages or [])]
if not any((resolve_package(n) or {}).get("label") in considered
for n in pkg_names):
continue
if v.cve_id in touched:
continue
# Drop OUR source; close only when nobody else still reports it (the
# skip-if-cross-confirmed rule deadlocked across reconciles).
v.remove_source(SOURCE_NAME)
if v.source_list:
continue
old = v.status
v.status = VulnerabilityStatus.patched
v.patched_at = datetime.now()
resolved += 1
try:
from app.routers.vulnerabilities import log_vulnerability_change
log_vulnerability_change(
db, None, v.id, old, v.status,
reason=f"MSRC scan: {asset.hostname} is now at or past the fixed build",
cve_id=v.cve_id, source="msrc_scan",
)
except Exception as e:
logger.warning("audit log for msrc auto-resolve failed (vuln_id=%s): %s", v.id, e)
return resolved
def _os_labels(asset) -> set:
"""Every package_name an OS finding on this asset can legitimately carry.
_resolve_stale matches package_name EXACTLY, and the label depends on who
created the row: MSRC writes its catalogue name ("Microsoft Windows Server
2025"), while the app scan / Wazuh write the asset's own OS string
("Microsoft Windows Server 2025 Standard", "... 2016 Datacenter"). A
finding first seen by another scanner therefore never matched the MSRC
label, so once that scanner retracted its source the row was left open
forever with msrc as the last claimant exactly what the tester saw on
fully patched 2016 and 2025 hosts.
Deliberately NOT a prefix match: "Microsoft Windows Server 2012" is a
prefix of "... 2012 R2", which would close a different release's findings.
"""
prod = resolve_os(asset.operating_system or "")
if not prod:
return set()
labels = {prod["label"]}
os_str = (asset.operating_system or "").strip()
if os_str:
labels.add(os_str)
return labels
def resolve_stale_os(db: Session, asset, touched: set) -> int:
"""Reconcile the OS-kind MSRC findings for this asset.
Three writers, three spellings of package_name for the SAME OS finding:
MSRC "Microsoft Windows Server 2016" (catalogue)
app scan "Microsoft Windows Server 2016 Datacenter" (asset OS string)
Wazuh "Microsoft Windows Server 2016 Datacenter 10.0.14393.9234"
(asset OS string + build)
_resolve_stale compares package_name exactly, so the Wazuh spelling never
matched and those rows stayed open with msrc as the last claimant the
tester's CVE-2026-49798 (open, first seen by wazuh) next to CVE-2026-50518
(patched, first seen by app-scan) on the very same host.
So: exact match for the labels, plus a PREFIX match on the asset's own OS
string to catch anything appended to it. The prefix is safe because it uses
the asset's FULL OS string — "…Server 2012 Standard" cannot prefix
"…Server 2012 R2 Standard". The MSRC catalogue label is deliberately NOT
used as a prefix, since "Microsoft Windows Server 2012" would.
"""
from app.models.vulnerability import Vulnerability, VulnerabilityStatus
labels = _os_labels(asset)
if not labels:
return 0
os_str = (asset.operating_system or "").strip()
rows = (db.query(Vulnerability)
.filter(Vulnerability.asset_id == asset.id,
Vulnerability.status == VulnerabilityStatus.open,
Vulnerability.sources.contains(f'"{SOURCE_NAME}"'))
.all())
resolved = 0
for v in rows:
pkg = (v.package_name or "").strip()
if pkg not in labels and not (os_str and pkg.startswith(os_str)):
continue # a different product — not ours to close
if v.cve_id in touched:
continue # still reported by this pass
v.remove_source(SOURCE_NAME)
if v.source_list:
continue # another scanner still claims it
old_status = v.status
v.status = VulnerabilityStatus.patched
v.patched_at = datetime.now()
resolved += 1
try:
from app.routers.vulnerabilities import log_vulnerability_change
log_vulnerability_change(
db, None, v.id, old_status, v.status,
reason=f"Host build {asset.os_version} is at/past the MSRC fixed "
f"build for this CVE",
cve_id=v.cve_id, source="msrc",
hostname=asset.hostname,
)
except Exception as e:
logger.warning("audit log for MSRC OS auto-resolve failed (%s): %s", v.id, e)
return resolved
def resolve_stale_packages(db: Session, asset, touched: set) -> int:
"""Reconcile the package (pkg-kind) MSRC findings after a package scan.
Considers ALL pkg product labels we just saw the full inventory, so a
product that vanished (uninstalled) should resolve too."""
labels = {p["label"] for p in _PRODUCTS if p["kind"] == "pkg"}
return _resolve_stale(db, asset, touched, labels)
def run_msrc_scan(db: Session, asset_id: Optional[int] = None) -> dict:
"""Scan Windows-Server assets against the MSRC fixed-build index."""
from app.models.asset import Asset
stats = {"assets": 0, "findings": 0, "new": 0, "resolved": 0, "errors": []}
index = load_index(db)
if not index:
logger.info("msrc-scan: index missing — building now (one-time, then nightly)")
try:
index = build_product_index(db) or {}
except Exception as e:
stats["errors"].append(f"index build failed: {e}")
return stats
if not index:
return stats
new_ids: list = []
q = db.query(Asset)
if asset_id is not None:
q = q.filter(Asset.id == asset_id)
for asset in q.all():
prod = resolve_os(asset.operating_system or "")
if not prod:
continue
if not (asset.os_version or "").strip():
continue # no build → nothing to compare
touched: set = set()
try:
stats["findings"] += scan_asset(db, asset, index, new_ids, touched=touched)
# Only this asset's OS product — the package pass owns its own labels.
stats["resolved"] += _resolve_stale(db, asset, touched, _os_labels(asset))
stats["assets"] += 1
db.commit()
except Exception as e:
db.rollback()
stats["errors"].append(f"asset {asset.id}: {e}")
stats["new"] = len(new_ids)
if new_ids:
try:
from app.services.audit_events import audit_new_vulnerabilities
audit_new_vulnerabilities(db, new_ids, source=SOURCE_NAME)
db.commit()
except Exception as e:
logger.debug("msrc-scan detected-audit failed: %s", e)
try:
from app.models.vulnerability import Vulnerability
from app.services.enrichment_service import enrich_vulnerabilities
from app.services.email_service import dispatch_new_vuln_notifications
fresh = db.query(Vulnerability).filter(Vulnerability.id.in_(new_ids)).all()
if fresh:
enrich_vulnerabilities(db, fresh)
stats["notifications"] = dispatch_new_vuln_notifications(db, fresh)
except Exception as e:
logger.debug("msrc-scan enrichment/notify failed: %s", e)
logger.info("MSRC scan: %d assets, %d findings (%d new, %d auto-resolved)",
stats["assets"], stats["findings"], stats["new"], stats["resolved"])
return stats
+240
View File
@@ -0,0 +1,240 @@
"""
Microsoft Security Response Center (MSRC) CVRF enrichment.
Microsoft's per-CVE shortcut endpoint 404s; the stable source is the
monthly CVRF document (https://api.msrc.microsoft.com/cvrf/v3.0/cvrf/
{YYYY-Mon}, ~4-5 MB, ~1000 CVEs each). So we pull the last N monthly
documents in a background job, extract per-CVE remediations, and upsert
them into cve_remediations(source='msrc'). The CVE detail page then reads
them from the DB (no live 4 MB fetch per click).
Covers Windows OS **and** Microsoft products (Office/365, .NET, SQL,
Exchange, Visual Studio, ...), not just OS CVEs.
CVRF shapes we use, per Vulnerability:
Remediations[]:
Type 2 "Security Update" KB in Description.Value (digits) + FixedBuild
+ download URL kind=fix
Type 3 support.microsoft.com/help/{KB} link (merged
into the matching fix row by KB)
Notes[]:
Title "Workarounds" kind=workaround (HTML text)
Title "Mitigations" kind=mitigation (HTML text)
"""
import logging
import re
from datetime import datetime, timedelta
from typing import Dict, List, Optional
import httpx
from sqlalchemy.orm import Session
from app.models.cve_remediation import CveRemediation
from app.models.setting import Setting
logger = logging.getLogger(__name__)
MSRC_BASE = "https://api.msrc.microsoft.com/cvrf/v3.0"
HTTP_TIMEOUT = 90.0
# How many recent monthly documents to ingest per run. ~18 months covers
# the CVEs realistically present on managed estates. Override via setting
# `msrc_months_back`.
DEFAULT_MONTHS_BACK = 18
SETTING_MONTHS_BACK = "msrc_months_back"
MSRC_LAST_REFRESH_KEY = "msrc_last_refresh_at"
_TAG_RE = re.compile(r"<[^>]+>")
_WS_RE = re.compile(r"[ \t]*\n[ \t]*")
def _html_to_text(html: Optional[str]) -> Optional[str]:
if not html:
return None
txt = _TAG_RE.sub(" ", html)
txt = (txt.replace("&nbsp;", " ").replace("&amp;", "&")
.replace("&lt;", "<").replace("&gt;", ">").replace("&quot;", '"'))
txt = re.sub(r"[ \t]{2,}", " ", txt).strip()
return txt or None
def _setting_int(db: Session, key: str, default: int) -> int:
s = db.query(Setting).filter(Setting.key == key).first()
if s and s.value and str(s.value).strip().isdigit():
return int(s.value)
return default
# ============================================================
# parse one CVRF Vulnerability into remediation rows
# ============================================================
def parse_vulnerability(v: dict) -> List[dict]:
"""Return remediation dicts for one CVRF Vulnerability entry."""
out: List[dict] = []
# Fixes — Type 2 carries the KB (digit Description) + FixedBuild + URL.
# Type 3 is the support.microsoft.com link for the same KB; merge it in.
fixes: Dict[str, dict] = {}
support_links: Dict[str, str] = {}
for r in v.get("Remediations", []) or []:
rtype = r.get("Type")
desc = str((r.get("Description") or {}).get("Value", "") or "").strip()
url = (r.get("URL") or "").strip() or None
build = (r.get("FixedBuild") or "").strip() or None
sub = (r.get("SubType") or "").strip() or None
if rtype == 2:
kb = desc if desc.isdigit() else None
key = kb or build or url or (sub or "fix")
row = fixes.setdefault(key, {"kb": kb, "fixed_build": build, "url": url, "sub": sub})
# keep first non-empty values
row["kb"] = row.get("kb") or kb
row["fixed_build"] = row.get("fixed_build") or build
row["url"] = row.get("url") or url
row["sub"] = row.get("sub") or sub
elif rtype == 3:
kb3 = (sub if (sub or "").isdigit() else None) or (desc if desc.isdigit() else None)
if kb3 and url:
support_links[kb3] = url
for key, row in fixes.items():
kb = row.get("kb")
url = row.get("url") or (support_links.get(kb) if kb else None)
bits = []
if kb:
bits.append(f"KB{kb}")
if row.get("fixed_build"):
bits.append(f"build {row['fixed_build']}")
title = " · ".join(bits) or (row.get("sub") or "Security Update")
out.append({
"kind": "fix",
"title": title[:300],
# `detail` carries the MSRC update type (SubType) so the detail
# view can keep the newest KB per (build-branch + update-type) —
# e.g. "Security Update" vs "Security Hotpatch Update".
"detail": (row.get("sub") or "Security Update"),
"kb": kb,
"fixed_build": row.get("fixed_build"),
"url": url,
})
# Workarounds / Mitigations from Notes (HTML → text).
for n in v.get("Notes", []) or []:
title = (n.get("Title") or "").strip()
if title not in ("Workarounds", "Mitigations"):
continue
text = _html_to_text(str(n.get("Value", "")))
if not text:
continue
out.append({
"kind": "workaround" if title == "Workarounds" else "mitigation",
"title": title[:300],
"detail": text[:4000],
"kb": None,
"fixed_build": None,
"url": None,
})
return out
# ============================================================
# fetch + ingest
# ============================================================
def _list_recent_docs(client: "httpx.Client", months_back: int) -> List[str]:
"""Return the last `months_back` monthly CVRF document IDs (YYYY-Mon)."""
r = client.get(f"{MSRC_BASE}/updates", headers={"Accept": "application/json"})
r.raise_for_status()
ids = [
u.get("ID") for u in (r.json().get("value") or [])
if u.get("ID") and re.match(r"^\d{4}-[A-Za-z]{3}$", u["ID"])
]
# The index is roughly chronological but not guaranteed; sort by the
# CurrentReleaseDate when present, else keep order, then take the tail.
return ids[-months_back:]
def _upsert_cve(db: Session, cve_id: str, rows: List[dict]) -> None:
"""Replace all msrc rows for a cve_id with the freshly parsed set."""
db.query(CveRemediation).filter(
CveRemediation.cve_id == cve_id,
CveRemediation.source == "msrc",
).delete(synchronize_session=False)
now = datetime.now()
for r in rows:
db.add(CveRemediation(
cve_id=cve_id, source="msrc", kind=r["kind"],
title=r.get("title"), detail=r.get("detail"),
kb=r.get("kb"), fixed_build=r.get("fixed_build"),
url=r.get("url"), fetched_at=now,
))
def refresh_msrc(db: Session, months_back: Optional[int] = None,
only_known_cves: bool = True) -> dict:
"""Ingest the last N monthly MSRC documents into cve_remediations.
only_known_cves: when True (default) we only store remediations for CVE
ids already present in our vulnerabilities table keeps the table
relevant + small instead of mirroring ~18k MS CVEs.
"""
if months_back is None:
months_back = _setting_int(db, SETTING_MONTHS_BACK, DEFAULT_MONTHS_BACK)
known: Optional[set] = None
if only_known_cves:
from app.models.vulnerability import Vulnerability
known = {
c for (c,) in db.query(Vulnerability.cve_id).distinct().all()
if c and c.upper().startswith("CVE-")
}
stats = {"docs": 0, "cves_seen": 0, "cves_stored": 0, "rows": 0, "errors": []}
with httpx.Client(timeout=HTTP_TIMEOUT, follow_redirects=True,
headers={"User-Agent": "TrueVuln/1.0"}) as client:
try:
doc_ids = _list_recent_docs(client, months_back)
except httpx.HTTPError as e:
raise RuntimeError(f"MSRC updates index fetch failed: {e}") from e
for doc_id in doc_ids:
try:
r = client.get(f"{MSRC_BASE}/cvrf/{doc_id}",
headers={"Accept": "application/json"})
if r.status_code != 200:
stats["errors"].append(f"{doc_id}: HTTP {r.status_code}")
continue
doc = r.json()
except (httpx.HTTPError, ValueError) as e:
stats["errors"].append(f"{doc_id}: {e}")
continue
stats["docs"] += 1
for v in doc.get("Vulnerability", []) or []:
cve = (v.get("CVE") or "").strip().upper()
if not cve.startswith("CVE-"):
continue
stats["cves_seen"] += 1
if known is not None and cve not in known:
continue
rows = parse_vulnerability(v)
if not rows:
continue
_upsert_cve(db, cve, rows)
stats["cves_stored"] += 1
stats["rows"] += len(rows)
db.commit()
logger.info("MSRC: ingested %s (%d CVEs stored so far)", doc_id, stats["cves_stored"])
_set_last_refresh(db)
logger.info("MSRC refresh done: %s", {k: v for k, v in stats.items() if k != "errors"})
return stats
def _set_last_refresh(db: Session) -> None:
s = db.query(Setting).filter(Setting.key == MSRC_LAST_REFRESH_KEY).first()
if s:
s.value = datetime.now().isoformat()
else:
db.add(Setting(key=MSRC_LAST_REFRESH_KEY, value=datetime.now().isoformat(),
description="Timestamp of last MSRC CVRF refresh"))
db.commit()
+179 -31
View File
@@ -1,5 +1,5 @@
"""
Nessus VulnCheck sync service.
Nessus TrueVuln sync service.
Imports scan findings from Tenable Nessus and merges them onto existing
Wazuh-sourced vulnerabilities using `(cve_id, asset_id)` as the dedup key.
@@ -197,6 +197,28 @@ _OFFICE_RE = re.compile(r"microsoft\s*office", re.I)
_OFFICE_YEAR_RE = re.compile(r"\b(20\d{2})\b")
# Catch-all Nessus solution strings that carry no product-specific fix.
# A cross-confirmed CVE can match several plugins; a generic plugin must
# not overwrite (or block) the specific plugin's real solution.
_GENERIC_REMEDIATION_MARKERS = (
"install the patches listed below",
"apply the appropriate patch",
"apply the patches",
"there is no known fix",
"no known solution",
"n/a",
"refer to the vendor",
)
def _is_generic_remediation(text: Optional[str]) -> bool:
"""True for empty or catch-all remediation text (no specific fix)."""
if not text or not text.strip():
return True
low = text.strip().lower()
return any(m in low for m in _GENERIC_REMEDIATION_MARKERS)
def _office_year(plugin_name: str) -> Optional[str]:
"""Extract the 4-digit year from an Office plugin name, or None."""
m = _OFFICE_YEAR_RE.search(plugin_name or "")
@@ -204,12 +226,46 @@ def _office_year(plugin_name: str) -> Optional[str]:
def _office_pseudo_cve(plugin_name: str, plugin_id) -> str:
"""Return `EOL-MS-OFFICE-YYYY` for Office variants, else `EOL-NESSUS-{pid}`."""
"""Return `EOL-MS-OFFICE-YYYY` for Office variants, else `EOL-NESSUS-{pid}`.
NOTE: prefer `_slug_pseudo_cve(plugin_name, plugin_id, installed_version)`
for the slug-based naming (`EOL-{SLUG}-{VERSION}`). Kept as a fallback
when product-name resolution fails.
"""
if _OFFICE_RE.search(plugin_name or "") and _office_year(plugin_name):
return f"EOL-MS-OFFICE-{_office_year(plugin_name)}"
return f"EOL-NESSUS-{plugin_id}"
def _slug_pseudo_cve(plugin_name: str, plugin_id, installed_version: Optional[str] = None) -> str:
"""Return product-name-based pseudo-CVE id (`EOL-MSSQLSERVER-...`) when
we can resolve a slug, falling back to the legacy `EOL-NESSUS-{pid}`.
Slug comes from `eol_service.resolve_product_slug(plugin_name)`. The
trailing token is the installed version (sanitised) or the last 4
digits of the plugin id when no version is present, so the row is
still stable across re-syncs.
"""
# Office is its own special case — keep the year-based id so the
# language-pack dedup in `run_nessus_sync` keeps working.
if _OFFICE_RE.search(plugin_name or "") and _office_year(plugin_name):
return f"EOL-MS-OFFICE-{_office_year(plugin_name)}"
# Lazy import: eol_service imports from a few places; avoid a hard
# import cycle at module load.
try:
from app.services.eol_service import resolve_product_slug
slug = resolve_product_slug(plugin_name)
except Exception:
slug = None
if slug:
if installed_version:
safe_v = re.sub(r"[^A-Za-z0-9._-]", "_", str(installed_version))[:24] or "x"
return f"EOL-{slug.upper()}-{safe_v}"[:50]
# No version → last 4 digits of plugin id keeps it stable
return f"EOL-{slug.upper()}-P{str(plugin_id)[-4:]}"[:50]
return f"EOL-NESSUS-{plugin_id}"
def _normalise_office_pkg(pkg: str) -> str:
"""Collapse all Office sub-flavour strings to the unified `MS Office`."""
if _OFFICE_RE.search(pkg or ""):
@@ -240,7 +296,19 @@ def _upsert_nessus_eol(
widget alongside endoflife.date findings. Dedup key is (cve_id,
asset_id), stable across re-syncs.
"""
cve_id = _office_pseudo_cve(plugin_name, plugin_id)
cve_id = _slug_pseudo_cve(plugin_name, plugin_id, installed_version)
# Homogenisation: once a plugin resolves to a proper product slug
# (EOL-ADOBE-ACROBAT-..., EOL-MSSQLSERVER-...), drop any legacy
# EOL-NESSUS-{plugin_id} row left over from before the slug was known.
# Without this the old plugin-id row lingers next to the new named one
# (tester: "nach neuem Scan noch PLUGIN ID UND NESSUS").
if plugin_id and not cve_id.startswith("EOL-NESSUS-"):
legacy_id = f"EOL-NESSUS-{plugin_id}"
if legacy_id != cve_id:
db.query(Vulnerability).filter(
Vulnerability.cve_id == legacy_id,
Vulnerability.asset_id == asset.id,
).delete(synchronize_session=False)
# Strip the "... Unsupported Version Detection" suffix for a clean
# PACKAGE column ("Microsoft SQL Server"). Office sub-flavours collapse
# to "MS Office" so the language-pack noise stops multiplying rows.
@@ -266,9 +334,8 @@ def _upsert_nessus_eol(
existing.description = description
existing.add_source(SOURCE_NAME)
existing.nessus_plugin_id = str(plugin_id) if plugin_id else existing.nessus_plugin_id
if existing.status == VulnerabilityStatus.patched:
existing.status = VulnerabilityStatus.open
existing.patched_at = None
from app.services.audit_events import reopen_if_patched
reopen_if_patched(db, existing, reason="Nessus reports this finding on the host again", source="nessus_sync")
# Resync bumps detected_at so the Newly EOL/EOS widget ranks the
# freshest finding first (was stale before this fix).
existing.detected_at = datetime.now()
@@ -322,7 +389,7 @@ def run_nessus_sync(
) -> dict:
"""
Pull findings from Nessus for the requested scans (or all configured
default_scan_ids) and merge them into VulnCheck.
default_scan_ids) and merge them into TrueVuln.
Returns a stats dict suitable for logging + API response.
"""
@@ -352,7 +419,8 @@ def run_nessus_sync(
}
newly_created_vuln_ids: List[int] = []
seen_nessus_uuids: set = set() # for sync-driven asset reconciliation
seen_nessus_uuids: set = set() # legacy uuid-keyed reconcile
seen_asset_ids: set = set() # robust id-keyed reconcile
with _build_client(config) as client:
# Auto-discover scans if no explicit IDs and no defaults
@@ -398,10 +466,17 @@ def run_nessus_sync(
})
continue
stats["hosts_synced"] += 1
# Track for sync-driven reconciliation — only count assets that
# have a nessus_host_uuid pinned (the key we reconcile on).
# Track for sync-driven reconciliation. Two sets:
# - seen_nessus_uuids: legacy uuid-keyed path (kept).
# - seen_asset_ids: robust id-keyed path. A scan host
# whose host_info lacks host_uuid leaves the uuid set
# empty → the fail-open guard skipped EVERYTHING and
# nothing got inactivated (tester bug). Tracking the
# matched asset.id sidesteps the missing-uuid case.
if asset.nessus_host_uuid:
seen_nessus_uuids.add(asset.nessus_host_uuid)
if asset.id:
seen_asset_ids.add(asset.id)
# OS Identification (Nessus plugin 11936 + host info fields)
# Only fills when the asset row has nothing — Wazuh-sourced
@@ -468,17 +543,12 @@ def run_nessus_sync(
n_solution = NessusClient.plugin_solution(plugin_payload) if plugin_payload else None
n_see_also = NessusClient.plugin_see_also(plugin_payload) if plugin_payload else []
# Compose a description with the solution appended — Nessus
# provides both as separate fields, our schema has one text
# column, so we glue them together for the detail view.
full_description = None
if n_description or n_solution:
parts = []
if n_description:
parts.append(n_description.strip())
if n_solution:
parts.append("\n\nSolution:\n" + n_solution.strip())
full_description = "".join(parts)
# Description = Nessus synopsis/description only. The
# solution text goes into the dedicated `remediation`
# column (rendered as its own section) instead of being
# glued onto the description.
full_description = n_description.strip() if n_description else None
remediation = n_solution.strip() if n_solution else None
if not cve_list:
# Most non-CVE plugins (compliance / cipher / info) stay
@@ -500,7 +570,9 @@ def run_nessus_sync(
# Mark as seen THIS run so the source-backfill below
# doesn't immediately drop nessus + patch the row we
# just upserted (it keys on cve_id membership).
seen_cves_for_asset.add(f"EOL-NESSUS-{plugin_id}")
seen_cves_for_asset.add(
_slug_pseudo_cve(eol_name, plugin_id, installed_version)
)
if _upsert_nessus_eol(
db,
asset=asset,
@@ -647,10 +719,25 @@ def run_nessus_sync(
if n_see_also and not existing.references:
existing.references = json.dumps(n_see_also)
changed = True
# Remediation precedence: a SPECIFIC solution
# always beats a generic/empty one; a generic
# solution never overwrites a specific one. Fixes
# cross-confirmed CVEs where a catch-all plugin
# ("Install the patches listed below.") clobbered
# the real plugin fix ("Upgrade to ... X.Y.Z").
if remediation and (
not existing.remediation
or (_is_generic_remediation(existing.remediation)
and not _is_generic_remediation(remediation))
):
existing.remediation = remediation
changed = True
# If previously marked patched but Nessus sees it again → reopen
if existing.status == VulnerabilityStatus.patched:
existing.status = VulnerabilityStatus.open
existing.patched_at = None
from app.services.audit_events import reopen_if_patched
if reopen_if_patched(
db, existing,
reason="Nessus reports this finding on the host again",
source="nessus_sync"):
changed = True
if changed:
existing.refresh_scores()
@@ -677,6 +764,7 @@ def run_nessus_sync(
package_name=plugin_name[:255] if plugin_name else None,
package_version=installed_version,
description=full_description,
remediation=remediation,
references=json.dumps(n_see_also) if n_see_also else None,
exploit_available=bool(exploit_avail) if exploit_avail is not None else False,
exploit_maturity=exploit_mat[:50] if exploit_mat else None,
@@ -832,21 +920,35 @@ def run_nessus_sync(
# flipped to INACTIVE. Vice-versa: INACTIVE NESSUS assets that
# re-appeared are flipped back to ACTIVE. Audit-logged.
try:
from app.services.asset_lifecycle import reconcile_missing_from_sync
recon = reconcile_missing_from_sync(
from app.services.asset_lifecycle import reconcile_nessus_by_seen_ids
recon = reconcile_nessus_by_seen_ids(
db,
source=AssetSource.NESSUS,
seen_ids=seen_nessus_uuids,
id_field=Asset.nessus_host_uuid,
seen_asset_ids=seen_asset_ids,
reason=f"not in latest Nessus scan (scans={target_scan_ids})",
)
stats["assets_inactivated"] = recon["inactivated"]
stats["assets_reactivated"] = recon["reactivated"]
logger.info(
"Nessus sync reconcile: %d seen, %d inactivated, %d reactivated, %d candidates",
len(seen_asset_ids), recon["inactivated"], recon["reactivated"],
recon.get("candidates", 0),
)
except Exception as e:
logger.warning("Nessus sync: asset reconciliation failed (non-fatal): %s", e)
db.commit()
# Revisionssicher: initial VULNERABILITY_DETECTED audit event per new
# finding (previously the audit trail only began at the first status
# change).
if newly_created_vuln_ids:
try:
from app.services.audit_events import audit_new_vulnerabilities
audit_new_vulnerabilities(db, newly_created_vuln_ids, source="nessus")
db.commit()
except Exception as e:
logger.warning("Nessus sync: detected-audit failed (non-fatal): %s", e)
# Best-effort enrichment + notification (re-use Wazuh path)
if newly_created_vuln_ids:
try:
@@ -859,7 +961,9 @@ def run_nessus_sync(
.all()
)
try:
enrich_vulnerabilities(db, fresh)
# NVD date backfill deferred to the nightly enrichment job
# (rate-limited — would stall the sync).
enrich_vulnerabilities(db, fresh, use_nvd_dates=False)
except Exception as e:
logger.warning("Nessus sync: enrichment failed (non-fatal): %s", e)
@@ -874,3 +978,47 @@ def run_nessus_sync(
stats["vulns_marked_patched"], len(stats["unmatched_hosts"]),
)
return stats
def reconcile_legacy_nessus_assets(db: Session) -> dict:
"""One-shot helper for testers: flip ACTIVE NESSUS-sourced assets that
have no `nessus_host_uuid` pinned (legacy rows from before the
reconcile path was hardened) to INACTIVE.
These rows were created by older Nessus syncs that matched by IP
only, so they never get a UUID and are silently skipped by
`reconcile_missing_from_sync`. Without this, a reduced scan leaves
them all ACTIVE.
Returns {"inactivated": int, "scanned": int}.
Safe to run multiple times. Logs an audit entry for each row flipped.
"""
from app.services.asset_lifecycle import _audit_asset_status
from app.models.asset import AssetSource, AssetStatus
legacy = (
db.query(Asset)
.filter(
Asset.source == AssetSource.NESSUS,
Asset.status == AssetStatus.ACTIVE,
Asset.nessus_host_uuid.is_(None),
)
.all()
)
stats = {"scanned": len(legacy), "inactivated": 0}
for a in legacy:
a.status = AssetStatus.INACTIVE
_audit_asset_status(
db, a, "active", "inactive",
"legacy Nessus-sourced asset without pinned nessus_host_uuid — "
"cannot be reconciled event-driven; flipped via reconcile_legacy_nessus_assets",
)
stats["inactivated"] += 1
if stats["inactivated"]:
db.commit()
logger.info(
"nessus legacy reconcile: %d inactivated (of %d legacy ACTIVE rows)",
stats["inactivated"], stats["scanned"],
)
return stats
+32
View File
@@ -0,0 +1,32 @@
"""What counts as a finding in an exported report.
Lives outside the router so the rule can be tested without standing up the
web stack, and so every report is forced through the same definition the
reports drifted apart precisely because each one wrote its own query.
"""
from sqlalchemy.orm import Session
from app.models.vulnerability import Vulnerability
# The EOL check and the Nessus plugin importer both write findings with
# synthetic ids. They are real work items, but they are NOT CVEs, and counting
# them as such inflated every number past what the dashboard shows.
PSEUDO_PREFIXES = ("EOL-", "NESSUS-PLUGIN-")
def scoped(db: Session):
"""Real CVEs on assets that still exist.
Reports used to query the table raw, so they counted findings on
decommissioned and inactive assets hosts the dashboard deliberately
hides. An executive summary that disagrees with the screen it was exported
from is worse than no summary, and the gap grows with every retired
machine. Orphan findings (no asset at all) are kept, same as the dashboard.
"""
from app.models.asset import Asset, AssetStatus
q = (db.query(Vulnerability)
.outerjoin(Asset, Vulnerability.asset_id == Asset.id)
.filter((Asset.id.is_(None)) | (Asset.status == AssetStatus.ACTIVE)))
for p in PSEUDO_PREFIXES:
q = q.filter(~Vulnerability.cve_id.startswith(p))
return q
+156
View File
@@ -0,0 +1,156 @@
"""
Asset "Risk Dimensions" high-value-target (crown-jewel) role detection.
Network exposure alone (open ports) doesn't capture WHY a host matters.
A Domain Controller, a Certificate Authority, a SQL/Exchange/backup server
once compromised enable lateral movement, domain takeover and ransomware
spread. This service detects such roles from the data Wazuh syscollector
already provides (listening ports + process names + installed packages) and
produces:
- a high_value_score (0-100)
- a list of detected role dimensions ({role, label, weight})
The score feeds the URS via risk_factor() (see urs_service): roles raise an
asset's risk weighting even when the operator left criticality at "normal".
Detection sources: ports (port number + process name) and installed package
names. Package-based roles (Exchange/WSUS/MSSQL/backup/SW-distribution) are
reliable; port/process roles (DC/DNS/DHCP/WinRM) are good. Deep NTLM/Kerberos
usage analysis is NOT available from syscollector out of scope here.
"""
from __future__ import annotations
import re
from typing import Dict, List, Optional
# role key → (label, weight, port-set, process-substrings, package-substrings)
# A role fires if ANY of its port/process/package signals match.
_ROLES: List[dict] = [
{"role": "domain_controller", "label": "Domain Controller (AD DS)", "weight": 100,
"ports": {88, 464}, "ports_all": {389, 88}, # kerberos+ldap together = strong DC
"proc": ("ntds",), "pkg": ()},
{"role": "adcs", "label": "AD Certificate Services (CA)", "weight": 100,
"ports": set(), "proc": ("certsrv",),
"pkg": ("active directory certificate services", "certification authority")},
{"role": "backup", "label": "Backup server", "weight": 90,
"ports": set(), "proc": ("veeam", "acronis", "rubrik"),
"pkg": ("veeam", "acronis", "rubrik", "arcserve", "commvault", "netbackup",
"veritas backup", "altaro", "nakivo")},
{"role": "sw_distribution", "label": "Software distribution", "weight": 85,
"ports": set(), "proc": ("ccmexec",),
"pkg": ("configuration manager", "system center configuration", "endpoint configuration manager",
"configmgr", "pdq deploy", "bigfix", "ivanti")},
{"role": "exchange", "label": "Exchange (on-prem)", "weight": 85,
"ports": set(), "proc": ("msexchange",),
"pkg": ("microsoft exchange server",)},
{"role": "wsus", "label": "WSUS", "weight": 80,
"ports": {8530, 8531}, "proc": (),
"pkg": ("windows server update services", "wsus")},
{"role": "mssql", "label": "MS SQL Server", "weight": 70,
"ports": {1433}, "proc": ("sqlservr",),
"pkg": ("microsoft sql server 20", "sql server database engine")},
{"role": "dns", "label": "DNS server", "weight": 60,
"ports": {53}, "proc": ("dns.exe", "named", "dnsmasq"), "pkg": ()},
{"role": "dhcp", "label": "DHCP server", "weight": 55,
"ports": {67}, "proc": ("dhcpserver", "dhcpd"), "pkg": ("dhcp server",)},
{"role": "winrm", "label": "WinRM / PS-Remoting", "weight": 30,
"ports": {5985, 5986}, "proc": (), "pkg": ()},
]
def _norm(s: str) -> str:
return re.sub(r"\s+", " ", (s or "").lower()).strip()
# Every port any role cares about — the only ones an ESTABLISHED socket may
# be read as evidence of a local service.
_ROLE_PORTS: set = set()
for _r in _ROLES:
_ROLE_PORTS |= set(_r.get("ports") or ())
_ROLE_PORTS |= set(_r.get("ports_all") or ())
def _listening_ports(ports: List[dict]) -> tuple:
"""Return (set_of_listening_ports, set_of_process_substrings_lower)."""
open_ports: set = set()
procs: set = set()
for p in ports or []:
if not isinstance(p, dict):
continue
state = str(p.get("state") or "").lower()
proto = str(p.get("protocol") or p.get("proto") or "").lower()
# Same reason as in exposure_service: syscollector does not always
# report the listener. A DC serving live Kerberos or RDP showed no
# role at all because only ESTABLISHED sockets came through. An
# inbound connection — one whose LOCAL port is the service port —
# proves the service is running; outbound ones carry an ephemeral
# local port and cannot be confused with it.
if proto == "tcp" and state and state not in ("listening", "established"):
continue
try:
port = int(p.get("local_port") or (p.get("local") or {}).get("port") or 0)
except (ValueError, TypeError):
continue
# Only ports a role actually asks about can be read this way. Any other
# local port on an ESTABLISHED socket may just be the ephemeral end of
# an OUTBOUND connection, which says nothing about a service here.
if state == "established" and port not in _ROLE_PORTS:
continue
if port > 0:
open_ports.add(port)
proc = _norm(p.get("process") or "")
if proc:
procs.add(proc)
return open_ports, procs
def detect_risk_dimensions(ports: List[dict], packages: List[dict]) -> dict:
"""Detect crown-jewel roles → {score, dimensions:[{role,label,weight}]}.
Pure function (no DB / network) unit-testable.
"""
open_ports, procs = _listening_ports(ports)
pkg_names = [_norm(p.get("name") or "") for p in (packages or []) if isinstance(p, dict)]
pkg_blob = " | ".join(pkg_names)
detected: List[dict] = []
for r in _ROLES:
hit = False
# ports: any of `ports`, OR all of `ports_all`
if r.get("ports") and (open_ports & r["ports"]):
hit = True
if not hit and r.get("ports_all") and r["ports_all"].issubset(open_ports):
hit = True
# process substrings
if not hit and r.get("proc"):
if any(any(sub in pr for pr in procs) for sub in r["proc"]):
hit = True
# package substrings
if not hit and r.get("pkg"):
if any(sub in pkg_blob for sub in r["pkg"]):
hit = True
if hit:
detected.append({"role": r["role"], "label": r["label"], "weight": r["weight"]})
if not detected:
return {"score": 0.0, "dimensions": []}
weights = sorted((d["weight"] for d in detected), reverse=True)
score = float(weights[0]) + 0.3 * sum(weights[1:])
score = round(min(score, 100.0), 1)
# surface highest-weight roles first
detected.sort(key=lambda d: d["weight"], reverse=True)
return {"score": score, "dimensions": detected}
def risk_factor(high_value_score: Optional[float]) -> float:
"""Map the high-value score to a URS multiplier band.
>=90 1.5 (critical), >=70 1.3 (high), >=40 1.15, else 1.0."""
s = high_value_score or 0.0
if s >= 90:
return 1.5
if s >= 70:
return 1.3
if s >= 40:
return 1.15
return 1.0
+151
View File
@@ -0,0 +1,151 @@
"""
Samsung Security Maintenance Release (SMR) per-CVE detection.
For Samsung Android devices, security.samsungmobile.com's yearly page is a
MORE PRECISE source than the raw Google Android Security Bulletin (ASB):
Samsung explicitly excludes CVEs that don't apply to its own devices/chipsets
("Not applicable to Samsung devices") and adds Samsung Semiconductor-specific
fixes. Using raw ASB for a Samsung device produces false positives on
chipset-specific CVEs Samsung's own page says don't apply
(e.g. CVE-2025-59604 Qualcomm-only, explicitly not-applicable to Samsung).
The page ignores its own year/month query params in the sense that it always
serves the FULL requested year's content server-side (all ~12 SMR sections
are present in the raw HTML the accordion UI is pure client-side CSS/JS,
it doesn't gate what's delivered), so ?year=YYYY is fetched once and cached,
covering every month of that year.
Falls back to the raw-ASB scanner (android_cve_service) for months this page
doesn't cover (very old dates, or a fetch failure).
"""
from __future__ import annotations
import json
import logging
import re
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Tuple
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
_SMR_URL = "https://security.samsungmobile.com/securityUpdate.smsb"
_CACHE_PREFIX = "smr_year_v1_"
_CACHE_TTL = timedelta(hours=24) # the current year gains a new SMR monthly
_MONTHS = {"JAN": 1, "FEB": 2, "MAR": 3, "APR": 4, "MAY": 5, "JUN": 6,
"JUL": 7, "AUG": 8, "SEP": 9, "OCT": 10, "NOV": 11, "DEC": 12}
_CVE_RE = re.compile(r"CVE-\d{4}-\d{4,7}")
_SEV_LIST_RE = {
sev: re.compile(rf'<strong><font[^>]*>{sev}</font></strong><br\s*/?>([^<]*)', re.I)
for sev in ("Critical", "High")
}
_NA_RE = re.compile(r"Not applicable to Samsung devices</font></strong><br\s*/?>([^<]*)", re.I)
_SEM_HEADER_RE = re.compile(r"Samsung Semiconductor patch is also included", re.I)
def _parse_smr_html(html: str) -> Dict[Tuple[int, int], List[Tuple[str, str]]]:
"""{(year, month): [(cve, severity)]}. severity is 'critical'|'high'.
Google Critical/High minus the "Not applicable" list, plus the Samsung
Semiconductor Critical/High list (Samsung's own chipset-fix scope)."""
positions = [(m.start(), m.group(1), m.group(2))
for m in re.finditer(r"SMR-([A-Z]{3})-(\d{4})", html)]
out: Dict[Tuple[int, int], List[Tuple[str, str]]] = {}
for i, (pos, mon, yr) in enumerate(positions):
month = _MONTHS.get(mon)
if not month:
continue
end = positions[i + 1][0] if i + 1 < len(positions) else len(html)
block = html[pos:end]
def sev_cves(label: str, text: str) -> List[str]:
m = _SEV_LIST_RE[label].search(text)
return _CVE_RE.findall(m.group(1)) if m else []
critical = sev_cves("Critical", block)
high = sev_cves("High", block)
na_m = _NA_RE.search(block)
not_applicable = set(_CVE_RE.findall(na_m.group(1))) if na_m else set()
sem_critical: List[str] = []
sem_high: List[str] = []
sem_m = _SEM_HEADER_RE.search(block)
if sem_m:
sem_block = block[sem_m.start():sem_m.start() + 2000]
sem_critical = sev_cves("Critical", sem_block)
sem_high = sev_cves("High", sem_block)
pairs: List[Tuple[str, str]] = []
seen: set = set()
for cve in critical:
if cve in not_applicable or cve in seen:
continue
seen.add(cve)
pairs.append((cve, "critical"))
for cve in high:
if cve in not_applicable or cve in seen:
continue
seen.add(cve)
pairs.append((cve, "high"))
for cve in sem_critical:
if cve in seen:
continue
seen.add(cve)
pairs.append((cve, "critical"))
for cve in sem_high:
if cve in seen:
continue
seen.add(cve)
pairs.append((cve, "high"))
out[(int(yr), month)] = pairs
return out
def fetch_smr_year(db: Session, year: int) -> Optional[Dict[Tuple[int, int], List[Tuple[str, str]]]]:
"""Cached fetch+parse of one year's SMR page (covers all its months).
None on fetch failure (caller should fall back to ASB)."""
from app.models.setting import Setting
key = f"{_CACHE_PREFIX}{year}"
row = db.query(Setting).filter(Setting.key == key).first()
if row and row.value:
try:
blob = json.loads(row.value)
ts = datetime.fromisoformat(blob["ts"])
if datetime.now() - ts < _CACHE_TTL:
return {tuple(map(int, k.split("-"))): [tuple(p) for p in v]
for k, v in blob["months"].items()}
except Exception:
pass
import httpx
try:
with httpx.Client(timeout=30.0, follow_redirects=True,
headers={"User-Agent": "TrueVuln/1.0"}) as c:
r = c.get(_SMR_URL, params={"year": year})
if r.status_code != 200:
return None
parsed = _parse_smr_html(r.text)
except Exception as e:
logger.debug("SMR fetch failed for year %s: %s", year, e)
return None
if not parsed:
return None
payload = json.dumps({
"ts": datetime.now().isoformat(),
"months": {f"{y}-{m}": pairs for (y, m), pairs in parsed.items()},
})
if row:
row.value = payload
else:
db.add(Setting(key=key, value=payload, description=f"Samsung SMR {year} (cve,severity) per month"))
db.commit()
return parsed
def get_smr_month(db: Session, year: int, month: int) -> Optional[List[Tuple[str, str]]]:
"""CVEs Samsung's own SMR page attributes as applicable for (year, month),
or None if that year's page couldn't be fetched or doesn't cover the
month (caller should fall back to raw ASB)."""
parsed = fetch_smr_year(db, year)
if parsed is None:
return None
return parsed.get((year, month))
+225
View File
@@ -0,0 +1,225 @@
"""
Forward audit-log events to an external syslog server (SIEM ingestion).
Every row inserted into `audit_logs` is mirrored as an RFC-5424 syslog message
over UDP or TCP, with a per-event severity so a SIEM can decode/rule/alert on
them (login failures, lockouts, access-denied, config changes, ...).
Design (ponytail):
- One bounded queue + one daemon worker thread. The SQLAlchemy after_insert
hook only enqueues (never blocks the request / DB flush). Bursty syncs that
write thousands of VULNERABILITY_DETECTED rows drain sequentially through the
single worker instead of spawning a thread per event.
- Config (`syslog_config` setting) is cached for 30 s so the hot path never
hits the DB. TCP keeps a persistent socket and reconnects on failure.
- Disabled by default the hook is a cheap no-op until an admin turns it on.
"""
from __future__ import annotations
import json
import logging
import queue
import socket
import threading
import time
from datetime import datetime
from typing import Optional
logger = logging.getLogger(__name__)
APP_NAME = "truevuln"
_QUEUE: "queue.Queue[dict]" = queue.Queue(maxsize=10000)
_worker_started = False
_worker_lock = threading.Lock()
# Config cache
_cfg_cache: dict = {"ts": 0.0, "cfg": None}
_CFG_TTL = 30.0
# RFC-5424 severities
_SEV_EMERG, _SEV_ALERT, _SEV_CRIT, _SEV_ERR, _SEV_WARN, _SEV_NOTICE, _SEV_INFO, _SEV_DEBUG = range(8)
def _severity_for(event_type: str) -> int:
"""Map an AuditEventType name to a syslog severity so a SIEM can prioritise."""
e = (event_type or "").upper()
if "SECURITY_ALERT" in e or "PERMISSION_ESCALATION" in e:
return _SEV_ALERT
if "FAILED" in e or "DENIED" in e or "LOCK" in e:
return _SEV_WARN
if "DELETED" in e or "DEACTIVATED" in e or "DISABLED" in e or "CONFIG_CHANGE" in e:
return _SEV_NOTICE
return _SEV_INFO
def _load_config() -> Optional[dict]:
"""Cached read of the `syslog_config` setting. Returns None when disabled/
unset. Shape: {enabled, host, port, protocol('udp'|'tcp'), facility(int)}."""
now = time.monotonic()
if now - _cfg_cache["ts"] < _CFG_TTL:
return _cfg_cache["cfg"]
cfg = None
try:
from app.database import SessionLocal
from app.models.setting import Setting
db = SessionLocal()
try:
row = db.query(Setting).filter(Setting.key == "syslog_config").first()
if row and row.value:
parsed = json.loads(row.value)
if parsed.get("enabled") and parsed.get("host"):
cfg = {
"host": str(parsed["host"]).strip(),
"port": int(parsed.get("port") or 514),
"protocol": str(parsed.get("protocol") or "udp").lower(),
"facility": int(parsed.get("facility") if parsed.get("facility") is not None else 16),
}
finally:
db.close()
except Exception as e: # never let config trouble break the app
logger.debug("syslog config load failed: %s", e)
cfg = None
_cfg_cache["ts"] = now
_cfg_cache["cfg"] = cfg
return cfg
def _build_message(evt: dict, facility: int) -> bytes:
sev = _severity_for(evt.get("event_type", ""))
pri = facility * 8 + sev
ts = (evt.get("timestamp") or datetime.now()).astimezone().isoformat()
host = socket.gethostname() or "-"
msgid = (evt.get("event_type") or "AUDIT")[:32]
# MSG: human-readable + a few key=value fields for easy SIEM extraction.
parts = [evt.get("event_description") or ""]
if evt.get("user_id") is not None:
parts.append(f"user_id={evt['user_id']}")
if evt.get("resource_type"):
parts.append(f"resource={evt['resource_type']}:{evt.get('resource_id') or ''}")
if evt.get("ip_address"):
parts.append(f"src_ip={evt['ip_address']}")
msg = " ".join(p for p in parts if p)
line = f"<{pri}>1 {ts} {host} {APP_NAME} - {msgid} - {msg}"
return line.encode("utf-8", "replace")
class _Sender:
"""Holds a persistent TCP socket (reconnect on failure) or a UDP socket."""
def __init__(self):
self._sock: Optional[socket.socket] = None
self._key: tuple = ()
def _ensure(self, host: str, port: int, proto: str):
key = (host, port, proto)
if self._sock is not None and key == self._key:
return
self.close()
self._key = key
if proto == "tcp":
s = socket.create_connection((host, port), timeout=5)
else:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
self._sock = s
def send(self, data: bytes, host: str, port: int, proto: str):
self._ensure(host, port, proto)
assert self._sock is not None
if proto == "tcp":
# RFC 6587 non-transparent (LF) framing — accepted by rsyslog/syslog-ng.
self._sock.sendall(data + b"\n")
else:
self._sock.sendto(data, (host, port))
def close(self):
if self._sock is not None:
try:
self._sock.close()
except Exception:
pass
self._sock = None
self._key = ()
def _worker():
sender = _Sender()
while True:
evt = _QUEUE.get()
try:
cfg = _load_config()
if not cfg:
continue
data = _build_message(evt, cfg["facility"])
try:
sender.send(data, cfg["host"], cfg["port"], cfg["protocol"])
except Exception as e:
sender.close() # force reconnect next time
logger.debug("syslog send failed (%s:%s/%s): %s",
cfg["host"], cfg["port"], cfg["protocol"], e)
finally:
_QUEUE.task_done()
def _ensure_worker():
global _worker_started
if _worker_started:
return
with _worker_lock:
if _worker_started:
return
threading.Thread(target=_worker, name="syslog-forwarder", daemon=True).start()
_worker_started = True
def enqueue(evt: dict) -> None:
"""Best-effort: drop the event rather than block or raise if the queue is
full or the worker can't start."""
try:
_ensure_worker()
_QUEUE.put_nowait(evt)
except queue.Full:
pass
except Exception as e:
logger.debug("syslog enqueue failed: %s", e)
def send_test(cfg: dict) -> tuple[bool, str]:
"""Send a one-off test message with an explicit config (admin 'Test' button)."""
try:
facility = int(cfg.get("facility") if cfg.get("facility") is not None else 16)
evt = {"event_type": "SECURITY_ALERT", "event_description": "TrueVuln syslog test message",
"timestamp": datetime.now()}
data = _build_message(evt, facility)
s = _Sender()
try:
s.send(data, str(cfg["host"]).strip(), int(cfg.get("port") or 514),
str(cfg.get("protocol") or "udp").lower())
finally:
s.close()
return True, "sent"
except Exception as e:
return False, str(e)
def register_audit_listener() -> None:
"""Hook every AuditLog insert → enqueue. Called once at startup."""
from sqlalchemy import event
from app.models.audit_log import AuditLog
@event.listens_for(AuditLog, "after_insert")
def _after_insert(mapper, connection, target): # noqa: ARG001
# Only scalar columns here — relationships would emit SQL mid-flush.
try:
enqueue({
"event_type": target.event_type.value if getattr(target, "event_type", None) else None,
"event_description": target.event_description,
"user_id": target.user_id,
"resource_type": target.resource_type,
"resource_id": target.resource_id,
"ip_address": target.ip_address,
"timestamp": target.timestamp,
})
except Exception:
pass
logger.info("syslog: audit-log forwarder registered")
+11 -1
View File
@@ -227,14 +227,24 @@ def compute_urs(
avs = compute_avs(db, asset_id, mode=avs_mode)
ass, policy_count = compute_ass(db, asset_id)
# Effective weighting = max(operator criticality, detected-role factor).
# A crown-jewel role (DC/ADCS → factor 1.5) raises the URS even when the
# operator left criticality at "normal"; the operator can still set a
# higher criticality, never a lower-than-role one.
crit_factor = _criticality_factor(asset.criticality)
try:
from app.services.risk_dimensions_service import risk_factor
role_factor = risk_factor(asset.high_value_score)
except Exception:
role_factor = 1.0
eff_factor = max(crit_factor, role_factor)
parts = [v for v in (avs, ass) if v is not None]
if not parts:
urs: Optional[float] = None
else:
base = sum(parts) / len(parts)
urs = min(round(base * crit_factor, 1), 100.0)
urs = min(round(base * eff_factor, 1), 100.0)
# Spec wants integer URS — round to nearest int but keep one
# decimal in storage so trend arrows can detect 0.5-point moves.
urs = round(urs, 1)
+189 -10
View File
@@ -592,7 +592,7 @@ class VulnOverrideService:
# covers far more CVEs (every published CVE, not just CISA-curated
# ones). Disk-cached for 12h to avoid hammering GitHub.
_CVELIST_ZIP_URL = "https://github.com/CVEProject/cvelistV5/archive/refs/heads/main.zip"
_CVELIST_CACHE_PATH = "/tmp/vulncheck-cvelistv5-cache.zip"
_CVELIST_CACHE_PATH = "/tmp/truevuln-cvelistv5-cache.zip"
_CVELIST_CACHE_TTL_SECONDS = 12 * 3600
def load_cisa_vulnrichment_data(self, cve_ids: List[str]) -> Dict[str, VerifiedCVEData]:
@@ -633,8 +633,13 @@ class VulnOverrideService:
v = verified.get(cid)
return v is None or v.cvss_score is None
# Cap scales with the NVD API key: 50 req/30s authenticated vs 5
# unauthenticated, so a key makes a much larger batch feasible before
# cvelistV5 (stage 3) mops up the rest.
import os as _os
nvd_cap = 1500 if _os.getenv("NVD_API_KEY", "").strip() else 100
missing = [c for c in cve_ids_upper if _needs_cvss(c)]
if missing and len(missing) <= 100:
if missing and len(missing) <= nvd_cap:
try:
nvd_data = self._load_via_nvd(missing)
for cve_id, data in nvd_data.items():
@@ -647,9 +652,9 @@ class VulnOverrideService:
logger.warning("NVD fallback failed: %s", e)
elif missing:
logger.info(
"stage 2 (NVD): skipped — %d missing CVEs exceeds the 100-cap "
"stage 2 (NVD): skipped — %d missing CVEs exceeds the %d-cap "
"to avoid rate-limit; falling through to cvelistV5",
len(missing),
len(missing), nvd_cap,
)
# Stage 3 — cvelistV5 ZIP snapshot from CVE.org. Used when many
@@ -668,6 +673,23 @@ class VulnOverrideService:
)
except Exception as e:
logger.warning("cvelistV5 fallback failed: %s", e)
# Stage 4 — GitHub Security Advisories. Backstop for CVEs still missing
# a score after NVD + cvelistV5, i.e. very fresh CVEs GHSA has but the
# others don't yet (the gap the tester hit). Self-throttles on the
# GitHub rate limit; a github_pat setting lifts it to 5000 req/h.
missing = [c for c in cve_ids_upper if _needs_cvss(c)]
if missing:
try:
ghsa_data = self._load_via_ghsa(missing)
for cve_id, data in ghsa_data.items():
_merge_cvss_into(verified, cve_id, data)
logger.info(
"stage 4 (GHSA): %d/%d missing CVEs filled",
len(ghsa_data), len(missing),
)
except Exception as e:
logger.warning("GHSA fallback failed: %s", e)
return verified
def _load_via_per_cve_raw(self, cve_ids: List[str]) -> Dict[str, VerifiedCVEData]:
@@ -711,6 +733,76 @@ class VulnOverrideService:
logger.error("vulnrichment client error: %s", e)
return verified
def _load_via_ghsa(self, cve_ids: List[str]) -> Dict[str, VerifiedCVEData]:
"""GitHub Security Advisories — last-resort CVSS/severity/description.
GHSA mirrors CVEs that can still be missing from NVD and cvelistV5 when
very fresh (the gap the tester hit on new Firefox/Notepad++ CVEs). The
global-advisory API returns cvss + severity + description keyed by CVE.
Optional PAT (setting `github_pat`) lifts the rate limit 60 5000/h;
the loop stops cleanly when the limit is hit.
Only CVSS/severity/description are filled GHSA 'unreviewed' advisories
(desktop-app CVEs like Notepad++) carry NO affected-version range, so no
fix/version data can be derived here (verified against the live API)."""
import httpx
from app.auth.setting_crypto import read_setting_value
token = None
try:
token = (read_setting_value(self.db, "github_pat") or "").strip() or None
except Exception:
token = None
headers = {"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28"}
if token:
headers["Authorization"] = f"Bearer {token}"
verified: Dict[str, VerifiedCVEData] = {}
with httpx.Client(timeout=15.0, follow_redirects=True, headers=headers) as client:
for cve_id in cve_ids:
try:
r = client.get("https://api.github.com/advisories",
params={"cve_id": cve_id})
if r.status_code == 403 and r.headers.get("x-ratelimit-remaining") == "0":
logger.warning(
"GHSA: rate limit hit — stopping (set the github_pat "
"setting for 5000 req/h)")
break
if r.status_code != 200:
continue
arr = r.json() or []
if not arr:
continue
adv = arr[0]
cvss = adv.get("cvss") or {}
score = cvss.get("score")
if not score: # cvss.score can be 0/None → try structured block
sev_block = adv.get("cvss_severities") or {}
for k in ("cvss_v4", "cvss_v3"):
s = (sev_block.get(k) or {}).get("score")
if s:
score, cvss = s, sev_block[k]
break
if not score:
continue
score = float(score)
sev = (adv.get("severity") or "").lower() or \
self._severity_from_cvss(score).value
verified[cve_id] = VerifiedCVEData(
cve_id=cve_id,
cvss_score=score,
cvss_vector=cvss.get("vector_string") or None,
severity=sev,
description=(adv.get("description") or adv.get("summary") or None),
references=adv.get("references") or None,
source="ghsa",
)
except Exception as e:
logger.debug("GHSA fetch failed for %s: %s", cve_id, e)
return verified
def _load_via_nvd(self, cve_ids: List[str]) -> Dict[str, VerifiedCVEData]:
"""
Pull CVSSv3 from the public NVD REST API as a Vulnrichment
@@ -725,13 +817,18 @@ class VulnOverrideService:
loaders so apply_overrides treats both sources identically.
"""
import httpx
import os
import time
# NVD API key (env NVD_API_KEY, same as the enrichment service) lifts
# the limit from 5 to 50 req/30s and lets us throttle far less.
api_key = os.getenv("NVD_API_KEY", "").strip()
headers = {"apiKey": api_key} if api_key else {}
batch = 45 if api_key else 5 # requests before a 1s pause
verified: Dict[str, VerifiedCVEData] = {}
# Throttle to be polite — 1 req/sec stays well below the unauth limit.
with httpx.Client(timeout=15.0, follow_redirects=True) as client:
with httpx.Client(timeout=15.0, follow_redirects=True, headers=headers) as client:
for idx, cve_id in enumerate(cve_ids):
if idx and idx % 5 == 0:
if idx and idx % batch == 0:
time.sleep(1.0) # crude throttle
try:
r = client.get(
@@ -814,7 +911,7 @@ class VulnOverrideService:
Stage 3 fallback official MITRE/CVE.org cvelistV5 cache.
Pulls https://github.com/CVEProject/cvelistV5/archive/refs/heads/main.zip
once per 12h (disk cache at /tmp/vulncheck-cvelistv5-cache.zip),
once per 12h (disk cache at /tmp/truevuln-cvelistv5-cache.zip),
then walks the wanted CVE files in-place. Same CVE-5 JSON shape
as Vulnrichment so _parse_vulnrichment_record handles both.
@@ -910,6 +1007,88 @@ class VulnOverrideService:
)
return verified
def load_cve_dates_via_zip(self, cve_ids: List[str]) -> Dict[str, dict]:
"""Bulk-extract {cve_id: {"published": iso, "last_modified": iso}}
from the cvelistV5 ZIP snapshot reuses the SAME 12h disk cache as
the CVSS-correction cascade (/tmp/truevuln-cvelistv5-cache.zip), so
when CVSS-correction already pulled the ZIP this is download-free.
Used by the enrichment date-backfill when many CVEs are missing
dates at once (fresh DB) one 557 MB ZIP + local walk beats
thousands of per-CVE HTTP round-trips. Dates come from the
authoritative cveMetadata.datePublished / .dateUpdated.
"""
import os
import re
import time
import zipfile
out: Dict[str, dict] = {}
cve_pattern = re.compile(r"^CVE-(\d{4})-(\d+)$")
wanted = set(cve_ids)
def _zip_path_for(cve_id: str) -> Optional[str]:
m = cve_pattern.match(cve_id)
if not m:
return None
year, num = m.group(1), m.group(2)
return f"cvelistV5-main/cves/{year}/{int(num) // 1000}xxx/{cve_id}.json"
cache_path = self._CVELIST_CACHE_PATH
cache_fresh = (
os.path.exists(cache_path)
and (time.time() - os.path.getmtime(cache_path)) < self._CVELIST_CACHE_TTL_SECONDS
and os.path.getsize(cache_path) > 100_000_000
)
if not cache_fresh:
# Reuse the cascade's downloader (handles streaming + .part swap).
self._download_cvelistv5_zip()
with zipfile.ZipFile(cache_path) as zf:
names = set(zf.namelist())
for cve_id in wanted:
in_zip = _zip_path_for(cve_id)
if not in_zip or in_zip not in names:
continue
try:
with zf.open(in_zip) as jf:
meta = (json.loads(jf.read().decode("utf-8")).get("cveMetadata") or {})
out[cve_id] = {
"published": (meta.get("datePublished") or None),
"last_modified": (meta.get("dateUpdated") or None),
}
except Exception as e:
logger.debug("cvelistV5 date parse failed for %s: %s", cve_id, e)
logger.info("cvelistV5 dates: %d/%d CVEs found in ZIP", len(out), len(wanted))
return out
def _download_cvelistv5_zip(self) -> None:
"""Stream the cvelistV5 main.zip to the shared disk cache (12h TTL)."""
import httpx
import os
cache_path = self._CVELIST_CACHE_PATH
tmp_path = cache_path + ".part"
logger.info("cvelistV5: downloading fresh ZIP to %s", cache_path)
try:
with httpx.Client(timeout=httpx.Timeout(120.0, connect=15.0),
follow_redirects=True) as client:
with client.stream("GET", self._CVELIST_ZIP_URL) as resp:
resp.raise_for_status()
with open(tmp_path, "wb") as f:
for chunk in resp.iter_bytes(chunk_size=1024 * 512):
f.write(chunk)
os.replace(tmp_path, cache_path)
logger.info("cvelistV5: download complete (%.1f MB)",
os.path.getsize(cache_path) / 1_048_576)
except Exception:
if os.path.exists(tmp_path):
try:
os.remove(tmp_path)
except Exception:
pass
raise
def _load_via_zip_snapshot(self, cve_ids: List[str]) -> Dict[str, VerifiedCVEData]:
"""Single 249 MB ZIP download → walk locally for the requested CVE
files. Two orders of magnitude faster than per-CVE 404 lookups
@@ -1406,7 +1585,7 @@ class VulnOverrideService:
Vulnerability.status == VulnerabilityStatus.open
).all()
cve_ids = list(set(v.cve_id for v in vulns if v.cve_id and not v.cve_id.startswith("NESSUS-")))
cve_ids = list(set(v.cve_id for v in vulns if v.cve_id and v.cve_id.upper().startswith("CVE-")))
if not cve_ids:
return {"checked": 0, "updated": 0, "message": "Keine CVEs zum Korrigieren"}
@@ -1540,7 +1719,7 @@ def correct_vulnerability_scores(
return {"message": "Keine Vulnerabilities zum Korrigieren", "updated": 0}
# Sammle alle CVE-IDs für Vulnrichment-Abfrage
all_cve_ids = list(set(v.cve_id for v in vulns if v.cve_id and not v.cve_id.startswith("NESSUS-")))
all_cve_ids = list(set(v.cve_id for v in vulns if v.cve_id and v.cve_id.upper().startswith("CVE-")))
if not all_cve_ids:
return {"message": "Keine echten CVEs zum Korrigieren", "updated": 0}
-2
View File
@@ -1,5 +1,3 @@
version: '3.8'
services:
# Database
postgres:
Binary file not shown.

Before

Width:  |  Height:  |  Size: 641 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 451 KiB

+5 -29
View File
@@ -1,36 +1,12 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
# TrueVuln Frontend
## Getting Started
Next.js 16 (App Router) UI for TrueVuln. See the [root README](../README.md) for setup, deployment, and configuration — this app is deployed via Docker Compose alongside the backend, not standalone or on Vercel.
First, run the development server:
## Local dev (without Docker)
```bash
npm install
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
Open [http://localhost:3000](http://localhost:3000). The dev server proxies API calls to the backend — see [../README.DEV.md](../README.DEV.md) for the full local (non-Docker) setup.
+120 -61
View File
@@ -1,8 +1,7 @@
"use client";
import { useEffect, useState } from 'react';
import api from '../../../lib/api'; // Pfad prüfen: admin/audit-logs -> ../../../lib/api
import { UserInfo } from '../../../types';
import { useEffect, useState, useCallback } from 'react';
import api from '../../../lib/api';
import { useRouter } from 'next/navigation';
import {
ClockIcon,
@@ -12,6 +11,7 @@ import {
ComputerDesktopIcon,
ArrowPathIcon,
ArrowDownTrayIcon,
MagnifyingGlassIcon,
} from '@heroicons/react/24/outline';
interface AuditLog {
@@ -26,41 +26,65 @@ interface AuditLog {
timestamp: string;
}
const PAGE_SIZE = 100;
type SortKey = 'timestamp' | 'user_id' | 'event_type' | 'resource_type' | 'ip_address';
const PAGE_SIZES = [25, 50, 100, 250];
// column header → sort key (null = not sortable)
const COLUMNS: { label: string; key: SortKey | null }[] = [
{ label: 'Time', key: 'timestamp' },
{ label: 'User', key: 'user_id' },
{ label: 'Event', key: 'event_type' },
{ label: 'Description', key: null },
{ label: 'Resource', key: 'resource_type' },
{ label: 'IP', key: 'ip_address' },
];
export default function AuditLogsPage() {
const [logs, setLogs] = useState<AuditLog[]>([]);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(0); // 0-indexed
const [pageSize, setPageSize] = useState(100);
const [search, setSearch] = useState('');
const [searchInput, setSearchInput] = useState('');
const [sort, setSort] = useState<SortKey>('timestamp');
const [order, setOrder] = useState<'asc' | 'desc'>('desc');
const router = useRouter();
useEffect(() => {
fetchLogs(0, true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const fetchLogs = async (skip = 0, replace = false) => {
const fetchLogs = useCallback(async () => {
setLoading(true);
try {
if (replace) setLoading(true);
else setLoadingMore(true);
const res = await api.get('/audit/logs', { params: { skip, limit: PAGE_SIZE } });
const incoming: AuditLog[] = Array.isArray(res.data) ? res.data : [];
setHasMore(incoming.length === PAGE_SIZE);
if (replace) {
setLogs(incoming);
} else {
setLogs(prev => [...prev, ...incoming]);
}
const res = await api.get('/audit/logs', {
params: { skip: page * pageSize, limit: pageSize, search: search || undefined, sort, order },
});
setLogs(Array.isArray(res.data) ? res.data : []);
const t = parseInt(res.headers['x-total-count'] ?? '0', 10);
setTotal(Number.isNaN(t) ? 0 : t);
} catch (error: any) {
console.error("Failed to fetch logs", error);
if (error.response?.status === 403) {
router.push('/dashboard');
}
console.error('Failed to fetch logs', error);
if (error.response?.status === 403) router.push('/');
} finally {
setLoading(false);
setLoadingMore(false);
}
}, [page, pageSize, search, sort, order, router]);
useEffect(() => { fetchLogs(); }, [fetchLogs]);
// Debounce the search box → commit to `search` (which resets to page 0).
useEffect(() => {
const id = setTimeout(() => { setPage(0); setSearch(searchInput.trim()); }, 400);
return () => clearTimeout(id);
}, [searchInput]);
const toggleSort = (key: SortKey | null) => {
if (!key) return;
if (sort === key) {
setOrder(o => (o === 'asc' ? 'desc' : 'asc'));
} else {
setSort(key);
setOrder('desc');
}
setPage(0);
};
const getEventIcon = (type: string) => {
@@ -71,34 +95,41 @@ export default function AuditLogsPage() {
return <DocumentTextIcon className="h-4 w-4 text-gray-500" />;
};
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleString();
};
const formatDate = (dateStr: string) => new Date(dateStr).toLocaleString();
if (loading) {
return <div className="p-8 text-center text-gray-500 font-mono">Loading Audit Logs...</div>;
}
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const from = total === 0 ? 0 : page * pageSize + 1;
const to = Math.min((page + 1) * pageSize, total);
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div className="flex flex-wrap justify-between items-center gap-3">
<h1 className="text-2xl font-bold text-gray-900 tracking-tight font-mono">
Audit Logs
<span className="ml-2 text-sm font-normal text-gray-500 bg-gray-100 px-2 py-0.5 rounded-full">
{logs.length} events
{total.toLocaleString()} events
</span>
</h1>
<div className="flex items-center gap-2">
<div className="relative">
<MagnifyingGlassIcon className="h-4 w-4 text-gray-400 absolute left-2.5 top-1/2 -translate-y-1/2" />
<input
type="text"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Search description, event, resource, IP, user…"
className="w-72 rounded-md border-gray-300 pl-8 pr-3 h-9 text-sm font-mono focus:border-truevuln-blue focus:ring-truevuln-blue"
/>
</div>
<a
href="/audit/logs/export"
className="inline-flex items-center gap-1 p-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors text-sm font-mono"
title="Export full audit log as CSV (admin)"
>
<ArrowDownTrayIcon className="h-5 w-5" />
CSV
<ArrowDownTrayIcon className="h-5 w-5" /> CSV
</a>
<button
onClick={() => fetchLogs(0, true)}
onClick={() => fetchLogs()}
className="p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
title="Refresh Logs"
>
@@ -112,16 +143,24 @@ export default function AuditLogsPage() {
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider font-mono">Time</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider font-mono">User</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider font-mono">Event</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider font-mono">Description</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider font-mono">Resource</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider font-mono">IP</th>
{COLUMNS.map((c) => (
<th
key={c.label}
onClick={() => toggleSort(c.key)}
className={`px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider font-mono select-none ${c.key ? 'cursor-pointer hover:text-gray-700' : ''}`}
>
{c.label}
{c.key && sort === c.key && <span className="ml-1">{order === 'asc' ? '▲' : '▼'}</span>}
</th>
))}
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200 font-mono text-sm">
{logs.map((log) => (
{loading ? (
<tr><td colSpan={6} className="px-6 py-12 text-center text-gray-500">Loading</td></tr>
) : logs.length === 0 ? (
<tr><td colSpan={6} className="px-6 py-12 text-center text-gray-500">No audit logs found.</td></tr>
) : logs.map((log) => (
<tr key={log.id} className="hover:bg-gray-50 transition-colors">
<td className="px-6 py-4 whitespace-nowrap text-gray-500">
<div className="flex items-center gap-2">
@@ -155,27 +194,47 @@ export default function AuditLogsPage() {
</td>
</tr>
))}
{logs.length === 0 && (
<tr>
<td colSpan={6} className="px-6 py-12 text-center text-gray-500">
No audit logs found.
</td>
</tr>
)}
</tbody>
</table>
</div>
{hasMore && (
<div className="border-t border-gray-200 p-3 text-center bg-gray-50">
<button
disabled={loadingMore}
onClick={() => fetchLogs(logs.length, false)}
className="text-sm font-mono px-4 py-1.5 rounded border border-gray-300 hover:bg-white disabled:opacity-60"
{/* Pagination bar */}
<div className="border-t border-gray-200 px-4 py-3 flex flex-wrap items-center justify-between gap-3 bg-gray-50 text-sm font-mono text-gray-600">
<div className="flex items-center gap-2">
<span>Rows per page:</span>
<select
value={pageSize}
onChange={(e) => { setPageSize(parseInt(e.target.value, 10)); setPage(0); }}
className="rounded-md border-gray-300 h-8 text-sm py-0"
>
{loadingMore ? 'Loading…' : `Load next ${PAGE_SIZE}`}
</button>
{PAGE_SIZES.map(s => <option key={s} value={s}>{s}</option>)}
</select>
<span className="ml-2">{from.toLocaleString()}{to.toLocaleString()} of {total.toLocaleString()}</span>
</div>
)}
<div className="flex items-center gap-2">
<button
disabled={page === 0}
onClick={() => setPage(0)}
className="px-2 py-1 rounded border border-gray-300 bg-white hover:bg-gray-100 disabled:opacity-40 disabled:cursor-not-allowed"
>« First</button>
<button
disabled={page === 0}
onClick={() => setPage(p => Math.max(0, p - 1))}
className="px-3 py-1 rounded border border-gray-300 bg-white hover:bg-gray-100 disabled:opacity-40 disabled:cursor-not-allowed"
>Prev</button>
<span className="px-2">Page {page + 1} of {totalPages.toLocaleString()}</span>
<button
disabled={page + 1 >= totalPages}
onClick={() => setPage(p => p + 1)}
className="px-3 py-1 rounded border border-gray-300 bg-white hover:bg-gray-100 disabled:opacity-40 disabled:cursor-not-allowed"
>Next</button>
<button
disabled={page + 1 >= totalPages}
onClick={() => setPage(totalPages - 1)}
className="px-2 py-1 rounded border border-gray-300 bg-white hover:bg-gray-100 disabled:opacity-40 disabled:cursor-not-allowed"
>Last »</button>
</div>
</div>
</div>
</div>
);
+7 -7
View File
@@ -195,7 +195,7 @@ export default function AuthAdminPage() {
<button
onClick={() => runTest(p.name as any)}
disabled={!p.configured || testing === p.name}
className="w-full text-xs font-mono bg-vulncheck-blue text-white rounded px-2 py-1 hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed"
className="w-full text-xs font-mono bg-truevuln-blue text-white rounded px-2 py-1 hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed"
>
{testing === p.name ? "Testing…" : `Test ${p.name.toUpperCase()} connection`}
</button>
@@ -223,7 +223,7 @@ export default function AuthAdminPage() {
<button
onClick={saveMappings}
disabled={saving}
className="text-sm font-mono bg-vulncheck-blue text-white rounded px-4 py-2 hover:bg-blue-600 disabled:opacity-60"
className="text-sm font-mono bg-truevuln-blue text-white rounded px-4 py-2 hover:bg-blue-600 disabled:opacity-60"
>
{saving ? "Saving…" : "Save mappings"}
</button>
@@ -260,10 +260,10 @@ export default function AuthAdminPage() {
onChange={(e) => updateRule(provider, idx, { pattern: e.target.value })}
placeholder={
provider === "ldap"
? "CN=VulnCheck-Admins,*"
? "CN=TrueVuln-Admins,*"
: provider === "oidc"
? "vulncheck-admins | <azure-group-uuid>"
: "VulnCheck-Admins"
? "truevuln-admins | <azure-group-uuid>"
: "TrueVuln-Admins"
}
className="w-full text-xs border border-gray-300 rounded px-2 py-1"
/>
@@ -281,9 +281,9 @@ export default function AuthAdminPage() {
</td>
<td className="px-3 py-2 text-right whitespace-nowrap">
<button onClick={() => moveRule(provider, idx, -1)} disabled={idx === 0}
className="px-1 text-gray-400 hover:text-vulncheck-blue disabled:opacity-30"></button>
className="px-1 text-gray-400 hover:text-truevuln-blue disabled:opacity-30"></button>
<button onClick={() => moveRule(provider, idx, +1)} disabled={idx === (mappings[provider]?.length || 0) - 1}
className="px-1 text-gray-400 hover:text-vulncheck-blue disabled:opacity-30"></button>
className="px-1 text-gray-400 hover:text-truevuln-blue disabled:opacity-30"></button>
<button onClick={() => removeRule(provider, idx)}
className="ml-2 px-2 py-0.5 text-[10px] bg-red-50 text-red-600 border border-red-200 rounded hover:bg-red-100">remove</button>
</td>
+161
View File
@@ -0,0 +1,161 @@
"use client";
// Security Advisory Feeds — CISA KEV (actively exploited) plus configurable
// RSS sources (ZDI / CERT-EU / BSI / Cisco / custom). These sources publish
// ahead of NVD/cvelistV5, so this page is the early-warning surface.
import { useEffect, useState } from 'react';
import api from '../../lib/api';
type FeedItem = { title: string; link: string; date: string; summary: string };
type Feed = { id: string; name: string; url: string; enabled: boolean; items: FeedItem[]; error: string | null };
type FeedCfg = { id: string; name: string; url: string; enabled: boolean };
export default function AdvisoriesPage() {
const [kev, setKev] = useState<any[]>([]);
const [feeds, setFeeds] = useState<Feed[]>([]);
const [fetchedAt, setFetchedAt] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [userRole, setUserRole] = useState('');
const [openFeed, setOpenFeed] = useState<string | null>(null);
// Admin config editor
const [cfg, setCfg] = useState<FeedCfg[]>([]);
const [showCfg, setShowCfg] = useState(false);
const [cfgMsg, setCfgMsg] = useState('');
const load = async () => {
try {
const [k, f, me] = await Promise.all([
api.get('/api/v1/advisories/kev-recent?limit=15').catch(() => ({ data: { items: [] } })),
api.get('/api/v1/advisories/feeds').catch(() => ({ data: { feeds: [], fetched_at: null } })),
api.get('/auth/me').catch(() => ({ data: {} })),
]);
setKev(k.data?.items || []);
setFeeds(f.data?.feeds || []);
setFetchedAt(f.data?.fetched_at || null);
setUserRole(me.data?.role || '');
// config mirror for the admin editor (from the cache view — same rows)
setCfg((f.data?.feeds || []).map((x: Feed) => ({ id: x.id, name: x.name, url: x.url, enabled: x.enabled })));
} finally { setLoading(false); }
};
useEffect(() => { load(); }, []);
const refresh = async () => {
setRefreshing(true);
try { await api.post('/api/v1/advisories/feeds/refresh'); await load(); }
catch (e: any) { alert(e?.response?.data?.detail || 'Refresh failed'); }
finally { setRefreshing(false); }
};
const saveCfg = async () => {
try {
await api.put('/api/v1/settings/advisory_feeds_config', { value: JSON.stringify(cfg) });
setCfgMsg('Saved — refreshing feeds…');
await api.post('/api/v1/advisories/feeds/refresh').catch(() => { });
await load();
setCfgMsg('Saved.');
} catch (e: any) {
setCfgMsg(e?.response?.data?.detail || 'Save failed');
}
};
const canEdit = userRole === 'admin' || userRole === 'editor';
if (loading) return <div className="p-8">Loading Advisories...</div>;
return (
<div className="p-8">
<div className="flex items-start justify-between mb-6">
<div>
<h2 className="text-3xl font-bold text-gray-900 font-mono">Security Advisory Feeds</h2>
<p className="mt-1 text-sm text-gray-500">
Early-warning sources that often publish before NVD / cvelistV5.
{fetchedAt && <span className="ml-2 font-mono text-xs text-gray-400">Last fetch: {new Date(fetchedAt).toLocaleString()}</span>}
</p>
</div>
<div className="flex gap-2">
{userRole === 'admin' && (
<button onClick={() => setShowCfg(!showCfg)} className="rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50">
Configure
</button>
)}
{canEdit && (
<button onClick={refresh} disabled={refreshing} className="rounded-md bg-truevuln-blue px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-600 disabled:opacity-50">
{refreshing ? 'Refreshing…' : 'Refresh now'}
</button>
)}
</div>
</div>
{/* Admin: feed configuration */}
{showCfg && userRole === 'admin' && (
<div className="bg-white border border-gray-200 shadow-sm rounded-sm p-4 mb-6">
<h3 className="text-sm font-bold font-mono text-gray-900 mb-2">Feed configuration</h3>
<p className="text-xs text-gray-500 mb-3">Enable/disable sources or add a custom RSS/Atom URL. Feeds with DOCTYPE/ENTITY declarations are refused (XXE protection).</p>
<div className="space-y-2">
{cfg.map((f, i) => (
<div key={i} className="flex items-center gap-2">
<input type="checkbox" checked={f.enabled} onChange={(e) => { const n = [...cfg]; n[i] = { ...f, enabled: e.target.checked }; setCfg(n); }} className="h-4 w-4 rounded border-gray-300 text-truevuln-blue" />
<input type="text" value={f.name} onChange={(e) => { const n = [...cfg]; n[i] = { ...f, name: e.target.value }; setCfg(n); }} className="w-64 rounded-md border-gray-300 text-xs font-mono h-8 px-2" />
<input type="text" value={f.url} onChange={(e) => { const n = [...cfg]; n[i] = { ...f, url: e.target.value }; setCfg(n); }} className="flex-1 rounded-md border-gray-300 text-xs font-mono h-8 px-2" />
<button onClick={() => setCfg(cfg.filter((_, j) => j !== i))} className="text-red-600 text-xs px-2"></button>
</div>
))}
</div>
<div className="flex items-center gap-2 mt-3">
<button onClick={() => setCfg([...cfg, { id: `custom-${Date.now()}`, name: 'Custom feed', url: '', enabled: true }])} className="text-xs font-mono px-3 py-1.5 border border-gray-300 rounded-md hover:bg-gray-50">+ Add feed</button>
<button onClick={saveCfg} className="text-xs font-mono px-3 py-1.5 bg-truevuln-blue text-white rounded-md hover:bg-blue-600">Save</button>
{cfgMsg && <span className="text-xs font-mono text-gray-500">{cfgMsg}</span>}
</div>
</div>
)}
{/* CISA KEV — actively exploited */}
<div className="bg-white border border-gray-200 shadow-sm rounded-sm mb-6">
<div className="px-4 py-3 border-b border-gray-100 bg-red-50">
<h3 className="text-sm font-bold font-mono text-red-800">CISA KEV Actively Exploited (latest additions)</h3>
</div>
<ul className="divide-y divide-gray-100">
{kev.length === 0 && <li className="px-4 py-3 text-sm text-gray-400 font-mono">No KEV data yet run Refresh Threat Intel.</li>}
{kev.map((k: any, i: number) => (
<li key={i} className="px-4 py-2 flex items-center justify-between gap-3 text-sm">
<div className="min-w-0">
<a href={`/vulnerabilities?cve_id=${k.cve_id}`} className="font-mono font-semibold text-truevuln-blue hover:underline">{k.cve_id}</a>
<span className="ml-2 text-gray-600">{k.vulnerability_name || k.short_description || ''}</span>
</div>
<div className="flex items-center gap-2 flex-none text-xs font-mono">
{k.in_inventory && <span className="rounded px-1.5 py-0.5 bg-red-100 text-red-700 font-bold">IN INVENTORY</span>}
<span className="text-gray-400">{k.date_added || ''}</span>
</div>
</li>
))}
</ul>
</div>
{/* RSS feeds */}
{feeds.filter(f => f.enabled).map((f) => (
<div key={f.id} className="bg-white border border-gray-200 shadow-sm rounded-sm mb-4">
<button onClick={() => setOpenFeed(openFeed === f.id ? null : f.id)} className="w-full px-4 py-3 border-b border-gray-100 bg-gray-50 flex items-center justify-between text-left">
<h3 className="text-sm font-bold font-mono text-gray-900">{f.name}
<span className="ml-2 text-xs font-normal text-gray-400">{f.items.length} items</span>
{f.error && <span className="ml-2 text-xs text-red-600">fetch failed: {f.error}</span>}
</h3>
<span className="text-gray-400 text-xs">{openFeed === f.id ? '▲' : '▼'}</span>
</button>
{(openFeed === f.id || feeds.filter(x => x.enabled).length <= 2) && (
<ul className="divide-y divide-gray-100">
{f.items.slice(0, 15).map((it, i) => (
<li key={i} className="px-4 py-2 text-sm">
<a href={it.link} target="_blank" rel="noopener noreferrer" className="font-medium text-truevuln-blue hover:underline">{it.title}</a>
<span className="ml-2 text-xs text-gray-400 font-mono">{it.date}</span>
{it.summary && <p className="text-xs text-gray-500 mt-0.5 line-clamp-2">{it.summary}</p>}
</li>
))}
{f.items.length === 0 && !f.error && <li className="px-4 py-3 text-sm text-gray-400 font-mono">No items.</li>}
</ul>
)}
</div>
))}
</div>
);
}
+246 -53
View File
@@ -2,9 +2,10 @@
import { useEffect, useState } from 'react';
import api from '../../lib/api';
import { formatScanStats } from '../../lib/scanStats';
import { Asset, UserInfo, Group } from '../../types';
import Link from 'next/link';
import { PencilSquareIcon, TrashIcon, ArrowPathIcon, UserGroupIcon, ChevronDownIcon } from '@heroicons/react/24/outline';
import { PencilSquareIcon, TrashIcon, ArrowPathIcon, UserGroupIcon, ChevronDownIcon, MagnifyingGlassIcon, ListBulletIcon } from '@heroicons/react/24/outline';
import { UserCircleIcon } from '@heroicons/react/24/solid';
export default function AssetsPage() {
@@ -14,16 +15,26 @@ export default function AssetsPage() {
const [loading, setLoading] = useState(true);
const [rescanLoading, setRescanLoading] = useState<number | null>(null);
const [nessusRescanLoading, setNessusRescanLoading] = useState<number | null>(null);
const [appScanLoading, setAppScanLoading] = useState<number | null>(null);
const [userRole, setUserRole] = useState('');
const [isModalOpen, setIsModalOpen] = useState(false);
const [searchText, setSearchText] = useState('');
const [showInactive, setShowInactive] = useState(false);
const [sourceFilter, setSourceFilter] = useState('');
// Table sort state
const [sortBy, setSortBy] = useState<string>('hostname');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
// Coverage-gap report modal
const [gapReport, setGapReport] = useState<any | null>(null);
const [gapLoading, setGapLoading] = useState<number | null>(null);
// Installed-software (on-demand) modal
const [softwareReport, setSoftwareReport] = useState<any | null>(null);
const [softwareLoading, setSoftwareLoading] = useState<number | null>(null);
const [selectedIds, setSelectedIds] = useState<number[]>([]);
// Pagination
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(100);
const [total, setTotal] = useState(0);
// Form State
const [editingId, setEditingId] = useState<number | null>(null);
@@ -52,17 +63,23 @@ export default function AssetsPage() {
const params: any = {};
if (searchText) params.search = searchText;
if (showInactive) params.include_inactive = true;
if (sourceFilter) params.source = sourceFilter;
params.sort_by = sortBy;
params.sort_order = sortOrder;
params.limit = pageSize;
params.offset = (page - 1) * pageSize;
const [assetsRes, usersRes, groupsRes] = await Promise.all([
const [assetsRes, usersRes, groupsRes, meRes] = await Promise.all([
api.get('/api/v1/assets', { params }),
api.get('/auth/users').catch(() => ({ data: [] })),
api.get('/api/v1/groups')
api.get('/api/v1/groups'),
api.get('/auth/me').catch(() => ({ data: {} })),
]);
setAssets(assetsRes.data);
setTotal(parseInt(assetsRes.headers?.['x-total-count'] ?? '0', 10) || assetsRes.data.length);
setUsers(usersRes.data);
setGroups(groupsRes.data);
setUserRole(meRes.data?.role || '');
} catch (error) {
console.error("Failed to fetch assets:", error);
} finally {
@@ -84,7 +101,7 @@ export default function AssetsPage() {
return <span className="text-gray-300 ml-1"></span>;
}
return (
<span className="text-vulncheck-blue ml-1">
<span className="text-truevuln-blue ml-1">
{sortOrder === 'desc' ? '↓' : '↑'}
</span>
);
@@ -100,21 +117,23 @@ export default function AssetsPage() {
};
useEffect(() => {
fetchAssets();
fetchPolicies();
}, []);
// Re-fetch when the inactive toggle flips.
// Any filter/search/page-size change → jump back to page 1.
useEffect(() => {
fetchAssets();
setPage(1);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showInactive]);
}, [searchText, showInactive, sortBy, sortOrder, pageSize, sourceFilter]);
// Re-fetch on column sort change.
// Fetch on page / filter / search change (debounced for typing).
useEffect(() => {
fetchAssets();
const t = setTimeout(fetchAssets, searchText ? 300 : 0);
return () => clearTimeout(t);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sortBy, sortOrder]);
}, [page, pageSize, sortBy, sortOrder, showInactive, searchText, sourceFilter]);
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const resetForm = () => {
setFormData({
@@ -212,6 +231,36 @@ export default function AssetsPage() {
}
};
const handleShowSoftware = async (asset: Asset) => {
setSoftwareLoading(asset.id);
try {
const res = await api.get(`/api/v1/assets/${asset.id}/software`);
setSoftwareReport(res.data);
} catch (error: any) {
alert(error.response?.data?.detail || 'Could not load installed software.');
} finally {
setSoftwareLoading(null);
}
};
const handleAppRescan = async (asset: Asset) => {
setAppScanLoading(asset.id);
try {
const res = await api.post(`/api/v1/vulnerabilities/app-cve-scan?asset_id=${asset.id}`);
// Same counters the global scan button reports — this used to name
// three of them and drop the rest (FP-suppressed, pruned packages).
const extra = formatScanStats(res.data, ['assets', 'findings', 'new', 'errors']);
alert(`App CVE re-scan done for ${asset.hostname}: `
+ `${res.data?.findings ?? 0} findings (${res.data?.new ?? 0} new)`
+ (extra ? ` · ${extra}` : '') + '.');
fetchAssets();
} catch (error: any) {
alert(error.response?.data?.detail || 'App re-scan failed.');
} finally {
setAppScanLoading(null);
}
};
const handleNessusRescan = async (asset: Asset) => {
if (!asset.ip_address) {
alert('Asset has no IP address — Nessus cannot target it.');
@@ -296,8 +345,13 @@ export default function AssetsPage() {
if (loading) return <div className="p-8">Loading Assets...</div>;
// Mutating actions require editor+ (rescans/edit) or admin (delete) server-
// side; hide them from read-only users so they don't get a 403 alert.
const canEdit = userRole === 'admin' || userRole === 'editor';
const canDelete = userRole === 'admin';
return (
<div className="max-w-7xl mx-auto">
<div className="w-full">
<div className="flex md:items-center md:justify-between mb-8">
<div>
<h2 className="text-3xl font-bold leading-7 text-gray-900 font-mono">
@@ -312,7 +366,7 @@ export default function AssetsPage() {
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && fetchAssets()}
className="block w-64 rounded-sm border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-vulncheck-blue sm:text-sm sm:leading-6 font-mono"
className="block w-64 rounded-sm border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-truevuln-blue sm:text-sm sm:leading-6 font-mono"
/>
<button
onClick={fetchAssets}
@@ -323,14 +377,26 @@ export default function AssetsPage() {
</svg>
</button>
</div>
<label className="flex items-center gap-1.5 text-xs font-mono text-gray-600 cursor-pointer whitespace-nowrap" title="Show soft-inactive + decommissioned assets (no source sync within the threshold)">
<select
value={sourceFilter}
onChange={(e) => setSourceFilter(e.target.value)}
title="Filter by the source that first registered the asset"
className="rounded-sm border-0 py-1.5 pl-3 pr-8 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-truevuln-blue sm:text-sm font-mono"
>
<option value="">All sources</option>
<option value="WAZUH">Wazuh</option>
<option value="NESSUS">Nessus</option>
<option value="INTUNE">Intune / Defender</option>
<option value="MANUAL">Manual</option>
</select>
<label className="flex items-center gap-1.5 text-xs font-mono text-gray-600 cursor-pointer whitespace-nowrap" title="INACTIVE assets are always shown (amber badge). Tick to also show operator-retired DECOMMISSIONED assets.">
<input
type="checkbox"
checked={showInactive}
onChange={(e) => setShowInactive(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-vulncheck-blue focus:ring-vulncheck-blue"
className="h-4 w-4 rounded border-gray-300 text-truevuln-blue focus:ring-truevuln-blue"
/>
Show inactive
Show decommissioned
</label>
<button
type="button"
@@ -349,19 +415,21 @@ export default function AssetsPage() {
>
Exposure
</button>
<button
type="button"
onClick={() => { resetForm(); setIsModalOpen(true); }}
className="inline-flex items-center rounded-sm bg-vulncheck-blue px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 font-mono whitespace-nowrap"
>
Add Asset
</button>
{canEdit && (
<button
type="button"
onClick={() => { resetForm(); setIsModalOpen(true); }}
className="inline-flex items-center rounded-sm bg-truevuln-blue px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 font-mono whitespace-nowrap"
>
Add Asset
</button>
)}
</div>
</div>
{/* Bulk Actions Bar */}
{selectedIds.length > 0 && (
<div className="sticky top-0 z-40 bg-vulncheck-blue/90 backdrop-blur-sm text-white px-6 py-3 mb-4 rounded-sm shadow-lg flex items-center justify-between animate-in slide-in-from-top duration-300">
<div className="sticky top-0 z-40 bg-truevuln-blue/90 backdrop-blur-sm text-white px-6 py-3 mb-4 rounded-sm shadow-lg flex items-center justify-between animate-in slide-in-from-top duration-300">
<div className="flex items-center gap-4">
<span className="font-mono font-bold">{selectedIds.length} Assets selected</span>
<div className="h-6 w-px bg-white/20" />
@@ -425,7 +493,7 @@ export default function AssetsPage() {
required
value={formData.hostname}
onChange={(e) => setFormData({ ...formData, hostname: e.target.value })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-vulncheck-blue focus:ring-vulncheck-blue sm:text-sm"
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-truevuln-blue focus:ring-truevuln-blue sm:text-sm"
/>
</div>
<div>
@@ -434,7 +502,7 @@ export default function AssetsPage() {
type="text"
value={formData.ip_address}
onChange={(e) => setFormData({ ...formData, ip_address: e.target.value })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-vulncheck-blue focus:ring-vulncheck-blue sm:text-sm"
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-truevuln-blue focus:ring-truevuln-blue sm:text-sm"
/>
</div>
<div className="grid grid-cols-2 gap-4">
@@ -444,7 +512,7 @@ export default function AssetsPage() {
type="text"
value={formData.operating_system}
onChange={(e) => setFormData({ ...formData, operating_system: e.target.value })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-vulncheck-blue focus:ring-vulncheck-blue sm:text-sm"
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-truevuln-blue focus:ring-truevuln-blue sm:text-sm"
/>
</div>
<div>
@@ -453,7 +521,7 @@ export default function AssetsPage() {
type="text"
value={formData.os_version}
onChange={(e) => setFormData({ ...formData, os_version: e.target.value })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-vulncheck-blue focus:ring-vulncheck-blue sm:text-sm"
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-truevuln-blue focus:ring-truevuln-blue sm:text-sm"
placeholder="e.g. 22.04"
/>
</div>
@@ -464,7 +532,7 @@ export default function AssetsPage() {
type="text"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-vulncheck-blue focus:ring-vulncheck-blue sm:text-sm"
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-truevuln-blue focus:ring-truevuln-blue sm:text-sm"
/>
</div>
<div>
@@ -475,7 +543,7 @@ export default function AssetsPage() {
<select
value={formData.criticality}
onChange={(e) => setFormData({ ...formData, criticality: e.target.value as any })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-vulncheck-blue focus:ring-vulncheck-blue sm:text-sm font-mono"
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-truevuln-blue focus:ring-truevuln-blue sm:text-sm font-mono"
>
<option value="low">Low (×0.7)</option>
<option value="normal">Normal (×1.0)</option>
@@ -488,7 +556,7 @@ export default function AssetsPage() {
<select
value={formData.policy_id || ''}
onChange={(e) => setFormData({ ...formData, policy_id: e.target.value ? Number(e.target.value) : null })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-vulncheck-blue focus:ring-vulncheck-blue sm:text-sm"
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-truevuln-blue focus:ring-truevuln-blue sm:text-sm"
>
<option value="">-- No Policy --</option>
{policies.map(p => (
@@ -506,7 +574,7 @@ export default function AssetsPage() {
</button>
<button
type="submit"
className="px-4 py-2 text-sm font-medium text-white bg-vulncheck-blue rounded-md hover:bg-blue-600"
className="px-4 py-2 text-sm font-medium text-white bg-truevuln-blue rounded-md hover:bg-blue-600"
>
{editingId ? 'Update Asset' : 'Create Asset'}
</button>
@@ -526,7 +594,7 @@ export default function AssetsPage() {
type="checkbox"
checked={assets.length > 0 && selectedIds.length === assets.length}
onChange={toggleSelectAll}
className="h-4 w-4 rounded border-gray-300 text-vulncheck-blue focus:ring-vulncheck-blue"
className="h-4 w-4 rounded border-gray-300 text-truevuln-blue focus:ring-truevuln-blue"
/>
</th>
<th scope="col" onClick={() => handleSort('hostname')} className="px-3 py-3 text-left text-xs font-mono font-medium text-gray-500 uppercase tracking-wider cursor-pointer select-none hover:text-gray-700">Hostname<SortArrow column="hostname" /></th>
@@ -534,6 +602,7 @@ export default function AssetsPage() {
<th scope="col" onClick={() => handleSort('operating_system')} className="px-3 py-3 text-left text-xs font-mono font-medium text-gray-500 uppercase tracking-wider cursor-pointer select-none hover:text-gray-700">OS<SortArrow column="operating_system" /></th>
<th scope="col" onClick={() => handleSort('status')} className="px-3 py-3 text-left text-xs font-mono font-medium text-gray-500 uppercase tracking-wider cursor-pointer select-none hover:text-gray-700">Status<SortArrow column="status" /></th>
<th scope="col" onClick={() => handleSort('network_exposure_score')} className="px-3 py-3 text-left text-xs font-mono font-medium text-gray-500 uppercase tracking-wider cursor-pointer select-none hover:text-gray-700" title="Network exposure from open listeners (VNC/RDP/Telnet/...)">Exposure<SortArrow column="network_exposure_score" /></th>
<th scope="col" onClick={() => handleSort('high_value_score')} className="px-3 py-3 text-left text-xs font-mono font-medium text-gray-500 uppercase tracking-wider cursor-pointer select-none hover:text-gray-700" title="Risk Dimensions — high-value roles (Domain Controller, ADCS, SQL, Exchange, backup, ...)">Risk<SortArrow column="high_value_score" /></th>
<th scope="col" onClick={() => handleSort('last_scan')} className="px-3 py-3 text-left text-xs font-mono font-medium text-gray-500 uppercase tracking-wider cursor-pointer select-none hover:text-gray-700">Last Scan<SortArrow column="last_scan" /></th>
<th scope="col" onClick={() => handleSort('policy_name')} className="px-3 py-3 text-left text-xs font-mono font-medium text-gray-500 uppercase tracking-wider cursor-pointer select-none hover:text-gray-700">Policy<SortArrow column="policy_name" /></th>
<th scope="col" onClick={() => handleSort('assigned_user_name')} className="px-3 py-3 text-left text-xs font-mono font-medium text-gray-500 uppercase tracking-wider cursor-pointer select-none hover:text-gray-700">Assigned To<SortArrow column="assigned_user_name" /></th>
@@ -548,10 +617,10 @@ export default function AssetsPage() {
type="checkbox"
checked={selectedIds.includes(asset.id)}
onChange={() => toggleSelect(asset.id)}
className="h-4 w-4 rounded border-gray-300 text-vulncheck-blue focus:ring-vulncheck-blue"
className="h-4 w-4 rounded border-gray-300 text-truevuln-blue focus:ring-truevuln-blue"
/>
</td>
<td className="px-3 py-4 whitespace-nowrap font-bold text-vulncheck-blue">
<td className="px-3 py-4 whitespace-nowrap font-bold text-truevuln-blue">
<Link href={`/vulnerabilities?asset_id=${asset.id}`} className="hover:underline">
{asset.hostname}
</Link>
@@ -595,6 +664,28 @@ export default function AssetsPage() {
<span className="text-gray-300 text-xs"></span>
)}
</td>
<td className="px-3 py-4 whitespace-nowrap">
{asset.high_value_score != null && asset.high_value_score > 0 ? (
<span
className={`inline-flex items-center rounded-md px-2 py-0.5 text-xs font-bold font-mono
${asset.high_value_score >= 90 ? 'bg-red-100 text-red-700'
: asset.high_value_score >= 70 ? 'bg-orange-100 text-orange-700'
: asset.high_value_score >= 40 ? 'bg-yellow-100 text-yellow-800'
: 'bg-gray-100 text-gray-600'}`}
title={(asset.risk_dimensions || []).map(d => `${d.label} (${d.weight})`).join(', ') || 'High-value roles'}
>
{asset.high_value_score.toFixed(0)}
{asset.risk_dimensions && asset.risk_dimensions.length > 0 && (
<span className="ml-1 font-normal text-[10px] opacity-80">
{asset.risk_dimensions.slice(0, 2).map(d => d.role.toUpperCase().replace(/_/g, '·').slice(0, 8)).join('/')}
{asset.risk_dimensions.length > 2 ? '…' : ''}
</span>
)}
</span>
) : (
<span className="text-gray-300 text-xs"></span>
)}
</td>
<td className="px-3 py-4 text-gray-500">{asset.last_scan ? new Date(asset.last_scan).toLocaleDateString() : 'Never'}</td>
<td className="px-3 py-4 whitespace-nowrap">
{asset.policy_name ? (
@@ -615,6 +706,13 @@ export default function AssetsPage() {
) : (
<UserCircleIcon className={`h-5 w-5 ${asset.assigned_user_id ? 'text-indigo-600' : 'text-gray-300'}`} />
)}
{!canEdit ? (
// Read-only: show the assignee as text (the /auth/users list is
// admin-only, so a disabled <select> would read "Unassigned").
<span className="text-xs font-medium text-gray-700 truncate" style={{ maxWidth: '200px' }}>
{(asset.groups && asset.groups.length > 0 ? asset.groups[0] : asset.assigned_user_name) || <span className="text-gray-400">Unassigned</span>}
</span>
) : (
<div className="relative">
<select
value={
@@ -624,7 +722,7 @@ export default function AssetsPage() {
}
onChange={(e) => handleAssign(asset.id, e.target.value)}
className="block w-full appearance-none rounded-md border-0 bg-transparent py-1.5 pl-2 pr-8 text-xs font-medium text-gray-900 focus:ring-1 focus:ring-inset focus:ring-indigo-600 cursor-pointer hover:bg-gray-50 transition-colors truncate"
style={{ maxWidth: '140px' }}
style={{ maxWidth: '200px', minWidth: '120px' }}
>
<option value="">Unassigned</option>
<optgroup label="Users">
@@ -642,11 +740,12 @@ export default function AssetsPage() {
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
</div>
</div>
)}
</div>
</td>
<td className="px-3 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="flex justify-end gap-x-2">
{asset.wazuh_agent_id && (
{asset.wazuh_agent_id && canEdit && (
<button
onClick={() => handleRescan(asset.id)}
disabled={rescanLoading === asset.id}
@@ -656,7 +755,7 @@ export default function AssetsPage() {
<ArrowPathIcon className={`h-5 w-5 ${rescanLoading === asset.id ? 'animate-spin' : ''}`} />
</button>
)}
{asset.ip_address && (
{asset.ip_address && canEdit && (
<button
onClick={() => handleNessusRescan(asset)}
disabled={nessusRescanLoading === asset.id}
@@ -665,12 +764,37 @@ export default function AssetsPage() {
>
{nessusRescanLoading === asset.id
? <ArrowPathIcon className="h-5 w-5 animate-spin" />
: <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" title="Nessus">
: <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
<title>Nessus</title>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3c-4.97 0-9 4.03-9 9s4.03 9 9 9 9-4.03 9-9-4.03-9-9-9zm0 0v18M3 12h18" />
</svg>
}
</button>
)}
{(asset.wazuh_agent_id || asset.intune_device_id) && (
<button
onClick={() => handleShowSoftware(asset)}
disabled={softwareLoading === asset.id}
title="Show installed software (live inventory)"
className={`${softwareLoading === asset.id ? 'text-gray-300' : 'text-sky-600 hover:text-sky-800'}`}
>
{softwareLoading === asset.id
? <ArrowPathIcon className="h-5 w-5 animate-spin" />
: <ListBulletIcon className="h-5 w-5" />}
</button>
)}
{(asset.wazuh_agent_id || asset.intune_device_id) && canEdit && (
<button
onClick={() => handleAppRescan(asset)}
disabled={appScanLoading === asset.id}
title="App CVE re-scan — match this asset's installed software to CVEs (curated + cvelistV5)"
className={`${appScanLoading === asset.id ? 'text-gray-300' : 'text-emerald-600 hover:text-emerald-800'}`}
>
{appScanLoading === asset.id
? <ArrowPathIcon className="h-5 w-5 animate-spin" />
: <MagnifyingGlassIcon className="h-5 w-5" />}
</button>
)}
{(asset.wazuh_agent_id || asset.nessus_host_uuid) && (
<button
onClick={() => handleCoverageGap(asset)}
@@ -686,28 +810,62 @@ export default function AssetsPage() {
}
</button>
)}
<button
onClick={() => handleEdit(asset)}
className="text-vulncheck-blue hover:text-blue-900"
>
<PencilSquareIcon className="h-5 w-5" />
</button>
<button
onClick={() => handleDeleteAsset(asset.id)}
className="text-red-600 hover:text-red-900"
>
<TrashIcon className="h-5 w-5" />
</button>
{canEdit && (
<button
onClick={() => handleEdit(asset)}
className="text-truevuln-blue hover:text-blue-900"
>
<PencilSquareIcon className="h-5 w-5" />
</button>
)}
{canDelete && (
<button
onClick={() => handleDeleteAsset(asset.id)}
className="text-red-600 hover:text-red-900"
>
<TrashIcon className="h-5 w-5" />
</button>
)}
</div>
</td>
</tr>
))}
{assets.length === 0 && (
<tr><td colSpan={6} className="text-center py-4">No assets found.</td></tr>
<tr><td colSpan={11} className="text-center py-4">No assets found.</td></tr>
)}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-gray-200 px-4 py-3 text-sm font-mono text-gray-600">
<div>
{total === 0 ? '0' : `${(page - 1) * pageSize + 1}${Math.min(page * pageSize, total)}`} of {total}
</div>
<div className="flex items-center gap-3">
<label className="flex items-center gap-1.5">
<span className="text-xs text-gray-500">Per page</span>
<select
value={pageSize}
onChange={(e) => setPageSize(parseInt(e.target.value, 10))}
className="rounded-md border-gray-300 text-sm py-1 pl-2 pr-7 focus:border-truevuln-blue focus:ring-truevuln-blue"
>
{[50, 100, 250, 500, 1000].map(n => <option key={n} value={n}>{n}</option>)}
</select>
</label>
<div className="flex items-center gap-1">
<button onClick={() => setPage(1)} disabled={page <= 1}
className="px-2 py-1 rounded border border-gray-300 disabled:opacity-40 hover:bg-gray-50">«</button>
<button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page <= 1}
className="px-2 py-1 rounded border border-gray-300 disabled:opacity-40 hover:bg-gray-50"> Prev</button>
<span className="px-2">Page {page} / {totalPages}</span>
<button onClick={() => setPage(p => Math.min(totalPages, p + 1))} disabled={page >= totalPages}
className="px-2 py-1 rounded border border-gray-300 disabled:opacity-40 hover:bg-gray-50">Next </button>
<button onClick={() => setPage(totalPages)} disabled={page >= totalPages}
className="px-2 py-1 rounded border border-gray-300 disabled:opacity-40 hover:bg-gray-50">»</button>
</div>
</div>
</div>
</div>
{/* Coverage-gap modal — installed packages with no finding */}
@@ -718,7 +876,7 @@ export default function AssetsPage() {
<h3 className="text-lg font-bold font-mono text-gray-900">Coverage Gap {gapReport.hostname}</h3>
<p className="text-xs text-gray-500 font-mono mt-1">
{gapReport.gap_count} of {gapReport.total_packages} installed packages have NO open vuln finding.
Investigate manually VulnCheck makes no automatic CVE claim here.
Investigate manually TrueVuln makes no automatic CVE claim here.
</p>
</div>
<div className="overflow-y-auto p-4">
@@ -751,6 +909,41 @@ export default function AssetsPage() {
</div>
</div>
)}
{softwareReport && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={() => setSoftwareReport(null)}>
<div className="bg-white rounded-lg shadow-xl max-w-2xl w-full max-h-[80vh] flex flex-col" onClick={(e) => e.stopPropagation()}>
<div className="p-4 border-b border-gray-200">
<h3 className="text-lg font-bold font-mono text-gray-900">Installed Software {softwareReport.hostname}</h3>
<p className="text-xs text-gray-500 font-mono mt-1">
{softwareReport.count} items · live from <span className="uppercase">{softwareReport.source}</span> (not stored)
</p>
</div>
<div className="overflow-y-auto p-4">
{softwareReport.count === 0 ? (
<p className="text-sm text-gray-500 font-mono">No software reported for this asset.</p>
) : (
<table className="min-w-full text-xs font-mono">
<thead className="text-gray-500 uppercase">
<tr><th className="text-left py-1">Name</th><th className="text-left py-1">Version</th><th className="text-left py-1">Vendor</th></tr>
</thead>
<tbody className="divide-y divide-gray-100">
{softwareReport.software.map((s: any, i: number) => (
<tr key={i}>
<td className="py-1 pr-3 text-gray-900">{s.name}</td>
<td className="py-1 pr-3 text-gray-500">{s.version || '—'}</td>
<td className="py-1 text-gray-500">{s.vendor || '—'}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className="p-3 border-t border-gray-200 text-right">
<button onClick={() => setSoftwareReport(null)} className="rounded-md bg-gray-100 px-4 py-1.5 text-sm font-semibold text-gray-700 hover:bg-gray-200">Close</button>
</div>
</div>
</div>
)}
</div>
);
}
+13 -6
View File
@@ -137,8 +137,13 @@ export default function CompliancePage() {
useEffect(() => { fetchAll(); }, [avsMode]);
// CSV upload state
// CSV upload state. The import endpoint is admin-only, so non-admins were
// shown an upload control that silently 403'd.
const [uploading, setUploading] = useState(false);
const [isAdmin, setIsAdmin] = useState(false);
useEffect(() => {
api.get('/auth/me').then(r => setIsAdmin((r.data?.role || '') === 'admin')).catch(() => { });
}, []);
const uploadCsvs = async (filelist: FileList | null) => {
if (!filelist || filelist.length === 0) return;
setUploading(true);
@@ -242,7 +247,7 @@ export default function CompliancePage() {
<button
onClick={refreshAll}
disabled={refreshing}
className="inline-flex items-center gap-2 rounded-md bg-vulncheck-blue px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-600 disabled:opacity-50 font-mono"
className="inline-flex items-center gap-2 rounded-md bg-truevuln-blue px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-600 disabled:opacity-50 font-mono"
>
<ArrowPathIcon className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
{refreshing ? 'Refreshing…' : 'Refresh All'}
@@ -291,7 +296,7 @@ export default function CompliancePage() {
<ul className="divide-y divide-gray-100">
{summary.worst_offenders.map(a => (
<li key={a.asset_id} className="py-2 flex items-center justify-between text-sm font-mono">
<button onClick={() => openAsset(a.asset_id)} className="text-vulncheck-blue hover:underline text-left">
<button onClick={() => openAsset(a.asset_id)} className="text-truevuln-blue hover:underline text-left">
{a.hostname}
</button>
<span className="text-gray-500 text-xs truncate ml-2 max-w-xs" title={a.worst_policy || ''}>
@@ -354,7 +359,7 @@ export default function CompliancePage() {
)}
{ursRows.map(r => (
<tr key={r.asset_id} className="hover:bg-gray-50">
<td className="px-4 py-2 text-vulncheck-blue font-bold">{r.hostname || `#${r.asset_id}`}</td>
<td className="px-4 py-2 text-truevuln-blue font-bold">{r.hostname || `#${r.asset_id}`}</td>
<td className="px-4 py-2 text-gray-700 uppercase text-xs">{r.criticality || 'normal'}<span className="text-gray-400 ml-1">×{r.criticality_factor ?? 1.0}</span></td>
<td className="px-4 py-2 text-gray-700">{r.avs !== null ? r.avs.toFixed(1) : '—'}</td>
<td className="px-4 py-2 text-gray-700">{r.ass !== null ? r.ass.toFixed(1) : '—'}</td>
@@ -371,7 +376,8 @@ export default function CompliancePage() {
</div>
</div>
{/* Impact CSV upload */}
{/* Impact CSV upload — admin only (POST /compliance/impacts/import) */}
{isAdmin && (
<div className="bg-white rounded-lg border border-gray-200 shadow-sm p-4 mb-8">
<div className="flex items-center justify-between mb-3">
<div>
@@ -410,6 +416,7 @@ export default function CompliancePage() {
</ul>
)}
</div>
)}
{/* All assets table */}
<div className="bg-white rounded-lg border border-gray-200 shadow-sm overflow-hidden">
@@ -436,7 +443,7 @@ export default function CompliancePage() {
)}
{assets.map(a => (
<tr key={a.asset_id} className="hover:bg-gray-50 cursor-pointer" onClick={() => openAsset(a.asset_id)}>
<td className="px-4 py-2 text-vulncheck-blue font-bold">{a.hostname}</td>
<td className="px-4 py-2 text-truevuln-blue font-bold">{a.hostname}</td>
<td className="px-4 py-2 text-gray-600 truncate max-w-xs">{a.operating_system || '—'}</td>
<td className="px-4 py-2 text-gray-700">{a.policy_count}</td>
<td className={`px-4 py-2 ${scoreColor(a.avg_score)}`}>
+176 -2
View File
@@ -6,7 +6,7 @@
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-vulncheck-blue: #0066FF;
--color-truevuln-blue: #0066FF;
--color-securis-dark: #1F2937;
--color-securis-gray: #F3F4F6;
--color-securis-danger: #EF4444;
@@ -71,4 +71,178 @@ textarea::placeholder {
}
.scroll-visible-x::-webkit-scrollbar-thumb:hover {
background: #64748b;
}
}
/* ============================================================
* Theme layer light (default) / mid (soft dark) / dark (full dark).
*
* The pages are written with hardcoded light utilities (bg-white,
* text-gray-900, ). Instead of rewriting every page with dark:
* variants, we remap the light-surface utilities under
* html[data-theme="mid"|"dark"]. These rules are UN-layered, so they
* beat Tailwind's @layer utilities without !important (CSS cascade
* layers: un-layered author CSS wins).
*
* Deliberately NOT remapped: bg-gray-800/900 (the sidebar is already
* dark and must stay), brand colors, and white-on-color button text.
* ============================================================ */
html[data-theme="mid"] {
--tv-bg: #222933; /* page background */
--tv-surface: #2b3440; /* cards, tables, inputs */
--tv-surface-2: #333e4d; /* table heads, subtle fills */
--tv-surface-3: #3c4a5c; /* hover fills, chips */
--tv-border: #46546a;
--tv-border-soft: #3a4657;
--tv-text: #e7ebf0;
--tv-text-soft: #c3cad4;
--tv-text-muted: #94a0ae;
}
html[data-theme="dark"] {
--tv-bg: #0b0f16;
--tv-surface: #131a24;
--tv-surface-2: #1a2330;
--tv-surface-3: #232e3e;
--tv-border: #2e3a4b;
--tv-border-soft: #26303f;
--tv-text: #edf1f6;
--tv-text-soft: #c6cdd6;
--tv-text-muted: #8e99a8;
}
html[data-theme="mid"],
html[data-theme="dark"] {
color-scheme: dark;
}
html[data-theme="mid"] body,
html[data-theme="dark"] body {
color-scheme: dark;
background-color: var(--tv-bg);
color: var(--tv-text);
}
/* ---- neutral surfaces ---- */
html[data-theme="mid"] .bg-white, html[data-theme="dark"] .bg-white,
html[data-theme="mid"] .bg-base-100, html[data-theme="dark"] .bg-base-100 { background-color: var(--tv-surface); }
html[data-theme="mid"] .bg-gray-50, html[data-theme="dark"] .bg-gray-50 { background-color: var(--tv-surface-2); }
html[data-theme="mid"] .bg-gray-100, html[data-theme="dark"] .bg-gray-100 { background-color: var(--tv-surface-3); }
html[data-theme="mid"] .bg-gray-200, html[data-theme="dark"] .bg-gray-200 { background-color: var(--tv-surface-3); }
html[data-theme="mid"] .hover\:bg-gray-50:hover, html[data-theme="dark"] .hover\:bg-gray-50:hover,
html[data-theme="mid"] .hover\:bg-white:hover, html[data-theme="dark"] .hover\:bg-white:hover { background-color: var(--tv-surface-3); }
html[data-theme="mid"] .hover\:bg-gray-100:hover, html[data-theme="dark"] .hover\:bg-gray-100:hover,
html[data-theme="mid"] .hover\:bg-gray-200:hover, html[data-theme="dark"] .hover\:bg-gray-200:hover { background-color: var(--tv-border-soft); }
/* ---- neutral text ---- */
html[data-theme="mid"] .text-gray-900, html[data-theme="dark"] .text-gray-900,
html[data-theme="mid"] .text-black, html[data-theme="dark"] .text-black,
html[data-theme="mid"] .text-base-content, html[data-theme="dark"] .text-base-content { color: var(--tv-text); }
html[data-theme="mid"] .text-gray-800, html[data-theme="dark"] .text-gray-800,
html[data-theme="mid"] .text-gray-700, html[data-theme="dark"] .text-gray-700 { color: var(--tv-text-soft); }
html[data-theme="mid"] .text-gray-600, html[data-theme="dark"] .text-gray-600,
html[data-theme="mid"] .text-gray-500, html[data-theme="dark"] .text-gray-500 { color: var(--tv-text-muted); }
html[data-theme="mid"] .text-gray-400, html[data-theme="dark"] .text-gray-400 { color: #7e8896; }
html[data-theme="mid"] .text-gray-300, html[data-theme="dark"] .text-gray-300 { color: #67707e; }
html[data-theme="mid"] .hover\:text-gray-900:hover, html[data-theme="dark"] .hover\:text-gray-900:hover,
html[data-theme="mid"] .hover\:text-gray-700:hover, html[data-theme="dark"] .hover\:text-gray-700:hover { color: var(--tv-text); }
/* ---- borders / dividers / rings ---- */
html[data-theme="mid"] .border-gray-100, html[data-theme="dark"] .border-gray-100,
html[data-theme="mid"] .border-gray-200, html[data-theme="dark"] .border-gray-200,
html[data-theme="mid"] .border-base-300, html[data-theme="dark"] .border-base-300 { border-color: var(--tv-border-soft); }
html[data-theme="mid"] .border-gray-300, html[data-theme="dark"] .border-gray-300 { border-color: var(--tv-border); }
html[data-theme="mid"] .divide-gray-100 > :not([hidden]) ~ :not([hidden]), html[data-theme="dark"] .divide-gray-100 > :not([hidden]) ~ :not([hidden]),
html[data-theme="mid"] .divide-gray-200 > :not([hidden]) ~ :not([hidden]), html[data-theme="dark"] .divide-gray-200 > :not([hidden]) ~ :not([hidden]) { border-color: var(--tv-border-soft); }
html[data-theme="mid"] .ring-gray-300, html[data-theme="dark"] .ring-gray-300 { --tw-ring-color: var(--tv-border); }
html[data-theme="mid"] .bg-base-300, html[data-theme="dark"] .bg-base-300 { background-color: var(--tv-border-soft); }
/* ---- form controls ---- */
html[data-theme="mid"] input, html[data-theme="dark"] input,
html[data-theme="mid"] select, html[data-theme="dark"] select,
html[data-theme="mid"] textarea, html[data-theme="dark"] textarea {
background-color: var(--tv-surface-2);
color: var(--tv-text);
border-color: var(--tv-border);
}
html[data-theme="mid"] input::placeholder, html[data-theme="dark"] input::placeholder,
html[data-theme="mid"] textarea::placeholder, html[data-theme="dark"] textarea::placeholder { color: var(--tv-text-muted); }
html[data-theme="mid"] input:-webkit-autofill, html[data-theme="dark"] input:-webkit-autofill {
/* !important needed: the light-scheme autofill rule above uses it too */
-webkit-box-shadow: 0 0 0 1000px var(--tv-surface-2) inset !important;
-webkit-text-fill-color: var(--tv-text) !important;
caret-color: var(--tv-text);
}
/* ---- tinted badges: pastel fills → translucent dark tints ---- */
html[data-theme="mid"] .bg-red-50, html[data-theme="dark"] .bg-red-50 { background-color: rgba(239,68,68,.12); }
html[data-theme="mid"] .bg-red-100, html[data-theme="dark"] .bg-red-100 { background-color: rgba(239,68,68,.2); }
html[data-theme="mid"] .bg-green-50, html[data-theme="dark"] .bg-green-50 { background-color: rgba(16,185,129,.12); }
html[data-theme="mid"] .bg-green-100, html[data-theme="dark"] .bg-green-100 { background-color: rgba(16,185,129,.2); }
html[data-theme="mid"] .bg-emerald-100, html[data-theme="dark"] .bg-emerald-100 { background-color: rgba(16,185,129,.2); }
html[data-theme="mid"] .bg-blue-50, html[data-theme="dark"] .bg-blue-50 { background-color: rgba(59,130,246,.14); }
html[data-theme="mid"] .bg-blue-100, html[data-theme="dark"] .bg-blue-100 { background-color: rgba(59,130,246,.22); }
html[data-theme="mid"] .bg-yellow-50, html[data-theme="dark"] .bg-yellow-50 { background-color: rgba(234,179,8,.12); }
html[data-theme="mid"] .bg-yellow-100, html[data-theme="dark"] .bg-yellow-100 { background-color: rgba(234,179,8,.2); }
html[data-theme="mid"] .bg-orange-100, html[data-theme="dark"] .bg-orange-100 { background-color: rgba(249,115,22,.2); }
html[data-theme="mid"] .bg-purple-100, html[data-theme="dark"] .bg-purple-100 { background-color: rgba(168,85,247,.2); }
html[data-theme="mid"] .bg-amber-50, html[data-theme="dark"] .bg-amber-50 { background-color: rgba(245,158,11,.12); }
/* readable tinted text on dark */
html[data-theme="mid"] .text-red-800, html[data-theme="dark"] .text-red-800,
html[data-theme="mid"] .text-red-700, html[data-theme="dark"] .text-red-700 { color: #f87171; }
html[data-theme="mid"] .text-red-600, html[data-theme="dark"] .text-red-600 { color: #ef7d7d; }
html[data-theme="mid"] .text-green-700, html[data-theme="dark"] .text-green-700,
html[data-theme="mid"] .text-green-600, html[data-theme="dark"] .text-green-600 { color: #4ade80; }
html[data-theme="mid"] .text-emerald-700, html[data-theme="dark"] .text-emerald-700 { color: #34d399; }
html[data-theme="mid"] .text-blue-700, html[data-theme="dark"] .text-blue-700,
html[data-theme="mid"] .text-blue-600, html[data-theme="dark"] .text-blue-600 { color: #60a5fa; }
html[data-theme="mid"] .text-yellow-800, html[data-theme="dark"] .text-yellow-800,
html[data-theme="mid"] .text-yellow-700, html[data-theme="dark"] .text-yellow-700 { color: #facc15; }
html[data-theme="mid"] .text-orange-700, html[data-theme="dark"] .text-orange-700,
html[data-theme="mid"] .text-orange-600, html[data-theme="dark"] .text-orange-600 { color: #fb923c; }
html[data-theme="mid"] .text-amber-700, html[data-theme="dark"] .text-amber-700,
html[data-theme="mid"] .text-amber-600, html[data-theme="dark"] .text-amber-600 { color: #fbbf24; }
html[data-theme="mid"] .text-purple-700, html[data-theme="dark"] .text-purple-700 { color: #c084fc; }
html[data-theme="mid"] .text-indigo-600, html[data-theme="dark"] .text-indigo-600 { color: #818cf8; }
/* tinted borders */
html[data-theme="mid"] .border-red-200, html[data-theme="dark"] .border-red-200,
html[data-theme="mid"] .border-red-300, html[data-theme="dark"] .border-red-300 { border-color: rgba(239,68,68,.4); }
html[data-theme="mid"] .border-blue-200, html[data-theme="dark"] .border-blue-200 { border-color: rgba(59,130,246,.4); }
html[data-theme="mid"] .border-orange-200, html[data-theme="dark"] .border-orange-200 { border-color: rgba(249,115,22,.4); }
/* scrollbars follow the theme */
html[data-theme="mid"] .scroll-visible-x, html[data-theme="dark"] .scroll-visible-x {
scrollbar-color: var(--tv-border) var(--tv-surface-2);
}
html[data-theme="mid"] .scroll-visible-x::-webkit-scrollbar, html[data-theme="dark"] .scroll-visible-x::-webkit-scrollbar { background: var(--tv-surface-2); }
html[data-theme="mid"] .scroll-visible-x::-webkit-scrollbar-thumb, html[data-theme="dark"] .scroll-visible-x::-webkit-scrollbar-thumb {
background: var(--tv-border);
border-color: var(--tv-surface-2);
}
/* ---- opacity / arbitrary-value variants (own class names, missed by the
* plain remaps above tester: widget headers + sticky list header stayed
* light-gray under light text) ---- */
html[data-theme="mid"] .bg-gray-50\/50, html[data-theme="dark"] .bg-gray-50\/50 { background-color: var(--tv-surface-2); }
html[data-theme="mid"] .bg-white\/50, html[data-theme="dark"] .bg-white\/50 { background-color: var(--tv-surface-3); }
/* vulnerabilities list: sticky header band uses an arbitrary value of the
* light page background (#F3F4F6 at 95%) */
html[data-theme="mid"] .bg-\[\#F3F4F6\]\/95, html[data-theme="dark"] .bg-\[\#F3F4F6\]\/95 { background-color: var(--tv-bg); }
html[data-theme="mid"] .group:hover .group-hover\:bg-blue-50\/30, html[data-theme="dark"] .group:hover .group-hover\:bg-blue-50\/30 { background-color: var(--tv-surface-3); }
/* ---- indigo family (AI widgets: pastel gradient + indigo text) ---- */
html[data-theme="mid"] .bg-gradient-to-br, html[data-theme="dark"] .bg-gradient-to-br {
background-image: none;
background-color: var(--tv-surface);
}
html[data-theme="mid"] .bg-indigo-50, html[data-theme="dark"] .bg-indigo-50,
html[data-theme="mid"] .hover\:bg-indigo-50:hover, html[data-theme="dark"] .hover\:bg-indigo-50:hover { background-color: rgba(99,102,241,.14); }
html[data-theme="mid"] .bg-indigo-100, html[data-theme="dark"] .bg-indigo-100 { background-color: rgba(99,102,241,.22); }
html[data-theme="mid"] .text-indigo-700, html[data-theme="dark"] .text-indigo-700,
html[data-theme="mid"] .text-indigo-800, html[data-theme="dark"] .text-indigo-800,
html[data-theme="mid"] .text-indigo-900, html[data-theme="dark"] .text-indigo-900 { color: #a5b4fc; }
html[data-theme="mid"] .hover\:text-indigo-800:hover, html[data-theme="dark"] .hover\:text-indigo-800:hover { color: #c7d2fe; }
html[data-theme="mid"] .border-indigo-100, html[data-theme="dark"] .border-indigo-100,
html[data-theme="mid"] .border-indigo-200, html[data-theme="dark"] .border-indigo-200 { border-color: rgba(99,102,241,.35); }
/* daisyui base-200 (used by a few shells) */
html[data-theme="mid"] .bg-base-200, html[data-theme="dark"] .bg-base-200 { background-color: var(--tv-surface-2); }
+5 -5
View File
@@ -123,7 +123,7 @@ const GroupsPage = () => {
<div className="mt-4 flex md:ml-4 md:mt-0">
<button
onClick={() => handleOpenModal()}
className="inline-flex items-center rounded-md bg-vulncheck-blue px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-600 font-mono"
className="inline-flex items-center rounded-md bg-truevuln-blue px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-600 font-mono"
>
<PlusIcon className="h-4 w-4 mr-2" />
Create Group
@@ -202,7 +202,7 @@ const GroupsPage = () => {
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-vulncheck-blue focus:ring-vulncheck-blue sm:text-sm"
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-truevuln-blue focus:ring-truevuln-blue sm:text-sm"
/>
</div>
<div>
@@ -210,7 +210,7 @@ const GroupsPage = () => {
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-vulncheck-blue focus:ring-vulncheck-blue sm:text-sm"
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-truevuln-blue focus:ring-truevuln-blue sm:text-sm"
rows={3}
/>
</div>
@@ -224,7 +224,7 @@ const GroupsPage = () => {
id={`user-${u.id}`}
checked={formData.user_ids.includes(u.id)}
onChange={() => toggleUserSelection(u.id)}
className="h-4 w-4 rounded border-gray-300 text-vulncheck-blue focus:ring-vulncheck-blue"
className="h-4 w-4 rounded border-gray-300 text-truevuln-blue focus:ring-truevuln-blue"
/>
<label htmlFor={`user-${u.id}`} className="ml-2 block text-sm text-gray-900 font-mono">
{u.username} <span className="text-gray-400 text-xs">({u.email})</span>
@@ -241,7 +241,7 @@ const GroupsPage = () => {
<div className="flex justify-end gap-2 mt-6">
<button onClick={() => setIsModalOpen(false)} className="rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50">Cancel</button>
<button onClick={handleSave} className="rounded-md bg-vulncheck-blue px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-600">Save</button>
<button onClick={handleSave} className="rounded-md bg-truevuln-blue px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-600">Save</button>
</div>
</div>
</div>
+10 -1
View File
@@ -14,7 +14,7 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "VulnCheck",
title: "TrueVuln",
description: "Advanced Vulnerability Management for Wazuh",
icons: {
icon: "/logo.svg",
@@ -28,6 +28,15 @@ export default function RootLayout({
}>) {
return (
<html lang="en" suppressHydrationWarning>
<head>
{/* Apply the stored theme BEFORE first paint so dark users don't get a
white flash. Mirrors normalizeTheme() in hooks/useTheme.ts. */}
<script
dangerouslySetInnerHTML={{
__html: `(function(){try{var t=localStorage.getItem('theme');if(t==='corporate')t='light';if(t!=='light'&&t!=='mid'&&t!=='dark')t='light';var h=document.documentElement;h.setAttribute('data-theme',t);if(t!=='light')h.classList.add('dark');}catch(e){}})();`,
}}
/>
</head>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-base-100 text-base-content`}
>
+19 -8
View File
@@ -90,7 +90,18 @@ export default function LoginPage() {
setError('MFA verification failed.');
} catch (err: any) {
const detail = err.response?.data?.detail;
setError(detail || 'Invalid or expired MFA code.');
// Expired challenge (5-min window) → the mfa_token is dead; a new
// code won't help. Send the user back to the credentials step
// instead of stranding them on a form that can't succeed.
const expired = /expired/i.test(detail || '');
if (expired) {
setStage('credentials');
setMfaCode('');
setMfaToken('');
setError('MFA session expired — please sign in again.');
} else {
setError(detail || 'Invalid or expired MFA code.');
}
} finally {
setLoading(false);
}
@@ -99,9 +110,9 @@ export default function LoginPage() {
return (
<div className="flex min-h-screen flex-col justify-center px-6 py-12 lg:px-8 bg-gray-50">
<div className="sm:mx-auto sm:w-full sm:max-w-sm">
<img src="/logo.svg" alt="VulnCheck Logo" className="mx-auto h-24 w-24" />
<img src="/logo.svg" alt="TrueVuln Logo" className="mx-auto h-24 w-24" />
<h2 className="mt-6 text-center text-2xl font-bold leading-9 tracking-tight text-gray-900 font-mono">
VulnCheck
TrueVuln
</h2>
<p className="text-center text-sm text-gray-500 font-mono mt-2">
{stage === 'credentials' ? 'Sign in to your account' : 'Enter your verification code'}
@@ -152,7 +163,7 @@ export default function LoginPage() {
required
value={username}
onChange={(e) => setUsername(e.target.value)}
className="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-vulncheck-blue sm:text-sm sm:leading-6"
className="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-truevuln-blue sm:text-sm sm:leading-6"
/>
</div>
</div>
@@ -170,7 +181,7 @@ export default function LoginPage() {
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-vulncheck-blue sm:text-sm sm:leading-6"
className="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-truevuln-blue sm:text-sm sm:leading-6"
/>
</div>
</div>
@@ -185,7 +196,7 @@ export default function LoginPage() {
<button
type="submit"
disabled={loading}
className="flex w-full justify-center rounded-md bg-vulncheck-blue px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-blue-600 disabled:opacity-60 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 font-mono"
className="flex w-full justify-center rounded-md bg-truevuln-blue px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-blue-600 disabled:opacity-60 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 font-mono"
>
{loading ? 'Signing in…' : 'Sign in'}
</button>
@@ -223,7 +234,7 @@ export default function LoginPage() {
required
value={mfaCode}
onChange={(e) => setMfaCode(e.target.value.replace(/\D/g, ''))}
className="block w-full rounded-md border-0 py-2 text-center text-xl tracking-[0.5em] font-mono text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-vulncheck-blue"
className="block w-full rounded-md border-0 py-2 text-center text-xl tracking-[0.5em] font-mono text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-truevuln-blue"
placeholder="000000"
/>
</div>
@@ -246,7 +257,7 @@ export default function LoginPage() {
<button
type="submit"
disabled={loading || mfaCode.length !== 6}
className="flex-[2] rounded-md bg-vulncheck-blue px-3 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-600 disabled:opacity-60 font-mono"
className="flex-[2] rounded-md bg-truevuln-blue px-3 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-600 disabled:opacity-60 font-mono"
>
{loading ? 'Verifying…' : 'Verify'}
</button>
+3 -3
View File
@@ -85,7 +85,7 @@ export default function ForcedMfaSetupPage() {
return (
<div className="flex min-h-screen flex-col justify-center px-6 py-12 lg:px-8 bg-gray-50">
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<img src="/logo.svg" alt="VulnCheck Logo" className="mx-auto h-20 w-20" />
<img src="/logo.svg" alt="TrueVuln Logo" className="mx-auto h-20 w-20" />
<h2 className="mt-6 text-center text-2xl font-bold leading-9 tracking-tight text-gray-900 font-mono">
Multi-Factor Authentication required
</h2>
@@ -151,7 +151,7 @@ export default function ForcedMfaSetupPage() {
required
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))}
className="mt-2 block w-full rounded-md border-0 py-2 text-center text-xl tracking-[0.5em] font-mono text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-vulncheck-blue"
className="mt-2 block w-full rounded-md border-0 py-2 text-center text-xl tracking-[0.5em] font-mono text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-truevuln-blue"
placeholder="000000"
/>
</div>
@@ -165,7 +165,7 @@ export default function ForcedMfaSetupPage() {
<button
type="submit"
disabled={loading || code.length !== 6}
className="w-full rounded-md bg-vulncheck-blue px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-600 disabled:opacity-60 font-mono"
className="w-full rounded-md bg-truevuln-blue px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-600 disabled:opacity-60 font-mono"
>
{loading ? 'Activating…' : 'Activate MFA & Sign in'}
</button>
+8 -8
View File
@@ -64,14 +64,14 @@ export default function NotificationsPage() {
<p className="text-sm text-gray-500 mt-1">Audit log of all outbound emails and alerts.</p>
</div>
<div className="bg-white p-2 rounded-lg shadow-sm border border-gray-100 flex items-center gap-2">
<EnvelopeIcon className="h-5 w-5 text-vulncheck-blue" />
<EnvelopeIcon className="h-5 w-5 text-truevuln-blue" />
<span className="text-sm font-bold text-gray-700">{logs.length} Notifications</span>
</div>
</div>
{loading ? (
<div className="flex justify-center items-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-vulncheck-blue"></div>
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-truevuln-blue"></div>
</div>
) : (
<div className="bg-white shadow-sm border border-gray-100 rounded-xl overflow-hidden">
@@ -103,7 +103,7 @@ export default function NotificationsPage() {
</td>
<td className="px-6 py-4">
<div className="text-sm text-gray-900 line-clamp-1">{log.subject}</div>
<div className="text-xs text-vulncheck-blue font-mono">
<div className="text-xs text-truevuln-blue font-mono">
{log.cve_id} {log.asset_hostname}
</div>
</td>
@@ -113,7 +113,7 @@ export default function NotificationsPage() {
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button
onClick={() => setPreviewLog(log)}
className="text-vulncheck-blue hover:text-blue-900 flex items-center gap-1 ml-auto"
className="text-truevuln-blue hover:text-blue-900 flex items-center gap-1 ml-auto"
>
<EyeIcon className="h-4 w-4" />
Preview
@@ -144,8 +144,8 @@ export default function NotificationsPage() {
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-2xl transform transition-all sm:my-8 sm:align-middle sm:max-w-4xl sm:w-full relative z-10 border border-gray-200">
<div className="bg-white px-6 py-4 border-b border-gray-100 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="bg-vulncheck-blue/10 p-2 rounded-lg">
<EnvelopeIcon className="h-6 w-6 text-vulncheck-blue" />
<div className="bg-truevuln-blue/10 p-2 rounded-lg">
<EnvelopeIcon className="h-6 w-6 text-truevuln-blue" />
</div>
<h3 className="text-xl font-bold text-gray-900">Email Transmission Preview</h3>
</div>
@@ -160,7 +160,7 @@ export default function NotificationsPage() {
<div className="space-y-3 mb-8 bg-gray-50 p-4 rounded-xl border border-gray-100">
<div className="flex text-sm">
<span className="w-24 font-bold text-gray-500 uppercase tracking-wider text-[10px] self-center">From</span>
<span className="text-gray-900 font-medium">VulnCheck Security Operations Center &lt;notifications@vulncheck.io&gt;</span>
<span className="text-gray-900 font-medium">TrueVuln Security Operations Center &lt;notifications@truevuln.io&gt;</span>
</div>
<div className="flex text-sm">
<span className="w-24 font-bold text-gray-500 uppercase tracking-wider text-[10px] self-center">To</span>
@@ -191,7 +191,7 @@ export default function NotificationsPage() {
<div className="bg-gray-50 px-6 py-4 sm:flex sm:flex-row-reverse gap-3 border-t border-gray-100">
<button
type="button"
className="w-full inline-flex justify-center rounded-lg border border-transparent shadow-sm px-6 py-2.5 bg-vulncheck-blue text-sm font-bold text-white hover:bg-blue-700 transition-all sm:w-auto"
className="w-full inline-flex justify-center rounded-lg border border-transparent shadow-sm px-6 py-2.5 bg-truevuln-blue text-sm font-bold text-white hover:bg-blue-700 transition-all sm:w-auto"
onClick={() => setPreviewLog(null)}
>
Done

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