Compare commits
145
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf945f838f | ||
|
|
d55ebdbec4 | ||
|
|
64204f41b7 | ||
|
|
c6118d3d52 | ||
|
|
54edb38b3a | ||
|
|
948589d07f | ||
|
|
8fd09d1f81 | ||
|
|
56454dff2a | ||
|
|
b4d0caf8ad | ||
|
|
d304463996 | ||
|
|
2519427b41 | ||
|
|
c4abbaa1c1 | ||
|
|
e19c8d9da7 | ||
|
|
5703dfa806 | ||
|
|
d4ac987c03 | ||
|
|
f5c2c58ab9 | ||
|
|
e0b3e531b2 | ||
|
|
bf529ed421 | ||
|
|
7e15d6552a | ||
|
|
1609d3edbc | ||
|
|
904a3ec1ca | ||
|
|
bf07b90330 | ||
|
|
93e2b1912c | ||
|
|
65d6c62561 | ||
|
|
867498c3d3 | ||
|
|
e0fb38e344 | ||
|
|
f9d9919615 | ||
|
|
a289c07ccb | ||
|
|
4bb5138e8e | ||
|
|
d698726621 | ||
|
|
5e85a5cf25 | ||
|
|
4a25a5d1c0 | ||
|
|
702dfcbfb3 | ||
|
|
440e96f965 | ||
|
|
01e766dace | ||
|
|
7bb396a606 | ||
|
|
1c7a0c42d1 | ||
|
|
96e6072a41 | ||
|
|
4aac1bfd06 | ||
|
|
ae52cfe293 | ||
|
|
c3b9bb6cbf | ||
|
|
ebea13f740 | ||
|
|
572b6fe217 | ||
|
|
18080a31e4 | ||
|
|
381c21034c | ||
|
|
e78b4fef80 | ||
|
|
ee4e1d9c8b | ||
|
|
e9ed5133a4 | ||
|
|
099c1d8af0 | ||
|
|
937251fc24 | ||
|
|
ce1765fb42 | ||
|
|
340e70ce67 | ||
|
|
d54a821054 | ||
|
|
d6d2c3209b | ||
|
|
405b4bc541 | ||
|
|
b35c2c95f7 | ||
|
|
064daaf8ed | ||
|
|
10692c67d4 | ||
|
|
4417dc18ae | ||
|
|
2aa8b60b5b | ||
|
|
1f305b5be3 | ||
|
|
c90a7bc0e5 | ||
|
|
a4b8c85d06 | ||
|
|
dbad9a365e | ||
|
|
a75d2d87be | ||
|
|
41947920dc | ||
|
|
7425d79453 | ||
|
|
5d2669df21 | ||
|
|
f645cadc7b | ||
|
|
4cd39b163a | ||
|
|
101445109f | ||
|
|
59e4afbeb3 | ||
|
|
ed78810ad8 | ||
|
|
c1fc992285 | ||
|
|
46594c153d | ||
|
|
cd65dd0076 | ||
|
|
61ce7497ff | ||
|
|
ade5f435d9 | ||
|
|
d14ed835e4 | ||
|
|
428c3f2737 | ||
|
|
35940de51e | ||
|
|
d8c838fa09 | ||
|
|
98bd366ddf | ||
|
|
02cfb39486 | ||
|
|
d460364590 | ||
|
|
3123263cf0 | ||
|
|
36a560d508 | ||
|
|
36976c6647 | ||
|
|
cc9954595c | ||
|
|
7890391283 | ||
|
|
c3a75e7a77 | ||
|
|
d0d27b2c99 | ||
|
|
d90044e696 | ||
|
|
4d6ccb71d2 | ||
|
|
2087b484d9 | ||
|
|
8e8cfee19a | ||
|
|
355adad829 | ||
|
|
c2cad0088d | ||
|
|
922341b378 | ||
|
|
5418d6a9d3 | ||
|
|
9714fb72a2 | ||
|
|
933a47a10e | ||
|
|
ac16f99be7 | ||
|
|
29f828678b | ||
|
|
eea9f5aa94 | ||
|
|
00d22fc318 | ||
|
|
3e382a489d | ||
|
|
e2625e036e | ||
|
|
65e2c222ce | ||
|
|
95750458d2 | ||
|
|
efee1d6e61 | ||
|
|
7bc7f996a0 | ||
|
|
ba7f2069e2 | ||
|
|
9021943f38 | ||
|
|
36cc29f69d | ||
|
|
baefbf2b96 | ||
|
|
67c201497e | ||
|
|
3b541780f8 | ||
|
|
3adf02c6cb | ||
|
|
fbfa1d8e97 | ||
|
|
8a614ae5e5 | ||
|
|
97cb7915c6 | ||
|
|
033f39f0bb | ||
|
|
1fb49e4336 | ||
|
|
df3d98ba68 | ||
|
|
af9bb89d7f | ||
|
|
d22ddb73b9 | ||
|
|
74a49be3eb | ||
|
|
3ae4ac9e1e | ||
|
|
dc8ef025f5 | ||
|
|
45524f7f8a | ||
|
|
57e457e92f | ||
|
|
89ba5b2efc | ||
|
|
0cf0e5313f | ||
|
|
ee3b0f18bb | ||
|
|
3693cfce24 | ||
|
|
45e2be8d13 | ||
|
|
638f6de0a5 | ||
|
|
6f7c4a8c54 | ||
|
|
bab0092940 | ||
|
|
9385ca4e0d | ||
|
|
0d51457a10 | ||
|
|
703728c69d | ||
|
|
d450b961aa | ||
|
|
ac0c611125 |
+52
-13
@@ -1,5 +1,5 @@
|
||||
# =============================================================================
|
||||
# VulnCheck Environment Configuration
|
||||
# TrueVuln Environment Configuration
|
||||
# =============================================================================
|
||||
# Copy this file to .env and adjust values before starting:
|
||||
# cp .env.example .env
|
||||
@@ -27,6 +27,10 @@ JWT_SECRET_KEY=CHANGE-ME-GENERATE-WITH-openssl-rand-hex-32
|
||||
# 'production' disables Swagger docs and enables security headers.
|
||||
# 'development' enables Swagger UI at /docs and relaxes some checks.
|
||||
ENV=production
|
||||
# Expose Swagger UI (/docs) + ReDoc (/redoc) on a production instance without
|
||||
# switching to development — handy for ITSM/CMDB API integrators. Default off.
|
||||
# The raw OpenAPI spec (/openapi.json) is always served regardless.
|
||||
ENABLE_API_DOCS=false
|
||||
|
||||
# --- Cookies ---
|
||||
# Set to true when using HTTPS (recommended, required behind HTTPS proxy)
|
||||
@@ -59,9 +63,44 @@ DEFAULT_ADMIN_EMAIL=admin@vulnmanager.local
|
||||
TIMEZONE=Europe/Zurich
|
||||
|
||||
# --- Dashboard URL ---
|
||||
# Used in email notifications for links back to the dashboard.
|
||||
# Set this to your external URL (behind reverse proxy).
|
||||
# DASHBOARD_URL=https://vuln.example.com
|
||||
# Base URL used to build ALL links in email notifications (dashboard button,
|
||||
# per-CVE / per-asset deep links, the CVE links inside the digest table).
|
||||
# The backend can't know how your browser reaches the frontend, so you MUST set
|
||||
# this to exactly how users open TrueVuln — otherwise links point at the
|
||||
# fallback http://localhost:3000 and won't work.
|
||||
# e.g. http://<host-or-ip>:${FRONTEND_PORT} or https://vuln.example.com behind a proxy
|
||||
# Set it, then restart the backend (docker compose up -d).
|
||||
DASHBOARD_URL=http://localhost:3003
|
||||
|
||||
# --- Microsoft Intune / Graph (optional) ---
|
||||
# Configured in the UI: Settings → "Microsoft Intune (Graph API)" card
|
||||
# (stored encrypted as the `intune_config` setting), NOT via env. Pulls
|
||||
# Intune managed devices → assets + OS-EOL.
|
||||
# App-only (client-credentials): register an Entra app, add the Application
|
||||
# permission DeviceManagementManagedDevices.Read.All, grant admin consent,
|
||||
# create a client secret. (Defender TVM phase: BOTH Machine.Read.All AND
|
||||
# Vulnerability.Read.All on WindowsDefenderATP — the /api/machines call
|
||||
# requires Machine.Read.All.) No env vars required.
|
||||
|
||||
# --- NVD API key (optional, recommended) ---
|
||||
# Used to backfill each CVE's official published / lastModified date from
|
||||
# the NVD CVE API (drives the "Newly Published" dashboard widget sort).
|
||||
# Without a key NVD allows 5 requests / 30s; with one, 50 / 30s — so a
|
||||
# fresh database fills in published dates ~10x faster. Free, request at:
|
||||
# https://nvd.nist.gov/developers/request-an-api-key
|
||||
# NVD_API_KEY=your-nvd-api-key
|
||||
|
||||
# --- OpenRouter AI remediation (optional) ---
|
||||
# Enables the "AI Remediation" button on the CVE detail page — generates
|
||||
# OS-aware fix steps/commands on demand. OpenAI-compatible; get a key at
|
||||
# https://openrouter.ai/keys (free tier ~10 req/day across free models).
|
||||
# Without a key the feature stays hidden.
|
||||
# OPENROUTER_API_KEY=sk-or-...
|
||||
# Model slug (OpenRouter uses dots, e.g. anthropic/claude-sonnet-4.6).
|
||||
# Default openrouter/free auto-routes across available free models.
|
||||
# OPENROUTER_MODEL=openrouter/free
|
||||
# Optional comma-separated fallback models (route=fallback):
|
||||
# OPENROUTER_FALLBACKS=openrouter/auto,anthropic/claude-sonnet-4.6
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -108,7 +147,7 @@ LDAP_CA_CERT_PATH=/etc/ssl/certs/company-ca.pem
|
||||
LDAP_VALIDATE_CERT=true # NEVER set to false in production
|
||||
|
||||
# Service account for the search-then-bind flow.
|
||||
LDAP_BIND_DN=cn=svc-vulncheck,ou=ServiceAccounts,dc=company,dc=local
|
||||
LDAP_BIND_DN=cn=svc-truevuln,ou=ServiceAccounts,dc=company,dc=local
|
||||
# Bootstrap password — loaded once, encrypted, stored in DB.
|
||||
# Remove from env AFTER the first successful start.
|
||||
LDAP_BIND_PASSWORD_BOOTSTRAP=
|
||||
@@ -136,7 +175,7 @@ OIDC_PROVIDER_NAME=Entra ID
|
||||
OIDC_DISCOVERY_URL=
|
||||
OIDC_CLIENT_ID=
|
||||
OIDC_CLIENT_SECRET=
|
||||
OIDC_REDIRECT_URI=https://vulncheck.company.com/auth/oidc/callback
|
||||
OIDC_REDIRECT_URI=https://truevuln.company.com/auth/oidc/callback
|
||||
OIDC_SCOPES=openid profile email groups
|
||||
|
||||
# Claim names — defaults work for Entra ID / Keycloak. Adjust per IdP.
|
||||
@@ -151,20 +190,20 @@ OIDC_CLAIM_SUBJECT=sub
|
||||
# SAML 2.0
|
||||
# -----------------------------------------------------------------------------
|
||||
SAML_PROVIDER_NAME=Single Sign-On
|
||||
SAML_SP_ENTITY_ID=https://vulncheck.company.com/auth/saml/metadata
|
||||
SAML_SP_ACS_URL=https://vulncheck.company.com/auth/saml/acs
|
||||
SAML_SP_SLO_URL=https://vulncheck.company.com/auth/saml/slo
|
||||
SAML_SP_ENTITY_ID=https://truevuln.company.com/auth/saml/metadata
|
||||
SAML_SP_ACS_URL=https://truevuln.company.com/auth/saml/acs
|
||||
SAML_SP_SLO_URL=https://truevuln.company.com/auth/saml/slo
|
||||
|
||||
# SP cert + key — generate a keypair specifically for this SP:
|
||||
# openssl req -x509 -newkey rsa:2048 -nodes \
|
||||
# -keyout sp.key -out sp.crt -days 730 \
|
||||
# -subj "/CN=vulncheck.company.com"
|
||||
SAML_SP_CERT_PATH=/etc/vulncheck/saml/sp.crt
|
||||
SAML_SP_PRIVATE_KEY_PATH=/etc/vulncheck/saml/sp.key
|
||||
# -subj "/CN=truevuln.company.com"
|
||||
SAML_SP_CERT_PATH=/etc/truevuln/saml/sp.crt
|
||||
SAML_SP_PRIVATE_KEY_PATH=/etc/truevuln/saml/sp.key
|
||||
|
||||
# IdP metadata source — exactly one of:
|
||||
SAML_IDP_METADATA_URL=
|
||||
# SAML_IDP_METADATA_PATH=/etc/vulncheck/saml/idp-metadata.xml
|
||||
# SAML_IDP_METADATA_PATH=/etc/truevuln/saml/idp-metadata.xml
|
||||
|
||||
# Attribute mapping (defaults work for most IdPs)
|
||||
SAML_ATTR_USERNAME=urn:oid:0.9.2342.19200300.100.1.1
|
||||
|
||||
+3
-3
@@ -1,4 +1,4 @@
|
||||
# VulnCheck — Architecture Overview
|
||||
# TrueVuln — Architecture Overview
|
||||
|
||||
> Stand: Mai 2026 (dev-Branch, post-Migration 022). Diese Doku spiegelt
|
||||
> den `dev`-Branch wider. Stable `main` ist eine Teilmenge — siehe
|
||||
@@ -72,7 +72,7 @@
|
||||
|
||||
## 2. Security Architecture (OWASP Top 10 Mitigation)
|
||||
|
||||
| OWASP risk | Defence in VulnCheck |
|
||||
| OWASP risk | Defence in TrueVuln |
|
||||
|---|---|
|
||||
| **A01 Broken Access Control** | RBAC enforced via `require_role()` dependency on every router; admin / editor / viewer scopes. Asset-ownership filter on vuln listings. |
|
||||
| **A02 Cryptographic Failures** | TOTP secrets encrypted at rest with `AUTH_PROVIDER_CRYPTO_KEY` (Fernet). LDAP bind password same. bcrypt cost-12 for local passwords. JWT secret rotated per deployment. |
|
||||
@@ -296,7 +296,7 @@ docker compose exec backend alembic current # expect: 022 (head)
|
||||
|
||||
- Wazuh-Indexer pagination beyond the OpenSearch 10 000-hit cap: search-after / scroll in `WazuhClient.get_vulnerabilities`.
|
||||
- Vulnrichment cascade auto-routes to ZIP snapshot when > 25 CVEs requested — avoids 25 × N raw fetches.
|
||||
- cvelistV5 ZIP (~557 MB) is on-disk-cached 12 h at `/tmp/vulncheck-cvelistv5-cache.zip`.
|
||||
- cvelistV5 ZIP (~557 MB) is on-disk-cached 12 h at `/tmp/truevuln-cvelistv5-cache.zip`.
|
||||
- NVD stage caps at 100 CVEs per run (rate limit). >100 missing → falls through to cvelistV5 directly.
|
||||
- `sources` / `enrichment_sources` JSON columns avoid table-join chatter for per-vuln source filtering.
|
||||
- Indexed columns: `cve_id`, `asset_id`, `cvss_score`, `severity`, `epss_score`, `kev_listed`, `euvd_listed`, `exploitation_status`, `ssvc_technical_impact`, `ssvc_automatable`, `nessus_vpr_score`, `nessus_plugin_id`, `package_name`, `detected_at`.
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
# VulnCheck — Database Schema
|
||||
# TrueVuln — Database Schema
|
||||
|
||||
> Stand: Mai 2026, alembic head = `022_backfill_ssvc_only_exploitation_source`.
|
||||
> PostgreSQL 16. Times in UTC. `TimestampMixin` adds `created_at` + `updated_at`
|
||||
|
||||
+4
-4
@@ -1,4 +1,4 @@
|
||||
# VulnCheck — Project Overview
|
||||
# TrueVuln — Project Overview
|
||||
|
||||
> Vulnerability Management Dashboard for a Swiss-school IT-Security setup.
|
||||
> Wazuh agents + Nessus scanner + CISA/ENISA/EPSS threat intel + CIS-Benchmark
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
## 🎯 Summary
|
||||
|
||||
VulnCheck collects vulnerability findings from multiple scanners (Wazuh,
|
||||
TrueVuln collects vulnerability findings from multiple scanners (Wazuh,
|
||||
Nessus), enriches them with several free public threat-intel sources, scores
|
||||
the result on a unified 0-100 risk scale, and surfaces them in a role-based
|
||||
web UI with SLA-tracking, mail notifications, compliance evidence (Wazuh SCA
|
||||
@@ -180,7 +180,7 @@ AUTH_PROVIDER_CRYPTO_KEY=<fernet-key>
|
||||
# Wazuh
|
||||
WAZUH_API_URL=https://wazuh-manager:55000
|
||||
WAZUH_INDEXER_URL=https://wazuh-indexer:9200
|
||||
WAZUH_API_USER=vulncheck-readonly
|
||||
WAZUH_API_USER=truevuln-readonly
|
||||
WAZUH_API_PASS=<pw>
|
||||
|
||||
# Optional
|
||||
@@ -283,7 +283,7 @@ UPDATE settings SET value='true' WHERE key='sla_breach_enabled';
|
||||
- **`sla_breach_enabled` toggle has no UI yet** — DB-only (Settings page improvement on backlog)
|
||||
- **CIRCL aggregator** not used — we hit CISA + ENISA directly
|
||||
- **No real-time KEV push** — we poll the CISA feed (24 h cache)
|
||||
- **No two-way Nessus FP sync** — marking false-positive in VulnCheck doesn't update Nessus
|
||||
- **No two-way Nessus FP sync** — marking false-positive in TrueVuln doesn't update Nessus
|
||||
- **Wazuh-side fix-version field doesn't exist** in the indexer schema — relying entirely on Vulnrichment/NVD/cvelistV5 cascade for PATCH AVAILABLE
|
||||
- **Per-framework URS breakdown** schema-ready but UI hidden
|
||||
- **No WebAuthn / FIDO2** — TOTP is the only second factor
|
||||
|
||||
+242
-30
@@ -1,8 +1,8 @@
|
||||
<p align="center">
|
||||
<img src="frontend/public/logo.png" alt="VulnCheck Logo" width="120" />
|
||||
<img src="frontend/public/logo.png" alt="TrueVuln Logo" width="120" />
|
||||
</p>
|
||||
|
||||
<h1 align="center">VulnCheck — Dev Branch Features</h1>
|
||||
<h1 align="center">TrueVuln — Dev Branch Features</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>Threat-Intel-Enrichment, Risk-Based Prioritization & Multi-Provider Auth</strong><br>
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
## What this branch adds
|
||||
|
||||
Five feature groups on top of stable `main`:
|
||||
Six feature groups on top of stable `main`:
|
||||
|
||||
**1. Threat-intel enrichment** — extends the priority score beyond CVSS + Wazuh exploit flags with multiple independent threat-intelligence sources, plus a separate CPR (Cybersecurity Priority Risk) score.
|
||||
|
||||
@@ -43,6 +43,8 @@ All sources are **free**, no API key required.
|
||||
|
||||
**5. Unified Risk Score (URS)** — single 0-100 figure per asset combining Asset Vulnerability Score (AVS, from CPR) and Asset Security Score (ASS, from impact-weighted SCA), multiplied by per-asset criticality. Daily snapshots feed a trend arrow. CSV-importable CIS-Benchmark impact weights drive the weighting.
|
||||
|
||||
**6. Built-in App→CVE detection + Mobile Device Security** — a curated OSV/NVD-CPE/cvelistV5 scanner maps installed software to real CVEs (and suppresses Wazuh's loose-CPE false positives) for hosts with no real vulnerability scanner; a parallel track handles mobile devices — EOL/EOS for Samsung/Apple models, Android patch-level staleness, and per-CVE Android detection via Samsung's own SMR page (preferred, precise) or Google's ASB (fallback). Plus a CISA-KEV "actively exploited" advisory feed, independent of asset findings.
|
||||
|
||||
---
|
||||
|
||||
## Priority Score (current formula)
|
||||
@@ -410,14 +412,14 @@ curl -X PUT https://<host>/api/v1/auth-config/role-mappings \
|
||||
-d '{
|
||||
"mappings": {
|
||||
"ldap": [
|
||||
{"pattern": "CN=VulnCheck-Admins,*", "role": "admin"},
|
||||
{"pattern": "CN=VulnCheck-Editors,*", "role": "editor"}
|
||||
{"pattern": "CN=TrueVuln-Admins,*", "role": "admin"},
|
||||
{"pattern": "CN=TrueVuln-Editors,*", "role": "editor"}
|
||||
],
|
||||
"oidc": [
|
||||
{"pattern": "<azure-group-uuid-admin>", "role": "admin"}
|
||||
],
|
||||
"saml": [
|
||||
{"pattern": "VulnCheck-Admins", "role": "admin"}
|
||||
{"pattern": "TrueVuln-Admins", "role": "admin"}
|
||||
]
|
||||
}
|
||||
}'
|
||||
@@ -492,6 +494,32 @@ AUTH_JIT_DEFAULT_ROLE=readonly
|
||||
AUTH_PROVIDER_CRYPTO_KEY=<fernet-key> # python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||
```
|
||||
|
||||
### Account lockout
|
||||
|
||||
Login is rate-limited (`5/min`/IP) and locks a local account after
|
||||
`ACCOUNT_LOCKOUT_THRESHOLD` (5) failed attempts. Two modes:
|
||||
|
||||
- **Temporary** (default) — auto-unlocks after `ACCOUNT_LOCKOUT_DURATION_MIN`
|
||||
(15 min). No admin action, no self-lockout risk.
|
||||
- **Permanent** — set the `auth_lockout_permanent` setting (Settings → General)
|
||||
to `true`. The account stays locked until an admin clears it via
|
||||
`POST /auth/users/{id}/unlock` (Unlock button in User Management). Safeguard:
|
||||
`_is_last_active_admin()` prevents permanently locking the last recoverable
|
||||
admin (it falls back to the temporary window) so a brute-force on the admin
|
||||
login can't lock the whole system out. Columns: `users.locked`, `locked_at`
|
||||
(migration 037).
|
||||
|
||||
### Audit-log → syslog / SIEM forwarding
|
||||
|
||||
`syslog_service.py` mirrors every `audit_logs` insert to an external syslog
|
||||
server (RFC-5424, UDP or TCP) when the `syslog_config` setting is enabled
|
||||
(`{enabled, host, port, protocol, facility}`; admin card under Settings). A
|
||||
SQLAlchemy `after_insert` hook enqueues onto a bounded queue drained by one
|
||||
daemon worker — never blocks the request/flush, bursts drain sequentially.
|
||||
Per-event severity: FAILED/DENIED/LOCK → warning, SECURITY_ALERT/escalation →
|
||||
alert, deletes/config-change → notice, else info. Disabled = cheap no-op.
|
||||
No TLS yet (UDP/TCP). Test probe: `POST /api/v1/settings/syslog/test`.
|
||||
|
||||
See [`.env.example`](.env.example) for full LDAP / OIDC / SAML blocks with worked
|
||||
examples for Entra ID, Okta, Keycloak, Google, and Active Directory.
|
||||
|
||||
@@ -587,7 +615,7 @@ logging in via the new paths.
|
||||
| MFA setup returns 404 HTML with `x-nextjs-cache: HIT` | Next.js had no proxy route for `/auth/mfa/*` so it served them as page routes | Catch-all `frontend/app/auth/[...path]/route.ts` is now in place. Frontend rebuild required. |
|
||||
| MFA card not visible in Settings | Frontend container has the pre-MFA build | `docker compose build frontend && docker compose up -d frontend`. Hard reload (Ctrl+Shift+R). |
|
||||
| LDAP test bind fails immediately | Bootstrap password loaded once and encrypted in DB; subsequent decrypts fail if you rotated `AUTH_PROVIDER_CRYPTO_KEY` | Clear the cached bind: `DELETE FROM settings WHERE key='ldap_bind_password_encrypted';` then restart. The next start re-bootstraps from `LDAP_BIND_PASSWORD_BOOTSTRAP`. |
|
||||
| No mail after a Wazuh sync that found new CVEs | Recipient cascade returned empty (vuln/asset not assigned to any user or group with an email) OR severity below `notification_min_severity` threshold OR notifications suppressed on the vuln | Assign asset/vuln, lower threshold in Settings, or un-suppress via the bell icon. Verify with `docker compose logs backend | grep 'Wazuh sync notifications'`. |
|
||||
| No mail after a sync (Wazuh/Nessus/app-scan/Defender) that found new CVEs | Recipient cascade empty **and** the fallback also empty (finding/asset unassigned, `notification_default_recipients` unset, **and** no active admin has an email) OR severity below `notification_min_severity` OR notifications suppressed on the vuln OR `notification_schedule=nightly` (per-sync sends are deferred to the nightly roundup) | Give an admin an email, set `notification_default_recipients`, assign the asset, lower the threshold, un-suppress via the bell icon, or check the delivery schedule. Verify with `docker compose logs backend \| grep -iE 'digest\|notifications'`. (SLA-breach mails have **no** fallback — they still require an assignee; see *Asset / Finding assignment*.) |
|
||||
|
||||
---
|
||||
|
||||
@@ -600,52 +628,95 @@ relevant to them. No SMTP rate-limit problems with large syncs.
|
||||
|
||||
## How it triggers
|
||||
|
||||
Both sync paths route through the same dispatcher:
|
||||
**Every** sync source routes new findings through the same dispatcher
|
||||
(`dispatch_new_vuln_notifications()`): **Wazuh, Nessus, the App→CVE scanner,
|
||||
and Defender TVM**. (Earlier only Wazuh/Nessus did — app-scan and Defender
|
||||
findings were silently skipped; fixed.) Each source calls the dispatcher after
|
||||
it enriches its freshly-created findings.
|
||||
|
||||
- **Manual:** `POST /api/v1/vulnerabilities/sync/wazuh` (UI button or curl)
|
||||
- **Scheduled:** the APScheduler-driven sync jobs (Settings → Scan Schedules)
|
||||
After a sync completes:
|
||||
|
||||
After the sync completes:
|
||||
|
||||
1. Backend collects `newly_created_vuln_ids` during the sync
|
||||
1. Backend collects the sync's newly-created vuln ids
|
||||
2. Loads them, applies `notification_min_severity` filter
|
||||
3. Resolves recipient per CVE via the existing cascade
|
||||
3. Resolves recipient per CVE via the cascade
|
||||
(`vuln.assigned_user` → `vuln.assigned_group` → `asset.assigned_user`
|
||||
→ `asset.assigned_groups`)
|
||||
→ `asset.assigned_groups`), else the default-recipients/admins fallback
|
||||
4. Groups CVEs by recipient email
|
||||
5. Sends one digest mail per recipient via `dispatch_new_vuln_notifications()`
|
||||
5. Sends one digest mail per recipient (rows sorted by CPR desc)
|
||||
6. Writes one `NotificationLog` per recipient (anchor vuln + total count)
|
||||
|
||||
If `vulns_created = 0` (Wazuh reported only updates), no mails fire. Designed
|
||||
this way to avoid noise on routine syncs.
|
||||
If a sync created 0 new findings, no mails fire (no noise on routine syncs).
|
||||
|
||||
### Delivery schedule (per-sync vs nightly roundup)
|
||||
|
||||
`notification_schedule` controls *when* mail goes out:
|
||||
|
||||
- **`per_sync`** (default) — dispatch sends at the end of each sync run.
|
||||
- **`nightly`** — per-sync sends are suppressed (`dispatch_new_vuln_notifications`
|
||||
returns early when `respect_schedule=True`). A scheduler job
|
||||
(`new_vuln_digest_nightly`, registered hourly, self-gates on
|
||||
`notification_nightly_hour` + the schedule flag) then calls
|
||||
`send_nightly_new_vuln_digest()` which aggregates **all** active findings
|
||||
detected since the last run — windowed via the `notification_nightly_last_run`
|
||||
setting so nothing double-sends or is missed — into ONE mail per recipient.
|
||||
Fewer mails → friendlier to provider anti-spam.
|
||||
|
||||
### Send rate limiting
|
||||
|
||||
The dispatch send-loop paces mail per `get_email_rate_limit()`:
|
||||
`email_rate_delay_seconds` (sleep between mails) and `email_max_per_run`
|
||||
(hard cap per dispatch; 0 = unlimited). Guards against provider bursting.
|
||||
|
||||
## Settings
|
||||
|
||||
| Setting key | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `notification_mode` | `digest` | `digest` (one mail per recipient) or `single` (legacy, one mail per CVE) |
|
||||
| `notification_schedule` | `per_sync` | `per_sync` (send after each sync) or `nightly` (suppress per-sync, one roundup) |
|
||||
| `notification_nightly_hour` | `6` | Hour (0–23, server time) the nightly roundup fires |
|
||||
| `notification_nightly_last_run` | — | Internal — ISO timestamp of the last nightly roundup (window anchor) |
|
||||
| `notification_min_severity` | `critical` | Skip CVEs below this severity (`critical` / `high` / `medium` / `low`) |
|
||||
| `notification_lifecycle_mode` | `exclude` | `exclude` = pseudo-findings that aren't real CVEs (`EOL-*`, `ANDROID-PATCH-*`, `NESSUS-PLUGIN-*`) never trigger the CVE mails; `include` = legacy mixed behaviour. Rule is "anything not `CVE-*`" (`is_lifecycle_finding()`), so new pseudo prefixes are covered automatically |
|
||||
| `notification_default_recipients` | — | Fallback recipients (comma-separated) for **new-CVE** mails when a finding has no assignee. Empty → all active admins. |
|
||||
| `email_rate_delay_seconds` | `0` | Seconds to sleep between mails in a dispatch (anti-spam pacing) |
|
||||
| `email_max_per_run` | `0` | Max mails per dispatch run (0 = unlimited) |
|
||||
| `smtp_config` | — | Required. Without SMTP no mails go out. |
|
||||
|
||||
All three editable via Settings UI (no env-var needed).
|
||||
All editable via Settings → Notifications (no env-var needed).
|
||||
|
||||
## Mail content
|
||||
|
||||
Subject: `[VULNCHECK] N new vulnerabilities detected`
|
||||
Subject: `[TRUEVULN] N new vulnerabilities detected`
|
||||
|
||||
Body: severity-count badges (Critical / High / Medium / Low), table of CVE +
|
||||
Severity + CVSS + Host + Package, "Open in dashboard" button. Template lives
|
||||
in `app/services/email_service.py:DEFAULT_DIGEST_TEMPLATE` and is overridable
|
||||
via the `email_template_new_vuln_digest` setting (custom HTML/Jinja-light
|
||||
variables `{{total}}`, `{{count_critical}}`, `{{count_high}}`, `{{rows}}`,
|
||||
`{{detected_at}}`, `{{recipient_name}}`, `{{dashboard_url}}`).
|
||||
Body: severity-count badges, then a table **sorted by CPR descending** with
|
||||
columns **CVE (deep link) · Severity · CVSS · CPR · Host · #Systems · Package**,
|
||||
and an "Open in dashboard" button. `#Systems` = distinct assets affected by that
|
||||
CVE across the whole inventory (`_affected_counts()`). Default template lives in
|
||||
`email_service.py:DEFAULT_DIGEST_TEMPLATE`, editable in **Settings →
|
||||
Notifications → New Vulnerability Digest Template** (the `email_template_new_vuln_digest`
|
||||
setting). The `email_template_new_vuln` (single-mode) template is separate and
|
||||
only used when `notification_mode='single'` — the UI badges show which is active.
|
||||
|
||||
Digest top-level variables: `{{total}}`, `{{affected_assets_count}}`
|
||||
(distinct systems across the digest), `{{rows}}` (the pre-rendered table,
|
||||
CPR/#Systems/links baked in), `{{count_critical|high|medium|low}}`,
|
||||
`{{recipient_name}}`, `{{detected_at}}`, `{{dashboard_url}}`.
|
||||
|
||||
Single-mode variables (per CVE): `{{cve_id}}`, `{{severity_upper}}`,
|
||||
`{{cvss_score}}`, `{{cpr_score}}`, `{{affected_assets_count}}`,
|
||||
`{{asset_hostname}}`, `{{package_name}}`, `{{description}}`, `{{detected_at}}`,
|
||||
`{{dashboard_url}}`, and ready-made deep links `{{cve_link}}`, `{{asset_link}}`,
|
||||
`{{cve_on_asset_link}}`.
|
||||
|
||||
## Fresh-install behaviour
|
||||
|
||||
Out-of-box flow for a clean deployment:
|
||||
|
||||
1. SMTP configured in Settings → Email
|
||||
2. Asset assigned to a user or group with a populated `users.email`
|
||||
2. A recipient exists: either the asset/finding is assigned to a user/group
|
||||
with a populated `users.email`, **or** a fallback applies (see
|
||||
*Asset / Finding assignment* below) — an active admin email, or the
|
||||
configured `notification_default_recipients`
|
||||
3. `notification_min_severity` set (default `critical` works for most)
|
||||
4. First Wazuh sync runs (manual or scheduled)
|
||||
5. New CVEs found → digest mail goes out automatically, no extra config
|
||||
@@ -668,6 +739,34 @@ Wazuh sync notifications: 3 sent, 0 failed, 3 recipients
|
||||
|
||||
`/notifications` UI shows the resulting log row(s) per recipient.
|
||||
|
||||
## Asset / Finding assignment — what it actually does
|
||||
|
||||
Assignment (a **user or group**, set on an **asset** or on an individual
|
||||
**finding**) is purely a **responsibility / notification tag**. It does **not**
|
||||
gate scanning, scoring, the SLA calculation itself, report contents, or
|
||||
visibility — RBAC is role-based, so anyone with view rights sees every CVE
|
||||
regardless of who it's assigned to.
|
||||
|
||||
Recipient **cascade** (both mail paths, first match wins):
|
||||
`finding.assigned_user` → `finding.assigned_group` → `asset.assigned_user`
|
||||
→ `asset.assigned_groups`.
|
||||
|
||||
What assignment controls:
|
||||
|
||||
| Effect | Behaviour |
|
||||
|---|---|
|
||||
| **New-CVE digest** (`email_service._resolve_recipients_for_vuln`) | Goes to the assignee via the cascade. **Fallback when nothing is assigned:** `notification_default_recipients`, or — if that's empty — every active admin. So "the admin gets everything" works without any assignment. |
|
||||
| **SLA-breach digest** (`scheduler.check_sla_breaches`) | Goes to the assignee via the cascade **only** — **no fallback**. An unassigned SLA breach sends **no mail** to anyone. |
|
||||
| **"Assigned To" column** (Assets & Vulnerabilities pages) | Display + sort only. |
|
||||
| **Cleanup** | Deleting a user/group nulls their assignments automatically. |
|
||||
|
||||
**Runbook recommendation:** assign each asset to a system owner (a user or
|
||||
group with a populated email). It's the only way to (a) route new-CVE mails to
|
||||
the real owner instead of blanket-to-admins, and (b) make **SLA-breach mails
|
||||
reach anyone at all** — the admin fallback does *not* apply to SLA breaches.
|
||||
For a catch-all on new-CVE mails without per-asset ownership, set
|
||||
Settings → Notifications → **Default Recipient(s)** instead.
|
||||
|
||||
---
|
||||
|
||||
# Nessus Integration (multi-scanner unified view)
|
||||
@@ -735,7 +834,7 @@ Per Nessus host, the sync tries in order:
|
||||
|
||||
Unmatched hosts are returned in the sync response under
|
||||
`unmatched_hosts` so admins can spot drift between Nessus targets and
|
||||
the VulnCheck inventory.
|
||||
the TrueVuln inventory.
|
||||
|
||||
## Merge logic per `(cve_id, asset_id)`
|
||||
|
||||
@@ -796,7 +895,7 @@ bounded even for full-DB corrections.
|
||||
┌─ Stage 3 — cvelistV5 (MITRE/CVE.org) ──────────────────────────┐
|
||||
│ github.com/CVEProject/cvelistV5 │
|
||||
│ • 557 MB ZIP, disk-cached 12h at │
|
||||
│ /tmp/vulncheck-cvelistv5-cache.zip │
|
||||
│ /tmp/truevuln-cvelistv5-cache.zip │
|
||||
│ • exhaustive — every published CVE (~250k+) │
|
||||
│ • same CVE-5 JSON shape as Vulnrichment, parser reused │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
@@ -943,7 +1042,7 @@ UI smoke test:
|
||||
|
||||
## Nessus — out of scope for v1
|
||||
|
||||
- Triggering Nessus scans from VulnCheck (we only import existing scans)
|
||||
- Triggering Nessus scans from TrueVuln (we only import existing scans)
|
||||
- Two-way sync of false-positive status back to Nessus
|
||||
- Network discovery / SNMP-based asset auto-creation beyond the
|
||||
hostname/IP-match step
|
||||
@@ -1184,6 +1283,119 @@ GROUP BY severity ORDER BY severity;"
|
||||
|
||||
---
|
||||
|
||||
# App→CVE Detection Engine & Mobile Device Security
|
||||
|
||||
Closes the coverage gap where a device has no real vulnerability scanner
|
||||
(Intune-only endpoints, mobile devices) or where the scanner's own CPE
|
||||
matching is too loose (false positives) or too narrow (false negatives).
|
||||
|
||||
## Built-in App CVE Scanner
|
||||
|
||||
Maps installed software (Wazuh syscollector packages, Intune `detectedApps`)
|
||||
to real CVEs via two independent, complementary sources:
|
||||
|
||||
| Source | Module | Strength |
|
||||
|---|---|---|
|
||||
| **OSV.dev** | `app_cve_scanner_service.py` | Precise server-side version matching for language ecosystems (npm, PyPI, ...) |
|
||||
| **NVD-CPE** | `app_cve_scanner_service.py` | Broad desktop-app coverage via curated CPE registry + own version-range check (cpeMatch start/end incl/excl) |
|
||||
| **cvelistV5 range match** | `cvelistv5_scan_service.py` | Catches CVEs NVD hasn't CPE'd yet, or filed under a CPE product string we didn't curate (e.g. a TeamViewer CVE under `teamviewer:remote`, not `teamviewer:teamviewer`) — matches directly against the CNA's own `affected[].vendor/product` + version ranges |
|
||||
|
||||
Both registries are curated (name-regex → vendor/product), not fuzzy —
|
||||
unknown software is skipped rather than guessed, to keep false-positives
|
||||
near zero. Findings upsert as `source='app-scan'` with real CVE ids, so the
|
||||
normal EPSS/KEV/CVSS enrichment and multi-source cross-confirm apply.
|
||||
|
||||
The cvelistV5 path needs a **reverse index** (`{vendor,product} → CVE
|
||||
ranges`) built by walking the ~557 MB cvelistV5 ZIP once; cached in a
|
||||
Setting, rebuilt by the nightly job. A manual "App CVE Scan" run builds it
|
||||
on demand if missing (slower on first run, then cached).
|
||||
|
||||
**False-positive suppression** (same cvelistV5 data, inverse direction):
|
||||
Wazuh's own CPE matching sometimes over-reports across product editions
|
||||
(e.g. flags a SQL Server 2019 host with a CVE that only affects 2022/2025).
|
||||
`cvelistv5_scan_service.suppress_false_positives` marks a Wazuh finding
|
||||
`false_positive` only when the installed version is provably outside
|
||||
**every** clean cvelistV5 range for the matched product — conservative by
|
||||
design (Wazuh-sourced only, ≥2 shared significant tokens required to scope
|
||||
a product, any unbounded/ambiguous range aborts the check).
|
||||
|
||||
Endpoints: `POST /api/v1/vulnerabilities/app-cve-scan`,
|
||||
`POST /api/v1/vulnerabilities/suppress-false-positives` (both scoped to
|
||||
`asset_id` optionally). Nightly job runs the scan, rebuilds the cvelistV5
|
||||
index, then runs suppression.
|
||||
|
||||
## Mobile Device Security (Intune)
|
||||
|
||||
Runs inline during the Intune sync — no extra Graph calls beyond the
|
||||
device dict and `detectedApps` already fetched.
|
||||
|
||||
- **Device EOL/EOS** (`mobile_eol_service.py`) — Apple (iPhone/iPad) fuzzy-
|
||||
matches endoflife.date's full release list by marketing name; Samsung
|
||||
needs a curated SM-code → release-name table (no textual bridge exists
|
||||
between Intune's model code and endoflife's marketing name in either
|
||||
dataset) — ~120 models, all verified against the live endoflife API.
|
||||
- **OS-level CVEs** — iOS/iPadOS/macOS via NVD-CPE (`app_cve_scanner_service`),
|
||||
with a platform check (CPE `target_sw` token) so e.g. a Firefox-for-iOS
|
||||
CVE can't match a desktop Firefox install.
|
||||
- **Android patch-level staleness** — Intune's `androidSecurityPatchLevel`
|
||||
vs. today; graduated severity (≥90/180/365 days → low/medium/high).
|
||||
- **Android per-CVE detection** — two sources, Samsung preferred:
|
||||
- **Samsung SMR** (`samsung_smr_service.py`) — `securityUpdate.smsb?year=YYYY`
|
||||
serves the full year's ~12 monthly sections server-side (the accordion
|
||||
UI is pure CSS/JS, doesn't gate content); parses each `SMR-MMM-YYYY`
|
||||
block's Google Critical/High list **minus** "Not applicable to Samsung
|
||||
devices" (chipset-specific CVEs Samsung's own page excludes) plus
|
||||
Samsung Semiconductor fixes. Precise per-device applicability.
|
||||
- **Google ASB** (`android_cve_service.py`) — fallback for non-Samsung
|
||||
Android or months SMR doesn't cover. Section-aware parser keeps only
|
||||
AOSP sections (Framework/System/Kernel/...), drops SoC/vendor sections
|
||||
(Qualcomm/MediaTek/...) that only apply to that specific chipset.
|
||||
URL format changed in 2026 (`/bulletin/{year}/{month}` vs. the older
|
||||
flat `/bulletin/{month}`) — both tried.
|
||||
- Both cache per-month/year in a Setting; empty/404 months are
|
||||
negatively cached (short TTL) so a not-yet-published month doesn't
|
||||
trigger a re-fetch on every device sync.
|
||||
|
||||
Dashboard: a dedicated **"Mobile Security · EOL & Patch Level"** widget,
|
||||
kept separate from the desktop-software EOL widget, sorted so a reached
|
||||
vendor-EOL outranks patch-level staleness. `GET /api/v1/vulnerabilities?
|
||||
finding_type=mobile` backs both the widget and its "View All".
|
||||
|
||||
## Advisory Awareness Feed
|
||||
|
||||
Independent of asset findings — a rolling view of what's actively
|
||||
exploited in the wild (CISA KEV), so 0-days are visible before any scanner
|
||||
flags an affected asset. Reuses the KEV catalog enrichment already
|
||||
fetches/caches (24h). `GET /api/v1/advisories/kev-recent` → dashboard
|
||||
widget with an "in inventory / not seen" badge per CVE (one grouped query,
|
||||
no per-CVE lookup).
|
||||
|
||||
## Assets: filter by sync source
|
||||
|
||||
`GET /api/v1/assets?source=...` now filters by the **actual scanner
|
||||
linkage** (`wazuh_agent_id` / `nessus_host_uuid` / `intune_device_id` /
|
||||
`defender_machine_id` / a vuln row whose `sources` names that scanner) —
|
||||
not the creation-time `source` enum, which never updates after an asset is
|
||||
matched by a second scanner post-creation.
|
||||
|
||||
## Out of scope (future ideas)
|
||||
|
||||
- Samsung-proprietary SVE CVEs (no ASB equivalent; would need
|
||||
`security.samsungmobile.com`'s per-device JS-loaded detail view, not
|
||||
scrapeable without a browser)
|
||||
- Android bulletin OEM coverage beyond Samsung (Google Pixel / others) —
|
||||
ASB-only fallback already covers them, just without a vendor-specific
|
||||
applicability filter
|
||||
- SAP (Business Client / GUI / Analysis for MS Office) CVE detection needs
|
||||
a patch-level (SP/PL) aware matcher — NVD's CPE covers a whole minor
|
||||
version with no SP granularity, so a naive match false-positives on
|
||||
already-patched installs. Needs a curated per-CVE fixed-SP table.
|
||||
- Microsoft Teams classic EOL flag — no endoflife.date product exists;
|
||||
would need a hardcoded retirement-date exotic (same shape as the existing
|
||||
Silverlight/VC++ redistributable entries)
|
||||
|
||||
---
|
||||
|
||||
## Out of scope (future ideas)
|
||||
|
||||
- LDAP password change flow (currently read-only — users change pw in AD)
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<p align="center">
|
||||
<img src="frontend/public/logo.png" alt="VulnCheck Logo" width="120" />
|
||||
<img src="frontend/public/logo.png" alt="TrueVuln Logo" width="120" />
|
||||
</p>
|
||||
|
||||
<h1 align="center">VulnCheck Dashboard</h1>
|
||||
<h1 align="center">TrueVuln Dashboard</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>Open Source Vulnerability Management for Wazuh</strong><br>
|
||||
Prioritize, verify, and resolve vulnerabilities with automated workflows and AI-powered analysis.
|
||||
<strong>Open Source Vulnerability Management — Wazuh · Nessus · Microsoft Intune</strong><br>
|
||||
Aggregate findings from multiple scanners, enrich them from authoritative sources, and prioritize, verify, and resolve them with automated workflows and AI-powered analysis.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -21,16 +21,22 @@
|
||||
|
||||
## Overview
|
||||
|
||||
VulnCheck is a self-hosted vulnerability management dashboard that integrates with [Wazuh](https://wazuh.com/) to provide a centralized view of your infrastructure's security posture. It automates vulnerability discovery, SLA tracking, patch verification, and reporting -- backed by AI analysis from multiple providers.
|
||||
TrueVuln is a self-hosted vulnerability management dashboard that aggregates findings from [Wazuh](https://wazuh.com/), Tenable Nessus, and Microsoft Intune/Defender into a single, prioritized view of your infrastructure's security posture. It enriches every CVE from authoritative open sources, detects end-of-life software and Microsoft 365 Apps gaps that scanners miss, and automates SLA tracking, patch verification, and reporting -- backed by AI analysis from multiple providers.
|
||||
|
||||
**Key capabilities:**
|
||||
|
||||
- Sync vulnerabilities and assets from Wazuh automatically
|
||||
- AI-powered CVE analysis with remediation recommendations
|
||||
- SLA policy enforcement with automated email alerts
|
||||
- Automated patch verification via Wazuh Syscollector rescans
|
||||
- Role-based access with full audit trail
|
||||
- PDF/CSV reporting for compliance workflows
|
||||
- Multi-source inventory & findings: Wazuh agents, Nessus scans, Microsoft Intune (MDM/UEM) + Defender for Endpoint TVM
|
||||
- Cross-confirmation when several scanners report the same CVE on the same host
|
||||
- Threat-intel enrichment: EPSS, CISA KEV, ENISA EUVD, public-exploit catalogs (Exploit-DB / PoC-in-GitHub / Metasploit), and authoritative CVE dates/CVSS (cvelistV5 / Vulnrichment / NVD)
|
||||
- Multi-source remediation: scanner solution + Microsoft (MSRC KBs), Ubuntu USN, Red Hat/CentOS errata, and the OSV.dev aggregator
|
||||
- End-of-life / end-of-support detection (endoflife.date + the Microsoft product-lifecycle export) and Microsoft 365 Apps CVE detection (not in NVD)
|
||||
- Built-in App→CVE scanner (OSV / NVD-CPE / cvelistV5) for software with no real vulnerability scanner, plus Wazuh false-positive suppression
|
||||
- Mobile device security for Intune-managed phones/tablets: EOL/EOS (Samsung/Apple), Android patch-level staleness, and per-CVE Android detection (Samsung SMR / Google ASB)
|
||||
- **Security Advisory Feeds** page: CISA KEV "actively exploited" plus configurable RSS sources (ZDI, CERT-EU, BSI/CERT-Bund, Cisco PSIRT, custom URLs) — early-warning sources that often publish before NVD/cvelistV5
|
||||
- AI-powered CVE analysis + on-demand, OS-aware remediation generation
|
||||
- Priority + CPR risk scoring, SLA enforcement with email alerts, automated patch verification
|
||||
- Multi-provider authentication (local, LDAP, OIDC, SAML) with TOTP MFA
|
||||
- Role-based access with a full revision-proof audit trail, PDF/CSV reporting
|
||||
|
||||
---
|
||||
|
||||
@@ -47,14 +53,40 @@ VulnCheck is a self-hosted vulnerability management dashboard that integrates wi
|
||||
- Status tracking: Open, Patched, Pending Verification, Patch Failed, Accepted Risk, False Positive, Deferred
|
||||
- Per-vulnerability notification suppression
|
||||
|
||||
### Wazuh Integration
|
||||
- Auto-discover agents and sync vulnerability data
|
||||
- Trigger Syscollector scans directly from the UI
|
||||
- Automated patch verification: mark as patched, VulnCheck rescans and confirms
|
||||
- Deduplication of CVEs per asset
|
||||
### Scanner & Inventory Integrations
|
||||
- **Wazuh** -- auto-discover agents, sync vulnerability data, trigger Syscollector scans from the UI, automated patch verification (mark patched → rescan → confirm)
|
||||
- **Tenable Nessus** -- import findings from configured scans (X-ApiKeys), launch/poll/import scans, VPR score, exploit availability/maturity, scanner remediation text
|
||||
- **Microsoft Intune (Graph API)** -- app-only (client-credentials) sync of managed devices → assets + OS-EOL, and `detectedApps` → EOL/M365 detection (no Wazuh agent required)
|
||||
- **Microsoft Defender for Endpoint (TVM)** -- optional real per-device CVEs via the Defender API
|
||||
- One CVE per asset (deduplicated); multiple scanners on the same finding are merged and flagged **cross-confirmed**; assets are soft-inactivated (not deleted) when a source stops reporting them, with audit trail
|
||||
|
||||
### Threat-Intelligence Enrichment
|
||||
- **EPSS** (FIRST.org) exploitation probability, **CISA KEV** (known-exploited, plus a dedicated "actively exploited" advisory dashboard widget independent of asset findings), **ENISA EUVD** (EU exploited/critical)
|
||||
- **Public-exploit catalogs** -- Exploit-DB, PoC-in-GitHub, Metasploit module index
|
||||
- **Authoritative CVE metadata** -- published/last-modified dates, CVSS, and descriptions from CVE.org **cvelistV5**, CISA **Vulnrichment**, and the **NVD** API (CVSS-correction cascade for placeholder/wrong scores)
|
||||
|
||||
### End-of-Life & Microsoft 365 Detection
|
||||
- **EOL/EOS detection** for installed software and OS via [endoflife.date](https://endoflife.date) plus the Microsoft product-lifecycle export (covers exotics like SQL Server, Visual C++ Redistributables, Silverlight)
|
||||
- **Microsoft 365 Apps CVE detection** -- compares the installed Office build against the Microsoft 365 Apps security-update channels for CVEs that never reach NVD or Wazuh
|
||||
|
||||
### Windows OS & Microsoft Product CVE Detection
|
||||
- **Windows OS CVEs (Server AND Client)** via cvelistV5's bounded build ranges, family-matched (a Win11 24H2 host never matches a Server-2025-only fix even though both live on build line 26100) — patch-level accurate, available on Patch Tuesday, well ahead of the Wazuh CTI feed
|
||||
- **SharePoint 2013/2016/2019/Subscription** and **modern .NET (8/9/10)** detection with one registry key per release, so generic version floors can't cross-match releases (.NET Framework is excluded by design: its ARP version is static across monthly patches — Defender TVM covers it file/KB-based)
|
||||
- **MSRC fixed-build scan** as a second, authoritative source (KB numbers per servicing branch); cross-confirms, auto-resolves once a host catches up, nightly job + manual trigger (`POST /api/v1/vulnerabilities/msrc-scan`)
|
||||
|
||||
### Built-in App→CVE Scanner & Mobile Device Security
|
||||
- **App→CVE scanner** -- maps installed software (Wazuh syscollector packages, Intune `detectedApps`) to real CVEs via a curated OSV / NVD-CPE registry plus a direct cvelistV5 range match (catches fresh CVEs NVD hasn't CPE'd yet, or ones filed under a CPE product string that wasn't curated); results cross-confirm and enrich like any other source
|
||||
- **False-positive suppression** -- flags a Wazuh finding as false-positive when the installed version is provably outside every clean cvelistV5 version range for the matched product (e.g. a SQL Server 2019 host wrongly flagged with a 2022/2025-only CVE)
|
||||
- **Mobile device EOL/EOS** (Samsung, Apple) and **Android security-patch-level staleness** for Intune-managed phones/tablets, with a dedicated dashboard widget
|
||||
- **Per-CVE Android detection** -- Samsung's own SMR bulletin (precise, excludes chipset CVEs that don't apply to the device) with Google's ASB as a fallback
|
||||
|
||||
### Multi-Source Remediation
|
||||
The CVE detail page shows remediation from every available source side by side:
|
||||
- Scanner solution (Nessus), **Microsoft (MSRC)** KB + fixed build + download link (filtered to the host's build), **Ubuntu USN**, **Red Hat / CentOS / Alma** errata, and the **OSV.dev** aggregator (Debian, SUSE, Alpine, Rocky, language ecosystems)
|
||||
- Workarounds / mitigations / containment when no patch exists yet; the MSRC update-guide link per CVE
|
||||
|
||||
### AI-Powered Analysis
|
||||
Supports multiple AI providers for CVE analysis, threat assessment, and remediation guidance:
|
||||
Supports multiple AI providers for CVE analysis, threat assessment, and on-demand OS-aware remediation generation (including an OpenRouter integration for the per-CVE "Generate fix steps" button, EOL-aware for end-of-life findings):
|
||||
|
||||
| Provider | Models |
|
||||
|---|---|
|
||||
@@ -65,7 +97,7 @@ Supports multiple AI providers for CVE analysis, threat assessment, and remediat
|
||||
| Ollama (local) | Llama 3.3, Mistral, CodeLlama, Phi-4 |
|
||||
| Infomaniak | Llama 3, Mistral 3, Mixtral, Granite, Qwen 3, Gemma 3n |
|
||||
|
||||
> **Ollama in Docker:** Use `http://host.docker.internal:11434/v1` as the Base URL when running VulnCheck in Docker with Ollama on the host.
|
||||
> **Ollama in Docker:** Use `http://host.docker.internal:11434/v1` as the Base URL when running TrueVuln in Docker with Ollama on the host.
|
||||
|
||||
### Automated Workflows
|
||||
- **Scheduled scans** with configurable intervals (hourly, daily, weekly) or cron expressions
|
||||
@@ -73,18 +105,23 @@ Supports multiple AI providers for CVE analysis, threat assessment, and remediat
|
||||
- **Patch verification** triggers a Wazuh rescan and updates status automatically
|
||||
|
||||
### Notifications
|
||||
- Customizable HTML email templates for SLA breaches and new vulnerability alerts
|
||||
- Live preview of email templates with sample data
|
||||
- SMTP configuration with test email functionality
|
||||
- Full notification history with status tracking (Sent, Failed, Suppressed)
|
||||
- New-CVE email alerts from **every** scanner source (Wazuh, Nessus, App→CVE scanner, Defender TVM) — not just Wazuh
|
||||
- Customizable HTML email templates for SLA breaches, single new-CVE alerts, **and** the aggregated digest — all editable in the UI with live preview
|
||||
- Digest table sorted by **CPR** (priority) descending, with per-CVE affected-systems count and ready-made deep links; template variables include `{{cpr_score}}`, `{{affected_assets_count}}`, `{{cve_link}}`, `{{asset_link}}`
|
||||
- **Delivery schedule:** per-sync (immediate) or a **nightly roundup** — one aggregated mail per recipient of the day's new CVEs
|
||||
- **Send rate limiting** (delay between mails + max per run) to respect SMTP-provider anti-spam limits
|
||||
- Recipient routing by asset/finding assignment (user or group), with a configurable **Default Recipient(s)** fallback — or all admins — for unassigned new-CVE mails
|
||||
- Notification history with status tracking (Sent, Failed, Suppressed) — admins see all; other roles see only notifications addressed to them
|
||||
|
||||
> **Performance Note:** Avoid sending large batches of emails simultaneously. Most SMTP providers (especially Proton, Gmail, Outlook) enforce strict rate limits (~10-20 emails per minute). Exceeding these limits may result in "too many connections" errors or temporary blocks. SLA breach notifications are throttled to 1 email per vulnerability per 24 hours by default.
|
||||
> **Performance Note:** Most SMTP providers (Proton, Gmail, Outlook) enforce strict rate limits (~10-20 emails/minute). Use **nightly roundup** mode and/or the send rate limit to stay under them. SLA-breach notifications are throttled to 1 email per vulnerability per 24 hours by default.
|
||||
|
||||
### Asset Management
|
||||
- Auto-discovery from Wazuh agents or manual creation
|
||||
- Bulk assignment to users, groups, and SLA policies
|
||||
- Track OS, location, owner, and scan history per asset
|
||||
|
||||
> **Asset ownership:** Assigning an asset (or an individual finding) to a user/group is a **notification/ownership tag** — it routes new-CVE and SLA-breach emails to that owner. It does **not** affect scanning, scoring, or visibility (all view-permitted users see every CVE). New-CVE mails fall back to the default recipients / admins when unassigned; **SLA-breach mails have no fallback and require an assignee**, so assign each asset to a system owner. See README.DEV for details.
|
||||
|
||||
### SLA Policies
|
||||
- Define severity-based remediation windows (e.g., Critical: 2 days, High: 7 days)
|
||||
- Assign policies to assets
|
||||
@@ -97,8 +134,8 @@ Supports multiple AI providers for CVE analysis, threat assessment, and remediat
|
||||
|
||||
### Security
|
||||
- JWT authentication with role-based access control (Admin, Editor, Readonly)
|
||||
- Bcrypt password hashing with account lockout
|
||||
- Comprehensive audit logging (who changed what, when)
|
||||
- Bcrypt password hashing; login rate-limit + **account lockout** after 5 failed attempts — temporary 15-min auto-unlock, or opt-in **permanent lockout** cleared only by an admin (the last active admin is never permanently locked)
|
||||
- Comprehensive audit logging (who changed what, when), optionally **forwarded to a syslog/SIEM** server (UDP/TCP, RFC-5424, per-event severity) for central alerting
|
||||
- Security headers (CSP, HSTS, X-Frame-Options)
|
||||
- Rate limiting on API endpoints
|
||||
|
||||
@@ -109,7 +146,9 @@ Three roles with hierarchical permissions: **Admin > Editor > Readonly**
|
||||
| Action | Readonly | Editor | Admin |
|
||||
|---|:---:|:---:|:---:|
|
||||
| **View** vulnerabilities, assets, reports, dashboards | ✅ | ✅ | ✅ |
|
||||
| **View** scan history, notification history | ✅ | ✅ | ✅ |
|
||||
| **View/download** reports (Executive, Technical CSV, ISO 27001, Patching) | ✅ | ✅ | ✅ |
|
||||
| **View** scan history | ✅ | ✅ | ✅ |
|
||||
| **View** notification history | own only | own only | ✅ all |
|
||||
| **View** AI analysis history | ✅ | ✅ | ✅ |
|
||||
| **Edit** vulnerabilities (status, assign, defer, reopen) | ❌ | ✅ | ✅ |
|
||||
| **Trigger** Wazuh sync and scans | ❌ | ✅ | ✅ |
|
||||
@@ -122,7 +161,7 @@ Three roles with hierarchical permissions: **Admin > Editor > Readonly**
|
||||
| **Create/Delete** SLA policies | ❌ | ❌ | ✅ |
|
||||
| **Manage** users (create, edit, delete, reset password) | ❌ | ❌ | ✅ |
|
||||
| **Manage** groups | ❌ | ❌ | ✅ |
|
||||
| **Configure** settings (Wazuh, AI, SMTP) | ❌ | ❌ | ✅ |
|
||||
| **Configure** settings (Wazuh, Nessus, Intune, AI, SMTP) | ❌ | ❌ | ✅ |
|
||||
| **Send** test/critical notifications | ❌ | ❌ | ✅ |
|
||||
| **View** audit logs | ❌ | ❌ | ✅ |
|
||||
|
||||
@@ -131,7 +170,7 @@ Three roles with hierarchical permissions: **Admin > Editor > Readonly**
|
||||
## Screenshots
|
||||
|
||||
### Dashboard
|
||||

|
||||

|
||||
|
||||
---
|
||||
|
||||
@@ -149,7 +188,7 @@ Three roles with hierarchical permissions: **Admin > Editor > Readonly**
|
||||
| RAM | 2 GB | 4 GB |
|
||||
| Disk | 10 GB | 20 GB |
|
||||
|
||||
> **Note:** These requirements are for the VulnCheck application only. If running Wazuh on the same VM, add its requirements accordingly.
|
||||
> **Note:** These requirements are for the TrueVuln application only. If running Wazuh on the same VM, add its requirements accordingly.
|
||||
|
||||
### 1. Clone the repository
|
||||
|
||||
@@ -216,7 +255,7 @@ This starts three containers:
|
||||
|
||||
### Reverse Proxy (recommended)
|
||||
|
||||
VulnCheck is designed to run behind a reverse proxy (Nginx Proxy Manager, Traefik, Caddy, etc.):
|
||||
TrueVuln is designed to run behind a reverse proxy (Nginx Proxy Manager, Traefik, Caddy, etc.):
|
||||
|
||||
1. Point your reverse proxy to the **frontend** port (default `3000`)
|
||||
2. The frontend proxies all API calls to the backend internally via Docker networking
|
||||
@@ -295,11 +334,13 @@ curl -X POST https://your-domain.tld/auth/setup-admin \
|
||||
|
||||
### 5. Configure integrations
|
||||
|
||||
In **Settings**, configure:
|
||||
In **Settings**, configure the integrations you need (all optional, all stored encrypted at rest):
|
||||
|
||||
1. **Wazuh SIEM** -- API URL, credentials, and Indexer connection
|
||||
2. **AI Provider** -- Choose your provider and enter API credentials
|
||||
3. **SMTP Email** -- For SLA breach and vulnerability notifications
|
||||
2. **Tenable Nessus** -- base URL + API keys, default scan IDs
|
||||
3. **Microsoft Intune (Graph API)** -- tenant/client ID + client secret (app-only); optional Defender TVM
|
||||
4. **AI Provider / OpenRouter** -- choose your provider and enter API credentials
|
||||
5. **SMTP Email** -- for SLA breach and vulnerability notifications
|
||||
|
||||
---
|
||||
|
||||
@@ -314,11 +355,13 @@ In **Settings**, configure:
|
||||
┌─────────────┼─────────────┐
|
||||
│ │ │
|
||||
┌─────▼────┐ ┌────▼─────┐ ┌───▼────┐
|
||||
│PostgreSQL │ │ Wazuh │ │ AI │
|
||||
│ 15 │ │ SIEM │ │Provider│
|
||||
│PostgreSQL │ │ Scanners │ │ AI │
|
||||
│ 15 │ │ & feeds │ │Provider│
|
||||
└──────────┘ └──────────┘ └────────┘
|
||||
```
|
||||
|
||||
**External sources:** Wazuh, Tenable Nessus, Microsoft Graph (Intune) + Defender TVM, and read-only enrichment feeds — EPSS/KEV/EUVD, cvelistV5/Vulnrichment/NVD, endoflife.date + MS lifecycle export, MSRC, Ubuntu/Red Hat advisories, OSV.dev, public-exploit catalogs.
|
||||
|
||||
**Tech Stack:**
|
||||
|
||||
| Layer | Technology |
|
||||
@@ -358,7 +401,7 @@ All configuration is done via the `.env` file:
|
||||
|
||||
### Integrations
|
||||
|
||||
All integrations (Wazuh, AI, SMTP) are configured through the **Settings** page in the UI. No additional environment variables are needed for these.
|
||||
All integrations (Wazuh, Nessus, Intune/Defender, AI/OpenRouter, SMTP) are configured through the **Settings** page in the UI and stored encrypted at rest. No additional environment variables are required. Optional env keys exist for headless/CI use (e.g. `NVD_API_KEY`, `OPENROUTER_API_KEY`) — see `.env.example`. Multi-provider auth (LDAP/OIDC/SAML/MFA) is configured via env + the auth admin UI; see `.env.example`.
|
||||
|
||||
---
|
||||
|
||||
@@ -366,7 +409,7 @@ All integrations (Wazuh, AI, SMTP) are configured through the **Settings** page
|
||||
|
||||
### Reverse Proxy Setup
|
||||
|
||||
VulnCheck is designed to run behind a reverse proxy. Only the **frontend port** needs to be reachable by the proxy -- the frontend handles all API routing internally.
|
||||
TrueVuln is designed to run behind a reverse proxy. Only the **frontend port** needs to be reachable by the proxy -- the frontend handles all API routing internally.
|
||||
|
||||
```
|
||||
Internet → Reverse Proxy (443/HTTPS) → Frontend (3003) → Backend (8022, internal)
|
||||
@@ -487,8 +530,9 @@ The backend exposes a REST API at `/api/v1/`. All endpoints require JWT authenti
|
||||
| Endpoint Group | Base Path | Description |
|
||||
|---|---|---|
|
||||
| Authentication | `/auth` | Login, logout, user management |
|
||||
| Vulnerabilities | `/api/v1/vulnerabilities` | CRUD, AI analysis, Wazuh sync, bulk ops |
|
||||
| Assets | `/api/v1/assets` | Inventory, assignment, bulk ops |
|
||||
| Vulnerabilities | `/api/v1/vulnerabilities` | CRUD, AI analysis, Wazuh sync, App→CVE scan, FP suppression, bulk ops |
|
||||
| Assets | `/api/v1/assets` | Inventory, assignment, sync-source filter, bulk ops |
|
||||
| Advisories | `/api/v1/advisories` | CISA KEV feed + configurable RSS advisory feeds (`/feeds`, `/feeds/refresh`) |
|
||||
| Scans | `/api/v1/scans` | Scan jobs, schedules |
|
||||
| Policies | `/api/v1/policies` | SLA policy management |
|
||||
| Groups | `/api/v1/groups` | User group management |
|
||||
@@ -498,7 +542,117 @@ The backend exposes a REST API at `/api/v1/`. All endpoints require JWT authenti
|
||||
| Audit | `/audit` | Audit trail (admin only) |
|
||||
| Health | `/health` | Health check (no auth) |
|
||||
|
||||
Interactive API docs are available at `http://localhost:8022/docs` (Swagger UI).
|
||||
**Interactive docs (Swagger UI):** `http://<host>:8022/docs`, ReDoc at `/redoc`.
|
||||
Enabled automatically in development (`ENV=development`). On a production
|
||||
instance set `ENABLE_API_DOCS=true` in `.env` to turn them on. The raw OpenAPI
|
||||
spec is **always** served at `/openapi.json` — import it into Postman/Insomnia
|
||||
or generate a client, regardless of the docs toggle.
|
||||
|
||||
### Calling the API from an external system
|
||||
|
||||
The API is standard REST + JSON, secured with a JWT **Bearer** token. Flow:
|
||||
**(1)** log in once to get a token, **(2)** send it as `Authorization: Bearer …`
|
||||
on every request. Access tokens expire after **30 min**; use the refresh token
|
||||
or just log in again for a fresh one.
|
||||
|
||||
> Use a dedicated service user (role `viewer` is enough for read-only ITSM/CMDB
|
||||
> pulls) and **disable MFA** on it — otherwise `/auth/login` returns
|
||||
> `mfa_required` and expects a second `/auth/mfa/verify` step. Replace
|
||||
> `https://truevuln.example.com` with your host.
|
||||
|
||||
**1 — Log in, get a token**
|
||||
|
||||
```bash
|
||||
TOKEN=$(curl -s -X POST https://truevuln.example.com/auth/login \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"username":"svc-itsm","password":"••••••"}' \
|
||||
| jq -r .access_token)
|
||||
```
|
||||
|
||||
Response body:
|
||||
|
||||
```json
|
||||
{
|
||||
"access_token": "eyJhbGci…",
|
||||
"refresh_token": "eyJhbGci…",
|
||||
"token_type": "bearer",
|
||||
"user": { "id": 7, "username": "svc-itsm", "role": "viewer" },
|
||||
"mfa_required": false
|
||||
}
|
||||
```
|
||||
|
||||
**2 — List all vulnerabilities (with the token)**
|
||||
|
||||
```bash
|
||||
curl -s https://truevuln.example.com/api/v1/vulnerabilities \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
**Filter** — the list endpoint takes query params (combine freely):
|
||||
|
||||
```bash
|
||||
# Only actively-exploited (CISA KEV), critical, open findings — top 500
|
||||
curl -s "https://truevuln.example.com/api/v1/vulnerabilities?kev_only=true&severity=critical&status=open&limit=500" \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
|
||||
# Everything on one specific asset
|
||||
curl -s "https://truevuln.example.com/api/v1/vulnerabilities?asset_id=42" \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
|
||||
# Only Nessus-sourced findings, sorted by CVSS
|
||||
curl -s "https://truevuln.example.com/api/v1/vulnerabilities?source=nessus&sort_by=cvss&sort_order=desc" \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
Common query params: `severity` (critical/high/medium/low), `status`,
|
||||
`kev_only`, `exploitable`, `epss_min` (0.0–1.0), `source` (wazuh/nessus/manual),
|
||||
`asset_id`, `search` (CVE-ID/package/title), `sort_by` (priority/cvss/detected_at),
|
||||
`sort_order`, `limit` (≤1000). Full list + response schema in `/docs`.
|
||||
|
||||
**3 — Pull the asset inventory (ITAM/CMDB sync)**
|
||||
|
||||
```bash
|
||||
curl -s https://truevuln.example.com/api/v1/assets \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
**4 — Refresh an expired token** (no re-login needed within refresh-token life)
|
||||
|
||||
```bash
|
||||
curl -s -X POST https://truevuln.example.com/auth/refresh \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"refresh_token\":\"$REFRESH_TOKEN\"}"
|
||||
```
|
||||
|
||||
**Python example** (e.g. an ITSM integration script):
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
BASE = "https://truevuln.example.com"
|
||||
tok = requests.post(f"{BASE}/auth/login",
|
||||
json={"username": "svc-itsm", "password": "••••••"}).json()["access_token"]
|
||||
hdr = {"Authorization": f"Bearer {tok}"}
|
||||
|
||||
vulns = requests.get(f"{BASE}/api/v1/vulnerabilities",
|
||||
params={"kev_only": True, "status": "open", "limit": 1000},
|
||||
headers=hdr).json()
|
||||
for v in vulns:
|
||||
print(v["cve_id"], v["severity"], v["asset_hostname"])
|
||||
```
|
||||
|
||||
Each finding is a flat JSON object — key fields: `cve_id`, `asset_id`,
|
||||
`asset_hostname`, `cvss_score`, `severity`, `status`, `epss_score`,
|
||||
`kev_listed`, `exploit_available`, `priority_score`, `sources`, `detected_at`.
|
||||
|
||||
> **Response shape:** with the default `sort_by=priority` the endpoint returns a
|
||||
> bare JSON **array**. With `sort_by=cvss` or `sort_by=detected_at` it returns a
|
||||
> paged object `{"items": [...], "total": N}` instead — read `.items` in that
|
||||
> case.
|
||||
|
||||
> **Note:** tokens are short-lived user JWTs (30 min). A long-lived API key /
|
||||
> service token and outbound webhooks are not built yet — say so if you need
|
||||
> them.
|
||||
|
||||
---
|
||||
|
||||
@@ -517,17 +671,6 @@ Please open an issue first for larger changes to discuss the approach.
|
||||
|
||||
---
|
||||
|
||||
## Roadmap
|
||||
|
||||
- [ ] Webhook notifications (Slack, Teams, generic)
|
||||
- [ ] LDAP/Active Directory authentication
|
||||
- [ ] Multi-tenant support
|
||||
- [ ] Additional SIEM integrations
|
||||
- [ ] Vulnerability scanning without Wazuh (standalone agent)
|
||||
- [ ] Dark mode
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the **GNU Affero General Public License v3.0 (AGPLv3)**. See [LICENSE](LICENSE) for details.
|
||||
@@ -650,7 +793,7 @@ If you encounter issues or have questions, please [open an issue](https://gitea.
|
||||
|
||||
## Support the Project
|
||||
|
||||
If VulnCheck is useful to you, consider buying me a coffee! ☕
|
||||
If TrueVuln is useful to you, consider buying me a coffee! ☕
|
||||
|
||||
<a href="https://buymeacoffee.com/vulncheck" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" height="50"></a>
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ Adds ``vulnerabilities.nessus_vpr_score`` — Tenable's proprietary
|
||||
Vulnerability Priority Rating (0–10) from the Nessus plugin payload.
|
||||
|
||||
Stored alongside our own ``priority_score`` so users can compare:
|
||||
- VulnCheck's contextual priority (CVSS + KEV + EUVD + asset policy + age)
|
||||
- TrueVuln's contextual priority (CVSS + KEV + EUVD + asset policy + age)
|
||||
- Tenable's commercial VPR ranking
|
||||
|
||||
Idempotent — skips the column add if it already exists.
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Reconcile legacy Nessus-sourced assets without a pinned nessus_host_uuid
|
||||
|
||||
Revision ID: 028
|
||||
Revises: 027
|
||||
Create Date: 2026-06-02 14:00:00.000000
|
||||
|
||||
Tester feedback round 2026-06-02 (#3 INACTIVE not flipping on reduced
|
||||
Nessus scan): the event-driven reconciliation in
|
||||
`app.services.asset_lifecycle.reconcile_missing_from_sync` only inactivates
|
||||
assets whose `nessus_host_uuid` is in `seen_ids` of a recent sync. Assets
|
||||
created by older Nessus syncs that matched by IP or hostname (before the
|
||||
UUID-backfill path was added) have `nessus_host_uuid IS NULL` and are
|
||||
silently skipped. After a reduced scan they stay ACTIVE forever, which
|
||||
contradicts the "sync-driven INACTIVE" promise.
|
||||
|
||||
This migration is the one-shot cleanup for the existing backlog (33 rows
|
||||
in the test instance). New rows created after the 0006 commit (which
|
||||
adds the diagnostic log + the `reconcile_legacy_nessus_assets` runtime
|
||||
helper) are handled in code.
|
||||
|
||||
Idempotent: a row already INACTIVE matches the filter only when the
|
||||
status check is omitted, so the body re-checks status before flipping.
|
||||
Audit-logged via the same `_audit_asset_status` helper as the runtime
|
||||
path so the audit trail is consistent.
|
||||
|
||||
Downgrade is a no-op — restoring a row to ACTIVE would require operator
|
||||
intent, not a migration reversal.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "028"
|
||||
down_revision = "027"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Use raw SQL through the migration's session_bind so the connection
|
||||
# is the same one alembic manages — no extra pool, no second engine.
|
||||
bind = op.get_bind()
|
||||
# Re-import the model in the migration context. Alembic env has
|
||||
# already imported Base.metadata via app.models.base; this import
|
||||
# pulls in the Asset / AuditLog / AssetSource / AssetStatus enums
|
||||
# we need for the audit insert.
|
||||
from app.models.asset import Asset, AssetSource, AssetStatus
|
||||
from app.services.asset_lifecycle import _audit_asset_status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
with Session(bind=bind) as db:
|
||||
legacy = (
|
||||
db.query(Asset)
|
||||
.filter(
|
||||
Asset.source == AssetSource.NESSUS,
|
||||
Asset.status == AssetStatus.ACTIVE,
|
||||
Asset.nessus_host_uuid.is_(None),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
if not legacy:
|
||||
# Nothing to do — migration is a no-op on already-clean DBs.
|
||||
return
|
||||
for a in legacy:
|
||||
a.status = AssetStatus.INACTIVE
|
||||
_audit_asset_status(
|
||||
db,
|
||||
a,
|
||||
"active",
|
||||
"inactive",
|
||||
"legacy Nessus-sourced asset without pinned nessus_host_uuid — "
|
||||
"flipped by alembic migration 028 (reconcile_legacy_nessus_assets)",
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# No-op. Restoring INACTIVE -> ACTIVE is an operator decision, not a
|
||||
# migration reversal. The legacy rows can be re-activated by a fresh
|
||||
# Nessus sync that reports them (event-driven revive in
|
||||
# reconcile_missing_from_sync).
|
||||
pass
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Add last_modified_date to vulnerabilities (NVD lastModified)
|
||||
|
||||
Revision ID: 029
|
||||
Revises: 028
|
||||
Create Date: 2026-06-03 10:00:00.000000
|
||||
|
||||
Tester feedback: "Newly Published" widget sorted wrong even in VIEW ALL.
|
||||
Root cause — published_date was NEVER populated by any ingest path
|
||||
(Nessus/Wazuh import only set detected_at), so the column was all-NULL
|
||||
and the nulls-last sort produced arbitrary order.
|
||||
|
||||
Fix is two-part:
|
||||
1) Backfill published_date from the NVD CVE API (enrichment_service).
|
||||
2) Also persist the CVE's lastModified date so the UI can show
|
||||
"published vs updated" — the tester explicitly wanted both
|
||||
("Die Infos aus den CVEs published date und updated date").
|
||||
|
||||
This migration only adds the new column; published_date already exists.
|
||||
Idempotent — ADD COLUMN IF NOT EXISTS.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "029"
|
||||
down_revision = "028"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("""
|
||||
ALTER TABLE vulnerabilities
|
||||
ADD COLUMN IF NOT EXISTS last_modified_date TIMESTAMP;
|
||||
""")
|
||||
op.execute("""
|
||||
CREATE INDEX IF NOT EXISTS ix_vulnerabilities_published_date
|
||||
ON vulnerabilities (published_date);
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP INDEX IF EXISTS ix_vulnerabilities_published_date;")
|
||||
op.execute("""
|
||||
ALTER TABLE vulnerabilities
|
||||
DROP COLUMN IF EXISTS last_modified_date;
|
||||
""")
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Add remediation column to vulnerabilities (Nessus solution text)
|
||||
|
||||
Revision ID: 030
|
||||
Revises: 029
|
||||
Create Date: 2026-06-04 09:00:00.000000
|
||||
|
||||
Tester feature: Nessus scan results already carry a per-finding
|
||||
remediation ("solution") text. It was only being appended into the
|
||||
description blob — surface it in its own column so the CVE detail page can
|
||||
render a dedicated "Remediation" section below the affected package.
|
||||
|
||||
Idempotent — ADD COLUMN IF NOT EXISTS.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "030"
|
||||
down_revision = "029"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("""
|
||||
ALTER TABLE vulnerabilities
|
||||
ADD COLUMN IF NOT EXISTS remediation TEXT;
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("""
|
||||
ALTER TABLE vulnerabilities
|
||||
DROP COLUMN IF EXISTS remediation;
|
||||
""")
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Add VULNERABILITY_DETECTED audit event type
|
||||
|
||||
Revision ID: 031
|
||||
Revises: 030
|
||||
Create Date: 2026-06-09 10:00:00.000000
|
||||
|
||||
Tester: a CVE newly created by a Wazuh/Nessus sync appeared in the vuln
|
||||
list but had NO initial audit event ("new CVE detected on asset X") — the
|
||||
audit trail started with the first status change. Not revisionssicher.
|
||||
|
||||
This adds the enum value; the sync paths now write one
|
||||
VULNERABILITY_DETECTED event per newly created finding.
|
||||
|
||||
ALTER TYPE ... ADD VALUE cannot run inside a transaction block on older
|
||||
Postgres; alembic's autocommit_block handles that.
|
||||
Idempotent — IF NOT EXISTS.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "031"
|
||||
down_revision = "030"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.get_context().autocommit_block():
|
||||
op.execute(
|
||||
"ALTER TYPE auditeventtype ADD VALUE IF NOT EXISTS 'VULNERABILITY_DETECTED'"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Postgres cannot remove enum values; harmless to leave in place.
|
||||
pass
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Add cve_remediations table (multi-source enrichment)
|
||||
|
||||
Revision ID: 032
|
||||
Revises: 031
|
||||
Create Date: 2026-06-10 09:00:00.000000
|
||||
|
||||
Tester feature: enrich remediation coverage beyond the Nessus scanner
|
||||
solution. External primary sources (MSRC CVRF for Windows + MS products,
|
||||
later Ubuntu USN / CentOS errata for Linux) provide per-CVE fixes (KB +
|
||||
fixed build + download URL), workarounds, and mitigations/containment for
|
||||
cases where no patch/KB exists yet.
|
||||
|
||||
These are CVE-level (not per-asset-row), so they live in their own table
|
||||
keyed by cve_id; the existing vulnerabilities.remediation column (the
|
||||
scanner solution) is unchanged and shown alongside.
|
||||
|
||||
Idempotent — CREATE TABLE / INDEX IF NOT EXISTS.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "032"
|
||||
down_revision = "031"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("""
|
||||
CREATE TABLE IF NOT EXISTS cve_remediations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
cve_id VARCHAR(50) NOT NULL,
|
||||
source VARCHAR(32) NOT NULL, -- msrc | ubuntu | centos | ...
|
||||
kind VARCHAR(24) NOT NULL, -- fix | workaround | mitigation | advisory
|
||||
title VARCHAR(300),
|
||||
detail TEXT,
|
||||
kb VARCHAR(64),
|
||||
fixed_build VARCHAR(120),
|
||||
url TEXT,
|
||||
fetched_at TIMESTAMP NOT NULL DEFAULT now()
|
||||
);
|
||||
""")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_cve_remediations_cve_id ON cve_remediations (cve_id);")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_cve_remediations_cve_src ON cve_remediations (cve_id, source);")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP TABLE IF EXISTS cve_remediations;")
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Add INTUNE asset source + intune_device_id / defender_machine_id
|
||||
|
||||
Revision ID: 033
|
||||
Revises: 032
|
||||
Create Date: 2026-06-14 09:00:00.000000
|
||||
|
||||
Microsoft Intune (MDM/UEM) as a third inventory source next to Wazuh and
|
||||
Nessus. Adds the enum label + the pin columns used to reconnect an asset to
|
||||
its Intune managedDevice and (phase 3) its Defender for Endpoint machine.
|
||||
|
||||
ALTER TYPE ... ADD VALUE cannot run inside a transaction block on older
|
||||
Postgres → autocommit_block. Idempotent (IF NOT EXISTS).
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "033"
|
||||
down_revision = "032"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.get_context().autocommit_block():
|
||||
op.execute("ALTER TYPE assetsource ADD VALUE IF NOT EXISTS 'INTUNE'")
|
||||
op.execute("""
|
||||
ALTER TABLE assets
|
||||
ADD COLUMN IF NOT EXISTS intune_device_id VARCHAR(64),
|
||||
ADD COLUMN IF NOT EXISTS defender_machine_id VARCHAR(64);
|
||||
""")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_assets_intune_device_id ON assets (intune_device_id);")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_assets_defender_machine_id ON assets (defender_machine_id);")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP INDEX IF EXISTS ix_assets_intune_device_id;")
|
||||
op.execute("DROP INDEX IF EXISTS ix_assets_defender_machine_id;")
|
||||
op.execute("""
|
||||
ALTER TABLE assets
|
||||
DROP COLUMN IF EXISTS intune_device_id,
|
||||
DROP COLUMN IF EXISTS defender_machine_id;
|
||||
""")
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Add asset risk-dimension (high-value-target) columns
|
||||
|
||||
Revision ID: 034
|
||||
Revises: 033
|
||||
Create Date: 2026-06-16 09:00:00.000000
|
||||
|
||||
"Risk Dimensions": crown-jewel role detection (Domain Controller, ADCS,
|
||||
SQL, Exchange, WSUS, backup, ...) computed alongside network exposure and
|
||||
fed into the URS. Stores a 0-100 high_value_score + the detected roles.
|
||||
|
||||
Idempotent — ADD COLUMN IF NOT EXISTS.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "034"
|
||||
down_revision = "033"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("""
|
||||
ALTER TABLE assets
|
||||
ADD COLUMN IF NOT EXISTS high_value_score DOUBLE PRECISION,
|
||||
ADD COLUMN IF NOT EXISTS risk_dimensions TEXT,
|
||||
ADD COLUMN IF NOT EXISTS risk_dimensions_updated_at TIMESTAMP;
|
||||
""")
|
||||
op.execute("""
|
||||
CREATE INDEX IF NOT EXISTS ix_assets_high_value_score
|
||||
ON assets (high_value_score)
|
||||
WHERE high_value_score > 0;
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP INDEX IF EXISTS ix_assets_high_value_score;")
|
||||
op.execute("""
|
||||
ALTER TABLE assets
|
||||
DROP COLUMN IF EXISTS high_value_score,
|
||||
DROP COLUMN IF EXISTS risk_dimensions,
|
||||
DROP COLUMN IF EXISTS risk_dimensions_updated_at;
|
||||
""")
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Add app_cve_cache for the built-in app→CVE scanner
|
||||
|
||||
Revision ID: 035
|
||||
Revises: 034
|
||||
Create Date: 2026-06-19 09:00:00.000000
|
||||
|
||||
Caches (product, version) → CVE lookups from OSV / NVD-CPE so the built-in
|
||||
scanner doesn't re-query the same Chrome/Firefox/... version for every host
|
||||
(and stays under NVD's rate limit). CVE↔version is stable; refreshed on a
|
||||
TTL.
|
||||
|
||||
Idempotent.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "035"
|
||||
down_revision = "034"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("""
|
||||
CREATE TABLE IF NOT EXISTS app_cve_cache (
|
||||
id SERIAL PRIMARY KEY,
|
||||
product_key VARCHAR(160) NOT NULL, -- cpe:a:google:chrome | osv:npm:lodash
|
||||
version VARCHAR(120) NOT NULL,
|
||||
cves TEXT, -- JSON [{cve,cvss,severity,fixed}]
|
||||
fetched_at TIMESTAMP NOT NULL DEFAULT now()
|
||||
);
|
||||
""")
|
||||
op.execute("""
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_app_cve_cache_key_ver
|
||||
ON app_cve_cache (product_key, version);
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP TABLE IF EXISTS app_cve_cache;")
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Add assets.aad_device_id for cross-source (Intune ↔ Defender) merge
|
||||
|
||||
Revision ID: 036
|
||||
Revises: 035
|
||||
Create Date: 2026-07-09 10:00:00.000000
|
||||
|
||||
The same physical device has different per-service ids (intune_device_id vs
|
||||
defender_machine_id), so Intune and Defender could only merge by hostname —
|
||||
which fails when the names differ (e.g. Defender's computerDnsName is the
|
||||
Intune management name). The Entra/AAD device id is the stable cross-service
|
||||
anchor (Intune `azureADDeviceId` == Defender `aadDeviceId`); store + match on
|
||||
it so one physical device is one asset regardless of name/rename.
|
||||
|
||||
Idempotent.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "036"
|
||||
down_revision = "035"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("ALTER TABLE assets ADD COLUMN IF NOT EXISTS aad_device_id VARCHAR(64);")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_assets_aad_device_id ON assets (aad_device_id);")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP INDEX IF EXISTS ix_assets_aad_device_id;")
|
||||
op.execute("ALTER TABLE assets DROP COLUMN IF EXISTS aad_device_id;")
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Add users.locked + locked_at for permanent account lockout
|
||||
|
||||
Revision ID: 037
|
||||
Revises: 036
|
||||
Create Date: 2026-07-13 12:00:00.000000
|
||||
|
||||
Permanent-lockout mode (opt-in via the auth_lockout_permanent setting): after
|
||||
the failed-attempt threshold an account is locked until an admin clears it
|
||||
(post-incident), instead of the temporary 15-minute auto-unlock. The last
|
||||
active admin is never permanently locked (recoverable via the temporary path)
|
||||
so the system can't be fully locked out.
|
||||
|
||||
Idempotent.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "037"
|
||||
down_revision = "036"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("ALTER TABLE users ADD COLUMN IF NOT EXISTS locked BOOLEAN NOT NULL DEFAULT FALSE;")
|
||||
op.execute("ALTER TABLE users ADD COLUMN IF NOT EXISTS locked_at TIMESTAMP NULL;")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("ALTER TABLE users DROP COLUMN IF EXISTS locked_at;")
|
||||
op.execute("ALTER TABLE users DROP COLUMN IF EXISTS locked;")
|
||||
@@ -6,11 +6,11 @@ Configuration lives in the `settings` table under key `auth_role_mappings`
|
||||
|
||||
{
|
||||
"ldap": [
|
||||
{"pattern": "CN=VulnCheck-Admins,*", "role": "admin"},
|
||||
{"pattern": "CN=VulnCheck-Editors,*", "role": "editor"}
|
||||
{"pattern": "CN=TrueVuln-Admins,*", "role": "admin"},
|
||||
{"pattern": "CN=TrueVuln-Editors,*", "role": "editor"}
|
||||
],
|
||||
"oidc": [
|
||||
{"pattern": "vulncheck-admins", "role": "admin"}
|
||||
{"pattern": "truevuln-admins", "role": "admin"}
|
||||
],
|
||||
"saml": [...]
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ PROTECTED_SETTING_KEYS: frozenset[str] = frozenset({
|
||||
"wazuh_config",
|
||||
"smtp_config",
|
||||
"nessus_config",
|
||||
"openrouter_api_key",
|
||||
"intune_config",
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -15,13 +15,39 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth.jwt_handler import hash_password, verify_password
|
||||
from app.auth.strategies.base import AuthError, AuthStrategy, ExternalIdentity
|
||||
from app.models.user import AuthProvider, User
|
||||
from app.models.user import AuthProvider, User, UserRole
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ACCOUNT_LOCKOUT_THRESHOLD = 5
|
||||
ACCOUNT_LOCKOUT_DURATION_MIN = 15
|
||||
|
||||
|
||||
def _lockout_is_permanent(db: Session) -> bool:
|
||||
"""Opt-in via the `auth_lockout_permanent` setting. Off → the classic
|
||||
temporary 15-min auto-unlock."""
|
||||
from app.models.setting import Setting
|
||||
try:
|
||||
s = db.query(Setting).filter(Setting.key == "auth_lockout_permanent").first()
|
||||
return bool(s and str(s.value).strip().lower() in ("1", "true", "yes"))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _is_last_active_admin(db: Session, user: User) -> bool:
|
||||
"""True if `user` is the only admin that could still log in. Used to refuse
|
||||
PERMANENTLY locking the last recoverable admin — otherwise a brute-force on
|
||||
the admin login would lock everyone out of the system for good."""
|
||||
if user.role != UserRole.ADMIN:
|
||||
return False
|
||||
others = (
|
||||
db.query(User)
|
||||
.filter(User.role == UserRole.ADMIN, User.is_active.is_(True),
|
||||
User.locked.is_(False), User.id != user.id)
|
||||
.count()
|
||||
)
|
||||
return others == 0
|
||||
|
||||
# Pre-computed valid bcrypt hash used in the user-not-found branch so that
|
||||
# verify cost is constant-time regardless of whether the username exists.
|
||||
# Generated once at module import. The plaintext is irrelevant — it is never
|
||||
@@ -61,7 +87,15 @@ class LocalAuthStrategy(AuthStrategy):
|
||||
if not user.is_active:
|
||||
raise AuthError(detail=f"inactive user '{username}'")
|
||||
|
||||
# Account lockout
|
||||
# Permanent lock (admin must clear it) — takes precedence over the
|
||||
# temporary window and can only be lifted via the admin unlock endpoint.
|
||||
if getattr(user, "locked", False):
|
||||
raise AuthError(
|
||||
detail=f"user '{username}' is permanently locked (admin unlock required)",
|
||||
safe_message="Account locked. Contact an administrator.",
|
||||
)
|
||||
|
||||
# Temporary lockout window
|
||||
if user.failed_login_attempts >= ACCOUNT_LOCKOUT_THRESHOLD:
|
||||
# If updated_at is older than lockout window, auto-reset.
|
||||
unlock_after = (user.updated_at or user.created_at) + timedelta(
|
||||
@@ -77,6 +111,16 @@ class LocalAuthStrategy(AuthStrategy):
|
||||
# Password check
|
||||
if not user.password_hash or not verify_password(password, user.password_hash):
|
||||
user.failed_login_attempts += 1
|
||||
# Escalate to a PERMANENT lock at the threshold when enabled — but
|
||||
# never permalock the last recoverable admin (avoid total lockout;
|
||||
# they fall back to the temporary window instead).
|
||||
if (user.failed_login_attempts >= ACCOUNT_LOCKOUT_THRESHOLD
|
||||
and _lockout_is_permanent(self.db)
|
||||
and not _is_last_active_admin(self.db, user)):
|
||||
user.locked = True
|
||||
user.locked_at = datetime.now()
|
||||
logger.warning("Account '%s' permanently locked after %d failed attempts",
|
||||
username, user.failed_login_attempts)
|
||||
self.db.commit()
|
||||
raise AuthError(detail=f"bad password for '{username}'")
|
||||
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@ from cryptography.fernet import Fernet, InvalidToken
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ISSUER = "VulnCheck"
|
||||
ISSUER = "TrueVuln"
|
||||
TOTP_DIGITS = 6
|
||||
TOTP_INTERVAL = 30
|
||||
TOTP_VALID_WINDOW = 1 # accept current ± 1 step to tolerate clock skew
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
"""
|
||||
Microsoft Defender for Endpoint (TVM) API client — app-only.
|
||||
|
||||
Separate from Microsoft Graph: the threat & vulnerability management data
|
||||
lives on api.securitycenter.microsoft.com with its own token resource
|
||||
scope and app permission (Vulnerability.Read.All on WindowsDefenderATP).
|
||||
Reuses the same Entra app (tenant/client/secret) as the Intune/Graph
|
||||
integration; only the requested scope differs.
|
||||
|
||||
Returns REAL per-device CVEs (not pseudo) → they enrich like any CVE.
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import httpx
|
||||
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MDE_BASE = "https://api.securitycenter.microsoft.com/api"
|
||||
LOGIN_BASE = "https://login.microsoftonline.com"
|
||||
MDE_SCOPE = "https://api.securitycenter.microsoft.com/.default"
|
||||
|
||||
|
||||
class DefenderAPIError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class DefenderAuthError(DefenderAPIError):
|
||||
pass
|
||||
|
||||
|
||||
class DefenderClient:
|
||||
def __init__(self, tenant_id: str, client_id: str, client_secret: str, verify_ssl: bool = True):
|
||||
if not all([tenant_id, client_id, client_secret]):
|
||||
raise ValueError("Defender TVM requires tenant_id, client_id and client_secret.")
|
||||
self.tenant_id = tenant_id
|
||||
self.client_id = client_id
|
||||
self.client_secret = client_secret
|
||||
self._token: Optional[str] = None
|
||||
self._token_expires_at: Optional[datetime] = None
|
||||
self.client = httpx.Client(
|
||||
verify=verify_ssl,
|
||||
timeout=httpx.Timeout(30.0, connect=8.0),
|
||||
limits=httpx.Limits(max_connections=10, max_keepalive_connections=5),
|
||||
)
|
||||
|
||||
def _authenticate(self) -> str:
|
||||
url = f"{LOGIN_BASE}/{self.tenant_id}/oauth2/v2.0/token"
|
||||
data = {
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": self.client_id,
|
||||
"client_secret": self.client_secret,
|
||||
"scope": MDE_SCOPE,
|
||||
}
|
||||
try:
|
||||
r = self.client.post(url, data=data)
|
||||
if r.status_code in (400, 401):
|
||||
detail = ""
|
||||
try:
|
||||
detail = r.json().get("error_description", "")[:200]
|
||||
except Exception:
|
||||
detail = r.text[:200]
|
||||
raise DefenderAuthError(f"token rejected ({r.status_code}): {detail}")
|
||||
r.raise_for_status()
|
||||
payload = r.json()
|
||||
except httpx.HTTPError as e:
|
||||
raise DefenderAuthError(f"token request failed: {e}") from e
|
||||
token = payload.get("access_token")
|
||||
if not token:
|
||||
raise DefenderAuthError("no access_token in token response")
|
||||
self._token = token
|
||||
self._token_expires_at = datetime.now() + timedelta(seconds=max(60, int(payload.get("expires_in", 3600)) - 120))
|
||||
return token
|
||||
|
||||
def _ensure_token(self) -> str:
|
||||
if not self._token or not self._token_expires_at or datetime.now() >= self._token_expires_at:
|
||||
return self._authenticate()
|
||||
return self._token
|
||||
|
||||
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8),
|
||||
retry=retry_if_exception_type((httpx.ReadTimeout, httpx.WriteTimeout)))
|
||||
def _get(self, url: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
token = self._ensure_token()
|
||||
r = self.client.get(url, headers={"Authorization": f"Bearer {token}", "Accept": "application/json"}, params=params)
|
||||
if r.status_code == 429:
|
||||
raise DefenderAPIError("Defender rate limit (429)")
|
||||
if r.status_code >= 400:
|
||||
raise DefenderAPIError(f"Defender GET {url} -> {r.status_code}: {r.text[:200]}")
|
||||
return r.json()
|
||||
|
||||
def _get_all(self, path: str, max_pages: int = 200) -> List[dict]:
|
||||
url = f"{MDE_BASE}{path}"
|
||||
out: List[dict] = []
|
||||
pages = 0
|
||||
while url and pages < max_pages:
|
||||
data = self._get(url)
|
||||
out.extend(data.get("value", []) or [])
|
||||
url = data.get("@odata.nextLink")
|
||||
pages += 1
|
||||
return out
|
||||
|
||||
def get_machines(self) -> List[dict]:
|
||||
return self._get_all("/machines")
|
||||
|
||||
def get_machine_vulnerabilities(self, machine_id: str) -> List[dict]:
|
||||
return self._get_all(f"/machines/{machine_id}/vulnerabilities")
|
||||
|
||||
def get_software_vulnerabilities_by_machine(self) -> List[dict]:
|
||||
"""Tenant-wide (device, CVE, software) assessment — the JSON-response
|
||||
export. One paginated call maps every machine+CVE to its affected
|
||||
software (the per-machine /vulnerabilities endpoint omits software).
|
||||
Rows carry deviceId/cveId + softwareVendor/softwareName/softwareVersion.
|
||||
Best-effort: returns [] if the tenant/plan doesn't expose it."""
|
||||
try:
|
||||
return self._get_all("/machines/SoftwareVulnerabilitiesByMachine")
|
||||
except DefenderAPIError as e:
|
||||
logger.warning("Defender software-vuln export unavailable: %s", e)
|
||||
return []
|
||||
|
||||
def test_connection(self) -> dict:
|
||||
try:
|
||||
self._ensure_token()
|
||||
except DefenderAuthError as e:
|
||||
return {"ok": False, "step": "auth", "error": str(e)}
|
||||
try:
|
||||
data = self._get(f"{MDE_BASE}/machines", params={"$top": 1})
|
||||
return {"ok": True, "machine_sample": len(data.get("value", []) or [])}
|
||||
except DefenderAPIError as e:
|
||||
return {"ok": False, "step": "machines", "error": str(e)}
|
||||
|
||||
def close(self) -> None:
|
||||
try:
|
||||
self.client.close()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,232 @@
|
||||
"""
|
||||
Microsoft Graph API client (app-only / client-credentials).
|
||||
|
||||
Used to pull Intune (MDM/UEM) inventory — managed devices and their
|
||||
detected apps — as a third asset/software source next to Wazuh and Nessus.
|
||||
|
||||
Auth: client-credentials. A bearer token is fetched from
|
||||
login.microsoftonline.com and cached until shortly before expiry, mirroring
|
||||
the token-cache pattern in app/integrations/wazuh_client.py. No msal /
|
||||
azure-identity dependency — plain httpx.
|
||||
|
||||
Required Entra app (Application) permissions + admin consent:
|
||||
DeviceManagementManagedDevices.Read.All (devices + detected apps)
|
||||
"""
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import httpx
|
||||
from tenacity import (
|
||||
retry, stop_after_attempt, wait_exponential, retry_if_exception_type,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
GRAPH_BASE = "https://graph.microsoft.com/v1.0"
|
||||
LOGIN_BASE = "https://login.microsoftonline.com"
|
||||
_DETECTED_APPS_MIN_GAP = 0.3 # seconds between get_detected_apps calls on one client
|
||||
GRAPH_SCOPE = "https://graph.microsoft.com/.default"
|
||||
|
||||
|
||||
class GraphAPIError(Exception):
|
||||
"""Base exception for Microsoft Graph errors."""
|
||||
|
||||
|
||||
class GraphAuthError(GraphAPIError):
|
||||
"""Token acquisition / authentication failure."""
|
||||
|
||||
|
||||
class GraphClient:
|
||||
def __init__(
|
||||
self,
|
||||
tenant_id: str,
|
||||
client_id: str,
|
||||
client_secret: str,
|
||||
verify_ssl: bool = True,
|
||||
):
|
||||
if not all([tenant_id, client_id, client_secret]):
|
||||
raise ValueError("Intune/Graph requires tenant_id, client_id and client_secret.")
|
||||
self.tenant_id = tenant_id
|
||||
self.client_id = client_id
|
||||
self.client_secret = client_secret
|
||||
self.verify_ssl = verify_ssl
|
||||
|
||||
self._token: Optional[str] = None
|
||||
self._token_expires_at: Optional[datetime] = None
|
||||
self._v1_detected_apps_ok = True # flips off after the first v1.0 400
|
||||
self._last_detected_apps_call = 0.0 # paces get_detected_apps across a device loop
|
||||
|
||||
# connect fails fast (unreachable), read generous for paged calls.
|
||||
self.client = httpx.Client(
|
||||
verify=verify_ssl,
|
||||
timeout=httpx.Timeout(30.0, connect=8.0),
|
||||
limits=httpx.Limits(max_connections=10, max_keepalive_connections=5),
|
||||
)
|
||||
|
||||
# ---- auth ----------------------------------------------------------
|
||||
def _authenticate(self) -> str:
|
||||
url = f"{LOGIN_BASE}/{self.tenant_id}/oauth2/v2.0/token"
|
||||
data = {
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": self.client_id,
|
||||
"client_secret": self.client_secret,
|
||||
"scope": GRAPH_SCOPE,
|
||||
}
|
||||
try:
|
||||
r = self.client.post(url, data=data)
|
||||
if r.status_code in (400, 401):
|
||||
# AAD returns error_description with the real cause.
|
||||
detail = ""
|
||||
try:
|
||||
detail = r.json().get("error_description", "")[:200]
|
||||
except Exception:
|
||||
detail = r.text[:200]
|
||||
raise GraphAuthError(f"token request rejected ({r.status_code}): {detail}")
|
||||
r.raise_for_status()
|
||||
payload = r.json()
|
||||
except httpx.HTTPError as e:
|
||||
raise GraphAuthError(f"token request failed: {e}") from e
|
||||
|
||||
token = payload.get("access_token")
|
||||
if not token:
|
||||
raise GraphAuthError("no access_token in token response")
|
||||
expires_in = int(payload.get("expires_in", 3600))
|
||||
self._token = token
|
||||
# refresh 2 min before expiry
|
||||
self._token_expires_at = datetime.now() + timedelta(seconds=max(60, expires_in - 120))
|
||||
return token
|
||||
|
||||
def _ensure_token(self) -> str:
|
||||
if not self._token or not self._token_expires_at or datetime.now() >= self._token_expires_at:
|
||||
return self._authenticate()
|
||||
return self._token
|
||||
|
||||
# ---- requests ------------------------------------------------------
|
||||
@retry(
|
||||
stop=stop_after_attempt(3),
|
||||
wait=wait_exponential(multiplier=1, min=1, max=8),
|
||||
retry=retry_if_exception_type((httpx.ReadTimeout, httpx.WriteTimeout)),
|
||||
)
|
||||
def _get(self, url: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
token = self._ensure_token()
|
||||
for attempt in range(4):
|
||||
r = self.client.get(url, headers={"Authorization": f"Bearer {token}",
|
||||
"Accept": "application/json"}, params=params)
|
||||
if r.status_code == 429:
|
||||
# Honour Retry-After (Graph sets it); cap so a bad header can't hang us.
|
||||
wait = r.headers.get("Retry-After")
|
||||
delay = min(int(wait), 60) if (wait or "").isdigit() else (attempt + 1) * 5
|
||||
logger.debug("Graph 429, waiting %ss (try %d)", delay, attempt + 1)
|
||||
time.sleep(delay)
|
||||
continue
|
||||
break
|
||||
if r.status_code == 429:
|
||||
raise GraphAPIError("Graph rate limit (429) after retries")
|
||||
if r.status_code >= 400:
|
||||
raise GraphAPIError(f"Graph GET {url} -> {r.status_code}: {r.text[:200]}")
|
||||
return r.json()
|
||||
|
||||
def _get_all(self, path: str, params: Optional[Dict[str, Any]] = None,
|
||||
max_pages: int = 200) -> List[dict]:
|
||||
"""Follow @odata.nextLink pagination, return the flattened value list."""
|
||||
url = f"{GRAPH_BASE}{path}"
|
||||
out: List[dict] = []
|
||||
pages = 0
|
||||
while url and pages < max_pages:
|
||||
data = self._get(url, params=params)
|
||||
out.extend(data.get("value", []) or [])
|
||||
url = data.get("@odata.nextLink")
|
||||
params = None # nextLink already carries the query
|
||||
pages += 1
|
||||
return out
|
||||
|
||||
# ---- API surface ---------------------------------------------------
|
||||
def get_managed_devices(self) -> List[dict]:
|
||||
"""All Intune managed devices."""
|
||||
fields = ("id,deviceName,operatingSystem,osVersion,complianceState,"
|
||||
"lastSyncDateTime,manufacturer,model,serialNumber,"
|
||||
"managedDeviceOwnerType,azureADDeviceId,androidSecurityPatchLevel")
|
||||
return self._get_all("/deviceManagement/managedDevices",
|
||||
params={"$select": fields})
|
||||
|
||||
def get_detected_apps(self, device_id: str) -> List[dict]:
|
||||
"""Detected (installed) apps for one managed device → {name, version}.
|
||||
|
||||
Phase 2: feeds the existing EOL / M365 per-package detection.
|
||||
|
||||
detectedApps is not expandable on managedDevices in the v1.0 Graph
|
||||
(returns HTTP 400). The reliable path is the dedicated navigation
|
||||
property on the beta endpoint; we try v1.0 $expand once, then fall
|
||||
back to beta's /detectedApps collection — and once v1.0 has 400'd we
|
||||
skip it for the rest of this client's life (no 400 per device).
|
||||
|
||||
Paced: a device-by-device loop calling this in a tight sequence was
|
||||
sustaining 429s from Graph (no per-call delay was enough on its own).
|
||||
Enforce a minimum gap since the last call on this client.
|
||||
"""
|
||||
gap = time.monotonic() - self._last_detected_apps_call
|
||||
if gap < _DETECTED_APPS_MIN_GAP:
|
||||
time.sleep(_DETECTED_APPS_MIN_GAP - gap)
|
||||
self._last_detected_apps_call = time.monotonic()
|
||||
|
||||
# 1) v1.0 $expand (works on some tenants) — only until it 400s once.
|
||||
if self._v1_detected_apps_ok:
|
||||
try:
|
||||
data = self._get(
|
||||
f"{GRAPH_BASE}/deviceManagement/managedDevices/{device_id}",
|
||||
params={"$expand": "detectedApps"},
|
||||
)
|
||||
apps = data.get("detectedApps")
|
||||
if apps is not None:
|
||||
return self._map_apps(apps)
|
||||
except GraphAPIError as e:
|
||||
self._v1_detected_apps_ok = False
|
||||
logger.debug("v1.0 detectedApps $expand unsupported, using beta: %s", e)
|
||||
|
||||
# 2) beta dedicated navigation collection (paginated)
|
||||
try:
|
||||
out: List[dict] = []
|
||||
url = (f"https://graph.microsoft.com/beta/deviceManagement/"
|
||||
f"managedDevices/{device_id}/detectedApps")
|
||||
pages = 0
|
||||
while url and pages < 50:
|
||||
data = self._get(url)
|
||||
out.extend(self._map_apps(data.get("value", []) or []))
|
||||
url = data.get("@odata.nextLink")
|
||||
pages += 1
|
||||
return out
|
||||
except GraphAPIError as e:
|
||||
logger.debug("beta detectedApps failed for %s: %s", device_id, e)
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def _map_apps(apps: list) -> List[dict]:
|
||||
out = []
|
||||
for a in apps or []:
|
||||
name = (a.get("displayName") or "").strip()
|
||||
if name:
|
||||
out.append({"name": name, "version": (a.get("version") or "").strip()})
|
||||
return out
|
||||
|
||||
def test_connection(self) -> dict:
|
||||
"""Acquire a token + read one device → connectivity check."""
|
||||
try:
|
||||
self._ensure_token()
|
||||
except GraphAuthError as e:
|
||||
return {"ok": False, "step": "auth", "error": str(e)}
|
||||
try:
|
||||
data = self._get(f"{GRAPH_BASE}/deviceManagement/managedDevices",
|
||||
params={"$top": 1, "$select": "id,deviceName"})
|
||||
# $count needs ConsistencyLevel; cheap proxy: did we get a page.
|
||||
sample = len(data.get("value", []) or [])
|
||||
return {"ok": True, "tenant": self.tenant_id, "device_sample": sample}
|
||||
except GraphAPIError as e:
|
||||
return {"ok": False, "step": "devices", "error": str(e)}
|
||||
|
||||
def close(self) -> None:
|
||||
try:
|
||||
self.client.close()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -301,7 +301,7 @@ class NessusClient:
|
||||
"Nessus Essentials — targeted host scans via the API "
|
||||
"aren't supported by the free edition. Workarounds: "
|
||||
"(1) launch the scan manually from the Nessus UI, then "
|
||||
"click \"Sync Data (Nessus)\" in VulnCheck to import "
|
||||
"click \"Sync Data (Nessus)\" in TrueVuln to import "
|
||||
"results, or (2) upgrade to Nessus Professional / "
|
||||
"Tenable.io which support API-driven launches."
|
||||
) from e
|
||||
|
||||
@@ -282,6 +282,86 @@ class WazuhClient:
|
||||
# Vulnerability Management
|
||||
# ============================================
|
||||
|
||||
_STATES_INDEX = "/wazuh-states-vulnerabilities-*"
|
||||
|
||||
def _paged_hits(self, agent_id, query, page_size, offset, max_total):
|
||||
"""Classic from/size paging. OpenSearch rejects from+size beyond
|
||||
index.max_result_window (default 10000) with a 400, so this path stops
|
||||
at that ceiling instead of blowing up. Used only when an explicit
|
||||
offset is requested; full syncs scroll instead."""
|
||||
RESULT_WINDOW = 10_000
|
||||
hits: List[Dict[str, Any]] = []
|
||||
total_hits = 0
|
||||
page_offset = offset
|
||||
while True:
|
||||
body = {"size": page_size, "from": page_offset, "query": query,
|
||||
"track_total_hits": True}
|
||||
response = self._indexer_request("POST", f"{self._STATES_INDEX}/_search", json_data=body)
|
||||
page_hits = response.get("hits", {}).get("hits", []) or []
|
||||
total_hits = response.get("hits", {}).get("total", {}).get("value", 0)
|
||||
hits.extend(page_hits)
|
||||
if not page_hits or len(hits) >= total_hits or len(hits) >= max_total:
|
||||
break
|
||||
page_offset += page_size
|
||||
if page_offset + page_size > RESULT_WINDOW:
|
||||
logger.warning(
|
||||
f"Agent {agent_id}: stopping at the {RESULT_WINDOW}-doc result window "
|
||||
f"({total_hits - len(hits)} left); use offset=0 to scroll them all"
|
||||
)
|
||||
break
|
||||
return hits, total_hits
|
||||
|
||||
def _scroll_hits(self, agent_id, query, page_size, max_total):
|
||||
"""Scroll the full result set — no result-window ceiling. Falls back to
|
||||
from/size (window-bounded) if the indexer refuses scroll."""
|
||||
hits: List[Dict[str, Any]] = []
|
||||
total_hits = 0
|
||||
scroll_id = None
|
||||
try:
|
||||
body = {"size": page_size, "query": query, "track_total_hits": True}
|
||||
response = self._indexer_request(
|
||||
"POST", f"{self._STATES_INDEX}/_search?scroll=2m", json_data=body
|
||||
)
|
||||
except WazuhAPIError as e:
|
||||
logger.warning(
|
||||
f"Agent {agent_id}: scroll unavailable ({e}); falling back to windowed paging"
|
||||
)
|
||||
return self._paged_hits(agent_id, query, page_size, 0, max_total)
|
||||
|
||||
try:
|
||||
while True:
|
||||
scroll_id = response.get("_scroll_id") or scroll_id
|
||||
page_hits = response.get("hits", {}).get("hits", []) or []
|
||||
total_hits = response.get("hits", {}).get("total", {}).get("value", total_hits)
|
||||
hits.extend(page_hits)
|
||||
logger.info(
|
||||
f"Agent {agent_id}: scroll page returned {len(page_hits)} hits "
|
||||
f"(total: {total_hits}, accumulated: {len(hits)})"
|
||||
)
|
||||
if not page_hits or len(hits) >= total_hits:
|
||||
break
|
||||
if len(hits) >= max_total:
|
||||
logger.warning(
|
||||
f"Agent {agent_id}: hit MAX_TOTAL cap of {max_total}; "
|
||||
f"{total_hits - len(hits)} vulnerabilities skipped"
|
||||
)
|
||||
break
|
||||
if not scroll_id:
|
||||
break
|
||||
response = self._indexer_request(
|
||||
"POST", "/_search/scroll",
|
||||
json_data={"scroll": "2m", "scroll_id": scroll_id},
|
||||
)
|
||||
finally:
|
||||
if scroll_id:
|
||||
try:
|
||||
self._indexer_request(
|
||||
"DELETE", "/_search/scroll", json_data={"scroll_id": [scroll_id]}
|
||||
)
|
||||
except Exception:
|
||||
pass # context expires on its own
|
||||
return hits, total_hits
|
||||
|
||||
def get_vulnerabilities(
|
||||
self,
|
||||
agent_id: str,
|
||||
@@ -390,51 +470,33 @@ class WazuhClient:
|
||||
logger.warning(f"Could not query solved CVEs from alerts: {e}")
|
||||
|
||||
# Step 2: Query active vulnerabilities from states index.
|
||||
# Pagination: OpenSearch caps from+size at index.max_result_window
|
||||
# (default 10000), so we loop in pages of `limit` (default 5000)
|
||||
# until total_hits is drained or we hit MAX_TOTAL safety cap.
|
||||
# Prior behaviour was a single 5000-hit fetch — silently dropped
|
||||
# everything above 5000 on agents with very large finding sets
|
||||
# (tester saw 10000 hits returning only 5000). 50k cap covers
|
||||
# every real Wazuh fleet without unbounded memory growth.
|
||||
# Paged via scroll (see _scroll_hits) — from/size is hard-capped by
|
||||
# OpenSearch's index.max_result_window (default 10000) and 400s beyond
|
||||
# it, which made big agents look like "0 vulns" and skip their backfill.
|
||||
# MAX_TOTAL is a memory guard, not a protocol limit: 50k covers every
|
||||
# real Wazuh fleet without unbounded growth.
|
||||
MAX_TOTAL = 50_000
|
||||
PAGE_SIZE = max(1, min(limit, 5000))
|
||||
hits: List[Dict[str, Any]] = []
|
||||
total_hits = 0
|
||||
page_offset = offset
|
||||
base_query = {"bool": {"must": [{"term": {"agent.id": agent_id}}]}}
|
||||
try:
|
||||
while True:
|
||||
query = {
|
||||
"size": PAGE_SIZE,
|
||||
"from": page_offset,
|
||||
"query": {
|
||||
"bool": {
|
||||
"must": [{"term": {"agent.id": agent_id}}]
|
||||
}
|
||||
},
|
||||
"track_total_hits": True,
|
||||
}
|
||||
response = self._indexer_request(
|
||||
"POST",
|
||||
"/wazuh-states-vulnerabilities-*/_search",
|
||||
json_data=query,
|
||||
if offset:
|
||||
# Explicit offset requested → keep the classic from/size path,
|
||||
# bounded below the result window (see _paged_hits).
|
||||
hits, total_hits = self._paged_hits(
|
||||
agent_id, base_query, PAGE_SIZE, offset, MAX_TOTAL
|
||||
)
|
||||
page_hits = response.get("hits", {}).get("hits", []) or []
|
||||
total_hits = response.get("hits", {}).get("total", {}).get("value", 0)
|
||||
hits.extend(page_hits)
|
||||
logger.info(
|
||||
f"Agent {agent_id}: page from={page_offset} returned "
|
||||
f"{len(page_hits)} hits (total: {total_hits}, accumulated: {len(hits)})"
|
||||
else:
|
||||
# Full sync: scroll. from/size is capped by OpenSearch's
|
||||
# index.max_result_window (default 10000) — agents with more
|
||||
# findings 400'd at from=10000, the whole agent then looked like
|
||||
# "0 vulns" and its backfill was skipped (tester: agents with
|
||||
# 24k/27k findings). Scroll has no such ceiling and needs no
|
||||
# unique sort field.
|
||||
hits, total_hits = self._scroll_hits(
|
||||
agent_id, base_query, PAGE_SIZE, MAX_TOTAL
|
||||
)
|
||||
if not page_hits or len(hits) >= total_hits:
|
||||
break
|
||||
if len(hits) >= MAX_TOTAL:
|
||||
logger.warning(
|
||||
f"Agent {agent_id}: hit MAX_TOTAL cap of {MAX_TOTAL}; "
|
||||
f"{total_hits - len(hits)} vulnerabilities skipped"
|
||||
)
|
||||
break
|
||||
page_offset += PAGE_SIZE
|
||||
|
||||
results = []
|
||||
skipped_no_cve = 0
|
||||
|
||||
+17
-5
@@ -29,7 +29,7 @@ from slowapi.errors import RateLimitExceeded
|
||||
|
||||
|
||||
|
||||
from app.routers import auth, auth_admin, vulnerabilities, assets, policies, scans, settings, notifications, groups, reports, audit, nessus, compliance
|
||||
from app.routers import auth, auth_admin, vulnerabilities, assets, policies, scans, settings, notifications, groups, reports, audit, nessus, compliance, intune, advisories
|
||||
try:
|
||||
from app.routers import auth_oidc
|
||||
_HAS_OIDC = True
|
||||
@@ -60,6 +60,9 @@ logger = logging.getLogger(__name__)
|
||||
# Environment
|
||||
ENV = os.getenv("ENV", "production")
|
||||
DEBUG = ENV == "development"
|
||||
# Opt-in: expose Swagger/ReDoc on a production instance (for ITSM/CMDB
|
||||
# integrators) without full debug mode. Default off.
|
||||
API_DOCS = DEBUG or os.getenv("ENABLE_API_DOCS", "false").lower() in ("1", "true", "yes")
|
||||
|
||||
# Rate Limiter (OWASP A04: Insecure Design)
|
||||
|
||||
@@ -105,6 +108,13 @@ async def lifespan(app: FastAPI):
|
||||
except Exception as e:
|
||||
logger.warning(f"Scheduler could not be started: {e}")
|
||||
|
||||
# Audit-log → syslog forwarder (SIEM). No-op until enabled in settings.
|
||||
try:
|
||||
from app.services.syslog_service import register_audit_listener
|
||||
register_audit_listener()
|
||||
except Exception as e:
|
||||
logger.warning(f"Syslog forwarder could not be registered: {e}")
|
||||
|
||||
yield
|
||||
|
||||
stop_scheduler()
|
||||
@@ -113,11 +123,11 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
# FastAPI App
|
||||
app = FastAPI(
|
||||
title="VulnManager API",
|
||||
description="Vulnerability Management Dashboard mit Wazuh & KI-Integration",
|
||||
title="TrueVuln API",
|
||||
description="TrueVuln — Vulnerability Management Dashboard mit Wazuh & KI-Integration",
|
||||
version="1.0.0",
|
||||
docs_url="/docs" if DEBUG else None, # Swagger UI nur in Development
|
||||
redoc_url="/redoc" if DEBUG else None,
|
||||
docs_url="/docs" if API_DOCS else None, # Swagger UI: Dev, oder ENABLE_API_DOCS=true
|
||||
redoc_url="/redoc" if API_DOCS else None,
|
||||
lifespan=lifespan
|
||||
)
|
||||
|
||||
@@ -320,6 +330,8 @@ if _HAS_SAML and auth_saml is not None:
|
||||
app.include_router(auth_saml.router)
|
||||
app.include_router(vulnerabilities.router)
|
||||
app.include_router(nessus.router)
|
||||
app.include_router(intune.router)
|
||||
app.include_router(advisories.router)
|
||||
app.include_router(assets.router)
|
||||
app.include_router(policies.router)
|
||||
app.include_router(scans.router)
|
||||
|
||||
@@ -14,6 +14,8 @@ from app.models.scan_schedule import ScanSchedule
|
||||
from app.models.notification_log import NotificationLog
|
||||
from app.models.setting import Setting
|
||||
from app.models.ai_report import AIReport
|
||||
from app.models.cve_remediation import CveRemediation
|
||||
from app.models.app_cve_cache import AppCveCache
|
||||
from app.models.compliance import (
|
||||
ComplianceResult, ComplianceCheck, ComplianceImpact, AssetRiskSnapshot,
|
||||
)
|
||||
@@ -32,6 +34,8 @@ __all__ = [
|
||||
"NotificationLog",
|
||||
"Setting",
|
||||
"AIReport",
|
||||
"CveRemediation",
|
||||
"AppCveCache",
|
||||
"ComplianceResult",
|
||||
"ComplianceCheck",
|
||||
"ComplianceImpact",
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
"""
|
||||
Cache for the built-in app→CVE scanner: (product_key, version) → CVE list.
|
||||
Keeps OSV / NVD-CPE lookups bounded (same software version across many hosts
|
||||
= one query) and under NVD's rate limit. Refreshed on a TTL.
|
||||
"""
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime, func
|
||||
|
||||
from app.models.base import Base
|
||||
|
||||
|
||||
class AppCveCache(Base):
|
||||
__tablename__ = "app_cve_cache"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
product_key = Column(String(160), nullable=False, index=True) # cpe:a:vendor:product | osv:eco:name
|
||||
version = Column(String(120), nullable=False)
|
||||
cves = Column(Text, nullable=True) # JSON [{cve, cvss, severity, fixed}]
|
||||
fetched_at = Column(DateTime, nullable=False, server_default=func.now())
|
||||
@@ -20,6 +20,7 @@ class AssetSource(str, Enum):
|
||||
WAZUH = "WAZUH"
|
||||
NESSUS = "NESSUS"
|
||||
MANUAL = "MANUAL"
|
||||
INTUNE = "INTUNE"
|
||||
|
||||
|
||||
class AssetStatus(str, Enum):
|
||||
@@ -48,6 +49,14 @@ class Asset(Base, TimestampMixin):
|
||||
# Nessus host UUID — pinned after first hostname/IP match so future Nessus
|
||||
# syncs reconnect deterministically even if hostname or IP changes.
|
||||
nessus_host_uuid = Column(String(64), nullable=True, index=True)
|
||||
# Microsoft Intune managedDevice id — pinned after first match so future
|
||||
# Graph syncs reconnect deterministically. defender_machine_id maps the
|
||||
# asset to its Microsoft Defender for Endpoint machine (TVM, phase 3).
|
||||
intune_device_id = Column(String(64), nullable=True, index=True)
|
||||
defender_machine_id = Column(String(64), nullable=True, index=True)
|
||||
# Entra/AAD device id — the stable anchor shared by Intune (azureADDeviceId)
|
||||
# and Defender (aadDeviceId); merges the same physical device across both.
|
||||
aad_device_id = Column(String(64), nullable=True, index=True)
|
||||
|
||||
# System-Information
|
||||
operating_system = Column(String(255), nullable=True)
|
||||
@@ -69,6 +78,12 @@ class Asset(Base, TimestampMixin):
|
||||
exposed_services = Column(Text, nullable=True) # JSON [{port, proto, service, risk}]
|
||||
exposure_updated_at = Column(DateTime, nullable=True)
|
||||
|
||||
# Risk Dimensions — crown-jewel role scoring (DC, ADCS, SQL, Exchange,
|
||||
# WSUS, backup, ...). Feeds the URS via risk_dimensions_service.risk_factor.
|
||||
high_value_score = Column(Float, nullable=True, index=True) # 0-100
|
||||
risk_dimensions = Column(Text, nullable=True) # JSON [{role, label, weight}]
|
||||
risk_dimensions_updated_at = Column(DateTime, nullable=True)
|
||||
|
||||
# Metadata
|
||||
source = Column(
|
||||
SQLEnum(AssetSource),
|
||||
|
||||
@@ -35,6 +35,7 @@ class AuditEventType(str, Enum):
|
||||
USER_DELETED = "USER_DELETED"
|
||||
|
||||
# Data Operations
|
||||
VULNERABILITY_DETECTED = "VULNERABILITY_DETECTED" # sync created a new finding
|
||||
VULNERABILITY_UPDATED = "VULNERABILITY_UPDATED"
|
||||
ASSET_CREATED = "ASSET_CREATED"
|
||||
ASSET_UPDATED = "ASSET_UPDATED"
|
||||
|
||||
@@ -52,7 +52,7 @@ class ComplianceResult(Base, TimestampMixin):
|
||||
|
||||
# When Wazuh last evaluated this policy (from the SCA endpoint)
|
||||
end_scan = Column(DateTime, nullable=True)
|
||||
# When VulnCheck last refreshed from Wazuh
|
||||
# When TrueVuln last refreshed from Wazuh
|
||||
last_synced = Column(DateTime, nullable=False, default=datetime.utcnow)
|
||||
|
||||
# ORM. cascade + passive_deletes so DELETE on Asset propagates via
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"""
|
||||
CVE-level remediation enrichment from external primary sources.
|
||||
|
||||
Separate from the per-asset Vulnerability.remediation column (the Nessus
|
||||
scanner solution): these rows are keyed by cve_id and come from MSRC CVRF
|
||||
(Windows + MS products) and, later, Linux distro advisories. The CVE detail
|
||||
page shows them alongside the scanner remediation, grouped by source.
|
||||
"""
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime, func
|
||||
|
||||
from app.models.base import Base
|
||||
|
||||
|
||||
class CveRemediation(Base):
|
||||
__tablename__ = "cve_remediations"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
cve_id = Column(String(50), nullable=False, index=True)
|
||||
source = Column(String(32), nullable=False) # msrc | ubuntu | centos | ...
|
||||
kind = Column(String(24), nullable=False) # fix | workaround | mitigation | advisory
|
||||
title = Column(String(300), nullable=True)
|
||||
detail = Column(Text, nullable=True)
|
||||
kb = Column(String(64), nullable=True)
|
||||
fixed_build = Column(String(120), nullable=True)
|
||||
url = Column(Text, nullable=True)
|
||||
fetched_at = Column(DateTime, nullable=False, server_default=func.now())
|
||||
|
||||
def __repr__(self):
|
||||
return f"<CveRemediation(cve_id='{self.cve_id}', source='{self.source}', kind='{self.kind}')>"
|
||||
@@ -84,6 +84,10 @@ class User(Base, TimestampMixin):
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
is_verified = Column(Boolean, default=False, nullable=False)
|
||||
failed_login_attempts = Column(Integer, default=0, nullable=False)
|
||||
# Permanent lockout (auth_lockout_permanent mode): stays locked until an
|
||||
# admin clears it, independent of the temporary 15-min auto-unlock window.
|
||||
locked = Column(Boolean, default=False, nullable=False)
|
||||
locked_at = Column(DateTime, nullable=True)
|
||||
|
||||
# Relationships
|
||||
audit_logs = relationship("AuditLog", back_populates="user", cascade="all, delete-orphan")
|
||||
|
||||
@@ -88,12 +88,14 @@ class Vulnerability(Base, TimestampMixin):
|
||||
ssvc_automatable = Column(String(8), nullable=True, index=True) # yes | no
|
||||
|
||||
# Zeitstempel
|
||||
published_date = Column(DateTime, nullable=True)
|
||||
published_date = Column(DateTime, nullable=True, index=True) # NVD `published`
|
||||
last_modified_date = Column(DateTime, nullable=True) # NVD `lastModified`
|
||||
detected_at = Column(DateTime, nullable=False, index=True)
|
||||
patched_at = Column(DateTime, nullable=True)
|
||||
|
||||
# Zusätzliche Informationen
|
||||
references = Column(Text, nullable=True) # JSON-Array mit URLs
|
||||
remediation = Column(Text, nullable=True) # Nessus solution / fix guidance
|
||||
cwe_id = Column(String(20), nullable=True) # Common Weakness Enumeration
|
||||
|
||||
# Assignment
|
||||
@@ -203,7 +205,9 @@ class Vulnerability(Base, TimestampMixin):
|
||||
"""True for non-CVE findings rendered as rows in the vulns list.
|
||||
Two flavours: NESSUS-PLUGIN-* (compliance/cipher/EOL detected
|
||||
by Nessus) and EOL-* (endoflife.date scan)."""
|
||||
return self.cve_id.startswith("NESSUS-PLUGIN-") or self.cve_id.startswith("EOL-")
|
||||
return (self.cve_id.startswith("NESSUS-PLUGIN-")
|
||||
or self.cve_id.startswith("EOL-")
|
||||
or self.cve_id.startswith("ANDROID-PATCH-"))
|
||||
|
||||
@property
|
||||
def is_eol_finding(self) -> bool:
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Security-advisory awareness feeds (CISA KEV + configurable RSS sources)."""
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.auth.dependencies import get_current_user, RequireEditor
|
||||
|
||||
router = APIRouter(prefix="/api/v1/advisories", tags=["Advisories"])
|
||||
|
||||
|
||||
@router.get("/feeds")
|
||||
def advisory_feeds(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Cached security-advisory RSS feeds (ZDI / CERT-EU / BSI / ...). Served
|
||||
from the cache the scheduler fills; first call ever triggers a fetch."""
|
||||
from app.services.advisory_feed_service import get_cached_feeds, refresh_feeds
|
||||
cached = get_cached_feeds(db)
|
||||
if cached is None:
|
||||
refresh_feeds(db)
|
||||
cached = get_cached_feeds(db) or {"fetched_at": None, "feeds": []}
|
||||
return cached
|
||||
|
||||
|
||||
@router.post("/feeds/refresh")
|
||||
# Sync def → worker threadpool; N feed fetches are blocking I/O.
|
||||
def refresh_advisory_feeds(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(RequireEditor),
|
||||
):
|
||||
"""Re-fetch every enabled advisory feed now."""
|
||||
from app.services.advisory_feed_service import refresh_feeds
|
||||
return refresh_feeds(db)
|
||||
|
||||
|
||||
@router.get("/kev-recent")
|
||||
def kev_recent(
|
||||
limit: int = Query(20, le=100, description="How many recent KEV entries to return"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Most recently added CISA KEV (actively-exploited) CVEs, newest first,
|
||||
annotated with whether we already have that CVE in inventory."""
|
||||
from app.services.advisory_service import get_recent_kev
|
||||
return {"items": get_recent_kev(db, limit=limit)}
|
||||
+177
-18
@@ -5,9 +5,9 @@ Endpoints for asset management (Wazuh Agents + manual systems).
|
||||
"""
|
||||
from typing import Optional, List, Any
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query, Response
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import asc, desc, nulls_last
|
||||
from sqlalchemy import asc, desc, func, nulls_last
|
||||
from pydantic import BaseModel, field_validator
|
||||
|
||||
from app.database import get_db
|
||||
@@ -34,6 +34,10 @@ class AssetResponse(BaseModel):
|
||||
ip_address: Optional[str]
|
||||
wazuh_agent_id: Optional[str]
|
||||
nessus_host_uuid: Optional[str] = None
|
||||
# Intune/Defender ids — surfaced so the UI can offer the software/app-scan
|
||||
# actions on MDM-managed (iOS/macOS/Android) assets, not just Wazuh hosts.
|
||||
intune_device_id: Optional[str] = None
|
||||
defender_machine_id: Optional[str] = None
|
||||
operating_system: Optional[str]
|
||||
os_version: Optional[str]
|
||||
source: AssetSource
|
||||
@@ -55,10 +59,13 @@ class AssetResponse(BaseModel):
|
||||
network_exposure_score: Optional[float] = None
|
||||
exposed_services: Optional[List[dict]] = None
|
||||
exposure_updated_at: Optional[datetime] = None
|
||||
# Risk Dimensions (crown-jewel roles)
|
||||
high_value_score: Optional[float] = None
|
||||
risk_dimensions: Optional[List[dict]] = None
|
||||
|
||||
@field_validator('exposed_services', mode='before')
|
||||
@field_validator('exposed_services', 'risk_dimensions', mode='before')
|
||||
@classmethod
|
||||
def parse_exposed_services(cls, v: Any) -> Optional[List[dict]]:
|
||||
def parse_json_list(cls, v: Any) -> Optional[List[dict]]:
|
||||
if v is None or isinstance(v, list):
|
||||
return v
|
||||
if isinstance(v, str):
|
||||
@@ -199,7 +206,7 @@ async def reconcile_lifecycle(
|
||||
|
||||
|
||||
@router.get("/{asset_id}/coverage-gap")
|
||||
async def asset_coverage_gap(
|
||||
def asset_coverage_gap(
|
||||
asset_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
@@ -297,8 +304,86 @@ async def asset_coverage_gap(
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{asset_id}/software")
|
||||
def asset_software(
|
||||
asset_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""On-demand installed-software inventory for one asset.
|
||||
|
||||
Pulled LIVE (not persisted) from whichever source backs the asset:
|
||||
Wazuh syscollector packages, else Intune detectedApps. This is the same
|
||||
inventory the app-CVE scanner consumes — surfaced read-only so the operator
|
||||
can see what's installed without a DB table.
|
||||
"""
|
||||
import json as _json
|
||||
from app.auth.setting_crypto import read_setting_value
|
||||
|
||||
asset = db.query(Asset).filter(Asset.id == asset_id).first()
|
||||
if not asset:
|
||||
raise HTTPException(404, "Asset not found")
|
||||
|
||||
software: list[dict] = []
|
||||
source = None
|
||||
if asset.wazuh_agent_id:
|
||||
from app.integrations.wazuh_client import WazuhClient
|
||||
raw = read_setting_value(db, "wazuh_config")
|
||||
if not raw:
|
||||
raise HTTPException(400, "Wazuh is not configured")
|
||||
cfg = _json.loads(raw)
|
||||
wazuh = WazuhClient(
|
||||
base_url=cfg.get("api_url"), username=cfg.get("username"),
|
||||
password=cfg.get("password"), indexer_url=cfg.get("indexer_url"),
|
||||
indexer_username=cfg.get("indexer_username"),
|
||||
indexer_password=cfg.get("indexer_password"),
|
||||
verify_ssl=cfg.get("verify_ssl", False),
|
||||
)
|
||||
try:
|
||||
pkgs = wazuh.get_packages(asset.wazuh_agent_id) or []
|
||||
except Exception as e:
|
||||
raise HTTPException(502, f"Could not fetch packages from Wazuh: {e}")
|
||||
source = "wazuh"
|
||||
software = [{"name": (p.get("name") or "").strip(),
|
||||
"version": (p.get("version") or "").strip(),
|
||||
"vendor": (p.get("vendor") or p.get("format") or "").strip()} for p in pkgs]
|
||||
elif asset.intune_device_id:
|
||||
from app.services.intune_service import load_intune_config, _build_client
|
||||
icfg = load_intune_config(db)
|
||||
if not icfg:
|
||||
raise HTTPException(400, "Intune is not configured")
|
||||
client = _build_client(icfg)
|
||||
try:
|
||||
apps = client.get_detected_apps(asset.intune_device_id) or []
|
||||
except Exception as e:
|
||||
raise HTTPException(502, f"Could not fetch detectedApps from Intune: {e}")
|
||||
finally:
|
||||
client.close()
|
||||
source = "intune"
|
||||
software = [{"name": (a.get("name") or "").strip(),
|
||||
"version": (a.get("version") or "").strip(),
|
||||
"vendor": ""} for a in apps]
|
||||
else:
|
||||
raise HTTPException(422, "Asset has no Wazuh agent or Intune device — no live software inventory")
|
||||
|
||||
# Dedup (name, version) + drop nameless, sort by name.
|
||||
seen: set = set()
|
||||
out: list[dict] = []
|
||||
for s in software:
|
||||
if not s["name"]:
|
||||
continue
|
||||
key = (s["name"].lower(), s["version"])
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
out.append(s)
|
||||
out.sort(key=lambda s: s["name"].lower())
|
||||
return {"asset_id": asset_id, "hostname": asset.hostname, "source": source,
|
||||
"count": len(out), "software": out}
|
||||
|
||||
|
||||
@router.post("/refresh-exposure")
|
||||
async def refresh_exposure(
|
||||
def refresh_exposure(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
@@ -339,19 +424,23 @@ async def list_assets(
|
||||
status: Optional[AssetStatus] = Query(None),
|
||||
source: Optional[AssetSource] = Query(None),
|
||||
search: Optional[str] = Query(None, description="Suche in Hostname, IP"),
|
||||
include_inactive: bool = Query(False, description="Include soft-inactive + decommissioned assets"),
|
||||
include_inactive: bool = Query(False, description="Also include DECOMMISSIONED assets (INACTIVE are shown by default)"),
|
||||
sort_by: str = Query("hostname", description="hostname, ip_address, operating_system, status, last_scan, network_exposure_score, policy_name, assigned_user_name"),
|
||||
sort_order: str = Query("asc", description="asc, desc"),
|
||||
limit: int = Query(100, le=1000),
|
||||
offset: int = Query(0),
|
||||
response: Response = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Liste aller Assets mit Filterung.
|
||||
|
||||
Default zeigt nur ACTIVE — soft-inactive assets (kein source-sync
|
||||
innerhalb asset_inactive_after_days) sind ausgeblendet bis
|
||||
include_inactive=true. Vuln-historie + audit bleiben erhalten.
|
||||
Default zeigt ACTIVE **und** INACTIVE — der Sinn der Status-Spalte ist
|
||||
ja, soft-inactive Assets (kein source-sync innerhalb
|
||||
asset_inactive_after_days) mit Badge sichtbar zu halten, nicht sie zu
|
||||
verstecken. Nur DECOMMISSIONED (operator-final) ist per default
|
||||
ausgeblendet; include_inactive=true zeigt auch die.
|
||||
Vuln-historie + audit bleiben erhalten.
|
||||
"""
|
||||
query = db.query(Asset)
|
||||
|
||||
@@ -361,9 +450,41 @@ async def list_assets(
|
||||
# everything (active + inactive + decommissioned)
|
||||
pass
|
||||
else:
|
||||
query = query.filter(Asset.status == AssetStatus.ACTIVE)
|
||||
# active + inactive visible; hide only operator-retired
|
||||
query = query.filter(Asset.status != AssetStatus.DECOMMISSIONED)
|
||||
|
||||
if source:
|
||||
# Filter by the actual sync linkage, not the creation-time `source`
|
||||
# enum: an asset first created by Wazuh and later matched by Nessus
|
||||
# keeps source=WAZUH but carries a nessus_host_uuid. The per-scanner
|
||||
# id columns are the multi-source truth, so a merged asset correctly
|
||||
# appears under every scanner that sees it.
|
||||
def _has_vuln_source(tag: str):
|
||||
# Asset has ≥1 finding reported by `tag` (the vuln-level `sources`
|
||||
# list is the maintained multi-source truth). Catches Nessus
|
||||
# imports whose host had no nessus_host_uuid (matched by hostname).
|
||||
return Asset.id.in_(
|
||||
db.query(Vulnerability.asset_id)
|
||||
.filter(Vulnerability.sources.contains(f'"{tag}"'))
|
||||
)
|
||||
|
||||
if source == AssetSource.WAZUH:
|
||||
query = query.filter(Asset.wazuh_agent_id.isnot(None) | _has_vuln_source("wazuh"))
|
||||
elif source == AssetSource.NESSUS:
|
||||
query = query.filter(Asset.nessus_host_uuid.isnot(None) | _has_vuln_source("nessus"))
|
||||
elif source == AssetSource.INTUNE:
|
||||
# Intune devices often carry no findings → id columns are the signal.
|
||||
query = query.filter(
|
||||
Asset.intune_device_id.isnot(None) | Asset.defender_machine_id.isnot(None)
|
||||
| _has_vuln_source("intune") | _has_vuln_source("defender"))
|
||||
elif source == AssetSource.MANUAL:
|
||||
# genuinely manual = no scanner linkage AND no scanner-sourced vuln
|
||||
query = query.filter(
|
||||
Asset.wazuh_agent_id.is_(None), Asset.nessus_host_uuid.is_(None),
|
||||
Asset.intune_device_id.is_(None), Asset.defender_machine_id.is_(None),
|
||||
~_has_vuln_source("wazuh"), ~_has_vuln_source("nessus"),
|
||||
~_has_vuln_source("intune"), ~_has_vuln_source("defender"))
|
||||
else:
|
||||
query = query.filter(Asset.source == source)
|
||||
|
||||
if search:
|
||||
@@ -383,15 +504,25 @@ async def list_assets(
|
||||
"status": Asset.status,
|
||||
"last_scan": Asset.last_scan,
|
||||
"network_exposure_score": Asset.network_exposure_score,
|
||||
"high_value_score": Asset.high_value_score,
|
||||
}
|
||||
sort_dir = desc if sort_order == "desc" else asc
|
||||
if sort_by in _SORT_MAP_DIRECT:
|
||||
col = _SORT_MAP_DIRECT[sort_by]
|
||||
# last_scan can be NULL (never-synced) — keep them at the bottom.
|
||||
if sort_by == "last_scan":
|
||||
query = query.order_by(sort_dir(nulls_last(col)))
|
||||
# NULL-safe on BOTH directions — never-scanned assets land at
|
||||
# the bottom regardless of asc/desc (PG: NULLS LAST is independent
|
||||
# of the primary direction).
|
||||
query = query.order_by(nulls_last(sort_dir(col)))
|
||||
elif sort_by == "hostname":
|
||||
# Case-insensitive alpha sort — a host called "alpine" must
|
||||
# not outrank "Webserver" because the literal `A` > `W`.
|
||||
query = query.order_by(nulls_last(sort_dir(func.lower(col))))
|
||||
else:
|
||||
query = query.order_by(sort_dir(col))
|
||||
# NULLs always last (both directions) — sorting Exposure/Risk
|
||||
# desc must surface the real high scores first, not the empty
|
||||
# ("—") rows. (Tester: desc showed blanks before real values.)
|
||||
query = query.order_by(nulls_last(sort_dir(col)))
|
||||
elif sort_by == "policy_name":
|
||||
query = query.outerjoin(Policy, Asset.policy_id == Policy.id).order_by(sort_dir(Policy.name))
|
||||
elif sort_by == "assigned_user_name":
|
||||
@@ -400,6 +531,12 @@ async def list_assets(
|
||||
# Unknown / unsupported sort_by — fall back to hostname asc.
|
||||
query = query.order_by(asc(Asset.hostname))
|
||||
|
||||
# Total (pre-pagination) so the UI can page; returned as a header to
|
||||
# keep the response body a plain array (other consumers expect a list).
|
||||
total_count = query.order_by(None).count()
|
||||
response.headers["X-Total-Count"] = str(total_count)
|
||||
response.headers["Access-Control-Expose-Headers"] = "X-Total-Count"
|
||||
|
||||
assets = query.offset(offset).limit(limit).all()
|
||||
|
||||
# Vulnerability-Counts + assigned user info
|
||||
@@ -570,16 +707,38 @@ async def delete_asset(
|
||||
detail="Asset not found"
|
||||
)
|
||||
|
||||
# Audit-Log vor Löschung
|
||||
# Revisionssicher audit — snapshot the full asset + cascade impact
|
||||
# BEFORE deletion so the trail says WHO deleted WHAT (and how much
|
||||
# history it took with it), analog to the CVE status-change entries.
|
||||
import json as _json
|
||||
vuln_count = db.query(Vulnerability).filter(
|
||||
Vulnerability.asset_id == asset.id
|
||||
).count()
|
||||
snapshot = {
|
||||
"hostname": asset.hostname,
|
||||
"ip_address": asset.ip_address,
|
||||
"operating_system": asset.operating_system,
|
||||
"source": asset.source.value if hasattr(asset.source, "value") else str(asset.source),
|
||||
"status": asset.status.value if hasattr(asset.status, "value") else str(asset.status),
|
||||
"wazuh_agent_id": asset.wazuh_agent_id,
|
||||
"vulnerabilities_cascade_deleted": vuln_count,
|
||||
"deleted_by": current_user.username,
|
||||
}
|
||||
audit_log = AuditLog(
|
||||
user_id=current_user.id,
|
||||
event_type=AuditEventType.ASSET_DELETED,
|
||||
event_description=f"Asset deleted: {asset.hostname}",
|
||||
event_description=(
|
||||
f"Asset '{asset.hostname}' ({asset.ip_address or 'no-ip'}) deleted by "
|
||||
f"{current_user.username} — {vuln_count} vulnerabilities cascade-removed"
|
||||
)[:500],
|
||||
resource_type="asset",
|
||||
resource_id=str(asset.id),
|
||||
old_value=_json.dumps(snapshot),
|
||||
new_value="deleted",
|
||||
timestamp=datetime.now()
|
||||
)
|
||||
db.add(audit_log)
|
||||
db.commit() # persist audit first so it survives even if delete fails
|
||||
|
||||
db.delete(asset)
|
||||
db.commit()
|
||||
@@ -663,7 +822,7 @@ from app.integrations.wazuh_client import WazuhClient, WazuhAPIError
|
||||
from app.models.setting import Setting
|
||||
|
||||
@router.post("/sync_wazuh", response_model=dict)
|
||||
async def sync_wazuh_assets(
|
||||
def sync_wazuh_assets(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(RequireEditor)
|
||||
):
|
||||
@@ -794,7 +953,7 @@ async def sync_wazuh_assets(
|
||||
|
||||
|
||||
@router.post("/{asset_id}/rescan", response_model=dict)
|
||||
async def rescan_asset(
|
||||
def rescan_asset(
|
||||
asset_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(RequireEditor)
|
||||
|
||||
+52
-3
@@ -1034,12 +1034,44 @@ async def list_users(
|
||||
"email": u.email,
|
||||
"role": u.role.value,
|
||||
"is_active": u.is_active,
|
||||
"failed_login_attempts": u.failed_login_attempts
|
||||
"failed_login_attempts": u.failed_login_attempts,
|
||||
"locked": getattr(u, "locked", False),
|
||||
"locked_at": getattr(u, "locked_at", None),
|
||||
}
|
||||
for u in users
|
||||
]
|
||||
|
||||
|
||||
@router.post("/users/{user_id}/unlock")
|
||||
@limiter.limit("10/minute")
|
||||
async def unlock_user(
|
||||
request: Request,
|
||||
user_id: int,
|
||||
current_user: User = Depends(RequireAdmin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Clear a permanent lockout (and reset the failed-attempt counter) after an
|
||||
admin has reviewed the incident. Admin only; audit-logged."""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
was_locked = bool(getattr(user, "locked", False)) or user.failed_login_attempts > 0
|
||||
user.locked = False
|
||||
user.locked_at = None
|
||||
user.failed_login_attempts = 0
|
||||
db.commit()
|
||||
|
||||
log_audit_event(
|
||||
db,
|
||||
AuditEventType.SECURITY_ALERT,
|
||||
f"Account unlocked by admin: {user.username}",
|
||||
user_id=current_user.id,
|
||||
request=request,
|
||||
)
|
||||
return {"message": f"User {user.username} unlocked", "was_locked": was_locked}
|
||||
|
||||
|
||||
class UserUpdateRequest(BaseModel):
|
||||
role: Optional[UserRole] = None
|
||||
email: Optional[EmailStr] = None
|
||||
@@ -1139,11 +1171,28 @@ async def delete_user(
|
||||
|
||||
username = user.username
|
||||
|
||||
# Clear user assignments before deletion to avoid FK constraint errors
|
||||
# Every FK into `users` must be cleared first, or Postgres blocks the
|
||||
# delete (→ 500). Historical rows are NULLed, not deleted: notifications,
|
||||
# AI reports and especially the AUDIT LOG must survive so a deleted user's
|
||||
# actions stay on record (revision-proof). Group memberships are removed
|
||||
# outright (the assoc row has no meaning without the user).
|
||||
from app.models.asset import Asset
|
||||
from app.models.vulnerability import Vulnerability
|
||||
from app.models.notification_log import NotificationLog
|
||||
from app.models.ai_report import AIReport
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.group import user_groups
|
||||
|
||||
db.query(Asset).filter(Asset.assigned_user_id == user_id).update({"assigned_user_id": None})
|
||||
db.query(Vulnerability).filter(Vulnerability.assigned_user_id == user_id).update({"assigned_user_id": None})
|
||||
db.query(NotificationLog).filter(NotificationLog.user_id == user_id).update({"user_id": None})
|
||||
db.query(AIReport).filter(AIReport.created_by_id == user_id).update({"created_by_id": None})
|
||||
db.query(AuditLog).filter(AuditLog.user_id == user_id).update({"user_id": None})
|
||||
db.execute(user_groups.delete().where(user_groups.c.user_id == user_id))
|
||||
db.flush()
|
||||
# Drop cached relationship state so the User.audit_logs delete-orphan
|
||||
# cascade doesn't re-delete the rows we just detached above.
|
||||
db.expire(user)
|
||||
|
||||
db.delete(user)
|
||||
db.commit()
|
||||
@@ -1151,7 +1200,7 @@ async def delete_user(
|
||||
log_audit_event(
|
||||
db,
|
||||
AuditEventType.USER_DELETED,
|
||||
f"User deleted: {username}",
|
||||
f"User deleted: {username} (id {user_id})",
|
||||
user_id=current_user.id,
|
||||
request=request
|
||||
)
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
"""
|
||||
Microsoft Intune / Graph integration HTTP endpoints.
|
||||
|
||||
- POST /api/v1/integrations/intune/test auth/connectivity probe
|
||||
- POST /api/v1/integrations/intune/sync trigger device+inventory sync
|
||||
|
||||
Config lives in settings table key `intune_config` (encrypted JSON:
|
||||
tenant_id, client_id, client_secret, verify_ssl, auto_create_assets,
|
||||
detected_apps).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth.dependencies import RequireAdmin, RequireEditor
|
||||
from app.database import get_db, SessionLocal
|
||||
from app.models.user import User
|
||||
from app.services.intune_service import load_intune_config, run_intune_sync, _build_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/integrations/intune", tags=["Intune"])
|
||||
|
||||
# Last/current manual-sync state so the GUI can poll for completion (the sync
|
||||
# itself is fire-and-forget 202). ponytail: module-level state assumes the
|
||||
# single uvicorn worker we ship with; if you add --workers, move this to the DB.
|
||||
_INTUNE_SYNC: dict = {"running": False, "result": None, "error": None,
|
||||
"started_at": None, "finished_at": None}
|
||||
|
||||
|
||||
@router.post("/test")
|
||||
async def test_intune(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(RequireAdmin),
|
||||
):
|
||||
"""Verify the Intune/Graph credentials: acquire a token + read 1 device."""
|
||||
cfg = load_intune_config(db)
|
||||
if not cfg:
|
||||
raise HTTPException(400, "Intune is not configured (tenant_id/client_id/client_secret missing).")
|
||||
client = _build_client(cfg)
|
||||
try:
|
||||
return client.test_connection()
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
def _run_intune_sync_threaded() -> None:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
stats = run_intune_sync(db)
|
||||
result = {k: v for k, v in stats.items() if k != "errors"}
|
||||
_INTUNE_SYNC["result"] = result
|
||||
_INTUNE_SYNC["error"] = None
|
||||
logger.info("Intune sync (manual) done: %s", result)
|
||||
except Exception as e:
|
||||
_INTUNE_SYNC["result"] = None
|
||||
_INTUNE_SYNC["error"] = str(e)
|
||||
logger.error("Intune sync (manual) failed: %s", e)
|
||||
finally:
|
||||
db.close()
|
||||
_INTUNE_SYNC["running"] = False
|
||||
_INTUNE_SYNC["finished_at"] = time.time()
|
||||
|
||||
|
||||
@router.post("/sync", status_code=202)
|
||||
async def sync_intune(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(RequireEditor),
|
||||
):
|
||||
"""Kick off an Intune device/inventory sync in the background (202).
|
||||
|
||||
Fire-and-forget so a large tenant can't trip the reverse-proxy request
|
||||
timeout. Poll GET /sync/status for completion."""
|
||||
if not load_intune_config(db):
|
||||
raise HTTPException(400, "Intune is not configured.")
|
||||
if _INTUNE_SYNC["running"]:
|
||||
return {"status": "already_running", "detail": "An Intune sync is already in progress."}
|
||||
_INTUNE_SYNC.update({"running": True, "result": None, "error": None,
|
||||
"started_at": time.time(), "finished_at": None})
|
||||
threading.Thread(target=_run_intune_sync_threaded, daemon=True).start()
|
||||
return {"status": "started", "detail": "Intune sync started in the background."}
|
||||
|
||||
|
||||
@router.get("/sync/status")
|
||||
async def sync_intune_status(current_user: User = Depends(RequireEditor)):
|
||||
"""Poll target for the GUI: current/last manual-sync state + result stats."""
|
||||
s = _INTUNE_SYNC
|
||||
state = ("running" if s["running"]
|
||||
else "error" if s["error"]
|
||||
else "done" if s["result"]
|
||||
else "idle")
|
||||
return {"state": state, "running": s["running"], "result": s["result"],
|
||||
"error": s["error"], "finished_at": s["finished_at"]}
|
||||
@@ -42,7 +42,7 @@ class NessusSyncRequest(BaseModel):
|
||||
|
||||
|
||||
class NessusScanHostRequest(BaseModel):
|
||||
asset_id: int = Field(..., description="VulnCheck asset ID to rescan")
|
||||
asset_id: int = Field(..., description="TrueVuln asset ID to rescan")
|
||||
scan_id: Optional[int] = Field(
|
||||
default=None,
|
||||
description=(
|
||||
@@ -65,7 +65,7 @@ class NessusScanImportRequest(BaseModel):
|
||||
|
||||
# ---------- endpoints ----------
|
||||
@router.post("/test")
|
||||
async def test_nessus_connection(
|
||||
def test_nessus_connection(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(RequireAdmin),
|
||||
):
|
||||
@@ -103,7 +103,7 @@ async def test_nessus_connection(
|
||||
|
||||
|
||||
@router.get("/scans")
|
||||
async def list_nessus_scans(
|
||||
def list_nessus_scans(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(RequireEditor),
|
||||
):
|
||||
@@ -138,7 +138,9 @@ async def list_nessus_scans(
|
||||
|
||||
|
||||
@router.post("/sync")
|
||||
async def trigger_nessus_sync(
|
||||
# Sync def → worker threadpool (off the event loop) so a long Nessus
|
||||
# import doesn't freeze the web GUI.
|
||||
def trigger_nessus_sync(
|
||||
payload: NessusSyncRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(RequireEditor),
|
||||
@@ -162,7 +164,7 @@ async def trigger_nessus_sync(
|
||||
raise HTTPException(
|
||||
502,
|
||||
"Nessus server unreachable from the backend. Check the "
|
||||
"configured base_url is reachable from the VulnCheck container "
|
||||
"configured base_url is reachable from the TrueVuln container "
|
||||
f"(not localhost/127.0.0.1) and the host/port/firewall. ({e})",
|
||||
)
|
||||
except _httpx.TimeoutException as e:
|
||||
@@ -175,7 +177,7 @@ async def trigger_nessus_sync(
|
||||
|
||||
|
||||
@router.post("/scan-host")
|
||||
async def nessus_scan_host(
|
||||
def nessus_scan_host(
|
||||
payload: NessusScanHostRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(RequireEditor),
|
||||
@@ -184,7 +186,7 @@ async def nessus_scan_host(
|
||||
Launch a targeted Nessus scan for a single asset.
|
||||
|
||||
Nessus ``POST /scans/{id}/launch`` supports ``alt_targets`` which overrides
|
||||
the scan's configured scope. VulnCheck resolves the asset's IP and passes
|
||||
the scan's configured scope. TrueVuln resolves the asset's IP and passes
|
||||
it as the only target — useful for post-patch rescans without waiting for
|
||||
the next full scheduled scan.
|
||||
|
||||
|
||||
@@ -54,9 +54,18 @@ async def list_notification_logs(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""List notification history"""
|
||||
"""List notification history.
|
||||
|
||||
Privacy: the log holds recipient emails + which CVE/asset went to whom.
|
||||
Admins see everything (operational overview); everyone else sees only the
|
||||
notifications addressed to them (user_id == their own id).
|
||||
"""
|
||||
from app.models.user import UserRole
|
||||
query = db.query(NotificationLog).order_by(desc(NotificationLog.sent_at))
|
||||
|
||||
if current_user.role != UserRole.ADMIN:
|
||||
query = query.filter(NotificationLog.user_id == current_user.id)
|
||||
|
||||
if notification_type:
|
||||
try:
|
||||
nt = NotificationType(notification_type)
|
||||
@@ -100,7 +109,8 @@ async def list_notification_logs(
|
||||
|
||||
|
||||
@router.post("/test")
|
||||
async def send_test_email(
|
||||
# Sync def → threadpool; the blocking SMTP send won't stall the event loop.
|
||||
def send_test_email(
|
||||
test_data: TestEmailRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(RequireAdmin)
|
||||
@@ -111,12 +121,12 @@ async def send_test_email(
|
||||
if not config:
|
||||
raise HTTPException(status_code=400, detail="SMTP not configured")
|
||||
|
||||
subject = "VulnCheck Dashboard - Test Email"
|
||||
subject = "TrueVuln Dashboard - Test Email"
|
||||
body = """
|
||||
<html>
|
||||
<body style="font-family: Arial, sans-serif; padding: 20px;">
|
||||
<h2>SMTP Test Successful</h2>
|
||||
<p>This is a test email from VulnCheck Dashboard.</p>
|
||||
<p>This is a test email from TrueVuln Dashboard.</p>
|
||||
<p>Your SMTP configuration is working correctly.</p>
|
||||
<hr>
|
||||
<p style="color: #888; font-size: 12px;">Sent at: {}</p>
|
||||
@@ -133,7 +143,9 @@ async def send_test_email(
|
||||
|
||||
|
||||
@router.post("/notify-critical")
|
||||
async def notify_unnotified_critical_vulnerabilities(
|
||||
# Sync def → Starlette runs it in a worker threadpool (off the event loop), so
|
||||
# the blocking SMTP send loop over thousands of CVEs doesn't freeze the web GUI.
|
||||
def notify_unnotified_critical_vulnerabilities(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(RequireAdmin)
|
||||
):
|
||||
@@ -143,7 +155,7 @@ async def notify_unnotified_critical_vulnerabilities(
|
||||
"""
|
||||
try:
|
||||
from app.models.vulnerability import VulnerabilityStatus, VulnerabilitySeverity
|
||||
from app.services.email_service import send_new_vulnerability_notification
|
||||
from app.services.email_service import send_new_vulnerability_notification, _resolve_recipients_for_vuln
|
||||
from app.models.group import Group
|
||||
|
||||
smtp_config = get_smtp_config(db)
|
||||
@@ -168,21 +180,11 @@ async def notify_unnotified_critical_vulnerabilities(
|
||||
if not asset:
|
||||
continue
|
||||
|
||||
# Get recipients from asset assignment
|
||||
recipients = []
|
||||
if asset.assigned_user_id:
|
||||
user = db.query(User).filter(User.id == asset.assigned_user_id).first()
|
||||
if user and user.email:
|
||||
recipients.append((user.id, user.email, user.username))
|
||||
elif asset.groups:
|
||||
# Asset uses M2M groups (no single assigned_group_id column)
|
||||
for group in asset.groups:
|
||||
for u in group.users:
|
||||
if u.email:
|
||||
recipients.append((u.id, u.email, u.username))
|
||||
|
||||
# Recipients via the full cascade (vuln/asset assignment), with a
|
||||
# fallback to the configured default recipients / active admins so
|
||||
# unassigned findings still notify someone.
|
||||
recipients = _resolve_recipients_for_vuln(db, vuln)
|
||||
if not recipients:
|
||||
# No recipients for this vulnerability's asset
|
||||
continue
|
||||
|
||||
for r_uid, r_email, r_username in recipients:
|
||||
@@ -197,7 +199,7 @@ async def notify_unnotified_critical_vulnerabilities(
|
||||
"title": vuln.title or "No description",
|
||||
"description": vuln.title or "No description",
|
||||
"detected_at": vuln.detected_at.strftime("%Y-%m-%d %H:%M UTC") if vuln.detected_at else "Unknown",
|
||||
"dashboard_url": "https://your-vulncheck-instance.com/vulnerabilities",
|
||||
"dashboard_url": "https://your-truevuln-instance.com/vulnerabilities",
|
||||
"recipient_name": r_username,
|
||||
"recipient_email": r_email
|
||||
}
|
||||
@@ -211,7 +213,7 @@ async def notify_unnotified_critical_vulnerabilities(
|
||||
user_id=r_uid,
|
||||
notification_type=NotificationType.MANUAL,
|
||||
sent_at=datetime.now(),
|
||||
subject=f"[VULNCHECK] {vuln.severity.value.upper()} Vulnerability: {vuln.cve_id}",
|
||||
subject=f"[TRUEVULN] {vuln.severity.value.upper()} Vulnerability: {vuln.cve_id}",
|
||||
recipient_email=r_email,
|
||||
status=NotificationStatus.SENT if success else NotificationStatus.FAILED,
|
||||
message_body=f"{vuln.severity.value} vulnerability {vuln.cve_id} on {asset.hostname}",
|
||||
|
||||
@@ -227,7 +227,7 @@ from app.integrations.wazuh_client import WazuhClient, WazuhAPIError
|
||||
from app.models.setting import Setting
|
||||
|
||||
@router.post("/autoscan", response_model=dict)
|
||||
async def trigger_autoscan(
|
||||
def trigger_autoscan(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(RequireEditor)
|
||||
):
|
||||
|
||||
+46
-15
@@ -20,6 +20,24 @@ from app.auth.setting_crypto import (
|
||||
|
||||
router = APIRouter(prefix="/api/v1/settings", tags=["Settings"])
|
||||
|
||||
|
||||
class SyslogTestRequest(BaseModel):
|
||||
host: str
|
||||
port: int = 514
|
||||
protocol: str = "udp"
|
||||
facility: int = 16
|
||||
|
||||
|
||||
@router.post("/syslog/test")
|
||||
async def test_syslog(
|
||||
payload: SyslogTestRequest,
|
||||
current_user: User = Depends(RequireAdmin),
|
||||
):
|
||||
"""Send a one-off test message to the given syslog target. Admin only."""
|
||||
from app.services.syslog_service import send_test
|
||||
ok, detail = send_test(payload.model_dump())
|
||||
return {"ok": ok, "detail": detail}
|
||||
|
||||
# Keys that may never be written through this API — owned by env vars or
|
||||
# managed by the auth subsystem. Overwriting them would lock users out of
|
||||
# MFA or compromise key isolation.
|
||||
@@ -31,14 +49,19 @@ _DENYLISTED_KEYS = {"auth_provider_crypto_key"}
|
||||
_SECRET_SUBFIELDS = {
|
||||
"password", "secret", "secret_key", "access_key",
|
||||
"api_key", "indexer_password", "bind_password",
|
||||
"token", "smtp_password",
|
||||
"token", "smtp_password", "client_secret",
|
||||
}
|
||||
|
||||
|
||||
def _redact_protected_value(key: str, stored: Optional[str]) -> Optional[str]:
|
||||
"""Decrypt + redact secret subfields for safe GET responses."""
|
||||
if stored is None:
|
||||
if not stored:
|
||||
return None
|
||||
try:
|
||||
if not decrypt_value(stored):
|
||||
return None # empty after decrypt = treated as unset
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
plaintext = decrypt_value(stored)
|
||||
except Exception:
|
||||
@@ -118,22 +141,30 @@ async def update_setting(
|
||||
|
||||
value_to_store = update_data.value
|
||||
if is_protected(key):
|
||||
# If admin re-submitted a redacted value, refuse — they probably
|
||||
# didn't intend to overwrite secrets with the literal "***set***".
|
||||
# For protected JSON configs, MERGE secret subfields: if the admin
|
||||
# left a secret blank or re-submitted the redaction placeholder
|
||||
# ("***set***"), keep the previously-stored secret instead of
|
||||
# wiping it. Lets the operator edit non-secret fields (host, ids,
|
||||
# toggles) without re-typing the password/secret every time.
|
||||
try:
|
||||
parsed = json.loads(update_data.value)
|
||||
if isinstance(parsed, dict):
|
||||
for f in parsed:
|
||||
if f.lower() in _SECRET_SUBFIELDS and parsed[f] == "***set***":
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
f"Refusing to store redaction placeholder in '{f}'. "
|
||||
"Re-submit the actual secret value."
|
||||
),
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
parsed = None
|
||||
if isinstance(parsed, dict):
|
||||
existing_row = db.query(Setting).filter(Setting.key == key).first()
|
||||
existing_cfg = {}
|
||||
if existing_row and existing_row.value:
|
||||
try:
|
||||
existing_cfg = json.loads(decrypt_value(existing_row.value))
|
||||
except Exception:
|
||||
existing_cfg = {}
|
||||
for f in list(parsed.keys()):
|
||||
if f.lower() in _SECRET_SUBFIELDS and (parsed[f] in ("", "***set***", None)):
|
||||
if existing_cfg.get(f):
|
||||
parsed[f] = existing_cfg[f] # preserve stored secret
|
||||
else:
|
||||
parsed.pop(f, None) # nothing to keep → drop
|
||||
update_data.value = json.dumps(parsed)
|
||||
value_to_store = encrypt_value(update_data.value)
|
||||
|
||||
setting = db.query(Setting).filter(Setting.key == key).first()
|
||||
|
||||
+641
-31
@@ -149,8 +149,10 @@ class VulnerabilityDetailResponse(VulnerabilityResponse):
|
||||
cvss_vector: Optional[str]
|
||||
exploit_maturity: Optional[str]
|
||||
published_date: Optional[datetime]
|
||||
last_modified_date: Optional[datetime]
|
||||
patched_at: Optional[datetime]
|
||||
references: Optional[str]
|
||||
remediation: Optional[str] = None
|
||||
cwe_id: Optional[str]
|
||||
ai_analysis: Optional[dict]
|
||||
|
||||
@@ -282,6 +284,28 @@ def log_vulnerability_change(
|
||||
db.commit()
|
||||
|
||||
|
||||
def _sanitize_wazuh_fix(pkg_name, pkg_version, fix):
|
||||
"""Drop a Wazuh-reported fix build that belongs to a DIFFERENT Windows
|
||||
release. Wazuh CTI sometimes attaches e.g. 6.2.9200.26079 (Server 2012) to
|
||||
an OS finding on a Server 2025 host (10.0.26100.x); a fix on another build
|
||||
line says nothing about this host — better no fix than misinformation (the
|
||||
MSRC remediation panel supplies the correct per-branch KB anyway)."""
|
||||
if not (fix and pkg_name and "microsoft windows" in str(pkg_name).lower()):
|
||||
return fix
|
||||
|
||||
def _bl(v):
|
||||
try:
|
||||
t = [int(x) for x in str(v).split(".")]
|
||||
return tuple(t[:3]) if len(t) >= 4 else None
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
inst_bl, fix_bl = _bl(pkg_version), _bl(fix)
|
||||
if inst_bl and fix_bl and inst_bl != fix_bl:
|
||||
return None
|
||||
return fix
|
||||
|
||||
|
||||
def _build_vuln_response(vuln: Vulnerability) -> dict:
|
||||
"""Build a vulnerability response dict with assigned user info"""
|
||||
breakdown = vuln.calculate_priority_breakdown()
|
||||
@@ -372,8 +396,10 @@ def _build_vuln_response(vuln: Vulnerability) -> dict:
|
||||
"cvss_vector": getattr(vuln, 'cvss_vector', None),
|
||||
"exploit_maturity": getattr(vuln, 'exploit_maturity', None),
|
||||
"published_date": getattr(vuln, 'published_date', None),
|
||||
"last_modified_date": getattr(vuln, 'last_modified_date', None),
|
||||
"patched_at": getattr(vuln, 'patched_at', None),
|
||||
"references": getattr(vuln, 'references', None),
|
||||
"remediation": getattr(vuln, 'remediation', None),
|
||||
"cwe_id": getattr(vuln, 'cwe_id', None),
|
||||
}
|
||||
|
||||
@@ -477,10 +503,12 @@ async def list_vulnerabilities(
|
||||
in_both_catalogs: Optional[bool] = Query(None, description="CVE in BEIDEN Catalogs (KEV und EUVD)"),
|
||||
epss_min: Optional[float] = Query(None, description="Minimaler EPSS-Score (0.0-1.0)"),
|
||||
source: Optional[str] = Query(None, description="Filter by scanner source: wazuh, nessus, manual"),
|
||||
finding_type: Optional[str] = Query(None, description="Special finding group: 'mobile' = mobile device EOL/EOS + Android patch-level staleness"),
|
||||
cross_confirmed: Optional[bool] = Query(None, description="Only CVEs reported by 2+ scanners"),
|
||||
asset_id: Optional[int] = Query(None, description="Filter nach Asset"),
|
||||
search: Optional[str] = Query(None, description="Suche in CVE-ID, Package, Title"),
|
||||
include_inactive_assets: bool = Query(False, description="Include CVEs on INACTIVE/DECOMMISSIONED assets (off by default)"),
|
||||
distinct_cve: bool = Query(False, description="Collapse per-asset duplicates to one row per CVE-ID (dashboard 'newest N distinct CVEs' widgets)"),
|
||||
sort_by: str = Query("priority", description="Sortierung: priority, cvss, detected_at"),
|
||||
sort_order: str = Query("desc", description="Reihenfolge: asc, desc"),
|
||||
limit: int = Query(100, le=1000),
|
||||
@@ -568,9 +596,21 @@ async def list_vulnerabilities(
|
||||
# `wazuh_only` would be too brittle, use string contains.
|
||||
if source:
|
||||
src = source.strip().lower()
|
||||
if src in {"wazuh", "nessus", "manual"}:
|
||||
if src in {"wazuh", "nessus", "manual", "app-scan", "defender", "intune"}:
|
||||
query = query.filter(Vulnerability.sources.contains(f'"{src}"'))
|
||||
|
||||
if finding_type == "mobile":
|
||||
# Mobile device findings: vendor EOL/EOS (EOL- pseudo-CVE on a phone/
|
||||
# tablet slug) + Android patch-level staleness. One group for the
|
||||
# dashboard "Mobile Security" widget + its View All.
|
||||
query = query.filter(
|
||||
Vulnerability.cve_id.like("ANDROID-PATCH-%")
|
||||
| Vulnerability.cve_id.like("EOL-IPHONE-%")
|
||||
| Vulnerability.cve_id.like("EOL-IPAD-%")
|
||||
| Vulnerability.cve_id.like("EOL-SAMSUNG-MOBILE-%")
|
||||
| Vulnerability.cve_id.like("EOL-SAMSUNG-GALAXY-TAB-%")
|
||||
)
|
||||
|
||||
if cross_confirmed:
|
||||
# Pragmatic: rows with at least one comma in the JSON list have >=2 sources.
|
||||
# `["wazuh"]` has no comma, `["wazuh","nessus"]` does.
|
||||
@@ -614,6 +654,25 @@ async def list_vulnerabilities(
|
||||
(Vulnerability.title.ilike(search_pattern))
|
||||
)
|
||||
|
||||
# Collapse per-asset duplicates to one row per CVE-ID. Without this a CVE
|
||||
# sitting on many assets fills the page with identical-CVE rows, so the
|
||||
# dashboard's client-side dedup starves and a "newest 10" widget showed
|
||||
# only ~4 (tester feedback). Keep the newest-published (then most-recently-
|
||||
# detected) representative per CVE; composes with every sort_by below.
|
||||
if distinct_cve:
|
||||
from sqlalchemy import func as _wf
|
||||
_rn = _wf.row_number().over(
|
||||
partition_by=Vulnerability.cve_id,
|
||||
order_by=(
|
||||
Vulnerability.published_date.desc().nullslast(),
|
||||
Vulnerability.detected_at.desc().nullslast(),
|
||||
Vulnerability.id.desc(),
|
||||
),
|
||||
).label("rn")
|
||||
_sub = query.with_entities(Vulnerability.id.label("vid"), _rn).subquery()
|
||||
_keep_ids = db.query(_sub.c.vid).filter(_sub.c.rn == 1)
|
||||
query = query.filter(Vulnerability.id.in_(_keep_ids))
|
||||
|
||||
# Get total count before pagination
|
||||
total_count = query.count()
|
||||
|
||||
@@ -689,19 +748,44 @@ async def list_vulnerabilities(
|
||||
return {"items": items_resp, "total": total_count}
|
||||
|
||||
if sort_by == "published_date":
|
||||
# CVE-IDs are NOT chronological — CISA/MITRE assign them in batches.
|
||||
# Order by official published_date with a detected_at fallback so
|
||||
# CVEs we have not yet enriched still get a reasonable position.
|
||||
from sqlalchemy import func as sa_func
|
||||
ordering_expr = sa_func.coalesce(
|
||||
Vulnerability.published_date, Vulnerability.detected_at
|
||||
# "Newly Published" = sort by the CVE's OFFICIAL published_date.
|
||||
#
|
||||
# The old COALESCE(published_date, detected_at) fallback was wrong:
|
||||
# a Nessus sync imports old CVEs (2014-2023) with published_date
|
||||
# NULL but detected_at=today, so COALESCE made every freshly-
|
||||
# imported old CVE look like it was "published today" and flooded
|
||||
# the top of the list in arbitrary id order (tester: "komisch
|
||||
# durcheinander"). Now rows WITHOUT a real published_date sink to
|
||||
# the bottom (nulls-last) instead of masquerading as newest.
|
||||
search_eol = (search or "").upper().startswith("EOL")
|
||||
if not search_eol and finding_type != "mobile":
|
||||
query = query.filter(
|
||||
~Vulnerability.cve_id.like("EOL-%"),
|
||||
~Vulnerability.cve_id.like("NESSUS-PLUGIN-%"),
|
||||
)
|
||||
# Secondary key: until the NVD published_date backfill drains, most
|
||||
# rows have published_date NULL. Ordering those by id is meaningless
|
||||
# (tester: "danach wieder NICHT richtig sortiert"). Fall back to the
|
||||
# CVE's own year+sequence so "newest CVE number first" still holds.
|
||||
from sqlalchemy import func as _sf, case as _case, Integer as _Int
|
||||
_yr = _sf.nullif(_sf.split_part(Vulnerability.cve_id, '-', 2), '')
|
||||
_nm = _sf.nullif(_sf.split_part(Vulnerability.cve_id, '-', 3), '')
|
||||
_yr_i = _case((_yr.op('~')('^[0-9]+$'), _sf.cast(_yr, _Int)), else_=None)
|
||||
_nm_i = _case((_nm.op('~')('^[0-9]+$'), _sf.cast(_nm, _Int)), else_=None)
|
||||
if sort_order == "desc":
|
||||
query = query.order_by(
|
||||
ordering_expr.is_(None), desc(ordering_expr), desc(Vulnerability.id)
|
||||
Vulnerability.published_date.is_(None), # nulls last
|
||||
desc(Vulnerability.published_date),
|
||||
desc(_yr_i), desc(_nm_i),
|
||||
desc(Vulnerability.id),
|
||||
)
|
||||
else:
|
||||
query = query.order_by(asc(ordering_expr), asc(Vulnerability.id))
|
||||
query = query.order_by(
|
||||
Vulnerability.published_date.is_(None),
|
||||
asc(Vulnerability.published_date),
|
||||
asc(_yr_i), asc(_nm_i),
|
||||
asc(Vulnerability.id),
|
||||
)
|
||||
vulnerabilities = query.offset(offset).limit(limit).all()
|
||||
items_resp = [_build_vuln_response(v) for v in vulnerabilities]
|
||||
_attach_last_change(db, vulnerabilities, items_resp)
|
||||
@@ -1215,12 +1299,27 @@ def verify_patch_with_rescan(
|
||||
# 4. Prüfe ob CVE noch in Wazuh-Daten
|
||||
cve_still_exists = any(v.get("cve") == vuln.cve_id for v in current_vulns)
|
||||
|
||||
old_status = vuln.status
|
||||
if cve_still_exists:
|
||||
# Patch fehlgeschlagen
|
||||
vuln.status = VulnerabilityStatus.patch_failed
|
||||
_reason = f"Patch verification rescan: CVE still reported by Wazuh on agent {agent_id}"
|
||||
else:
|
||||
# Patch erfolgreich verifiziert
|
||||
vuln.status = VulnerabilityStatus.patched
|
||||
vuln.patched_at = datetime.now()
|
||||
_reason = f"Patch verification rescan: CVE no longer reported by Wazuh on agent {agent_id}"
|
||||
|
||||
# Revisionssicher: record the verify outcome (feeds audit log +
|
||||
# per-CVE Change History). Background task → user_id=None.
|
||||
if vuln.status != old_status:
|
||||
try:
|
||||
log_vulnerability_change(
|
||||
db, None, vuln.id, old_status, vuln.status,
|
||||
reason=_reason, cve_id=vuln.cve_id, source="verify_patch_rescan",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("audit log for patch-verify failed (vuln_id=%s): %s", vuln.id, e)
|
||||
|
||||
db.commit()
|
||||
|
||||
@@ -1304,7 +1403,7 @@ def analyze_vulnerability_with_ai(
|
||||
|
||||
|
||||
@router.post("/{vuln_id}/enrich")
|
||||
async def enrich_single_vulnerability(
|
||||
def enrich_single_vulnerability(
|
||||
vuln_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(RequireEditor)
|
||||
@@ -1337,7 +1436,9 @@ class BulkEnrichRequest(BaseModel):
|
||||
|
||||
|
||||
@router.post("/enrich/bulk")
|
||||
async def bulk_enrich_vulnerabilities(
|
||||
# Sync def → worker threadpool (off the event loop); EPSS/KEV enrichment
|
||||
# over many CVEs no longer freezes the web GUI.
|
||||
def bulk_enrich_vulnerabilities(
|
||||
payload: BulkEnrichRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(RequireEditor)
|
||||
@@ -1361,7 +1462,10 @@ async def bulk_enrich_vulnerabilities(
|
||||
if not vulns:
|
||||
return {"stats": {"epss_updated": 0, "kev_marked": 0, "kev_cleared": 0, "total": 0}}
|
||||
|
||||
stats = enrich_vulnerabilities(db, vulns)
|
||||
# NVD date backfill is rate-limited (up to 6.5s/CVE without a key) and
|
||||
# would hang this synchronous request for minutes. It runs in the
|
||||
# nightly enrichment job instead; the manual button stays fast.
|
||||
stats = enrich_vulnerabilities(db, vulns, use_nvd_dates=False)
|
||||
|
||||
audit_log = AuditLog(
|
||||
user_id=current_user.id,
|
||||
@@ -1377,8 +1481,73 @@ async def bulk_enrich_vulnerabilities(
|
||||
return {"stats": stats}
|
||||
|
||||
|
||||
# Guard so a double-click / impatient retry doesn't stack concurrent
|
||||
# 600 MB ZIP walks. Module-level flag is fine — single backend process.
|
||||
_DATE_BACKFILL_RUNNING = False
|
||||
|
||||
|
||||
def _run_date_backfill_threaded() -> dict:
|
||||
"""Date-only enrichment with its own DB session (runs in a detached
|
||||
thread). Fills published_date/last_modified_date for every CVE row
|
||||
that lacks a published_date, via the cvelistV5 ZIP/raw cascade (NVD
|
||||
fallback). No EPSS/KEV/EUVD work — just dates."""
|
||||
global _DATE_BACKFILL_RUNNING
|
||||
from app.database import SessionLocal
|
||||
db = SessionLocal()
|
||||
try:
|
||||
vulns = (
|
||||
db.query(Vulnerability)
|
||||
.filter(
|
||||
Vulnerability.published_date.is_(None),
|
||||
Vulnerability.cve_id.like("CVE-%"),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
if not vulns:
|
||||
logger.info("Date backfill: nothing to do (no undated CVEs)")
|
||||
return {"total": 0, "nvd_dates_set": 0}
|
||||
stats = enrich_vulnerabilities(
|
||||
db, vulns,
|
||||
use_epss=False, use_kev=False, use_euvd=False, use_nvd_dates=True,
|
||||
)
|
||||
logger.info("Date backfill done: %s", stats)
|
||||
return stats
|
||||
except Exception as e:
|
||||
logger.error("Date backfill failed: %s", e)
|
||||
return {"error": str(e)}
|
||||
finally:
|
||||
db.close()
|
||||
_DATE_BACKFILL_RUNNING = False
|
||||
|
||||
|
||||
@router.post("/dates/backfill", status_code=202)
|
||||
async def backfill_cve_dates(
|
||||
current_user: User = Depends(RequireEditor),
|
||||
):
|
||||
"""Kick off an on-demand CVE published/last-modified backfill.
|
||||
|
||||
Fire-and-forget: launches the date-only fill (cvelistV5 ZIP/raw → NVD
|
||||
fallback) in a detached background thread and returns 202 immediately,
|
||||
so a slow ZIP walk over thousands of CVEs can't trip the reverse-proxy
|
||||
request timeout (was returning 503). Watch progress in the logs:
|
||||
docker compose logs -f backend | grep -E "CVE dates:|Date backfill"
|
||||
No NVD API key required — cvelistV5 is the primary, keyless source.
|
||||
"""
|
||||
global _DATE_BACKFILL_RUNNING
|
||||
if _DATE_BACKFILL_RUNNING:
|
||||
return {"status": "already_running",
|
||||
"detail": "A date backfill is already in progress."}
|
||||
_DATE_BACKFILL_RUNNING = True
|
||||
import threading
|
||||
threading.Thread(target=_run_date_backfill_threaded, daemon=True).start()
|
||||
return {"status": "started",
|
||||
"detail": "Date backfill started in the background. "
|
||||
"The Newly-Published order updates as it completes "
|
||||
"(watch the backend logs)."}
|
||||
|
||||
|
||||
@router.post("/enrich/kev/refresh")
|
||||
async def refresh_kev_catalog(
|
||||
def refresh_kev_catalog(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(RequireEditor)
|
||||
):
|
||||
@@ -1541,7 +1710,7 @@ async def unmark_false_positive(
|
||||
|
||||
|
||||
@router.post("/sync/wazuh")
|
||||
async def sync_vulnerabilities_from_wazuh(
|
||||
def sync_vulnerabilities_from_wazuh(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(RequireEditor)
|
||||
):
|
||||
@@ -1702,7 +1871,8 @@ def run_wazuh_vulnerability_sync(db: Session) -> dict:
|
||||
existing.package_version = merged_versions
|
||||
# Backfill fixed_version when missing (older syncs
|
||||
# didn't extract it; new syncs do).
|
||||
fv = vuln_data.get("fixed_version")
|
||||
fv = _sanitize_wazuh_fix(merged_packages, merged_versions,
|
||||
vuln_data.get("fixed_version"))
|
||||
if fv and not existing.fixed_version:
|
||||
existing.fixed_version = fv
|
||||
|
||||
@@ -1725,7 +1895,9 @@ def run_wazuh_vulnerability_sync(db: Session) -> dict:
|
||||
title=vuln_data.get("title"),
|
||||
package_name=merged_packages,
|
||||
package_version=merged_versions,
|
||||
fixed_version=vuln_data.get("fixed_version"),
|
||||
fixed_version=_sanitize_wazuh_fix(
|
||||
merged_packages, merged_versions,
|
||||
vuln_data.get("fixed_version")),
|
||||
detected_at=datetime.now(),
|
||||
sources='["wazuh"]',
|
||||
first_detected_by="wazuh",
|
||||
@@ -1800,6 +1972,17 @@ def run_wazuh_vulnerability_sync(db: Session) -> dict:
|
||||
db.commit()
|
||||
wazuh.close()
|
||||
|
||||
# Revisionssicher: initial VULNERABILITY_DETECTED audit event per
|
||||
# new finding (audit trail no longer starts at the first status
|
||||
# change only).
|
||||
if newly_created_vuln_ids:
|
||||
try:
|
||||
from app.services.audit_events import audit_new_vulnerabilities
|
||||
audit_new_vulnerabilities(db, newly_created_vuln_ids, source="wazuh")
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
logger.warning(f"Wazuh sync: detected-audit failed (non-fatal): {e}")
|
||||
|
||||
# Best-effort enrichment of newly created vulns (by ID, not a
|
||||
# broad scan of all un-enriched rows which could be very slow).
|
||||
if newly_created_vuln_ids:
|
||||
@@ -1808,7 +1991,9 @@ def run_wazuh_vulnerability_sync(db: Session) -> dict:
|
||||
Vulnerability.id.in_(newly_created_vuln_ids)
|
||||
).all()
|
||||
if new_vulns:
|
||||
enrich_stats = enrich_vulnerabilities(db, new_vulns)
|
||||
# Skip NVD date backfill here — keeps the sync fast;
|
||||
# the nightly enrichment job dates these rows.
|
||||
enrich_stats = enrich_vulnerabilities(db, new_vulns, use_nvd_dates=False)
|
||||
stats["enrichment"] = enrich_stats
|
||||
except Exception as e:
|
||||
logger.warning(f"Wazuh sync: enrichment of new vulns failed: {e}")
|
||||
@@ -1891,6 +2076,7 @@ def sync_agent_vulnerabilities(db: Session, wazuh: WazuhClient, agent_id: str, a
|
||||
pkg_name = vuln_data.get("name")
|
||||
pkg_version = vuln_data.get("version")
|
||||
pkg_fix = vuln_data.get("fixed_version")
|
||||
pkg_fix = _sanitize_wazuh_fix(pkg_name, pkg_version, pkg_fix)
|
||||
if pkg_name:
|
||||
unique_cves[cve_id]["packages"].add(pkg_name)
|
||||
existing_pkg = unique_cves[cve_id]["pkg_rows"].get(pkg_name)
|
||||
@@ -2098,6 +2284,17 @@ def sync_agent_vulnerabilities(db: Session, wazuh: WazuhClient, agent_id: str, a
|
||||
db.commit()
|
||||
logger.info(f"Sync: Completed for agent {agent_id} ({asset.hostname}): {new_count} new, {updated_count} updated, {len(active_cves)} total active CVEs")
|
||||
|
||||
# Revisionssicher: initial VULNERABILITY_DETECTED audit event per new
|
||||
# finding. Written here (not in the callers) so the scheduler loop and
|
||||
# the per-asset rescan endpoint are both covered.
|
||||
if new_vuln_ids:
|
||||
try:
|
||||
from app.services.audit_events import audit_new_vulnerabilities
|
||||
audit_new_vulnerabilities(db, new_vuln_ids, source="wazuh")
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
logger.warning(f"Sync: detected-audit failed for agent {agent_id} (non-fatal): {e}")
|
||||
|
||||
# 4. Enrich newly created vulns with EPSS + KEV (best-effort, non-fatal)
|
||||
if new_count > 0:
|
||||
try:
|
||||
@@ -2107,7 +2304,8 @@ def sync_agent_vulnerabilities(db: Session, wazuh: WazuhClient, agent_id: str, a
|
||||
Vulnerability.enrichment_updated_at.is_(None)
|
||||
).all()
|
||||
if new_vulns:
|
||||
enrich_vulnerabilities(db, new_vulns)
|
||||
# NVD date backfill deferred to the nightly job (rate-limited).
|
||||
enrich_vulnerabilities(db, new_vulns, use_nvd_dates=False)
|
||||
except Exception as e:
|
||||
logger.warning(f"Sync: Enrichment skipped for agent {agent_id}: {e}")
|
||||
|
||||
@@ -2206,7 +2404,7 @@ async def check_incorrect_scores(
|
||||
|
||||
|
||||
@router.post("/override/nessus/{asset_id}")
|
||||
async def override_from_nessus(
|
||||
def override_from_nessus(
|
||||
asset_id: int,
|
||||
scan_id: Optional[int] = None,
|
||||
dry_run: bool = Query(True, description="Nur simulieren ohne DB-Änderungen"),
|
||||
@@ -2312,7 +2510,9 @@ async def override_from_nessus(
|
||||
|
||||
|
||||
@router.post("/override/vulnrichment")
|
||||
async def override_from_vulnrichment(
|
||||
# Sync def → worker threadpool; the cvelistV5/Vulnrichment cascade (incl.
|
||||
# ZIP download) runs off the event loop so the web GUI stays responsive.
|
||||
def override_from_vulnrichment(
|
||||
cve_ids: Optional[List[str]] = Query(None, description="CVE-IDs zum Korrigieren (leer = alle)"),
|
||||
asset_ids: Optional[List[int]] = Query(None, description="Asset-IDs zum Korrigieren (leer = alle)"),
|
||||
dry_run: bool = Query(True, description="Nur simulieren ohne DB-Änderungen"),
|
||||
@@ -2423,7 +2623,9 @@ async def list_vulnrichment_correction_jobs(
|
||||
|
||||
|
||||
@router.post("/exploit-intel/refresh")
|
||||
async def refresh_exploit_intel(
|
||||
# Sync def → FastAPI runs it in a worker threadpool, off the event loop,
|
||||
# so the catalog downloads don't freeze the web GUI.
|
||||
def refresh_exploit_intel(
|
||||
only_open: bool = Query(True, description="Limit to status=open"),
|
||||
fetch_pocs: bool = Query(True, description="Also fetch PoC-in-GitHub (slow)"),
|
||||
fetch_msf: bool = Query(True, description="Also fetch Metasploit modules"),
|
||||
@@ -2440,7 +2642,9 @@ async def refresh_exploit_intel(
|
||||
|
||||
|
||||
@router.post("/eol-check")
|
||||
async def run_eol_check(
|
||||
# Sync def → runs in a worker threadpool (off the event loop) so the
|
||||
# per-asset endoflife.date / MS-lifecycle work doesn't freeze the web GUI.
|
||||
def run_eol_check(
|
||||
asset_id: Optional[int] = Query(None, description="Scope to one asset; omit for all wazuh-linked assets"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(RequireEditor),
|
||||
@@ -2545,18 +2749,34 @@ async def run_eol_check(
|
||||
continue
|
||||
seen_keys.add(key)
|
||||
stats["packages_checked"] += 1
|
||||
slug = eol_service.resolve_product_slug(name)
|
||||
if not slug:
|
||||
stats["products_unmapped"] += 1
|
||||
continue
|
||||
status = None
|
||||
# 1) endoflife.date (when the name maps to a known slug).
|
||||
if eol_service.resolve_product_slug(name):
|
||||
try:
|
||||
status = eol_service.check_eol(db, name, version)
|
||||
except Exception as e:
|
||||
logger.warning("EOL check failed for %s %s: %s", name, version, e)
|
||||
continue
|
||||
if not status:
|
||||
continue
|
||||
if not (status.is_eol or status.is_eol_soon or status.is_eoas):
|
||||
status = None
|
||||
actionable = status and (status.is_eol or status.is_eol_soon or status.is_eoas)
|
||||
# 2) MS-lifecycle export / hardcoded exotics (Plan O) whenever
|
||||
# endoflife.date had nothing actionable — this also covers
|
||||
# products endoflife.date maps to a slug but can't resolve a
|
||||
# release for (e.g. Visual C++ Redistributables → visual-cpp,
|
||||
# whose endoflife data doesn't match redistributable builds).
|
||||
if not actionable:
|
||||
try:
|
||||
from app.services import ms_lifecycle_service
|
||||
ms_status = ms_lifecycle_service.resolve_ms_lifecycle_eol(db, name, version)
|
||||
except Exception as e:
|
||||
logger.debug("MS-lifecycle fallback failed for %s: %s", name, e)
|
||||
ms_status = None
|
||||
if ms_status and (ms_status.is_eol or ms_status.is_eol_soon):
|
||||
status = ms_status
|
||||
actionable = True
|
||||
stats.setdefault("ms_lifecycle_findings", 0)
|
||||
stats["ms_lifecycle_findings"] += 1
|
||||
if not actionable:
|
||||
stats["products_unmapped"] += 1
|
||||
continue
|
||||
try:
|
||||
_, was_created = eol_service.upsert_eol_vulnerability(
|
||||
@@ -2572,11 +2792,401 @@ async def run_eol_check(
|
||||
except Exception as e:
|
||||
logger.warning("EOL upsert failed for %s on asset %s: %s", name, asset.id, e)
|
||||
db.commit()
|
||||
logger.info(
|
||||
"EOL check done: %d assets, %d packages, %d EOL findings (%d new), "
|
||||
"%d via MS-lifecycle/exotics, %d OS, %d unmapped",
|
||||
stats.get("assets_scanned", 0), stats.get("packages_checked", 0),
|
||||
stats.get("eol_findings_total", 0), stats.get("eol_findings_new", 0),
|
||||
stats.get("ms_lifecycle_findings", 0), stats.get("os_eol_findings", 0),
|
||||
stats.get("products_unmapped", 0),
|
||||
)
|
||||
return stats
|
||||
|
||||
|
||||
@router.post("/m365-check")
|
||||
# Sync def → runs in a worker threadpool (off the event loop) so the
|
||||
# cvelistV5/MSRC metadata work doesn't freeze the web GUI.
|
||||
def run_m365_check_endpoint(
|
||||
asset_id: Optional[int] = Query(None, description="Scope to one asset; omit for all wazuh-linked assets"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(RequireEditor),
|
||||
):
|
||||
"""Detect Microsoft 365 Apps CVEs (Plan P).
|
||||
|
||||
M365 Apps security fixes are not in NVD and are invisible to Wazuh's
|
||||
vulnerability detector. This parses the Microsoft 365 Apps security-
|
||||
updates page, compares the installed build (from syscollector) against
|
||||
the latest patched build for the matching update channel, and creates
|
||||
real-CVE rows for every monthly update the host is behind on.
|
||||
|
||||
Idempotent — re-runs upsert existing rows.
|
||||
"""
|
||||
from app.integrations.wazuh_client import WazuhClient
|
||||
from app.auth.setting_crypto import read_setting_value
|
||||
from app.services import m365_service
|
||||
|
||||
raw_wazuh = read_setting_value(db, "wazuh_config")
|
||||
if not raw_wazuh:
|
||||
raise HTTPException(400, "Wazuh is not configured (settings.wazuh_config missing).")
|
||||
try:
|
||||
wazuh_config = json.loads(raw_wazuh)
|
||||
except json.JSONDecodeError:
|
||||
raise HTTPException(400, "Invalid Wazuh configuration format.")
|
||||
api_url = wazuh_config.get("api_url")
|
||||
username = wazuh_config.get("username")
|
||||
password = wazuh_config.get("password")
|
||||
if not all([api_url, username, password]):
|
||||
raise HTTPException(400, "Incomplete Wazuh configuration (api_url/username/password missing).")
|
||||
wazuh = WazuhClient(
|
||||
base_url=api_url,
|
||||
username=username,
|
||||
password=password,
|
||||
indexer_url=wazuh_config.get("indexer_url"),
|
||||
indexer_username=wazuh_config.get("indexer_username"),
|
||||
indexer_password=wazuh_config.get("indexer_password"),
|
||||
verify_ssl=wazuh_config.get("verify_ssl", False),
|
||||
)
|
||||
|
||||
try:
|
||||
return m365_service.run_m365_check(db, wazuh, asset_id=asset_id)
|
||||
except m365_service.M365Error as e:
|
||||
raise HTTPException(502, f"MS365 detection failed: {e}")
|
||||
|
||||
|
||||
@router.post("/app-cve-scan")
|
||||
# Sync def → worker threadpool; OSV/NVD-CPE lookups are blocking I/O and
|
||||
# must not freeze the web GUI.
|
||||
def run_app_cve_scan_endpoint(
|
||||
asset_id: Optional[int] = Query(None, description="Scope to one asset; omit for all assets with software inventory"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(RequireEditor),
|
||||
):
|
||||
"""Built-in app→CVE scanner (curated + precise).
|
||||
|
||||
Maps installed software (Wazuh syscollector packages + Intune
|
||||
detectedApps) to real CVEs via OSV / NVD-CPE with an own version-range
|
||||
check, so assets without a real scanner (Intune-only / mobile) still
|
||||
get findings. Source 'app-scan'; cross-confirms with existing scanners
|
||||
on the same (cve, asset). Idempotent.
|
||||
"""
|
||||
from app.services import app_cve_scanner_service
|
||||
try:
|
||||
return app_cve_scanner_service.run_app_cve_scan(db, asset_id=asset_id)
|
||||
except Exception as e:
|
||||
raise HTTPException(502, f"App CVE scan failed: {e}")
|
||||
|
||||
|
||||
@router.post("/msrc-scan")
|
||||
# Sync def → worker threadpool; the CVRF fetch is blocking I/O.
|
||||
def run_msrc_scan_endpoint(
|
||||
asset_id: Optional[int] = Query(None, description="Scope to one asset; omit for all Windows Server assets"),
|
||||
rebuild_index: bool = Query(False, description="Re-pull the MSRC CVRF documents first (slow; the nightly job does this)"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(RequireEditor),
|
||||
):
|
||||
"""MSRC fixed-build scan for Windows Server OS CVEs.
|
||||
|
||||
Compares the host's OS build against the FixedBuild MSRC publishes per CVE
|
||||
and product, on the host's own servicing branch. Patch-level accurate and
|
||||
available on Patch Tuesday — well before the CVE reaches the Wazuh CTI feed.
|
||||
NVD/cvelistV5 cannot do this: they carry no fixed build for MS products.
|
||||
Source 'msrc'; idempotent, and auto-resolves once a host catches up.
|
||||
"""
|
||||
from app.services import msrc_scan_service
|
||||
try:
|
||||
if rebuild_index:
|
||||
msrc_scan_service.build_product_index(db)
|
||||
return msrc_scan_service.run_msrc_scan(db, asset_id=asset_id)
|
||||
except Exception as e:
|
||||
raise HTTPException(502, f"MSRC scan failed: {e}")
|
||||
|
||||
|
||||
@router.post("/suppress-false-positives")
|
||||
# Sync def → worker threadpool; reads the cvelistV5 ZIP (blocking I/O).
|
||||
def suppress_false_positives_endpoint(
|
||||
asset_id: Optional[int] = Query(None, description="Scope to one asset; omit for all"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(RequireEditor),
|
||||
):
|
||||
"""Auto-flag Wazuh findings whose installed version is provably outside
|
||||
all cvelistV5 affected ranges for the matched product (loose-CPE false
|
||||
positives, e.g. a SQL 2019 host carrying a 16.x/17.x-only CVE). Sets
|
||||
status=false_positive (reversible via unmark). Conservative.
|
||||
"""
|
||||
from app.services import cvelistv5_scan_service
|
||||
try:
|
||||
return cvelistv5_scan_service.suppress_false_positives(db, asset_id=asset_id)
|
||||
except Exception as e:
|
||||
raise HTTPException(502, f"FP suppression failed: {e}")
|
||||
|
||||
|
||||
@router.get("/ai-remediation/status")
|
||||
async def ai_remediation_status(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Whether AI remediation is configured (key present). UI hides the
|
||||
button when disabled."""
|
||||
from app.services import ai_service
|
||||
return {"enabled": ai_service.is_enabled(db)}
|
||||
|
||||
|
||||
@router.post("/{vuln_id}/ai-remediation")
|
||||
async def ai_remediation(
|
||||
vuln_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(RequireEditor),
|
||||
):
|
||||
"""Generate OS-aware remediation guidance for a vulnerability via
|
||||
OpenRouter. Runs OFF the event loop so the GUI stays responsive."""
|
||||
import asyncio
|
||||
from app.services import ai_service
|
||||
|
||||
vuln = db.query(Vulnerability).filter(Vulnerability.id == vuln_id).first()
|
||||
if not vuln:
|
||||
raise HTTPException(404, "Vulnerability not found")
|
||||
asset = db.query(Asset).filter(Asset.id == vuln.asset_id).first()
|
||||
os_name = None
|
||||
if asset:
|
||||
os_name = (asset.operating_system or "").strip() or None
|
||||
if os_name and asset.os_version:
|
||||
os_name = f"{os_name} {asset.os_version}".strip()
|
||||
|
||||
kwargs = dict(
|
||||
cve_id=vuln.cve_id,
|
||||
title=vuln.title,
|
||||
description=vuln.description,
|
||||
package=vuln.package_name,
|
||||
installed=vuln.package_version,
|
||||
fixed=vuln.fixed_version,
|
||||
os_name=os_name,
|
||||
scanner_remediation=getattr(vuln, "remediation", None),
|
||||
)
|
||||
try:
|
||||
result = await asyncio.to_thread(ai_service.generate_remediation, db, **kwargs)
|
||||
except ai_service.AIServiceError as e:
|
||||
raise HTTPException(502, str(e))
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/{vuln_id}/remediations")
|
||||
async def get_vuln_remediations(
|
||||
vuln_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Remediation guidance for a finding from every source:
|
||||
the per-row scanner solution (Nessus) + CVE-level external sources
|
||||
(MSRC, later Ubuntu/CentOS) grouped by source. Pure DB read."""
|
||||
from app.models.cve_remediation import CveRemediation
|
||||
vuln = db.query(Vulnerability).filter(Vulnerability.id == vuln_id).first()
|
||||
if not vuln:
|
||||
raise HTTPException(404, "Vulnerability not found")
|
||||
|
||||
# On-demand non-Windows enrichment for this CVE, cached into
|
||||
# cve_remediations on first view: the matching vendor provider (Ubuntu
|
||||
# USN / RHEL-family errata) when the OS is known + OSV.dev aggregator
|
||||
# (Debian/SUSE/Alpine/Rocky/Alma/language ecosystems). MSRC is populated
|
||||
# by its own background job, not here.
|
||||
if vuln.cve_id and vuln.cve_id.upper().startswith("CVE-"):
|
||||
try:
|
||||
import asyncio
|
||||
from app.services import linux_remediation_service as lrs
|
||||
asset = db.query(Asset).filter(Asset.id == vuln.asset_id).first()
|
||||
os_name = (asset.operating_system if asset else None) or ""
|
||||
is_windows = "windows" in os_name.lower()
|
||||
# OSV is the always-run source; if it's not cached yet, enrich.
|
||||
if not is_windows and not lrs.has_cached(db, vuln.cve_id, "osv"):
|
||||
await asyncio.to_thread(lrs.enrich_cve_linux, db, vuln.cve_id, os_name)
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
logger.debug("Linux/OSV remediation enrich failed for %s: %s", vuln.cve_id, e)
|
||||
|
||||
ext = (
|
||||
db.query(CveRemediation)
|
||||
.filter(CveRemediation.cve_id == vuln.cve_id)
|
||||
.all()
|
||||
)
|
||||
groups: dict = {}
|
||||
for r in ext:
|
||||
g = groups.setdefault(r.source, {"source": r.source, "items": [], "fetched_at": None})
|
||||
g["items"].append({
|
||||
"kind": r.kind, "title": r.title, "detail": r.detail,
|
||||
"kb": r.kb, "fixed_build": r.fixed_build, "url": r.url,
|
||||
})
|
||||
if r.fetched_at and (g["fetched_at"] is None or r.fetched_at > g["fetched_at"]):
|
||||
g["fetched_at"] = r.fetched_at
|
||||
|
||||
# MSRC block tailoring for the affected host.
|
||||
msrc = groups.get("msrc")
|
||||
if msrc:
|
||||
a = db.query(Asset).filter(Asset.id == vuln.asset_id).first()
|
||||
is_m365 = (vuln.first_detected_by == "m365_check")
|
||||
|
||||
def _branch(v: str):
|
||||
# build "10.0.26100.32690" → branch "26100", rev 32690
|
||||
parts = [p for p in (v or "").split(".") if p.isdigit()]
|
||||
return (parts[2], int(parts[3])) if len(parts) >= 4 else (None, None)
|
||||
|
||||
def _major(v):
|
||||
# first numeric segment: "8.0.12" → "8", "18.6.3" → "18"
|
||||
for p in (v or "").split("."):
|
||||
if p.isdigit():
|
||||
return p
|
||||
return None
|
||||
|
||||
items = msrc["items"]
|
||||
fixes = [i for i in items if i.get("kind") == "fix"]
|
||||
others = [i for i in items if i.get("kind") != "fix"]
|
||||
|
||||
# Keep only the NEWEST KB per (build-branch + update-type). MS monthly
|
||||
# updates are cumulative, so older revisions of the same line are
|
||||
# superseded; distinct types (Security Update vs Security Hotpatch
|
||||
# Update) are kept separately. Non-Windows product builds (.NET / Visual
|
||||
# Studio / SQL have no 4-part Windows build → branch None) are keyed on
|
||||
# the build itself so DIFFERENT products aren't collapsed into one
|
||||
# (tester: a .NET finding showed a Visual Studio build because both had
|
||||
# branch None).
|
||||
# Collect ALL candidates per (build-branch + update-type) first — the
|
||||
# host-aware pick below needs the full set. Collapsing to the newest
|
||||
# rev up-front was wrong: one branch can carry SEVERAL revision
|
||||
# sequences (10.0.26100 is Windows 11 24H2 at rev ~8xxx AND Windows
|
||||
# Server 2025 at rev ~33xxx), so 'newest' handed a Win11 host the
|
||||
# Server KB (tester: KB5099536 build .33158 suggested at .8655).
|
||||
best: dict = {}
|
||||
by_branch: dict = {}
|
||||
for i in fixes:
|
||||
build = i.get("fixed_build") or ""
|
||||
br, rev = _branch(build)
|
||||
if br is None:
|
||||
key = ("nb", build or i.get("kb") or i.get("url") or i.get("title") or "", i.get("detail") or "")
|
||||
best.setdefault(key, i)
|
||||
else:
|
||||
key = (br, i.get("detail") or "")
|
||||
by_branch.setdefault(key, []).append((rev or 0, i))
|
||||
if key not in best or (rev or 0) > (_branch(best[key].get("fixed_build") or "")[1] or 0):
|
||||
best[key] = i
|
||||
deduped = list(best.values())
|
||||
|
||||
# Windows-OS finding: narrow to the host's own build branch. Within the
|
||||
# branch, pick per update-type the SMALLEST fix revision still above the
|
||||
# installed one — that's the host's own servicing sequence (a 24H2 box
|
||||
# at .8655 takes .8875, not Server 2025's .33158; a Server box at
|
||||
# .32690 skips .8875 because it's below installed and takes .33158).
|
||||
# MS servicing is cumulative, so smallest-above is the fixing update.
|
||||
# If the host is past every candidate (already patched), fall back to
|
||||
# the newest as reference. For Microsoft 365 Apps findings the MSRC KBs
|
||||
# are MSI-version builds that never match the installed Click-to-Run
|
||||
# channel build, so we don't branch-filter — we show the deduped set
|
||||
# plus the actionable channel-build hint below.
|
||||
host_branch, host_rev = _branch(a.os_version if a else "")
|
||||
shown = deduped
|
||||
if host_branch and not is_m365:
|
||||
matched = []
|
||||
for (br, _detail), cands in by_branch.items():
|
||||
if br != host_branch:
|
||||
continue
|
||||
above = [c for c in cands if host_rev is not None and c[0] > host_rev]
|
||||
pick = min(above)[1] if above else max(cands)[1]
|
||||
matched.append(pick)
|
||||
if matched:
|
||||
shown = matched
|
||||
# Non-Windows Microsoft product finding (.NET / SQL / Visual Studio all
|
||||
# share one CVE across products, each with its own fix build): narrow
|
||||
# to the build line whose major matches the installed package version,
|
||||
# so a .NET 8.x finding shows the .NET 8.x fix — not a Visual Studio
|
||||
# 18.x build. Fallback to the full set if nothing matches (never hide
|
||||
# everything).
|
||||
if not is_m365 and shown is deduped:
|
||||
inst_major = _major(vuln.package_version)
|
||||
if inst_major:
|
||||
prod = [i for i in shown if _major(i.get("fixed_build")) == inst_major]
|
||||
if prod:
|
||||
shown = prod
|
||||
shown.sort(key=lambda i: _branch(i.get("fixed_build") or "")[1] or 0, reverse=True)
|
||||
|
||||
# M365 Apps: the real fix is "update the Office channel to build X".
|
||||
if is_m365 and vuln.fixed_version:
|
||||
others = [{
|
||||
"kind": "mitigation",
|
||||
"title": "Update via Office channel",
|
||||
"detail": (f"Update Microsoft 365 Apps to build {vuln.fixed_version} "
|
||||
f"or later via the configured Office update channel "
|
||||
f"(Click-to-Run). The KB list below is for perpetual/MSI "
|
||||
f"Office versions and may not apply to this install."),
|
||||
"kb": None, "fixed_build": None, "url": None,
|
||||
}] + others
|
||||
|
||||
link = {
|
||||
"kind": "advisory",
|
||||
"title": "MSRC update guide",
|
||||
"detail": None, "kb": None, "fixed_build": None,
|
||||
"url": f"https://msrc.microsoft.com/update-guide/vulnerability/{vuln.cve_id}",
|
||||
}
|
||||
msrc["items"] = [link] + others + shown
|
||||
|
||||
return {
|
||||
"scanner": getattr(vuln, "remediation", None),
|
||||
"external": list(groups.values()),
|
||||
}
|
||||
|
||||
|
||||
# Last/current MSRC-refresh state so the GUI can poll for completion (the
|
||||
# refresh is fire-and-forget 202). ponytail: module state assumes the single
|
||||
# uvicorn worker we ship with; move to the DB if --workers is ever added.
|
||||
_MSRC_REFRESH: dict = {"running": False, "result": None, "error": None, "finished_at": None}
|
||||
|
||||
|
||||
def _run_msrc_refresh_threaded(months_back: Optional[int] = None) -> None:
|
||||
import time as _time
|
||||
from app.database import SessionLocal
|
||||
from app.services import msrc_service
|
||||
db = SessionLocal()
|
||||
try:
|
||||
stats = msrc_service.refresh_msrc(db, months_back=months_back)
|
||||
_MSRC_REFRESH["result"] = {k: v for k, v in (stats or {}).items() if k != "errors"}
|
||||
_MSRC_REFRESH["error"] = None
|
||||
except Exception as e:
|
||||
_MSRC_REFRESH["result"] = None
|
||||
_MSRC_REFRESH["error"] = str(e)
|
||||
logger.error("MSRC refresh failed: %s", e)
|
||||
finally:
|
||||
db.close()
|
||||
_MSRC_REFRESH["running"] = False
|
||||
_MSRC_REFRESH["finished_at"] = _time.time()
|
||||
|
||||
|
||||
@router.post("/msrc/refresh", status_code=202)
|
||||
async def refresh_msrc_endpoint(
|
||||
months_back: Optional[int] = Query(None, description="How many recent monthly MSRC docs to ingest"),
|
||||
current_user: User = Depends(RequireEditor),
|
||||
):
|
||||
"""Kick off an MSRC CVRF ingest in the background (fire-and-forget).
|
||||
|
||||
Pulls the last N monthly Microsoft security-update documents and stores
|
||||
per-CVE fixes (KB + build + link), workarounds, and mitigations for the
|
||||
CVEs already in the DB. Returns 202 immediately — watch the logs
|
||||
('MSRC refresh done')."""
|
||||
if _MSRC_REFRESH["running"]:
|
||||
return {"status": "already_running", "detail": "An MSRC refresh is already in progress."}
|
||||
_MSRC_REFRESH.update({"running": True, "result": None, "error": None, "finished_at": None})
|
||||
import threading
|
||||
threading.Thread(target=_run_msrc_refresh_threaded, args=(months_back,), daemon=True).start()
|
||||
return {"status": "started", "detail": "MSRC enrichment started in the background."}
|
||||
|
||||
|
||||
@router.get("/msrc/refresh/status")
|
||||
async def msrc_refresh_status(current_user: User = Depends(RequireEditor)):
|
||||
"""Poll target for the GUI: current/last MSRC-refresh state + stats."""
|
||||
s = _MSRC_REFRESH
|
||||
state = ("running" if s["running"] else "error" if s["error"]
|
||||
else "done" if s["result"] is not None else "idle")
|
||||
return {"state": state, "running": s["running"], "result": s["result"],
|
||||
"error": s["error"], "finished_at": s["finished_at"]}
|
||||
|
||||
|
||||
@router.post("/recompute-scores")
|
||||
async def recompute_priority_scores(
|
||||
def recompute_priority_scores(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(RequireEditor),
|
||||
):
|
||||
|
||||
+289
-16
@@ -41,7 +41,7 @@ INTERVAL_MAP = {
|
||||
}
|
||||
|
||||
|
||||
async def execute_scheduled_scan(schedule_id: int):
|
||||
def execute_scheduled_scan(schedule_id: int):
|
||||
"""Executes a scheduled scan. Routes by ScanSchedule.scanner_type
|
||||
to either the Wazuh agent loop or the Nessus sync service."""
|
||||
db = SessionLocal()
|
||||
@@ -153,7 +153,7 @@ async def execute_scheduled_scan(schedule_id: int):
|
||||
db.close()
|
||||
|
||||
|
||||
async def check_sla_breaches():
|
||||
def check_sla_breaches():
|
||||
"""
|
||||
Hourly SLA-breach check. Honors notification_mode setting:
|
||||
- 'digest' (default): one summary mail per recipient
|
||||
@@ -347,7 +347,7 @@ async def check_sla_breaches():
|
||||
user_id=user_id,
|
||||
notification_type=NotificationType.SLA_BREACH,
|
||||
sent_at=now,
|
||||
subject=f"[VULNCHECK] {len(items)} SLA-breached vulnerabilities require action",
|
||||
subject=f"[TRUEVULN] {len(items)} SLA-breached vulnerabilities require action",
|
||||
recipient_email=email,
|
||||
status=NotificationStatus.SENT if success else NotificationStatus.FAILED,
|
||||
message_body=f"SLA digest: {it['vuln'].cve_id} on {it['asset'].hostname} ({it['hours_overdue']}h overdue)",
|
||||
@@ -410,7 +410,7 @@ async def check_sla_breaches():
|
||||
db.close()
|
||||
|
||||
|
||||
async def refresh_threat_intel_enrichment():
|
||||
def refresh_threat_intel_enrichment():
|
||||
"""Daily refresh of EPSS scores + CISA KEV catalog for all open vulnerabilities."""
|
||||
from app.services.enrichment_service import enrich_all_open_vulnerabilities
|
||||
|
||||
@@ -428,7 +428,7 @@ async def refresh_threat_intel_enrichment():
|
||||
db.close()
|
||||
|
||||
|
||||
async def exploit_intel_nightly():
|
||||
def exploit_intel_nightly():
|
||||
"""Refresh public-exploit catalogs (Exploit-DB / PoC-in-GitHub /
|
||||
Metasploit). Runs 03:45 UTC — after Vulnrichment (03:00) and the
|
||||
audit prune (03:30), before URS recompute (04:00) so the new
|
||||
@@ -449,7 +449,7 @@ async def exploit_intel_nightly():
|
||||
db.close()
|
||||
|
||||
|
||||
async def eol_check_nightly():
|
||||
def eol_check_nightly():
|
||||
"""Run endoflife.date EOL detection for every Wazuh-linked asset.
|
||||
|
||||
Pseudo-CVE rows (cve_id starts with EOL-) get upserted so the next
|
||||
@@ -524,13 +524,26 @@ async def eol_check_nightly():
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
if not eol_service.resolve_product_slug(name):
|
||||
continue
|
||||
status = None
|
||||
if eol_service.resolve_product_slug(name):
|
||||
try:
|
||||
status = eol_service.check_eol(db, name, version)
|
||||
except Exception:
|
||||
continue
|
||||
if not status or not (status.is_eol or status.is_eol_soon or status.is_eoas):
|
||||
status = None
|
||||
actionable = status and (status.is_eol or status.is_eol_soon or status.is_eoas)
|
||||
# MS-lifecycle fallback whenever endoflife.date had nothing
|
||||
# actionable (covers VC++ Redistributables, exotics, and MS
|
||||
# products endoflife.date can't resolve a release for).
|
||||
if not actionable:
|
||||
try:
|
||||
from app.services import ms_lifecycle_service
|
||||
ms_status = ms_lifecycle_service.resolve_ms_lifecycle_eol(db, name, version)
|
||||
if ms_status and (ms_status.is_eol or ms_status.is_eol_soon):
|
||||
status = ms_status
|
||||
actionable = True
|
||||
except Exception as e:
|
||||
logger.debug("MS-lifecycle nightly fallback failed for %s: %s", name, e)
|
||||
if not actionable:
|
||||
continue
|
||||
try:
|
||||
eol_service.upsert_eol_vulnerability(
|
||||
@@ -552,7 +565,152 @@ async def eol_check_nightly():
|
||||
db.close()
|
||||
|
||||
|
||||
async def prune_audit_logs_nightly():
|
||||
def intune_sync_nightly():
|
||||
"""Sync Microsoft Intune managed devices → assets + OS-EOL (Graph API).
|
||||
|
||||
Skipped when intune_config is not set. Slotted at 02:10 UTC, before the
|
||||
other inventory-derived jobs."""
|
||||
from app.services.intune_service import load_intune_config, run_intune_sync
|
||||
db = SessionLocal()
|
||||
try:
|
||||
if not load_intune_config(db):
|
||||
logger.info("Intune sync skipped — intune_config not set")
|
||||
return
|
||||
stats = run_intune_sync(db)
|
||||
logger.info("Intune nightly: %s", {k: v for k, v in stats.items() if k != "errors"})
|
||||
except Exception as e:
|
||||
logger.error("Intune nightly failed: %s", e)
|
||||
db.rollback()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def m365_check_nightly():
|
||||
"""Detect Microsoft 365 Apps CVEs (Plan P) for every Wazuh-linked asset.
|
||||
|
||||
M365 Apps security fixes never reach NVD and are invisible to Wazuh's
|
||||
vulnerability detector. This parses the MS365 Apps security-updates
|
||||
page (cached 24h), compares the installed build per channel, and
|
||||
upserts real-CVE rows for the months each host is behind on.
|
||||
|
||||
Slotted at 03:20 UTC — right after the EOL nightly (03:15), before the
|
||||
audit prune (03:30).
|
||||
"""
|
||||
from app.integrations.wazuh_client import WazuhClient
|
||||
from app.services import m365_service
|
||||
from app.auth.setting_crypto import read_setting_value
|
||||
import json as _json
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
raw = read_setting_value(db, "wazuh_config")
|
||||
if not raw:
|
||||
logger.info("M365 check skipped — wazuh_config not set")
|
||||
return
|
||||
try:
|
||||
cfg = _json.loads(raw)
|
||||
except _json.JSONDecodeError:
|
||||
logger.warning("M365 check skipped — invalid wazuh_config JSON")
|
||||
return
|
||||
if not all([cfg.get("api_url"), cfg.get("username"), cfg.get("password")]):
|
||||
logger.info("M365 check skipped — wazuh_config incomplete")
|
||||
return
|
||||
wazuh = WazuhClient(
|
||||
base_url=cfg.get("api_url"),
|
||||
username=cfg.get("username"),
|
||||
password=cfg.get("password"),
|
||||
indexer_url=cfg.get("indexer_url"),
|
||||
indexer_username=cfg.get("indexer_username"),
|
||||
indexer_password=cfg.get("indexer_password"),
|
||||
verify_ssl=cfg.get("verify_ssl", False),
|
||||
)
|
||||
stats = m365_service.run_m365_check(db, wazuh)
|
||||
logger.info("M365 nightly: %s", stats)
|
||||
except m365_service.M365Error as e:
|
||||
logger.warning("M365 nightly skipped — %s", e)
|
||||
except Exception as e:
|
||||
logger.error("M365 nightly failed: %s", e)
|
||||
db.rollback()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def app_cve_scan_nightly():
|
||||
"""Built-in app→CVE scanner for every asset with software inventory.
|
||||
|
||||
Maps installed software (Wazuh packages + Intune detectedApps) to real
|
||||
CVEs via OSV / NVD-CPE (curated + precise, own version-range check) —
|
||||
closes the coverage gap for Intune-only / mobile devices that have no
|
||||
real scanner. Source 'app-scan'; cross-confirms with the other scanners.
|
||||
|
||||
Slotted at 03:25 UTC — after M365 (03:20), before the audit prune (03:30).
|
||||
Cache (TTL 7d) keeps OSV/NVD load bounded; NVD_API_KEY recommended.
|
||||
"""
|
||||
from app.services import app_cve_scanner_service, cvelistv5_scan_service
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# Rebuild the cvelistV5 reverse index first (one ~557 MB ZIP walk) so
|
||||
# the scan below has fresh product→CVE ranges for curated software.
|
||||
try:
|
||||
cvelistv5_scan_service.build_product_index(db)
|
||||
except Exception as e:
|
||||
logger.warning("cvelistV5 index build failed (non-fatal): %s", e)
|
||||
stats = app_cve_scanner_service.run_app_cve_scan(db)
|
||||
logger.info("App CVE scan nightly: %s", {k: v for k, v in stats.items() if k != "errors"})
|
||||
# Auto-suppress Wazuh findings whose installed version is outside all
|
||||
# cvelistV5 affected ranges (loose-CPE false positives).
|
||||
try:
|
||||
fp = cvelistv5_scan_service.suppress_false_positives(db)
|
||||
logger.info("cvelistV5 FP-suppression nightly: %s", {k: v for k, v in fp.items() if k != "errors"})
|
||||
except Exception as e:
|
||||
logger.warning("cvelistV5 FP-suppression failed (non-fatal): %s", e)
|
||||
except Exception as e:
|
||||
logger.error("App CVE scan nightly failed: %s", e)
|
||||
db.rollback()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def msrc_refresh_weekly():
|
||||
"""Ingest the recent monthly MSRC CVRF documents into cve_remediations.
|
||||
|
||||
Microsoft revises advisories (containment-only first, KBs later), so a
|
||||
weekly pull keeps the per-CVE fixes/workarounds/mitigations current.
|
||||
"""
|
||||
from app.services import msrc_service
|
||||
db = SessionLocal()
|
||||
try:
|
||||
stats = msrc_service.refresh_msrc(db)
|
||||
logger.info("MSRC weekly: %s", {k: v for k, v in stats.items() if k != "errors"})
|
||||
except Exception as e:
|
||||
logger.error("MSRC weekly refresh failed: %s", e)
|
||||
db.rollback()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def msrc_scan_nightly():
|
||||
"""Rebuild the MSRC fixed-build index and flag Windows-Server OS CVEs whose
|
||||
FixedBuild is ahead of the host's build.
|
||||
|
||||
MSRC publishes on Patch Tuesday, well before the CVE reaches the Wazuh CTI
|
||||
feed — this closes that gap, and it is patch-level accurate (NVD/cvelistV5
|
||||
carry no fixed build for MS products, see msrc_scan_service).
|
||||
"""
|
||||
from app.services import msrc_scan_service
|
||||
db = SessionLocal()
|
||||
try:
|
||||
msrc_scan_service.build_product_index(db)
|
||||
stats = msrc_scan_service.run_msrc_scan(db)
|
||||
logger.info("MSRC scan nightly: %s", {k: v for k, v in stats.items() if k != "errors"})
|
||||
except Exception as e:
|
||||
logger.error("MSRC scan nightly failed: %s", e)
|
||||
db.rollback()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def prune_audit_logs_nightly():
|
||||
"""Prune audit_logs older than `audit_log_retention_days` setting.
|
||||
|
||||
Default 1825 days (≈ 5 years) so ISO 27001 / SOX / DSGVO Art.5
|
||||
@@ -591,7 +749,7 @@ async def prune_audit_logs_nightly():
|
||||
db.close()
|
||||
|
||||
|
||||
async def reconcile_assets_nightly():
|
||||
def reconcile_assets_nightly():
|
||||
"""Soft-inactivate assets no source has reported within the window;
|
||||
revive recently-seen inactive ones. Runs 04:15 UTC, after URS."""
|
||||
from app.services.asset_lifecycle import reconcile_asset_lifecycle
|
||||
@@ -606,7 +764,7 @@ async def reconcile_assets_nightly():
|
||||
db.close()
|
||||
|
||||
|
||||
async def refresh_exposure_nightly():
|
||||
def refresh_exposure_nightly():
|
||||
"""Refresh network-exposure scores from Wazuh syscollector ports.
|
||||
Runs 02:30 UTC, after SCA (02:00)."""
|
||||
from app.auth.setting_crypto import read_setting_value
|
||||
@@ -640,7 +798,7 @@ async def refresh_exposure_nightly():
|
||||
db.close()
|
||||
|
||||
|
||||
async def recompute_urs_nightly():
|
||||
def recompute_urs_nightly():
|
||||
"""Recompute Unified Risk Score (URS) for every asset and snapshot.
|
||||
|
||||
Runs 04:00 UTC — after SCA refresh (02:00) and Vulnrichment
|
||||
@@ -674,7 +832,7 @@ async def recompute_urs_nightly():
|
||||
db.close()
|
||||
|
||||
|
||||
async def refresh_compliance_sca():
|
||||
def refresh_compliance_sca():
|
||||
"""Nightly Wazuh SCA refresh — pulls /sca/{agent_id} for every
|
||||
asset that has a wazuh_agent_id and upserts compliance_results.
|
||||
|
||||
@@ -696,7 +854,7 @@ async def refresh_compliance_sca():
|
||||
db.close()
|
||||
|
||||
|
||||
async def refresh_cisa_vulnrichment():
|
||||
def refresh_cisa_vulnrichment():
|
||||
"""Nightly job — pull CISA Vulnrichment ZIP snapshot + correct CVSS / SSVC.
|
||||
|
||||
CISA commits to cisagov/vulnrichment several times per day. Running
|
||||
@@ -799,6 +957,48 @@ def sync_schedules():
|
||||
db.close()
|
||||
|
||||
|
||||
def new_vuln_digest_nightly():
|
||||
"""Nightly roundup of all new CVEs → one aggregated mail per recipient.
|
||||
|
||||
Registered hourly and self-gates on the configured hour, so both the
|
||||
on/off (notification_schedule) and the send hour (notification_nightly_hour)
|
||||
are GUI-configurable without re-registering the job.
|
||||
ponytail: 24 cheap no-op checks/day beats re-registration plumbing.
|
||||
"""
|
||||
from datetime import datetime as _dt
|
||||
db = SessionLocal()
|
||||
try:
|
||||
from app.services.email_service import (
|
||||
get_notification_schedule, get_notification_nightly_hour,
|
||||
send_nightly_new_vuln_digest,
|
||||
)
|
||||
if get_notification_schedule(db) != "nightly":
|
||||
return
|
||||
if _dt.now().hour != get_notification_nightly_hour(db):
|
||||
return
|
||||
stats = send_nightly_new_vuln_digest(db)
|
||||
logger.info("Nightly new-vuln digest sent: %s", stats)
|
||||
except Exception as e:
|
||||
logger.error("Nightly new-vuln digest failed: %s", e)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def advisory_feeds_refresh():
|
||||
"""Refresh the security-advisory RSS feeds (ZDI/CERT-EU/BSI/...) so the
|
||||
advisories page serves from cache. Every 6h — these sources publish ahead
|
||||
of NVD/cvelistV5, that's their whole value."""
|
||||
from app.services.advisory_feed_service import refresh_feeds
|
||||
db = SessionLocal()
|
||||
try:
|
||||
stats = refresh_feeds(db)
|
||||
logger.info("Advisory feeds refresh: %s", stats)
|
||||
except Exception as e:
|
||||
logger.error("Advisory feeds refresh failed: %s", e)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def start_scheduler():
|
||||
"""Starts the background scheduler"""
|
||||
if not HAS_APSCHEDULER or scheduler is None:
|
||||
@@ -892,6 +1092,25 @@ def start_scheduler():
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
# Advisory RSS feeds (ZDI/CERT-EU/BSI/...) every 6h at :20.
|
||||
scheduler.add_job(
|
||||
advisory_feeds_refresh,
|
||||
trigger=CronTrigger(hour="*/6", minute=20),
|
||||
id="advisory_feeds_refresh",
|
||||
name="Security Advisory Feeds Refresh",
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
# New-vuln nightly roundup — runs hourly at :05, self-gates on the
|
||||
# configured hour + notification_schedule=='nightly' (see the function).
|
||||
scheduler.add_job(
|
||||
new_vuln_digest_nightly,
|
||||
trigger=CronTrigger(minute=5),
|
||||
id="new_vuln_digest_nightly",
|
||||
name="Nightly New-Vulnerability Roundup",
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
# Nightly audit-log prune at 03:30 — between Vulnrichment (03:00)
|
||||
# and URS (04:00). Retention configurable via setting
|
||||
# `audit_log_retention_days` (default 1825 = ~5 years; 0 = keep
|
||||
@@ -915,6 +1134,18 @@ def start_scheduler():
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
# Nightly Microsoft 365 Apps CVE detection (Plan P) at 03:20 — parses
|
||||
# the MS365 Apps security-updates page and creates real-CVE rows for
|
||||
# builds behind the latest channel patch. Closes the gap where M365
|
||||
# fixes never reach NVD / Wazuh.
|
||||
scheduler.add_job(
|
||||
m365_check_nightly,
|
||||
trigger=CronTrigger(hour=3, minute=20),
|
||||
id="m365_check_nightly",
|
||||
name="Nightly Microsoft 365 Apps CVE Detection",
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
# Nightly exploit-intel refresh (Plan M) at 03:45 — pulls
|
||||
# Exploit-DB CSV + PoC-in-GitHub + Metasploit module index, writes
|
||||
# per-vuln counts + ref lists.
|
||||
@@ -926,6 +1157,48 @@ def start_scheduler():
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
# Nightly Microsoft Intune device/inventory sync (02:10 UTC).
|
||||
scheduler.add_job(
|
||||
intune_sync_nightly,
|
||||
trigger=CronTrigger(hour=2, minute=10),
|
||||
id="intune_sync_nightly",
|
||||
name="Nightly Microsoft Intune Inventory Sync",
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
# Nightly built-in app→CVE scan (03:25 UTC) — maps installed software
|
||||
# (Wazuh packages + Intune detectedApps) to real CVEs via OSV/NVD-CPE;
|
||||
# closes the coverage gap for Intune-only / mobile devices.
|
||||
scheduler.add_job(
|
||||
app_cve_scan_nightly,
|
||||
trigger=CronTrigger(hour=3, minute=25),
|
||||
id="app_cve_scan_nightly",
|
||||
name="Nightly Built-in App CVE Scan",
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
# Weekly MSRC CVRF ingest (Sun 04:40 UTC) — per-CVE Microsoft fixes
|
||||
# (KB + build + link), workarounds, and mitigations into
|
||||
# cve_remediations for the Windows/MS-product findings.
|
||||
scheduler.add_job(
|
||||
msrc_refresh_weekly,
|
||||
trigger=CronTrigger(day_of_week="sun", hour=4, minute=40),
|
||||
id="msrc_refresh_weekly",
|
||||
name="Weekly MSRC Remediation Enrichment",
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
# Nightly MSRC fixed-build scan (05:10 UTC) — Windows-Server OS CVEs whose
|
||||
# FixedBuild is ahead of the host's build. Daily (not weekly) so a Patch
|
||||
# Tuesday lands the next morning instead of days later.
|
||||
scheduler.add_job(
|
||||
msrc_scan_nightly,
|
||||
trigger=CronTrigger(hour=5, minute=10),
|
||||
id="msrc_scan_nightly",
|
||||
name="Nightly MSRC Fixed-Build Scan (Windows OS)",
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
scheduler.start()
|
||||
logger.info(
|
||||
"Background scheduler started (SLA Breach Checker + Threat Intel Refresh + Vulnrichment Nightly + Compliance SCA Nightly + URS Nightly + Audit-Log Prune)"
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
"""
|
||||
Security advisory RSS/Atom feeds → central awareness page.
|
||||
|
||||
Complements the CISA-KEV feed with vendor/CERT advisories that often precede
|
||||
NVD/cvelistV5 publication (tester: newest 7-Zip advisory was on ZDI before
|
||||
either). Feeds are configurable (setting `advisory_feeds_config`); the parsed
|
||||
items are cached in a setting so the page renders instantly and the fetch cost
|
||||
is paid by the scheduler, not the request.
|
||||
|
||||
Default feeds were verified live before shipping:
|
||||
zdi-published / zdi-upcoming — Zero Day Initiative
|
||||
cert-eu — CERT-EU security advisories
|
||||
bsi-wid — BSI / CERT-Bund WID advisories
|
||||
cisco-psirt — Cisco PSIRT (feed emits trailing junk after
|
||||
the XML root → lenient per-item fallback)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import urllib.request
|
||||
import xml.etree.ElementTree as ET
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CONFIG_SETTING = "advisory_feeds_config"
|
||||
CACHE_SETTING = "advisory_feeds_cache"
|
||||
MAX_ITEMS_PER_FEED = 30
|
||||
HTTP_TIMEOUT = 30
|
||||
|
||||
DEFAULT_FEEDS: List[dict] = [
|
||||
{"id": "zdi-published", "name": "Zero Day Initiative — Published",
|
||||
"url": "https://www.zerodayinitiative.com/rss/published/", "enabled": True},
|
||||
{"id": "zdi-upcoming", "name": "Zero Day Initiative — Upcoming",
|
||||
"url": "https://www.zerodayinitiative.com/rss/upcoming/", "enabled": False},
|
||||
{"id": "cert-eu", "name": "CERT-EU Security Advisories",
|
||||
"url": "https://cert.europa.eu/publications/security-advisories-rss", "enabled": True},
|
||||
{"id": "bsi-wid", "name": "BSI / CERT-Bund (WID)",
|
||||
"url": "https://wid.cert-bund.de/content/public/securityAdvisory/rss", "enabled": True},
|
||||
{"id": "cisco-psirt", "name": "Cisco PSIRT Advisories",
|
||||
"url": "https://sec.cloudapps.cisco.com/security/center/rss.x?i=44", "enabled": False},
|
||||
]
|
||||
|
||||
_ATOM = "{http://www.w3.org/2005/Atom}"
|
||||
_TAG_RE = re.compile(r"<[^>]+>")
|
||||
_ITEM_RE = re.compile(r"<item[\s>].*?</item>", re.S | re.I)
|
||||
|
||||
|
||||
def get_feed_config(db: Session) -> List[dict]:
|
||||
"""Configured feeds; defaults when the setting is unset/broken."""
|
||||
from app.models.setting import Setting
|
||||
try:
|
||||
row = db.query(Setting).filter(Setting.key == CONFIG_SETTING).first()
|
||||
if row and row.value:
|
||||
cfg = json.loads(row.value)
|
||||
if isinstance(cfg, list) and cfg:
|
||||
return [f for f in cfg if f.get("url")]
|
||||
except Exception as e:
|
||||
logger.warning("advisory-feeds: config unreadable, using defaults: %s", e)
|
||||
return DEFAULT_FEEDS
|
||||
|
||||
|
||||
def _text(el) -> str:
|
||||
return "" if el is None or el.text is None else el.text.strip()
|
||||
|
||||
|
||||
def _first(node, *tags):
|
||||
for t in tags:
|
||||
el = node.find(t)
|
||||
if el is not None:
|
||||
return el
|
||||
return None
|
||||
|
||||
|
||||
_DTD_RE = re.compile(rb"<!(?:DOCTYPE|ENTITY)", re.I)
|
||||
|
||||
|
||||
def _parse_items(raw: bytes) -> List[dict]:
|
||||
"""RSS <item> / Atom <entry> → dicts. Falls back to per-item regex slicing
|
||||
for feeds that emit junk after the XML root (Cisco).
|
||||
|
||||
Security: any DOCTYPE/ENTITY declaration is rejected outright — legitimate
|
||||
feeds never need DTDs, and refusing them shuts down XXE and billion-laughs
|
||||
entity-expansion attacks without pulling in defusedxml."""
|
||||
if _DTD_RE.search(raw):
|
||||
raise ValueError("feed contains DOCTYPE/ENTITY declarations — refused")
|
||||
try:
|
||||
root = ET.fromstring(raw)
|
||||
nodes = root.findall(".//item") or root.findall(f".//{_ATOM}entry")
|
||||
except ET.ParseError:
|
||||
text = raw.decode("utf-8", "replace")
|
||||
nodes = []
|
||||
for m in _ITEM_RE.findall(text):
|
||||
try:
|
||||
nodes.append(ET.fromstring(m))
|
||||
except ET.ParseError:
|
||||
continue
|
||||
items = []
|
||||
for n in nodes[: MAX_ITEMS_PER_FEED * 2]:
|
||||
title = _text(_first(n, "title", f"{_ATOM}title"))
|
||||
link_el = _first(n, "link", f"{_ATOM}link")
|
||||
link = _text(link_el)
|
||||
if not link and link_el is not None: # Atom: href attribute
|
||||
link = (link_el.get("href") or "").strip()
|
||||
date = _text(_first(n, "pubDate", f"{_ATOM}updated", f"{_ATOM}published", "dc:date"))
|
||||
summary = _TAG_RE.sub(" ", _text(_first(n, "description", f"{_ATOM}summary")))[:400].strip()
|
||||
if title:
|
||||
items.append({"title": title[:300], "link": link[:1000],
|
||||
"date": date[:64], "summary": summary})
|
||||
if len(items) >= MAX_ITEMS_PER_FEED:
|
||||
break
|
||||
return items
|
||||
|
||||
|
||||
def refresh_feeds(db: Session) -> dict:
|
||||
"""Fetch every ENABLED feed, cache the parsed items. Per-feed errors are
|
||||
recorded on the feed (page shows them) and never fail the run."""
|
||||
from app.models.setting import Setting
|
||||
out = {"fetched_at": datetime.now().isoformat(), "feeds": []}
|
||||
ok = failed = 0
|
||||
for f in get_feed_config(db):
|
||||
entry = {"id": f.get("id"), "name": f.get("name") or f.get("id"),
|
||||
"url": f["url"], "enabled": bool(f.get("enabled")),
|
||||
"items": [], "error": None}
|
||||
if entry["enabled"]:
|
||||
try:
|
||||
raw = urllib.request.urlopen(
|
||||
urllib.request.Request(f["url"], headers={"User-Agent": "truevuln-feed/1.0"}),
|
||||
timeout=HTTP_TIMEOUT).read()
|
||||
entry["items"] = _parse_items(raw)
|
||||
ok += 1
|
||||
except Exception as e:
|
||||
entry["error"] = f"{type(e).__name__}: {e}"[:200]
|
||||
failed += 1
|
||||
logger.warning("advisory-feeds: %s failed: %s", f.get("id"), e)
|
||||
out["feeds"].append(entry)
|
||||
|
||||
payload = json.dumps(out)
|
||||
row = db.query(Setting).filter(Setting.key == CACHE_SETTING).first()
|
||||
if row:
|
||||
row.value = payload
|
||||
else:
|
||||
db.add(Setting(key=CACHE_SETTING, value=payload,
|
||||
description="Cached security-advisory feed items"))
|
||||
db.commit()
|
||||
logger.info("advisory-feeds: refreshed (%d ok, %d failed)", ok, failed)
|
||||
return {"ok": ok, "failed": failed, "feeds": len(out["feeds"])}
|
||||
|
||||
|
||||
def get_cached_feeds(db: Session) -> Optional[dict]:
|
||||
from app.models.setting import Setting
|
||||
row = db.query(Setting).filter(Setting.key == CACHE_SETTING).first()
|
||||
if not row or not row.value:
|
||||
return None
|
||||
try:
|
||||
return json.loads(row.value)
|
||||
except Exception:
|
||||
return None
|
||||
@@ -0,0 +1,66 @@
|
||||
"""
|
||||
Security-advisory awareness feed.
|
||||
|
||||
Independent of asset findings: a rolling view of what's being actively
|
||||
exploited in the wild (CISA KEV), so operators see 0-days/exploited CVEs even
|
||||
when no scanner has flagged an affected asset yet. Each entry is annotated
|
||||
with whether we already have that CVE in inventory (and on how many assets).
|
||||
|
||||
Reuses the KEV catalog enrichment already fetches + caches (24h).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _parse_date(s) -> Optional[datetime]:
|
||||
try:
|
||||
return datetime.strptime(str(s)[:10], "%Y-%m-%d")
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def get_recent_kev(db: Session, limit: int = 20) -> List[dict]:
|
||||
"""Most recently added CISA KEV entries, newest first, annotated with our
|
||||
inventory status. Returns [] on fetch failure (awareness is best-effort)."""
|
||||
from app.services.enrichment_service import fetch_kev_catalog
|
||||
try:
|
||||
kev = fetch_kev_catalog(db)
|
||||
except Exception as e:
|
||||
logger.warning("advisory: KEV fetch failed: %s", e)
|
||||
return []
|
||||
|
||||
rows = []
|
||||
for cve, e in kev.items():
|
||||
rows.append({
|
||||
"cve_id": cve,
|
||||
"vendor": e.get("vendor"),
|
||||
"product": e.get("product"),
|
||||
"name": e.get("name"),
|
||||
"date_added": e.get("date_added"),
|
||||
"ransomware": bool(e.get("ransomware_use")),
|
||||
"description": e.get("short_description"),
|
||||
})
|
||||
rows.sort(key=lambda r: (_parse_date(r["date_added"]) or datetime.min), reverse=True)
|
||||
rows = rows[:limit]
|
||||
|
||||
# Annotate with inventory presence in one query.
|
||||
from app.models.vulnerability import Vulnerability
|
||||
cves = [r["cve_id"] for r in rows]
|
||||
counts = {}
|
||||
if cves:
|
||||
q = (db.query(Vulnerability.cve_id, func.count(func.distinct(Vulnerability.asset_id)))
|
||||
.filter(Vulnerability.cve_id.in_(cves))
|
||||
.group_by(Vulnerability.cve_id))
|
||||
counts = {cve: n for cve, n in q.all()}
|
||||
for r in rows:
|
||||
r["asset_count"] = int(counts.get(r["cve_id"], 0))
|
||||
r["in_inventory"] = r["asset_count"] > 0
|
||||
return rows
|
||||
@@ -0,0 +1,193 @@
|
||||
"""
|
||||
AI remediation service (OpenRouter, OpenAI-compatible).
|
||||
|
||||
On-demand generator that turns a vulnerability + its host context into
|
||||
concrete, OS-aware remediation steps. Uses OpenRouter's OpenAI-compatible
|
||||
REST API directly via httpx (no extra SDK dependency).
|
||||
|
||||
Config (env first, then settings table, so it works headless or via UI):
|
||||
OPENROUTER_API_KEY — required to enable the feature
|
||||
OPENROUTER_MODEL — default "openrouter/free" (free auto-router)
|
||||
OPENROUTER_FALLBACKS — optional comma list for route=fallback
|
||||
|
||||
Free tier: ~10 requests/day across free models — fine for on-demand use.
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
from typing import List, Optional
|
||||
|
||||
import httpx
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.setting import Setting
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
|
||||
DEFAULT_MODEL = "openrouter/free"
|
||||
HTTP_TIMEOUT = 60.0
|
||||
|
||||
SETTING_KEY = "openrouter_api_key"
|
||||
SETTING_MODEL = "openrouter_model"
|
||||
SETTING_ENABLED = "ai_remediation_enabled"
|
||||
|
||||
|
||||
class AIServiceError(Exception):
|
||||
"""Raised when AI remediation cannot be produced."""
|
||||
|
||||
|
||||
def _cfg(db: Session, env_name: str, setting_key: str, default: str = "") -> str:
|
||||
val = os.getenv(env_name, "").strip()
|
||||
if val:
|
||||
return val
|
||||
try:
|
||||
from app.auth.setting_crypto import read_setting_value
|
||||
sv = read_setting_value(db, setting_key)
|
||||
if sv:
|
||||
return str(sv).strip()
|
||||
except Exception:
|
||||
s = db.query(Setting).filter(Setting.key == setting_key).first()
|
||||
if s and s.value:
|
||||
return str(s.value).strip()
|
||||
return default
|
||||
|
||||
|
||||
def is_enabled(db: Session) -> bool:
|
||||
"""True when an API key is configured (env or settings)."""
|
||||
return bool(_cfg(db, "OPENROUTER_API_KEY", SETTING_KEY))
|
||||
|
||||
|
||||
def _build_messages(*, cve_id, title, description, package, installed,
|
||||
fixed, os_name, scanner_remediation) -> List[dict]:
|
||||
sys = (
|
||||
"You are a senior security engineer. Given a vulnerability and the "
|
||||
"affected host, produce concise, ACTIONABLE remediation guidance for "
|
||||
"the specific operating system. Prefer concrete commands in fenced "
|
||||
"code blocks (apt/dnf/zypper for Linux distros, PowerShell/winget/MSI "
|
||||
"for Windows). Include: 1) the fix (upgrade/patch/config), 2) exact "
|
||||
"commands for THIS OS, 3) a verification step, 4) a mitigation if no "
|
||||
"patch is available. Be brief — no preamble, no marketing."
|
||||
)
|
||||
# EOL/EOS pseudo-findings (cve_id starts with EOL- / NESSUS-PLUGIN-) are
|
||||
# not patchable CVEs — there is no fix, the product is out of support.
|
||||
# Give the model EOL-specific instructions so the answer is an upgrade/
|
||||
# replacement plan, not a "apply the patch" hallucination.
|
||||
is_eol = bool(cve_id) and (
|
||||
cve_id.upper().startswith("EOL-") or cve_id.upper().startswith("NESSUS-PLUGIN-")
|
||||
)
|
||||
if is_eol:
|
||||
sys = (
|
||||
"You are a senior IT-security engineer advising on END-OF-LIFE / "
|
||||
"END-OF-SUPPORT (EOL/EOS) software. The product below no longer "
|
||||
"receives security patches — there is NO CVE patch to apply, so do "
|
||||
"NOT suggest 'apply the update'. Produce a concise upgrade/migration "
|
||||
"plan for THIS operating system:\n"
|
||||
"1) State the EOL/EOS situation and the risk of staying on it.\n"
|
||||
"2) The supported target release/edition to move to (name the "
|
||||
"current supported version and its support timeline if known).\n"
|
||||
"3) Concrete upgrade/replace commands for THIS OS (apt/dnf/zypper "
|
||||
"dist-upgrade or repo swap on Linux; winget/MSI/installer or OS "
|
||||
"in-place upgrade on Windows), plus where to download the supported "
|
||||
"build.\n"
|
||||
"4) Interim COMPENSATING CONTROLS / containment while the upgrade is "
|
||||
"pending (network isolation/segmentation, restrict exposure, disable "
|
||||
"the component, WAF/firewall rules, increased monitoring).\n"
|
||||
"5) A verification step. Be brief, no preamble."
|
||||
)
|
||||
lines = [
|
||||
("EOL/EOS finding ID: " if is_eol else "CVE / ID: ") + str(cve_id),
|
||||
f"Title: {title or '—'}",
|
||||
f"Affected OS: {os_name or 'unknown'}",
|
||||
f"Product / package: {package or '—'}",
|
||||
f"Installed version: {installed or '—'}",
|
||||
(f"Latest supported version: {fixed or '—'}" if is_eol
|
||||
else f"Fixed version: {fixed or '—'}"),
|
||||
]
|
||||
if scanner_remediation:
|
||||
lines.append(f"Scanner-suggested remediation: {scanner_remediation}")
|
||||
if description:
|
||||
lines.append(f"\nDescription:\n{description[:1500]}")
|
||||
if is_eol:
|
||||
lines.append(
|
||||
"\nThis is an end-of-life / out-of-support product, NOT a patchable "
|
||||
"CVE. Give the upgrade/migration plan + interim compensating controls."
|
||||
)
|
||||
else:
|
||||
lines.append("\nGive the remediation now.")
|
||||
return [
|
||||
{"role": "system", "content": sys},
|
||||
{"role": "user", "content": "\n".join(lines)},
|
||||
]
|
||||
|
||||
|
||||
def generate_remediation(
|
||||
db: Session,
|
||||
*,
|
||||
cve_id: str,
|
||||
title: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
package: Optional[str] = None,
|
||||
installed: Optional[str] = None,
|
||||
fixed: Optional[str] = None,
|
||||
os_name: Optional[str] = None,
|
||||
scanner_remediation: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""Call OpenRouter and return {"content": str, "model": str}.
|
||||
|
||||
Synchronous + blocking — the caller must run it OFF the event loop
|
||||
(asyncio.to_thread) so it never freezes the GUI.
|
||||
"""
|
||||
api_key = _cfg(db, "OPENROUTER_API_KEY", SETTING_KEY)
|
||||
if not api_key:
|
||||
raise AIServiceError(
|
||||
"OpenRouter not configured — set OPENROUTER_API_KEY (env or Settings)."
|
||||
)
|
||||
model = _cfg(db, "OPENROUTER_MODEL", SETTING_MODEL, DEFAULT_MODEL)
|
||||
fallbacks = _cfg(db, "OPENROUTER_FALLBACKS", "openrouter_fallbacks", "")
|
||||
|
||||
body = {
|
||||
"model": model,
|
||||
"messages": _build_messages(
|
||||
cve_id=cve_id, title=title, description=description, package=package,
|
||||
installed=installed, fixed=fixed, os_name=os_name,
|
||||
scanner_remediation=scanner_remediation,
|
||||
),
|
||||
}
|
||||
if fallbacks:
|
||||
models = [model] + [m.strip() for m in fallbacks.split(",") if m.strip()]
|
||||
body["models"] = models
|
||||
body["route"] = "fallback"
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
# OpenRouter attribution headers (optional but recommended).
|
||||
"HTTP-Referer": "https://truevuln.local",
|
||||
"X-Title": "TrueVuln",
|
||||
}
|
||||
try:
|
||||
with httpx.Client(timeout=HTTP_TIMEOUT) as client:
|
||||
resp = client.post(OPENROUTER_URL, headers=headers, json=body)
|
||||
except httpx.HTTPError as e:
|
||||
raise AIServiceError(f"OpenRouter request failed: {e}") from e
|
||||
|
||||
if resp.status_code == 401:
|
||||
raise AIServiceError("OpenRouter rejected the API key (401).")
|
||||
if resp.status_code == 402:
|
||||
raise AIServiceError(
|
||||
"OpenRouter quota/credits exhausted (402) — free-tier daily cap hit "
|
||||
"or a paid model needs credits."
|
||||
)
|
||||
if resp.status_code >= 400:
|
||||
raise AIServiceError(f"OpenRouter error {resp.status_code}: {resp.text[:300]}")
|
||||
|
||||
try:
|
||||
data = resp.json()
|
||||
content = data["choices"][0]["message"]["content"]
|
||||
used_model = data.get("model", model)
|
||||
except (KeyError, IndexError, ValueError) as e:
|
||||
raise AIServiceError(f"Unexpected OpenRouter response shape: {e}") from e
|
||||
|
||||
if not content or not content.strip():
|
||||
raise AIServiceError("OpenRouter returned an empty response.")
|
||||
return {"content": content.strip(), "model": used_model}
|
||||
@@ -0,0 +1,265 @@
|
||||
"""
|
||||
Android per-CVE detection from the Google Android Security Bulletin (ASB).
|
||||
|
||||
For an Intune-managed Android device we know its security patch level
|
||||
(androidSecurityPatchLevel, e.g. "2025-03-01"). Every monthly ASB published
|
||||
AFTER that level lists CVEs the device has NOT yet received. We fetch those
|
||||
months from source.android.com (stable, static, per-month URLs), extract the
|
||||
CVEs + severity, and raise real-CVE findings (source 'android-asb').
|
||||
|
||||
Why ASB and not Samsung's SMR page: Samsung's securityUpdate.smsb ignores the
|
||||
year/month query param and loads the month via JS, so a plain fetch can't get
|
||||
a historical month. ASB is the upstream source for the Google CVEs Samsung
|
||||
ships (the security-critical bulk) and is cleanly scrapeable. Samsung-
|
||||
proprietary SVE CVEs are not covered (their page is unscrapeable).
|
||||
|
||||
Scope guards (volume): Critical + High only, last _MAX_MONTHS months.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import date, datetime
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_ASB_BASE = "https://source.android.com/docs/security/bulletin"
|
||||
|
||||
|
||||
def _asb_urls(month: str) -> List[str]:
|
||||
"""Candidate ASB URLs for a month slug. From 2026 Google nests the page
|
||||
under a year segment (/bulletin/2026/2026-01-01); older months are flat
|
||||
(/bulletin/2025-10-01). Try the year-nested form first, then flat, so we
|
||||
survive whichever format applies (and future shifts)."""
|
||||
year = month[:4]
|
||||
return [f"{_ASB_BASE}/{year}/{month}", f"{_ASB_BASE}/{month}"]
|
||||
_CACHE_PREFIX = "asb_month_v2_" # v2: bumped when the URL/section-filter
|
||||
# logic changed, so stale cache entries
|
||||
# from before those fixes are ignored
|
||||
# and every month is refetched fresh.
|
||||
_MAX_MONTHS = 12 # cap lookback so a very stale device can't flood
|
||||
_WANT_SEV = {"critical", "high"} # actionable severities only
|
||||
_CVE_RE = re.compile(r"CVE-\d{4}-\d{4,7}")
|
||||
_SEV_RE = re.compile(r">(Critical|High|Moderate|Low)<")
|
||||
# Section headers + rows, in document order, so each CVE is scoped to its
|
||||
# ASB section.
|
||||
_TOKEN_RE = re.compile(r"<h[23][^>]*>(.*?)</h[23]>|<tr[^>]*>(.*?)</tr>", re.S)
|
||||
# SoC / third-party vendor sections — those CVEs only affect devices with that
|
||||
# chipset (Qualcomm/MediaTek/etc.), so importing them onto every Android device
|
||||
# produces false positives (Samsung's own SMR lists many as "Not applicable").
|
||||
# We keep only the AOSP sections (Framework/System/Kernel/Runtime/Media/Play/
|
||||
# Widevine) that apply to any Android device at that patch level.
|
||||
_SOC_SECTION = re.compile(
|
||||
r"qualcomm|mediatek|unisoc|spreadtrum|imagination|arm component|"
|
||||
r"broadcom|nvidia|marvell|kryo|adreno", re.I)
|
||||
|
||||
|
||||
def _parse_patch_month(raw) -> Optional[Tuple[int, int]]:
|
||||
"""androidSecurityPatchLevel 'YYYY-MM-DD' → (year, month)."""
|
||||
m = re.match(r"(\d{4})-(\d{2})", str(raw or ""))
|
||||
if not m:
|
||||
return None
|
||||
return int(m.group(1)), int(m.group(2))
|
||||
|
||||
|
||||
def _months_after(year: int, month: int, today: date) -> List[str]:
|
||||
"""ASB month slugs ('YYYY-MM-01') strictly after (year,month) up to today,
|
||||
newest first, capped at _MAX_MONTHS."""
|
||||
out = []
|
||||
y, mo = year, month
|
||||
while True:
|
||||
mo += 1
|
||||
if mo > 12:
|
||||
mo = 1
|
||||
y += 1
|
||||
if (y, mo) > (today.year, today.month):
|
||||
break
|
||||
out.append(f"{y:04d}-{mo:02d}-01")
|
||||
return out[-_MAX_MONTHS:][::-1]
|
||||
|
||||
|
||||
def _parse_asb_html(html: str) -> List[Tuple[str, str]]:
|
||||
"""→ [(cve, severity)] from one ASB page, walking section headers + rows in
|
||||
order. CVEs under SoC/vendor sections (chipset-specific) are skipped so we
|
||||
don't false-positive them onto devices with a different SoC. Severity
|
||||
carries forward across rowspan rows (Android merges the severity cell)."""
|
||||
out: List[Tuple[str, str]] = []
|
||||
seen: set = set()
|
||||
last_sev = "High"
|
||||
skip = False
|
||||
for m in _TOKEN_RE.finditer(html):
|
||||
if m.group(1) is not None: # section header
|
||||
last_sev = "High"
|
||||
skip = bool(_SOC_SECTION.search(re.sub(r"<[^>]+>", "", m.group(1))))
|
||||
continue
|
||||
cell = m.group(2)
|
||||
cm = _CVE_RE.search(cell)
|
||||
if not cm:
|
||||
continue
|
||||
sm = _SEV_RE.search(cell)
|
||||
if sm:
|
||||
last_sev = sm.group(1)
|
||||
if skip:
|
||||
continue # SoC/vendor section → chipset-specific, not universal
|
||||
cve = cm.group(0).upper()
|
||||
if cve in seen:
|
||||
continue
|
||||
seen.add(cve)
|
||||
out.append((cve, last_sev.lower()))
|
||||
return out
|
||||
|
||||
|
||||
# A month with CVEs is immutable → cache forever. An empty/404 month (a
|
||||
# future month Google hasn't published yet) is negatively cached for this long
|
||||
# so we don't re-fetch it on every device sync, but still pick it up once it
|
||||
# goes live.
|
||||
_NEG_TTL_DAYS = 3
|
||||
|
||||
|
||||
def _cache_read(db: Session, key: str):
|
||||
"""→ (pairs, is_fresh). pairs may be []; is_fresh False means refetch."""
|
||||
from app.models.setting import Setting
|
||||
row = db.query(Setting).filter(Setting.key == key).first()
|
||||
if not row or not row.value:
|
||||
return None, False
|
||||
try:
|
||||
blob = json.loads(row.value)
|
||||
except Exception:
|
||||
return None, False
|
||||
if isinstance(blob, list): # legacy positive entry
|
||||
return [tuple(x) for x in blob], True
|
||||
pairs = [tuple(x) for x in (blob.get("pairs") or [])]
|
||||
if pairs:
|
||||
return pairs, True # non-empty is immutable
|
||||
try:
|
||||
ts = datetime.fromisoformat(blob.get("ts"))
|
||||
except Exception:
|
||||
return [], False
|
||||
return [], (datetime.now() - ts).days < _NEG_TTL_DAYS
|
||||
|
||||
|
||||
def _cache_write(db: Session, key: str, month: str, pairs: list) -> None:
|
||||
from app.models.setting import Setting
|
||||
payload = json.dumps({"ts": datetime.now().isoformat(), "pairs": pairs})
|
||||
row = db.query(Setting).filter(Setting.key == key).first()
|
||||
if row:
|
||||
row.value = payload
|
||||
else:
|
||||
db.add(Setting(key=key, value=payload,
|
||||
description=f"Android Security Bulletin {month} (cve,severity)"))
|
||||
db.commit()
|
||||
|
||||
|
||||
def fetch_asb_month(db: Session, month: str) -> List[Tuple[str, str]]:
|
||||
"""Cached fetch+parse of one ASB month. Empty/404 months are negatively
|
||||
cached (short TTL) so a future month Google hasn't published yet doesn't
|
||||
trigger a re-fetch on every device sync."""
|
||||
key = _CACHE_PREFIX + month
|
||||
pairs, fresh = _cache_read(db, key)
|
||||
if fresh:
|
||||
return pairs
|
||||
import httpx
|
||||
pairs = []
|
||||
try:
|
||||
with httpx.Client(timeout=30.0, follow_redirects=True,
|
||||
headers={"User-Agent": "TrueVuln/1.0"}) as c:
|
||||
for url in _asb_urls(month):
|
||||
r = c.get(url)
|
||||
if r.status_code == 200:
|
||||
pairs = _parse_asb_html(r.text)
|
||||
break
|
||||
except Exception as e:
|
||||
logger.debug("ASB fetch failed for %s: %s", month, e)
|
||||
pairs = []
|
||||
_cache_write(db, key, month, pairs) # cache empties too (negative cache)
|
||||
return pairs
|
||||
|
||||
|
||||
def _upsert(db: Session, asset, month: str, cve: str, sev: str, new_ids: list,
|
||||
source: str = "android-asb", label: str = "ASB") -> None:
|
||||
from app.models.vulnerability import Vulnerability, VulnerabilitySeverity, VulnerabilityStatus
|
||||
cve_id = cve.upper()
|
||||
existing = (db.query(Vulnerability)
|
||||
.filter(Vulnerability.cve_id == cve_id, Vulnerability.asset_id == asset.id)
|
||||
.first())
|
||||
if existing:
|
||||
existing.add_source(source)
|
||||
if not existing.package_name:
|
||||
existing.package_name = f"Android ({label} {month[:7]})"[:255]
|
||||
if existing.status == VulnerabilityStatus.patched:
|
||||
existing.status = VulnerabilityStatus.open
|
||||
existing.patched_at = None
|
||||
try:
|
||||
existing.refresh_scores()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
sevmap = {"critical": VulnerabilitySeverity.critical, "high": VulnerabilitySeverity.high,
|
||||
"moderate": VulnerabilitySeverity.medium, "low": VulnerabilitySeverity.low}
|
||||
row = Vulnerability(
|
||||
cve_id=cve_id, asset_id=asset.id, severity=sevmap.get(sev, VulnerabilitySeverity.high),
|
||||
status=VulnerabilityStatus.open,
|
||||
title=f"Android {month[:7]} security patch — {cve_id}"[:500],
|
||||
package_name=f"Android ({label} {month[:7]})"[:255],
|
||||
package_version=(asset.os_version or "")[:100] or None,
|
||||
detected_at=datetime.now(),
|
||||
sources=json.dumps([source]), first_detected_by=source,
|
||||
)
|
||||
db.add(row)
|
||||
db.flush()
|
||||
try:
|
||||
row.refresh_scores()
|
||||
except Exception:
|
||||
pass
|
||||
new_ids.append(row.id)
|
||||
|
||||
|
||||
def check_android_cves(db: Session, asset, patch_level, manufacturer: str = "",
|
||||
new_ids: Optional[list] = None) -> int:
|
||||
"""Raise findings for the months the device is behind on. Critical+High
|
||||
only, last _MAX_MONTHS months.
|
||||
|
||||
Samsung devices: prefer security.samsungmobile.com's own SMR page per
|
||||
month (excludes chipset CVEs Samsung says don't apply — avoids false
|
||||
positives the raw ASB would produce, e.g. a Qualcomm-only CVE on a
|
||||
Samsung device with a different SoC). Falls back to raw ASB (source
|
||||
'android-asb') for any month the SMR page doesn't cover or fails to
|
||||
fetch, and for all non-Samsung Android devices.
|
||||
"""
|
||||
if new_ids is None:
|
||||
new_ids = []
|
||||
ym = _parse_patch_month(patch_level)
|
||||
if not ym:
|
||||
return 0
|
||||
months = _months_after(ym[0], ym[1], date.today())
|
||||
is_samsung = "samsung" in (manufacturer or "").lower()
|
||||
count = 0
|
||||
for month in months:
|
||||
pairs = None
|
||||
if is_samsung:
|
||||
try:
|
||||
from app.services import samsung_smr_service
|
||||
y, m = int(month[:4]), int(month[5:7])
|
||||
pairs = samsung_smr_service.get_smr_month(db, y, m)
|
||||
except Exception as e:
|
||||
logger.debug("Samsung SMR lookup failed for %s: %s", month, e)
|
||||
pairs = None
|
||||
if pairs is not None:
|
||||
source, label = "samsung-smr", "SMR"
|
||||
else:
|
||||
pairs = fetch_asb_month(db, month)
|
||||
source, label = "android-asb", "ASB"
|
||||
for cve, sev in pairs:
|
||||
if sev not in _WANT_SEV:
|
||||
continue
|
||||
before = len(new_ids)
|
||||
try:
|
||||
_upsert(db, asset, month, cve, sev, new_ids, source=source, label=label)
|
||||
count += 1 if len(new_ids) > before else 0
|
||||
except Exception as e:
|
||||
logger.debug("%s upsert failed (%s on %s): %s", source, cve, asset.id, e)
|
||||
return count
|
||||
@@ -0,0 +1,754 @@
|
||||
"""
|
||||
Built-in app→CVE scanner.
|
||||
|
||||
Maps installed software (Wazuh syscollector packages + Intune detectedApps)
|
||||
to CVEs so assets without a real scanner (Intune-only / mobile, or any app
|
||||
no scanner covers) still get findings.
|
||||
|
||||
Design: CURATED + PRECISE (low false-positives).
|
||||
- Only a curated product registry is matched — unknown app names are
|
||||
ignored (no CPE auto-guessing → no FP storm).
|
||||
- Per product: query OSV (language/OSS ecosystems, server-side version
|
||||
match) or NVD-CPE (desktop apps), then verify the installed version
|
||||
actually falls inside the CVE's affected version range ourselves.
|
||||
- Results cached per (product_key, version) in app_cve_cache (TTL) so the
|
||||
same Chrome version across N hosts = one query (and NVD rate limit).
|
||||
- Findings are upserted as source 'app-scan' with REAL CVE ids → the
|
||||
normal EPSS/KEV/CVSS enrichment + multi-source remediation apply, and
|
||||
they cross-confirm with Wazuh/Nessus/Defender on the same (cve, asset).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
import httpx
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
OSV_QUERY_URL = "https://api.osv.dev/v1/query"
|
||||
NVD_CVE_API = "https://services.nvd.nist.gov/rest/json/cves/2.0"
|
||||
HTTP_TIMEOUT = 30.0
|
||||
# Was 7 days — but a (product,version) queried BEFORE a new CVE for that
|
||||
# exact version is published stays cached empty for the whole window,
|
||||
# hiding the CVE from every host on that version until it expires (tester:
|
||||
# CVE-2026-14152 undetected while a same-day sibling CVE was). 24h still
|
||||
# collapses most redundant NVD/OSV traffic (many hosts share a version).
|
||||
CACHE_TTL = timedelta(hours=24)
|
||||
NVD_SLEEP_NO_KEY = 6.5
|
||||
NVD_SLEEP_WITH_KEY = 0.7
|
||||
|
||||
# Curated registry: (compiled name regex, entry). First match wins.
|
||||
# entry: {"key", "kind": "cpe"|"osv", "cpe"? "a:vendor:product", "eco"?, "oname"?}
|
||||
def _cpe(name_re: str, vendor_product: str, name_ver: bool = False) -> tuple:
|
||||
e = {"key": f"cpe:{vendor_product}", "kind": "cpe",
|
||||
"cpe": f"cpe:2.3:a:{vendor_product}"}
|
||||
if name_ver:
|
||||
# Take the version from the software NAME, not the package version
|
||||
# field. Some products (e.g. .NET Runtime) report an MSI build number
|
||||
# (48.x, 94.x) in the version field while the real semantic version
|
||||
# (6.0.16) lives in the display name — the field version never matches
|
||||
# NVD's 6.0.x/8.0.x ranges, so the CVE was silently missed.
|
||||
e["name_ver"] = True
|
||||
return (re.compile(name_re, re.I), e)
|
||||
|
||||
_REGISTRY: List[tuple] = [
|
||||
# "Google Chrome" (Wazuh/Windows) + "com.android.chrome" (Intune Android
|
||||
# package id). Android Chrome shares version numbers AND security fixes with
|
||||
# Desktop (Google: "Android releases contain the same security fixes"), so
|
||||
# the same google:chrome ranges apply. iOS Chrome is WebKit-backed and
|
||||
# reported as bare "Chrome" — deliberately NOT matched (Blink CVEs N/A).
|
||||
_cpe(r"google chrome|com\.android\.chrome", "google:chrome"),
|
||||
_cpe(r"microsoft edge(?!.*webview)", "microsoft:edge_chromium"),
|
||||
_cpe(r"mozilla firefox|(?<!\w)firefox", "mozilla:firefox"),
|
||||
_cpe(r"thunderbird", "mozilla:thunderbird"),
|
||||
_cpe(r"acrobat reader|adobe acrobat reader", "adobe:acrobat_reader_dc"),
|
||||
_cpe(r"adobe acrobat(?!.*reader)", "adobe:acrobat_dc"),
|
||||
_cpe(r"7-?zip", "7-zip:7-zip"),
|
||||
_cpe(r"notepad\+\+", "notepad-plus-plus:notepad-plus-plus"),
|
||||
_cpe(r"vlc media player|videolan", "videolan:vlc_media_player"),
|
||||
_cpe(r"(?<!\w)putty", "putty:putty"),
|
||||
_cpe(r"winscp", "winscp:winscp"),
|
||||
_cpe(r"wireshark", "wireshark:wireshark"),
|
||||
# Exclude the FIPS provider/module builds (e.g. Veeam ships "OpenSSL v3.0.0
|
||||
# FIPS"): OpenSSL advisories explicitly carve the FIPS modules OUT of most
|
||||
# CVEs (the vulnerable code is outside the FIPS boundary), and they carry a
|
||||
# separate 4-part build version that doesn't map to NVD's ranges anyway →
|
||||
# matching them is a false positive (tester: CVE-2025-15467).
|
||||
_cpe(r"openssl(?!.*fips)", "openssl:openssl"),
|
||||
_cpe(r"openvpn", "openvpn:openvpn"),
|
||||
_cpe(r"node\.?js", "nodejs:node.js"),
|
||||
_cpe(r"(?<!\w)python(?!.*launcher)", "python:python"),
|
||||
_cpe(r"teamviewer", "teamviewer:teamviewer"),
|
||||
_cpe(r"(?<!\w)zoom(?!\w)", "zoom:zoom"),
|
||||
_cpe(r"libreoffice", "libreoffice:libreoffice"),
|
||||
_cpe(r"filezilla", "filezilla:filezilla"),
|
||||
_cpe(r"(?<!\w)git(?: for windows| version control)", "git:git"),
|
||||
_cpe(r"oracle vm virtualbox|virtualbox", "oracle:vm_virtualbox"),
|
||||
_cpe(r"(?<!\w)gimp(?!\w)", "gimp:gimp"),
|
||||
# .NET (6/7/8/9) and .NET Framework share the SAME NVD CPE
|
||||
# (microsoft:.net) — NVD scopes each major version's fix range via its own
|
||||
# cpeMatch entry under that one product, so one registry row covers both.
|
||||
# The semantic version lives in the NAME ("… - 6.0.16 (x64)"), not the
|
||||
# version field (an MSI build number) → name_ver=True. The developer
|
||||
# reference packs (Targeting Pack / Multi-Targeting Pack / Framework SDK)
|
||||
# are NOT the runtime; excluded via lookahead so their version doesn't
|
||||
# false-positive against a runtime CVE range. (Bare ".NET Framework" the
|
||||
# runtime is a Windows feature, often not a syscollector package anyway.)
|
||||
_cpe(r"microsoft\s+\.net\s+(?:desktop\s+)?(?:runtime|host|sdk)\b"
|
||||
r"|microsoft\s+\.net\s+framework(?!.*(?:targeting|sdk|client))\s+[\d.]+",
|
||||
"microsoft:.net", name_ver=True),
|
||||
# Only Teams itself — NOT the Office add-in, the VDI/Citrix plugin, or the
|
||||
# machine-wide installer (separate products with their own versioning that
|
||||
# would false-positive against Teams-app CVE ranges).
|
||||
_cpe(r"microsoft teams(?!.*(machine-wide|add-in|plugin|vdi|citrix))", "microsoft:teams"),
|
||||
# OSV ecosystem examples (rarely in desktop inventory, but supported):
|
||||
(re.compile(r"^node-(.+)$", re.I), {"key": "osv:npm", "kind": "osv", "eco": "npm"}),
|
||||
]
|
||||
|
||||
|
||||
# OS-level CPEs. Apple ships the precise OS version (e.g. 18.1.2) and NVD
|
||||
# carries proper version ranges for it → a clean CPE-range check, same as the
|
||||
# desktop apps. Android is deliberately absent: NVD only lists the base
|
||||
# version (13/14/15) without ranges, so it needs the Intune security-patch
|
||||
# level + Android bulletin parsing — a separate feature.
|
||||
# ponytail: iOS/iPadOS only; add Android when the patch-level path is built.
|
||||
# Windows OS CVEs are handled by cvelistv5_scan_service.scan_asset_os(), not
|
||||
# from here. Modern Microsoft CVE records DO carry real build ranges (e.g.
|
||||
# CVE-2026-47291: 20/20 affected entries with a numeric lessThan) — but NVD
|
||||
# flattens them to an END bound only (versionEndExcluding, no start), so a
|
||||
# 1607 host (14393.x) would fall inside the 22H2 range (endExcluding
|
||||
# 19045.7417) and false-positive. cvelistV5 keeps the range BOUNDED
|
||||
# (version 10.0.22631.0 .. lessThan 10.0.22631.7219), and those bounds pick the
|
||||
# host's release on their own — which is why the OS path lives there.
|
||||
_OS_REGISTRY: List[tuple] = [
|
||||
(re.compile(r"ipad", re.I), {"key": "cpe:apple:ipados",
|
||||
"cpe": "cpe:2.3:o:apple:ipados", "label": "Apple iPadOS"}),
|
||||
(re.compile(r"ios|iphone", re.I), {"key": "cpe:apple:iphone_os",
|
||||
"cpe": "cpe:2.3:o:apple:iphone_os", "label": "Apple iOS"}),
|
||||
(re.compile(r"mac ?os|macos|mac_os|os x", re.I), {"key": "cpe:apple:macos",
|
||||
"cpe": "cpe:2.3:o:apple:macos", "label": "Apple macOS"}),
|
||||
]
|
||||
|
||||
|
||||
_NAME_VER_RE = re.compile(r"(\d+\.\d+(?:\.\d+)*)")
|
||||
|
||||
|
||||
def _effective_version(name: str, version: str, entry: dict) -> str:
|
||||
"""Version to scan with. For name_ver products the real semantic version
|
||||
is in the display name, not the (MSI-build) version field."""
|
||||
if entry.get("name_ver"):
|
||||
m = _NAME_VER_RE.search(name or "")
|
||||
if m:
|
||||
return m.group(1)
|
||||
return version
|
||||
|
||||
|
||||
def _resolve_os(os_name: str) -> Optional[dict]:
|
||||
n = (os_name or "").lower()
|
||||
for rx, e in _OS_REGISTRY:
|
||||
if rx.search(n):
|
||||
return e
|
||||
return None
|
||||
|
||||
|
||||
def _os_family(os_name: str) -> Optional[str]:
|
||||
"""Map an asset OS string to a CPE target_sw token."""
|
||||
n = (os_name or "").lower()
|
||||
if "windows" in n:
|
||||
return "windows"
|
||||
if "ipad" in n:
|
||||
return "ipados"
|
||||
if "iphone" in n or "ios" in n:
|
||||
return "iphone_os"
|
||||
if "android" in n:
|
||||
return "android"
|
||||
if "mac" in n or "darwin" in n or "os x" in n:
|
||||
return "macos"
|
||||
if any(x in n for x in ("linux", "ubuntu", "debian", "centos", "red hat",
|
||||
"rhel", "fedora", "suse", "alma", "rocky")):
|
||||
return "linux"
|
||||
return None
|
||||
|
||||
|
||||
def _platform_ok(tsws, plat: Optional[str]) -> bool:
|
||||
"""A CVE's matching cpeMatch target_sw must be platform-neutral (*) or
|
||||
name the asset's OS. Stops desktop Firefox-on-Windows matching the
|
||||
Firefox-for-iOS CPE (target_sw=iphone_os) and similar cross-platform FPs.
|
||||
"""
|
||||
if not tsws:
|
||||
return True # unknown (e.g. pre-fix cache) → keep
|
||||
if any(t in ("*", "-", "") for t in tsws):
|
||||
return True
|
||||
return bool(plat) and plat in tsws
|
||||
|
||||
|
||||
def resolve_product(name: str) -> Optional[dict]:
|
||||
n = (name or "").strip().lower()
|
||||
if not n:
|
||||
return None
|
||||
for rx, entry in _REGISTRY:
|
||||
m = rx.search(n)
|
||||
if m:
|
||||
e = dict(entry)
|
||||
if e["kind"] == "osv" and not e.get("oname"):
|
||||
# derive package name from the capture group when present
|
||||
e["oname"] = (m.group(1) if m.groups() else n)
|
||||
e["key"] = f"osv:{e['eco']}:{e['oname']}"
|
||||
return e
|
||||
return None
|
||||
|
||||
|
||||
class _TransientNVD(Exception):
|
||||
"""NVD was unreachable/throttled — caller must NOT cache the empty result."""
|
||||
|
||||
|
||||
# ---------- version compare ----------
|
||||
def _clean_version(v: str) -> Optional[str]:
|
||||
"""Desktop version core, or None for distro/rpm/deb-style versions.
|
||||
|
||||
Linux package versions (epoch `1:3.2`, release tags `4.6.5-3.el8`,
|
||||
`2.43.0.windows.1`) are Wazuh's job — pushing them at NVD produces
|
||||
noise (503 storms) and false-positives. Only clean dotted-numeric
|
||||
versions go to the scanner; a trailing build like `7.0.2 (34567)` is
|
||||
trimmed to its core.
|
||||
# ponytail: dotted-numeric only; if a real product ever uses a hyphenated
|
||||
# version we want, special-case it then, not now.
|
||||
"""
|
||||
v = (v or "").strip()
|
||||
if not v or ":" in v:
|
||||
return None
|
||||
core = re.split(r"[ (]", v, 1)[0]
|
||||
return core if re.fullmatch(r"\d+(\.\d+)*", core) else None
|
||||
|
||||
|
||||
def _vtuple(v: str) -> Optional[tuple]:
|
||||
if not v:
|
||||
return None
|
||||
nums = re.findall(r"\d+", v)
|
||||
if not nums:
|
||||
return None
|
||||
return tuple(int(x) for x in nums[:6])
|
||||
|
||||
|
||||
def _vcmp(a: str, b: str) -> Optional[int]:
|
||||
"""-1/0/1, or None when not comparable."""
|
||||
ta, tb = _vtuple(a), _vtuple(b)
|
||||
if ta is None or tb is None:
|
||||
return None
|
||||
n = max(len(ta), len(tb))
|
||||
ta += (0,) * (n - len(ta))
|
||||
tb += (0,) * (n - len(tb))
|
||||
return (ta > tb) - (ta < tb)
|
||||
|
||||
|
||||
def _in_range(installed: str, m: dict) -> bool:
|
||||
"""True if installed version satisfies one NVD cpeMatch entry."""
|
||||
sI, sE = m.get("versionStartIncluding"), m.get("versionStartExcluding")
|
||||
eI, eE = m.get("versionEndIncluding"), m.get("versionEndExcluding")
|
||||
if not any([sI, sE, eI, eE]):
|
||||
# exact version in the CPE criteria (criteria[5]); equal only.
|
||||
crit = m.get("criteria", "")
|
||||
parts = crit.split(":")
|
||||
ver = parts[5] if len(parts) > 5 else "*"
|
||||
if ver in ("*", "-", ""):
|
||||
return False # wildcard → would match everything → skip (FP)
|
||||
return _vcmp(installed, ver) == 0
|
||||
if sI is not None and (_vcmp(installed, sI) is None or _vcmp(installed, sI) < 0):
|
||||
return False
|
||||
if sE is not None and (_vcmp(installed, sE) is None or _vcmp(installed, sE) <= 0):
|
||||
return False
|
||||
if eI is not None and (_vcmp(installed, eI) is None or _vcmp(installed, eI) > 0):
|
||||
return False
|
||||
if eE is not None and (_vcmp(installed, eE) is None or _vcmp(installed, eE) >= 0):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
# ---------- sources ----------
|
||||
def _query_osv(eco: str, name: str, version: str) -> List[dict]:
|
||||
try:
|
||||
with httpx.Client(timeout=HTTP_TIMEOUT, headers={"User-Agent": "TrueVuln/1.0"}) as c:
|
||||
r = c.post(OSV_QUERY_URL, json={"package": {"name": name, "ecosystem": eco}, "version": version})
|
||||
if r.status_code != 200:
|
||||
return []
|
||||
data = r.json()
|
||||
except (httpx.HTTPError, ValueError) as e:
|
||||
logger.debug("OSV query failed %s/%s@%s: %s", eco, name, version, e)
|
||||
return []
|
||||
out = []
|
||||
for v in data.get("vulns", []) or []:
|
||||
cid = v.get("id", "")
|
||||
aliases = v.get("aliases", []) or []
|
||||
cve = cid if cid.upper().startswith("CVE-") else next((a for a in aliases if a.upper().startswith("CVE-")), None)
|
||||
if not cve:
|
||||
continue
|
||||
out.append({"cve": cve.upper(), "cvss": None, "severity": None, "fixed": None})
|
||||
return out
|
||||
|
||||
|
||||
def _query_nvd_cpe(cpe: str, version: str) -> List[dict]:
|
||||
api_key = os.getenv("NVD_API_KEY", "").strip()
|
||||
headers = {"apiKey": api_key} if api_key else None
|
||||
url = f"{NVD_CVE_API}?virtualMatchString={cpe}:{version}&resultsPerPage=200"
|
||||
sleep = NVD_SLEEP_WITH_KEY if api_key else NVD_SLEEP_NO_KEY
|
||||
items = None
|
||||
for attempt in range(4):
|
||||
try:
|
||||
with httpx.Client(timeout=HTTP_TIMEOUT) as c:
|
||||
r = c.get(url, headers=headers)
|
||||
except httpx.HTTPError as e:
|
||||
logger.debug("NVD cpe query error %s@%s: %s", cpe, version, e)
|
||||
else:
|
||||
if r.status_code == 200:
|
||||
try:
|
||||
items = r.json().get("vulnerabilities", []) or []
|
||||
except ValueError:
|
||||
items = []
|
||||
break
|
||||
if r.status_code not in (429, 502, 503, 504):
|
||||
items = [] # permanent (e.g. 404/400) → genuinely empty, cacheable
|
||||
break
|
||||
logger.debug("NVD %s for %s@%s (try %d)", r.status_code, cpe, version, attempt + 1)
|
||||
# NVD 2.0 throws 503 under load even with a key → back off generously.
|
||||
time.sleep(max(3.0, sleep) * (attempt + 1))
|
||||
time.sleep(sleep)
|
||||
if items is None:
|
||||
raise _TransientNVD(f"NVD unavailable for {cpe}@{version}")
|
||||
|
||||
prod_token = ":".join(cpe.split(":")[3:5]) # vendor:product
|
||||
out = []
|
||||
for it in items:
|
||||
cve_obj = it.get("cve", {})
|
||||
cve_id = cve_obj.get("id", "")
|
||||
if not cve_id.upper().startswith("CVE-"):
|
||||
continue
|
||||
matched = False
|
||||
fixed = None
|
||||
tsws: set = set()
|
||||
for cfg in cve_obj.get("configurations", []) or []:
|
||||
for node in cfg.get("nodes", []) or []:
|
||||
for m in node.get("cpeMatch", []) or []:
|
||||
crit = m.get("criteria") or ""
|
||||
if prod_token not in crit:
|
||||
continue
|
||||
if not m.get("vulnerable", True):
|
||||
continue
|
||||
if _in_range(version, m):
|
||||
matched = True
|
||||
parts = crit.split(":")
|
||||
tsws.add(parts[9] if len(parts) > 9 else "*") # target_sw
|
||||
fixed = fixed or m.get("versionEndExcluding")
|
||||
if not matched:
|
||||
continue
|
||||
cvss, sev = _nvd_cvss(cve_obj)
|
||||
out.append({"cve": cve_id.upper(), "cvss": cvss, "severity": sev,
|
||||
"fixed": fixed, "tsw": sorted(tsws)})
|
||||
return out
|
||||
|
||||
|
||||
def _nvd_cvss(cve_obj: dict) -> Tuple[Optional[float], Optional[str]]:
|
||||
metrics = cve_obj.get("metrics", {}) or {}
|
||||
for key in ("cvssMetricV31", "cvssMetricV30", "cvssMetricV2"):
|
||||
arr = metrics.get(key) or []
|
||||
if arr:
|
||||
d = arr[0].get("cvssData", {})
|
||||
score = d.get("baseScore")
|
||||
sev = (d.get("baseSeverity") or arr[0].get("baseSeverity") or "").lower() or None
|
||||
try:
|
||||
return (float(score) if score is not None else None), sev
|
||||
except (TypeError, ValueError):
|
||||
return None, sev
|
||||
return None, None
|
||||
|
||||
|
||||
# ---------- cache ----------
|
||||
def _cache_get(db: Session, product_key: str, version: str) -> Optional[List[dict]]:
|
||||
from app.models.app_cve_cache import AppCveCache
|
||||
row = (db.query(AppCveCache)
|
||||
.filter(AppCveCache.product_key == product_key, AppCveCache.version == version)
|
||||
.first())
|
||||
if not row or not row.fetched_at:
|
||||
return None
|
||||
if datetime.now() - row.fetched_at > CACHE_TTL:
|
||||
return None
|
||||
try:
|
||||
return json.loads(row.cves or "[]")
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
|
||||
def _cache_put(db: Session, product_key: str, version: str, cves: List[dict]) -> None:
|
||||
from app.models.app_cve_cache import AppCveCache
|
||||
row = (db.query(AppCveCache)
|
||||
.filter(AppCveCache.product_key == product_key, AppCveCache.version == version)
|
||||
.first())
|
||||
if row:
|
||||
row.cves = json.dumps(cves)
|
||||
row.fetched_at = datetime.now()
|
||||
else:
|
||||
db.add(AppCveCache(product_key=product_key, version=version,
|
||||
cves=json.dumps(cves), fetched_at=datetime.now()))
|
||||
db.commit()
|
||||
|
||||
|
||||
CACHE_PREFIX = "v2:" # bump → ignores pre-target_sw cache rows (stale FPs)
|
||||
|
||||
|
||||
def lookup_cves(db: Session, entry: dict, version: str) -> List[dict]:
|
||||
"""Cached (product, version) → CVE list."""
|
||||
key = CACHE_PREFIX + entry["key"]
|
||||
cached = _cache_get(db, key, version)
|
||||
if cached is not None:
|
||||
return cached
|
||||
try:
|
||||
if entry["kind"] == "osv":
|
||||
cves = _query_osv(entry["eco"], entry["oname"], version)
|
||||
else:
|
||||
cves = _query_nvd_cpe(entry["cpe"], version)
|
||||
except _TransientNVD as e:
|
||||
logger.warning("app-cve: %s — not caching, will retry next run", e)
|
||||
return []
|
||||
_cache_put(db, key, version, cves)
|
||||
return cves
|
||||
|
||||
|
||||
# ---------- upsert + scan ----------
|
||||
def _upsert(db: Session, asset, pkg_name: str, version: str, c: dict, new_ids: list,
|
||||
touched: Optional[set] = None) -> None:
|
||||
from app.models.vulnerability import Vulnerability, VulnerabilitySeverity, VulnerabilityStatus
|
||||
sev_map = {"critical": VulnerabilitySeverity.critical, "high": VulnerabilitySeverity.high,
|
||||
"medium": VulnerabilitySeverity.medium, "low": VulnerabilitySeverity.low,
|
||||
"none": VulnerabilitySeverity.none}
|
||||
cve_id = c["cve"].upper()
|
||||
if touched is not None:
|
||||
touched.add(cve_id)
|
||||
existing = (db.query(Vulnerability)
|
||||
.filter(Vulnerability.cve_id == cve_id, Vulnerability.asset_id == asset.id)
|
||||
.first())
|
||||
if existing:
|
||||
existing.add_source("app-scan")
|
||||
if not existing.package_name:
|
||||
existing.package_name = pkg_name[:255]
|
||||
# Re-detected with the CURRENT inventory version → refresh it. Fill-only
|
||||
# left the first-ever version on the row (tester: Firefox showed
|
||||
# 'Installed: 150.0.3' while 152.0.5 was on the box).
|
||||
if version:
|
||||
existing.package_version = version[:100]
|
||||
# Backfill metric/fix data once better info arrives (e.g. cvelistV5 now
|
||||
# carries the CVSS the first import lacked) — don't overwrite existing.
|
||||
if existing.cvss_score is None and c.get("cvss") is not None:
|
||||
existing.cvss_score = c["cvss"]
|
||||
if c.get("fixed") and not existing.fixed_version:
|
||||
existing.fixed_version = c["fixed"]
|
||||
if c.get("severity") and existing.severity == VulnerabilitySeverity.medium:
|
||||
existing.severity = sev_map.get(c["severity"].lower(), existing.severity)
|
||||
if existing.status == VulnerabilityStatus.patched:
|
||||
existing.status = VulnerabilityStatus.open
|
||||
existing.patched_at = None
|
||||
try:
|
||||
existing.refresh_scores()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
sev = sev_map.get((c.get("severity") or "").lower(), VulnerabilitySeverity.medium)
|
||||
row = Vulnerability(
|
||||
cve_id=cve_id, asset_id=asset.id,
|
||||
cvss_score=c.get("cvss"), severity=sev,
|
||||
status=VulnerabilityStatus.open,
|
||||
title=f"{pkg_name} {version} — {cve_id}"[:500],
|
||||
package_name=pkg_name[:255], package_version=version[:100],
|
||||
fixed_version=(c.get("fixed") or None),
|
||||
detected_at=datetime.now(),
|
||||
sources=json.dumps(["app-scan"]), first_detected_by="app-scan",
|
||||
)
|
||||
db.add(row)
|
||||
db.flush()
|
||||
try:
|
||||
row.refresh_scores()
|
||||
except Exception:
|
||||
pass
|
||||
new_ids.append(row.id)
|
||||
|
||||
|
||||
def _is_citrix_shim(pkg: dict) -> bool:
|
||||
"""Citrix published-app delivery leaves a registry stub ('Firefox 1.0',
|
||||
vendor 'Delivered by Citrix') for software that is NOT installed on the
|
||||
box — matching it produced ancient-CVE false positives (tester:
|
||||
CVE-2008-2798 on 'Firefox 1.0'). The vendor field identifies the stub."""
|
||||
vendor = (pkg.get("vendor") or "").lower()
|
||||
return "citrix" in vendor
|
||||
|
||||
|
||||
def scan_asset_packages(db: Session, asset, packages: list, new_ids: Optional[list] = None,
|
||||
touched: Optional[set] = None) -> int:
|
||||
"""Match an asset's installed software to CVEs (curated + precise).
|
||||
Returns findings upserted. Caller commits."""
|
||||
if new_ids is None:
|
||||
new_ids = []
|
||||
count = 0
|
||||
seen: set = set()
|
||||
for pkg in packages or []:
|
||||
name = (pkg.get("name") or "").strip()
|
||||
version = (pkg.get("version") or "").strip()
|
||||
if not name:
|
||||
continue
|
||||
if _is_citrix_shim(pkg):
|
||||
continue
|
||||
entry = resolve_product(name)
|
||||
if not entry:
|
||||
continue
|
||||
eff_ver = _effective_version(name, version, entry)
|
||||
if not eff_ver:
|
||||
continue
|
||||
cver = _clean_version(eff_ver)
|
||||
if not cver:
|
||||
continue # distro/rpm version → Wazuh's domain, skip (no NVD noise)
|
||||
key = (entry["key"], cver)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
try:
|
||||
cves = lookup_cves(db, entry, cver)
|
||||
except Exception as e:
|
||||
logger.debug("app-cve lookup failed for %s %s: %s", name, version, e)
|
||||
continue
|
||||
plat = _os_family(asset.operating_system or "")
|
||||
for c in cves:
|
||||
if not _platform_ok(c.get("tsw"), plat):
|
||||
continue # CVE is for a different OS platform (e.g. Firefox-iOS)
|
||||
try:
|
||||
before = len(new_ids)
|
||||
_upsert(db, asset, name, eff_ver, c, new_ids, touched=touched)
|
||||
count += 1 if len(new_ids) > before else 0
|
||||
except Exception as e:
|
||||
logger.debug("app-cve upsert failed (%s on %s): %s", c.get("cve"), asset.id, e)
|
||||
return count
|
||||
|
||||
|
||||
def scan_asset_os(db: Session, asset, new_ids: list, touched: Optional[set] = None) -> int:
|
||||
"""OS-level CVEs from asset.operating_system + asset.os_version
|
||||
(iOS/iPadOS). Returns findings. Caller commits."""
|
||||
e = _resolve_os(asset.operating_system or "")
|
||||
if not e:
|
||||
return 0
|
||||
cver = _clean_version(asset.os_version or "")
|
||||
if not cver:
|
||||
return 0
|
||||
try:
|
||||
cves = lookup_cves(db, {"key": e["key"], "kind": "cpe", "cpe": e["cpe"]}, cver)
|
||||
except Exception as ex:
|
||||
logger.debug("app-cve OS lookup failed for %s: %s", asset.hostname, ex)
|
||||
return 0
|
||||
plat = _os_family(asset.operating_system or "")
|
||||
count = 0
|
||||
for c in cves:
|
||||
if not _platform_ok(c.get("tsw"), plat):
|
||||
continue
|
||||
before = len(new_ids)
|
||||
try:
|
||||
_upsert(db, asset, e["label"], asset.os_version or cver, c, new_ids, touched=touched)
|
||||
count += 1 if len(new_ids) > before else 0
|
||||
except Exception as ex:
|
||||
logger.debug("app-cve OS upsert failed (%s on %s): %s", c.get("cve"), asset.id, ex)
|
||||
return count
|
||||
|
||||
|
||||
def _resolve_stale_app_findings(db: Session, asset, touched_cves: set) -> int:
|
||||
"""Reconcile OPEN app-scan findings not re-detected this run: DROP the
|
||||
app-scan source (same contract as the Nessus backfill), and mark patched
|
||||
only once no source is left. The old skip-if-cross-confirmed rule dead-
|
||||
locked: a Chrome CVE seen by app-scan AND Defender was never closed by
|
||||
either reconcile (each deferred to the other), so a patched host kept an
|
||||
open cross-confirmed finding forever (tester: Chrome 150.0.7871.125
|
||||
installed, fix .115, finding still open). Caller must have had a real
|
||||
inventory this run."""
|
||||
from app.models.vulnerability import Vulnerability, VulnerabilityStatus
|
||||
rows = (db.query(Vulnerability)
|
||||
.filter(Vulnerability.asset_id == asset.id,
|
||||
Vulnerability.status == VulnerabilityStatus.open,
|
||||
Vulnerability.sources.contains('"app-scan"'))
|
||||
.all())
|
||||
resolved = 0
|
||||
for v in rows:
|
||||
if v.cve_id in touched_cves:
|
||||
continue
|
||||
v.remove_source("app-scan")
|
||||
if v.source_list:
|
||||
continue # another scanner still reports it → stays open under theirs
|
||||
old_status = v.status
|
||||
v.status = VulnerabilityStatus.patched
|
||||
v.patched_at = datetime.now()
|
||||
resolved += 1
|
||||
# Revisionssicher: one status-change row (feeds both the global audit
|
||||
# log AND the per-CVE Change History). user_id=None = automated.
|
||||
try:
|
||||
from app.routers.vulnerabilities import log_vulnerability_change
|
||||
log_vulnerability_change(
|
||||
db, None, v.id, old_status, v.status,
|
||||
reason=f"App CVE scan no longer detects this CVE on {asset.hostname} "
|
||||
f"(software updated/removed past the vulnerable version)",
|
||||
cve_id=v.cve_id,
|
||||
source="app_scan",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("audit log for app-scan auto-resolve failed (vuln_id=%s): %s", v.id, e)
|
||||
return resolved
|
||||
|
||||
|
||||
def run_app_cve_scan(db: Session, asset_id: Optional[int] = None) -> dict:
|
||||
"""Scan all assets with software inventory (Wazuh packages + Intune
|
||||
detectedApps) → app-scan CVEs, then enrich the new ones."""
|
||||
from app.models.asset import Asset, AssetSource
|
||||
stats = {"assets": 0, "findings": 0, "new": 0, "errors": []}
|
||||
new_ids: list = []
|
||||
logger.info("App CVE scan starting: NVD key %s",
|
||||
"present" if os.getenv("NVD_API_KEY", "").strip() else "MISSING (keyless = frequent 503)")
|
||||
|
||||
wazuh = None
|
||||
graph = None
|
||||
try:
|
||||
from app.integrations.wazuh_client import WazuhClient
|
||||
from app.auth.setting_crypto import read_setting_value
|
||||
raw = read_setting_value(db, "wazuh_config")
|
||||
if raw:
|
||||
cfg = json.loads(raw)
|
||||
if all([cfg.get("api_url"), cfg.get("username"), cfg.get("password")]):
|
||||
wazuh = WazuhClient(base_url=cfg.get("api_url"), username=cfg.get("username"),
|
||||
password=cfg.get("password"), indexer_url=cfg.get("indexer_url"),
|
||||
indexer_username=cfg.get("indexer_username"),
|
||||
indexer_password=cfg.get("indexer_password"),
|
||||
verify_ssl=cfg.get("verify_ssl", False))
|
||||
except Exception as e:
|
||||
logger.debug("app-cve: wazuh client unavailable: %s", e)
|
||||
try:
|
||||
from app.services.intune_service import load_intune_config, _build_client
|
||||
icfg = load_intune_config(db)
|
||||
if icfg:
|
||||
graph = _build_client(icfg)
|
||||
except Exception as e:
|
||||
logger.debug("app-cve: graph client unavailable: %s", e)
|
||||
|
||||
# cvelistV5 reverse index (curated products) — catches CVEs NVD hasn't
|
||||
# CPE'd yet / filed under a different CPE product string. Cached; built by
|
||||
# the nightly job. Absent → that pass is skipped (logged).
|
||||
cve5_index = {}
|
||||
try:
|
||||
from app.services import cvelistv5_scan_service
|
||||
cve5_index = cvelistv5_scan_service.load_index(db) or {}
|
||||
if not cve5_index:
|
||||
# No cached index → build it now so a manual scan works the same
|
||||
# as the nightly job (downloads/walks the cvelistV5 ZIP — slow on
|
||||
# first run, then cached + refreshed nightly).
|
||||
logger.info("app-cve: cvelistV5 index missing — building now (one-time, may take a few minutes)")
|
||||
cve5_index = cvelistv5_scan_service.build_product_index(db) or {}
|
||||
except Exception as e:
|
||||
logger.warning("app-cve: cvelistV5 index build/load failed: %s", e)
|
||||
|
||||
q = db.query(Asset)
|
||||
if asset_id is not None:
|
||||
q = q.filter(Asset.id == asset_id)
|
||||
for asset in q.all():
|
||||
touched = False
|
||||
touched_cves: set = set() # every CVE re-detected this run → reconcile base
|
||||
# OS-level CVEs (iOS/iPadOS) — version already in the DB, no client needed.
|
||||
try:
|
||||
n = scan_asset_os(db, asset, new_ids, touched=touched_cves)
|
||||
if n:
|
||||
stats["findings"] += n
|
||||
touched = True
|
||||
except Exception as e:
|
||||
stats["errors"].append(f"asset {asset.id} os: {e}")
|
||||
|
||||
# Windows OS CVEs from cvelistV5 — family-matched and release-bounded
|
||||
# (see scan_asset_os). Needs no software inventory, so it runs for every
|
||||
# Windows asset.
|
||||
if cve5_index:
|
||||
try:
|
||||
from app.services import cvelistv5_scan_service
|
||||
n = cvelistv5_scan_service.scan_asset_os(
|
||||
db, asset, cve5_index, new_ids, touched=touched_cves)
|
||||
if n:
|
||||
stats["findings"] += n
|
||||
touched = True
|
||||
except Exception as e:
|
||||
stats["errors"].append(f"asset {asset.id} win-os: {e}")
|
||||
|
||||
# Package-level CVEs (Wazuh syscollector / Intune detectedApps).
|
||||
packages: list = []
|
||||
try:
|
||||
if asset.wazuh_agent_id and wazuh:
|
||||
packages = wazuh.get_packages(asset.wazuh_agent_id) or []
|
||||
elif asset.intune_device_id and graph:
|
||||
packages = graph.get_detected_apps(asset.intune_device_id) or []
|
||||
except Exception as e:
|
||||
stats["errors"].append(f"asset {asset.id}: {e}")
|
||||
packages = []
|
||||
if packages:
|
||||
stats["findings"] += scan_asset_packages(db, asset, packages, new_ids, touched=touched_cves)
|
||||
if cve5_index:
|
||||
try:
|
||||
from app.services import cvelistv5_scan_service
|
||||
stats["findings"] += cvelistv5_scan_service.scan_asset(db, asset, packages, cve5_index, new_ids, touched=touched_cves)
|
||||
except Exception as e:
|
||||
stats["errors"].append(f"asset {asset.id} cvelistv5: {e}")
|
||||
touched = True
|
||||
# MSRC fixed-build scan for installed MS software (SharePoint):
|
||||
# Microsoft ships no version ranges, so this is the only source that
|
||||
# knows the patch state. Separate source ('msrc') + its own reconcile.
|
||||
try:
|
||||
from app.services import msrc_scan_service
|
||||
m_index = msrc_scan_service.load_index(db)
|
||||
if m_index:
|
||||
m_touched: set = set()
|
||||
stats["msrc_findings"] = stats.get("msrc_findings", 0) + \
|
||||
msrc_scan_service.scan_asset_packages(
|
||||
db, asset, packages, m_index, new_ids, touched=m_touched)
|
||||
stats["msrc_resolved"] = stats.get("msrc_resolved", 0) + \
|
||||
msrc_scan_service.resolve_stale_packages(db, asset, m_touched)
|
||||
except Exception as e:
|
||||
stats["errors"].append(f"asset {asset.id} msrc: {e}")
|
||||
# Auto-resolve: an app-scan-only finding no longer re-detected means
|
||||
# the software was updated/removed past it. Only safe when we had a
|
||||
# real inventory this run (packages non-empty) — else we'd close
|
||||
# everything on a transient fetch failure.
|
||||
stats["resolved"] = stats.get("resolved", 0) + _resolve_stale_app_findings(db, asset, touched_cves)
|
||||
|
||||
if touched:
|
||||
stats["assets"] += 1
|
||||
db.commit()
|
||||
|
||||
if graph:
|
||||
graph.close()
|
||||
stats["new"] = len(new_ids)
|
||||
|
||||
# Enrich + audit the new app-scan CVEs.
|
||||
if new_ids:
|
||||
try:
|
||||
from app.services.audit_events import audit_new_vulnerabilities
|
||||
audit_new_vulnerabilities(db, new_ids, source="app-scan")
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
logger.debug("app-cve detected-audit failed: %s", e)
|
||||
try:
|
||||
from app.models.vulnerability import Vulnerability
|
||||
from app.services.enrichment_service import enrich_vulnerabilities
|
||||
fresh = db.query(Vulnerability).filter(Vulnerability.id.in_(new_ids)).all()
|
||||
if fresh:
|
||||
enrich_vulnerabilities(db, fresh)
|
||||
# New-CVE email notifications (was Wazuh/Nessus-only — app-scan
|
||||
# findings silently skipped notifications). Same digest path.
|
||||
from app.services.email_service import dispatch_new_vuln_notifications
|
||||
stats["notifications"] = dispatch_new_vuln_notifications(db, fresh)
|
||||
except Exception as e:
|
||||
logger.debug("app-cve enrichment/notify failed: %s", e)
|
||||
|
||||
logger.info("App CVE scan: %d assets, %d findings (%d new, %d auto-resolved)",
|
||||
stats["assets"], stats["findings"], stats["new"], stats.get("resolved", 0))
|
||||
return stats
|
||||
@@ -3,7 +3,7 @@ Asset lifecycle reconciliation.
|
||||
|
||||
Tester request: when an asset is decommissioned in the source systems
|
||||
(removed from Wazuh + Nessus via the org's "system no longer exists"
|
||||
process), VulnCheck should not keep a data corpse. But hard-deleting
|
||||
process), TrueVuln should not keep a data corpse. But hard-deleting
|
||||
loses the vulnerability history + breaks the revisionssicher audit
|
||||
trail the operator also asked for.
|
||||
|
||||
@@ -203,6 +203,28 @@ def reconcile_missing_from_sync(
|
||||
for a in stale:
|
||||
a.source = AssetSource.NESSUS
|
||||
|
||||
# Diagnostic: log how many candidates we're about to evaluate so
|
||||
# a tester reporting "INACTIVE never flips" can paste this line
|
||||
# in the bug report — it tells us if the issue is upstream (no
|
||||
# nessus_host_uuid pinned) or downstream (reconcile logic).
|
||||
legacy_unpinned = (
|
||||
db.query(Asset)
|
||||
.filter(
|
||||
Asset.source == source,
|
||||
Asset.status == AssetStatus.ACTIVE,
|
||||
id_field.is_(None),
|
||||
)
|
||||
.count()
|
||||
)
|
||||
if legacy_unpinned:
|
||||
logger.info(
|
||||
"asset sync-reconcile (%s): %d ACTIVE assets have no %s pinned "
|
||||
"— they will be skipped by the per-id reconcile. Consider a "
|
||||
"host-name backfill job to set nessus_host_uuid for legacy rows.",
|
||||
source.value if hasattr(source, "value") else source,
|
||||
legacy_unpinned, id_field.key,
|
||||
)
|
||||
|
||||
active_q = (
|
||||
db.query(Asset)
|
||||
.filter(
|
||||
@@ -245,3 +267,144 @@ def reconcile_missing_from_sync(
|
||||
stats["inactivated"], stats["reactivated"],
|
||||
)
|
||||
return stats
|
||||
|
||||
|
||||
def reconcile_nessus_by_seen_ids(
|
||||
db: Session,
|
||||
*,
|
||||
seen_asset_ids: set,
|
||||
reason: str,
|
||||
) -> dict:
|
||||
"""Robust id-keyed Nessus reconcile — supersedes the uuid-keyed path.
|
||||
|
||||
Why id-keyed: the previous reconcile keyed on `nessus_host_uuid`. If
|
||||
the only host in a reduced-scope scan had no `host_uuid` in its
|
||||
Nessus host_info, `seen_uuids` came back EMPTY → the fail-open guard
|
||||
skipped everything → the dropped hosts stayed ACTIVE (tester bug).
|
||||
Tracking the matched `asset.id` of every host actually touched this
|
||||
sync avoids that: even a uuid-less host still contributes its id, so
|
||||
the seen-set is non-empty and the dropped assets get inactivated.
|
||||
|
||||
Candidate set = ACTIVE assets Nessus knows about, i.e.
|
||||
source == NESSUS OR nessus_host_uuid IS NOT NULL
|
||||
minus the ids seen this run.
|
||||
|
||||
Fail-open: empty seen set → skip (can't tell "scan saw nothing" from
|
||||
"upstream failed"). DECOMMISSIONED is operator-final, never touched.
|
||||
Caller commits.
|
||||
"""
|
||||
from sqlalchemy import or_ as _or
|
||||
stats = {"inactivated": 0, "reactivated": 0, "candidates": 0}
|
||||
|
||||
if not seen_asset_ids:
|
||||
logger.warning(
|
||||
"nessus reconcile (id-keyed): skipped — seen_asset_ids empty "
|
||||
"(scan returned no matched hosts? not deactivating anything)"
|
||||
)
|
||||
return stats
|
||||
|
||||
nessus_known = _or(
|
||||
Asset.source == AssetSource.NESSUS,
|
||||
Asset.nessus_host_uuid.isnot(None),
|
||||
)
|
||||
|
||||
# Inactivate: Nessus-known, ACTIVE, not seen this run.
|
||||
candidates = (
|
||||
db.query(Asset)
|
||||
.filter(
|
||||
nessus_known,
|
||||
Asset.status == AssetStatus.ACTIVE,
|
||||
~Asset.id.in_(seen_asset_ids),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
stats["candidates"] = len(candidates)
|
||||
for asset in candidates:
|
||||
old = asset.status.value if hasattr(asset.status, "value") else str(asset.status)
|
||||
asset.status = AssetStatus.INACTIVE
|
||||
_audit_asset_status(db, asset, old, "inactive", reason)
|
||||
stats["inactivated"] += 1
|
||||
|
||||
# Reactivate: Nessus-known, INACTIVE, seen again this run.
|
||||
revived = (
|
||||
db.query(Asset)
|
||||
.filter(
|
||||
nessus_known,
|
||||
Asset.status == AssetStatus.INACTIVE,
|
||||
Asset.id.in_(seen_asset_ids),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for asset in revived:
|
||||
asset.status = AssetStatus.ACTIVE
|
||||
_audit_asset_status(
|
||||
db, asset, "inactive", "active",
|
||||
"seen again by a Nessus sync (event-driven revive)",
|
||||
)
|
||||
stats["reactivated"] += 1
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
def reconcile_intune_by_seen_ids(
|
||||
db: Session,
|
||||
*,
|
||||
seen_asset_ids: set,
|
||||
reason: str,
|
||||
) -> dict:
|
||||
"""Id-keyed Intune reconcile — same robust pattern as the Nessus one.
|
||||
|
||||
Candidate set = ACTIVE Intune-known assets (source == INTUNE OR
|
||||
intune_device_id IS NOT NULL) minus the ids seen this Graph sync →
|
||||
INACTIVE; INACTIVE ones seen again → ACTIVE. Fail-open on empty seen
|
||||
set. DECOMMISSIONED untouched. Caller commits.
|
||||
"""
|
||||
from sqlalchemy import or_ as _or
|
||||
stats = {"inactivated": 0, "reactivated": 0, "candidates": 0}
|
||||
|
||||
if not seen_asset_ids:
|
||||
logger.warning(
|
||||
"intune reconcile: skipped — seen_asset_ids empty "
|
||||
"(sync returned no matched devices? not deactivating anything)"
|
||||
)
|
||||
return stats
|
||||
|
||||
intune_known = _or(
|
||||
Asset.source == AssetSource.INTUNE,
|
||||
Asset.intune_device_id.isnot(None),
|
||||
)
|
||||
|
||||
candidates = (
|
||||
db.query(Asset)
|
||||
.filter(
|
||||
intune_known,
|
||||
Asset.status == AssetStatus.ACTIVE,
|
||||
~Asset.id.in_(seen_asset_ids),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
stats["candidates"] = len(candidates)
|
||||
for asset in candidates:
|
||||
old = asset.status.value if hasattr(asset.status, "value") else str(asset.status)
|
||||
asset.status = AssetStatus.INACTIVE
|
||||
_audit_asset_status(db, asset, old, "inactive", reason)
|
||||
stats["inactivated"] += 1
|
||||
|
||||
revived = (
|
||||
db.query(Asset)
|
||||
.filter(
|
||||
intune_known,
|
||||
Asset.status == AssetStatus.INACTIVE,
|
||||
Asset.id.in_(seen_asset_ids),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for asset in revived:
|
||||
asset.status = AssetStatus.ACTIVE
|
||||
_audit_asset_status(
|
||||
db, asset, "inactive", "active",
|
||||
"seen again by an Intune sync (event-driven revive)",
|
||||
)
|
||||
stats["reactivated"] += 1
|
||||
|
||||
return stats
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"""
|
||||
Shared audit-event writers for sync-driven changes.
|
||||
|
||||
Tester requirement (revisionssicher): a finding newly created by a
|
||||
Wazuh/Nessus sync must leave an initial "VULNERABILITY_DETECTED" trail —
|
||||
previously the audit history only began with the first status change.
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Iterable
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.audit_log import AuditLog, AuditEventType
|
||||
from app.models.vulnerability import Vulnerability
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def audit_new_vulnerabilities(
|
||||
db: Session,
|
||||
vuln_ids: Iterable[int],
|
||||
*,
|
||||
source: str,
|
||||
) -> int:
|
||||
"""Write one VULNERABILITY_DETECTED audit row per newly created finding.
|
||||
|
||||
`source` names the detector ("wazuh", "nessus", "eol_check", ...).
|
||||
user_id stays NULL → the audit UI renders it as System/Auto, matching
|
||||
the asset-lifecycle events. Caller commits. Returns rows written.
|
||||
"""
|
||||
ids = [i for i in vuln_ids if i]
|
||||
if not ids:
|
||||
return 0
|
||||
rows = (
|
||||
db.query(Vulnerability)
|
||||
.filter(Vulnerability.id.in_(ids))
|
||||
.all()
|
||||
)
|
||||
written = 0
|
||||
now = datetime.now()
|
||||
for v in rows:
|
||||
hostname = v.asset.hostname if v.asset else f"asset #{v.asset_id}"
|
||||
desc = (
|
||||
f"New finding detected: {v.cve_id} on {hostname} "
|
||||
f"(severity={v.severity.value if hasattr(v.severity, 'value') else v.severity}, "
|
||||
f"source={source})"
|
||||
)
|
||||
db.add(AuditLog(
|
||||
user_id=None, # System/Auto
|
||||
event_type=AuditEventType.VULNERABILITY_DETECTED,
|
||||
event_description=desc[:500],
|
||||
resource_type="vulnerability",
|
||||
resource_id=str(v.id),
|
||||
timestamp=now,
|
||||
))
|
||||
written += 1
|
||||
if written:
|
||||
logger.info("audit: %d VULNERABILITY_DETECTED events (%s)", written, source)
|
||||
return written
|
||||
@@ -0,0 +1,688 @@
|
||||
"""
|
||||
cvelistV5-based CVE detection for installed software.
|
||||
|
||||
The NVD-CPE scanner (app_cve_scanner_service) misses CVEs when NVD hasn't
|
||||
published a CPE yet (fresh CVEs) or files them under a different CPE product
|
||||
string than we curated (e.g. TeamViewer CVE lives under teamviewer:remote,
|
||||
not teamviewer:teamviewer). cvelistV5 — the authoritative MITRE feed we
|
||||
already cache as a ZIP — carries clean affected[].vendor/product/version
|
||||
ranges instead, so we match those directly.
|
||||
|
||||
Design (CURATED + PRECISE, same as the CPE scanner):
|
||||
- A curated product registry maps an installed-software name → the
|
||||
cvelistV5 (vendor, product) pairs that identify it. Unknown software is
|
||||
ignored (no fuzzy vendor/product guessing → no FP storm).
|
||||
- One pass over the cached cvelistV5 ZIP builds a reverse index
|
||||
{product_key: [{cve, start, lt, lte}]} for the curated products only.
|
||||
The index is cached in a Setting (refreshed on the nightly job) so the
|
||||
557 MB walk happens once, not per scan.
|
||||
- Per installed package: resolve → look up indexed CVEs → check the
|
||||
installed version falls inside an affected range → upsert (source
|
||||
'app-scan', shared with the CPE scanner so the badge / cross-confirm /
|
||||
enrichment all apply).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import zipfile
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.services import app_cve_scanner_service as cpe
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Reuse the same ZIP the override service already downloads/caches (12h).
|
||||
_ZIP_PATH = "/tmp/truevuln-cvelistv5-cache.zip"
|
||||
_ZIP_URL = "https://github.com/CVEProject/cvelistV5/archive/refs/heads/main.zip"
|
||||
_ZIP_TTL = 12 * 3600
|
||||
_INDEX_SETTING = "cvelistv5_product_index_v7" # v7: modern .NET per-release keys
|
||||
_INDEX_TTL = timedelta(hours=26) # rebuilt nightly; a missed night still serves
|
||||
|
||||
# Curated registry: name-regex (installed software) → cvelistV5 (vendor,
|
||||
# product) pairs, lowercased. First regex match wins. Vendor/product are
|
||||
# matched case-insensitively against affected[].vendor / .product.
|
||||
# ponytail: curated; add a row when a product is missed — unknown names are
|
||||
# skipped, never guessed.
|
||||
_REGISTRY: List[dict] = [
|
||||
{"key": "teamviewer", "re": r"teamviewer",
|
||||
"pairs": [("teamviewer", "remote"), ("teamviewer", "tensor"),
|
||||
("teamviewer", "host"), ("teamviewer", "full client"),
|
||||
("teamviewer", "teamviewer")]},
|
||||
{"key": "notepad++", "re": r"notepad\+\+",
|
||||
"pairs": [("notepad-plus-plus", "notepad-plus-plus"), ("notepad++", "notepad++"),
|
||||
("don ho", "notepad++")]},
|
||||
{"key": "devolutions-rdm", "re": r"remote desktop manager|devolutions",
|
||||
"pairs": [("devolutions", "remote desktop manager")]},
|
||||
{"key": "7-zip", "re": r"7-?zip",
|
||||
"pairs": [("7-zip", "7-zip"), ("igor pavlov", "7-zip")]},
|
||||
{"key": "firefox", "re": r"mozilla firefox|(?<!\w)firefox",
|
||||
"pairs": [("mozilla", "firefox")]},
|
||||
{"key": "chrome", "re": r"google chrome|com\.android\.chrome",
|
||||
"pairs": [("google", "chrome")]},
|
||||
# SharePoint — ONE KEY PER RELEASE, deliberately. Every MS SharePoint range
|
||||
# uses a generic 16.0.0 floor (checked across 82 recent CVEs), and 2016,
|
||||
# 2019 and Subscription Edition all report 16.0.x, so a single shared key
|
||||
# would let a 2016 install (16.0.5456) fall inside the 2019 range
|
||||
# (16.0.0 .. 16.0.10417.20153) — the exact cross-release false positive the
|
||||
# Windows OS scan just had. The release comes from the NAME; the range then
|
||||
# only ever decides "patched or not" within that one release.
|
||||
# 2013 IS here: it is EOL (2023-04-11) and absent from recent MSRC docs, but
|
||||
# the CVE records from its supported years carry real fix builds
|
||||
# (CVE-2023-23395: 15.0.0 .. 15.0.5537.1000), and an unpatched 2013 farm is
|
||||
# behind all of them. Checking only recent MSRC docs is what made this look
|
||||
# undetectable — the tester's Nessus finds these, and so should we.
|
||||
{"key": "sharepoint-2013", "re": r"sharepoint.*\b2013\b",
|
||||
"pairs": [("microsoft", "microsoft sharepoint foundation 2013 service pack 1"),
|
||||
("microsoft", "microsoft sharepoint enterprise server 2013 service pack 1"),
|
||||
("microsoft", "microsoft sharepoint server 2013 service pack 1"),
|
||||
("microsoft", "microsoft sharepoint foundation 2013"),
|
||||
("microsoft", "microsoft sharepoint enterprise server 2013"),
|
||||
("microsoft", "microsoft sharepoint server 2013")]},
|
||||
{"key": "sharepoint-se", "re": r"sharepoint.*subscription",
|
||||
"pairs": [("microsoft", "microsoft sharepoint server subscription edition")]},
|
||||
{"key": "sharepoint-2019", "re": r"sharepoint.*\b2019\b",
|
||||
"pairs": [("microsoft", "microsoft sharepoint server 2019")]},
|
||||
{"key": "sharepoint-2016", "re": r"sharepoint.*\b2016\b",
|
||||
"pairs": [("microsoft", "microsoft sharepoint enterprise server 2016"),
|
||||
("microsoft", "microsoft sharepoint server 2016")]},
|
||||
# Modern .NET (8/9/10) — one key PER RELEASE (same reasoning as SharePoint:
|
||||
# the record floors are generic release floors, so a shared key would
|
||||
# cross-match releases). The real semantic version lives in the NAME
|
||||
# ("Microsoft .NET Runtime - 8.0.16 (x64)"), which bumps with every monthly
|
||||
# patch — the version FIELD is an MSI build → name_ver. SDKs are excluded:
|
||||
# their numbering (8.0.1xx) never falls inside the runtime fix range and
|
||||
# the runtime is installed alongside anyway.
|
||||
# .NET FRAMEWORK is deliberately NOT here: its ARP version (4.8.04084) is
|
||||
# STATIC across monthly patches (only file versions change), so comparing
|
||||
# it against fix builds like 4.8.4803.0 would flag every install forever.
|
||||
# Framework patch state is file/KB-based — Defender TVM covers it.
|
||||
{"key": "dotnet-10", "name_ver": True,
|
||||
"re": r"(?:microsoft \.net (?:desktop )?(?:runtime|host)|windows desktop runtime).*?\b10\.0\.",
|
||||
"pairs": [("microsoft", ".net 10.0")]},
|
||||
{"key": "dotnet-9", "name_ver": True,
|
||||
"re": r"(?:microsoft \.net (?:desktop )?(?:runtime|host)|windows desktop runtime).*?\b9\.0\.",
|
||||
"pairs": [("microsoft", ".net 9.0")]},
|
||||
{"key": "dotnet-8", "name_ver": True,
|
||||
"re": r"(?:microsoft \.net (?:desktop )?(?:runtime|host)|windows desktop runtime).*?\b8\.0\.",
|
||||
"pairs": [("microsoft", ".net 8.0")]},
|
||||
{"key": "edge", "re": r"microsoft edge(?!.*webview)",
|
||||
"pairs": [("microsoft", "microsoft edge (chromium-based)"),
|
||||
("microsoft", "edge (chromium-based)"), ("microsoft", "microsoft edge")]},
|
||||
{"key": "vlc", "re": r"vlc media player|videolan",
|
||||
"pairs": [("videolan", "vlc media player"), ("videolan", "vlc")]},
|
||||
{"key": "putty", "re": r"(?<!\w)putty",
|
||||
"pairs": [("putty", "putty"), ("simon tatham", "putty")]},
|
||||
{"key": "winscp", "re": r"winscp", "pairs": [("winscp", "winscp"), ("martin prikryl", "winscp")]},
|
||||
{"key": "wireshark", "re": r"wireshark", "pairs": [("wireshark", "wireshark")]},
|
||||
{"key": "filezilla", "re": r"filezilla", "pairs": [("filezilla", "filezilla"), ("filezilla", "filezilla client")]},
|
||||
{"key": "zoom", "re": r"(?<!\w)zoom(?!\w)", "pairs": [("zoom", "zoom"), ("zoom", "meetings"), ("zoom", "zoom client for meetings")]},
|
||||
# Teams itself only — not the Office add-in / VDI / Citrix plugin. And
|
||||
# only the Windows/desktop/generic product entries: the "for Mac" / mobile
|
||||
# products are dropped so a Mac-only Teams CVE can't land on a Windows host
|
||||
# (the per-scan platform filter is the general backstop, but not every CNA
|
||||
# fills the platforms field, so scoping the pairs is belt-and-suspenders).
|
||||
{"key": "teams", "re": r"microsoft teams(?!.*(machine-wide|add-in|plugin|vdi|citrix))",
|
||||
"pairs": [("microsoft", "microsoft teams for desktop"),
|
||||
("microsoft", "microsoft teams for windows"),
|
||||
("microsoft", "microsoft teams"), ("microsoft", "teams")]},
|
||||
]
|
||||
|
||||
_COMPILED = [(re.compile(e["re"], re.I), e) for e in _REGISTRY]
|
||||
_KEY_ENTRY = {e["key"]: e for e in _REGISTRY}
|
||||
|
||||
# Reverse map (vendor_lc, product_lc) → product_key, for the index build.
|
||||
_PAIR_TO_KEY: Dict[Tuple[str, str], str] = {}
|
||||
for _e in _REGISTRY:
|
||||
for _v, _p in _e["pairs"]:
|
||||
_PAIR_TO_KEY[(_v.lower(), _p.lower())] = _e["key"]
|
||||
|
||||
# Pattern products — one curated key for a whole family whose CVE records name
|
||||
# a product per RELEASE ("Windows 11 Version 23H2", "Windows Server 2025
|
||||
# (Server Core installation)", ...), so an exact pair list would rot with every
|
||||
# new release. The entry keeps its product NAME so the scan can tell the
|
||||
# releases apart; the build line alone cannot (see _WIN_FAMILIES).
|
||||
_PRODUCT_PATTERNS: List[dict] = [
|
||||
{"key": "windows", "vendor_re": r"^microsoft$",
|
||||
"product_re": r"^windows\s+(10|11|server)\b"},
|
||||
]
|
||||
|
||||
# Asset OS string → which cvelistV5 product names it may match.
|
||||
#
|
||||
# This is the part that was missing and caused the cross-release FPs: Windows 11
|
||||
# 24H2 and Windows Server 2025 both live on build line 10.0.26100 but keep
|
||||
# SEPARATE revision sequences (24H2 fix .8655, Server 2025 fix .32860), so
|
||||
# CVE-2026-41089 — Server 2025 only — matched a fully-patched 24H2 client.
|
||||
# Deciding the family from the OS string first makes the build range a
|
||||
# within-release "patched or not" test, which is all it can honestly answer.
|
||||
# Order matters: "Windows Server" must be tested before the bare client names.
|
||||
_WIN_FAMILIES: List[tuple] = [
|
||||
(re.compile(r"windows\s+server", re.I), re.compile(r"^windows\s+server\b", re.I)),
|
||||
(re.compile(r"windows\s*11", re.I), re.compile(r"^windows\s+11\b", re.I)),
|
||||
(re.compile(r"windows\s*10", re.I), re.compile(r"^windows\s+10\b", re.I)),
|
||||
]
|
||||
|
||||
|
||||
def _win_family(os_name: str) -> Optional[re.Pattern]:
|
||||
"""Asset OS string → regex the entry's product name must satisfy."""
|
||||
n = (os_name or "").strip().lower()
|
||||
for os_rx, prod_rx in _WIN_FAMILIES:
|
||||
if os_rx.search(n):
|
||||
return prod_rx
|
||||
return None
|
||||
_PATTERNS_COMPILED = [
|
||||
(re.compile(p["vendor_re"], re.I), re.compile(p["product_re"], re.I), p["key"])
|
||||
for p in _PRODUCT_PATTERNS
|
||||
]
|
||||
|
||||
|
||||
def _pair_key(vendor: str, product: str) -> Optional[str]:
|
||||
"""(vendor, product) from a CVE record → curated product key."""
|
||||
v, p = (vendor or "").strip().lower(), (product or "").strip().lower()
|
||||
key = _PAIR_TO_KEY.get((v, p))
|
||||
if key:
|
||||
return key
|
||||
for vrx, prx, k in _PATTERNS_COMPILED:
|
||||
if vrx.search(v) and prx.search(p):
|
||||
return k
|
||||
return None
|
||||
|
||||
|
||||
def resolve(name: str) -> Optional[str]:
|
||||
n = (name or "").strip().lower()
|
||||
if not n:
|
||||
return None
|
||||
for rx, e in _COMPILED:
|
||||
if rx.search(n):
|
||||
return e["key"]
|
||||
return None
|
||||
|
||||
|
||||
# ---------- CVSS base score ----------
|
||||
def _cvss_from_record(data: dict) -> Tuple[Optional[float], Optional[str]]:
|
||||
"""Pull (baseScore, baseSeverity) from a CVE-5 record. Prefers CVSS 3.1 >
|
||||
3.0 > 4.0, and the CNA's own metrics over the CISA-ADP block. Returns
|
||||
(None, None) when the record carries no score (many fresh CVEs) — enrichment
|
||||
can still backfill later."""
|
||||
conts = data.get("containers") or {}
|
||||
metric_blocks = [(conts.get("cna") or {}).get("metrics") or []]
|
||||
for adp in (conts.get("adp") or []):
|
||||
metric_blocks.append(adp.get("metrics") or [])
|
||||
for field in ("cvssV3_1", "cvssV3_0", "cvssV4_0"):
|
||||
for metrics in metric_blocks:
|
||||
for m in metrics:
|
||||
cv = m.get(field) if isinstance(m, dict) else None
|
||||
if isinstance(cv, dict) and cv.get("baseScore") is not None:
|
||||
try:
|
||||
return float(cv["baseScore"]), (cv.get("baseSeverity") or "").lower() or None
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return None, None
|
||||
|
||||
|
||||
# ---------- affected[] range parsing ----------
|
||||
def _ranges_from_affected(aff: dict) -> List[Tuple[Optional[str], Optional[str], Optional[str]]]:
|
||||
"""→ [(version_start, lessThan, lessThanOrEqual)] for the affected entry.
|
||||
Only ranges with a real upper bound are returned (exact-version-only and
|
||||
unbounded entries are skipped → no over-matching)."""
|
||||
out = []
|
||||
unaffected_floors: List[str] = []
|
||||
for v in aff.get("versions", []) or []:
|
||||
if not isinstance(v, dict):
|
||||
continue
|
||||
if (v.get("status") or "affected") != "affected":
|
||||
# Some CNAs state the INVERSE — Mozilla ships no affected range at
|
||||
# all, only "152.0.6 and up are unaffected" (status=unaffected,
|
||||
# lessThanOrEqual="*"). Skipping those meant the newest Firefox CVEs
|
||||
# never entered the index. "X and up are fixed" == "below X is
|
||||
# affected", so remember X as a fix bound.
|
||||
if (v.get("status") == "unaffected"
|
||||
and not v.get("lessThan")
|
||||
and (v.get("lessThanOrEqual") in ("*", None))):
|
||||
ver = v.get("version")
|
||||
if isinstance(ver, str) and ver.strip() not in ("0", "*", "-", ""):
|
||||
unaffected_floors.append(ver.strip())
|
||||
continue
|
||||
start = None
|
||||
lt = v.get("lessThan")
|
||||
lte = v.get("lessThanOrEqual")
|
||||
ver = v.get("version")
|
||||
if isinstance(ver, str):
|
||||
vs = ver.strip()
|
||||
if vs.startswith("<="):
|
||||
lte = lte or vs[2:].strip()
|
||||
elif vs.startswith("<"):
|
||||
lt = lt or vs[1:].strip()
|
||||
elif vs not in ("0", "*", "-", ""):
|
||||
start = vs
|
||||
if lt in ("*", "-", ""):
|
||||
lt = None
|
||||
if lte in ("*", "-", ""):
|
||||
lte = None
|
||||
if start is not None and (start == lt or start == lte):
|
||||
# Some CNAs emit "version" == "lessThan" (e.g. a few Chrome
|
||||
# records) — a literal reading gives a zero-width, impossible
|
||||
# range. NVD treats these as an open floor (no lower bound); do
|
||||
# the same rather than silently dropping the CVE.
|
||||
start = None
|
||||
if lt or lte:
|
||||
out.append((start, lt, lte))
|
||||
# Inverse-only records (see above): derive the fix bound from the single
|
||||
# "unaffected" floor. Only when the entry stated no affected range of its
|
||||
# own, and only when there is exactly one floor — several floors mean
|
||||
# several servicing branches (release vs ESR) and picking one would either
|
||||
# over- or under-report.
|
||||
if not out and len(unaffected_floors) == 1:
|
||||
out.append((None, unaffected_floors[0], None))
|
||||
return out
|
||||
|
||||
|
||||
# Recognised OS names in cvelistV5 affected[].platforms. Microsoft also uses
|
||||
# that field for CPU arch ("x64-based Systems", "ARM64-based Systems") — those
|
||||
# are NOT OS names, so when a record lists only arch/hardware we can't judge
|
||||
# the OS and must keep the finding (never drop on arch alone).
|
||||
_OS_PLATFORMS = {
|
||||
"windows": {"windows"},
|
||||
"macos": {"macos", "mac os", "mac os x", "os x", "mac"},
|
||||
"linux": {"linux"},
|
||||
"iphone_os": {"ios"},
|
||||
"ipados": {"ipados", "ios"},
|
||||
"android": {"android"},
|
||||
}
|
||||
_ALL_OS_TOKENS = {tok for toks in _OS_PLATFORMS.values() for tok in toks}
|
||||
|
||||
|
||||
def _platform_ok(asset_family: Optional[str], platforms) -> bool:
|
||||
"""Keep a finding unless the CVE explicitly lists OS platform(s) and the
|
||||
asset's OS isn't among them. Records with no platforms, or only
|
||||
arch/hardware platforms (x64/ARM/…), are kept (can't judge the OS)."""
|
||||
if not asset_family or not platforms:
|
||||
return True
|
||||
listed = {str(p).lower().strip() for p in platforms}
|
||||
os_listed = listed & _ALL_OS_TOKENS
|
||||
if not os_listed:
|
||||
return True # arch/hardware only → not an OS signal
|
||||
return bool(_OS_PLATFORMS.get(asset_family, set()) & os_listed)
|
||||
|
||||
|
||||
def _affected(installed: str, start: Optional[str], lt: Optional[str], lte: Optional[str]) -> bool:
|
||||
if start:
|
||||
c = cpe._vcmp(installed, start)
|
||||
if c is None or c < 0:
|
||||
return False
|
||||
if lt:
|
||||
c = cpe._vcmp(installed, lt)
|
||||
return c is not None and c < 0
|
||||
if lte:
|
||||
c = cpe._vcmp(installed, lte)
|
||||
return c is not None and c <= 0
|
||||
return False
|
||||
|
||||
|
||||
# ---------- index build + cache ----------
|
||||
def _ensure_zip() -> bool:
|
||||
fresh = (os.path.exists(_ZIP_PATH)
|
||||
and (time.time() - os.path.getmtime(_ZIP_PATH)) < _ZIP_TTL
|
||||
and os.path.getsize(_ZIP_PATH) > 100_000_000)
|
||||
if fresh:
|
||||
return True
|
||||
import httpx
|
||||
tmp = _ZIP_PATH + ".part"
|
||||
try:
|
||||
logger.info("cvelistv5-scan: downloading ZIP snapshot (~557 MB)…")
|
||||
with httpx.Client(timeout=httpx.Timeout(600.0, connect=15.0), follow_redirects=True) as c:
|
||||
with c.stream("GET", _ZIP_URL) as r:
|
||||
r.raise_for_status()
|
||||
with open(tmp, "wb") as f:
|
||||
for chunk in r.iter_bytes(chunk_size=1024 * 512):
|
||||
f.write(chunk)
|
||||
os.replace(tmp, _ZIP_PATH)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning("cvelistv5-scan: ZIP download failed: %s", e)
|
||||
if os.path.exists(tmp):
|
||||
try:
|
||||
os.remove(tmp)
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def build_product_index(db: Session) -> dict:
|
||||
"""Walk the cvelistV5 ZIP once, building {product_key: [{cve,start,lt,lte}]}
|
||||
for the curated products. Heavy (~250k files) → call from the nightly job.
|
||||
Caches the result in a Setting. Returns the index."""
|
||||
if not _ensure_zip():
|
||||
return load_index(db) or {}
|
||||
index: Dict[str, list] = {}
|
||||
seen: set = set()
|
||||
scanned = 0
|
||||
parsed = 0
|
||||
# Pre-filter on raw bytes: only JSON-parse files that mention a curated
|
||||
# vendor. ~99% of the 250k CVEs don't, so this skips that many json.loads
|
||||
# (the expensive part) — build drops from minutes to ~a minute.
|
||||
vendor_bytes = {v.encode() for (v, _p) in _PAIR_TO_KEY.keys()}
|
||||
with zipfile.ZipFile(_ZIP_PATH) as zf:
|
||||
for name in zf.namelist():
|
||||
if not name.endswith(".json") or "/cves/" not in name:
|
||||
continue
|
||||
scanned += 1
|
||||
try:
|
||||
raw = zf.read(name)
|
||||
except Exception:
|
||||
continue
|
||||
low = raw.lower()
|
||||
if not any(vb in low for vb in vendor_bytes):
|
||||
continue
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except Exception:
|
||||
continue
|
||||
parsed += 1
|
||||
cna = (data.get("containers") or {}).get("cna") or {}
|
||||
affected = cna.get("affected") or []
|
||||
if not affected:
|
||||
continue
|
||||
cve_id = ((data.get("cveMetadata") or {}).get("cveId") or "").upper()
|
||||
if not cve_id.startswith("CVE-"):
|
||||
continue
|
||||
cvss, sev = _cvss_from_record(data)
|
||||
for aff in affected:
|
||||
key = _pair_key(aff.get("vendor") or "", aff.get("product") or "")
|
||||
if not key:
|
||||
continue
|
||||
plats = [str(p).lower().strip() for p in (aff.get("platforms") or [])]
|
||||
prod = (aff.get("product") or "").strip()
|
||||
for start, lt, lte in _ranges_from_affected(aff):
|
||||
sig = (key, cve_id, start, lt, lte, prod.lower())
|
||||
if sig in seen:
|
||||
continue
|
||||
seen.add(sig)
|
||||
index.setdefault(key, []).append(
|
||||
{"cve": cve_id, "start": start, "lt": lt, "lte": lte,
|
||||
"plats": plats, "cvss": cvss, "sev": sev, "prod": prod})
|
||||
_store_index(db, index)
|
||||
logger.info("cvelistv5-scan: index built (%d files scanned, %d parsed) → %d products, %d ranges",
|
||||
scanned, parsed, len(index), sum(len(v) for v in index.values()))
|
||||
return index
|
||||
|
||||
|
||||
def _store_index(db: Session, index: dict) -> None:
|
||||
from app.models.setting import Setting
|
||||
payload = json.dumps({"built_at": datetime.now().isoformat(), "index": index})
|
||||
row = db.query(Setting).filter(Setting.key == _INDEX_SETTING).first()
|
||||
if row:
|
||||
row.value = payload
|
||||
else:
|
||||
db.add(Setting(key=_INDEX_SETTING, value=payload,
|
||||
description="cvelistV5 product→CVE reverse index (curated)"))
|
||||
db.commit()
|
||||
|
||||
|
||||
def load_index(db: Session, allow_stale: bool = True) -> Optional[dict]:
|
||||
from app.models.setting import Setting
|
||||
row = db.query(Setting).filter(Setting.key == _INDEX_SETTING).first()
|
||||
if not row or not row.value:
|
||||
return None
|
||||
try:
|
||||
blob = json.loads(row.value)
|
||||
built = datetime.fromisoformat(blob.get("built_at"))
|
||||
except Exception:
|
||||
return None
|
||||
if not allow_stale and datetime.now() - built > _INDEX_TTL:
|
||||
return None
|
||||
return blob.get("index") or {}
|
||||
|
||||
|
||||
# ---------- scan ----------
|
||||
# ---------- false-positive suppression ----------
|
||||
_FP_STOP = {"setup", "edition", "en", "english", "x64", "x86", "cu", "gdr",
|
||||
"for", "based", "systems", "the", "of", "and", "client", "full",
|
||||
"host", "version", "core", "server"}
|
||||
|
||||
|
||||
def _sig_tokens(s: str) -> set:
|
||||
"""Significant tokens: drop stopwords, 4-digit years, and bare numbers."""
|
||||
out = set()
|
||||
for t in re.findall(r"[a-z0-9]+", (s or "").lower()):
|
||||
if t in _FP_STOP or re.fullmatch(r"(19|20)\d{2}", t) or re.fullmatch(r"\d+", t):
|
||||
continue
|
||||
out.add(t)
|
||||
return out
|
||||
|
||||
|
||||
def _product_matches(installed_name: str, vendor: str, product: str) -> bool:
|
||||
"""True when the CVE's affected product is the same product family as the
|
||||
installed package. Requires ≥2 shared significant tokens — so only
|
||||
multi-word products (SQL Server, Visual Studio, …) where Wazuh's loose
|
||||
CPE match over-reports across editions are ever scoped for suppression;
|
||||
single-token apps never match → their findings are left untouched."""
|
||||
a = _sig_tokens(f"{vendor} {product}")
|
||||
b = _sig_tokens(installed_name)
|
||||
return len(a & b) >= 2
|
||||
|
||||
|
||||
def _zip_path_for(cve_id: str) -> Optional[str]:
|
||||
m = re.match(r"^CVE-(\d{4})-(\d+)$", cve_id)
|
||||
if not m:
|
||||
return None
|
||||
year, num = m.group(1), m.group(2)
|
||||
return f"cvelistV5-main/cves/{year}/{int(num) // 1000}xxx/{cve_id}.json"
|
||||
|
||||
|
||||
def suppress_false_positives(db: Session, asset_id: Optional[int] = None) -> dict:
|
||||
"""Auto-flag Wazuh findings whose installed version is provably OUTSIDE
|
||||
all cvelistV5 affected ranges for the matched product (e.g. a SQL Server
|
||||
2019 / 15.x host carrying a CVE that only affects 16.x/17.x because Wazuh
|
||||
matched 'Microsoft SQL Server' too loosely).
|
||||
|
||||
Conservative — only marks when ALL relevant affected entries have clean
|
||||
numeric ranges and the install is outside every one. Sets
|
||||
status=false_positive (reversible, audit-logged); never auto-unmarks.
|
||||
"""
|
||||
from app.models.vulnerability import Vulnerability, VulnerabilityStatus
|
||||
stats = {"checked": 0, "suppressed": 0, "errors": []}
|
||||
if not _ensure_zip():
|
||||
stats["errors"].append("cvelistV5 ZIP unavailable")
|
||||
return stats
|
||||
|
||||
q = (db.query(Vulnerability)
|
||||
.filter(Vulnerability.status == VulnerabilityStatus.open,
|
||||
Vulnerability.cve_id.like("CVE-%"),
|
||||
Vulnerability.sources.contains('"wazuh"'),
|
||||
Vulnerability.package_name.isnot(None),
|
||||
Vulnerability.package_version.isnot(None)))
|
||||
if asset_id is not None:
|
||||
q = q.filter(Vulnerability.asset_id == asset_id)
|
||||
candidates = q.all()
|
||||
if not candidates:
|
||||
return stats
|
||||
|
||||
suppressed_ids: list = []
|
||||
with zipfile.ZipFile(_ZIP_PATH) as zf:
|
||||
names = set(zf.namelist())
|
||||
for v in candidates:
|
||||
stats["checked"] += 1
|
||||
cver = cpe._clean_version(v.package_version or "")
|
||||
if not cver:
|
||||
continue
|
||||
path = _zip_path_for(v.cve_id)
|
||||
if not path or path not in names:
|
||||
continue # CVE not in snapshot → can't judge → keep
|
||||
try:
|
||||
data = json.loads(zf.read(path))
|
||||
except Exception:
|
||||
continue
|
||||
affected = ((data.get("containers") or {}).get("cna") or {}).get("affected") or []
|
||||
relevant = [a for a in affected
|
||||
if _product_matches(v.package_name, a.get("vendor") or "", a.get("product") or "")]
|
||||
if not relevant:
|
||||
continue # CVE doesn't clearly name this product → keep
|
||||
ranges = []
|
||||
uncertain = False
|
||||
for a in relevant:
|
||||
rs = _ranges_from_affected(a)
|
||||
if not rs:
|
||||
uncertain = True # an entry we can't bound → don't risk it
|
||||
break
|
||||
ranges.extend(rs)
|
||||
if uncertain or not ranges:
|
||||
continue
|
||||
if any(_affected(cver, s, lt, lte) for s, lt, lte in ranges):
|
||||
continue # installed IS in an affected range → real, keep
|
||||
# Outside every clean range → false positive.
|
||||
v.status = VulnerabilityStatus.false_positive
|
||||
v.notification_suppressed = True
|
||||
rng = "; ".join(f"[{s or '0'}, {lt or lte})" for s, lt, lte in ranges)
|
||||
v.defer_reason = (f"[auto] installed {v.package_version} is outside all "
|
||||
f"cvelistV5 affected ranges for this product ({rng})")[:500]
|
||||
suppressed_ids.append(v.id)
|
||||
stats["suppressed"] += 1
|
||||
|
||||
if suppressed_ids:
|
||||
db.commit()
|
||||
logger.info("cvelistV5 FP-suppression: checked %d, suppressed %d",
|
||||
stats["checked"], stats["suppressed"])
|
||||
return stats
|
||||
|
||||
|
||||
def _build_line(v: Optional[str]) -> Optional[tuple]:
|
||||
"""First three segments of a Windows build — the release line
|
||||
(10.0.14393.9234 → (10,0,14393) = Server 2016 / Win10 1607)."""
|
||||
t = cpe._vtuple(v or "")
|
||||
return t[:3] if t and len(t) >= 3 else None
|
||||
|
||||
|
||||
def _release_bounded(start: Optional[str], lt: Optional[str]) -> bool:
|
||||
"""True when a range actually pins ONE Windows release, i.e. its floor and
|
||||
its fix sit on the same build line (10.0.22631.0 .. 10.0.22631.7219).
|
||||
|
||||
Modern MS records do this; older ones (≈pre-2022) use a generic floor —
|
||||
CVE-2021-26432 says `version 10.0.0, lessThan 10.0.17763.2114` for Server
|
||||
2019, and that range swallows EVERY lower build, so a Server 2016 host
|
||||
(14393.9234) got flagged with a 17763 fix. Such an entry carries no release
|
||||
information at all, so the OS scan must skip it rather than guess: the OS
|
||||
string alone can't name the client release, which is the whole reason we
|
||||
lean on the bounds.
|
||||
"""
|
||||
sl, ll = _build_line(start), _build_line(lt)
|
||||
return bool(sl and ll and sl == ll)
|
||||
|
||||
|
||||
def scan_asset_os(db: Session, asset, index: dict,
|
||||
new_ids: Optional[list] = None, touched: Optional[set] = None) -> int:
|
||||
"""Windows OS CVEs straight from the asset's build (asset.os_version).
|
||||
|
||||
Two guards, both learned the hard way — either one alone is not enough:
|
||||
|
||||
1. FAMILY (_win_family): the entry's product name must belong to the same
|
||||
family as the asset. Windows 11 24H2 and Windows Server 2025 share build
|
||||
line 10.0.26100 but keep separate revision sequences, so CVE-2026-41089
|
||||
(Server 2025 only, fix .32860) otherwise matches a fully-patched 24H2
|
||||
client at .8655.
|
||||
2. RELEASE-BOUNDED (_release_bounded): the range's floor and fix must sit on
|
||||
one build line. Older records use a generic 10.0.0 floor, which swallows
|
||||
every lower build (CVE-2021-26432 put a 17763 fix on a 14393 host).
|
||||
|
||||
With both, the range only ever answers "patched or not" inside the host's
|
||||
own release — which is all it can honestly answer.
|
||||
"""
|
||||
if not index:
|
||||
return 0
|
||||
entries = index.get("windows") or []
|
||||
if not entries:
|
||||
return 0
|
||||
if cpe._os_family(asset.operating_system or "") != "windows":
|
||||
return 0
|
||||
fam_rx = _win_family(asset.operating_system or "")
|
||||
if not fam_rx:
|
||||
return 0 # Windows flavour we can't place (e.g. bare "Windows") → skip
|
||||
cver = cpe._clean_version(asset.os_version or "")
|
||||
if not cver:
|
||||
return 0
|
||||
if new_ids is None:
|
||||
new_ids = []
|
||||
label = (asset.operating_system or "Microsoft Windows").strip()
|
||||
count = 0
|
||||
for entry in entries:
|
||||
if not fam_rx.search((entry.get("prod") or "").strip()):
|
||||
continue # different Windows family → its revisions are unrelated
|
||||
if not _release_bounded(entry.get("start"), entry.get("lt")):
|
||||
continue # range can't tell releases apart → would cross-match
|
||||
if not _affected(cver, entry.get("start"), entry.get("lt"), entry.get("lte")):
|
||||
continue
|
||||
c = {"cve": entry["cve"], "cvss": entry.get("cvss"), "severity": entry.get("sev"),
|
||||
"fixed": entry.get("lt")}
|
||||
try:
|
||||
before = len(new_ids)
|
||||
cpe._upsert(db, asset, label, asset.os_version or cver, c, new_ids, touched=touched)
|
||||
count += 1 if len(new_ids) > before else 0
|
||||
except Exception as e:
|
||||
logger.debug("cvelistv5 OS upsert failed (%s on %s): %s", entry["cve"], asset.id, e)
|
||||
return count
|
||||
|
||||
|
||||
def scan_asset(db: Session, asset, packages: list, index: dict,
|
||||
new_ids: Optional[list] = None, touched: Optional[set] = None) -> int:
|
||||
"""Match an asset's installed software against the cvelistV5 index.
|
||||
Returns findings upserted. Caller commits."""
|
||||
if not index:
|
||||
return 0
|
||||
if new_ids is None:
|
||||
new_ids = []
|
||||
count = 0
|
||||
seen: set = set()
|
||||
fam = cpe._os_family(asset.operating_system or "")
|
||||
for pkg in packages or []:
|
||||
name = (pkg.get("name") or "").strip()
|
||||
version = (pkg.get("version") or "").strip()
|
||||
if not name or not version:
|
||||
continue
|
||||
if cpe._is_citrix_shim(pkg):
|
||||
continue # published-app registry stub, software not on the box
|
||||
key = resolve(name)
|
||||
if not key or key not in index:
|
||||
continue
|
||||
# name_ver products (modern .NET): the semantic version lives in the
|
||||
# display NAME; the version field is an MSI build that never matches.
|
||||
eff_ver = cpe._effective_version(name, version, _KEY_ENTRY.get(key) or {})
|
||||
cver = cpe._clean_version(eff_ver)
|
||||
if not cver:
|
||||
continue
|
||||
dedup = (key, cver)
|
||||
if dedup in seen:
|
||||
continue
|
||||
seen.add(dedup)
|
||||
for entry in index[key]:
|
||||
if not _affected(cver, entry.get("start"), entry.get("lt"), entry.get("lte")):
|
||||
continue
|
||||
if not _platform_ok(fam, entry.get("plats")):
|
||||
continue # CVE is for a different OS platform (e.g. Teams-for-Mac)
|
||||
# Only lessThan is a real fix target; lessThanOrEqual means that
|
||||
# version is still affected (no published fix) → leave fixed empty.
|
||||
c = {"cve": entry["cve"], "cvss": entry.get("cvss"), "severity": entry.get("sev"),
|
||||
"fixed": entry.get("lt")}
|
||||
try:
|
||||
before = len(new_ids)
|
||||
cpe._upsert(db, asset, name, eff_ver, c, new_ids, touched=touched)
|
||||
count += 1 if len(new_ids) > before else 0
|
||||
except Exception as e:
|
||||
logger.debug("cvelistv5 upsert failed (%s on %s): %s", entry["cve"], asset.id, e)
|
||||
return count
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# ponytail: one self-check for the metric parser (CNA v3.1 preferred, ADP
|
||||
# fallback, missing → None). Run: python -m app.services.cvelistv5_scan_service
|
||||
rec = {"containers": {"cna": {"metrics": [{"cvssV3_1": {"baseScore": 4.3, "baseSeverity": "MEDIUM"}}]},
|
||||
"adp": [{"metrics": [{"cvssV3_1": {"baseScore": 9.8, "baseSeverity": "CRITICAL"}}]}]}}
|
||||
assert _cvss_from_record(rec) == (4.3, "medium"), _cvss_from_record(rec) # CNA wins
|
||||
assert _cvss_from_record({"containers": {"adp": [{"metrics": [{"cvssV3_0": {"baseScore": 7.5}}]}]}}) == (7.5, None)
|
||||
assert _cvss_from_record({"containers": {"cna": {}}}) == (None, None) # no metrics
|
||||
print("cvelistv5 _cvss_from_record self-check OK")
|
||||
@@ -0,0 +1,257 @@
|
||||
"""
|
||||
Microsoft Defender for Endpoint (TVM) → real per-device CVEs.
|
||||
|
||||
Phase 3 of the Intune integration. Reuses the Entra app from intune_config
|
||||
(toggle `defender_tvm`) but talks to the Defender API. Maps each Defender
|
||||
machine to an existing asset (by computerDnsName) and upserts REAL CVE
|
||||
rows (source='defender'), which then enrich via the normal EPSS/KEV/
|
||||
cvelistV5 + multi-source-remediation paths.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.asset import Asset
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SOURCE_NAME = "defender"
|
||||
|
||||
_SEV_MAP = {
|
||||
"critical": "critical", "high": "high", "medium": "medium",
|
||||
"low": "low", "informational": "none", "none": "none",
|
||||
}
|
||||
|
||||
|
||||
def _severity(raw: Optional[str]):
|
||||
from app.models.vulnerability import VulnerabilitySeverity
|
||||
return {
|
||||
"critical": VulnerabilitySeverity.critical,
|
||||
"high": VulnerabilitySeverity.high,
|
||||
"medium": VulnerabilitySeverity.medium,
|
||||
"low": VulnerabilitySeverity.low,
|
||||
"none": VulnerabilitySeverity.none,
|
||||
}.get(_SEV_MAP.get((raw or "").lower(), "medium"), VulnerabilitySeverity.medium)
|
||||
|
||||
|
||||
def _match_asset(db: Session, machine: dict):
|
||||
"""Match a Defender machine to an asset by stable id
|
||||
(defender_machine_id → aad_device_id → computerDnsName). Matching on the
|
||||
Entra/AAD device id first merges the machine onto the SAME asset the Intune
|
||||
sync created (usually the cleaner name), instead of forking a second asset
|
||||
off Defender's management-name computerDnsName."""
|
||||
mid = (machine.get("id") or "").strip() or None
|
||||
aad_id = (machine.get("aadDeviceId") or "").strip() or None
|
||||
dns = (machine.get("computerDnsName") or "").strip()
|
||||
|
||||
def _pin(a):
|
||||
if mid and not a.defender_machine_id:
|
||||
a.defender_machine_id = mid
|
||||
if aad_id and not a.aad_device_id:
|
||||
a.aad_device_id = aad_id
|
||||
|
||||
if mid:
|
||||
a = db.query(Asset).filter(Asset.defender_machine_id == mid).first()
|
||||
if a:
|
||||
_pin(a)
|
||||
return a
|
||||
if aad_id:
|
||||
a = db.query(Asset).filter(Asset.aad_device_id == aad_id).first()
|
||||
if a:
|
||||
_pin(a)
|
||||
return a
|
||||
short = dns.split(".")[0] if dns else ""
|
||||
for cand in [c for c in (dns, short) if c]:
|
||||
a = db.query(Asset).filter(Asset.hostname.ilike(cand)).first()
|
||||
if a:
|
||||
_pin(a)
|
||||
return a
|
||||
if short:
|
||||
a = db.query(Asset).filter(Asset.hostname.ilike(f"{short}.%")).first()
|
||||
if a:
|
||||
_pin(a)
|
||||
return a
|
||||
return None
|
||||
|
||||
|
||||
def _upsert_cve(db: Session, asset, vuln: dict, new_ids: list, software: Optional[str] = None) -> None:
|
||||
from app.models.vulnerability import Vulnerability, VulnerabilityStatus
|
||||
cve_id = (vuln.get("id") or "").strip().upper()
|
||||
if not cve_id.startswith("CVE-"):
|
||||
return
|
||||
cvss = vuln.get("cvssV3")
|
||||
try:
|
||||
cvss = float(cvss) if cvss is not None else None
|
||||
except (TypeError, ValueError):
|
||||
cvss = None
|
||||
sev = _severity(vuln.get("severity"))
|
||||
|
||||
existing = (
|
||||
db.query(Vulnerability)
|
||||
.filter(Vulnerability.cve_id == cve_id, Vulnerability.asset_id == asset.id)
|
||||
.first()
|
||||
)
|
||||
if existing:
|
||||
existing.add_source(SOURCE_NAME)
|
||||
# Fill the affected-software/package column if it was empty.
|
||||
if software and not existing.package_name:
|
||||
existing.package_name = software[:255]
|
||||
if existing.status == VulnerabilityStatus.patched:
|
||||
existing.status = VulnerabilityStatus.open
|
||||
existing.patched_at = None
|
||||
try:
|
||||
existing.refresh_scores()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
row = Vulnerability(
|
||||
cve_id=cve_id,
|
||||
asset_id=asset.id,
|
||||
cvss_score=cvss,
|
||||
severity=sev,
|
||||
status=VulnerabilityStatus.open,
|
||||
title=(vuln.get("name") or cve_id)[:500],
|
||||
description=(vuln.get("description") or None),
|
||||
package_name=(software[:255] if software else None),
|
||||
detected_at=datetime.now(),
|
||||
sources=json.dumps([SOURCE_NAME]),
|
||||
first_detected_by=SOURCE_NAME,
|
||||
)
|
||||
db.add(row)
|
||||
db.flush()
|
||||
try:
|
||||
row.refresh_scores()
|
||||
except Exception:
|
||||
pass
|
||||
new_ids.append(row.id)
|
||||
|
||||
|
||||
def _resolve_stale(db: Session, asset, seen_cves: set) -> int:
|
||||
"""Mark defender-only OPEN findings on this asset patched when Defender no
|
||||
longer reports them (device remediated). Leaves findings any other scanner
|
||||
still reports. Writes a revisionssicher status-change row per resolve."""
|
||||
from app.models.vulnerability import Vulnerability, VulnerabilityStatus
|
||||
rows = (db.query(Vulnerability)
|
||||
.filter(Vulnerability.asset_id == asset.id,
|
||||
Vulnerability.status == VulnerabilityStatus.open,
|
||||
Vulnerability.sources.contains('"defender"'))
|
||||
.all())
|
||||
resolved = 0
|
||||
for v in rows:
|
||||
if v.cve_id in seen_cves:
|
||||
continue
|
||||
# Drop OUR source; close only when nobody else still reports it (same
|
||||
# contract as the Nessus backfill — the old skip-if-cross-confirmed
|
||||
# rule deadlocked with the app-scan reconcile and left patched hosts
|
||||
# with permanently open cross-confirmed findings).
|
||||
v.remove_source(SOURCE_NAME)
|
||||
if v.source_list:
|
||||
continue
|
||||
old_status = v.status
|
||||
v.status = VulnerabilityStatus.patched
|
||||
v.patched_at = datetime.now()
|
||||
resolved += 1
|
||||
try:
|
||||
from app.routers.vulnerabilities import log_vulnerability_change
|
||||
log_vulnerability_change(
|
||||
db, None, v.id, old_status, v.status,
|
||||
reason=f"Defender TVM no longer reports this CVE on {asset.hostname} (device remediated)",
|
||||
cve_id=v.cve_id, source="defender_sync",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("audit log for defender auto-resolve failed (vuln_id=%s): %s", v.id, e)
|
||||
return resolved
|
||||
|
||||
|
||||
def run_defender_sync(db: Session) -> dict:
|
||||
"""Pull Defender TVM CVEs and upsert per matched asset. Returns stats."""
|
||||
from app.services.intune_service import load_intune_config
|
||||
from app.integrations.defender_client import DefenderClient
|
||||
|
||||
cfg = load_intune_config(db)
|
||||
if not cfg or not cfg.get("defender_tvm"):
|
||||
return {"skipped": "defender_tvm disabled"}
|
||||
|
||||
client = DefenderClient(cfg["tenant_id"], cfg["client_id"], cfg["client_secret"],
|
||||
verify_ssl=cfg.get("verify_ssl", True))
|
||||
stats = {"machines": 0, "matched": 0, "unmatched": 0, "cve_rows": 0, "new": 0, "errors": []}
|
||||
new_ids: list = []
|
||||
try:
|
||||
machines = client.get_machines()
|
||||
except Exception as e:
|
||||
client.close()
|
||||
raise RuntimeError(f"Defender machines fetch failed: {e}") from e
|
||||
|
||||
# (machineId, CVE) → affected software string. One tenant-wide export
|
||||
# call; the per-machine /vulnerabilities endpoint omits software.
|
||||
sw_map: dict = {}
|
||||
try:
|
||||
for r in client.get_software_vulnerabilities_by_machine():
|
||||
mid = (r.get("deviceId") or r.get("machineId") or "").strip()
|
||||
cve = (r.get("cveId") or "").strip().upper()
|
||||
if not mid or not cve:
|
||||
continue
|
||||
vendor = (r.get("softwareVendor") or r.get("productVendor") or "").strip()
|
||||
name = (r.get("softwareName") or r.get("productName") or "").strip()
|
||||
ver = (r.get("softwareVersion") or r.get("productVersion") or "").strip()
|
||||
label = " ".join(x for x in (vendor, name, ver) if x).strip()
|
||||
if label and (mid, cve) not in sw_map:
|
||||
sw_map[(mid, cve)] = label
|
||||
except Exception as e:
|
||||
logger.debug("defender software map build failed: %s", e)
|
||||
|
||||
for m in machines:
|
||||
stats["machines"] += 1
|
||||
asset = _match_asset(db, m)
|
||||
if not asset:
|
||||
stats["unmatched"] += 1
|
||||
continue
|
||||
stats["matched"] += 1
|
||||
seen_cves: set = set()
|
||||
try:
|
||||
vulns = client.get_machine_vulnerabilities(m["id"])
|
||||
for v in vulns:
|
||||
cve = (v.get("id") or "").strip().upper()
|
||||
if cve:
|
||||
seen_cves.add(cve)
|
||||
software = sw_map.get((m.get("id", ""), cve))
|
||||
_upsert_cve(db, asset, v, new_ids, software=software)
|
||||
stats["cve_rows"] += 1
|
||||
# Auto-resolve defender-only findings this machine no longer reports.
|
||||
# Guarded to non-empty responses so a transient/clean read can't
|
||||
# mass-close (same safety as the Nessus/app-scan backfills).
|
||||
if seen_cves:
|
||||
stats["resolved"] = stats.get("resolved", 0) + _resolve_stale(db, asset, seen_cves)
|
||||
except Exception as e:
|
||||
stats["errors"].append(f"machine {m.get('computerDnsName')}: {e}")
|
||||
db.commit()
|
||||
|
||||
client.close()
|
||||
stats["new"] = len(new_ids)
|
||||
|
||||
# Initial detected-audit + metric/date enrichment for the new CVEs.
|
||||
if new_ids:
|
||||
try:
|
||||
from app.services.audit_events import audit_new_vulnerabilities
|
||||
audit_new_vulnerabilities(db, new_ids, source="defender")
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
logger.debug("defender detected-audit failed: %s", e)
|
||||
try:
|
||||
from app.models.vulnerability import Vulnerability
|
||||
from app.services.enrichment_service import enrich_vulnerabilities
|
||||
fresh = db.query(Vulnerability).filter(Vulnerability.id.in_(new_ids)).all()
|
||||
if fresh:
|
||||
enrich_vulnerabilities(db, fresh)
|
||||
# New-CVE email notifications (was Wazuh/Nessus-only). Same path.
|
||||
from app.services.email_service import dispatch_new_vuln_notifications
|
||||
stats["notifications"] = dispatch_new_vuln_notifications(db, fresh)
|
||||
except Exception as e:
|
||||
logger.debug("defender enrichment/notify failed: %s", e)
|
||||
|
||||
logger.info("Defender TVM sync: %s", {k: v for k, v in stats.items() if k != "errors"})
|
||||
return stats
|
||||
+302
-27
@@ -8,6 +8,7 @@ import os
|
||||
import re
|
||||
import smtplib
|
||||
from datetime import datetime
|
||||
from urllib.parse import quote
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from typing import Optional
|
||||
@@ -94,7 +95,7 @@ DEFAULT_SLA_BREACH_TEMPLATE = """<!DOCTYPE html>
|
||||
<a href="{{dashboard_url}}" class="btn">REMEDIATE NOW</a>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<strong>VulnCheck Security Operations Center</strong><br>
|
||||
<strong>TrueVuln Security Operations Center</strong><br>
|
||||
Automated compliance monitoring system. Do not reply to this email.<br>
|
||||
<span style="font-size: 10px; opacity: 0.7;">Sent to: {{recipient_name}} ({{recipient_email}})</span>
|
||||
</div>
|
||||
@@ -147,6 +148,14 @@ DEFAULT_NEW_VULN_TEMPLATE = """<!DOCTYPE html>
|
||||
<span class="detail-label">Package</span>
|
||||
<span class="detail-value">{{package_name}}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">CPR (priority)</span>
|
||||
<span class="detail-value">{{cpr_score}}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Affected systems</span>
|
||||
<span class="detail-value">{{affected_assets_count}}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Detected At</span>
|
||||
<span class="detail-value">{{detected_at}}</span>
|
||||
@@ -162,10 +171,11 @@ DEFAULT_NEW_VULN_TEMPLATE = """<!DOCTYPE html>
|
||||
{{description}}
|
||||
</div>
|
||||
|
||||
<a href="{{dashboard_url}}" class="action-btn">View Details & Remediate</a>
|
||||
<a href="{{cve_on_asset_link}}" class="action-btn">View this CVE on {{asset_hostname}}</a>
|
||||
<a href="{{asset_link}}" class="action-btn" style="background:#495057;margin-top:10px;">View all findings on this asset</a>
|
||||
</div>
|
||||
<div class="footer">
|
||||
Generated by VulnCheck Dashboard • {{detected_at}}
|
||||
Generated by TrueVuln Dashboard • {{detected_at}}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
@@ -178,7 +188,7 @@ DEFAULT_NEW_VULN_SUBJECT = "ALERT: New {{severity_upper}} Vulnerability ({{cve_i
|
||||
# Digest variant — one mail per recipient summarising N new CVEs
|
||||
# instead of one mail per CVE. Selected by setting `notification_mode`.
|
||||
# ---------------------------------------------------------------
|
||||
DEFAULT_DIGEST_SUBJECT = "[VULNCHECK] {{total}} new vulnerabilities detected"
|
||||
DEFAULT_DIGEST_SUBJECT = "[TRUEVULN] {{total}} new vulnerabilities detected"
|
||||
DEFAULT_DIGEST_TEMPLATE = """<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><style>
|
||||
body{font-family:Arial,sans-serif;color:#1f2937;max-width:760px;margin:24px auto;padding:0 16px}
|
||||
@@ -211,41 +221,76 @@ td{padding:7px 6px;border-bottom:1px solid #f3f4f6}
|
||||
<div class="low">LOW<br>{{count_low}}</div>
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr><th>CVE</th><th>Severity</th><th>CVSS</th><th>Host</th><th>Package</th></tr></thead>
|
||||
<thead><tr><th>CVE</th><th>Severity</th><th>CVSS</th><th>CPR</th><th>Systems</th><th>Package</th></tr></thead>
|
||||
<tbody>{{rows}}</tbody>
|
||||
</table>
|
||||
<a class="btn" href="{{dashboard_url}}">Open in dashboard</a>
|
||||
<div class="foot">
|
||||
You receive this because the affected asset or vulnerability is assigned to you or one of your groups.
|
||||
Manage assignments and suppression in the VulnCheck UI.
|
||||
Manage assignments and suppression in the TrueVuln UI.
|
||||
</div>
|
||||
</div></body></html>"""
|
||||
|
||||
|
||||
def render_digest_rows(items: list) -> str:
|
||||
"""Render the <tr> rows for the digest table. Items: list of dicts with
|
||||
cve_id, severity, cvss_score, asset_hostname, package_name. All dynamic
|
||||
values HTML-escaped to prevent injection from compromised scanner data."""
|
||||
rows = []
|
||||
def aggregate_by_cve(items: list) -> list:
|
||||
"""Collapse per-(CVE, asset) items to ONE entry per CVE. The representative
|
||||
is the highest-CPR occurrence; `systems` is how many distinct assets in this
|
||||
batch carry the CVE. Sorted by CPR descending (unscored last)."""
|
||||
groups: dict[str, dict] = {}
|
||||
for it in items:
|
||||
cve = str(it.get("cve_id") or "")
|
||||
g = groups.get(cve)
|
||||
if g is None:
|
||||
g = groups[cve] = {"rep": it, "assets": set()}
|
||||
if it.get("asset_id") is not None:
|
||||
g["assets"].add(it["asset_id"])
|
||||
if (it.get("cpr_score") or -1) > (g["rep"].get("cpr_score") or -1):
|
||||
g["rep"] = it
|
||||
out = []
|
||||
for cve, g in groups.items():
|
||||
rep = dict(g["rep"])
|
||||
rep["systems"] = len(g["assets"]) or 1
|
||||
out.append(rep)
|
||||
out.sort(key=lambda it: (it.get("cpr_score") is None, -(it.get("cpr_score") or 0.0)))
|
||||
return out
|
||||
|
||||
|
||||
def render_digest_rows(items: list, base_url: str = "") -> str:
|
||||
"""Render the digest table — ONE row per CVE, not per (CVE, asset). A CVE on
|
||||
200 hosts is one line: the CVE links to the filtered vulnerability view
|
||||
(?cve_id=…) that lists every affected asset, and #Systems says how many.
|
||||
Columns: CVE | Sev | CVSS | CPR | #Systems | Package, CPR-descending. All
|
||||
dynamic values HTML-escaped against compromised scanner data."""
|
||||
rows = []
|
||||
for it in aggregate_by_cve(items):
|
||||
raw_sev = (it.get("severity") or "none").lower()
|
||||
# whitelist severity for CSS class — anything else falls back to 'none'
|
||||
sev = raw_sev if raw_sev in {"critical", "high", "medium", "low", "none"} else "none"
|
||||
cvss = it.get("cvss_score")
|
||||
cvss_str = html.escape(str(cvss)) if cvss is not None else "-"
|
||||
cpr = it.get("cpr_score")
|
||||
cpr_str = html.escape(f"{cpr:.1f}") if isinstance(cpr, (int, float)) else "-"
|
||||
systems = it.get("systems") or 1
|
||||
cve = str(it.get("cve_id", ""))
|
||||
cve_esc = html.escape(cve)
|
||||
if base_url and cve:
|
||||
link = f"{base_url}?cve_id={quote(cve)}" # no asset_id → shows ALL affected assets
|
||||
cve_cell = f"<a href='{html.escape(link)}'>{cve_esc}</a>"
|
||||
else:
|
||||
cve_cell = f"<strong>{cve_esc}</strong>"
|
||||
rows.append(
|
||||
f"<tr>"
|
||||
f"<td><strong>{html.escape(str(it.get('cve_id', '')))}</strong></td>"
|
||||
f"<td>{cve_cell}</td>"
|
||||
f"<td><span class='sev sev-{sev}'>{sev.upper()}</span></td>"
|
||||
f"<td>{cvss_str}</td>"
|
||||
f"<td>{html.escape(str(it.get('asset_hostname', '')))}</td>"
|
||||
f"<td>{cpr_str}</td>"
|
||||
f"<td>{html.escape(str(systems))}</td>"
|
||||
f"<td>{html.escape(str(it.get('package_name') or '')[:60])}</td>"
|
||||
f"</tr>"
|
||||
)
|
||||
return "".join(rows)
|
||||
|
||||
|
||||
DEFAULT_SLA_DIGEST_SUBJECT = "[VULNCHECK] {{total}} SLA-breached vulnerabilities require action"
|
||||
DEFAULT_SLA_DIGEST_SUBJECT = "[TRUEVULN] {{total}} SLA-breached vulnerabilities require action"
|
||||
DEFAULT_SLA_DIGEST_TEMPLATE = """<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><style>
|
||||
body{font-family:Arial,sans-serif;color:#1f2937;max-width:780px;margin:24px auto;padding:0 16px}
|
||||
@@ -371,14 +416,25 @@ def send_new_vulnerability_digest(
|
||||
if not items:
|
||||
return False, "no items"
|
||||
|
||||
# Counts are per DISTINCT CVE, matching the one-row-per-CVE table — a CVE on
|
||||
# 200 hosts counts once, not 200×. `total` = distinct CVEs; the top-level
|
||||
# affected-systems tile stays distinct assets across the whole digest.
|
||||
counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "none": 0}
|
||||
_assets: set = set()
|
||||
for it in items:
|
||||
if it.get("asset_id") is not None:
|
||||
_assets.add(it["asset_id"])
|
||||
unique = aggregate_by_cve(items)
|
||||
for it in unique:
|
||||
sev = (it.get("severity") or "none").lower()
|
||||
if sev in counts:
|
||||
counts[sev] += 1
|
||||
|
||||
variables = {
|
||||
"total": str(len(items)),
|
||||
"total": str(len(unique)),
|
||||
# Distinct assets across this digest (top-level "affected systems" tile).
|
||||
# Per-CVE spread is the #Systems column inside {{rows}}.
|
||||
"affected_assets_count": str(len(_assets)),
|
||||
"count_critical": str(counts["critical"]),
|
||||
"count_high": str(counts["high"]),
|
||||
"count_medium": str(counts["medium"]),
|
||||
@@ -387,7 +443,7 @@ def send_new_vulnerability_digest(
|
||||
"recipient_name": recipient_name,
|
||||
"recipient_email": to_email,
|
||||
"dashboard_url": dashboard_url,
|
||||
"rows": render_digest_rows(items),
|
||||
"rows": render_digest_rows(items, base_url=dashboard_url),
|
||||
}
|
||||
|
||||
subject_template, body_template = get_email_template(db, "email_template_new_vuln_digest")
|
||||
@@ -545,11 +601,115 @@ def get_notification_mode(db: Session) -> str:
|
||||
return "digest"
|
||||
|
||||
|
||||
def _get_setting_str(db: Session, key: str) -> Optional[str]:
|
||||
from app.models.setting import Setting
|
||||
try:
|
||||
s = db.query(Setting).filter(Setting.key == key).first()
|
||||
return s.value.strip() if s and s.value else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def is_lifecycle_finding(cve_id: Optional[str]) -> bool:
|
||||
"""True for pseudo-findings that aren't real CVEs — endoflife.date rows
|
||||
(EOL-*), Android patch-level staleness (ANDROID-PATCH-*), Nessus plugin
|
||||
rows (NESSUS-PLUGIN-*), etc. Deliberately 'anything not CVE-*' so a new
|
||||
pseudo prefix is covered without touching this."""
|
||||
return not (cve_id or "").upper().startswith("CVE-")
|
||||
|
||||
|
||||
def get_notification_lifecycle_mode(db: Session) -> str:
|
||||
"""`notification_lifecycle_mode`: 'exclude' (default) or 'include'.
|
||||
exclude = lifecycle/EOL pseudo-findings never trigger the CVE mails, so
|
||||
vulnerability reporting stays unmixed with lifecycle hygiene.
|
||||
include = legacy behaviour, everything in one mail."""
|
||||
val = (_get_setting_str(db, "notification_lifecycle_mode") or "").lower()
|
||||
return val if val in ("exclude", "include") else "exclude"
|
||||
|
||||
|
||||
def get_notification_schedule(db: Session) -> str:
|
||||
"""`notification_schedule`: 'per_sync' (default) or 'nightly'.
|
||||
per_sync = notify at the end of each sync run (immediate).
|
||||
nightly = per-sync sends are suppressed; one roundup job at night sends
|
||||
ALL of the day's new CVEs in a single aggregated mail per
|
||||
recipient (fewer mails → friendlier to provider anti-spam)."""
|
||||
val = (_get_setting_str(db, "notification_schedule") or "").lower()
|
||||
return val if val in ("per_sync", "nightly") else "per_sync"
|
||||
|
||||
|
||||
def get_notification_nightly_hour(db: Session) -> int:
|
||||
"""Hour (0-23, server local time) the nightly roundup fires. Default 6."""
|
||||
try:
|
||||
h = int(_get_setting_str(db, "notification_nightly_hour") or "6")
|
||||
return h if 0 <= h <= 23 else 6
|
||||
except (TypeError, ValueError):
|
||||
return 6
|
||||
|
||||
|
||||
def get_email_rate_limit(db: Session) -> tuple[float, int]:
|
||||
"""(delay_seconds_between_mails, max_mails_per_run). 0 = unlimited/no delay.
|
||||
Throttles the send loop so a big batch doesn't trip provider rate limits."""
|
||||
try:
|
||||
delay = float(_get_setting_str(db, "email_rate_delay_seconds") or "0")
|
||||
except (TypeError, ValueError):
|
||||
delay = 0.0
|
||||
try:
|
||||
cap = int(_get_setting_str(db, "email_max_per_run") or "0")
|
||||
except (TypeError, ValueError):
|
||||
cap = 0
|
||||
return max(0.0, delay), max(0, cap)
|
||||
|
||||
|
||||
def get_default_recipients(db: Session) -> list[tuple]:
|
||||
"""Fallback recipients for findings with NO assignee anywhere in the
|
||||
cascade. The `notification_default_recipients` setting (comma/semicolon/
|
||||
space-separated emails) wins; if it's unset/empty, every active admin
|
||||
user — so 'the admin gets everything' works out of the box, which is
|
||||
what operators expect after configuring SMTP + an admin email but never
|
||||
assigning the thousands of scanner findings to anyone.
|
||||
|
||||
Returns (user_id_or_None, email, display_name).
|
||||
"""
|
||||
from app.models.setting import Setting
|
||||
from app.models.user import User, UserRole
|
||||
|
||||
out: list[tuple] = []
|
||||
seen: set[str] = set()
|
||||
raw = None
|
||||
try:
|
||||
s = db.query(Setting).filter(Setting.key == "notification_default_recipients").first()
|
||||
raw = s.value if s else None
|
||||
except Exception:
|
||||
raw = None
|
||||
|
||||
if raw and raw.strip():
|
||||
for em in (e.strip() for e in re.split(r"[,;\s]+", raw) if e.strip()):
|
||||
if em in seen:
|
||||
continue
|
||||
u = db.query(User).filter(User.email == em).first()
|
||||
out.append(((u.id if u else None), em, (u.username if u else em)))
|
||||
seen.add(em)
|
||||
return out
|
||||
|
||||
# No configured default → all active admins.
|
||||
try:
|
||||
admins = db.query(User).filter(
|
||||
User.role == UserRole.ADMIN, User.is_active.is_(True)).all()
|
||||
except Exception:
|
||||
admins = []
|
||||
for u in admins:
|
||||
if u.email and u.email not in seen:
|
||||
out.append((u.id, u.email, u.username))
|
||||
seen.add(u.email)
|
||||
return out
|
||||
|
||||
|
||||
def _resolve_recipients_for_vuln(db: Session, vuln) -> list[tuple[int, str, str]]:
|
||||
"""
|
||||
Returns list of (user_id, email, username) for a vulnerability following
|
||||
the cascade: vuln.assigned_user > vuln.assigned_group > asset.assigned_user
|
||||
> asset.assigned_group. Empty list when nothing matches.
|
||||
> asset.assigned_group. When the cascade is empty, falls back to the
|
||||
configured default recipients / active admins (get_default_recipients).
|
||||
"""
|
||||
from app.models.group import Group
|
||||
from app.models.user import User
|
||||
@@ -575,16 +735,44 @@ def _resolve_recipients_for_vuln(db: Session, vuln) -> list[tuple[int, str, str]
|
||||
for g in vuln.asset.groups:
|
||||
for u in g.users:
|
||||
_push(u)
|
||||
|
||||
if not recipients:
|
||||
for uid, email, uname in get_default_recipients(db):
|
||||
if email and email not in seen_emails:
|
||||
recipients.append((uid, email, uname))
|
||||
seen_emails.add(email)
|
||||
return recipients
|
||||
|
||||
|
||||
def dispatch_new_vuln_notifications(db: Session, new_vulns: list) -> dict:
|
||||
def _affected_counts(db: Session, cve_ids: set) -> dict:
|
||||
"""{cve_id: number of distinct assets with this CVE in an active state}.
|
||||
One grouped query for the whole batch."""
|
||||
if not cve_ids:
|
||||
return {}
|
||||
from sqlalchemy import func
|
||||
from app.models.vulnerability import Vulnerability, VulnerabilityStatus
|
||||
active = (VulnerabilityStatus.open, VulnerabilityStatus.pending_verification,
|
||||
VulnerabilityStatus.patch_failed)
|
||||
rows = (
|
||||
db.query(Vulnerability.cve_id, func.count(func.distinct(Vulnerability.asset_id)))
|
||||
.filter(Vulnerability.cve_id.in_(list(cve_ids)), Vulnerability.status.in_(active))
|
||||
.group_by(Vulnerability.cve_id)
|
||||
.all()
|
||||
)
|
||||
return {cve: cnt for cve, cnt in rows}
|
||||
|
||||
|
||||
def dispatch_new_vuln_notifications(db: Session, new_vulns: list, respect_schedule: bool = True) -> dict:
|
||||
"""
|
||||
Entry point for the Wazuh sync. Groups new vulns by recipient,
|
||||
applies the severity threshold, and dispatches either:
|
||||
Shared entry point for every sync (Wazuh/Nessus/app-scan/Defender). Groups
|
||||
new vulns by recipient, applies the severity threshold, and dispatches either:
|
||||
- one digest email per recipient (mode='digest', default), or
|
||||
- one email per CVE per recipient (mode='single', legacy).
|
||||
|
||||
respect_schedule: when True (sync callers) and notification_schedule is
|
||||
'nightly', sending is skipped here — the nightly roundup job sends instead.
|
||||
The nightly job calls with respect_schedule=False to actually send.
|
||||
|
||||
Returns a stats dict for logging.
|
||||
"""
|
||||
from app.models.notification_log import NotificationLog, NotificationType, NotificationStatus
|
||||
@@ -593,15 +781,35 @@ def dispatch_new_vuln_notifications(db: Session, new_vulns: list) -> dict:
|
||||
if not new_vulns:
|
||||
return stats
|
||||
|
||||
# Keep CVE reporting unmixed with lifecycle/EOL hygiene findings unless the
|
||||
# operator opts back in. Applies to both delivery modes and both schedules.
|
||||
if get_notification_lifecycle_mode(db) == "exclude":
|
||||
kept = [v for v in new_vulns if not is_lifecycle_finding(v.cve_id)]
|
||||
skipped = len(new_vulns) - len(kept)
|
||||
if skipped:
|
||||
stats["lifecycle_skipped"] = skipped
|
||||
new_vulns = kept
|
||||
if not new_vulns:
|
||||
return stats
|
||||
|
||||
if respect_schedule and get_notification_schedule(db) == "nightly":
|
||||
stats["deferred_to_nightly"] = len(new_vulns)
|
||||
return stats
|
||||
|
||||
smtp = get_smtp_config(db)
|
||||
if not smtp:
|
||||
logger.info("SMTP not configured — skipping new-vuln notifications")
|
||||
return stats
|
||||
|
||||
rate_delay, rate_cap = get_email_rate_limit(db)
|
||||
mode = get_notification_mode(db)
|
||||
dashboard_url = os.getenv("DASHBOARD_URL", "http://localhost:3000").rstrip("/") + "/vulnerabilities"
|
||||
detected_at_str = datetime.now().strftime("%Y-%m-%d %H:%M UTC")
|
||||
|
||||
# How many distinct assets each CVE affects (whole inventory, not just this
|
||||
# batch) — lets the template convey blast radius / spread.
|
||||
affected = _affected_counts(db, {v.cve_id for v in new_vulns if v.cve_id})
|
||||
|
||||
# Bucket vulns per recipient email (only those above the severity threshold).
|
||||
buckets: dict[str, dict] = {} # email -> {user_id, username, items: [vuln_summary]}
|
||||
notif_log_anchors: dict[str, list] = {} # email -> list of (vuln_id, asset_id) for logging
|
||||
@@ -615,6 +823,8 @@ def dispatch_new_vuln_notifications(db: Session, new_vulns: list) -> dict:
|
||||
"cve_id": vuln.cve_id,
|
||||
"severity": vuln.severity.value if vuln.severity else "none",
|
||||
"cvss_score": vuln.cvss_score,
|
||||
"cpr_score": vuln.cpr_score,
|
||||
"affected_count": affected.get(vuln.cve_id, 1),
|
||||
"asset_hostname": vuln.asset.hostname if vuln.asset else "Unknown",
|
||||
"package_name": vuln.package_name,
|
||||
"vuln_id": vuln.id,
|
||||
@@ -624,7 +834,16 @@ def dispatch_new_vuln_notifications(db: Session, new_vulns: list) -> dict:
|
||||
|
||||
stats["recipients"] = len(buckets)
|
||||
|
||||
import time as _time
|
||||
_sent_this_run = 0
|
||||
for email, bucket in buckets.items():
|
||||
# Rate-limit: cap per run + pace between mails (provider anti-spam).
|
||||
if rate_cap and _sent_this_run >= rate_cap:
|
||||
stats["rate_capped"] = stats.get("rate_capped", 0) + 1
|
||||
continue
|
||||
if rate_delay and _sent_this_run > 0:
|
||||
_time.sleep(rate_delay)
|
||||
_sent_this_run += 1
|
||||
items = bucket["items"]
|
||||
username = bucket["username"]
|
||||
user_id = bucket["user_id"]
|
||||
@@ -640,37 +859,47 @@ def dispatch_new_vuln_notifications(db: Session, new_vulns: list) -> dict:
|
||||
)
|
||||
anchor_vuln_id = items[0]["vuln_id"]
|
||||
anchor_asset_id = items[0]["asset_id"]
|
||||
_distinct = len({it.get("cve_id") for it in items})
|
||||
db.add(NotificationLog(
|
||||
vulnerability_id=anchor_vuln_id, # anchor — full list is in body
|
||||
asset_id=anchor_asset_id,
|
||||
user_id=user_id,
|
||||
notification_type=NotificationType.NEW_VULNERABILITY,
|
||||
sent_at=datetime.now(),
|
||||
subject=f"[VULNCHECK] {len(items)} new vulnerabilities detected",
|
||||
subject=f"[TRUEVULN] {_distinct} new vulnerabilities detected",
|
||||
recipient_email=email,
|
||||
status=NotificationStatus.SENT if success else NotificationStatus.FAILED,
|
||||
message_body=f"Digest of {len(items)} new vulnerabilities",
|
||||
message_body=f"Digest of {_distinct} distinct CVEs across {len(items)} findings",
|
||||
error_message=None if success else err,
|
||||
))
|
||||
if success:
|
||||
stats["emails_sent"] += 1
|
||||
logger.info(f"Digest email sent to {email}: {len(items)} CVEs")
|
||||
logger.info(f"Digest email sent to {email}: {_distinct} distinct CVEs ({len(items)} findings)")
|
||||
else:
|
||||
stats["emails_failed"] += 1
|
||||
logger.warning(f"Digest email FAILED to {email}: {err}")
|
||||
else:
|
||||
# Legacy single mode — one mail per CVE per recipient
|
||||
for item in items:
|
||||
_cve = item["cve_id"]
|
||||
_aid = item.get("asset_id")
|
||||
_cpr = item.get("cpr_score")
|
||||
variables = {
|
||||
"cve_id": item["cve_id"],
|
||||
"cve_id": _cve,
|
||||
"severity": item["severity"],
|
||||
"severity_upper": item["severity"].upper(),
|
||||
"cvss_score": str(item.get("cvss_score") or "N/A"),
|
||||
"cpr_score": (f"{_cpr:.1f}" if isinstance(_cpr, (int, float)) else "N/A"),
|
||||
"affected_assets_count": str(item.get("affected_count") or 1),
|
||||
"asset_hostname": item["asset_hostname"],
|
||||
"package_name": item.get("package_name") or "",
|
||||
"title": item["cve_id"],
|
||||
"title": _cve,
|
||||
"detected_at": detected_at_str,
|
||||
"dashboard_url": dashboard_url,
|
||||
# Ready-made deep links (no manual ?cve_id= assembly needed):
|
||||
"cve_link": f"{dashboard_url}?cve_id={quote(_cve)}",
|
||||
"asset_link": (f"{dashboard_url}?asset_id={_aid}" if _aid else dashboard_url),
|
||||
"cve_on_asset_link": (f"{dashboard_url}?asset_id={_aid}&cve_id={quote(_cve)}" if _aid else f"{dashboard_url}?cve_id={quote(_cve)}"),
|
||||
"recipient_name": username,
|
||||
"recipient_email": email,
|
||||
}
|
||||
@@ -681,7 +910,7 @@ def dispatch_new_vuln_notifications(db: Session, new_vulns: list) -> dict:
|
||||
user_id=user_id,
|
||||
notification_type=NotificationType.NEW_VULNERABILITY,
|
||||
sent_at=datetime.now(),
|
||||
subject=f"[VULNCHECK] New {item['severity'].upper()} Vulnerability: {item['cve_id']}",
|
||||
subject=f"[TRUEVULN] New {item['severity'].upper()} Vulnerability: {item['cve_id']}",
|
||||
recipient_email=email,
|
||||
status=NotificationStatus.SENT if success else NotificationStatus.FAILED,
|
||||
message_body=f"New {item['severity']} vulnerability {item['cve_id']} on {item['asset_hostname']}",
|
||||
@@ -696,6 +925,52 @@ def dispatch_new_vuln_notifications(db: Session, new_vulns: list) -> dict:
|
||||
return stats
|
||||
|
||||
|
||||
def _set_setting_str(db: Session, key: str, value: str) -> None:
|
||||
from app.models.setting import Setting
|
||||
s = db.query(Setting).filter(Setting.key == key).first()
|
||||
if s:
|
||||
s.value = value
|
||||
else:
|
||||
db.add(Setting(key=key, value=value))
|
||||
db.commit()
|
||||
|
||||
|
||||
def send_nightly_new_vuln_digest(db: Session) -> dict:
|
||||
"""Nightly roundup: one aggregated mail per recipient with ALL new CVEs
|
||||
since the last run. Only active when notification_schedule == 'nightly'
|
||||
(per-sync sends are suppressed in that mode). Window is tracked in the
|
||||
`notification_nightly_last_run` setting so nothing is sent twice and
|
||||
nothing is missed between runs."""
|
||||
from datetime import timedelta
|
||||
from app.models.vulnerability import Vulnerability, VulnerabilityStatus
|
||||
|
||||
if get_notification_schedule(db) != "nightly":
|
||||
return {"skipped": "notification_schedule != nightly"}
|
||||
|
||||
last_raw = _get_setting_str(db, "notification_nightly_last_run")
|
||||
since = None
|
||||
if last_raw:
|
||||
try:
|
||||
since = datetime.fromisoformat(last_raw)
|
||||
except ValueError:
|
||||
since = None
|
||||
if since is None:
|
||||
since = datetime.now() - timedelta(hours=24)
|
||||
|
||||
active = (VulnerabilityStatus.open, VulnerabilityStatus.pending_verification,
|
||||
VulnerabilityStatus.patch_failed)
|
||||
vulns = (
|
||||
db.query(Vulnerability)
|
||||
.filter(Vulnerability.status.in_(active),
|
||||
Vulnerability.detected_at >= since)
|
||||
.all()
|
||||
)
|
||||
stats = dispatch_new_vuln_notifications(db, vulns, respect_schedule=False)
|
||||
_set_setting_str(db, "notification_nightly_last_run", datetime.now().isoformat())
|
||||
logger.info("Nightly new-vuln digest: %s new since %s → %s", len(vulns), since, stats)
|
||||
return stats
|
||||
|
||||
|
||||
def should_notify_for_severity(db: Session, severity) -> bool:
|
||||
"""
|
||||
Return True if the configured notification threshold lets this severity
|
||||
|
||||
@@ -10,7 +10,9 @@ Alle Quellen sind kostenlos und benötigen keinen API-Key.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Optional, Iterable
|
||||
|
||||
@@ -44,10 +46,43 @@ EUVD_CACHE_KEY = "enrichment_euvd_cache"
|
||||
EUVD_CACHE_TS_KEY = "enrichment_euvd_cache_updated_at"
|
||||
EUVD_TTL_HOURS = 24
|
||||
|
||||
# ---------- NVD CVE dates (published / lastModified) ----------
|
||||
# NVD is the only authoritative source for a CVE's official publish date.
|
||||
# Nessus/Wazuh imports never carried it, so published_date was all-NULL
|
||||
# and the "Newly Published" sort was meaningless. We backfill it here.
|
||||
NVD_CVE_API_URL = "https://services.nvd.nist.gov/rest/json/cves/2.0"
|
||||
# Official CVE.org cvelistV5 raw JSON — primary, non-rate-limited date
|
||||
# source (cveMetadata.datePublished / .dateUpdated for every CVE).
|
||||
CVELISTV5_RAW_BASE = "https://raw.githubusercontent.com/CVEProject/cvelistV5/main/cves"
|
||||
# Per-run cap on date lookups. cvelistV5 has no aggressive rate limit, so
|
||||
# this can be generous; a fresh DB still drains over a couple nightly runs.
|
||||
CVE_DATE_MAX_LOOKUPS_PER_RUN = 4000
|
||||
# Above this many missing CVEs, one cvelistV5 ZIP snapshot (reusing the
|
||||
# CVSS cascade's shared 12h disk cache) beats thousands of per-CVE HTTP
|
||||
# round-trips. Below it, per-CVE raw fetches avoid a 557 MB download for a
|
||||
# handful of new CVEs.
|
||||
CVE_DATE_ZIP_THRESHOLD = 200
|
||||
# Persistent cache — a CVE's published date is immutable, lastModified
|
||||
# changes rarely. No TTL: once cached we never refetch (keeps us well
|
||||
# under NVD's rate limit).
|
||||
NVD_DATE_CACHE_KEY = "enrichment_nvd_date_cache"
|
||||
# Per-run lookup cap so a fresh DB with thousands of CVEs doesn't hammer
|
||||
# NVD in one go — the nightly job catches up incrementally over days.
|
||||
# Key-aware: without a key each request must sleep 6.5s, so a big cap
|
||||
# would stall the whole enrichment job for the better part of an hour.
|
||||
# With a key the floor is 0.7s, so we can drain a much larger batch.
|
||||
NVD_MAX_LOOKUPS_NO_KEY = 150 # ~16 min/run
|
||||
NVD_MAX_LOOKUPS_WITH_KEY = 1500 # ~18 min/run
|
||||
# NVD rate limit: 5 req / 30s without key, 50 req / 30s with key.
|
||||
# Sleep just over the floor to stay safe.
|
||||
NVD_SLEEP_NO_KEY = 6.5
|
||||
NVD_SLEEP_WITH_KEY = 0.7
|
||||
|
||||
# ---------- Toggles ----------
|
||||
SETTING_EPSS_ENABLED = "enrichment_epss_enabled"
|
||||
SETTING_KEV_ENABLED = "enrichment_kev_enabled"
|
||||
SETTING_EUVD_ENABLED = "enrichment_euvd_enabled"
|
||||
SETTING_NVD_DATES_ENABLED = "enrichment_nvd_dates_enabled"
|
||||
|
||||
# ---------- HTTP ----------
|
||||
HTTP_TIMEOUT = 30.0
|
||||
@@ -194,6 +229,10 @@ def fetch_kev_catalog(db: Session, force_refresh: bool = False) -> Dict[str, dic
|
||||
"date_added": entry.get("dateAdded"),
|
||||
"ransomware_use": (entry.get("knownRansomwareCampaignUse") or "").lower() == "known",
|
||||
"short_description": entry.get("shortDescription"),
|
||||
# Extra fields for the advisory/awareness feed (enrichment ignores them).
|
||||
"vendor": entry.get("vendorProject"),
|
||||
"product": entry.get("product"),
|
||||
"name": entry.get("vulnerabilityName"),
|
||||
}
|
||||
|
||||
_store_kev_cache(db, kev_map)
|
||||
@@ -454,12 +493,177 @@ def fetch_euvd_catalogs(db: Session, force_refresh: bool = False) -> Dict[str, d
|
||||
# Apply to DB
|
||||
# ============================================================
|
||||
|
||||
# ============================================================
|
||||
# NVD CVE dates (published / lastModified)
|
||||
# ============================================================
|
||||
|
||||
def _parse_nvd_dt(raw) -> Optional[str]:
|
||||
"""NVD timestamps look like '2024-01-31T17:15:34.123'. Keep ISO str."""
|
||||
if not raw or not isinstance(raw, str):
|
||||
return None
|
||||
return raw.strip() or None
|
||||
|
||||
|
||||
def _load_nvd_date_cache(db: Session) -> Dict[str, dict]:
|
||||
s = db.query(Setting).filter(Setting.key == NVD_DATE_CACHE_KEY).first()
|
||||
if not s or not s.value:
|
||||
return {}
|
||||
try:
|
||||
return json.loads(s.value)
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
|
||||
|
||||
def _cvelistv5_raw_url(cve_id: str) -> Optional[str]:
|
||||
"""Per-CVE raw URL in the official CVE.org cvelistV5 GitHub repo.
|
||||
.../cves/2026/9xxx/CVE-2026-9988.json
|
||||
"""
|
||||
m = re.fullmatch(r"CVE-(\d{4})-(\d+)", cve_id.upper())
|
||||
if not m:
|
||||
return None
|
||||
year, num = m.group(1), m.group(2)
|
||||
bucket = f"{int(num) // 1000}xxx"
|
||||
return f"{CVELISTV5_RAW_BASE}/{year}/{bucket}/{cve_id.upper()}.json"
|
||||
|
||||
|
||||
def _dates_from_cvelistv5(payload: dict) -> dict:
|
||||
"""Extract published/lastModified from a cvelistV5 record.
|
||||
cveMetadata.datePublished / .dateUpdated are the authoritative MITRE
|
||||
timestamps and exist for every published CVE.
|
||||
"""
|
||||
meta = payload.get("cveMetadata") or {}
|
||||
return {
|
||||
"published": _parse_nvd_dt(meta.get("datePublished")),
|
||||
"last_modified": _parse_nvd_dt(meta.get("dateUpdated")),
|
||||
}
|
||||
|
||||
|
||||
def _dates_from_nvd(client: "httpx.Client", cve: str) -> Optional[dict]:
|
||||
"""NVD fallback for a single CVE (rate-limited; only when cvelistV5 misses)."""
|
||||
api_key = os.getenv("NVD_API_KEY", "").strip()
|
||||
headers = {"apiKey": api_key} if api_key else None
|
||||
resp = client.get(NVD_CVE_API_URL, params={"cveId": cve}, headers=headers)
|
||||
if resp.status_code == 404:
|
||||
return {}
|
||||
resp.raise_for_status()
|
||||
items = resp.json().get("vulnerabilities") or []
|
||||
if not items:
|
||||
return {}
|
||||
obj = items[0].get("cve") or {}
|
||||
return {
|
||||
"published": _parse_nvd_dt(obj.get("published")),
|
||||
"last_modified": _parse_nvd_dt(obj.get("lastModified")),
|
||||
}
|
||||
|
||||
|
||||
def fetch_nvd_cve_dates(
|
||||
db: Session,
|
||||
cve_ids: Iterable[str],
|
||||
max_lookups: Optional[int] = None,
|
||||
) -> Dict[str, dict]:
|
||||
"""Resolve {cve_id: {"published": iso, "last_modified": iso}}.
|
||||
|
||||
Primary source is the official CVE.org **cvelistV5** raw JSON on GitHub
|
||||
(cveMetadata.datePublished / .dateUpdated) — it has dates for every
|
||||
published CVE and, unlike the NVD API, is not aggressively rate-limited,
|
||||
so we can fill thousands of CVEs quickly without the per-request sleep
|
||||
that used to make this crawl take hours. NVD is kept only as a per-CVE
|
||||
fallback when cvelistV5 has no record (and honours NVD_API_KEY).
|
||||
|
||||
Persistent settings cache (dates are effectively immutable) so each CVE
|
||||
is fetched once ever; only cache-missing CVEs are looked up, capped at
|
||||
`max_lookups` per run so a fresh DB drains over a few nightly runs.
|
||||
|
||||
NOTE: must run OFF the asyncio loop (scheduler jobs are sync → executed
|
||||
in a worker thread). Never call this inline on a request handler.
|
||||
"""
|
||||
cache = _load_nvd_date_cache(db)
|
||||
cve_list = [c for c in cve_ids if c and CVE_REGEX.fullmatch(c)]
|
||||
missing = [c for c in cve_list if c not in cache]
|
||||
if not missing:
|
||||
return cache
|
||||
|
||||
if max_lookups is None:
|
||||
max_lookups = CVE_DATE_MAX_LOOKUPS_PER_RUN
|
||||
|
||||
# Bulk path: many missing at once → one cvelistV5 ZIP snapshot over the
|
||||
# WHOLE missing set (a local zip walk is cheap, so it is NOT subject to
|
||||
# the per-run cap — this dates a fresh DB completely in a single run so
|
||||
# the "Newly Published" widget isn't stuck showing a partial subset).
|
||||
# Reuses the CVSS cascade's shared 12h disk cache (download-free when
|
||||
# CVSS-correction already pulled it).
|
||||
if len(missing) > CVE_DATE_ZIP_THRESHOLD:
|
||||
try:
|
||||
from app.services.vuln_override_service import VulnOverrideService
|
||||
zip_dates = VulnOverrideService(db).load_cve_dates_via_zip(missing)
|
||||
for cve, d in zip_dates.items():
|
||||
cache[cve] = {
|
||||
"published": _parse_nvd_dt(d.get("published")),
|
||||
"last_modified": _parse_nvd_dt(d.get("last_modified")),
|
||||
}
|
||||
missing = [c for c in missing if c not in cache]
|
||||
logger.info(
|
||||
"CVE dates: ZIP filled %d, %d remain for per-CVE fallback",
|
||||
len(zip_dates), len(missing),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("cvelistV5 ZIP date pass failed (%s) — per-CVE fallback", e)
|
||||
|
||||
# Per-CVE path (raw cvelistV5 → NVD) only for whatever the ZIP missed,
|
||||
# capped so a huge unresolved remainder doesn't run forever.
|
||||
to_fetch = missing[:max_lookups]
|
||||
|
||||
from_cvelist = 0
|
||||
from_nvd = 0
|
||||
not_found = 0
|
||||
with httpx.Client(timeout=HTTP_TIMEOUT, follow_redirects=True,
|
||||
headers={"User-Agent": "TrueVuln/1.0"}) as client:
|
||||
for cve in to_fetch:
|
||||
url = _cvelistv5_raw_url(cve)
|
||||
got = None
|
||||
if url:
|
||||
try:
|
||||
r = client.get(url)
|
||||
if r.status_code == 200:
|
||||
got = _dates_from_cvelistv5(r.json())
|
||||
from_cvelist += 1
|
||||
elif r.status_code != 404:
|
||||
r.raise_for_status()
|
||||
except (httpx.HTTPError, ValueError) as e:
|
||||
logger.debug("cvelistV5 date fetch failed for %s: %s", cve, e)
|
||||
# Fallback to NVD only when cvelistV5 had no record.
|
||||
if got is None:
|
||||
try:
|
||||
got = _dates_from_nvd(client, cve)
|
||||
if got:
|
||||
from_nvd += 1
|
||||
# NVD courtesy delay (no key = 5 req/30s).
|
||||
time.sleep(NVD_SLEEP_WITH_KEY if os.getenv("NVD_API_KEY", "").strip() else NVD_SLEEP_NO_KEY)
|
||||
except httpx.HTTPError as e:
|
||||
logger.debug("NVD date fallback failed for %s: %s", cve, e)
|
||||
continue # don't cache failure — retry next run
|
||||
if got is None:
|
||||
continue
|
||||
if not got.get("published") and not got.get("last_modified"):
|
||||
not_found += 1
|
||||
cache[cve] = got
|
||||
|
||||
_set_setting(db, NVD_DATE_CACHE_KEY, json.dumps(cache),
|
||||
"CVE published/lastModified cache (cvelistV5 + NVD, persistent)")
|
||||
logger.info(
|
||||
"CVE dates: %d from cvelistV5, %d from NVD, %d empty, %d cached total, %d still missing",
|
||||
from_cvelist, from_nvd, not_found, len(cache), max(0, len(missing) - len(to_fetch)),
|
||||
)
|
||||
return cache
|
||||
|
||||
|
||||
def enrich_vulnerabilities(
|
||||
db: Session,
|
||||
vulns: List[Vulnerability],
|
||||
use_epss: Optional[bool] = None,
|
||||
use_kev: Optional[bool] = None,
|
||||
use_euvd: Optional[bool] = None,
|
||||
use_nvd_dates: Optional[bool] = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Reichert eine Liste von Vulnerabilities in-place an und commited.
|
||||
@@ -472,6 +676,7 @@ def enrich_vulnerabilities(
|
||||
"epss_updated": 0,
|
||||
"kev_marked": 0, "kev_cleared": 0,
|
||||
"euvd_marked": 0, "euvd_cleared": 0,
|
||||
"nvd_dates_set": 0,
|
||||
"total": 0,
|
||||
}
|
||||
|
||||
@@ -481,11 +686,14 @@ def enrich_vulnerabilities(
|
||||
use_kev = _setting_bool(db, SETTING_KEV_ENABLED, default=True)
|
||||
if use_euvd is None:
|
||||
use_euvd = _setting_bool(db, SETTING_EUVD_ENABLED, default=True)
|
||||
if use_nvd_dates is None:
|
||||
use_nvd_dates = _setting_bool(db, SETTING_NVD_DATES_ENABLED, default=True)
|
||||
|
||||
stats = {
|
||||
"epss_updated": 0,
|
||||
"kev_marked": 0, "kev_cleared": 0,
|
||||
"euvd_marked": 0, "euvd_cleared": 0,
|
||||
"nvd_dates_set": 0,
|
||||
"total": len(vulns),
|
||||
}
|
||||
|
||||
@@ -517,6 +725,21 @@ def enrich_vulnerabilities(
|
||||
except Exception as e:
|
||||
logger.error(f"EUVD enrichment failed unexpectedly: {e}")
|
||||
|
||||
# NVD published/lastModified backfill. Only look up CVEs that still
|
||||
# lack a published_date — that's the entire point (sort was broken
|
||||
# because the column was NULL). Saves NVD calls on already-dated rows.
|
||||
nvd_dates: Dict[str, dict] = {}
|
||||
if use_nvd_dates:
|
||||
need_dates = sorted({
|
||||
v.cve_id for v in vulns
|
||||
if v.cve_id and v.published_date is None and CVE_REGEX.fullmatch(v.cve_id)
|
||||
})
|
||||
if need_dates:
|
||||
try:
|
||||
nvd_dates = fetch_nvd_cve_dates(db, need_dates)
|
||||
except Exception as e:
|
||||
logger.error(f"NVD date backfill failed unexpectedly: {e}")
|
||||
|
||||
now = datetime.now()
|
||||
|
||||
for vuln in vulns:
|
||||
@@ -580,6 +803,23 @@ def enrich_vulnerabilities(
|
||||
vuln.euvd_id = None
|
||||
stats["euvd_cleared"] += 1
|
||||
|
||||
if use_nvd_dates and vuln.cve_id in nvd_dates:
|
||||
entry = nvd_dates[vuln.cve_id]
|
||||
pub = entry.get("published")
|
||||
mod = entry.get("last_modified")
|
||||
if pub and vuln.published_date is None:
|
||||
try:
|
||||
vuln.published_date = datetime.fromisoformat(pub.replace("Z", "+00:00"))
|
||||
stats["nvd_dates_set"] += 1
|
||||
sources_used.append("nvd")
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
if mod and vuln.last_modified_date is None:
|
||||
try:
|
||||
vuln.last_modified_date = datetime.fromisoformat(mod.replace("Z", "+00:00"))
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
|
||||
if sources_used:
|
||||
# Merge with existing sources
|
||||
existing = []
|
||||
@@ -600,7 +840,8 @@ def enrich_vulnerabilities(
|
||||
f"Enrichment done: {stats['total']} vulns, "
|
||||
f"epss_updated={stats['epss_updated']}, "
|
||||
f"kev_marked={stats['kev_marked']}, kev_cleared={stats['kev_cleared']}, "
|
||||
f"euvd_marked={stats['euvd_marked']}, euvd_cleared={stats['euvd_cleared']}"
|
||||
f"euvd_marked={stats['euvd_marked']}, euvd_cleared={stats['euvd_cleared']}, "
|
||||
f"nvd_dates_set={stats['nvd_dates_set']}"
|
||||
)
|
||||
return stats
|
||||
|
||||
|
||||
+131
-1
@@ -73,6 +73,17 @@ _PRODUCT_SLUGS: dict[str, str] = {
|
||||
"exchangeserver": "exchange-server",
|
||||
"microsoftoffice": "ms-office",
|
||||
"msoffice": "ms-office",
|
||||
# SharePoint — endoflife.date tracks it as `sharepoint` (2013 EOL
|
||||
# 2023-04-11, 2016/2019 EOL 2026-07-14, Subscription Edition still
|
||||
# supported). Neither the MS-lifecycle export nor the plain slug lookup
|
||||
# caught it, so Foundation/Server installs never got an EOL finding.
|
||||
# Covers the Server, Enterprise Server and Foundation flavours generically.
|
||||
"sharepoint": "sharepoint",
|
||||
"microsoftsharepoint": "sharepoint",
|
||||
"microsoftsharepointserver": "sharepoint",
|
||||
"microsoftsharepointfoundation": "sharepoint",
|
||||
"microsoftsharepointenterpriseserver": "sharepoint",
|
||||
"microsoftsharepointdesigner": "sharepoint",
|
||||
"microsoftofficeproofing": "ms-office",
|
||||
"microsoftofficeosxmui": "ms-office",
|
||||
"microsoftofficeosxmuigerman": "ms-office",
|
||||
@@ -108,11 +119,30 @@ _PRODUCT_SLUGS: dict[str, str] = {
|
||||
"postgres": "postgresql",
|
||||
"mongodb": "mongodb",
|
||||
"redis": "redis",
|
||||
# Microsoft Visual C++ Redistributable (all flavours — 2005/2008/2010/2012/2013/2015-2022).
|
||||
# endoflife.date exposes the product as `visual-cpp`; map any sane
|
||||
# spelling here. Versions are matched by endoflife.date.
|
||||
"visualc": "visual-cpp",
|
||||
"visualcppredistributable": "visual-cpp",
|
||||
"microsoftvisualc": "visual-cpp",
|
||||
"microsoftvisualcp": "visual-cpp",
|
||||
"microsoftvisualcppr": "visual-cpp",
|
||||
"microsoftvisualcpprdistributable": "visual-cpp",
|
||||
"microsoftvisualcplusplus": "visual-cpp",
|
||||
"vcredist": "visual-cpp",
|
||||
"vcruntime": "visual-cpp",
|
||||
"msvcr": "visual-cpp",
|
||||
"msvcp": "visual-cpp",
|
||||
# Adobe
|
||||
"adobeacrobat": "adobe-acrobat",
|
||||
"adobeacrobatreader": "adobe-acrobat",
|
||||
"adobeacrobatreaderdc": "adobe-acrobat",
|
||||
"adobeacrobatdc": "adobe-acrobat",
|
||||
# Nessus plugin 56213 reports "Adobe Reader" (no "Acrobat"), so the
|
||||
# acrobat-prefixed keys above never substring-matched → fell back to
|
||||
# EOL-NESSUS-56213. These aliases fix the slug resolution.
|
||||
"adobereader": "adobe-acrobat",
|
||||
"acrobatreader": "adobe-acrobat",
|
||||
# Linux distros
|
||||
"ubuntu": "ubuntu",
|
||||
"debian": "debian",
|
||||
@@ -139,6 +169,10 @@ _WRAPPER_TOKENS = (
|
||||
"veeam", "explorerfor", "backup", "connector", "odbc", "jdbc",
|
||||
"driver", "clientfor", "agentfor", "pluginfor", "extensionfor",
|
||||
"providerfor", "managementpack", "monitoringfor",
|
||||
# Sub-components of a tracked product that have their own (different)
|
||||
# lifecycle — matching the parent would give a false EOL signal.
|
||||
"nativeclient", "setupsupportfiles", "setupsql", "setup",
|
||||
"premium", "clicktorun", "subscription",
|
||||
)
|
||||
|
||||
|
||||
@@ -314,7 +348,12 @@ def _pick_release(releases: List[dict], installed: str) -> Optional[dict]:
|
||||
# the year lives in the PRODUCT NAME ("Microsoft Office ... 2016"), so we
|
||||
# match on the year token, not the numeric version prefix. Mirrors the
|
||||
# OS path (resolve_os_to_eol), which also keys on name not version.
|
||||
_YEAR_KEYED_SLUGS = {"office", "ms-office"}
|
||||
# Release is a YEAR ("2016") while the installed version is a build number
|
||||
# ("16.0.5556.1005") — prefix-matching the version against the cycle can never
|
||||
# hit, so the year comes from the product name instead. SharePoint is the same
|
||||
# shape as Office, and worse: 2016, 2019 AND Subscription Edition all report
|
||||
# 16.0.x, so the version alone can't even tell the releases apart.
|
||||
_YEAR_KEYED_SLUGS = {"office", "ms-office", "sharepoint"}
|
||||
|
||||
|
||||
def _extract_year(text: Optional[str]) -> Optional[str]:
|
||||
@@ -562,6 +601,39 @@ def _pseudo_cve_id(slug: str, release_name: str) -> str:
|
||||
return f"EOL-{slug.upper()}-{safe_rel}"[:50]
|
||||
|
||||
|
||||
def _supersede_old_eol(db: "Session", asset_id: int, slug: Optional[str], keep_cve_id: str) -> None:
|
||||
"""A product runs exactly ONE release per asset. When we upsert the EOL
|
||||
finding for the current release, any OTHER open EOL finding for the same
|
||||
product/asset is stale (the device moved to a new major, e.g. Chrome
|
||||
149→150) — resolve it so the old release doesn't linger as a duplicate."""
|
||||
from app.models.vulnerability import Vulnerability, VulnerabilityStatus
|
||||
if not slug or slug == "unknown":
|
||||
return
|
||||
prefix = f"EOL-{slug.upper()}-"
|
||||
stale = (
|
||||
db.query(Vulnerability)
|
||||
.filter(Vulnerability.asset_id == asset_id,
|
||||
Vulnerability.status == VulnerabilityStatus.open,
|
||||
Vulnerability.first_detected_by == "eol_check",
|
||||
Vulnerability.cve_id.like(f"{prefix}%"),
|
||||
Vulnerability.cve_id != keep_cve_id)
|
||||
.all()
|
||||
)
|
||||
for v in stale:
|
||||
old_status = v.status
|
||||
v.status = VulnerabilityStatus.patched
|
||||
v.patched_at = datetime.now()
|
||||
try:
|
||||
from app.routers.vulnerabilities import log_vulnerability_change
|
||||
log_vulnerability_change(
|
||||
db, None, v.id, old_status, v.status,
|
||||
reason=f"Superseded — asset moved to a newer {slug} release ({keep_cve_id})",
|
||||
cve_id=v.cve_id, source="eol_supersede",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("audit log for EOL supersede failed (vuln_id=%s): %s", v.id, e)
|
||||
|
||||
|
||||
def upsert_eol_vulnerability(
|
||||
db: Session,
|
||||
*,
|
||||
@@ -636,6 +708,7 @@ def upsert_eol_vulnerability(
|
||||
existing.refresh_scores()
|
||||
except Exception:
|
||||
pass
|
||||
_supersede_old_eol(db, asset_id, status.product_slug, cve_id)
|
||||
return existing.id, False
|
||||
|
||||
vuln = Vulnerability(
|
||||
@@ -659,4 +732,61 @@ def upsert_eol_vulnerability(
|
||||
vuln.refresh_scores()
|
||||
except Exception:
|
||||
pass
|
||||
# Revisionssicher: initial detected-event for the new EOL finding.
|
||||
try:
|
||||
from app.services.audit_events import audit_new_vulnerabilities
|
||||
audit_new_vulnerabilities(db, [vuln.id], source="eol_check")
|
||||
except Exception:
|
||||
pass
|
||||
_supersede_old_eol(db, asset_id, status.product_slug, cve_id)
|
||||
return vuln.id, True
|
||||
|
||||
|
||||
def run_eol_for_packages(db: "Session", asset, packages: list) -> int:
|
||||
"""Source-agnostic per-package EOL detection for one asset.
|
||||
|
||||
`packages` = list of {name, version}. endoflife.date first, then the
|
||||
MS-lifecycle export / hardcoded-exotics fallback when endoflife has
|
||||
nothing actionable (same precedence as the eol-check endpoint). Used by
|
||||
both the Wazuh eol-check and the Intune detectedApps inventory. Returns
|
||||
the number of EOL findings upserted. Caller commits.
|
||||
"""
|
||||
count = 0
|
||||
seen: set = set()
|
||||
for pkg in packages or []:
|
||||
name = (pkg.get("name") or "").strip()
|
||||
version = (pkg.get("version") or "").strip()
|
||||
if not name or not version:
|
||||
continue
|
||||
key = (name.lower(), version)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
|
||||
status = None
|
||||
if resolve_product_slug(name):
|
||||
try:
|
||||
status = check_eol(db, name, version)
|
||||
except Exception:
|
||||
status = None
|
||||
actionable = status and (status.is_eol or status.is_eol_soon or status.is_eoas)
|
||||
if not actionable:
|
||||
try:
|
||||
from app.services import ms_lifecycle_service
|
||||
ms = ms_lifecycle_service.resolve_ms_lifecycle_eol(db, name, version)
|
||||
if ms and (ms.is_eol or ms.is_eol_soon):
|
||||
status = ms
|
||||
actionable = True
|
||||
except Exception:
|
||||
pass
|
||||
if not actionable:
|
||||
continue
|
||||
try:
|
||||
upsert_eol_vulnerability(
|
||||
db, asset_id=asset.id, product_name=name,
|
||||
installed_version=version, status=status,
|
||||
)
|
||||
count += 1
|
||||
except Exception as e:
|
||||
logger.warning("EOL-for-packages upsert failed (%s on asset %s): %s", name, asset.id, e)
|
||||
return count
|
||||
|
||||
@@ -57,7 +57,7 @@ logger = logging.getLogger(__name__)
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
_EDB_CSV_URL = "https://gitlab.com/exploit-database/exploitdb/-/raw/main/files_exploits.csv"
|
||||
_EDB_CACHE_PATH = "/tmp/vulncheck-exploit-db.csv"
|
||||
_EDB_CACHE_PATH = "/tmp/truevuln-exploit-db.csv"
|
||||
_EDB_CACHE_TTL = 24 * 3600
|
||||
|
||||
# Lower-case header → index lookup (the GitLab CSV occasionally
|
||||
@@ -133,7 +133,7 @@ def fetch_exploit_db_cve_map() -> Dict[str, List[str]]:
|
||||
# Direct raw URL — heavy but cacheable. Pattern: one folder per year
|
||||
# under the repo, each holding {CVE-ID}.json.
|
||||
_POC_RAW_BASE = "https://raw.githubusercontent.com/nomi-sec/PoC-in-GitHub/master"
|
||||
_POC_CACHE_PATH = "/tmp/vulncheck-pocs-github.json"
|
||||
_POC_CACHE_PATH = "/tmp/truevuln-pocs-github.json"
|
||||
_POC_CACHE_TTL = 24 * 3600
|
||||
|
||||
|
||||
@@ -257,7 +257,7 @@ _MSF_META_URL = (
|
||||
"https://raw.githubusercontent.com/rapid7/metasploit-framework/master/"
|
||||
"db/modules_metadata_base.json"
|
||||
)
|
||||
_MSF_CACHE_PATH = "/tmp/vulncheck-msf-modules.json"
|
||||
_MSF_CACHE_PATH = "/tmp/truevuln-msf-modules.json"
|
||||
_MSF_CACHE_TTL = 24 * 3600
|
||||
|
||||
|
||||
|
||||
@@ -27,26 +27,31 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
# port → (service label, base risk weight 0-40). Higher = worse to expose.
|
||||
# Remote-control + cleartext-admin protocols rank highest.
|
||||
#
|
||||
# Weights rebalanced (tester: almost every Windows host hit 100 because
|
||||
# baseline Windows services — SMB/MSRPC/NetBIOS/WinRM — were weighted like
|
||||
# real exposures). Baseline Windows services are now LOW; genuine remote-
|
||||
# control / cleartext-admin exposures stay HIGH. Crown-jewel ROLES (DC,
|
||||
# ADCS, SQL, Exchange, …) are scored separately by risk_dimensions_service.
|
||||
_RISKY_PORTS: Dict[int, tuple] = {
|
||||
23: ("Telnet (cleartext)", 40),
|
||||
3389: ("RDP", 35),
|
||||
3389: ("RDP", 30),
|
||||
5900: ("VNC", 35), 5901: ("VNC", 35), 5902: ("VNC", 30),
|
||||
5903: ("VNC", 30), 5904: ("VNC", 30), 5905: ("VNC", 30),
|
||||
445: ("SMB", 30),
|
||||
139: ("NetBIOS", 25),
|
||||
21: ("FTP (cleartext)", 28),
|
||||
21: ("FTP (cleartext)", 25),
|
||||
512: ("rexec", 30), 513: ("rlogin", 30), 514: ("rsh", 30),
|
||||
1433: ("MSSQL", 22), 3306: ("MySQL", 22), 5432: ("PostgreSQL", 22),
|
||||
27017: ("MongoDB", 24), 6379: ("Redis", 26), 9200: ("Elasticsearch", 22),
|
||||
27017: ("MongoDB", 24), 6379: ("Redis", 26), 9200: ("Elasticsearch", 20),
|
||||
11211: ("Memcached", 24),
|
||||
135: ("MSRPC", 20),
|
||||
161: ("SNMP", 20),
|
||||
389: ("LDAP (cleartext)", 18),
|
||||
5985: ("WinRM-HTTP", 22), 5986: ("WinRM-HTTPS", 14),
|
||||
2049: ("NFS", 20),
|
||||
8834: ("Nessus", 10),
|
||||
# SSH is normal admin but still an exposure datapoint.
|
||||
22: ("SSH", 8),
|
||||
1433: ("MSSQL", 18), 3306: ("MySQL", 18), 5432: ("PostgreSQL", 18),
|
||||
161: ("SNMP", 14),
|
||||
2049: ("NFS", 16),
|
||||
5985: ("WinRM-HTTP", 12), 5986: ("WinRM-HTTPS", 8),
|
||||
445: ("SMB", 10),
|
||||
389: ("LDAP (cleartext)", 8),
|
||||
135: ("MSRPC", 6),
|
||||
139: ("NetBIOS", 6),
|
||||
22: ("SSH", 6),
|
||||
8834: ("Nessus", 8),
|
||||
}
|
||||
|
||||
# Listeners bound to these local IPs are NOT network-exposed.
|
||||
@@ -130,7 +135,25 @@ def refresh_asset_exposure(db: Session, wazuh, asset: Asset) -> Optional[dict]:
|
||||
asset.network_exposure_score = score
|
||||
asset.exposed_services = json.dumps(services) if services else None
|
||||
asset.exposure_updated_at = datetime.now()
|
||||
return {"score": score, "count": len(services)}
|
||||
|
||||
# Risk Dimensions (crown-jewel roles) — reuse the ports already fetched
|
||||
# plus the package list; no extra Wazuh round-trip beyond get_packages.
|
||||
hv_score = 0.0
|
||||
try:
|
||||
from app.services.risk_dimensions_service import detect_risk_dimensions
|
||||
try:
|
||||
packages = wazuh.get_packages(asset.wazuh_agent_id) or []
|
||||
except Exception:
|
||||
packages = []
|
||||
rd = detect_risk_dimensions(ports, packages)
|
||||
hv_score = rd["score"]
|
||||
asset.high_value_score = rd["score"]
|
||||
asset.risk_dimensions = json.dumps(rd["dimensions"]) if rd["dimensions"] else None
|
||||
asset.risk_dimensions_updated_at = datetime.now()
|
||||
except Exception as e:
|
||||
logger.warning("risk-dimensions failed for %s: %s", asset.hostname, e)
|
||||
|
||||
return {"score": score, "count": len(services), "high_value_score": hv_score}
|
||||
|
||||
|
||||
def refresh_all_exposure(db: Session, wazuh) -> dict:
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
"""
|
||||
Microsoft Intune (MDM/UEM) inventory sync.
|
||||
|
||||
Pulls Intune managed devices via Microsoft Graph (app-only) and registers
|
||||
them as assets (source=INTUNE), then runs OS-level EOL detection on each —
|
||||
the same find-or-create + lifecycle-reconcile pattern as the Nessus sync.
|
||||
|
||||
Phase 2 (detectedApps → EOL/M365 per installed app) hooks in here too.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.asset import Asset, AssetSource, AssetStatus
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SOURCE_NAME = "intune"
|
||||
SETTING_KEY = "intune_config"
|
||||
|
||||
|
||||
def load_intune_config(db: Session) -> Optional[dict]:
|
||||
"""Decrypt + parse intune_config, or None when not configured."""
|
||||
from app.auth.setting_crypto import read_setting_value
|
||||
raw = read_setting_value(db, SETTING_KEY)
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
cfg = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("intune_config is not valid JSON")
|
||||
return None
|
||||
if not all([cfg.get("tenant_id"), cfg.get("client_id"), cfg.get("client_secret")]):
|
||||
return None
|
||||
return cfg
|
||||
|
||||
|
||||
def _build_client(cfg: dict):
|
||||
from app.integrations.graph_client import GraphClient
|
||||
return GraphClient(
|
||||
tenant_id=cfg["tenant_id"],
|
||||
client_id=cfg["client_id"],
|
||||
client_secret=cfg["client_secret"],
|
||||
verify_ssl=cfg.get("verify_ssl", True),
|
||||
)
|
||||
|
||||
|
||||
# Intune's `deviceName` is the Entra/management name for supervised / userless
|
||||
# / ABM iOS devices — a "<enrollment-GUID>_<Model>_<M/D/YYYY>_<time>" blob.
|
||||
# Detect it so we can show a readable, stable name instead.
|
||||
_MGMT_NAME_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}_", re.I)
|
||||
|
||||
|
||||
def _clean_device_name(device: dict) -> str:
|
||||
"""Readable hostname. Graph fills `deviceName` with the management-name
|
||||
GUID blob for supervised/userless iOS; compose a stable name from
|
||||
model + serial instead. Falls back to whatever's there."""
|
||||
name = (device.get("deviceName") or "").strip()
|
||||
if name and not _MGMT_NAME_RE.match(name):
|
||||
return name
|
||||
model = (device.get("model") or "").strip()
|
||||
serial = (device.get("serialNumber") or "").strip()
|
||||
if model and serial:
|
||||
return f"{model}-{serial}"[:255]
|
||||
if model and device.get("id"):
|
||||
return f"{model}-{device['id'][:8]}"[:255]
|
||||
return name # nothing better available
|
||||
|
||||
|
||||
def _find_or_create_asset(db: Session, device: dict, auto_create: bool):
|
||||
"""Match an Intune device to an asset by stable id
|
||||
(intune_device_id → aad_device_id → hostname), else auto-create. Matching
|
||||
on ids first makes device renames (very common in autodeployments) a no-op
|
||||
— the asset is found by id and its hostname refreshed."""
|
||||
device_id = (device.get("id") or "").strip() or None
|
||||
aad_id = (device.get("azureADDeviceId") or "").strip() or None
|
||||
hostname = _clean_device_name(device)
|
||||
|
||||
def _pin(a):
|
||||
# Backfill both ids on whatever we matched, so future syncs (and the
|
||||
# Defender sync) converge on this one asset.
|
||||
if device_id and not a.intune_device_id:
|
||||
a.intune_device_id = device_id
|
||||
if aad_id and not a.aad_device_id:
|
||||
a.aad_device_id = aad_id
|
||||
|
||||
if device_id:
|
||||
a = db.query(Asset).filter(Asset.intune_device_id == device_id).first()
|
||||
if a:
|
||||
_pin(a)
|
||||
return a, "intune_id"
|
||||
|
||||
if aad_id:
|
||||
a = db.query(Asset).filter(Asset.aad_device_id == aad_id).first()
|
||||
if a:
|
||||
_pin(a)
|
||||
return a, "aad_id"
|
||||
|
||||
short = hostname.split(".")[0] if hostname else ""
|
||||
for candidate in [c for c in (hostname, short) if c]:
|
||||
a = db.query(Asset).filter(Asset.hostname.ilike(candidate)).first()
|
||||
if a:
|
||||
_pin(a)
|
||||
return a, "hostname"
|
||||
if short:
|
||||
a = db.query(Asset).filter(Asset.hostname.ilike(f"{short}.%")).first()
|
||||
if a:
|
||||
_pin(a)
|
||||
return a, "hostname-fqdn-prefix"
|
||||
|
||||
if auto_create and hostname:
|
||||
a = Asset(
|
||||
hostname=short or hostname,
|
||||
intune_device_id=device_id,
|
||||
aad_device_id=aad_id,
|
||||
source=AssetSource.INTUNE,
|
||||
status=AssetStatus.ACTIVE,
|
||||
)
|
||||
db.add(a)
|
||||
db.flush()
|
||||
logger.info("Intune sync: auto-created asset %s", a.hostname)
|
||||
return a, "created"
|
||||
|
||||
return None, "skipped"
|
||||
|
||||
|
||||
def _os_string(device: dict) -> str:
|
||||
"""Intune operatingSystem is short ('Windows'/'macOS'); prefix so the
|
||||
EOL OS resolver recognises it like a Wazuh OS string."""
|
||||
os_name = (device.get("operatingSystem") or "").strip()
|
||||
if os_name.lower() == "windows":
|
||||
return "Microsoft Windows"
|
||||
return os_name
|
||||
|
||||
|
||||
# Cross-process guard: only one Intune/Defender sync may touch the asset rows
|
||||
# at a time. The router's module-level flag is per-worker, and the nightly
|
||||
# scheduler runs in yet another context — two overlapping syncs update the same
|
||||
# assets in different orders → Postgres deadlock. A Postgres advisory lock is
|
||||
# global to the DB, so it serialises every caller.
|
||||
_SYNC_ADVISORY_LOCK_KEY = 0x54560101 # arbitrary constant ("TV" + 01)
|
||||
|
||||
|
||||
def _try_sync_lock(db: Session) -> bool:
|
||||
from sqlalchemy import text
|
||||
return bool(db.execute(text("SELECT pg_try_advisory_lock(:k)"),
|
||||
{"k": _SYNC_ADVISORY_LOCK_KEY}).scalar())
|
||||
|
||||
|
||||
def _release_sync_lock(db: Session) -> None:
|
||||
from sqlalchemy import text
|
||||
try:
|
||||
db.rollback() # unlock must run outside a failed (deadlocked) txn
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
db.execute(text("SELECT pg_advisory_unlock(:k)"), {"k": _SYNC_ADVISORY_LOCK_KEY})
|
||||
db.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def run_intune_sync(db: Session, asset_id: Optional[int] = None) -> dict:
|
||||
"""Sync Intune managed devices → assets + OS-EOL. Returns stats."""
|
||||
cfg = load_intune_config(db)
|
||||
if not cfg:
|
||||
raise RuntimeError("Intune is not configured (settings.intune_config missing/incomplete).")
|
||||
|
||||
if not _try_sync_lock(db):
|
||||
logger.warning("Intune sync skipped — another Intune/Defender sync holds the lock")
|
||||
return {"skipped": "another sync already running"}
|
||||
try:
|
||||
return _run_intune_sync_locked(db, cfg)
|
||||
finally:
|
||||
_release_sync_lock(db)
|
||||
|
||||
|
||||
def _run_intune_sync_locked(db: Session, cfg: dict) -> dict:
|
||||
from app.services import eol_service
|
||||
from app.services.asset_lifecycle import reconcile_intune_by_seen_ids
|
||||
|
||||
auto_create = bool(cfg.get("auto_create_assets", True))
|
||||
detected_apps_enabled = bool(cfg.get("detected_apps", True))
|
||||
|
||||
client = _build_client(cfg)
|
||||
stats = {
|
||||
"devices": 0, "assets_matched": 0, "assets_created": 0,
|
||||
"os_eol_findings": 0, "app_findings": 0,
|
||||
"assets_inactivated": 0, "assets_reactivated": 0, "errors": [],
|
||||
}
|
||||
seen_asset_ids: set = set()
|
||||
try:
|
||||
devices = client.get_managed_devices()
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Graph managedDevices fetch failed: {e}") from e
|
||||
|
||||
for device in devices:
|
||||
stats["devices"] += 1
|
||||
try:
|
||||
asset, how = _find_or_create_asset(db, device, auto_create)
|
||||
if not asset:
|
||||
continue
|
||||
if how == "created":
|
||||
stats["assets_created"] += 1
|
||||
else:
|
||||
stats["assets_matched"] += 1
|
||||
# Rename tracking: matched by a stable id → adopt the current
|
||||
# (cleaned) device name so autodeploy renames propagate.
|
||||
new_name = _clean_device_name(device)
|
||||
if how in ("intune_id", "aad_id") and new_name and asset.hostname != new_name:
|
||||
asset.hostname = new_name.split(".")[0] or new_name
|
||||
# refresh inventory fields
|
||||
os_name = _os_string(device)
|
||||
if os_name:
|
||||
asset.operating_system = os_name[:255]
|
||||
if device.get("osVersion"):
|
||||
asset.os_version = str(device["osVersion"])[:100]
|
||||
asset.last_scan = datetime.now()
|
||||
db.flush()
|
||||
if asset.id:
|
||||
seen_asset_ids.add(asset.id)
|
||||
|
||||
# OS-level EOL
|
||||
try:
|
||||
os_status = eol_service.check_os_eol(db, asset.operating_system or "", asset.os_version or "")
|
||||
if os_status and (os_status.is_eol or os_status.is_eol_soon or os_status.is_eoas):
|
||||
eol_service.upsert_eol_vulnerability(
|
||||
db, asset_id=asset.id,
|
||||
product_name=(asset.operating_system or "Operating System").strip(),
|
||||
installed_version=(asset.os_version or os_status.release_name or "unknown"),
|
||||
status=os_status,
|
||||
)
|
||||
stats["os_eol_findings"] += 1
|
||||
except Exception as e:
|
||||
logger.warning("Intune OS-EOL failed for %s: %s", asset.hostname, e)
|
||||
|
||||
# Mobile device EOL/EOS (model) + Android patch-level staleness.
|
||||
try:
|
||||
from app.services import mobile_eol_service
|
||||
stats["mobile_eol_findings"] = (
|
||||
stats.get("mobile_eol_findings", 0)
|
||||
+ mobile_eol_service.check_device(db, asset, device)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("Intune mobile-EOL failed for %s: %s", asset.hostname, e)
|
||||
|
||||
# Phase 2 — detected apps → existing EOL/M365 per-package detection
|
||||
if detected_apps_enabled and device.get("id"):
|
||||
try:
|
||||
pkgs = client.get_detected_apps(device["id"])
|
||||
if pkgs:
|
||||
stats["app_findings"] += _run_app_inventory(db, asset, pkgs)
|
||||
except Exception as e:
|
||||
logger.debug("Intune detectedApps failed for %s: %s", asset.hostname, e)
|
||||
except Exception as e:
|
||||
stats["errors"].append(f"device {device.get('deviceName')}: {e}")
|
||||
|
||||
db.commit()
|
||||
|
||||
# Lifecycle reconcile (event-driven, id-keyed).
|
||||
try:
|
||||
recon = reconcile_intune_by_seen_ids(
|
||||
db, seen_asset_ids=seen_asset_ids,
|
||||
reason="not reported by the latest Intune sync",
|
||||
)
|
||||
stats["assets_inactivated"] = recon["inactivated"]
|
||||
stats["assets_reactivated"] = recon["reactivated"]
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
logger.warning("Intune reconcile failed: %s", e)
|
||||
|
||||
client.close()
|
||||
|
||||
# Phase 3 — Defender for Endpoint TVM real CVEs (opt-in, separate API).
|
||||
if cfg.get("defender_tvm"):
|
||||
try:
|
||||
from app.services.defender_service import run_defender_sync
|
||||
dstats = run_defender_sync(db)
|
||||
stats["defender"] = {k: v for k, v in dstats.items() if k != "errors"}
|
||||
except Exception as e:
|
||||
logger.warning("Defender TVM sync failed (non-fatal): %s", e)
|
||||
|
||||
logger.info(
|
||||
"Intune sync done: %d devices, %d matched, %d created, %d OS-EOL, "
|
||||
"%d app findings, %d inactivated, %d reactivated",
|
||||
stats["devices"], stats["assets_matched"], stats["assets_created"],
|
||||
stats["os_eol_findings"], stats["app_findings"],
|
||||
stats["assets_inactivated"], stats["assets_reactivated"],
|
||||
)
|
||||
return stats
|
||||
|
||||
|
||||
def _run_app_inventory(db: Session, asset, packages: list) -> int:
|
||||
"""Feed Intune detectedApps into the existing EOL + M365 detection.
|
||||
Returns number of findings upserted (best-effort)."""
|
||||
count = 0
|
||||
try:
|
||||
from app.services import eol_service
|
||||
count += eol_service.run_eol_for_packages(db, asset, packages)
|
||||
except Exception as e:
|
||||
logger.debug("Intune EOL-for-packages failed on %s: %s", asset.hostname, e)
|
||||
try:
|
||||
from app.services import m365_service
|
||||
count += m365_service.run_m365_for_packages(db, asset, packages)
|
||||
except Exception as e:
|
||||
logger.debug("Intune M365-for-packages failed on %s: %s", asset.hostname, e)
|
||||
try:
|
||||
from app.services import app_cve_scanner_service
|
||||
count += app_cve_scanner_service.scan_asset_packages(db, asset, packages)
|
||||
except Exception as e:
|
||||
logger.debug("Intune app-cve scan failed on %s: %s", asset.hostname, e)
|
||||
return count
|
||||
@@ -0,0 +1,257 @@
|
||||
"""
|
||||
Linux distro CVE remediation enrichment (step 2 of multi-source enrichment).
|
||||
|
||||
Unlike MSRC (monthly bulk doc), the Linux security trackers are queryable
|
||||
PER CVE, cheaply and without auth — so we fetch on demand when a CVE detail
|
||||
is opened for a Linux host, then cache into cve_remediations so repeat
|
||||
views are instant. Same table + UI as the MSRC path.
|
||||
|
||||
Providers:
|
||||
ubuntu → https://ubuntu.com/security/cves/<CVE>.json
|
||||
packages[].statuses[] (release_codename, status, description=
|
||||
fixed version), notices_ids (USN), mitigation.
|
||||
redhat → https://access.redhat.com/hydra/rest/securitydata/cve/<CVE>.json
|
||||
(CentOS / AlmaLinux / Rocky / Oracle rebuild RHEL, so the RHSA
|
||||
+ fixed package NVR is the actionable fix). affected_release[],
|
||||
mitigation/statement.
|
||||
"""
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
import httpx
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.cve_remediation import CveRemediation
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
HTTP_TIMEOUT = 20.0
|
||||
UA = {"User-Agent": "TrueVuln/1.0", "Accept": "application/json"}
|
||||
|
||||
UBUNTU_URL = "https://ubuntu.com/security/cves/{cve}.json"
|
||||
REDHAT_URL = "https://access.redhat.com/hydra/rest/securitydata/cve/{cve}.json"
|
||||
|
||||
|
||||
def provider_for_os(os_name: Optional[str]) -> Optional[str]:
|
||||
"""Map an asset OS string to a Linux security provider, or None."""
|
||||
n = (os_name or "").lower()
|
||||
if "ubuntu" in n:
|
||||
return "ubuntu"
|
||||
if any(k in n for k in ("centos", "red hat", "redhat", "rhel", "rocky",
|
||||
"alma", "oracle linux", "fedora")):
|
||||
return "redhat"
|
||||
return None
|
||||
|
||||
|
||||
# ============================================================
|
||||
# providers
|
||||
# ============================================================
|
||||
|
||||
def fetch_ubuntu(cve_id: str) -> List[dict]:
|
||||
try:
|
||||
r = httpx.get(UBUNTU_URL.format(cve=cve_id.upper()), headers=UA,
|
||||
timeout=HTTP_TIMEOUT, follow_redirects=True)
|
||||
if r.status_code == 404:
|
||||
return []
|
||||
r.raise_for_status()
|
||||
d = r.json()
|
||||
except (httpx.HTTPError, ValueError) as e:
|
||||
logger.debug("ubuntu fetch failed for %s: %s", cve_id, e)
|
||||
return []
|
||||
|
||||
out: List[dict] = []
|
||||
for pkg in d.get("packages", []) or []:
|
||||
name = pkg.get("name")
|
||||
for s in pkg.get("statuses", []) or []:
|
||||
if s.get("status") != "released":
|
||||
continue
|
||||
codename = s.get("release_codename") or s.get("release") or ""
|
||||
fixed = (s.get("description") or "").strip()
|
||||
if not fixed:
|
||||
continue
|
||||
out.append({
|
||||
"kind": "fix",
|
||||
"title": f"{name} ({codename})"[:300],
|
||||
"detail": None,
|
||||
"kb": None,
|
||||
"fixed_build": fixed[:120],
|
||||
"url": None,
|
||||
})
|
||||
# USN advisories
|
||||
for usn in (d.get("notices_ids") or []):
|
||||
out.append({
|
||||
"kind": "advisory",
|
||||
"title": str(usn)[:300],
|
||||
"detail": None,
|
||||
"kb": None,
|
||||
"fixed_build": None,
|
||||
"url": f"https://ubuntu.com/security/notices/{usn}",
|
||||
})
|
||||
mit = (d.get("mitigation") or "").strip()
|
||||
if mit:
|
||||
out.append({"kind": "mitigation", "title": "Mitigation",
|
||||
"detail": _clean(mit)[:4000], "kb": None,
|
||||
"fixed_build": None, "url": None})
|
||||
return out
|
||||
|
||||
|
||||
def fetch_redhat(cve_id: str) -> List[dict]:
|
||||
try:
|
||||
r = httpx.get(REDHAT_URL.format(cve=cve_id.upper()), headers=UA,
|
||||
timeout=HTTP_TIMEOUT, follow_redirects=True)
|
||||
if r.status_code == 404:
|
||||
return []
|
||||
r.raise_for_status()
|
||||
d = r.json()
|
||||
except (httpx.HTTPError, ValueError) as e:
|
||||
logger.debug("redhat fetch failed for %s: %s", cve_id, e)
|
||||
return []
|
||||
|
||||
out: List[dict] = []
|
||||
seen = set()
|
||||
for a in d.get("affected_release", []) or []:
|
||||
adv = a.get("advisory")
|
||||
pkg = a.get("package")
|
||||
if not adv:
|
||||
continue
|
||||
key = (adv, pkg)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
out.append({
|
||||
"kind": "fix",
|
||||
"title": f"{adv}: {pkg}"[:300] if pkg else str(adv)[:300],
|
||||
"detail": (a.get("product_name") or None),
|
||||
"kb": adv,
|
||||
"fixed_build": (pkg or None),
|
||||
"url": f"https://access.redhat.com/errata/{adv}",
|
||||
})
|
||||
for field, label in (("mitigation", "Mitigation"), ("statement", "Statement")):
|
||||
val = (d.get(field) or "").strip()
|
||||
if val:
|
||||
out.append({"kind": "mitigation" if field == "mitigation" else "advisory",
|
||||
"title": label, "detail": _clean(val)[:4000],
|
||||
"kb": None, "fixed_build": None, "url": None})
|
||||
return out
|
||||
|
||||
|
||||
_TAG_RE = re.compile(r"<[^>]+>")
|
||||
|
||||
|
||||
def _clean(text: str) -> str:
|
||||
return re.sub(r"[ \t]{2,}", " ", _TAG_RE.sub(" ", text)).strip()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# orchestration
|
||||
# ============================================================
|
||||
|
||||
OSV_URL = "https://api.osv.dev/v1/vulns/{cve}"
|
||||
# Reference URLs worth surfacing as advisories (distro errata / advisories
|
||||
# across ecosystems). OSV aggregates many sources, so this is the gap-filler.
|
||||
_ADVISORY_HINTS = ("errata", "/security/notices", "usn-", "rhsa", "dsa-",
|
||||
"dla-", "ghsa", "/advisories/", "suse.com/security",
|
||||
"alas", "rocky", "almalinux", "debian.org/security")
|
||||
_HEX40 = re.compile(r"^[0-9a-f]{16,}$", re.I)
|
||||
|
||||
|
||||
def fetch_osv(cve_id: str) -> List[dict]:
|
||||
"""OSV.dev aggregator (Debian, Alpine, Rocky, Alma, SUSE, language
|
||||
ecosystems, ...). Augments the vendor providers + fills gaps for
|
||||
distros they don't cover. Stored under source='osv'."""
|
||||
try:
|
||||
r = httpx.get(OSV_URL.format(cve=cve_id.upper()), headers=UA,
|
||||
timeout=HTTP_TIMEOUT, follow_redirects=True)
|
||||
if r.status_code == 404:
|
||||
return []
|
||||
r.raise_for_status()
|
||||
d = r.json()
|
||||
except (httpx.HTTPError, ValueError) as e:
|
||||
logger.debug("osv fetch failed for %s: %s", cve_id, e)
|
||||
return []
|
||||
|
||||
out: List[dict] = []
|
||||
seen_fix = set()
|
||||
for a in d.get("affected", []) or []:
|
||||
pkg = a.get("package", {}) or {}
|
||||
eco = pkg.get("ecosystem")
|
||||
name = pkg.get("name")
|
||||
if not eco or not name:
|
||||
continue # git-only / upstream entry → no actionable distro fix
|
||||
for rg in a.get("ranges", []) or []:
|
||||
for ev in rg.get("events", []) or []:
|
||||
fixed = ev.get("fixed")
|
||||
if not fixed or _HEX40.match(str(fixed)):
|
||||
continue # skip commit-hash "fixes"
|
||||
key = (eco, name, fixed)
|
||||
if key in seen_fix:
|
||||
continue
|
||||
seen_fix.add(key)
|
||||
out.append({
|
||||
"kind": "fix",
|
||||
"title": f"{name} ({eco})"[:300],
|
||||
"detail": None, "kb": None,
|
||||
"fixed_build": str(fixed)[:120],
|
||||
"url": None,
|
||||
})
|
||||
# advisory references (deduped, capped)
|
||||
adv_seen = set()
|
||||
for ref in d.get("references", []) or []:
|
||||
url = (ref.get("url") or "").strip()
|
||||
if not url:
|
||||
continue
|
||||
low = url.lower()
|
||||
if not any(h in low for h in _ADVISORY_HINTS):
|
||||
continue
|
||||
if url in adv_seen:
|
||||
continue
|
||||
adv_seen.add(url)
|
||||
out.append({"kind": "advisory", "title": url.split("/")[2][:300] if "//" in url else "advisory",
|
||||
"detail": None, "kb": None, "fixed_build": None, "url": url})
|
||||
if len(adv_seen) >= 8:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def enrich_cve_linux(db: Session, cve_id: str, os_name: Optional[str]) -> int:
|
||||
"""Fetch + cache CVE remediations for a non-Windows host: the matching
|
||||
vendor provider (Ubuntu USN / RHEL-family errata) when the OS is known,
|
||||
PLUS OSV.dev as an additional source (own block, never overwrites
|
||||
vendor). Returns total rows stored. Caller commits.
|
||||
"""
|
||||
cve = cve_id.upper()
|
||||
if not cve.startswith("CVE-"):
|
||||
return 0
|
||||
now = datetime.now()
|
||||
total = 0
|
||||
|
||||
jobs = [] # (source, rows)
|
||||
provider = provider_for_os(os_name)
|
||||
if provider:
|
||||
jobs.append((provider, fetch_ubuntu(cve) if provider == "ubuntu" else fetch_redhat(cve)))
|
||||
jobs.append(("osv", fetch_osv(cve)))
|
||||
|
||||
for source, rows in jobs:
|
||||
# Replace existing rows for this (cve, source) — keeps it current.
|
||||
db.query(CveRemediation).filter(
|
||||
CveRemediation.cve_id == cve, CveRemediation.source == source,
|
||||
).delete(synchronize_session=False)
|
||||
for r in rows:
|
||||
db.add(CveRemediation(
|
||||
cve_id=cve, source=source, kind=r["kind"],
|
||||
title=r.get("title"), detail=r.get("detail"), kb=r.get("kb"),
|
||||
fixed_build=r.get("fixed_build"), url=r.get("url"), fetched_at=now,
|
||||
))
|
||||
total += len(rows)
|
||||
return total
|
||||
|
||||
|
||||
def has_cached(db: Session, cve_id: str, provider: str) -> bool:
|
||||
return db.query(
|
||||
db.query(CveRemediation).filter(
|
||||
CveRemediation.cve_id == cve_id.upper(),
|
||||
CveRemediation.source == provider,
|
||||
).exists()
|
||||
).scalar()
|
||||
@@ -0,0 +1,603 @@
|
||||
"""
|
||||
Microsoft 365 Apps CVE detection (Plan P).
|
||||
|
||||
Microsoft 365 Apps (formerly Office 365 ProPlus) security fixes are NOT
|
||||
published to NVD and are NOT detected by Wazuh's vulnerability detector —
|
||||
they only live on one human-readable Microsoft Learn page:
|
||||
|
||||
https://learn.microsoft.com/en-us/officeupdates/microsoft365-apps-security-updates
|
||||
|
||||
There is no Microsoft API. So we parse that page, learn the latest patched
|
||||
build per update channel, compare it against the build Wazuh's syscollector
|
||||
reports as installed, and create real-CVE vulnerability rows for every
|
||||
monthly update the host is behind on.
|
||||
|
||||
Build logic (verified against the tester's example):
|
||||
installed 16.0.19929.20172 vs Monthly Enterprise Channel 19929.20162
|
||||
-> 20172 >= 20162 -> UNAFFECTED (no CVEs)
|
||||
installed < a section's channel build -> AFFECTED -> attach that
|
||||
section's CVEs (union across every section the host is behind on).
|
||||
|
||||
Channel mapping (tester's rule): the deployed channel isn't in the
|
||||
syscollector name, so we approximate it from the product name —
|
||||
"...enterprise..." -> Monthly Enterprise Channel, else Current Channel.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
import httpx
|
||||
import lxml.html
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.setting import Setting
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
M365_SECURITY_URL = (
|
||||
"https://learn.microsoft.com/en-us/officeupdates/"
|
||||
"microsoft365-apps-security-updates"
|
||||
)
|
||||
|
||||
# ---------- cache (settings table) ----------
|
||||
M365_CACHE_KEY = "m365_security_cache"
|
||||
M365_CACHE_TS_KEY = "m365_security_cache_updated_at"
|
||||
M365_TTL_HOURS = 24
|
||||
|
||||
# ---------- toggle ----------
|
||||
SETTING_M365_ENABLED = "m365_detection_enabled"
|
||||
|
||||
HTTP_TIMEOUT = 30.0
|
||||
|
||||
# Build line: "Monthly Enterprise Channel: Version 2604 (Build 19929.20162)"
|
||||
_BUILD_RE = re.compile(
|
||||
r"([A-Za-z0-9()/ \-]+?):\s*Version\s+(\d{3,4})\s*\(\s*Build\s+(\d+\.\d+)\s*\)"
|
||||
)
|
||||
# Month-day-year heading that delimits each monthly section.
|
||||
_DATE_RE = re.compile(
|
||||
r"\b(January|February|March|April|May|June|July|August|September|"
|
||||
r"October|November|December)\s+(\d{1,2}),\s+(\d{4})\b"
|
||||
)
|
||||
_CVE_RE = re.compile(r"CVE-\d{4}-\d{4,}", re.IGNORECASE)
|
||||
|
||||
# Product-name -> channel approximation.
|
||||
CHANNEL_MONTHLY_ENTERPRISE = "Monthly Enterprise Channel"
|
||||
CHANNEL_CURRENT = "Current Channel"
|
||||
|
||||
|
||||
class M365Error(Exception):
|
||||
"""Raised when the M365 security page cannot be fetched/parsed."""
|
||||
|
||||
|
||||
# ============================================================
|
||||
# build helpers
|
||||
# ============================================================
|
||||
|
||||
def parse_build(version: str) -> Optional[Tuple[int, int]]:
|
||||
"""'16.0.19929.20172' or '19929.20172' -> (19929, 20172).
|
||||
|
||||
Microsoft 365 build numbers are the last two dotted segments
|
||||
(BBBBB.RRRRR). The leading '16.0.' is the Office major and is
|
||||
constant, so we ignore it.
|
||||
"""
|
||||
if not version:
|
||||
return None
|
||||
nums = re.findall(r"\d+", version)
|
||||
if len(nums) < 2:
|
||||
return None
|
||||
try:
|
||||
return int(nums[-2]), int(nums[-1])
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def channel_for_product(product_name: str) -> str:
|
||||
"""Tester's rule: name contains 'enterprise' -> MEC, else Current."""
|
||||
return (
|
||||
CHANNEL_MONTHLY_ENTERPRISE
|
||||
if "enterprise" in (product_name or "").lower()
|
||||
else CHANNEL_CURRENT
|
||||
)
|
||||
|
||||
|
||||
def is_m365_apps(product_name: str) -> bool:
|
||||
"""True for syscollector entries like 'Microsoft 365 Apps for enterprise'."""
|
||||
n = (product_name or "").lower()
|
||||
return "microsoft 365 apps" in n or "office 365 proplus" in n
|
||||
|
||||
|
||||
# ============================================================
|
||||
# page fetch + parse
|
||||
# ============================================================
|
||||
|
||||
def _parse_security_page(html: str) -> List[dict]:
|
||||
"""Parse the MS365 security page into a list of monthly releases.
|
||||
|
||||
Each release: {
|
||||
"date": "May 12, 2026",
|
||||
"channel_builds": {channel_name: [(major, rev), ...]}, # max = newest
|
||||
"cves": ["CVE-2026-40361", ...], # every CVE in the section
|
||||
}
|
||||
Releases are returned in page order (newest first).
|
||||
"""
|
||||
# Flatten to text in document order. The page is a linear sequence of
|
||||
# date headings -> channel/build lines -> product headings -> CVE
|
||||
# bullets, so segmenting the flattened text by date heading is robust
|
||||
# against markup churn.
|
||||
doc = lxml.html.fromstring(html)
|
||||
for bad in doc.xpath("//script | //style | //nav | //header | //footer"):
|
||||
bad.getparent().remove(bad)
|
||||
body = doc.xpath("//main") or [doc]
|
||||
text = body[0].text_content()
|
||||
|
||||
# Find date-heading anchors and slice between them.
|
||||
matches = list(_DATE_RE.finditer(text))
|
||||
releases: List[dict] = []
|
||||
for i, m in enumerate(matches):
|
||||
start = m.end()
|
||||
end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
|
||||
section = text[start:end]
|
||||
date_label = f"{m.group(1)} {m.group(2)}, {m.group(3)}"
|
||||
|
||||
channel_builds: Dict[str, List[Tuple[int, int]]] = {}
|
||||
for bm in _BUILD_RE.finditer(section):
|
||||
channel = bm.group(1).strip()
|
||||
build = parse_build(bm.group(3))
|
||||
if build:
|
||||
channel_builds.setdefault(channel, []).append(build)
|
||||
if not channel_builds:
|
||||
# Not a real release section (e.g. intro paragraph mentioning
|
||||
# a date) — skip.
|
||||
continue
|
||||
|
||||
cves = sorted({c.upper() for c in _CVE_RE.findall(section)})
|
||||
if not cves:
|
||||
continue
|
||||
|
||||
releases.append({
|
||||
"date": date_label,
|
||||
"channel_builds": channel_builds,
|
||||
"cves": cves,
|
||||
})
|
||||
return releases
|
||||
|
||||
|
||||
def _load_cache(db: Session) -> Optional[List[dict]]:
|
||||
ts = db.query(Setting).filter(Setting.key == M365_CACHE_TS_KEY).first()
|
||||
cache = db.query(Setting).filter(Setting.key == M365_CACHE_KEY).first()
|
||||
if not ts or not cache or not cache.value:
|
||||
return None
|
||||
try:
|
||||
if datetime.now() - datetime.fromisoformat(ts.value) > timedelta(hours=M365_TTL_HOURS):
|
||||
return None
|
||||
return json.loads(cache.value)
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
def _store_cache(db: Session, releases: List[dict]) -> None:
|
||||
s = db.query(Setting).filter(Setting.key == M365_CACHE_KEY).first()
|
||||
if s:
|
||||
s.value = json.dumps(releases)
|
||||
else:
|
||||
db.add(Setting(key=M365_CACHE_KEY, value=json.dumps(releases),
|
||||
description="MS365 Apps security-updates parse cache (24h)"))
|
||||
ts = db.query(Setting).filter(Setting.key == M365_CACHE_TS_KEY).first()
|
||||
if ts:
|
||||
ts.value = datetime.now().isoformat()
|
||||
else:
|
||||
db.add(Setting(key=M365_CACHE_TS_KEY, value=datetime.now().isoformat(),
|
||||
description="Timestamp of last MS365 page parse"))
|
||||
db.commit()
|
||||
|
||||
|
||||
def fetch_security_data(db: Session, force_refresh: bool = False) -> List[dict]:
|
||||
"""Return parsed monthly releases, cached 24h in the settings table."""
|
||||
if not force_refresh:
|
||||
cached = _load_cache(db)
|
||||
if cached is not None:
|
||||
return cached
|
||||
try:
|
||||
with httpx.Client(timeout=HTTP_TIMEOUT, follow_redirects=True,
|
||||
headers={"User-Agent": "TrueVuln/1.0"}) as client:
|
||||
resp = client.get(M365_SECURITY_URL)
|
||||
resp.raise_for_status()
|
||||
html = resp.text
|
||||
except httpx.HTTPError as e:
|
||||
raise M365Error(f"could not fetch MS365 security page: {e}") from e
|
||||
|
||||
releases = _parse_security_page(html)
|
||||
if not releases:
|
||||
raise M365Error("MS365 page parsed to zero releases — layout changed?")
|
||||
_store_cache(db, releases)
|
||||
logger.info("MS365: parsed %d monthly releases", len(releases))
|
||||
return releases
|
||||
|
||||
|
||||
# ============================================================
|
||||
# detection
|
||||
# ============================================================
|
||||
|
||||
def _channel_max(release: dict, channel: str) -> Optional[Tuple[int, int]]:
|
||||
"""Newest (max) build for `channel` in a release, as a tuple."""
|
||||
builds = release.get("channel_builds", {}).get(channel)
|
||||
if not builds:
|
||||
return None
|
||||
# builds may be lists from JSON -> normalise to tuples
|
||||
return max(tuple(b) for b in builds)
|
||||
|
||||
|
||||
def detect_missing_cves(
|
||||
releases: List[dict],
|
||||
*,
|
||||
installed_version: str,
|
||||
channel: str,
|
||||
) -> dict:
|
||||
"""Compare an installed M365 build against the parsed releases.
|
||||
|
||||
Returns {
|
||||
"affected": bool,
|
||||
"installed_build": "19929.20172" or None,
|
||||
"latest_build": "19929.20162" or None, # newest patched, this channel
|
||||
"missing_cves": [ ... ], # union, deduped
|
||||
"behind_releases": [ "May 12, 2026", ... ],
|
||||
}
|
||||
"""
|
||||
out = {
|
||||
"affected": False,
|
||||
"installed_build": None,
|
||||
"latest_build": None,
|
||||
"missing_cves": [],
|
||||
"behind_releases": [],
|
||||
}
|
||||
installed = parse_build(installed_version)
|
||||
if not installed:
|
||||
return out
|
||||
out["installed_build"] = f"{installed[0]}.{installed[1]}"
|
||||
|
||||
# Newest patched build for this channel across the whole page.
|
||||
channel_builds = [b for r in releases if (b := _channel_max(r, channel))]
|
||||
if not channel_builds:
|
||||
return out
|
||||
latest = max(channel_builds)
|
||||
out["latest_build"] = f"{latest[0]}.{latest[1]}"
|
||||
|
||||
if installed >= latest:
|
||||
return out # fully patched -> unaffected
|
||||
|
||||
# Behind: union CVEs from every section whose channel build the host
|
||||
# has not reached.
|
||||
out["affected"] = True
|
||||
cve_set: set = set()
|
||||
for r in releases:
|
||||
b = _channel_max(r, channel)
|
||||
if b and installed < b:
|
||||
cve_set.update(r.get("cves", []))
|
||||
out["behind_releases"].append(r.get("date"))
|
||||
out["missing_cves"] = sorted(cve_set)
|
||||
return out
|
||||
|
||||
|
||||
def upsert_m365_vulnerability(
|
||||
db: Session,
|
||||
*,
|
||||
asset_id: int,
|
||||
cve_id: str,
|
||||
product_name: str,
|
||||
installed_version: str,
|
||||
fixed_build: Optional[str],
|
||||
) -> Tuple[Optional[int], bool]:
|
||||
"""Create/refresh a real-CVE M365 vuln row. Returns (id, was_created).
|
||||
|
||||
CVSS/severity are left as a neutral placeholder; the nightly
|
||||
enrichment (EPSS/KEV/NVD dates) and the Correct-CVSS job refine them.
|
||||
These are real CVE ids, so they enrich like any other CVE.
|
||||
"""
|
||||
from app.models.vulnerability import (
|
||||
Vulnerability, VulnerabilitySeverity, VulnerabilityStatus,
|
||||
)
|
||||
|
||||
cve_id = cve_id.upper()
|
||||
existing = (
|
||||
db.query(Vulnerability)
|
||||
.filter(Vulnerability.cve_id == cve_id, Vulnerability.asset_id == asset_id)
|
||||
.first()
|
||||
)
|
||||
title = f"{product_name} — {cve_id} (Microsoft 365 Apps security update)"
|
||||
desc = (
|
||||
f"{cve_id} affects {product_name} and is fixed by a Microsoft 365 "
|
||||
f"Apps security update not yet applied on this host.\n"
|
||||
f"Installed build: {installed_version}. Fixed in build: "
|
||||
f"{fixed_build or 'unknown'} or later — update via the configured "
|
||||
f"Office update channel.\n\n"
|
||||
f"Detection source: this finding comes from the host's installed "
|
||||
f"Microsoft 365 Apps build (Wazuh syscollector inventory) compared "
|
||||
f"against the Microsoft 365 Apps security-updates release notes — "
|
||||
f"M365 Apps fixes are NOT published to NVD and are NOT seen by "
|
||||
f"Wazuh's vulnerability detector.\n"
|
||||
f"Severity / CVSS / dates are enriched from MSRC + CISA Vulnrichment "
|
||||
f"/ cvelistV5 for this real CVE id (see the Remediation section for "
|
||||
f"the Microsoft (MSRC) KB / advisory details)."
|
||||
)
|
||||
|
||||
if existing:
|
||||
existing.title = title[:500]
|
||||
existing.description = desc
|
||||
existing.package_name = product_name[:255]
|
||||
existing.package_version = installed_version[:100]
|
||||
existing.fixed_version = (fixed_build or None)
|
||||
if existing.status == VulnerabilityStatus.patched:
|
||||
existing.status = VulnerabilityStatus.open
|
||||
existing.patched_at = None
|
||||
existing.detected_at = datetime.now()
|
||||
try:
|
||||
existing.refresh_scores()
|
||||
except Exception:
|
||||
pass
|
||||
return existing.id, False
|
||||
|
||||
vuln = Vulnerability(
|
||||
cve_id=cve_id,
|
||||
asset_id=asset_id,
|
||||
cvss_score=None,
|
||||
severity=VulnerabilitySeverity.medium, # placeholder; enrichment refines
|
||||
status=VulnerabilityStatus.open,
|
||||
title=title[:500],
|
||||
description=desc,
|
||||
package_name=product_name[:255],
|
||||
package_version=installed_version[:100],
|
||||
fixed_version=(fixed_build or None),
|
||||
detected_at=datetime.now(),
|
||||
sources='["microsoft365-apps"]',
|
||||
first_detected_by="m365_check",
|
||||
)
|
||||
db.add(vuln)
|
||||
db.flush()
|
||||
try:
|
||||
vuln.refresh_scores()
|
||||
except Exception:
|
||||
pass
|
||||
# Revisionssicher: initial detected-event for the new M365 finding.
|
||||
try:
|
||||
from app.services.audit_events import audit_new_vulnerabilities
|
||||
audit_new_vulnerabilities(db, [vuln.id], source="m365_check")
|
||||
except Exception:
|
||||
pass
|
||||
return vuln.id, True
|
||||
|
||||
|
||||
# ============================================================
|
||||
# orchestration (shared by the endpoint and the nightly job)
|
||||
# ============================================================
|
||||
|
||||
def run_m365_check(db: Session, wazuh, asset_id: Optional[int] = None) -> dict:
|
||||
"""Walk Wazuh-linked assets, detect M365-Apps CVE exposure, upsert rows.
|
||||
|
||||
`wazuh` is an already-configured WazuhClient (the caller owns its
|
||||
lifecycle, matching the eol-check pattern).
|
||||
"""
|
||||
from app.models.asset import Asset
|
||||
|
||||
releases = fetch_security_data(db)
|
||||
|
||||
q = db.query(Asset).filter(Asset.wazuh_agent_id.isnot(None))
|
||||
if asset_id is not None:
|
||||
q = q.filter(Asset.id == asset_id)
|
||||
assets = q.all()
|
||||
|
||||
stats = {
|
||||
"assets_scanned": 0,
|
||||
"m365_installs": 0,
|
||||
"assets_affected": 0,
|
||||
"cve_findings_total": 0,
|
||||
"cve_findings_new": 0,
|
||||
"releases_parsed": len(releases),
|
||||
"errors": [],
|
||||
}
|
||||
touched_cves: set = set()
|
||||
|
||||
for asset in assets:
|
||||
try:
|
||||
pkgs = wazuh.get_packages(asset.wazuh_agent_id) or []
|
||||
except Exception as e:
|
||||
stats["errors"].append(f"asset {asset.id} ({asset.hostname}): {e}")
|
||||
continue
|
||||
stats["assets_scanned"] += 1
|
||||
|
||||
# An asset can list the same product per language pack (de-de,
|
||||
# en-us, .proof, ...) — collapse to one detection per build.
|
||||
seen_builds: set = set()
|
||||
asset_affected = False
|
||||
for pkg in pkgs:
|
||||
name = (pkg.get("name") or "").strip()
|
||||
version = (pkg.get("version") or "").strip()
|
||||
if not name or not version or not is_m365_apps(name):
|
||||
continue
|
||||
stats["m365_installs"] += 1
|
||||
channel = channel_for_product(name)
|
||||
key = (channel, version)
|
||||
if key in seen_builds:
|
||||
continue
|
||||
seen_builds.add(key)
|
||||
|
||||
result = detect_missing_cves(
|
||||
releases, installed_version=version, channel=channel
|
||||
)
|
||||
if not result["affected"]:
|
||||
continue
|
||||
asset_affected = True
|
||||
for cve_id in result["missing_cves"]:
|
||||
try:
|
||||
_, created = upsert_m365_vulnerability(
|
||||
db,
|
||||
asset_id=asset.id,
|
||||
cve_id=cve_id,
|
||||
product_name=name,
|
||||
installed_version=version,
|
||||
fixed_build=result["latest_build"],
|
||||
)
|
||||
stats["cve_findings_total"] += 1
|
||||
touched_cves.add(cve_id.upper())
|
||||
if created:
|
||||
stats["cve_findings_new"] += 1
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"M365 upsert failed (%s on asset %s): %s",
|
||||
cve_id, asset.id, e,
|
||||
)
|
||||
if asset_affected:
|
||||
stats["assets_affected"] += 1
|
||||
|
||||
db.commit()
|
||||
|
||||
# Pull real metrics for the M365 CVEs right at check-in: these are real
|
||||
# CVE ids absent from Wazuh/NVD, so the CVSS/severity placeholder is
|
||||
# corrected from the vulnrichment → cvelistV5 → NVD cascade, and dates
|
||||
# via the enrichment service. The custom source note in `description`
|
||||
# is preserved (the override path never touches description).
|
||||
if touched_cves:
|
||||
cve_list = sorted(touched_cves)
|
||||
try:
|
||||
from app.services.vuln_override_service import correct_vulnerability_scores
|
||||
cstats = correct_vulnerability_scores(db, cve_ids=cve_list)
|
||||
stats["cvss_corrected"] = cstats.get("updated", 0)
|
||||
except Exception as e:
|
||||
logger.warning("M365: CVSS correction failed (non-fatal): %s", e)
|
||||
try:
|
||||
from app.models.vulnerability import Vulnerability
|
||||
from app.services.enrichment_service import enrich_vulnerabilities
|
||||
fresh = db.query(Vulnerability).filter(Vulnerability.cve_id.in_(cve_list)).all()
|
||||
if fresh:
|
||||
enrich_vulnerabilities(db, fresh) # EPSS/KEV/EUVD + NVD dates
|
||||
except Exception as e:
|
||||
logger.warning("M365: enrichment failed (non-fatal): %s", e)
|
||||
try:
|
||||
stats["meta_filled"] = apply_real_cve_metadata(db, cve_list)
|
||||
except Exception as e:
|
||||
logger.warning("M365: real CVE metadata fill failed (non-fatal): %s", e)
|
||||
|
||||
logger.info(
|
||||
"M365 check: %d assets scanned, %d installs, %d affected, "
|
||||
"%d CVE rows (%d new), %d cvss-corrected",
|
||||
stats["assets_scanned"], stats["m365_installs"],
|
||||
stats["assets_affected"], stats["cve_findings_total"],
|
||||
stats["cve_findings_new"], stats.get("cvss_corrected", 0),
|
||||
)
|
||||
return stats
|
||||
|
||||
|
||||
_M365_SOURCE_NOTE = (
|
||||
"\n\n— Detected via the Microsoft 365 Apps security-updates page "
|
||||
"(installed build vs. patched channel build; not published to NVD and "
|
||||
"not seen by Wazuh). Title/description from CVE.org cvelistV5; "
|
||||
"severity/CVSS/dates via MSRC + CISA Vulnrichment."
|
||||
)
|
||||
|
||||
|
||||
def _fetch_cve_title_desc(cve_id: str) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""Real CVE title + English description from CVE.org cvelistV5 raw."""
|
||||
m = re.fullmatch(r"CVE-(\d{4})-(\d+)", cve_id.upper())
|
||||
if not m:
|
||||
return None, None
|
||||
year, num = m.group(1), m.group(2)
|
||||
url = (f"https://raw.githubusercontent.com/CVEProject/cvelistV5/main/"
|
||||
f"cves/{year}/{int(num) // 1000}xxx/{cve_id.upper()}.json")
|
||||
try:
|
||||
with httpx.Client(timeout=15.0, follow_redirects=True,
|
||||
headers={"User-Agent": "TrueVuln/1.0"}) as c:
|
||||
r = c.get(url)
|
||||
if r.status_code != 200:
|
||||
return None, None
|
||||
cna = (r.json().get("containers") or {}).get("cna") or {}
|
||||
except (httpx.HTTPError, ValueError):
|
||||
return None, None
|
||||
title = cna.get("title")
|
||||
desc = None
|
||||
for d in cna.get("descriptions") or []:
|
||||
if (d.get("lang") or "").lower().startswith("en"):
|
||||
desc = d.get("value")
|
||||
break
|
||||
return title, desc
|
||||
|
||||
|
||||
def apply_real_cve_metadata(db: Session, cve_ids: list) -> int:
|
||||
"""Fill the REAL CVE title + description (cvelistV5) on M365 findings,
|
||||
keeping a trailing note that the finding originated from the M365 Apps
|
||||
source. CVSS-correction deliberately leaves title/description alone, so
|
||||
this runs separately. Returns rows updated. Caller commits."""
|
||||
from app.models.vulnerability import Vulnerability
|
||||
updated = 0
|
||||
for cve_id in sorted({c.upper() for c in cve_ids if c}):
|
||||
title, desc = _fetch_cve_title_desc(cve_id)
|
||||
if not title and not desc:
|
||||
continue
|
||||
rows = (
|
||||
db.query(Vulnerability)
|
||||
.filter(
|
||||
Vulnerability.cve_id == cve_id,
|
||||
Vulnerability.first_detected_by == "m365_check",
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for v in rows:
|
||||
if title:
|
||||
v.title = title[:500]
|
||||
if desc:
|
||||
v.description = desc.strip() + _M365_SOURCE_NOTE
|
||||
updated += 1
|
||||
if updated:
|
||||
db.commit()
|
||||
return updated
|
||||
|
||||
|
||||
def run_m365_for_packages(db: Session, asset, packages: list) -> int:
|
||||
"""Source-agnostic M365-Apps CVE detection for one asset's installed
|
||||
apps (e.g. Intune detectedApps). Same build-vs-channel logic as
|
||||
run_m365_check; pulls real metrics for new CVEs. Returns findings
|
||||
upserted. Caller commits."""
|
||||
try:
|
||||
releases = fetch_security_data(db)
|
||||
except M365Error as e:
|
||||
logger.debug("M365-for-packages: security data unavailable: %s", e)
|
||||
return 0
|
||||
|
||||
count = 0
|
||||
touched: set = set()
|
||||
seen: set = set()
|
||||
for pkg in packages or []:
|
||||
name = (pkg.get("name") or "").strip()
|
||||
version = (pkg.get("version") or "").strip()
|
||||
if not name or not version or not is_m365_apps(name):
|
||||
continue
|
||||
channel = channel_for_product(name)
|
||||
key = (channel, version)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
result = detect_missing_cves(releases, installed_version=version, channel=channel)
|
||||
if not result["affected"]:
|
||||
continue
|
||||
for cve_id in result["missing_cves"]:
|
||||
try:
|
||||
upsert_m365_vulnerability(
|
||||
db, asset_id=asset.id, cve_id=cve_id, product_name=name,
|
||||
installed_version=version, fixed_build=result["latest_build"],
|
||||
)
|
||||
count += 1
|
||||
touched.add(cve_id.upper())
|
||||
except Exception as e:
|
||||
logger.warning("M365-for-packages upsert failed (%s on asset %s): %s", cve_id, asset.id, e)
|
||||
|
||||
if touched:
|
||||
try:
|
||||
from app.services.vuln_override_service import correct_vulnerability_scores
|
||||
correct_vulnerability_scores(db, cve_ids=sorted(touched))
|
||||
except Exception as e:
|
||||
logger.debug("M365-for-packages CVSS correction failed: %s", e)
|
||||
try:
|
||||
apply_real_cve_metadata(db, sorted(touched))
|
||||
except Exception as e:
|
||||
logger.debug("M365-for-packages metadata fill failed: %s", e)
|
||||
return count
|
||||
@@ -0,0 +1,248 @@
|
||||
"""
|
||||
EOL/EOS + Android patch-level checks for MDM (Intune) mobile devices.
|
||||
|
||||
Reuses eol_service (endoflife.date fetch/cache/EOLStatus/upsert). The only
|
||||
NEW work is mapping an Intune device model → endoflife slug + release, since
|
||||
endoflife keys phones by marketing name (Galaxy S25 Ultra, iPhone 15 Pro)
|
||||
while Intune reports model identifiers:
|
||||
- Apple reports the marketing name ("iPhone 15 Pro") → fuzzy-match the
|
||||
endoflife release label/name.
|
||||
- Samsung reports an SM-code ("SM-S938B") with NO textual overlap → a
|
||||
curated SM-prefix → endoflife-release table (extend as inventory grows).
|
||||
|
||||
Plus a cheap "Android security patch level is N months stale" finding from
|
||||
Intune's androidSecurityPatchLevel — the control instance that checks whether
|
||||
patches were actually applied, without scraping any vendor bulletin.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from datetime import date, datetime
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.services import eol_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Curated Samsung SM-base-code → (endoflife slug, release `name`). Matched by
|
||||
# prefix so the region/variant suffix (B/N/U/0/F…) is ignored: "SM-S938B"
|
||||
# starts with "SM-S938". Names verified against endoflife.date.
|
||||
# ponytail: curated; add a row when a new model shows up in inventory —
|
||||
# unknown models are skipped (no false-positive), not guessed.
|
||||
_PHONE = "samsung-mobile"
|
||||
_TAB = "samsung-galaxy-tab"
|
||||
# SM-base-code (4 digits) → (endoflife slug, release `name`). Codes are
|
||||
# unique per model, so no prefix collides with another. Slug per row routes
|
||||
# tablets (SM-X/T/P) to samsung-galaxy-tab. Names verified against the live
|
||||
# endoflife.date API.
|
||||
_SAMSUNG: list[Tuple[str, str, str]] = [
|
||||
# --- Galaxy S25 / S24 / S23 / S22 (base / + / Ultra / FE) ---
|
||||
("SM-S938", _PHONE, "galaxy-s25-ultra"), ("SM-S936", _PHONE, "galaxy-s25+"), ("SM-S931", _PHONE, "galaxy-s25"), ("SM-S731", _PHONE, "galaxy-s25-fe"),
|
||||
("SM-S928", _PHONE, "galaxy-s24-ultra"), ("SM-S926", _PHONE, "galaxy-s24+"), ("SM-S921", _PHONE, "galaxy-s24"), ("SM-S721", _PHONE, "galaxy-s24-fe"),
|
||||
("SM-S918", _PHONE, "galaxy-s23-ultra"), ("SM-S916", _PHONE, "galaxy-s23+"), ("SM-S911", _PHONE, "galaxy-s23"), ("SM-S711", _PHONE, "galaxy-s23-fe"),
|
||||
("SM-S908", _PHONE, "galaxy-s22-ultra"), ("SM-S906", _PHONE, "galaxy-s22+"), ("SM-S901", _PHONE, "galaxy-s22"),
|
||||
# --- Galaxy S21 / S20 (note the -5g suffix on S21) ---
|
||||
("SM-G998", _PHONE, "galaxy-s21-ultra-5g"), ("SM-G996", _PHONE, "galaxy-s21+-5g"), ("SM-G991", _PHONE, "galaxy-s21-5g"), ("SM-G990", _PHONE, "galaxy-s21-fe-5g"),
|
||||
("SM-G988", _PHONE, "galaxy-s20-ultra-5g"), ("SM-G986", _PHONE, "galaxy-s20+-5g"), ("SM-G985", _PHONE, "galaxy-s20+"),
|
||||
("SM-G981", _PHONE, "galaxy-s20-5g"), ("SM-G980", _PHONE, "galaxy-s20"), ("SM-G781", _PHONE, "galaxy-s20-fe-5g"), ("SM-G780", _PHONE, "galaxy-s20-fe"),
|
||||
# --- Galaxy Note 20 / 10 ---
|
||||
("SM-N986", _PHONE, "galaxy-note20-ultra-5g"), ("SM-N985", _PHONE, "galaxy-note20-ultra"), ("SM-N981", _PHONE, "galaxy-note20-5g"), ("SM-N980", _PHONE, "galaxy-note20"),
|
||||
("SM-N976", _PHONE, "galaxy-note10+-5g"), ("SM-N975", _PHONE, "galaxy-note10+"), ("SM-N971", _PHONE, "galaxy-note10-5g"), ("SM-N970", _PHONE, "galaxy-note10"), ("SM-N770", _PHONE, "galaxy-note10-lite"),
|
||||
# --- Galaxy Z Fold / Flip ---
|
||||
("SM-F966", _PHONE, "galaxy-z-fold7"), ("SM-F956", _PHONE, "galaxy-z-fold6"), ("SM-F946", _PHONE, "galaxy-z-fold5"), ("SM-F936", _PHONE, "galaxy-z-fold4"), ("SM-F926", _PHONE, "galaxy-z-fold3-5g"), ("SM-F916", _PHONE, "galaxy-z-fold2-5g"),
|
||||
("SM-F766", _PHONE, "galaxy-z-flip7"), ("SM-F741", _PHONE, "galaxy-z-flip6"), ("SM-F731", _PHONE, "galaxy-z-flip5"), ("SM-F721", _PHONE, "galaxy-z-flip4"), ("SM-F711", _PHONE, "galaxy-z-flip3-5g"),
|
||||
# --- Galaxy A-series (5G + LTE) ---
|
||||
("SM-A576", _PHONE, "galaxy-a57-5g"), ("SM-A566", _PHONE, "galaxy-a56-5g"), ("SM-A556", _PHONE, "galaxy-a55-5g"), ("SM-A546", _PHONE, "galaxy-a54-5g"), ("SM-A536", _PHONE, "galaxy-a53-5g"),
|
||||
("SM-A376", _PHONE, "galaxy-a37-5g"), ("SM-A366", _PHONE, "galaxy-a36-5g"), ("SM-A356", _PHONE, "galaxy-a35-5g"), ("SM-A346", _PHONE, "galaxy-a34-5g"), ("SM-A336", _PHONE, "galaxy-a33-5g"),
|
||||
("SM-A266", _PHONE, "galaxy-a26-5g"), ("SM-A256", _PHONE, "galaxy-a25-5g"), ("SM-A166", _PHONE, "galaxy-a16-5g"), ("SM-A165", _PHONE, "galaxy-a16"),
|
||||
("SM-A156", _PHONE, "galaxy-a15-5g"), ("SM-A155", _PHONE, "galaxy-a15"), ("SM-A146", _PHONE, "galaxy-a14-5g"), ("SM-A145", _PHONE, "galaxy-a14"),
|
||||
# --- Galaxy XCover (rugged business) ---
|
||||
("SM-G556", _PHONE, "galaxy-xcover7"), ("SM-G736", _PHONE, "galaxy-xcover6-pro"), ("SM-G525", _PHONE, "galaxy-xcover5"), ("SM-G715", _PHONE, "galaxy-xcover-pro"),
|
||||
("SM-G398", _PHONE, "galaxy-xcover-4s"), ("SM-G390", _PHONE, "galaxy-xcover-4"), ("SM-G389", _PHONE, "galaxy-xcover3-g389f"), ("SM-G388", _PHONE, "galaxy-xcover-3"),
|
||||
# --- Galaxy Tab S10 / S9 / S8 / S7 / S6 (SM-X newer, SM-T/P older) ---
|
||||
("SM-X926", _TAB, "galaxy-tab-s10-ultra"), ("SM-X826", _TAB, "galaxy-tab-s10+"),
|
||||
("SM-X916", _TAB, "galaxy-tab-s9-ultra"), ("SM-X816", _TAB, "galaxy-tab-s9+"), ("SM-X716", _TAB, "galaxy-tab-s9"), ("SM-X710", _TAB, "galaxy-tab-s9"),
|
||||
("SM-X616", _TAB, "galaxy-tab-s9-fe+"), ("SM-X610", _TAB, "galaxy-tab-s9-fe+"), ("SM-X516", _TAB, "galaxy-tab-s9-fe"), ("SM-X510", _TAB, "galaxy-tab-s9-fe"),
|
||||
("SM-X906", _TAB, "galaxy-tab-s8-ultra"), ("SM-X806", _TAB, "galaxy-tab-s8+"), ("SM-X706", _TAB, "galaxy-tab-s8"), ("SM-X700", _TAB, "galaxy-tab-s8"),
|
||||
("SM-T976", _TAB, "galaxy-tab-s7+"), ("SM-T970", _TAB, "galaxy-tab-s7+"), ("SM-T875", _TAB, "galaxy-tab-s7"), ("SM-T870", _TAB, "galaxy-tab-s7"), ("SM-T736", _TAB, "galaxy-tab-s7-fe"), ("SM-T730", _TAB, "galaxy-tab-s7-fe"),
|
||||
("SM-T866", _TAB, "galaxy-tab-s6"), ("SM-T860", _TAB, "galaxy-tab-s6"), ("SM-P625", _TAB, "galaxy-tab-s6-lite-2024"), ("SM-P620", _TAB, "galaxy-tab-s6-lite-2024"), ("SM-P619", _TAB, "galaxy-tab-s6-lite"), ("SM-P613", _TAB, "galaxy-tab-s6-lite"), ("SM-P615", _TAB, "galaxy-tab-s6-lite-2020"), ("SM-P610", _TAB, "galaxy-tab-s6-lite-2020"),
|
||||
# --- Galaxy Tab A (budget) ---
|
||||
("SM-X236", _TAB, "galaxy-tab-a11+"), ("SM-X230", _TAB, "galaxy-tab-a11+"), ("SM-X135", _TAB, "galaxy-tab-a11"), ("SM-X130", _TAB, "galaxy-tab-a11"),
|
||||
("SM-X216", _TAB, "galaxy-tab-a9+"), ("SM-X210", _TAB, "galaxy-tab-a9+"), ("SM-X116", _TAB, "galaxy-tab-a9"), ("SM-X110", _TAB, "galaxy-tab-a9"), ("SM-X205", _TAB, "galaxy-tab-a8"), ("SM-X200", _TAB, "galaxy-tab-a8"),
|
||||
("SM-T350", _TAB, "galaxy-tab-a-8.0-2015"), ("SM-T280", _TAB, "galaxy-tab-a-7.0-2016"),
|
||||
# --- Galaxy Tab Active (rugged tablets) ---
|
||||
("SM-X356", _TAB, "galaxy-tab-active5-pro"), ("SM-X306", _TAB, "galaxy-tab-active5"), ("SM-X300", _TAB, "galaxy-tab-active5"), ("SM-T575", _TAB, "galaxy-tab-active3"),
|
||||
("SM-T395", _TAB, "galaxy-tab-active2"), ("SM-T365", _TAB, "galaxy-tab-active-lte"), ("SM-T360", _TAB, "galaxy-tab-active"),
|
||||
]
|
||||
|
||||
_STALE_CVE_ID = "ANDROID-PATCH-LEVEL-STALE"
|
||||
|
||||
|
||||
def _norm(s: Optional[str]) -> str:
|
||||
return re.sub(r"[^a-z0-9]+", "-", (s or "").lower()).strip("-")
|
||||
|
||||
|
||||
def _resolve_device(manuf: str, model: str, os_name: str):
|
||||
"""→ (slug, matcher) or None. matcher = {'kind':'name','name':...} for the
|
||||
Samsung table, or {'kind':'apple','model':...} for Apple fuzzy match."""
|
||||
m = model or ""
|
||||
mu = (manuf or "").lower()
|
||||
ml = m.lower()
|
||||
osl = (os_name or "").lower()
|
||||
|
||||
if "apple" in mu or ml.startswith("ipad") or ml.startswith("iphone") or "ios" in osl:
|
||||
slug = "ipad" if ("ipad" in ml or "ipados" in osl) else "iphone"
|
||||
return slug, {"kind": "apple", "model": m}
|
||||
|
||||
if "samsung" in mu or re.match(r"sm-[a-z]\d", ml):
|
||||
up = m.upper()
|
||||
for prefix, slug, name in _SAMSUNG:
|
||||
if up.startswith(prefix):
|
||||
return slug, {"kind": "name", "name": name}
|
||||
logger.debug("mobile-eol: unmapped Samsung model %s", m)
|
||||
return None
|
||||
|
||||
|
||||
def _find_release(data: dict, matcher: dict) -> Optional[dict]:
|
||||
rels = ((data.get("result") or {}).get("releases") or []) if isinstance(data, dict) else []
|
||||
if matcher["kind"] == "name":
|
||||
return next((r for r in rels if r.get("name") == matcher["name"]), None)
|
||||
target = _norm(matcher["model"]) # "iphone-15-pro-max" / "ipad-air-5th-generation"
|
||||
for r in rels:
|
||||
label = r.get("label") or ""
|
||||
cands = {_norm(r.get("name")), _norm(label),
|
||||
_norm("iphone " + label), _norm("ipad " + label)}
|
||||
if target in cands:
|
||||
return r
|
||||
return None
|
||||
|
||||
|
||||
def _parse_patch_date(raw) -> Optional[date]:
|
||||
s = str(raw or "").strip()[:10]
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
return datetime.strptime(s, "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _upsert_android_stale(db: Session, asset, patch_level: str, age_days: int) -> bool:
|
||||
"""Pseudo-finding: the device's Android security patch level is stale.
|
||||
Stable cve_id per asset → idempotent. Returns True on create."""
|
||||
from app.models.vulnerability import Vulnerability, VulnerabilitySeverity, VulnerabilityStatus
|
||||
|
||||
months = age_days // 30
|
||||
if age_days >= 365:
|
||||
sev, cvss = VulnerabilitySeverity.high, 8.0
|
||||
elif age_days >= 180:
|
||||
sev, cvss = VulnerabilitySeverity.medium, 5.5
|
||||
else:
|
||||
sev, cvss = VulnerabilitySeverity.low, 3.0
|
||||
|
||||
title = f"Android security patch level {months} months behind ({patch_level})"
|
||||
desc = (f"Intune reports this device's Android security patch level as {patch_level} "
|
||||
f"— {age_days} days ({months} months) old. Monthly Android/OEM security "
|
||||
f"patches since then have not been applied, so any CVE fixed in those "
|
||||
f"bulletins remains open regardless of MDM patch policy.")
|
||||
|
||||
existing = (db.query(Vulnerability)
|
||||
.filter(Vulnerability.cve_id == _STALE_CVE_ID, Vulnerability.asset_id == asset.id)
|
||||
.first())
|
||||
if existing:
|
||||
existing.severity = sev
|
||||
existing.cvss_score = cvss
|
||||
existing.title = title[:500]
|
||||
existing.description = desc
|
||||
existing.package_version = str(patch_level)[:100]
|
||||
existing.detected_at = datetime.now()
|
||||
if existing.status == VulnerabilityStatus.patched:
|
||||
existing.status = VulnerabilityStatus.open
|
||||
existing.patched_at = None
|
||||
try:
|
||||
existing.refresh_scores()
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
row = Vulnerability(
|
||||
cve_id=_STALE_CVE_ID, asset_id=asset.id, cvss_score=cvss, severity=sev,
|
||||
status=VulnerabilityStatus.open, title=title[:500], description=desc,
|
||||
package_name="Android Security Patch Level", package_version=str(patch_level)[:100],
|
||||
detected_at=datetime.now(), sources='["intune"]', first_detected_by="intune",
|
||||
)
|
||||
db.add(row)
|
||||
db.flush()
|
||||
try:
|
||||
row.refresh_scores()
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
|
||||
|
||||
def check_device(db: Session, asset, device: dict) -> int:
|
||||
"""EOL/EOS for the device model + Android patch-level staleness.
|
||||
Returns findings upserted. Caller commits."""
|
||||
count = 0
|
||||
manuf = (device.get("manufacturer") or "").strip()
|
||||
model = (device.get("model") or "").strip()
|
||||
os_name = (device.get("operatingSystem") or "").strip()
|
||||
os_version = (device.get("osVersion") or "").strip()
|
||||
|
||||
# 1) Device-model EOL/EOS via endoflife.date.
|
||||
try:
|
||||
res = _resolve_device(manuf, model, os_name)
|
||||
if res:
|
||||
slug, matcher = res
|
||||
data = eol_service.fetch_product(db, slug)
|
||||
rel = _find_release(data, matcher) if data else None
|
||||
if rel:
|
||||
status = eol_service._build_eol_status(rel, slug)
|
||||
if status.is_eol or status.is_eol_soon or status.is_eoas:
|
||||
# product_name is the VENDOR only — upsert_eol_vulnerability
|
||||
# appends the release label itself, so passing "Samsung
|
||||
# Galaxy Tab A8" would double it ("…A8 Galaxy Tab A8").
|
||||
# endoflife labels: iPhone lacks the "iPhone" prefix, iPad
|
||||
# and Samsung labels already carry it.
|
||||
vendor = {"iphone": "Apple iPhone", "ipad": "Apple",
|
||||
"samsung-mobile": "Samsung",
|
||||
"samsung-galaxy-tab": "Samsung"}.get(slug, (manuf or "Device").title())
|
||||
vid, _ = eol_service.upsert_eol_vulnerability(
|
||||
db, asset_id=asset.id, product_name=vendor,
|
||||
installed_version=(f"{os_name} {os_version}".strip() or "unknown"),
|
||||
status=status,
|
||||
)
|
||||
# Keep the full device name in the package column.
|
||||
if vid:
|
||||
from app.models.vulnerability import Vulnerability
|
||||
row = db.query(Vulnerability).filter(Vulnerability.id == vid).first()
|
||||
if row:
|
||||
row.package_name = f"{vendor} {rel.get('label') or model}".strip()[:255]
|
||||
count += 1
|
||||
except Exception as e:
|
||||
logger.debug("mobile-eol device check failed for %s: %s", asset.hostname, e)
|
||||
|
||||
# 2) Android security-patch-level staleness + per-CVE detail from ASB.
|
||||
if "android" in os_name.lower():
|
||||
patch = device.get("androidSecurityPatchLevel")
|
||||
d = _parse_patch_date(patch)
|
||||
if d:
|
||||
age = (date.today() - d).days
|
||||
if age >= 90: # <90d = within a normal monthly-patch window
|
||||
try:
|
||||
_upsert_android_stale(db, asset, str(patch)[:10], age)
|
||||
count += 1
|
||||
except Exception as e:
|
||||
logger.debug("mobile-eol android-patch failed for %s: %s", asset.hostname, e)
|
||||
# Per-CVE findings for the months the device is behind (Google ASB).
|
||||
try:
|
||||
from app.services import android_cve_service
|
||||
count += android_cve_service.check_android_cves(db, asset, patch, manufacturer=manuf)
|
||||
except Exception as e:
|
||||
logger.debug("android-asb CVEs failed for %s: %s", asset.hostname, e)
|
||||
|
||||
return count
|
||||
@@ -0,0 +1,290 @@
|
||||
"""
|
||||
Microsoft product-lifecycle EOL detection (Plan O).
|
||||
|
||||
endoflife.date covers the popular products well, but Microsoft "exotics"
|
||||
(and many server/SKU variants) are not there. Microsoft has no lifecycle
|
||||
API. The only machine-readable primary source is the monthly Excel export
|
||||
linked from:
|
||||
|
||||
https://learn.microsoft.com/en-us/lifecycle/products/export/
|
||||
|
||||
That page links a file like
|
||||
https://download.microsoft.com/download/<guid>/eos-product-listing-<month>-<year>.xlsx
|
||||
whose GUID + filename change every month — so we scrape the page for the
|
||||
current link, download the .xlsx, and parse it.
|
||||
|
||||
Sheet columns: ListingName | Release | AzureFeature | EndDate
|
||||
e.g. ("Microsoft SQL Server 2014", "Service Pack 3", None, 2024-07-09)
|
||||
|
||||
A few true exotics are NOT in the export at all (the tester called these
|
||||
out): Silverlight and the Visual C++ Redistributables have their own
|
||||
single pages. Those are hardcoded below — their EOL dates are fixed and
|
||||
never change.
|
||||
|
||||
This is a *fallback* EOL source: the EOL check consults endoflife.date
|
||||
first and only falls back here for names endoflife.date can't map.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, date, timedelta
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import httpx
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.setting import Setting
|
||||
from app.services.eol_service import EOLStatus, EOL_SOON_DAYS, _days_until
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
EXPORT_PAGE_URL = "https://learn.microsoft.com/en-us/lifecycle/products/export/"
|
||||
# The xlsx link on that page. GUID + month change monthly.
|
||||
_XLSX_RE = re.compile(
|
||||
r"https://download\.microsoft\.com/download/[^\s\"'>]+?"
|
||||
r"eos-product-listing-[^\s\"'>]+?\.xlsx",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# ---------- cache (settings table) ----------
|
||||
MSL_CACHE_KEY = "ms_lifecycle_cache"
|
||||
MSL_CACHE_TS_KEY = "ms_lifecycle_cache_updated_at"
|
||||
MSL_TTL_HOURS = 24
|
||||
|
||||
# ---------- toggle ----------
|
||||
SETTING_MSL_ENABLED = "ms_lifecycle_enabled"
|
||||
|
||||
HTTP_TIMEOUT = 60.0
|
||||
|
||||
|
||||
class MSLifecycleError(Exception):
|
||||
"""Raised when the MS lifecycle export cannot be fetched/parsed."""
|
||||
|
||||
|
||||
# ============================================================
|
||||
# hardcoded exotics (not in the export file)
|
||||
# ============================================================
|
||||
# {match_substring: (display_name, eol_date_iso)}. Matched case-insensitive
|
||||
# against the syscollector product name. EOL dates are fixed/announced.
|
||||
_HARDCODED_EOL: Dict[str, tuple] = {
|
||||
# https://learn.microsoft.com/lifecycle/announcements/silverlight-end-of-support
|
||||
"silverlight": ("Microsoft Silverlight", "2021-10-12"),
|
||||
# Visual C++ Redistributables track their Visual Studio lifecycle. The
|
||||
# runtimes from EOL Visual Studio versions are themselves out of
|
||||
# support. (Latest 2015-2022 redist stays supported — not listed.)
|
||||
# https://learn.microsoft.com/cpp/windows/latest-supported-vc-redist
|
||||
"visual c++ 2013": ("Visual C++ 2013 Redistributable", "2024-04-09"),
|
||||
"visual c++ 2012": ("Visual C++ 2012 Redistributable", "2023-01-10"),
|
||||
"visual c++ 2010": ("Visual C++ 2010 Redistributable", "2020-07-14"),
|
||||
"visual c++ 2008": ("Visual C++ 2008 Redistributable", "2018-04-10"),
|
||||
}
|
||||
|
||||
|
||||
def _normalise(name: str) -> str:
|
||||
n = (name or "").lower()
|
||||
n = n.replace("microsoft", " ").replace("(r)", " ").replace("®", " ")
|
||||
n = re.sub(r"[^a-z0-9]+", " ", n)
|
||||
return re.sub(r"\s+", " ", n).strip()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# fetch + parse
|
||||
# ============================================================
|
||||
|
||||
def _find_xlsx_url(html: str) -> Optional[str]:
|
||||
m = _XLSX_RE.search(html)
|
||||
return m.group(0) if m else None
|
||||
|
||||
|
||||
def _parse_xlsx(content: bytes) -> List[dict]:
|
||||
"""Parse the eos-product-listing workbook into row dicts."""
|
||||
import io
|
||||
import openpyxl
|
||||
|
||||
wb = openpyxl.load_workbook(io.BytesIO(content), read_only=True, data_only=True)
|
||||
ws = wb[wb.sheetnames[0]]
|
||||
rows = ws.iter_rows(values_only=True)
|
||||
header = next(rows, None)
|
||||
if not header:
|
||||
return []
|
||||
# Tolerate column reordering by mapping header names.
|
||||
idx = {str(h).strip().lower(): i for i, h in enumerate(header) if h}
|
||||
i_name = idx.get("listingname", 0)
|
||||
i_rel = idx.get("release", 1)
|
||||
i_end = idx.get("enddate", 3)
|
||||
|
||||
out: List[dict] = []
|
||||
for r in rows:
|
||||
if not r or len(r) <= i_end:
|
||||
continue
|
||||
name = r[i_name]
|
||||
end = r[i_end]
|
||||
if not name or end is None:
|
||||
continue
|
||||
if isinstance(end, (datetime, date)):
|
||||
end_iso = end.strftime("%Y-%m-%d")
|
||||
else:
|
||||
# occasionally a string date — keep first 10 chars if ISO-ish
|
||||
s = str(end).strip()
|
||||
end_iso = s[:10] if re.match(r"\d{4}-\d{2}-\d{2}", s) else None
|
||||
if not end_iso:
|
||||
continue
|
||||
out.append({
|
||||
"name": str(name).strip(),
|
||||
"release": str(r[i_rel]).strip() if (len(r) > i_rel and r[i_rel]) else "",
|
||||
"end_date": end_iso,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _load_cache(db: Session) -> Optional[List[dict]]:
|
||||
ts = db.query(Setting).filter(Setting.key == MSL_CACHE_TS_KEY).first()
|
||||
cache = db.query(Setting).filter(Setting.key == MSL_CACHE_KEY).first()
|
||||
if not ts or not cache or not cache.value:
|
||||
return None
|
||||
try:
|
||||
if datetime.now() - datetime.fromisoformat(ts.value) > timedelta(hours=MSL_TTL_HOURS):
|
||||
return None
|
||||
return json.loads(cache.value)
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
def _store_cache(db: Session, rows: List[dict]) -> None:
|
||||
c = db.query(Setting).filter(Setting.key == MSL_CACHE_KEY).first()
|
||||
if c:
|
||||
c.value = json.dumps(rows)
|
||||
else:
|
||||
db.add(Setting(key=MSL_CACHE_KEY, value=json.dumps(rows),
|
||||
description="MS lifecycle EOL export cache (24h)"))
|
||||
ts = db.query(Setting).filter(Setting.key == MSL_CACHE_TS_KEY).first()
|
||||
if ts:
|
||||
ts.value = datetime.now().isoformat()
|
||||
else:
|
||||
db.add(Setting(key=MSL_CACHE_TS_KEY, value=datetime.now().isoformat(),
|
||||
description="Timestamp of last MS lifecycle export parse"))
|
||||
db.commit()
|
||||
|
||||
|
||||
def fetch_lifecycle_data(db: Session, force_refresh: bool = False) -> List[dict]:
|
||||
"""Return parsed lifecycle rows, cached 24h in the settings table."""
|
||||
if not force_refresh:
|
||||
cached = _load_cache(db)
|
||||
if cached is not None:
|
||||
return cached
|
||||
try:
|
||||
with httpx.Client(timeout=HTTP_TIMEOUT, follow_redirects=True,
|
||||
headers={"User-Agent": "Mozilla/5.0 TrueVuln/1.0"}) as client:
|
||||
page = client.get(EXPORT_PAGE_URL)
|
||||
page.raise_for_status()
|
||||
xlsx_url = _find_xlsx_url(page.text)
|
||||
if not xlsx_url:
|
||||
raise MSLifecycleError("export xlsx link not found on the page (layout changed?)")
|
||||
xlsx = client.get(xlsx_url)
|
||||
xlsx.raise_for_status()
|
||||
rows = _parse_xlsx(xlsx.content)
|
||||
except httpx.HTTPError as e:
|
||||
raise MSLifecycleError(f"could not fetch MS lifecycle export: {e}") from e
|
||||
|
||||
if not rows:
|
||||
raise MSLifecycleError("MS lifecycle export parsed to zero rows")
|
||||
_store_cache(db, rows)
|
||||
logger.info("MS lifecycle: parsed %d product rows", len(rows))
|
||||
return rows
|
||||
|
||||
|
||||
# ============================================================
|
||||
# resolver
|
||||
# ============================================================
|
||||
|
||||
def _status_from_end_date(
|
||||
*, display_name: str, release: str, end_iso: str, installed_version: str
|
||||
) -> EOLStatus:
|
||||
days = _days_until(end_iso)
|
||||
is_eol = days is not None and days < 0
|
||||
is_soon = days is not None and 0 <= days <= EOL_SOON_DAYS
|
||||
slug = "ms-lifecycle"
|
||||
return EOLStatus(
|
||||
is_eol=is_eol,
|
||||
is_eoas=False,
|
||||
is_maintained=not is_eol,
|
||||
is_eol_soon=is_soon,
|
||||
days_to_eol=days,
|
||||
release_label=release or display_name,
|
||||
release_name=(release or display_name)[:40],
|
||||
eol_date=end_iso,
|
||||
product_slug=slug,
|
||||
latest_version=None,
|
||||
)
|
||||
|
||||
|
||||
def resolve_ms_lifecycle_eol(
|
||||
db: Session, product_name: str, installed_version: str = ""
|
||||
) -> Optional[EOLStatus]:
|
||||
"""Resolve an EOL status for an MS product via hardcoded exotics first,
|
||||
then the lifecycle export. Returns None when no match or no EOL signal.
|
||||
"""
|
||||
pname = product_name or ""
|
||||
|
||||
# 1) hardcoded exotics (Silverlight, old VC++ redists)
|
||||
low = pname.lower()
|
||||
for needle, (disp, end_iso) in _HARDCODED_EOL.items():
|
||||
if needle in low:
|
||||
st = _status_from_end_date(
|
||||
display_name=disp, release="", end_iso=end_iso,
|
||||
installed_version=installed_version,
|
||||
)
|
||||
if st.is_eol or st.is_eol_soon:
|
||||
return st
|
||||
return None
|
||||
|
||||
# 2) lifecycle export — match listing by normalised name, then take the
|
||||
# LATEST end date among matching rows (the last service pack defines
|
||||
# when security support truly ends).
|
||||
try:
|
||||
rows = fetch_lifecycle_data(db)
|
||||
except MSLifecycleError as e:
|
||||
logger.debug("MS lifecycle unavailable: %s", e)
|
||||
return None
|
||||
|
||||
target = _normalise(pname)
|
||||
if not target or len(target) < 4:
|
||||
return None
|
||||
|
||||
matches = []
|
||||
for row in rows:
|
||||
rn = _normalise(row["name"])
|
||||
if not rn:
|
||||
continue
|
||||
# Require the LISTING name to be contained in the product name —
|
||||
# one direction only. The old bidirectional check also matched
|
||||
# `target in rn`, which let a short product name like "Edge"
|
||||
# (normalised from "Microsoft Edge", "microsoft" stripped) match
|
||||
# the unrelated listing "Azure Stack Edge" → false EOL (tester
|
||||
# screenshot: evergreen Edge browser flagged EOL 2024-03-31).
|
||||
# "rn in target" keeps the legit cases: listing "SQL Server 2014"
|
||||
# ⊂ product "Microsoft SQL Server 2014 Management Objects"; an
|
||||
# exact-equal name is also covered (rn == target ⇒ rn in target).
|
||||
if rn in target:
|
||||
matches.append(row)
|
||||
if not matches:
|
||||
return None
|
||||
|
||||
# Prefer the latest NON-ESU end date: paid Extended Security Updates are
|
||||
# an add-on most hosts don't have, so security-conservatively a product
|
||||
# is EOL when its standard (extended) support ends, not when the
|
||||
# purchasable ESU window closes. Fall back to the overall max only if
|
||||
# every matching row is an ESU row.
|
||||
def _is_esu(r: dict) -> bool:
|
||||
rel = (r.get("release") or "").lower()
|
||||
return "extended security update" in rel or "esu" in rel
|
||||
|
||||
non_esu = [r for r in matches if not _is_esu(r)]
|
||||
best = max(non_esu or matches, key=lambda r: r["end_date"])
|
||||
st = _status_from_end_date(
|
||||
display_name=best["name"], release=best["release"],
|
||||
end_iso=best["end_date"], installed_version=installed_version,
|
||||
)
|
||||
if st.is_eol or st.is_eol_soon:
|
||||
return st
|
||||
return None
|
||||
@@ -0,0 +1,476 @@
|
||||
"""
|
||||
MSRC-driven OS CVE detection — patch-level accurate, ahead of the Wazuh CTI feed.
|
||||
|
||||
Why this exists (and why NVD/cvelistV5 can't do it): Microsoft does not express
|
||||
fixes as version ranges. NVD lists MS OS entries as rangeless CPEs
|
||||
(`cpe:2.3:o:microsoft:windows_server_2016:-:*`, versionEndExcluding=null) and
|
||||
cvelistV5 MS records use `lessThan: "publication"` — neither says which BUILD
|
||||
carries the fix, so neither can tell a patched host from an unpatched one.
|
||||
MSRC's CVRF does: each Type-2 remediation carries a `FixedBuild` plus the
|
||||
`ProductID`s it applies to, e.g.
|
||||
|
||||
CVE-2026-33834 FixedBuild 10.0.14393.9140 ProductID ['10816','10855'] KB5087537
|
||||
ProductTree: 10816 -> "Windows Server 2016"
|
||||
10855 -> "Windows Server 2016 (Server Core installation)"
|
||||
|
||||
So: index (product → [cve, fixed_build]) from the monthly CVRF docs, then compare
|
||||
an asset's installed OS build against the fixed build of its own servicing
|
||||
branch. installed < fixed → affected. Same curated-and-precise contract as the
|
||||
other scanners: only products we can map to an asset are indexed, nothing is
|
||||
guessed, and a host that is patched is never flagged.
|
||||
|
||||
Scope: Windows Server only for now — its OS string names the product outright.
|
||||
Client Windows (10/11) needs a build→release table before it can join.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SOURCE_NAME = "msrc"
|
||||
_INDEX_SETTING = "msrc_product_index_v1"
|
||||
_INDEX_TTL = timedelta(hours=26) # rebuilt nightly; a missed night still serves
|
||||
|
||||
# Curated map: asset side (`match_re`) → MSRC ProductTree name (`msrc_re`).
|
||||
#
|
||||
# `kind` os = matched against asset.operating_system + asset.os_version
|
||||
# pkg = matched against an installed-software name + its version
|
||||
# `branch` True → a fix only speaks to hosts sharing its build prefix, because
|
||||
# the 3rd segment IS the release (Windows Server 2016 = 14393;
|
||||
# SharePoint 2019 = 10417 — verified stable across releases).
|
||||
# False → the 3rd segment moves with every CU, so prefix-matching would
|
||||
# silently never hit; identity comes from the NAME instead and
|
||||
# the compare is a plain installed < fixed. Verified: SharePoint
|
||||
# 2016 shows 5535/5539/5543/5552/5556 over six months.
|
||||
#
|
||||
# `msrc_re` is anchored so "Microsoft .NET Framework 4.8 on Windows Server 2016"
|
||||
# (a *different* product that merely mentions the OS) can't match the OS family.
|
||||
# Order matters — R2 must precede its base year.
|
||||
#
|
||||
# SharePoint 2013 is deliberately absent: it is EOL (2023-04-11) and Microsoft
|
||||
# publishes no fixes for it, so there is no FixedBuild to compare — no amount of
|
||||
# fix data can flag it. The EOL finding is the signal there (see eol_service).
|
||||
_PRODUCTS: List[dict] = [
|
||||
{"key": "ws2012r2", "kind": "os", "branch": True, "match_re": r"windows server\s*2012\s*r2",
|
||||
"msrc_re": r"^windows server 2012 r2\b", "label": "Microsoft Windows Server 2012 R2"},
|
||||
{"key": "ws2012", "kind": "os", "branch": True, "match_re": r"windows server\s*2012(?!\s*r2)",
|
||||
"msrc_re": r"^windows server 2012\b(?!\s*r2)", "label": "Microsoft Windows Server 2012"},
|
||||
{"key": "ws2016", "kind": "os", "branch": True, "match_re": r"windows server\s*2016",
|
||||
"msrc_re": r"^windows server 2016\b", "label": "Microsoft Windows Server 2016"},
|
||||
{"key": "ws2019", "kind": "os", "branch": True, "match_re": r"windows server\s*2019",
|
||||
"msrc_re": r"^windows server 2019\b", "label": "Microsoft Windows Server 2019"},
|
||||
{"key": "ws2022", "kind": "os", "branch": True, "match_re": r"windows server\s*2022",
|
||||
"msrc_re": r"^windows server 2022\b", "label": "Microsoft Windows Server 2022"},
|
||||
{"key": "ws2025", "kind": "os", "branch": True, "match_re": r"windows server\s*2025",
|
||||
"msrc_re": r"^windows server 2025\b", "label": "Microsoft Windows Server 2025"},
|
||||
# SharePoint — 2016, 2019 and Subscription Edition ALL report 16.0.x, so the
|
||||
# year in the name is the only thing that tells the releases apart.
|
||||
{"key": "sp2016", "kind": "pkg", "branch": False, "match_re": r"sharepoint.*\b2016\b",
|
||||
"msrc_re": r"^microsoft sharepoint (enterprise )?server 2016\b",
|
||||
"label": "Microsoft SharePoint Server 2016"},
|
||||
{"key": "sp2019", "kind": "pkg", "branch": False, "match_re": r"sharepoint.*\b2019\b",
|
||||
"msrc_re": r"^microsoft sharepoint server 2019\b",
|
||||
"label": "Microsoft SharePoint Server 2019"},
|
||||
{"key": "spse", "kind": "pkg", "branch": False, "match_re": r"sharepoint.*subscription",
|
||||
"msrc_re": r"^microsoft sharepoint server subscription edition\b",
|
||||
"label": "Microsoft SharePoint Server Subscription Edition"},
|
||||
]
|
||||
_OS_COMPILED = [(re.compile(p["match_re"], re.I), p) for p in _PRODUCTS if p["kind"] == "os"]
|
||||
_PKG_COMPILED = [(re.compile(p["match_re"], re.I), p) for p in _PRODUCTS if p["kind"] == "pkg"]
|
||||
_MSRC_COMPILED = [(re.compile(p["msrc_re"], re.I), p) for p in _PRODUCTS]
|
||||
|
||||
_BUILD_RE = re.compile(r"^\d+(\.\d+)+$")
|
||||
|
||||
|
||||
def resolve_os(os_name: str) -> Optional[dict]:
|
||||
"""Asset OS string → curated product entry (None = not ours to scan)."""
|
||||
n = (os_name or "").strip().lower()
|
||||
if not n:
|
||||
return None
|
||||
for rx, p in _OS_COMPILED:
|
||||
if rx.search(n):
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_msrc_product(name: str) -> Optional[dict]:
|
||||
n = (name or "").strip().lower()
|
||||
for rx, p in _MSRC_COMPILED:
|
||||
if rx.search(n):
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def _btuple(b: str) -> Optional[tuple]:
|
||||
if not b or not _BUILD_RE.match(b):
|
||||
return None
|
||||
try:
|
||||
return tuple(int(x) for x in b.split("."))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _branch(b: str) -> Optional[tuple]:
|
||||
"""Servicing branch = build minus its revision, e.g.
|
||||
10.0.14393.9140 → (10, 0, 14393). A fix only speaks to hosts on its own
|
||||
branch: Server 2016 (14393) says nothing about Server 2019 (17763)."""
|
||||
t = _btuple(b)
|
||||
return t[:3] if t and len(t) >= 3 else None
|
||||
|
||||
|
||||
# ---------- index ----------
|
||||
|
||||
def build_product_index(db: Session, months_back: Optional[int] = None) -> dict:
|
||||
"""Walk the recent monthly CVRF docs → {product_key: [{cve, build, kb}]}."""
|
||||
import httpx
|
||||
from app.services.msrc_service import (
|
||||
MSRC_BASE, HTTP_TIMEOUT, DEFAULT_MONTHS_BACK, SETTING_MONTHS_BACK, _setting_int,
|
||||
)
|
||||
|
||||
months = months_back or _setting_int(db, SETTING_MONTHS_BACK, DEFAULT_MONTHS_BACK)
|
||||
index: Dict[str, List[dict]] = {}
|
||||
seen: set = set()
|
||||
docs_done = 0
|
||||
|
||||
with httpx.Client(timeout=HTTP_TIMEOUT, headers={"Accept": "application/json"}) as client:
|
||||
r = client.get(f"{MSRC_BASE}/updates")
|
||||
r.raise_for_status()
|
||||
doc_ids = [v["ID"] for v in (r.json().get("value") or [])][-months:]
|
||||
|
||||
for doc_id in doc_ids:
|
||||
try:
|
||||
resp = client.get(f"{MSRC_BASE}/cvrf/{doc_id}")
|
||||
resp.raise_for_status()
|
||||
doc = resp.json()
|
||||
except Exception as e:
|
||||
logger.warning("msrc-scan: doc %s failed: %s", doc_id, e)
|
||||
continue
|
||||
|
||||
# ProductID → curated product key (only the ones we can map).
|
||||
pid_key: Dict[str, str] = {}
|
||||
for fp in (doc.get("ProductTree", {}) or {}).get("FullProductName", []) or []:
|
||||
p = _resolve_msrc_product(fp.get("Value") or "")
|
||||
if p and fp.get("ProductID"):
|
||||
pid_key[str(fp["ProductID"])] = p["key"]
|
||||
if not pid_key:
|
||||
continue
|
||||
|
||||
for v in doc.get("Vulnerability", []) or []:
|
||||
cve = (v.get("CVE") or "").strip().upper()
|
||||
if not cve.startswith("CVE-"):
|
||||
continue
|
||||
for rem in v.get("Remediations", []) or []:
|
||||
if rem.get("Type") != 2:
|
||||
continue
|
||||
build = (rem.get("FixedBuild") or "").strip()
|
||||
if not _btuple(build):
|
||||
continue # no usable build → tells us nothing about patch state
|
||||
desc = str((rem.get("Description") or {}).get("Value", "") or "").strip()
|
||||
kb = desc if desc.isdigit() else None
|
||||
for pid in rem.get("ProductID") or []:
|
||||
key = pid_key.get(str(pid))
|
||||
if not key:
|
||||
continue
|
||||
sig = (key, cve, build)
|
||||
if sig in seen:
|
||||
continue
|
||||
seen.add(sig)
|
||||
index.setdefault(key, []).append({"cve": cve, "build": build, "kb": kb})
|
||||
docs_done += 1
|
||||
|
||||
_store_index(db, index)
|
||||
logger.info("msrc-scan: index built (%d docs) → %d products, %d fix entries",
|
||||
docs_done, len(index), sum(len(v) for v in index.values()))
|
||||
return index
|
||||
|
||||
|
||||
def _store_index(db: Session, index: dict) -> None:
|
||||
from app.models.setting import Setting
|
||||
payload = json.dumps({"built_at": datetime.now().isoformat(), "index": index})
|
||||
row = db.query(Setting).filter(Setting.key == _INDEX_SETTING).first()
|
||||
if row:
|
||||
row.value = payload
|
||||
else:
|
||||
db.add(Setting(key=_INDEX_SETTING, value=payload,
|
||||
description="MSRC product→(CVE, FixedBuild) index (curated)"))
|
||||
db.commit()
|
||||
|
||||
|
||||
def load_index(db: Session, allow_stale: bool = True) -> Optional[dict]:
|
||||
from app.models.setting import Setting
|
||||
row = db.query(Setting).filter(Setting.key == _INDEX_SETTING).first()
|
||||
if not row or not row.value:
|
||||
return None
|
||||
try:
|
||||
blob = json.loads(row.value)
|
||||
built = datetime.fromisoformat(blob.get("built_at"))
|
||||
except Exception:
|
||||
return None
|
||||
if not allow_stale and datetime.now() - built > _INDEX_TTL:
|
||||
return None
|
||||
return blob.get("index") or {}
|
||||
|
||||
|
||||
# ---------- scan ----------
|
||||
|
||||
def affected_cves(entries: List[dict], installed: str, branch_match: bool = True) -> List[dict]:
|
||||
"""CVEs whose fix is newer than the installed build. Per CVE the NEWEST
|
||||
fixed build wins — that's the one that actually has to be on the box.
|
||||
|
||||
branch_match: see _PRODUCTS. True → only fixes on the host's own build
|
||||
prefix count (the prefix is the release). False → the prefix moves with
|
||||
every CU, so identity already came from the product name and every fix for
|
||||
that product applies (MS servicing is cumulative, so installed < fixed is
|
||||
exactly the right test)."""
|
||||
inst_t = _btuple(installed)
|
||||
if not inst_t:
|
||||
return []
|
||||
inst_b = _branch(installed)
|
||||
if branch_match and not inst_b:
|
||||
return []
|
||||
newest: Dict[str, dict] = {}
|
||||
for e in entries:
|
||||
bt = _btuple(e.get("build") or "")
|
||||
if not bt:
|
||||
continue
|
||||
if branch_match and _branch(e["build"]) != inst_b:
|
||||
continue # different servicing branch → says nothing about this host
|
||||
cur = newest.get(e["cve"])
|
||||
if cur is None or bt > _btuple(cur["build"]):
|
||||
newest[e["cve"]] = e
|
||||
return [e for e in newest.values() if inst_t < _btuple(e["build"])]
|
||||
|
||||
|
||||
def resolve_package(name: str) -> Optional[dict]:
|
||||
"""Installed-software name → curated MSRC product (None = not ours)."""
|
||||
n = (name or "").strip().lower()
|
||||
if not n:
|
||||
return None
|
||||
for rx, p in _PKG_COMPILED:
|
||||
if rx.search(n):
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def scan_asset(db: Session, asset, index: dict, new_ids: list,
|
||||
touched: Optional[set] = None) -> int:
|
||||
"""Flag MS OS CVEs whose FixedBuild is ahead of this host's build."""
|
||||
if not index:
|
||||
return 0
|
||||
prod = resolve_os(asset.operating_system or "")
|
||||
if not prod:
|
||||
return 0
|
||||
entries = index.get(prod["key"]) or []
|
||||
if not entries:
|
||||
return 0
|
||||
installed = (asset.os_version or "").strip()
|
||||
return _flag(db, asset, prod, installed, entries, new_ids, touched)
|
||||
|
||||
|
||||
def scan_asset_packages(db: Session, asset, packages: list, index: dict,
|
||||
new_ids: list, touched: Optional[set] = None) -> int:
|
||||
"""Same fixed-build compare for installed MS software (SharePoint today).
|
||||
Called from the app-CVE scan, which already has the inventory in hand."""
|
||||
if not index:
|
||||
return 0
|
||||
count = 0
|
||||
seen: set = set()
|
||||
for pkg in packages or []:
|
||||
name = (pkg.get("name") or "").strip()
|
||||
version = (pkg.get("version") or "").strip()
|
||||
if not name or not version:
|
||||
continue
|
||||
prod = resolve_package(name)
|
||||
if not prod:
|
||||
continue
|
||||
# One product reports several components (Core / Lang Pack / SQL
|
||||
# Express) all carrying the same build — scan the product once.
|
||||
dedup = (prod["key"], version)
|
||||
if dedup in seen:
|
||||
continue
|
||||
seen.add(dedup)
|
||||
entries = index.get(prod["key"]) or []
|
||||
if entries:
|
||||
count += _flag(db, asset, prod, version, entries, new_ids, touched)
|
||||
return count
|
||||
|
||||
|
||||
def _flag(db: Session, asset, prod: dict, installed: str, entries: List[dict],
|
||||
new_ids: list, touched: Optional[set]) -> int:
|
||||
hits = affected_cves(entries, installed, branch_match=prod.get("branch", True))
|
||||
count = 0
|
||||
for h in hits:
|
||||
if touched is not None:
|
||||
touched.add(h["cve"])
|
||||
try:
|
||||
if _upsert(db, asset, prod["label"], installed, h, new_ids):
|
||||
count += 1
|
||||
except Exception as e:
|
||||
logger.debug("msrc-scan upsert failed (%s on %s): %s", h["cve"], asset.id, e)
|
||||
return count
|
||||
|
||||
|
||||
def _upsert(db: Session, asset, product: str, installed: str, hit: dict, new_ids: list) -> bool:
|
||||
from app.models.vulnerability import Vulnerability, VulnerabilityStatus
|
||||
cve_id = hit["cve"]
|
||||
fixed = hit["build"]
|
||||
kb = hit.get("kb")
|
||||
existing = (db.query(Vulnerability)
|
||||
.filter(Vulnerability.cve_id == cve_id, Vulnerability.asset_id == asset.id)
|
||||
.first())
|
||||
if existing:
|
||||
existing.add_source(SOURCE_NAME)
|
||||
if not existing.package_name:
|
||||
existing.package_name = product[:255]
|
||||
if not existing.package_version:
|
||||
existing.package_version = installed[:100]
|
||||
if not existing.fixed_version:
|
||||
existing.fixed_version = fixed
|
||||
if existing.status == VulnerabilityStatus.patched:
|
||||
existing.status = VulnerabilityStatus.open
|
||||
existing.patched_at = None
|
||||
try:
|
||||
existing.refresh_scores()
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
title = f"{product} — {cve_id}" + (f" (KB{kb})" if kb else "")
|
||||
row = Vulnerability(
|
||||
cve_id=cve_id, asset_id=asset.id,
|
||||
status=VulnerabilityStatus.open,
|
||||
title=title[:500],
|
||||
description=(f"MSRC reports {product} is fixed in build {fixed}"
|
||||
+ (f" via KB{kb}" if kb else "")
|
||||
+ f"; this host reports {installed}."),
|
||||
package_name=product[:255], package_version=installed[:100],
|
||||
fixed_version=fixed,
|
||||
detected_at=datetime.now(),
|
||||
sources=json.dumps([SOURCE_NAME]), first_detected_by=SOURCE_NAME,
|
||||
)
|
||||
db.add(row)
|
||||
db.flush()
|
||||
try:
|
||||
row.refresh_scores()
|
||||
except Exception:
|
||||
pass
|
||||
new_ids.append(row.id)
|
||||
return True
|
||||
|
||||
|
||||
def _resolve_stale(db: Session, asset, touched: set, considered: set) -> int:
|
||||
"""A msrc-only finding no longer reported means the host caught up past the
|
||||
FixedBuild → patched. Same contract as the app-scan reconcile: never touch
|
||||
findings another scanner also reports.
|
||||
|
||||
`considered` = the product labels THIS pass actually evaluated. Without it
|
||||
the OS pass would close every SharePoint finding it never looked at (and
|
||||
vice versa), since neither pass touches the other's CVEs."""
|
||||
from app.models.vulnerability import Vulnerability, VulnerabilityStatus
|
||||
if not considered:
|
||||
return 0
|
||||
rows = (db.query(Vulnerability)
|
||||
.filter(Vulnerability.asset_id == asset.id,
|
||||
Vulnerability.status == VulnerabilityStatus.open,
|
||||
Vulnerability.sources.contains('"msrc"'),
|
||||
Vulnerability.package_name.in_(list(considered)))
|
||||
.all())
|
||||
resolved = 0
|
||||
for v in rows:
|
||||
if v.cve_id in touched:
|
||||
continue
|
||||
# Drop OUR source; close only when nobody else still reports it (the
|
||||
# skip-if-cross-confirmed rule deadlocked across reconciles).
|
||||
v.remove_source(SOURCE_NAME)
|
||||
if v.source_list:
|
||||
continue
|
||||
old = v.status
|
||||
v.status = VulnerabilityStatus.patched
|
||||
v.patched_at = datetime.now()
|
||||
resolved += 1
|
||||
try:
|
||||
from app.routers.vulnerabilities import log_vulnerability_change
|
||||
log_vulnerability_change(
|
||||
db, None, v.id, old, v.status,
|
||||
reason=f"MSRC scan: {asset.hostname} is now at or past the fixed build",
|
||||
cve_id=v.cve_id, source="msrc_scan",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("audit log for msrc auto-resolve failed (vuln_id=%s): %s", v.id, e)
|
||||
return resolved
|
||||
|
||||
|
||||
def resolve_stale_packages(db: Session, asset, touched: set) -> int:
|
||||
"""Reconcile the package (pkg-kind) MSRC findings after a package scan.
|
||||
Considers ALL pkg product labels — we just saw the full inventory, so a
|
||||
product that vanished (uninstalled) should resolve too."""
|
||||
labels = {p["label"] for p in _PRODUCTS if p["kind"] == "pkg"}
|
||||
return _resolve_stale(db, asset, touched, labels)
|
||||
|
||||
|
||||
def run_msrc_scan(db: Session, asset_id: Optional[int] = None) -> dict:
|
||||
"""Scan Windows-Server assets against the MSRC fixed-build index."""
|
||||
from app.models.asset import Asset
|
||||
|
||||
stats = {"assets": 0, "findings": 0, "new": 0, "resolved": 0, "errors": []}
|
||||
index = load_index(db)
|
||||
if not index:
|
||||
logger.info("msrc-scan: index missing — building now (one-time, then nightly)")
|
||||
try:
|
||||
index = build_product_index(db) or {}
|
||||
except Exception as e:
|
||||
stats["errors"].append(f"index build failed: {e}")
|
||||
return stats
|
||||
if not index:
|
||||
return stats
|
||||
|
||||
new_ids: list = []
|
||||
q = db.query(Asset)
|
||||
if asset_id is not None:
|
||||
q = q.filter(Asset.id == asset_id)
|
||||
for asset in q.all():
|
||||
prod = resolve_os(asset.operating_system or "")
|
||||
if not prod:
|
||||
continue
|
||||
if not (asset.os_version or "").strip():
|
||||
continue # no build → nothing to compare
|
||||
touched: set = set()
|
||||
try:
|
||||
stats["findings"] += scan_asset(db, asset, index, new_ids, touched=touched)
|
||||
# Only this asset's OS product — the package pass owns its own labels.
|
||||
stats["resolved"] += _resolve_stale(db, asset, touched, {prod["label"]})
|
||||
stats["assets"] += 1
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
stats["errors"].append(f"asset {asset.id}: {e}")
|
||||
|
||||
stats["new"] = len(new_ids)
|
||||
if new_ids:
|
||||
try:
|
||||
from app.services.audit_events import audit_new_vulnerabilities
|
||||
audit_new_vulnerabilities(db, new_ids, source=SOURCE_NAME)
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
logger.debug("msrc-scan detected-audit failed: %s", e)
|
||||
try:
|
||||
from app.models.vulnerability import Vulnerability
|
||||
from app.services.enrichment_service import enrich_vulnerabilities
|
||||
from app.services.email_service import dispatch_new_vuln_notifications
|
||||
fresh = db.query(Vulnerability).filter(Vulnerability.id.in_(new_ids)).all()
|
||||
if fresh:
|
||||
enrich_vulnerabilities(db, fresh)
|
||||
stats["notifications"] = dispatch_new_vuln_notifications(db, fresh)
|
||||
except Exception as e:
|
||||
logger.debug("msrc-scan enrichment/notify failed: %s", e)
|
||||
|
||||
logger.info("MSRC scan: %d assets, %d findings (%d new, %d auto-resolved)",
|
||||
stats["assets"], stats["findings"], stats["new"], stats["resolved"])
|
||||
return stats
|
||||
@@ -0,0 +1,240 @@
|
||||
"""
|
||||
Microsoft Security Response Center (MSRC) CVRF enrichment.
|
||||
|
||||
Microsoft's per-CVE shortcut endpoint 404s; the stable source is the
|
||||
monthly CVRF document (https://api.msrc.microsoft.com/cvrf/v3.0/cvrf/
|
||||
{YYYY-Mon}, ~4-5 MB, ~1000 CVEs each). So we pull the last N monthly
|
||||
documents in a background job, extract per-CVE remediations, and upsert
|
||||
them into cve_remediations(source='msrc'). The CVE detail page then reads
|
||||
them from the DB (no live 4 MB fetch per click).
|
||||
|
||||
Covers Windows OS **and** Microsoft products (Office/365, .NET, SQL,
|
||||
Exchange, Visual Studio, ...), not just OS CVEs.
|
||||
|
||||
CVRF shapes we use, per Vulnerability:
|
||||
Remediations[]:
|
||||
Type 2 "Security Update" → KB in Description.Value (digits) + FixedBuild
|
||||
+ download URL → kind=fix
|
||||
Type 3 → support.microsoft.com/help/{KB} link (merged
|
||||
into the matching fix row by KB)
|
||||
Notes[]:
|
||||
Title "Workarounds" → kind=workaround (HTML → text)
|
||||
Title "Mitigations" → kind=mitigation (HTML → text)
|
||||
"""
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import httpx
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.cve_remediation import CveRemediation
|
||||
from app.models.setting import Setting
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MSRC_BASE = "https://api.msrc.microsoft.com/cvrf/v3.0"
|
||||
HTTP_TIMEOUT = 90.0
|
||||
# How many recent monthly documents to ingest per run. ~18 months covers
|
||||
# the CVEs realistically present on managed estates. Override via setting
|
||||
# `msrc_months_back`.
|
||||
DEFAULT_MONTHS_BACK = 18
|
||||
SETTING_MONTHS_BACK = "msrc_months_back"
|
||||
|
||||
MSRC_LAST_REFRESH_KEY = "msrc_last_refresh_at"
|
||||
|
||||
_TAG_RE = re.compile(r"<[^>]+>")
|
||||
_WS_RE = re.compile(r"[ \t]*\n[ \t]*")
|
||||
|
||||
|
||||
def _html_to_text(html: Optional[str]) -> Optional[str]:
|
||||
if not html:
|
||||
return None
|
||||
txt = _TAG_RE.sub(" ", html)
|
||||
txt = (txt.replace(" ", " ").replace("&", "&")
|
||||
.replace("<", "<").replace(">", ">").replace(""", '"'))
|
||||
txt = re.sub(r"[ \t]{2,}", " ", txt).strip()
|
||||
return txt or None
|
||||
|
||||
|
||||
def _setting_int(db: Session, key: str, default: int) -> int:
|
||||
s = db.query(Setting).filter(Setting.key == key).first()
|
||||
if s and s.value and str(s.value).strip().isdigit():
|
||||
return int(s.value)
|
||||
return default
|
||||
|
||||
|
||||
# ============================================================
|
||||
# parse one CVRF Vulnerability into remediation rows
|
||||
# ============================================================
|
||||
|
||||
def parse_vulnerability(v: dict) -> List[dict]:
|
||||
"""Return remediation dicts for one CVRF Vulnerability entry."""
|
||||
out: List[dict] = []
|
||||
|
||||
# Fixes — Type 2 carries the KB (digit Description) + FixedBuild + URL.
|
||||
# Type 3 is the support.microsoft.com link for the same KB; merge it in.
|
||||
fixes: Dict[str, dict] = {}
|
||||
support_links: Dict[str, str] = {}
|
||||
for r in v.get("Remediations", []) or []:
|
||||
rtype = r.get("Type")
|
||||
desc = str((r.get("Description") or {}).get("Value", "") or "").strip()
|
||||
url = (r.get("URL") or "").strip() or None
|
||||
build = (r.get("FixedBuild") or "").strip() or None
|
||||
sub = (r.get("SubType") or "").strip() or None
|
||||
if rtype == 2:
|
||||
kb = desc if desc.isdigit() else None
|
||||
key = kb or build or url or (sub or "fix")
|
||||
row = fixes.setdefault(key, {"kb": kb, "fixed_build": build, "url": url, "sub": sub})
|
||||
# keep first non-empty values
|
||||
row["kb"] = row.get("kb") or kb
|
||||
row["fixed_build"] = row.get("fixed_build") or build
|
||||
row["url"] = row.get("url") or url
|
||||
row["sub"] = row.get("sub") or sub
|
||||
elif rtype == 3:
|
||||
kb3 = (sub if (sub or "").isdigit() else None) or (desc if desc.isdigit() else None)
|
||||
if kb3 and url:
|
||||
support_links[kb3] = url
|
||||
|
||||
for key, row in fixes.items():
|
||||
kb = row.get("kb")
|
||||
url = row.get("url") or (support_links.get(kb) if kb else None)
|
||||
bits = []
|
||||
if kb:
|
||||
bits.append(f"KB{kb}")
|
||||
if row.get("fixed_build"):
|
||||
bits.append(f"build {row['fixed_build']}")
|
||||
title = " · ".join(bits) or (row.get("sub") or "Security Update")
|
||||
out.append({
|
||||
"kind": "fix",
|
||||
"title": title[:300],
|
||||
# `detail` carries the MSRC update type (SubType) so the detail
|
||||
# view can keep the newest KB per (build-branch + update-type) —
|
||||
# e.g. "Security Update" vs "Security Hotpatch Update".
|
||||
"detail": (row.get("sub") or "Security Update"),
|
||||
"kb": kb,
|
||||
"fixed_build": row.get("fixed_build"),
|
||||
"url": url,
|
||||
})
|
||||
|
||||
# Workarounds / Mitigations from Notes (HTML → text).
|
||||
for n in v.get("Notes", []) or []:
|
||||
title = (n.get("Title") or "").strip()
|
||||
if title not in ("Workarounds", "Mitigations"):
|
||||
continue
|
||||
text = _html_to_text(str(n.get("Value", "")))
|
||||
if not text:
|
||||
continue
|
||||
out.append({
|
||||
"kind": "workaround" if title == "Workarounds" else "mitigation",
|
||||
"title": title[:300],
|
||||
"detail": text[:4000],
|
||||
"kb": None,
|
||||
"fixed_build": None,
|
||||
"url": None,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
# ============================================================
|
||||
# fetch + ingest
|
||||
# ============================================================
|
||||
|
||||
def _list_recent_docs(client: "httpx.Client", months_back: int) -> List[str]:
|
||||
"""Return the last `months_back` monthly CVRF document IDs (YYYY-Mon)."""
|
||||
r = client.get(f"{MSRC_BASE}/updates", headers={"Accept": "application/json"})
|
||||
r.raise_for_status()
|
||||
ids = [
|
||||
u.get("ID") for u in (r.json().get("value") or [])
|
||||
if u.get("ID") and re.match(r"^\d{4}-[A-Za-z]{3}$", u["ID"])
|
||||
]
|
||||
# The index is roughly chronological but not guaranteed; sort by the
|
||||
# CurrentReleaseDate when present, else keep order, then take the tail.
|
||||
return ids[-months_back:]
|
||||
|
||||
|
||||
def _upsert_cve(db: Session, cve_id: str, rows: List[dict]) -> None:
|
||||
"""Replace all msrc rows for a cve_id with the freshly parsed set."""
|
||||
db.query(CveRemediation).filter(
|
||||
CveRemediation.cve_id == cve_id,
|
||||
CveRemediation.source == "msrc",
|
||||
).delete(synchronize_session=False)
|
||||
now = datetime.now()
|
||||
for r in rows:
|
||||
db.add(CveRemediation(
|
||||
cve_id=cve_id, source="msrc", kind=r["kind"],
|
||||
title=r.get("title"), detail=r.get("detail"),
|
||||
kb=r.get("kb"), fixed_build=r.get("fixed_build"),
|
||||
url=r.get("url"), fetched_at=now,
|
||||
))
|
||||
|
||||
|
||||
def refresh_msrc(db: Session, months_back: Optional[int] = None,
|
||||
only_known_cves: bool = True) -> dict:
|
||||
"""Ingest the last N monthly MSRC documents into cve_remediations.
|
||||
|
||||
only_known_cves: when True (default) we only store remediations for CVE
|
||||
ids already present in our vulnerabilities table — keeps the table
|
||||
relevant + small instead of mirroring ~18k MS CVEs.
|
||||
"""
|
||||
if months_back is None:
|
||||
months_back = _setting_int(db, SETTING_MONTHS_BACK, DEFAULT_MONTHS_BACK)
|
||||
|
||||
known: Optional[set] = None
|
||||
if only_known_cves:
|
||||
from app.models.vulnerability import Vulnerability
|
||||
known = {
|
||||
c for (c,) in db.query(Vulnerability.cve_id).distinct().all()
|
||||
if c and c.upper().startswith("CVE-")
|
||||
}
|
||||
|
||||
stats = {"docs": 0, "cves_seen": 0, "cves_stored": 0, "rows": 0, "errors": []}
|
||||
with httpx.Client(timeout=HTTP_TIMEOUT, follow_redirects=True,
|
||||
headers={"User-Agent": "TrueVuln/1.0"}) as client:
|
||||
try:
|
||||
doc_ids = _list_recent_docs(client, months_back)
|
||||
except httpx.HTTPError as e:
|
||||
raise RuntimeError(f"MSRC updates index fetch failed: {e}") from e
|
||||
|
||||
for doc_id in doc_ids:
|
||||
try:
|
||||
r = client.get(f"{MSRC_BASE}/cvrf/{doc_id}",
|
||||
headers={"Accept": "application/json"})
|
||||
if r.status_code != 200:
|
||||
stats["errors"].append(f"{doc_id}: HTTP {r.status_code}")
|
||||
continue
|
||||
doc = r.json()
|
||||
except (httpx.HTTPError, ValueError) as e:
|
||||
stats["errors"].append(f"{doc_id}: {e}")
|
||||
continue
|
||||
stats["docs"] += 1
|
||||
for v in doc.get("Vulnerability", []) or []:
|
||||
cve = (v.get("CVE") or "").strip().upper()
|
||||
if not cve.startswith("CVE-"):
|
||||
continue
|
||||
stats["cves_seen"] += 1
|
||||
if known is not None and cve not in known:
|
||||
continue
|
||||
rows = parse_vulnerability(v)
|
||||
if not rows:
|
||||
continue
|
||||
_upsert_cve(db, cve, rows)
|
||||
stats["cves_stored"] += 1
|
||||
stats["rows"] += len(rows)
|
||||
db.commit()
|
||||
logger.info("MSRC: ingested %s (%d CVEs stored so far)", doc_id, stats["cves_stored"])
|
||||
|
||||
_set_last_refresh(db)
|
||||
logger.info("MSRC refresh done: %s", {k: v for k, v in stats.items() if k != "errors"})
|
||||
return stats
|
||||
|
||||
|
||||
def _set_last_refresh(db: Session) -> None:
|
||||
s = db.query(Setting).filter(Setting.key == MSRC_LAST_REFRESH_KEY).first()
|
||||
if s:
|
||||
s.value = datetime.now().isoformat()
|
||||
else:
|
||||
db.add(Setting(key=MSRC_LAST_REFRESH_KEY, value=datetime.now().isoformat(),
|
||||
description="Timestamp of last MSRC CVRF refresh"))
|
||||
db.commit()
|
||||
+172
-25
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Nessus → VulnCheck sync service.
|
||||
Nessus → TrueVuln sync service.
|
||||
|
||||
Imports scan findings from Tenable Nessus and merges them onto existing
|
||||
Wazuh-sourced vulnerabilities using `(cve_id, asset_id)` as the dedup key.
|
||||
@@ -197,6 +197,28 @@ _OFFICE_RE = re.compile(r"microsoft\s*office", re.I)
|
||||
_OFFICE_YEAR_RE = re.compile(r"\b(20\d{2})\b")
|
||||
|
||||
|
||||
# Catch-all Nessus solution strings that carry no product-specific fix.
|
||||
# A cross-confirmed CVE can match several plugins; a generic plugin must
|
||||
# not overwrite (or block) the specific plugin's real solution.
|
||||
_GENERIC_REMEDIATION_MARKERS = (
|
||||
"install the patches listed below",
|
||||
"apply the appropriate patch",
|
||||
"apply the patches",
|
||||
"there is no known fix",
|
||||
"no known solution",
|
||||
"n/a",
|
||||
"refer to the vendor",
|
||||
)
|
||||
|
||||
|
||||
def _is_generic_remediation(text: Optional[str]) -> bool:
|
||||
"""True for empty or catch-all remediation text (no specific fix)."""
|
||||
if not text or not text.strip():
|
||||
return True
|
||||
low = text.strip().lower()
|
||||
return any(m in low for m in _GENERIC_REMEDIATION_MARKERS)
|
||||
|
||||
|
||||
def _office_year(plugin_name: str) -> Optional[str]:
|
||||
"""Extract the 4-digit year from an Office plugin name, or None."""
|
||||
m = _OFFICE_YEAR_RE.search(plugin_name or "")
|
||||
@@ -204,12 +226,46 @@ def _office_year(plugin_name: str) -> Optional[str]:
|
||||
|
||||
|
||||
def _office_pseudo_cve(plugin_name: str, plugin_id) -> str:
|
||||
"""Return `EOL-MS-OFFICE-YYYY` for Office variants, else `EOL-NESSUS-{pid}`."""
|
||||
"""Return `EOL-MS-OFFICE-YYYY` for Office variants, else `EOL-NESSUS-{pid}`.
|
||||
|
||||
NOTE: prefer `_slug_pseudo_cve(plugin_name, plugin_id, installed_version)`
|
||||
for the slug-based naming (`EOL-{SLUG}-{VERSION}`). Kept as a fallback
|
||||
when product-name resolution fails.
|
||||
"""
|
||||
if _OFFICE_RE.search(plugin_name or "") and _office_year(plugin_name):
|
||||
return f"EOL-MS-OFFICE-{_office_year(plugin_name)}"
|
||||
return f"EOL-NESSUS-{plugin_id}"
|
||||
|
||||
|
||||
def _slug_pseudo_cve(plugin_name: str, plugin_id, installed_version: Optional[str] = None) -> str:
|
||||
"""Return product-name-based pseudo-CVE id (`EOL-MSSQLSERVER-...`) when
|
||||
we can resolve a slug, falling back to the legacy `EOL-NESSUS-{pid}`.
|
||||
|
||||
Slug comes from `eol_service.resolve_product_slug(plugin_name)`. The
|
||||
trailing token is the installed version (sanitised) or the last 4
|
||||
digits of the plugin id when no version is present, so the row is
|
||||
still stable across re-syncs.
|
||||
"""
|
||||
# Office is its own special case — keep the year-based id so the
|
||||
# language-pack dedup in `run_nessus_sync` keeps working.
|
||||
if _OFFICE_RE.search(plugin_name or "") and _office_year(plugin_name):
|
||||
return f"EOL-MS-OFFICE-{_office_year(plugin_name)}"
|
||||
# Lazy import: eol_service imports from a few places; avoid a hard
|
||||
# import cycle at module load.
|
||||
try:
|
||||
from app.services.eol_service import resolve_product_slug
|
||||
slug = resolve_product_slug(plugin_name)
|
||||
except Exception:
|
||||
slug = None
|
||||
if slug:
|
||||
if installed_version:
|
||||
safe_v = re.sub(r"[^A-Za-z0-9._-]", "_", str(installed_version))[:24] or "x"
|
||||
return f"EOL-{slug.upper()}-{safe_v}"[:50]
|
||||
# No version → last 4 digits of plugin id keeps it stable
|
||||
return f"EOL-{slug.upper()}-P{str(plugin_id)[-4:]}"[:50]
|
||||
return f"EOL-NESSUS-{plugin_id}"
|
||||
|
||||
|
||||
def _normalise_office_pkg(pkg: str) -> str:
|
||||
"""Collapse all Office sub-flavour strings to the unified `MS Office`."""
|
||||
if _OFFICE_RE.search(pkg or ""):
|
||||
@@ -240,7 +296,19 @@ def _upsert_nessus_eol(
|
||||
widget alongside endoflife.date findings. Dedup key is (cve_id,
|
||||
asset_id), stable across re-syncs.
|
||||
"""
|
||||
cve_id = _office_pseudo_cve(plugin_name, plugin_id)
|
||||
cve_id = _slug_pseudo_cve(plugin_name, plugin_id, installed_version)
|
||||
# Homogenisation: once a plugin resolves to a proper product slug
|
||||
# (EOL-ADOBE-ACROBAT-..., EOL-MSSQLSERVER-...), drop any legacy
|
||||
# EOL-NESSUS-{plugin_id} row left over from before the slug was known.
|
||||
# Without this the old plugin-id row lingers next to the new named one
|
||||
# (tester: "nach neuem Scan noch PLUGIN ID UND NESSUS").
|
||||
if plugin_id and not cve_id.startswith("EOL-NESSUS-"):
|
||||
legacy_id = f"EOL-NESSUS-{plugin_id}"
|
||||
if legacy_id != cve_id:
|
||||
db.query(Vulnerability).filter(
|
||||
Vulnerability.cve_id == legacy_id,
|
||||
Vulnerability.asset_id == asset.id,
|
||||
).delete(synchronize_session=False)
|
||||
# Strip the "... Unsupported Version Detection" suffix for a clean
|
||||
# PACKAGE column ("Microsoft SQL Server"). Office sub-flavours collapse
|
||||
# to "MS Office" so the language-pack noise stops multiplying rows.
|
||||
@@ -322,7 +390,7 @@ def run_nessus_sync(
|
||||
) -> dict:
|
||||
"""
|
||||
Pull findings from Nessus for the requested scans (or all configured
|
||||
default_scan_ids) and merge them into VulnCheck.
|
||||
default_scan_ids) and merge them into TrueVuln.
|
||||
|
||||
Returns a stats dict suitable for logging + API response.
|
||||
"""
|
||||
@@ -352,7 +420,8 @@ def run_nessus_sync(
|
||||
}
|
||||
|
||||
newly_created_vuln_ids: List[int] = []
|
||||
seen_nessus_uuids: set = set() # for sync-driven asset reconciliation
|
||||
seen_nessus_uuids: set = set() # legacy uuid-keyed reconcile
|
||||
seen_asset_ids: set = set() # robust id-keyed reconcile
|
||||
|
||||
with _build_client(config) as client:
|
||||
# Auto-discover scans if no explicit IDs and no defaults
|
||||
@@ -398,10 +467,17 @@ def run_nessus_sync(
|
||||
})
|
||||
continue
|
||||
stats["hosts_synced"] += 1
|
||||
# Track for sync-driven reconciliation — only count assets that
|
||||
# have a nessus_host_uuid pinned (the key we reconcile on).
|
||||
# Track for sync-driven reconciliation. Two sets:
|
||||
# - seen_nessus_uuids: legacy uuid-keyed path (kept).
|
||||
# - seen_asset_ids: robust id-keyed path. A scan host
|
||||
# whose host_info lacks host_uuid leaves the uuid set
|
||||
# empty → the fail-open guard skipped EVERYTHING and
|
||||
# nothing got inactivated (tester bug). Tracking the
|
||||
# matched asset.id sidesteps the missing-uuid case.
|
||||
if asset.nessus_host_uuid:
|
||||
seen_nessus_uuids.add(asset.nessus_host_uuid)
|
||||
if asset.id:
|
||||
seen_asset_ids.add(asset.id)
|
||||
|
||||
# OS Identification (Nessus plugin 11936 + host info fields)
|
||||
# Only fills when the asset row has nothing — Wazuh-sourced
|
||||
@@ -468,17 +544,12 @@ def run_nessus_sync(
|
||||
n_solution = NessusClient.plugin_solution(plugin_payload) if plugin_payload else None
|
||||
n_see_also = NessusClient.plugin_see_also(plugin_payload) if plugin_payload else []
|
||||
|
||||
# Compose a description with the solution appended — Nessus
|
||||
# provides both as separate fields, our schema has one text
|
||||
# column, so we glue them together for the detail view.
|
||||
full_description = None
|
||||
if n_description or n_solution:
|
||||
parts = []
|
||||
if n_description:
|
||||
parts.append(n_description.strip())
|
||||
if n_solution:
|
||||
parts.append("\n\nSolution:\n" + n_solution.strip())
|
||||
full_description = "".join(parts)
|
||||
# Description = Nessus synopsis/description only. The
|
||||
# solution text goes into the dedicated `remediation`
|
||||
# column (rendered as its own section) instead of being
|
||||
# glued onto the description.
|
||||
full_description = n_description.strip() if n_description else None
|
||||
remediation = n_solution.strip() if n_solution else None
|
||||
|
||||
if not cve_list:
|
||||
# Most non-CVE plugins (compliance / cipher / info) stay
|
||||
@@ -500,7 +571,9 @@ def run_nessus_sync(
|
||||
# Mark as seen THIS run so the source-backfill below
|
||||
# doesn't immediately drop nessus + patch the row we
|
||||
# just upserted (it keys on cve_id membership).
|
||||
seen_cves_for_asset.add(f"EOL-NESSUS-{plugin_id}")
|
||||
seen_cves_for_asset.add(
|
||||
_slug_pseudo_cve(eol_name, plugin_id, installed_version)
|
||||
)
|
||||
if _upsert_nessus_eol(
|
||||
db,
|
||||
asset=asset,
|
||||
@@ -647,6 +720,19 @@ def run_nessus_sync(
|
||||
if n_see_also and not existing.references:
|
||||
existing.references = json.dumps(n_see_also)
|
||||
changed = True
|
||||
# Remediation precedence: a SPECIFIC solution
|
||||
# always beats a generic/empty one; a generic
|
||||
# solution never overwrites a specific one. Fixes
|
||||
# cross-confirmed CVEs where a catch-all plugin
|
||||
# ("Install the patches listed below.") clobbered
|
||||
# the real plugin fix ("Upgrade to ... X.Y.Z").
|
||||
if remediation and (
|
||||
not existing.remediation
|
||||
or (_is_generic_remediation(existing.remediation)
|
||||
and not _is_generic_remediation(remediation))
|
||||
):
|
||||
existing.remediation = remediation
|
||||
changed = True
|
||||
# If previously marked patched but Nessus sees it again → reopen
|
||||
if existing.status == VulnerabilityStatus.patched:
|
||||
existing.status = VulnerabilityStatus.open
|
||||
@@ -677,6 +763,7 @@ def run_nessus_sync(
|
||||
package_name=plugin_name[:255] if plugin_name else None,
|
||||
package_version=installed_version,
|
||||
description=full_description,
|
||||
remediation=remediation,
|
||||
references=json.dumps(n_see_also) if n_see_also else None,
|
||||
exploit_available=bool(exploit_avail) if exploit_avail is not None else False,
|
||||
exploit_maturity=exploit_mat[:50] if exploit_mat else None,
|
||||
@@ -832,21 +919,35 @@ def run_nessus_sync(
|
||||
# flipped to INACTIVE. Vice-versa: INACTIVE NESSUS assets that
|
||||
# re-appeared are flipped back to ACTIVE. Audit-logged.
|
||||
try:
|
||||
from app.services.asset_lifecycle import reconcile_missing_from_sync
|
||||
recon = reconcile_missing_from_sync(
|
||||
from app.services.asset_lifecycle import reconcile_nessus_by_seen_ids
|
||||
recon = reconcile_nessus_by_seen_ids(
|
||||
db,
|
||||
source=AssetSource.NESSUS,
|
||||
seen_ids=seen_nessus_uuids,
|
||||
id_field=Asset.nessus_host_uuid,
|
||||
seen_asset_ids=seen_asset_ids,
|
||||
reason=f"not in latest Nessus scan (scans={target_scan_ids})",
|
||||
)
|
||||
stats["assets_inactivated"] = recon["inactivated"]
|
||||
stats["assets_reactivated"] = recon["reactivated"]
|
||||
logger.info(
|
||||
"Nessus sync reconcile: %d seen, %d inactivated, %d reactivated, %d candidates",
|
||||
len(seen_asset_ids), recon["inactivated"], recon["reactivated"],
|
||||
recon.get("candidates", 0),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Nessus sync: asset reconciliation failed (non-fatal): %s", e)
|
||||
|
||||
db.commit()
|
||||
|
||||
# Revisionssicher: initial VULNERABILITY_DETECTED audit event per new
|
||||
# finding (previously the audit trail only began at the first status
|
||||
# change).
|
||||
if newly_created_vuln_ids:
|
||||
try:
|
||||
from app.services.audit_events import audit_new_vulnerabilities
|
||||
audit_new_vulnerabilities(db, newly_created_vuln_ids, source="nessus")
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
logger.warning("Nessus sync: detected-audit failed (non-fatal): %s", e)
|
||||
|
||||
# Best-effort enrichment + notification (re-use Wazuh path)
|
||||
if newly_created_vuln_ids:
|
||||
try:
|
||||
@@ -859,7 +960,9 @@ def run_nessus_sync(
|
||||
.all()
|
||||
)
|
||||
try:
|
||||
enrich_vulnerabilities(db, fresh)
|
||||
# NVD date backfill deferred to the nightly enrichment job
|
||||
# (rate-limited — would stall the sync).
|
||||
enrich_vulnerabilities(db, fresh, use_nvd_dates=False)
|
||||
except Exception as e:
|
||||
logger.warning("Nessus sync: enrichment failed (non-fatal): %s", e)
|
||||
|
||||
@@ -874,3 +977,47 @@ def run_nessus_sync(
|
||||
stats["vulns_marked_patched"], len(stats["unmatched_hosts"]),
|
||||
)
|
||||
return stats
|
||||
|
||||
|
||||
def reconcile_legacy_nessus_assets(db: Session) -> dict:
|
||||
"""One-shot helper for testers: flip ACTIVE NESSUS-sourced assets that
|
||||
have no `nessus_host_uuid` pinned (legacy rows from before the
|
||||
reconcile path was hardened) to INACTIVE.
|
||||
|
||||
These rows were created by older Nessus syncs that matched by IP
|
||||
only, so they never get a UUID and are silently skipped by
|
||||
`reconcile_missing_from_sync`. Without this, a reduced scan leaves
|
||||
them all ACTIVE.
|
||||
|
||||
Returns {"inactivated": int, "scanned": int}.
|
||||
|
||||
Safe to run multiple times. Logs an audit entry for each row flipped.
|
||||
"""
|
||||
from app.services.asset_lifecycle import _audit_asset_status
|
||||
from app.models.asset import AssetSource, AssetStatus
|
||||
|
||||
legacy = (
|
||||
db.query(Asset)
|
||||
.filter(
|
||||
Asset.source == AssetSource.NESSUS,
|
||||
Asset.status == AssetStatus.ACTIVE,
|
||||
Asset.nessus_host_uuid.is_(None),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
stats = {"scanned": len(legacy), "inactivated": 0}
|
||||
for a in legacy:
|
||||
a.status = AssetStatus.INACTIVE
|
||||
_audit_asset_status(
|
||||
db, a, "active", "inactive",
|
||||
"legacy Nessus-sourced asset without pinned nessus_host_uuid — "
|
||||
"cannot be reconciled event-driven; flipped via reconcile_legacy_nessus_assets",
|
||||
)
|
||||
stats["inactivated"] += 1
|
||||
if stats["inactivated"]:
|
||||
db.commit()
|
||||
logger.info(
|
||||
"nessus legacy reconcile: %d inactivated (of %d legacy ACTIVE rows)",
|
||||
stats["inactivated"], stats["scanned"],
|
||||
)
|
||||
return stats
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
"""
|
||||
Asset "Risk Dimensions" — high-value-target (crown-jewel) role detection.
|
||||
|
||||
Network exposure alone (open ports) doesn't capture WHY a host matters.
|
||||
A Domain Controller, a Certificate Authority, a SQL/Exchange/backup server
|
||||
— once compromised — enable lateral movement, domain takeover and ransomware
|
||||
spread. This service detects such roles from the data Wazuh syscollector
|
||||
already provides (listening ports + process names + installed packages) and
|
||||
produces:
|
||||
- a high_value_score (0-100)
|
||||
- a list of detected role dimensions ({role, label, weight})
|
||||
|
||||
The score feeds the URS via risk_factor() (see urs_service): roles raise an
|
||||
asset's risk weighting even when the operator left criticality at "normal".
|
||||
|
||||
Detection sources: ports (port number + process name) and installed package
|
||||
names. Package-based roles (Exchange/WSUS/MSSQL/backup/SW-distribution) are
|
||||
reliable; port/process roles (DC/DNS/DHCP/WinRM) are good. Deep NTLM/Kerberos
|
||||
usage analysis is NOT available from syscollector → out of scope here.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
# role key → (label, weight, port-set, process-substrings, package-substrings)
|
||||
# A role fires if ANY of its port/process/package signals match.
|
||||
_ROLES: List[dict] = [
|
||||
{"role": "domain_controller", "label": "Domain Controller (AD DS)", "weight": 100,
|
||||
"ports": {88, 464}, "ports_all": {389, 88}, # kerberos+ldap together = strong DC
|
||||
"proc": ("ntds",), "pkg": ()},
|
||||
{"role": "adcs", "label": "AD Certificate Services (CA)", "weight": 100,
|
||||
"ports": set(), "proc": ("certsrv",),
|
||||
"pkg": ("active directory certificate services", "certification authority")},
|
||||
{"role": "backup", "label": "Backup server", "weight": 90,
|
||||
"ports": set(), "proc": ("veeam", "acronis", "rubrik"),
|
||||
"pkg": ("veeam", "acronis", "rubrik", "arcserve", "commvault", "netbackup",
|
||||
"veritas backup", "altaro", "nakivo")},
|
||||
{"role": "sw_distribution", "label": "Software distribution", "weight": 85,
|
||||
"ports": set(), "proc": ("ccmexec",),
|
||||
"pkg": ("configuration manager", "system center configuration", "endpoint configuration manager",
|
||||
"configmgr", "pdq deploy", "bigfix", "ivanti")},
|
||||
{"role": "exchange", "label": "Exchange (on-prem)", "weight": 85,
|
||||
"ports": set(), "proc": ("msexchange",),
|
||||
"pkg": ("microsoft exchange server",)},
|
||||
{"role": "wsus", "label": "WSUS", "weight": 80,
|
||||
"ports": {8530, 8531}, "proc": (),
|
||||
"pkg": ("windows server update services", "wsus")},
|
||||
{"role": "mssql", "label": "MS SQL Server", "weight": 70,
|
||||
"ports": {1433}, "proc": ("sqlservr",),
|
||||
"pkg": ("microsoft sql server 20", "sql server database engine")},
|
||||
{"role": "dns", "label": "DNS server", "weight": 60,
|
||||
"ports": {53}, "proc": ("dns.exe", "named", "dnsmasq"), "pkg": ()},
|
||||
{"role": "dhcp", "label": "DHCP server", "weight": 55,
|
||||
"ports": {67}, "proc": ("dhcpserver", "dhcpd"), "pkg": ("dhcp server",)},
|
||||
{"role": "winrm", "label": "WinRM / PS-Remoting", "weight": 30,
|
||||
"ports": {5985, 5986}, "proc": (), "pkg": ()},
|
||||
]
|
||||
|
||||
|
||||
def _norm(s: str) -> str:
|
||||
return re.sub(r"\s+", " ", (s or "").lower()).strip()
|
||||
|
||||
|
||||
def _listening_ports(ports: List[dict]) -> tuple:
|
||||
"""Return (set_of_listening_ports, set_of_process_substrings_lower)."""
|
||||
open_ports: set = set()
|
||||
procs: set = set()
|
||||
for p in ports or []:
|
||||
if not isinstance(p, dict):
|
||||
continue
|
||||
state = str(p.get("state") or "").lower()
|
||||
proto = str(p.get("protocol") or p.get("proto") or "").lower()
|
||||
if proto == "tcp" and state and state != "listening":
|
||||
continue
|
||||
try:
|
||||
port = int(p.get("local_port") or (p.get("local") or {}).get("port") or 0)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
if port > 0:
|
||||
open_ports.add(port)
|
||||
proc = _norm(p.get("process") or "")
|
||||
if proc:
|
||||
procs.add(proc)
|
||||
return open_ports, procs
|
||||
|
||||
|
||||
def detect_risk_dimensions(ports: List[dict], packages: List[dict]) -> dict:
|
||||
"""Detect crown-jewel roles → {score, dimensions:[{role,label,weight}]}.
|
||||
|
||||
Pure function (no DB / network) → unit-testable.
|
||||
"""
|
||||
open_ports, procs = _listening_ports(ports)
|
||||
pkg_names = [_norm(p.get("name") or "") for p in (packages or []) if isinstance(p, dict)]
|
||||
pkg_blob = " | ".join(pkg_names)
|
||||
|
||||
detected: List[dict] = []
|
||||
for r in _ROLES:
|
||||
hit = False
|
||||
# ports: any of `ports`, OR all of `ports_all`
|
||||
if r.get("ports") and (open_ports & r["ports"]):
|
||||
hit = True
|
||||
if not hit and r.get("ports_all") and r["ports_all"].issubset(open_ports):
|
||||
hit = True
|
||||
# process substrings
|
||||
if not hit and r.get("proc"):
|
||||
if any(any(sub in pr for pr in procs) for sub in r["proc"]):
|
||||
hit = True
|
||||
# package substrings
|
||||
if not hit and r.get("pkg"):
|
||||
if any(sub in pkg_blob for sub in r["pkg"]):
|
||||
hit = True
|
||||
if hit:
|
||||
detected.append({"role": r["role"], "label": r["label"], "weight": r["weight"]})
|
||||
|
||||
if not detected:
|
||||
return {"score": 0.0, "dimensions": []}
|
||||
|
||||
weights = sorted((d["weight"] for d in detected), reverse=True)
|
||||
score = float(weights[0]) + 0.3 * sum(weights[1:])
|
||||
score = round(min(score, 100.0), 1)
|
||||
# surface highest-weight roles first
|
||||
detected.sort(key=lambda d: d["weight"], reverse=True)
|
||||
return {"score": score, "dimensions": detected}
|
||||
|
||||
|
||||
def risk_factor(high_value_score: Optional[float]) -> float:
|
||||
"""Map the high-value score to a URS multiplier band.
|
||||
>=90 → 1.5 (critical), >=70 → 1.3 (high), >=40 → 1.15, else 1.0."""
|
||||
s = high_value_score or 0.0
|
||||
if s >= 90:
|
||||
return 1.5
|
||||
if s >= 70:
|
||||
return 1.3
|
||||
if s >= 40:
|
||||
return 1.15
|
||||
return 1.0
|
||||
@@ -0,0 +1,151 @@
|
||||
"""
|
||||
Samsung Security Maintenance Release (SMR) per-CVE detection.
|
||||
|
||||
For Samsung Android devices, security.samsungmobile.com's yearly page is a
|
||||
MORE PRECISE source than the raw Google Android Security Bulletin (ASB):
|
||||
Samsung explicitly excludes CVEs that don't apply to its own devices/chipsets
|
||||
("Not applicable to Samsung devices") and adds Samsung Semiconductor-specific
|
||||
fixes. Using raw ASB for a Samsung device produces false positives on
|
||||
chipset-specific CVEs Samsung's own page says don't apply
|
||||
(e.g. CVE-2025-59604 — Qualcomm-only, explicitly not-applicable to Samsung).
|
||||
|
||||
The page ignores its own year/month query params in the sense that it always
|
||||
serves the FULL requested year's content server-side (all ~12 SMR sections
|
||||
are present in the raw HTML — the accordion UI is pure client-side CSS/JS,
|
||||
it doesn't gate what's delivered), so ?year=YYYY is fetched once and cached,
|
||||
covering every month of that year.
|
||||
|
||||
Falls back to the raw-ASB scanner (android_cve_service) for months this page
|
||||
doesn't cover (very old dates, or a fetch failure).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SMR_URL = "https://security.samsungmobile.com/securityUpdate.smsb"
|
||||
_CACHE_PREFIX = "smr_year_v1_"
|
||||
_CACHE_TTL = timedelta(hours=24) # the current year gains a new SMR monthly
|
||||
_MONTHS = {"JAN": 1, "FEB": 2, "MAR": 3, "APR": 4, "MAY": 5, "JUN": 6,
|
||||
"JUL": 7, "AUG": 8, "SEP": 9, "OCT": 10, "NOV": 11, "DEC": 12}
|
||||
_CVE_RE = re.compile(r"CVE-\d{4}-\d{4,7}")
|
||||
_SEV_LIST_RE = {
|
||||
sev: re.compile(rf'<strong><font[^>]*>{sev}</font></strong><br\s*/?>([^<]*)', re.I)
|
||||
for sev in ("Critical", "High")
|
||||
}
|
||||
_NA_RE = re.compile(r"Not applicable to Samsung devices</font></strong><br\s*/?>([^<]*)", re.I)
|
||||
_SEM_HEADER_RE = re.compile(r"Samsung Semiconductor patch is also included", re.I)
|
||||
|
||||
|
||||
def _parse_smr_html(html: str) -> Dict[Tuple[int, int], List[Tuple[str, str]]]:
|
||||
"""→ {(year, month): [(cve, severity)]}. severity is 'critical'|'high'.
|
||||
Google Critical/High minus the "Not applicable" list, plus the Samsung
|
||||
Semiconductor Critical/High list (Samsung's own chipset-fix scope)."""
|
||||
positions = [(m.start(), m.group(1), m.group(2))
|
||||
for m in re.finditer(r"SMR-([A-Z]{3})-(\d{4})", html)]
|
||||
out: Dict[Tuple[int, int], List[Tuple[str, str]]] = {}
|
||||
for i, (pos, mon, yr) in enumerate(positions):
|
||||
month = _MONTHS.get(mon)
|
||||
if not month:
|
||||
continue
|
||||
end = positions[i + 1][0] if i + 1 < len(positions) else len(html)
|
||||
block = html[pos:end]
|
||||
|
||||
def sev_cves(label: str, text: str) -> List[str]:
|
||||
m = _SEV_LIST_RE[label].search(text)
|
||||
return _CVE_RE.findall(m.group(1)) if m else []
|
||||
|
||||
critical = sev_cves("Critical", block)
|
||||
high = sev_cves("High", block)
|
||||
na_m = _NA_RE.search(block)
|
||||
not_applicable = set(_CVE_RE.findall(na_m.group(1))) if na_m else set()
|
||||
|
||||
sem_critical: List[str] = []
|
||||
sem_high: List[str] = []
|
||||
sem_m = _SEM_HEADER_RE.search(block)
|
||||
if sem_m:
|
||||
sem_block = block[sem_m.start():sem_m.start() + 2000]
|
||||
sem_critical = sev_cves("Critical", sem_block)
|
||||
sem_high = sev_cves("High", sem_block)
|
||||
|
||||
pairs: List[Tuple[str, str]] = []
|
||||
seen: set = set()
|
||||
for cve in critical:
|
||||
if cve in not_applicable or cve in seen:
|
||||
continue
|
||||
seen.add(cve)
|
||||
pairs.append((cve, "critical"))
|
||||
for cve in high:
|
||||
if cve in not_applicable or cve in seen:
|
||||
continue
|
||||
seen.add(cve)
|
||||
pairs.append((cve, "high"))
|
||||
for cve in sem_critical:
|
||||
if cve in seen:
|
||||
continue
|
||||
seen.add(cve)
|
||||
pairs.append((cve, "critical"))
|
||||
for cve in sem_high:
|
||||
if cve in seen:
|
||||
continue
|
||||
seen.add(cve)
|
||||
pairs.append((cve, "high"))
|
||||
out[(int(yr), month)] = pairs
|
||||
return out
|
||||
|
||||
|
||||
def fetch_smr_year(db: Session, year: int) -> Optional[Dict[Tuple[int, int], List[Tuple[str, str]]]]:
|
||||
"""Cached fetch+parse of one year's SMR page (covers all its months).
|
||||
None on fetch failure (caller should fall back to ASB)."""
|
||||
from app.models.setting import Setting
|
||||
key = f"{_CACHE_PREFIX}{year}"
|
||||
row = db.query(Setting).filter(Setting.key == key).first()
|
||||
if row and row.value:
|
||||
try:
|
||||
blob = json.loads(row.value)
|
||||
ts = datetime.fromisoformat(blob["ts"])
|
||||
if datetime.now() - ts < _CACHE_TTL:
|
||||
return {tuple(map(int, k.split("-"))): [tuple(p) for p in v]
|
||||
for k, v in blob["months"].items()}
|
||||
except Exception:
|
||||
pass
|
||||
import httpx
|
||||
try:
|
||||
with httpx.Client(timeout=30.0, follow_redirects=True,
|
||||
headers={"User-Agent": "TrueVuln/1.0"}) as c:
|
||||
r = c.get(_SMR_URL, params={"year": year})
|
||||
if r.status_code != 200:
|
||||
return None
|
||||
parsed = _parse_smr_html(r.text)
|
||||
except Exception as e:
|
||||
logger.debug("SMR fetch failed for year %s: %s", year, e)
|
||||
return None
|
||||
if not parsed:
|
||||
return None
|
||||
payload = json.dumps({
|
||||
"ts": datetime.now().isoformat(),
|
||||
"months": {f"{y}-{m}": pairs for (y, m), pairs in parsed.items()},
|
||||
})
|
||||
if row:
|
||||
row.value = payload
|
||||
else:
|
||||
db.add(Setting(key=key, value=payload, description=f"Samsung SMR {year} (cve,severity) per month"))
|
||||
db.commit()
|
||||
return parsed
|
||||
|
||||
|
||||
def get_smr_month(db: Session, year: int, month: int) -> Optional[List[Tuple[str, str]]]:
|
||||
"""CVEs Samsung's own SMR page attributes as applicable for (year, month),
|
||||
or None if that year's page couldn't be fetched or doesn't cover the
|
||||
month (caller should fall back to raw ASB)."""
|
||||
parsed = fetch_smr_year(db, year)
|
||||
if parsed is None:
|
||||
return None
|
||||
return parsed.get((year, month))
|
||||
@@ -0,0 +1,225 @@
|
||||
"""
|
||||
Forward audit-log events to an external syslog server (SIEM ingestion).
|
||||
|
||||
Every row inserted into `audit_logs` is mirrored as an RFC-5424 syslog message
|
||||
over UDP or TCP, with a per-event severity so a SIEM can decode/rule/alert on
|
||||
them (login failures, lockouts, access-denied, config changes, ...).
|
||||
|
||||
Design (ponytail):
|
||||
- One bounded queue + one daemon worker thread. The SQLAlchemy after_insert
|
||||
hook only enqueues (never blocks the request / DB flush). Bursty syncs that
|
||||
write thousands of VULNERABILITY_DETECTED rows drain sequentially through the
|
||||
single worker instead of spawning a thread per event.
|
||||
- Config (`syslog_config` setting) is cached for 30 s so the hot path never
|
||||
hits the DB. TCP keeps a persistent socket and reconnects on failure.
|
||||
- Disabled by default → the hook is a cheap no-op until an admin turns it on.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import queue
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
APP_NAME = "truevuln"
|
||||
_QUEUE: "queue.Queue[dict]" = queue.Queue(maxsize=10000)
|
||||
_worker_started = False
|
||||
_worker_lock = threading.Lock()
|
||||
|
||||
# Config cache
|
||||
_cfg_cache: dict = {"ts": 0.0, "cfg": None}
|
||||
_CFG_TTL = 30.0
|
||||
|
||||
# RFC-5424 severities
|
||||
_SEV_EMERG, _SEV_ALERT, _SEV_CRIT, _SEV_ERR, _SEV_WARN, _SEV_NOTICE, _SEV_INFO, _SEV_DEBUG = range(8)
|
||||
|
||||
|
||||
def _severity_for(event_type: str) -> int:
|
||||
"""Map an AuditEventType name to a syslog severity so a SIEM can prioritise."""
|
||||
e = (event_type or "").upper()
|
||||
if "SECURITY_ALERT" in e or "PERMISSION_ESCALATION" in e:
|
||||
return _SEV_ALERT
|
||||
if "FAILED" in e or "DENIED" in e or "LOCK" in e:
|
||||
return _SEV_WARN
|
||||
if "DELETED" in e or "DEACTIVATED" in e or "DISABLED" in e or "CONFIG_CHANGE" in e:
|
||||
return _SEV_NOTICE
|
||||
return _SEV_INFO
|
||||
|
||||
|
||||
def _load_config() -> Optional[dict]:
|
||||
"""Cached read of the `syslog_config` setting. Returns None when disabled/
|
||||
unset. Shape: {enabled, host, port, protocol('udp'|'tcp'), facility(int)}."""
|
||||
now = time.monotonic()
|
||||
if now - _cfg_cache["ts"] < _CFG_TTL:
|
||||
return _cfg_cache["cfg"]
|
||||
cfg = None
|
||||
try:
|
||||
from app.database import SessionLocal
|
||||
from app.models.setting import Setting
|
||||
db = SessionLocal()
|
||||
try:
|
||||
row = db.query(Setting).filter(Setting.key == "syslog_config").first()
|
||||
if row and row.value:
|
||||
parsed = json.loads(row.value)
|
||||
if parsed.get("enabled") and parsed.get("host"):
|
||||
cfg = {
|
||||
"host": str(parsed["host"]).strip(),
|
||||
"port": int(parsed.get("port") or 514),
|
||||
"protocol": str(parsed.get("protocol") or "udp").lower(),
|
||||
"facility": int(parsed.get("facility") if parsed.get("facility") is not None else 16),
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
except Exception as e: # never let config trouble break the app
|
||||
logger.debug("syslog config load failed: %s", e)
|
||||
cfg = None
|
||||
_cfg_cache["ts"] = now
|
||||
_cfg_cache["cfg"] = cfg
|
||||
return cfg
|
||||
|
||||
|
||||
def _build_message(evt: dict, facility: int) -> bytes:
|
||||
sev = _severity_for(evt.get("event_type", ""))
|
||||
pri = facility * 8 + sev
|
||||
ts = (evt.get("timestamp") or datetime.now()).astimezone().isoformat()
|
||||
host = socket.gethostname() or "-"
|
||||
msgid = (evt.get("event_type") or "AUDIT")[:32]
|
||||
# MSG: human-readable + a few key=value fields for easy SIEM extraction.
|
||||
parts = [evt.get("event_description") or ""]
|
||||
if evt.get("user_id") is not None:
|
||||
parts.append(f"user_id={evt['user_id']}")
|
||||
if evt.get("resource_type"):
|
||||
parts.append(f"resource={evt['resource_type']}:{evt.get('resource_id') or ''}")
|
||||
if evt.get("ip_address"):
|
||||
parts.append(f"src_ip={evt['ip_address']}")
|
||||
msg = " ".join(p for p in parts if p)
|
||||
line = f"<{pri}>1 {ts} {host} {APP_NAME} - {msgid} - {msg}"
|
||||
return line.encode("utf-8", "replace")
|
||||
|
||||
|
||||
class _Sender:
|
||||
"""Holds a persistent TCP socket (reconnect on failure) or a UDP socket."""
|
||||
|
||||
def __init__(self):
|
||||
self._sock: Optional[socket.socket] = None
|
||||
self._key: tuple = ()
|
||||
|
||||
def _ensure(self, host: str, port: int, proto: str):
|
||||
key = (host, port, proto)
|
||||
if self._sock is not None and key == self._key:
|
||||
return
|
||||
self.close()
|
||||
self._key = key
|
||||
if proto == "tcp":
|
||||
s = socket.create_connection((host, port), timeout=5)
|
||||
else:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
self._sock = s
|
||||
|
||||
def send(self, data: bytes, host: str, port: int, proto: str):
|
||||
self._ensure(host, port, proto)
|
||||
assert self._sock is not None
|
||||
if proto == "tcp":
|
||||
# RFC 6587 non-transparent (LF) framing — accepted by rsyslog/syslog-ng.
|
||||
self._sock.sendall(data + b"\n")
|
||||
else:
|
||||
self._sock.sendto(data, (host, port))
|
||||
|
||||
def close(self):
|
||||
if self._sock is not None:
|
||||
try:
|
||||
self._sock.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._sock = None
|
||||
self._key = ()
|
||||
|
||||
|
||||
def _worker():
|
||||
sender = _Sender()
|
||||
while True:
|
||||
evt = _QUEUE.get()
|
||||
try:
|
||||
cfg = _load_config()
|
||||
if not cfg:
|
||||
continue
|
||||
data = _build_message(evt, cfg["facility"])
|
||||
try:
|
||||
sender.send(data, cfg["host"], cfg["port"], cfg["protocol"])
|
||||
except Exception as e:
|
||||
sender.close() # force reconnect next time
|
||||
logger.debug("syslog send failed (%s:%s/%s): %s",
|
||||
cfg["host"], cfg["port"], cfg["protocol"], e)
|
||||
finally:
|
||||
_QUEUE.task_done()
|
||||
|
||||
|
||||
def _ensure_worker():
|
||||
global _worker_started
|
||||
if _worker_started:
|
||||
return
|
||||
with _worker_lock:
|
||||
if _worker_started:
|
||||
return
|
||||
threading.Thread(target=_worker, name="syslog-forwarder", daemon=True).start()
|
||||
_worker_started = True
|
||||
|
||||
|
||||
def enqueue(evt: dict) -> None:
|
||||
"""Best-effort: drop the event rather than block or raise if the queue is
|
||||
full or the worker can't start."""
|
||||
try:
|
||||
_ensure_worker()
|
||||
_QUEUE.put_nowait(evt)
|
||||
except queue.Full:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("syslog enqueue failed: %s", e)
|
||||
|
||||
|
||||
def send_test(cfg: dict) -> tuple[bool, str]:
|
||||
"""Send a one-off test message with an explicit config (admin 'Test' button)."""
|
||||
try:
|
||||
facility = int(cfg.get("facility") if cfg.get("facility") is not None else 16)
|
||||
evt = {"event_type": "SECURITY_ALERT", "event_description": "TrueVuln syslog test message",
|
||||
"timestamp": datetime.now()}
|
||||
data = _build_message(evt, facility)
|
||||
s = _Sender()
|
||||
try:
|
||||
s.send(data, str(cfg["host"]).strip(), int(cfg.get("port") or 514),
|
||||
str(cfg.get("protocol") or "udp").lower())
|
||||
finally:
|
||||
s.close()
|
||||
return True, "sent"
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
|
||||
def register_audit_listener() -> None:
|
||||
"""Hook every AuditLog insert → enqueue. Called once at startup."""
|
||||
from sqlalchemy import event
|
||||
from app.models.audit_log import AuditLog
|
||||
|
||||
@event.listens_for(AuditLog, "after_insert")
|
||||
def _after_insert(mapper, connection, target): # noqa: ARG001
|
||||
# Only scalar columns here — relationships would emit SQL mid-flush.
|
||||
try:
|
||||
enqueue({
|
||||
"event_type": target.event_type.value if getattr(target, "event_type", None) else None,
|
||||
"event_description": target.event_description,
|
||||
"user_id": target.user_id,
|
||||
"resource_type": target.resource_type,
|
||||
"resource_id": target.resource_id,
|
||||
"ip_address": target.ip_address,
|
||||
"timestamp": target.timestamp,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logger.info("syslog: audit-log forwarder registered")
|
||||
@@ -227,14 +227,24 @@ def compute_urs(
|
||||
|
||||
avs = compute_avs(db, asset_id, mode=avs_mode)
|
||||
ass, policy_count = compute_ass(db, asset_id)
|
||||
# Effective weighting = max(operator criticality, detected-role factor).
|
||||
# A crown-jewel role (DC/ADCS → factor 1.5) raises the URS even when the
|
||||
# operator left criticality at "normal"; the operator can still set a
|
||||
# higher criticality, never a lower-than-role one.
|
||||
crit_factor = _criticality_factor(asset.criticality)
|
||||
try:
|
||||
from app.services.risk_dimensions_service import risk_factor
|
||||
role_factor = risk_factor(asset.high_value_score)
|
||||
except Exception:
|
||||
role_factor = 1.0
|
||||
eff_factor = max(crit_factor, role_factor)
|
||||
|
||||
parts = [v for v in (avs, ass) if v is not None]
|
||||
if not parts:
|
||||
urs: Optional[float] = None
|
||||
else:
|
||||
base = sum(parts) / len(parts)
|
||||
urs = min(round(base * crit_factor, 1), 100.0)
|
||||
urs = min(round(base * eff_factor, 1), 100.0)
|
||||
# Spec wants integer URS — round to nearest int but keep one
|
||||
# decimal in storage so trend arrows can detect 0.5-point moves.
|
||||
urs = round(urs, 1)
|
||||
|
||||
@@ -592,7 +592,7 @@ class VulnOverrideService:
|
||||
# covers far more CVEs (every published CVE, not just CISA-curated
|
||||
# ones). Disk-cached for 12h to avoid hammering GitHub.
|
||||
_CVELIST_ZIP_URL = "https://github.com/CVEProject/cvelistV5/archive/refs/heads/main.zip"
|
||||
_CVELIST_CACHE_PATH = "/tmp/vulncheck-cvelistv5-cache.zip"
|
||||
_CVELIST_CACHE_PATH = "/tmp/truevuln-cvelistv5-cache.zip"
|
||||
_CVELIST_CACHE_TTL_SECONDS = 12 * 3600
|
||||
|
||||
def load_cisa_vulnrichment_data(self, cve_ids: List[str]) -> Dict[str, VerifiedCVEData]:
|
||||
@@ -814,7 +814,7 @@ class VulnOverrideService:
|
||||
Stage 3 fallback — official MITRE/CVE.org cvelistV5 cache.
|
||||
|
||||
Pulls https://github.com/CVEProject/cvelistV5/archive/refs/heads/main.zip
|
||||
once per 12h (disk cache at /tmp/vulncheck-cvelistv5-cache.zip),
|
||||
once per 12h (disk cache at /tmp/truevuln-cvelistv5-cache.zip),
|
||||
then walks the wanted CVE files in-place. Same CVE-5 JSON shape
|
||||
as Vulnrichment so _parse_vulnrichment_record handles both.
|
||||
|
||||
@@ -910,6 +910,88 @@ class VulnOverrideService:
|
||||
)
|
||||
return verified
|
||||
|
||||
def load_cve_dates_via_zip(self, cve_ids: List[str]) -> Dict[str, dict]:
|
||||
"""Bulk-extract {cve_id: {"published": iso, "last_modified": iso}}
|
||||
from the cvelistV5 ZIP snapshot — reuses the SAME 12h disk cache as
|
||||
the CVSS-correction cascade (/tmp/truevuln-cvelistv5-cache.zip), so
|
||||
when CVSS-correction already pulled the ZIP this is download-free.
|
||||
|
||||
Used by the enrichment date-backfill when many CVEs are missing
|
||||
dates at once (fresh DB) — one 557 MB ZIP + local walk beats
|
||||
thousands of per-CVE HTTP round-trips. Dates come from the
|
||||
authoritative cveMetadata.datePublished / .dateUpdated.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import zipfile
|
||||
|
||||
out: Dict[str, dict] = {}
|
||||
cve_pattern = re.compile(r"^CVE-(\d{4})-(\d+)$")
|
||||
wanted = set(cve_ids)
|
||||
|
||||
def _zip_path_for(cve_id: str) -> Optional[str]:
|
||||
m = cve_pattern.match(cve_id)
|
||||
if not m:
|
||||
return None
|
||||
year, num = m.group(1), m.group(2)
|
||||
return f"cvelistV5-main/cves/{year}/{int(num) // 1000}xxx/{cve_id}.json"
|
||||
|
||||
cache_path = self._CVELIST_CACHE_PATH
|
||||
cache_fresh = (
|
||||
os.path.exists(cache_path)
|
||||
and (time.time() - os.path.getmtime(cache_path)) < self._CVELIST_CACHE_TTL_SECONDS
|
||||
and os.path.getsize(cache_path) > 100_000_000
|
||||
)
|
||||
if not cache_fresh:
|
||||
# Reuse the cascade's downloader (handles streaming + .part swap).
|
||||
self._download_cvelistv5_zip()
|
||||
|
||||
with zipfile.ZipFile(cache_path) as zf:
|
||||
names = set(zf.namelist())
|
||||
for cve_id in wanted:
|
||||
in_zip = _zip_path_for(cve_id)
|
||||
if not in_zip or in_zip not in names:
|
||||
continue
|
||||
try:
|
||||
with zf.open(in_zip) as jf:
|
||||
meta = (json.loads(jf.read().decode("utf-8")).get("cveMetadata") or {})
|
||||
out[cve_id] = {
|
||||
"published": (meta.get("datePublished") or None),
|
||||
"last_modified": (meta.get("dateUpdated") or None),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.debug("cvelistV5 date parse failed for %s: %s", cve_id, e)
|
||||
logger.info("cvelistV5 dates: %d/%d CVEs found in ZIP", len(out), len(wanted))
|
||||
return out
|
||||
|
||||
def _download_cvelistv5_zip(self) -> None:
|
||||
"""Stream the cvelistV5 main.zip to the shared disk cache (12h TTL)."""
|
||||
import httpx
|
||||
import os
|
||||
|
||||
cache_path = self._CVELIST_CACHE_PATH
|
||||
tmp_path = cache_path + ".part"
|
||||
logger.info("cvelistV5: downloading fresh ZIP to %s", cache_path)
|
||||
try:
|
||||
with httpx.Client(timeout=httpx.Timeout(120.0, connect=15.0),
|
||||
follow_redirects=True) as client:
|
||||
with client.stream("GET", self._CVELIST_ZIP_URL) as resp:
|
||||
resp.raise_for_status()
|
||||
with open(tmp_path, "wb") as f:
|
||||
for chunk in resp.iter_bytes(chunk_size=1024 * 512):
|
||||
f.write(chunk)
|
||||
os.replace(tmp_path, cache_path)
|
||||
logger.info("cvelistV5: download complete (%.1f MB)",
|
||||
os.path.getsize(cache_path) / 1_048_576)
|
||||
except Exception:
|
||||
if os.path.exists(tmp_path):
|
||||
try:
|
||||
os.remove(tmp_path)
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
|
||||
def _load_via_zip_snapshot(self, cve_ids: List[str]) -> Dict[str, VerifiedCVEData]:
|
||||
"""Single 249 MB ZIP download → walk locally for the requested CVE
|
||||
files. Two orders of magnitude faster than per-CVE 404 lookups
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# Database
|
||||
postgres:
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 641 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 139 KiB After Width: | Height: | Size: 451 KiB |
+5
-29
@@ -1,36 +1,12 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
# TrueVuln Frontend
|
||||
|
||||
## Getting Started
|
||||
Next.js 16 (App Router) UI for TrueVuln. See the [root README](../README.md) for setup, deployment, and configuration — this app is deployed via Docker Compose alongside the backend, not standalone or on Vercel.
|
||||
|
||||
First, run the development server:
|
||||
## Local dev (without Docker)
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
Open [http://localhost:3000](http://localhost:3000). The dev server proxies API calls to the backend — see [../README.DEV.md](../README.DEV.md) for the full local (non-Docker) setup.
|
||||
|
||||
@@ -195,7 +195,7 @@ export default function AuthAdminPage() {
|
||||
<button
|
||||
onClick={() => runTest(p.name as any)}
|
||||
disabled={!p.configured || testing === p.name}
|
||||
className="w-full text-xs font-mono bg-vulncheck-blue text-white rounded px-2 py-1 hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="w-full text-xs font-mono bg-truevuln-blue text-white rounded px-2 py-1 hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{testing === p.name ? "Testing…" : `Test ${p.name.toUpperCase()} connection`}
|
||||
</button>
|
||||
@@ -223,7 +223,7 @@ export default function AuthAdminPage() {
|
||||
<button
|
||||
onClick={saveMappings}
|
||||
disabled={saving}
|
||||
className="text-sm font-mono bg-vulncheck-blue text-white rounded px-4 py-2 hover:bg-blue-600 disabled:opacity-60"
|
||||
className="text-sm font-mono bg-truevuln-blue text-white rounded px-4 py-2 hover:bg-blue-600 disabled:opacity-60"
|
||||
>
|
||||
{saving ? "Saving…" : "Save mappings"}
|
||||
</button>
|
||||
@@ -260,10 +260,10 @@ export default function AuthAdminPage() {
|
||||
onChange={(e) => updateRule(provider, idx, { pattern: e.target.value })}
|
||||
placeholder={
|
||||
provider === "ldap"
|
||||
? "CN=VulnCheck-Admins,*"
|
||||
? "CN=TrueVuln-Admins,*"
|
||||
: provider === "oidc"
|
||||
? "vulncheck-admins | <azure-group-uuid>"
|
||||
: "VulnCheck-Admins"
|
||||
? "truevuln-admins | <azure-group-uuid>"
|
||||
: "TrueVuln-Admins"
|
||||
}
|
||||
className="w-full text-xs border border-gray-300 rounded px-2 py-1"
|
||||
/>
|
||||
@@ -281,9 +281,9 @@ export default function AuthAdminPage() {
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right whitespace-nowrap">
|
||||
<button onClick={() => moveRule(provider, idx, -1)} disabled={idx === 0}
|
||||
className="px-1 text-gray-400 hover:text-vulncheck-blue disabled:opacity-30">↑</button>
|
||||
className="px-1 text-gray-400 hover:text-truevuln-blue disabled:opacity-30">↑</button>
|
||||
<button onClick={() => moveRule(provider, idx, +1)} disabled={idx === (mappings[provider]?.length || 0) - 1}
|
||||
className="px-1 text-gray-400 hover:text-vulncheck-blue disabled:opacity-30">↓</button>
|
||||
className="px-1 text-gray-400 hover:text-truevuln-blue disabled:opacity-30">↓</button>
|
||||
<button onClick={() => removeRule(provider, idx)}
|
||||
className="ml-2 px-2 py-0.5 text-[10px] bg-red-50 text-red-600 border border-red-200 rounded hover:bg-red-100">remove</button>
|
||||
</td>
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
"use client";
|
||||
|
||||
// Security Advisory Feeds — CISA KEV (actively exploited) plus configurable
|
||||
// RSS sources (ZDI / CERT-EU / BSI / Cisco / custom). These sources publish
|
||||
// ahead of NVD/cvelistV5, so this page is the early-warning surface.
|
||||
import { useEffect, useState } from 'react';
|
||||
import api from '../../lib/api';
|
||||
|
||||
type FeedItem = { title: string; link: string; date: string; summary: string };
|
||||
type Feed = { id: string; name: string; url: string; enabled: boolean; items: FeedItem[]; error: string | null };
|
||||
type FeedCfg = { id: string; name: string; url: string; enabled: boolean };
|
||||
|
||||
export default function AdvisoriesPage() {
|
||||
const [kev, setKev] = useState<any[]>([]);
|
||||
const [feeds, setFeeds] = useState<Feed[]>([]);
|
||||
const [fetchedAt, setFetchedAt] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [userRole, setUserRole] = useState('');
|
||||
const [openFeed, setOpenFeed] = useState<string | null>(null);
|
||||
// Admin config editor
|
||||
const [cfg, setCfg] = useState<FeedCfg[]>([]);
|
||||
const [showCfg, setShowCfg] = useState(false);
|
||||
const [cfgMsg, setCfgMsg] = useState('');
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
const [k, f, me] = await Promise.all([
|
||||
api.get('/api/v1/advisories/kev-recent?limit=15').catch(() => ({ data: { items: [] } })),
|
||||
api.get('/api/v1/advisories/feeds').catch(() => ({ data: { feeds: [], fetched_at: null } })),
|
||||
api.get('/auth/me').catch(() => ({ data: {} })),
|
||||
]);
|
||||
setKev(k.data?.items || []);
|
||||
setFeeds(f.data?.feeds || []);
|
||||
setFetchedAt(f.data?.fetched_at || null);
|
||||
setUserRole(me.data?.role || '');
|
||||
// config mirror for the admin editor (from the cache view — same rows)
|
||||
setCfg((f.data?.feeds || []).map((x: Feed) => ({ id: x.id, name: x.name, url: x.url, enabled: x.enabled })));
|
||||
} finally { setLoading(false); }
|
||||
};
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const refresh = async () => {
|
||||
setRefreshing(true);
|
||||
try { await api.post('/api/v1/advisories/feeds/refresh'); await load(); }
|
||||
catch (e: any) { alert(e?.response?.data?.detail || 'Refresh failed'); }
|
||||
finally { setRefreshing(false); }
|
||||
};
|
||||
|
||||
const saveCfg = async () => {
|
||||
try {
|
||||
await api.put('/api/v1/settings/advisory_feeds_config', { value: JSON.stringify(cfg) });
|
||||
setCfgMsg('Saved — refreshing feeds…');
|
||||
await api.post('/api/v1/advisories/feeds/refresh').catch(() => { });
|
||||
await load();
|
||||
setCfgMsg('Saved.');
|
||||
} catch (e: any) {
|
||||
setCfgMsg(e?.response?.data?.detail || 'Save failed');
|
||||
}
|
||||
};
|
||||
|
||||
const canEdit = userRole === 'admin' || userRole === 'editor';
|
||||
|
||||
if (loading) return <div className="p-8">Loading Advisories...</div>;
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="flex items-start justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold text-gray-900 font-mono">Security Advisory Feeds</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Early-warning sources that often publish before NVD / cvelistV5.
|
||||
{fetchedAt && <span className="ml-2 font-mono text-xs text-gray-400">Last fetch: {new Date(fetchedAt).toLocaleString()}</span>}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{userRole === 'admin' && (
|
||||
<button onClick={() => setShowCfg(!showCfg)} className="rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50">
|
||||
⚙️ Configure
|
||||
</button>
|
||||
)}
|
||||
{canEdit && (
|
||||
<button onClick={refresh} disabled={refreshing} className="rounded-md bg-truevuln-blue px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-600 disabled:opacity-50">
|
||||
{refreshing ? 'Refreshing…' : 'Refresh now'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Admin: feed configuration */}
|
||||
{showCfg && userRole === 'admin' && (
|
||||
<div className="bg-white border border-gray-200 shadow-sm rounded-sm p-4 mb-6">
|
||||
<h3 className="text-sm font-bold font-mono text-gray-900 mb-2">Feed configuration</h3>
|
||||
<p className="text-xs text-gray-500 mb-3">Enable/disable sources or add a custom RSS/Atom URL. Feeds with DOCTYPE/ENTITY declarations are refused (XXE protection).</p>
|
||||
<div className="space-y-2">
|
||||
{cfg.map((f, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<input type="checkbox" checked={f.enabled} onChange={(e) => { const n = [...cfg]; n[i] = { ...f, enabled: e.target.checked }; setCfg(n); }} className="h-4 w-4 rounded border-gray-300 text-truevuln-blue" />
|
||||
<input type="text" value={f.name} onChange={(e) => { const n = [...cfg]; n[i] = { ...f, name: e.target.value }; setCfg(n); }} className="w-64 rounded-md border-gray-300 text-xs font-mono h-8 px-2" />
|
||||
<input type="text" value={f.url} onChange={(e) => { const n = [...cfg]; n[i] = { ...f, url: e.target.value }; setCfg(n); }} className="flex-1 rounded-md border-gray-300 text-xs font-mono h-8 px-2" />
|
||||
<button onClick={() => setCfg(cfg.filter((_, j) => j !== i))} className="text-red-600 text-xs px-2">✕</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-3">
|
||||
<button onClick={() => setCfg([...cfg, { id: `custom-${Date.now()}`, name: 'Custom feed', url: '', enabled: true }])} className="text-xs font-mono px-3 py-1.5 border border-gray-300 rounded-md hover:bg-gray-50">+ Add feed</button>
|
||||
<button onClick={saveCfg} className="text-xs font-mono px-3 py-1.5 bg-truevuln-blue text-white rounded-md hover:bg-blue-600">Save</button>
|
||||
{cfgMsg && <span className="text-xs font-mono text-gray-500">{cfgMsg}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CISA KEV — actively exploited */}
|
||||
<div className="bg-white border border-gray-200 shadow-sm rounded-sm mb-6">
|
||||
<div className="px-4 py-3 border-b border-gray-100 bg-red-50">
|
||||
<h3 className="text-sm font-bold font-mono text-red-800">CISA KEV — Actively Exploited (latest additions)</h3>
|
||||
</div>
|
||||
<ul className="divide-y divide-gray-100">
|
||||
{kev.length === 0 && <li className="px-4 py-3 text-sm text-gray-400 font-mono">No KEV data yet — run Refresh Threat Intel.</li>}
|
||||
{kev.map((k: any, i: number) => (
|
||||
<li key={i} className="px-4 py-2 flex items-center justify-between gap-3 text-sm">
|
||||
<div className="min-w-0">
|
||||
<a href={`/vulnerabilities?cve_id=${k.cve_id}`} className="font-mono font-semibold text-truevuln-blue hover:underline">{k.cve_id}</a>
|
||||
<span className="ml-2 text-gray-600">{k.vulnerability_name || k.short_description || ''}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-none text-xs font-mono">
|
||||
{k.in_inventory && <span className="rounded px-1.5 py-0.5 bg-red-100 text-red-700 font-bold">IN INVENTORY</span>}
|
||||
<span className="text-gray-400">{k.date_added || ''}</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* RSS feeds */}
|
||||
{feeds.filter(f => f.enabled).map((f) => (
|
||||
<div key={f.id} className="bg-white border border-gray-200 shadow-sm rounded-sm mb-4">
|
||||
<button onClick={() => setOpenFeed(openFeed === f.id ? null : f.id)} className="w-full px-4 py-3 border-b border-gray-100 bg-gray-50 flex items-center justify-between text-left">
|
||||
<h3 className="text-sm font-bold font-mono text-gray-900">{f.name}
|
||||
<span className="ml-2 text-xs font-normal text-gray-400">{f.items.length} items</span>
|
||||
{f.error && <span className="ml-2 text-xs text-red-600">fetch failed: {f.error}</span>}
|
||||
</h3>
|
||||
<span className="text-gray-400 text-xs">{openFeed === f.id ? '▲' : '▼'}</span>
|
||||
</button>
|
||||
{(openFeed === f.id || feeds.filter(x => x.enabled).length <= 2) && (
|
||||
<ul className="divide-y divide-gray-100">
|
||||
{f.items.slice(0, 15).map((it, i) => (
|
||||
<li key={i} className="px-4 py-2 text-sm">
|
||||
<a href={it.link} target="_blank" rel="noopener noreferrer" className="font-medium text-truevuln-blue hover:underline">{it.title}</a>
|
||||
<span className="ml-2 text-xs text-gray-400 font-mono">{it.date}</span>
|
||||
{it.summary && <p className="text-xs text-gray-500 mt-0.5 line-clamp-2">{it.summary}</p>}
|
||||
</li>
|
||||
))}
|
||||
{f.items.length === 0 && !f.error && <li className="px-4 py-3 text-sm text-gray-400 font-mono">No items.</li>}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+224
-36
@@ -4,7 +4,7 @@ import { useEffect, useState } from 'react';
|
||||
import api from '../../lib/api';
|
||||
import { Asset, UserInfo, Group } from '../../types';
|
||||
import Link from 'next/link';
|
||||
import { PencilSquareIcon, TrashIcon, ArrowPathIcon, UserGroupIcon, ChevronDownIcon } from '@heroicons/react/24/outline';
|
||||
import { PencilSquareIcon, TrashIcon, ArrowPathIcon, UserGroupIcon, ChevronDownIcon, MagnifyingGlassIcon, ListBulletIcon } from '@heroicons/react/24/outline';
|
||||
import { UserCircleIcon } from '@heroicons/react/24/solid';
|
||||
|
||||
export default function AssetsPage() {
|
||||
@@ -14,16 +14,26 @@ export default function AssetsPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [rescanLoading, setRescanLoading] = useState<number | null>(null);
|
||||
const [nessusRescanLoading, setNessusRescanLoading] = useState<number | null>(null);
|
||||
const [appScanLoading, setAppScanLoading] = useState<number | null>(null);
|
||||
const [userRole, setUserRole] = useState('');
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [showInactive, setShowInactive] = useState(false);
|
||||
const [sourceFilter, setSourceFilter] = useState('');
|
||||
// Table sort state
|
||||
const [sortBy, setSortBy] = useState<string>('hostname');
|
||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
|
||||
// Coverage-gap report modal
|
||||
const [gapReport, setGapReport] = useState<any | null>(null);
|
||||
const [gapLoading, setGapLoading] = useState<number | null>(null);
|
||||
// Installed-software (on-demand) modal
|
||||
const [softwareReport, setSoftwareReport] = useState<any | null>(null);
|
||||
const [softwareLoading, setSoftwareLoading] = useState<number | null>(null);
|
||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||
// Pagination
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(100);
|
||||
const [total, setTotal] = useState(0);
|
||||
|
||||
// Form State
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
@@ -52,17 +62,23 @@ export default function AssetsPage() {
|
||||
const params: any = {};
|
||||
if (searchText) params.search = searchText;
|
||||
if (showInactive) params.include_inactive = true;
|
||||
if (sourceFilter) params.source = sourceFilter;
|
||||
params.sort_by = sortBy;
|
||||
params.sort_order = sortOrder;
|
||||
params.limit = pageSize;
|
||||
params.offset = (page - 1) * pageSize;
|
||||
|
||||
const [assetsRes, usersRes, groupsRes] = await Promise.all([
|
||||
const [assetsRes, usersRes, groupsRes, meRes] = await Promise.all([
|
||||
api.get('/api/v1/assets', { params }),
|
||||
api.get('/auth/users').catch(() => ({ data: [] })),
|
||||
api.get('/api/v1/groups')
|
||||
api.get('/api/v1/groups'),
|
||||
api.get('/auth/me').catch(() => ({ data: {} })),
|
||||
]);
|
||||
setAssets(assetsRes.data);
|
||||
setTotal(parseInt(assetsRes.headers?.['x-total-count'] ?? '0', 10) || assetsRes.data.length);
|
||||
setUsers(usersRes.data);
|
||||
setGroups(groupsRes.data);
|
||||
setUserRole(meRes.data?.role || '');
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch assets:", error);
|
||||
} finally {
|
||||
@@ -84,7 +100,7 @@ export default function AssetsPage() {
|
||||
return <span className="text-gray-300 ml-1">↕</span>;
|
||||
}
|
||||
return (
|
||||
<span className="text-vulncheck-blue ml-1">
|
||||
<span className="text-truevuln-blue ml-1">
|
||||
{sortOrder === 'desc' ? '↓' : '↑'}
|
||||
</span>
|
||||
);
|
||||
@@ -100,21 +116,23 @@ export default function AssetsPage() {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchAssets();
|
||||
fetchPolicies();
|
||||
}, []);
|
||||
|
||||
// Re-fetch when the inactive toggle flips.
|
||||
// Any filter/search/page-size change → jump back to page 1.
|
||||
useEffect(() => {
|
||||
fetchAssets();
|
||||
setPage(1);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [showInactive]);
|
||||
}, [searchText, showInactive, sortBy, sortOrder, pageSize, sourceFilter]);
|
||||
|
||||
// Re-fetch on column sort change.
|
||||
// Fetch on page / filter / search change (debounced for typing).
|
||||
useEffect(() => {
|
||||
fetchAssets();
|
||||
const t = setTimeout(fetchAssets, searchText ? 300 : 0);
|
||||
return () => clearTimeout(t);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sortBy, sortOrder]);
|
||||
}, [page, pageSize, sortBy, sortOrder, showInactive, searchText, sourceFilter]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
@@ -212,6 +230,32 @@ export default function AssetsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleShowSoftware = async (asset: Asset) => {
|
||||
setSoftwareLoading(asset.id);
|
||||
try {
|
||||
const res = await api.get(`/api/v1/assets/${asset.id}/software`);
|
||||
setSoftwareReport(res.data);
|
||||
} catch (error: any) {
|
||||
alert(error.response?.data?.detail || 'Could not load installed software.');
|
||||
} finally {
|
||||
setSoftwareLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAppRescan = async (asset: Asset) => {
|
||||
setAppScanLoading(asset.id);
|
||||
try {
|
||||
const res = await api.post(`/api/v1/vulnerabilities/app-cve-scan?asset_id=${asset.id}`);
|
||||
const d = res.data || {};
|
||||
alert(`App CVE re-scan done for ${asset.hostname}: ${d.findings ?? 0} findings (${d.new ?? 0} new, ${d.resolved ?? 0} auto-resolved).`);
|
||||
fetchAssets();
|
||||
} catch (error: any) {
|
||||
alert(error.response?.data?.detail || 'App re-scan failed.');
|
||||
} finally {
|
||||
setAppScanLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNessusRescan = async (asset: Asset) => {
|
||||
if (!asset.ip_address) {
|
||||
alert('Asset has no IP address — Nessus cannot target it.');
|
||||
@@ -296,8 +340,13 @@ export default function AssetsPage() {
|
||||
|
||||
if (loading) return <div className="p-8">Loading Assets...</div>;
|
||||
|
||||
// Mutating actions require editor+ (rescans/edit) or admin (delete) server-
|
||||
// side; hide them from read-only users so they don't get a 403 alert.
|
||||
const canEdit = userRole === 'admin' || userRole === 'editor';
|
||||
const canDelete = userRole === 'admin';
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="w-full">
|
||||
<div className="flex md:items-center md:justify-between mb-8">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold leading-7 text-gray-900 font-mono">
|
||||
@@ -312,7 +361,7 @@ export default function AssetsPage() {
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && fetchAssets()}
|
||||
className="block w-64 rounded-sm border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-vulncheck-blue sm:text-sm sm:leading-6 font-mono"
|
||||
className="block w-64 rounded-sm border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-truevuln-blue sm:text-sm sm:leading-6 font-mono"
|
||||
/>
|
||||
<button
|
||||
onClick={fetchAssets}
|
||||
@@ -323,14 +372,26 @@ export default function AssetsPage() {
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<label className="flex items-center gap-1.5 text-xs font-mono text-gray-600 cursor-pointer whitespace-nowrap" title="Show soft-inactive + decommissioned assets (no source sync within the threshold)">
|
||||
<select
|
||||
value={sourceFilter}
|
||||
onChange={(e) => setSourceFilter(e.target.value)}
|
||||
title="Filter by the source that first registered the asset"
|
||||
className="rounded-sm border-0 py-1.5 pl-3 pr-8 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-truevuln-blue sm:text-sm font-mono"
|
||||
>
|
||||
<option value="">All sources</option>
|
||||
<option value="WAZUH">Wazuh</option>
|
||||
<option value="NESSUS">Nessus</option>
|
||||
<option value="INTUNE">Intune / Defender</option>
|
||||
<option value="MANUAL">Manual</option>
|
||||
</select>
|
||||
<label className="flex items-center gap-1.5 text-xs font-mono text-gray-600 cursor-pointer whitespace-nowrap" title="INACTIVE assets are always shown (amber badge). Tick to also show operator-retired DECOMMISSIONED assets.">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showInactive}
|
||||
onChange={(e) => setShowInactive(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-vulncheck-blue focus:ring-vulncheck-blue"
|
||||
className="h-4 w-4 rounded border-gray-300 text-truevuln-blue focus:ring-truevuln-blue"
|
||||
/>
|
||||
Show inactive
|
||||
Show decommissioned
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
@@ -349,19 +410,21 @@ export default function AssetsPage() {
|
||||
>
|
||||
⚡ Exposure
|
||||
</button>
|
||||
{canEdit && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { resetForm(); setIsModalOpen(true); }}
|
||||
className="inline-flex items-center rounded-sm bg-vulncheck-blue px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 font-mono whitespace-nowrap"
|
||||
className="inline-flex items-center rounded-sm bg-truevuln-blue px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 font-mono whitespace-nowrap"
|
||||
>
|
||||
Add Asset
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bulk Actions Bar */}
|
||||
{selectedIds.length > 0 && (
|
||||
<div className="sticky top-0 z-40 bg-vulncheck-blue/90 backdrop-blur-sm text-white px-6 py-3 mb-4 rounded-sm shadow-lg flex items-center justify-between animate-in slide-in-from-top duration-300">
|
||||
<div className="sticky top-0 z-40 bg-truevuln-blue/90 backdrop-blur-sm text-white px-6 py-3 mb-4 rounded-sm shadow-lg flex items-center justify-between animate-in slide-in-from-top duration-300">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="font-mono font-bold">{selectedIds.length} Assets selected</span>
|
||||
<div className="h-6 w-px bg-white/20" />
|
||||
@@ -425,7 +488,7 @@ export default function AssetsPage() {
|
||||
required
|
||||
value={formData.hostname}
|
||||
onChange={(e) => setFormData({ ...formData, hostname: e.target.value })}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-vulncheck-blue focus:ring-vulncheck-blue sm:text-sm"
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-truevuln-blue focus:ring-truevuln-blue sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -434,7 +497,7 @@ export default function AssetsPage() {
|
||||
type="text"
|
||||
value={formData.ip_address}
|
||||
onChange={(e) => setFormData({ ...formData, ip_address: e.target.value })}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-vulncheck-blue focus:ring-vulncheck-blue sm:text-sm"
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-truevuln-blue focus:ring-truevuln-blue sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
@@ -444,7 +507,7 @@ export default function AssetsPage() {
|
||||
type="text"
|
||||
value={formData.operating_system}
|
||||
onChange={(e) => setFormData({ ...formData, operating_system: e.target.value })}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-vulncheck-blue focus:ring-vulncheck-blue sm:text-sm"
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-truevuln-blue focus:ring-truevuln-blue sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -453,7 +516,7 @@ export default function AssetsPage() {
|
||||
type="text"
|
||||
value={formData.os_version}
|
||||
onChange={(e) => setFormData({ ...formData, os_version: e.target.value })}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-vulncheck-blue focus:ring-vulncheck-blue sm:text-sm"
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-truevuln-blue focus:ring-truevuln-blue sm:text-sm"
|
||||
placeholder="e.g. 22.04"
|
||||
/>
|
||||
</div>
|
||||
@@ -464,7 +527,7 @@ export default function AssetsPage() {
|
||||
type="text"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-vulncheck-blue focus:ring-vulncheck-blue sm:text-sm"
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-truevuln-blue focus:ring-truevuln-blue sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -475,7 +538,7 @@ export default function AssetsPage() {
|
||||
<select
|
||||
value={formData.criticality}
|
||||
onChange={(e) => setFormData({ ...formData, criticality: e.target.value as any })}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-vulncheck-blue focus:ring-vulncheck-blue sm:text-sm font-mono"
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-truevuln-blue focus:ring-truevuln-blue sm:text-sm font-mono"
|
||||
>
|
||||
<option value="low">Low (×0.7)</option>
|
||||
<option value="normal">Normal (×1.0)</option>
|
||||
@@ -488,7 +551,7 @@ export default function AssetsPage() {
|
||||
<select
|
||||
value={formData.policy_id || ''}
|
||||
onChange={(e) => setFormData({ ...formData, policy_id: e.target.value ? Number(e.target.value) : null })}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-vulncheck-blue focus:ring-vulncheck-blue sm:text-sm"
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-truevuln-blue focus:ring-truevuln-blue sm:text-sm"
|
||||
>
|
||||
<option value="">-- No Policy --</option>
|
||||
{policies.map(p => (
|
||||
@@ -506,7 +569,7 @@ export default function AssetsPage() {
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-vulncheck-blue rounded-md hover:bg-blue-600"
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-truevuln-blue rounded-md hover:bg-blue-600"
|
||||
>
|
||||
{editingId ? 'Update Asset' : 'Create Asset'}
|
||||
</button>
|
||||
@@ -526,7 +589,7 @@ export default function AssetsPage() {
|
||||
type="checkbox"
|
||||
checked={assets.length > 0 && selectedIds.length === assets.length}
|
||||
onChange={toggleSelectAll}
|
||||
className="h-4 w-4 rounded border-gray-300 text-vulncheck-blue focus:ring-vulncheck-blue"
|
||||
className="h-4 w-4 rounded border-gray-300 text-truevuln-blue focus:ring-truevuln-blue"
|
||||
/>
|
||||
</th>
|
||||
<th scope="col" onClick={() => handleSort('hostname')} className="px-3 py-3 text-left text-xs font-mono font-medium text-gray-500 uppercase tracking-wider cursor-pointer select-none hover:text-gray-700">Hostname<SortArrow column="hostname" /></th>
|
||||
@@ -534,6 +597,7 @@ export default function AssetsPage() {
|
||||
<th scope="col" onClick={() => handleSort('operating_system')} className="px-3 py-3 text-left text-xs font-mono font-medium text-gray-500 uppercase tracking-wider cursor-pointer select-none hover:text-gray-700">OS<SortArrow column="operating_system" /></th>
|
||||
<th scope="col" onClick={() => handleSort('status')} className="px-3 py-3 text-left text-xs font-mono font-medium text-gray-500 uppercase tracking-wider cursor-pointer select-none hover:text-gray-700">Status<SortArrow column="status" /></th>
|
||||
<th scope="col" onClick={() => handleSort('network_exposure_score')} className="px-3 py-3 text-left text-xs font-mono font-medium text-gray-500 uppercase tracking-wider cursor-pointer select-none hover:text-gray-700" title="Network exposure from open listeners (VNC/RDP/Telnet/...)">Exposure<SortArrow column="network_exposure_score" /></th>
|
||||
<th scope="col" onClick={() => handleSort('high_value_score')} className="px-3 py-3 text-left text-xs font-mono font-medium text-gray-500 uppercase tracking-wider cursor-pointer select-none hover:text-gray-700" title="Risk Dimensions — high-value roles (Domain Controller, ADCS, SQL, Exchange, backup, ...)">Risk<SortArrow column="high_value_score" /></th>
|
||||
<th scope="col" onClick={() => handleSort('last_scan')} className="px-3 py-3 text-left text-xs font-mono font-medium text-gray-500 uppercase tracking-wider cursor-pointer select-none hover:text-gray-700">Last Scan<SortArrow column="last_scan" /></th>
|
||||
<th scope="col" onClick={() => handleSort('policy_name')} className="px-3 py-3 text-left text-xs font-mono font-medium text-gray-500 uppercase tracking-wider cursor-pointer select-none hover:text-gray-700">Policy<SortArrow column="policy_name" /></th>
|
||||
<th scope="col" onClick={() => handleSort('assigned_user_name')} className="px-3 py-3 text-left text-xs font-mono font-medium text-gray-500 uppercase tracking-wider cursor-pointer select-none hover:text-gray-700">Assigned To<SortArrow column="assigned_user_name" /></th>
|
||||
@@ -548,10 +612,10 @@ export default function AssetsPage() {
|
||||
type="checkbox"
|
||||
checked={selectedIds.includes(asset.id)}
|
||||
onChange={() => toggleSelect(asset.id)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-vulncheck-blue focus:ring-vulncheck-blue"
|
||||
className="h-4 w-4 rounded border-gray-300 text-truevuln-blue focus:ring-truevuln-blue"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-4 whitespace-nowrap font-bold text-vulncheck-blue">
|
||||
<td className="px-3 py-4 whitespace-nowrap font-bold text-truevuln-blue">
|
||||
<Link href={`/vulnerabilities?asset_id=${asset.id}`} className="hover:underline">
|
||||
{asset.hostname}
|
||||
</Link>
|
||||
@@ -595,6 +659,28 @@ export default function AssetsPage() {
|
||||
<span className="text-gray-300 text-xs">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-4 whitespace-nowrap">
|
||||
{asset.high_value_score != null && asset.high_value_score > 0 ? (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-md px-2 py-0.5 text-xs font-bold font-mono
|
||||
${asset.high_value_score >= 90 ? 'bg-red-100 text-red-700'
|
||||
: asset.high_value_score >= 70 ? 'bg-orange-100 text-orange-700'
|
||||
: asset.high_value_score >= 40 ? 'bg-yellow-100 text-yellow-800'
|
||||
: 'bg-gray-100 text-gray-600'}`}
|
||||
title={(asset.risk_dimensions || []).map(d => `${d.label} (${d.weight})`).join(', ') || 'High-value roles'}
|
||||
>
|
||||
{asset.high_value_score.toFixed(0)}
|
||||
{asset.risk_dimensions && asset.risk_dimensions.length > 0 && (
|
||||
<span className="ml-1 font-normal text-[10px] opacity-80">
|
||||
{asset.risk_dimensions.slice(0, 2).map(d => d.role.toUpperCase().replace(/_/g, '·').slice(0, 8)).join('/')}
|
||||
{asset.risk_dimensions.length > 2 ? '…' : ''}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-300 text-xs">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-4 text-gray-500">{asset.last_scan ? new Date(asset.last_scan).toLocaleDateString() : 'Never'}</td>
|
||||
<td className="px-3 py-4 whitespace-nowrap">
|
||||
{asset.policy_name ? (
|
||||
@@ -615,6 +701,13 @@ export default function AssetsPage() {
|
||||
) : (
|
||||
<UserCircleIcon className={`h-5 w-5 ${asset.assigned_user_id ? 'text-indigo-600' : 'text-gray-300'}`} />
|
||||
)}
|
||||
{!canEdit ? (
|
||||
// Read-only: show the assignee as text (the /auth/users list is
|
||||
// admin-only, so a disabled <select> would read "Unassigned").
|
||||
<span className="text-xs font-medium text-gray-700 truncate" style={{ maxWidth: '200px' }}>
|
||||
{(asset.groups && asset.groups.length > 0 ? asset.groups[0] : asset.assigned_user_name) || <span className="text-gray-400">Unassigned</span>}
|
||||
</span>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<select
|
||||
value={
|
||||
@@ -624,7 +717,7 @@ export default function AssetsPage() {
|
||||
}
|
||||
onChange={(e) => handleAssign(asset.id, e.target.value)}
|
||||
className="block w-full appearance-none rounded-md border-0 bg-transparent py-1.5 pl-2 pr-8 text-xs font-medium text-gray-900 focus:ring-1 focus:ring-inset focus:ring-indigo-600 cursor-pointer hover:bg-gray-50 transition-colors truncate"
|
||||
style={{ maxWidth: '140px' }}
|
||||
style={{ maxWidth: '200px', minWidth: '120px' }}
|
||||
>
|
||||
<option value="">Unassigned</option>
|
||||
<optgroup label="Users">
|
||||
@@ -642,11 +735,12 @@ export default function AssetsPage() {
|
||||
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div className="flex justify-end gap-x-2">
|
||||
{asset.wazuh_agent_id && (
|
||||
{asset.wazuh_agent_id && canEdit && (
|
||||
<button
|
||||
onClick={() => handleRescan(asset.id)}
|
||||
disabled={rescanLoading === asset.id}
|
||||
@@ -656,7 +750,7 @@ export default function AssetsPage() {
|
||||
<ArrowPathIcon className={`h-5 w-5 ${rescanLoading === asset.id ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
)}
|
||||
{asset.ip_address && (
|
||||
{asset.ip_address && canEdit && (
|
||||
<button
|
||||
onClick={() => handleNessusRescan(asset)}
|
||||
disabled={nessusRescanLoading === asset.id}
|
||||
@@ -665,12 +759,37 @@ export default function AssetsPage() {
|
||||
>
|
||||
{nessusRescanLoading === asset.id
|
||||
? <ArrowPathIcon className="h-5 w-5 animate-spin" />
|
||||
: <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" title="Nessus">
|
||||
: <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
|
||||
<title>Nessus</title>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3c-4.97 0-9 4.03-9 9s4.03 9 9 9 9-4.03 9-9-4.03-9-9-9zm0 0v18M3 12h18" />
|
||||
</svg>
|
||||
}
|
||||
</button>
|
||||
)}
|
||||
{(asset.wazuh_agent_id || asset.intune_device_id) && (
|
||||
<button
|
||||
onClick={() => handleShowSoftware(asset)}
|
||||
disabled={softwareLoading === asset.id}
|
||||
title="Show installed software (live inventory)"
|
||||
className={`${softwareLoading === asset.id ? 'text-gray-300' : 'text-sky-600 hover:text-sky-800'}`}
|
||||
>
|
||||
{softwareLoading === asset.id
|
||||
? <ArrowPathIcon className="h-5 w-5 animate-spin" />
|
||||
: <ListBulletIcon className="h-5 w-5" />}
|
||||
</button>
|
||||
)}
|
||||
{(asset.wazuh_agent_id || asset.intune_device_id) && canEdit && (
|
||||
<button
|
||||
onClick={() => handleAppRescan(asset)}
|
||||
disabled={appScanLoading === asset.id}
|
||||
title="App CVE re-scan — match this asset's installed software to CVEs (curated + cvelistV5)"
|
||||
className={`${appScanLoading === asset.id ? 'text-gray-300' : 'text-emerald-600 hover:text-emerald-800'}`}
|
||||
>
|
||||
{appScanLoading === asset.id
|
||||
? <ArrowPathIcon className="h-5 w-5 animate-spin" />
|
||||
: <MagnifyingGlassIcon className="h-5 w-5" />}
|
||||
</button>
|
||||
)}
|
||||
{(asset.wazuh_agent_id || asset.nessus_host_uuid) && (
|
||||
<button
|
||||
onClick={() => handleCoverageGap(asset)}
|
||||
@@ -686,28 +805,62 @@ export default function AssetsPage() {
|
||||
}
|
||||
</button>
|
||||
)}
|
||||
{canEdit && (
|
||||
<button
|
||||
onClick={() => handleEdit(asset)}
|
||||
className="text-vulncheck-blue hover:text-blue-900"
|
||||
className="text-truevuln-blue hover:text-blue-900"
|
||||
>
|
||||
<PencilSquareIcon className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
{canDelete && (
|
||||
<button
|
||||
onClick={() => handleDeleteAsset(asset.id)}
|
||||
className="text-red-600 hover:text-red-900"
|
||||
>
|
||||
<TrashIcon className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{assets.length === 0 && (
|
||||
<tr><td colSpan={6} className="text-center py-4">No assets found.</td></tr>
|
||||
<tr><td colSpan={11} className="text-center py-4">No assets found.</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-gray-200 px-4 py-3 text-sm font-mono text-gray-600">
|
||||
<div>
|
||||
{total === 0 ? '0' : `${(page - 1) * pageSize + 1}–${Math.min(page * pageSize, total)}`} of {total}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="flex items-center gap-1.5">
|
||||
<span className="text-xs text-gray-500">Per page</span>
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => setPageSize(parseInt(e.target.value, 10))}
|
||||
className="rounded-md border-gray-300 text-sm py-1 pl-2 pr-7 focus:border-truevuln-blue focus:ring-truevuln-blue"
|
||||
>
|
||||
{[50, 100, 250, 500, 1000].map(n => <option key={n} value={n}>{n}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={() => setPage(1)} disabled={page <= 1}
|
||||
className="px-2 py-1 rounded border border-gray-300 disabled:opacity-40 hover:bg-gray-50">«</button>
|
||||
<button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page <= 1}
|
||||
className="px-2 py-1 rounded border border-gray-300 disabled:opacity-40 hover:bg-gray-50">‹ Prev</button>
|
||||
<span className="px-2">Page {page} / {totalPages}</span>
|
||||
<button onClick={() => setPage(p => Math.min(totalPages, p + 1))} disabled={page >= totalPages}
|
||||
className="px-2 py-1 rounded border border-gray-300 disabled:opacity-40 hover:bg-gray-50">Next ›</button>
|
||||
<button onClick={() => setPage(totalPages)} disabled={page >= totalPages}
|
||||
className="px-2 py-1 rounded border border-gray-300 disabled:opacity-40 hover:bg-gray-50">»</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Coverage-gap modal — installed packages with no finding */}
|
||||
@@ -718,7 +871,7 @@ export default function AssetsPage() {
|
||||
<h3 className="text-lg font-bold font-mono text-gray-900">Coverage Gap — {gapReport.hostname}</h3>
|
||||
<p className="text-xs text-gray-500 font-mono mt-1">
|
||||
{gapReport.gap_count} of {gapReport.total_packages} installed packages have NO open vuln finding.
|
||||
Investigate manually — VulnCheck makes no automatic CVE claim here.
|
||||
Investigate manually — TrueVuln makes no automatic CVE claim here.
|
||||
</p>
|
||||
</div>
|
||||
<div className="overflow-y-auto p-4">
|
||||
@@ -751,6 +904,41 @@ export default function AssetsPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{softwareReport && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={() => setSoftwareReport(null)}>
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-2xl w-full max-h-[80vh] flex flex-col" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="p-4 border-b border-gray-200">
|
||||
<h3 className="text-lg font-bold font-mono text-gray-900">Installed Software — {softwareReport.hostname}</h3>
|
||||
<p className="text-xs text-gray-500 font-mono mt-1">
|
||||
{softwareReport.count} items · live from <span className="uppercase">{softwareReport.source}</span> (not stored)
|
||||
</p>
|
||||
</div>
|
||||
<div className="overflow-y-auto p-4">
|
||||
{softwareReport.count === 0 ? (
|
||||
<p className="text-sm text-gray-500 font-mono">No software reported for this asset.</p>
|
||||
) : (
|
||||
<table className="min-w-full text-xs font-mono">
|
||||
<thead className="text-gray-500 uppercase">
|
||||
<tr><th className="text-left py-1">Name</th><th className="text-left py-1">Version</th><th className="text-left py-1">Vendor</th></tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{softwareReport.software.map((s: any, i: number) => (
|
||||
<tr key={i}>
|
||||
<td className="py-1 pr-3 text-gray-900">{s.name}</td>
|
||||
<td className="py-1 pr-3 text-gray-500">{s.version || '—'}</td>
|
||||
<td className="py-1 text-gray-500">{s.vendor || '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-3 border-t border-gray-200 text-right">
|
||||
<button onClick={() => setSoftwareReport(null)} className="rounded-md bg-gray-100 px-4 py-1.5 text-sm font-semibold text-gray-700 hover:bg-gray-200">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -137,8 +137,13 @@ export default function CompliancePage() {
|
||||
|
||||
useEffect(() => { fetchAll(); }, [avsMode]);
|
||||
|
||||
// CSV upload state
|
||||
// CSV upload state. The import endpoint is admin-only, so non-admins were
|
||||
// shown an upload control that silently 403'd.
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
useEffect(() => {
|
||||
api.get('/auth/me').then(r => setIsAdmin((r.data?.role || '') === 'admin')).catch(() => { });
|
||||
}, []);
|
||||
const uploadCsvs = async (filelist: FileList | null) => {
|
||||
if (!filelist || filelist.length === 0) return;
|
||||
setUploading(true);
|
||||
@@ -242,7 +247,7 @@ export default function CompliancePage() {
|
||||
<button
|
||||
onClick={refreshAll}
|
||||
disabled={refreshing}
|
||||
className="inline-flex items-center gap-2 rounded-md bg-vulncheck-blue px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-600 disabled:opacity-50 font-mono"
|
||||
className="inline-flex items-center gap-2 rounded-md bg-truevuln-blue px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-600 disabled:opacity-50 font-mono"
|
||||
>
|
||||
<ArrowPathIcon className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
{refreshing ? 'Refreshing…' : 'Refresh All'}
|
||||
@@ -291,7 +296,7 @@ export default function CompliancePage() {
|
||||
<ul className="divide-y divide-gray-100">
|
||||
{summary.worst_offenders.map(a => (
|
||||
<li key={a.asset_id} className="py-2 flex items-center justify-between text-sm font-mono">
|
||||
<button onClick={() => openAsset(a.asset_id)} className="text-vulncheck-blue hover:underline text-left">
|
||||
<button onClick={() => openAsset(a.asset_id)} className="text-truevuln-blue hover:underline text-left">
|
||||
{a.hostname}
|
||||
</button>
|
||||
<span className="text-gray-500 text-xs truncate ml-2 max-w-xs" title={a.worst_policy || ''}>
|
||||
@@ -354,7 +359,7 @@ export default function CompliancePage() {
|
||||
)}
|
||||
{ursRows.map(r => (
|
||||
<tr key={r.asset_id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-2 text-vulncheck-blue font-bold">{r.hostname || `#${r.asset_id}`}</td>
|
||||
<td className="px-4 py-2 text-truevuln-blue font-bold">{r.hostname || `#${r.asset_id}`}</td>
|
||||
<td className="px-4 py-2 text-gray-700 uppercase text-xs">{r.criticality || 'normal'}<span className="text-gray-400 ml-1">×{r.criticality_factor ?? 1.0}</span></td>
|
||||
<td className="px-4 py-2 text-gray-700">{r.avs !== null ? r.avs.toFixed(1) : '—'}</td>
|
||||
<td className="px-4 py-2 text-gray-700">{r.ass !== null ? r.ass.toFixed(1) : '—'}</td>
|
||||
@@ -371,7 +376,8 @@ export default function CompliancePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Impact CSV upload */}
|
||||
{/* Impact CSV upload — admin only (POST /compliance/impacts/import) */}
|
||||
{isAdmin && (
|
||||
<div className="bg-white rounded-lg border border-gray-200 shadow-sm p-4 mb-8">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
@@ -410,6 +416,7 @@ export default function CompliancePage() {
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* All assets table */}
|
||||
<div className="bg-white rounded-lg border border-gray-200 shadow-sm overflow-hidden">
|
||||
@@ -436,7 +443,7 @@ export default function CompliancePage() {
|
||||
)}
|
||||
{assets.map(a => (
|
||||
<tr key={a.asset_id} className="hover:bg-gray-50 cursor-pointer" onClick={() => openAsset(a.asset_id)}>
|
||||
<td className="px-4 py-2 text-vulncheck-blue font-bold">{a.hostname}</td>
|
||||
<td className="px-4 py-2 text-truevuln-blue font-bold">{a.hostname}</td>
|
||||
<td className="px-4 py-2 text-gray-600 truncate max-w-xs">{a.operating_system || '—'}</td>
|
||||
<td className="px-4 py-2 text-gray-700">{a.policy_count}</td>
|
||||
<td className={`px-4 py-2 ${scoreColor(a.avg_score)}`}>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
|
||||
--color-vulncheck-blue: #0066FF;
|
||||
--color-truevuln-blue: #0066FF;
|
||||
--color-securis-dark: #1F2937;
|
||||
--color-securis-gray: #F3F4F6;
|
||||
--color-securis-danger: #EF4444;
|
||||
|
||||
@@ -123,7 +123,7 @@ const GroupsPage = () => {
|
||||
<div className="mt-4 flex md:ml-4 md:mt-0">
|
||||
<button
|
||||
onClick={() => handleOpenModal()}
|
||||
className="inline-flex items-center rounded-md bg-vulncheck-blue px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-600 font-mono"
|
||||
className="inline-flex items-center rounded-md bg-truevuln-blue px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-600 font-mono"
|
||||
>
|
||||
<PlusIcon className="h-4 w-4 mr-2" />
|
||||
Create Group
|
||||
@@ -202,7 +202,7 @@ const GroupsPage = () => {
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-vulncheck-blue focus:ring-vulncheck-blue sm:text-sm"
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-truevuln-blue focus:ring-truevuln-blue sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -210,7 +210,7 @@ const GroupsPage = () => {
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-vulncheck-blue focus:ring-vulncheck-blue sm:text-sm"
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-truevuln-blue focus:ring-truevuln-blue sm:text-sm"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
@@ -224,7 +224,7 @@ const GroupsPage = () => {
|
||||
id={`user-${u.id}`}
|
||||
checked={formData.user_ids.includes(u.id)}
|
||||
onChange={() => toggleUserSelection(u.id)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-vulncheck-blue focus:ring-vulncheck-blue"
|
||||
className="h-4 w-4 rounded border-gray-300 text-truevuln-blue focus:ring-truevuln-blue"
|
||||
/>
|
||||
<label htmlFor={`user-${u.id}`} className="ml-2 block text-sm text-gray-900 font-mono">
|
||||
{u.username} <span className="text-gray-400 text-xs">({u.email})</span>
|
||||
@@ -241,7 +241,7 @@ const GroupsPage = () => {
|
||||
|
||||
<div className="flex justify-end gap-2 mt-6">
|
||||
<button onClick={() => setIsModalOpen(false)} className="rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50">Cancel</button>
|
||||
<button onClick={handleSave} className="rounded-md bg-vulncheck-blue px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-600">Save</button>
|
||||
<button onClick={handleSave} className="rounded-md bg-truevuln-blue px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-600">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,7 @@ const geistMono = Geist_Mono({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "VulnCheck",
|
||||
title: "TrueVuln",
|
||||
description: "Advanced Vulnerability Management for Wazuh",
|
||||
icons: {
|
||||
icon: "/logo.svg",
|
||||
|
||||
@@ -99,9 +99,9 @@ export default function LoginPage() {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col justify-center px-6 py-12 lg:px-8 bg-gray-50">
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-sm">
|
||||
<img src="/logo.svg" alt="VulnCheck Logo" className="mx-auto h-24 w-24" />
|
||||
<img src="/logo.svg" alt="TrueVuln Logo" className="mx-auto h-24 w-24" />
|
||||
<h2 className="mt-6 text-center text-2xl font-bold leading-9 tracking-tight text-gray-900 font-mono">
|
||||
VulnCheck
|
||||
TrueVuln
|
||||
</h2>
|
||||
<p className="text-center text-sm text-gray-500 font-mono mt-2">
|
||||
{stage === 'credentials' ? 'Sign in to your account' : 'Enter your verification code'}
|
||||
@@ -152,7 +152,7 @@ export default function LoginPage() {
|
||||
required
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-vulncheck-blue sm:text-sm sm:leading-6"
|
||||
className="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-truevuln-blue sm:text-sm sm:leading-6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -170,7 +170,7 @@ export default function LoginPage() {
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-vulncheck-blue sm:text-sm sm:leading-6"
|
||||
className="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-truevuln-blue sm:text-sm sm:leading-6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -185,7 +185,7 @@ export default function LoginPage() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="flex w-full justify-center rounded-md bg-vulncheck-blue px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-blue-600 disabled:opacity-60 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 font-mono"
|
||||
className="flex w-full justify-center rounded-md bg-truevuln-blue px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-blue-600 disabled:opacity-60 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 font-mono"
|
||||
>
|
||||
{loading ? 'Signing in…' : 'Sign in'}
|
||||
</button>
|
||||
@@ -223,7 +223,7 @@ export default function LoginPage() {
|
||||
required
|
||||
value={mfaCode}
|
||||
onChange={(e) => setMfaCode(e.target.value.replace(/\D/g, ''))}
|
||||
className="block w-full rounded-md border-0 py-2 text-center text-xl tracking-[0.5em] font-mono text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-vulncheck-blue"
|
||||
className="block w-full rounded-md border-0 py-2 text-center text-xl tracking-[0.5em] font-mono text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-truevuln-blue"
|
||||
placeholder="000000"
|
||||
/>
|
||||
</div>
|
||||
@@ -246,7 +246,7 @@ export default function LoginPage() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || mfaCode.length !== 6}
|
||||
className="flex-[2] rounded-md bg-vulncheck-blue px-3 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-600 disabled:opacity-60 font-mono"
|
||||
className="flex-[2] rounded-md bg-truevuln-blue px-3 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-600 disabled:opacity-60 font-mono"
|
||||
>
|
||||
{loading ? 'Verifying…' : 'Verify'}
|
||||
</button>
|
||||
|
||||
@@ -85,7 +85,7 @@ export default function ForcedMfaSetupPage() {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col justify-center px-6 py-12 lg:px-8 bg-gray-50">
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<img src="/logo.svg" alt="VulnCheck Logo" className="mx-auto h-20 w-20" />
|
||||
<img src="/logo.svg" alt="TrueVuln Logo" className="mx-auto h-20 w-20" />
|
||||
<h2 className="mt-6 text-center text-2xl font-bold leading-9 tracking-tight text-gray-900 font-mono">
|
||||
Multi-Factor Authentication required
|
||||
</h2>
|
||||
@@ -151,7 +151,7 @@ export default function ForcedMfaSetupPage() {
|
||||
required
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))}
|
||||
className="mt-2 block w-full rounded-md border-0 py-2 text-center text-xl tracking-[0.5em] font-mono text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-vulncheck-blue"
|
||||
className="mt-2 block w-full rounded-md border-0 py-2 text-center text-xl tracking-[0.5em] font-mono text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-truevuln-blue"
|
||||
placeholder="000000"
|
||||
/>
|
||||
</div>
|
||||
@@ -165,7 +165,7 @@ export default function ForcedMfaSetupPage() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || code.length !== 6}
|
||||
className="w-full rounded-md bg-vulncheck-blue px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-600 disabled:opacity-60 font-mono"
|
||||
className="w-full rounded-md bg-truevuln-blue px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-600 disabled:opacity-60 font-mono"
|
||||
>
|
||||
{loading ? 'Activating…' : 'Activate MFA & Sign in'}
|
||||
</button>
|
||||
|
||||
@@ -64,14 +64,14 @@ export default function NotificationsPage() {
|
||||
<p className="text-sm text-gray-500 mt-1">Audit log of all outbound emails and alerts.</p>
|
||||
</div>
|
||||
<div className="bg-white p-2 rounded-lg shadow-sm border border-gray-100 flex items-center gap-2">
|
||||
<EnvelopeIcon className="h-5 w-5 text-vulncheck-blue" />
|
||||
<EnvelopeIcon className="h-5 w-5 text-truevuln-blue" />
|
||||
<span className="text-sm font-bold text-gray-700">{logs.length} Notifications</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-vulncheck-blue"></div>
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-truevuln-blue"></div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white shadow-sm border border-gray-100 rounded-xl overflow-hidden">
|
||||
@@ -103,7 +103,7 @@ export default function NotificationsPage() {
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="text-sm text-gray-900 line-clamp-1">{log.subject}</div>
|
||||
<div className="text-xs text-vulncheck-blue font-mono">
|
||||
<div className="text-xs text-truevuln-blue font-mono">
|
||||
{log.cve_id} • {log.asset_hostname}
|
||||
</div>
|
||||
</td>
|
||||
@@ -113,7 +113,7 @@ export default function NotificationsPage() {
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<button
|
||||
onClick={() => setPreviewLog(log)}
|
||||
className="text-vulncheck-blue hover:text-blue-900 flex items-center gap-1 ml-auto"
|
||||
className="text-truevuln-blue hover:text-blue-900 flex items-center gap-1 ml-auto"
|
||||
>
|
||||
<EyeIcon className="h-4 w-4" />
|
||||
Preview
|
||||
@@ -144,8 +144,8 @@ export default function NotificationsPage() {
|
||||
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-2xl transform transition-all sm:my-8 sm:align-middle sm:max-w-4xl sm:w-full relative z-10 border border-gray-200">
|
||||
<div className="bg-white px-6 py-4 border-b border-gray-100 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-vulncheck-blue/10 p-2 rounded-lg">
|
||||
<EnvelopeIcon className="h-6 w-6 text-vulncheck-blue" />
|
||||
<div className="bg-truevuln-blue/10 p-2 rounded-lg">
|
||||
<EnvelopeIcon className="h-6 w-6 text-truevuln-blue" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-gray-900">Email Transmission Preview</h3>
|
||||
</div>
|
||||
@@ -160,7 +160,7 @@ export default function NotificationsPage() {
|
||||
<div className="space-y-3 mb-8 bg-gray-50 p-4 rounded-xl border border-gray-100">
|
||||
<div className="flex text-sm">
|
||||
<span className="w-24 font-bold text-gray-500 uppercase tracking-wider text-[10px] self-center">From</span>
|
||||
<span className="text-gray-900 font-medium">VulnCheck Security Operations Center <notifications@vulncheck.io></span>
|
||||
<span className="text-gray-900 font-medium">TrueVuln Security Operations Center <notifications@truevuln.io></span>
|
||||
</div>
|
||||
<div className="flex text-sm">
|
||||
<span className="w-24 font-bold text-gray-500 uppercase tracking-wider text-[10px] self-center">To</span>
|
||||
@@ -191,7 +191,7 @@ export default function NotificationsPage() {
|
||||
<div className="bg-gray-50 px-6 py-4 sm:flex sm:flex-row-reverse gap-3 border-t border-gray-100">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full inline-flex justify-center rounded-lg border border-transparent shadow-sm px-6 py-2.5 bg-vulncheck-blue text-sm font-bold text-white hover:bg-blue-700 transition-all sm:w-auto"
|
||||
className="w-full inline-flex justify-center rounded-lg border border-transparent shadow-sm px-6 py-2.5 bg-truevuln-blue text-sm font-bold text-white hover:bg-blue-700 transition-all sm:w-auto"
|
||||
onClick={() => setPreviewLog(null)}
|
||||
>
|
||||
Done
|
||||
|
||||
+188
-37
@@ -22,8 +22,21 @@ function renderVulnWidget(opts: {
|
||||
vulns: Vulnerability[];
|
||||
onRowClick: (cveId: string) => void;
|
||||
viewAllHref?: string;
|
||||
// EOL widget: the pseudo-CVE id ("EOL-CHROME-148") is noise — show the
|
||||
// product/package name in the first column instead, with a "PRODUCT"
|
||||
// header. Falls back to cve_id when no package name.
|
||||
labelField?: 'cve_id' | 'package_name';
|
||||
firstColHeader?: string;
|
||||
// EOL widget: replace the (often empty) CPR column with the affected
|
||||
// asset's hostname, linked to that asset's EOL findings.
|
||||
assetColumn?: boolean;
|
||||
}) {
|
||||
const { title, subtitle, vulns, onRowClick, viewAllHref } = opts;
|
||||
const labelField = opts.labelField || 'cve_id';
|
||||
const firstColHeader = opts.firstColHeader || 'CVE';
|
||||
const assetColumn = opts.assetColumn || false;
|
||||
const firstColLabel = (v: Vulnerability) =>
|
||||
labelField === 'package_name' ? (v.package_name || v.cve_id) : v.cve_id;
|
||||
const prioCls = (p: number | null | undefined) =>
|
||||
p == null ? 'text-gray-400'
|
||||
: p >= 80 ? 'text-red-700 font-bold'
|
||||
@@ -34,31 +47,43 @@ function renderVulnWidget(opts: {
|
||||
: c >= 50 ? 'text-red-700 font-bold'
|
||||
: c >= 20 ? 'text-orange-700' : 'text-gray-600';
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 shadow-sm rounded-sm overflow-hidden">
|
||||
<div className="bg-white border border-gray-200 shadow-sm rounded-sm overflow-hidden flex flex-col">
|
||||
<div className="p-4 border-b border-gray-200 flex justify-between items-baseline bg-gray-50/50">
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-gray-900 font-mono">{title}</h3>
|
||||
<p className="text-[11px] text-gray-500 font-mono mt-0.5">{subtitle}</p>
|
||||
</div>
|
||||
<Link href={viewAllHref || "/vulnerabilities"} prefetch className="text-vulncheck-blue text-[10px] font-bold uppercase tracking-wider font-mono hover:text-blue-700">View All ></Link>
|
||||
<Link href={viewAllHref || "/vulnerabilities"} prefetch className="text-truevuln-blue text-[10px] font-bold uppercase tracking-wider font-mono hover:text-blue-700">View All ></Link>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<div className="overflow-x-auto flex-1">
|
||||
<table className="min-w-full divide-y divide-gray-200 table-fixed">
|
||||
<colgroup>
|
||||
<col className="w-[34%]" />
|
||||
<col className="w-[12%]" />
|
||||
<col className="w-[10%]" />
|
||||
<col className="w-[12%]" />
|
||||
<col className="w-[12%]" />
|
||||
<col className="w-[20%]" />
|
||||
</colgroup>
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left text-[10px] font-mono font-medium text-gray-500 uppercase">CVE</th>
|
||||
<th className="px-3 py-2 text-left text-[10px] font-mono font-medium text-gray-500 uppercase">Sev</th>
|
||||
<th className="px-3 py-2 text-left text-[10px] font-mono font-medium text-gray-500 uppercase" title="CVSS Base Score">CVSS</th>
|
||||
<th className="px-3 py-2 text-left text-[10px] font-mono font-medium text-gray-500 uppercase" title="Priority Score 0-100">PRIO</th>
|
||||
<th className="px-3 py-2 text-left text-[10px] font-mono font-medium text-gray-500 uppercase" title="CPR 0-100 (JacquesKruger)">CPR</th>
|
||||
<th className="px-3 py-2 text-left text-[10px] font-mono font-medium text-gray-500 uppercase">Flags</th>
|
||||
<th className="px-2 py-2 text-left text-[10px] font-mono font-medium text-gray-500 uppercase">{firstColHeader}</th>
|
||||
<th className="px-2 py-2 text-left text-[10px] font-mono font-medium text-gray-500 uppercase">Sev</th>
|
||||
<th className="px-2 py-2 text-left text-[10px] font-mono font-medium text-gray-500 uppercase" title="CVSS Base Score">CVSS</th>
|
||||
<th className="px-2 py-2 text-left text-[10px] font-mono font-medium text-gray-500 uppercase" title="Priority Score 0-100">PRIO</th>
|
||||
{assetColumn
|
||||
? <th className="px-2 py-2 text-left text-[10px] font-mono font-medium text-gray-500 uppercase" title="Affected asset — click for all its EOL findings">Asset</th>
|
||||
: <th className="px-2 py-2 text-left text-[10px] font-mono font-medium text-gray-500 uppercase" title="CPR 0-100 (JacquesKruger)">CPR</th>}
|
||||
<th className="px-2 py-2 text-left text-[10px] font-mono font-medium text-gray-500 uppercase">Flags</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200 font-mono text-xs">
|
||||
{vulns.map((vuln) => (
|
||||
<tr key={vuln.id} onClick={() => onRowClick(vuln.cve_id)} className="cursor-pointer hover:bg-gray-50 transition-colors">
|
||||
<td className="px-3 py-2 whitespace-nowrap font-bold text-indigo-600 hover:text-indigo-800">{vuln.cve_id}</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap">
|
||||
<td className="px-2 py-2 font-bold text-indigo-600 hover:text-indigo-800">
|
||||
<div className="truncate" title={`${firstColLabel(vuln)} · ${vuln.cve_id}`}>{firstColLabel(vuln)}</div>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<span className={`inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-bold ${vuln.severity === 'critical' ? 'bg-red-100 text-red-700'
|
||||
: vuln.severity === 'high' ? 'bg-orange-100 text-orange-700'
|
||||
: vuln.severity === 'medium' ? 'bg-blue-100 text-blue-700' : 'bg-green-100 text-green-700'
|
||||
@@ -66,14 +91,29 @@ function renderVulnWidget(opts: {
|
||||
{vuln.severity.toUpperCase().slice(0, 4)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap font-bold">{vuln.cvss_score ?? '—'}</td>
|
||||
<td className={`px-3 py-2 whitespace-nowrap ${prioCls(vuln.priority_score ?? null)}`}>
|
||||
<td className="px-2 py-2 font-bold">{vuln.cvss_score ?? '—'}</td>
|
||||
<td className={`px-2 py-2 ${prioCls(vuln.priority_score ?? null)}`}>
|
||||
{vuln.priority_score == null ? '—' : vuln.priority_score.toFixed(0)}
|
||||
</td>
|
||||
<td className={`px-3 py-2 whitespace-nowrap ${cprCls(vuln.cpr_score ?? null)}`}>
|
||||
{assetColumn ? (
|
||||
<td className="px-2 py-2">
|
||||
{vuln.asset_id ? (
|
||||
<Link
|
||||
href={`/vulnerabilities?asset_id=${vuln.asset_id}&eol=1`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-indigo-600 hover:text-indigo-800 hover:underline truncate block"
|
||||
title={`All EOL findings on ${vuln.asset_hostname || `asset #${vuln.asset_id}`}`}
|
||||
>
|
||||
{vuln.asset_hostname || `#${vuln.asset_id}`}
|
||||
</Link>
|
||||
) : '—'}
|
||||
</td>
|
||||
) : (
|
||||
<td className={`px-2 py-2 ${cprCls(vuln.cpr_score ?? null)}`}>
|
||||
{vuln.cpr_score == null ? '—' : vuln.cpr_score.toFixed(1)}
|
||||
</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap">
|
||||
)}
|
||||
<td className="px-2 py-2">
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{vuln.kev_listed && <span className="inline-flex items-center rounded bg-red-100 px-1 py-0.5 text-[9px] font-bold text-red-700">KEV</span>}
|
||||
{vuln.euvd_listed && <span className="inline-flex items-center rounded bg-blue-100 px-1 py-0.5 text-[9px] font-bold text-blue-700">EUVD</span>}
|
||||
@@ -105,6 +145,12 @@ export default function Dashboard() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [aiLoading, setAiLoading] = useState(false);
|
||||
const [aiError, setAiError] = useState<string | null>(null);
|
||||
// AI audit hits an editor-gated endpoint → hide the controls from read-only.
|
||||
const [userRole, setUserRole] = useState('');
|
||||
useEffect(() => {
|
||||
api.get('/auth/me').then(r => setUserRole(r.data?.role || '')).catch(() => { });
|
||||
}, []);
|
||||
const canEdit = userRole === 'admin' || userRole === 'editor';
|
||||
const [aiSeverity, setAiSeverity] = useState<string>('all');
|
||||
const [aiAbortController, setAiAbortController] = useState<AbortController | null>(null);
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
@@ -196,15 +242,18 @@ export default function Dashboard() {
|
||||
// dashboard surface (tester request).
|
||||
const [criticalVulns, setCriticalVulns] = useState<Vulnerability[]>([]);
|
||||
const [eolVulns, setEolVulns] = useState<Vulnerability[]>([]);
|
||||
const [mobileVulns, setMobileVulns] = useState<Vulnerability[]>([]);
|
||||
const [kevAdvisories, setKevAdvisories] = useState<any[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [statsRes, vulnsRes, criticalRes, eolRes, schedRes, compRes, ursRes] = await Promise.all([
|
||||
const [statsRes, vulnsRes, criticalRes, eolRes, mobileRes, kevRes, schedRes, compRes, ursRes] = await Promise.all([
|
||||
api.get('/api/v1/vulnerabilities/reports/dashboard'),
|
||||
// Newly Published: sort by published_date desc, wide window
|
||||
// so client-side dedup still surfaces 10 distinct CVEs.
|
||||
api.get('/api/v1/vulnerabilities?limit=80&status=open&sort_by=published_date&sort_order=desc'),
|
||||
// Newly Published: sort by published_date desc. distinct_cve=true
|
||||
// collapses per-asset duplicates server-side so we reliably get 10
|
||||
// distinct CVEs (client dedup alone starved when a CVE hit N assets).
|
||||
api.get('/api/v1/vulnerabilities?limit=12&status=open&sort_by=published_date&sort_order=desc&distinct_cve=true'),
|
||||
// Recent Critical: CVSS ≥ 8 OR KEV OR EUVD, sort by PRIORITY
|
||||
// desc. Was updated_at — but after a bulk EOL/CVSS/exploit
|
||||
// run every row's updated_at is fresh, so the widget showed
|
||||
@@ -213,9 +262,19 @@ export default function Dashboard() {
|
||||
api.get('/api/v1/vulnerabilities?limit=80&status=open&sort_by=priority&sort_order=desc')
|
||||
.catch(() => ({ data: { items: [] } })),
|
||||
// EOL / EOS: pseudo-CVEs from endoflife.date check (cve_id
|
||||
// starts with "EOL-"). Sort by detected_at desc so the
|
||||
// freshest EOL findings surface first.
|
||||
api.get('/api/v1/vulnerabilities?limit=15&status=active&search=EOL-&sort_by=detected_at&sort_order=desc')
|
||||
// starts with "EOL-"). Sort by detected_at desc so the freshest EOL
|
||||
// findings surface first; distinct_cve collapses the same EOL stream
|
||||
// across many assets to one row (else the widget starved to ~4).
|
||||
api.get('/api/v1/vulnerabilities?limit=12&status=active&search=EOL-&sort_by=detected_at&sort_order=desc&distinct_cve=true')
|
||||
.catch(() => ({ data: { items: [] } })),
|
||||
// Mobile Security: vendor EOL/EOS + Android patch-level staleness on
|
||||
// phones/tablets. cvss desc surfaces the worst first (EOL 9 → patch
|
||||
// ≥1y 8 → EOL-soon 5.5 → end-of-active-support 3).
|
||||
api.get('/api/v1/vulnerabilities?limit=15&status=active&finding_type=mobile&sort_by=cvss&sort_order=desc')
|
||||
.catch(() => ({ data: { items: [] } })),
|
||||
// Advisory feed: recently-added CISA KEV (actively exploited in the
|
||||
// wild), independent of whether we have an affected asset yet.
|
||||
api.get('/api/v1/advisories/kev-recent?limit=12')
|
||||
.catch(() => ({ data: { items: [] } })),
|
||||
api.get('/api/v1/scans/schedules').catch(() => ({ data: [] })),
|
||||
api.get('/api/v1/compliance/summary').catch(() => ({ data: null })),
|
||||
@@ -260,11 +319,19 @@ export default function Dashboard() {
|
||||
}
|
||||
setCriticalVulns(recentCritical);
|
||||
|
||||
// Mobile findings have their own widget — keep them out of the
|
||||
// generic (desktop-software) EOL widget so the two stay cleanly apart.
|
||||
const isMobileFinding = (v: Vulnerability) => !!v.cve_id && (
|
||||
v.cve_id.startsWith('ANDROID-PATCH-') ||
|
||||
v.cve_id.startsWith('EOL-IPHONE-') || v.cve_id.startsWith('EOL-IPAD-') ||
|
||||
v.cve_id.startsWith('EOL-SAMSUNG-MOBILE-') || v.cve_id.startsWith('EOL-SAMSUNG-GALAXY-TAB-'));
|
||||
|
||||
// EOL widget — dedup by cve_id (one row per EOL stream is plenty).
|
||||
const rawEol: Vulnerability[] = (eolRes.data && (eolRes.data.items || eolRes.data)) || [];
|
||||
const seenEol = new Set<string>();
|
||||
const recentEol: Vulnerability[] = [];
|
||||
for (const v of rawEol) {
|
||||
if (isMobileFinding(v)) continue;
|
||||
if (v.cve_id && !seenEol.has(v.cve_id)) {
|
||||
seenEol.add(v.cve_id);
|
||||
recentEol.push(v);
|
||||
@@ -273,6 +340,23 @@ export default function Dashboard() {
|
||||
}
|
||||
setEolVulns(recentEol);
|
||||
|
||||
// Mobile Security widget — dedup by cve_id + asset (the SAME EOL
|
||||
// stream on N different devices is N distinct findings).
|
||||
const rawMobile: Vulnerability[] = (mobileRes.data && (mobileRes.data.items || mobileRes.data)) || [];
|
||||
const seenMobile = new Set<string>();
|
||||
const recentMobile: Vulnerability[] = [];
|
||||
for (const v of rawMobile) {
|
||||
const key = `${v.cve_id}|${v.asset_id}`;
|
||||
if (!seenMobile.has(key)) {
|
||||
seenMobile.add(key);
|
||||
recentMobile.push(v);
|
||||
if (recentMobile.length >= 10) break;
|
||||
}
|
||||
}
|
||||
setMobileVulns(recentMobile);
|
||||
|
||||
setKevAdvisories((kevRes.data && kevRes.data.items) || []);
|
||||
|
||||
const schedules = Array.isArray(schedRes.data) ? schedRes.data : [];
|
||||
setActiveScheduleCount(schedules.filter((s: any) => s.enabled).length);
|
||||
setCompliance(compRes.data);
|
||||
@@ -293,12 +377,14 @@ export default function Dashboard() {
|
||||
|
||||
const statItems = [
|
||||
{ name: 'CRITICAL', value: stats?.critical_count, change: '+0', changeType: 'increase', color: 'text-securis-danger' },
|
||||
{ name: 'HIGH', value: stats?.high_count, change: '+0', changeType: 'decrease', color: 'text-vulncheck-blue' },
|
||||
{ name: 'HIGH', value: stats?.high_count, change: '+0', changeType: 'decrease', color: 'text-truevuln-blue' },
|
||||
{ name: 'MEDIUM', value: stats?.medium_count, change: '+0', changeType: 'increase', color: 'text-blue-600' },
|
||||
{ name: 'LOW', value: stats?.low_count, change: '+0', changeType: 'decrease', color: 'text-green-600' },
|
||||
];
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto">
|
||||
// Full available width (minus AppShell padding) — tester wanted the
|
||||
// empty left/right gutters used on wide monitors. No max-width cap.
|
||||
<div className="w-full">
|
||||
{/* Header Section */}
|
||||
<div className="md:flex md:items-center md:justify-between mb-8">
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -307,7 +393,8 @@ export default function Dashboard() {
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">Monitor and manage security vulnerabilities across your infrastructure</p>
|
||||
</div>
|
||||
<div className="mt-4 flex md:ml-4 md:mt-0 items-center gap-2">
|
||||
<div className="mt-4 flex flex-wrap md:ml-4 md:mt-0 items-center gap-2 justify-end">
|
||||
{canEdit && (<>
|
||||
<div className="flex items-center ring-1 ring-inset ring-gray-300 rounded-md bg-white px-2">
|
||||
<span className="text-xs font-mono text-gray-500 mr-1 uppercase font-bold text-indigo-600">Audit filter:</span>
|
||||
<select
|
||||
@@ -340,6 +427,8 @@ export default function Dashboard() {
|
||||
AI Audit
|
||||
</button>
|
||||
)}
|
||||
</>)}
|
||||
{canEdit && (
|
||||
<button
|
||||
onClick={loadHistory}
|
||||
className="inline-flex items-center rounded-md px-3 py-2 text-sm font-semibold shadow-sm ring-1 ring-inset ring-gray-300 bg-white text-gray-900 hover:bg-gray-50 transition-all ml-2"
|
||||
@@ -347,12 +436,15 @@ export default function Dashboard() {
|
||||
>
|
||||
<ClockIcon className="h-4 w-4 text-gray-500" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
)}
|
||||
{/* Was a dead button (no onClick) for every role — point it at the
|
||||
reports page, which is where the exports actually live. */}
|
||||
<Link
|
||||
href="/reports"
|
||||
className="inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50"
|
||||
>
|
||||
Export Report
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -418,7 +510,7 @@ export default function Dashboard() {
|
||||
<h3 className="text-lg font-bold text-gray-900 font-mono">
|
||||
Unified Risk Score (URS)
|
||||
</h3>
|
||||
<Link href="/compliance" prefetch className="text-vulncheck-blue text-xs font-bold uppercase tracking-wider font-mono hover:text-blue-700">
|
||||
<Link href="/compliance" prefetch className="text-truevuln-blue text-xs font-bold uppercase tracking-wider font-mono hover:text-blue-700">
|
||||
Open >
|
||||
</Link>
|
||||
</div>
|
||||
@@ -482,7 +574,7 @@ export default function Dashboard() {
|
||||
<h3 className="text-lg font-bold text-gray-900 font-mono">
|
||||
Compliance (Wazuh SCA)
|
||||
</h3>
|
||||
<Link href="/compliance" prefetch className="text-vulncheck-blue text-xs font-bold uppercase tracking-wider font-mono hover:text-blue-700">
|
||||
<Link href="/compliance" prefetch className="text-truevuln-blue text-xs font-bold uppercase tracking-wider font-mono hover:text-blue-700">
|
||||
Open >
|
||||
</Link>
|
||||
</div>
|
||||
@@ -525,7 +617,7 @@ export default function Dashboard() {
|
||||
: o.avg_score >= 40 ? 'text-orange-700' : 'text-red-700';
|
||||
return (
|
||||
<li key={o.asset_id} className="flex justify-between gap-2">
|
||||
<Link href={`/compliance`} className="text-vulncheck-blue hover:underline truncate" title={o.worst_policy || ''}>
|
||||
<Link href={`/compliance`} className="text-truevuln-blue hover:underline truncate" title={o.worst_policy || ''}>
|
||||
{o.hostname}
|
||||
</Link>
|
||||
<span className={`${c} font-bold`}>
|
||||
@@ -544,13 +636,15 @@ export default function Dashboard() {
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* AI Smart Priorities */}
|
||||
{/* AI Smart Priorities — editor+ only (the audit that fills it is gated) */}
|
||||
{canEdit && (
|
||||
<AIRecommendations
|
||||
recommendations={aiPriorities?.recommendations || []}
|
||||
strategy={aiPriorities?.global_strategy}
|
||||
loading={aiLoading}
|
||||
error={aiError}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 mb-8">
|
||||
@@ -563,7 +657,7 @@ export default function Dashboard() {
|
||||
{stats?.severity_history.map((h, idx) => {
|
||||
const maxCount = Math.max(...(stats?.severity_history.map(s => s.count) || [1]));
|
||||
const height = stats?.severity_history ? (h.count / maxCount * 100) : 0;
|
||||
const colors = ['bg-amber-900', 'bg-amber-700', 'bg-vulncheck-blue', 'bg-blue-900', 'bg-red-700', 'bg-green-900', 'bg-blue-500'];
|
||||
const colors = ['bg-amber-900', 'bg-amber-700', 'bg-truevuln-blue', 'bg-blue-900', 'bg-red-700', 'bg-green-900', 'bg-blue-500'];
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -595,7 +689,7 @@ export default function Dashboard() {
|
||||
</div>
|
||||
<div className="w-full bg-gray-100 rounded-full h-2.5">
|
||||
<div
|
||||
className="bg-vulncheck-blue h-2.5 rounded-full"
|
||||
className="bg-truevuln-blue h-2.5 rounded-full"
|
||||
style={{ width: `${stats?.total_assets ? (stats.scanned_assets / stats.total_assets * 100) : 0}%` }}
|
||||
></div>
|
||||
</div>
|
||||
@@ -664,7 +758,7 @@ export default function Dashboard() {
|
||||
Vulnrichment corrections + KEV/EUVD just-landed)
|
||||
middle = Newly Published CVEs (sort published_date desc)
|
||||
right = Newly EOL / EOS (endoflife.date pseudo-CVEs) */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 items-start">
|
||||
{renderVulnWidget({
|
||||
title: 'Recent Critical CVEs',
|
||||
subtitle: 'CVSS ≥ 8 or KEV or EUVD · sorted by priority',
|
||||
@@ -682,12 +776,69 @@ export default function Dashboard() {
|
||||
})}
|
||||
{renderVulnWidget({
|
||||
title: 'Newly EOL / EOS',
|
||||
subtitle: 'endoflife.date pseudo-CVEs · sorted by detection',
|
||||
subtitle: 'End-of-life software · sorted by detection',
|
||||
vulns: eolVulns,
|
||||
labelField: 'package_name',
|
||||
firstColHeader: 'Product',
|
||||
assetColumn: true,
|
||||
onRowClick: (cveId) => router.push(`/vulnerabilities?cve_id=${cveId}`),
|
||||
// eol=1 drives the eolOnly toggle on the list page.
|
||||
viewAllHref: '/vulnerabilities?eol=1&sort_by=detected_at&sort_order=desc',
|
||||
})}
|
||||
{renderVulnWidget({
|
||||
title: 'Mobile Security · EOL & Patch Level',
|
||||
subtitle: 'Phones & tablets · vendor EOL/EOS + Android patch staleness',
|
||||
vulns: mobileVulns,
|
||||
labelField: 'package_name',
|
||||
firstColHeader: 'Device / Item',
|
||||
assetColumn: true,
|
||||
onRowClick: (cveId) => router.push(`/vulnerabilities?cve_id=${cveId}`),
|
||||
viewAllHref: '/vulnerabilities?finding_type=mobile&sort_by=cvss&sort_order=desc',
|
||||
})}
|
||||
|
||||
{/* Advisory feed — CISA KEV (actively exploited in the wild), independent
|
||||
of asset findings. "In inventory" badge when we already track it. */}
|
||||
<div className="bg-white border border-gray-200 shadow-sm rounded-sm overflow-hidden flex flex-col">
|
||||
<div className="p-4 border-b border-gray-200 flex justify-between items-baseline bg-gray-50/50">
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-gray-900 font-mono">Actively Exploited · CISA KEV</h3>
|
||||
<p className="text-[11px] text-gray-500 font-mono mt-0.5">Newly added known-exploited CVEs · 🔒 = ransomware use</p>
|
||||
</div>
|
||||
<a href="https://www.cisa.gov/known-exploited-vulnerabilities-catalog" target="_blank" rel="noreferrer"
|
||||
className="text-truevuln-blue text-[10px] font-bold uppercase tracking-wider font-mono hover:text-blue-700">View All ></a>
|
||||
</div>
|
||||
<div className="overflow-x-auto flex-1">
|
||||
{kevAdvisories.length === 0 ? (
|
||||
<p className="p-4 text-xs text-gray-400 font-mono">No KEV data.</p>
|
||||
) : (
|
||||
<table className="min-w-full text-sm">
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{kevAdvisories.map((k: any) => (
|
||||
<tr key={k.cve_id} className="hover:bg-gray-50 cursor-pointer"
|
||||
onClick={() => router.push(`/vulnerabilities?cve_id=${k.cve_id}`)}>
|
||||
<td className="px-3 py-2 whitespace-nowrap">
|
||||
<span className="font-mono text-truevuln-blue text-xs font-bold">{k.cve_id}</span>
|
||||
{k.ransomware && <span title="Known ransomware campaign use" className="ml-1">🔒</span>}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs text-gray-600 truncate max-w-[180px]" title={`${k.vendor || ''} ${k.product || ''}`}>
|
||||
{[k.vendor, k.product].filter(Boolean).join(' · ')}
|
||||
</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap text-[11px] text-gray-400 font-mono">{k.date_added}</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap text-right">
|
||||
{k.in_inventory ? (
|
||||
<span className="inline-flex items-center rounded-sm border border-red-200 bg-red-50 px-1.5 py-0.5 text-[10px] font-bold uppercase text-red-700"
|
||||
title={`Present on ${k.asset_count} asset(s)`}>In inventory · {k.asset_count}</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center rounded-sm border border-gray-200 bg-gray-50 px-1.5 py-0.5 text-[10px] font-mono uppercase text-gray-400">not seen</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div >
|
||||
);
|
||||
|
||||
@@ -108,7 +108,7 @@ export default function PoliciesPage() {
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-vulncheck-blue text-white rounded-lg font-bold hover:bg-blue-700 transition-colors shadow-lg shadow-blue-500/20"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-truevuln-blue text-white rounded-lg font-bold hover:bg-blue-700 transition-colors shadow-lg shadow-blue-500/20"
|
||||
>
|
||||
<PlusIcon className="h-5 w-5" />
|
||||
Create Policy
|
||||
@@ -125,7 +125,7 @@ export default function PoliciesPage() {
|
||||
</dd>
|
||||
<div className="mt-4 w-full bg-gray-100 h-2 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="bg-vulncheck-blue h-full transition-all duration-500"
|
||||
className="bg-truevuln-blue h-full transition-all duration-500"
|
||||
style={{ width: `${policies.length > 0 ? (policies.reduce((sum, p) => sum + (p.compliance || 0), 0) / policies.length) : 0}%` }}
|
||||
></div>
|
||||
</div>
|
||||
@@ -142,7 +142,7 @@ export default function PoliciesPage() {
|
||||
|
||||
<div className="grid grid-cols-1 gap-6">
|
||||
{policies.map((policy) => (
|
||||
<div key={policy.id} className="bg-white rounded-xl border border-gray-100 shadow-sm overflow-hidden hover:border-vulncheck-blue/30 transition-all group">
|
||||
<div key={policy.id} className="bg-white rounded-xl border border-gray-100 shadow-sm overflow-hidden hover:border-truevuln-blue/30 transition-all group">
|
||||
<div className="p-6 flex flex-col md:flex-row md:items-center justify-between gap-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className={`p-3 rounded-xl ${policy.status === 'active' ? 'bg-green-50 text-green-600' : 'bg-gray-50 text-gray-400'}`}>
|
||||
@@ -190,7 +190,7 @@ export default function PoliciesPage() {
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleEdit(policy)}
|
||||
className="p-2 text-gray-400 hover:text-vulncheck-blue hover:bg-blue-50 rounded-lg transition-all"
|
||||
className="p-2 text-gray-400 hover:text-truevuln-blue hover:bg-blue-50 rounded-lg transition-all"
|
||||
title="Edit Policy"
|
||||
>
|
||||
<PencilIcon className="h-5 w-5" />
|
||||
@@ -226,7 +226,7 @@ export default function PoliciesPage() {
|
||||
required
|
||||
value={editingPolicy.name}
|
||||
onChange={(e) => setEditingPolicy({ ...editingPolicy, name: e.target.value })}
|
||||
className="w-full bg-gray-50 border-gray-100 rounded-xl px-4 py-3 focus:ring-vulncheck-blue focus:border-vulncheck-blue"
|
||||
className="w-full bg-gray-50 border-gray-100 rounded-xl px-4 py-3 focus:ring-truevuln-blue focus:border-truevuln-blue"
|
||||
placeholder="e.g. Critical Infrastructure SLA"
|
||||
/>
|
||||
</div>
|
||||
@@ -236,7 +236,7 @@ export default function PoliciesPage() {
|
||||
rows={2}
|
||||
value={editingPolicy.description}
|
||||
onChange={(e) => setEditingPolicy({ ...editingPolicy, description: e.target.value })}
|
||||
className="w-full bg-gray-50 border-gray-100 rounded-xl px-4 py-3 focus:ring-vulncheck-blue focus:border-vulncheck-blue"
|
||||
className="w-full bg-gray-50 border-gray-100 rounded-xl px-4 py-3 focus:ring-truevuln-blue focus:border-truevuln-blue"
|
||||
placeholder="Remediation standards for high-security systems..."
|
||||
/>
|
||||
</div>
|
||||
@@ -247,7 +247,7 @@ export default function PoliciesPage() {
|
||||
type="text"
|
||||
value={editingPolicy.category}
|
||||
onChange={(e) => setEditingPolicy({ ...editingPolicy, category: e.target.value })}
|
||||
className="w-full bg-gray-50 border-gray-100 rounded-xl px-4 py-3 focus:ring-vulncheck-blue focus:border-vulncheck-blue"
|
||||
className="w-full bg-gray-50 border-gray-100 rounded-xl px-4 py-3 focus:ring-truevuln-blue focus:border-truevuln-blue"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -255,7 +255,7 @@ export default function PoliciesPage() {
|
||||
<select
|
||||
value={editingPolicy.status}
|
||||
onChange={(e) => setEditingPolicy({ ...editingPolicy, status: e.target.value })}
|
||||
className="w-full bg-gray-50 border-gray-100 rounded-xl px-4 py-3 focus:ring-vulncheck-blue focus:border-vulncheck-blue"
|
||||
className="w-full bg-gray-50 border-gray-100 rounded-xl px-4 py-3 focus:ring-truevuln-blue focus:border-truevuln-blue"
|
||||
>
|
||||
<option value="active">Active Enforcement</option>
|
||||
<option value="warning">Observation Only</option>
|
||||
@@ -322,7 +322,7 @@ export default function PoliciesPage() {
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-8 py-2.5 bg-vulncheck-blue text-white rounded-xl font-bold hover:bg-blue-700 transition-all shadow-lg shadow-blue-500/20"
|
||||
className="px-8 py-2.5 bg-truevuln-blue text-white rounded-xl font-bold hover:bg-blue-700 transition-all shadow-lg shadow-blue-500/20"
|
||||
>
|
||||
Deploy Policy
|
||||
</button>
|
||||
|
||||
@@ -111,7 +111,7 @@ export default function ReportsPage() {
|
||||
<div key={report.id} className="relative flex flex-col bg-white border border-gray-200 rounded-sm shadow-sm p-6 hover:shadow-md transition-shadow">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="p-3 bg-blue-50 rounded-lg">
|
||||
<DocumentTextIcon className="h-6 w-6 text-vulncheck-blue" />
|
||||
<DocumentTextIcon className="h-6 w-6 text-truevuln-blue" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-gray-900 font-mono">{report.name}</h3>
|
||||
@@ -125,7 +125,7 @@ export default function ReportsPage() {
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleDownload(report)}
|
||||
className="flex items-center gap-2 text-sm font-semibold text-vulncheck-blue hover:text-blue-700"
|
||||
className="flex items-center gap-2 text-sm font-semibold text-truevuln-blue hover:text-blue-700"
|
||||
>
|
||||
<ArrowDownTrayIcon className="h-4 w-4" />
|
||||
Download
|
||||
|
||||
+15
-15
@@ -58,7 +58,7 @@ export default function ScansPage() {
|
||||
try {
|
||||
const [summaryRes, assetsRes, schedulesRes] = await Promise.all([
|
||||
api.get('/api/v1/scans/summary'),
|
||||
api.get('/api/v1/assets'),
|
||||
api.get('/api/v1/assets', { params: { limit: 1000 } }),
|
||||
api.get('/api/v1/scans/schedules')
|
||||
]);
|
||||
setScanRuns(summaryRes.data);
|
||||
@@ -261,7 +261,7 @@ export default function ScansPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="max-w-[1800px] mx-auto">
|
||||
<div className="flex md:items-center md:justify-between mb-8">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold leading-7 text-gray-900 font-mono">
|
||||
@@ -292,7 +292,7 @@ export default function ScansPage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAutoscan}
|
||||
className="inline-flex items-center gap-x-1.5 rounded-sm bg-vulncheck-blue px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-600 font-mono"
|
||||
className="inline-flex items-center gap-x-1.5 rounded-sm bg-truevuln-blue px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-600 font-mono"
|
||||
title="Trigger a new scan on all agents immediately"
|
||||
>
|
||||
<PlayIcon className="-ml-0.5 h-5 w-5" aria-hidden="true" />
|
||||
@@ -340,7 +340,7 @@ export default function ScansPage() {
|
||||
type="checkbox"
|
||||
checked={scanRuns.length > 0 && selectedRuns.size === scanRuns.length}
|
||||
onChange={toggleSelectAll}
|
||||
className="h-4 w-4 rounded border-gray-300 text-vulncheck-blue focus:ring-vulncheck-blue"
|
||||
className="h-4 w-4 rounded border-gray-300 text-truevuln-blue focus:ring-truevuln-blue"
|
||||
/>
|
||||
</th>
|
||||
<th scope="col" className="px-4 py-3 text-left text-xs font-mono font-medium text-gray-500 uppercase tracking-wider w-8"></th>
|
||||
@@ -371,7 +371,7 @@ export default function ScansPage() {
|
||||
type="checkbox"
|
||||
checked={selectedRuns.has(idx)}
|
||||
onChange={() => toggleRunSelection(idx)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-vulncheck-blue focus:ring-vulncheck-blue"
|
||||
className="h-4 w-4 rounded border-gray-300 text-truevuln-blue focus:ring-truevuln-blue"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-4 text-gray-400" onClick={() => hasFailed && setExpandedRun(isExpanded ? null : idx)}>
|
||||
@@ -444,7 +444,7 @@ export default function ScansPage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsScheduleModalOpen(true)}
|
||||
className="inline-flex items-center gap-x-1.5 rounded-sm bg-vulncheck-blue px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-600 font-mono"
|
||||
className="inline-flex items-center gap-x-1.5 rounded-sm bg-truevuln-blue px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-600 font-mono"
|
||||
>
|
||||
+ New Schedule
|
||||
</button>
|
||||
@@ -489,7 +489,7 @@ export default function ScansPage() {
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleEditSchedule(schedule)}
|
||||
className="p-1 text-gray-400 hover:text-vulncheck-blue transition-colors"
|
||||
className="p-1 text-gray-400 hover:text-truevuln-blue transition-colors"
|
||||
title="Edit Schedule"
|
||||
>
|
||||
<PencilIcon className="h-4 w-4" />
|
||||
@@ -521,7 +521,7 @@ export default function ScansPage() {
|
||||
value={newSchedule.name}
|
||||
onChange={(e) => setNewSchedule({ ...newSchedule, name: e.target.value })}
|
||||
placeholder="e.g. Nightly Full Scan"
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-vulncheck-blue focus:ring-vulncheck-blue sm:text-sm"
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-truevuln-blue focus:ring-truevuln-blue sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -529,7 +529,7 @@ export default function ScansPage() {
|
||||
<select
|
||||
value={newSchedule.interval}
|
||||
onChange={(e) => setNewSchedule({ ...newSchedule, interval: e.target.value })}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-vulncheck-blue focus:ring-vulncheck-blue sm:text-sm"
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-truevuln-blue focus:ring-truevuln-blue sm:text-sm"
|
||||
>
|
||||
<option value="every_hour">Every Hour</option>
|
||||
<option value="every_6_hours">Every 6 Hours</option>
|
||||
@@ -548,7 +548,7 @@ export default function ScansPage() {
|
||||
value={newSchedule.cron_expression}
|
||||
onChange={(e) => setNewSchedule({ ...newSchedule, cron_expression: e.target.value })}
|
||||
placeholder="* * * * * (min hour day month weekday)"
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-vulncheck-blue focus:ring-vulncheck-blue sm:text-sm"
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-truevuln-blue focus:ring-truevuln-blue sm:text-sm"
|
||||
/>
|
||||
<p className="mt-1 text-[10px] text-gray-500 font-mono">
|
||||
Example: "30 14 * * *" for 14:30 daily.
|
||||
@@ -560,7 +560,7 @@ export default function ScansPage() {
|
||||
<select
|
||||
value={newSchedule.scanner_type}
|
||||
onChange={(e) => setNewSchedule({ ...newSchedule, scanner_type: e.target.value })}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-vulncheck-blue focus:ring-vulncheck-blue sm:text-sm font-mono"
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-truevuln-blue focus:ring-truevuln-blue sm:text-sm font-mono"
|
||||
>
|
||||
<option value="wazuh">Wazuh (agent-based)</option>
|
||||
<option value="nessus">Tenable Nessus (network scan + import)</option>
|
||||
@@ -582,7 +582,7 @@ export default function ScansPage() {
|
||||
setEditingScheduleId(null);
|
||||
setNewSchedule({ name: '', interval: 'daily', cron_expression: '', scanner_type: 'wazuh' });
|
||||
}} className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 font-mono">Cancel</button>
|
||||
<button type="submit" className="px-4 py-2 text-sm font-medium text-white bg-vulncheck-blue rounded-md hover:bg-blue-600">
|
||||
<button type="submit" className="px-4 py-2 text-sm font-medium text-white bg-truevuln-blue rounded-md hover:bg-blue-600">
|
||||
{editingScheduleId ? 'Save Changes' : 'Create Schedule'}
|
||||
</button>
|
||||
</div>
|
||||
@@ -599,7 +599,7 @@ export default function ScansPage() {
|
||||
|
||||
{autoscanStatus === 'running' && (
|
||||
<div className="py-8">
|
||||
<ArrowPathIcon className="h-12 w-12 text-vulncheck-blue animate-spin mx-auto mb-4" />
|
||||
<ArrowPathIcon className="h-12 w-12 text-truevuln-blue animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-600 font-mono">Initiating scans on all connected agents...</p>
|
||||
<p className="text-xs text-gray-400 mt-2 font-mono">This may take a few moments.</p>
|
||||
</div>
|
||||
@@ -630,7 +630,7 @@ export default function ScansPage() {
|
||||
<div className="mt-6">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex w-full justify-center rounded-md bg-vulncheck-blue px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-600 font-mono"
|
||||
className="inline-flex w-full justify-center rounded-md bg-truevuln-blue px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-600 font-mono"
|
||||
onClick={() => setIsAutoscanModalOpen(false)}
|
||||
>
|
||||
Close
|
||||
@@ -717,7 +717,7 @@ export default function ScansPage() {
|
||||
<div className="mt-6">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex w-full justify-center rounded-md bg-vulncheck-blue px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-600 font-mono"
|
||||
className="inline-flex w-full justify-center rounded-md bg-truevuln-blue px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-600 font-mono"
|
||||
onClick={() => setIsSyncModalOpen(false)}
|
||||
>
|
||||
Close
|
||||
|
||||
+750
-67
File diff suppressed because it is too large
Load Diff
@@ -21,6 +21,281 @@ interface VulnDetail extends Vulnerability {
|
||||
assigned_group_name?: string;
|
||||
}
|
||||
|
||||
// Linux-kernel CVEs report their "fix" as an upstream git commit hash
|
||||
// (e.g. 7713bd320ed4fc3d08a22...) rather than a Debian package version —
|
||||
// raw it looks like garbage in the "Fixed in" cell. Detect a hex hash
|
||||
// (no version separators) and show a short, labelled form instead.
|
||||
function formatFixedVersion(v?: string | null): string {
|
||||
if (!v) return '';
|
||||
const s = v.trim();
|
||||
if (/^[0-9a-f]{12,64}$/i.test(s) && !/[.:~_+]/.test(s)) {
|
||||
return `upstream commit ${s.slice(0, 12)}`;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
// AI remediation (OpenRouter). Self-contained: checks whether the feature
|
||||
// is configured, then generates OS-aware fix guidance on demand.
|
||||
// Copy-to-clipboard button for code blocks.
|
||||
function CopyButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
return (
|
||||
<button
|
||||
onClick={async () => {
|
||||
// navigator.clipboard only exists in a secure context (HTTPS or
|
||||
// localhost). Over plain http://<ip> it's undefined → fall back
|
||||
// to a temp-textarea + execCommand so Copy still works.
|
||||
let ok = false;
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
ok = true;
|
||||
}
|
||||
} catch { /* fall through */ }
|
||||
if (!ok) {
|
||||
try {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.opacity = '0';
|
||||
document.body.appendChild(ta);
|
||||
ta.focus(); ta.select();
|
||||
ok = document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
} catch { /* give up */ }
|
||||
}
|
||||
if (ok) { setCopied(true); setTimeout(() => setCopied(false), 1500); }
|
||||
}}
|
||||
className="absolute top-2 right-2 text-[10px] font-mono px-2 py-1 rounded bg-gray-700/80 text-gray-100 hover:bg-gray-600"
|
||||
>
|
||||
{copied ? '✓ Copied' : 'Copy'}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Render a small subset of Markdown: fenced code blocks (with copy button),
|
||||
// **bold**, `inline code`, #/## headings, and - bullet lists. Good enough
|
||||
// for LLM remediation output without pulling in a markdown dependency.
|
||||
function inlineFmt(s: string, keyBase: string) {
|
||||
// Handle <br> first (LLMs emit it in table cells / steps) so it renders
|
||||
// as a line break instead of literal text — and WITHOUT splitting the
|
||||
// markdown line, which would corrupt table rows.
|
||||
const brParts = s.split(/(<br\s*\/?>)/gi);
|
||||
return brParts.map((bp, bi) => {
|
||||
if (/^<br/i.test(bp)) return <br key={`${keyBase}-br-${bi}`} />;
|
||||
// split on **bold** and `code`, keep delimiters
|
||||
const parts = bp.split(/(\*\*[^*]+\*\*|`[^`]+`)/g);
|
||||
return parts.map((p, i) => {
|
||||
const k = `${keyBase}-${bi}-${i}`;
|
||||
if (/^\*\*[^*]+\*\*$/.test(p)) return <strong key={k}>{p.slice(2, -2)}</strong>;
|
||||
if (/^`[^`]+`$/.test(p)) return <code key={k} className="px-1 py-0.5 rounded bg-gray-100 text-pink-700 text-[13px] font-mono">{p.slice(1, -1)}</code>;
|
||||
return <span key={k}>{p}</span>;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const _isRow = (l: string) => /^\s*\|.*\|\s*$/.test(l);
|
||||
const _isSep = (l: string) => /^\s*\|[\s:|-]+\|\s*$/.test(l);
|
||||
const _cells = (l: string) => l.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map(c => c.trim());
|
||||
|
||||
function AiMarkdown({ content }: { content: string }) {
|
||||
// Unescape literal escapes some models emit (e.g. "\nadb shell …\n" shown
|
||||
// verbatim). <br> is left intact here and handled in inlineFmt.
|
||||
const norm = (content || '').replace(/\\r\\n|\\n/g, '\n').replace(/\\t/g, ' ');
|
||||
// Split into code-fence vs text segments.
|
||||
const segs = norm.split(/(```[\s\S]*?```)/g);
|
||||
return (
|
||||
<div className="text-sm text-gray-800 leading-relaxed space-y-2">
|
||||
{segs.map((seg, si) => {
|
||||
const fence = seg.match(/^```(\w*)\n?([\s\S]*?)```$/);
|
||||
if (fence) {
|
||||
const code = fence[2].replace(/\n$/, '');
|
||||
return (
|
||||
<div key={si} className="relative">
|
||||
{fence[1] && <span className="absolute top-2 left-3 text-[10px] font-mono uppercase text-gray-400">{fence[1]}</span>}
|
||||
<CopyButton text={code} />
|
||||
<pre className="bg-gray-900 text-gray-100 rounded-md p-3 pt-7 overflow-x-auto text-[13px] font-mono whitespace-pre">{code}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// text segment → walk lines, grouping GFM tables.
|
||||
const lines = seg.split('\n');
|
||||
const out: React.ReactNode[] = [];
|
||||
let i = 0;
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
// GFM table: header row + separator row + body rows.
|
||||
if (_isRow(line) && i + 1 < lines.length && _isSep(lines[i + 1])) {
|
||||
const header = _cells(line);
|
||||
let j = i + 2;
|
||||
const rows: string[][] = [];
|
||||
while (j < lines.length && _isRow(lines[j]) && !_isSep(lines[j])) { rows.push(_cells(lines[j])); j++; }
|
||||
out.push(
|
||||
<div key={`${si}-t${i}`} className="overflow-x-auto my-2">
|
||||
<table className="text-[13px] border-collapse w-full">
|
||||
<thead><tr>{header.map((c, ci) => (
|
||||
<th key={ci} className="border border-gray-300 bg-gray-50 px-2 py-1 text-left font-semibold">{inlineFmt(c, `${si}-th-${i}-${ci}`)}</th>
|
||||
))}</tr></thead>
|
||||
<tbody>{rows.map((r, ri) => (
|
||||
<tr key={ri}>{header.map((_, ci) => (
|
||||
<td key={ci} className="border border-gray-300 px-2 py-1 align-top">{inlineFmt(r[ci] ?? '', `${si}-td-${i}-${ri}-${ci}`)}</td>
|
||||
))}</tr>
|
||||
))}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
const key = `${si}-${i}`;
|
||||
if (!line.trim()) { out.push(<div key={key} className="h-1" />); i++; continue; }
|
||||
const h = line.match(/^(#{1,4})\s+(.*)$/);
|
||||
if (h) { out.push(<p key={key} className="font-bold text-gray-900 mt-2">{inlineFmt(h[2], key)}</p>); i++; continue; }
|
||||
const li2 = line.match(/^\s*[-*]\s+(.*)$/);
|
||||
if (li2) { out.push(<p key={key} className="pl-4 relative before:content-['•'] before:absolute before:left-0 before:text-gray-400">{inlineFmt(li2[1], key)}</p>); i++; continue; }
|
||||
const num = line.match(/^\s*(\d+)\.\s+(.*)$/);
|
||||
if (num) { out.push(<p key={key} className="pl-1"><span className="font-bold text-gray-700">{num[1]}.</span> {inlineFmt(num[2], key)}</p>); i++; continue; }
|
||||
out.push(<p key={key}>{inlineFmt(line, key)}</p>); i++;
|
||||
}
|
||||
return <div key={si}>{out}</div>;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Remediation section: the scanner solution (Nessus) plus CVE-level
|
||||
// external sources (MSRC, later Ubuntu/CentOS), each in its own block.
|
||||
type RemItem = { kind: string; title?: string; detail?: string; kb?: string; fixed_build?: string; url?: string };
|
||||
type RemGroup = { source: string; items: RemItem[]; fetched_at?: string };
|
||||
const SOURCE_LABEL: Record<string, string> = { msrc: 'Microsoft (MSRC)', ubuntu: 'Ubuntu USN', redhat: 'Red Hat / CentOS / Alma', osv: 'OSV.dev (aggregator)' };
|
||||
|
||||
function RemediationsSection({ vulnId, scannerFallback }: { vulnId: number; scannerFallback?: string | null }) {
|
||||
const [scanner, setScanner] = useState<string | null>(scannerFallback || null);
|
||||
const [external, setExternal] = useState<RemGroup[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let ok = true;
|
||||
api.get(`/api/v1/vulnerabilities/${vulnId}/remediations`)
|
||||
.then(r => { if (!ok) return; setScanner(r.data?.scanner ?? null); setExternal(r.data?.external || []); })
|
||||
.catch(() => {});
|
||||
return () => { ok = false; };
|
||||
}, [vulnId]);
|
||||
|
||||
if (!scanner && external.length === 0) return null;
|
||||
|
||||
const renderItems = (items: RemItem[]) => {
|
||||
const fixes = items.filter(i => i.kind === 'fix');
|
||||
const notes = items.filter(i => i.kind !== 'fix');
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{fixes.length > 0 && (
|
||||
<div>
|
||||
<p className="text-[10px] uppercase tracking-wider text-emerald-700 font-mono mb-1">Patches / KBs</p>
|
||||
<ul className="space-y-1">
|
||||
{fixes.map((f, i) => (
|
||||
<li key={i} className="text-sm flex items-center gap-2 flex-wrap">
|
||||
<span className="inline-flex items-center rounded bg-emerald-100 px-1.5 py-0.5 text-[11px] font-bold text-emerald-700">{f.title || 'Fix'}</span>
|
||||
{f.url && <a href={f.url} target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline text-xs font-mono">download →</a>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{notes.map((n, i) => (
|
||||
<div key={i}>
|
||||
<p className={`text-[10px] uppercase tracking-wider font-mono mb-1 ${n.kind === 'mitigation' ? 'text-amber-700' : 'text-indigo-700'}`}>
|
||||
{n.kind === 'mitigation' ? 'Mitigation / Containment' : n.kind === 'workaround' ? 'Workaround' : 'Advisory'}
|
||||
</p>
|
||||
{n.detail && <p className="text-sm text-gray-700 whitespace-pre-wrap leading-relaxed">{n.detail}</p>}
|
||||
{n.url && (
|
||||
<a href={n.url} target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline text-sm font-mono break-all">
|
||||
{n.title || n.url} →
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{scanner && (
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<h2 className="text-lg font-bold text-gray-900 mb-3 flex items-center gap-2">
|
||||
<span>🛠️</span> Remediation
|
||||
<span className="text-[10px] uppercase tracking-wider text-gray-400 font-mono">via scanner</span>
|
||||
</h2>
|
||||
<p className="text-sm text-gray-700 whitespace-pre-wrap leading-relaxed">{scanner}</p>
|
||||
</div>
|
||||
)}
|
||||
{external.map((g, gi) => (
|
||||
<div key={gi} className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<h2 className="text-lg font-bold text-gray-900 mb-3 flex items-center gap-2">
|
||||
<span>🛡️</span> Remediation
|
||||
<span className="text-[10px] uppercase tracking-wider text-gray-400 font-mono">via {SOURCE_LABEL[g.source] || g.source}</span>
|
||||
</h2>
|
||||
{renderItems(g.items)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AIRemediationSection({ vulnId }: { vulnId: number }) {
|
||||
const [enabled, setEnabled] = useState<boolean | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [content, setContent] = useState<string | null>(null);
|
||||
const [model, setModel] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let ok = true;
|
||||
api.get('/api/v1/vulnerabilities/ai-remediation/status')
|
||||
.then(r => { if (ok) setEnabled(!!r.data?.enabled); })
|
||||
.catch(() => { if (ok) setEnabled(false); });
|
||||
return () => { ok = false; };
|
||||
}, []);
|
||||
|
||||
if (enabled === false) return null; // feature off → no clutter
|
||||
|
||||
const run = async () => {
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
const r = await api.post(`/api/v1/vulnerabilities/${vulnId}/ai-remediation`);
|
||||
setContent(r.data?.content || '');
|
||||
setModel(r.data?.model || null);
|
||||
} catch (e: any) {
|
||||
setError(e?.response?.data?.detail || e?.message || 'unknown error');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-lg font-bold text-gray-900 flex items-center gap-2">
|
||||
<span>🤖</span> AI Remediation
|
||||
{model && <span className="text-[10px] uppercase tracking-wider text-gray-400 font-mono">{model}</span>}
|
||||
</h2>
|
||||
<button
|
||||
onClick={run}
|
||||
disabled={busy || enabled === null}
|
||||
className="inline-flex items-center gap-1 text-xs font-mono px-3 py-2 border border-indigo-500 text-indigo-700 rounded-md hover:bg-indigo-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{busy ? <><span className="animate-spin">⟳</span> Generating…</>
|
||||
: content ? 'Regenerate' : 'Generate fix steps'}
|
||||
</button>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-600 font-mono">Error: {error}</p>}
|
||||
{content
|
||||
? <AiMarkdown content={content} />
|
||||
: !error && <p className="text-xs text-gray-400 font-mono">OS-aware fix commands generated on demand via OpenRouter.</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Refresh button with loading state + ephemeral "done" badge so a
|
||||
// click that returns identical EPSS/KEV data still gives the operator
|
||||
// visual confirmation the call ran (vs the previous silent UX).
|
||||
@@ -60,6 +335,11 @@ export default function VulnerabilityDetailPage() {
|
||||
const [vuln, setVuln] = useState<VulnDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [userRole, setUserRole] = useState('');
|
||||
useEffect(() => {
|
||||
api.get('/auth/me').then(r => setUserRole(r.data?.role || '')).catch(() => { });
|
||||
}, []);
|
||||
const canEdit = userRole === 'admin' || userRole === 'editor';
|
||||
// Audit / change history for this vuln — populated separately so
|
||||
// a slow audit query doesn't block the vuln detail render.
|
||||
type AuditEntry = {
|
||||
@@ -103,6 +383,43 @@ export default function VulnerabilityDetailPage() {
|
||||
}
|
||||
}, [params.id]);
|
||||
|
||||
const [dismissBusy, setDismissBusy] = useState(false);
|
||||
const reloadVuln = async () => {
|
||||
try {
|
||||
const res = await api.get(`/api/v1/vulnerabilities/${params.id}`);
|
||||
setVuln(res.data);
|
||||
} catch { /* keep current */ }
|
||||
};
|
||||
// Dismiss = mark false positive (with reason). Works for EOL/pseudo-CVE
|
||||
// rows too — they're normal vulnerability records. Reactivate reverses it.
|
||||
const handleDismiss = async () => {
|
||||
const reason = window.prompt(
|
||||
'Dismiss this finding as a false positive?\nEnter a reason (audit-logged):',
|
||||
'Not applicable / accepted'
|
||||
);
|
||||
if (reason === null) return;
|
||||
setDismissBusy(true);
|
||||
try {
|
||||
await api.patch(`/api/v1/vulnerabilities/${params.id}/false-positive`, { reason });
|
||||
await reloadVuln();
|
||||
} catch (e: any) {
|
||||
alert(`Dismiss failed: ${e?.response?.data?.detail || e?.message || 'unknown'}`);
|
||||
} finally {
|
||||
setDismissBusy(false);
|
||||
}
|
||||
};
|
||||
const handleReactivate = async () => {
|
||||
setDismissBusy(true);
|
||||
try {
|
||||
await api.patch(`/api/v1/vulnerabilities/${params.id}/unmark-false-positive`);
|
||||
await reloadVuln();
|
||||
} catch (e: any) {
|
||||
alert(`Reactivate failed: ${e?.response?.data?.detail || e?.message || 'unknown'}`);
|
||||
} finally {
|
||||
setDismissBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Parse the JSON payload audit-logs use for status changes.
|
||||
// new_value can be a plain string (old format) or a JSON object
|
||||
// {status, reason, source, cve_id} (new format).
|
||||
@@ -133,7 +450,7 @@ export default function VulnerabilityDetailPage() {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-8 flex justify-center items-center min-h-screen">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-vulncheck-blue"></div>
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-truevuln-blue"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -180,6 +497,25 @@ export default function VulnerabilityDetailPage() {
|
||||
<span className={`px-4 py-2 rounded-lg text-sm font-medium ${statusColors[vuln.status] || 'bg-gray-100'}`}>
|
||||
{vuln.status.replace('_', ' ')}
|
||||
</span>
|
||||
{canEdit && (vuln.status === 'false_positive' ? (
|
||||
<button
|
||||
onClick={handleReactivate}
|
||||
disabled={dismissBusy}
|
||||
className="px-3 py-2 rounded-lg text-sm font-medium border border-gray-300 text-gray-700 hover:bg-gray-50 disabled:opacity-50"
|
||||
title="Reopen this finding"
|
||||
>
|
||||
{dismissBusy ? '…' : 'Reactivate'}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
disabled={dismissBusy}
|
||||
className="px-3 py-2 rounded-lg text-sm font-medium border border-amber-300 text-amber-700 hover:bg-amber-50 disabled:opacity-50"
|
||||
title="Dismiss as false positive (hides it from the active list)"
|
||||
>
|
||||
{dismissBusy ? '…' : 'Dismiss'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -235,8 +571,9 @@ export default function VulnerabilityDetailPage() {
|
||||
</div>
|
||||
<div className="col-span-3">
|
||||
<span className="text-[10px] uppercase tracking-wider text-gray-500">Fixed in</span>
|
||||
<p className={`font-bold ${p.has_fix ? 'text-emerald-700' : 'text-gray-400'}`}>
|
||||
{p.fixed_version || (p.has_fix ? '—' : 'not announced')}
|
||||
<p className={`font-bold break-words ${p.has_fix ? 'text-emerald-700' : 'text-gray-400'}`}
|
||||
title={p.fixed_version || ''}>
|
||||
{formatFixedVersion(p.fixed_version) || (p.has_fix ? '—' : 'not announced')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="col-span-1 text-right">
|
||||
@@ -269,7 +606,7 @@ export default function VulnerabilityDetailPage() {
|
||||
{vuln.fixed_version && (
|
||||
<div>
|
||||
<span className="text-gray-500">Fixed in:</span>
|
||||
<p className="font-bold text-emerald-700">{vuln.fixed_version}</p>
|
||||
<p className="font-bold text-emerald-700 break-words" title={vuln.fixed_version}>{formatFixedVersion(vuln.fixed_version)}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -278,7 +615,19 @@ export default function VulnerabilityDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* External Links */}
|
||||
{/* Remediation — scanner solution + external sources (MSRC, ...) */}
|
||||
<RemediationsSection vulnId={vuln.id} scannerFallback={vuln.remediation} />
|
||||
|
||||
{/* AI remediation (OpenRouter) — hidden when not configured */}
|
||||
{/* Editor+ only: generating fix steps hits an editor-gated
|
||||
endpoint and the output isn't persisted, so there's
|
||||
nothing for a read-only user to see. */}
|
||||
{canEdit && <AIRemediationSection vulnId={vuln.id} />}
|
||||
|
||||
{/* External Links — only for real CVE ids. EOL- /
|
||||
NESSUS-PLUGIN- pseudo-CVEs have no NVD/CVE.org/Exploit-DB
|
||||
record, so the links would 404 / lead to nirvana. */}
|
||||
{/^CVE-/i.test(vuln.cve_id) && (
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<h2 className="text-lg font-bold text-gray-900 mb-4 flex items-center gap-2">
|
||||
<LinkIcon className="h-5 w-5 text-gray-500" />
|
||||
@@ -311,6 +660,7 @@ export default function VulnerabilityDetailPage() {
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right Column - Metadata */}
|
||||
@@ -579,8 +929,9 @@ export default function VulnerabilityDetailPage() {
|
||||
Affected Asset
|
||||
</h2>
|
||||
<Link
|
||||
href={`/assets?id=${vuln.asset_id}`}
|
||||
href={`/vulnerabilities?asset_id=${vuln.asset_id}`}
|
||||
className="block bg-gray-50 rounded-lg p-4 hover:bg-gray-100 transition-colors"
|
||||
title="Show all findings on this asset"
|
||||
>
|
||||
<p className="font-bold text-gray-900 font-mono">
|
||||
{vuln.asset_hostname || `Asset #${vuln.asset_id}`}
|
||||
|
||||
@@ -90,7 +90,7 @@ function ExploitIntelButton({ onDone }: { onDone: () => void }) {
|
||||
<>💥 Exploit Intel</>
|
||||
)}
|
||||
{result && !busy && (
|
||||
<span className="absolute top-full right-0 mt-1 z-30 whitespace-nowrap rounded-md bg-red-50 border border-red-200 px-2 py-1 text-[10px] text-red-800 shadow-sm">
|
||||
<span className="absolute top-full right-0 mt-1 z-30 whitespace-normal w-60 rounded-md bg-red-50 border border-red-200 px-2 py-1 text-[10px] text-red-800 shadow-sm">
|
||||
{result}
|
||||
</span>
|
||||
)}
|
||||
@@ -139,7 +139,213 @@ function EolCheckButton({ onDone }: { onDone: () => void }) {
|
||||
<>⏳ EOL Check</>
|
||||
)}
|
||||
{result && !busy && (
|
||||
<span className="absolute top-full right-0 mt-1 z-30 whitespace-nowrap rounded-md bg-purple-50 border border-purple-200 px-2 py-1 text-[10px] text-purple-800 shadow-sm">
|
||||
<span className="absolute top-full right-0 mt-1 z-30 whitespace-normal w-60 rounded-md bg-purple-50 border border-purple-200 px-2 py-1 text-[10px] text-purple-800 shadow-sm">
|
||||
{result}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function M365CheckButton({ onDone }: { onDone: () => void }) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [result, setResult] = useState<string | null>(null);
|
||||
return (
|
||||
<button
|
||||
disabled={busy}
|
||||
onClick={async () => {
|
||||
if (!confirm(
|
||||
'Run Microsoft 365 Apps CVE detection?\n\n' +
|
||||
'Parses the Microsoft 365 Apps security-updates page, ' +
|
||||
'compares each host\'s installed build (from Wazuh ' +
|
||||
'syscollector) against the latest patched build for its ' +
|
||||
'update channel, and creates real-CVE rows for the ' +
|
||||
'monthly updates the host is behind on. These CVEs are ' +
|
||||
'not in NVD and are invisible to Wazuh. Runs synchronously.'
|
||||
)) return;
|
||||
setBusy(true);
|
||||
setResult(null);
|
||||
try {
|
||||
const res = await api.post('/api/v1/vulnerabilities/m365-check');
|
||||
const s = res.data || {};
|
||||
setResult(
|
||||
`${s.assets_scanned || 0} assets, ` +
|
||||
`${s.m365_installs || 0} M365 installs, ` +
|
||||
`${s.assets_affected || 0} affected, ` +
|
||||
`${s.cve_findings_new || 0} new CVE rows ` +
|
||||
`(${s.cve_findings_total || 0} total).`
|
||||
);
|
||||
onDone();
|
||||
} catch (e: any) {
|
||||
setResult(`Error: ${e?.response?.data?.detail || e?.message || 'unknown'}`);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
className="inline-flex items-center gap-1 text-xs font-mono px-3 py-2 border border-blue-600 text-blue-700 rounded-md hover:bg-blue-50 disabled:opacity-50 disabled:cursor-not-allowed relative"
|
||||
title={result || 'Detect Microsoft 365 Apps CVEs (not in NVD / not seen by Wazuh)'}
|
||||
>
|
||||
{busy ? (
|
||||
<><span className="animate-spin">⟳</span> Checking M365…</>
|
||||
) : (
|
||||
<>📅 M365 CVEs</>
|
||||
)}
|
||||
{result && !busy && (
|
||||
<span className="absolute top-full right-0 mt-1 z-30 whitespace-normal w-60 rounded-md bg-blue-50 border border-blue-200 px-2 py-1 text-[10px] text-blue-800 shadow-sm">
|
||||
{result}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function AppCveScanButton({ onDone }: { onDone: () => void }) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [result, setResult] = useState<string | null>(null);
|
||||
return (
|
||||
<button
|
||||
disabled={busy}
|
||||
onClick={async () => {
|
||||
if (!confirm(
|
||||
'Run the built-in App CVE scan?\n\n' +
|
||||
'Maps installed software (Wazuh packages + Intune ' +
|
||||
'detectedApps) to real CVEs via OSV / NVD-CPE with an ' +
|
||||
'own version-range check — curated product list, low ' +
|
||||
'false-positives. Closes the coverage gap for Intune-' +
|
||||
'only / mobile devices that have no real scanner. ' +
|
||||
'Source "app-scan"; cross-confirms with other scanners. ' +
|
||||
'Runs synchronously (may take a while on first run).'
|
||||
)) return;
|
||||
setBusy(true);
|
||||
setResult(null);
|
||||
try {
|
||||
const res = await api.post('/api/v1/vulnerabilities/app-cve-scan');
|
||||
const s = res.data || {};
|
||||
setResult(
|
||||
`${s.assets || 0} assets, ` +
|
||||
`${s.findings || 0} findings (${s.new || 0} new).`
|
||||
);
|
||||
onDone();
|
||||
} catch (e: any) {
|
||||
setResult(`Error: ${e?.response?.data?.detail || e?.message || 'unknown'}`);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
className="inline-flex items-center gap-1 text-xs font-mono px-3 py-2 border border-orange-600 text-orange-700 rounded-md hover:bg-orange-50 disabled:opacity-50 disabled:cursor-not-allowed relative"
|
||||
title={result || 'Built-in app→CVE scan (curated + precise) for software without a real scanner'}
|
||||
>
|
||||
{busy ? (
|
||||
<><span className="animate-spin">⟳</span> Scanning apps…</>
|
||||
) : (
|
||||
<>🔎 App CVE Scan</>
|
||||
)}
|
||||
{result && !busy && (
|
||||
<span className="absolute top-full right-0 mt-1 z-30 whitespace-normal w-60 rounded-md bg-orange-50 border border-orange-200 px-2 py-1 text-[10px] text-orange-800 shadow-sm">
|
||||
{result}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function MsrcRefreshButton() {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [result, setResult] = useState<string | null>(null);
|
||||
return (
|
||||
<button
|
||||
disabled={busy}
|
||||
onClick={async () => {
|
||||
if (!confirm(
|
||||
'Enrich remediations from Microsoft (MSRC)?\n\n' +
|
||||
'Pulls the recent monthly Microsoft security-update documents ' +
|
||||
'and stores per-CVE fixes (KB + build + download link), ' +
|
||||
'workarounds, and mitigations for the Windows / MS-product ' +
|
||||
'CVEs in the database. Runs in the background; shown in the ' +
|
||||
'CVE detail under "Remediation via Microsoft (MSRC)".'
|
||||
)) return;
|
||||
setBusy(true);
|
||||
setResult(null);
|
||||
try {
|
||||
await api.post('/api/v1/vulnerabilities/msrc/refresh');
|
||||
setResult('Running…');
|
||||
// Fire-and-forget on the backend (202) → poll for the real outcome.
|
||||
const poll = async () => {
|
||||
try {
|
||||
const s = await api.get('/api/v1/vulnerabilities/msrc/refresh/status');
|
||||
if (s.data?.running) { setTimeout(poll, 4000); return; }
|
||||
if (s.data?.error) {
|
||||
setResult(`Failed: ${s.data.error}`);
|
||||
} else if (s.data?.result) {
|
||||
const d = s.data.result;
|
||||
const bits = Object.entries(d).slice(0, 4).map(([k, v]) => `${k}: ${v}`).join(', ');
|
||||
setResult(`Done — ${bits || 'finished'}`);
|
||||
} else {
|
||||
setResult('Done.');
|
||||
}
|
||||
setBusy(false);
|
||||
} catch { setTimeout(poll, 4000); }
|
||||
};
|
||||
setTimeout(poll, 4000);
|
||||
} catch (e: any) {
|
||||
setResult(`Error: ${e?.response?.data?.detail || e?.message || 'unknown'}`);
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
className="inline-flex items-center gap-1 text-xs font-mono px-3 py-2 border border-sky-600 text-sky-700 rounded-md hover:bg-sky-50 disabled:opacity-50 disabled:cursor-not-allowed relative"
|
||||
title={result || 'Pull Microsoft (MSRC) per-CVE fixes / workarounds / mitigations'}
|
||||
>
|
||||
{busy ? <><span className="animate-spin">⟳</span> MSRC…</> : <>🛡️ MSRC Enrich</>}
|
||||
{result && !busy && (
|
||||
<span className="absolute top-full right-0 mt-1 z-30 whitespace-normal w-60 rounded-md bg-sky-50 border border-sky-200 px-2 py-1 text-[10px] text-sky-800 shadow-sm">
|
||||
{result}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function DateBackfillButton({ onDone }: { onDone: () => void }) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [result, setResult] = useState<string | null>(null);
|
||||
return (
|
||||
<button
|
||||
disabled={busy}
|
||||
onClick={async () => {
|
||||
if (!confirm(
|
||||
'Backfill CVE published / last-modified dates?\n\n' +
|
||||
'Fills the official publish + update dates (CVE.org ' +
|
||||
'cvelistV5, NVD fallback) for every CVE that has none yet, ' +
|
||||
'so the "Newly Published" ordering is correct. Runs in the ' +
|
||||
'background — no API key needed. First run downloads the ' +
|
||||
'cvelistV5 snapshot if not already cached.'
|
||||
)) return;
|
||||
setBusy(true);
|
||||
setResult(null);
|
||||
try {
|
||||
const res = await api.post('/api/v1/vulnerabilities/dates/backfill');
|
||||
const s = res.data || {};
|
||||
setResult(s.status === 'already_running'
|
||||
? 'Already running…'
|
||||
: 'Started in background — refresh in a minute.');
|
||||
// Give it a moment, then refresh the list to pick up dates.
|
||||
setTimeout(onDone, 8000);
|
||||
} catch (e: any) {
|
||||
setResult(`Error: ${e?.response?.data?.detail || e?.message || 'unknown'}`);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
className="inline-flex items-center gap-1 text-xs font-mono px-3 py-2 border border-emerald-600 text-emerald-700 rounded-md hover:bg-emerald-50 disabled:opacity-50 disabled:cursor-not-allowed relative"
|
||||
title={result || 'Fill CVE published/updated dates (cvelistV5 → NVD); fixes Newly-Published sort'}
|
||||
>
|
||||
{busy ? (
|
||||
<><span className="animate-spin">⟳</span> Dating…</>
|
||||
) : (
|
||||
<>📆 Backfill Dates</>
|
||||
)}
|
||||
{result && !busy && (
|
||||
<span className="absolute top-full right-0 mt-1 z-30 whitespace-normal w-60 rounded-md bg-emerald-50 border border-emerald-200 px-2 py-1 text-[10px] text-emerald-800 shadow-sm">
|
||||
{result}
|
||||
</span>
|
||||
)}
|
||||
@@ -162,7 +368,8 @@ function VulnerabilitiesContent() {
|
||||
const [selectedStatus, setSelectedStatus] = useState('active');
|
||||
const [kevOnly, setKevOnly] = useState(false);
|
||||
const [euvdOnly, setEuvdOnly] = useState(false);
|
||||
const [sourceFilter, setSourceFilter] = useState<'all' | 'wazuh' | 'nessus' | 'manual'>('all');
|
||||
const [sourceFilter, setSourceFilter] = useState<'all' | 'wazuh' | 'nessus' | 'manual' | 'app-scan'>('all');
|
||||
const [findingType, setFindingType] = useState<string>(''); // 'mobile' from dashboard View All
|
||||
const [crossConfirmedOnly, setCrossConfirmedOnly] = useState(false);
|
||||
const [euCritical, setEuCritical] = useState(false);
|
||||
const [inBothCatalogs, setInBothCatalogs] = useState(false);
|
||||
@@ -190,7 +397,9 @@ function VulnerabilitiesContent() {
|
||||
const overridePollRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Sortierung (klickbare Header)
|
||||
const [sortBy, setSortBy] = useState<string>('priority');
|
||||
// Default sort = CPR desc — tester: CPR is the best single risk metric
|
||||
// for risk-based vuln management (was 'priority').
|
||||
const [sortBy, setSortBy] = useState<string>('cpr');
|
||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
|
||||
|
||||
// Horizontaler Tabellen-Scroll (Maus ohne H-Scroll-Rad)
|
||||
@@ -251,7 +460,7 @@ function VulnerabilitiesContent() {
|
||||
if (sortBy !== column) {
|
||||
return <span className="text-gray-300 ml-1">↕</span>;
|
||||
}
|
||||
return <span className="text-vulncheck-blue ml-1">{sortOrder === 'desc' ? '↓' : '↑'}</span>;
|
||||
return <span className="text-truevuln-blue ml-1">{sortOrder === 'desc' ? '↓' : '↑'}</span>;
|
||||
};
|
||||
|
||||
// Help-Tooltip text per column
|
||||
@@ -292,7 +501,7 @@ function VulnerabilitiesContent() {
|
||||
e.stopPropagation();
|
||||
setOpenHelp(isOpen ? null : column);
|
||||
}}
|
||||
className="text-gray-400 hover:text-vulncheck-blue cursor-pointer inline-flex"
|
||||
className="text-gray-400 hover:text-truevuln-blue cursor-pointer inline-flex"
|
||||
title={text}
|
||||
aria-label={`Help for ${column}`}
|
||||
>
|
||||
@@ -304,7 +513,7 @@ function VulnerabilitiesContent() {
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<strong className="text-vulncheck-blue uppercase text-[11px] tracking-wider">{column}</strong>
|
||||
<strong className="text-truevuln-blue uppercase text-[11px] tracking-wider">{column}</strong>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setOpenHelp(null); }}
|
||||
className="text-gray-400 hover:text-gray-700"
|
||||
@@ -407,6 +616,8 @@ function VulnerabilitiesContent() {
|
||||
if (searchParams.get('euvd_only') === 'true') setEuvdOnly(true);
|
||||
if (searchParams.get('in_both_catalogs') === 'true') setInBothCatalogs(true);
|
||||
if (searchParams.get('publ_expl') === '1') setExploitCatalogOnly(true);
|
||||
const ft = searchParams.get('finding_type');
|
||||
if (ft) setFindingType(ft);
|
||||
}, [searchParams]);
|
||||
|
||||
// Fetch Current User
|
||||
@@ -427,6 +638,7 @@ function VulnerabilitiesContent() {
|
||||
// user-typed term still wins as a more specific filter.
|
||||
const effectiveSearch = eolOnly && !searchText ? 'EOL-' : searchText;
|
||||
if (effectiveSearch) params.search = effectiveSearch;
|
||||
if (findingType) params.finding_type = findingType;
|
||||
// Always send the literal value (including 'all', 'active',
|
||||
// 'closed') — backend handles all three groups + enum literals.
|
||||
if (selectedStatus) params.status = selectedStatus;
|
||||
@@ -446,7 +658,7 @@ function VulnerabilitiesContent() {
|
||||
|
||||
const [vulnRes, assetsRes, usersRes, groupsRes] = await Promise.all([
|
||||
api.get('/api/v1/vulnerabilities', { params }),
|
||||
api.get('/api/v1/assets'),
|
||||
api.get('/api/v1/assets', { params: { limit: 1000 } }),
|
||||
api.get('/auth/users').catch(() => ({ data: [] })),
|
||||
api.get('/api/v1/groups'),
|
||||
]);
|
||||
@@ -475,7 +687,7 @@ function VulnerabilitiesContent() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [selectedAssetId, selectedSeverity, searchText, selectedStatus, currentPage, kevOnly, euvdOnly, euCritical, inBothCatalogs, sourceFilter, crossConfirmedOnly, epssMin, sortBy, sortOrder, eolOnly, exploitCatalogOnly]);
|
||||
}, [selectedAssetId, selectedSeverity, searchText, selectedStatus, currentPage, kevOnly, euvdOnly, euCritical, inBothCatalogs, sourceFilter, crossConfirmedOnly, epssMin, sortBy, sortOrder, eolOnly, exploitCatalogOnly, findingType]);
|
||||
|
||||
const handleEnrichAll = async () => {
|
||||
if (isEnriching) return;
|
||||
@@ -779,7 +991,7 @@ function VulnerabilitiesContent() {
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
placeholder="Search CVE, package, or title..."
|
||||
className="w-full pl-9 pr-3 py-2 border border-gray-300 rounded-md text-sm font-mono focus:ring-vulncheck-blue focus:border-vulncheck-blue"
|
||||
className="w-full pl-9 pr-3 py-2 border border-gray-300 rounded-md text-sm font-mono focus:ring-truevuln-blue focus:border-truevuln-blue"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -789,7 +1001,7 @@ function VulnerabilitiesContent() {
|
||||
<select
|
||||
value={selectedSeverity}
|
||||
onChange={(e) => { setSelectedSeverity(e.target.value); setCurrentPage(1); }}
|
||||
className="border border-gray-300 rounded-md text-sm font-mono py-2 px-3 focus:ring-vulncheck-blue focus:border-vulncheck-blue"
|
||||
className="border border-gray-300 rounded-md text-sm font-mono py-2 px-3 focus:ring-truevuln-blue focus:border-truevuln-blue"
|
||||
>
|
||||
<option value="">All Severities</option>
|
||||
<option value="critical">Critical</option>
|
||||
@@ -805,7 +1017,7 @@ function VulnerabilitiesContent() {
|
||||
<select
|
||||
value={selectedStatus}
|
||||
onChange={(e) => { setSelectedStatus(e.target.value); setCurrentPage(1); }}
|
||||
className="border border-gray-300 rounded-md text-sm font-mono py-2 px-3 focus:ring-vulncheck-blue focus:border-vulncheck-blue"
|
||||
className="border border-gray-300 rounded-md text-sm font-mono py-2 px-3 focus:ring-truevuln-blue focus:border-truevuln-blue"
|
||||
>
|
||||
<optgroup label="Groups">
|
||||
<option value="active">Active (open + pending + patch failed)</option>
|
||||
@@ -911,12 +1123,13 @@ function VulnerabilitiesContent() {
|
||||
<select
|
||||
value={sourceFilter}
|
||||
onChange={(e) => { setSourceFilter(e.target.value as any); setCurrentPage(1); }}
|
||||
className="border border-gray-300 rounded-md text-xs font-mono py-2 px-2 focus:ring-vulncheck-blue focus:border-vulncheck-blue"
|
||||
className="border border-gray-300 rounded-md text-xs font-mono py-2 px-2 focus:ring-truevuln-blue focus:border-truevuln-blue"
|
||||
title="Which scanner reported this CVE"
|
||||
>
|
||||
<option value="all">all scanners</option>
|
||||
<option value="wazuh">Wazuh only</option>
|
||||
<option value="nessus">Nessus only</option>
|
||||
<option value="app-scan">App scan only</option>
|
||||
<option value="manual">manual</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -943,20 +1156,22 @@ function VulnerabilitiesContent() {
|
||||
value={epssMin}
|
||||
onChange={(e) => { setEpssMin(e.target.value); setCurrentPage(1); }}
|
||||
placeholder="50"
|
||||
className="w-20 border border-gray-300 rounded-md text-sm font-mono py-2 px-2 focus:ring-vulncheck-blue focus:border-vulncheck-blue"
|
||||
className="w-20 border border-gray-300 rounded-md text-sm font-mono py-2 px-2 focus:ring-truevuln-blue focus:border-truevuln-blue"
|
||||
/>
|
||||
<span className="text-xs text-gray-500 font-mono">%</span>
|
||||
</div>
|
||||
|
||||
{/* Manual Bulk Enrich */}
|
||||
{currentUser?.role !== 'readonly' && (
|
||||
<button
|
||||
onClick={handleEnrichAll}
|
||||
disabled={isEnriching || currentUser?.role === 'readonly'}
|
||||
className="inline-flex items-center gap-1 text-xs font-mono px-3 py-2 border border-vulncheck-blue text-vulncheck-blue rounded-md hover:bg-blue-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={isEnriching}
|
||||
className="inline-flex items-center gap-1 text-xs font-mono px-3 py-2 border border-truevuln-blue text-truevuln-blue rounded-md hover:bg-blue-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Run EPSS + CISA KEV enrichment for all open vulnerabilities"
|
||||
>
|
||||
{isEnriching ? 'Enriching…' : 'Refresh Threat Intel'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Clear Filters */}
|
||||
{hasFilters && (
|
||||
@@ -977,7 +1192,7 @@ function VulnerabilitiesContent() {
|
||||
'Run CVSS score correction from CISA Vulnrichment?\n\n' +
|
||||
'This downloads the full vulnrichment snapshot (~250 MB) ' +
|
||||
'and fixes placeholder 10.0 scores + severity mismatches. ' +
|
||||
'Runs in the background — you can keep using VulnCheck ' +
|
||||
'Runs in the background — you can keep using TrueVuln ' +
|
||||
'while it works. Progress shows below.'
|
||||
)) return;
|
||||
setIsCorrectingScores(true);
|
||||
@@ -1042,6 +1257,26 @@ function VulnerabilitiesContent() {
|
||||
{currentUser?.role !== 'readonly' && (
|
||||
<ExploitIntelButton onDone={fetchData} />
|
||||
)}
|
||||
|
||||
{/* Plan P — Microsoft 365 Apps CVE detection (page parse +
|
||||
build comparison; CVEs absent from NVD/Wazuh). */}
|
||||
{currentUser?.role !== 'readonly' && (
|
||||
<>
|
||||
<M365CheckButton onDone={fetchData} />
|
||||
<AppCveScanButton onDone={fetchData} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* On-demand CVE date backfill (cvelistV5 → NVD) — fixes
|
||||
the Newly-Published ordering without the nightly wait. */}
|
||||
{currentUser?.role !== 'readonly' && (
|
||||
<DateBackfillButton onDone={fetchData} />
|
||||
)}
|
||||
|
||||
{/* MSRC remediation enrichment (Windows + MS products) */}
|
||||
{currentUser?.role !== 'readonly' && (
|
||||
<MsrcRefreshButton />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Active Search Tags */}
|
||||
@@ -1089,7 +1324,7 @@ function VulnerabilitiesContent() {
|
||||
<button
|
||||
onClick={() => scrollTable('left')}
|
||||
disabled={!scrollState.canLeft}
|
||||
className="p-1 rounded border border-gray-300 bg-white hover:bg-vulncheck-blue hover:text-white hover:border-vulncheck-blue disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
className="p-1 rounded border border-gray-300 bg-white hover:bg-truevuln-blue hover:text-white hover:border-truevuln-blue disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
title="Scroll left"
|
||||
aria-label="Scroll left"
|
||||
>
|
||||
@@ -1098,7 +1333,7 @@ function VulnerabilitiesContent() {
|
||||
<button
|
||||
onClick={() => scrollTable('right')}
|
||||
disabled={!scrollState.canRight}
|
||||
className="p-1 rounded border border-gray-300 bg-white hover:bg-vulncheck-blue hover:text-white hover:border-vulncheck-blue disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
className="p-1 rounded border border-gray-300 bg-white hover:bg-truevuln-blue hover:text-white hover:border-truevuln-blue disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
title="Scroll right"
|
||||
aria-label="Scroll right"
|
||||
>
|
||||
@@ -1116,7 +1351,7 @@ function VulnerabilitiesContent() {
|
||||
<th className="px-4 py-3 text-left">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-gray-300 text-vulncheck-blue focus:ring-vulncheck-blue"
|
||||
className="rounded border-gray-300 text-truevuln-blue focus:ring-truevuln-blue"
|
||||
checked={vulnerabilities.length > 0 && selectedIds.length === vulnerabilities.length}
|
||||
onChange={handleSelectAll}
|
||||
disabled={currentUser?.role === 'readonly'}
|
||||
@@ -1196,7 +1431,9 @@ function VulnerabilitiesContent() {
|
||||
Source<SortArrow column="source" />
|
||||
</th>
|
||||
<th scope="col" className="px-4 py-3 text-left text-xs font-mono font-medium text-gray-500 uppercase tracking-wider">Assigned To</th>
|
||||
{currentUser?.role !== 'readonly' && (
|
||||
<th scope="col" className="sticky right-0 z-10 bg-gray-50 px-4 py-3 text-right text-xs font-mono font-medium text-gray-500 uppercase tracking-wider shadow-[-4px_0_8px_-2px_rgba(0,0,0,0.1)] border-l border-gray-200">Actions</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200 font-mono text-sm">
|
||||
@@ -1205,7 +1442,7 @@ function VulnerabilitiesContent() {
|
||||
<td className="px-4 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-gray-200 text-vulncheck-blue focus:ring-vulncheck-blue"
|
||||
className="rounded border-gray-200 text-truevuln-blue focus:ring-truevuln-blue"
|
||||
checked={selectedIds.includes(vuln.id)}
|
||||
onChange={() => toggleSelect(vuln.id)}
|
||||
disabled={currentUser?.role === 'readonly'}
|
||||
@@ -1214,7 +1451,7 @@ function VulnerabilitiesContent() {
|
||||
<td className="px-4 py-3 whitespace-nowrap">
|
||||
<a
|
||||
href={`/vulnerabilities/${vuln.id}`}
|
||||
className="font-bold text-vulncheck-blue hover:text-blue-800 hover:underline cursor-pointer"
|
||||
className="font-bold text-truevuln-blue hover:text-blue-800 hover:underline cursor-pointer"
|
||||
>
|
||||
{vuln.cve_id}
|
||||
</a>
|
||||
@@ -1222,7 +1459,7 @@ function VulnerabilitiesContent() {
|
||||
<td className="px-4 py-3 whitespace-nowrap text-gray-700">
|
||||
<a
|
||||
href={`/vulnerabilities?asset_id=${vuln.asset_id}`}
|
||||
className="hover:text-vulncheck-blue hover:underline"
|
||||
className="hover:text-truevuln-blue hover:underline"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setSelectedAssetId(String(vuln.asset_id));
|
||||
@@ -1535,6 +1772,12 @@ function VulnerabilitiesContent() {
|
||||
? 'bg-green-100 text-green-700 border-green-200'
|
||||
: src === 'nessus'
|
||||
? 'bg-purple-100 text-purple-700 border-purple-200'
|
||||
: src === 'intune'
|
||||
? 'bg-blue-100 text-blue-700 border-blue-200'
|
||||
: src === 'defender'
|
||||
? 'bg-cyan-100 text-cyan-700 border-cyan-200'
|
||||
: src === 'app-scan'
|
||||
? 'bg-orange-100 text-orange-700 border-orange-200'
|
||||
: 'bg-gray-100 text-gray-600 border-gray-200';
|
||||
// Italic + lower opacity when source is the fallback (no scanner sees it now)
|
||||
const faded = live.length === 0 ? ' opacity-60 italic' : '';
|
||||
@@ -1563,14 +1806,22 @@ function VulnerabilitiesContent() {
|
||||
<td className="px-4 py-3 whitespace-nowrap">
|
||||
<div className="flex items-center gap-2">
|
||||
{vuln.assigned_group_id ? (
|
||||
<UserGroupIcon className={`h-5 w-5 text-vulncheck-blue`} />
|
||||
<UserGroupIcon className={`h-5 w-5 text-truevuln-blue`} />
|
||||
) : (
|
||||
<UserCircleIcon className={`h-5 w-5 ${vuln.assigned_user_id ? 'text-vulncheck-blue' : 'text-gray-300'}`} />
|
||||
<UserCircleIcon className={`h-5 w-5 ${vuln.assigned_user_id ? 'text-truevuln-blue' : 'text-gray-300'}`} />
|
||||
)}
|
||||
{currentUser?.role === 'readonly' ? (
|
||||
// Read-only: show the actual assignee (from the payload — the
|
||||
// /auth/users list is admin-only, so a disabled <select> would
|
||||
// wrongly read "Unassigned"). No control, just the name.
|
||||
<span className="text-xs text-gray-700 truncate" style={{ maxWidth: '120px' }}>
|
||||
{vuln.assigned_group_name || vuln.assigned_user_name || <span className="text-gray-400">Unassigned</span>}
|
||||
</span>
|
||||
) : (
|
||||
<select
|
||||
value={vuln.assigned_group_id ? `group_${vuln.assigned_group_id}` : (vuln.assigned_user_id ? String(vuln.assigned_user_id) : "")}
|
||||
onChange={(e) => handleAssign(vuln.id, e.target.value)}
|
||||
className="block w-full rounded-md border-0 py-1.5 pl-2 pr-7 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-vulncheck-blue sm:text-xs sm:leading-6 bg-transparent"
|
||||
className="block w-full rounded-md border-0 py-1.5 pl-2 pr-7 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-truevuln-blue sm:text-xs sm:leading-6 bg-transparent"
|
||||
style={{ maxWidth: '120px' }}
|
||||
>
|
||||
<option value="">Unassigned</option>
|
||||
@@ -1585,15 +1836,17 @@ function VulnerabilitiesContent() {
|
||||
))}
|
||||
</optgroup>
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
{currentUser?.role !== 'readonly' && (
|
||||
<td
|
||||
className={`sticky right-0 z-10 px-4 py-3 whitespace-nowrap text-right shadow-[-4px_0_8px_-2px_rgba(0,0,0,0.1)] border-l border-gray-100 ${selectedIds.includes(vuln.id) ? 'bg-blue-50' : 'bg-white group-hover:bg-blue-50/30'}`}
|
||||
>
|
||||
<div className="flex justify-end gap-1">
|
||||
<button
|
||||
onClick={() => handleOpenStatusModal(vuln)}
|
||||
className="p-1 hover:bg-gray-100 rounded text-gray-400 hover:text-vulncheck-blue"
|
||||
className="p-1 hover:bg-gray-100 rounded text-gray-400 hover:text-truevuln-blue"
|
||||
title="Change Status & Comment"
|
||||
>
|
||||
<PencilSquareIcon className="h-4 w-4" />
|
||||
@@ -1614,10 +1867,11 @@ function VulnerabilitiesContent() {
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
{vulnerabilities.length === 0 && !loading && (
|
||||
<tr><td colSpan={6} className="text-center py-8 text-gray-500">No vulnerabilities found.</td></tr>
|
||||
<tr><td colSpan={currentUser?.role !== 'readonly' ? 6 : 5} className="text-center py-8 text-gray-500">No vulnerabilities found.</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -1658,7 +1912,7 @@ function VulnerabilitiesContent() {
|
||||
onClick={() => setCurrentPage(page)}
|
||||
className={`px-3 py-1.5 text-sm font-mono rounded-md ${
|
||||
currentPage === page
|
||||
? 'bg-vulncheck-blue text-white font-bold'
|
||||
? 'bg-truevuln-blue text-white font-bold'
|
||||
: 'border border-gray-300 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
@@ -1683,13 +1937,13 @@ function VulnerabilitiesContent() {
|
||||
{/* Status & Change Modal */}
|
||||
{isStatusModalOpen && statusTarget && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-gray-500/75 backdrop-blur-sm">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-lg w-full p-8 border border-gray-100 border-t-8 border-t-vulncheck-blue">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-lg w-full p-8 border border-gray-100 border-t-8 border-t-truevuln-blue">
|
||||
<h3 className="text-xl font-bold mb-2 font-mono flex items-center gap-2">
|
||||
<ShieldExclamationIcon className="h-6 w-6 text-vulncheck-blue" />
|
||||
<ShieldExclamationIcon className="h-6 w-6 text-truevuln-blue" />
|
||||
Change Status
|
||||
</h3>
|
||||
<p className="text-gray-500 text-sm mb-6 font-mono">
|
||||
Vulnerability: <span className="text-vulncheck-blue font-bold">{statusTarget.cve_id}</span>
|
||||
Vulnerability: <span className="text-truevuln-blue font-bold">{statusTarget.cve_id}</span>
|
||||
</p>
|
||||
|
||||
<div className="space-y-6">
|
||||
@@ -1698,7 +1952,7 @@ function VulnerabilitiesContent() {
|
||||
<select
|
||||
value={newStatus}
|
||||
onChange={(e) => setNewStatus(e.target.value)}
|
||||
className="w-full bg-gray-50 border border-gray-200 rounded-md py-2 px-3 focus:outline-none focus:ring-2 focus:ring-vulncheck-blue font-mono"
|
||||
className="w-full bg-gray-50 border border-gray-200 rounded-md py-2 px-3 focus:outline-none focus:ring-2 focus:ring-truevuln-blue font-mono"
|
||||
>
|
||||
<option value="open">OPEN</option>
|
||||
<option value="patched">PATCHED (Triggers Rescan)</option>
|
||||
@@ -1715,7 +1969,7 @@ function VulnerabilitiesContent() {
|
||||
type="date"
|
||||
value={deferDate}
|
||||
onChange={(e) => setDeferDate(e.target.value)}
|
||||
className="w-full bg-gray-50 border border-gray-200 rounded-md py-2 px-3 focus:outline-none focus:ring-2 focus:ring-vulncheck-blue font-mono"
|
||||
className="w-full bg-gray-50 border border-gray-200 rounded-md py-2 px-3 focus:outline-none focus:ring-2 focus:ring-truevuln-blue font-mono"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -1727,7 +1981,7 @@ function VulnerabilitiesContent() {
|
||||
value={statusComment}
|
||||
onChange={(e) => setStatusComment(e.target.value)}
|
||||
placeholder="Add a reason for this status change..."
|
||||
className="w-full bg-gray-50 border border-gray-200 rounded-md py-2 px-3 focus:outline-none focus:ring-2 focus:ring-vulncheck-blue font-mono text-sm"
|
||||
className="w-full bg-gray-50 border border-gray-200 rounded-md py-2 px-3 focus:outline-none focus:ring-2 focus:ring-truevuln-blue font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1746,7 +2000,7 @@ function VulnerabilitiesContent() {
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirmStatusChange}
|
||||
className="flex-1 bg-vulncheck-blue text-white font-bold font-mono rounded-md hover:bg-blue-600 shadow-md shadow-blue-500/20 transition-all uppercase text-sm tracking-wider"
|
||||
className="flex-1 bg-truevuln-blue text-white font-bold font-mono rounded-md hover:bg-blue-600 shadow-md shadow-blue-500/20 transition-all uppercase text-sm tracking-wider"
|
||||
>
|
||||
Update Status
|
||||
</button>
|
||||
@@ -1761,7 +2015,7 @@ function VulnerabilitiesContent() {
|
||||
<div className="fixed bottom-8 left-1/2 -translate-x-1/2 z-40 animate-in fade-in slide-in-from-bottom-4 duration-300">
|
||||
<div className="bg-white rounded-2xl shadow-2xl border border-blue-100 p-3 flex items-center gap-6 px-6">
|
||||
<div className="flex items-center gap-3 pr-6 border-r border-gray-100">
|
||||
<div className="bg-vulncheck-blue text-white w-8 h-8 rounded-full flex items-center justify-center font-bold font-mono text-sm leading-none shadow-lg shadow-blue-500/30">
|
||||
<div className="bg-truevuln-blue text-white w-8 h-8 rounded-full flex items-center justify-center font-bold font-mono text-sm leading-none shadow-lg shadow-blue-500/30">
|
||||
{selectedIds.length}
|
||||
</div>
|
||||
<span className="text-gray-600 font-mono text-sm font-bold uppercase tracking-wider">Selected</span>
|
||||
@@ -1771,7 +2025,7 @@ function VulnerabilitiesContent() {
|
||||
<div className="flex items-center gap-2">
|
||||
<UserCircleIcon className="h-5 w-5 text-gray-400" />
|
||||
<select
|
||||
className="text-sm font-mono bg-gray-50 border-gray-100 rounded-lg px-3 py-1.5 focus:ring-2 focus:ring-vulncheck-blue outline-none border transition-all"
|
||||
className="text-sm font-mono bg-gray-50 border-gray-100 rounded-lg px-3 py-1.5 focus:ring-2 focus:ring-truevuln-blue outline-none border transition-all"
|
||||
onChange={(e) => setBulkActionTarget({ type: 'user', id: parseInt(e.target.value) })}
|
||||
value={bulkActionTarget?.type === 'user' ? bulkActionTarget.id : ""}
|
||||
>
|
||||
@@ -1786,7 +2040,7 @@ function VulnerabilitiesContent() {
|
||||
<div className="flex items-center gap-2">
|
||||
<UserGroupIcon className="h-5 w-5 text-gray-400" />
|
||||
<select
|
||||
className="text-sm font-mono bg-gray-50 border-gray-100 rounded-lg px-3 py-1.5 focus:ring-2 focus:ring-vulncheck-blue outline-none border transition-all"
|
||||
className="text-sm font-mono bg-gray-50 border-gray-100 rounded-lg px-3 py-1.5 focus:ring-2 focus:ring-truevuln-blue outline-none border transition-all"
|
||||
onChange={(e) => setBulkActionTarget({ type: 'group', id: parseInt(e.target.value) })}
|
||||
value={bulkActionTarget?.type === 'group' ? bulkActionTarget.id : ""}
|
||||
>
|
||||
@@ -1801,7 +2055,7 @@ function VulnerabilitiesContent() {
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldExclamationIcon className="h-5 w-5 text-gray-400" />
|
||||
<select
|
||||
className="text-sm font-mono bg-gray-50 border-gray-100 rounded-lg px-3 py-1.5 focus:ring-2 focus:ring-vulncheck-blue outline-none border transition-all"
|
||||
className="text-sm font-mono bg-gray-50 border-gray-100 rounded-lg px-3 py-1.5 focus:ring-2 focus:ring-truevuln-blue outline-none border transition-all"
|
||||
onChange={(e) => setBulkActionTarget({ type: 'status', value: e.target.value })}
|
||||
value={bulkActionTarget?.type === 'status' ? bulkActionTarget.value : ""}
|
||||
>
|
||||
@@ -1820,7 +2074,7 @@ function VulnerabilitiesContent() {
|
||||
title={currentUser?.role === 'readonly' ? "Read-Only User" : ""}
|
||||
className={`px-4 py-1.5 rounded-lg font-mono text-sm font-bold uppercase tracking-wider transition-all
|
||||
${bulkActionTarget && !isBulkUpdating && currentUser?.role !== 'readonly'
|
||||
? 'bg-vulncheck-blue text-white shadow-lg shadow-blue-500/30 hover:bg-blue-600'
|
||||
? 'bg-truevuln-blue text-white shadow-lg shadow-blue-500/30 hover:bg-blue-600'
|
||||
: 'bg-gray-100 text-gray-400 cursor-not-allowed'}`}
|
||||
>
|
||||
{isBulkUpdating ? 'Applying...' : 'Apply'}
|
||||
|
||||
@@ -164,7 +164,7 @@ export default function MfaCard() {
|
||||
) : (
|
||||
<button
|
||||
onClick={() => { reset(); setStage("setup"); }}
|
||||
className="rounded-md bg-vulncheck-blue px-3 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-600 font-mono"
|
||||
className="rounded-md bg-truevuln-blue px-3 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-600 font-mono"
|
||||
>
|
||||
Enable MFA
|
||||
</button>
|
||||
@@ -186,11 +186,11 @@ export default function MfaCard() {
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
className="block w-full rounded-md border-0 py-1.5 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-vulncheck-blue sm:text-sm font-mono"
|
||||
className="block w-full rounded-md border-0 py-1.5 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-truevuln-blue sm:text-sm font-mono"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={reset} className="rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50 font-mono">Cancel</button>
|
||||
<button type="submit" disabled={busy} className="rounded-md bg-vulncheck-blue px-3 py-1.5 text-sm font-semibold text-white hover:bg-blue-600 disabled:opacity-60 font-mono">
|
||||
<button type="submit" disabled={busy} className="rounded-md bg-truevuln-blue px-3 py-1.5 text-sm font-semibold text-white hover:bg-blue-600 disabled:opacity-60 font-mono">
|
||||
{busy ? "Generating…" : "Continue"}
|
||||
</button>
|
||||
</div>
|
||||
@@ -235,13 +235,13 @@ export default function MfaCard() {
|
||||
autoComplete="one-time-code"
|
||||
autoFocus
|
||||
placeholder="000000"
|
||||
className="w-40 text-center text-xl tracking-[0.5em] font-mono rounded-md border-0 py-2 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-vulncheck-blue"
|
||||
className="w-40 text-center text-xl tracking-[0.5em] font-mono rounded-md border-0 py-2 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-truevuln-blue"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={reset} className="rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50 font-mono">Cancel</button>
|
||||
<button type="submit" disabled={busy || code.length !== 6} className="rounded-md bg-vulncheck-blue px-3 py-1.5 text-sm font-semibold text-white hover:bg-blue-600 disabled:opacity-60 font-mono">
|
||||
<button type="submit" disabled={busy || code.length !== 6} className="rounded-md bg-truevuln-blue px-3 py-1.5 text-sm font-semibold text-white hover:bg-blue-600 disabled:opacity-60 font-mono">
|
||||
{busy ? "Activating…" : "Activate MFA"}
|
||||
</button>
|
||||
</div>
|
||||
@@ -262,7 +262,7 @@ export default function MfaCard() {
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
className="block w-full rounded-md border-0 py-1.5 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-vulncheck-blue sm:text-sm font-mono"
|
||||
className="block w-full rounded-md border-0 py-1.5 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-truevuln-blue sm:text-sm font-mono"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={reset} className="rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50 font-mono">Cancel</button>
|
||||
|
||||
@@ -73,8 +73,8 @@ export default function AppShell({ children }: { children: React.ReactNode }) {
|
||||
<Bars3Icon className="h-6 w-6" aria-hidden="true" />
|
||||
</button>
|
||||
<div className="flex flex-1 items-center gap-2">
|
||||
<img src="/logo.svg" alt="VulnCheck Logo" className="h-8 w-8" />
|
||||
<div className="text-sm font-bold leading-6 text-gray-900 font-mono tracking-tighter">Vuln<span className="text-vulncheck-blue">Check</span></div>
|
||||
<img src="/logo.svg" alt="TrueVuln Logo" className="h-8 w-8" />
|
||||
<div className="text-sm font-bold leading-6 text-gray-900 font-mono tracking-tighter">True<span className="text-truevuln-blue">Vuln</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -54,15 +54,15 @@ export default function Drawer({ sidebarOpen, setSidebarOpen, user }: DrawerProp
|
||||
<Dialog.Panel className="relative mr-16 flex w-full max-w-xs flex-1">
|
||||
<div className="flex grow flex-col gap-y-5 overflow-y-auto bg-gray-900 px-6 pb-4 ring-1 ring-white/10">
|
||||
<div className="flex h-16 shrink-0 items-center gap-3">
|
||||
<img src="/logo.svg" alt="VulnCheck Logo" className="h-10 w-10" />
|
||||
<span className="text-white font-extrabold text-xl font-mono tracking-tight">Vuln<span className="text-vulncheck-blue">Check</span></span>
|
||||
<img src="/logo.svg" alt="TrueVuln Logo" className="h-10 w-10" />
|
||||
<span className="text-white font-extrabold text-xl font-mono tracking-tight">True<span className="text-truevuln-blue">Vuln</span></span>
|
||||
</div>
|
||||
<Navigation />
|
||||
<div className="mt-auto pb-4">
|
||||
{user && (
|
||||
<div className="mb-4 px-2 py-3 bg-gray-800/50 rounded-lg border border-gray-700/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-8 w-8 rounded-full bg-vulncheck-blue/20 flex items-center justify-center border border-vulncheck-blue/30 text-vulncheck-blue font-bold font-mono">
|
||||
<div className="h-8 w-8 rounded-full bg-truevuln-blue/20 flex items-center justify-center border border-truevuln-blue/30 text-truevuln-blue font-bold font-mono">
|
||||
{user.username.substring(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<div className="flex flex-col overflow-hidden">
|
||||
@@ -91,9 +91,9 @@ export default function Drawer({ sidebarOpen, setSidebarOpen, user }: DrawerProp
|
||||
<div className="hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:w-72 lg:flex-col">
|
||||
<div className="flex grow flex-col gap-y-5 bg-gray-900 px-6 pb-4 border-r border-white/5">
|
||||
<div className="flex h-20 shrink-0 items-center gap-3">
|
||||
<img src="/logo.svg" alt="VulnCheck Logo" className="h-12 w-12" />
|
||||
<img src="/logo.svg" alt="TrueVuln Logo" className="h-12 w-12" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-white font-extrabold text-2xl font-mono tracking-tighter leading-none">Vuln<span className="text-vulncheck-blue">Check</span></span>
|
||||
<span className="text-white font-extrabold text-2xl font-mono tracking-tighter leading-none">True<span className="text-truevuln-blue">Vuln</span></span>
|
||||
<span className="text-[10px] text-gray-500 font-mono tracking-widest uppercase mt-1">Enterprise Security</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -102,7 +102,7 @@ export default function Drawer({ sidebarOpen, setSidebarOpen, user }: DrawerProp
|
||||
{user && (
|
||||
<div className="mb-4 px-2 py-3 bg-gray-800/50 rounded-lg border border-gray-700/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-8 w-8 rounded-full bg-vulncheck-blue/20 flex items-center justify-center border border-vulncheck-blue/30 text-vulncheck-blue font-bold font-mono">
|
||||
<div className="h-8 w-8 rounded-full bg-truevuln-blue/20 flex items-center justify-center border border-truevuln-blue/30 text-truevuln-blue font-bold font-mono">
|
||||
{user.username.substring(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<div className="flex flex-col overflow-hidden">
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
EyeIcon,
|
||||
KeyIcon,
|
||||
CheckBadgeIcon,
|
||||
BellAlertIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
@@ -33,6 +34,7 @@ type NavItem = {
|
||||
const navigation: NavItem[] = [
|
||||
{ name: 'Dashboard', href: '/', icon: Square2StackIcon },
|
||||
{ name: 'Vulnerabilities', href: '/vulnerabilities', icon: ShieldCheckIcon },
|
||||
{ name: 'Advisories', href: '/advisories', icon: BellAlertIcon },
|
||||
{ name: 'Assets', href: '/assets', icon: ServerIcon },
|
||||
{ name: 'Compliance', href: '/compliance', icon: CheckBadgeIcon },
|
||||
{ name: 'Scan Jobs', href: '/scans', icon: CpuChipIcon, requires: 'editor' },
|
||||
@@ -90,13 +92,13 @@ export default function Navigation() {
|
||||
href={item.href}
|
||||
className={clsx(
|
||||
isActive
|
||||
? 'bg-gray-800 text-vulncheck-blue group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold'
|
||||
? 'bg-gray-800 text-truevuln-blue group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold'
|
||||
: 'text-gray-400 hover:text-white hover:bg-gray-800 group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold'
|
||||
)}
|
||||
>
|
||||
<item.icon
|
||||
className={clsx(
|
||||
isActive ? 'text-vulncheck-blue' : 'text-gray-400 group-hover:text-white',
|
||||
isActive ? 'text-truevuln-blue' : 'text-gray-400 group-hover:text-white',
|
||||
'h-6 w-6 shrink-0'
|
||||
)}
|
||||
aria-hidden="true"
|
||||
|
||||
@@ -45,7 +45,7 @@ export default function SearchableSelect({
|
||||
<div className={twMerge("w-full relative", className)}>
|
||||
<Combobox value={selectedOption} onChange={(val) => onChange(val ? val.value : null)}>
|
||||
<div className="relative mt-1">
|
||||
<div className="relative w-full cursor-default overflow-hidden rounded-md bg-white text-left border border-gray-300 focus-within:ring-2 focus-within:ring-vulncheck-blue focus-within:border-vulncheck-blue sm:text-sm">
|
||||
<div className="relative w-full cursor-default overflow-hidden rounded-md bg-white text-left border border-gray-300 focus-within:ring-2 focus-within:ring-truevuln-blue focus-within:border-truevuln-blue sm:text-sm">
|
||||
<ComboboxInput
|
||||
className="w-full border-none py-2 pl-3 pr-10 text-sm leading-5 text-gray-900 focus:ring-0 font-mono placeholder:text-gray-400"
|
||||
displayValue={(option: SearchableSelectOption | null) => option ? option.label : ''}
|
||||
@@ -76,7 +76,7 @@ export default function SearchableSelect({
|
||||
<ComboboxOption
|
||||
key={option.value}
|
||||
className={({ active }) =>
|
||||
`relative cursor-default select-none py-2 pl-10 pr-4 font-mono ${active ? 'bg-vulncheck-blue text-white' : 'text-gray-900'
|
||||
`relative cursor-default select-none py-2 pl-10 pr-4 font-mono ${active ? 'bg-truevuln-blue text-white' : 'text-gray-900'
|
||||
}`
|
||||
}
|
||||
value={option}
|
||||
@@ -91,7 +91,7 @@ export default function SearchableSelect({
|
||||
</span>
|
||||
{selected ? (
|
||||
<span
|
||||
className={`absolute inset-y-0 left-0 flex items-center pl-3 ${active ? 'text-white' : 'text-vulncheck-blue'
|
||||
className={`absolute inset-y-0 left-0 flex items-center pl-3 ${active ? 'text-white' : 'text-truevuln-blue'
|
||||
}`}
|
||||
>
|
||||
<CheckIcon className="h-5 w-5" aria-hidden="true" />
|
||||
|
||||
@@ -64,6 +64,8 @@ export interface Vulnerability {
|
||||
packages?: VulnerabilityPackage[]; // per-package detail (CVE-2023-48795 hits PuTTY + WinSCP both)
|
||||
has_fix_any?: boolean; // at least one package has a real fix (not equal to installed)
|
||||
published_date: string;
|
||||
last_modified_date?: string | null;
|
||||
remediation?: string | null; // scanner-provided fix guidance (Nessus solution)
|
||||
detected_at?: string;
|
||||
asset_id: number;
|
||||
asset_hostname?: string;
|
||||
@@ -131,7 +133,8 @@ export interface Asset {
|
||||
last_scan?: string;
|
||||
wazuh_agent_id?: string | null;
|
||||
nessus_host_uuid?: string | null;
|
||||
source?: 'wazuh' | 'nessus' | 'manual';
|
||||
intune_device_id?: string | null;
|
||||
source?: 'wazuh' | 'nessus' | 'manual' | 'intune';
|
||||
status: 'active' | 'inactive' | 'decommissioned';
|
||||
policy_id?: number | null;
|
||||
description?: string;
|
||||
@@ -145,6 +148,8 @@ export interface Asset {
|
||||
// Network-exposure risk dimension (Wazuh syscollector ports)
|
||||
network_exposure_score?: number | null;
|
||||
exposed_services?: { port: number; proto: string; service: string; risk: number; local_ip?: string }[] | null;
|
||||
high_value_score?: number | null;
|
||||
risk_dimensions?: { role: string; label: string; weight: number }[] | null;
|
||||
exposure_updated_at?: string | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -61,3 +61,7 @@ itsdangerous==2.2.0
|
||||
# SAML 2.0 (Phase 4) — requires system libs xmlsec1, libxml2-dev, libxmlsec1-dev
|
||||
python3-saml==1.16.0
|
||||
lxml==5.3.0
|
||||
|
||||
# Microsoft product-lifecycle EOL export parsing (Plan O) — reads the
|
||||
# monthly eos-product-listing .xlsx from download.microsoft.com.
|
||||
openpyxl==3.1.5
|
||||
|
||||
Reference in New Issue
Block a user