Compare commits
4
Commits
6740b44edb
...
ab6d4c6152
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab6d4c6152 | ||
|
|
36ecf7edaf | ||
|
|
0b71453c48 | ||
|
|
262cd46652 |
+62
@@ -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
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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 name→product match (tester: 'Microsoft Edge'
|
||||
Without this, fixing a bad name→product 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,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?"
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
#
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"}]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
open→patched→open, 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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user