Compare commits

4 Commits
Author SHA1 Message Date
vulncheck ab6d4c6152 fix(kev-alert): separate source badges with a real space
Badges were emitted back-to-back (`</span><span>`) and relied only on
`.src { margin-right: 3px }` for the gap. Mail clients that drop margins on
inline elements rendered them merged: "CISA KEVENISA EUVD",
"CISA KEVENISA EUVDRANSOMWARE".

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

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

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

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

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

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

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

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

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

Also untracks the local editor-assistant config and an empty root
package-lock.json stub that had no package.json beside it.
2026-08-26 15:34:05 +02:00
79 changed files with 310 additions and 235 deletions
+62
View File
@@ -72,3 +72,65 @@ CODEBASE_KNOWLEDGE_BASE.md
# Local agent instructions (auto-generated by gitnexus setup/analyze)
AGENTS.md
CLAUDE.md
# --- Secrets: alle .env-Varianten, Zertifikate, Keys ---
.env.*
!.env.example
*.pem
*.key
*.crt
*.cer
*.p12
*.pfx
sp.key
sp.crt
idp-metadata.xml
secrets/
# --- AI-Tooling-State (nie in einem public Repo) ---
.serena/
.gitnexus/
.aider*
.cursor/
.windsurf/
.continue/
.gemini/
# --- Python-Luecken ---
.venv/
.mypy_cache/
.ruff_cache/
.tox/
.hypothesis/
coverage.xml
.coverage.*
# --- Node/Next auf Root-Ebene (frontend/.gitignore deckt nur frontend/) ---
node_modules/
.next/
out/
.turbo/
*.tsbuildinfo
npm-debug.log*
yarn-error.log*
# --- OS/Editor-Luecken ---
._*
.Spotlight-V100
desktop.ini
.zed/
.fleet/
.history/
# --- Generierte Exporte mit echten Scan-Daten ---
vulnerabilities_report_*.csv
executive_summary_*.pdf
audit_log_*.csv
exports/
uploads/
reports/
*.sqlite
*.dump
# --- Eingebetteter Fremd-Checkout (eigenes Repo, kein Submodul) ---
wazuh-risk-insight/
+4 -4
View File
@@ -1,6 +1,6 @@
# TrueVuln — Project Overview
> Vulnerability Management Dashboard for a Swiss-school IT-Security setup.
> Self-hosted vulnerability management for small and mid-sized IT teams.
> Wazuh agents + Nessus scanner + CISA/ENISA/EPSS threat intel + CIS-Benchmark
> compliance + Unified Risk Score, all behind a single FastAPI + Next.js stack.
>
@@ -299,8 +299,8 @@ docker compose exec backend pytest tests/
```
Smoke test covers auth, RBAC, vuln CRUD, sync mocking. Coverage is partial
— integration paths (Wazuh, Nessus, enrichment HTTP) rely on manual + tester
verification.
— integration paths (Wazuh, Nessus, enrichment HTTP) rely on manual
verification against a live environment.
### Add a new vuln column
@@ -374,4 +374,4 @@ LDAP bind decryption failures, missing SLA mails, etc.).
## 📧 Support
Internal IT-Security team. See `README.md` for project owner contact.
Open an issue on GitHub. For security vulnerabilities in TrueVuln itself, see `SECURITY.md`.
+12 -10
View File
@@ -97,14 +97,16 @@ The CVE detail page shows remediation from every available source side by side:
### AI-Powered Analysis
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 |
|---|---|
| OpenAI | GPT-5.2, GPT-4.1, o3, o3-mini |
| Anthropic | Claude Opus 4.5, Sonnet 4.5, Haiku 4.5 |
| Google Gemini | Gemini 3 Pro, 3 Flash, 2.5 Pro |
| DeepSeek | V3, R1 (Reasoner) |
| Ollama (local) | Llama 3.3, Mistral, CodeLlama, Phi-4 |
| Infomaniak | Llama 3, Mistral 3, Mixtral, Granite, Qwen 3, Gemma 3n |
**Model lists are fetched live** from each provider's own `/models` endpoint the moment you enter an API key -- the dropdown shows exactly what your key can reach, including models released after this README was written. There is no hardcoded model list to keep in sync.
| Provider | Default endpoint | Notes |
|---|---|---|
| OpenAI | `api.openai.com/v1` | |
| Anthropic | `api.anthropic.com/v1` | |
| Google Gemini | `generativelanguage.googleapis.com/v1beta` | key travels in a header, never in the URL |
| DeepSeek | `api.deepseek.com` | OpenAI-compatible wire format |
| Ollama (local) | `localhost:11434` | no API key, no data leaves the host |
| Infomaniak | Swiss-hosted | provider-side web search is enabled for CVE lookups |
> **Ollama in Docker:** Use `http://host.docker.internal:11434/v1` as the Base URL when running TrueVuln in Docker with Ollama on the host.
@@ -235,7 +237,7 @@ Three roles with hierarchical permissions: **Admin > Editor > Readonly**
### 1. Clone the repository
```bash
git clone https://gitea.isuit.ch/vulncheck/vulncheck.git
git clone https://github.com/{{GH_OWNER}}/{{GH_REPO}}.git
cd vulncheck
```
@@ -828,7 +830,7 @@ docker exec vulnmanager-db pg_isready -U vulnmanager
## Support
If you encounter issues or have questions, please [open an issue](https://gitea.isuit.ch/vulncheck/vulncheck/issues) in this repository.
If you encounter issues or have questions, please [open an issue](https://github.com/{{GH_OWNER}}/{{GH_REPO}}/issues) in this repository.
**Email Support:** [support-vulncheck.sq9vd@passmail.net](mailto:support-vulncheck.sq9vd@passmail.net)
@@ -12,8 +12,8 @@ discarded by the override service:
Background: CISA Vulnrichment scores every CVE on three SSVC
dimensions — Exploitation (already stored as
``exploitation_status``), Technical Impact and Automatable. Tester
asked us to surface the latter two so the operator can prioritise
``exploitation_status``), Technical Impact and Automatable. Field
feedback asked to surface the latter two so the operator can prioritise
"total + automatable + active" CVEs (i.e. mass-exploitable, full
takeover) over "partial + no + none".
@@ -7,7 +7,7 @@ Create Date: 2026-05-19 16:00:00.000000
Background: when Base.metadata.create_all() runs at app startup before
alembic upgrade (or after a failed partial migration), Postgres ends
up with tables whose id column has no DEFAULT nextval(...). Inserts
then fail with NotNullViolation. Tester has hit this on every deploy
then fail with NotNullViolation. This was hit on every deploy
involving the 4 new Plan-D/E tables.
This migration is idempotent and runs every time `alembic upgrade
@@ -11,7 +11,7 @@ changed.
Result: operator filter
WHERE exploitation_source='vulnrichment' AND exploitation_status != 'none'
under-reported by orders of magnitude (4 vs ~840 in tester's DB).
under-reported by orders of magnitude (4 vs ~840 in a live DB).
Stamp those rows as vulnrichment-sourced so the filter works. Only
touch rows that have at least one Vulnrichment-supplied SSVC field
@@ -10,7 +10,7 @@ paginated FIRST in SQL then re-sorted only the current page by the
computed score — so "PRIO desc" looked correct row-by-row but pages 2+
were not in global order.
Tester reported: "Spaltensortierung gilt nur teilweise für die
Field report: "Spaltensortierung gilt nur teilweise für die
aktuell angezeigten CVES."
Add two indexed columns. Forward-write paths (sync / enrich / override
+1 -1
View File
@@ -4,7 +4,7 @@ Revision ID: 026
Revises: 025
Create Date: 2026-05-31 16:00:00.000000
Tester request: use Wazuh IT-hygiene (open listeners / ports) as an
Field request: use Wazuh IT-hygiene (open listeners / ports) as an
extra risk dimension — a host with an exposed VNC/RDP/Telnet listener
is network-vulnerabler regardless of its CVE count.
@@ -4,7 +4,7 @@ Revision ID: 027
Revises: 026
Create Date: 2026-06-02 00:00:00.000000
Tester feedback round 2026-06-01:
Field feedback round 2026-06-01:
- Sync-driven reconciliation needs to distinguish Nessus-sourced
assets from Wazuh-sourced ones, so `AssetSource.NESSUS` is added.
- Audit log gains ASSET_DEACTIVATED + ASSET_REACTIVATED event types
@@ -4,7 +4,7 @@ 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
Field feedback round 2026-06-02 (#3 INACTIVE not flipping on reduced
Nessus scan): the event-driven reconciliation in
`app.services.asset_lifecycle.reconcile_missing_from_sync` only inactivates
assets whose `nessus_host_uuid` is in `seen_ids` of a recent sync. Assets
@@ -4,7 +4,7 @@ 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.
Field feedback: "Newly Published" widget sorted wrong even in VIEW ALL.
Root cause — published_date was NEVER populated by any ingest path
(Nessus/Wazuh import only set detected_at), so the column was all-NULL
and the nulls-last sort produced arbitrary order.
@@ -12,7 +12,7 @@ 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
"published vs updated"both dates were explicitly requested
("Die Infos aus den CVEs published date und updated date").
This migration only adds the new column; published_date already exists.
+1 -1
View File
@@ -4,7 +4,7 @@ Revision ID: 030
Revises: 029
Create Date: 2026-06-04 09:00:00.000000
Tester feature: Nessus scan results already carry a per-finding
Field request: Nessus scan results already carry a per-finding
remediation ("solution") text. It was only being appended into the
description blob — surface it in its own column so the CVE detail page can
render a dedicated "Remediation" section below the affected package.
@@ -4,7 +4,7 @@ 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
Reported: a CVE newly created by a Wazuh/Nessus sync appeared in the vuln
list but had NO initial audit event ("new CVE detected on asset X") — the
audit trail started with the first status change. Not revisionssicher.
+1 -1
View File
@@ -4,7 +4,7 @@ Revision ID: 032
Revises: 031
Create Date: 2026-06-10 09:00:00.000000
Tester feature: enrich remediation coverage beyond the Nessus scanner
Field request: enrich remediation coverage beyond the Nessus scanner
solution. External primary sources (MSRC CVRF for Windows + MS products,
later Ubuntu USN / CentOS errata for Linux) provide per-CVE fixes (KB +
fixed build + download URL), workarounds, and mitigations/containment for
+1 -1
View File
@@ -138,7 +138,7 @@ def decrypt_secret(stored: str) -> str:
the AUTH_PROVIDER_CRYPTO_KEY requirement landed would see "Invalid
MFA Code" on the next login and have to reset enrolment, even
though their authenticator app + DB row still hold the correct
secret. This is the bug the tester hit during the v0.12 upgrade.
secret. This is the bug that surfaced during the v0.12 upgrade.
"""
if not stored:
raise InvalidToken("empty stored secret")
+1 -1
View File
@@ -86,7 +86,7 @@ class DefenderClient:
_tries: int = 4) -> Dict[str, Any]:
# MDE allows ~100 calls/minute, and the per-machine vulnerability walk
# is one call per machine — so a sync of any size hits 429, more so when
# an app scan runs alongside it (tester: a wall of "429 Too Many
# an app scan runs alongside it (observed: a wall of "429 Too Many
# Requests"). Treating that as a hard error threw away everything the
# sync had left to do; 429 is a "come back shortly", not a failure.
# Microsoft states the wait in Retry-After, so honour it.
+2 -2
View File
@@ -491,7 +491,7 @@ class WazuhClient:
# 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
# "0 vulns" and its backfill was skipped (observed: agents with
# 24k/27k findings). Scroll has no such ceiling and needs no
# unique sort field.
hits, total_hits = self._scroll_hits(
@@ -523,7 +523,7 @@ class WazuhClient:
# Wazuh-Indexer emits -1 / out-of-range placeholders for
# packages it could not score. Clamp anything outside the
# CVSSv3 spec range [0.0, 10.0] back to None so downstream
# CPR maths cannot produce negative results (tester saw
# CPR maths cannot produce negative results (observed:
# CPR=-9.3 driven by cvss_score=-1).
base_score = None
try:
+2 -2
View File
@@ -246,7 +246,7 @@ class Vulnerability(Base, TimestampMixin):
Sources that carry no score seed a neutral placeholder (MSRC only
publishes a CVSS for a minority of its CVEs), and whatever fills the
score later — the correction cascade, sibling inheritance — used to
leave the placeholder behind: the tester saw Edge findings sitting at
leave the placeholder behind: Edge findings were seen sitting at
MEDIUM with a CVSS of 9.6. Never touches a row whose severity an
operator pinned via the override path (exploitation_source set).
"""
@@ -328,7 +328,7 @@ class Vulnerability(Base, TimestampMixin):
Previous implementation was a pure multiplicative
``CVSS × EPSS × 10`` which collapsed to near-zero for the 99% of
CVEs that have EPSS < 1%, making the score useless for triage
(tester reported CVE-2026-8390 with CVSS 9.8 + EPSS 0.04% scored
(field report: CVE-2026-8390 with CVSS 9.8 + EPSS 0.04% scored
0.04 — a 'critical-CVSS, low-real-world-risk' finding now scores
~60 instead, which actually reflects its triage position).
+2 -2
View File
@@ -219,7 +219,7 @@ def asset_coverage_gap(
):
"""Coverage-gap report: installed packages with NO vuln finding.
Tester: Wazuh's vuln-detector misses some products (MS365,
Field report: Wazuh's vuln-detector misses some products (MS365,
mRemoteNG, …). This surfaces the GAP — packages present in
syscollector but not matched to any open finding — so the operator
can investigate manually. Deliberately makes NO automatic CVE
@@ -545,7 +545,7 @@ async def list_assets(
else:
# 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.)
# ("—") rows. (Observed: 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))
+1 -1
View File
@@ -42,7 +42,7 @@ def _hostnames_for(db: Session, logs: list) -> dict:
The Asset column reads new_value.hostname. Writers fill it today, but every
row written before they did — and any that stores a blank hostname — shows
an empty Asset cell forever, which is exactly what the tester sees on the
an empty Asset cell forever, which is exactly what operators see on the
EOL new-finding events. One extra query per page repairs the whole history
without touching the stored rows.
"""
+16 -16
View File
@@ -234,7 +234,7 @@ class DashboardStatsResponse(BaseModel):
def _auto_reason(new_status, user_name: str) -> str:
"""Fallback reason string when the user submitted no comment.
Tester report: bulk + single status changes accepted blank
Field report: bulk + single status changes accepted blank
comments → audit trail couldn't distinguish manual patches from
scanner auto-patches. Auto-stamp ensures every manual change
still carries who/when in the audit log.
@@ -264,7 +264,7 @@ def log_vulnerability_change(
`source` tags WHERE the change came from
(`manual` / `nessus_sync` / `wazuh_sync` / `verify_patch_rescan`).
`hostname` names the affected asset in the description so the entry can
be attributed and FOUND by host in the audit-log search (tester: reopen
be attributed and FOUND by host in the audit-log search (observed: reopen
rows said which CVE but not which system).
"""
old_val = old_status.value if hasattr(old_status, "value") else str(old_status)
@@ -709,7 +709,7 @@ async def list_vulnerabilities(
# 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-
# only ~4 (field 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
@@ -815,7 +815,7 @@ async def list_vulnerabilities(
# 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
# the top of the list in arbitrary id order (reported: "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")
@@ -826,7 +826,7 @@ async def list_vulnerabilities(
)
# 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
# (reported: "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), '')
@@ -1271,7 +1271,7 @@ async def update_vulnerability_status(
# Wazuh die vuln auch wirklich reportet. Nessus-only rows
# (sources=["nessus"]) können nicht durch einen wazuh-syscollector
# rescan verifiziert werden — der wazuh agent kennt das CVE nicht
# → row blieb ewig in PENDING_VERIFICATION (tester report).
# → row blieb ewig in PENDING_VERIFICATION (field report).
if new_status == VulnerabilityStatus.patched:
wazuh_can_verify = (
vuln.asset
@@ -1872,7 +1872,7 @@ def run_wazuh_vulnerability_sync(db: Session) -> dict:
# Refresh OS name/build from the agent record. Only the separate
# ASSET sync did this before, so a host running just the vuln sync
# + app scan kept a stale os_version — and scan_asset_os judged
# Windows-OS CVEs against the OLD build forever (tester: Server
# Windows-OS CVEs against the OLD build forever (observed: Server
# 2016 host on .9339 = the fix build, findings stuck open showing
# installed .9140).
os_info = agent.get("os") or {}
@@ -1958,7 +1958,7 @@ def run_wazuh_vulnerability_sync(db: Session) -> dict:
# `changed` tracks whether this run actually altered the
# row. Before, vulns_updated counted ONLY a CVSS-score
# change, so a sync that reopened findings and refreshed
# versions still reported "Updated: 0" (tester).
# versions still reported "Updated: 0" (seen in the field).
changed = False
# Track wazuh as a source; reopen if Wazuh sees it again
# after it was marked patched.
@@ -2066,7 +2066,7 @@ def run_wazuh_vulnerability_sync(db: Session) -> dict:
# only be judged once the run is over — see
# reconcile_empty_agents(). This is the collective sync behind
# the "Sync Data" button, which is the one an operator reaches
# for after patching a host (tester: a Windows host Wazuh
# for after patching a host (observed: a Windows host Wazuh
# reported clean kept all 348 OS findings open).
run_stats.setdefault("empty_agents", []).append(asset.id)
logger.warning(
@@ -2466,7 +2466,7 @@ def sync_agent_vulnerabilities(db: Session, wazuh: WazuhClient, agent_id: str,
# apart on its own. Hand it to the caller: a run in which OTHER agents
# returned CVEs proves the API is healthy, so the emptiness is real.
# Without that, a fully patched host kept every finding it ever had
# (tester: Wazuh showed 0 CVEs for ise-dc01 while 348 sat open here).
# (observed: Wazuh showed 0 CVEs for a domain controller while 348 sat open here).
if run_stats is not None:
run_stats.setdefault("empty_agents", []).append(asset.id)
logger.warning(
@@ -2869,7 +2869,7 @@ def refresh_exploit_intel(
# Background EOL job. The synchronous endpoint below still exists (API
# clients, small installs), but the GUI uses start/status: a full EOL check
# walks every asset's package list against endoflife.date + the MS lifecycle
# export and can outlive the browser's patience (tester got "Backend
# export and can outlive the browser's patience (observed: "Backend
# connection failed" while the backend was still working).
_EOL_JOB: dict = {"running": False, "stage": None, "done": 0, "total": 0,
"result": None, "error": None, "finished_at": None}
@@ -3056,7 +3056,7 @@ def run_eol_check(
stats.get("eol_uninstalled_closed", 0) + res["closed"])
db.commit()
# Re-check open MS-lifecycle findings and close the ones a since-corrected
# name match no longer produces (tester: Edge browser vs "Azure Stack Edge").
# name match no longer produces (observed: Edge browser vs "Azure Stack Edge").
try:
stats["ms_lifecycle_closed"] = eol_service.revalidate_ms_lifecycle_findings(db)
except Exception as e:
@@ -3124,7 +3124,7 @@ def run_m365_check_endpoint(
# Background app-scan job — same reason as the EOL check: a full run walks
# every asset's inventory against NVD/OSV and rebuilds the cvelistV5 and MSRC
# indexes on first use, which outlives the browser's HTTP patience. The tester
# indexes on first use, which outlives the browser's HTTP patience. Operators
# saw "Backend connection failed" while the scan ran happily to completion.
_APPSCAN_JOB: dict = {"running": False, "stage": None, "done": 0, "total": 0,
"result": None, "error": None, "finished_at": None}
@@ -3389,14 +3389,14 @@ async def get_vuln_remediations(
# 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
# (observed: 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).
# Server KB (observed: KB5099536 build .33158 suggested at .8655).
best: dict = {}
by_branch: dict = {}
for i in fixes:
@@ -3559,7 +3559,7 @@ async def canonicalize_cve_metadata(
):
"""One-off normalisation of canonical CVE fields across sibling rows.
Tester report: "Same CVE shows different CVSS depending on sort
Field report: "Same CVE shows different CVSS depending on sort
order." Cause: per-asset rows of the same CVE drifted apart over
time — override service touched some, not others.
+2 -2
View File
@@ -276,7 +276,7 @@ def check_sla_breaches():
if policy:
# Disabled policy = operator opted out of SLA tracking
# for these assets. Skip breach evaluation entirely so
# no notification is generated. Tester reported mails
# no notification is generated. Field report: mails
# kept arriving after disabling all policies.
from app.models.policy import PolicyStatus
if policy.status == PolicyStatus.DISABLED:
@@ -739,7 +739,7 @@ def msrc_scan_nightly():
# found. refresh_msrc only stores rows for CVEs already in the
# vulnerabilities table, so running it AFTER the scans is what makes
# a Patch-Tuesday CVE show its KB the next morning. Weekly-only meant
# up to 7 days with no Remediation block at all (tester: CVE-2026-6727
# up to 7 days with no Remediation block at all (observed: CVE-2026-6727
# and CVE-2026-70304, found Wed 12.08., still bare on Fri 14.08.).
# Two months back: the current document plus the one that just rolled
# over. The Sunday job still does the full 18-month backfill.
+1 -1
View File
@@ -2,7 +2,7 @@
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
NVD/cvelistV5 publication (observed: 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.
+17 -17
View File
@@ -37,7 +37,7 @@ 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:
# hiding the CVE from every host on that version until it expires (observed:
# 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)
@@ -109,7 +109,7 @@ _REGISTRY: List[tuple] = [
# the middle — "Asian Language And Spelling Dictionaries Support For Adobe
# Acrobat Reader" is a dictionary pack, and its own version (23.008.20421)
# looks exactly like an old Reader build, so an unanchored match reported
# every Reader CVE against it (tester: CVE-2026-48373 eight times over).
# every Reader CVE against it (observed: CVE-2026-48373 eight times over).
_cpe(r"^(adobe )?acrobat reader(?!.*(language pack|dictionar|spelling|font pack))",
"adobe:acrobat_reader_dc",
also=["adobe:acrobat_reader"]),
@@ -157,7 +157,7 @@ _REGISTRY: List[tuple] = [
# 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).
# matching them is a false positive (observed: CVE-2025-15467).
_cpe(r"openssl(?!.*fips)", "openssl:openssl"),
_cpe(r"openvpn", "openvpn:openvpn"),
_cpe(r"node\.?js", "nodejs:node.js"),
@@ -174,12 +174,12 @@ _REGISTRY: List[tuple] = [
# Windows Python reports an MSI BUILD in the version field ("Python 3.13.7
# (64-bit)" → version 3.13.7150.0) while the semantic version lives in the
# NAME. Matching the build number against NVD's 3.13.x ranges finds nothing,
# so real Python CVEs were silently missed (tester: CVE-2025-12781 et al.).
# so real Python CVEs were silently missed (seen: CVE-2025-12781 et al.).
# The name must END at the interpreter (optionally with its release, as
# "python3" / "python3.11" / "Python 3.13.7"): a trailing "-" or letter
# means a PyPI/distro MODULE, not CPython — "python-dotenv 1.1.1" was read
# as Python 1.1.1 and flagged with every CPython CVE up to 2.7.15
# (tester: CVE-2017-1000158). Same for python-dateutil, python3-pip,
# (observed: CVE-2017-1000158). Same for python-dateutil, python3-pip,
# pythonnet — each its own product with its own version line.
_cpe(r"(?<!\w)python[\d.]*(?![-\w.])(?!.*launcher)", "python:python",
name_ver=True),
@@ -409,7 +409,7 @@ def _platform_ok(tsws, plat: Optional[str]) -> bool:
# "<something> for <product>" names a companion, not the product. Backup tools,
# plugins and connectors are all built this way: "Veeam Explorer for Microsoft
# Teams" is Veeam's software, and its 13.3.2.3 has nothing to do with Teams'
# 25122.x — the tester saw four Teams CVEs land on it. The same shape produced
# 25122.x — four Teams CVEs were seen landing on it. The same shape produced
# Chrome findings on "Citrix Workspace app for Google Chrome" and Firefox
# findings on "Kaspersky Plugin for Mozilla Firefox".
#
@@ -764,7 +764,7 @@ def _query_nvd_cpe(cpe: str, version: str, sap_pl: Optional[int] = None) -> List
# BROWSER versions (up to 126.0.2592.81). Desktop
# Acrobat 26.001.21771 compares below that, so every
# host with Acrobat installed collected the extension's
# CVEs (tester: CVE-2026-48294, CVE-2024-39379,
# CVEs (observed: CVE-2026-48294, CVE-2024-39379,
# CVE-2024-20721, CVE-2024-20709).
tsws.add(parts[10] if len(parts) > 10 else "*")
end_excl = m.get("versionEndExcluding")
@@ -775,7 +775,7 @@ def _query_nvd_cpe(cpe: str, version: str, sap_pl: Optional[int] = None) -> List
# the same as cvelistV5's lessThanOrEqual — but the
# floor is still actionable ("newer than X"), and
# only that path reported it. NVD-sourced findings
# showed a bare "not announced" (tester:
# showed a bare "not announced" (observed:
# CVE-2026-48294 on the Acrobat extension, where
# NVD says "Up to (including) 26.5.2.2").
end_incl = m.get("versionEndIncluding")
@@ -968,7 +968,7 @@ def _upsert(db: Session, asset, pkg_name: str, version: str, c: dict, new_ids: l
if vendor and not existing.package_vendor:
existing.package_vendor = vendor[:255]
# Re-detected with the CURRENT inventory version → refresh it. Fill-only
# left the first-ever version on the row (tester: Firefox showed
# left the first-ever version on the row (observed: Firefox showed
# 'Installed: 150.0.3' while 152.0.5 was on the box).
if version:
existing.package_version = version[:100]
@@ -1008,7 +1008,7 @@ def _upsert(db: Session, asset, pkg_name: str, version: str, c: dict, new_ids: l
# CVE metrics (CVSS/EPSS/KEV) are properties of the CVE, not of one host.
# Only 49 of 436 Edge CVEs carry a CVSSScoreSet in the CVRF, so an MSRC-only
# finding often has no score of its own while the SAME CVE on another asset
# already does (tester: CVE-2026-16423 showed 8.8 on the Chrome row and
# already does (observed: CVE-2026-16423 showed 8.8 on the Chrome row and
# '-' / priority 0 on the Edge row). Inherit from a sibling before scoring.
try:
if row.cvss_score is None:
@@ -1026,7 +1026,7 @@ def _upsert(db: Session, asset, pkg_name: str, version: str, c: dict, new_ids: l
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:
box matching it produced ancient-CVE false positives (observed:
CVE-2008-2798 on 'Firefox 1.0'). The vendor field identifies the stub."""
vendor = (pkg.get("vendor") or "").lower()
return "citrix" in vendor
@@ -1242,7 +1242,7 @@ def prune_stale_packages(db: Session, asset, since: datetime) -> int:
A finding is one row per (cve, asset), but it lists every affected product
and a CVE can hit two products that patch on completely separate
schedules. CVE-2026-17733 hits Chrome and Edge; the tester patched Chrome
schedules. CVE-2026-17733 hits Chrome and Edge; a host 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. The row is only ever
touched while the product is detected, so once it is patched nothing
@@ -1277,7 +1277,7 @@ def prune_stale_packages(db: Session, asset, since: datetime) -> int:
# The finding's own package_name/version columns are written once, when
# it is created, and never revisited — so once the product they name is
# pruned the list view keeps showing it while the detail page lists only
# the surviving ones (tester: list said "Asian Language And Spelling
# the surviving ones (observed: list said "Asian Language And Spelling
# Dictionaries Support For Adobe Acrobat Reader", detail said "Adobe
# Acrobat (64-bit) 26.001.21662" for the same finding). Move the columns
# to a product that is still there.
@@ -1298,7 +1298,7 @@ def _resolve_stale_app_findings(db: Session, asset, touched_cves: set) -> int:
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
open cross-confirmed finding forever (observed: Chrome 150.0.7871.125
installed, fix .115, finding still open). Caller must have had a real
inventory this run."""
from sqlalchemy import or_
@@ -1540,7 +1540,7 @@ def run_app_cve_scan(db: Session, asset_id: Optional[int] = None) -> dict:
# only in run_msrc_scan (nightly / its own button), so a
# patched Windows host kept its MSRC OS findings open until
# that separate job happened to run — the app scan looked
# like it had done nothing (tester).
# like it had done nothing (field report).
os_touched: set = set()
stats["msrc_os_findings"] = stats.get("msrc_os_findings", 0) + \
msrc_scan_service.scan_asset(db, asset, m_index, new_ids,
@@ -1557,7 +1557,7 @@ def run_app_cve_scan(db: Session, asset_id: Optional[int] = None) -> dict:
# AND only on a LIVE asset. A disconnected Wazuh agent still serves
# its last stored syscollector data, so the scan "succeeds" on data
# that may be months old — closing findings from it is unprovable
# (tester: a disconnected host ended up with zero CVEs while Wazuh
# (seen: a disconnected host ended up with zero CVEs while Wazuh
# still listed 31 for it). Wazuh's own vuln sync skips inactive
# agents too, so nothing would ever reopen them.
#
@@ -1638,7 +1638,7 @@ def run_app_cve_scan(db: Session, asset_id: Optional[int] = None) -> dict:
# them standing. Scoped to the same asset_id as the scan itself, so a
# single-asset run stays a single-asset run.
# A fixed_version is written once and never overwritten, so a bad one
# sticks for the life of the finding — the tester's Node 24.13.1 was told
# sticks for the life of the finding — a Node 24.13.1 install was told
# to upgrade to "4.*", a release line taken from another entry of the same
# record. New scans no longer store those (see _fix_target), but the ones
# already written have to be cleared, or they stay wrong forever.
+5 -5
View File
@@ -1,7 +1,7 @@
"""
Asset lifecycle reconciliation.
Tester request: when an asset is decommissioned in the source systems
Field request: when an asset is decommissioned in the source systems
(removed from Wazuh + Nessus via the org's "system no longer exists"
process), TrueVuln should not keep a data corpse. But hard-deleting
loses the vulnerability history + breaks the revisionssicher audit
@@ -171,7 +171,7 @@ def apply_status(db: Session, asset: Asset, new: AssetStatus, reason: str) -> bo
`asset.status = ...` directly leaves the asset in a state the audit log
never mentions the log then shows a deactivation as the last word on an
asset the UI shows as ACTIVE, which is exactly what a revisionssicher
trail must not do (tester report 2026-08-18).
trail must not do (field report 2026-08-18).
DECOMMISSIONED is operator-final: a sync never revives it, mirroring both
reconcile functions in this module.
@@ -272,7 +272,7 @@ def reconcile_missing_from_sync(
every registered agent, disconnected ones included, so a blanket
revive undid the keepalive verdict `status_for_last_seen` had
written seconds earlier deactivate + reactivate on the same
host, in the same sync, forever (tester report 2026-08-18).
host, in the same sync, forever (field report 2026-08-18).
Reviving is the caller's job: it holds the per-host evidence
(keepalive, scan result) and writes the status via `apply_status`.
- The caller is responsible for db.commit(); this function only
@@ -316,7 +316,7 @@ def reconcile_missing_from_sync(
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
# an operator 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 = (
@@ -377,7 +377,7 @@ def reconcile_nessus_by_seen_ids(
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).
skipped everything the dropped hosts stayed ACTIVE (observed 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.
+3 -3
View File
@@ -1,7 +1,7 @@
"""
Shared audit-event writers for sync-driven changes.
Tester requirement (revisionssicher): a finding newly created by a
Field 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.
"""
@@ -96,7 +96,7 @@ def reopen_if_patched(db: Session, vuln, *, reason: str, source: str) -> bool:
each one did it inline without writing a status-change row so only the
positive direction (open patched) ever appeared in the audit log and the
per-CVE Change History. A finding could silently go patched open, which
is exactly the transition an auditor most wants to see (tester flagged it).
is exactly the transition an auditor most wants to see (field report).
Returns True when a reopen actually happened.
"""
@@ -188,7 +188,7 @@ def record_affected_package(db: Session, vuln, *, name: str, version: str = None
row.fixed_version = fixed_version[:100]
# Provenance is per-package and cumulative: a product confirmed by
# app-scan AND MSRC must show BOTH, otherwise whichever scanner
# wrote first owns the "via …" line forever (tester: an app-scan
# wrote first owns the "via …" line forever (seen: an app-scan
# detection kept showing 'via MSRC' after the MSRC run).
if source:
have = [s for s in (row.source or "").split(",") if s]
+12 -12
View File
@@ -62,7 +62,7 @@ _REGISTRY: List[dict] = [
"pairs": [("devolutions", "remote desktop manager")]},
# Oracle Java. NVD leaves these CVEs "Awaiting Enrichment" (no CPEs, no
# affected block), so cvelistV5 is the ONLY structured source — verified by
# the tester across CVE-2026-60526 / -21925 / -47057 / -62574.
# field testing across CVE-2026-60526 / -21925 / -47057 / -62574.
# Both matching routes are covered by `pairs`: the vendor/product block
# ("Oracle Corporation" / "Oracle Java SE") and the CPE product
# (oracle:java_se, plus the older oracle:jre / oracle:jdk spellings — the
@@ -218,7 +218,7 @@ _REGISTRY: List[dict] = [
# honest ranges per release line ("0 .. <3.13.15", "3.14.0 .. <3.14.7").
# name_ver is what makes them comparable: the Windows installer reports an
# MSI BUILD in the version field (Python 3.13.7 → 3.13.7150.0) while the
# semantic version sits in the NAME — exactly the tester's hunch. Read as
# semantic version sits in the NAME — exactly as suspected. Read as
# a build, 3.13.7150.0 is ABOVE every 3.13.x bound, so every Python CVE
# silently passed. Same regex as the CPE registry entry, launcher excluded
# (its name carries no version at all).
@@ -226,7 +226,7 @@ _REGISTRY: List[dict] = [
# same name version and collapse to one finding via the scan-side dedup.
# The name has to END at the interpreter (optionally with its release):
# a trailing "-" or letter means a MODULE — "python-dotenv" resolved here
# and its 1.1.1 was compared against CPython's ranges (tester:
# and its 1.1.1 was compared against CPython's ranges (observed:
# CVE-2017-1000158, fixed in 2.7.15).
{"key": "python", "re": r"(?<!\w)python[\d.]*(?![-\w.])(?!.*launcher)",
"name_ver": True,
@@ -256,7 +256,7 @@ _REGISTRY: List[dict] = [
("apache software foundation", "tomcat"), ("n/a", "apache tomcat")]},
# Require the vendor word: match "Mozilla Firefox" (and "Mozilla Firefox
# ESR"), never a bare "Firefox" — a stray "…Firefox…" in some other
# product's name must not resolve here (tester: "nur 'Mozilla' UND
# product's name must not resolve here (requested: "nur 'Mozilla' UND
# 'Firefox', nicht 'Firefox' alleine"). Windows ARP / Wazuh always carry the
# "Mozilla" prefix, so this loses no real install.
# Android reports the app by its store id, org.mozilla.firefox, which has
@@ -278,7 +278,7 @@ _REGISTRY: List[dict] = [
# 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.
# undetectable — 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"),
@@ -482,7 +482,7 @@ def resolve(name: str) -> Optional[str]:
# Firefox ranges describe the RELEASE train (fresh records list
# only "unaffected 153+"), so matching an ESR build against
# them flags CVEs whose MFSA advisory doesn't touch ESR at all
# (tester: CVE-2026-16395 on Firefox 52 ESR; mfsa2026-70 lists
# (seen: CVE-2026-16395 on Firefox 52 ESR; mfsa2026-70 lists
# no ESR fix). ESR patch state needs the MFSA fixed_in data —
# until that's wired, no match beats a false positive.
return None
@@ -597,7 +597,7 @@ def _fix_target(lt, lte=None):
"""The fix to show. A lessThan bound names a real build; a lessThanOrEqual
only bounds the damage, so it is reported as a floor: ">26.5.2.2".
The tester asked for this twice and he is right: if "26.5.2.2 and earlier"
This was requested twice, and rightly so: if "26.5.2.2 and earlier"
are affected, then anything above it is not, and that is a valid, useful
statement an admin can act on "newer than 26.5.2.2" even when the vendor
named no build. Adobe writes CVE-2026-48294 exactly that way.
@@ -622,7 +622,7 @@ def _fix_build(lt):
CVE-2026-21710 lists Node 20.20.1, 22.22.1, 24.14.0 and 25.8.1 as concrete
entries and then adds "4.0 lessThan 4.*" and "5.0 lessThan 5.*" for the
ancient lines. A wildcard is a release LINE, not a version you can install,
and the tester's host ended up advised to upgrade Node 24.13.1 to "4.*".
and a host ended up advised to upgrade Node 24.13.1 to "4.*".
It matters more than it looks: a fixed_version is only ever filled in once
and never overwritten (see vuln_override_service), so a wrong one sticks
@@ -751,7 +751,7 @@ def _ranges_from_affected(aff: dict) -> List[Tuple[Optional[str], Optional[str],
# as "0 → < 5.0.0-beta3" and GitHub's advisory for the same flaw
# says 5.0.0-beta1 — the bug was written in a 5.0 beta and fixed
# three betas later. With the floor left open, every shipped 4.x
# agent sat below the bound and collected it (tester: a Windows
# agent sat below the bound and collected it (observed: a Windows
# agent on 4.14.5). The floor is the base version's first
# prerelease, which excludes 4.x below it and the finished 5.0.0
# above it while keeping the betas in between — "-1" and not "-0"
@@ -774,7 +774,7 @@ def _ranges_from_affected(aff: dict) -> List[Tuple[Optional[str], Optional[str],
# "unaffected" floor — but ONLY when the record has EXACTLY ONE unaffected
# entry. Firefox ESR CVEs list two ("115.38 lte 115.*" AND "140.13 lte *"),
# and Mozilla writes the ESR floor as an unbounded "lte *", so a "below X"
# rule wrongly catches regular Firefox (tester: ESR-only CVE-2026-16361
# rule wrongly catches regular Firefox (observed: ESR-only CVE-2026-16361
# flagged on Firefox 121/152). Multiple unaffected entries = multi-train
# (ESR + release) record → the inverse heuristic can't tell them apart, so
# skip it rather than risk the false positive. Distinguishing them reliably
@@ -1048,7 +1048,7 @@ def _merge_mozilla_mfsa(db: Session, index: Dict[str, list],
train and which are ESR (read naively, "below 140.14" flags a regular
Firefox 121 for an ESR-only advisory). So every multi-train record 21 of
the 23 CVEs in mfsa2026-74 produced no range and was never indexed. The
two that were are exactly the two the tester saw arrive (CVE-2026-74975,
two that were are exactly the two seen arriving (CVE-2026-74975,
-74989); the rest depended on NVD publishing a CPE, which is why coverage
looked random from one CVE to the next.
@@ -1609,7 +1609,7 @@ def _resolve_stale_vsphere(db: Session, asset, label: str, still_affected: set,
sits behind `if packages:` so it never ran for an ESXi host or a vCenter
appliance. Nothing else ever revisited these findings either: patching a
host wrote the new version and build onto the asset (visible in the GUI)
and left every CVE the OLD build had wide open, run after run (tester:
and left every CVE the OLD build had wide open, run after run (observed:
hosts patched during the day, still flagged after the nightly job).
Same contract as every other reconcile here retract OUR source, and mark
+2 -2
View File
@@ -126,7 +126,7 @@ def _upsert_cve(db: Session, asset, vuln: dict, new_ids: list, software: Optiona
# is never in the payload, and for an OS-level CVE the software label is
# missing entirely — those rows showed an empty package and an empty
# "Installed", which reads as "we know nothing" when the asset record has
# had the OS and its version all along (tester, CVE-2026-64726 on iPhones).
# had the OS and its version all along (seen: CVE-2026-64726 on iPhones).
label = software or (asset.operating_system or None)
installed = asset.os_version if not software else None
@@ -285,7 +285,7 @@ def run_defender_sync(db: Session) -> dict:
# machine entry — and resolving per machine made them fight: the
# machine that no longer lists the CVE closes the finding, the one
# that still lists it reopens it a minute later, every sync
# (tester: CVE-2026-66313, patched 13:41, open 13:42, patched
# (observed: CVE-2026-66313, patched 13:41, open 13:42, patched
# 15:00). A finding may only be closed once EVERY machine behind
# the asset has been asked.
seen_by_asset.setdefault(asset.id, {"asset": asset, "cves": set(),
+1 -1
View File
@@ -895,7 +895,7 @@ def enrich_vulnerabilities(
# Mozilla MFSA severity for fresh Firefox CVEs — Mozilla's authoritative
# `impact` fills the placeholder severity when NVD/cvelistV5 have no score
# yet (the gap the tester hit on brand-new Firefox CVEs). Best-effort.
# yet (the gap seen on brand-new Firefox CVEs). Best-effort.
try:
from app.services import mozilla_advisory_service
stats["mozilla_severity"] = mozilla_advisory_service.apply_mozilla_severity(db, cve_ids)
+10 -10
View File
@@ -240,7 +240,7 @@ _WRAPPER_TOKENS = (
#
# Worse than one wrong row: msexchange is a single-release slug, so the 2007,
# 2010 and 2016 add-ons superseded each other in turn, one status flip per
# package per run — the open→patched→open loop in the tester's change history,
# package per run — the open→patched→open loop seen in the change history,
# with no change on the host at all.
#
# Same shape on the MS-lifecycle side: "Microsoft Lync Server 2013,
@@ -459,7 +459,7 @@ class EOLStatus:
# Window for the "EOL SOON" warning — security support ends within
# this many days from today. Tester: Windows Server 2016 still gets
# this many days from today. Observed: Windows Server 2016 still gets
# monthly CUs until 2027-01, shouldn't be flagged as already-EOL.
EOL_SOON_DAYS = 90
@@ -825,7 +825,7 @@ def _pseudo_cve_id(slug: str, release_name: str,
whatever that sheet says usually "Original Release". Every such product
on a host therefore collapsed into ONE row, EOL-MS-LIFECYCLE-Original_
Release, whose title, package and description were overwritten by whichever
package the sweep touched last. The tester's Lync-2013 finding kept turning
package the sweep touched last. A Lync-2013 finding kept turning
into Visual C++ 2012 and back, with a status flip logged each time.
"""
safe_rel = re.sub(r"[^A-Za-z0-9._-]", "_", release_name)[:30]
@@ -842,7 +842,7 @@ def _pseudo_cve_id(slug: str, release_name: str,
#
# The rule used to apply to every product, on the assumption that "a product
# runs exactly one release per asset". That is true for a browser or an OS and
# false for most Windows components: the tester's host carries Visual C++ 2008,
# false for most Windows components: a single host can carry Visual C++ 2008,
# 2010, 2012, 2013, 2015 and 2022 redistributables at the same time, all of
# them genuinely installed and several genuinely EOL.
#
@@ -1026,7 +1026,7 @@ def upsert_eol_vulnerability(
# Chrome ship every four weeks and the previous version stops getting
# patches the day the next one lands, so every install except the very
# newest is end-of-life by that definition. That is a pending update,
# not a dead product: the tester's host moved 150 → 152 and the finding
# not a dead product: a host moved 150 → 152 and the finding
# simply reappeared as EOL-FIREFOX-152, at CVSS 9.0, on its way to
# critical as the days counted up.
#
@@ -1061,7 +1061,7 @@ def upsert_eol_vulnerability(
# Which source actually made the call. The description and the source badge
# said "endoflife.date" for every finding, including the ones that came from
# Microsoft's lifecycle export — so a row whose id read EOL-MS-LIFECYCLE-…
# claimed endoflife.date as its evidence, and the tester could not tell
# claimed endoflife.date as its evidence, and operators could not tell
# which source to check the date against.
from_msl = (status.product_slug or "") == "ms-lifecycle"
source_name = "Microsoft product lifecycle" if from_msl else "endoflife.date"
@@ -1175,7 +1175,7 @@ def run_eol_for_packages(db: "Session", asset, packages: list, *,
# Published-app stubs (vendor "Delivered by Citrix") carry a placeholder
# version ("1.0"), which reads as ancient and therefore end-of-life. The CVE
# scanner drops them from the inventory outright and this sweep used to
# reuse that filter — which cost real findings: the tester's Intune list has
# reuse that filter — which cost real findings: one Intune list has
# "Microsoft Access 2010" and "Microsoft Visio 2016", both long out of
# support, both delivered by Citrix. Their release is in the NAME, so the
# Microsoft lifecycle listing dates them without consulting the version.
@@ -1196,7 +1196,7 @@ def run_eol_for_packages(db: "Session", asset, packages: list, *,
vendor = (pkg.get("vendor") or "").strip()
# A missing version used to skip the entry entirely — but the whole
# inventory row is still evidence the product is installed, and the
# name-only sources need no version. The tester's host lists its engine
# name-only sources need no version. One host lists its engine
# as "Microsoft SQL Server 2012" with no version at all, so the one
# entry that names the EOL product outright was the one never checked.
if not name:
@@ -1267,7 +1267,7 @@ def run_eol_for_packages(db: "Session", asset, packages: list, *,
def _resolve_stale_ms_lifecycle(db: "Session", asset_id: int, kept: set) -> None:
"""Resolve open MS-lifecycle EOL findings the current run no longer produced.
Without this, fixing a bad nameproduct match (tester: 'Microsoft Edge'
Without this, fixing a bad nameproduct match (observed: 'Microsoft Edge'
browser mis-mapped to the 'Azure Stack Edge' listing) left the wrong finding
open forever, since _supersede_old_eol only fires when a REPLACEMENT is
created. Scoped to the EOL-MS-LIFECYCLE- prefix, which only the package path
@@ -1307,7 +1307,7 @@ def revalidate_ms_lifecycle_findings(db: "Session") -> int:
Path-independent on purpose. _resolve_stale_ms_lifecycle only runs inside
run_eol_for_packages, but the EOL-check endpoint (the button) has its own
loop and never called it so a finding produced by a since-fixed name match
stayed open forever (tester: 'Microsoft Edge' the browser matched the
stayed open forever (observed: 'Microsoft Edge' the browser matched the
'Azure Stack Edge' listing; the match was fixed, the finding was not).
Re-asking the resolver per finding is cheap: the lifecycle rows are memoised
in-process, so this costs one fetch at most.
+1 -1
View File
@@ -1,7 +1,7 @@
"""
Exploit-intel enrichment from third-party catalogs.
Closes the gap noted by tester: "Nutzt Du darüber hinaus auch bereits
Closes a gap raised in the field: "Nutzt Du darüber hinaus auch bereits
alle Infos wie von exploit-db.com? Das ist in der Detailansicht nur
verlinkt fliessen die Infos in den Score?"
+4 -4
View File
@@ -1,7 +1,7 @@
"""
Network-exposure scoring from Wazuh syscollector ports.
Tester request: a host running an exposed remote-control or legacy-
Field request: a host running an exposed remote-control or legacy-
cleartext listener (VNC, RDP, Telnet, SMB, ) is network-vulnerabler
regardless of its CVE count. This service walks the open LISTENING
sockets per asset, classifies the risky ones, and computes a 0-100
@@ -28,7 +28,7 @@ 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
# Weights rebalanced (observed: 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,
@@ -83,7 +83,7 @@ def analyze_ports(ports: List[dict]) -> tuple:
state = str(p.get("state") or "").lower()
proto = str(p.get("protocol") or p.get("proto") or "").lower()
# A listener is the normal proof that a service is running. But Wazuh
# does not always report one: on a Windows Server 2025 DC the tester saw
# does not always report one: on a Windows Server 2025 DC we saw
# 3389 ESTABLISHED in netstat and no listening entry from syscollector
# at all, so RDP scored zero exposure on a box serving live RDP
# sessions. An ESTABLISHED socket whose LOCAL port is the well-known
@@ -109,7 +109,7 @@ def analyze_ports(ports: List[dict]) -> tuple:
# Deduplicate by PORT alone. It used to include the protocol, which was
# harmless while only listeners counted, but a busy host has many
# ESTABLISHED sockets on the same service port — and tcp vs tcp6 made
# even the listeners look like two services. The tester's DC listed
# even the listeners look like two services. One DC listed
# "RDP :3389" four times and LDAP four times, and since every extra
# entry adds 40% of its weight, the exposure score inflated to 100 on
# what is really one RDP and one LDAP service.
+2 -2
View File
@@ -502,7 +502,7 @@ def scan_asset(db: Session, asset, packages: list, new_ids: Optional[list] = Non
continue
# Only a real build number is a fix. Wazuh answers some advisories
# with a commit hash ("44bf114d2f49"), and that was carried through
# verbatim — the tester was told to upgrade to an upstream commit.
# verbatim — operators were told to upgrade to an upstream commit.
# A fixed_version is written once and never revised, so a wrong one
# outlives the finding; empty is recoverable.
c = {"cve": e["cve"], "cvss": e.get("cvss"), "severity": e.get("sev"),
@@ -530,7 +530,7 @@ if __name__ == "__main__":
# ponytail: one self-check for the range parser — the only non-trivial
# logic. Covers every format seen in the live Notepad++ feed.
# Run: python -m app.services.github_repo_advisory_service
assert is_affected("8.9.6.4", "<= v8.9.6.4", "v8.9.7") is True # tester's CVE-2026-57233
assert is_affected("8.9.6.4", "<= v8.9.6.4", "v8.9.7") is True # observed CVE-2026-57233
assert is_affected("8.9.7", "<= v8.9.6.4", "v8.9.7") is False # patched
assert is_affected("8.9.6", "<= v8.9.6.4", "v8.9.7") is True # older affected
assert is_affected("8.9.6.4", "< v8.9.6.4", "v8.9.7") is False # exclusive bound
+3 -3
View File
@@ -92,7 +92,7 @@ def _clean_device_name(device: dict) -> str:
# has no Entra device object of its own — personally-owned / userless mobile
# devices, mostly. It is a placeholder, not an id: every such device carries
# the SAME value, so matching an asset on it welds unrelated devices onto one
# record (tester: several mobile devices missing from the inventory, one
# record (observed: several mobile devices missing from the inventory, one
# surviving asset whose "re-enrolled" log line re-pinned it to a different
# device every few minutes, carrying the findings of all of them).
_PLACEHOLDER_GUID = "00000000-0000-0000-0000-000000000000"
@@ -227,8 +227,8 @@ def _dedupe_reenrolled(devices: list) -> tuple:
process all of them, each `_pin`ning its own id onto the asset last one
in Graph's arbitrary order wins:
AndroidEnterprise-R6GL re-enrolled device id b89b -> a603
AndroidEnterprise-R6GL re-enrolled device id a603 -> b89b
AndroidEnterprise-TESTDEV re-enrolled device id 1111 -> 2222
AndroidEnterprise-TESTDEV re-enrolled device id 2222 -> 1111
That is one sync flipping the asset back onto the DEAD enrolment. Every
inventory read after it then went to the old device: this sync's
+7 -7
View File
@@ -62,7 +62,7 @@ body{font-family:Arial,sans-serif;color:#1f2937;max-width:820px;margin:24px auto
table{width:100%;border-collapse:collapse;margin-top:8px;font-size:13px}
th{text-align:left;background:#f3f4f6;padding:8px 6px;border-bottom:1px solid #e5e7eb;font-size:11px;text-transform:uppercase;color:#6b7280}
td{padding:7px 6px;border-bottom:1px solid #f3f4f6;vertical-align:top}
.src{display:inline-block;padding:1px 6px;border-radius:3px;font-size:10px;font-weight:bold;margin-right:3px;background:#fee2e2;color:#991b1b}
.src{display:inline-block;padding:1px 6px;border-radius:3px;font-size:10px;font-weight:bold;margin-right:3px;white-space:nowrap;background:#fee2e2;color:#991b1b}
.hosts{font-family:monospace;font-size:11px;color:#374151}
.note{font-size:11px;color:#6b7280}
.btn{display:inline-block;margin-top:14px;padding:9px 16px;background:#991b1b;color:#fff;text-decoration:none;border-radius:4px;font-size:13px}
@@ -225,13 +225,13 @@ def render_alert_rows(alerts: List[dict], base_url: str = "") -> str:
name = html.escape(str(a.get("name") or a.get("description") or ""))[:160]
vendor_product = html.escape(
" · ".join(x for x in (a.get("vendor"), a.get("product")) if x))
srcs = "".join(
f'<span class="src">'
f'{html.escape(KEV_SOURCES[s].label if s in KEV_SOURCES else str(s))}'
f'</span>'
for s in a.get("sources") or [])
labels = [html.escape(KEV_SOURCES[s].label if s in KEV_SOURCES else str(s))
for s in a.get("sources") or []]
if a.get("ransomware"):
srcs += '<span class="src">RANSOMWARE</span>'
labels.append("RANSOMWARE")
# Joined with a real space, not just the CSS margin: mail clients that
# drop margins on inline elements otherwise render "CISA KEVENISA EUVD".
srcs = " ".join(f'<span class="src">{x}</span>' for x in labels)
hosts = a.get("hosts") or []
# Hostname plus IP: the recipient has to find the machine, and a
+3 -3
View File
@@ -12,13 +12,13 @@ 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):
Build logic (verified against a real-world 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
Channel mapping (field 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.
"""
@@ -94,7 +94,7 @@ def parse_build(version: str) -> Optional[Tuple[int, int]]:
def channel_for_product(product_name: str) -> str:
"""Tester's rule: name contains 'enterprise' -> MEC, else Current."""
"""Field rule: name contains 'enterprise' -> MEC, else Current."""
return (
CHANNEL_MONTHLY_ENTERPRISE
if "enterprise" in (product_name or "").lower()
+2 -2
View File
@@ -192,7 +192,7 @@ def _drop_android_findings(db: Session, asset) -> int:
They can only get there by mis-attribution one asset that stood for two
physical devices and nothing ever revisits them: the ASB/SMR pass runs
for Android devices only, so on an iOS asset they would stay open forever
with no scan able to clear them (tester: an iPhone showing ASB and SMR
with no scan able to clear them (observed: an iPhone showing ASB and SMR
CVEs). Same closing contract as everywhere else: drop OUR source, close
only when no other scanner still reports the CVE.
"""
@@ -242,7 +242,7 @@ def check_device(db: Session, asset, device: dict,
caller's reconcile pass. The model finding is stored like any other EOL
row (first_detected_by=eol_check), so the detectedApps sweep that runs
right after us on the same asset retracts it as "no longer installed"
a device model is in no app inventory. That is the tester's Samsung flap:
a device model is in no app inventory. That is the observed Samsung flap:
raised and closed seconds apart, every sync, forever."""
count = 0
manuf = (device.get("manufacturer") or "").strip()
+6 -6
View File
@@ -16,8 +16,8 @@ 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
A few true exotics are NOT in the export at all (flagged from the
field): Silverlight and the Visual C++ Redistributables have their own
single pages. Those are hardcoded below their EOL dates are fixed and
never change.
@@ -62,7 +62,7 @@ MSL_TTL_HOURS = 24
# In-process memo. resolve_ms_lifecycle_eol() runs once PER PACKAGE across every
# synced device; without this, a fresh 24h DB cache still let each concurrent
# device re-download the export before the first _store_cache commit landed —
# the tester saw dozens of identical GET .../lifecycle/products/export/ per sync.
# we saw dozens of identical GET .../lifecycle/products/export/ per sync.
# This holds the parsed rows in the worker for a short window so one sync fetches
# at most once. ponytail: module-global memo, fine for a read-only reference list.
_MEM_ROWS: Optional[List[dict]] = None
@@ -177,7 +177,7 @@ def _connector_targets(target: str, vendor: str) -> List[str]:
2012", "SQL Server Browser for SQL Server 2012", "Microsoft VSS Writer for
SQL Server 2012". The prefix-only rule matched none of them, so a host whose
only SQL Server 2008 R2 / 2012 evidence is its service packs and hotfixes
the tester's, every component of the engine itself being an add-on the sweep
a real case, every component of the engine itself being an add-on the sweep
already refuses produced no EOL finding at all, and the ones it had were
retracted as "no longer matches".
@@ -324,7 +324,7 @@ def fetch_lifecycle_data(db: Session, force_refresh: bool = False) -> List[dict]
rows = _parse_xlsx(xlsx.content)
except httpx.HTTPError as e:
# Remember the FAILURE too. Without this every one of the thousands of
# scanned packages re-fetched the export page (tester saw an endless
# scanned packages re-fetched the export page (observed: an endless
# run of GET .../lifecycle/products/export/ and the UI timing out),
# because the memo was only ever set on success.
_MEM_ROWS, _MEM_TS = [], _t.time()
@@ -446,7 +446,7 @@ def resolve_ms_lifecycle_eol(
# GUI for Windows 8.00 64bit (Patch 17)" normalises to "sap gui for
# windows 8 00 64bit patch 17", which contains the "Windows 8" listing
# verbatim (the version's dot becomes a space, so "8.00" ends in the
# token "8"). The tester got a CRITICAL "EOL for 3866 days, security
# token "8"). This produced a CRITICAL "EOL for 3866 days, security
# support ended 2016-01-12" on an SAP client SAP shipped in 2023 — that
# is Windows 8's date, on a product that is not Windows.
#
+9 -9
View File
@@ -42,7 +42,7 @@ SYNTH_DESC_PREFIX = "MSRC reports "
# v2: adding Microsoft Edge to _PRODUCTS changes what the index CONTAINS, and
# a cache built by the previous version has no edge key at all. Bumping the
# setting name retires that cache on deploy instead of serving it for another
# night (tester ran app-scan + MSRC refresh and still saw no Edge findings).
# night (an app-scan + MSRC refresh still produced no Edge findings).
_INDEX_SETTING = "msrc_product_index_v2"
_INDEX_TTL = timedelta(hours=26) # rebuilt nightly; a missed night still serves
@@ -90,7 +90,7 @@ _PRODUCTS: List[dict] = [
"msrc_re": r"^microsoft sharepoint server subscription edition\b",
"label": "Microsoft SharePoint Server Subscription Edition"},
# Microsoft Edge — MSRC is the ONLY machine-readable source for these.
# Edge CVEs are absent from NVD and cvelistV5 (tester checked
# Edge CVEs are absent from NVD and cvelistV5 (checked
# CVE-2026-57989/-57990/-57978: "CVE ID Not Found" at NVD, no match in
# cvelistV5), while MSRC carries CVSS, severity AND the fixed build. The
# July CVRF alone lists 436 Edge CVEs with a FixedBuild.
@@ -105,7 +105,7 @@ _PRODUCTS: List[dict] = [
# fix, so it correctly drops out.
#
# WebView2 is excluded — it ships as its own package with its own version
# (the tester's host had Edge .83 next to WebView2 .99), so folding it in
# (one host had Edge .83 next to WebView2 .99), so folding it in
# here would compare one product's build against the other's fix.
{"key": "edge", "kind": "pkg", "branch": False,
"match_re": r"microsoft edge(?!.*webview)",
@@ -316,7 +316,7 @@ def affected_cves(entries: List[dict], installed: str, branch_match: bool = True
# Teams ships 26183.1003.4002.4460 while MSRC states its fix as
# 25060212043 — a single eleven-digit stamp. Python compares those
# tuples element by element, so 26183 < 25060212043 came out True and
# a current Teams was reported vulnerable (tester, CVE-2025-49731).
# a current Teams was reported vulnerable (observed: CVE-2025-49731).
# Differing segment counts mean the two sides are not the same scheme,
# and no ordering between them carries meaning.
if len(bt) != len(inst_t):
@@ -483,7 +483,7 @@ def _upsert(db: Session, asset, product: str, installed: str, hit: dict, new_ids
# CVE metrics (CVSS/EPSS/KEV) are properties of the CVE, not of one host.
# Only 49 of 436 Edge CVEs carry a CVSSScoreSet in the CVRF, so an MSRC-only
# finding often has no score of its own while the SAME CVE on another asset
# already does (tester: CVE-2026-16423 showed 8.8 on the Chrome row and
# already does (observed: CVE-2026-16423 showed 8.8 on the Chrome row and
# '-' / priority 0 on the Edge row). Inherit from a sibling before scoring.
try:
if row.cvss_score is None:
@@ -522,7 +522,7 @@ def _resolve_stale(db: Session, asset, touched: set, considered: set) -> int:
# the app scan writes the inventory's own wording — "Microsoft Edge" —
# while this pass only ever looked for "Microsoft Edge
# (Chromium-based)". The row was therefore invisible to its own
# reconcile and stayed open forever: the tester's host sat on Edge
# reconcile and stayed open forever: a host sat on Edge
# .105, long past the .99 fix, with the finding still open after
# repeated scans.
# This same function serves the OS pass, whose labels resolve through
@@ -536,7 +536,7 @@ def _resolve_stale(db: Session, asset, touched: set, considered: set) -> int:
# for a CVE that hits Chrome and Edge alike that is often
# "Google Chrome" — which resolves to no MSRC product at all,
# so the finding was skipped and never closed even though MSRC
# tracks its Edge half (tester: CVE-2026-16807, Edge long past
# tracks its Edge half (seen: CVE-2026-16807, Edge long past
# the fix, still open). The per-package rows carry the product
# this pass actually knows about, so ask them too.
pkg_names = [p.package_name for p in (v.packages or [])]
@@ -575,7 +575,7 @@ def _os_labels(asset) -> set:
("Microsoft Windows Server 2025 Standard", "... 2016 Datacenter"). A
finding first seen by another scanner therefore never matched the MSRC
label, so once that scanner retracted its source the row was left open
forever with msrc as the last claimant exactly what the tester saw on
forever with msrc as the last claimant exactly what was seen on
fully patched 2016 and 2025 hosts.
Deliberately NOT a prefix match: "Microsoft Windows Server 2012" is a
@@ -601,7 +601,7 @@ def resolve_stale_os(db: Session, asset, touched: set) -> int:
(asset OS string + build)
_resolve_stale compares package_name exactly, so the Wazuh spelling never
matched and those rows stayed open with msrc as the last claimant the
tester's CVE-2026-49798 (open, first seen by wazuh) next to CVE-2026-50518
observed CVE-2026-49798 (open, first seen by wazuh) next to CVE-2026-50518
(patched, first seen by app-scan) on the very same host.
So: exact match for the labels, plus a PREFIX match on the asset's own OS
+2 -2
View File
@@ -167,7 +167,7 @@ def _list_recent_docs(client: "httpx.Client", months_back: int) -> List[str]:
# on it put junk at the tail, so the 2-month catch-up ingest could miss the
# current month entirely while the 18-doc index build still saw it — a CVE
# detected by the fixed-build scan with no Remediation block behind it
# (tester: CVE-2026-62727, found 20.08., still bare after the nightly).
# (observed: CVE-2026-62727, found 20.08., still bare after the nightly).
docs.sort(key=_doc_month)
return docs[-months_back:]
@@ -186,7 +186,7 @@ def _backfill_description(db: Session, cve_id: str, text: str) -> int:
msrc_scan_service creates its rows with a placeholder description the
fixed-build index carries no flaw text. The app-scan backfill only fills
an EMPTY description, so an MSRC-only finding kept the placeholder
forever (tester: CVE-2026-6727 showed "MSRC reports Microsoft Windows
forever (observed: CVE-2026-6727 showed "MSRC reports Microsoft Windows
Server 2025 is fixed in build ..." where the cross-confirmed
CVE-2026-70304 next to it showed the real text). The monthly document does
carry it; write it over the placeholder and only over the placeholder.
+4 -4
View File
@@ -103,7 +103,7 @@ def _find_or_create_asset(
# 2) Hostname (case-insensitive) — try full + short forms so a
# Nessus FQDN like 'host01.umgebung.local' matches a Wazuh
# asset registered as just 'host01' (Wazuh-Agent name is
# typically the short hostname). Tester reported duplicate
# typically the short hostname). Field reports showed duplicate
# assets created from FQDN/short mismatches.
short = hostname.split(".")[0] if hostname else ""
candidates = []
@@ -301,7 +301,7 @@ def _upsert_nessus_eol(
# (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").
# (field report: "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:
@@ -471,7 +471,7 @@ def run_nessus_sync(
# - 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
# nothing got inactivated (observed bug). Tracking the
# matched asset.id sidesteps the missing-uuid case.
if asset.nessus_host_uuid:
seen_nessus_uuids.add(asset.nessus_host_uuid)
@@ -981,7 +981,7 @@ def run_nessus_sync(
def reconcile_legacy_nessus_assets(db: Session) -> dict:
"""One-shot helper for testers: flip ACTIVE NESSUS-sourced assets that
"""One-shot helper for operators: flip ACTIVE NESSUS-sourced assets that
have no `nessus_host_uuid` pinned (legacy rows from before the
reconcile path was hardened) to INACTIVE.
+1 -1
View File
@@ -8,7 +8,7 @@ Inputs:
Output:
URS = round((AVS + ASS) / 2) × criticality_multiplier (capped 100)
Severity bands (per tester spec):
Severity bands (per spec):
90-100 CRITICAL eskalation
70-89 HIGH 24-48 h
40-69 MEDIUM 1-2 Wochen
+1 -1
View File
@@ -14,7 +14,7 @@ read. From CVE-2026-47876 (VMSA, ESX) and CVE-2026-59310 (vCenter):
None of those bounds is a version number. `_is_version` rejects them, so
`_ranges_from_affected` dropped the entries and every vSphere CVE was invisible
NVD is no help either (both records sit there with no configuration at all,
which is exactly what the tester reported).
which is exactly what was reported from the field).
The one comparable quantity on both sides is the BUILD NUMBER. ESXi bounds
carry it inline; vCenter bounds name a release ("8.0 U3k") whose build lives in
+6 -6
View File
@@ -189,7 +189,7 @@ def _fix_from_version_string(value: Optional[str]) -> Optional[str]:
# the finding lives on). When one vuln row gets updated with a value
# from an authoritative source, every sibling row sharing the same
# cve_id should converge — otherwise sort-by-priority / sort-by-CPR
# shows the SAME CVE with different scores across hosts (tester
# shows the SAME CVE with different scores across hosts (field
# report: "Same-CVE / divergent-CVSS rows").
#
# `fixed_version` is INTENTIONALLY excluded — Plan I (multi-stream
@@ -676,7 +676,7 @@ class VulnOverrideService:
# Stage 4 — GitHub Security Advisories. Backstop for CVEs still missing
# a score after NVD + cvelistV5, i.e. very fresh CVEs GHSA has but the
# others don't yet (the gap the tester hit). Self-throttles on the
# others don't yet (the gap seen in the field). Self-throttles on the
# GitHub rate limit; a github_pat setting lifts it to 5000 req/h.
missing = [c for c in cve_ids_upper if _needs_cvss(c)]
if missing:
@@ -737,7 +737,7 @@ class VulnOverrideService:
"""GitHub Security Advisories — last-resort CVSS/severity/description.
GHSA mirrors CVEs that can still be missing from NVD and cvelistV5 when
very fresh (the gap the tester hit on new Firefox/Notepad++ CVEs). The
very fresh (the gap seen on new Firefox/Notepad++ CVEs). The
global-advisory API returns cvss + severity + description keyed by CVE.
Optional PAT (setting `github_pat`) lifts the rate limit 60 5000/h;
the loop stops cleanly when the limit is hit.
@@ -1179,8 +1179,8 @@ class VulnOverrideService:
- ``containers.adp[].metrics[].cvssV3_1`` secondary score
from an Authorized Data Publisher (CISA).
Earlier versions of this parser only checked ADP. Tester
reported CVE-2026-40416 (MS Edge) not being corrected root
Earlier versions of this parser only checked ADP. A field
report showed CVE-2026-40416 (MS Edge) not being corrected root
cause: Microsoft put CVSS 4.3 in the CNA container, CISA's
ADP container only carries SSVC, so we never saw the score.
@@ -1552,7 +1552,7 @@ class VulnOverrideService:
# changed, not just CVSS/severity. Earlier behaviour only pinned the
# source on CVSS/severity diffs, so a CVE whose Wazuh CVSS happened
# to already match Vulnrichment got SSVC fields written but
# exploitation_source stayed NULL. Tester filter
# exploitation_source stayed NULL. The filter
# WHERE exploitation_source='vulnrichment' AND exploitation_status != 'none'
# then under-reported by orders of magnitude (4 vs the real ~840).
if changes["has_changes"] and not dry_run:
+1 -1
View File
@@ -220,7 +220,7 @@ html[data-theme="mid"] .scroll-visible-x::-webkit-scrollbar-thumb, html[data-the
}
/* ---- opacity / arbitrary-value variants (own class names, missed by the
* plain remaps above tester: widget headers + sticky list header stayed
* plain remaps above observed: widget headers + sticky list header stayed
* light-gray under light text) ---- */
html[data-theme="mid"] .bg-gray-50\/50, html[data-theme="dark"] .bg-gray-50\/50 { background-color: var(--tv-surface-2); }
html[data-theme="mid"] .bg-white\/50, html[data-theme="dark"] .bg-white\/50 { background-color: var(--tv-surface-3); }
+6 -6
View File
@@ -210,7 +210,7 @@ export default function Dashboard() {
// Number of enabled scan schedules — drives the "Sync active" badge
// in the stat cards. Hardcoded label before this commit always said
// "Sync active" even when no schedules existed, which tester flagged
// "Sync active" even when no schedules existed, which was flagged
// as misleading.
const [activeScheduleCount, setActiveScheduleCount] = useState<number | null>(null);
// Compliance widget feed
@@ -239,7 +239,7 @@ export default function Dashboard() {
// Two split widgets — Recent Critical (high impact, recently changed)
// and Newly Published (chronological first-seen) — plus a third for
// endoflife.date EOL pseudo-CVEs so unsupported software has its own
// dashboard surface (tester request).
// dashboard surface (requested from the field).
const [criticalVulns, setCriticalVulns] = useState<Vulnerability[]>([]);
const [eolVulns, setEolVulns] = useState<Vulnerability[]>([]);
const [mobileVulns, setMobileVulns] = useState<Vulnerability[]>([]);
@@ -257,7 +257,7 @@ export default function Dashboard() {
// Recent Critical: CVSS ≥ 8 OR KEV OR EUVD. Feeder sorts by CVE
// published date desc so the widget actually shows RECENT criticals
// (priority-sorted it pinned the same old 2021 KEV heavyweights
// forever — tester). Client tie-breaks same-day CVEs by CPR desc.
// forever — observed). Client tie-breaks same-day CVEs by CPR desc.
// distinct_cve + deep limit: the newest rows are mostly low/medium,
// so a shallow per-asset window left only 2 criticals (same starving
// Newly Published had before distinct_cve).
@@ -336,7 +336,7 @@ export default function Dashboard() {
// is all there is: Chrome CVEs ship no CVSS at all (Google states
// "Chromium security severity: Critical" in prose instead), and NVD
// often never scores them. Judging those on the score alone kept
// every critical Chrome CVE off this widget — the tester's dashboard
// every critical Chrome CVE off this widget — a live dashboard
// read "No data" while a batch of them sat in Newly Published.
const isCritical =
(v.cvss_score ?? 0) >= 8.0
@@ -427,8 +427,8 @@ export default function Dashboard() {
{ name: 'LOW', value: stats?.low_count, change: '+0', changeType: 'decrease', color: 'text-green-600' },
];
return (
// Full available width (minus AppShell padding) — tester wanted the
// empty left/right gutters used on wide monitors. No max-width cap.
// Full available width (minus AppShell padding) — the empty left/right
// gutters should be 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">
+3 -3
View File
@@ -1229,7 +1229,7 @@ export default function SettingsPage() {
severity_upper: 'HIGH',
cvss_score: '7.5',
asset_hostname: 'webserver-01',
asset_ip: '10.10.66.30',
asset_ip: '10.0.0.30',
package_name: 'openssl-3.0.2',
title: 'Remote Code Execution in OpenSSL',
description: 'A buffer overflow vulnerability in OpenSSL allows remote attackers to execute arbitrary code via crafted network packets. This affects all versions prior to 3.0.3.',
@@ -1968,7 +1968,7 @@ export default function SettingsPage() {
value={wazuhConfig.api_url}
onChange={(e) => setWazuhConfig({ ...wazuhConfig, api_url: e.target.value })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-truevuln-blue focus:ring-truevuln-blue sm:text-sm h-12 px-4"
placeholder="https://10.10.66.100:55000"
placeholder="https://wazuh.local:55000"
/>
</div>
@@ -2007,7 +2007,7 @@ export default function SettingsPage() {
value={wazuhConfig.indexer_url}
onChange={(e) => setWazuhConfig({ ...wazuhConfig, indexer_url: e.target.value })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-truevuln-blue focus:ring-truevuln-blue sm:text-sm h-12 px-4"
placeholder="https://10.10.66.102:9200"
placeholder="https://wazuh-indexer.local:9200"
/>
</div>
+4 -4
View File
@@ -293,7 +293,7 @@ function AppCveScanButton({ onDone }: { onDone: () => void }) {
// rendered the empty result as "0 assets, 0 findings
// (0 new)" while the backend scan ran happily on.
// A 401 replayed after a token refresh does that, and
// so does an HTML body from a login redirect (tester:
// so does an HTML body from a login redirect (observed:
// showed up after ~15-30 min with several tabs open —
// i.e. exactly when the access token expired
// mid-scan).
@@ -319,7 +319,7 @@ function AppCveScanButton({ onDone }: { onDone: () => void }) {
// found — and report ALL of it. This used to name
// the counters one by one, so anything the backend
// reported but the list did not mention stayed
// invisible (tester: log said "11 FP-suppressed",
// invisible (observed: log said "11 FP-suppressed",
// button said "0 findings (0 new)").
setResult(formatFindingsResult(st.result));
onDone();
@@ -505,7 +505,7 @@ function VulnerabilitiesContent() {
const overridePollRef = useRef<NodeJS.Timeout | null>(null);
// Sortierung (klickbare Header)
// Default sort = CPR desc — tester: CPR is the best single risk metric
// Default sort = CPR desc — 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');
@@ -706,7 +706,7 @@ function VulnerabilitiesContent() {
// Initialize from URL params. Dashboard "VIEW ALL" deep-links pass
// sort_by/sort_order + filter flags; without reading them here both
// links landed on the default view ("beides gleich und nicht
// passend" — tester).
// passend" — field report).
useEffect(() => {
const assetId = searchParams.get('asset_id');
const severity = searchParams.get('severity');
+1 -1
View File
@@ -3,7 +3,7 @@
Adobe's own name appears inside the names of its add-ons, and those add-ons
carry version numbers that look exactly like old Reader builds the dictionary
pack ships as 23.008.20421. An unanchored match therefore reported every Reader
CVE against them (tester: CVE-2026-48373 listed eight times on one host, none
CVE against them (observed: CVE-2026-48373 listed eight times on one host, none
of them the Reader itself).
"""
import os
+1 -1
View File
@@ -34,7 +34,7 @@ def test_both_spellings_are_the_same_build():
def test_current_acrobat_is_not_affected_by_a_2019_cve():
# The reported false positives, verbatim from the tester's inventory.
# The reported false positives, verbatim from a field inventory.
assert not _hit("26.001.21771") # Adobe Acrobat (64-bit)
assert not _hit("26.001.21662") # same, older host
assert not _hit("19.012.20035") # Acrobat Reader DC - Deutsch, patched
@@ -1,6 +1,6 @@
"""Android bulletin CVEs never stay on a non-Android device — run: python tests/test_android_findings_wrong_platform.py
Tester report 2026-08-19: an iOS asset showed Android Security Bulletin and
Field report 2026-08-19: an iOS asset showed Android Security Bulletin and
Samsung SMR CVEs. They arrived while a matching bug let one asset stand for
two physical devices (see test_intune_shared_aad_id), and nothing could ever
clear them again: the ASB/SMR pass only runs for Android devices, so on an iOS
+1 -1
View File
@@ -125,7 +125,7 @@ def _test_wazuh_last_scan_is_not_liveness():
"""
now = datetime.now()
# Tester's agent 105 six weeks on: keepalive frozen, last_scan fresh.
# Agent 105 six weeks on: keepalive frozen, last_scan fresh.
stale_agent = Asset(hostname="n1", status=AssetStatus.ACTIVE,
source=AssetSource.WAZUH, wazuh_agent_id="105")
stale_agent.last_scan = now - timedelta(hours=6)
+2 -2
View File
@@ -1,6 +1,6 @@
"""Every status write is audited — run: python tests/test_asset_status_audit.py
Tester report 2026-08-18: the audit log's last word on an asset was
Field report 2026-08-18: the audit log's last word on an asset was
"active → inactive" (17.8., 06:44), while the inventory showed it ACTIVE.
Nothing had gone wrong with the status the grace window introduced that
morning correctly revived a merely-disconnected Wazuh agent but the agent
@@ -30,7 +30,7 @@ KEEPALIVE = datetime.now() - timedelta(days=2) # disconnected, inside the 42d
class _FakeWazuh:
"""One agent, disconnected — exactly the tester's agent 105."""
"""One agent, disconnected — exactly the reported agent 105."""
def __init__(self, *a, **k):
pass
+9 -9
View File
@@ -27,7 +27,7 @@ def _ver(name, version="25.1.60.0"):
def demo():
# Verbatim inventory rows from the tester's estate. 25.1.60.0 is the
# Verbatim inventory rows from a production estate. 25.1.60.0 is the
# 2026 first-customer-ship build, i.e. no update applied.
assert _ver("AutoCAD LT 2026 - Deutsch (German)") == \
("cpe:autodesk:autocad_lt", "2026.0.0")
@@ -50,7 +50,7 @@ def demo():
def demo_family():
"""The wider Autodesk family, from the tester's real inventory.
"""The wider Autodesk family, from a real production inventory.
Two AutoCAD LT generations sit on one host, and every product ships
language packs as separate entries with the SAME version as the
@@ -105,27 +105,27 @@ def demo_advisory():
for start, lt in (("2027.0.0", "2027.1.0"),
("2026.0.0", "2026.1.2")))
tester = ("Autodesk AutoCAD LT 2026 - Deutsch (German)", "25.1.60.0")
key, ver = scanned(*tester)
stock = ("Autodesk AutoCAD LT 2026 - Deutsch (German)", "25.1.60.0")
key, ver = scanned(*stock)
assert key == "autodesk-autocad-lt", key
assert ver == "2026.0.0", ver
assert affected(ver), "the tester's stock 2026 install IS affected"
assert affected(ver), "the stock 2026 install IS affected"
# …and the update Autodesk names as the mitigation clears it. This is the
# half a bare "2026" can never do.
assert scanned(tester[0], "25.1.172.0")[1] == "2026.1.2"
assert scanned(stock[0], "25.1.172.0")[1] == "2026.1.2"
assert not affected("2026.1.2")
# Every step in between stays affected.
for build, rel in (("25.1.74.0", "2026.0.1"), ("25.1.122.0", "2026.1"),
("25.1.164.0", "2026.1.1")):
assert scanned(tester[0], build)[1] == rel, build
assert scanned(stock[0], build)[1] == rel, build
assert affected(rel), build
# A build newer than the table resolves to the newest row it is above —
# over-reporting rather than hiding, deliberately.
assert scanned(tester[0], "25.1.180.0")[1] == "2026.1.2"
assert scanned(stock[0], "25.1.180.0")[1] == "2026.1.2"
# 2027 GA is in the same advisory; 2025 and 2022 are not.
assert affected(scanned("Autodesk AutoCAD LT 2027", "26.0.60.0")[1])
assert not affected(scanned(tester[0].replace("2026", "2025"), "25.0.58.0")[1])
assert not affected(scanned(stock[0].replace("2026", "2025"), "25.0.58.0")[1])
print("autodesk advisory OK")
+1 -1
View File
@@ -5,7 +5,7 @@ inside a browser. The Acrobat extension ships its own CVEs (CVE-2026-48294,
Chrome, up to 26.5.2.2) which therefore went nowhere or, until c88eb45,
onto the desktop application, whose CPE NVD shares with it.
Data below is verbatim from the tester's IT Hygiene index.
Data below is verbatim from a live IT Hygiene index.
"""
import os
import sys
+1 -1
View File
@@ -29,7 +29,7 @@ def demo():
# CVE-2025-32919's three branch bounds, verbatim.
branches = [("2.4.0", "2.4.0p13"), ("2.3.0", "2.3.0p38"), ("2.2.0", "2.2.0p46")]
# The tester's agent: 2.4.0p12 — affected, and only by its own branch.
# The observed agent: 2.4.0p12 — affected, and only by its own branch.
assert [_affected("2.4.0p12", s, lt, None) for s, lt in branches] == \
[True, False, False]
# Patched on that branch.
+2 -2
View File
@@ -3,7 +3,7 @@
The CVE scanner drops the stub outright, and rightly: its version is a
placeholder ("Firefox 1.0"), so version-based matching reports decade-old CVEs
on software that is not installed. The EOL sweep reused that same filter and
inherited a false NEGATIVE the tester's Intune inventory lists
inherited a false NEGATIVE a reported Intune inventory lists
Microsoft Access 2010 1.0 Delivered by Citrix
Microsoft Visio 2016 1.0 Delivered by Citrix
@@ -82,7 +82,7 @@ def demo():
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
db = sessionmaker(bind=engine)()
asset = Asset(hostname="ise-citrix-vdi", ip_address="10.0.0.11")
asset = Asset(hostname="citrix-vdi", ip_address="10.0.0.11")
db.add(asset)
db.commit()
+1 -1
View File
@@ -21,7 +21,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.services import eol_service as E
from app.services import ms_lifecycle_service as M
# The tester's Wazuh inventory, verbatim.
# A real Wazuh inventory, verbatim.
INVENTORY = [
("Microsoft .NET 8.0.29 - Windows Server Hosting", "8.0.29.26325", "8"),
("Microsoft .NET Host - 10.0.10 (x64)", "80.40.55332", "10"),
+4 -4
View File
@@ -1,6 +1,6 @@
"""A mobile device-model EOL finding must survive its own sync — run: python tests/test_eol_mobile_flap.py
Tester report 2026-08-21: the audit log shows the same two Samsung findings
Field report 2026-08-21: the audit log shows the same two Samsung findings
flipping patched -> open -> patched every Intune sync, seconds apart:
EOL-SAMSUNG-GALAXY-TAB-galaxy-tab-a8 patched -> open 02:25:06
@@ -11,7 +11,7 @@ model finding, then the detectedApps sweep in the same loop iteration
reconciles the asset's EOL findings against the app inventory it just read —
and the model finding is not in an app inventory, so it reads as "no longer
installed" and gets closed again. The device ends every sync with its EOL
hidden, which is the false negative the tester sees.
hidden, which is the false negative operators see.
Pinned here: the sync leaves the finding it raised itself open.
"""
@@ -58,8 +58,8 @@ class _FakeClient:
"""Graph, minus Graph. detectedApps returns a real-looking list, which is
what arms the reconcile."""
def get_managed_devices(self):
return [_device("AndroidEnterprise-R9YT", "SM-X205", "dev-tab-a8"),
_device("AndroidEnterprise-R5SC", "SM-G525F", "dev-xcover5")]
return [_device("AndroidEnterprise-TESTTAB01", "SM-X205", "dev-tab-a8"),
_device("AndroidEnterprise-TESTXCOV01", "SM-G525F", "dev-xcover5")]
def get_detected_apps(self, device_id):
return [{"name": "Microsoft Teams", "version": "1500.0.0"}]
+4 -4
View File
@@ -1,6 +1,6 @@
"""A Microsoft update names its product AFTER the "for" — and keeps it.
The tester's host runs SQL Server 2008 R2 and 2012, both long end-of-life, and
A reported host runs SQL Server 2008 R2 and 2012, both long end-of-life, and
every EOL finding it had was retracted overnight as "MS lifecycle no longer
matches this installed product (re-evaluated not end-of-life)". The listing
had to PREFIX the inventory name, and none of the host's evidence is shaped
@@ -61,7 +61,7 @@ ROWS = [
MS = "Microsoft Corporation"
# (name, version, vendor) — the tester's inventory, plus the controls.
# (name, version, vendor) — that host's inventory, plus the controls.
EOL_EXPECTED = (
("Service Pack 1 for SQL Server 2008 R2 (KB2528583)", "10.51.2500.0", MS),
("Service Pack 4 for SQL Server 2012 (KB4018073)", "11.4.7001.0", MS),
@@ -98,7 +98,7 @@ def demo():
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
db = sessionmaker(bind=engine)()
asset = Asset(hostname="ise-sqlhost", ip_address="10.0.0.11")
asset = Asset(hostname="sqlhost", ip_address="10.0.0.11")
db.add(asset)
db.commit()
@@ -124,7 +124,7 @@ def demo():
# The nightly re-check judges from the stored row. Without the vendor on it,
# every finding above would close the same night it was raised — the flap
# the tester saw in the audit log, open → patched with nothing changed.
# observed in the audit log, open → patched with nothing changed.
closed = E.revalidate_ms_lifecycle_findings(db)
assert closed == 0, f"revalidation retracted {closed} fresh finding(s)"
assert set(open_rows()) == set(rows), "revalidation moved a finding"
+2 -2
View File
@@ -1,6 +1,6 @@
"""A Microsoft lifecycle listing may only match a product name it PREFIXES.
The tester's screenshot: "SAP GUI for Windows 8.00 64bit (Patch 17)", a client
A field screenshot: "SAP GUI for Windows 8.00 64bit (Patch 17)", a client
SAP shipped in 2023, reported CRITICAL "EOL for 3866 days — security support
ended 2016-01-12". That date is Windows 8's. The name normalises to
"sap gui for windows 8 00 64bit patch 17" and the old containment check found
@@ -82,7 +82,7 @@ def demo():
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
db = sessionmaker(bind=engine)()
asset = Asset(hostname="ise-sapclient", ip_address="10.0.0.9")
asset = Asset(hostname="sapclient", ip_address="10.0.0.9")
db.add(asset)
db.commit()
+1 -1
View File
@@ -2,7 +2,7 @@
They release every four weeks and the previous version stops receiving patches
the day the next one lands, so every install except the newest is end-of-life
by that definition. The tester's host moved Firefox 150 -> 152 and the finding
by that definition. One host moved Firefox 150 -> 152 and the finding
came straight back as EOL-FIREFOX-152 at CVSS 9.0, escalating toward critical
as the days counted up outranking genuinely abandoned software like Exchange
Server 2016.
+1 -1
View File
@@ -1,6 +1,6 @@
"""EOL supersede must not fire on side-by-side products.
The tester's host has Visual C++ 2008, 2010, 2012, 2013, 2015 and 2022
One reported host has Visual C++ 2008, 2010, 2012, 2013, 2015 and 2022
redistributables installed at once normal on Windows, and several of them
genuinely EOL. Supersede assumed one release per product, so within a single
scan the 2008 entry closed the 2013 finding as "superseded", the 2013 entry
+3 -3
View File
@@ -1,6 +1,6 @@
"""A repeated EOL sweep over an unchanged inventory must change nothing.
The tester's audit log filled with EOL-MSEXCHANGE-2007 / -2010 / -2016 flipping
The audit log filled with EOL-MSEXCHANGE-2007 / -2010 / -2016 flipping
openpatchedopen, several times per run, on hosts where nothing had changed
including one running only Exchange Server Subscription Edition, which is fully
supported and should carry no Exchange finding at all.
@@ -113,8 +113,8 @@ def demo():
Base.metadata.create_all(engine)
db = sessionmaker(bind=engine)()
se = Asset(hostname="ise-mailhyb", ip_address="10.0.0.1")
ex = Asset(hostname="ise-exch2016", ip_address="10.0.0.2")
se = Asset(hostname="mailhyb", ip_address="10.0.0.1")
ex = Asset(hostname="exch2016", ip_address="10.0.0.2")
db.add_all([se, ex])
db.commit()
+1 -1
View File
@@ -44,7 +44,7 @@ def demo():
# build without the leading zeros — comparing as numbers makes that a
# non-issue, which is precisely where string matching fails.
hit = lambda v: c5._affected(v, "15.02.0.0", "15.02.2562.043", None)
assert hit("15.2.2562.27") # tester's host, Sep25 hotfix
assert hit("15.2.2562.27") # observed host, Sep25 hotfix
assert hit("15.2.2562.17") # RTM
assert not hit("15.2.2562.43") # the fix itself
assert not hit("15.2.2562.45") # Jul26SU, newer still
+2 -2
View File
@@ -1,6 +1,6 @@
"""Firefox CVEs come from Mozilla's MFSA — run: python tests/test_firefox_mfsa_source.py
Why this exists (tester, 20.08.2026): of the 23 CVEs in mfsa2026-74 (announced
Why this exists (field report, 20.08.2026): of the 23 CVEs in mfsa2026-74 (announced
18.08.), TrueVuln had found two. Not a cache problem the MFSA yml was fetched
on 19.08. 15:39 but a parsing one, in the OTHER source:
@@ -96,7 +96,7 @@ def demo():
assert set(by_cve) == {"CVE-2026-74990", "CVE-2026-74989"}, by_cve
for cve in ("CVE-2026-74990", "CVE-2026-74989"):
assert by_cve[cve]["lt"] == "154"
# Same verdict on both installed builds the tester compared — this is
# Same verdict on both installed builds that were compared — this is
# what makes two assets on one version report one identical CVE set.
for ver in ("153.0.4", "147.0.4"):
assert c5._affected(ver, None, "154", None), (cve, ver)
+1 -1
View File
@@ -38,7 +38,7 @@ def demo():
"versionType": "semver", "status": "affected"}]}
assert _ranges_from_affected(node) == [("24", None, "24.18.0"),
("22", None, "22.23.1")]
assert _hits(node, "24.13.1") # the tester's host — was a false negative
assert _hits(node, "24.13.1") # the reported host — was a false negative
assert _hits(node, "24.18.0") # the bound itself, inclusive
assert not _hits(node, "24.18.1") # fixed
assert _hits(node, "22.20.0") # the other release line
+7 -7
View File
@@ -1,12 +1,12 @@
"""Re-enrolled device: the LIVE enrolment wins — run: python tests/test_intune_reenrollment.py
Tester report 2026-08-18: an Android asset was wiped and enrolled again, so
Field report 2026-08-18: an Android asset was wiped and enrolled again, so
Intune listed it twice under the same deviceName dead entry (old device id)
next to the live one. TrueVuln matched both to the same asset (correct, one
asset) but processed both, each pinning its own device id:
... re-enrolled device id b89b -> a603
... re-enrolled device id a603 -> b89b
... re-enrolled device id 1111 -> 2222
... re-enrolled device id 2222 -> 1111
The last one in Graph's arbitrary order won, which was the DEAD enrolment, so
every detectedApps read went to the old device's frozen app list and CVEs that
@@ -24,9 +24,9 @@ from app.services.intune_service import _dedupe_reenrolled
def demo():
stale = {"id": "b89b1645", "deviceName": "AndroidEnterprise-R6GL12PGH2A",
stale = {"id": "11111111", "deviceName": "AndroidEnterprise-TESTDEVICE01",
"lastSyncDateTime": "2026-06-08T13:33:00Z"}
live = {"id": "a603fe7c", "deviceName": "AndroidEnterprise-R6GL12PGH2A",
live = {"id": "22222222", "deviceName": "AndroidEnterprise-TESTDEVICE01",
"lastSyncDateTime": "2026-08-12T05:35:00Z"}
other = {"id": "c111", "deviceName": "LAPTOP-1",
"lastSyncDateTime": "2026-08-12T05:35:00Z"}
@@ -34,12 +34,12 @@ def demo():
# Both orders must land on the live enrolment — Graph's order is arbitrary.
for devices in ([stale, live], [live, stale]):
kept, dropped = _dedupe_reenrolled(devices)
assert [d["id"] for d in kept] == ["a603fe7c"], kept
assert [d["id"] for d in kept] == ["22222222"], kept
assert dropped == 1, dropped
# Distinct devices are untouched.
kept, dropped = _dedupe_reenrolled([live, other])
assert sorted(d["id"] for d in kept) == ["a603fe7c", "c111"], kept
assert sorted(d["id"] for d in kept) == ["22222222", "c111"], kept
assert dropped == 0
# No check-in on the newer record → enrolledDateTime decides.
+1 -1
View File
@@ -1,6 +1,6 @@
"""A shared Entra device id must not merge devices — run: python tests/test_intune_shared_aad_id.py
Tester report 2026-08-19: several mobile devices were missing from the
Field report 2026-08-19: several mobile devices were missing from the
inventory completely, while one surviving asset kept logging a re-enrolment
every few minutes, each time pinned to a different device and carried the
findings of all of them (Android bulletin CVEs on an iOS device).
+11
View File
@@ -349,3 +349,14 @@ def test_configured_sources_parse():
if __name__ == "__main__":
import pytest
sys.exit(pytest.main([__file__, "-q"]))
def test_source_badges_are_space_separated():
"""Mail clients that drop CSS margins used to render "CISA KEVENISA EUVD"."""
rows = alert.render_alert_rows([{
"cve_id": "CVE-2025-29824", "sources": ["cisa", "euvd"], "ransomware": True,
"date_added": "2025-04-08", "open_asset_count": 4, "hosts": [],
}])
assert "</span> <span" in rows
assert "KEVENISA" not in rows
assert "EUVDRANSOMWARE" not in rows
+2 -2
View File
@@ -1,6 +1,6 @@
"""An MSRC-only finding gets the real CVE text, and gets it the next morning.
Two defects the tester hit on CVE-2026-6727 (MSRC-only) and CVE-2026-70304
Two defects observed on CVE-2026-6727 (MSRC-only) and CVE-2026-70304
(cross-confirmed), both found 12.08.2026 and both still wrong on 14.08.:
1. No Remediation block. The CVRF ingest ran WEEKLY (Sun 04:40) while the
@@ -79,7 +79,7 @@ assert not desc.startswith(SYNTH_DESC_PREFIX)
# junk at the tail, so the 2-month catch-up ingest could skip the current
# month while the 18-doc index build — a wider tail of the same bad order —
# still saw it: CVE detected by the fixed-build scan, no Remediation block
# behind it (tester: CVE-2026-62727, 20.08.2026). Sort on the month in the ID.
# behind it (observed: CVE-2026-62727, 20.08.2026). Sort on the month in the ID.
INDEX = {"value": [
{"ID": "2026-Aug", "CurrentReleaseDate": "2026-08-11T07:00:00Z"},
{"ID": "2026-Jun", "CurrentReleaseDate": "2026-06-09T07:00:00Z"},
+1 -1
View File
@@ -55,7 +55,7 @@ def demo():
# A node-* npm package is a different thing and must not resolve here.
assert c5.resolve("node-tar") != "nodejs"
# The tester's host: Node.js 24.13.1, below the 24.18.0 fix.
# The reported host: Node.js 24.13.1, below the 24.18.0 fix.
assert _hit("24.13.1", CVE_2026_56846)
assert _hit("24.13.1", CVE_2026_56848)
+1 -1
View File
@@ -35,7 +35,7 @@ def demo():
assert _sap_affected(entries, "7.70", 3) # inside the PL range
assert not _sap_affected(entries, "7.70", 26) # patched past it
assert _sap_affected(entries, "8.00", 1)
assert not _sap_affected(entries, "8.00", 17) # the tester's host
assert not _sap_affected(entries, "8.00", 17) # the reported host
# A release with NO PL entry falls back to the release-wide bound.
assert _sap_affected(entries, "7.00", None)
assert not _sap_affected(entries, "9.00", None)
+2 -2
View File
@@ -14,7 +14,7 @@ from app.services.app_cve_scanner_service import _sap_affected, _sap_patch_level
def demo():
# VERBATIM syscollector rows from the tester's SAP host — the level lives
# VERBATIM syscollector rows from a real SAP host — the level lives
# in the version field for Business Client and in the NAME for SAP GUI,
# whose version field states a compilation instead.
assert _sap_patch_level("SAP Business Client 8.00", "8.00 PL26") == 26
@@ -47,7 +47,7 @@ def demo():
# The release has to match too. Every release carries the same level
# NUMBERS, so level-only matching drops a 7.70 CVE onto an 8.00 host —
# exactly the tester's box (SAP GUI 8.00, Patch 17).
# exactly the host above (SAP GUI 8.00, Patch 17).
assert not _sap_affected(pl17, "8.00", 17)
assert _sap_affected(pl17, "7.70", 17)
# Wazuh says "8.00", NVD says "8.0" — same release, compared numerically.
+2 -2
View File
@@ -89,7 +89,7 @@ def demo():
("10.1.53", None, "10.1.53"),
("9.0.116", None, "9.0.116"),
]
assert not _hit(WIN_NAME, "9.0.107", T_34486) # the tester's host
assert not _hit(WIN_NAME, "9.0.107", T_34486) # the reported host
assert not _hit(WIN_NAME, "9.0.117", T_34486) # the fix
assert _hit(WIN_NAME, "9.0.116", T_34486) # the one affected build
assert not _hit("Apache Tomcat 10.1", "10.1.52", T_34486)
@@ -134,7 +134,7 @@ def demo():
assert eol._pick_release(releases, "9.0.107")["name"] == "9.0"
assert eol._pick_release(releases, "8.5.100")["name"] == "8.5"
assert eol._pick_release(releases, "7.0.109")["name"] == "7"
# 9.0 is supported until 2027-03-31, so the tester's host gets no EOL
# 9.0 is supported until 2027-03-31, so a 9.0.107 host gets no EOL
# finding; 8.5 died in 2024 and does.
assert not eol._past("2027-03-31")
assert eol._past("2024-03-31")
+3 -3
View File
@@ -4,8 +4,8 @@ Run: python tests/test_vmware_vsphere.py
Central infrastructure with no coverage at all until now: a hypervisor runs no
agent, so Wazuh and Intune never see it, and NVD carries nothing usable
CVE-2026-47876 and CVE-2026-59310 both sit there with no configuration. The
tester checked both and found the CVEListV5 records complete where NVD is
CVE-2026-47876 and CVE-2026-59310 both sit there with no configuration. A
check of both found the CVEListV5 records complete where NVD is
empty, which is what this path reads.
The three things that decide whether this is trustworthy:
@@ -31,7 +31,7 @@ from app.services import eol_service
from app.services import vmware_release_service as vmr
# --- Fixtures taken verbatim from the two records the tester cited ----------
# --- Fixtures taken verbatim from the two records cited above ---------------
# github.com/CVEProject/cvelistV5 → cves/2026/47xxx/CVE-2026-47876.json
CVE_47876_ESX = {
"vendor": "VMware", "product": "ESX", "defaultStatus": "unaffected",
+5 -5
View File
@@ -122,7 +122,7 @@ def github_advisories():
assert resolve_repo("Notepad++") == "notepad-plus-plus/notepad-plus-plus"
# GitHub's range syntax, as Wazuh actually writes it.
assert is_affected("4.14.5", ">= 4.3.0", "4.14.6") # the tester's build
assert is_affected("4.14.5", ">= 4.3.0", "4.14.6") # the reported build
assert is_affected("4.14.1", ">= 4.6.0", "4.14.6")
assert not is_affected("4.14.6", ">= 4.3.0", "4.14.6") # patched
# The floor has to hold, or a 3.x install inherits a 4.x advisory.
@@ -424,7 +424,7 @@ def rate_limit_aborts_the_build():
def fix_floor_and_description():
"""Two gaps the tester found in the same screenshot.
"""Two gaps found in the same screenshot.
The fix floor (">26.5.2.2") was only derived on the cvelistV5 path. Node.js
comes from there and showed "need newer than 24.14.0"; the Acrobat
@@ -522,7 +522,7 @@ def multi_package_advisories():
GHSA-mvh4-g699-984j (CVE-2026-54085) lists wazuh-agent ">= 4.2.0" AND
wazuh-manager "> 4.2.0", each with its own range. The index kept
vulnerabilities[0] and threw the rest away, so the manager side was never
seen: the tester found the CVE on the agents and nowhere else. 7 of
seen: the CVE showed up on the agents and nowhere else. 7 of
wazuh/wazuh's 74 advisories are shaped this way — three of them lose the
manager entirely, four lose a second range for the same package.
"""
@@ -581,7 +581,7 @@ def range_and_patched_free_text():
"""
from app.services.github_repo_advisory_service import is_affected, patched_floor
# CVE-2026-74039 — the tester's second false negative.
# CVE-2026-74039 — the second reported false negative.
assert is_affected("4.14.5", ">= 4.0.0, <= 5.0.0-beta2", "4.14.7")
assert not is_affected("4.14.7", ">= 4.0.0, <= 5.0.0-beta2", "4.14.7")
assert not is_affected("3.13.0", ">= 4.0.0, <= 5.0.0-beta2", "4.14.7")
@@ -642,7 +642,7 @@ def cvelistv5_states_the_component():
The only guard was the prose heuristic, and this description
("Wazuh 4.0.0 before 4.14.7 … POST /security/user/authenticate/run_as")
names neither manager nor agent so it let the finding through. That is
the tester's false positive: the agent carried a manager-only API DoS.
the reported false positive: the agent carried a manager-only API DoS.
"""
from app.services.cvelistv5_scan_service import _wazuh_component_ok
from app.services.app_cve_scanner_service import _is_manager_only
@@ -1,6 +1,6 @@
"""A long-disconnected agent stays INACTIVE — run: python tests/test_wazuh_stale_agent_stays_inactive.py
Tester report 2026-08-18: Wazuh showed 30 disconnected agents, TrueVuln showed
Field report 2026-08-18: Wazuh showed 30 disconnected agents, TrueVuln showed
zero INACTIVE Wazuh assets, and the audit log carried a DEACTIVATED and a
REACTIVATED row for the same host in the same second.
+1 -1
View File
@@ -19,7 +19,7 @@ import sys
sys.path.insert(0, "/app")
# Products the tester has in inventory that this export is supposed to cover.
# Products seen in real inventories that this export is supposed to cover.
PROBES = [
"Microsoft Exchange Server 2016 Cumulative Update 23",
"Microsoft Visual C++ 2013 Redistributable (x86) - 12.0.21005",