Author SHA1 Message Date
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
99 changed files with 11592 additions and 689 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
+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`.
+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
+242 -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)
+195 -52
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,40 @@ 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/wrong 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)
- **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`)
### 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)
- **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,7 +97,7 @@ 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
@@ -73,18 +105,23 @@ Supports multiple AI providers for CVE analysis, threat assessment, and remediat
- **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)
- 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:** 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.
> **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 +134,8 @@ 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)
- 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 +146,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 +161,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 +170,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 +188,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 +255,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 +334,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 +355,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 |
@@ -358,7 +401,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 +409,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 +530,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 +542,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 +671,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 +793,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;")
+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": [...]
}
+2
View File
@@ -28,6 +28,8 @@ PROTECTED_SETTING_KEYS: frozenset[str] = frozenset({
"wazuh_config",
"smtp_config",
"nessus_config",
"openrouter_api_key",
"intune_config",
})
+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
+137
View File
@@ -0,0 +1,137 @@
"""
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
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) -> Dict[str, Any]:
token = self._ensure_token()
r = self.client.get(url, headers={"Authorization": f"Bearer {token}", "Accept": "application/json"}, params=params)
if r.status_code == 429:
raise DefenderAPIError("Defender rate limit (429)")
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
+232
View File
@@ -0,0 +1,232 @@
"""
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()})
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
+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
+100 -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
+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")
+6 -2
View File
@@ -88,12 +88,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 +205,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:
+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": ""} 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)
+52 -3
View File
@@ -1034,12 +1034,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 +1171,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 +1200,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}",
+1 -1
View File
@@ -227,7 +227,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)
):
+46 -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()
+645 -35
View File
@@ -149,8 +149,10 @@ class VulnerabilityDetailResponse(VulnerabilityResponse):
cvss_vector: Optional[str]
exploit_maturity: Optional[str]
published_date: Optional[datetime]
last_modified_date: Optional[datetime]
patched_at: Optional[datetime]
references: Optional[str]
remediation: Optional[str] = None
cwe_id: Optional[str]
ai_analysis: Optional[dict]
@@ -282,6 +284,28 @@ def log_vulnerability_change(
db.commit()
def _sanitize_wazuh_fix(pkg_name, pkg_version, fix):
"""Drop a Wazuh-reported fix build that belongs to a DIFFERENT Windows
release. Wazuh CTI sometimes attaches e.g. 6.2.9200.26079 (Server 2012) to
an OS finding on a Server 2025 host (10.0.26100.x); a fix on another build
line says nothing about this host better no fix than misinformation (the
MSRC remediation panel supplies the correct per-branch KB anyway)."""
if not (fix and pkg_name and "microsoft windows" in str(pkg_name).lower()):
return fix
def _bl(v):
try:
t = [int(x) for x in str(v).split(".")]
return tuple(t[:3]) if len(t) >= 4 else None
except (ValueError, TypeError):
return None
inst_bl, fix_bl = _bl(pkg_version), _bl(fix)
if inst_bl and fix_bl and inst_bl != fix_bl:
return None
return fix
def _build_vuln_response(vuln: Vulnerability) -> dict:
"""Build a vulnerability response dict with assigned user info"""
breakdown = vuln.calculate_priority_breakdown()
@@ -372,8 +396,10 @@ def _build_vuln_response(vuln: Vulnerability) -> dict:
"cvss_vector": getattr(vuln, 'cvss_vector', None),
"exploit_maturity": getattr(vuln, 'exploit_maturity', None),
"published_date": getattr(vuln, 'published_date', None),
"last_modified_date": getattr(vuln, 'last_modified_date', None),
"patched_at": getattr(vuln, 'patched_at', None),
"references": getattr(vuln, 'references', None),
"remediation": getattr(vuln, 'remediation', None),
"cwe_id": getattr(vuln, 'cwe_id', None),
}
@@ -477,10 +503,12 @@ async def list_vulnerabilities(
in_both_catalogs: Optional[bool] = Query(None, description="CVE in BEIDEN Catalogs (KEV und EUVD)"),
epss_min: Optional[float] = Query(None, description="Minimaler EPSS-Score (0.0-1.0)"),
source: Optional[str] = Query(None, description="Filter by scanner source: wazuh, nessus, manual"),
finding_type: Optional[str] = Query(None, description="Special finding group: 'mobile' = mobile device EOL/EOS + Android patch-level staleness"),
cross_confirmed: Optional[bool] = Query(None, description="Only CVEs reported by 2+ scanners"),
asset_id: Optional[int] = Query(None, description="Filter nach Asset"),
search: Optional[str] = Query(None, description="Suche in CVE-ID, Package, Title"),
include_inactive_assets: bool = Query(False, description="Include CVEs on INACTIVE/DECOMMISSIONED assets (off by default)"),
distinct_cve: bool = Query(False, description="Collapse per-asset duplicates to one row per CVE-ID (dashboard 'newest N distinct CVEs' widgets)"),
sort_by: str = Query("priority", description="Sortierung: priority, cvss, detected_at"),
sort_order: str = Query("desc", description="Reihenfolge: asc, desc"),
limit: int = Query(100, le=1000),
@@ -568,9 +596,21 @@ async def list_vulnerabilities(
# `wazuh_only` would be too brittle, use string contains.
if source:
src = source.strip().lower()
if src in {"wazuh", "nessus", "manual"}:
if src in {"wazuh", "nessus", "manual", "app-scan", "defender", "intune"}:
query = query.filter(Vulnerability.sources.contains(f'"{src}"'))
if finding_type == "mobile":
# Mobile device findings: vendor EOL/EOS (EOL- pseudo-CVE on a phone/
# tablet slug) + Android patch-level staleness. One group for the
# dashboard "Mobile Security" widget + its View All.
query = query.filter(
Vulnerability.cve_id.like("ANDROID-PATCH-%")
| Vulnerability.cve_id.like("EOL-IPHONE-%")
| Vulnerability.cve_id.like("EOL-IPAD-%")
| Vulnerability.cve_id.like("EOL-SAMSUNG-MOBILE-%")
| Vulnerability.cve_id.like("EOL-SAMSUNG-GALAXY-TAB-%")
)
if cross_confirmed:
# Pragmatic: rows with at least one comma in the JSON list have >=2 sources.
# `["wazuh"]` has no comma, `["wazuh","nessus"]` does.
@@ -614,6 +654,25 @@ async def list_vulnerabilities(
(Vulnerability.title.ilike(search_pattern))
)
# Collapse per-asset duplicates to one row per CVE-ID. Without this a CVE
# sitting on many assets fills the page with identical-CVE rows, so the
# dashboard's client-side dedup starves and a "newest 10" widget showed
# only ~4 (tester feedback). Keep the newest-published (then most-recently-
# detected) representative per CVE; composes with every sort_by below.
if distinct_cve:
from sqlalchemy import func as _wf
_rn = _wf.row_number().over(
partition_by=Vulnerability.cve_id,
order_by=(
Vulnerability.published_date.desc().nullslast(),
Vulnerability.detected_at.desc().nullslast(),
Vulnerability.id.desc(),
),
).label("rn")
_sub = query.with_entities(Vulnerability.id.label("vid"), _rn).subquery()
_keep_ids = db.query(_sub.c.vid).filter(_sub.c.rn == 1)
query = query.filter(Vulnerability.id.in_(_keep_ids))
# Get total count before pagination
total_count = query.count()
@@ -689,19 +748,44 @@ async def list_vulnerabilities(
return {"items": items_resp, "total": total_count}
if sort_by == "published_date":
# CVE-IDs are NOT chronological — CISA/MITRE assign them in batches.
# Order by official published_date with a detected_at fallback so
# CVEs we have not yet enriched still get a reasonable position.
from sqlalchemy import func as sa_func
ordering_expr = sa_func.coalesce(
Vulnerability.published_date, Vulnerability.detected_at
)
# "Newly Published" = sort by the CVE's OFFICIAL published_date.
#
# The old COALESCE(published_date, detected_at) fallback was wrong:
# a Nessus sync imports old CVEs (2014-2023) with published_date
# NULL but detected_at=today, so COALESCE made every freshly-
# imported old CVE look like it was "published today" and flooded
# the top of the list in arbitrary id order (tester: "komisch
# durcheinander"). Now rows WITHOUT a real published_date sink to
# the bottom (nulls-last) instead of masquerading as newest.
search_eol = (search or "").upper().startswith("EOL")
if not search_eol and finding_type != "mobile":
query = query.filter(
~Vulnerability.cve_id.like("EOL-%"),
~Vulnerability.cve_id.like("NESSUS-PLUGIN-%"),
)
# Secondary key: until the NVD published_date backfill drains, most
# rows have published_date NULL. Ordering those by id is meaningless
# (tester: "danach wieder NICHT richtig sortiert"). Fall back to the
# CVE's own year+sequence so "newest CVE number first" still holds.
from sqlalchemy import func as _sf, case as _case, Integer as _Int
_yr = _sf.nullif(_sf.split_part(Vulnerability.cve_id, '-', 2), '')
_nm = _sf.nullif(_sf.split_part(Vulnerability.cve_id, '-', 3), '')
_yr_i = _case((_yr.op('~')('^[0-9]+$'), _sf.cast(_yr, _Int)), else_=None)
_nm_i = _case((_nm.op('~')('^[0-9]+$'), _sf.cast(_nm, _Int)), else_=None)
if sort_order == "desc":
query = query.order_by(
ordering_expr.is_(None), desc(ordering_expr), desc(Vulnerability.id)
Vulnerability.published_date.is_(None), # nulls last
desc(Vulnerability.published_date),
desc(_yr_i), desc(_nm_i),
desc(Vulnerability.id),
)
else:
query = query.order_by(asc(ordering_expr), asc(Vulnerability.id))
query = query.order_by(
Vulnerability.published_date.is_(None),
asc(Vulnerability.published_date),
asc(_yr_i), asc(_nm_i),
asc(Vulnerability.id),
)
vulnerabilities = query.offset(offset).limit(limit).all()
items_resp = [_build_vuln_response(v) for v in vulnerabilities]
_attach_last_change(db, vulnerabilities, items_resp)
@@ -1215,12 +1299,27 @@ def verify_patch_with_rescan(
# 4. Prüfe ob CVE noch in Wazuh-Daten
cve_still_exists = any(v.get("cve") == vuln.cve_id for v in current_vulns)
old_status = vuln.status
if cve_still_exists:
# Patch fehlgeschlagen
vuln.status = VulnerabilityStatus.patch_failed
_reason = f"Patch verification rescan: CVE still reported by Wazuh on agent {agent_id}"
else:
# Patch erfolgreich verifiziert
vuln.status = VulnerabilityStatus.patched
vuln.patched_at = datetime.now()
_reason = f"Patch verification rescan: CVE no longer reported by Wazuh on agent {agent_id}"
# Revisionssicher: record the verify outcome (feeds audit log +
# per-CVE Change History). Background task → user_id=None.
if vuln.status != old_status:
try:
log_vulnerability_change(
db, None, vuln.id, old_status, vuln.status,
reason=_reason, cve_id=vuln.cve_id, source="verify_patch_rescan",
)
except Exception as e:
logger.warning("audit log for patch-verify failed (vuln_id=%s): %s", vuln.id, e)
db.commit()
@@ -1304,7 +1403,7 @@ def analyze_vulnerability_with_ai(
@router.post("/{vuln_id}/enrich")
async def enrich_single_vulnerability(
def enrich_single_vulnerability(
vuln_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor)
@@ -1337,7 +1436,9 @@ class BulkEnrichRequest(BaseModel):
@router.post("/enrich/bulk")
async def bulk_enrich_vulnerabilities(
# Sync def → worker threadpool (off the event loop); EPSS/KEV enrichment
# over many CVEs no longer freezes the web GUI.
def bulk_enrich_vulnerabilities(
payload: BulkEnrichRequest,
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor)
@@ -1361,7 +1462,10 @@ async def bulk_enrich_vulnerabilities(
if not vulns:
return {"stats": {"epss_updated": 0, "kev_marked": 0, "kev_cleared": 0, "total": 0}}
stats = enrich_vulnerabilities(db, vulns)
# NVD date backfill is rate-limited (up to 6.5s/CVE without a key) and
# would hang this synchronous request for minutes. It runs in the
# nightly enrichment job instead; the manual button stays fast.
stats = enrich_vulnerabilities(db, vulns, use_nvd_dates=False)
audit_log = AuditLog(
user_id=current_user.id,
@@ -1377,8 +1481,73 @@ async def bulk_enrich_vulnerabilities(
return {"stats": stats}
# Guard so a double-click / impatient retry doesn't stack concurrent
# 600 MB ZIP walks. Module-level flag is fine — single backend process.
_DATE_BACKFILL_RUNNING = False
def _run_date_backfill_threaded() -> dict:
"""Date-only enrichment with its own DB session (runs in a detached
thread). Fills published_date/last_modified_date for every CVE row
that lacks a published_date, via the cvelistV5 ZIP/raw cascade (NVD
fallback). No EPSS/KEV/EUVD work just dates."""
global _DATE_BACKFILL_RUNNING
from app.database import SessionLocal
db = SessionLocal()
try:
vulns = (
db.query(Vulnerability)
.filter(
Vulnerability.published_date.is_(None),
Vulnerability.cve_id.like("CVE-%"),
)
.all()
)
if not vulns:
logger.info("Date backfill: nothing to do (no undated CVEs)")
return {"total": 0, "nvd_dates_set": 0}
stats = enrich_vulnerabilities(
db, vulns,
use_epss=False, use_kev=False, use_euvd=False, use_nvd_dates=True,
)
logger.info("Date backfill done: %s", stats)
return stats
except Exception as e:
logger.error("Date backfill failed: %s", e)
return {"error": str(e)}
finally:
db.close()
_DATE_BACKFILL_RUNNING = False
@router.post("/dates/backfill", status_code=202)
async def backfill_cve_dates(
current_user: User = Depends(RequireEditor),
):
"""Kick off an on-demand CVE published/last-modified backfill.
Fire-and-forget: launches the date-only fill (cvelistV5 ZIP/raw NVD
fallback) in a detached background thread and returns 202 immediately,
so a slow ZIP walk over thousands of CVEs can't trip the reverse-proxy
request timeout (was returning 503). Watch progress in the logs:
docker compose logs -f backend | grep -E "CVE dates:|Date backfill"
No NVD API key required cvelistV5 is the primary, keyless source.
"""
global _DATE_BACKFILL_RUNNING
if _DATE_BACKFILL_RUNNING:
return {"status": "already_running",
"detail": "A date backfill is already in progress."}
_DATE_BACKFILL_RUNNING = True
import threading
threading.Thread(target=_run_date_backfill_threaded, daemon=True).start()
return {"status": "started",
"detail": "Date backfill started in the background. "
"The Newly-Published order updates as it completes "
"(watch the backend logs)."}
@router.post("/enrich/kev/refresh")
async def refresh_kev_catalog(
def refresh_kev_catalog(
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor)
):
@@ -1541,7 +1710,7 @@ async def unmark_false_positive(
@router.post("/sync/wazuh")
async def sync_vulnerabilities_from_wazuh(
def sync_vulnerabilities_from_wazuh(
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor)
):
@@ -1702,7 +1871,8 @@ def run_wazuh_vulnerability_sync(db: Session) -> dict:
existing.package_version = merged_versions
# Backfill fixed_version when missing (older syncs
# didn't extract it; new syncs do).
fv = vuln_data.get("fixed_version")
fv = _sanitize_wazuh_fix(merged_packages, merged_versions,
vuln_data.get("fixed_version"))
if fv and not existing.fixed_version:
existing.fixed_version = fv
@@ -1725,7 +1895,9 @@ def run_wazuh_vulnerability_sync(db: Session) -> dict:
title=vuln_data.get("title"),
package_name=merged_packages,
package_version=merged_versions,
fixed_version=vuln_data.get("fixed_version"),
fixed_version=_sanitize_wazuh_fix(
merged_packages, merged_versions,
vuln_data.get("fixed_version")),
detected_at=datetime.now(),
sources='["wazuh"]',
first_detected_by="wazuh",
@@ -1800,6 +1972,17 @@ def run_wazuh_vulnerability_sync(db: Session) -> dict:
db.commit()
wazuh.close()
# Revisionssicher: initial VULNERABILITY_DETECTED audit event per
# new finding (audit trail no longer starts at the first status
# change only).
if newly_created_vuln_ids:
try:
from app.services.audit_events import audit_new_vulnerabilities
audit_new_vulnerabilities(db, newly_created_vuln_ids, source="wazuh")
db.commit()
except Exception as e:
logger.warning(f"Wazuh sync: detected-audit failed (non-fatal): {e}")
# Best-effort enrichment of newly created vulns (by ID, not a
# broad scan of all un-enriched rows which could be very slow).
if newly_created_vuln_ids:
@@ -1808,7 +1991,9 @@ def run_wazuh_vulnerability_sync(db: Session) -> dict:
Vulnerability.id.in_(newly_created_vuln_ids)
).all()
if new_vulns:
enrich_stats = enrich_vulnerabilities(db, new_vulns)
# Skip NVD date backfill here — keeps the sync fast;
# the nightly enrichment job dates these rows.
enrich_stats = enrich_vulnerabilities(db, new_vulns, use_nvd_dates=False)
stats["enrichment"] = enrich_stats
except Exception as e:
logger.warning(f"Wazuh sync: enrichment of new vulns failed: {e}")
@@ -1891,6 +2076,7 @@ def sync_agent_vulnerabilities(db: Session, wazuh: WazuhClient, agent_id: str, a
pkg_name = vuln_data.get("name")
pkg_version = vuln_data.get("version")
pkg_fix = vuln_data.get("fixed_version")
pkg_fix = _sanitize_wazuh_fix(pkg_name, pkg_version, pkg_fix)
if pkg_name:
unique_cves[cve_id]["packages"].add(pkg_name)
existing_pkg = unique_cves[cve_id]["pkg_rows"].get(pkg_name)
@@ -2098,6 +2284,17 @@ def sync_agent_vulnerabilities(db: Session, wazuh: WazuhClient, agent_id: str, a
db.commit()
logger.info(f"Sync: Completed for agent {agent_id} ({asset.hostname}): {new_count} new, {updated_count} updated, {len(active_cves)} total active CVEs")
# Revisionssicher: initial VULNERABILITY_DETECTED audit event per new
# finding. Written here (not in the callers) so the scheduler loop and
# the per-asset rescan endpoint are both covered.
if new_vuln_ids:
try:
from app.services.audit_events import audit_new_vulnerabilities
audit_new_vulnerabilities(db, new_vuln_ids, source="wazuh")
db.commit()
except Exception as e:
logger.warning(f"Sync: detected-audit failed for agent {agent_id} (non-fatal): {e}")
# 4. Enrich newly created vulns with EPSS + KEV (best-effort, non-fatal)
if new_count > 0:
try:
@@ -2107,7 +2304,8 @@ def sync_agent_vulnerabilities(db: Session, wazuh: WazuhClient, agent_id: str, a
Vulnerability.enrichment_updated_at.is_(None)
).all()
if new_vulns:
enrich_vulnerabilities(db, new_vulns)
# NVD date backfill deferred to the nightly job (rate-limited).
enrich_vulnerabilities(db, new_vulns, use_nvd_dates=False)
except Exception as e:
logger.warning(f"Sync: Enrichment skipped for agent {agent_id}: {e}")
@@ -2206,7 +2404,7 @@ async def check_incorrect_scores(
@router.post("/override/nessus/{asset_id}")
async def override_from_nessus(
def override_from_nessus(
asset_id: int,
scan_id: Optional[int] = None,
dry_run: bool = Query(True, description="Nur simulieren ohne DB-Änderungen"),
@@ -2312,7 +2510,9 @@ async def override_from_nessus(
@router.post("/override/vulnrichment")
async def override_from_vulnrichment(
# Sync def → worker threadpool; the cvelistV5/Vulnrichment cascade (incl.
# ZIP download) runs off the event loop so the web GUI stays responsive.
def override_from_vulnrichment(
cve_ids: Optional[List[str]] = Query(None, description="CVE-IDs zum Korrigieren (leer = alle)"),
asset_ids: Optional[List[int]] = Query(None, description="Asset-IDs zum Korrigieren (leer = alle)"),
dry_run: bool = Query(True, description="Nur simulieren ohne DB-Änderungen"),
@@ -2423,7 +2623,9 @@ async def list_vulnrichment_correction_jobs(
@router.post("/exploit-intel/refresh")
async def refresh_exploit_intel(
# Sync def → FastAPI runs it in a worker threadpool, off the event loop,
# so the catalog downloads don't freeze the web GUI.
def refresh_exploit_intel(
only_open: bool = Query(True, description="Limit to status=open"),
fetch_pocs: bool = Query(True, description="Also fetch PoC-in-GitHub (slow)"),
fetch_msf: bool = Query(True, description="Also fetch Metasploit modules"),
@@ -2440,7 +2642,9 @@ async def refresh_exploit_intel(
@router.post("/eol-check")
async def run_eol_check(
# Sync def → runs in a worker threadpool (off the event loop) so the
# per-asset endoflife.date / MS-lifecycle work doesn't freeze the web GUI.
def run_eol_check(
asset_id: Optional[int] = Query(None, description="Scope to one asset; omit for all wazuh-linked assets"),
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor),
@@ -2545,19 +2749,35 @@ async def run_eol_check(
continue
seen_keys.add(key)
stats["packages_checked"] += 1
slug = eol_service.resolve_product_slug(name)
if not slug:
status = None
# 1) endoflife.date (when the name maps to a known slug).
if eol_service.resolve_product_slug(name):
try:
status = eol_service.check_eol(db, name, version)
except Exception as e:
logger.warning("EOL check failed for %s %s: %s", name, version, e)
status = None
actionable = status and (status.is_eol or status.is_eol_soon or status.is_eoas)
# 2) MS-lifecycle export / hardcoded exotics (Plan O) whenever
# endoflife.date had nothing actionable — this also covers
# products endoflife.date maps to a slug but can't resolve a
# release for (e.g. Visual C++ Redistributables → visual-cpp,
# whose endoflife data doesn't match redistributable builds).
if not actionable:
try:
from app.services import ms_lifecycle_service
ms_status = ms_lifecycle_service.resolve_ms_lifecycle_eol(db, name, version)
except Exception as e:
logger.debug("MS-lifecycle fallback failed for %s: %s", name, e)
ms_status = None
if ms_status and (ms_status.is_eol or ms_status.is_eol_soon):
status = ms_status
actionable = True
stats.setdefault("ms_lifecycle_findings", 0)
stats["ms_lifecycle_findings"] += 1
if not actionable:
stats["products_unmapped"] += 1
continue
try:
status = eol_service.check_eol(db, name, version)
except Exception as e:
logger.warning("EOL check failed for %s %s: %s", name, version, e)
continue
if not status:
continue
if not (status.is_eol or status.is_eol_soon or status.is_eoas):
continue
try:
_, was_created = eol_service.upsert_eol_vulnerability(
db,
@@ -2572,11 +2792,401 @@ async def run_eol_check(
except Exception as e:
logger.warning("EOL upsert failed for %s on asset %s: %s", name, asset.id, e)
db.commit()
logger.info(
"EOL check done: %d assets, %d packages, %d EOL findings (%d new), "
"%d via MS-lifecycle/exotics, %d OS, %d unmapped",
stats.get("assets_scanned", 0), stats.get("packages_checked", 0),
stats.get("eol_findings_total", 0), stats.get("eol_findings_new", 0),
stats.get("ms_lifecycle_findings", 0), stats.get("os_eol_findings", 0),
stats.get("products_unmapped", 0),
)
return stats
@router.post("/m365-check")
# Sync def → runs in a worker threadpool (off the event loop) so the
# cvelistV5/MSRC metadata work doesn't freeze the web GUI.
def run_m365_check_endpoint(
asset_id: Optional[int] = Query(None, description="Scope to one asset; omit for all wazuh-linked assets"),
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor),
):
"""Detect Microsoft 365 Apps CVEs (Plan P).
M365 Apps security fixes are not in NVD and are invisible to Wazuh's
vulnerability detector. This parses the Microsoft 365 Apps security-
updates page, compares the installed build (from syscollector) against
the latest patched build for the matching update channel, and creates
real-CVE rows for every monthly update the host is behind on.
Idempotent re-runs upsert existing rows.
"""
from app.integrations.wazuh_client import WazuhClient
from app.auth.setting_crypto import read_setting_value
from app.services import m365_service
raw_wazuh = read_setting_value(db, "wazuh_config")
if not raw_wazuh:
raise HTTPException(400, "Wazuh is not configured (settings.wazuh_config missing).")
try:
wazuh_config = json.loads(raw_wazuh)
except json.JSONDecodeError:
raise HTTPException(400, "Invalid Wazuh configuration format.")
api_url = wazuh_config.get("api_url")
username = wazuh_config.get("username")
password = wazuh_config.get("password")
if not all([api_url, username, password]):
raise HTTPException(400, "Incomplete Wazuh configuration (api_url/username/password missing).")
wazuh = WazuhClient(
base_url=api_url,
username=username,
password=password,
indexer_url=wazuh_config.get("indexer_url"),
indexer_username=wazuh_config.get("indexer_username"),
indexer_password=wazuh_config.get("indexer_password"),
verify_ssl=wazuh_config.get("verify_ssl", False),
)
try:
return m365_service.run_m365_check(db, wazuh, asset_id=asset_id)
except m365_service.M365Error as e:
raise HTTPException(502, f"MS365 detection failed: {e}")
@router.post("/app-cve-scan")
# Sync def → worker threadpool; OSV/NVD-CPE lookups are blocking I/O and
# must not freeze the web GUI.
def run_app_cve_scan_endpoint(
asset_id: Optional[int] = Query(None, description="Scope to one asset; omit for all assets with software inventory"),
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor),
):
"""Built-in app→CVE scanner (curated + precise).
Maps installed software (Wazuh syscollector packages + Intune
detectedApps) to real CVEs via OSV / NVD-CPE with an own version-range
check, so assets without a real scanner (Intune-only / mobile) still
get findings. Source 'app-scan'; cross-confirms with existing scanners
on the same (cve, asset). Idempotent.
"""
from app.services import app_cve_scanner_service
try:
return app_cve_scanner_service.run_app_cve_scan(db, asset_id=asset_id)
except Exception as e:
raise HTTPException(502, f"App CVE scan failed: {e}")
@router.post("/msrc-scan")
# Sync def → worker threadpool; the CVRF fetch is blocking I/O.
def run_msrc_scan_endpoint(
asset_id: Optional[int] = Query(None, description="Scope to one asset; omit for all Windows Server assets"),
rebuild_index: bool = Query(False, description="Re-pull the MSRC CVRF documents first (slow; the nightly job does this)"),
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor),
):
"""MSRC fixed-build scan for Windows Server OS CVEs.
Compares the host's OS build against the FixedBuild MSRC publishes per CVE
and product, on the host's own servicing branch. Patch-level accurate and
available on Patch Tuesday well before the CVE reaches the Wazuh CTI feed.
NVD/cvelistV5 cannot do this: they carry no fixed build for MS products.
Source 'msrc'; idempotent, and auto-resolves once a host catches up.
"""
from app.services import msrc_scan_service
try:
if rebuild_index:
msrc_scan_service.build_product_index(db)
return msrc_scan_service.run_msrc_scan(db, asset_id=asset_id)
except Exception as e:
raise HTTPException(502, f"MSRC scan failed: {e}")
@router.post("/suppress-false-positives")
# Sync def → worker threadpool; reads the cvelistV5 ZIP (blocking I/O).
def suppress_false_positives_endpoint(
asset_id: Optional[int] = Query(None, description="Scope to one asset; omit for all"),
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor),
):
"""Auto-flag Wazuh findings whose installed version is provably outside
all cvelistV5 affected ranges for the matched product (loose-CPE false
positives, e.g. a SQL 2019 host carrying a 16.x/17.x-only CVE). Sets
status=false_positive (reversible via unmark). Conservative.
"""
from app.services import cvelistv5_scan_service
try:
return cvelistv5_scan_service.suppress_false_positives(db, asset_id=asset_id)
except Exception as e:
raise HTTPException(502, f"FP suppression failed: {e}")
@router.get("/ai-remediation/status")
async def ai_remediation_status(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Whether AI remediation is configured (key present). UI hides the
button when disabled."""
from app.services import ai_service
return {"enabled": ai_service.is_enabled(db)}
@router.post("/{vuln_id}/ai-remediation")
async def ai_remediation(
vuln_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor),
):
"""Generate OS-aware remediation guidance for a vulnerability via
OpenRouter. Runs OFF the event loop so the GUI stays responsive."""
import asyncio
from app.services import ai_service
vuln = db.query(Vulnerability).filter(Vulnerability.id == vuln_id).first()
if not vuln:
raise HTTPException(404, "Vulnerability not found")
asset = db.query(Asset).filter(Asset.id == vuln.asset_id).first()
os_name = None
if asset:
os_name = (asset.operating_system or "").strip() or None
if os_name and asset.os_version:
os_name = f"{os_name} {asset.os_version}".strip()
kwargs = dict(
cve_id=vuln.cve_id,
title=vuln.title,
description=vuln.description,
package=vuln.package_name,
installed=vuln.package_version,
fixed=vuln.fixed_version,
os_name=os_name,
scanner_remediation=getattr(vuln, "remediation", None),
)
try:
result = await asyncio.to_thread(ai_service.generate_remediation, db, **kwargs)
except ai_service.AIServiceError as e:
raise HTTPException(502, str(e))
return result
@router.get("/{vuln_id}/remediations")
async def get_vuln_remediations(
vuln_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Remediation guidance for a finding from every source:
the per-row scanner solution (Nessus) + CVE-level external sources
(MSRC, later Ubuntu/CentOS) grouped by source. Pure DB read."""
from app.models.cve_remediation import CveRemediation
vuln = db.query(Vulnerability).filter(Vulnerability.id == vuln_id).first()
if not vuln:
raise HTTPException(404, "Vulnerability not found")
# On-demand non-Windows enrichment for this CVE, cached into
# cve_remediations on first view: the matching vendor provider (Ubuntu
# USN / RHEL-family errata) when the OS is known + OSV.dev aggregator
# (Debian/SUSE/Alpine/Rocky/Alma/language ecosystems). MSRC is populated
# by its own background job, not here.
if vuln.cve_id and vuln.cve_id.upper().startswith("CVE-"):
try:
import asyncio
from app.services import linux_remediation_service as lrs
asset = db.query(Asset).filter(Asset.id == vuln.asset_id).first()
os_name = (asset.operating_system if asset else None) or ""
is_windows = "windows" in os_name.lower()
# OSV is the always-run source; if it's not cached yet, enrich.
if not is_windows and not lrs.has_cached(db, vuln.cve_id, "osv"):
await asyncio.to_thread(lrs.enrich_cve_linux, db, vuln.cve_id, os_name)
db.commit()
except Exception as e:
logger.debug("Linux/OSV remediation enrich failed for %s: %s", vuln.cve_id, e)
ext = (
db.query(CveRemediation)
.filter(CveRemediation.cve_id == vuln.cve_id)
.all()
)
groups: dict = {}
for r in ext:
g = groups.setdefault(r.source, {"source": r.source, "items": [], "fetched_at": None})
g["items"].append({
"kind": r.kind, "title": r.title, "detail": r.detail,
"kb": r.kb, "fixed_build": r.fixed_build, "url": r.url,
})
if r.fetched_at and (g["fetched_at"] is None or r.fetched_at > g["fetched_at"]):
g["fetched_at"] = r.fetched_at
# MSRC block tailoring for the affected host.
msrc = groups.get("msrc")
if msrc:
a = db.query(Asset).filter(Asset.id == vuln.asset_id).first()
is_m365 = (vuln.first_detected_by == "m365_check")
def _branch(v: str):
# build "10.0.26100.32690" → branch "26100", rev 32690
parts = [p for p in (v or "").split(".") if p.isdigit()]
return (parts[2], int(parts[3])) if len(parts) >= 4 else (None, None)
def _major(v):
# first numeric segment: "8.0.12" → "8", "18.6.3" → "18"
for p in (v or "").split("."):
if p.isdigit():
return p
return None
items = msrc["items"]
fixes = [i for i in items if i.get("kind") == "fix"]
others = [i for i in items if i.get("kind") != "fix"]
# Keep only the NEWEST KB per (build-branch + update-type). MS monthly
# updates are cumulative, so older revisions of the same line are
# superseded; distinct types (Security Update vs Security Hotpatch
# Update) are kept separately. Non-Windows product builds (.NET / Visual
# Studio / SQL have no 4-part Windows build → branch None) are keyed on
# the build itself so DIFFERENT products aren't collapsed into one
# (tester: a .NET finding showed a Visual Studio build because both had
# branch None).
# Collect ALL candidates per (build-branch + update-type) first — the
# host-aware pick below needs the full set. Collapsing to the newest
# rev up-front was wrong: one branch can carry SEVERAL revision
# sequences (10.0.26100 is Windows 11 24H2 at rev ~8xxx AND Windows
# Server 2025 at rev ~33xxx), so 'newest' handed a Win11 host the
# Server KB (tester: KB5099536 build .33158 suggested at .8655).
best: dict = {}
by_branch: dict = {}
for i in fixes:
build = i.get("fixed_build") or ""
br, rev = _branch(build)
if br is None:
key = ("nb", build or i.get("kb") or i.get("url") or i.get("title") or "", i.get("detail") or "")
best.setdefault(key, i)
else:
key = (br, i.get("detail") or "")
by_branch.setdefault(key, []).append((rev or 0, i))
if key not in best or (rev or 0) > (_branch(best[key].get("fixed_build") or "")[1] or 0):
best[key] = i
deduped = list(best.values())
# Windows-OS finding: narrow to the host's own build branch. Within the
# branch, pick per update-type the SMALLEST fix revision still above the
# installed one — that's the host's own servicing sequence (a 24H2 box
# at .8655 takes .8875, not Server 2025's .33158; a Server box at
# .32690 skips .8875 because it's below installed and takes .33158).
# MS servicing is cumulative, so smallest-above is the fixing update.
# If the host is past every candidate (already patched), fall back to
# the newest as reference. For Microsoft 365 Apps findings the MSRC KBs
# are MSI-version builds that never match the installed Click-to-Run
# channel build, so we don't branch-filter — we show the deduped set
# plus the actionable channel-build hint below.
host_branch, host_rev = _branch(a.os_version if a else "")
shown = deduped
if host_branch and not is_m365:
matched = []
for (br, _detail), cands in by_branch.items():
if br != host_branch:
continue
above = [c for c in cands if host_rev is not None and c[0] > host_rev]
pick = min(above)[1] if above else max(cands)[1]
matched.append(pick)
if matched:
shown = matched
# Non-Windows Microsoft product finding (.NET / SQL / Visual Studio all
# share one CVE across products, each with its own fix build): narrow
# to the build line whose major matches the installed package version,
# so a .NET 8.x finding shows the .NET 8.x fix — not a Visual Studio
# 18.x build. Fallback to the full set if nothing matches (never hide
# everything).
if not is_m365 and shown is deduped:
inst_major = _major(vuln.package_version)
if inst_major:
prod = [i for i in shown if _major(i.get("fixed_build")) == inst_major]
if prod:
shown = prod
shown.sort(key=lambda i: _branch(i.get("fixed_build") or "")[1] or 0, reverse=True)
# M365 Apps: the real fix is "update the Office channel to build X".
if is_m365 and vuln.fixed_version:
others = [{
"kind": "mitigation",
"title": "Update via Office channel",
"detail": (f"Update Microsoft 365 Apps to build {vuln.fixed_version} "
f"or later via the configured Office update channel "
f"(Click-to-Run). The KB list below is for perpetual/MSI "
f"Office versions and may not apply to this install."),
"kb": None, "fixed_build": None, "url": None,
}] + others
link = {
"kind": "advisory",
"title": "MSRC update guide",
"detail": None, "kb": None, "fixed_build": None,
"url": f"https://msrc.microsoft.com/update-guide/vulnerability/{vuln.cve_id}",
}
msrc["items"] = [link] + others + shown
return {
"scanner": getattr(vuln, "remediation", None),
"external": list(groups.values()),
}
# Last/current MSRC-refresh state so the GUI can poll for completion (the
# refresh is fire-and-forget 202). ponytail: module state assumes the single
# uvicorn worker we ship with; move to the DB if --workers is ever added.
_MSRC_REFRESH: dict = {"running": False, "result": None, "error": None, "finished_at": None}
def _run_msrc_refresh_threaded(months_back: Optional[int] = None) -> None:
import time as _time
from app.database import SessionLocal
from app.services import msrc_service
db = SessionLocal()
try:
stats = msrc_service.refresh_msrc(db, months_back=months_back)
_MSRC_REFRESH["result"] = {k: v for k, v in (stats or {}).items() if k != "errors"}
_MSRC_REFRESH["error"] = None
except Exception as e:
_MSRC_REFRESH["result"] = None
_MSRC_REFRESH["error"] = str(e)
logger.error("MSRC refresh failed: %s", e)
finally:
db.close()
_MSRC_REFRESH["running"] = False
_MSRC_REFRESH["finished_at"] = _time.time()
@router.post("/msrc/refresh", status_code=202)
async def refresh_msrc_endpoint(
months_back: Optional[int] = Query(None, description="How many recent monthly MSRC docs to ingest"),
current_user: User = Depends(RequireEditor),
):
"""Kick off an MSRC CVRF ingest in the background (fire-and-forget).
Pulls the last N monthly Microsoft security-update documents and stores
per-CVE fixes (KB + build + link), workarounds, and mitigations for the
CVEs already in the DB. Returns 202 immediately watch the logs
('MSRC refresh done')."""
if _MSRC_REFRESH["running"]:
return {"status": "already_running", "detail": "An MSRC refresh is already in progress."}
_MSRC_REFRESH.update({"running": True, "result": None, "error": None, "finished_at": None})
import threading
threading.Thread(target=_run_msrc_refresh_threaded, args=(months_back,), daemon=True).start()
return {"status": "started", "detail": "MSRC enrichment started in the background."}
@router.get("/msrc/refresh/status")
async def msrc_refresh_status(current_user: User = Depends(RequireEditor)):
"""Poll target for the GUI: current/last MSRC-refresh state + stats."""
s = _MSRC_REFRESH
state = ("running" if s["running"] else "error" if s["error"]
else "done" if s["result"] is not None else "idle")
return {"state": state, "running": s["running"], "result": s["result"],
"error": s["error"], "finished_at": s["finished_at"]}
@router.post("/recompute-scores")
async def recompute_priority_scores(
def recompute_priority_scores(
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor),
):
+292 -19
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()
@@ -153,7 +153,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 +347,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 +410,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 +428,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 +449,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 +524,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 +565,152 @@ 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)
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"})
# Auto-suppress Wazuh findings whose installed version is outside all
# cvelistV5 affected ranges (loose-CPE false positives).
try:
fp = cvelistv5_scan_service.suppress_false_positives(db)
logger.info("cvelistV5 FP-suppression nightly: %s", {k: v for k, v in fp.items() if k != "errors"})
except Exception as e:
logger.warning("cvelistV5 FP-suppression failed (non-fatal): %s", e)
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 +749,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 +764,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 +798,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 +832,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 +854,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 +957,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:
@@ -892,6 +1092,25 @@ 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
@@ -915,6 +1134,18 @@ def start_scheduler():
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=20),
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.
@@ -926,6 +1157,48 @@ def start_scheduler():
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=25),
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=5, minute=10),
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)"
+163
View File
@@ -0,0 +1,163 @@
"""
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/rss.x?i=44", "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:
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}
+265
View File
@@ -0,0 +1,265 @@
"""
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]
if existing.status == VulnerabilityStatus.patched:
existing.status = VulnerabilityStatus.open
existing.patched_at = None
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
+754
View File
@@ -0,0 +1,754 @@
"""
Built-in appCVE scanner.
Maps installed software (Wazuh syscollector packages + Intune detectedApps)
to CVEs so assets without a real scanner (Intune-only / mobile, or any app
no scanner covers) still get findings.
Design: CURATED + PRECISE (low false-positives).
- Only a curated product registry is matched unknown app names are
ignored (no CPE auto-guessing no FP storm).
- Per product: query OSV (language/OSS ecosystems, server-side version
match) or NVD-CPE (desktop apps), then verify the installed version
actually falls inside the CVE's affected version range ourselves.
- Results cached per (product_key, version) in app_cve_cache (TTL) so the
same Chrome version across N hosts = one query (and NVD rate limit).
- Findings are upserted 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).
"""
from __future__ import annotations
import json
import logging
import os
import re
import time
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Tuple
import httpx
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
OSV_QUERY_URL = "https://api.osv.dev/v1/query"
NVD_CVE_API = "https://services.nvd.nist.gov/rest/json/cves/2.0"
HTTP_TIMEOUT = 30.0
# Was 7 days — but a (product,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 (tester:
# CVE-2026-14152 undetected while a same-day sibling CVE was). 24h still
# collapses most redundant NVD/OSV traffic (many hosts share a version).
CACHE_TTL = timedelta(hours=24)
NVD_SLEEP_NO_KEY = 6.5
NVD_SLEEP_WITH_KEY = 0.7
# Curated registry: (compiled name regex, entry). First match wins.
# entry: {"key", "kind": "cpe"|"osv", "cpe"? "a:vendor:product", "eco"?, "oname"?}
def _cpe(name_re: str, vendor_product: str, name_ver: bool = False) -> tuple:
e = {"key": f"cpe:{vendor_product}", "kind": "cpe",
"cpe": f"cpe:2.3:a:{vendor_product}"}
if name_ver:
# Take the version from the software NAME, not the package version
# field. Some products (e.g. .NET Runtime) report an MSI build number
# (48.x, 94.x) in the version field while the real semantic version
# (6.0.16) lives in the display name — the field version never matches
# NVD's 6.0.x/8.0.x ranges, so the CVE was silently missed.
e["name_ver"] = True
return (re.compile(name_re, re.I), e)
_REGISTRY: List[tuple] = [
# "Google Chrome" (Wazuh/Windows) + "com.android.chrome" (Intune Android
# package id). Android Chrome shares version numbers AND security fixes with
# Desktop (Google: "Android releases contain the same security fixes"), so
# the same google:chrome ranges apply. iOS Chrome is WebKit-backed and
# reported as bare "Chrome" — deliberately NOT matched (Blink CVEs N/A).
_cpe(r"google chrome|com\.android\.chrome", "google:chrome"),
_cpe(r"microsoft edge(?!.*webview)", "microsoft:edge_chromium"),
_cpe(r"mozilla firefox|(?<!\w)firefox", "mozilla:firefox"),
_cpe(r"thunderbird", "mozilla:thunderbird"),
_cpe(r"acrobat reader|adobe acrobat reader", "adobe:acrobat_reader_dc"),
_cpe(r"adobe acrobat(?!.*reader)", "adobe:acrobat_dc"),
_cpe(r"7-?zip", "7-zip:7-zip"),
_cpe(r"notepad\+\+", "notepad-plus-plus:notepad-plus-plus"),
_cpe(r"vlc media player|videolan", "videolan:vlc_media_player"),
_cpe(r"(?<!\w)putty", "putty:putty"),
_cpe(r"winscp", "winscp:winscp"),
_cpe(r"wireshark", "wireshark:wireshark"),
# Exclude the FIPS provider/module builds (e.g. Veeam ships "OpenSSL v3.0.0
# FIPS"): OpenSSL advisories explicitly carve the FIPS modules OUT of most
# CVEs (the vulnerable code is outside the FIPS boundary), and they carry a
# separate 4-part build version that doesn't map to NVD's ranges anyway →
# matching them is a false positive (tester: CVE-2025-15467).
_cpe(r"openssl(?!.*fips)", "openssl:openssl"),
_cpe(r"openvpn", "openvpn:openvpn"),
_cpe(r"node\.?js", "nodejs:node.js"),
_cpe(r"(?<!\w)python(?!.*launcher)", "python:python"),
_cpe(r"teamviewer", "teamviewer:teamviewer"),
_cpe(r"(?<!\w)zoom(?!\w)", "zoom:zoom"),
_cpe(r"libreoffice", "libreoffice:libreoffice"),
_cpe(r"filezilla", "filezilla:filezilla"),
_cpe(r"(?<!\w)git(?: for windows| version control)", "git:git"),
_cpe(r"oracle vm virtualbox|virtualbox", "oracle:vm_virtualbox"),
_cpe(r"(?<!\w)gimp(?!\w)", "gimp:gimp"),
# .NET (6/7/8/9) and .NET Framework share the SAME NVD CPE
# (microsoft:.net) — NVD scopes each major version's fix range via its own
# cpeMatch entry under that one product, so one registry row covers both.
# The semantic version lives in the NAME ("… - 6.0.16 (x64)"), not the
# version field (an MSI build number) → name_ver=True. The developer
# reference packs (Targeting Pack / Multi-Targeting Pack / Framework SDK)
# are NOT the runtime; excluded via lookahead so their version doesn't
# false-positive against a runtime CVE range. (Bare ".NET Framework" the
# runtime is a Windows feature, often not a syscollector package anyway.)
_cpe(r"microsoft\s+\.net\s+(?:desktop\s+)?(?:runtime|host|sdk)\b"
r"|microsoft\s+\.net\s+framework(?!.*(?:targeting|sdk|client))\s+[\d.]+",
"microsoft:.net", name_ver=True),
# Only Teams itself — NOT the Office add-in, the VDI/Citrix plugin, or the
# machine-wide installer (separate products with their own versioning that
# would false-positive against Teams-app CVE ranges).
_cpe(r"microsoft teams(?!.*(machine-wide|add-in|plugin|vdi|citrix))", "microsoft:teams"),
# OSV ecosystem examples (rarely in desktop inventory, but supported):
(re.compile(r"^node-(.+)$", re.I), {"key": "osv:npm", "kind": "osv", "eco": "npm"}),
]
# OS-level CPEs. Apple ships the precise OS version (e.g. 18.1.2) and NVD
# carries proper version ranges for it → a clean CPE-range check, same as the
# desktop apps. Android is deliberately absent: NVD only lists the base
# version (13/14/15) without ranges, so it needs the Intune security-patch
# level + Android bulletin parsing — a separate feature.
# ponytail: iOS/iPadOS only; add Android when the patch-level path is built.
# Windows OS CVEs are handled by cvelistv5_scan_service.scan_asset_os(), not
# from here. Modern Microsoft CVE records DO carry real build ranges (e.g.
# CVE-2026-47291: 20/20 affected entries with a numeric lessThan) — but NVD
# flattens them to an END bound only (versionEndExcluding, no start), so a
# 1607 host (14393.x) would fall inside the 22H2 range (endExcluding
# 19045.7417) and false-positive. cvelistV5 keeps the range BOUNDED
# (version 10.0.22631.0 .. lessThan 10.0.22631.7219), and those bounds pick the
# host's release on their own — which is why the OS path lives there.
_OS_REGISTRY: List[tuple] = [
(re.compile(r"ipad", re.I), {"key": "cpe:apple:ipados",
"cpe": "cpe:2.3:o:apple:ipados", "label": "Apple iPadOS"}),
(re.compile(r"ios|iphone", re.I), {"key": "cpe:apple:iphone_os",
"cpe": "cpe:2.3:o:apple:iphone_os", "label": "Apple iOS"}),
(re.compile(r"mac ?os|macos|mac_os|os x", re.I), {"key": "cpe:apple:macos",
"cpe": "cpe:2.3:o:apple:macos", "label": "Apple macOS"}),
]
_NAME_VER_RE = re.compile(r"(\d+\.\d+(?:\.\d+)*)")
def _effective_version(name: str, version: str, entry: dict) -> str:
"""Version to scan with. For name_ver products the real semantic version
is in the display name, not the (MSI-build) version field."""
if entry.get("name_ver"):
m = _NAME_VER_RE.search(name or "")
if m:
return m.group(1)
return version
def _resolve_os(os_name: str) -> Optional[dict]:
n = (os_name or "").lower()
for rx, e in _OS_REGISTRY:
if rx.search(n):
return e
return None
def _os_family(os_name: str) -> Optional[str]:
"""Map an asset OS string to a CPE target_sw token."""
n = (os_name or "").lower()
if "windows" in n:
return "windows"
if "ipad" in n:
return "ipados"
if "iphone" in n or "ios" in n:
return "iphone_os"
if "android" in n:
return "android"
if "mac" in n or "darwin" in n or "os x" in n:
return "macos"
if any(x in n for x in ("linux", "ubuntu", "debian", "centos", "red hat",
"rhel", "fedora", "suse", "alma", "rocky")):
return "linux"
return None
def _platform_ok(tsws, plat: Optional[str]) -> bool:
"""A CVE's matching cpeMatch target_sw must be platform-neutral (*) or
name the asset's OS. Stops desktop Firefox-on-Windows matching the
Firefox-for-iOS CPE (target_sw=iphone_os) and similar cross-platform FPs.
"""
if not tsws:
return True # unknown (e.g. pre-fix cache) → keep
if any(t in ("*", "-", "") for t in tsws):
return True
return bool(plat) and plat in tsws
def resolve_product(name: str) -> Optional[dict]:
n = (name or "").strip().lower()
if not n:
return None
for rx, entry in _REGISTRY:
m = rx.search(n)
if m:
e = dict(entry)
if e["kind"] == "osv" and not e.get("oname"):
# derive package name from the capture group when present
e["oname"] = (m.group(1) if m.groups() else n)
e["key"] = f"osv:{e['eco']}:{e['oname']}"
return e
return None
class _TransientNVD(Exception):
"""NVD was unreachable/throttled — caller must NOT cache the empty result."""
# ---------- version compare ----------
def _clean_version(v: str) -> Optional[str]:
"""Desktop version core, or None for distro/rpm/deb-style versions.
Linux package versions (epoch `1:3.2`, release tags `4.6.5-3.el8`,
`2.43.0.windows.1`) are Wazuh's job — pushing them at NVD produces
noise (503 storms) and false-positives. Only clean dotted-numeric
versions go to the scanner; a trailing build like `7.0.2 (34567)` is
trimmed to its core.
# ponytail: dotted-numeric only; if a real product ever uses a hyphenated
# version we want, special-case it then, not now.
"""
v = (v or "").strip()
if not v or ":" in v:
return None
core = re.split(r"[ (]", v, 1)[0]
return core if re.fullmatch(r"\d+(\.\d+)*", core) else None
def _vtuple(v: str) -> Optional[tuple]:
if not v:
return None
nums = re.findall(r"\d+", v)
if not nums:
return None
return tuple(int(x) for x in nums[:6])
def _vcmp(a: str, b: str) -> Optional[int]:
"""-1/0/1, or None when not comparable."""
ta, tb = _vtuple(a), _vtuple(b)
if ta is None or tb is None:
return None
n = max(len(ta), len(tb))
ta += (0,) * (n - len(ta))
tb += (0,) * (n - len(tb))
return (ta > tb) - (ta < tb)
def _in_range(installed: str, m: dict) -> bool:
"""True if installed version satisfies one NVD cpeMatch entry."""
sI, sE = m.get("versionStartIncluding"), m.get("versionStartExcluding")
eI, eE = m.get("versionEndIncluding"), m.get("versionEndExcluding")
if not any([sI, sE, eI, eE]):
# exact version in the CPE criteria (criteria[5]); equal only.
crit = m.get("criteria", "")
parts = crit.split(":")
ver = parts[5] if len(parts) > 5 else "*"
if ver in ("*", "-", ""):
return False # wildcard → would match everything → skip (FP)
return _vcmp(installed, ver) == 0
if sI is not None and (_vcmp(installed, sI) is None or _vcmp(installed, sI) < 0):
return False
if sE is not None and (_vcmp(installed, sE) is None or _vcmp(installed, sE) <= 0):
return False
if eI is not None and (_vcmp(installed, eI) is None or _vcmp(installed, eI) > 0):
return False
if eE is not None and (_vcmp(installed, eE) is None or _vcmp(installed, eE) >= 0):
return False
return True
# ---------- sources ----------
def _query_osv(eco: str, name: str, version: str) -> List[dict]:
try:
with httpx.Client(timeout=HTTP_TIMEOUT, headers={"User-Agent": "TrueVuln/1.0"}) as c:
r = c.post(OSV_QUERY_URL, json={"package": {"name": name, "ecosystem": eco}, "version": version})
if r.status_code != 200:
return []
data = r.json()
except (httpx.HTTPError, ValueError) as e:
logger.debug("OSV query failed %s/%s@%s: %s", eco, name, version, e)
return []
out = []
for v in data.get("vulns", []) or []:
cid = v.get("id", "")
aliases = v.get("aliases", []) or []
cve = cid if cid.upper().startswith("CVE-") else next((a for a in aliases if a.upper().startswith("CVE-")), None)
if not cve:
continue
out.append({"cve": cve.upper(), "cvss": None, "severity": None, "fixed": None})
return out
def _query_nvd_cpe(cpe: str, version: str) -> List[dict]:
api_key = os.getenv("NVD_API_KEY", "").strip()
headers = {"apiKey": api_key} if api_key else None
url = f"{NVD_CVE_API}?virtualMatchString={cpe}:{version}&resultsPerPage=200"
sleep = NVD_SLEEP_WITH_KEY if api_key else NVD_SLEEP_NO_KEY
items = None
for attempt in range(4):
try:
with httpx.Client(timeout=HTTP_TIMEOUT) as c:
r = c.get(url, headers=headers)
except httpx.HTTPError as e:
logger.debug("NVD cpe query error %s@%s: %s", cpe, version, e)
else:
if r.status_code == 200:
try:
items = r.json().get("vulnerabilities", []) or []
except ValueError:
items = []
break
if r.status_code not in (429, 502, 503, 504):
items = [] # permanent (e.g. 404/400) → genuinely empty, cacheable
break
logger.debug("NVD %s for %s@%s (try %d)", r.status_code, cpe, version, attempt + 1)
# NVD 2.0 throws 503 under load even with a key → back off generously.
time.sleep(max(3.0, sleep) * (attempt + 1))
time.sleep(sleep)
if items is None:
raise _TransientNVD(f"NVD unavailable for {cpe}@{version}")
prod_token = ":".join(cpe.split(":")[3:5]) # vendor:product
out = []
for it in items:
cve_obj = it.get("cve", {})
cve_id = cve_obj.get("id", "")
if not cve_id.upper().startswith("CVE-"):
continue
matched = False
fixed = None
tsws: set = set()
for cfg in cve_obj.get("configurations", []) or []:
for node in cfg.get("nodes", []) or []:
for m in node.get("cpeMatch", []) or []:
crit = m.get("criteria") or ""
if prod_token not in crit:
continue
if not m.get("vulnerable", True):
continue
if _in_range(version, m):
matched = True
parts = crit.split(":")
tsws.add(parts[9] if len(parts) > 9 else "*") # target_sw
fixed = fixed or m.get("versionEndExcluding")
if not matched:
continue
cvss, sev = _nvd_cvss(cve_obj)
out.append({"cve": cve_id.upper(), "cvss": cvss, "severity": sev,
"fixed": fixed, "tsw": sorted(tsws)})
return out
def _nvd_cvss(cve_obj: dict) -> Tuple[Optional[float], Optional[str]]:
metrics = cve_obj.get("metrics", {}) or {}
for key in ("cvssMetricV31", "cvssMetricV30", "cvssMetricV2"):
arr = metrics.get(key) or []
if arr:
d = arr[0].get("cvssData", {})
score = d.get("baseScore")
sev = (d.get("baseSeverity") or arr[0].get("baseSeverity") or "").lower() or None
try:
return (float(score) if score is not None else None), sev
except (TypeError, ValueError):
return None, sev
return None, None
# ---------- cache ----------
def _cache_get(db: Session, product_key: str, version: str) -> Optional[List[dict]]:
from app.models.app_cve_cache import AppCveCache
row = (db.query(AppCveCache)
.filter(AppCveCache.product_key == product_key, AppCveCache.version == version)
.first())
if not row or not row.fetched_at:
return None
if datetime.now() - row.fetched_at > CACHE_TTL:
return None
try:
return json.loads(row.cves or "[]")
except json.JSONDecodeError:
return None
def _cache_put(db: Session, product_key: str, version: str, cves: List[dict]) -> None:
from app.models.app_cve_cache import AppCveCache
row = (db.query(AppCveCache)
.filter(AppCveCache.product_key == product_key, AppCveCache.version == version)
.first())
if row:
row.cves = json.dumps(cves)
row.fetched_at = datetime.now()
else:
db.add(AppCveCache(product_key=product_key, version=version,
cves=json.dumps(cves), fetched_at=datetime.now()))
db.commit()
CACHE_PREFIX = "v2:" # bump → ignores pre-target_sw cache rows (stale FPs)
def lookup_cves(db: Session, entry: dict, version: str) -> List[dict]:
"""Cached (product, version) → CVE list."""
key = CACHE_PREFIX + entry["key"]
cached = _cache_get(db, key, version)
if cached is not None:
return cached
try:
if entry["kind"] == "osv":
cves = _query_osv(entry["eco"], entry["oname"], version)
else:
cves = _query_nvd_cpe(entry["cpe"], version)
except _TransientNVD as e:
logger.warning("app-cve: %s — not caching, will retry next run", e)
return []
_cache_put(db, key, version, cves)
return cves
# ---------- upsert + scan ----------
def _upsert(db: Session, asset, pkg_name: str, version: str, c: dict, new_ids: list,
touched: Optional[set] = None) -> None:
from app.models.vulnerability import Vulnerability, VulnerabilitySeverity, VulnerabilityStatus
sev_map = {"critical": VulnerabilitySeverity.critical, "high": VulnerabilitySeverity.high,
"medium": VulnerabilitySeverity.medium, "low": VulnerabilitySeverity.low,
"none": VulnerabilitySeverity.none}
cve_id = c["cve"].upper()
if touched is not None:
touched.add(cve_id)
existing = (db.query(Vulnerability)
.filter(Vulnerability.cve_id == cve_id, Vulnerability.asset_id == asset.id)
.first())
if existing:
existing.add_source("app-scan")
if not existing.package_name:
existing.package_name = pkg_name[:255]
# Re-detected with the CURRENT inventory version → refresh it. Fill-only
# left the first-ever version on the row (tester: Firefox showed
# 'Installed: 150.0.3' while 152.0.5 was on the box).
if version:
existing.package_version = version[:100]
# Backfill metric/fix data once better info arrives (e.g. cvelistV5 now
# carries the CVSS the first import lacked) — don't overwrite existing.
if existing.cvss_score is None and c.get("cvss") is not None:
existing.cvss_score = c["cvss"]
if c.get("fixed") and not existing.fixed_version:
existing.fixed_version = c["fixed"]
if c.get("severity") and existing.severity == VulnerabilitySeverity.medium:
existing.severity = sev_map.get(c["severity"].lower(), existing.severity)
if existing.status == VulnerabilityStatus.patched:
existing.status = VulnerabilityStatus.open
existing.patched_at = None
try:
existing.refresh_scores()
except Exception:
pass
return
sev = sev_map.get((c.get("severity") or "").lower(), VulnerabilitySeverity.medium)
row = Vulnerability(
cve_id=cve_id, asset_id=asset.id,
cvss_score=c.get("cvss"), severity=sev,
status=VulnerabilityStatus.open,
title=f"{pkg_name} {version}{cve_id}"[:500],
package_name=pkg_name[:255], package_version=version[:100],
fixed_version=(c.get("fixed") or None),
detected_at=datetime.now(),
sources=json.dumps(["app-scan"]), first_detected_by="app-scan",
)
db.add(row)
db.flush()
try:
row.refresh_scores()
except Exception:
pass
new_ids.append(row.id)
def _is_citrix_shim(pkg: dict) -> bool:
"""Citrix published-app delivery leaves a registry stub ('Firefox 1.0',
vendor 'Delivered by Citrix') for software that is NOT installed on the
box matching it produced ancient-CVE false positives (tester:
CVE-2008-2798 on 'Firefox 1.0'). The vendor field identifies the stub."""
vendor = (pkg.get("vendor") or "").lower()
return "citrix" in vendor
def scan_asset_packages(db: Session, asset, packages: list, new_ids: Optional[list] = None,
touched: Optional[set] = None) -> int:
"""Match an asset's installed software to CVEs (curated + precise).
Returns findings upserted. Caller commits."""
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:
continue
if _is_citrix_shim(pkg):
continue
entry = resolve_product(name)
if not entry:
continue
eff_ver = _effective_version(name, version, entry)
if not eff_ver:
continue
cver = _clean_version(eff_ver)
if not cver:
continue # distro/rpm version → Wazuh's domain, skip (no NVD noise)
key = (entry["key"], cver)
if key in seen:
continue
seen.add(key)
try:
cves = lookup_cves(db, entry, cver)
except Exception as e:
logger.debug("app-cve lookup failed for %s %s: %s", name, version, e)
continue
plat = _os_family(asset.operating_system or "")
for c in cves:
if not _platform_ok(c.get("tsw"), plat):
continue # CVE is for a different OS platform (e.g. Firefox-iOS)
try:
before = len(new_ids)
_upsert(db, asset, name, eff_ver, c, new_ids, touched=touched)
count += 1 if len(new_ids) > before else 0
except Exception as e:
logger.debug("app-cve upsert failed (%s on %s): %s", c.get("cve"), asset.id, e)
return count
def scan_asset_os(db: Session, asset, new_ids: list, touched: Optional[set] = None) -> int:
"""OS-level CVEs from asset.operating_system + asset.os_version
(iOS/iPadOS). Returns findings. Caller commits."""
e = _resolve_os(asset.operating_system or "")
if not e:
return 0
cver = _clean_version(asset.os_version or "")
if not cver:
return 0
try:
cves = lookup_cves(db, {"key": e["key"], "kind": "cpe", "cpe": e["cpe"]}, cver)
except Exception as ex:
logger.debug("app-cve OS lookup failed for %s: %s", asset.hostname, ex)
return 0
plat = _os_family(asset.operating_system or "")
count = 0
for c in cves:
if not _platform_ok(c.get("tsw"), plat):
continue
before = len(new_ids)
try:
_upsert(db, asset, e["label"], asset.os_version or cver, c, new_ids, touched=touched)
count += 1 if len(new_ids) > before else 0
except Exception as ex:
logger.debug("app-cve OS upsert failed (%s on %s): %s", c.get("cve"), asset.id, ex)
return count
def _resolve_stale_app_findings(db: Session, asset, touched_cves: set) -> int:
"""Reconcile OPEN app-scan findings not re-detected this run: DROP the
app-scan source (same contract as the Nessus backfill), and mark patched
only once no source is left. The old skip-if-cross-confirmed rule dead-
locked: a Chrome CVE seen by app-scan AND Defender was never closed by
either reconcile (each deferred to the other), so a patched host kept an
open cross-confirmed finding forever (tester: Chrome 150.0.7871.125
installed, fix .115, finding still open). Caller must have had a real
inventory this run."""
from app.models.vulnerability import Vulnerability, VulnerabilityStatus
rows = (db.query(Vulnerability)
.filter(Vulnerability.asset_id == asset.id,
Vulnerability.status == VulnerabilityStatus.open,
Vulnerability.sources.contains('"app-scan"'))
.all())
resolved = 0
for v in rows:
if v.cve_id in touched_cves:
continue
v.remove_source("app-scan")
if v.source_list:
continue # another scanner still reports it → stays open under theirs
old_status = v.status
v.status = VulnerabilityStatus.patched
v.patched_at = datetime.now()
resolved += 1
# Revisionssicher: one status-change row (feeds both the global audit
# log AND the per-CVE Change History). user_id=None = automated.
try:
from app.routers.vulnerabilities import log_vulnerability_change
log_vulnerability_change(
db, None, v.id, old_status, v.status,
reason=f"App CVE scan no longer detects this CVE on {asset.hostname} "
f"(software updated/removed past the vulnerable version)",
cve_id=v.cve_id,
source="app_scan",
)
except Exception as e:
logger.warning("audit log for app-scan auto-resolve failed (vuln_id=%s): %s", v.id, e)
return resolved
def run_app_cve_scan(db: Session, asset_id: Optional[int] = None) -> dict:
"""Scan all assets with software inventory (Wazuh packages + Intune
detectedApps) app-scan CVEs, then enrich the new ones."""
from app.models.asset import Asset, AssetSource
stats = {"assets": 0, "findings": 0, "new": 0, "errors": []}
new_ids: list = []
logger.info("App CVE scan starting: NVD key %s",
"present" if os.getenv("NVD_API_KEY", "").strip() else "MISSING (keyless = frequent 503)")
wazuh = None
graph = None
try:
from app.integrations.wazuh_client import WazuhClient
from app.auth.setting_crypto import read_setting_value
raw = read_setting_value(db, "wazuh_config")
if raw:
cfg = json.loads(raw)
if all([cfg.get("api_url"), cfg.get("username"), cfg.get("password")]):
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))
except Exception as e:
logger.debug("app-cve: wazuh client unavailable: %s", e)
try:
from app.services.intune_service import load_intune_config, _build_client
icfg = load_intune_config(db)
if icfg:
graph = _build_client(icfg)
except Exception as e:
logger.debug("app-cve: graph client unavailable: %s", e)
# cvelistV5 reverse index (curated products) — catches CVEs NVD hasn't
# CPE'd yet / filed under a different CPE product string. Cached; built by
# the nightly job. Absent → that pass is skipped (logged).
cve5_index = {}
try:
from app.services import cvelistv5_scan_service
cve5_index = cvelistv5_scan_service.load_index(db) or {}
if not cve5_index:
# No cached index → build it now so a manual scan works the same
# as the nightly job (downloads/walks the cvelistV5 ZIP — slow on
# first run, then cached + refreshed nightly).
logger.info("app-cve: cvelistV5 index missing — building now (one-time, may take a few minutes)")
cve5_index = cvelistv5_scan_service.build_product_index(db) or {}
except Exception as e:
logger.warning("app-cve: cvelistV5 index build/load failed: %s", e)
q = db.query(Asset)
if asset_id is not None:
q = q.filter(Asset.id == asset_id)
for asset in q.all():
touched = False
touched_cves: set = set() # every CVE re-detected this run → reconcile base
# OS-level CVEs (iOS/iPadOS) — version already in the DB, no client needed.
try:
n = scan_asset_os(db, asset, new_ids, touched=touched_cves)
if n:
stats["findings"] += n
touched = True
except Exception as e:
stats["errors"].append(f"asset {asset.id} os: {e}")
# Windows OS CVEs from cvelistV5 — family-matched and release-bounded
# (see scan_asset_os). Needs no software inventory, so it runs for every
# Windows asset.
if cve5_index:
try:
from app.services import cvelistv5_scan_service
n = cvelistv5_scan_service.scan_asset_os(
db, asset, cve5_index, new_ids, touched=touched_cves)
if n:
stats["findings"] += n
touched = True
except Exception as e:
stats["errors"].append(f"asset {asset.id} win-os: {e}")
# Package-level CVEs (Wazuh syscollector / Intune detectedApps).
packages: list = []
try:
if asset.wazuh_agent_id and wazuh:
packages = wazuh.get_packages(asset.wazuh_agent_id) or []
elif asset.intune_device_id and graph:
packages = graph.get_detected_apps(asset.intune_device_id) or []
except Exception as e:
stats["errors"].append(f"asset {asset.id}: {e}")
packages = []
if packages:
stats["findings"] += scan_asset_packages(db, asset, packages, new_ids, touched=touched_cves)
if cve5_index:
try:
from app.services import cvelistv5_scan_service
stats["findings"] += cvelistv5_scan_service.scan_asset(db, asset, packages, cve5_index, new_ids, touched=touched_cves)
except Exception as e:
stats["errors"].append(f"asset {asset.id} cvelistv5: {e}")
touched = True
# MSRC fixed-build scan for installed MS software (SharePoint):
# Microsoft ships no version ranges, so this is the only source that
# knows the patch state. Separate source ('msrc') + its own reconcile.
try:
from app.services import msrc_scan_service
m_index = msrc_scan_service.load_index(db)
if m_index:
m_touched: set = set()
stats["msrc_findings"] = stats.get("msrc_findings", 0) + \
msrc_scan_service.scan_asset_packages(
db, asset, packages, m_index, new_ids, touched=m_touched)
stats["msrc_resolved"] = stats.get("msrc_resolved", 0) + \
msrc_scan_service.resolve_stale_packages(db, asset, m_touched)
except Exception as e:
stats["errors"].append(f"asset {asset.id} msrc: {e}")
# Auto-resolve: an app-scan-only finding no longer re-detected means
# the software was updated/removed past it. Only safe when we had a
# real inventory this run (packages non-empty) — else we'd close
# everything on a transient fetch failure.
stats["resolved"] = stats.get("resolved", 0) + _resolve_stale_app_findings(db, asset, touched_cves)
if touched:
stats["assets"] += 1
db.commit()
if graph:
graph.close()
stats["new"] = len(new_ids)
# Enrich + audit the new app-scan CVEs.
if new_ids:
try:
from app.services.audit_events import audit_new_vulnerabilities
audit_new_vulnerabilities(db, new_ids, source="app-scan")
db.commit()
except Exception as e:
logger.debug("app-cve 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 — app-scan
# findings silently skipped notifications). Same digest 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("app-cve enrichment/notify failed: %s", e)
logger.info("App CVE scan: %d assets, %d findings (%d new, %d auto-resolved)",
stats["assets"], stats["findings"], stats["new"], stats.get("resolved", 0))
return stats
+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
+60
View File
@@ -0,0 +1,60 @@
"""
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
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
+688
View File
@@ -0,0 +1,688 @@
"""
cvelistV5-based CVE detection for installed software.
The NVD-CPE scanner (app_cve_scanner_service) misses CVEs when NVD hasn't
published a CPE yet (fresh CVEs) or files them under a different CPE product
string than we curated (e.g. TeamViewer CVE lives under teamviewer:remote,
not teamviewer:teamviewer). cvelistV5 the authoritative MITRE feed we
already cache as a ZIP carries clean affected[].vendor/product/version
ranges instead, so we match those directly.
Design (CURATED + PRECISE, same as the CPE scanner):
- A curated product registry maps an installed-software name the
cvelistV5 (vendor, product) pairs that identify it. Unknown software is
ignored (no fuzzy vendor/product guessing no FP storm).
- One pass over the cached cvelistV5 ZIP builds a reverse index
{product_key: [{cve, start, lt, lte}]} for the curated products only.
The index is cached in a Setting (refreshed on the nightly job) so the
557 MB walk happens once, not per scan.
- Per installed package: resolve look up indexed CVEs check the
installed version falls inside an affected range upsert (source
'app-scan', shared with the CPE scanner so the badge / cross-confirm /
enrichment all apply).
"""
from __future__ import annotations
import json
import logging
import os
import re
import time
import zipfile
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Tuple
from sqlalchemy.orm import Session
from app.services import app_cve_scanner_service as cpe
logger = logging.getLogger(__name__)
# Reuse the same ZIP the override service already downloads/caches (12h).
_ZIP_PATH = "/tmp/truevuln-cvelistv5-cache.zip"
_ZIP_URL = "https://github.com/CVEProject/cvelistV5/archive/refs/heads/main.zip"
_ZIP_TTL = 12 * 3600
_INDEX_SETTING = "cvelistv5_product_index_v7" # v7: modern .NET per-release keys
_INDEX_TTL = timedelta(hours=26) # rebuilt nightly; a missed night still serves
# Curated registry: name-regex (installed software) → cvelistV5 (vendor,
# product) pairs, lowercased. First regex match wins. Vendor/product are
# matched case-insensitively against affected[].vendor / .product.
# ponytail: curated; add a row when a product is missed — unknown names are
# skipped, never guessed.
_REGISTRY: List[dict] = [
{"key": "teamviewer", "re": r"teamviewer",
"pairs": [("teamviewer", "remote"), ("teamviewer", "tensor"),
("teamviewer", "host"), ("teamviewer", "full client"),
("teamviewer", "teamviewer")]},
{"key": "notepad++", "re": r"notepad\+\+",
"pairs": [("notepad-plus-plus", "notepad-plus-plus"), ("notepad++", "notepad++"),
("don ho", "notepad++")]},
{"key": "devolutions-rdm", "re": r"remote desktop manager|devolutions",
"pairs": [("devolutions", "remote desktop manager")]},
{"key": "7-zip", "re": r"7-?zip",
"pairs": [("7-zip", "7-zip"), ("igor pavlov", "7-zip")]},
{"key": "firefox", "re": r"mozilla firefox|(?<!\w)firefox",
"pairs": [("mozilla", "firefox")]},
{"key": "chrome", "re": r"google chrome|com\.android\.chrome",
"pairs": [("google", "chrome")]},
# SharePoint — ONE KEY PER RELEASE, deliberately. Every MS SharePoint range
# uses a generic 16.0.0 floor (checked across 82 recent CVEs), and 2016,
# 2019 and Subscription Edition all report 16.0.x, so a single shared key
# would let a 2016 install (16.0.5456) fall inside the 2019 range
# (16.0.0 .. 16.0.10417.20153) — the exact cross-release false positive the
# Windows OS scan just had. The release comes from the NAME; the range then
# only ever decides "patched or not" within that one release.
# 2013 IS here: it is EOL (2023-04-11) and absent from recent MSRC docs, but
# the CVE records from its supported years carry real fix builds
# (CVE-2023-23395: 15.0.0 .. 15.0.5537.1000), and an unpatched 2013 farm is
# behind all of them. Checking only recent MSRC docs is what made this look
# undetectable — the tester's Nessus finds these, and so should we.
{"key": "sharepoint-2013", "re": r"sharepoint.*\b2013\b",
"pairs": [("microsoft", "microsoft sharepoint foundation 2013 service pack 1"),
("microsoft", "microsoft sharepoint enterprise server 2013 service pack 1"),
("microsoft", "microsoft sharepoint server 2013 service pack 1"),
("microsoft", "microsoft sharepoint foundation 2013"),
("microsoft", "microsoft sharepoint enterprise server 2013"),
("microsoft", "microsoft sharepoint server 2013")]},
{"key": "sharepoint-se", "re": r"sharepoint.*subscription",
"pairs": [("microsoft", "microsoft sharepoint server subscription edition")]},
{"key": "sharepoint-2019", "re": r"sharepoint.*\b2019\b",
"pairs": [("microsoft", "microsoft sharepoint server 2019")]},
{"key": "sharepoint-2016", "re": r"sharepoint.*\b2016\b",
"pairs": [("microsoft", "microsoft sharepoint enterprise server 2016"),
("microsoft", "microsoft sharepoint server 2016")]},
# Modern .NET (8/9/10) — one key PER RELEASE (same reasoning as SharePoint:
# the record floors are generic release floors, so a shared key would
# cross-match releases). The real semantic version lives in the NAME
# ("Microsoft .NET Runtime - 8.0.16 (x64)"), which bumps with every monthly
# patch — the version FIELD is an MSI build → name_ver. SDKs are excluded:
# their numbering (8.0.1xx) never falls inside the runtime fix range and
# the runtime is installed alongside anyway.
# .NET FRAMEWORK is deliberately NOT here: its ARP version (4.8.04084) is
# STATIC across monthly patches (only file versions change), so comparing
# it against fix builds like 4.8.4803.0 would flag every install forever.
# Framework patch state is file/KB-based — Defender TVM covers it.
{"key": "dotnet-10", "name_ver": True,
"re": r"(?:microsoft \.net (?:desktop )?(?:runtime|host)|windows desktop runtime).*?\b10\.0\.",
"pairs": [("microsoft", ".net 10.0")]},
{"key": "dotnet-9", "name_ver": True,
"re": r"(?:microsoft \.net (?:desktop )?(?:runtime|host)|windows desktop runtime).*?\b9\.0\.",
"pairs": [("microsoft", ".net 9.0")]},
{"key": "dotnet-8", "name_ver": True,
"re": r"(?:microsoft \.net (?:desktop )?(?:runtime|host)|windows desktop runtime).*?\b8\.0\.",
"pairs": [("microsoft", ".net 8.0")]},
{"key": "edge", "re": r"microsoft edge(?!.*webview)",
"pairs": [("microsoft", "microsoft edge (chromium-based)"),
("microsoft", "edge (chromium-based)"), ("microsoft", "microsoft edge")]},
{"key": "vlc", "re": r"vlc media player|videolan",
"pairs": [("videolan", "vlc media player"), ("videolan", "vlc")]},
{"key": "putty", "re": r"(?<!\w)putty",
"pairs": [("putty", "putty"), ("simon tatham", "putty")]},
{"key": "winscp", "re": r"winscp", "pairs": [("winscp", "winscp"), ("martin prikryl", "winscp")]},
{"key": "wireshark", "re": r"wireshark", "pairs": [("wireshark", "wireshark")]},
{"key": "filezilla", "re": r"filezilla", "pairs": [("filezilla", "filezilla"), ("filezilla", "filezilla client")]},
{"key": "zoom", "re": r"(?<!\w)zoom(?!\w)", "pairs": [("zoom", "zoom"), ("zoom", "meetings"), ("zoom", "zoom client for meetings")]},
# Teams itself only — not the Office add-in / VDI / Citrix plugin. And
# only the Windows/desktop/generic product entries: the "for Mac" / mobile
# products are dropped so a Mac-only Teams CVE can't land on a Windows host
# (the per-scan platform filter is the general backstop, but not every CNA
# fills the platforms field, so scoping the pairs is belt-and-suspenders).
{"key": "teams", "re": r"microsoft teams(?!.*(machine-wide|add-in|plugin|vdi|citrix))",
"pairs": [("microsoft", "microsoft teams for desktop"),
("microsoft", "microsoft teams for windows"),
("microsoft", "microsoft teams"), ("microsoft", "teams")]},
]
_COMPILED = [(re.compile(e["re"], re.I), e) for e in _REGISTRY]
_KEY_ENTRY = {e["key"]: e for e in _REGISTRY}
# Reverse map (vendor_lc, product_lc) → product_key, for the index build.
_PAIR_TO_KEY: Dict[Tuple[str, str], str] = {}
for _e in _REGISTRY:
for _v, _p in _e["pairs"]:
_PAIR_TO_KEY[(_v.lower(), _p.lower())] = _e["key"]
# Pattern products — one curated key for a whole family whose CVE records name
# a product per RELEASE ("Windows 11 Version 23H2", "Windows Server 2025
# (Server Core installation)", ...), so an exact pair list would rot with every
# new release. The entry keeps its product NAME so the scan can tell the
# releases apart; the build line alone cannot (see _WIN_FAMILIES).
_PRODUCT_PATTERNS: List[dict] = [
{"key": "windows", "vendor_re": r"^microsoft$",
"product_re": r"^windows\s+(10|11|server)\b"},
]
# Asset OS string → which cvelistV5 product names it may match.
#
# This is the part that was missing and caused the cross-release FPs: Windows 11
# 24H2 and Windows Server 2025 both live on build line 10.0.26100 but keep
# SEPARATE revision sequences (24H2 fix .8655, Server 2025 fix .32860), so
# CVE-2026-41089 — Server 2025 only — matched a fully-patched 24H2 client.
# Deciding the family from the OS string first makes the build range a
# within-release "patched or not" test, which is all it can honestly answer.
# Order matters: "Windows Server" must be tested before the bare client names.
_WIN_FAMILIES: List[tuple] = [
(re.compile(r"windows\s+server", re.I), re.compile(r"^windows\s+server\b", re.I)),
(re.compile(r"windows\s*11", re.I), re.compile(r"^windows\s+11\b", re.I)),
(re.compile(r"windows\s*10", re.I), re.compile(r"^windows\s+10\b", re.I)),
]
def _win_family(os_name: str) -> Optional[re.Pattern]:
"""Asset OS string → regex the entry's product name must satisfy."""
n = (os_name or "").strip().lower()
for os_rx, prod_rx in _WIN_FAMILIES:
if os_rx.search(n):
return prod_rx
return None
_PATTERNS_COMPILED = [
(re.compile(p["vendor_re"], re.I), re.compile(p["product_re"], re.I), p["key"])
for p in _PRODUCT_PATTERNS
]
def _pair_key(vendor: str, product: str) -> Optional[str]:
"""(vendor, product) from a CVE record → curated product key."""
v, p = (vendor or "").strip().lower(), (product or "").strip().lower()
key = _PAIR_TO_KEY.get((v, p))
if key:
return key
for vrx, prx, k in _PATTERNS_COMPILED:
if vrx.search(v) and prx.search(p):
return k
return None
def resolve(name: str) -> Optional[str]:
n = (name or "").strip().lower()
if not n:
return None
for rx, e in _COMPILED:
if rx.search(n):
return e["key"]
return None
# ---------- CVSS base score ----------
def _cvss_from_record(data: dict) -> Tuple[Optional[float], Optional[str]]:
"""Pull (baseScore, baseSeverity) from a CVE-5 record. Prefers CVSS 3.1 >
3.0 > 4.0, and the CNA's own metrics over the CISA-ADP block. Returns
(None, None) when the record carries no score (many fresh CVEs) enrichment
can still backfill later."""
conts = data.get("containers") or {}
metric_blocks = [(conts.get("cna") or {}).get("metrics") or []]
for adp in (conts.get("adp") or []):
metric_blocks.append(adp.get("metrics") or [])
for field in ("cvssV3_1", "cvssV3_0", "cvssV4_0"):
for metrics in metric_blocks:
for m in metrics:
cv = m.get(field) if isinstance(m, dict) else None
if isinstance(cv, dict) and cv.get("baseScore") is not None:
try:
return float(cv["baseScore"]), (cv.get("baseSeverity") or "").lower() or None
except (TypeError, ValueError):
pass
return None, None
# ---------- affected[] range parsing ----------
def _ranges_from_affected(aff: dict) -> List[Tuple[Optional[str], Optional[str], Optional[str]]]:
"""→ [(version_start, lessThan, lessThanOrEqual)] for the affected entry.
Only ranges with a real upper bound are returned (exact-version-only and
unbounded entries are skipped no over-matching)."""
out = []
unaffected_floors: List[str] = []
for v in aff.get("versions", []) or []:
if not isinstance(v, dict):
continue
if (v.get("status") or "affected") != "affected":
# Some CNAs state the INVERSE — Mozilla ships no affected range at
# all, only "152.0.6 and up are unaffected" (status=unaffected,
# lessThanOrEqual="*"). Skipping those meant the newest Firefox CVEs
# never entered the index. "X and up are fixed" == "below X is
# affected", so remember X as a fix bound.
if (v.get("status") == "unaffected"
and not v.get("lessThan")
and (v.get("lessThanOrEqual") in ("*", None))):
ver = v.get("version")
if isinstance(ver, str) and ver.strip() not in ("0", "*", "-", ""):
unaffected_floors.append(ver.strip())
continue
start = None
lt = v.get("lessThan")
lte = v.get("lessThanOrEqual")
ver = v.get("version")
if isinstance(ver, str):
vs = ver.strip()
if vs.startswith("<="):
lte = lte or vs[2:].strip()
elif vs.startswith("<"):
lt = lt or vs[1:].strip()
elif vs not in ("0", "*", "-", ""):
start = vs
if lt in ("*", "-", ""):
lt = None
if lte in ("*", "-", ""):
lte = None
if start is not None and (start == lt or start == lte):
# Some CNAs emit "version" == "lessThan" (e.g. a few Chrome
# records) — a literal reading gives a zero-width, impossible
# range. NVD treats these as an open floor (no lower bound); do
# the same rather than silently dropping the CVE.
start = None
if lt or lte:
out.append((start, lt, lte))
# Inverse-only records (see above): derive the fix bound from the single
# "unaffected" floor. Only when the entry stated no affected range of its
# own, and only when there is exactly one floor — several floors mean
# several servicing branches (release vs ESR) and picking one would either
# over- or under-report.
if not out and len(unaffected_floors) == 1:
out.append((None, unaffected_floors[0], None))
return out
# Recognised OS names in cvelistV5 affected[].platforms. Microsoft also uses
# that field for CPU arch ("x64-based Systems", "ARM64-based Systems") — those
# are NOT OS names, so when a record lists only arch/hardware we can't judge
# the OS and must keep the finding (never drop on arch alone).
_OS_PLATFORMS = {
"windows": {"windows"},
"macos": {"macos", "mac os", "mac os x", "os x", "mac"},
"linux": {"linux"},
"iphone_os": {"ios"},
"ipados": {"ipados", "ios"},
"android": {"android"},
}
_ALL_OS_TOKENS = {tok for toks in _OS_PLATFORMS.values() for tok in toks}
def _platform_ok(asset_family: Optional[str], platforms) -> bool:
"""Keep a finding unless the CVE explicitly lists OS platform(s) and the
asset's OS isn't among them. Records with no platforms, or only
arch/hardware platforms (x64/ARM/), are kept (can't judge the OS)."""
if not asset_family or not platforms:
return True
listed = {str(p).lower().strip() for p in platforms}
os_listed = listed & _ALL_OS_TOKENS
if not os_listed:
return True # arch/hardware only → not an OS signal
return bool(_OS_PLATFORMS.get(asset_family, set()) & os_listed)
def _affected(installed: str, start: Optional[str], lt: Optional[str], lte: Optional[str]) -> bool:
if start:
c = cpe._vcmp(installed, start)
if c is None or c < 0:
return False
if lt:
c = cpe._vcmp(installed, lt)
return c is not None and c < 0
if lte:
c = cpe._vcmp(installed, lte)
return c is not None and c <= 0
return False
# ---------- index build + cache ----------
def _ensure_zip() -> bool:
fresh = (os.path.exists(_ZIP_PATH)
and (time.time() - os.path.getmtime(_ZIP_PATH)) < _ZIP_TTL
and os.path.getsize(_ZIP_PATH) > 100_000_000)
if fresh:
return True
import httpx
tmp = _ZIP_PATH + ".part"
try:
logger.info("cvelistv5-scan: downloading ZIP snapshot (~557 MB)…")
with httpx.Client(timeout=httpx.Timeout(600.0, connect=15.0), follow_redirects=True) as c:
with c.stream("GET", _ZIP_URL) as r:
r.raise_for_status()
with open(tmp, "wb") as f:
for chunk in r.iter_bytes(chunk_size=1024 * 512):
f.write(chunk)
os.replace(tmp, _ZIP_PATH)
return True
except Exception as e:
logger.warning("cvelistv5-scan: ZIP download failed: %s", e)
if os.path.exists(tmp):
try:
os.remove(tmp)
except Exception:
pass
return False
def build_product_index(db: Session) -> dict:
"""Walk the cvelistV5 ZIP once, building {product_key: [{cve,start,lt,lte}]}
for the curated products. Heavy (~250k files) call from the nightly job.
Caches the result in a Setting. Returns the index."""
if not _ensure_zip():
return load_index(db) or {}
index: Dict[str, list] = {}
seen: set = set()
scanned = 0
parsed = 0
# Pre-filter on raw bytes: only JSON-parse files that mention a curated
# vendor. ~99% of the 250k CVEs don't, so this skips that many json.loads
# (the expensive part) — build drops from minutes to ~a minute.
vendor_bytes = {v.encode() for (v, _p) in _PAIR_TO_KEY.keys()}
with zipfile.ZipFile(_ZIP_PATH) as zf:
for name in zf.namelist():
if not name.endswith(".json") or "/cves/" not in name:
continue
scanned += 1
try:
raw = zf.read(name)
except Exception:
continue
low = raw.lower()
if not any(vb in low for vb in vendor_bytes):
continue
try:
data = json.loads(raw)
except Exception:
continue
parsed += 1
cna = (data.get("containers") or {}).get("cna") or {}
affected = cna.get("affected") or []
if not affected:
continue
cve_id = ((data.get("cveMetadata") or {}).get("cveId") or "").upper()
if not cve_id.startswith("CVE-"):
continue
cvss, sev = _cvss_from_record(data)
for aff in affected:
key = _pair_key(aff.get("vendor") or "", aff.get("product") or "")
if not key:
continue
plats = [str(p).lower().strip() for p in (aff.get("platforms") or [])]
prod = (aff.get("product") or "").strip()
for start, lt, lte in _ranges_from_affected(aff):
sig = (key, cve_id, start, lt, lte, prod.lower())
if sig in seen:
continue
seen.add(sig)
index.setdefault(key, []).append(
{"cve": cve_id, "start": start, "lt": lt, "lte": lte,
"plats": plats, "cvss": cvss, "sev": sev, "prod": prod})
_store_index(db, index)
logger.info("cvelistv5-scan: index built (%d files scanned, %d parsed) → %d products, %d ranges",
scanned, parsed, 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="cvelistV5 product→CVE reverse 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 ----------
# ---------- false-positive suppression ----------
_FP_STOP = {"setup", "edition", "en", "english", "x64", "x86", "cu", "gdr",
"for", "based", "systems", "the", "of", "and", "client", "full",
"host", "version", "core", "server"}
def _sig_tokens(s: str) -> set:
"""Significant tokens: drop stopwords, 4-digit years, and bare numbers."""
out = set()
for t in re.findall(r"[a-z0-9]+", (s or "").lower()):
if t in _FP_STOP or re.fullmatch(r"(19|20)\d{2}", t) or re.fullmatch(r"\d+", t):
continue
out.add(t)
return out
def _product_matches(installed_name: str, vendor: str, product: str) -> bool:
"""True when the CVE's affected product is the same product family as the
installed package. Requires 2 shared significant tokens so only
multi-word products (SQL Server, Visual Studio, ) where Wazuh's loose
CPE match over-reports across editions are ever scoped for suppression;
single-token apps never match their findings are left untouched."""
a = _sig_tokens(f"{vendor} {product}")
b = _sig_tokens(installed_name)
return len(a & b) >= 2
def _zip_path_for(cve_id: str) -> Optional[str]:
m = re.match(r"^CVE-(\d{4})-(\d+)$", 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"
def suppress_false_positives(db: Session, asset_id: Optional[int] = None) -> dict:
"""Auto-flag Wazuh findings whose installed version is provably OUTSIDE
all cvelistV5 affected ranges for the matched product (e.g. a SQL Server
2019 / 15.x host carrying a CVE that only affects 16.x/17.x because Wazuh
matched 'Microsoft SQL Server' too loosely).
Conservative only marks when ALL relevant affected entries have clean
numeric ranges and the install is outside every one. Sets
status=false_positive (reversible, audit-logged); never auto-unmarks.
"""
from app.models.vulnerability import Vulnerability, VulnerabilityStatus
stats = {"checked": 0, "suppressed": 0, "errors": []}
if not _ensure_zip():
stats["errors"].append("cvelistV5 ZIP unavailable")
return stats
q = (db.query(Vulnerability)
.filter(Vulnerability.status == VulnerabilityStatus.open,
Vulnerability.cve_id.like("CVE-%"),
Vulnerability.sources.contains('"wazuh"'),
Vulnerability.package_name.isnot(None),
Vulnerability.package_version.isnot(None)))
if asset_id is not None:
q = q.filter(Vulnerability.asset_id == asset_id)
candidates = q.all()
if not candidates:
return stats
suppressed_ids: list = []
with zipfile.ZipFile(_ZIP_PATH) as zf:
names = set(zf.namelist())
for v in candidates:
stats["checked"] += 1
cver = cpe._clean_version(v.package_version or "")
if not cver:
continue
path = _zip_path_for(v.cve_id)
if not path or path not in names:
continue # CVE not in snapshot → can't judge → keep
try:
data = json.loads(zf.read(path))
except Exception:
continue
affected = ((data.get("containers") or {}).get("cna") or {}).get("affected") or []
relevant = [a for a in affected
if _product_matches(v.package_name, a.get("vendor") or "", a.get("product") or "")]
if not relevant:
continue # CVE doesn't clearly name this product → keep
ranges = []
uncertain = False
for a in relevant:
rs = _ranges_from_affected(a)
if not rs:
uncertain = True # an entry we can't bound → don't risk it
break
ranges.extend(rs)
if uncertain or not ranges:
continue
if any(_affected(cver, s, lt, lte) for s, lt, lte in ranges):
continue # installed IS in an affected range → real, keep
# Outside every clean range → false positive.
v.status = VulnerabilityStatus.false_positive
v.notification_suppressed = True
rng = "; ".join(f"[{s or '0'}, {lt or lte})" for s, lt, lte in ranges)
v.defer_reason = (f"[auto] installed {v.package_version} is outside all "
f"cvelistV5 affected ranges for this product ({rng})")[:500]
suppressed_ids.append(v.id)
stats["suppressed"] += 1
if suppressed_ids:
db.commit()
logger.info("cvelistV5 FP-suppression: checked %d, suppressed %d",
stats["checked"], stats["suppressed"])
return stats
def _build_line(v: Optional[str]) -> Optional[tuple]:
"""First three segments of a Windows build — the release line
(10.0.14393.9234 (10,0,14393) = Server 2016 / Win10 1607)."""
t = cpe._vtuple(v or "")
return t[:3] if t and len(t) >= 3 else None
def _release_bounded(start: Optional[str], lt: Optional[str]) -> bool:
"""True when a range actually pins ONE Windows release, i.e. its floor and
its fix sit on the same build line (10.0.22631.0 .. 10.0.22631.7219).
Modern MS records do this; older ones (pre-2022) use a generic floor
CVE-2021-26432 says `version 10.0.0, lessThan 10.0.17763.2114` for Server
2019, and that range swallows EVERY lower build, so a Server 2016 host
(14393.9234) got flagged with a 17763 fix. Such an entry carries no release
information at all, so the OS scan must skip it rather than guess: the OS
string alone can't name the client release, which is the whole reason we
lean on the bounds.
"""
sl, ll = _build_line(start), _build_line(lt)
return bool(sl and ll and sl == ll)
def scan_asset_os(db: Session, asset, index: dict,
new_ids: Optional[list] = None, touched: Optional[set] = None) -> int:
"""Windows OS CVEs straight from the asset's build (asset.os_version).
Two guards, both learned the hard way either one alone is not enough:
1. FAMILY (_win_family): the entry's product name must belong to the same
family as the asset. 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) otherwise matches a fully-patched 24H2
client at .8655.
2. RELEASE-BOUNDED (_release_bounded): the range's floor and fix must sit on
one build line. Older records use a generic 10.0.0 floor, which swallows
every lower build (CVE-2021-26432 put a 17763 fix on a 14393 host).
With both, the range only ever answers "patched or not" inside the host's
own release which is all it can honestly answer.
"""
if not index:
return 0
entries = index.get("windows") or []
if not entries:
return 0
if cpe._os_family(asset.operating_system or "") != "windows":
return 0
fam_rx = _win_family(asset.operating_system or "")
if not fam_rx:
return 0 # Windows flavour we can't place (e.g. bare "Windows") → skip
cver = cpe._clean_version(asset.os_version or "")
if not cver:
return 0
if new_ids is None:
new_ids = []
label = (asset.operating_system or "Microsoft Windows").strip()
count = 0
for entry in entries:
if not fam_rx.search((entry.get("prod") or "").strip()):
continue # different Windows family → its revisions are unrelated
if not _release_bounded(entry.get("start"), entry.get("lt")):
continue # range can't tell releases apart → would cross-match
if not _affected(cver, entry.get("start"), entry.get("lt"), entry.get("lte")):
continue
c = {"cve": entry["cve"], "cvss": entry.get("cvss"), "severity": entry.get("sev"),
"fixed": entry.get("lt")}
try:
before = len(new_ids)
cpe._upsert(db, asset, label, asset.os_version or cver, c, new_ids, touched=touched)
count += 1 if len(new_ids) > before else 0
except Exception as e:
logger.debug("cvelistv5 OS upsert failed (%s on %s): %s", entry["cve"], asset.id, e)
return count
def scan_asset(db: Session, asset, packages: list, index: dict,
new_ids: Optional[list] = None, touched: Optional[set] = None) -> int:
"""Match an asset's installed software against the cvelistV5 index.
Returns findings upserted. Caller commits."""
if not index:
return 0
if new_ids is None:
new_ids = []
count = 0
seen: set = set()
fam = cpe._os_family(asset.operating_system or "")
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 # published-app registry stub, software not on the box
key = resolve(name)
if not key or key not in index:
continue
# name_ver products (modern .NET): the semantic version lives in the
# display NAME; the version field is an MSI build that never matches.
eff_ver = cpe._effective_version(name, version, _KEY_ENTRY.get(key) or {})
cver = cpe._clean_version(eff_ver)
if not cver:
continue
dedup = (key, cver)
if dedup in seen:
continue
seen.add(dedup)
for entry in index[key]:
if not _affected(cver, entry.get("start"), entry.get("lt"), entry.get("lte")):
continue
if not _platform_ok(fam, entry.get("plats")):
continue # CVE is for a different OS platform (e.g. Teams-for-Mac)
# Only lessThan is a real fix target; lessThanOrEqual means that
# version is still affected (no published fix) → leave fixed empty.
c = {"cve": entry["cve"], "cvss": entry.get("cvss"), "severity": entry.get("sev"),
"fixed": entry.get("lt")}
try:
before = len(new_ids)
cpe._upsert(db, asset, name, eff_ver, c, new_ids, touched=touched)
count += 1 if len(new_ids) > before else 0
except Exception as e:
logger.debug("cvelistv5 upsert failed (%s on %s): %s", entry["cve"], asset.id, e)
return count
if __name__ == "__main__":
# ponytail: one self-check for the metric parser (CNA v3.1 preferred, ADP
# fallback, missing → None). Run: python -m app.services.cvelistv5_scan_service
rec = {"containers": {"cna": {"metrics": [{"cvssV3_1": {"baseScore": 4.3, "baseSeverity": "MEDIUM"}}]},
"adp": [{"metrics": [{"cvssV3_1": {"baseScore": 9.8, "baseSeverity": "CRITICAL"}}]}]}}
assert _cvss_from_record(rec) == (4.3, "medium"), _cvss_from_record(rec) # CNA wins
assert _cvss_from_record({"containers": {"adp": [{"metrics": [{"cvssV3_0": {"baseScore": 7.5}}]}]}}) == (7.5, None)
assert _cvss_from_record({"containers": {"cna": {}}}) == (None, None) # no metrics
print("cvelistv5 _cvss_from_record self-check OK")
+257
View File
@@ -0,0 +1,257 @@
"""
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) -> 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()
)
if existing:
existing.add_source(SOURCE_NAME)
# Fill the affected-software/package column if it was empty.
if software and not existing.package_name:
existing.package_name = software[:255]
if existing.status == VulnerabilityStatus.patched:
existing.status = VulnerabilityStatus.open
existing.patched_at = None
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=(software[:255] if software 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
except Exception as e:
logger.debug("defender software map build failed: %s", e)
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)
software = sw_map.get((m.get("id", ""), cve))
_upsert_cve(db, asset, v, new_ids, software=software)
stats["cve_rows"] += 1
# Auto-resolve defender-only findings this machine no longer reports.
# Guarded to non-empty responses so a transient/clean read can't
# mass-close (same safety as the Nessus/app-scan backfills).
if seen_cves:
stats["resolved"] = stats.get("resolved", 0) + _resolve_stale(db, asset, seen_cves)
except Exception as e:
stats["errors"].append(f"machine {m.get('computerDnsName')}: {e}")
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
+242 -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 = []
@@ -600,7 +840,8 @@ def enrich_vulnerabilities(
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']}"
)
return stats
+131 -1
View File
@@ -73,6 +73,17 @@ _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",
@@ -108,11 +119,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 +169,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 +348,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 +601,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,
*,
@@ -636,6 +708,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 +732,61 @@ 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()
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
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)
return count
+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
+38 -15
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.
@@ -130,7 +135,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:
+316
View File
@@ -0,0 +1,316 @@
"""
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):
# Backfill both ids on whatever we matched, so future syncs (and the
# Defender sync) converge on this one asset.
if device_id and not a.intune_device_id:
a.intune_device_id = device_id
if aad_id and not a.aad_device_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()
+603
View File
@@ -0,0 +1,603 @@
"""
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 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)
if existing.status == VulnerabilityStatus.patched:
existing.status = VulnerabilityStatus.open
existing.patched_at = None
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)
# ============================================================
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)
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
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
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"]:
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())
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
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
count = 0
touched: set = set()
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 or not is_m365_apps(name):
continue
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"]:
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())
except Exception as e:
logger.warning("M365-for-packages upsert failed (%s on asset %s): %s", cve_id, asset.id, e)
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
+248
View File
@@ -0,0 +1,248 @@
"""
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()
if existing.status == VulnerabilityStatus.patched:
existing.status = VulnerabilityStatus.open
existing.patched_at = None
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
+290
View File
@@ -0,0 +1,290 @@
"""
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
# ---------- 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."""
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": "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:
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:
raise MSLifecycleError(f"could not fetch MS lifecycle export: {e}") from e
if not rows:
raise MSLifecycleError("MS lifecycle export parsed to zero rows")
_store_cache(db, rows)
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
+476
View File
@@ -0,0 +1,476 @@
"""
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"
_INDEX_SETTING = "msrc_product_index_v1"
_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"},
]
_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+)+$")
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()
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})
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
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
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)
if not existing.package_name:
existing.package_name = product[:255]
if not existing.package_version:
existing.package_version = installed[:100]
if not existing.fixed_version:
existing.fixed_version = fixed
if existing.status == VulnerabilityStatus.patched:
existing.status = VulnerabilityStatus.open
existing.patched_at = None
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,
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()
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"'),
Vulnerability.package_name.in_(list(considered)))
.all())
resolved = 0
for v in rows:
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 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, {prod["label"]})
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()
+172 -25
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.
@@ -322,7 +390,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 +420,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 +467,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 +544,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 +571,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,6 +720,19 @@ 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
@@ -677,6 +763,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 +919,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 +960,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 +977,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
+137
View File
@@ -0,0 +1,137 @@
"""
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()
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()
if proto == "tcp" and state and state != "listening":
continue
try:
port = int(p.get("local_port") or (p.get("local") or {}).get("port") or 0)
except (ValueError, TypeError):
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)
+84 -2
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]:
@@ -814,7 +814,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 +910,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
-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.
+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>
);
}
+241 -53
View File
@@ -4,7 +4,7 @@ import { useEffect, useState } from 'react';
import api from '../../lib/api';
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 +14,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 +62,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 +100,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 +116,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 +230,32 @@ 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}`);
const d = res.data || {};
alert(`App CVE re-scan done for ${asset.hostname}: ${d.findings ?? 0} findings (${d.new ?? 0} new, ${d.resolved ?? 0} auto-resolved).`);
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 +340,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 +361,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 +372,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 +410,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 +488,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 +497,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 +507,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 +516,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 +527,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 +538,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 +551,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 +569,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 +589,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 +597,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 +612,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 +659,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 +701,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 +717,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 +735,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 +750,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 +759,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 +805,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 +871,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 +904,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)}`}>
+1 -1
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;
+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>
+1 -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",
+7 -7
View File
@@ -99,9 +99,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 +152,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 +170,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 +185,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 +223,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 +246,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
+196 -45
View File
@@ -22,8 +22,21 @@ function renderVulnWidget(opts: {
vulns: Vulnerability[];
onRowClick: (cveId: string) => void;
viewAllHref?: string;
// EOL widget: the pseudo-CVE id ("EOL-CHROME-148") is noise — show the
// product/package name in the first column instead, with a "PRODUCT"
// header. Falls back to cve_id when no package name.
labelField?: 'cve_id' | 'package_name';
firstColHeader?: string;
// EOL widget: replace the (often empty) CPR column with the affected
// asset's hostname, linked to that asset's EOL findings.
assetColumn?: boolean;
}) {
const { title, subtitle, vulns, onRowClick, viewAllHref } = opts;
const labelField = opts.labelField || 'cve_id';
const firstColHeader = opts.firstColHeader || 'CVE';
const assetColumn = opts.assetColumn || false;
const firstColLabel = (v: Vulnerability) =>
labelField === 'package_name' ? (v.package_name || v.cve_id) : v.cve_id;
const prioCls = (p: number | null | undefined) =>
p == null ? 'text-gray-400'
: p >= 80 ? 'text-red-700 font-bold'
@@ -34,31 +47,43 @@ function renderVulnWidget(opts: {
: c >= 50 ? 'text-red-700 font-bold'
: c >= 20 ? 'text-orange-700' : 'text-gray-600';
return (
<div className="bg-white border border-gray-200 shadow-sm rounded-sm overflow-hidden">
<div className="bg-white border border-gray-200 shadow-sm rounded-sm overflow-hidden flex flex-col">
<div className="p-4 border-b border-gray-200 flex justify-between items-baseline bg-gray-50/50">
<div>
<h3 className="text-base font-bold text-gray-900 font-mono">{title}</h3>
<p className="text-[11px] text-gray-500 font-mono mt-0.5">{subtitle}</p>
</div>
<Link href={viewAllHref || "/vulnerabilities"} prefetch className="text-vulncheck-blue text-[10px] font-bold uppercase tracking-wider font-mono hover:text-blue-700">View All &gt;</Link>
<Link href={viewAllHref || "/vulnerabilities"} prefetch className="text-truevuln-blue text-[10px] font-bold uppercase tracking-wider font-mono hover:text-blue-700">View All &gt;</Link>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<div className="overflow-x-auto flex-1">
<table className="min-w-full divide-y divide-gray-200 table-fixed">
<colgroup>
<col className="w-[34%]" />
<col className="w-[12%]" />
<col className="w-[10%]" />
<col className="w-[12%]" />
<col className="w-[12%]" />
<col className="w-[20%]" />
</colgroup>
<thead className="bg-gray-50">
<tr>
<th className="px-3 py-2 text-left text-[10px] font-mono font-medium text-gray-500 uppercase">CVE</th>
<th className="px-3 py-2 text-left text-[10px] font-mono font-medium text-gray-500 uppercase">Sev</th>
<th className="px-3 py-2 text-left text-[10px] font-mono font-medium text-gray-500 uppercase" title="CVSS Base Score">CVSS</th>
<th className="px-3 py-2 text-left text-[10px] font-mono font-medium text-gray-500 uppercase" title="Priority Score 0-100">PRIO</th>
<th className="px-3 py-2 text-left text-[10px] font-mono font-medium text-gray-500 uppercase" title="CPR 0-100 (JacquesKruger)">CPR</th>
<th className="px-3 py-2 text-left text-[10px] font-mono font-medium text-gray-500 uppercase">Flags</th>
<th className="px-2 py-2 text-left text-[10px] font-mono font-medium text-gray-500 uppercase">{firstColHeader}</th>
<th className="px-2 py-2 text-left text-[10px] font-mono font-medium text-gray-500 uppercase">Sev</th>
<th className="px-2 py-2 text-left text-[10px] font-mono font-medium text-gray-500 uppercase" title="CVSS Base Score">CVSS</th>
<th className="px-2 py-2 text-left text-[10px] font-mono font-medium text-gray-500 uppercase" title="Priority Score 0-100">PRIO</th>
{assetColumn
? <th className="px-2 py-2 text-left text-[10px] font-mono font-medium text-gray-500 uppercase" title="Affected asset — click for all its EOL findings">Asset</th>
: <th className="px-2 py-2 text-left text-[10px] font-mono font-medium text-gray-500 uppercase" title="CPR 0-100 (JacquesKruger)">CPR</th>}
<th className="px-2 py-2 text-left text-[10px] font-mono font-medium text-gray-500 uppercase">Flags</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200 font-mono text-xs">
{vulns.map((vuln) => (
<tr key={vuln.id} onClick={() => onRowClick(vuln.cve_id)} className="cursor-pointer hover:bg-gray-50 transition-colors">
<td className="px-3 py-2 whitespace-nowrap font-bold text-indigo-600 hover:text-indigo-800">{vuln.cve_id}</td>
<td className="px-3 py-2 whitespace-nowrap">
<td className="px-2 py-2 font-bold text-indigo-600 hover:text-indigo-800">
<div className="truncate" title={`${firstColLabel(vuln)} · ${vuln.cve_id}`}>{firstColLabel(vuln)}</div>
</td>
<td className="px-2 py-2">
<span className={`inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-bold ${vuln.severity === 'critical' ? 'bg-red-100 text-red-700'
: vuln.severity === 'high' ? 'bg-orange-100 text-orange-700'
: vuln.severity === 'medium' ? 'bg-blue-100 text-blue-700' : 'bg-green-100 text-green-700'
@@ -66,14 +91,29 @@ function renderVulnWidget(opts: {
{vuln.severity.toUpperCase().slice(0, 4)}
</span>
</td>
<td className="px-3 py-2 whitespace-nowrap font-bold">{vuln.cvss_score ?? '—'}</td>
<td className={`px-3 py-2 whitespace-nowrap ${prioCls(vuln.priority_score ?? null)}`}>
<td className="px-2 py-2 font-bold">{vuln.cvss_score ?? '—'}</td>
<td className={`px-2 py-2 ${prioCls(vuln.priority_score ?? null)}`}>
{vuln.priority_score == null ? '—' : vuln.priority_score.toFixed(0)}
</td>
<td className={`px-3 py-2 whitespace-nowrap ${cprCls(vuln.cpr_score ?? null)}`}>
{vuln.cpr_score == null ? '—' : vuln.cpr_score.toFixed(1)}
</td>
<td className="px-3 py-2 whitespace-nowrap">
{assetColumn ? (
<td className="px-2 py-2">
{vuln.asset_id ? (
<Link
href={`/vulnerabilities?asset_id=${vuln.asset_id}&eol=1`}
onClick={(e) => e.stopPropagation()}
className="text-indigo-600 hover:text-indigo-800 hover:underline truncate block"
title={`All EOL findings on ${vuln.asset_hostname || `asset #${vuln.asset_id}`}`}
>
{vuln.asset_hostname || `#${vuln.asset_id}`}
</Link>
) : '—'}
</td>
) : (
<td className={`px-2 py-2 ${cprCls(vuln.cpr_score ?? null)}`}>
{vuln.cpr_score == null ? '—' : vuln.cpr_score.toFixed(1)}
</td>
)}
<td className="px-2 py-2">
<div className="flex gap-1 flex-wrap">
{vuln.kev_listed && <span className="inline-flex items-center rounded bg-red-100 px-1 py-0.5 text-[9px] font-bold text-red-700">KEV</span>}
{vuln.euvd_listed && <span className="inline-flex items-center rounded bg-blue-100 px-1 py-0.5 text-[9px] font-bold text-blue-700">EUVD</span>}
@@ -105,6 +145,12 @@ export default function Dashboard() {
const [loading, setLoading] = useState(true);
const [aiLoading, setAiLoading] = useState(false);
const [aiError, setAiError] = useState<string | null>(null);
// AI audit hits an editor-gated endpoint → hide the controls from read-only.
const [userRole, setUserRole] = useState('');
useEffect(() => {
api.get('/auth/me').then(r => setUserRole(r.data?.role || '')).catch(() => { });
}, []);
const canEdit = userRole === 'admin' || userRole === 'editor';
const [aiSeverity, setAiSeverity] = useState<string>('all');
const [aiAbortController, setAiAbortController] = useState<AbortController | null>(null);
const [showHistory, setShowHistory] = useState(false);
@@ -196,15 +242,18 @@ export default function Dashboard() {
// dashboard surface (tester request).
const [criticalVulns, setCriticalVulns] = useState<Vulnerability[]>([]);
const [eolVulns, setEolVulns] = useState<Vulnerability[]>([]);
const [mobileVulns, setMobileVulns] = useState<Vulnerability[]>([]);
const [kevAdvisories, setKevAdvisories] = useState<any[]>([]);
useEffect(() => {
const fetchData = async () => {
try {
const [statsRes, vulnsRes, criticalRes, eolRes, schedRes, compRes, ursRes] = await Promise.all([
const [statsRes, vulnsRes, criticalRes, eolRes, mobileRes, kevRes, schedRes, compRes, ursRes] = await Promise.all([
api.get('/api/v1/vulnerabilities/reports/dashboard'),
// Newly Published: sort by published_date desc, wide window
// so client-side dedup still surfaces 10 distinct CVEs.
api.get('/api/v1/vulnerabilities?limit=80&status=open&sort_by=published_date&sort_order=desc'),
// Newly Published: sort by published_date desc. distinct_cve=true
// collapses per-asset duplicates server-side so we reliably get 10
// distinct CVEs (client dedup alone starved when a CVE hit N assets).
api.get('/api/v1/vulnerabilities?limit=12&status=open&sort_by=published_date&sort_order=desc&distinct_cve=true'),
// Recent Critical: CVSS ≥ 8 OR KEV OR EUVD, sort by PRIORITY
// desc. Was updated_at — but after a bulk EOL/CVSS/exploit
// run every row's updated_at is fresh, so the widget showed
@@ -213,9 +262,19 @@ export default function Dashboard() {
api.get('/api/v1/vulnerabilities?limit=80&status=open&sort_by=priority&sort_order=desc')
.catch(() => ({ data: { items: [] } })),
// EOL / EOS: pseudo-CVEs from endoflife.date check (cve_id
// starts with "EOL-"). Sort by detected_at desc so the
// freshest EOL findings surface first.
api.get('/api/v1/vulnerabilities?limit=15&status=active&search=EOL-&sort_by=detected_at&sort_order=desc')
// starts with "EOL-"). Sort by detected_at desc so the freshest EOL
// findings surface first; distinct_cve collapses the same EOL stream
// across many assets to one row (else the widget starved to ~4).
api.get('/api/v1/vulnerabilities?limit=12&status=active&search=EOL-&sort_by=detected_at&sort_order=desc&distinct_cve=true')
.catch(() => ({ data: { items: [] } })),
// Mobile Security: vendor EOL/EOS + Android patch-level staleness on
// phones/tablets. cvss desc surfaces the worst first (EOL 9 → patch
// ≥1y 8 → EOL-soon 5.5 → end-of-active-support 3).
api.get('/api/v1/vulnerabilities?limit=15&status=active&finding_type=mobile&sort_by=cvss&sort_order=desc')
.catch(() => ({ data: { items: [] } })),
// Advisory feed: recently-added CISA KEV (actively exploited in the
// wild), independent of whether we have an affected asset yet.
api.get('/api/v1/advisories/kev-recent?limit=12')
.catch(() => ({ data: { items: [] } })),
api.get('/api/v1/scans/schedules').catch(() => ({ data: [] })),
api.get('/api/v1/compliance/summary').catch(() => ({ data: null })),
@@ -260,11 +319,19 @@ export default function Dashboard() {
}
setCriticalVulns(recentCritical);
// Mobile findings have their own widget — keep them out of the
// generic (desktop-software) EOL widget so the two stay cleanly apart.
const isMobileFinding = (v: Vulnerability) => !!v.cve_id && (
v.cve_id.startsWith('ANDROID-PATCH-') ||
v.cve_id.startsWith('EOL-IPHONE-') || v.cve_id.startsWith('EOL-IPAD-') ||
v.cve_id.startsWith('EOL-SAMSUNG-MOBILE-') || v.cve_id.startsWith('EOL-SAMSUNG-GALAXY-TAB-'));
// EOL widget — dedup by cve_id (one row per EOL stream is plenty).
const rawEol: Vulnerability[] = (eolRes.data && (eolRes.data.items || eolRes.data)) || [];
const seenEol = new Set<string>();
const recentEol: Vulnerability[] = [];
for (const v of rawEol) {
if (isMobileFinding(v)) continue;
if (v.cve_id && !seenEol.has(v.cve_id)) {
seenEol.add(v.cve_id);
recentEol.push(v);
@@ -273,6 +340,23 @@ export default function Dashboard() {
}
setEolVulns(recentEol);
// Mobile Security widget — dedup by cve_id + asset (the SAME EOL
// stream on N different devices is N distinct findings).
const rawMobile: Vulnerability[] = (mobileRes.data && (mobileRes.data.items || mobileRes.data)) || [];
const seenMobile = new Set<string>();
const recentMobile: Vulnerability[] = [];
for (const v of rawMobile) {
const key = `${v.cve_id}|${v.asset_id}`;
if (!seenMobile.has(key)) {
seenMobile.add(key);
recentMobile.push(v);
if (recentMobile.length >= 10) break;
}
}
setMobileVulns(recentMobile);
setKevAdvisories((kevRes.data && kevRes.data.items) || []);
const schedules = Array.isArray(schedRes.data) ? schedRes.data : [];
setActiveScheduleCount(schedules.filter((s: any) => s.enabled).length);
setCompliance(compRes.data);
@@ -293,12 +377,14 @@ export default function Dashboard() {
const statItems = [
{ name: 'CRITICAL', value: stats?.critical_count, change: '+0', changeType: 'increase', color: 'text-securis-danger' },
{ name: 'HIGH', value: stats?.high_count, change: '+0', changeType: 'decrease', color: 'text-vulncheck-blue' },
{ name: 'HIGH', value: stats?.high_count, change: '+0', changeType: 'decrease', color: 'text-truevuln-blue' },
{ name: 'MEDIUM', value: stats?.medium_count, change: '+0', changeType: 'increase', color: 'text-blue-600' },
{ name: 'LOW', value: stats?.low_count, change: '+0', changeType: 'decrease', color: 'text-green-600' },
];
return (
<div className="max-w-7xl mx-auto">
// Full available width (minus AppShell padding) — tester wanted the
// empty left/right gutters used on wide monitors. No max-width cap.
<div className="w-full">
{/* Header Section */}
<div className="md:flex md:items-center md:justify-between mb-8">
<div className="min-w-0 flex-1">
@@ -307,7 +393,8 @@ export default function Dashboard() {
</h2>
<p className="mt-1 text-sm text-gray-500">Monitor and manage security vulnerabilities across your infrastructure</p>
</div>
<div className="mt-4 flex md:ml-4 md:mt-0 items-center gap-2">
<div className="mt-4 flex flex-wrap md:ml-4 md:mt-0 items-center gap-2 justify-end">
{canEdit && (<>
<div className="flex items-center ring-1 ring-inset ring-gray-300 rounded-md bg-white px-2">
<span className="text-xs font-mono text-gray-500 mr-1 uppercase font-bold text-indigo-600">Audit filter:</span>
<select
@@ -340,6 +427,8 @@ export default function Dashboard() {
AI Audit
</button>
)}
</>)}
{canEdit && (
<button
onClick={loadHistory}
className="inline-flex items-center rounded-md px-3 py-2 text-sm font-semibold shadow-sm ring-1 ring-inset ring-gray-300 bg-white text-gray-900 hover:bg-gray-50 transition-all ml-2"
@@ -347,12 +436,15 @@ export default function Dashboard() {
>
<ClockIcon className="h-4 w-4 text-gray-500" />
</button>
<button
type="button"
)}
{/* Was a dead button (no onClick) for every role point it at the
reports page, which is where the exports actually live. */}
<Link
href="/reports"
className="inline-flex items-center 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"
>
Export Report
</button>
</Link>
</div>
</div>
@@ -418,7 +510,7 @@ export default function Dashboard() {
<h3 className="text-lg font-bold text-gray-900 font-mono">
Unified Risk Score (URS)
</h3>
<Link href="/compliance" prefetch className="text-vulncheck-blue text-xs font-bold uppercase tracking-wider font-mono hover:text-blue-700">
<Link href="/compliance" prefetch className="text-truevuln-blue text-xs font-bold uppercase tracking-wider font-mono hover:text-blue-700">
Open &gt;
</Link>
</div>
@@ -482,7 +574,7 @@ export default function Dashboard() {
<h3 className="text-lg font-bold text-gray-900 font-mono">
Compliance (Wazuh SCA)
</h3>
<Link href="/compliance" prefetch className="text-vulncheck-blue text-xs font-bold uppercase tracking-wider font-mono hover:text-blue-700">
<Link href="/compliance" prefetch className="text-truevuln-blue text-xs font-bold uppercase tracking-wider font-mono hover:text-blue-700">
Open &gt;
</Link>
</div>
@@ -525,7 +617,7 @@ export default function Dashboard() {
: o.avg_score >= 40 ? 'text-orange-700' : 'text-red-700';
return (
<li key={o.asset_id} className="flex justify-between gap-2">
<Link href={`/compliance`} className="text-vulncheck-blue hover:underline truncate" title={o.worst_policy || ''}>
<Link href={`/compliance`} className="text-truevuln-blue hover:underline truncate" title={o.worst_policy || ''}>
{o.hostname}
</Link>
<span className={`${c} font-bold`}>
@@ -544,13 +636,15 @@ export default function Dashboard() {
);
})()}
{/* AI Smart Priorities */}
<AIRecommendations
recommendations={aiPriorities?.recommendations || []}
strategy={aiPriorities?.global_strategy}
loading={aiLoading}
error={aiError}
/>
{/* AI Smart Priorities — editor+ only (the audit that fills it is gated) */}
{canEdit && (
<AIRecommendations
recommendations={aiPriorities?.recommendations || []}
strategy={aiPriorities?.global_strategy}
loading={aiLoading}
error={aiError}
/>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 mb-8">
@@ -563,7 +657,7 @@ export default function Dashboard() {
{stats?.severity_history.map((h, idx) => {
const maxCount = Math.max(...(stats?.severity_history.map(s => s.count) || [1]));
const height = stats?.severity_history ? (h.count / maxCount * 100) : 0;
const colors = ['bg-amber-900', 'bg-amber-700', 'bg-vulncheck-blue', 'bg-blue-900', 'bg-red-700', 'bg-green-900', 'bg-blue-500'];
const colors = ['bg-amber-900', 'bg-amber-700', 'bg-truevuln-blue', 'bg-blue-900', 'bg-red-700', 'bg-green-900', 'bg-blue-500'];
return (
<div
@@ -595,7 +689,7 @@ export default function Dashboard() {
</div>
<div className="w-full bg-gray-100 rounded-full h-2.5">
<div
className="bg-vulncheck-blue h-2.5 rounded-full"
className="bg-truevuln-blue h-2.5 rounded-full"
style={{ width: `${stats?.total_assets ? (stats.scanned_assets / stats.total_assets * 100) : 0}%` }}
></div>
</div>
@@ -664,7 +758,7 @@ export default function Dashboard() {
Vulnrichment corrections + KEV/EUVD just-landed)
middle = Newly Published CVEs (sort published_date desc)
right = Newly EOL / EOS (endoflife.date pseudo-CVEs) */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 items-start">
{renderVulnWidget({
title: 'Recent Critical CVEs',
subtitle: 'CVSS ≥ 8 or KEV or EUVD · sorted by priority',
@@ -682,12 +776,69 @@ export default function Dashboard() {
})}
{renderVulnWidget({
title: 'Newly EOL / EOS',
subtitle: 'endoflife.date pseudo-CVEs · sorted by detection',
subtitle: 'End-of-life software · sorted by detection',
vulns: eolVulns,
labelField: 'package_name',
firstColHeader: 'Product',
assetColumn: true,
onRowClick: (cveId) => router.push(`/vulnerabilities?cve_id=${cveId}`),
// eol=1 drives the eolOnly toggle on the list page.
viewAllHref: '/vulnerabilities?eol=1&sort_by=detected_at&sort_order=desc',
})}
{renderVulnWidget({
title: 'Mobile Security · EOL & Patch Level',
subtitle: 'Phones & tablets · vendor EOL/EOS + Android patch staleness',
vulns: mobileVulns,
labelField: 'package_name',
firstColHeader: 'Device / Item',
assetColumn: true,
onRowClick: (cveId) => router.push(`/vulnerabilities?cve_id=${cveId}`),
viewAllHref: '/vulnerabilities?finding_type=mobile&sort_by=cvss&sort_order=desc',
})}
{/* Advisory feed CISA KEV (actively exploited in the wild), independent
of asset findings. "In inventory" badge when we already track it. */}
<div className="bg-white border border-gray-200 shadow-sm rounded-sm overflow-hidden flex flex-col">
<div className="p-4 border-b border-gray-200 flex justify-between items-baseline bg-gray-50/50">
<div>
<h3 className="text-base font-bold text-gray-900 font-mono">Actively Exploited · CISA KEV</h3>
<p className="text-[11px] text-gray-500 font-mono mt-0.5">Newly added known-exploited CVEs · 🔒 = ransomware use</p>
</div>
<a href="https://www.cisa.gov/known-exploited-vulnerabilities-catalog" target="_blank" rel="noreferrer"
className="text-truevuln-blue text-[10px] font-bold uppercase tracking-wider font-mono hover:text-blue-700">View All &gt;</a>
</div>
<div className="overflow-x-auto flex-1">
{kevAdvisories.length === 0 ? (
<p className="p-4 text-xs text-gray-400 font-mono">No KEV data.</p>
) : (
<table className="min-w-full text-sm">
<tbody className="divide-y divide-gray-100">
{kevAdvisories.map((k: any) => (
<tr key={k.cve_id} className="hover:bg-gray-50 cursor-pointer"
onClick={() => router.push(`/vulnerabilities?cve_id=${k.cve_id}`)}>
<td className="px-3 py-2 whitespace-nowrap">
<span className="font-mono text-truevuln-blue text-xs font-bold">{k.cve_id}</span>
{k.ransomware && <span title="Known ransomware campaign use" className="ml-1">🔒</span>}
</td>
<td className="px-3 py-2 text-xs text-gray-600 truncate max-w-[180px]" title={`${k.vendor || ''} ${k.product || ''}`}>
{[k.vendor, k.product].filter(Boolean).join(' · ')}
</td>
<td className="px-3 py-2 whitespace-nowrap text-[11px] text-gray-400 font-mono">{k.date_added}</td>
<td className="px-3 py-2 whitespace-nowrap text-right">
{k.in_inventory ? (
<span className="inline-flex items-center rounded-sm border border-red-200 bg-red-50 px-1.5 py-0.5 text-[10px] font-bold uppercase text-red-700"
title={`Present on ${k.asset_count} asset(s)`}>In inventory · {k.asset_count}</span>
) : (
<span className="inline-flex items-center rounded-sm border border-gray-200 bg-gray-50 px-1.5 py-0.5 text-[10px] font-mono uppercase text-gray-400">not seen</span>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
</div>
</div >
);
+9 -9
View File
@@ -108,7 +108,7 @@ export default function PoliciesPage() {
</div>
<button
onClick={handleCreate}
className="flex items-center gap-2 px-4 py-2 bg-vulncheck-blue text-white rounded-lg font-bold hover:bg-blue-700 transition-colors shadow-lg shadow-blue-500/20"
className="flex items-center gap-2 px-4 py-2 bg-truevuln-blue text-white rounded-lg font-bold hover:bg-blue-700 transition-colors shadow-lg shadow-blue-500/20"
>
<PlusIcon className="h-5 w-5" />
Create Policy
@@ -125,7 +125,7 @@ export default function PoliciesPage() {
</dd>
<div className="mt-4 w-full bg-gray-100 h-2 rounded-full overflow-hidden">
<div
className="bg-vulncheck-blue h-full transition-all duration-500"
className="bg-truevuln-blue h-full transition-all duration-500"
style={{ width: `${policies.length > 0 ? (policies.reduce((sum, p) => sum + (p.compliance || 0), 0) / policies.length) : 0}%` }}
></div>
</div>
@@ -142,7 +142,7 @@ export default function PoliciesPage() {
<div className="grid grid-cols-1 gap-6">
{policies.map((policy) => (
<div key={policy.id} className="bg-white rounded-xl border border-gray-100 shadow-sm overflow-hidden hover:border-vulncheck-blue/30 transition-all group">
<div key={policy.id} className="bg-white rounded-xl border border-gray-100 shadow-sm overflow-hidden hover:border-truevuln-blue/30 transition-all group">
<div className="p-6 flex flex-col md:flex-row md:items-center justify-between gap-6">
<div className="flex items-start gap-4">
<div className={`p-3 rounded-xl ${policy.status === 'active' ? 'bg-green-50 text-green-600' : 'bg-gray-50 text-gray-400'}`}>
@@ -190,7 +190,7 @@ export default function PoliciesPage() {
<div className="flex gap-2">
<button
onClick={() => handleEdit(policy)}
className="p-2 text-gray-400 hover:text-vulncheck-blue hover:bg-blue-50 rounded-lg transition-all"
className="p-2 text-gray-400 hover:text-truevuln-blue hover:bg-blue-50 rounded-lg transition-all"
title="Edit Policy"
>
<PencilIcon className="h-5 w-5" />
@@ -226,7 +226,7 @@ export default function PoliciesPage() {
required
value={editingPolicy.name}
onChange={(e) => setEditingPolicy({ ...editingPolicy, name: e.target.value })}
className="w-full bg-gray-50 border-gray-100 rounded-xl px-4 py-3 focus:ring-vulncheck-blue focus:border-vulncheck-blue"
className="w-full bg-gray-50 border-gray-100 rounded-xl px-4 py-3 focus:ring-truevuln-blue focus:border-truevuln-blue"
placeholder="e.g. Critical Infrastructure SLA"
/>
</div>
@@ -236,7 +236,7 @@ export default function PoliciesPage() {
rows={2}
value={editingPolicy.description}
onChange={(e) => setEditingPolicy({ ...editingPolicy, description: e.target.value })}
className="w-full bg-gray-50 border-gray-100 rounded-xl px-4 py-3 focus:ring-vulncheck-blue focus:border-vulncheck-blue"
className="w-full bg-gray-50 border-gray-100 rounded-xl px-4 py-3 focus:ring-truevuln-blue focus:border-truevuln-blue"
placeholder="Remediation standards for high-security systems..."
/>
</div>
@@ -247,7 +247,7 @@ export default function PoliciesPage() {
type="text"
value={editingPolicy.category}
onChange={(e) => setEditingPolicy({ ...editingPolicy, category: e.target.value })}
className="w-full bg-gray-50 border-gray-100 rounded-xl px-4 py-3 focus:ring-vulncheck-blue focus:border-vulncheck-blue"
className="w-full bg-gray-50 border-gray-100 rounded-xl px-4 py-3 focus:ring-truevuln-blue focus:border-truevuln-blue"
/>
</div>
<div>
@@ -255,7 +255,7 @@ export default function PoliciesPage() {
<select
value={editingPolicy.status}
onChange={(e) => setEditingPolicy({ ...editingPolicy, status: e.target.value })}
className="w-full bg-gray-50 border-gray-100 rounded-xl px-4 py-3 focus:ring-vulncheck-blue focus:border-vulncheck-blue"
className="w-full bg-gray-50 border-gray-100 rounded-xl px-4 py-3 focus:ring-truevuln-blue focus:border-truevuln-blue"
>
<option value="active">Active Enforcement</option>
<option value="warning">Observation Only</option>
@@ -322,7 +322,7 @@ export default function PoliciesPage() {
</button>
<button
type="submit"
className="px-8 py-2.5 bg-vulncheck-blue text-white rounded-xl font-bold hover:bg-blue-700 transition-all shadow-lg shadow-blue-500/20"
className="px-8 py-2.5 bg-truevuln-blue text-white rounded-xl font-bold hover:bg-blue-700 transition-all shadow-lg shadow-blue-500/20"
>
Deploy Policy
</button>
+2 -2
View File
@@ -111,7 +111,7 @@ export default function ReportsPage() {
<div key={report.id} className="relative flex flex-col bg-white border border-gray-200 rounded-sm shadow-sm p-6 hover:shadow-md transition-shadow">
<div className="flex items-center gap-4 mb-4">
<div className="p-3 bg-blue-50 rounded-lg">
<DocumentTextIcon className="h-6 w-6 text-vulncheck-blue" />
<DocumentTextIcon className="h-6 w-6 text-truevuln-blue" />
</div>
<div>
<h3 className="text-base font-bold text-gray-900 font-mono">{report.name}</h3>
@@ -125,7 +125,7 @@ export default function ReportsPage() {
</span>
<button
onClick={() => handleDownload(report)}
className="flex items-center gap-2 text-sm font-semibold text-vulncheck-blue hover:text-blue-700"
className="flex items-center gap-2 text-sm font-semibold text-truevuln-blue hover:text-blue-700"
>
<ArrowDownTrayIcon className="h-4 w-4" />
Download
+15 -15
View File
@@ -58,7 +58,7 @@ export default function ScansPage() {
try {
const [summaryRes, assetsRes, schedulesRes] = await Promise.all([
api.get('/api/v1/scans/summary'),
api.get('/api/v1/assets'),
api.get('/api/v1/assets', { params: { limit: 1000 } }),
api.get('/api/v1/scans/schedules')
]);
setScanRuns(summaryRes.data);
@@ -261,7 +261,7 @@ export default function ScansPage() {
};
return (
<div className="max-w-7xl mx-auto">
<div className="max-w-[1800px] mx-auto">
<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">
@@ -292,7 +292,7 @@ export default function ScansPage() {
<button
type="button"
onClick={handleAutoscan}
className="inline-flex items-center gap-x-1.5 rounded-sm bg-vulncheck-blue px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-600 font-mono"
className="inline-flex items-center gap-x-1.5 rounded-sm bg-truevuln-blue px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-600 font-mono"
title="Trigger a new scan on all agents immediately"
>
<PlayIcon className="-ml-0.5 h-5 w-5" aria-hidden="true" />
@@ -340,7 +340,7 @@ export default function ScansPage() {
type="checkbox"
checked={scanRuns.length > 0 && selectedRuns.size === scanRuns.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" className="px-4 py-3 text-left text-xs font-mono font-medium text-gray-500 uppercase tracking-wider w-8"></th>
@@ -371,7 +371,7 @@ export default function ScansPage() {
type="checkbox"
checked={selectedRuns.has(idx)}
onChange={() => toggleRunSelection(idx)}
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-4 py-4 text-gray-400" onClick={() => hasFailed && setExpandedRun(isExpanded ? null : idx)}>
@@ -444,7 +444,7 @@ export default function ScansPage() {
<button
type="button"
onClick={() => setIsScheduleModalOpen(true)}
className="inline-flex items-center gap-x-1.5 rounded-sm bg-vulncheck-blue px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-600 font-mono"
className="inline-flex items-center gap-x-1.5 rounded-sm bg-truevuln-blue px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-600 font-mono"
>
+ New Schedule
</button>
@@ -489,7 +489,7 @@ export default function ScansPage() {
</button>
<button
onClick={() => handleEditSchedule(schedule)}
className="p-1 text-gray-400 hover:text-vulncheck-blue transition-colors"
className="p-1 text-gray-400 hover:text-truevuln-blue transition-colors"
title="Edit Schedule"
>
<PencilIcon className="h-4 w-4" />
@@ -521,7 +521,7 @@ export default function ScansPage() {
value={newSchedule.name}
onChange={(e) => setNewSchedule({ ...newSchedule, name: e.target.value })}
placeholder="e.g. Nightly Full Scan"
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>
@@ -529,7 +529,7 @@ export default function ScansPage() {
<select
value={newSchedule.interval}
onChange={(e) => setNewSchedule({ ...newSchedule, interval: 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"
>
<option value="every_hour">Every Hour</option>
<option value="every_6_hours">Every 6 Hours</option>
@@ -548,7 +548,7 @@ export default function ScansPage() {
value={newSchedule.cron_expression}
onChange={(e) => setNewSchedule({ ...newSchedule, cron_expression: e.target.value })}
placeholder="* * * * * (min hour day month weekday)"
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"
/>
<p className="mt-1 text-[10px] text-gray-500 font-mono">
Example: "30 14 * * *" for 14:30 daily.
@@ -560,7 +560,7 @@ export default function ScansPage() {
<select
value={newSchedule.scanner_type}
onChange={(e) => setNewSchedule({ ...newSchedule, scanner_type: 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 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="wazuh">Wazuh (agent-based)</option>
<option value="nessus">Tenable Nessus (network scan + import)</option>
@@ -582,7 +582,7 @@ export default function ScansPage() {
setEditingScheduleId(null);
setNewSchedule({ name: '', interval: 'daily', cron_expression: '', scanner_type: 'wazuh' });
}} className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 font-mono">Cancel</button>
<button type="submit" className="px-4 py-2 text-sm font-medium text-white bg-vulncheck-blue rounded-md hover:bg-blue-600">
<button type="submit" className="px-4 py-2 text-sm font-medium text-white bg-truevuln-blue rounded-md hover:bg-blue-600">
{editingScheduleId ? 'Save Changes' : 'Create Schedule'}
</button>
</div>
@@ -599,7 +599,7 @@ export default function ScansPage() {
{autoscanStatus === 'running' && (
<div className="py-8">
<ArrowPathIcon className="h-12 w-12 text-vulncheck-blue animate-spin mx-auto mb-4" />
<ArrowPathIcon className="h-12 w-12 text-truevuln-blue animate-spin mx-auto mb-4" />
<p className="text-gray-600 font-mono">Initiating scans on all connected agents...</p>
<p className="text-xs text-gray-400 mt-2 font-mono">This may take a few moments.</p>
</div>
@@ -630,7 +630,7 @@ export default function ScansPage() {
<div className="mt-6">
<button
type="button"
className="inline-flex w-full justify-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 w-full justify-center rounded-md bg-truevuln-blue px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-600 font-mono"
onClick={() => setIsAutoscanModalOpen(false)}
>
Close
@@ -717,7 +717,7 @@ export default function ScansPage() {
<div className="mt-6">
<button
type="button"
className="inline-flex w-full justify-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 w-full justify-center rounded-md bg-truevuln-blue px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-600 font-mono"
onClick={() => setIsSyncModalOpen(false)}
>
Close
File diff suppressed because it is too large Load Diff
+357 -6
View File
@@ -21,6 +21,281 @@ interface VulnDetail extends Vulnerability {
assigned_group_name?: string;
}
// Linux-kernel CVEs report their "fix" as an upstream git commit hash
// (e.g. 7713bd320ed4fc3d08a22...) rather than a Debian package version —
// raw it looks like garbage in the "Fixed in" cell. Detect a hex hash
// (no version separators) and show a short, labelled form instead.
function formatFixedVersion(v?: string | null): string {
if (!v) return '';
const s = v.trim();
if (/^[0-9a-f]{12,64}$/i.test(s) && !/[.:~_+]/.test(s)) {
return `upstream commit ${s.slice(0, 12)}`;
}
return s;
}
// AI remediation (OpenRouter). Self-contained: checks whether the feature
// is configured, then generates OS-aware fix guidance on demand.
// Copy-to-clipboard button for code blocks.
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
return (
<button
onClick={async () => {
// navigator.clipboard only exists in a secure context (HTTPS or
// localhost). Over plain http://<ip> it's undefined → fall back
// to a temp-textarea + execCommand so Copy still works.
let ok = false;
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
ok = true;
}
} catch { /* fall through */ }
if (!ok) {
try {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.focus(); ta.select();
ok = document.execCommand('copy');
document.body.removeChild(ta);
} catch { /* give up */ }
}
if (ok) { setCopied(true); setTimeout(() => setCopied(false), 1500); }
}}
className="absolute top-2 right-2 text-[10px] font-mono px-2 py-1 rounded bg-gray-700/80 text-gray-100 hover:bg-gray-600"
>
{copied ? '✓ Copied' : 'Copy'}
</button>
);
}
// Render a small subset of Markdown: fenced code blocks (with copy button),
// **bold**, `inline code`, #/## headings, and - bullet lists. Good enough
// for LLM remediation output without pulling in a markdown dependency.
function inlineFmt(s: string, keyBase: string) {
// Handle <br> first (LLMs emit it in table cells / steps) so it renders
// as a line break instead of literal text — and WITHOUT splitting the
// markdown line, which would corrupt table rows.
const brParts = s.split(/(<br\s*\/?>)/gi);
return brParts.map((bp, bi) => {
if (/^<br/i.test(bp)) return <br key={`${keyBase}-br-${bi}`} />;
// split on **bold** and `code`, keep delimiters
const parts = bp.split(/(\*\*[^*]+\*\*|`[^`]+`)/g);
return parts.map((p, i) => {
const k = `${keyBase}-${bi}-${i}`;
if (/^\*\*[^*]+\*\*$/.test(p)) return <strong key={k}>{p.slice(2, -2)}</strong>;
if (/^`[^`]+`$/.test(p)) return <code key={k} className="px-1 py-0.5 rounded bg-gray-100 text-pink-700 text-[13px] font-mono">{p.slice(1, -1)}</code>;
return <span key={k}>{p}</span>;
});
});
}
const _isRow = (l: string) => /^\s*\|.*\|\s*$/.test(l);
const _isSep = (l: string) => /^\s*\|[\s:|-]+\|\s*$/.test(l);
const _cells = (l: string) => l.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map(c => c.trim());
function AiMarkdown({ content }: { content: string }) {
// Unescape literal escapes some models emit (e.g. "\nadb shell …\n" shown
// verbatim). <br> is left intact here and handled in inlineFmt.
const norm = (content || '').replace(/\\r\\n|\\n/g, '\n').replace(/\\t/g, ' ');
// Split into code-fence vs text segments.
const segs = norm.split(/(```[\s\S]*?```)/g);
return (
<div className="text-sm text-gray-800 leading-relaxed space-y-2">
{segs.map((seg, si) => {
const fence = seg.match(/^```(\w*)\n?([\s\S]*?)```$/);
if (fence) {
const code = fence[2].replace(/\n$/, '');
return (
<div key={si} className="relative">
{fence[1] && <span className="absolute top-2 left-3 text-[10px] font-mono uppercase text-gray-400">{fence[1]}</span>}
<CopyButton text={code} />
<pre className="bg-gray-900 text-gray-100 rounded-md p-3 pt-7 overflow-x-auto text-[13px] font-mono whitespace-pre">{code}</pre>
</div>
);
}
// text segment → walk lines, grouping GFM tables.
const lines = seg.split('\n');
const out: React.ReactNode[] = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
// GFM table: header row + separator row + body rows.
if (_isRow(line) && i + 1 < lines.length && _isSep(lines[i + 1])) {
const header = _cells(line);
let j = i + 2;
const rows: string[][] = [];
while (j < lines.length && _isRow(lines[j]) && !_isSep(lines[j])) { rows.push(_cells(lines[j])); j++; }
out.push(
<div key={`${si}-t${i}`} className="overflow-x-auto my-2">
<table className="text-[13px] border-collapse w-full">
<thead><tr>{header.map((c, ci) => (
<th key={ci} className="border border-gray-300 bg-gray-50 px-2 py-1 text-left font-semibold">{inlineFmt(c, `${si}-th-${i}-${ci}`)}</th>
))}</tr></thead>
<tbody>{rows.map((r, ri) => (
<tr key={ri}>{header.map((_, ci) => (
<td key={ci} className="border border-gray-300 px-2 py-1 align-top">{inlineFmt(r[ci] ?? '', `${si}-td-${i}-${ri}-${ci}`)}</td>
))}</tr>
))}</tbody>
</table>
</div>
);
i = j;
continue;
}
const key = `${si}-${i}`;
if (!line.trim()) { out.push(<div key={key} className="h-1" />); i++; continue; }
const h = line.match(/^(#{1,4})\s+(.*)$/);
if (h) { out.push(<p key={key} className="font-bold text-gray-900 mt-2">{inlineFmt(h[2], key)}</p>); i++; continue; }
const li2 = line.match(/^\s*[-*]\s+(.*)$/);
if (li2) { out.push(<p key={key} className="pl-4 relative before:content-['•'] before:absolute before:left-0 before:text-gray-400">{inlineFmt(li2[1], key)}</p>); i++; continue; }
const num = line.match(/^\s*(\d+)\.\s+(.*)$/);
if (num) { out.push(<p key={key} className="pl-1"><span className="font-bold text-gray-700">{num[1]}.</span> {inlineFmt(num[2], key)}</p>); i++; continue; }
out.push(<p key={key}>{inlineFmt(line, key)}</p>); i++;
}
return <div key={si}>{out}</div>;
})}
</div>
);
}
// Remediation section: the scanner solution (Nessus) plus CVE-level
// external sources (MSRC, later Ubuntu/CentOS), each in its own block.
type RemItem = { kind: string; title?: string; detail?: string; kb?: string; fixed_build?: string; url?: string };
type RemGroup = { source: string; items: RemItem[]; fetched_at?: string };
const SOURCE_LABEL: Record<string, string> = { msrc: 'Microsoft (MSRC)', ubuntu: 'Ubuntu USN', redhat: 'Red Hat / CentOS / Alma', osv: 'OSV.dev (aggregator)' };
function RemediationsSection({ vulnId, scannerFallback }: { vulnId: number; scannerFallback?: string | null }) {
const [scanner, setScanner] = useState<string | null>(scannerFallback || null);
const [external, setExternal] = useState<RemGroup[]>([]);
useEffect(() => {
let ok = true;
api.get(`/api/v1/vulnerabilities/${vulnId}/remediations`)
.then(r => { if (!ok) return; setScanner(r.data?.scanner ?? null); setExternal(r.data?.external || []); })
.catch(() => {});
return () => { ok = false; };
}, [vulnId]);
if (!scanner && external.length === 0) return null;
const renderItems = (items: RemItem[]) => {
const fixes = items.filter(i => i.kind === 'fix');
const notes = items.filter(i => i.kind !== 'fix');
return (
<div className="space-y-3">
{fixes.length > 0 && (
<div>
<p className="text-[10px] uppercase tracking-wider text-emerald-700 font-mono mb-1">Patches / KBs</p>
<ul className="space-y-1">
{fixes.map((f, i) => (
<li key={i} className="text-sm flex items-center gap-2 flex-wrap">
<span className="inline-flex items-center rounded bg-emerald-100 px-1.5 py-0.5 text-[11px] font-bold text-emerald-700">{f.title || 'Fix'}</span>
{f.url && <a href={f.url} target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline text-xs font-mono">download </a>}
</li>
))}
</ul>
</div>
)}
{notes.map((n, i) => (
<div key={i}>
<p className={`text-[10px] uppercase tracking-wider font-mono mb-1 ${n.kind === 'mitigation' ? 'text-amber-700' : 'text-indigo-700'}`}>
{n.kind === 'mitigation' ? 'Mitigation / Containment' : n.kind === 'workaround' ? 'Workaround' : 'Advisory'}
</p>
{n.detail && <p className="text-sm text-gray-700 whitespace-pre-wrap leading-relaxed">{n.detail}</p>}
{n.url && (
<a href={n.url} target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline text-sm font-mono break-all">
{n.title || n.url}
</a>
)}
</div>
))}
</div>
);
};
return (
<div className="space-y-4">
{scanner && (
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h2 className="text-lg font-bold text-gray-900 mb-3 flex items-center gap-2">
<span>🛠</span> Remediation
<span className="text-[10px] uppercase tracking-wider text-gray-400 font-mono">via scanner</span>
</h2>
<p className="text-sm text-gray-700 whitespace-pre-wrap leading-relaxed">{scanner}</p>
</div>
)}
{external.map((g, gi) => (
<div key={gi} className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h2 className="text-lg font-bold text-gray-900 mb-3 flex items-center gap-2">
<span>🛡</span> Remediation
<span className="text-[10px] uppercase tracking-wider text-gray-400 font-mono">via {SOURCE_LABEL[g.source] || g.source}</span>
</h2>
{renderItems(g.items)}
</div>
))}
</div>
);
}
function AIRemediationSection({ vulnId }: { vulnId: number }) {
const [enabled, setEnabled] = useState<boolean | null>(null);
const [busy, setBusy] = useState(false);
const [content, setContent] = useState<string | null>(null);
const [model, setModel] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let ok = true;
api.get('/api/v1/vulnerabilities/ai-remediation/status')
.then(r => { if (ok) setEnabled(!!r.data?.enabled); })
.catch(() => { if (ok) setEnabled(false); });
return () => { ok = false; };
}, []);
if (enabled === false) return null; // feature off → no clutter
const run = async () => {
setBusy(true); setError(null);
try {
const r = await api.post(`/api/v1/vulnerabilities/${vulnId}/ai-remediation`);
setContent(r.data?.content || '');
setModel(r.data?.model || null);
} catch (e: any) {
setError(e?.response?.data?.detail || e?.message || 'unknown error');
} finally {
setBusy(false);
}
};
return (
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-bold text-gray-900 flex items-center gap-2">
<span>🤖</span> AI Remediation
{model && <span className="text-[10px] uppercase tracking-wider text-gray-400 font-mono">{model}</span>}
</h2>
<button
onClick={run}
disabled={busy || enabled === null}
className="inline-flex items-center gap-1 text-xs font-mono px-3 py-2 border border-indigo-500 text-indigo-700 rounded-md hover:bg-indigo-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
{busy ? <><span className="animate-spin"></span> Generating</>
: content ? 'Regenerate' : 'Generate fix steps'}
</button>
</div>
{error && <p className="text-sm text-red-600 font-mono">Error: {error}</p>}
{content
? <AiMarkdown content={content} />
: !error && <p className="text-xs text-gray-400 font-mono">OS-aware fix commands generated on demand via OpenRouter.</p>}
</div>
);
}
// Refresh button with loading state + ephemeral "done" badge so a
// click that returns identical EPSS/KEV data still gives the operator
// visual confirmation the call ran (vs the previous silent UX).
@@ -60,6 +335,11 @@ export default function VulnerabilityDetailPage() {
const [vuln, setVuln] = useState<VulnDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [userRole, setUserRole] = useState('');
useEffect(() => {
api.get('/auth/me').then(r => setUserRole(r.data?.role || '')).catch(() => { });
}, []);
const canEdit = userRole === 'admin' || userRole === 'editor';
// Audit / change history for this vuln — populated separately so
// a slow audit query doesn't block the vuln detail render.
type AuditEntry = {
@@ -103,6 +383,43 @@ export default function VulnerabilityDetailPage() {
}
}, [params.id]);
const [dismissBusy, setDismissBusy] = useState(false);
const reloadVuln = async () => {
try {
const res = await api.get(`/api/v1/vulnerabilities/${params.id}`);
setVuln(res.data);
} catch { /* keep current */ }
};
// Dismiss = mark false positive (with reason). Works for EOL/pseudo-CVE
// rows too — they're normal vulnerability records. Reactivate reverses it.
const handleDismiss = async () => {
const reason = window.prompt(
'Dismiss this finding as a false positive?\nEnter a reason (audit-logged):',
'Not applicable / accepted'
);
if (reason === null) return;
setDismissBusy(true);
try {
await api.patch(`/api/v1/vulnerabilities/${params.id}/false-positive`, { reason });
await reloadVuln();
} catch (e: any) {
alert(`Dismiss failed: ${e?.response?.data?.detail || e?.message || 'unknown'}`);
} finally {
setDismissBusy(false);
}
};
const handleReactivate = async () => {
setDismissBusy(true);
try {
await api.patch(`/api/v1/vulnerabilities/${params.id}/unmark-false-positive`);
await reloadVuln();
} catch (e: any) {
alert(`Reactivate failed: ${e?.response?.data?.detail || e?.message || 'unknown'}`);
} finally {
setDismissBusy(false);
}
};
// Parse the JSON payload audit-logs use for status changes.
// new_value can be a plain string (old format) or a JSON object
// {status, reason, source, cve_id} (new format).
@@ -133,7 +450,7 @@ export default function VulnerabilityDetailPage() {
if (loading) {
return (
<div className="p-8 flex justify-center items-center min-h-screen">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-vulncheck-blue"></div>
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-truevuln-blue"></div>
</div>
);
}
@@ -180,6 +497,25 @@ export default function VulnerabilityDetailPage() {
<span className={`px-4 py-2 rounded-lg text-sm font-medium ${statusColors[vuln.status] || 'bg-gray-100'}`}>
{vuln.status.replace('_', ' ')}
</span>
{canEdit && (vuln.status === 'false_positive' ? (
<button
onClick={handleReactivate}
disabled={dismissBusy}
className="px-3 py-2 rounded-lg text-sm font-medium border border-gray-300 text-gray-700 hover:bg-gray-50 disabled:opacity-50"
title="Reopen this finding"
>
{dismissBusy ? '…' : 'Reactivate'}
</button>
) : (
<button
onClick={handleDismiss}
disabled={dismissBusy}
className="px-3 py-2 rounded-lg text-sm font-medium border border-amber-300 text-amber-700 hover:bg-amber-50 disabled:opacity-50"
title="Dismiss as false positive (hides it from the active list)"
>
{dismissBusy ? '…' : 'Dismiss'}
</button>
))}
</div>
</div>
</div>
@@ -235,8 +571,9 @@ export default function VulnerabilityDetailPage() {
</div>
<div className="col-span-3">
<span className="text-[10px] uppercase tracking-wider text-gray-500">Fixed in</span>
<p className={`font-bold ${p.has_fix ? 'text-emerald-700' : 'text-gray-400'}`}>
{p.fixed_version || (p.has_fix ? '' : 'not announced')}
<p className={`font-bold break-words ${p.has_fix ? 'text-emerald-700' : 'text-gray-400'}`}
title={p.fixed_version || ''}>
{formatFixedVersion(p.fixed_version) || (p.has_fix ? '—' : 'not announced')}
</p>
</div>
<div className="col-span-1 text-right">
@@ -269,7 +606,7 @@ export default function VulnerabilityDetailPage() {
{vuln.fixed_version && (
<div>
<span className="text-gray-500">Fixed in:</span>
<p className="font-bold text-emerald-700">{vuln.fixed_version}</p>
<p className="font-bold text-emerald-700 break-words" title={vuln.fixed_version}>{formatFixedVersion(vuln.fixed_version)}</p>
</div>
)}
</div>
@@ -278,7 +615,19 @@ export default function VulnerabilityDetailPage() {
</div>
)}
{/* External Links */}
{/* Remediation — scanner solution + external sources (MSRC, ...) */}
<RemediationsSection vulnId={vuln.id} scannerFallback={vuln.remediation} />
{/* AI remediation (OpenRouter) — hidden when not configured */}
{/* Editor+ only: generating fix steps hits an editor-gated
endpoint and the output isn't persisted, so there's
nothing for a read-only user to see. */}
{canEdit && <AIRemediationSection vulnId={vuln.id} />}
{/* External Links — only for real CVE ids. EOL- /
NESSUS-PLUGIN- pseudo-CVEs have no NVD/CVE.org/Exploit-DB
record, so the links would 404 / lead to nirvana. */}
{/^CVE-/i.test(vuln.cve_id) && (
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h2 className="text-lg font-bold text-gray-900 mb-4 flex items-center gap-2">
<LinkIcon className="h-5 w-5 text-gray-500" />
@@ -311,6 +660,7 @@ export default function VulnerabilityDetailPage() {
</a>
</div>
</div>
)}
</div>
{/* Right Column - Metadata */}
@@ -579,8 +929,9 @@ export default function VulnerabilityDetailPage() {
Affected Asset
</h2>
<Link
href={`/assets?id=${vuln.asset_id}`}
href={`/vulnerabilities?asset_id=${vuln.asset_id}`}
className="block bg-gray-50 rounded-lg p-4 hover:bg-gray-100 transition-colors"
title="Show all findings on this asset"
>
<p className="font-bold text-gray-900 font-mono">
{vuln.asset_hostname || `Asset #${vuln.asset_id}`}
+303 -49
View File
@@ -90,7 +90,7 @@ function ExploitIntelButton({ onDone }: { onDone: () => void }) {
<>💥 Exploit Intel</>
)}
{result && !busy && (
<span className="absolute top-full right-0 mt-1 z-30 whitespace-nowrap rounded-md bg-red-50 border border-red-200 px-2 py-1 text-[10px] text-red-800 shadow-sm">
<span className="absolute top-full right-0 mt-1 z-30 whitespace-normal w-60 rounded-md bg-red-50 border border-red-200 px-2 py-1 text-[10px] text-red-800 shadow-sm">
{result}
</span>
)}
@@ -139,7 +139,213 @@ function EolCheckButton({ onDone }: { onDone: () => void }) {
<> EOL Check</>
)}
{result && !busy && (
<span className="absolute top-full right-0 mt-1 z-30 whitespace-nowrap rounded-md bg-purple-50 border border-purple-200 px-2 py-1 text-[10px] text-purple-800 shadow-sm">
<span className="absolute top-full right-0 mt-1 z-30 whitespace-normal w-60 rounded-md bg-purple-50 border border-purple-200 px-2 py-1 text-[10px] text-purple-800 shadow-sm">
{result}
</span>
)}
</button>
);
}
function M365CheckButton({ onDone }: { onDone: () => void }) {
const [busy, setBusy] = useState(false);
const [result, setResult] = useState<string | null>(null);
return (
<button
disabled={busy}
onClick={async () => {
if (!confirm(
'Run Microsoft 365 Apps CVE detection?\n\n' +
'Parses the Microsoft 365 Apps security-updates page, ' +
'compares each host\'s installed build (from Wazuh ' +
'syscollector) against the latest patched build for its ' +
'update channel, and creates real-CVE rows for the ' +
'monthly updates the host is behind on. These CVEs are ' +
'not in NVD and are invisible to Wazuh. Runs synchronously.'
)) return;
setBusy(true);
setResult(null);
try {
const res = await api.post('/api/v1/vulnerabilities/m365-check');
const s = res.data || {};
setResult(
`${s.assets_scanned || 0} assets, ` +
`${s.m365_installs || 0} M365 installs, ` +
`${s.assets_affected || 0} affected, ` +
`${s.cve_findings_new || 0} new CVE rows ` +
`(${s.cve_findings_total || 0} total).`
);
onDone();
} catch (e: any) {
setResult(`Error: ${e?.response?.data?.detail || e?.message || 'unknown'}`);
} finally {
setBusy(false);
}
}}
className="inline-flex items-center gap-1 text-xs font-mono px-3 py-2 border border-blue-600 text-blue-700 rounded-md hover:bg-blue-50 disabled:opacity-50 disabled:cursor-not-allowed relative"
title={result || 'Detect Microsoft 365 Apps CVEs (not in NVD / not seen by Wazuh)'}
>
{busy ? (
<><span className="animate-spin"></span> Checking M365</>
) : (
<>📅 M365 CVEs</>
)}
{result && !busy && (
<span className="absolute top-full right-0 mt-1 z-30 whitespace-normal w-60 rounded-md bg-blue-50 border border-blue-200 px-2 py-1 text-[10px] text-blue-800 shadow-sm">
{result}
</span>
)}
</button>
);
}
function AppCveScanButton({ onDone }: { onDone: () => void }) {
const [busy, setBusy] = useState(false);
const [result, setResult] = useState<string | null>(null);
return (
<button
disabled={busy}
onClick={async () => {
if (!confirm(
'Run the built-in App CVE scan?\n\n' +
'Maps installed software (Wazuh packages + Intune ' +
'detectedApps) to real CVEs via OSV / NVD-CPE with an ' +
'own version-range check — curated product list, low ' +
'false-positives. Closes the coverage gap for Intune-' +
'only / mobile devices that have no real scanner. ' +
'Source "app-scan"; cross-confirms with other scanners. ' +
'Runs synchronously (may take a while on first run).'
)) return;
setBusy(true);
setResult(null);
try {
const res = await api.post('/api/v1/vulnerabilities/app-cve-scan');
const s = res.data || {};
setResult(
`${s.assets || 0} assets, ` +
`${s.findings || 0} findings (${s.new || 0} new).`
);
onDone();
} catch (e: any) {
setResult(`Error: ${e?.response?.data?.detail || e?.message || 'unknown'}`);
} finally {
setBusy(false);
}
}}
className="inline-flex items-center gap-1 text-xs font-mono px-3 py-2 border border-orange-600 text-orange-700 rounded-md hover:bg-orange-50 disabled:opacity-50 disabled:cursor-not-allowed relative"
title={result || 'Built-in app→CVE scan (curated + precise) for software without a real scanner'}
>
{busy ? (
<><span className="animate-spin"></span> Scanning apps</>
) : (
<>🔎 App CVE Scan</>
)}
{result && !busy && (
<span className="absolute top-full right-0 mt-1 z-30 whitespace-normal w-60 rounded-md bg-orange-50 border border-orange-200 px-2 py-1 text-[10px] text-orange-800 shadow-sm">
{result}
</span>
)}
</button>
);
}
function MsrcRefreshButton() {
const [busy, setBusy] = useState(false);
const [result, setResult] = useState<string | null>(null);
return (
<button
disabled={busy}
onClick={async () => {
if (!confirm(
'Enrich remediations from Microsoft (MSRC)?\n\n' +
'Pulls the recent monthly Microsoft security-update documents ' +
'and stores per-CVE fixes (KB + build + download link), ' +
'workarounds, and mitigations for the Windows / MS-product ' +
'CVEs in the database. Runs in the background; shown in the ' +
'CVE detail under "Remediation via Microsoft (MSRC)".'
)) return;
setBusy(true);
setResult(null);
try {
await api.post('/api/v1/vulnerabilities/msrc/refresh');
setResult('Running…');
// Fire-and-forget on the backend (202) → poll for the real outcome.
const poll = async () => {
try {
const s = await api.get('/api/v1/vulnerabilities/msrc/refresh/status');
if (s.data?.running) { setTimeout(poll, 4000); return; }
if (s.data?.error) {
setResult(`Failed: ${s.data.error}`);
} else if (s.data?.result) {
const d = s.data.result;
const bits = Object.entries(d).slice(0, 4).map(([k, v]) => `${k}: ${v}`).join(', ');
setResult(`Done — ${bits || 'finished'}`);
} else {
setResult('Done.');
}
setBusy(false);
} catch { setTimeout(poll, 4000); }
};
setTimeout(poll, 4000);
} catch (e: any) {
setResult(`Error: ${e?.response?.data?.detail || e?.message || 'unknown'}`);
setBusy(false);
}
}}
className="inline-flex items-center gap-1 text-xs font-mono px-3 py-2 border border-sky-600 text-sky-700 rounded-md hover:bg-sky-50 disabled:opacity-50 disabled:cursor-not-allowed relative"
title={result || 'Pull Microsoft (MSRC) per-CVE fixes / workarounds / mitigations'}
>
{busy ? <><span className="animate-spin"></span> MSRC</> : <>🛡 MSRC Enrich</>}
{result && !busy && (
<span className="absolute top-full right-0 mt-1 z-30 whitespace-normal w-60 rounded-md bg-sky-50 border border-sky-200 px-2 py-1 text-[10px] text-sky-800 shadow-sm">
{result}
</span>
)}
</button>
);
}
function DateBackfillButton({ onDone }: { onDone: () => void }) {
const [busy, setBusy] = useState(false);
const [result, setResult] = useState<string | null>(null);
return (
<button
disabled={busy}
onClick={async () => {
if (!confirm(
'Backfill CVE published / last-modified dates?\n\n' +
'Fills the official publish + update dates (CVE.org ' +
'cvelistV5, NVD fallback) for every CVE that has none yet, ' +
'so the "Newly Published" ordering is correct. Runs in the ' +
'background — no API key needed. First run downloads the ' +
'cvelistV5 snapshot if not already cached.'
)) return;
setBusy(true);
setResult(null);
try {
const res = await api.post('/api/v1/vulnerabilities/dates/backfill');
const s = res.data || {};
setResult(s.status === 'already_running'
? 'Already running…'
: 'Started in background — refresh in a minute.');
// Give it a moment, then refresh the list to pick up dates.
setTimeout(onDone, 8000);
} catch (e: any) {
setResult(`Error: ${e?.response?.data?.detail || e?.message || 'unknown'}`);
} finally {
setBusy(false);
}
}}
className="inline-flex items-center gap-1 text-xs font-mono px-3 py-2 border border-emerald-600 text-emerald-700 rounded-md hover:bg-emerald-50 disabled:opacity-50 disabled:cursor-not-allowed relative"
title={result || 'Fill CVE published/updated dates (cvelistV5 → NVD); fixes Newly-Published sort'}
>
{busy ? (
<><span className="animate-spin"></span> Dating</>
) : (
<>📆 Backfill Dates</>
)}
{result && !busy && (
<span className="absolute top-full right-0 mt-1 z-30 whitespace-normal w-60 rounded-md bg-emerald-50 border border-emerald-200 px-2 py-1 text-[10px] text-emerald-800 shadow-sm">
{result}
</span>
)}
@@ -162,7 +368,8 @@ function VulnerabilitiesContent() {
const [selectedStatus, setSelectedStatus] = useState('active');
const [kevOnly, setKevOnly] = useState(false);
const [euvdOnly, setEuvdOnly] = useState(false);
const [sourceFilter, setSourceFilter] = useState<'all' | 'wazuh' | 'nessus' | 'manual'>('all');
const [sourceFilter, setSourceFilter] = useState<'all' | 'wazuh' | 'nessus' | 'manual' | 'app-scan'>('all');
const [findingType, setFindingType] = useState<string>(''); // 'mobile' from dashboard View All
const [crossConfirmedOnly, setCrossConfirmedOnly] = useState(false);
const [euCritical, setEuCritical] = useState(false);
const [inBothCatalogs, setInBothCatalogs] = useState(false);
@@ -190,7 +397,9 @@ function VulnerabilitiesContent() {
const overridePollRef = useRef<NodeJS.Timeout | null>(null);
// Sortierung (klickbare Header)
const [sortBy, setSortBy] = useState<string>('priority');
// Default sort = CPR desc — tester: CPR is the best single risk metric
// for risk-based vuln management (was 'priority').
const [sortBy, setSortBy] = useState<string>('cpr');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
// Horizontaler Tabellen-Scroll (Maus ohne H-Scroll-Rad)
@@ -251,7 +460,7 @@ function VulnerabilitiesContent() {
if (sortBy !== column) {
return <span className="text-gray-300 ml-1"></span>;
}
return <span className="text-vulncheck-blue ml-1">{sortOrder === 'desc' ? '↓' : '↑'}</span>;
return <span className="text-truevuln-blue ml-1">{sortOrder === 'desc' ? '↓' : '↑'}</span>;
};
// Help-Tooltip text per column
@@ -292,7 +501,7 @@ function VulnerabilitiesContent() {
e.stopPropagation();
setOpenHelp(isOpen ? null : column);
}}
className="text-gray-400 hover:text-vulncheck-blue cursor-pointer inline-flex"
className="text-gray-400 hover:text-truevuln-blue cursor-pointer inline-flex"
title={text}
aria-label={`Help for ${column}`}
>
@@ -304,7 +513,7 @@ function VulnerabilitiesContent() {
onClick={(e) => e.stopPropagation()}
>
<div className="mb-1 flex items-center justify-between">
<strong className="text-vulncheck-blue uppercase text-[11px] tracking-wider">{column}</strong>
<strong className="text-truevuln-blue uppercase text-[11px] tracking-wider">{column}</strong>
<button
onClick={(e) => { e.stopPropagation(); setOpenHelp(null); }}
className="text-gray-400 hover:text-gray-700"
@@ -407,6 +616,8 @@ function VulnerabilitiesContent() {
if (searchParams.get('euvd_only') === 'true') setEuvdOnly(true);
if (searchParams.get('in_both_catalogs') === 'true') setInBothCatalogs(true);
if (searchParams.get('publ_expl') === '1') setExploitCatalogOnly(true);
const ft = searchParams.get('finding_type');
if (ft) setFindingType(ft);
}, [searchParams]);
// Fetch Current User
@@ -427,6 +638,7 @@ function VulnerabilitiesContent() {
// user-typed term still wins as a more specific filter.
const effectiveSearch = eolOnly && !searchText ? 'EOL-' : searchText;
if (effectiveSearch) params.search = effectiveSearch;
if (findingType) params.finding_type = findingType;
// Always send the literal value (including 'all', 'active',
// 'closed') — backend handles all three groups + enum literals.
if (selectedStatus) params.status = selectedStatus;
@@ -446,7 +658,7 @@ function VulnerabilitiesContent() {
const [vulnRes, assetsRes, usersRes, groupsRes] = await Promise.all([
api.get('/api/v1/vulnerabilities', { params }),
api.get('/api/v1/assets'),
api.get('/api/v1/assets', { params: { limit: 1000 } }),
api.get('/auth/users').catch(() => ({ data: [] })),
api.get('/api/v1/groups'),
]);
@@ -475,7 +687,7 @@ function VulnerabilitiesContent() {
} finally {
setLoading(false);
}
}, [selectedAssetId, selectedSeverity, searchText, selectedStatus, currentPage, kevOnly, euvdOnly, euCritical, inBothCatalogs, sourceFilter, crossConfirmedOnly, epssMin, sortBy, sortOrder, eolOnly, exploitCatalogOnly]);
}, [selectedAssetId, selectedSeverity, searchText, selectedStatus, currentPage, kevOnly, euvdOnly, euCritical, inBothCatalogs, sourceFilter, crossConfirmedOnly, epssMin, sortBy, sortOrder, eolOnly, exploitCatalogOnly, findingType]);
const handleEnrichAll = async () => {
if (isEnriching) return;
@@ -779,7 +991,7 @@ function VulnerabilitiesContent() {
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
placeholder="Search CVE, package, or title..."
className="w-full pl-9 pr-3 py-2 border border-gray-300 rounded-md text-sm font-mono focus:ring-vulncheck-blue focus:border-vulncheck-blue"
className="w-full pl-9 pr-3 py-2 border border-gray-300 rounded-md text-sm font-mono focus:ring-truevuln-blue focus:border-truevuln-blue"
/>
</div>
@@ -789,7 +1001,7 @@ function VulnerabilitiesContent() {
<select
value={selectedSeverity}
onChange={(e) => { setSelectedSeverity(e.target.value); setCurrentPage(1); }}
className="border border-gray-300 rounded-md text-sm font-mono py-2 px-3 focus:ring-vulncheck-blue focus:border-vulncheck-blue"
className="border border-gray-300 rounded-md text-sm font-mono py-2 px-3 focus:ring-truevuln-blue focus:border-truevuln-blue"
>
<option value="">All Severities</option>
<option value="critical">Critical</option>
@@ -805,7 +1017,7 @@ function VulnerabilitiesContent() {
<select
value={selectedStatus}
onChange={(e) => { setSelectedStatus(e.target.value); setCurrentPage(1); }}
className="border border-gray-300 rounded-md text-sm font-mono py-2 px-3 focus:ring-vulncheck-blue focus:border-vulncheck-blue"
className="border border-gray-300 rounded-md text-sm font-mono py-2 px-3 focus:ring-truevuln-blue focus:border-truevuln-blue"
>
<optgroup label="Groups">
<option value="active">Active (open + pending + patch failed)</option>
@@ -911,12 +1123,13 @@ function VulnerabilitiesContent() {
<select
value={sourceFilter}
onChange={(e) => { setSourceFilter(e.target.value as any); setCurrentPage(1); }}
className="border border-gray-300 rounded-md text-xs font-mono py-2 px-2 focus:ring-vulncheck-blue focus:border-vulncheck-blue"
className="border border-gray-300 rounded-md text-xs font-mono py-2 px-2 focus:ring-truevuln-blue focus:border-truevuln-blue"
title="Which scanner reported this CVE"
>
<option value="all">all scanners</option>
<option value="wazuh">Wazuh only</option>
<option value="nessus">Nessus only</option>
<option value="app-scan">App scan only</option>
<option value="manual">manual</option>
</select>
</div>
@@ -943,20 +1156,22 @@ function VulnerabilitiesContent() {
value={epssMin}
onChange={(e) => { setEpssMin(e.target.value); setCurrentPage(1); }}
placeholder="50"
className="w-20 border border-gray-300 rounded-md text-sm font-mono py-2 px-2 focus:ring-vulncheck-blue focus:border-vulncheck-blue"
className="w-20 border border-gray-300 rounded-md text-sm font-mono py-2 px-2 focus:ring-truevuln-blue focus:border-truevuln-blue"
/>
<span className="text-xs text-gray-500 font-mono">%</span>
</div>
{/* Manual Bulk Enrich */}
<button
onClick={handleEnrichAll}
disabled={isEnriching || currentUser?.role === 'readonly'}
className="inline-flex items-center gap-1 text-xs font-mono px-3 py-2 border border-vulncheck-blue text-vulncheck-blue rounded-md hover:bg-blue-50 disabled:opacity-50 disabled:cursor-not-allowed"
title="Run EPSS + CISA KEV enrichment for all open vulnerabilities"
>
{isEnriching ? 'Enriching…' : 'Refresh Threat Intel'}
</button>
{currentUser?.role !== 'readonly' && (
<button
onClick={handleEnrichAll}
disabled={isEnriching}
className="inline-flex items-center gap-1 text-xs font-mono px-3 py-2 border border-truevuln-blue text-truevuln-blue rounded-md hover:bg-blue-50 disabled:opacity-50 disabled:cursor-not-allowed"
title="Run EPSS + CISA KEV enrichment for all open vulnerabilities"
>
{isEnriching ? 'Enriching…' : 'Refresh Threat Intel'}
</button>
)}
{/* Clear Filters */}
{hasFilters && (
@@ -977,7 +1192,7 @@ function VulnerabilitiesContent() {
'Run CVSS score correction from CISA Vulnrichment?\n\n' +
'This downloads the full vulnrichment snapshot (~250 MB) ' +
'and fixes placeholder 10.0 scores + severity mismatches. ' +
'Runs in the background — you can keep using VulnCheck ' +
'Runs in the background — you can keep using TrueVuln ' +
'while it works. Progress shows below.'
)) return;
setIsCorrectingScores(true);
@@ -1042,6 +1257,26 @@ function VulnerabilitiesContent() {
{currentUser?.role !== 'readonly' && (
<ExploitIntelButton onDone={fetchData} />
)}
{/* Plan P Microsoft 365 Apps CVE detection (page parse +
build comparison; CVEs absent from NVD/Wazuh). */}
{currentUser?.role !== 'readonly' && (
<>
<M365CheckButton onDone={fetchData} />
<AppCveScanButton onDone={fetchData} />
</>
)}
{/* On-demand CVE date backfill (cvelistV5 NVD) fixes
the Newly-Published ordering without the nightly wait. */}
{currentUser?.role !== 'readonly' && (
<DateBackfillButton onDone={fetchData} />
)}
{/* MSRC remediation enrichment (Windows + MS products) */}
{currentUser?.role !== 'readonly' && (
<MsrcRefreshButton />
)}
</div>
{/* Active Search Tags */}
@@ -1089,7 +1324,7 @@ function VulnerabilitiesContent() {
<button
onClick={() => scrollTable('left')}
disabled={!scrollState.canLeft}
className="p-1 rounded border border-gray-300 bg-white hover:bg-vulncheck-blue hover:text-white hover:border-vulncheck-blue disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
className="p-1 rounded border border-gray-300 bg-white hover:bg-truevuln-blue hover:text-white hover:border-truevuln-blue disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
title="Scroll left"
aria-label="Scroll left"
>
@@ -1098,7 +1333,7 @@ function VulnerabilitiesContent() {
<button
onClick={() => scrollTable('right')}
disabled={!scrollState.canRight}
className="p-1 rounded border border-gray-300 bg-white hover:bg-vulncheck-blue hover:text-white hover:border-vulncheck-blue disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
className="p-1 rounded border border-gray-300 bg-white hover:bg-truevuln-blue hover:text-white hover:border-truevuln-blue disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
title="Scroll right"
aria-label="Scroll right"
>
@@ -1116,7 +1351,7 @@ function VulnerabilitiesContent() {
<th className="px-4 py-3 text-left">
<input
type="checkbox"
className="rounded border-gray-300 text-vulncheck-blue focus:ring-vulncheck-blue"
className="rounded border-gray-300 text-truevuln-blue focus:ring-truevuln-blue"
checked={vulnerabilities.length > 0 && selectedIds.length === vulnerabilities.length}
onChange={handleSelectAll}
disabled={currentUser?.role === 'readonly'}
@@ -1196,7 +1431,9 @@ function VulnerabilitiesContent() {
Source<SortArrow column="source" />
</th>
<th scope="col" className="px-4 py-3 text-left text-xs font-mono font-medium text-gray-500 uppercase tracking-wider">Assigned To</th>
<th scope="col" className="sticky right-0 z-10 bg-gray-50 px-4 py-3 text-right text-xs font-mono font-medium text-gray-500 uppercase tracking-wider shadow-[-4px_0_8px_-2px_rgba(0,0,0,0.1)] border-l border-gray-200">Actions</th>
{currentUser?.role !== 'readonly' && (
<th scope="col" className="sticky right-0 z-10 bg-gray-50 px-4 py-3 text-right text-xs font-mono font-medium text-gray-500 uppercase tracking-wider shadow-[-4px_0_8px_-2px_rgba(0,0,0,0.1)] border-l border-gray-200">Actions</th>
)}
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200 font-mono text-sm">
@@ -1205,7 +1442,7 @@ function VulnerabilitiesContent() {
<td className="px-4 py-3">
<input
type="checkbox"
className="rounded border-gray-200 text-vulncheck-blue focus:ring-vulncheck-blue"
className="rounded border-gray-200 text-truevuln-blue focus:ring-truevuln-blue"
checked={selectedIds.includes(vuln.id)}
onChange={() => toggleSelect(vuln.id)}
disabled={currentUser?.role === 'readonly'}
@@ -1214,7 +1451,7 @@ function VulnerabilitiesContent() {
<td className="px-4 py-3 whitespace-nowrap">
<a
href={`/vulnerabilities/${vuln.id}`}
className="font-bold text-vulncheck-blue hover:text-blue-800 hover:underline cursor-pointer"
className="font-bold text-truevuln-blue hover:text-blue-800 hover:underline cursor-pointer"
>
{vuln.cve_id}
</a>
@@ -1222,7 +1459,7 @@ function VulnerabilitiesContent() {
<td className="px-4 py-3 whitespace-nowrap text-gray-700">
<a
href={`/vulnerabilities?asset_id=${vuln.asset_id}`}
className="hover:text-vulncheck-blue hover:underline"
className="hover:text-truevuln-blue hover:underline"
onClick={(e) => {
e.preventDefault();
setSelectedAssetId(String(vuln.asset_id));
@@ -1535,7 +1772,13 @@ function VulnerabilitiesContent() {
? 'bg-green-100 text-green-700 border-green-200'
: src === 'nessus'
? 'bg-purple-100 text-purple-700 border-purple-200'
: 'bg-gray-100 text-gray-600 border-gray-200';
: src === 'intune'
? 'bg-blue-100 text-blue-700 border-blue-200'
: src === 'defender'
? 'bg-cyan-100 text-cyan-700 border-cyan-200'
: src === 'app-scan'
? 'bg-orange-100 text-orange-700 border-orange-200'
: 'bg-gray-100 text-gray-600 border-gray-200';
// Italic + lower opacity when source is the fallback (no scanner sees it now)
const faded = live.length === 0 ? ' opacity-60 italic' : '';
return (
@@ -1563,14 +1806,22 @@ function VulnerabilitiesContent() {
<td className="px-4 py-3 whitespace-nowrap">
<div className="flex items-center gap-2">
{vuln.assigned_group_id ? (
<UserGroupIcon className={`h-5 w-5 text-vulncheck-blue`} />
<UserGroupIcon className={`h-5 w-5 text-truevuln-blue`} />
) : (
<UserCircleIcon className={`h-5 w-5 ${vuln.assigned_user_id ? 'text-vulncheck-blue' : 'text-gray-300'}`} />
<UserCircleIcon className={`h-5 w-5 ${vuln.assigned_user_id ? 'text-truevuln-blue' : 'text-gray-300'}`} />
)}
{currentUser?.role === 'readonly' ? (
// Read-only: show the actual assignee (from the payload — the
// /auth/users list is admin-only, so a disabled <select> would
// wrongly read "Unassigned"). No control, just the name.
<span className="text-xs text-gray-700 truncate" style={{ maxWidth: '120px' }}>
{vuln.assigned_group_name || vuln.assigned_user_name || <span className="text-gray-400">Unassigned</span>}
</span>
) : (
<select
value={vuln.assigned_group_id ? `group_${vuln.assigned_group_id}` : (vuln.assigned_user_id ? String(vuln.assigned_user_id) : "")}
onChange={(e) => handleAssign(vuln.id, e.target.value)}
className="block w-full rounded-md border-0 py-1.5 pl-2 pr-7 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-vulncheck-blue sm:text-xs sm:leading-6 bg-transparent"
className="block w-full rounded-md border-0 py-1.5 pl-2 pr-7 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-truevuln-blue sm:text-xs sm:leading-6 bg-transparent"
style={{ maxWidth: '120px' }}
>
<option value="">Unassigned</option>
@@ -1585,15 +1836,17 @@ function VulnerabilitiesContent() {
))}
</optgroup>
</select>
)}
</div>
</td>
{currentUser?.role !== 'readonly' && (
<td
className={`sticky right-0 z-10 px-4 py-3 whitespace-nowrap text-right shadow-[-4px_0_8px_-2px_rgba(0,0,0,0.1)] border-l border-gray-100 ${selectedIds.includes(vuln.id) ? 'bg-blue-50' : 'bg-white group-hover:bg-blue-50/30'}`}
>
<div className="flex justify-end gap-1">
<button
onClick={() => handleOpenStatusModal(vuln)}
className="p-1 hover:bg-gray-100 rounded text-gray-400 hover:text-vulncheck-blue"
className="p-1 hover:bg-gray-100 rounded text-gray-400 hover:text-truevuln-blue"
title="Change Status & Comment"
>
<PencilSquareIcon className="h-4 w-4" />
@@ -1614,10 +1867,11 @@ function VulnerabilitiesContent() {
</button>
</div>
</td>
)}
</tr>
))}
{vulnerabilities.length === 0 && !loading && (
<tr><td colSpan={6} className="text-center py-8 text-gray-500">No vulnerabilities found.</td></tr>
<tr><td colSpan={currentUser?.role !== 'readonly' ? 6 : 5} className="text-center py-8 text-gray-500">No vulnerabilities found.</td></tr>
)}
</tbody>
</table>
@@ -1658,7 +1912,7 @@ function VulnerabilitiesContent() {
onClick={() => setCurrentPage(page)}
className={`px-3 py-1.5 text-sm font-mono rounded-md ${
currentPage === page
? 'bg-vulncheck-blue text-white font-bold'
? 'bg-truevuln-blue text-white font-bold'
: 'border border-gray-300 hover:bg-gray-50'
}`}
>
@@ -1683,13 +1937,13 @@ function VulnerabilitiesContent() {
{/* Status & Change Modal */}
{isStatusModalOpen && statusTarget && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-gray-500/75 backdrop-blur-sm">
<div className="bg-white rounded-lg shadow-xl max-w-lg w-full p-8 border border-gray-100 border-t-8 border-t-vulncheck-blue">
<div className="bg-white rounded-lg shadow-xl max-w-lg w-full p-8 border border-gray-100 border-t-8 border-t-truevuln-blue">
<h3 className="text-xl font-bold mb-2 font-mono flex items-center gap-2">
<ShieldExclamationIcon className="h-6 w-6 text-vulncheck-blue" />
<ShieldExclamationIcon className="h-6 w-6 text-truevuln-blue" />
Change Status
</h3>
<p className="text-gray-500 text-sm mb-6 font-mono">
Vulnerability: <span className="text-vulncheck-blue font-bold">{statusTarget.cve_id}</span>
Vulnerability: <span className="text-truevuln-blue font-bold">{statusTarget.cve_id}</span>
</p>
<div className="space-y-6">
@@ -1698,7 +1952,7 @@ function VulnerabilitiesContent() {
<select
value={newStatus}
onChange={(e) => setNewStatus(e.target.value)}
className="w-full bg-gray-50 border border-gray-200 rounded-md py-2 px-3 focus:outline-none focus:ring-2 focus:ring-vulncheck-blue font-mono"
className="w-full bg-gray-50 border border-gray-200 rounded-md py-2 px-3 focus:outline-none focus:ring-2 focus:ring-truevuln-blue font-mono"
>
<option value="open">OPEN</option>
<option value="patched">PATCHED (Triggers Rescan)</option>
@@ -1715,7 +1969,7 @@ function VulnerabilitiesContent() {
type="date"
value={deferDate}
onChange={(e) => setDeferDate(e.target.value)}
className="w-full bg-gray-50 border border-gray-200 rounded-md py-2 px-3 focus:outline-none focus:ring-2 focus:ring-vulncheck-blue font-mono"
className="w-full bg-gray-50 border border-gray-200 rounded-md py-2 px-3 focus:outline-none focus:ring-2 focus:ring-truevuln-blue font-mono"
/>
</div>
)}
@@ -1727,7 +1981,7 @@ function VulnerabilitiesContent() {
value={statusComment}
onChange={(e) => setStatusComment(e.target.value)}
placeholder="Add a reason for this status change..."
className="w-full bg-gray-50 border border-gray-200 rounded-md py-2 px-3 focus:outline-none focus:ring-2 focus:ring-vulncheck-blue font-mono text-sm"
className="w-full bg-gray-50 border border-gray-200 rounded-md py-2 px-3 focus:outline-none focus:ring-2 focus:ring-truevuln-blue font-mono text-sm"
/>
</div>
@@ -1746,7 +2000,7 @@ function VulnerabilitiesContent() {
</button>
<button
onClick={handleConfirmStatusChange}
className="flex-1 bg-vulncheck-blue text-white font-bold font-mono rounded-md hover:bg-blue-600 shadow-md shadow-blue-500/20 transition-all uppercase text-sm tracking-wider"
className="flex-1 bg-truevuln-blue text-white font-bold font-mono rounded-md hover:bg-blue-600 shadow-md shadow-blue-500/20 transition-all uppercase text-sm tracking-wider"
>
Update Status
</button>
@@ -1761,7 +2015,7 @@ function VulnerabilitiesContent() {
<div className="fixed bottom-8 left-1/2 -translate-x-1/2 z-40 animate-in fade-in slide-in-from-bottom-4 duration-300">
<div className="bg-white rounded-2xl shadow-2xl border border-blue-100 p-3 flex items-center gap-6 px-6">
<div className="flex items-center gap-3 pr-6 border-r border-gray-100">
<div className="bg-vulncheck-blue text-white w-8 h-8 rounded-full flex items-center justify-center font-bold font-mono text-sm leading-none shadow-lg shadow-blue-500/30">
<div className="bg-truevuln-blue text-white w-8 h-8 rounded-full flex items-center justify-center font-bold font-mono text-sm leading-none shadow-lg shadow-blue-500/30">
{selectedIds.length}
</div>
<span className="text-gray-600 font-mono text-sm font-bold uppercase tracking-wider">Selected</span>
@@ -1771,7 +2025,7 @@ function VulnerabilitiesContent() {
<div className="flex items-center gap-2">
<UserCircleIcon className="h-5 w-5 text-gray-400" />
<select
className="text-sm font-mono bg-gray-50 border-gray-100 rounded-lg px-3 py-1.5 focus:ring-2 focus:ring-vulncheck-blue outline-none border transition-all"
className="text-sm font-mono bg-gray-50 border-gray-100 rounded-lg px-3 py-1.5 focus:ring-2 focus:ring-truevuln-blue outline-none border transition-all"
onChange={(e) => setBulkActionTarget({ type: 'user', id: parseInt(e.target.value) })}
value={bulkActionTarget?.type === 'user' ? bulkActionTarget.id : ""}
>
@@ -1786,7 +2040,7 @@ function VulnerabilitiesContent() {
<div className="flex items-center gap-2">
<UserGroupIcon className="h-5 w-5 text-gray-400" />
<select
className="text-sm font-mono bg-gray-50 border-gray-100 rounded-lg px-3 py-1.5 focus:ring-2 focus:ring-vulncheck-blue outline-none border transition-all"
className="text-sm font-mono bg-gray-50 border-gray-100 rounded-lg px-3 py-1.5 focus:ring-2 focus:ring-truevuln-blue outline-none border transition-all"
onChange={(e) => setBulkActionTarget({ type: 'group', id: parseInt(e.target.value) })}
value={bulkActionTarget?.type === 'group' ? bulkActionTarget.id : ""}
>
@@ -1801,7 +2055,7 @@ function VulnerabilitiesContent() {
<div className="flex items-center gap-2">
<ShieldExclamationIcon className="h-5 w-5 text-gray-400" />
<select
className="text-sm font-mono bg-gray-50 border-gray-100 rounded-lg px-3 py-1.5 focus:ring-2 focus:ring-vulncheck-blue outline-none border transition-all"
className="text-sm font-mono bg-gray-50 border-gray-100 rounded-lg px-3 py-1.5 focus:ring-2 focus:ring-truevuln-blue outline-none border transition-all"
onChange={(e) => setBulkActionTarget({ type: 'status', value: e.target.value })}
value={bulkActionTarget?.type === 'status' ? bulkActionTarget.value : ""}
>
@@ -1820,7 +2074,7 @@ function VulnerabilitiesContent() {
title={currentUser?.role === 'readonly' ? "Read-Only User" : ""}
className={`px-4 py-1.5 rounded-lg font-mono text-sm font-bold uppercase tracking-wider transition-all
${bulkActionTarget && !isBulkUpdating && currentUser?.role !== 'readonly'
? 'bg-vulncheck-blue text-white shadow-lg shadow-blue-500/30 hover:bg-blue-600'
? 'bg-truevuln-blue text-white shadow-lg shadow-blue-500/30 hover:bg-blue-600'
: 'bg-gray-100 text-gray-400 cursor-not-allowed'}`}
>
{isBulkUpdating ? 'Applying...' : 'Apply'}
+6 -6
View File
@@ -164,7 +164,7 @@ export default function MfaCard() {
) : (
<button
onClick={() => { reset(); setStage("setup"); }}
className="rounded-md bg-vulncheck-blue px-3 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-600 font-mono"
className="rounded-md bg-truevuln-blue px-3 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-600 font-mono"
>
Enable MFA
</button>
@@ -186,11 +186,11 @@ export default function MfaCard() {
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
required
className="block w-full rounded-md border-0 py-1.5 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-vulncheck-blue sm:text-sm font-mono"
className="block w-full rounded-md border-0 py-1.5 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"
/>
<div className="flex gap-2">
<button type="button" onClick={reset} className="rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50 font-mono">Cancel</button>
<button type="submit" disabled={busy} className="rounded-md bg-vulncheck-blue px-3 py-1.5 text-sm font-semibold text-white hover:bg-blue-600 disabled:opacity-60 font-mono">
<button type="submit" disabled={busy} className="rounded-md bg-truevuln-blue px-3 py-1.5 text-sm font-semibold text-white hover:bg-blue-600 disabled:opacity-60 font-mono">
{busy ? "Generating…" : "Continue"}
</button>
</div>
@@ -235,13 +235,13 @@ export default function MfaCard() {
autoComplete="one-time-code"
autoFocus
placeholder="000000"
className="w-40 text-center text-xl tracking-[0.5em] font-mono rounded-md border-0 py-2 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-vulncheck-blue"
className="w-40 text-center text-xl tracking-[0.5em] font-mono rounded-md border-0 py-2 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-truevuln-blue"
/>
</div>
<div className="flex gap-2">
<button type="button" onClick={reset} className="rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50 font-mono">Cancel</button>
<button type="submit" disabled={busy || code.length !== 6} className="rounded-md bg-vulncheck-blue px-3 py-1.5 text-sm font-semibold text-white hover:bg-blue-600 disabled:opacity-60 font-mono">
<button type="submit" disabled={busy || code.length !== 6} className="rounded-md bg-truevuln-blue px-3 py-1.5 text-sm font-semibold text-white hover:bg-blue-600 disabled:opacity-60 font-mono">
{busy ? "Activating…" : "Activate MFA"}
</button>
</div>
@@ -262,7 +262,7 @@ export default function MfaCard() {
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
required
className="block w-full rounded-md border-0 py-1.5 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-vulncheck-blue sm:text-sm font-mono"
className="block w-full rounded-md border-0 py-1.5 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"
/>
<div className="flex gap-2">
<button type="button" onClick={reset} className="rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50 font-mono">Cancel</button>
@@ -73,8 +73,8 @@ export default function AppShell({ children }: { children: React.ReactNode }) {
<Bars3Icon className="h-6 w-6" aria-hidden="true" />
</button>
<div className="flex flex-1 items-center gap-2">
<img src="/logo.svg" alt="VulnCheck Logo" className="h-8 w-8" />
<div className="text-sm font-bold leading-6 text-gray-900 font-mono tracking-tighter">Vuln<span className="text-vulncheck-blue">Check</span></div>
<img src="/logo.svg" alt="TrueVuln Logo" className="h-8 w-8" />
<div className="text-sm font-bold leading-6 text-gray-900 font-mono tracking-tighter">True<span className="text-truevuln-blue">Vuln</span></div>
</div>
</div>
+6 -6
View File
@@ -54,15 +54,15 @@ export default function Drawer({ sidebarOpen, setSidebarOpen, user }: DrawerProp
<Dialog.Panel className="relative mr-16 flex w-full max-w-xs flex-1">
<div className="flex grow flex-col gap-y-5 overflow-y-auto bg-gray-900 px-6 pb-4 ring-1 ring-white/10">
<div className="flex h-16 shrink-0 items-center gap-3">
<img src="/logo.svg" alt="VulnCheck Logo" className="h-10 w-10" />
<span className="text-white font-extrabold text-xl font-mono tracking-tight">Vuln<span className="text-vulncheck-blue">Check</span></span>
<img src="/logo.svg" alt="TrueVuln Logo" className="h-10 w-10" />
<span className="text-white font-extrabold text-xl font-mono tracking-tight">True<span className="text-truevuln-blue">Vuln</span></span>
</div>
<Navigation />
<div className="mt-auto pb-4">
{user && (
<div className="mb-4 px-2 py-3 bg-gray-800/50 rounded-lg border border-gray-700/50">
<div className="flex items-center gap-3">
<div className="h-8 w-8 rounded-full bg-vulncheck-blue/20 flex items-center justify-center border border-vulncheck-blue/30 text-vulncheck-blue font-bold font-mono">
<div className="h-8 w-8 rounded-full bg-truevuln-blue/20 flex items-center justify-center border border-truevuln-blue/30 text-truevuln-blue font-bold font-mono">
{user.username.substring(0, 2).toUpperCase()}
</div>
<div className="flex flex-col overflow-hidden">
@@ -91,9 +91,9 @@ export default function Drawer({ sidebarOpen, setSidebarOpen, user }: DrawerProp
<div className="hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:w-72 lg:flex-col">
<div className="flex grow flex-col gap-y-5 bg-gray-900 px-6 pb-4 border-r border-white/5">
<div className="flex h-20 shrink-0 items-center gap-3">
<img src="/logo.svg" alt="VulnCheck Logo" className="h-12 w-12" />
<img src="/logo.svg" alt="TrueVuln Logo" className="h-12 w-12" />
<div className="flex flex-col">
<span className="text-white font-extrabold text-2xl font-mono tracking-tighter leading-none">Vuln<span className="text-vulncheck-blue">Check</span></span>
<span className="text-white font-extrabold text-2xl font-mono tracking-tighter leading-none">True<span className="text-truevuln-blue">Vuln</span></span>
<span className="text-[10px] text-gray-500 font-mono tracking-widest uppercase mt-1">Enterprise Security</span>
</div>
</div>
@@ -102,7 +102,7 @@ export default function Drawer({ sidebarOpen, setSidebarOpen, user }: DrawerProp
{user && (
<div className="mb-4 px-2 py-3 bg-gray-800/50 rounded-lg border border-gray-700/50">
<div className="flex items-center gap-3">
<div className="h-8 w-8 rounded-full bg-vulncheck-blue/20 flex items-center justify-center border border-vulncheck-blue/30 text-vulncheck-blue font-bold font-mono">
<div className="h-8 w-8 rounded-full bg-truevuln-blue/20 flex items-center justify-center border border-truevuln-blue/30 text-truevuln-blue font-bold font-mono">
{user.username.substring(0, 2).toUpperCase()}
</div>
<div className="flex flex-col overflow-hidden">
@@ -13,6 +13,7 @@ import {
EyeIcon,
KeyIcon,
CheckBadgeIcon,
BellAlertIcon,
} from '@heroicons/react/24/outline';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
@@ -33,6 +34,7 @@ type NavItem = {
const navigation: NavItem[] = [
{ name: 'Dashboard', href: '/', icon: Square2StackIcon },
{ name: 'Vulnerabilities', href: '/vulnerabilities', icon: ShieldCheckIcon },
{ name: 'Advisories', href: '/advisories', icon: BellAlertIcon },
{ name: 'Assets', href: '/assets', icon: ServerIcon },
{ name: 'Compliance', href: '/compliance', icon: CheckBadgeIcon },
{ name: 'Scan Jobs', href: '/scans', icon: CpuChipIcon, requires: 'editor' },
@@ -90,13 +92,13 @@ export default function Navigation() {
href={item.href}
className={clsx(
isActive
? 'bg-gray-800 text-vulncheck-blue group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold'
? 'bg-gray-800 text-truevuln-blue group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold'
: 'text-gray-400 hover:text-white hover:bg-gray-800 group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold'
)}
>
<item.icon
className={clsx(
isActive ? 'text-vulncheck-blue' : 'text-gray-400 group-hover:text-white',
isActive ? 'text-truevuln-blue' : 'text-gray-400 group-hover:text-white',
'h-6 w-6 shrink-0'
)}
aria-hidden="true"
+3 -3
View File
@@ -45,7 +45,7 @@ export default function SearchableSelect({
<div className={twMerge("w-full relative", className)}>
<Combobox value={selectedOption} onChange={(val) => onChange(val ? val.value : null)}>
<div className="relative mt-1">
<div className="relative w-full cursor-default overflow-hidden rounded-md bg-white text-left border border-gray-300 focus-within:ring-2 focus-within:ring-vulncheck-blue focus-within:border-vulncheck-blue sm:text-sm">
<div className="relative w-full cursor-default overflow-hidden rounded-md bg-white text-left border border-gray-300 focus-within:ring-2 focus-within:ring-truevuln-blue focus-within:border-truevuln-blue sm:text-sm">
<ComboboxInput
className="w-full border-none py-2 pl-3 pr-10 text-sm leading-5 text-gray-900 focus:ring-0 font-mono placeholder:text-gray-400"
displayValue={(option: SearchableSelectOption | null) => option ? option.label : ''}
@@ -76,7 +76,7 @@ export default function SearchableSelect({
<ComboboxOption
key={option.value}
className={({ active }) =>
`relative cursor-default select-none py-2 pl-10 pr-4 font-mono ${active ? 'bg-vulncheck-blue text-white' : 'text-gray-900'
`relative cursor-default select-none py-2 pl-10 pr-4 font-mono ${active ? 'bg-truevuln-blue text-white' : 'text-gray-900'
}`
}
value={option}
@@ -91,7 +91,7 @@ export default function SearchableSelect({
</span>
{selected ? (
<span
className={`absolute inset-y-0 left-0 flex items-center pl-3 ${active ? 'text-white' : 'text-vulncheck-blue'
className={`absolute inset-y-0 left-0 flex items-center pl-3 ${active ? 'text-white' : 'text-truevuln-blue'
}`}
>
<CheckIcon className="h-5 w-5" aria-hidden="true" />
+6 -1
View File
@@ -64,6 +64,8 @@ export interface Vulnerability {
packages?: VulnerabilityPackage[]; // per-package detail (CVE-2023-48795 hits PuTTY + WinSCP both)
has_fix_any?: boolean; // at least one package has a real fix (not equal to installed)
published_date: string;
last_modified_date?: string | null;
remediation?: string | null; // scanner-provided fix guidance (Nessus solution)
detected_at?: string;
asset_id: number;
asset_hostname?: string;
@@ -131,7 +133,8 @@ export interface Asset {
last_scan?: string;
wazuh_agent_id?: string | null;
nessus_host_uuid?: string | null;
source?: 'wazuh' | 'nessus' | 'manual';
intune_device_id?: string | null;
source?: 'wazuh' | 'nessus' | 'manual' | 'intune';
status: 'active' | 'inactive' | 'decommissioned';
policy_id?: number | null;
description?: string;
@@ -145,6 +148,8 @@ export interface Asset {
// Network-exposure risk dimension (Wazuh syscollector ports)
network_exposure_score?: number | null;
exposed_services?: { port: number; proto: string; service: string; risk: number; local_ip?: string }[] | null;
high_value_score?: number | null;
risk_dimensions?: { role: string; label: string; weight: number }[] | null;
exposure_updated_at?: string | null;
}
+4
View File
@@ -61,3 +61,7 @@ itsdangerous==2.2.0
# SAML 2.0 (Phase 4) — requires system libs xmlsec1, libxml2-dev, libxmlsec1-dev
python3-saml==1.16.0
lxml==5.3.0
# Microsoft product-lifecycle EOL export parsing (Plan O) — reads the
# monthly eos-product-listing .xlsx from download.microsoft.com.
openpyxl==3.1.5