Compare commits

8 Commits
Author SHA1 Message Date
vulncheck 97562abed0 feat(vendor): capture + show software vendor from Intune/Defender/Wazuh
Answers 'is vendor readable via the Intune/Defender API?' — yes:
- Intune detectedApps.publisher was being dropped in _map_apps; now kept
  as the package vendor.
- Defender softwareVendor was folded into the package label; now stored
  separately.
- Wazuh syscollector already carries vendor; now threaded through.

New vulnerabilities.package_vendor column (migration 038, idempotent),
populated at the package-finding chokepoints (app_cve_scanner._upsert +
cvelistv5 scan_asset via pkg vendor, defender _upsert_cve) and shown in
the Affected Package card on the CVE detail page.

Scope: single-package findings. Per-package (vulnerability_packages) and
Nessus/m365 vendor left as follow-up — Nessus rolls vendor into the
plugin name and m365/OS vendor is implicit (Microsoft).
2026-07-23 14:06:05 +02:00
vulncheck 8db635effa fix(scan): tighten Firefox match to require the Mozilla vendor word
Drop the bare-"firefox" alternative from the product key regex so a
stray "…Firefox…" inside another product's name can't resolve to the
Firefox CVE key. Windows ARP / Wazuh inventory always carry the "Mozilla"
prefix (incl. "Mozilla Firefox ESR"), so no real install is lost.
2026-07-23 13:59:38 +02:00
vulncheck 0ba07131cb feat(enrich): Mozilla MFSA severity source for fresh Firefox CVEs
Mozilla publishes a per-CVE impact rating (critical/high/moderate/low)
in foundation-security-advisories before NVD/cvelistV5 have a score — the
gap on brand-new Firefox CVEs. New mozilla_advisory_service builds a
{cve: severity/title/fixed_in/esr_only} index from the MFSA repo (line
parser, no PyYAML dep; 24h cache; github_pat lifts the rate limit) and
apply_mozilla_severity fills the placeholder severity in enrich_vulnerabilities
when the vuln has no CVSS-derived value. Mozilla carries no numeric CVSS,
so it only sets severity/description, never clobbers a real score.

fixed_in / esr_only (e.g. only 'Firefox ESR 115.13' → regular Firefox
unaffected) is the authoritative ESR discriminator, stored for the
upcoming Firefox-scan ESR exclusion.
2026-07-23 13:57:57 +02:00
vulncheck cab46fd819 feat(enrich): add GitHub Advisories (GHSA) as CVSS/severity fallback
Stage 4 of the score cascade (vulnrichment → NVD → cvelistV5 → GHSA).
GHSA carries advisories for CVEs still missing from NVD and cvelistV5
when very fresh — the gap the tester hit on new Firefox/Notepad++ CVEs.
Fills CVSS/severity/description/references only; GHSA 'unreviewed'
advisories carry no affected-version range (verified against the live
API), so no fix/detection data is derived — this is enrichment, not
new-CVE detection.

Optional github_pat setting (encrypted at rest, admin-only Settings
card) lifts the GitHub rate limit 60 → 5000 req/h; the loop stops
cleanly when the limit is hit.
2026-07-23 13:50:32 +02:00
vulncheck acec27367f fix(m365): auto-resolve findings once the host's Office build catches up
The M365 check only ever ADDED rows: upsert refreshes package_version
while a CVE is still missing, but when a host updated Office and the CVE
left the missing set, detect_missing_cves returned affected=False, the
loop skipped it, and the finding stayed OPEN forever with a stale
installed build (e.g. CVE-2026-45460 shown as installed 16.0.19929.20172
on a host already on 20131.20154, past the 20026.20166 fix).

