Compare commits
8
Commits
bf945f838f
...
97562abed0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97562abed0 | ||
|
|
8db635effa | ||
|
|
0ba07131cb | ||
|
|
cab46fd819 | ||
|
|
acec27367f | ||
|
|
7a220511b8 | ||
|
|
304900da1b | ||
|
|
3bb480c39f |
@@ -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** (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,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;")
|
||||
@@ -30,6 +30,7 @@ PROTECTED_SETTING_KEYS: frozenset[str] = frozenset({
|
||||
"nessus_config",
|
||||
"openrouter_api_key",
|
||||
"intune_config",
|
||||
"github_pat",
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user