Compare commits
249
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2474d2816f | ||
|
|
c042e9d549 | ||
|
|
faedd83ae9 | ||
|
|
816a684bf1 | ||
|
|
837f2fbc8a | ||
|
|
81f7bbdc60 | ||
|
|
fc0359acef | ||
|
|
47d588d734 | ||
|
|
1b5028fea6 | ||
|
|
396f47363f | ||
|
|
9b55d5e7af | ||
|
|
978437282e | ||
|
|
3384a1e918 | ||
|
|
3fd27d46e5 | ||
|
|
c88eb45733 | ||
|
|
9dc774e629 | ||
|
|
1224224516 | ||
|
|
f64902417e | ||
|
|
264d54c150 | ||
|
|
f0ae234b85 | ||
|
|
3e6c15cef6 | ||
|
|
2dc60becd6 | ||
|
|
5d01854130 | ||
|
|
93139610f6 | ||
|
|
29dcbaca5d | ||
|
|
c8c0ffa4ab | ||
|
|
45248001ec | ||
|
|
119e64fc05 | ||
|
|
42a3831abb | ||
|
|
fdec6a7446 | ||
|
|
b2431ddcea | ||
|
|
ad85ccd98d | ||
|
|
3f6a7dc3e5 | ||
|
|
ddf0a373df | ||
|
|
4879610dea | ||
|
|
793e840665 | ||
|
|
099bce723e | ||
|
|
e02d3035bb | ||
|
|
943569f12f | ||
|
|
5da1efd3fb | ||
|
|
77317a5435 | ||
|
|
378d5ccda9 | ||
|
|
3bf483a70a | ||
|
|
8257110764 | ||
|
|
1d1caf973b | ||
|
|
eb595b9e7b | ||
|
|
eca15f09be | ||
|
|
b5ade33835 | ||
|
|
ac9700b32c | ||
|
|
8ef4a5ce0d | ||
|
|
01cf91f764 | ||
|
|
6b3744e569 | ||
|
|
3f290ebbb4 | ||
|
|
72932a8f3e | ||
|
|
06715d2b15 | ||
|
|
d1badf70ed | ||
|
|
86da96fc06 | ||
|
|
1f0c141411 | ||
|
|
8ce476cd17 | ||
|
|
b0254bb497 | ||
|
|
f26f7baf94 | ||
|
|
e983b18d38 | ||
|
|
786071386f | ||
|
|
8c28167ebd | ||
|
|
206c97f14a | ||
|
|
d0c555d5ff | ||
|
|
5df35f43cf | ||
|
|
639fd00d30 | ||
|
|
03233d6ed1 | ||
|
|
dabec89021 | ||
|
|
da325bca77 | ||
|
|
3ad51a48d8 | ||
|
|
e4ae0df483 | ||
|
|
4dec4f70ee | ||
|
|
767c109816 | ||
|
|
24eebd3113 | ||
|
|
fbe107e28b | ||
|
|
d81a993bbe | ||
|
|
6ff2076164 | ||
|
|
75a9a5659f | ||
|
|
a74c229264 | ||
|
|
bf42e28b15 | ||
|
|
7851b31af7 | ||
|
|
1476318624 | ||
|
|
f7f627ee5c | ||
|
|
7941d0403f | ||
|
|
8d50547cbb | ||
|
|
3af68fcddc | ||
|
|
5233a65103 | ||
|
|
4ebb381db0 | ||
|
|
86159f44b1 | ||
|
|
c3cd62c461 | ||
|
|
6f9f2ae10b | ||
|
|
9007cdc03a | ||
|
|
56c0335839 | ||
|
|
32c064e3f2 | ||
|
|
97562abed0 | ||
|
|
8db635effa | ||
|
|
0ba07131cb | ||
|
|
cab46fd819 | ||
|
|
acec27367f | ||
|
|
7a220511b8 | ||
|
|
304900da1b | ||
|
|
3bb480c39f | ||
|
|
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
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
/cache
|
||||
/project.local.yml
|
||||
@@ -0,0 +1,167 @@
|
||||
# the name by which the project can be referenced within Serena/when chatting with the LLM.
|
||||
project_name: "vulnerability-dashboard"
|
||||
|
||||
# the encoding used by text files in the project
|
||||
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
|
||||
encoding: "utf-8"
|
||||
|
||||
# line ending convention to use when writing source files.
|
||||
# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
|
||||
# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
|
||||
line_ending:
|
||||
|
||||
# The language backend to use for this project.
|
||||
# If not set, the global setting from serena_config.yml is used.
|
||||
# Valid values: LSP, JetBrains
|
||||
# Note: the backend is fixed at startup. If a project with a different backend
|
||||
# is activated post-init, an error will be returned.
|
||||
language_backend:
|
||||
|
||||
# whether to use project's .gitignore files to ignore files
|
||||
ignore_all_files_in_gitignore: true
|
||||
|
||||
# advanced configuration option allowing to configure language server-specific options.
|
||||
# Maps the language key to the options.
|
||||
# The settings are considered only if the project is trusted (see global configuration to define trusted projects).
|
||||
# See https://oraios.github.io/serena/02-usage/050_configuration.html#language-server-specific-settings
|
||||
ls_specific_settings: {}
|
||||
|
||||
# list of additional paths to ignore in this project.
|
||||
# Same syntax as gitignore, so you can use * and **.
|
||||
# Important: quote patterns that start with `*`, otherwise YAML treats them as aliases.
|
||||
# Example:
|
||||
# ignored_paths:
|
||||
# - "examples/**"
|
||||
# - ".worktrees/**"
|
||||
# - "**/bin/**"
|
||||
# - "**/obj/**"
|
||||
# Note: global ignored_paths from serena_config.yml are also applied additively.
|
||||
ignored_paths: []
|
||||
|
||||
# whether the project is in read-only mode
|
||||
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
|
||||
# Added on 2025-04-18
|
||||
read_only: false
|
||||
|
||||
# list of tool names to exclude.
|
||||
# This extends the existing exclusions (e.g. from the global configuration)
|
||||
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
|
||||
excluded_tools: []
|
||||
|
||||
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default).
|
||||
# This extends the existing inclusions (e.g. from the global configuration).
|
||||
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
|
||||
included_optional_tools: []
|
||||
|
||||
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
|
||||
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
|
||||
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
|
||||
fixed_tools: []
|
||||
|
||||
# list of mode names that are to be activated by default, overriding the setting in the global configuration.
|
||||
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
|
||||
# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply.
|
||||
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
|
||||
# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply
|
||||
# for this project.
|
||||
# This setting can, in turn, be overridden by CLI parameters (--mode).
|
||||
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
|
||||
default_modes:
|
||||
|
||||
# list of mode names to be activated additionally for this project, e.g. ["query-projects"]
|
||||
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
|
||||
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
|
||||
added_modes:
|
||||
|
||||
# initial prompt for the project. It will always be given to the LLM upon activating the project
|
||||
# (contrary to the memories, which are loaded on demand).
|
||||
initial_prompt: ""
|
||||
|
||||
# time budget (seconds) per tool call for the retrieval of additional symbol information
|
||||
# such as docstrings or parameter information.
|
||||
# This overrides the corresponding setting in the global configuration; see the documentation there.
|
||||
# If null or missing, use the setting from the global configuration.
|
||||
symbol_info_budget:
|
||||
|
||||
# list of regex patterns which, when matched, mark a memory entry as read‑only.
|
||||
# Extends the list from the global configuration, merging the two lists.
|
||||
read_only_memory_patterns: []
|
||||
|
||||
# list of regex patterns for memories to completely ignore.
|
||||
# Matching memories will not appear in list_memories or activate_project output
|
||||
# and cannot be accessed via read_memory or write_memory.
|
||||
# To access ignored memory files, use the read_file tool on the raw file path.
|
||||
# Extends the list from the global configuration, merging the two lists.
|
||||
# Example: ["_archive/.*", "_episodes/.*"]
|
||||
ignored_memory_patterns: []
|
||||
|
||||
# optional shell command to run before the language backend (LSP or JetBrains) is initialised.
|
||||
# the command runs in the project root directory and is only executed if the project is trusted
|
||||
# (see trusted_project_path_patterns in the global configuration).
|
||||
# serena waits for the command to exit: a non-zero exit code is logged as an error but does not
|
||||
# abort activation. a per-project timeout (activation_command_timeout, default 180s) is the safety
|
||||
# backstop for non-terminating commands; on expiry the process is killed and activation continues.
|
||||
# example: activation_command: "npx nx run-many -t build"
|
||||
activation_command:
|
||||
|
||||
# maximum time in seconds to wait for activation_command to complete before killing it (default 180s).
|
||||
# must be a positive number.
|
||||
activation_command_timeout: 180.0
|
||||
|
||||
# list of additional workspace folder paths for cross-package reference support.
|
||||
# Paths can be absolute or relative to the project root.
|
||||
# Each folder is registered as an LSP workspace folder, enabling language servers to discover
|
||||
# symbols and references across package boundaries, but these folders are not indexed by Serena,
|
||||
# i.e. the respective symbols will not be found using Serena's symbol search tools.
|
||||
# Example:
|
||||
# additional_workspace_folders:
|
||||
# - ../sibling-package
|
||||
# - ../shared-lib
|
||||
ls_additional_workspace_folders: []
|
||||
|
||||
# list of workspace folder paths (LSP backend only).
|
||||
# These folders will be used to build up Serena's symbol index.
|
||||
# Paths must be within the project root and should thus be relative to the project root.
|
||||
# Furthermore, the paths should not be filtered by ignore settings.
|
||||
# Default setting: The entire project root folder (".") is considered.
|
||||
# In (large) monorepos, this can be used to index only subfolders of the project root, e.g.
|
||||
# ls_workspace_folders:
|
||||
# - "./subproject1"
|
||||
# - "./subproject2"
|
||||
ls_workspace_folders:
|
||||
- .
|
||||
|
||||
# list of language servers to start when using the LSP backend; choose from:
|
||||
# ada al angular ansible bash
|
||||
# bsl clojure cpp cpp_ccls crystal
|
||||
# csharp csharp_omnisharp cue dart elixir
|
||||
# elm erlang fortran fsharp gdscript
|
||||
# go groovy haskell haxe hlsl
|
||||
# html java json julia kotlin
|
||||
# latex lean4 lua luau markdown
|
||||
# matlab msl nix ocaml pascal
|
||||
# perl php php_phpactor php_phpantom powershell
|
||||
# python python_jedi python_pyrefly python_ty r
|
||||
# rego ruby ruby_solargraph rust scala
|
||||
# scss solidity svelte swift systemverilog
|
||||
# terraform toml typescript typescript_vts vue
|
||||
# yaml zig
|
||||
# (This list may be outdated; generated with scripts/print_language_list.py;
|
||||
# For the current list, see values of Language enum here:
|
||||
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py)
|
||||
# For some languages, there are several alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
|
||||
# Note:
|
||||
# - For C, use cpp
|
||||
# - For JavaScript, use typescript
|
||||
# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root)
|
||||
# - For Svelte projects, use svelte (subsumes typescript/javascript for .svelte projects; requires npm)
|
||||
# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three)
|
||||
# - For Free Pascal/Lazarus, use pascal
|
||||
# Special requirements:
|
||||
# Some language servers require additional setup/installations.
|
||||
# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
|
||||
# When using multiple language servers, the first language server that supports a given file will be used for that file.
|
||||
# The first language server is the default language and the respective language server will be used as a fallback.
|
||||
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
|
||||
language_servers:
|
||||
- python
|
||||
@@ -0,0 +1,44 @@
|
||||
<!-- gitnexus:start -->
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
This project is indexed by GitNexus as **vulncheck** (4279 symbols, 12312 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
|
||||
> Index stale? Run `node .gitnexus/run.cjs analyze` from the project root — it auto-selects an available runner. No `.gitnexus/run.cjs` yet? `npx gitnexus analyze` (npm 11 crash → `npm i -g gitnexus`; #1939).
|
||||
|
||||
## Always Do
|
||||
|
||||
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
|
||||
- **MUST run `detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows. For regression review, compare against the default branch: `detect_changes({scope: "compare", base_ref: "main"})`.
|
||||
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
|
||||
- When exploring unfamiliar code, use `query({search_query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
|
||||
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `context({name: "symbolName"})`.
|
||||
- For security review, `explain({target: "fileOrSymbol"})` lists taint findings (source→sink flows; needs `analyze --pdg`).
|
||||
|
||||
## Never Do
|
||||
|
||||
- NEVER edit a function, class, or method without first running `impact` on it.
|
||||
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
|
||||
- NEVER rename symbols with find-and-replace — use `rename` which understands the call graph.
|
||||
- NEVER commit changes without running `detect_changes()` to check affected scope.
|
||||
|
||||
## Resources
|
||||
|
||||
| Resource | Use for |
|
||||
|----------|---------|
|
||||
| `gitnexus://repo/vulncheck/context` | Codebase overview, check index freshness |
|
||||
| `gitnexus://repo/vulncheck/clusters` | All functional areas |
|
||||
| `gitnexus://repo/vulncheck/processes` | All execution flows |
|
||||
| `gitnexus://repo/vulncheck/process/{name}` | Step-by-step execution trace |
|
||||
|
||||
## CLI
|
||||
|
||||
| Task | Read this skill file |
|
||||
|------|---------------------|
|
||||
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
|
||||
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
|
||||
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
|
||||
| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
|
||||
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
|
||||
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
|
||||
|
||||
<!-- gitnexus:end -->
|
||||
+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`.
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<!-- gitnexus:start -->
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
This project is indexed by GitNexus as **vulncheck** (4279 symbols, 12312 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
|
||||
> Index stale? Run `node .gitnexus/run.cjs analyze` from the project root — it auto-selects an available runner. No `.gitnexus/run.cjs` yet? `npx gitnexus analyze` (npm 11 crash → `npm i -g gitnexus`; #1939).
|
||||
|
||||
## Always Do
|
||||
|
||||
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
|
||||
- **MUST run `detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows. For regression review, compare against the default branch: `detect_changes({scope: "compare", base_ref: "main"})`.
|
||||
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
|
||||
- When exploring unfamiliar code, use `query({search_query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
|
||||
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `context({name: "symbolName"})`.
|
||||
- For security review, `explain({target: "fileOrSymbol"})` lists taint findings (source→sink flows; needs `analyze --pdg`).
|
||||
|
||||
## Never Do
|
||||
|
||||
- NEVER edit a function, class, or method without first running `impact` on it.
|
||||
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
|
||||
- NEVER rename symbols with find-and-replace — use `rename` which understands the call graph.
|
||||
- NEVER commit changes without running `detect_changes()` to check affected scope.
|
||||
|
||||
## Resources
|
||||
|
||||
| Resource | Use for |
|
||||
|----------|---------|
|
||||
| `gitnexus://repo/vulncheck/context` | Codebase overview, check index freshness |
|
||||
| `gitnexus://repo/vulncheck/clusters` | All functional areas |
|
||||
| `gitnexus://repo/vulncheck/processes` | All execution flows |
|
||||
| `gitnexus://repo/vulncheck/process/{name}` | Step-by-step execution trace |
|
||||
|
||||
## CLI
|
||||
|
||||
| Task | Read this skill file |
|
||||
|------|---------------------|
|
||||
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
|
||||
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
|
||||
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
|
||||
| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
|
||||
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
|
||||
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
|
||||
|
||||
<!-- gitnexus:end -->
|
||||
+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
|
||||
|
||||
+335
-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)
|
||||
@@ -1193,3 +1405,96 @@ GROUP BY severity ORDER BY severity;"
|
||||
- LDAP referral chasing across multiple forests
|
||||
- Encrypted SAML assertions (currently only signed)
|
||||
- OIDC back-channel logout
|
||||
|
||||
|
||||
---
|
||||
|
||||
# Data sources added for CVEs that never reach NVD / cvelistV5
|
||||
|
||||
Three upstreams publish CVEs that the usual feeds do not carry. Each is
|
||||
version-range checked like any other source, so a patched host is never flagged.
|
||||
|
||||
| Source | Covers | Why it is needed |
|
||||
|---|---|---|
|
||||
| **MSRC CVRF** (`msrc_scan_service`) | Windows Server, SharePoint, **Microsoft Edge** | Microsoft ships no version ranges; the CVRF's `FixedBuild` is the only machine-readable "which build carries the fix". Edge CVEs are absent from NVD *and* cvelistV5 entirely. |
|
||||
| **GitHub repository advisories** (`github_repo_advisory_service`) | projects that self-publish, e.g. Notepad++ | The per-repo endpoint (unlike the global `/advisories`) carries `vulnerable_version_range` + `patched_versions`. |
|
||||
| **Mozilla MFSA** (`mozilla_advisory_service`) | Firefox severity | Mozilla rates impact before NVD/cvelistV5 have a score. |
|
||||
|
||||
Notes:
|
||||
- **Edge is never scored off Chrome data.** Edge 150.0.4078.99 rides on Chromium
|
||||
150.0.7871.187 — the schemes are unrelated, so a `google:chrome` range says
|
||||
nothing about an Edge build. WebView2 is excluded for the same reason (own
|
||||
package, own version).
|
||||
- The MSRC index is cached under `msrc_product_index_v2`. **Bump that key
|
||||
whenever `_PRODUCTS` changes** — a cache built by an older version simply has
|
||||
no entry for the new product, and `load_index(allow_stale=True)` will serve it
|
||||
anyway. The app scan rebuilds the index on demand when it is missing.
|
||||
- Repo-advisory version ranges are maintainer free-text (`<= v8.9.6.4`,
|
||||
`old versions - 8.8.1`, `v8.9.4 & v8.9.5`, bare versions). `patched_versions`
|
||||
is the hard safety net: at/past the patched release is never flagged, and an
|
||||
explicit version list matches exactly, never "everything below".
|
||||
|
||||
## One finding, several affected products
|
||||
|
||||
A finding is unique per `(cve_id, asset_id)` (index `uq_vuln_cve_asset`), so a
|
||||
CVE hitting two products on one host — CVE-2026-16417 affects Chrome **and**
|
||||
Edge — is ONE row carrying BOTH products, not two rows (splitting would break
|
||||
dedup, source reconciliation and every count). The per-product detail lives in
|
||||
`vulnerability_packages` and is written via
|
||||
`audit_events.record_affected_package()`; the API returns it as `packages` and
|
||||
the CVE detail page renders one card per entry.
|
||||
|
||||
The helper is idempotent per `(finding, package name)` and must never break a
|
||||
scan: a plain query does not see rows added earlier in the same unflushed
|
||||
transaction, so it also scans `db.new`, inserts inside a savepoint, and swallows
|
||||
what is left.
|
||||
|
||||
## Cross-source contract (do not weaken it)
|
||||
|
||||
Every scanner retracts **only its own source**; a finding closes when no source
|
||||
is left. An inventory-based override that closed over a lone Wazuh claim was
|
||||
removed after producing false negatives four different ways: multi-stream fixes
|
||||
(MySQL 8.0.23 vs a stored 7.6.34), 2.x minor servicing lines (Git 2.49.0 vs
|
||||
2.41.7), MSI build numbers in the version field (Python 3.13.7150.0 vs fix
|
||||
3.13.10) and stale inventory from disconnected agents. A single stored
|
||||
`fixed_version` cannot validate an arbitrary inventory version string.
|
||||
|
||||
Related guards:
|
||||
- The app-scan reconcile only runs on **ACTIVE** assets. A disconnected Wazuh
|
||||
agent still serves its last stored syscollector data, so "no longer detected"
|
||||
proves nothing — and Wazuh's own vuln sync skips inactive agents, so nothing
|
||||
would reopen the findings.
|
||||
- Products whose ARP version is an MSI build (Python, .NET) are marked
|
||||
`name_ver=True` so the semantic version is read from the display name.
|
||||
|
||||
## Background jobs (GUI)
|
||||
|
||||
Long scans return immediately and are polled, so the browser never times out:
|
||||
|
||||
| Start | Status |
|
||||
|---|---|
|
||||
| `POST /api/v1/vulnerabilities/eol-check/start` | `GET /api/v1/vulnerabilities/eol-check/status` |
|
||||
| `POST /api/v1/vulnerabilities/override/vulnrichment/start` | `GET .../vulnrichment/status/{job_id}` |
|
||||
| `POST /api/v1/vulnerabilities/msrc/refresh` | `GET /api/v1/vulnerabilities/msrc/refresh/status` |
|
||||
|
||||
Job state lives in process memory — a backend restart clears it (and aborts the
|
||||
run). The synchronous `POST /eol-check` remains for API clients.
|
||||
|
||||
## Settings (encrypted at rest)
|
||||
|
||||
| Key | Purpose |
|
||||
|---|---|
|
||||
| `github_pat` | Optional GitHub token. Lifts GHSA **and** the MFSA/repo-advisory fetches from 60 to 5000 req/h. Fine-grained, no scopes needed (public data). |
|
||||
|
||||
## UI
|
||||
|
||||
- **Themes**: light / mid (soft dark) / dark, switchable at the bottom of the
|
||||
sidebar, stored in `localStorage('theme')` and applied to
|
||||
`<html data-theme>` by a pre-paint script in `app/layout.tsx` (no white
|
||||
flash). Pages use hardcoded light utilities, so `globals.css` remaps them
|
||||
under `html[data-theme="mid"|"dark"]` in an **un-layered** block — un-layered
|
||||
author CSS beats Tailwind's `@layer utilities` without `!important`. Opacity
|
||||
and arbitrary-value variants (`bg-gray-50/50`, `bg-[#F3F4F6]/95`) are their
|
||||
own class names and need their own remap.
|
||||
- **Audit log**: `GET /audit/logs` takes `search`, `sort`, `order` and sets
|
||||
`X-Total-Count` (already CORS-exposed) for real pagination.
|
||||
|
||||
@@ -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,49 @@ 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/missing scores) -- CISA Vulnrichment → NVD → cvelistV5 → **GitHub Advisories (GHSA)**, each source only for the CVEs the previous one left empty; real CVE-IDs only. An `NVD_API_KEY` raises the NVD stage from 5 to 50 req/30s, an optional GitHub PAT lifts GHSA from 60 to 5000 req/h
|
||||
- **Mozilla MFSA** -- per-CVE impact rating (critical/high/moderate/low) straight from Mozilla's advisory repo, for fresh Firefox CVEs that have no score in NVD/cvelistV5 yet
|
||||
- **Metrics are CVE-global** -- a finding created without a score inherits CVSS/EPSS/KEV from the same CVE on another asset, so the same CVE never shows two different 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)
|
||||
- **Severity scales with age** -- a product 1000+ days past end-of-life is rated CRITICAL rather than a flat HIGH, and EOL findings cite the control failure (PCI-DSS 6.3.3, NIST 800-53 CM-8, HIPAA 164.312(a)(1)) for audit reports
|
||||
- **Self-correcting** -- findings whose product match no longer holds are re-evaluated and closed automatically; the check runs as a background job with live progress instead of blocking the browser
|
||||
- **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`)
|
||||
- **Microsoft Edge CVEs** -- MSRC is the *only* machine-readable source for these (they appear in neither NVD nor cvelistV5); matched against Edge's own fixed build, never against Chromium/Chrome ranges, since the two build schemes diverge entirely (Edge 150.0.4078.99 rides on Chromium 150.0.7871.187)
|
||||
|
||||
### 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)
|
||||
- **GitHub repository advisories** -- CVEs a project publishes only on its own GitHub advisory page, reaching neither NVD nor cvelistV5 (e.g. Notepad++); version ranges come straight from the maintainer, with the published patched version as a safety net
|
||||
- **One finding, every affected product** -- when a single CVE hits two products on the same host (e.g. Chrome *and* Edge), the finding lists both with their own installed and fixed versions instead of naming only whichever scanner ran first
|
||||
- **Vendor / publisher** captured from every inventory source (Wazuh `vendor`, Intune `publisher`, Defender `softwareVendor`) and shown on the finding
|
||||
- **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,26 +106,61 @@ 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
|
||||
- **SLA breach monitoring** runs hourly, sends email alerts to assigned users/groups
|
||||
- **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)
|
||||
#### Nightly job order (UTC)
|
||||
|
||||
> **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.
|
||||
The order is not arbitrary — each stage depends on the one above it. Inventory
|
||||
is collected first, findings are produced from it, enrichment corrects those
|
||||
findings, and only then are the aggregates computed. Anything that creates
|
||||
findings therefore has to run **before** the enrichment, or its scores would
|
||||
stay uncorrected until the following night.
|
||||
|
||||
| Time | Job | Stage |
|
||||
|------|-----|-------|
|
||||
| 02:00 | Wazuh SCA compliance refresh | inventory |
|
||||
| 02:10 | Intune inventory sync | inventory |
|
||||
| 02:30 | Network exposure + risk dimensions | inventory |
|
||||
| 03:00 | endoflife.date EOL detection | findings |
|
||||
| 03:10 | Microsoft 365 Apps CVE detection | findings |
|
||||
| 03:20 | Built-in App→CVE scan (cvelistV5 + NVD-CPE) | findings |
|
||||
| 03:50 | MSRC fixed-build scan (Windows OS, Edge, SharePoint) | findings |
|
||||
| 04:30 | CISA Vulnrichment CVSS / SSVC correction | enrichment |
|
||||
| 04:40 | MSRC remediation enrichment (weekly) | enrichment |
|
||||
| 04:50 | Public-exploit catalogue refresh | enrichment |
|
||||
| 05:10 | URS recompute + snapshot prune | aggregate |
|
||||
| 05:25 | Asset lifecycle reconcile | aggregate |
|
||||
| 05:40 | Audit-log retention prune | housekeeping |
|
||||
|
||||
Independent of the chain: security advisory feeds every 6h at :20, the
|
||||
new-vulnerability digest mail at the configured hour, and the hourly SLA check.
|
||||
|
||||
Wazuh's own vulnerability sync is a user-defined schedule (Scans → Schedules),
|
||||
not part of this chain.
|
||||
|
||||
### Notifications
|
||||
- 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:** 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 +173,11 @@ 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)
|
||||
- 30-minute access token with **silent refresh** against a 7-day refresh cookie -- an actively used session stays signed in; only real inactivity logs you out
|
||||
- **Revisionssicher audit trail** in both directions: a finding going open → patched *and* patched → open is recorded, naming the CVE, the host and the scanner that caused it
|
||||
- Audit log with full-text search (description, event, resource, IP, user), sortable columns and real pagination
|
||||
- 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 +188,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 +203,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 +212,7 @@ Three roles with hierarchical permissions: **Admin > Editor > Readonly**
|
||||
## Screenshots
|
||||
|
||||
### Dashboard
|
||||

|
||||

|
||||
|
||||
---
|
||||
|
||||
@@ -149,7 +230,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 +297,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 +376,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 +397,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 |
|
||||
@@ -349,6 +434,7 @@ All configuration is done via the `.env` file:
|
||||
| `JWT_SECRET_KEY` | -- | **Required.** JWT signing key (`openssl rand -hex 32`) |
|
||||
| `ENV` | `production` | `development` or `production` |
|
||||
| `TIMEZONE` | `UTC` | Server timezone (e.g., `Europe/Zurich`) |
|
||||
| `NVD_API_KEY` | -- | Optional. Raises the NVD stage of the CVSS cascade from 5 to 50 req/30s and its batch cap from 100 to 1500 CVEs |
|
||||
| `AUTH_COOKIE_SECURE` | auto | `true` for HTTPS, `false` for HTTP. Auto-detected from `ENV` if not set |
|
||||
| `AUTH_COOKIE_SAMESITE` | `lax` | Cookie SameSite policy |
|
||||
| `TRUST_PROXY_HEADERS` | `false` | Trust `X-Forwarded-For` for client IP (enable behind reverse proxy) |
|
||||
@@ -358,7 +444,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 +452,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 +573,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 +585,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 +714,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 +836,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;")
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Add vulnerabilities.package_vendor
|
||||
|
||||
Revision ID: 038
|
||||
Revises: 037
|
||||
Create Date: 2026-07-23 12:00:00.000000
|
||||
|
||||
Vendor/publisher of the affected software, now that it is actually read from
|
||||
the sources that carry it: Wazuh syscollector (vendor), Intune detectedApps
|
||||
(publisher), Defender TVM (softwareVendor). Display-only; helps disambiguate
|
||||
same-named products from different vendors.
|
||||
|
||||
Idempotent.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "038"
|
||||
down_revision = "037"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("ALTER TABLE vulnerabilities ADD COLUMN IF NOT EXISTS package_vendor VARCHAR(255) NULL;")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("ALTER TABLE vulnerabilities DROP COLUMN IF EXISTS package_vendor;")
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Widen vulnerability_packages.source for multi-scanner provenance
|
||||
|
||||
Revision ID: 039
|
||||
Revises: 038
|
||||
Create Date: 2026-07-28 09:00:00.000000
|
||||
|
||||
A package row now records EVERY scanner that confirmed it ("app-scan,msrc"),
|
||||
not just the one that wrote first. VARCHAR(20) could not hold three names.
|
||||
|
||||
Idempotent.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "039"
|
||||
down_revision = "038"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("ALTER TABLE vulnerability_packages ALTER COLUMN source TYPE VARCHAR(60);")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("ALTER TABLE vulnerability_packages ALTER COLUMN source TYPE VARCHAR(20);")
|
||||
@@ -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,9 @@ PROTECTED_SETTING_KEYS: frozenset[str] = frozenset({
|
||||
"wazuh_config",
|
||||
"smtp_config",
|
||||
"nessus_config",
|
||||
"openrouter_api_key",
|
||||
"intune_config",
|
||||
"github_pat",
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -134,12 +134,29 @@ Structure your response as JSON ONLY:
|
||||
}}
|
||||
"""
|
||||
|
||||
def _call_llm(self, prompt: str, enable_web_search: bool = False, temperature: float = 0.3) -> str:
|
||||
# A structured answer costs far more than 2000 tokens: ten prioritised
|
||||
# findings with a justification each, plus the strategy paragraph, runs
|
||||
# past that and the reply is cut off MID-JSON. The parser then fails, the
|
||||
# caller reports "no recommendations", and nothing says the answer was
|
||||
# simply truncated. Reasoning models make it worse — deepseek-reasoner
|
||||
# spends this same budget thinking before it writes a single character.
|
||||
MAX_TOKENS = 8000
|
||||
|
||||
# Providers that honour OpenAI's response_format. Asking these for JSON
|
||||
# makes a parse failure structurally impossible, rather than something we
|
||||
# hope a prompt talks the model into. Others ignore the field or reject
|
||||
# the request outright, so they are not sent it.
|
||||
_JSON_MODE_PROVIDERS = ("openai", "deepseek", "openrouter", "groq", "mistral")
|
||||
|
||||
def _call_llm(self, prompt: str, enable_web_search: bool = False,
|
||||
temperature: float = 0.3, json_mode: bool = False) -> str:
|
||||
if self.provider == "anthropic":
|
||||
return self._call_anthropic(prompt, temperature)
|
||||
return self._call_openai_compatible(prompt, enable_web_search, temperature)
|
||||
return self._call_openai_compatible(prompt, enable_web_search, temperature,
|
||||
json_mode=json_mode)
|
||||
|
||||
def _call_openai_compatible(self, prompt: str, enable_web_search: bool, temperature: float) -> str:
|
||||
def _call_openai_compatible(self, prompt: str, enable_web_search: bool,
|
||||
temperature: float, json_mode: bool = False) -> str:
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json"
|
||||
@@ -152,11 +169,13 @@ Structure your response as JSON ONLY:
|
||||
{"role": "user", "content": prompt}
|
||||
],
|
||||
"temperature": temperature,
|
||||
"max_tokens": 2000
|
||||
"max_tokens": self.MAX_TOKENS
|
||||
}
|
||||
|
||||
if enable_web_search and self.provider == "infomaniak":
|
||||
payload["tools"] = [{"type": "web_search"}]
|
||||
if json_mode and self.provider in self._JSON_MODE_PROVIDERS:
|
||||
payload["response_format"] = {"type": "json_object"}
|
||||
|
||||
logger.info(f"Calling {self.provider} API with model: {self.model}")
|
||||
|
||||
@@ -167,7 +186,15 @@ Structure your response as JSON ONLY:
|
||||
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return data["choices"][0]["message"]["content"]
|
||||
choice = (data.get("choices") or [{}])[0]
|
||||
# Say so when the answer was cut short. Silently returning half a JSON
|
||||
# document is what made this look like "the AI returned nothing".
|
||||
if choice.get("finish_reason") == "length":
|
||||
logger.warning(
|
||||
"%s/%s hit the %d token limit — the reply is truncated and will "
|
||||
"not parse. Use a model with more headroom or ask for less.",
|
||||
self.provider, self.model, self.MAX_TOKENS)
|
||||
return (choice.get("message") or {}).get("content") or ""
|
||||
|
||||
def _call_anthropic(self, prompt: str, temperature: float) -> str:
|
||||
headers = {
|
||||
@@ -178,13 +205,17 @@ Structure your response as JSON ONLY:
|
||||
payload = {
|
||||
"model": self.model,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"max_tokens": 2000,
|
||||
"max_tokens": self.MAX_TOKENS,
|
||||
"temperature": temperature
|
||||
}
|
||||
response = self.client.post(self.base_url, headers=headers, json=payload)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return data["content"][0]["text"]
|
||||
if data.get("stop_reason") == "max_tokens":
|
||||
logger.warning("%s/%s hit the %d token limit — reply truncated.",
|
||||
self.provider, self.model, self.MAX_TOKENS)
|
||||
blocks = data.get("content") or []
|
||||
return blocks[0].get("text", "") if blocks else ""
|
||||
|
||||
def _parse_analysis_response(self, response: str) -> Dict[str, Any]:
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
"""
|
||||
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
|
||||
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__)
|
||||
|
||||
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,
|
||||
_tries: int = 4) -> Dict[str, Any]:
|
||||
# MDE allows ~100 calls/minute, and the per-machine vulnerability walk
|
||||
# is one call per machine — so a sync of any size hits 429, more so when
|
||||
# an app scan runs alongside it (tester: a wall of "429 Too Many
|
||||
# Requests"). Treating that as a hard error threw away everything the
|
||||
# sync had left to do; 429 is a "come back shortly", not a failure.
|
||||
# Microsoft states the wait in Retry-After, so honour it.
|
||||
token = self._ensure_token()
|
||||
for attempt in range(_tries):
|
||||
r = self.client.get(url, headers={"Authorization": f"Bearer {token}",
|
||||
"Accept": "application/json"},
|
||||
params=params)
|
||||
if r.status_code != 429:
|
||||
break
|
||||
if attempt == _tries - 1:
|
||||
raise DefenderAPIError("Defender rate limit (429) — gave up after "
|
||||
f"{_tries} attempts")
|
||||
try:
|
||||
wait = float(r.headers.get("Retry-After", ""))
|
||||
except ValueError:
|
||||
wait = 0.0
|
||||
# No/!unusable header → back off geometrically instead of hammering.
|
||||
wait = min(max(wait, 2.0 * (2 ** attempt)), 60.0)
|
||||
logger.info("Defender 429 — retrying in %.0fs (attempt %d/%d)",
|
||||
wait, attempt + 1, _tries)
|
||||
time.sleep(wait)
|
||||
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,233 @@
|
||||
"""
|
||||
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(),
|
||||
"vendor": (a.get("publisher") or "").strip() or None})
|
||||
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
|
||||
@@ -85,30 +85,83 @@ class AIAnalysisService:
|
||||
return ai_analysis
|
||||
|
||||
def get_priority_recommendations(self, limit: int = 20, severity: Optional[str] = None) -> Dict[str, Any]:
|
||||
# Implementation similar to original but using universal client
|
||||
query = self.db.query(Vulnerability).filter(Vulnerability.status == "open")
|
||||
# Same scope the reports and the dashboard use: real CVEs on assets
|
||||
# that still exist. Without it the audit ranked findings on
|
||||
# decommissioned hosts and treated EOL- pseudo-CVEs as vulnerabilities.
|
||||
from app.services.report_scope import scoped
|
||||
query = scoped(self.db).filter(Vulnerability.status == "open")
|
||||
if severity:
|
||||
query = query.filter(Vulnerability.severity == severity)
|
||||
|
||||
top_vulns = query.order_by(desc(Vulnerability.cvss_score)).limit(limit).all()
|
||||
# ORDER BY cvss_score DESC put the WORST candidates first: Postgres
|
||||
# sorts NULLs before everything on DESC, so every finding without a
|
||||
# score — fresh Chrome CVEs carry none at all — was handed to the model
|
||||
# as the top of the list, ahead of a scored 9.8. Rank by the scores the
|
||||
# product computes for exactly this question, and put nulls last.
|
||||
top_vulns = (query.order_by(
|
||||
Vulnerability.priority_score.desc().nullslast(),
|
||||
Vulnerability.cpr_score.desc().nullslast(),
|
||||
Vulnerability.cvss_score.desc().nullslast(),
|
||||
# Over-fetch, because the dedup below happens after the LIMIT: one CVE
|
||||
# spread over 30 hosts would otherwise leave the model a handful of
|
||||
# distinct findings instead of `limit` of them.
|
||||
).limit(limit * 5).all())
|
||||
if not top_vulns:
|
||||
return {"recommendations": [], "global_strategy": "No open vulnerabilities found."}
|
||||
|
||||
# One entry per CVE. The same CVE on 30 hosts used to fill the whole
|
||||
# list, so the model ranked one problem thirty times and never saw the
|
||||
# other 29.
|
||||
vuln_data = []
|
||||
seen_cves = set()
|
||||
for v in top_vulns:
|
||||
if len(vuln_data) >= limit:
|
||||
break
|
||||
if v.cve_id in seen_cves:
|
||||
continue
|
||||
seen_cves.add(v.cve_id)
|
||||
# The model was given CVE id, host, CVSS and package — less than the
|
||||
# dashboard shows. It could not weigh what actually decides urgency:
|
||||
# known exploitation (KEV/EUVD), exploit probability (EPSS), and the
|
||||
# asset's own criticality. Those are the fields that make an 7.5
|
||||
# outrank a 9.8.
|
||||
vuln_data.append({
|
||||
"cve_id": v.cve_id,
|
||||
"hostname": v.asset.hostname if v.asset else "Unknown",
|
||||
"cvss_score": v.cvss_score,
|
||||
"package_name": v.package_name
|
||||
"package_name": v.package_name,
|
||||
"severity": v.severity.value if v.severity else None,
|
||||
"priority_score": v.priority_score,
|
||||
"epss_percentile": v.epss_percentile,
|
||||
"known_exploited_cisa_kev": bool(v.kev_listed),
|
||||
"listed_enisa_euvd": bool(v.euvd_listed),
|
||||
"fixed_version": v.fixed_version,
|
||||
"days_open": ((datetime.now() - v.detected_at).days
|
||||
if v.detected_at else None),
|
||||
})
|
||||
|
||||
# Prompt for structured prioritization
|
||||
prompt = f"""You are a Vulnerability Management Expert. Analyze the following list of vulnerabilites and prioritize the top 10 that need immediate attention.
|
||||
# "top 10" was hardcoded while `limit` defaulted to 20 — the model was
|
||||
# told to discard half its input for no stated reason, and at limit<10
|
||||
# it was asked for more items than it had been given.
|
||||
want = min(10, len(vuln_data))
|
||||
prompt = f"""You are a vulnerability management expert. Rank the {want} findings below that need attention first.
|
||||
|
||||
Vulnerabilities:
|
||||
Rank by exploitability and blast radius, not by CVSS alone:
|
||||
- known_exploited_cisa_kev = true outranks a higher CVSS that is not exploited.
|
||||
- listed_enisa_euvd = true is the EU equivalent signal.
|
||||
- epss_percentile is the probability of exploitation in the next 30 days (0-1).
|
||||
- days_open shows how long the finding has been unaddressed.
|
||||
- fixed_version = null means no patch exists yet, so advise mitigation instead.
|
||||
- cvss_score may be null when the vendor published no score; judge such a
|
||||
finding on its severity and the signals above rather than skipping it.
|
||||
|
||||
Findings ({len(vuln_data)} distinct CVEs):
|
||||
{json.dumps(vuln_data)}
|
||||
|
||||
Return exactly {want} recommendations, most urgent first, priority numbered
|
||||
from 1. Use only cve_id values from the list above — never invent one.
|
||||
|
||||
You MUST respond with a valid JSON object ONLY, following this exact structure:
|
||||
{{
|
||||
"global_strategy": "Brief strategic summary of the risk landscape and focus areas.",
|
||||
@@ -124,7 +177,7 @@ You MUST respond with a valid JSON object ONLY, following this exact structure:
|
||||
}}
|
||||
"""
|
||||
try:
|
||||
response_text = self.ai_client._call_llm(prompt)
|
||||
response_text = self.ai_client._call_llm(prompt, json_mode=True)
|
||||
|
||||
# Clean DeepSeek <think> tags if present
|
||||
if "<think>" in response_text:
|
||||
|
||||
@@ -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
|
||||
@@ -500,6 +562,7 @@ class WazuhClient:
|
||||
},
|
||||
"name": pkg.get("name"),
|
||||
"version": pkg.get("version"),
|
||||
"vendor": pkg.get("vendor"),
|
||||
"fixed_version": fixed_version,
|
||||
"severity": vuln.get("severity"),
|
||||
"title": vuln.get("title") or vuln.get("description"),
|
||||
@@ -583,6 +646,56 @@ class WazuhClient:
|
||||
response = self._request("GET", f"/syscollector/{agent_id}/ports")
|
||||
return response.get("data", {}).get("affected_items", [])
|
||||
|
||||
_EXT_INDEX = "/wazuh-states-inventory-browser-extensions-*"
|
||||
|
||||
def get_browser_extensions(self, agent_id: str) -> List[Dict[str, Any]]:
|
||||
"""Installed browser extensions for one agent, from IT Hygiene.
|
||||
|
||||
These live in the INDEXER, not the manager API — syscollector has no
|
||||
endpoint for them. Without this the browser extensions are a blind
|
||||
spot: the Acrobat extension for Chrome ships its own CVEs
|
||||
(CVE-2026-48294) that no other inventory can reach.
|
||||
|
||||
Returns the package block enriched with the browser, e.g.
|
||||
{name, version, id, enabled, browser, profile}. Empty list when the
|
||||
indexer is not configured — the caller then simply scans nothing.
|
||||
"""
|
||||
if not self.indexer_url:
|
||||
return []
|
||||
body = {
|
||||
"size": 1000,
|
||||
"query": {"term": {"agent.id": str(agent_id)}},
|
||||
"_source": ["agent.id", "browser.name", "browser.profile.name",
|
||||
"package.name", "package.version", "package.id",
|
||||
"package.enabled", "package.vendor",
|
||||
"package.from_webstore"],
|
||||
}
|
||||
try:
|
||||
resp = self._indexer_request(
|
||||
"POST", f"{self._EXT_INDEX}/_search", json_data=body)
|
||||
except WazuhAPIError as e:
|
||||
# IT Hygiene is optional and only exists from 4.14 — a missing
|
||||
# index must not take the whole scan down with it.
|
||||
logger.debug("browser extensions unavailable for %s: %s", agent_id, e)
|
||||
return []
|
||||
out: List[Dict[str, Any]] = []
|
||||
for hit in (resp.get("hits", {}) or {}).get("hits", []) or []:
|
||||
src = hit.get("_source") or {}
|
||||
pkg = src.get("package") or {}
|
||||
if not pkg.get("name"):
|
||||
continue
|
||||
out.append({
|
||||
"name": pkg.get("name"),
|
||||
"version": pkg.get("version"),
|
||||
"id": pkg.get("id"),
|
||||
"enabled": pkg.get("enabled"),
|
||||
"vendor": pkg.get("vendor"),
|
||||
"browser": ((src.get("browser") or {}).get("name") or "").lower(),
|
||||
"profile": (((src.get("browser") or {}).get("profile") or {})
|
||||
.get("name")),
|
||||
})
|
||||
return out
|
||||
|
||||
def get_os_info(self, agent_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Holt OS-Informationen eines Agents
|
||||
|
||||
+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")
|
||||
|
||||
@@ -72,6 +72,7 @@ class Vulnerability(Base, TimestampMixin):
|
||||
|
||||
# Betroffene Software
|
||||
package_name = Column(String(255), nullable=True, index=True)
|
||||
package_vendor = Column(String(255), nullable=True) # Wazuh/Intune/Defender vendor|publisher
|
||||
package_version = Column(String(100), nullable=True)
|
||||
fixed_version = Column(String(100), nullable=True)
|
||||
|
||||
@@ -88,12 +89,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 +206,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:
|
||||
@@ -232,6 +237,31 @@ class Vulnerability(Base, TimestampMixin):
|
||||
self.sources = json.dumps(current)
|
||||
return True
|
||||
|
||||
def sync_severity_with_cvss(self) -> bool:
|
||||
"""Keep `severity` consistent with `cvss_score`.
|
||||
|
||||
Sources that carry no score seed a neutral placeholder (MSRC only
|
||||
publishes a CVSS for a minority of its CVEs), and whatever fills the
|
||||
score later — the correction cascade, sibling inheritance — used to
|
||||
leave the placeholder behind: the tester saw Edge findings sitting at
|
||||
MEDIUM with a CVSS of 9.6. Never touches a row whose severity an
|
||||
operator pinned via the override path (exploitation_source set).
|
||||
"""
|
||||
if self.cvss_score is None:
|
||||
return False
|
||||
if getattr(self, "exploitation_source", None) == "manual":
|
||||
return False
|
||||
s = float(self.cvss_score)
|
||||
want = (VulnerabilitySeverity.critical if s >= 9.0 else
|
||||
VulnerabilitySeverity.high if s >= 7.0 else
|
||||
VulnerabilitySeverity.medium if s >= 4.0 else
|
||||
VulnerabilitySeverity.low if s > 0 else
|
||||
VulnerabilitySeverity.none)
|
||||
if self.severity != want:
|
||||
self.severity = want
|
||||
return True
|
||||
return False
|
||||
|
||||
def refresh_scores(self) -> None:
|
||||
"""Recompute + persist priority_score + cpr_score on the row.
|
||||
|
||||
@@ -241,6 +271,7 @@ class Vulnerability(Base, TimestampMixin):
|
||||
not per-page.
|
||||
"""
|
||||
try:
|
||||
self.sync_severity_with_cvss()
|
||||
self.priority_score = self.calculate_priority_breakdown().get("total")
|
||||
except Exception:
|
||||
self.priority_score = None
|
||||
@@ -262,6 +293,19 @@ class Vulnerability(Base, TimestampMixin):
|
||||
"""
|
||||
return self.calculate_priority_breakdown()["total"]
|
||||
|
||||
# Lower bound of each CVSS band. Used only when no score exists at all —
|
||||
# some vendors publish none: Chrome states "Chromium security severity:
|
||||
# Critical" in prose and NVD frequently never scores those CVEs. Both
|
||||
# scores below multiply the CVSS in, so a missing one drove priority and
|
||||
# CPR to zero and a genuinely critical finding sorted below a medium with a
|
||||
# score. Taking the band's FLOOR keeps the estimate conservative: it can
|
||||
# only ever understate a real score, never inflate one.
|
||||
_SEVERITY_FLOOR = {"critical": 9.0, "high": 7.0, "medium": 4.0, "low": 0.1}
|
||||
|
||||
def _severity_floor(self):
|
||||
sev = getattr(self.severity, "value", self.severity)
|
||||
return self._SEVERITY_FLOOR.get(str(sev or "").lower())
|
||||
|
||||
def calculate_cpr_score(self):
|
||||
"""
|
||||
Cybersecurity Priority Risk — weighted blend of CVSS + EPSS
|
||||
@@ -287,9 +331,10 @@ class Vulnerability(Base, TimestampMixin):
|
||||
|
||||
Returns: float 0-100, oder None wenn CVSS oder EPSS fehlt.
|
||||
"""
|
||||
if self.cvss_score is None or self.epss_score is None:
|
||||
base = self.cvss_score if self.cvss_score is not None else self._severity_floor()
|
||||
if base is None or self.epss_score is None:
|
||||
return None
|
||||
cvss_pct = max(0.0, min(self.cvss_score, 10.0)) * 10.0
|
||||
cvss_pct = max(0.0, min(base, 10.0)) * 10.0
|
||||
if self.epss_percentile is not None:
|
||||
epss_pct = max(0.0, min(self.epss_percentile, 1.0)) * 100.0
|
||||
else:
|
||||
@@ -306,8 +351,11 @@ class Vulnerability(Base, TimestampMixin):
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# 1. Technical Severity (0-10)
|
||||
technical_severity = self.cvss_score or 0.0
|
||||
# 1. Technical Severity (0-10). Falls back to the severity band's floor
|
||||
# when the vendor published no CVSS — see _SEVERITY_FLOOR. Without it a
|
||||
# Critical Chrome CVE scored 0 here and sorted below every medium.
|
||||
technical_severity = (self.cvss_score if self.cvss_score is not None
|
||||
else (self._severity_floor() or 0.0))
|
||||
|
||||
# 2. Exploit-Signal: max aus KEV/EUVD / EPSS / Wazuh / SSVC / maturity
|
||||
# KEV ODER EUVD bedeuten "real-world exploitation bestätigt"
|
||||
|
||||
@@ -41,10 +41,10 @@ class VulnerabilityPackage(Base, TimestampMixin):
|
||||
package_version = Column(String(100), nullable=True)
|
||||
fixed_version = Column(String(100), nullable=True)
|
||||
|
||||
# Source tag — which scanner reported this package row.
|
||||
# ("wazuh" / "nessus" / "manual"). Multi-scanner deployments
|
||||
# can show per-source confirmation in the per-package view.
|
||||
source = Column(String(20), nullable=True)
|
||||
# Source tag(s) — every scanner that reported this package row, comma
|
||||
# joined ("app-scan,msrc"). One scanner alone owning the field made a
|
||||
# cross-confirmed package look single-source in the UI.
|
||||
source = Column(String(60), nullable=True)
|
||||
|
||||
first_detected_at = Column(DateTime, nullable=False, default=datetime.utcnow)
|
||||
last_seen_at = Column(DateTime, nullable=False, default=datetime.utcnow)
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Security-advisory awareness feeds (CISA KEV + configurable RSS sources)."""
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.auth.dependencies import get_current_user, RequireEditor
|
||||
|
||||
router = APIRouter(prefix="/api/v1/advisories", tags=["Advisories"])
|
||||
|
||||
|
||||
@router.get("/feeds")
|
||||
def advisory_feeds(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Cached security-advisory RSS feeds (ZDI / CERT-EU / BSI / ...). Served
|
||||
from the cache the scheduler fills; first call ever triggers a fetch."""
|
||||
from app.services.advisory_feed_service import get_cached_feeds, refresh_feeds
|
||||
cached = get_cached_feeds(db)
|
||||
if cached is None:
|
||||
refresh_feeds(db)
|
||||
cached = get_cached_feeds(db) or {"fetched_at": None, "feeds": []}
|
||||
return cached
|
||||
|
||||
|
||||
@router.post("/feeds/refresh")
|
||||
# Sync def → worker threadpool; N feed fetches are blocking I/O.
|
||||
def refresh_advisory_feeds(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(RequireEditor),
|
||||
):
|
||||
"""Re-fetch every enabled advisory feed now."""
|
||||
from app.services.advisory_feed_service import refresh_feeds
|
||||
return refresh_feeds(db)
|
||||
|
||||
|
||||
@router.get("/kev-recent")
|
||||
def kev_recent(
|
||||
limit: int = Query(20, le=100, description="How many recent KEV entries to return"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Most recently added CISA KEV (actively-exploited) CVEs, newest first,
|
||||
annotated with whether we already have that CVE in inventory."""
|
||||
from app.services.advisory_service import get_recent_kev
|
||||
return {"items": get_recent_kev(db, limit=limit)}
|
||||
+178
-19
@@ -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": (a.get("vendor") or "").strip()} for a in apps]
|
||||
else:
|
||||
raise HTTPException(422, "Asset has no Wazuh agent or Intune device — no live software inventory")
|
||||
|
||||
# Dedup (name, version) + drop nameless, sort by name.
|
||||
seen: set = set()
|
||||
out: list[dict] = []
|
||||
for s in software:
|
||||
if not s["name"]:
|
||||
continue
|
||||
key = (s["name"].lower(), s["version"])
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
out.append(s)
|
||||
out.sort(key=lambda s: s["name"].lower())
|
||||
return {"asset_id": asset_id, "hostname": asset.hostname, "source": source,
|
||||
"count": len(out), "software": out}
|
||||
|
||||
|
||||
@router.post("/refresh-exposure")
|
||||
async def refresh_exposure(
|
||||
def refresh_exposure(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
@@ -339,19 +424,23 @@ async def list_assets(
|
||||
status: Optional[AssetStatus] = Query(None),
|
||||
source: Optional[AssetSource] = Query(None),
|
||||
search: Optional[str] = Query(None, description="Suche in Hostname, IP"),
|
||||
include_inactive: bool = Query(False, description="Include soft-inactive + decommissioned assets"),
|
||||
include_inactive: bool = Query(False, description="Also include DECOMMISSIONED assets (INACTIVE are shown by default)"),
|
||||
sort_by: str = Query("hostname", description="hostname, ip_address, operating_system, status, last_scan, network_exposure_score, policy_name, assigned_user_name"),
|
||||
sort_order: str = Query("asc", description="asc, desc"),
|
||||
limit: int = Query(100, le=1000),
|
||||
offset: int = Query(0),
|
||||
response: Response = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Liste aller Assets mit Filterung.
|
||||
|
||||
Default zeigt nur ACTIVE — soft-inactive assets (kein source-sync
|
||||
innerhalb asset_inactive_after_days) sind ausgeblendet bis
|
||||
include_inactive=true. Vuln-historie + audit bleiben erhalten.
|
||||
Default zeigt ACTIVE **und** INACTIVE — der Sinn der Status-Spalte ist
|
||||
ja, soft-inactive Assets (kein source-sync innerhalb
|
||||
asset_inactive_after_days) mit Badge sichtbar zu halten, nicht sie zu
|
||||
verstecken. Nur DECOMMISSIONED (operator-final) ist per default
|
||||
ausgeblendet; include_inactive=true zeigt auch die.
|
||||
Vuln-historie + audit bleiben erhalten.
|
||||
"""
|
||||
query = db.query(Asset)
|
||||
|
||||
@@ -361,10 +450,42 @@ async def list_assets(
|
||||
# everything (active + inactive + decommissioned)
|
||||
pass
|
||||
else:
|
||||
query = query.filter(Asset.status == AssetStatus.ACTIVE)
|
||||
# active + inactive visible; hide only operator-retired
|
||||
query = query.filter(Asset.status != AssetStatus.DECOMMISSIONED)
|
||||
|
||||
if source:
|
||||
query = query.filter(Asset.source == source)
|
||||
# Filter by the actual sync linkage, not the creation-time `source`
|
||||
# enum: an asset first created by Wazuh and later matched by Nessus
|
||||
# keeps source=WAZUH but carries a nessus_host_uuid. The per-scanner
|
||||
# id columns are the multi-source truth, so a merged asset correctly
|
||||
# appears under every scanner that sees it.
|
||||
def _has_vuln_source(tag: str):
|
||||
# Asset has ≥1 finding reported by `tag` (the vuln-level `sources`
|
||||
# list is the maintained multi-source truth). Catches Nessus
|
||||
# imports whose host had no nessus_host_uuid (matched by hostname).
|
||||
return Asset.id.in_(
|
||||
db.query(Vulnerability.asset_id)
|
||||
.filter(Vulnerability.sources.contains(f'"{tag}"'))
|
||||
)
|
||||
|
||||
if source == AssetSource.WAZUH:
|
||||
query = query.filter(Asset.wazuh_agent_id.isnot(None) | _has_vuln_source("wazuh"))
|
||||
elif source == AssetSource.NESSUS:
|
||||
query = query.filter(Asset.nessus_host_uuid.isnot(None) | _has_vuln_source("nessus"))
|
||||
elif source == AssetSource.INTUNE:
|
||||
# Intune devices often carry no findings → id columns are the signal.
|
||||
query = query.filter(
|
||||
Asset.intune_device_id.isnot(None) | Asset.defender_machine_id.isnot(None)
|
||||
| _has_vuln_source("intune") | _has_vuln_source("defender"))
|
||||
elif source == AssetSource.MANUAL:
|
||||
# genuinely manual = no scanner linkage AND no scanner-sourced vuln
|
||||
query = query.filter(
|
||||
Asset.wazuh_agent_id.is_(None), Asset.nessus_host_uuid.is_(None),
|
||||
Asset.intune_device_id.is_(None), Asset.defender_machine_id.is_(None),
|
||||
~_has_vuln_source("wazuh"), ~_has_vuln_source("nessus"),
|
||||
~_has_vuln_source("intune"), ~_has_vuln_source("defender"))
|
||||
else:
|
||||
query = query.filter(Asset.source == source)
|
||||
|
||||
if search:
|
||||
search_pattern = f"%{search}%"
|
||||
@@ -383,15 +504,25 @@ async def list_assets(
|
||||
"status": Asset.status,
|
||||
"last_scan": Asset.last_scan,
|
||||
"network_exposure_score": Asset.network_exposure_score,
|
||||
"high_value_score": Asset.high_value_score,
|
||||
}
|
||||
sort_dir = desc if sort_order == "desc" else asc
|
||||
if sort_by in _SORT_MAP_DIRECT:
|
||||
col = _SORT_MAP_DIRECT[sort_by]
|
||||
# last_scan can be NULL (never-synced) — keep them at the bottom.
|
||||
if sort_by == "last_scan":
|
||||
query = query.order_by(sort_dir(nulls_last(col)))
|
||||
# NULL-safe on BOTH directions — never-scanned assets land at
|
||||
# the bottom regardless of asc/desc (PG: NULLS LAST is independent
|
||||
# of the primary direction).
|
||||
query = query.order_by(nulls_last(sort_dir(col)))
|
||||
elif sort_by == "hostname":
|
||||
# Case-insensitive alpha sort — a host called "alpine" must
|
||||
# not outrank "Webserver" because the literal `A` > `W`.
|
||||
query = query.order_by(nulls_last(sort_dir(func.lower(col))))
|
||||
else:
|
||||
query = query.order_by(sort_dir(col))
|
||||
# NULLs always last (both directions) — sorting Exposure/Risk
|
||||
# desc must surface the real high scores first, not the empty
|
||||
# ("—") rows. (Tester: desc showed blanks before real values.)
|
||||
query = query.order_by(nulls_last(sort_dir(col)))
|
||||
elif sort_by == "policy_name":
|
||||
query = query.outerjoin(Policy, Asset.policy_id == Policy.id).order_by(sort_dir(Policy.name))
|
||||
elif sort_by == "assigned_user_name":
|
||||
@@ -400,6 +531,12 @@ async def list_assets(
|
||||
# Unknown / unsupported sort_by — fall back to hostname asc.
|
||||
query = query.order_by(asc(Asset.hostname))
|
||||
|
||||
# Total (pre-pagination) so the UI can page; returned as a header to
|
||||
# keep the response body a plain array (other consumers expect a list).
|
||||
total_count = query.order_by(None).count()
|
||||
response.headers["X-Total-Count"] = str(total_count)
|
||||
response.headers["Access-Control-Expose-Headers"] = "X-Total-Count"
|
||||
|
||||
assets = query.offset(offset).limit(limit).all()
|
||||
|
||||
# Vulnerability-Counts + assigned user info
|
||||
@@ -570,16 +707,38 @@ async def delete_asset(
|
||||
detail="Asset not found"
|
||||
)
|
||||
|
||||
# Audit-Log vor Löschung
|
||||
# Revisionssicher audit — snapshot the full asset + cascade impact
|
||||
# BEFORE deletion so the trail says WHO deleted WHAT (and how much
|
||||
# history it took with it), analog to the CVE status-change entries.
|
||||
import json as _json
|
||||
vuln_count = db.query(Vulnerability).filter(
|
||||
Vulnerability.asset_id == asset.id
|
||||
).count()
|
||||
snapshot = {
|
||||
"hostname": asset.hostname,
|
||||
"ip_address": asset.ip_address,
|
||||
"operating_system": asset.operating_system,
|
||||
"source": asset.source.value if hasattr(asset.source, "value") else str(asset.source),
|
||||
"status": asset.status.value if hasattr(asset.status, "value") else str(asset.status),
|
||||
"wazuh_agent_id": asset.wazuh_agent_id,
|
||||
"vulnerabilities_cascade_deleted": vuln_count,
|
||||
"deleted_by": current_user.username,
|
||||
}
|
||||
audit_log = AuditLog(
|
||||
user_id=current_user.id,
|
||||
event_type=AuditEventType.ASSET_DELETED,
|
||||
event_description=f"Asset deleted: {asset.hostname}",
|
||||
event_description=(
|
||||
f"Asset '{asset.hostname}' ({asset.ip_address or 'no-ip'}) deleted by "
|
||||
f"{current_user.username} — {vuln_count} vulnerabilities cascade-removed"
|
||||
)[:500],
|
||||
resource_type="asset",
|
||||
resource_id=str(asset.id),
|
||||
old_value=_json.dumps(snapshot),
|
||||
new_value="deleted",
|
||||
timestamp=datetime.now()
|
||||
)
|
||||
db.add(audit_log)
|
||||
db.commit() # persist audit first so it survives even if delete fails
|
||||
|
||||
db.delete(asset)
|
||||
db.commit()
|
||||
@@ -663,7 +822,7 @@ from app.integrations.wazuh_client import WazuhClient, WazuhAPIError
|
||||
from app.models.setting import Setting
|
||||
|
||||
@router.post("/sync_wazuh", response_model=dict)
|
||||
async def sync_wazuh_assets(
|
||||
def sync_wazuh_assets(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(RequireEditor)
|
||||
):
|
||||
@@ -794,7 +953,7 @@ async def sync_wazuh_assets(
|
||||
|
||||
|
||||
@router.post("/{asset_id}/rescan", response_model=dict)
|
||||
async def rescan_asset(
|
||||
def rescan_asset(
|
||||
asset_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(RequireEditor)
|
||||
|
||||
+40
-5
@@ -1,10 +1,10 @@
|
||||
import csv
|
||||
import io
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import desc
|
||||
from sqlalchemy import asc, cast, desc, or_, String
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.user import User, UserRole
|
||||
@@ -33,20 +33,38 @@ class AuditLogResponse(BaseModel):
|
||||
timestamp: datetime
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
# pydantic v2 name (v1's `orm_mode` still works but warns on import)
|
||||
from_attributes = True
|
||||
|
||||
# Whitelist of sortable columns → SQL column. Anything else falls back to
|
||||
# timestamp, so the client can't inject an arbitrary order_by.
|
||||
_SORT_COLUMNS = {
|
||||
"timestamp": AuditLog.timestamp,
|
||||
"event_type": AuditLog.event_type,
|
||||
"resource_type": AuditLog.resource_type,
|
||||
"resource_id": AuditLog.resource_id,
|
||||
"user_id": AuditLog.user_id,
|
||||
"ip_address": AuditLog.ip_address,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/logs", response_model=List[AuditLogResponse])
|
||||
async def get_audit_logs(
|
||||
response: Response,
|
||||
skip: int = 0,
|
||||
limit: int = Query(100, le=1000, description="Max rows per page (cap 1000)"),
|
||||
user_id: Optional[int] = None,
|
||||
resource_type: Optional[str] = None,
|
||||
resource_id: Optional[str] = None,
|
||||
search: Optional[str] = Query(None, description="Free-text over description, event, resource, IP, user"),
|
||||
sort: str = Query("timestamp", description="Sort column"),
|
||||
order: str = Query("desc", description="asc | desc"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get audit logs.
|
||||
Get audit logs. Sets `X-Total-Count` (matched rows before pagination) so
|
||||
the UI can render page controls.
|
||||
|
||||
Admins see everything. Editors can scope the query to a single
|
||||
resource (e.g. resource_type='vulnerability', resource_id='42')
|
||||
@@ -69,8 +87,25 @@ async def get_audit_logs(
|
||||
query = query.filter(AuditLog.resource_type == resource_type)
|
||||
if resource_id:
|
||||
query = query.filter(AuditLog.resource_id == resource_id)
|
||||
if search and search.strip():
|
||||
term = f"%{search.strip()}%"
|
||||
# username lives on the related User; outerjoin so system/auto rows
|
||||
# (user_id NULL) still match on the other columns.
|
||||
query = query.outerjoin(User, AuditLog.user_id == User.id).filter(or_(
|
||||
AuditLog.event_description.ilike(term),
|
||||
cast(AuditLog.event_type, String).ilike(term),
|
||||
AuditLog.resource_type.ilike(term),
|
||||
AuditLog.resource_id.ilike(term),
|
||||
AuditLog.ip_address.ilike(term),
|
||||
User.username.ilike(term),
|
||||
))
|
||||
|
||||
logs = query.order_by(desc(AuditLog.timestamp)).offset(skip).limit(limit).all()
|
||||
total = query.count()
|
||||
response.headers["X-Total-Count"] = str(total)
|
||||
|
||||
col = _SORT_COLUMNS.get(sort, AuditLog.timestamp)
|
||||
direction = asc if order == "asc" else desc
|
||||
logs = query.order_by(direction(col)).offset(skip).limit(limit).all()
|
||||
|
||||
response = []
|
||||
for log in logs:
|
||||
|
||||
+77
-9
@@ -364,9 +364,15 @@ async def mfa_verify(
|
||||
try:
|
||||
decoded = decode_token(payload.mfa_token)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired MFA token")
|
||||
if not decoded.get("mfa_pending"):
|
||||
raise HTTPException(status_code=401, detail="Invalid MFA token")
|
||||
decoded = None
|
||||
# decode_token returns None on an EXPIRED/invalid token (it does not raise).
|
||||
# Guarding only the exception path let `None.get(...)` throw below → 500
|
||||
# when a user sat on the MFA screen past the 5-min challenge window.
|
||||
if not decoded or not decoded.get("mfa_pending"):
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="MFA session expired — please log in again.",
|
||||
)
|
||||
|
||||
user = db.query(User).filter(User.username == decoded.get("sub")).first()
|
||||
if not user or not user.is_active:
|
||||
@@ -674,17 +680,30 @@ async def logout(
|
||||
|
||||
@router.post("/refresh", response_model=LoginResponse)
|
||||
async def refresh_token(
|
||||
refresh_request: RefreshTokenRequest,
|
||||
request: Request,
|
||||
refresh_request: Optional[RefreshTokenRequest] = None,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Renew access token with refresh token
|
||||
Renew access token with refresh token.
|
||||
|
||||
The token is taken from the request body when present, else from the
|
||||
HttpOnly `refresh_token` cookie — the browser can't read that cookie to
|
||||
put it in the body, so the cookie fallback is what makes silent refresh
|
||||
(access-token renewal without a re-login) work from the SPA.
|
||||
|
||||
Security:
|
||||
- Checks token type (must be "refresh")
|
||||
- Validates token signature and expiry
|
||||
"""
|
||||
payload = decode_token(refresh_request.refresh_token)
|
||||
presented = (refresh_request.refresh_token if refresh_request else None) \
|
||||
or request.cookies.get("refresh_token")
|
||||
if not presented:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Missing refresh token",
|
||||
)
|
||||
payload = decode_token(presented)
|
||||
|
||||
if not payload or payload.get("type") != "refresh":
|
||||
raise HTTPException(
|
||||
@@ -1034,12 +1053,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 +1190,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 +1219,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}",
|
||||
|
||||
+77
-36
@@ -17,6 +17,10 @@ from datetime import timedelta
|
||||
|
||||
router = APIRouter(prefix="/api/v1/reports", tags=["Reports"])
|
||||
|
||||
# Shared scope rule — see app/services/report_scope.py.
|
||||
from app.services.report_scope import scoped as _scoped
|
||||
|
||||
|
||||
@router.get("/vulnerabilities/csv")
|
||||
async def export_vulnerabilities_csv(
|
||||
db: Session = Depends(get_db),
|
||||
@@ -25,7 +29,10 @@ async def export_vulnerabilities_csv(
|
||||
"""
|
||||
Exports all vulnerabilities to a CSV file.
|
||||
"""
|
||||
vulns = db.query(Vulnerability).all()
|
||||
# Same scope as the PDFs so the two exports agree, and streamed in batches:
|
||||
# .all() on ~50k findings materialised every ORM object plus the whole CSV
|
||||
# in memory before the first byte reached the client.
|
||||
vulns = _scoped(db).order_by(Vulnerability.id).yield_per(500)
|
||||
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
@@ -83,27 +90,35 @@ async def export_executive_summary_pdf(
|
||||
story.append(Paragraph(f"Date: {datetime.now().strftime('%Y-%m-%d')}", styles['Normal']))
|
||||
story.append(Spacer(1, 12))
|
||||
|
||||
# Stats
|
||||
total_vulns = db.query(Vulnerability).count()
|
||||
critical = db.query(Vulnerability).filter(Vulnerability.severity == VulnerabilitySeverity.critical).count()
|
||||
high = db.query(Vulnerability).filter(Vulnerability.severity == VulnerabilitySeverity.high).count()
|
||||
medium = db.query(Vulnerability).filter(Vulnerability.severity == VulnerabilitySeverity.medium).count()
|
||||
low = db.query(Vulnerability).filter(Vulnerability.severity == VulnerabilitySeverity.low).count()
|
||||
# Stats. The severity counts are scoped to OPEN findings on purpose: the
|
||||
# old report counted every row regardless of status, so a fully remediated
|
||||
# estate still reported hundreds of "Critical Severity" — the patched ones.
|
||||
# A reader takes those numbers as outstanding work.
|
||||
total_vulns = _scoped(db).count()
|
||||
def _open_by_sev(sev):
|
||||
return (_scoped(db)
|
||||
.filter(Vulnerability.status == VulnerabilityStatus.open,
|
||||
Vulnerability.severity == sev).count())
|
||||
|
||||
open_vulns = db.query(Vulnerability).filter(Vulnerability.status == VulnerabilityStatus.open).count()
|
||||
patched = db.query(Vulnerability).filter(Vulnerability.status == VulnerabilityStatus.patched).count()
|
||||
critical = _open_by_sev(VulnerabilitySeverity.critical)
|
||||
high = _open_by_sev(VulnerabilitySeverity.high)
|
||||
medium = _open_by_sev(VulnerabilitySeverity.medium)
|
||||
low = _open_by_sev(VulnerabilitySeverity.low)
|
||||
|
||||
open_vulns = _scoped(db).filter(Vulnerability.status == VulnerabilityStatus.open).count()
|
||||
patched = _scoped(db).filter(Vulnerability.status == VulnerabilityStatus.patched).count()
|
||||
|
||||
story.append(Paragraph("Vulnerability Overview", styles['Heading2']))
|
||||
|
||||
|
||||
data = [
|
||||
['Metric', 'Count'],
|
||||
['Total Vulnerabilities', str(total_vulns)],
|
||||
['Critical Severity', str(critical)],
|
||||
['High Severity', str(high)],
|
||||
['Medium Severity', str(medium)],
|
||||
['Low Severity', str(low)],
|
||||
['Open Status', str(open_vulns)],
|
||||
['Patched Status', str(patched)],
|
||||
['Total Findings (all statuses)', str(total_vulns)],
|
||||
['Open — Critical', str(critical)],
|
||||
['Open — High', str(high)],
|
||||
['Open — Medium', str(medium)],
|
||||
['Open — Low', str(low)],
|
||||
['Open (total)', str(open_vulns)],
|
||||
['Patched', str(patched)],
|
||||
]
|
||||
|
||||
t = Table(data)
|
||||
@@ -122,10 +137,17 @@ async def export_executive_summary_pdf(
|
||||
# Top Risks (Critical & Open)
|
||||
story.append(Paragraph("Top Priority Risks (Critical & Open)", styles['Heading2']))
|
||||
|
||||
top_risks = db.query(Vulnerability).filter(
|
||||
Vulnerability.severity == VulnerabilitySeverity.critical,
|
||||
Vulnerability.status == VulnerabilityStatus.open
|
||||
).limit(5).all()
|
||||
# "Top" has to mean something. This had a limit and no ORDER BY, so the
|
||||
# database was free to return any five critical rows it liked — whatever
|
||||
# the scan happened to insert first — under a heading promising the worst
|
||||
# five. Rank by the score the product already computes for exactly this.
|
||||
top_risks = (_scoped(db)
|
||||
.filter(Vulnerability.severity == VulnerabilitySeverity.critical,
|
||||
Vulnerability.status == VulnerabilityStatus.open)
|
||||
.order_by(Vulnerability.priority_score.desc().nullslast(),
|
||||
Vulnerability.cvss_score.desc().nullslast(),
|
||||
Vulnerability.detected_at.desc())
|
||||
.limit(5).all())
|
||||
|
||||
if top_risks:
|
||||
risk_data = [['CVE ID', 'Description', 'Asset']]
|
||||
@@ -175,13 +197,24 @@ async def export_patching_progress_pdf(
|
||||
|
||||
# Query Patched in last 30 days
|
||||
last_30_days = datetime.now() - timedelta(days=30)
|
||||
patched_vulns = db.query(Vulnerability).filter(
|
||||
_q = _scoped(db).filter(
|
||||
Vulnerability.status == VulnerabilityStatus.patched,
|
||||
Vulnerability.patched_at >= last_30_days
|
||||
).all()
|
||||
Vulnerability.patched_at >= last_30_days,
|
||||
)
|
||||
# Count in the database, list only a page of it. A single reconcile can
|
||||
# close thousands of findings at once (one Wazuh sync closed 14703 here),
|
||||
# and the old version put every one of them in the table — a PDF nobody
|
||||
# can open, built from a result set held entirely in memory.
|
||||
total_patched = _q.count()
|
||||
_ROW_CAP = 100
|
||||
patched_vulns = (_q.order_by(Vulnerability.patched_at.desc())
|
||||
.limit(_ROW_CAP).all())
|
||||
|
||||
story.append(Paragraph(f"Remediation Summary (Last 30 Days)", styles['Heading2']))
|
||||
story.append(Paragraph(f"Total Vulnerabilities Patched: {len(patched_vulns)}", styles['Normal']))
|
||||
story.append(Paragraph(f"Total Vulnerabilities Patched: {total_patched}", styles['Normal']))
|
||||
if total_patched > _ROW_CAP:
|
||||
story.append(Paragraph(
|
||||
f"Listing the {_ROW_CAP} most recent below.", styles['Normal']))
|
||||
story.append(Spacer(1, 12))
|
||||
|
||||
if patched_vulns:
|
||||
@@ -190,7 +223,7 @@ async def export_patching_progress_pdf(
|
||||
data.append([
|
||||
v.cve_id,
|
||||
v.asset.hostname if v.asset else "Unknown",
|
||||
v.severity.value,
|
||||
v.severity.value if v.severity else "—",
|
||||
v.patched_at.strftime('%Y-%m-%d') if v.patched_at else "Unknown"
|
||||
])
|
||||
|
||||
@@ -239,12 +272,20 @@ async def export_compliance_audit_pdf(
|
||||
story.append(Spacer(1, 12))
|
||||
|
||||
# Audit Logic: Check for Open Critical/High vulnerabilities
|
||||
non_compliant_vulns = db.query(Vulnerability).filter(
|
||||
_nc = _scoped(db).filter(
|
||||
Vulnerability.status == VulnerabilityStatus.open,
|
||||
Vulnerability.severity.in_([VulnerabilitySeverity.critical, VulnerabilitySeverity.high])
|
||||
).all()
|
||||
Vulnerability.severity.in_([VulnerabilitySeverity.critical,
|
||||
VulnerabilitySeverity.high]),
|
||||
)
|
||||
# Only 20 rows are ever printed, so loading every non-compliant finding
|
||||
# just to call len() on it pulled tens of thousands of ORM objects into
|
||||
# memory for a number the database can return on its own.
|
||||
non_compliant_count = _nc.count()
|
||||
non_compliant_vulns = (_nc.order_by(
|
||||
Vulnerability.priority_score.desc().nullslast(),
|
||||
Vulnerability.cvss_score.desc().nullslast()).limit(20).all())
|
||||
|
||||
is_compliant = len(non_compliant_vulns) == 0
|
||||
is_compliant = non_compliant_count == 0
|
||||
|
||||
# Status Banner
|
||||
status_text = "COMPLIANT" if is_compliant else "NON-COMPLIANT"
|
||||
@@ -264,16 +305,16 @@ async def export_compliance_audit_pdf(
|
||||
story.append(Spacer(1, 12))
|
||||
|
||||
if not is_compliant:
|
||||
story.append(Paragraph(f"Findings: {len(non_compliant_vulns)} Critical/High vulnerabilities detected which violate timely remediation requirements.", styles['BodyText']))
|
||||
story.append(Paragraph(f"Findings: {non_compliant_count} Critical/High vulnerabilities detected which violate timely remediation requirements.", styles['BodyText']))
|
||||
story.append(Spacer(1, 6))
|
||||
|
||||
data = [['CVE ID', 'Severity', 'Asset', 'Detected At']]
|
||||
for v in non_compliant_vulns[:20]: # Limit to top 20 to avoid confusing PDF overflow
|
||||
for v in non_compliant_vulns: # already capped + ranked by the query
|
||||
data.append([
|
||||
v.cve_id,
|
||||
v.severity.value,
|
||||
v.severity.value if v.severity else "—",
|
||||
v.asset.hostname if v.asset else "Unknown",
|
||||
v.detected_at.strftime('%Y-%m-%d %H:%M')
|
||||
v.detected_at.strftime('%Y-%m-%d %H:%M') if v.detected_at else "—"
|
||||
])
|
||||
|
||||
t = Table(data, colWidths=[2*inch, 1*inch, 2.5*inch, 1.5*inch])
|
||||
@@ -286,8 +327,8 @@ async def export_compliance_audit_pdf(
|
||||
]))
|
||||
story.append(t)
|
||||
|
||||
if len(non_compliant_vulns) > 20:
|
||||
story.append(Paragraph(f"...and {len(non_compliant_vulns) - 20} more items.", styles['Normal']))
|
||||
if non_compliant_count > len(non_compliant_vulns):
|
||||
story.append(Paragraph(f"...and {non_compliant_count - len(non_compliant_vulns)} more items.", styles['Normal']))
|
||||
|
||||
else:
|
||||
story.append(Paragraph("No critical or high vulnerabilities found. System management appears to be in line with control requirements.", styles['Normal']))
|
||||
|
||||
+14
-4
@@ -14,7 +14,9 @@ from app.models.scan_schedule import ScanSchedule, ScheduleInterval
|
||||
from app.models.asset import Asset
|
||||
from app.models.user import User
|
||||
from app.auth.dependencies import get_current_user, RequireEditor
|
||||
from app.routers.vulnerabilities import sync_agent_vulnerabilities
|
||||
from app.routers.vulnerabilities import (
|
||||
sync_agent_vulnerabilities, reconcile_empty_agents,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/scans", tags=["Scans"])
|
||||
|
||||
@@ -227,7 +229,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)
|
||||
):
|
||||
@@ -262,6 +264,9 @@ async def trigger_autoscan(
|
||||
|
||||
# 3. Initialize Wazuh Client
|
||||
triggered_count = 0
|
||||
# Shared across the run — an agent that returns nothing is only
|
||||
# trustworthy once another agent has proven the API answers.
|
||||
run_stats: dict = {}
|
||||
errors = []
|
||||
|
||||
try:
|
||||
@@ -286,7 +291,8 @@ async def trigger_autoscan(
|
||||
db.add(scan)
|
||||
|
||||
# Sync vulnerabilities from Wazuh
|
||||
sync_agent_vulnerabilities(db, client, asset.wazuh_agent_id, asset)
|
||||
sync_agent_vulnerabilities(db, client, asset.wazuh_agent_id,
|
||||
asset, run_stats=run_stats)
|
||||
|
||||
scan.status = ScanStatus.COMPLETED
|
||||
scan.completed_at = datetime.now()
|
||||
@@ -297,8 +303,12 @@ async def trigger_autoscan(
|
||||
scan.completed_at = datetime.now()
|
||||
scan.error_message = str(e)
|
||||
errors.append(f"{asset.hostname}: {str(e)}")
|
||||
|
||||
|
||||
db.commit()
|
||||
try:
|
||||
reconcile_empty_agents(db, run_stats)
|
||||
except Exception as e:
|
||||
errors.append(f"empty-agent reconcile: {e}")
|
||||
|
||||
except Exception as e:
|
||||
# Mark all pending scans as FAILED since Wazuh connection failed
|
||||
|
||||
+151
-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()
|
||||
@@ -146,3 +177,108 @@ async def update_setting(
|
||||
db.commit()
|
||||
db.refresh(setting)
|
||||
return _serialize(setting)
|
||||
|
||||
|
||||
# ---------- live model discovery ----------
|
||||
#
|
||||
# The model dropdown used to be a hand-written list per provider, so it was
|
||||
# wrong the day after every release — DeepSeek-V3 sat there while V3.2 shipped,
|
||||
# and picking a model the provider had retired failed only at generation time.
|
||||
# Ask the provider instead: every one of these exposes a model list.
|
||||
#
|
||||
# The key travels in the request BODY, never a query string — URLs end up in
|
||||
# proxy and access logs.
|
||||
class AIModelsRequest(BaseModel):
|
||||
provider: str
|
||||
api_token: Optional[str] = None # omit to use the saved one
|
||||
base_url: Optional[str] = None
|
||||
|
||||
|
||||
@router.post("/ai/models")
|
||||
async def list_ai_models(
|
||||
body: AIModelsRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(RequireAdmin),
|
||||
):
|
||||
"""Fetch the models the configured provider currently offers."""
|
||||
import json as _json
|
||||
import httpx
|
||||
|
||||
provider = (body.provider or "").strip().lower()
|
||||
token = (body.api_token or "").strip()
|
||||
if not token:
|
||||
# Fall back to what is stored, so the dropdown works without retyping.
|
||||
row = db.query(Setting).filter(Setting.key == "ai_config").first()
|
||||
if row and row.value:
|
||||
try:
|
||||
token = (_json.loads(row.value) or {}).get("api_token") or ""
|
||||
except (ValueError, TypeError):
|
||||
token = ""
|
||||
base = (body.base_url or "").strip().rstrip("/")
|
||||
|
||||
# (url, headers, json-path to the list, key holding the model id)
|
||||
if provider in ("openai", "deepseek", "openrouter", "groq", "mistral"):
|
||||
default_base = {
|
||||
"openai": "https://api.openai.com/v1",
|
||||
"deepseek": "https://api.deepseek.com",
|
||||
"openrouter": "https://openrouter.ai/api/v1",
|
||||
"groq": "https://api.groq.com/openai/v1",
|
||||
"mistral": "https://api.mistral.ai/v1",
|
||||
}[provider]
|
||||
url = f"{base or default_base}/models"
|
||||
headers = {"Authorization": f"Bearer {token}"} if token else {}
|
||||
path, id_key = ("data",), "id"
|
||||
elif provider == "anthropic":
|
||||
url = f"{base or 'https://api.anthropic.com/v1'}/models"
|
||||
headers = {"x-api-key": token, "anthropic-version": "2023-06-01"}
|
||||
path, id_key = ("data",), "id"
|
||||
elif provider == "gemini":
|
||||
# Google takes the key in a header too — keeps it out of the URL.
|
||||
url = f"{base or 'https://generativelanguage.googleapis.com/v1beta'}/models"
|
||||
headers = {"x-goog-api-key": token}
|
||||
path, id_key = ("models",), "name"
|
||||
elif provider == "ollama":
|
||||
# Local daemon, no key. Its list lives outside the OpenAI-shaped API.
|
||||
root = (base or "http://localhost:11434").replace("/v1/chat/completions", "")
|
||||
url = f"{root.rstrip('/')}/api/tags"
|
||||
headers = {}
|
||||
path, id_key = ("models",), "name"
|
||||
else:
|
||||
raise HTTPException(400, f"Live model listing is not supported for '{provider}'")
|
||||
|
||||
if not token and provider not in ("ollama", "openrouter"):
|
||||
raise HTTPException(400, "Enter the API key first, then load the models")
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=20.0) as client:
|
||||
r = await client.get(url, headers=headers)
|
||||
except httpx.HTTPError as e:
|
||||
raise HTTPException(502, f"Could not reach {provider}: {e}")
|
||||
if r.status_code == 401 or r.status_code == 403:
|
||||
raise HTTPException(401, f"{provider} rejected the API key")
|
||||
if r.status_code >= 400:
|
||||
raise HTTPException(502, f"{provider} returned {r.status_code}: {r.text[:200]}")
|
||||
|
||||
try:
|
||||
payload = r.json()
|
||||
except ValueError:
|
||||
raise HTTPException(502, f"{provider} returned a non-JSON response")
|
||||
items = payload
|
||||
for step in path:
|
||||
items = (items or {}).get(step) if isinstance(items, dict) else None
|
||||
if not isinstance(items, list):
|
||||
raise HTTPException(502, f"Unexpected model list shape from {provider}")
|
||||
|
||||
models = []
|
||||
for it in items:
|
||||
if not isinstance(it, dict):
|
||||
continue
|
||||
mid = it.get(id_key)
|
||||
if not mid:
|
||||
continue
|
||||
# Gemini returns "models/gemini-3-pro" — the API wants the bare id.
|
||||
mid = str(mid).split("/")[-1] if provider == "gemini" else str(mid)
|
||||
models.append({"id": mid, "label": it.get("display_name") or it.get("name") or mid})
|
||||
# Newest first where the provider says so; otherwise stable alphabetical.
|
||||
models.sort(key=lambda m: m["id"])
|
||||
return {"provider": provider, "models": models, "count": len(models)}
|
||||
|
||||
+984
-51
File diff suppressed because it is too large
Load Diff
+309
-27
@@ -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()
|
||||
@@ -115,7 +115,12 @@ async def execute_scheduled_scan(schedule_id: int):
|
||||
verify_ssl=config.get("verify_ssl", False)
|
||||
) as client:
|
||||
# Sync vulnerabilities for each agent
|
||||
from app.routers.vulnerabilities import sync_agent_vulnerabilities
|
||||
from app.routers.vulnerabilities import (
|
||||
sync_agent_vulnerabilities, reconcile_empty_agents,
|
||||
)
|
||||
# Shared across the run so an agent that returns nothing can be
|
||||
# judged against the run as a whole (see reconcile_empty_agents).
|
||||
run_stats: dict = {}
|
||||
for asset in assets:
|
||||
try:
|
||||
scan = Scan(
|
||||
@@ -126,7 +131,8 @@ async def execute_scheduled_scan(schedule_id: int):
|
||||
)
|
||||
db.add(scan)
|
||||
|
||||
sync_agent_vulnerabilities(db, client, asset.wazuh_agent_id, asset)
|
||||
sync_agent_vulnerabilities(db, client, asset.wazuh_agent_id,
|
||||
asset, run_stats=run_stats)
|
||||
|
||||
scan.status = ScanStatus.COMPLETED
|
||||
scan.completed_at = datetime.now()
|
||||
@@ -137,6 +143,13 @@ async def execute_scheduled_scan(schedule_id: int):
|
||||
errors.append(f"{asset.hostname}: {e}")
|
||||
logger.error(f"Scheduled scan error for {asset.hostname}: {e}")
|
||||
|
||||
# Now that the run is done, agents that returned nothing can be
|
||||
# judged: real emptiness if the API answered for anyone else.
|
||||
try:
|
||||
reconcile_empty_agents(db, run_stats)
|
||||
except Exception as e:
|
||||
logger.error(f"Wazuh empty-agent reconcile failed: {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Wazuh connection error during scheduled scan: {e}")
|
||||
|
||||
@@ -153,7 +166,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 +360,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 +423,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 +441,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 +462,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 +537,26 @@ async def eol_check_nightly():
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
if not eol_service.resolve_product_slug(name):
|
||||
continue
|
||||
try:
|
||||
status = eol_service.check_eol(db, name, version)
|
||||
except Exception:
|
||||
continue
|
||||
if not status or not (status.is_eol or status.is_eol_soon or status.is_eoas):
|
||||
status = None
|
||||
if eol_service.resolve_product_slug(name):
|
||||
try:
|
||||
status = eol_service.check_eol(db, name, version)
|
||||
except Exception:
|
||||
status = None
|
||||
actionable = status and (status.is_eol or status.is_eol_soon or status.is_eoas)
|
||||
# MS-lifecycle fallback whenever endoflife.date had nothing
|
||||
# actionable (covers VC++ Redistributables, exotics, and MS
|
||||
# products endoflife.date can't resolve a release for).
|
||||
if not actionable:
|
||||
try:
|
||||
from app.services import ms_lifecycle_service
|
||||
ms_status = ms_lifecycle_service.resolve_ms_lifecycle_eol(db, name, version)
|
||||
if ms_status and (ms_status.is_eol or ms_status.is_eol_soon):
|
||||
status = ms_status
|
||||
actionable = True
|
||||
except Exception as e:
|
||||
logger.debug("MS-lifecycle nightly fallback failed for %s: %s", name, e)
|
||||
if not actionable:
|
||||
continue
|
||||
try:
|
||||
eol_service.upsert_eol_vulnerability(
|
||||
@@ -552,7 +578,148 @@ 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)
|
||||
# FP-suppression is part of run_app_cve_scan itself now, so every way of
|
||||
# starting a scan — nightly, GUI, single asset — produces the same
|
||||
# result. It used to hang off this job alone.
|
||||
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"})
|
||||
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 +758,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 +773,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 +807,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 +841,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 +863,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 +966,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:
|
||||
@@ -843,7 +1052,7 @@ def start_scheduler():
|
||||
# without spamming the GitHub raw API per-CVE.
|
||||
scheduler.add_job(
|
||||
refresh_cisa_vulnrichment,
|
||||
trigger=CronTrigger(hour=3, minute=0),
|
||||
trigger=CronTrigger(hour=4, minute=30),
|
||||
id="vulnrichment_nightly",
|
||||
name="Nightly CISA Vulnrichment CVSS / SSVC Correction",
|
||||
replace_existing=True,
|
||||
@@ -865,7 +1074,7 @@ def start_scheduler():
|
||||
# Also prunes asset_risk_snapshots older than 90 days.
|
||||
scheduler.add_job(
|
||||
recompute_urs_nightly,
|
||||
trigger=CronTrigger(hour=4, minute=0),
|
||||
trigger=CronTrigger(hour=5, minute=10),
|
||||
id="urs_nightly",
|
||||
name="Nightly URS Recompute + Snapshot Prune",
|
||||
replace_existing=True,
|
||||
@@ -876,7 +1085,7 @@ def start_scheduler():
|
||||
# (default 30), revive recently-seen ones. Runs after URS.
|
||||
scheduler.add_job(
|
||||
reconcile_assets_nightly,
|
||||
trigger=CronTrigger(hour=4, minute=15),
|
||||
trigger=CronTrigger(hour=5, minute=25),
|
||||
id="asset_lifecycle_nightly",
|
||||
name="Nightly Asset Lifecycle Reconcile",
|
||||
replace_existing=True,
|
||||
@@ -892,13 +1101,32 @@ 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
|
||||
# forever). Covers ISO 27001 / SOX / DSGVO Art.5 windows.
|
||||
scheduler.add_job(
|
||||
prune_audit_logs_nightly,
|
||||
trigger=CronTrigger(hour=3, minute=30),
|
||||
trigger=CronTrigger(hour=5, minute=40),
|
||||
id="audit_log_prune_nightly",
|
||||
name="Nightly Audit-Log Retention Prune",
|
||||
replace_existing=True,
|
||||
@@ -909,23 +1137,77 @@ def start_scheduler():
|
||||
# detection). Creates pseudo-CVEs (cve_id starts with "EOL-").
|
||||
scheduler.add_job(
|
||||
eol_check_nightly,
|
||||
trigger=CronTrigger(hour=3, minute=15),
|
||||
trigger=CronTrigger(hour=3, minute=0),
|
||||
id="eol_check_nightly",
|
||||
name="Nightly endoflife.date EOL Detection",
|
||||
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=10),
|
||||
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.
|
||||
scheduler.add_job(
|
||||
exploit_intel_nightly,
|
||||
trigger=CronTrigger(hour=3, minute=45),
|
||||
trigger=CronTrigger(hour=4, minute=50),
|
||||
id="exploit_intel_nightly",
|
||||
name="Nightly Public-Exploit Catalog Refresh",
|
||||
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=20),
|
||||
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=3, minute=50),
|
||||
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,166 @@
|
||||
"""
|
||||
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/psirtrss20/CiscoSecurityAdvisory.xml", "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:
|
||||
for f in cfg: # migrate stale Cisco RSS URL (rss.x?i=44 → DTD-refused)
|
||||
if isinstance(f.get("url"), str) and "rss.x?i=44" in f["url"]:
|
||||
f["url"] = "https://sec.cloudapps.cisco.com/security/center/psirtrss20/CiscoSecurityAdvisory.xml"
|
||||
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,264 @@
|
||||
"""
|
||||
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]
|
||||
from app.services.audit_events import reopen_if_patched
|
||||
reopen_if_patched(db, existing, reason="Android patch-level check reports this finding again", source="android_cve")
|
||||
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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,204 @@
|
||||
"""
|
||||
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, timedelta
|
||||
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
|
||||
|
||||
|
||||
# How far behind reality each source's inventory can be, and therefore how
|
||||
# long after a close its "still vulnerable" claim is not trustworthy.
|
||||
#
|
||||
# Measured against how the data actually arrives, not against the scan
|
||||
# schedule: Wazuh syscollector is close to live, Intune depends on the
|
||||
# tenant's inventory-refresh policy, and Defender TVM's software list is the
|
||||
# slowest of the three by a wide margin. Sources that read a live inventory
|
||||
# themselves (wazuh_sync, app_scan, msrc, nessus) get no grace — when they say
|
||||
# it is back, it is back.
|
||||
_REOPEN_GRACE = {
|
||||
"defender": timedelta(days=3),
|
||||
"intune": timedelta(days=1),
|
||||
"m365_check": timedelta(days=1), # same Graph inventory as Intune
|
||||
"mobile_eol": timedelta(days=1),
|
||||
"android_cve": timedelta(days=1),
|
||||
}
|
||||
|
||||
|
||||
def reopen_if_patched(db: Session, vuln, *, reason: str, source: str) -> bool:
|
||||
"""Flip a patched finding back to OPEN and AUDIT the transition.
|
||||
|
||||
Every scanner reopens findings it sees again after they were closed, but
|
||||
each one did it inline without writing a status-change row — so only the
|
||||
positive direction (open → patched) ever appeared in the audit log and the
|
||||
per-CVE Change History. A finding could silently go patched → open, which
|
||||
is exactly the transition an auditor most wants to see (tester flagged it).
|
||||
|
||||
Returns True when a reopen actually happened.
|
||||
"""
|
||||
from app.models.vulnerability import VulnerabilityStatus
|
||||
if vuln.status != VulnerabilityStatus.patched:
|
||||
return False
|
||||
# Don't let a slow source undo a fast source's conclusion.
|
||||
#
|
||||
# The three inventories do not see the same moment in time. Wazuh
|
||||
# syscollector is close to live, Intune depends on how aggressively the
|
||||
# tenant's policies push an inventory refresh, and Defender TVM's software
|
||||
# list trails by days. So the normal sequence after patching a host is:
|
||||
# the fast source stops reporting the CVE and the finding closes — then the
|
||||
# slow source runs, still holding its old picture, and reopens it. Next
|
||||
# night the same thing again. The finding flaps, and its history fills with
|
||||
# transitions that describe our polling, not the host.
|
||||
#
|
||||
# A source may therefore only reopen a finding once its own lag has had
|
||||
# time to pass. Below that, its claim is about a state the other source has
|
||||
# already superseded.
|
||||
grace = _REOPEN_GRACE.get((source or "").lower())
|
||||
if grace and vuln.patched_at and (datetime.now() - vuln.patched_at) < grace:
|
||||
logger.debug(
|
||||
"reopen from %s ignored for %s — patched %s ago, within its %s lag",
|
||||
source, vuln.cve_id, datetime.now() - vuln.patched_at, grace)
|
||||
return False
|
||||
old_status = vuln.status
|
||||
vuln.status = VulnerabilityStatus.open
|
||||
vuln.patched_at = None
|
||||
try:
|
||||
from app.routers.vulnerabilities import log_vulnerability_change
|
||||
# Name the host so the row is attributable and searchable. Lazy-loads
|
||||
# the asset, which is fine: reopens are rare (unlike bulk resolves).
|
||||
hostname = None
|
||||
try:
|
||||
hostname = vuln.asset.hostname if vuln.asset else None
|
||||
except Exception:
|
||||
hostname = None
|
||||
log_vulnerability_change(
|
||||
db, None, vuln.id, old_status, vuln.status,
|
||||
reason=reason, cve_id=vuln.cve_id, source=source, hostname=hostname,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("audit log for reopen failed (vuln_id=%s): %s", vuln.id, e)
|
||||
return True
|
||||
|
||||
|
||||
def record_affected_package(db: Session, vuln, *, name: str, version: str = None,
|
||||
fixed_version: str = None, source: str = None) -> None:
|
||||
"""Record ONE affected product on a finding, in vulnerability_packages.
|
||||
|
||||
A finding is unique per (cve_id, asset_id), so when two DIFFERENT products
|
||||
on the same host are hit by the same CVE — e.g. CVE-2026-16417 affects both
|
||||
Google Chrome and Microsoft Edge, which carry completely different build
|
||||
schemes — the single row's package_name can only name one of them. The
|
||||
per-package table is what keeps both visible (the CVE detail page already
|
||||
renders every entry, and the API returns them as `packages`).
|
||||
|
||||
Idempotent per (vulnerability, package name): re-detection refreshes the
|
||||
version/fix and last_seen_at instead of appending duplicates.
|
||||
"""
|
||||
from app.models.vulnerability_package import VulnerabilityPackage
|
||||
if not name:
|
||||
return
|
||||
name = name[:255]
|
||||
now = datetime.now()
|
||||
try:
|
||||
row = (db.query(VulnerabilityPackage)
|
||||
.filter(VulnerabilityPackage.vulnerability_id == vuln.id,
|
||||
VulnerabilityPackage.package_name == name)
|
||||
.first())
|
||||
if row is None:
|
||||
# A plain query does NOT see rows added earlier in this same
|
||||
# (unflushed) transaction, and one scan run legitimately hits the
|
||||
# same (finding, product) twice — e.g. several inventory entries
|
||||
# carrying the same product name, or two CVE entries for one
|
||||
# package. Without this the second add hit uq_vulnpkg_vuln_package
|
||||
# and the IntegrityError aborted the WHOLE app-scan with a 502.
|
||||
for pending in db.new:
|
||||
if (isinstance(pending, VulnerabilityPackage)
|
||||
and pending.vulnerability_id == vuln.id
|
||||
and pending.package_name == name):
|
||||
row = pending
|
||||
break
|
||||
if row is not None:
|
||||
if version:
|
||||
row.package_version = version[:100]
|
||||
if fixed_version:
|
||||
row.fixed_version = fixed_version[:100]
|
||||
# Provenance is per-package and cumulative: a product confirmed by
|
||||
# app-scan AND MSRC must show BOTH, otherwise whichever scanner
|
||||
# wrote first owns the "via …" line forever (tester: an app-scan
|
||||
# detection kept showing 'via MSRC' after the MSRC run).
|
||||
if source:
|
||||
have = [s for s in (row.source or "").split(",") if s]
|
||||
if source not in have:
|
||||
have.append(source)
|
||||
row.source = ",".join(have)[:60]
|
||||
row.last_seen_at = now
|
||||
return
|
||||
# Savepoint so a lost race (parallel scan inserting the same pair)
|
||||
# rolls back only this insert, never the caller's transaction.
|
||||
with db.begin_nested():
|
||||
db.add(VulnerabilityPackage(
|
||||
vulnerability_id=vuln.id,
|
||||
package_name=name,
|
||||
package_version=(version or None) and version[:100],
|
||||
fixed_version=(fixed_version or None) and fixed_version[:100],
|
||||
source=source,
|
||||
first_detected_at=now,
|
||||
last_seen_at=now,
|
||||
))
|
||||
except Exception as e:
|
||||
# Never let per-package bookkeeping break a scan — it is display data.
|
||||
logger.warning("per-package record skipped (vuln_id=%s, %s): %s",
|
||||
vuln.id, name, e)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,291 @@
|
||||
"""
|
||||
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,
|
||||
vendor: 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()
|
||||
)
|
||||
# Defender reports the CVE, not the build it sits on: the installed version
|
||||
# is never in the payload, and for an OS-level CVE the software label is
|
||||
# missing entirely — those rows showed an empty package and an empty
|
||||
# "Installed", which reads as "we know nothing" when the asset record has
|
||||
# had the OS and its version all along (tester, CVE-2026-64726 on iPhones).
|
||||
label = software or (asset.operating_system or None)
|
||||
installed = asset.os_version if not software else None
|
||||
|
||||
if existing:
|
||||
existing.add_source(SOURCE_NAME)
|
||||
# Fill the affected-software/package column if it was empty.
|
||||
if label and not existing.package_name:
|
||||
existing.package_name = label[:255]
|
||||
if installed and not existing.package_version:
|
||||
existing.package_version = installed[:100]
|
||||
if vendor and not existing.package_vendor:
|
||||
existing.package_vendor = vendor[:255]
|
||||
from app.services.audit_events import reopen_if_patched
|
||||
reopen_if_patched(db, existing, reason="Defender TVM reports this CVE on the device again", source="defender")
|
||||
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=(label[:255] if label else None),
|
||||
package_version=(installed[:100] if installed else None),
|
||||
package_vendor=(vendor[:255] if vendor 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": label, "vendor": vendor or None}
|
||||
except Exception as e:
|
||||
logger.debug("defender software map build failed: %s", e)
|
||||
|
||||
# asset id → the union of CVEs every machine behind it reported.
|
||||
seen_by_asset: dict = {}
|
||||
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)
|
||||
sw = sw_map.get((m.get("id", ""), cve)) or {}
|
||||
_upsert_cve(db, asset, v, new_ids,
|
||||
software=sw.get("label"), vendor=sw.get("vendor"))
|
||||
stats["cve_rows"] += 1
|
||||
# Collect, resolve later. Several Defender machines can map to ONE
|
||||
# asset — a re-imaged or dual-registered device keeps its old
|
||||
# machine entry — and resolving per machine made them fight: the
|
||||
# machine that no longer lists the CVE closes the finding, the one
|
||||
# that still lists it reopens it a minute later, every sync
|
||||
# (tester: CVE-2026-66313, patched 13:41, open 13:42, patched
|
||||
# 15:00). A finding may only be closed once EVERY machine behind
|
||||
# the asset has been asked.
|
||||
seen_by_asset.setdefault(asset.id, {"asset": asset, "cves": set(),
|
||||
"any": False})
|
||||
seen_by_asset[asset.id]["cves"] |= seen_cves
|
||||
if seen_cves:
|
||||
seen_by_asset[asset.id]["any"] = True
|
||||
except Exception as e:
|
||||
stats["errors"].append(f"machine {m.get('computerDnsName')}: {e}")
|
||||
db.commit()
|
||||
|
||||
# Every machine has been asked, so each asset's CVE union is complete now.
|
||||
# Guarded to non-empty responses so a transient or clean read can't
|
||||
# mass-close (same safety as the Nessus and app-scan backfills).
|
||||
for entry in seen_by_asset.values():
|
||||
if entry["any"]:
|
||||
stats["resolved"] = stats.get("resolved", 0) + _resolve_stale(
|
||||
db, entry["asset"], entry["cves"])
|
||||
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 = []
|
||||
@@ -596,11 +836,23 @@ def enrich_vulnerabilities(
|
||||
vuln.refresh_scores()
|
||||
|
||||
db.commit()
|
||||
|
||||
# Mozilla MFSA severity for fresh Firefox CVEs — Mozilla's authoritative
|
||||
# `impact` fills the placeholder severity when NVD/cvelistV5 have no score
|
||||
# yet (the gap the tester hit on brand-new Firefox CVEs). Best-effort.
|
||||
try:
|
||||
from app.services import mozilla_advisory_service
|
||||
stats["mozilla_severity"] = mozilla_advisory_service.apply_mozilla_severity(db, cve_ids)
|
||||
except Exception as e:
|
||||
logger.debug("Mozilla MFSA enrichment skipped: %s", e)
|
||||
|
||||
logger.info(
|
||||
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']}, "
|
||||
f"mozilla_severity={stats.get('mozilla_severity', 0)}"
|
||||
)
|
||||
return stats
|
||||
|
||||
|
||||
+264
-10
@@ -73,12 +73,28 @@ _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",
|
||||
"microsoftofficeformac": "ms-office",
|
||||
"office": "ms-office",
|
||||
"microsoftedge": "microsoft-edge",
|
||||
# Microsoft Edge is deliberately NOT mapped. endoflife.date carries no
|
||||
# record for it at all — the "microsoft-edge" slug 404s, so every EOL check
|
||||
# spent a request finding that out. Edge follows the Modern Lifecycle
|
||||
# Policy: it has no end-of-life date as long as it stays current, so "is
|
||||
# this version too old" is a patch question, which the CVE scan already
|
||||
# answers. Nothing is lost by leaving it out.
|
||||
"powershell": "powershell",
|
||||
"dotnet": "dotnet",
|
||||
"dotnetframework": "dotnetfx",
|
||||
@@ -108,11 +124,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 +174,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 +353,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 +606,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,
|
||||
*,
|
||||
@@ -578,15 +655,31 @@ def upsert_eol_vulnerability(
|
||||
|
||||
cve_id = _pseudo_cve_id(status.product_slug or "unknown", status.release_name or "unknown")
|
||||
|
||||
# Three-tier severity:
|
||||
# Severity tiers:
|
||||
# EOL 1000+ days → CRITICAL, cvss 9.8, title "EOL 1000d+"
|
||||
# EOL (security ended) → HIGH, cvss 9.0, title "EOL"
|
||||
# EOL SOON (≤90d) → MEDIUM, cvss 5.5, title "EOL SOON"
|
||||
# EOAS only (still patched)→ LOW, cvss 3.0, title "end-of-active-support"
|
||||
# The days-past-EOL escalation mirrors the endoflife.date/Wazuh EOL model:
|
||||
# the longer a product has been unpatched, the higher the standing risk.
|
||||
if status.is_eol:
|
||||
severity = VulnerabilitySeverity.high
|
||||
cvss = 9.0
|
||||
state_label = "EOL"
|
||||
state_desc = "EOL (no further security patches)."
|
||||
# days_to_eol is signed (negative = past); guard the bool-true case
|
||||
# (endoflife eolFrom=true, no date → days unknown).
|
||||
days_past = (-status.days_to_eol
|
||||
if status.days_to_eol is not None and status.days_to_eol < 0
|
||||
else None)
|
||||
if days_past is not None and days_past >= 1000:
|
||||
severity = VulnerabilitySeverity.critical
|
||||
cvss = 9.8
|
||||
state_label = f"EOL {days_past}d"
|
||||
state_desc = f"EOL for {days_past} days (no security patches — critical exposure)."
|
||||
else:
|
||||
severity = VulnerabilitySeverity.high
|
||||
cvss = 9.0
|
||||
state_label = f"EOL {days_past}d" if days_past is not None else "EOL"
|
||||
state_desc = (f"EOL for {days_past} days (no further security patches)."
|
||||
if days_past is not None
|
||||
else "EOL (no further security patches).")
|
||||
elif status.is_eol_soon:
|
||||
severity = VulnerabilitySeverity.medium
|
||||
cvss = 5.5
|
||||
@@ -618,6 +711,13 @@ def upsert_eol_vulnerability(
|
||||
if status.latest_version:
|
||||
desc_lines.append(f"Latest supported release: {status.latest_version} ({status.latest_date or 'date unknown'}).")
|
||||
desc_lines.append(f"Installed on this host: {installed_version}.")
|
||||
if status.is_eol:
|
||||
# Running EOL software is an explicit control failure in the major
|
||||
# frameworks — surface the mapping so audits/reports can cite it.
|
||||
desc_lines.append(
|
||||
"Compliance: running end-of-life software violates PCI-DSS 6.3.3, "
|
||||
"NIST 800-53 CM-8, and HIPAA 164.312(a)(1)."
|
||||
)
|
||||
description = "\n".join(desc_lines)
|
||||
|
||||
if existing:
|
||||
@@ -626,9 +726,8 @@ def upsert_eol_vulnerability(
|
||||
existing.description = description
|
||||
existing.package_version = installed_version[:100]
|
||||
existing.fixed_version = (status.latest_version or None)
|
||||
if existing.status == VulnerabilityStatus.patched:
|
||||
existing.status = VulnerabilityStatus.open
|
||||
existing.patched_at = None
|
||||
from app.services.audit_events import reopen_if_patched
|
||||
reopen_if_patched(db, existing, reason="endoflife.date check reports this product as EOL again", source="eol_check")
|
||||
# Resync bumps detected_at so the Newly EOL/EOS widget ranks the
|
||||
# freshest finding first.
|
||||
existing.detected_at = datetime.now()
|
||||
@@ -636,6 +735,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 +759,158 @@ 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()
|
||||
kept_msl: set = set() # MS-lifecycle EOL cve_ids still valid this run
|
||||
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
|
||||
if status and status.product_slug == "ms-lifecycle":
|
||||
kept_msl.add(_pseudo_cve_id("ms-lifecycle", status.release_name or "unknown"))
|
||||
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)
|
||||
|
||||
# Guard on a non-empty inventory: an empty list is a transient/failed read,
|
||||
# not proof the products are gone (same safety as the other reconciles).
|
||||
if packages:
|
||||
_resolve_stale_ms_lifecycle(db, asset.id, kept_msl)
|
||||
return count
|
||||
|
||||
|
||||
def _resolve_stale_ms_lifecycle(db: "Session", asset_id: int, kept: set) -> None:
|
||||
"""Resolve open MS-lifecycle EOL findings the current run no longer produced.
|
||||
|
||||
Without this, fixing a bad name→product match (tester: 'Microsoft Edge'
|
||||
browser mis-mapped to the 'Azure Stack Edge' listing) left the wrong finding
|
||||
open forever, since _supersede_old_eol only fires when a REPLACEMENT is
|
||||
created. Scoped to the EOL-MS-LIFECYCLE- prefix, which only the package path
|
||||
produces — OS-level endoflife.date findings use other slugs and are
|
||||
untouched. Only reconciles when at least one package was inventoried (empty
|
||||
package list = nothing to conclude)."""
|
||||
from app.models.vulnerability import Vulnerability, VulnerabilityStatus
|
||||
stale = (
|
||||
db.query(Vulnerability)
|
||||
.filter(Vulnerability.asset_id == asset_id,
|
||||
Vulnerability.status == VulnerabilityStatus.open,
|
||||
Vulnerability.cve_id.like("EOL-MS-LIFECYCLE-%"))
|
||||
.all()
|
||||
)
|
||||
for v in stale:
|
||||
if v.cve_id in kept:
|
||||
continue
|
||||
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="MS lifecycle no longer matches this installed product "
|
||||
"(re-evaluated — not end-of-life)",
|
||||
cve_id=v.cve_id, source="eol_reconcile",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("audit log for MS-lifecycle reconcile failed (vuln_id=%s): %s", v.id, e)
|
||||
|
||||
|
||||
def revalidate_ms_lifecycle_findings(db: "Session") -> int:
|
||||
"""Re-check every OPEN MS-lifecycle EOL finding and close the ones that no
|
||||
longer match. Returns how many were closed.
|
||||
|
||||
Path-independent on purpose. _resolve_stale_ms_lifecycle only runs inside
|
||||
run_eol_for_packages, but the EOL-check endpoint (the button) has its own
|
||||
loop and never called it — so a finding produced by a since-fixed name match
|
||||
stayed open forever (tester: 'Microsoft Edge' the browser matched the
|
||||
'Azure Stack Edge' listing; the match was fixed, the finding was not).
|
||||
Re-asking the resolver per finding is cheap: the lifecycle rows are memoised
|
||||
in-process, so this costs one fetch at most.
|
||||
"""
|
||||
from app.models.vulnerability import Vulnerability, VulnerabilityStatus
|
||||
from app.services import ms_lifecycle_service
|
||||
|
||||
rows = (db.query(Vulnerability)
|
||||
.filter(Vulnerability.status == VulnerabilityStatus.open,
|
||||
Vulnerability.cve_id.like("EOL-MS-LIFECYCLE-%"))
|
||||
.all())
|
||||
closed = 0
|
||||
for v in rows:
|
||||
name = (v.package_name or "").strip()
|
||||
if not name:
|
||||
continue
|
||||
try:
|
||||
st = ms_lifecycle_service.resolve_ms_lifecycle_eol(
|
||||
db, name, v.package_version or "")
|
||||
except Exception as e:
|
||||
logger.debug("MS-lifecycle revalidate failed for %s: %s", name, e)
|
||||
continue # unreachable source → leave the finding alone
|
||||
if st and (st.is_eol or st.is_eol_soon):
|
||||
continue # still EOL → keep
|
||||
old_status = v.status
|
||||
v.status = VulnerabilityStatus.patched
|
||||
v.patched_at = datetime.now()
|
||||
closed += 1
|
||||
try:
|
||||
from app.routers.vulnerabilities import log_vulnerability_change
|
||||
log_vulnerability_change(
|
||||
db, None, v.id, old_status, v.status,
|
||||
reason=f"MS lifecycle no longer reports '{name}' as end-of-life "
|
||||
f"(re-evaluated — earlier match was wrong)",
|
||||
cve_id=v.cve_id, source="eol_revalidate",
|
||||
hostname=(v.asset.hostname if v.asset else None),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("audit log for MS-lifecycle revalidate failed (%s): %s", v.id, e)
|
||||
if closed:
|
||||
db.commit()
|
||||
logger.info("MS-lifecycle revalidate: closed %d stale finding(s)", closed)
|
||||
return closed
|
||||
|
||||
@@ -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.
|
||||
@@ -77,22 +82,40 @@ def analyze_ports(ports: List[dict]) -> tuple:
|
||||
# Only listening sockets count as exposure.
|
||||
state = str(p.get("state") or "").lower()
|
||||
proto = str(p.get("protocol") or p.get("proto") or "").lower()
|
||||
# A listener is the normal proof that a service is running. But Wazuh
|
||||
# does not always report one: on a Windows Server 2025 DC the tester saw
|
||||
# 3389 ESTABLISHED in netstat and no listening entry from syscollector
|
||||
# at all, so RDP scored zero exposure on a box serving live RDP
|
||||
# sessions. An ESTABLISHED socket whose LOCAL port is the well-known
|
||||
# one is an inbound connection, which proves the service is there just
|
||||
# as well. (Outbound connections carry an ephemeral local port, so they
|
||||
# cannot be mistaken for this.)
|
||||
if proto == "tcp" and state and state != "listening":
|
||||
continue
|
||||
if state != "established":
|
||||
continue
|
||||
try:
|
||||
port = int(p.get("local_port") or p.get("local", {}).get("port") or 0)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
# An ESTABLISHED socket only counts when its LOCAL port is one of the
|
||||
# known service ports — otherwise it is the ephemeral end of an
|
||||
# outbound connection and proves nothing about this host.
|
||||
if port <= 0 or port not in _RISKY_PORTS:
|
||||
continue
|
||||
local_ip = p.get("local_ip") or (p.get("local") or {}).get("ip") or ""
|
||||
if not _is_externally_bound(local_ip):
|
||||
continue
|
||||
label, weight = _RISKY_PORTS[port]
|
||||
key = (port, proto)
|
||||
if key in seen:
|
||||
# Deduplicate by PORT alone. It used to include the protocol, which was
|
||||
# harmless while only listeners counted, but a busy host has many
|
||||
# ESTABLISHED sockets on the same service port — and tcp vs tcp6 made
|
||||
# even the listeners look like two services. The tester's DC listed
|
||||
# "RDP :3389" four times and LDAP four times, and since every extra
|
||||
# entry adds 40% of its weight, the exposure score inflated to 100 on
|
||||
# what is really one RDP and one LDAP service.
|
||||
if port in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
seen.add(port)
|
||||
risky.append({
|
||||
"port": port,
|
||||
"proto": proto or "tcp",
|
||||
@@ -130,7 +153,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,273 @@
|
||||
"""
|
||||
GitHub REPOSITORY security advisories → CVEs for desktop software.
|
||||
|
||||
Different source from the GLOBAL advisory API (api.github.com/advisories):
|
||||
that one mirrors NVD and its 'unreviewed' entries carry no version data at
|
||||
all. The per-repo endpoint
|
||||
|
||||
https://api.github.com/repos/{owner}/{repo}/security-advisories
|
||||
|
||||
is what a project maintainer publishes themselves, and it DOES carry
|
||||
`vulnerable_version_range` + `patched_versions` (verified live against
|
||||
notepad-plus-plus: 18 advisories, all 18 with a range, 12 with a CVE id).
|
||||
|
||||
That closes a real gap: Notepad++ CVEs (CVE-2026-57233, -54758, -52886, …)
|
||||
appear in neither NVD nor cvelistV5, so no other scanner path can see them.
|
||||
|
||||
Scope: products in `_REPO_MAP` whose upstream publishes advisories this way.
|
||||
Findings are written through the app-scan upsert with the app-scan source, so
|
||||
the existing app-scan reconcile auto-resolves them once the host updates —
|
||||
no separate source/reconcile pair to maintain.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.setting import Setting
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
INDEX_SETTING = "github_repo_advisory_cache"
|
||||
INDEX_TS_SETTING = "github_repo_advisory_cache_ts"
|
||||
TTL_HOURS = 24
|
||||
_API = "https://api.github.com/repos/{repo}/security-advisories?per_page=100"
|
||||
|
||||
# Installed-software name → GitHub repo publishing advisories for it.
|
||||
# Add a line per product; everything else is untouched.
|
||||
_REPO_MAP: List[Tuple[re.Pattern, str]] = [
|
||||
(re.compile(r"notepad\+\+", re.I), "notepad-plus-plus/notepad-plus-plus"),
|
||||
]
|
||||
|
||||
_SEV = {"critical": "critical", "high": "high", "moderate": "medium",
|
||||
"medium": "medium", "low": "low"}
|
||||
|
||||
|
||||
def resolve_repo(product_name: str) -> Optional[str]:
|
||||
n = (product_name or "").strip()
|
||||
for rx, repo in _REPO_MAP:
|
||||
if rx.search(n):
|
||||
return repo
|
||||
return None
|
||||
|
||||
|
||||
def _vt(s: Optional[str]) -> Optional[tuple]:
|
||||
"""'v8.9.6.4' → (8,9,6,4). None when not dotted-numeric."""
|
||||
s = (s or "").strip().lstrip("vV").strip()
|
||||
if not re.fullmatch(r"\d+(\.\d+)*", s):
|
||||
return None
|
||||
return tuple(int(x) for x in s.split("."))
|
||||
|
||||
|
||||
def is_affected(installed: str, rng: Optional[str], patched: Optional[str]) -> bool:
|
||||
"""Is `installed` inside the advisory's vulnerable range?
|
||||
|
||||
`vulnerable_version_range` is maintainer free-text — the real Notepad++
|
||||
feed uses '<= v8.9.6.4', '< v8.9.6.4', '<=8.9.1', 'old versions - 8.8.1',
|
||||
'v8.9.4 & v8.9.5' and bare single versions. `patched_versions` is always a
|
||||
clean single literal, so it doubles as the safety net: at/past the patched
|
||||
release is never affected, whatever the range text says.
|
||||
"""
|
||||
it = _vt(installed)
|
||||
if not it:
|
||||
return False
|
||||
pt = _vt(patched)
|
||||
if pt and it >= pt:
|
||||
return False # patched — hard stop, no FP possible
|
||||
|
||||
r = (rng or "").strip()
|
||||
m = re.match(r"^<=\s*v?([\d.]+)$", r, re.I)
|
||||
if m:
|
||||
b = _vt(m.group(1))
|
||||
return bool(b and it <= b)
|
||||
m = re.match(r"^<\s*v?([\d.]+)$", r, re.I)
|
||||
if m:
|
||||
b = _vt(m.group(1))
|
||||
return bool(b and it < b)
|
||||
m = re.match(r"^old versions\s*-\s*v?([\d.]+)$", r, re.I)
|
||||
if m:
|
||||
b = _vt(m.group(1))
|
||||
return bool(b and it <= b)
|
||||
parts = [p for p in re.split(r"[&,]", r) if p.strip()]
|
||||
if len(parts) > 1: # explicit list — exact matches only,
|
||||
return any(_vt(p) == it for p in parts) # older builds are NOT affected
|
||||
if len(parts) == 1 and _vt(parts[0]):
|
||||
return _vt(parts[0]) == it # single exact version
|
||||
return bool(pt and it < pt) # unparseable range → below-patched rule
|
||||
|
||||
|
||||
def _cvss_of(adv: dict) -> Optional[float]:
|
||||
score = (adv.get("cvss") or {}).get("score")
|
||||
if not score:
|
||||
sev = adv.get("cvss_severities") or {}
|
||||
for k in ("cvss_v4", "cvss_v3"):
|
||||
s = (sev.get(k) or {}).get("score")
|
||||
if s:
|
||||
score = s
|
||||
break
|
||||
try:
|
||||
return float(score) if score else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def build_index(db: Session) -> Dict[str, list]:
|
||||
"""{repo: [{cve, cvss, sev, range, patched, summary}, …]}, cached 24h."""
|
||||
import httpx
|
||||
from app.auth.setting_crypto import read_setting_value
|
||||
|
||||
headers = {"Accept": "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28"}
|
||||
try:
|
||||
pat = (read_setting_value(db, "github_pat") or "").strip()
|
||||
if pat:
|
||||
headers["Authorization"] = f"Bearer {pat}"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
index: Dict[str, list] = {}
|
||||
with httpx.Client(timeout=20.0, follow_redirects=True, headers=headers) as client:
|
||||
for _, repo in _REPO_MAP:
|
||||
if repo in index:
|
||||
continue
|
||||
try:
|
||||
r = client.get(_API.format(repo=repo))
|
||||
if r.status_code == 403 and r.headers.get("x-ratelimit-remaining") == "0":
|
||||
logger.warning("repo-advisories: GitHub rate limit hit — "
|
||||
"set github_pat for 5000 req/h")
|
||||
break
|
||||
if r.status_code != 200:
|
||||
logger.debug("repo-advisories: %s → HTTP %s", repo, r.status_code)
|
||||
continue
|
||||
entries = []
|
||||
for adv in r.json() or []:
|
||||
cve = (adv.get("cve_id") or "").strip().upper()
|
||||
# CVE-less advisories (GHSA id only) are skipped: the CVE id
|
||||
# is what the rest of the pipeline (enrichment, KEV/EPSS,
|
||||
# dedup across scanners) keys on.
|
||||
if not cve.startswith("CVE-"):
|
||||
continue
|
||||
if adv.get("withdrawn_at"):
|
||||
continue
|
||||
vulns = adv.get("vulnerabilities") or []
|
||||
v0 = vulns[0] if vulns else {}
|
||||
entries.append({
|
||||
"cve": cve,
|
||||
"cvss": _cvss_of(adv),
|
||||
"sev": _SEV.get((adv.get("severity") or "").lower()),
|
||||
"range": v0.get("vulnerable_version_range"),
|
||||
"patched": v0.get("patched_versions"),
|
||||
"summary": (adv.get("summary") or "")[:400],
|
||||
})
|
||||
index[repo] = entries
|
||||
logger.info("repo-advisories: %s → %d CVE advisories", repo, len(entries))
|
||||
except Exception as e:
|
||||
logger.debug("repo-advisories: fetch %s failed: %s", repo, e)
|
||||
|
||||
_store(db, index)
|
||||
return index
|
||||
|
||||
|
||||
def _store(db: Session, index: Dict[str, list]) -> None:
|
||||
for key, val in ((INDEX_SETTING, json.dumps(index)),
|
||||
(INDEX_TS_SETTING, datetime.now().isoformat())):
|
||||
row = db.query(Setting).filter(Setting.key == key).first()
|
||||
if row:
|
||||
row.value = val
|
||||
else:
|
||||
db.add(Setting(key=key, value=val))
|
||||
db.commit()
|
||||
|
||||
|
||||
def load_index(db: Session) -> Optional[Dict[str, list]]:
|
||||
ts = db.query(Setting).filter(Setting.key == INDEX_TS_SETTING).first()
|
||||
row = db.query(Setting).filter(Setting.key == INDEX_SETTING).first()
|
||||
if not ts or not row or not row.value:
|
||||
return None
|
||||
try:
|
||||
if datetime.now() - datetime.fromisoformat(ts.value) > timedelta(hours=TTL_HOURS):
|
||||
return None
|
||||
return json.loads(row.value)
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
def get_index(db: Session) -> Dict[str, list]:
|
||||
idx = load_index(db)
|
||||
if idx is not None:
|
||||
return idx
|
||||
try:
|
||||
return build_index(db)
|
||||
except Exception as e:
|
||||
logger.warning("repo-advisory index build failed: %s", e)
|
||||
return {}
|
||||
|
||||
|
||||
def scan_asset(db: Session, asset, packages: list, new_ids: Optional[list] = None,
|
||||
touched: Optional[set] = None) -> int:
|
||||
"""Match installed software against repo-published advisories.
|
||||
Findings go through the app-scan upsert (source 'app-scan'), so the
|
||||
existing app-scan reconcile closes them when the host updates.
|
||||
Returns findings upserted. Caller commits."""
|
||||
from app.services import app_cve_scanner_service as cpe
|
||||
|
||||
index = get_index(db)
|
||||
if not index:
|
||||
return 0
|
||||
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 or not version:
|
||||
continue
|
||||
if cpe._is_citrix_shim(pkg):
|
||||
continue
|
||||
repo = resolve_repo(name)
|
||||
if not repo or repo not in index:
|
||||
continue
|
||||
key = (repo, version)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
for e in index[repo]:
|
||||
if not is_affected(version, e.get("range"), e.get("patched")):
|
||||
continue
|
||||
c = {"cve": e["cve"], "cvss": e.get("cvss"), "severity": e.get("sev"),
|
||||
"fixed": (e.get("patched") or "").lstrip("vV") or None}
|
||||
try:
|
||||
before = len(new_ids)
|
||||
cpe._upsert(db, asset, name, version, c, new_ids, touched=touched,
|
||||
vendor=(pkg.get("vendor") or None))
|
||||
count += 1 if len(new_ids) > before else 0
|
||||
except Exception as ex:
|
||||
logger.debug("repo-advisory upsert failed (%s on %s): %s",
|
||||
e["cve"], asset.id, ex)
|
||||
return count
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# ponytail: one self-check for the range parser — the only non-trivial
|
||||
# logic. Covers every format seen in the live Notepad++ feed.
|
||||
# Run: python -m app.services.github_repo_advisory_service
|
||||
assert is_affected("8.9.6.4", "<= v8.9.6.4", "v8.9.7") is True # tester's CVE-2026-57233
|
||||
assert is_affected("8.9.7", "<= v8.9.6.4", "v8.9.7") is False # patched
|
||||
assert is_affected("8.9.6", "<= v8.9.6.4", "v8.9.7") is True # older affected
|
||||
assert is_affected("8.9.6.4", "< v8.9.6.4", "v8.9.7") is False # exclusive bound
|
||||
assert is_affected("8.9.0", "<=8.9.1", "8.9.2") is True # no space, no 'v'
|
||||
assert is_affected("8.8.0", "old versions - 8.8.1", "8.8.2") is True
|
||||
assert is_affected("8.9.3", "v8.9.4 & v8.9.5", "v8.9.6") is False # list ≠ range
|
||||
assert is_affected("8.9.4", "v8.9.4 & v8.9.5", "v8.9.6") is True
|
||||
assert is_affected("8.9.6.1", "v8.9.6.1", "v8.9.6.2") is True # single exact
|
||||
assert is_affected("8.9.5", "v8.9.6.1", "v8.9.6.2") is False
|
||||
assert is_affected("9.0", "old versions - 8.8.1", "8.8.2") is False # safety net
|
||||
assert is_affected("8.9.6.4", None, "v8.9.7") is True # no range → below-patched
|
||||
assert is_affected("1:8.9-1", "<= v8.9.6.4", "v8.9.7") is False # rpm-style → skip
|
||||
assert resolve_repo("Notepad++ (64-bit x64)") == "notepad-plus-plus/notepad-plus-plus"
|
||||
assert resolve_repo("Google Chrome") is None
|
||||
print("github_repo_advisory_service self-check OK")
|
||||
@@ -0,0 +1,329 @@
|
||||
"""
|
||||
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):
|
||||
# Adopt the ids Intune just gave us, so future syncs (and the Defender
|
||||
# sync) converge on this one asset.
|
||||
#
|
||||
# These used to be fill-only, which broke re-enrolment: wipe a device
|
||||
# and deploy it again and Intune issues a NEW device id, but the asset
|
||||
# kept the dead one. The hostname match still found the asset, so
|
||||
# nothing looked wrong — while every inventory fetch went to an id that
|
||||
# no longer exists and came back empty. An asset with no inventory is
|
||||
# skipped by the whole scan chain (no MSRC pass, no reconcile, no
|
||||
# prune), so its findings stay open forever with no way to clear them.
|
||||
# We are holding the id Intune reports for this device right now; it is
|
||||
# by definition the current one.
|
||||
if device_id and a.intune_device_id != device_id:
|
||||
if a.intune_device_id:
|
||||
logger.info("Intune sync: %s re-enrolled — device id %s → %s",
|
||||
a.hostname, a.intune_device_id, device_id)
|
||||
a.intune_device_id = device_id
|
||||
if aad_id and a.aad_device_id != aad_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,686 @@
|
||||
"""
|
||||
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 _os_owned_cve_ids(db: Session) -> set:
|
||||
"""CVE ids the cvelistV5 Windows-OS registry owns. The MS 365 Apps page
|
||||
dumps OS-level components (GDI, MSXML, …) under its "Office suite" heading —
|
||||
CVEs that are really Windows-OS bugs Office just bundles (e.g.
|
||||
CVE-2026-50387, a Windows GDI vuln). Those belong to scan_asset_os, which
|
||||
knows the correct build + MSRC KB; attributing them to M365 is wrong AND
|
||||
upsert clobbers the correct OS finding on the same (cve, asset) row."""
|
||||
try:
|
||||
from app.services import cvelistv5_scan_service
|
||||
idx = cvelistv5_scan_service.load_index(db) or {}
|
||||
return {(e.get("cve") or "").upper()
|
||||
for e in (idx.get("windows") or []) if e.get("cve")}
|
||||
except Exception as e:
|
||||
logger.debug("M365: OS-CVE dedup index unavailable: %s", e)
|
||||
return set()
|
||||
|
||||
|
||||
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)
|
||||
from app.services.audit_events import reopen_if_patched
|
||||
reopen_if_patched(db, existing, reason="Microsoft 365 Apps check reports this CVE again", source="m365_check")
|
||||
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)
|
||||
# ============================================================
|
||||
|
||||
_M365_SOURCE = "microsoft365-apps"
|
||||
|
||||
|
||||
def _resolve_stale_m365(db: Session, asset, still_affected: set) -> int:
|
||||
"""Mark M365-only OPEN findings patched when the host's current build has
|
||||
caught up (CVE no longer in the missing set). Without this the check only
|
||||
ever ADDED rows: a host that updated Office kept the old finding open with a
|
||||
stale installed build forever (upsert refreshes package_version only while
|
||||
the CVE is still missing). Mirrors the Defender/app-scan reconcile: drop OUR
|
||||
source, close only when nobody else still reports it. Caller guarantees an
|
||||
M365 install was actually seen on this asset (else 'patched' is unprovable).
|
||||
"""
|
||||
from app.models.vulnerability import Vulnerability, VulnerabilityStatus
|
||||
rows = (db.query(Vulnerability)
|
||||
.filter(Vulnerability.asset_id == asset.id,
|
||||
Vulnerability.status == VulnerabilityStatus.open,
|
||||
Vulnerability.sources.contains(f'"{_M365_SOURCE}"'))
|
||||
.all())
|
||||
resolved = 0
|
||||
for v in rows:
|
||||
if v.cve_id and v.cve_id.upper() in still_affected:
|
||||
continue
|
||||
v.remove_source(_M365_SOURCE)
|
||||
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"Installed Microsoft 365 Apps build on {asset.hostname} "
|
||||
f"now includes the fix for this CVE",
|
||||
cve_id=v.cve_id, source="m365_check",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("audit log for M365 auto-resolve failed (vuln_id=%s): %s", v.id, e)
|
||||
return resolved
|
||||
|
||||
|
||||
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)
|
||||
|
||||
os_cve_ids = _os_owned_cve_ids(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
|
||||
m365_install_seen = False
|
||||
keep_open: set = set() # CVEs still missing on the current build
|
||||
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
|
||||
m365_install_seen = True
|
||||
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"]:
|
||||
if cve_id.upper() in os_cve_ids:
|
||||
continue # Windows-OS CVE — owned by scan_asset_os, not M365
|
||||
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())
|
||||
keep_open.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
|
||||
# Resolve findings the host has since patched — only when we actually
|
||||
# saw an M365 install (else 'patched' is unprovable, same guard as the
|
||||
# Defender/app-scan reconcile against an empty read).
|
||||
if m365_install_seen:
|
||||
stats["resolved"] = stats.get("resolved", 0) + _resolve_stale_m365(
|
||||
db, asset, keep_open)
|
||||
|
||||
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
|
||||
|
||||
os_cve_ids = _os_owned_cve_ids(db)
|
||||
count = 0
|
||||
touched: set = set()
|
||||
seen: set = set()
|
||||
m365_install_seen = False
|
||||
keep_open: 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
|
||||
m365_install_seen = True
|
||||
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"]:
|
||||
if cve_id.upper() in os_cve_ids:
|
||||
continue # Windows-OS CVE — owned by scan_asset_os, not M365
|
||||
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())
|
||||
keep_open.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)
|
||||
|
||||
# Resolve findings the host has since patched (see run_m365_check).
|
||||
if m365_install_seen:
|
||||
_resolve_stale_m365(db, asset, keep_open)
|
||||
|
||||
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,247 @@
|
||||
"""
|
||||
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()
|
||||
from app.services.audit_events import reopen_if_patched
|
||||
reopen_if_patched(db, existing, reason="Mobile EOL/patch-level check reports this finding again", source="mobile_eol")
|
||||
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,266 @@
|
||||
"""
|
||||
Mozilla Foundation Security Advisories (MFSA) — per-CVE severity + fix train.
|
||||
|
||||
Source: github.com/mozilla/foundation-security-advisories (announce/YYYY/*.yml).
|
||||
Each MFSA yml maps CVE → {impact, title} plus an advisory-level `fixed_in`
|
||||
(e.g. ["Firefox 128", "Firefox ESR 115.13"]). Mozilla publishes an `impact`
|
||||
rating (critical/high/moderate/low) but NO numeric CVSS — so this is a
|
||||
SEVERITY + description source, not a CVSS source. It fills the gap where fresh
|
||||
Firefox CVEs have no score yet in NVD/cvelistV5.
|
||||
|
||||
`fixed_in` is the AUTHORITATIVE regular-vs-ESR discriminator (an advisory whose
|
||||
fixed_in lists only "Firefox ESR …" does not affect regular Firefox) — stored
|
||||
here for the Firefox-scan ESR exclusion, cf. [[ghsa-unreviewed-no-version-range]]
|
||||
sibling reference on why cvelistV5 alone can't tell them apart.
|
||||
|
||||
The parser is deliberately line-based (no PyYAML dependency): the MFSA schema is
|
||||
regular — top-level `fixed_in:` list, then an `advisories:` map of
|
||||
` CVE-…:` → ` impact:` / ` title:`.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.setting import Setting
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
INDEX_SETTING = "mozilla_mfsa_index"
|
||||
INDEX_TS_SETTING = "mozilla_mfsa_index_ts"
|
||||
TTL_HOURS = 24
|
||||
_API = "https://api.github.com/repos/mozilla/foundation-security-advisories"
|
||||
|
||||
_IMPACT_TO_SEV = {
|
||||
"critical": "critical",
|
||||
"high": "high",
|
||||
"moderate": "medium",
|
||||
"low": "low",
|
||||
"none": "none",
|
||||
}
|
||||
|
||||
_CVE_KEY = re.compile(r"^ (CVE-\d{4}-\d+):\s*$")
|
||||
_IMPACT = re.compile(r"^ impact:\s*([A-Za-z]+)")
|
||||
_TITLE = re.compile(r"^ title:\s*(.+?)\s*$")
|
||||
_LIST_ITEM = re.compile(r"^-\s*(.+?)\s*$")
|
||||
|
||||
|
||||
def _gh_headers(db: Session) -> dict:
|
||||
h = {"Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28"}
|
||||
try:
|
||||
from app.auth.setting_crypto import read_setting_value
|
||||
pat = (read_setting_value(db, "github_pat") or "").strip()
|
||||
if pat:
|
||||
h["Authorization"] = f"Bearer {pat}"
|
||||
except Exception:
|
||||
pass
|
||||
return h
|
||||
|
||||
|
||||
def _is_esr_only(fixed_in: List[str]) -> bool:
|
||||
"""True when every Firefox entry is an ESR build — the advisory does not
|
||||
affect regular Firefox. 'Firefox ESR 115.13' → ESR; 'Firefox 128' → regular.
|
||||
Thunderbird/other entries are ignored for the Firefox decision."""
|
||||
ff = [f for f in fixed_in if "firefox" in f.lower()]
|
||||
if not ff:
|
||||
return False
|
||||
return all("esr" in f.lower() for f in ff)
|
||||
|
||||
|
||||
def _parse_yml(text: str) -> Tuple[List[str], Dict[str, dict]]:
|
||||
"""Line-parse one MFSA yml → (fixed_in list, {cve: {impact, title}})."""
|
||||
fixed_in: List[str] = []
|
||||
cves: Dict[str, dict] = {}
|
||||
section = None # "fixed_in" | "advisories" | None
|
||||
cur = None
|
||||
for line in text.splitlines():
|
||||
if line.startswith("fixed_in:"):
|
||||
section = "fixed_in"
|
||||
continue
|
||||
if line.startswith("advisories:"):
|
||||
section = "advisories"
|
||||
cur = None
|
||||
continue
|
||||
# A non-indented, non-list line ends the current top-level block.
|
||||
if line and not line[0].isspace() and not line.startswith("-"):
|
||||
section = None
|
||||
cur = None
|
||||
if section == "fixed_in":
|
||||
m = _LIST_ITEM.match(line)
|
||||
if m:
|
||||
fixed_in.append(m.group(1))
|
||||
elif section == "advisories":
|
||||
mc = _CVE_KEY.match(line)
|
||||
if mc:
|
||||
cur = mc.group(1).upper()
|
||||
cves[cur] = {}
|
||||
continue
|
||||
if cur:
|
||||
mi = _IMPACT.match(line)
|
||||
if mi:
|
||||
cves[cur]["impact"] = mi.group(1).lower()
|
||||
continue
|
||||
mt = _TITLE.match(line)
|
||||
if mt and "title" not in cves[cur]:
|
||||
cves[cur]["title"] = mt.group(1)
|
||||
return fixed_in, cves
|
||||
|
||||
|
||||
def build_index(db: Session, years: Optional[List[int]] = None) -> Dict[str, dict]:
|
||||
"""Walk the MFSA repo for the given years, build {cve: {sev, title,
|
||||
fixed_in, esr_only}}, cache it. Defaults to current + previous year (the
|
||||
window where CVEs are fresh enough that NVD may still lag)."""
|
||||
import httpx
|
||||
|
||||
if years is None:
|
||||
y = datetime.now().year
|
||||
years = [y, y - 1]
|
||||
|
||||
index: Dict[str, dict] = {}
|
||||
headers = _gh_headers(db)
|
||||
with httpx.Client(timeout=20.0, follow_redirects=True, headers=headers) as client:
|
||||
for year in years:
|
||||
try:
|
||||
r = client.get(f"{_API}/contents/announce/{year}")
|
||||
if r.status_code == 403 and r.headers.get("x-ratelimit-remaining") == "0":
|
||||
logger.warning("MFSA: GitHub rate limit hit — set github_pat for 5000/h")
|
||||
break
|
||||
if r.status_code != 200:
|
||||
continue
|
||||
files = [f for f in (r.json() or [])
|
||||
if isinstance(f, dict) and str(f.get("name", "")).endswith(".yml")]
|
||||
except Exception as e:
|
||||
logger.debug("MFSA: listing %s failed: %s", year, e)
|
||||
continue
|
||||
for f in files:
|
||||
url = f.get("download_url")
|
||||
if not url:
|
||||
continue
|
||||
try:
|
||||
rr = client.get(url)
|
||||
if rr.status_code != 200:
|
||||
continue
|
||||
fixed_in, cves = _parse_yml(rr.text)
|
||||
esr_only = _is_esr_only(fixed_in)
|
||||
for cve_id, data in cves.items():
|
||||
sev = _IMPACT_TO_SEV.get(data.get("impact") or "")
|
||||
# First writer wins per CVE (a CVE can appear in several
|
||||
# MFSAs for different products; the Firefox one is fine).
|
||||
if cve_id not in index:
|
||||
index[cve_id] = {
|
||||
"sev": sev,
|
||||
"title": data.get("title"),
|
||||
"fixed_in": fixed_in,
|
||||
"esr_only": esr_only,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.debug("MFSA: parse %s failed: %s", f.get("name"), e)
|
||||
|
||||
_store(db, index)
|
||||
logger.info("MFSA index built: %d CVEs across years %s", len(index), years)
|
||||
return index
|
||||
|
||||
|
||||
def _store(db: Session, index: Dict[str, dict]) -> None:
|
||||
for key, val in ((INDEX_SETTING, json.dumps(index)),
|
||||
(INDEX_TS_SETTING, datetime.now().isoformat())):
|
||||
row = db.query(Setting).filter(Setting.key == key).first()
|
||||
if row:
|
||||
row.value = val
|
||||
else:
|
||||
db.add(Setting(key=key, value=val))
|
||||
db.commit()
|
||||
|
||||
|
||||
def load_index(db: Session) -> Optional[Dict[str, dict]]:
|
||||
ts = db.query(Setting).filter(Setting.key == INDEX_TS_SETTING).first()
|
||||
row = db.query(Setting).filter(Setting.key == INDEX_SETTING).first()
|
||||
if not ts or not row or not row.value:
|
||||
return None
|
||||
try:
|
||||
if datetime.now() - datetime.fromisoformat(ts.value) > timedelta(hours=TTL_HOURS):
|
||||
return None
|
||||
return json.loads(row.value)
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
def get_index(db: Session) -> Dict[str, dict]:
|
||||
"""Cached index, lazily (re)built when absent/stale. Build failure → {}."""
|
||||
idx = load_index(db)
|
||||
if idx is not None:
|
||||
return idx
|
||||
try:
|
||||
return build_index(db)
|
||||
except Exception as e:
|
||||
logger.warning("MFSA index build failed: %s", e)
|
||||
return {}
|
||||
|
||||
|
||||
def apply_mozilla_severity(db: Session, cve_ids: List[str]) -> int:
|
||||
"""Fill severity + description for Firefox CVEs from Mozilla's authoritative
|
||||
impact rating. Only overrides severity when the vuln has NO CVSS-derived
|
||||
value (cvss_score is None → severity is a default placeholder); Mozilla has
|
||||
no CVSS number so it must not clobber a real score-derived severity."""
|
||||
from app.models.vulnerability import Vulnerability, VulnerabilitySeverity
|
||||
|
||||
wanted = [c.upper() for c in cve_ids if c]
|
||||
if not wanted:
|
||||
return 0
|
||||
idx = get_index(db)
|
||||
if not idx:
|
||||
return 0
|
||||
hits = [c for c in wanted if c in idx]
|
||||
if not hits:
|
||||
return 0
|
||||
|
||||
updated = 0
|
||||
rows = db.query(Vulnerability).filter(Vulnerability.cve_id.in_(hits)).all()
|
||||
for v in rows:
|
||||
data = idx.get((v.cve_id or "").upper())
|
||||
if not data:
|
||||
continue
|
||||
sev = data.get("sev")
|
||||
if sev and v.cvss_score is None:
|
||||
new_sev = getattr(VulnerabilitySeverity, sev, None)
|
||||
if new_sev is not None and v.severity != new_sev:
|
||||
v.severity = new_sev
|
||||
updated += 1
|
||||
if not v.description and data.get("title"):
|
||||
v.description = data["title"]
|
||||
if updated:
|
||||
db.commit()
|
||||
return updated
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# ponytail: one self-check for the line parser + ESR discriminator — the two
|
||||
# non-trivial bits. Run: python -m app.services.mozilla_advisory_service
|
||||
sample = """announced: July 9th, 2024
|
||||
impact: high
|
||||
fixed_in:
|
||||
- Firefox 128
|
||||
- Firefox ESR 115.13
|
||||
title: Security Vulnerabilities fixed in Firefox 128
|
||||
advisories:
|
||||
CVE-2024-6601:
|
||||
title: Race condition in permission assignment
|
||||
impact: moderate
|
||||
reporter: Andreas Farre
|
||||
CVE-2024-6602:
|
||||
title: Memory corruption in NSS
|
||||
impact: critical
|
||||
"""
|
||||
fixed_in, cves = _parse_yml(sample)
|
||||
assert fixed_in == ["Firefox 128", "Firefox ESR 115.13"], fixed_in
|
||||
assert cves["CVE-2024-6601"] == {"title": "Race condition in permission assignment",
|
||||
"impact": "moderate"}, cves["CVE-2024-6601"]
|
||||
assert cves["CVE-2024-6602"]["impact"] == "critical"
|
||||
assert _is_esr_only(["Firefox 128", "Firefox ESR 115.13"]) is False # has regular
|
||||
assert _is_esr_only(["Firefox ESR 115.13"]) is True # ESR only
|
||||
assert _is_esr_only(["Thunderbird 128"]) is False # no firefox
|
||||
assert _IMPACT_TO_SEV["moderate"] == "medium"
|
||||
print("mozilla_advisory_service self-check OK")
|
||||
@@ -0,0 +1,314 @@
|
||||
"""
|
||||
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
|
||||
|
||||
# In-process memo. resolve_ms_lifecycle_eol() runs once PER PACKAGE across every
|
||||
# synced device; without this, a fresh 24h DB cache still let each concurrent
|
||||
# device re-download the export before the first _store_cache commit landed —
|
||||
# the tester saw dozens of identical GET .../lifecycle/products/export/ per sync.
|
||||
# This holds the parsed rows in the worker for a short window so one sync fetches
|
||||
# at most once. ponytail: module-global memo, fine for a read-only reference list.
|
||||
_MEM_ROWS: Optional[List[dict]] = None
|
||||
_MEM_TS: float = 0.0
|
||||
_MEM_TTL_SEC = 3600
|
||||
|
||||
# ---------- 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."""
|
||||
global _MEM_ROWS, _MEM_TS
|
||||
import time as _t
|
||||
if not force_refresh:
|
||||
# In-process memo first — collapses the per-package/per-device burst.
|
||||
if _MEM_ROWS is not None and (_t.time() - _MEM_TS) < _MEM_TTL_SEC:
|
||||
return _MEM_ROWS
|
||||
cached = _load_cache(db)
|
||||
if cached is not None:
|
||||
_MEM_ROWS, _MEM_TS = cached, _t.time()
|
||||
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:
|
||||
_MEM_ROWS, _MEM_TS = [], _t.time()
|
||||
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:
|
||||
# Remember the FAILURE too. Without this every one of the thousands of
|
||||
# scanned packages re-fetched the export page (tester saw an endless
|
||||
# run of GET .../lifecycle/products/export/ and the UI timing out),
|
||||
# because the memo was only ever set on success.
|
||||
_MEM_ROWS, _MEM_TS = [], _t.time()
|
||||
raise MSLifecycleError(f"could not fetch MS lifecycle export: {e}") from e
|
||||
|
||||
if not rows:
|
||||
_MEM_ROWS, _MEM_TS = [], _t.time()
|
||||
raise MSLifecycleError("MS lifecycle export parsed to zero rows")
|
||||
_store_cache(db, rows)
|
||||
_MEM_ROWS, _MEM_TS = rows, _t.time()
|
||||
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,709 @@
|
||||
"""
|
||||
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"
|
||||
# v2: adding Microsoft Edge to _PRODUCTS changes what the index CONTAINS, and
|
||||
# a cache built by the previous version has no edge key at all. Bumping the
|
||||
# setting name retires that cache on deploy instead of serving it for another
|
||||
# night (tester ran app-scan + MSRC refresh and still saw no Edge findings).
|
||||
_INDEX_SETTING = "msrc_product_index_v2"
|
||||
_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"},
|
||||
# Microsoft Edge — MSRC is the ONLY machine-readable source for these.
|
||||
# Edge CVEs are absent from NVD and cvelistV5 (tester checked
|
||||
# CVE-2026-57989/-57990/-57978: "CVE ID Not Found" at NVD, no match in
|
||||
# cvelistV5), while MSRC carries CVSS, severity AND the fixed build. The
|
||||
# July CVRF alone lists 436 Edge CVEs with a FixedBuild.
|
||||
#
|
||||
# Edge must NEVER be scored off Chromium/Chrome data: the builds diverge
|
||||
# completely (Edge 150.0.4078.99 rides on Chromium 150.0.7871.187), so a
|
||||
# google:chrome range says nothing about an Edge build.
|
||||
#
|
||||
# branch=False: Edge servicing is cumulative and the 3rd segment moves with
|
||||
# every release, so identity comes from the name and the test is a plain
|
||||
# installed < fixed. A newer major (151.x) compares greater than any 150.x
|
||||
# fix, so it correctly drops out.
|
||||
#
|
||||
# WebView2 is excluded — it ships as its own package with its own version
|
||||
# (the tester's host had Edge .83 next to WebView2 .99), so folding it in
|
||||
# here would compare one product's build against the other's fix.
|
||||
{"key": "edge", "kind": "pkg", "branch": False,
|
||||
"match_re": r"microsoft edge(?!.*webview)",
|
||||
"msrc_re": r"^microsoft edge \(chromium-based\)$",
|
||||
"label": "Microsoft Edge (Chromium-based)"},
|
||||
]
|
||||
_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+)+$")
|
||||
|
||||
# MSRC uses its own severity vocabulary in the CVRF Threats block.
|
||||
_MS_SEVERITY = {
|
||||
"critical": "critical",
|
||||
"important": "high",
|
||||
"moderate": "medium",
|
||||
"low": "low",
|
||||
}
|
||||
|
||||
|
||||
def _severity_from(hit: dict):
|
||||
"""MSRC severity word, else derive from the CVSS base score, else medium."""
|
||||
from app.models.vulnerability import VulnerabilitySeverity
|
||||
name = _MS_SEVERITY.get((hit.get("sev") or "").strip().lower())
|
||||
if not name:
|
||||
score = hit.get("cvss")
|
||||
if isinstance(score, (int, float)):
|
||||
name = ("critical" if score >= 9.0 else "high" if score >= 7.0
|
||||
else "medium" if score >= 4.0 else "low")
|
||||
return getattr(VulnerabilitySeverity, name or "medium", VulnerabilitySeverity.medium)
|
||||
|
||||
|
||||
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()
|
||||
# MSRC ships the score and its own severity word in the CVRF.
|
||||
# Carry them: Edge CVEs exist in NEITHER NVD NOR cvelistV5, so
|
||||
# the enrichment cascade can never fill them in later — without
|
||||
# this every Edge finding would sit at the neutral placeholder
|
||||
# forever.
|
||||
_sets = v.get("CVSSScoreSets") or []
|
||||
_cvss = None
|
||||
_vector = None
|
||||
if _sets:
|
||||
try:
|
||||
_cvss = float(_sets[0].get("BaseScore"))
|
||||
except (TypeError, ValueError):
|
||||
_cvss = None
|
||||
_vector = (_sets[0].get("Vector") or "").strip() or None
|
||||
_sev = None
|
||||
for _thr in v.get("Threats") or []:
|
||||
if _thr.get("Type") == 3:
|
||||
_sev = ((_thr.get("Description") or {}).get("Value") or "").strip().lower()
|
||||
break
|
||||
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,
|
||||
"cvss": _cvss, "vector": _vector, "sev": _sev})
|
||||
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
|
||||
# Two builds are only comparable when they are the same KIND of number.
|
||||
# Teams ships 26183.1003.4002.4460 while MSRC states its fix as
|
||||
# 25060212043 — a single eleven-digit stamp. Python compares those
|
||||
# tuples element by element, so 26183 < 25060212043 came out True and
|
||||
# a current Teams was reported vulnerable (tester, CVE-2025-49731).
|
||||
# Differing segment counts mean the two sides are not the same scheme,
|
||||
# and no ordering between them carries meaning.
|
||||
if len(bt) != len(inst_t):
|
||||
logger.debug(
|
||||
"msrc: skipping %s — build %r and installed %r use different "
|
||||
"version schemes", e.get("cve"), e.get("build"), installed)
|
||||
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, VulnerabilitySeverity,
|
||||
)
|
||||
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)
|
||||
# Record the product separately so a CVE affecting TWO products on one
|
||||
# host (Chrome via app-scan + Edge via MSRC — CVE-2026-16417) shows both.
|
||||
from app.services.audit_events import record_affected_package
|
||||
record_affected_package(db, existing, name=product, version=installed,
|
||||
fixed_version=fixed, source=SOURCE_NAME)
|
||||
if not existing.package_name:
|
||||
existing.package_name = product[:255]
|
||||
# Refresh, don't fill-only: the host's build moves with every patch, so
|
||||
# a fill-only write froze the finding at the version first seen (same
|
||||
# bug the app-scan had with Firefox showing 'Installed 150.0.3').
|
||||
if installed:
|
||||
existing.package_version = installed[:100]
|
||||
# Same fill-only trap, one field over — and this one produces FALSE
|
||||
# POSITIVES, not just a stale display. CVE-2026-15120 is a Chromium CVE
|
||||
# that Edge ingests, so the CPE path writes Chromium's fix
|
||||
# (150.0.7871.114) onto the finding first. MSRC knows the only build
|
||||
# that matters for Edge (150.0.4078.65), but fill-only never let it
|
||||
# through — the host at 150.0.4078.83 was long patched and still showed
|
||||
# OPEN, because .83 < .7871.114. MSRC is authoritative for its own
|
||||
# products' fixed build; let it correct the row.
|
||||
if fixed and existing.fixed_version != fixed:
|
||||
existing.fixed_version = fixed
|
||||
if existing.cvss_score is None and hit.get("cvss") is not None:
|
||||
existing.cvss_score = hit["cvss"]
|
||||
existing.cvss_vector = hit.get("vector") or existing.cvss_vector
|
||||
existing.severity = _severity_from(hit)
|
||||
from app.services.audit_events import reopen_if_patched
|
||||
reopen_if_patched(db, existing, reason="MSRC fixed-build scan reports this CVE again", source="msrc")
|
||||
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,
|
||||
# severity is NOT NULL. The CVRF index carries only (cve, build, kb) —
|
||||
# no score — so seed the same neutral placeholder the other
|
||||
# synthetic-finding source (m365) uses; the enrichment cascade
|
||||
# (vulnrichment → NVD → cvelistV5 → GHSA) refines it right after.
|
||||
# Until Edge joined, MSRC almost always hit the existing-row branch, so
|
||||
# this create path rarely ran and the missing column went unnoticed —
|
||||
# then every new Edge finding hit a NotNullViolation and aborted the
|
||||
# whole scan.
|
||||
severity=_severity_from(hit),
|
||||
cvss_score=hit.get("cvss"),
|
||||
cvss_vector=hit.get("vector"),
|
||||
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()
|
||||
from app.services.audit_events import record_affected_package
|
||||
record_affected_package(db, row, name=product, version=installed,
|
||||
fixed_version=fixed, source=SOURCE_NAME)
|
||||
# CVE metrics (CVSS/EPSS/KEV) are properties of the CVE, not of one host.
|
||||
# Only 49 of 436 Edge CVEs carry a CVSSScoreSet in the CVRF, so an MSRC-only
|
||||
# finding often has no score of its own while the SAME CVE on another asset
|
||||
# already does (tester: CVE-2026-16423 showed 8.8 on the Chrome row and
|
||||
# '-' / priority 0 on the Edge row). Inherit from a sibling before scoring.
|
||||
try:
|
||||
if row.cvss_score is None:
|
||||
from app.services.vuln_override_service import apply_canonical_from_siblings
|
||||
apply_canonical_from_siblings(db, row)
|
||||
except Exception as e:
|
||||
logger.debug("sibling metric inherit failed (%s): %s", row.cve_id, e)
|
||||
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"'))
|
||||
.all())
|
||||
resolved = 0
|
||||
for v in rows:
|
||||
# Match the finding to a product the way the SCAN does, not by exact
|
||||
# label. Whichever scanner creates the row first owns package_name, and
|
||||
# the app scan writes the inventory's own wording — "Microsoft Edge" —
|
||||
# while this pass only ever looked for "Microsoft Edge
|
||||
# (Chromium-based)". The row was therefore invisible to its own
|
||||
# reconcile and stayed open forever: the tester's host sat on Edge
|
||||
# .105, long past the .99 fix, with the finding still open after
|
||||
# repeated scans.
|
||||
# This same function serves the OS pass, whose labels resolve through
|
||||
# resolve_os instead — so try both, and keep the exact-name match for
|
||||
# rows this scanner wrote itself.
|
||||
name = v.package_name or ""
|
||||
if name not in considered:
|
||||
prod = resolve_package(name) or resolve_os(name)
|
||||
if not prod or prod["label"] not in considered:
|
||||
# The parent name is whatever the FIRST scanner called it, and
|
||||
# for a CVE that hits Chrome and Edge alike that is often
|
||||
# "Google Chrome" — which resolves to no MSRC product at all,
|
||||
# so the finding was skipped and never closed even though MSRC
|
||||
# tracks its Edge half (tester: CVE-2026-16807, Edge long past
|
||||
# the fix, still open). The per-package rows carry the product
|
||||
# this pass actually knows about, so ask them too.
|
||||
pkg_names = [p.package_name for p in (v.packages or [])]
|
||||
if not any((resolve_package(n) or {}).get("label") in considered
|
||||
for n in pkg_names):
|
||||
continue
|
||||
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 _os_labels(asset) -> set:
|
||||
"""Every package_name an OS finding on this asset can legitimately carry.
|
||||
|
||||
_resolve_stale matches package_name EXACTLY, and the label depends on who
|
||||
created the row: MSRC writes its catalogue name ("Microsoft Windows Server
|
||||
2025"), while the app scan / Wazuh write the asset's own OS string
|
||||
("Microsoft Windows Server 2025 Standard", "... 2016 Datacenter"). A
|
||||
finding first seen by another scanner therefore never matched the MSRC
|
||||
label, so once that scanner retracted its source the row was left open
|
||||
forever with msrc as the last claimant — exactly what the tester saw on
|
||||
fully patched 2016 and 2025 hosts.
|
||||
|
||||
Deliberately NOT a prefix match: "Microsoft Windows Server 2012" is a
|
||||
prefix of "... 2012 R2", which would close a different release's findings.
|
||||
"""
|
||||
prod = resolve_os(asset.operating_system or "")
|
||||
if not prod:
|
||||
return set()
|
||||
labels = {prod["label"]}
|
||||
os_str = (asset.operating_system or "").strip()
|
||||
if os_str:
|
||||
labels.add(os_str)
|
||||
return labels
|
||||
|
||||
|
||||
def resolve_stale_os(db: Session, asset, touched: set) -> int:
|
||||
"""Reconcile the OS-kind MSRC findings for this asset.
|
||||
|
||||
Three writers, three spellings of package_name for the SAME OS finding:
|
||||
MSRC "Microsoft Windows Server 2016" (catalogue)
|
||||
app scan "Microsoft Windows Server 2016 Datacenter" (asset OS string)
|
||||
Wazuh "Microsoft Windows Server 2016 Datacenter 10.0.14393.9234"
|
||||
(asset OS string + build)
|
||||
_resolve_stale compares package_name exactly, so the Wazuh spelling never
|
||||
matched and those rows stayed open with msrc as the last claimant — the
|
||||
tester's CVE-2026-49798 (open, first seen by wazuh) next to CVE-2026-50518
|
||||
(patched, first seen by app-scan) on the very same host.
|
||||
|
||||
So: exact match for the labels, plus a PREFIX match on the asset's own OS
|
||||
string to catch anything appended to it. The prefix is safe because it uses
|
||||
the asset's FULL OS string — "…Server 2012 Standard" cannot prefix
|
||||
"…Server 2012 R2 Standard". The MSRC catalogue label is deliberately NOT
|
||||
used as a prefix, since "Microsoft Windows Server 2012" would.
|
||||
"""
|
||||
from app.models.vulnerability import Vulnerability, VulnerabilityStatus
|
||||
labels = _os_labels(asset)
|
||||
if not labels:
|
||||
return 0
|
||||
os_str = (asset.operating_system or "").strip()
|
||||
|
||||
rows = (db.query(Vulnerability)
|
||||
.filter(Vulnerability.asset_id == asset.id,
|
||||
Vulnerability.status == VulnerabilityStatus.open,
|
||||
Vulnerability.sources.contains(f'"{SOURCE_NAME}"'))
|
||||
.all())
|
||||
resolved = 0
|
||||
for v in rows:
|
||||
pkg = (v.package_name or "").strip()
|
||||
if pkg not in labels and not (os_str and pkg.startswith(os_str)):
|
||||
continue # a different product — not ours to close
|
||||
if v.cve_id in touched:
|
||||
continue # still reported by this pass
|
||||
v.remove_source(SOURCE_NAME)
|
||||
if v.source_list:
|
||||
continue # another scanner still claims it
|
||||
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"Host build {asset.os_version} is at/past the MSRC fixed "
|
||||
f"build for this CVE",
|
||||
cve_id=v.cve_id, source="msrc",
|
||||
hostname=asset.hostname,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("audit log for MSRC OS auto-resolve failed (%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, _os_labels(asset))
|
||||
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()
|
||||
+179
-31
@@ -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.
|
||||
@@ -266,9 +334,8 @@ def _upsert_nessus_eol(
|
||||
existing.description = description
|
||||
existing.add_source(SOURCE_NAME)
|
||||
existing.nessus_plugin_id = str(plugin_id) if plugin_id else existing.nessus_plugin_id
|
||||
if existing.status == VulnerabilityStatus.patched:
|
||||
existing.status = VulnerabilityStatus.open
|
||||
existing.patched_at = None
|
||||
from app.services.audit_events import reopen_if_patched
|
||||
reopen_if_patched(db, existing, reason="Nessus reports this finding on the host again", source="nessus_sync")
|
||||
# Resync bumps detected_at so the Newly EOL/EOS widget ranks the
|
||||
# freshest finding first (was stale before this fix).
|
||||
existing.detected_at = datetime.now()
|
||||
@@ -322,7 +389,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 +419,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 +466,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 +543,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 +570,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,10 +719,25 @@ 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
|
||||
existing.patched_at = None
|
||||
from app.services.audit_events import reopen_if_patched
|
||||
if reopen_if_patched(
|
||||
db, existing,
|
||||
reason="Nessus reports this finding on the host again",
|
||||
source="nessus_sync"):
|
||||
changed = True
|
||||
if changed:
|
||||
existing.refresh_scores()
|
||||
@@ -677,6 +764,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 +920,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 +961,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 +978,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,32 @@
|
||||
"""What counts as a finding in an exported report.
|
||||
|
||||
Lives outside the router so the rule can be tested without standing up the
|
||||
web stack, and so every report is forced through the same definition — the
|
||||
reports drifted apart precisely because each one wrote its own query.
|
||||
"""
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.vulnerability import Vulnerability
|
||||
|
||||
# The EOL check and the Nessus plugin importer both write findings with
|
||||
# synthetic ids. They are real work items, but they are NOT CVEs, and counting
|
||||
# them as such inflated every number past what the dashboard shows.
|
||||
PSEUDO_PREFIXES = ("EOL-", "NESSUS-PLUGIN-")
|
||||
|
||||
|
||||
def scoped(db: Session):
|
||||
"""Real CVEs on assets that still exist.
|
||||
|
||||
Reports used to query the table raw, so they counted findings on
|
||||
decommissioned and inactive assets — hosts the dashboard deliberately
|
||||
hides. An executive summary that disagrees with the screen it was exported
|
||||
from is worse than no summary, and the gap grows with every retired
|
||||
machine. Orphan findings (no asset at all) are kept, same as the dashboard.
|
||||
"""
|
||||
from app.models.asset import Asset, AssetStatus
|
||||
q = (db.query(Vulnerability)
|
||||
.outerjoin(Asset, Vulnerability.asset_id == Asset.id)
|
||||
.filter((Asset.id.is_(None)) | (Asset.status == AssetStatus.ACTIVE)))
|
||||
for p in PSEUDO_PREFIXES:
|
||||
q = q.filter(~Vulnerability.cve_id.startswith(p))
|
||||
return q
|
||||
@@ -0,0 +1,156 @@
|
||||
"""
|
||||
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()
|
||||
|
||||
|
||||
# Every port any role cares about — the only ones an ESTABLISHED socket may
|
||||
# be read as evidence of a local service.
|
||||
_ROLE_PORTS: set = set()
|
||||
for _r in _ROLES:
|
||||
_ROLE_PORTS |= set(_r.get("ports") or ())
|
||||
_ROLE_PORTS |= set(_r.get("ports_all") or ())
|
||||
|
||||
|
||||
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()
|
||||
# Same reason as in exposure_service: syscollector does not always
|
||||
# report the listener. A DC serving live Kerberos or RDP showed no
|
||||
# role at all because only ESTABLISHED sockets came through. An
|
||||
# inbound connection — one whose LOCAL port is the service port —
|
||||
# proves the service is running; outbound ones carry an ephemeral
|
||||
# local port and cannot be confused with it.
|
||||
if proto == "tcp" and state and state not in ("listening", "established"):
|
||||
continue
|
||||
try:
|
||||
port = int(p.get("local_port") or (p.get("local") or {}).get("port") or 0)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
# Only ports a role actually asks about can be read this way. Any other
|
||||
# local port on an ESTABLISHED socket may just be the ephemeral end of
|
||||
# an OUTBOUND connection, which says nothing about a service here.
|
||||
if state == "established" and port not in _ROLE_PORTS:
|
||||
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]:
|
||||
@@ -633,8 +633,13 @@ class VulnOverrideService:
|
||||
v = verified.get(cid)
|
||||
return v is None or v.cvss_score is None
|
||||
|
||||
# Cap scales with the NVD API key: 50 req/30s authenticated vs 5
|
||||
# unauthenticated, so a key makes a much larger batch feasible before
|
||||
# cvelistV5 (stage 3) mops up the rest.
|
||||
import os as _os
|
||||
nvd_cap = 1500 if _os.getenv("NVD_API_KEY", "").strip() else 100
|
||||
missing = [c for c in cve_ids_upper if _needs_cvss(c)]
|
||||
if missing and len(missing) <= 100:
|
||||
if missing and len(missing) <= nvd_cap:
|
||||
try:
|
||||
nvd_data = self._load_via_nvd(missing)
|
||||
for cve_id, data in nvd_data.items():
|
||||
@@ -647,9 +652,9 @@ class VulnOverrideService:
|
||||
logger.warning("NVD fallback failed: %s", e)
|
||||
elif missing:
|
||||
logger.info(
|
||||
"stage 2 (NVD): skipped — %d missing CVEs exceeds the 100-cap "
|
||||
"stage 2 (NVD): skipped — %d missing CVEs exceeds the %d-cap "
|
||||
"to avoid rate-limit; falling through to cvelistV5",
|
||||
len(missing),
|
||||
len(missing), nvd_cap,
|
||||
)
|
||||
|
||||
# Stage 3 — cvelistV5 ZIP snapshot from CVE.org. Used when many
|
||||
@@ -668,6 +673,23 @@ class VulnOverrideService:
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("cvelistV5 fallback failed: %s", e)
|
||||
|
||||
# Stage 4 — GitHub Security Advisories. Backstop for CVEs still missing
|
||||
# a score after NVD + cvelistV5, i.e. very fresh CVEs GHSA has but the
|
||||
# others don't yet (the gap the tester hit). Self-throttles on the
|
||||
# GitHub rate limit; a github_pat setting lifts it to 5000 req/h.
|
||||
missing = [c for c in cve_ids_upper if _needs_cvss(c)]
|
||||
if missing:
|
||||
try:
|
||||
ghsa_data = self._load_via_ghsa(missing)
|
||||
for cve_id, data in ghsa_data.items():
|
||||
_merge_cvss_into(verified, cve_id, data)
|
||||
logger.info(
|
||||
"stage 4 (GHSA): %d/%d missing CVEs filled",
|
||||
len(ghsa_data), len(missing),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("GHSA fallback failed: %s", e)
|
||||
return verified
|
||||
|
||||
def _load_via_per_cve_raw(self, cve_ids: List[str]) -> Dict[str, VerifiedCVEData]:
|
||||
@@ -711,6 +733,76 @@ class VulnOverrideService:
|
||||
logger.error("vulnrichment client error: %s", e)
|
||||
return verified
|
||||
|
||||
def _load_via_ghsa(self, cve_ids: List[str]) -> Dict[str, VerifiedCVEData]:
|
||||
"""GitHub Security Advisories — last-resort CVSS/severity/description.
|
||||
|
||||
GHSA mirrors CVEs that can still be missing from NVD and cvelistV5 when
|
||||
very fresh (the gap the tester hit on new Firefox/Notepad++ CVEs). The
|
||||
global-advisory API returns cvss + severity + description keyed by CVE.
|
||||
Optional PAT (setting `github_pat`) lifts the rate limit 60 → 5000/h;
|
||||
the loop stops cleanly when the limit is hit.
|
||||
|
||||
Only CVSS/severity/description are filled — GHSA 'unreviewed' advisories
|
||||
(desktop-app CVEs like Notepad++) carry NO affected-version range, so no
|
||||
fix/version data can be derived here (verified against the live API)."""
|
||||
import httpx
|
||||
from app.auth.setting_crypto import read_setting_value
|
||||
|
||||
token = None
|
||||
try:
|
||||
token = (read_setting_value(self.db, "github_pat") or "").strip() or None
|
||||
except Exception:
|
||||
token = None
|
||||
|
||||
headers = {"Accept": "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28"}
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
|
||||
verified: Dict[str, VerifiedCVEData] = {}
|
||||
with httpx.Client(timeout=15.0, follow_redirects=True, headers=headers) as client:
|
||||
for cve_id in cve_ids:
|
||||
try:
|
||||
r = client.get("https://api.github.com/advisories",
|
||||
params={"cve_id": cve_id})
|
||||
if r.status_code == 403 and r.headers.get("x-ratelimit-remaining") == "0":
|
||||
logger.warning(
|
||||
"GHSA: rate limit hit — stopping (set the github_pat "
|
||||
"setting for 5000 req/h)")
|
||||
break
|
||||
if r.status_code != 200:
|
||||
continue
|
||||
arr = r.json() or []
|
||||
if not arr:
|
||||
continue
|
||||
adv = arr[0]
|
||||
cvss = adv.get("cvss") or {}
|
||||
score = cvss.get("score")
|
||||
if not score: # cvss.score can be 0/None → try structured block
|
||||
sev_block = adv.get("cvss_severities") or {}
|
||||
for k in ("cvss_v4", "cvss_v3"):
|
||||
s = (sev_block.get(k) or {}).get("score")
|
||||
if s:
|
||||
score, cvss = s, sev_block[k]
|
||||
break
|
||||
if not score:
|
||||
continue
|
||||
score = float(score)
|
||||
sev = (adv.get("severity") or "").lower() or \
|
||||
self._severity_from_cvss(score).value
|
||||
verified[cve_id] = VerifiedCVEData(
|
||||
cve_id=cve_id,
|
||||
cvss_score=score,
|
||||
cvss_vector=cvss.get("vector_string") or None,
|
||||
severity=sev,
|
||||
description=(adv.get("description") or adv.get("summary") or None),
|
||||
references=adv.get("references") or None,
|
||||
source="ghsa",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("GHSA fetch failed for %s: %s", cve_id, e)
|
||||
return verified
|
||||
|
||||
def _load_via_nvd(self, cve_ids: List[str]) -> Dict[str, VerifiedCVEData]:
|
||||
"""
|
||||
Pull CVSSv3 from the public NVD REST API as a Vulnrichment
|
||||
@@ -725,13 +817,18 @@ class VulnOverrideService:
|
||||
loaders so apply_overrides treats both sources identically.
|
||||
"""
|
||||
import httpx
|
||||
import os
|
||||
import time
|
||||
|
||||
# NVD API key (env NVD_API_KEY, same as the enrichment service) lifts
|
||||
# the limit from 5 to 50 req/30s and lets us throttle far less.
|
||||
api_key = os.getenv("NVD_API_KEY", "").strip()
|
||||
headers = {"apiKey": api_key} if api_key else {}
|
||||
batch = 45 if api_key else 5 # requests before a 1s pause
|
||||
verified: Dict[str, VerifiedCVEData] = {}
|
||||
# Throttle to be polite — 1 req/sec stays well below the unauth limit.
|
||||
with httpx.Client(timeout=15.0, follow_redirects=True) as client:
|
||||
with httpx.Client(timeout=15.0, follow_redirects=True, headers=headers) as client:
|
||||
for idx, cve_id in enumerate(cve_ids):
|
||||
if idx and idx % 5 == 0:
|
||||
if idx and idx % batch == 0:
|
||||
time.sleep(1.0) # crude throttle
|
||||
try:
|
||||
r = client.get(
|
||||
@@ -814,7 +911,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 +1007,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
|
||||
@@ -1406,7 +1585,7 @@ class VulnOverrideService:
|
||||
Vulnerability.status == VulnerabilityStatus.open
|
||||
).all()
|
||||
|
||||
cve_ids = list(set(v.cve_id for v in vulns if v.cve_id and not v.cve_id.startswith("NESSUS-")))
|
||||
cve_ids = list(set(v.cve_id for v in vulns if v.cve_id and v.cve_id.upper().startswith("CVE-")))
|
||||
|
||||
if not cve_ids:
|
||||
return {"checked": 0, "updated": 0, "message": "Keine CVEs zum Korrigieren"}
|
||||
@@ -1540,7 +1719,7 @@ def correct_vulnerability_scores(
|
||||
return {"message": "Keine Vulnerabilities zum Korrigieren", "updated": 0}
|
||||
|
||||
# Sammle alle CVE-IDs für Vulnrichment-Abfrage
|
||||
all_cve_ids = list(set(v.cve_id for v in vulns if v.cve_id and not v.cve_id.startswith("NESSUS-")))
|
||||
all_cve_ids = list(set(v.cve_id for v in vulns if v.cve_id and v.cve_id.upper().startswith("CVE-")))
|
||||
|
||||
if not all_cve_ids:
|
||||
return {"message": "Keine echten CVEs zum Korrigieren", "updated": 0}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import api from '../../../lib/api'; // Pfad prüfen: admin/audit-logs -> ../../../lib/api
|
||||
import { UserInfo } from '../../../types';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import api from '../../../lib/api';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
ClockIcon,
|
||||
@@ -12,6 +11,7 @@ import {
|
||||
ComputerDesktopIcon,
|
||||
ArrowPathIcon,
|
||||
ArrowDownTrayIcon,
|
||||
MagnifyingGlassIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
|
||||
interface AuditLog {
|
||||
@@ -26,41 +26,65 @@ interface AuditLog {
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 100;
|
||||
type SortKey = 'timestamp' | 'user_id' | 'event_type' | 'resource_type' | 'ip_address';
|
||||
const PAGE_SIZES = [25, 50, 100, 250];
|
||||
|
||||
// column header → sort key (null = not sortable)
|
||||
const COLUMNS: { label: string; key: SortKey | null }[] = [
|
||||
{ label: 'Time', key: 'timestamp' },
|
||||
{ label: 'User', key: 'user_id' },
|
||||
{ label: 'Event', key: 'event_type' },
|
||||
{ label: 'Description', key: null },
|
||||
{ label: 'Resource', key: 'resource_type' },
|
||||
{ label: 'IP', key: 'ip_address' },
|
||||
];
|
||||
|
||||
export default function AuditLogsPage() {
|
||||
const [logs, setLogs] = useState<AuditLog[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(0); // 0-indexed
|
||||
const [pageSize, setPageSize] = useState(100);
|
||||
const [search, setSearch] = useState('');
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const [sort, setSort] = useState<SortKey>('timestamp');
|
||||
const [order, setOrder] = useState<'asc' | 'desc'>('desc');
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
fetchLogs(0, true);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const fetchLogs = async (skip = 0, replace = false) => {
|
||||
const fetchLogs = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
if (replace) setLoading(true);
|
||||
else setLoadingMore(true);
|
||||
const res = await api.get('/audit/logs', { params: { skip, limit: PAGE_SIZE } });
|
||||
const incoming: AuditLog[] = Array.isArray(res.data) ? res.data : [];
|
||||
setHasMore(incoming.length === PAGE_SIZE);
|
||||
if (replace) {
|
||||
setLogs(incoming);
|
||||
} else {
|
||||
setLogs(prev => [...prev, ...incoming]);
|
||||
}
|
||||
const res = await api.get('/audit/logs', {
|
||||
params: { skip: page * pageSize, limit: pageSize, search: search || undefined, sort, order },
|
||||
});
|
||||
setLogs(Array.isArray(res.data) ? res.data : []);
|
||||
const t = parseInt(res.headers['x-total-count'] ?? '0', 10);
|
||||
setTotal(Number.isNaN(t) ? 0 : t);
|
||||
} catch (error: any) {
|
||||
console.error("Failed to fetch logs", error);
|
||||
if (error.response?.status === 403) {
|
||||
router.push('/dashboard');
|
||||
}
|
||||
console.error('Failed to fetch logs', error);
|
||||
if (error.response?.status === 403) router.push('/');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}, [page, pageSize, search, sort, order, router]);
|
||||
|
||||
useEffect(() => { fetchLogs(); }, [fetchLogs]);
|
||||
|
||||
// Debounce the search box → commit to `search` (which resets to page 0).
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => { setPage(0); setSearch(searchInput.trim()); }, 400);
|
||||
return () => clearTimeout(id);
|
||||
}, [searchInput]);
|
||||
|
||||
const toggleSort = (key: SortKey | null) => {
|
||||
if (!key) return;
|
||||
if (sort === key) {
|
||||
setOrder(o => (o === 'asc' ? 'desc' : 'asc'));
|
||||
} else {
|
||||
setSort(key);
|
||||
setOrder('desc');
|
||||
}
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
const getEventIcon = (type: string) => {
|
||||
@@ -71,34 +95,41 @@ export default function AuditLogsPage() {
|
||||
return <DocumentTextIcon className="h-4 w-4 text-gray-500" />;
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleString();
|
||||
};
|
||||
const formatDate = (dateStr: string) => new Date(dateStr).toLocaleString();
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-8 text-center text-gray-500 font-mono">Loading Audit Logs...</div>;
|
||||
}
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
const from = total === 0 ? 0 : page * pageSize + 1;
|
||||
const to = Math.min((page + 1) * pageSize, total);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex flex-wrap justify-between items-center gap-3">
|
||||
<h1 className="text-2xl font-bold text-gray-900 tracking-tight font-mono">
|
||||
Audit Logs
|
||||
<span className="ml-2 text-sm font-normal text-gray-500 bg-gray-100 px-2 py-0.5 rounded-full">
|
||||
{logs.length} events
|
||||
{total.toLocaleString()} events
|
||||
</span>
|
||||
</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<MagnifyingGlassIcon className="h-4 w-4 text-gray-400 absolute left-2.5 top-1/2 -translate-y-1/2" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
placeholder="Search description, event, resource, IP, user…"
|
||||
className="w-72 rounded-md border-gray-300 pl-8 pr-3 h-9 text-sm font-mono focus:border-truevuln-blue focus:ring-truevuln-blue"
|
||||
/>
|
||||
</div>
|
||||
<a
|
||||
href="/audit/logs/export"
|
||||
className="inline-flex items-center gap-1 p-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors text-sm font-mono"
|
||||
title="Export full audit log as CSV (admin)"
|
||||
>
|
||||
<ArrowDownTrayIcon className="h-5 w-5" />
|
||||
CSV
|
||||
<ArrowDownTrayIcon className="h-5 w-5" /> CSV
|
||||
</a>
|
||||
<button
|
||||
onClick={() => fetchLogs(0, true)}
|
||||
onClick={() => fetchLogs()}
|
||||
className="p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
title="Refresh Logs"
|
||||
>
|
||||
@@ -112,16 +143,24 @@ export default function AuditLogsPage() {
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider font-mono">Time</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider font-mono">User</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider font-mono">Event</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider font-mono">Description</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider font-mono">Resource</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider font-mono">IP</th>
|
||||
{COLUMNS.map((c) => (
|
||||
<th
|
||||
key={c.label}
|
||||
onClick={() => toggleSort(c.key)}
|
||||
className={`px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider font-mono select-none ${c.key ? 'cursor-pointer hover:text-gray-700' : ''}`}
|
||||
>
|
||||
{c.label}
|
||||
{c.key && sort === c.key && <span className="ml-1">{order === 'asc' ? '▲' : '▼'}</span>}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200 font-mono text-sm">
|
||||
{logs.map((log) => (
|
||||
{loading ? (
|
||||
<tr><td colSpan={6} className="px-6 py-12 text-center text-gray-500">Loading…</td></tr>
|
||||
) : logs.length === 0 ? (
|
||||
<tr><td colSpan={6} className="px-6 py-12 text-center text-gray-500">No audit logs found.</td></tr>
|
||||
) : logs.map((log) => (
|
||||
<tr key={log.id} className="hover:bg-gray-50 transition-colors">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-gray-500">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -155,27 +194,47 @@ export default function AuditLogsPage() {
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{logs.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-6 py-12 text-center text-gray-500">
|
||||
No audit logs found.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{hasMore && (
|
||||
<div className="border-t border-gray-200 p-3 text-center bg-gray-50">
|
||||
<button
|
||||
disabled={loadingMore}
|
||||
onClick={() => fetchLogs(logs.length, false)}
|
||||
className="text-sm font-mono px-4 py-1.5 rounded border border-gray-300 hover:bg-white disabled:opacity-60"
|
||||
|
||||
{/* Pagination bar */}
|
||||
<div className="border-t border-gray-200 px-4 py-3 flex flex-wrap items-center justify-between gap-3 bg-gray-50 text-sm font-mono text-gray-600">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>Rows per page:</span>
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => { setPageSize(parseInt(e.target.value, 10)); setPage(0); }}
|
||||
className="rounded-md border-gray-300 h-8 text-sm py-0"
|
||||
>
|
||||
{loadingMore ? 'Loading…' : `Load next ${PAGE_SIZE}`}
|
||||
</button>
|
||||
{PAGE_SIZES.map(s => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
<span className="ml-2">{from.toLocaleString()}–{to.toLocaleString()} of {total.toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
disabled={page === 0}
|
||||
onClick={() => setPage(0)}
|
||||
className="px-2 py-1 rounded border border-gray-300 bg-white hover:bg-gray-100 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>« First</button>
|
||||
<button
|
||||
disabled={page === 0}
|
||||
onClick={() => setPage(p => Math.max(0, p - 1))}
|
||||
className="px-3 py-1 rounded border border-gray-300 bg-white hover:bg-gray-100 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>Prev</button>
|
||||
<span className="px-2">Page {page + 1} of {totalPages.toLocaleString()}</span>
|
||||
<button
|
||||
disabled={page + 1 >= totalPages}
|
||||
onClick={() => setPage(p => p + 1)}
|
||||
className="px-3 py-1 rounded border border-gray-300 bg-white hover:bg-gray-100 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>Next</button>
|
||||
<button
|
||||
disabled={page + 1 >= totalPages}
|
||||
onClick={() => setPage(totalPages - 1)}
|
||||
className="px-2 py-1 rounded border border-gray-300 bg-white hover:bg-gray-100 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>Last »</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+246
-53
@@ -2,9 +2,10 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import api from '../../lib/api';
|
||||
import { formatScanStats } from '../../lib/scanStats';
|
||||
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 +15,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 +63,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 +101,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 +117,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 +231,36 @@ 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}`);
|
||||
// Same counters the global scan button reports — this used to name
|
||||
// three of them and drop the rest (FP-suppressed, pruned packages).
|
||||
const extra = formatScanStats(res.data, ['assets', 'findings', 'new', 'errors']);
|
||||
alert(`App CVE re-scan done for ${asset.hostname}: `
|
||||
+ `${res.data?.findings ?? 0} findings (${res.data?.new ?? 0} new)`
|
||||
+ (extra ? ` · ${extra}` : '') + '.');
|
||||
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 +345,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 +366,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 +377,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 +415,21 @@ export default function AssetsPage() {
|
||||
>
|
||||
⚡ Exposure
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { resetForm(); setIsModalOpen(true); }}
|
||||
className="inline-flex items-center rounded-sm bg-vulncheck-blue px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 font-mono whitespace-nowrap"
|
||||
>
|
||||
Add Asset
|
||||
</button>
|
||||
{canEdit && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { resetForm(); setIsModalOpen(true); }}
|
||||
className="inline-flex items-center rounded-sm bg-truevuln-blue px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 font-mono whitespace-nowrap"
|
||||
>
|
||||
Add Asset
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bulk Actions Bar */}
|
||||
{selectedIds.length > 0 && (
|
||||
<div className="sticky top-0 z-40 bg-vulncheck-blue/90 backdrop-blur-sm text-white px-6 py-3 mb-4 rounded-sm shadow-lg flex items-center justify-between animate-in slide-in-from-top duration-300">
|
||||
<div className="sticky top-0 z-40 bg-truevuln-blue/90 backdrop-blur-sm text-white px-6 py-3 mb-4 rounded-sm shadow-lg flex items-center justify-between animate-in slide-in-from-top duration-300">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="font-mono font-bold">{selectedIds.length} Assets selected</span>
|
||||
<div className="h-6 w-px bg-white/20" />
|
||||
@@ -425,7 +493,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 +502,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 +512,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 +521,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 +532,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 +543,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 +556,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 +574,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 +594,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 +602,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 +617,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 +664,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 +706,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 +722,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 +740,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 +755,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 +764,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 +810,62 @@ export default function AssetsPage() {
|
||||
}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleEdit(asset)}
|
||||
className="text-vulncheck-blue hover:text-blue-900"
|
||||
>
|
||||
<PencilSquareIcon className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteAsset(asset.id)}
|
||||
className="text-red-600 hover:text-red-900"
|
||||
>
|
||||
<TrashIcon className="h-5 w-5" />
|
||||
</button>
|
||||
{canEdit && (
|
||||
<button
|
||||
onClick={() => handleEdit(asset)}
|
||||
className="text-truevuln-blue hover:text-blue-900"
|
||||
>
|
||||
<PencilSquareIcon className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
{canDelete && (
|
||||
<button
|
||||
onClick={() => handleDeleteAsset(asset.id)}
|
||||
className="text-red-600 hover:text-red-900"
|
||||
>
|
||||
<TrashIcon className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{assets.length === 0 && (
|
||||
<tr><td colSpan={6} className="text-center py-4">No assets found.</td></tr>
|
||||
<tr><td colSpan={11} className="text-center py-4">No assets found.</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-gray-200 px-4 py-3 text-sm font-mono text-gray-600">
|
||||
<div>
|
||||
{total === 0 ? '0' : `${(page - 1) * pageSize + 1}–${Math.min(page * pageSize, total)}`} of {total}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="flex items-center gap-1.5">
|
||||
<span className="text-xs text-gray-500">Per page</span>
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => setPageSize(parseInt(e.target.value, 10))}
|
||||
className="rounded-md border-gray-300 text-sm py-1 pl-2 pr-7 focus:border-truevuln-blue focus:ring-truevuln-blue"
|
||||
>
|
||||
{[50, 100, 250, 500, 1000].map(n => <option key={n} value={n}>{n}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={() => setPage(1)} disabled={page <= 1}
|
||||
className="px-2 py-1 rounded border border-gray-300 disabled:opacity-40 hover:bg-gray-50">«</button>
|
||||
<button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page <= 1}
|
||||
className="px-2 py-1 rounded border border-gray-300 disabled:opacity-40 hover:bg-gray-50">‹ Prev</button>
|
||||
<span className="px-2">Page {page} / {totalPages}</span>
|
||||
<button onClick={() => setPage(p => Math.min(totalPages, p + 1))} disabled={page >= totalPages}
|
||||
className="px-2 py-1 rounded border border-gray-300 disabled:opacity-40 hover:bg-gray-50">Next ›</button>
|
||||
<button onClick={() => setPage(totalPages)} disabled={page >= totalPages}
|
||||
className="px-2 py-1 rounded border border-gray-300 disabled:opacity-40 hover:bg-gray-50">»</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Coverage-gap modal — installed packages with no finding */}
|
||||
@@ -718,7 +876,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 +909,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)}`}>
|
||||
|
||||
+176
-2
@@ -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;
|
||||
@@ -71,4 +71,178 @@ textarea::placeholder {
|
||||
}
|
||||
.scroll-visible-x::-webkit-scrollbar-thumb:hover {
|
||||
background: #64748b;
|
||||
}
|
||||
}
|
||||
/* ============================================================
|
||||
* Theme layer — light (default) / mid (soft dark) / dark (full dark).
|
||||
*
|
||||
* The pages are written with hardcoded light utilities (bg-white,
|
||||
* text-gray-900, …). Instead of rewriting every page with dark:
|
||||
* variants, we remap the light-surface utilities under
|
||||
* html[data-theme="mid"|"dark"]. These rules are UN-layered, so they
|
||||
* beat Tailwind's @layer utilities without !important (CSS cascade
|
||||
* layers: un-layered author CSS wins).
|
||||
*
|
||||
* Deliberately NOT remapped: bg-gray-800/900 (the sidebar is already
|
||||
* dark and must stay), brand colors, and white-on-color button text.
|
||||
* ============================================================ */
|
||||
|
||||
html[data-theme="mid"] {
|
||||
--tv-bg: #222933; /* page background */
|
||||
--tv-surface: #2b3440; /* cards, tables, inputs */
|
||||
--tv-surface-2: #333e4d; /* table heads, subtle fills */
|
||||
--tv-surface-3: #3c4a5c; /* hover fills, chips */
|
||||
--tv-border: #46546a;
|
||||
--tv-border-soft: #3a4657;
|
||||
--tv-text: #e7ebf0;
|
||||
--tv-text-soft: #c3cad4;
|
||||
--tv-text-muted: #94a0ae;
|
||||
}
|
||||
html[data-theme="dark"] {
|
||||
--tv-bg: #0b0f16;
|
||||
--tv-surface: #131a24;
|
||||
--tv-surface-2: #1a2330;
|
||||
--tv-surface-3: #232e3e;
|
||||
--tv-border: #2e3a4b;
|
||||
--tv-border-soft: #26303f;
|
||||
--tv-text: #edf1f6;
|
||||
--tv-text-soft: #c6cdd6;
|
||||
--tv-text-muted: #8e99a8;
|
||||
}
|
||||
|
||||
html[data-theme="mid"],
|
||||
html[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
}
|
||||
html[data-theme="mid"] body,
|
||||
html[data-theme="dark"] body {
|
||||
color-scheme: dark;
|
||||
background-color: var(--tv-bg);
|
||||
color: var(--tv-text);
|
||||
}
|
||||
|
||||
/* ---- neutral surfaces ---- */
|
||||
html[data-theme="mid"] .bg-white, html[data-theme="dark"] .bg-white,
|
||||
html[data-theme="mid"] .bg-base-100, html[data-theme="dark"] .bg-base-100 { background-color: var(--tv-surface); }
|
||||
html[data-theme="mid"] .bg-gray-50, html[data-theme="dark"] .bg-gray-50 { background-color: var(--tv-surface-2); }
|
||||
html[data-theme="mid"] .bg-gray-100, html[data-theme="dark"] .bg-gray-100 { background-color: var(--tv-surface-3); }
|
||||
html[data-theme="mid"] .bg-gray-200, html[data-theme="dark"] .bg-gray-200 { background-color: var(--tv-surface-3); }
|
||||
html[data-theme="mid"] .hover\:bg-gray-50:hover, html[data-theme="dark"] .hover\:bg-gray-50:hover,
|
||||
html[data-theme="mid"] .hover\:bg-white:hover, html[data-theme="dark"] .hover\:bg-white:hover { background-color: var(--tv-surface-3); }
|
||||
html[data-theme="mid"] .hover\:bg-gray-100:hover, html[data-theme="dark"] .hover\:bg-gray-100:hover,
|
||||
html[data-theme="mid"] .hover\:bg-gray-200:hover, html[data-theme="dark"] .hover\:bg-gray-200:hover { background-color: var(--tv-border-soft); }
|
||||
|
||||
/* ---- neutral text ---- */
|
||||
html[data-theme="mid"] .text-gray-900, html[data-theme="dark"] .text-gray-900,
|
||||
html[data-theme="mid"] .text-black, html[data-theme="dark"] .text-black,
|
||||
html[data-theme="mid"] .text-base-content, html[data-theme="dark"] .text-base-content { color: var(--tv-text); }
|
||||
html[data-theme="mid"] .text-gray-800, html[data-theme="dark"] .text-gray-800,
|
||||
html[data-theme="mid"] .text-gray-700, html[data-theme="dark"] .text-gray-700 { color: var(--tv-text-soft); }
|
||||
html[data-theme="mid"] .text-gray-600, html[data-theme="dark"] .text-gray-600,
|
||||
html[data-theme="mid"] .text-gray-500, html[data-theme="dark"] .text-gray-500 { color: var(--tv-text-muted); }
|
||||
html[data-theme="mid"] .text-gray-400, html[data-theme="dark"] .text-gray-400 { color: #7e8896; }
|
||||
html[data-theme="mid"] .text-gray-300, html[data-theme="dark"] .text-gray-300 { color: #67707e; }
|
||||
html[data-theme="mid"] .hover\:text-gray-900:hover, html[data-theme="dark"] .hover\:text-gray-900:hover,
|
||||
html[data-theme="mid"] .hover\:text-gray-700:hover, html[data-theme="dark"] .hover\:text-gray-700:hover { color: var(--tv-text); }
|
||||
|
||||
/* ---- borders / dividers / rings ---- */
|
||||
html[data-theme="mid"] .border-gray-100, html[data-theme="dark"] .border-gray-100,
|
||||
html[data-theme="mid"] .border-gray-200, html[data-theme="dark"] .border-gray-200,
|
||||
html[data-theme="mid"] .border-base-300, html[data-theme="dark"] .border-base-300 { border-color: var(--tv-border-soft); }
|
||||
html[data-theme="mid"] .border-gray-300, html[data-theme="dark"] .border-gray-300 { border-color: var(--tv-border); }
|
||||
html[data-theme="mid"] .divide-gray-100 > :not([hidden]) ~ :not([hidden]), html[data-theme="dark"] .divide-gray-100 > :not([hidden]) ~ :not([hidden]),
|
||||
html[data-theme="mid"] .divide-gray-200 > :not([hidden]) ~ :not([hidden]), html[data-theme="dark"] .divide-gray-200 > :not([hidden]) ~ :not([hidden]) { border-color: var(--tv-border-soft); }
|
||||
html[data-theme="mid"] .ring-gray-300, html[data-theme="dark"] .ring-gray-300 { --tw-ring-color: var(--tv-border); }
|
||||
html[data-theme="mid"] .bg-base-300, html[data-theme="dark"] .bg-base-300 { background-color: var(--tv-border-soft); }
|
||||
|
||||
/* ---- form controls ---- */
|
||||
html[data-theme="mid"] input, html[data-theme="dark"] input,
|
||||
html[data-theme="mid"] select, html[data-theme="dark"] select,
|
||||
html[data-theme="mid"] textarea, html[data-theme="dark"] textarea {
|
||||
background-color: var(--tv-surface-2);
|
||||
color: var(--tv-text);
|
||||
border-color: var(--tv-border);
|
||||
}
|
||||
html[data-theme="mid"] input::placeholder, html[data-theme="dark"] input::placeholder,
|
||||
html[data-theme="mid"] textarea::placeholder, html[data-theme="dark"] textarea::placeholder { color: var(--tv-text-muted); }
|
||||
html[data-theme="mid"] input:-webkit-autofill, html[data-theme="dark"] input:-webkit-autofill {
|
||||
/* !important needed: the light-scheme autofill rule above uses it too */
|
||||
-webkit-box-shadow: 0 0 0 1000px var(--tv-surface-2) inset !important;
|
||||
-webkit-text-fill-color: var(--tv-text) !important;
|
||||
caret-color: var(--tv-text);
|
||||
}
|
||||
|
||||
/* ---- tinted badges: pastel fills → translucent dark tints ---- */
|
||||
html[data-theme="mid"] .bg-red-50, html[data-theme="dark"] .bg-red-50 { background-color: rgba(239,68,68,.12); }
|
||||
html[data-theme="mid"] .bg-red-100, html[data-theme="dark"] .bg-red-100 { background-color: rgba(239,68,68,.2); }
|
||||
html[data-theme="mid"] .bg-green-50, html[data-theme="dark"] .bg-green-50 { background-color: rgba(16,185,129,.12); }
|
||||
html[data-theme="mid"] .bg-green-100, html[data-theme="dark"] .bg-green-100 { background-color: rgba(16,185,129,.2); }
|
||||
html[data-theme="mid"] .bg-emerald-100, html[data-theme="dark"] .bg-emerald-100 { background-color: rgba(16,185,129,.2); }
|
||||
html[data-theme="mid"] .bg-blue-50, html[data-theme="dark"] .bg-blue-50 { background-color: rgba(59,130,246,.14); }
|
||||
html[data-theme="mid"] .bg-blue-100, html[data-theme="dark"] .bg-blue-100 { background-color: rgba(59,130,246,.22); }
|
||||
html[data-theme="mid"] .bg-yellow-50, html[data-theme="dark"] .bg-yellow-50 { background-color: rgba(234,179,8,.12); }
|
||||
html[data-theme="mid"] .bg-yellow-100, html[data-theme="dark"] .bg-yellow-100 { background-color: rgba(234,179,8,.2); }
|
||||
html[data-theme="mid"] .bg-orange-100, html[data-theme="dark"] .bg-orange-100 { background-color: rgba(249,115,22,.2); }
|
||||
html[data-theme="mid"] .bg-purple-100, html[data-theme="dark"] .bg-purple-100 { background-color: rgba(168,85,247,.2); }
|
||||
html[data-theme="mid"] .bg-amber-50, html[data-theme="dark"] .bg-amber-50 { background-color: rgba(245,158,11,.12); }
|
||||
|
||||
/* readable tinted text on dark */
|
||||
html[data-theme="mid"] .text-red-800, html[data-theme="dark"] .text-red-800,
|
||||
html[data-theme="mid"] .text-red-700, html[data-theme="dark"] .text-red-700 { color: #f87171; }
|
||||
html[data-theme="mid"] .text-red-600, html[data-theme="dark"] .text-red-600 { color: #ef7d7d; }
|
||||
html[data-theme="mid"] .text-green-700, html[data-theme="dark"] .text-green-700,
|
||||
html[data-theme="mid"] .text-green-600, html[data-theme="dark"] .text-green-600 { color: #4ade80; }
|
||||
html[data-theme="mid"] .text-emerald-700, html[data-theme="dark"] .text-emerald-700 { color: #34d399; }
|
||||
html[data-theme="mid"] .text-blue-700, html[data-theme="dark"] .text-blue-700,
|
||||
html[data-theme="mid"] .text-blue-600, html[data-theme="dark"] .text-blue-600 { color: #60a5fa; }
|
||||
html[data-theme="mid"] .text-yellow-800, html[data-theme="dark"] .text-yellow-800,
|
||||
html[data-theme="mid"] .text-yellow-700, html[data-theme="dark"] .text-yellow-700 { color: #facc15; }
|
||||
html[data-theme="mid"] .text-orange-700, html[data-theme="dark"] .text-orange-700,
|
||||
html[data-theme="mid"] .text-orange-600, html[data-theme="dark"] .text-orange-600 { color: #fb923c; }
|
||||
html[data-theme="mid"] .text-amber-700, html[data-theme="dark"] .text-amber-700,
|
||||
html[data-theme="mid"] .text-amber-600, html[data-theme="dark"] .text-amber-600 { color: #fbbf24; }
|
||||
html[data-theme="mid"] .text-purple-700, html[data-theme="dark"] .text-purple-700 { color: #c084fc; }
|
||||
html[data-theme="mid"] .text-indigo-600, html[data-theme="dark"] .text-indigo-600 { color: #818cf8; }
|
||||
|
||||
/* tinted borders */
|
||||
html[data-theme="mid"] .border-red-200, html[data-theme="dark"] .border-red-200,
|
||||
html[data-theme="mid"] .border-red-300, html[data-theme="dark"] .border-red-300 { border-color: rgba(239,68,68,.4); }
|
||||
html[data-theme="mid"] .border-blue-200, html[data-theme="dark"] .border-blue-200 { border-color: rgba(59,130,246,.4); }
|
||||
html[data-theme="mid"] .border-orange-200, html[data-theme="dark"] .border-orange-200 { border-color: rgba(249,115,22,.4); }
|
||||
|
||||
/* scrollbars follow the theme */
|
||||
html[data-theme="mid"] .scroll-visible-x, html[data-theme="dark"] .scroll-visible-x {
|
||||
scrollbar-color: var(--tv-border) var(--tv-surface-2);
|
||||
}
|
||||
html[data-theme="mid"] .scroll-visible-x::-webkit-scrollbar, html[data-theme="dark"] .scroll-visible-x::-webkit-scrollbar { background: var(--tv-surface-2); }
|
||||
html[data-theme="mid"] .scroll-visible-x::-webkit-scrollbar-thumb, html[data-theme="dark"] .scroll-visible-x::-webkit-scrollbar-thumb {
|
||||
background: var(--tv-border);
|
||||
border-color: var(--tv-surface-2);
|
||||
}
|
||||
|
||||
/* ---- opacity / arbitrary-value variants (own class names, missed by the
|
||||
* plain remaps above — tester: widget headers + sticky list header stayed
|
||||
* light-gray under light text) ---- */
|
||||
html[data-theme="mid"] .bg-gray-50\/50, html[data-theme="dark"] .bg-gray-50\/50 { background-color: var(--tv-surface-2); }
|
||||
html[data-theme="mid"] .bg-white\/50, html[data-theme="dark"] .bg-white\/50 { background-color: var(--tv-surface-3); }
|
||||
/* vulnerabilities list: sticky header band uses an arbitrary value of the
|
||||
* light page background (#F3F4F6 at 95%) */
|
||||
html[data-theme="mid"] .bg-\[\#F3F4F6\]\/95, html[data-theme="dark"] .bg-\[\#F3F4F6\]\/95 { background-color: var(--tv-bg); }
|
||||
html[data-theme="mid"] .group:hover .group-hover\:bg-blue-50\/30, html[data-theme="dark"] .group:hover .group-hover\:bg-blue-50\/30 { background-color: var(--tv-surface-3); }
|
||||
|
||||
/* ---- indigo family (AI widgets: pastel gradient + indigo text) ---- */
|
||||
html[data-theme="mid"] .bg-gradient-to-br, html[data-theme="dark"] .bg-gradient-to-br {
|
||||
background-image: none;
|
||||
background-color: var(--tv-surface);
|
||||
}
|
||||
html[data-theme="mid"] .bg-indigo-50, html[data-theme="dark"] .bg-indigo-50,
|
||||
html[data-theme="mid"] .hover\:bg-indigo-50:hover, html[data-theme="dark"] .hover\:bg-indigo-50:hover { background-color: rgba(99,102,241,.14); }
|
||||
html[data-theme="mid"] .bg-indigo-100, html[data-theme="dark"] .bg-indigo-100 { background-color: rgba(99,102,241,.22); }
|
||||
html[data-theme="mid"] .text-indigo-700, html[data-theme="dark"] .text-indigo-700,
|
||||
html[data-theme="mid"] .text-indigo-800, html[data-theme="dark"] .text-indigo-800,
|
||||
html[data-theme="mid"] .text-indigo-900, html[data-theme="dark"] .text-indigo-900 { color: #a5b4fc; }
|
||||
html[data-theme="mid"] .hover\:text-indigo-800:hover, html[data-theme="dark"] .hover\:text-indigo-800:hover { color: #c7d2fe; }
|
||||
html[data-theme="mid"] .border-indigo-100, html[data-theme="dark"] .border-indigo-100,
|
||||
html[data-theme="mid"] .border-indigo-200, html[data-theme="dark"] .border-indigo-200 { border-color: rgba(99,102,241,.35); }
|
||||
|
||||
/* daisyui base-200 (used by a few shells) */
|
||||
html[data-theme="mid"] .bg-base-200, html[data-theme="dark"] .bg-base-200 { background-color: var(--tv-surface-2); }
|
||||
|
||||
@@ -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>
|
||||
|
||||
+10
-1
@@ -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",
|
||||
@@ -28,6 +28,15 @@ export default function RootLayout({
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head>
|
||||
{/* Apply the stored theme BEFORE first paint so dark users don't get a
|
||||
white flash. Mirrors normalizeTheme() in hooks/useTheme.ts. */}
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `(function(){try{var t=localStorage.getItem('theme');if(t==='corporate')t='light';if(t!=='light'&&t!=='mid'&&t!=='dark')t='light';var h=document.documentElement;h.setAttribute('data-theme',t);if(t!=='light')h.classList.add('dark');}catch(e){}})();`,
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-base-100 text-base-content`}
|
||||
>
|
||||
|
||||
@@ -90,7 +90,18 @@ export default function LoginPage() {
|
||||
setError('MFA verification failed.');
|
||||
} catch (err: any) {
|
||||
const detail = err.response?.data?.detail;
|
||||
setError(detail || 'Invalid or expired MFA code.');
|
||||
// Expired challenge (5-min window) → the mfa_token is dead; a new
|
||||
// code won't help. Send the user back to the credentials step
|
||||
// instead of stranding them on a form that can't succeed.
|
||||
const expired = /expired/i.test(detail || '');
|
||||
if (expired) {
|
||||
setStage('credentials');
|
||||
setMfaCode('');
|
||||
setMfaToken('');
|
||||
setError('MFA session expired — please sign in again.');
|
||||
} else {
|
||||
setError(detail || 'Invalid or expired MFA code.');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -99,9 +110,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 +163,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 +181,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 +196,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 +234,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 +257,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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user