Add a reconcile mirroring Defender/app-scan: after scanning an asset
where an M365 install was actually seen, drop the microsoft365-apps
source from open findings whose CVE is no longer missing, and mark
patched when no other source still reports them. Wired into both
run_m365_check and run_m365_for_packages.
2026-07-23 13:46:28 +02:00
vulncheck 7a220511b8 fix(m365): don't attribute Windows-OS CVEs to Microsoft 365 Apps
The MS 365 Apps security-updates page lists OS-level components (GDI,
MSXML, …) under its 'Office suite' heading — CVEs that are really
Windows-OS bugs Office bundles (e.g. CVE-2026-50387, a Windows GDI vuln
whose real fix is a Win10/Server2016/2025 OS build). The M365 check swept
those in and, worse, upsert overwrote the correct scan_asset_os finding
on the same (cve, asset) row with an M365 product + Office build.

Skip any CVE the cvelistV5 Windows-OS registry already owns, in both
entry points (run_m365_check + run_m365_for_packages). scan_asset_os
keeps ownership with the correct build + MSRC KB.
2026-07-23 13:37:39 +02:00
vulncheck 304900da1b fix(wazuh): scrub stale cross-build-line Windows OS fix on re-sync
Fill-only backfill never corrected fixed_version values stored by
pre-sanitize syncs, so an old cross-line build (e.g. 6.2.9200.x
Server-2012) stayed on a 10.0.26100 Server-2025 host even after the
sanitize guard shipped. Re-sanitize the stored value each sync and clear
it when it belongs to a different build line; MSRC panel supplies the
correct per-branch KB.
2026-07-23 13:34:16 +02:00
vulncheck 3bb480c39f fix(scan): drop ESR false positive; correct Cisco PSIRT feed URL
- cvelistv5: inverse-unaffected fix bound now only for records with
  exactly ONE unaffected entry. Firefox ESR CVEs carry two (e.g.
  CVE-2026-16361: 115.38 lte 115.* + 140.13 lte *), and Mozilla writes
  the ESR floor as an unbounded 'lte *', so a 'below X' rule wrongly
  flagged regular Firefox 121/152. Multi-train records now skipped.
- advisory feeds: DEFAULT cisco-psirt URL corrected to
  psirtrss20/CiscoSecurityAdvisory.xml (old rss.x?i=44 is DTD-refused);
  saved configs auto-migrate the stale URL on load.
