Commit Graph
419 Commits
Author SHA1 Message Date
vulncheckandClaude Opus 5 45248001ec fix(ui): report what the Intune sync did, same as the app scan
The backend log carried OS-EOL and mobile-EOL findings, reactivations, and
Defender's resolved count; the UI showed none of them. A sync that closed 241
Defender CVEs and surfaced 44 mobile EOL issues read as '144 devices, 144
matched, 0 created, 380 app findings' — nothing about the work it had done.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Three things Java needs that no other product did:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

New audit_events.record_affected_package(); wired into both upsert paths
of app_cve_scanner_service and msrc_scan_service (create + existing).
Idempotent per (finding, package name) — re-detection refreshes version,
fix and last_seen instead of appending duplicates. The API already returns
these as 'packages' and the CVE detail page already renders one card per
entry, so Chrome and Edge now appear side by side with their own installed
and fixed versions.
2026-07-26 11:58:33 +02:00