2026-07-23 13:27:20 +02:00
20 changed files with 874 additions and 22 deletions
+2
View File
@@ -0,0 +1,2 @@
/cache
/project.local.yml
+167
View File
@@ -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 readonly.
# 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
+44
View File
@@ -0,0 +1,44 @@
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **vulncheck** (3998 symbols, 11930 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 -->
+44
View File
@@ -0,0 +1,44 @@
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **vulncheck** (3998 symbols, 11930 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 -->
@@ -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;")
+1
View File
@@ -30,6 +30,7 @@ PROTECTED_SETTING_KEYS: frozenset[str] = frozenset({
"nessus_config",
"openrouter_api_key",
"intune_config",
"github_pat",
})
+2 -1
View File
@@ -207,7 +207,8 @@ class GraphClient:
for a in apps or []:
name = (a.get("displayName") or "").strip()
if name:
out.append({"name": name, "version": (a.get("version") or "").strip()})
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:
+1
View File
@@ -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)
+12
View File
@@ -64,6 +64,7 @@ class VulnerabilityResponse(BaseModel):
status: VulnerabilityStatus
title: Optional[str]
package_name: Optional[str]
package_vendor: Optional[str] = None
package_version: Optional[str]
fixed_version: Optional[str]
exploitable: bool
@@ -125,6 +126,7 @@ class VulnerabilityResponse(BaseModel):
class PackageInfo(BaseModel):
package_name: str
package_vendor: Optional[str] = None
package_version: Optional[str] = None
fixed_version: Optional[str] = None
source: Optional[str] = None
@@ -337,6 +339,7 @@ def _build_vuln_response(vuln: Vulnerability) -> dict:
"status": vuln.status,
"title": vuln.title,
"package_name": vuln.package_name,
"package_vendor": vuln.package_vendor,
"package_version": vuln.package_version,
"fixed_version": vuln.fixed_version,
"exploitable": vuln.exploitable,
@@ -1875,6 +1878,15 @@ def run_wazuh_vulnerability_sync(db: Session) -> dict:
vuln_data.get("fixed_version"))
if fv and not existing.fixed_version:
existing.fixed_version = fv
elif (existing.fixed_version and _sanitize_wazuh_fix(
merged_packages, merged_versions,
existing.fixed_version) is None):
# Scrub a stale cross-build-line fix stored by a
# pre-sanitize sync (e.g. 6.2.9200.x Server-2012 build
# left on a 10.0.26100 Server-2025 host). Fill-only
# backfill never corrected these. MSRC panel supplies
# the right per-branch KB.
existing.fixed_version = None
# Update score/severity if changed
if score and existing.cvss_score != score:
+4 -1
View File
@@ -43,7 +43,7 @@ DEFAULT_FEEDS: List[dict] = [
{"id": "bsi-wid", "name": "BSI / CERT-Bund (WID)",
"url": "https://wid.cert-bund.de/content/public/securityAdvisory/rss", "enabled": True},
{"id": "cisco-psirt", "name": "Cisco PSIRT Advisories",
"url": "https://sec.cloudapps.cisco.com/security/center/rss.x?i=44", "enabled": False},
"url": "https://sec.cloudapps.cisco.com/security/center/psirtrss20/CiscoSecurityAdvisory.xml", "enabled": False},
]
_ATOM = "{http://www.w3.org/2005/Atom}"
@@ -59,6 +59,9 @@ def get_feed_config(db: Session) -> List[dict]:
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)
+6 -2
View File
@@ -421,7 +421,7 @@ def lookup_cves(db: Session, entry: dict, version: str) -> List[dict]:
# ---------- upsert + scan ----------
def _upsert(db: Session, asset, pkg_name: str, version: str, c: dict, new_ids: list,
touched: Optional[set] = None) -> None:
touched: Optional[set] = None, vendor: Optional[str] = None) -> None:
from app.models.vulnerability import Vulnerability, VulnerabilitySeverity, VulnerabilityStatus
sev_map = {"critical": VulnerabilitySeverity.critical, "high": VulnerabilitySeverity.high,
"medium": VulnerabilitySeverity.medium, "low": VulnerabilitySeverity.low,
@@ -436,6 +436,8 @@ def _upsert(db: Session, asset, pkg_name: str, version: str, c: dict, new_ids: l
existing.add_source("app-scan")
if not existing.package_name:
existing.package_name = pkg_name[:255]
if vendor and not existing.package_vendor:
existing.package_vendor = vendor[:255]
# Re-detected with the CURRENT inventory version → refresh it. Fill-only
# left the first-ever version on the row (tester: Firefox showed
# 'Installed: 150.0.3' while 152.0.5 was on the box).
@@ -464,6 +466,7 @@ def _upsert(db: Session, asset, pkg_name: str, version: str, c: dict, new_ids: l
status=VulnerabilityStatus.open,
title=f"{pkg_name} {version}{cve_id}"[:500],
package_name=pkg_name[:255], package_version=version[:100],
package_vendor=(vendor[:255] if vendor else None),
fixed_version=(c.get("fixed") or None),
detected_at=datetime.now(),
sources=json.dumps(["app-scan"]), first_detected_by="app-scan",
@@ -525,7 +528,8 @@ def scan_asset_packages(db: Session, asset, packages: list, new_ids: Optional[li
continue # CVE is for a different OS platform (e.g. Firefox-iOS)
try:
before = len(new_ids)
_upsert(db, asset, name, eff_ver, c, new_ids, touched=touched)
_upsert(db, asset, name, eff_ver, c, new_ids, touched=touched,
vendor=(pkg.get("vendor") or None))
count += 1 if len(new_ids) > before else 0
except Exception as e:
logger.debug("app-cve upsert failed (%s on %s): %s", c.get("cve"), asset.id, e)
+25 -13
View File
@@ -62,7 +62,12 @@ _REGISTRY: List[dict] = [
"pairs": [("devolutions", "remote desktop manager")]},
{"key": "7-zip", "re": r"7-?zip",
"pairs": [("7-zip", "7-zip"), ("igor pavlov", "7-zip")]},
{"key": "firefox", "re": r"mozilla firefox|(?<!\w)firefox",
# Require the vendor word: match "Mozilla Firefox" (and "Mozilla Firefox
# ESR"), never a bare "Firefox" — a stray "…Firefox…" in some other
# product's name must not resolve here (tester: "nur 'Mozilla' UND
# 'Firefox', nicht 'Firefox' alleine"). Windows ARP / Wazuh always carry the
# "Mozilla" prefix, so this loses no real install.
{"key": "firefox", "re": r"mozilla firefox",
"pairs": [("mozilla", "firefox")]},
{"key": "chrome", "re": r"google chrome|com\.android\.chrome",
"pairs": [("google", "chrome")]},
@@ -233,6 +238,7 @@ def _ranges_from_affected(aff: dict) -> List[Tuple[Optional[str], Optional[str],
unbounded entries are skipped → no over-matching)."""
out = []
unaffected_floors: List[str] = []
unaffected_total = 0
for v in aff.get("versions", []) or []:
if not isinstance(v, dict):
continue
@@ -242,12 +248,13 @@ def _ranges_from_affected(aff: dict) -> List[Tuple[Optional[str], Optional[str],
# lessThanOrEqual="*"). Skipping those meant the newest Firefox CVEs
# never entered the index. "X and up are fixed" == "below X is
# affected", so remember X as a fix bound.
if (v.get("status") == "unaffected"
and not v.get("lessThan")
and (v.get("lessThanOrEqual") in ("*", None))):
ver = v.get("version")
if isinstance(ver, str) and ver.strip() not in ("0", "*", "-", ""):
unaffected_floors.append(ver.strip())
if v.get("status") == "unaffected":
unaffected_total += 1
if (not v.get("lessThan")
and (v.get("lessThanOrEqual") in ("*", None))):
ver = v.get("version")
if isinstance(ver, str) and ver.strip() not in ("0", "*", "-", ""):
unaffected_floors.append(ver.strip())
continue
start = None
lt = v.get("lessThan")
@@ -274,11 +281,15 @@ def _ranges_from_affected(aff: dict) -> List[Tuple[Optional[str], Optional[str],
if lt or lte:
out.append((start, lt, lte))
# Inverse-only records (see above): derive the fix bound from the single
# "unaffected" floor. Only when the entry stated no affected range of its
# own, and only when there is exactly one floor — several floors mean
# several servicing branches (release vs ESR) and picking one would either
# over- or under-report.
if not out and len(unaffected_floors) == 1:
# "unaffected" floor — but ONLY when the record has EXACTLY ONE unaffected
# entry. Firefox ESR CVEs list two ("115.38 lte 115.*" AND "140.13 lte *"),
# and Mozilla writes the ESR floor as an unbounded "lte *", so a "below X"
# rule wrongly catches regular Firefox (tester: ESR-only CVE-2026-16361
# flagged on Firefox 121/152). Multiple unaffected entries = multi-train
# (ESR + release) record → the inverse heuristic can't tell them apart, so
# skip it rather than risk the false positive. Distinguishing them reliably
# needs Mozilla's MFSA advisories (per-product), not cvelistV5 alone.
if not out and unaffected_total == 1 and len(unaffected_floors) == 1:
out.append((None, unaffected_floors[0], None))
return out
@@ -670,7 +681,8 @@ def scan_asset(db: Session, asset, packages: list, index: dict,
"fixed": entry.get("lt")}
try:
before = len(new_ids)
cpe._upsert(db, asset, name, eff_ver, c, new_ids, touched=touched)
cpe._upsert(db, asset, name, eff_ver, c, new_ids, touched=touched,
vendor=(pkg.get("vendor") or None))
count += 1 if len(new_ids) > before else 0
except Exception as e:
logger.debug("cvelistv5 upsert failed (%s on %s): %s", entry["cve"], asset.id, e)
+9 -4
View File
@@ -77,7 +77,8 @@ def _match_asset(db: Session, machine: dict):
return None
def _upsert_cve(db: Session, asset, vuln: dict, new_ids: list, software: Optional[str] = None) -> 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-"):
@@ -99,6 +100,8 @@ def _upsert_cve(db: Session, asset, vuln: dict, new_ids: list, software: Optiona
# Fill the affected-software/package column if it was empty.
if software and not existing.package_name:
existing.package_name = software[:255]
if vendor and not existing.package_vendor:
existing.package_vendor = vendor[:255]
if existing.status == VulnerabilityStatus.patched:
existing.status = VulnerabilityStatus.open
existing.patched_at = None
@@ -117,6 +120,7 @@ def _upsert_cve(db: Session, asset, vuln: dict, new_ids: list, software: Optiona
title=(vuln.get("name") or cve_id)[:500],
description=(vuln.get("description") or None),
package_name=(software[:255] if software else None),
package_vendor=(vendor[:255] if vendor else None),
detected_at=datetime.now(),
sources=json.dumps([SOURCE_NAME]),
first_detected_by=SOURCE_NAME,
@@ -200,7 +204,7 @@ def run_defender_sync(db: Session) -> dict:
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
sw_map[(mid, cve)] = {"label": label, "vendor": vendor or None}
except Exception as e:
logger.debug("defender software map build failed: %s", e)
@@ -218,8 +222,9 @@ def run_defender_sync(db: Session) -> dict:
cve = (v.get("id") or "").strip().upper()
if cve:
seen_cves.add(cve)
software = sw_map.get((m.get("id", ""), cve))
_upsert_cve(db, asset, v, new_ids, software=software)
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
# Auto-resolve defender-only findings this machine no longer reports.
# Guarded to non-empty responses so a transient/clean read can't
+12 -1
View File
@@ -836,12 +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"nvd_dates_set={stats['nvd_dates_set']}"
f"nvd_dates_set={stats['nvd_dates_set']}, "
f"mozilla_severity={stats.get('mozilla_severity', 0)}"
)
return stats
+84
View File
@@ -229,6 +229,23 @@ def _channel_max(release: dict, channel: str) -> Optional[Tuple[int, int]]:
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],
*,
@@ -372,6 +389,48 @@ def upsert_m365_vulnerability(
# 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.
@@ -382,6 +441,8 @@ def run_m365_check(db: Session, wazuh, asset_id: Optional[int] = None) -> dict:
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)
@@ -410,12 +471,15 @@ def run_m365_check(db: Session, wazuh, asset_id: Optional[int] = None) -> dict:
# 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:
@@ -429,6 +493,8 @@ def run_m365_check(db: Session, wazuh, asset_id: Optional[int] = None) -> dict:
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,
@@ -440,6 +506,7 @@ def run_m365_check(db: Session, wazuh, asset_id: Optional[int] = None) -> dict:
)
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:
@@ -449,6 +516,12 @@ def run_m365_check(db: Session, wazuh, asset_id: Optional[int] = None) -> dict:
)
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()
@@ -563,14 +636,18 @@ def run_m365_for_packages(db: Session, asset, packages: list) -> int:
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:
@@ -580,6 +657,8 @@ def run_m365_for_packages(db: Session, asset, packages: list) -> int:
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,
@@ -587,9 +666,14 @@ def run_m365_for_packages(db: Session, asset, packages: list) -> int:
)
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
+266
View File
@@ -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")
+87
View File
@@ -668,6 +668,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 +728,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
+73
View File
@@ -121,6 +121,78 @@ function OpenRouterCard() {
);
}
// GitHub PAT — lifts the GitHub Security Advisories (GHSA) enrichment fallback
// from 60 to 5000 req/h. Read-only scope is enough (public advisories).
function GitHubPatCard() {
const [pat, setPat] = useState('');
const [patSet, setPatSet] = useState(false);
const [status, setStatus] = useState<{ message: string; type: string }>({ message: '', type: '' });
useEffect(() => {
api.get('/api/v1/settings/github_pat')
.then(r => setPatSet(!!r.data?.value))
.catch(() => setPatSet(false));
}, []);
const save = async () => {
if (!pat.trim()) return;
setStatus({ message: 'Saving…', type: 'loading' });
try {
await api.put('/api/v1/settings/github_pat', { value: pat.trim() });
setPatSet(true);
setPat('');
setStatus({ message: 'GitHub PAT saved.', type: 'success' });
} catch (e: any) {
setStatus({ message: e?.response?.data?.detail || 'Save failed', type: 'error' });
}
};
const clear = async () => {
if (!confirm('Remove the stored GitHub PAT? GHSA enrichment falls back to the unauthenticated 60 req/h limit.')) return;
try {
await api.put('/api/v1/settings/github_pat', { value: '' });
setPatSet(false);
setStatus({ message: 'PAT removed.', type: 'success' });
} catch (e: any) {
setStatus({ message: e?.response?.data?.detail || 'Failed', type: 'error' });
}
};
return (
<div className="bg-white border border-gray-200 shadow-sm rounded-sm p-6 mt-8">
<h3 className="text-lg font-bold text-gray-900 font-mono mb-1 border-b border-gray-100 pb-2 flex items-center gap-2">
<span>🐙</span> GitHub Advisories (GHSA) enrichment
{patSet
? <span className="rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-600/20">Configured</span>
: <span className="rounded-md bg-gray-50 px-2 py-1 text-xs font-medium text-gray-500 ring-1 ring-inset ring-gray-300">Not set</span>}
</h3>
<p className="text-xs text-gray-500 mb-4">
Last-resort CVSS/severity/description source for fresh CVEs still missing from NVD and cvelistV5.
Optional without a token GHSA still works at 60 req/h; a fine-grained PAT (no scopes needed, public data) lifts it to 5000 req/h. Create one at github.com/settings/tokens.
</p>
<div>
<label className="block text-xs font-medium text-gray-700 font-mono mb-1">Personal Access Token</label>
<input
type="password"
value={pat}
onChange={(e) => setPat(e.target.value)}
placeholder={patSet ? '•••••••• (set — type to replace)' : 'github_pat_...'}
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-truevuln-blue focus:ring-truevuln-blue sm:text-sm h-11 px-3"
/>
</div>
{status.message && (
<p className={`mt-3 text-sm font-mono ${status.type === 'error' ? 'text-red-600' : status.type === 'success' ? 'text-green-600' : 'text-blue-600 animate-pulse'}`}>{status.message}</p>
)}
<div className="flex justify-end gap-2 mt-4">
{patSet && (
<button onClick={clear} className="px-4 py-2 text-sm font-medium text-red-700 bg-white border border-red-300 rounded-md hover:bg-red-50">Remove PAT</button>
)}
<button onClick={save} className="px-4 py-2 text-sm font-medium text-white bg-truevuln-blue rounded-md hover:bg-blue-600">Save</button>
</div>
</div>
);
}
// Syslog forwarding of audit-log events to an external SIEM (UDP/TCP, RFC-5424).
// Config is one plain JSON blob (not a secret). Self-contained.
function SyslogCard() {
@@ -1613,6 +1685,7 @@ export default function SettingsPage() {
<>
<OpenRouterCard />
<IntuneCard />
<GitHubPatCard />
<SyslogCard />
</>
)}
@@ -597,6 +597,12 @@ export default function VulnerabilityDetailPage() {
<span className="text-gray-500">Package:</span>
<p className="font-bold text-gray-900">{vuln.package_name}</p>
</div>
{vuln.package_vendor && (
<div>
<span className="text-gray-500">Vendor:</span>
<p className="font-bold text-gray-900 break-words">{vuln.package_vendor}</p>
</div>
)}
{vuln.package_version && (
<div>
<span className="text-gray-500">Installed:</span>
+1
View File
@@ -59,6 +59,7 @@ export interface Vulnerability {
status: 'open' | 'patched' | 'pending_verification' | 'patch_failed' | 'accepted_risk' | 'false_positive' | 'deferred';
description: string;
package_name?: string;
package_vendor?: string | null; // vendor/publisher from Wazuh/Intune/Defender
package_version?: string;
fixed_version?: string | null; // when set + status='open' → patch available
packages?: VulnerabilityPackage[]; // per-package detail (CVE-2023-48795 hits PuTTY + WinSCP both)