From 6919cecbb3b571c5209bb248fff2bdef675abfd2 Mon Sep 17 00:00:00 2001 From: Thomas Date: Mon, 22 Jun 2026 14:16:02 -0400 Subject: [PATCH 1/9] Created CVEFilter component for CVE autocomplete --- .../src/components/FilterDrawer/CVEFilter.tsx | 425 ++++++++++++++++++ 1 file changed, 425 insertions(+) create mode 100644 frontend/src/components/FilterDrawer/CVEFilter.tsx diff --git a/frontend/src/components/FilterDrawer/CVEFilter.tsx b/frontend/src/components/FilterDrawer/CVEFilter.tsx new file mode 100644 index 000000000..f2b9905f8 --- /dev/null +++ b/frontend/src/components/FilterDrawer/CVEFilter.tsx @@ -0,0 +1,425 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import Autocomplete from '@mui/material/Autocomplete'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Checkbox from '@mui/material/Checkbox'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import FormGroup from '@mui/material/FormGroup'; +import List from '@mui/material/List'; +import ListItem from '@mui/material/ListItem'; +import TextField from '@mui/material/TextField'; +import Typography from '@mui/material/Typography'; +import { useTheme } from '@mui/material/styles'; +import { useAuthContext } from '@/context'; +import { logger } from '@/utils/logger'; +import { ENDPOINTS } from '@/constants/endpoints'; + +export const DOMAIN_FILTER_KEY = 'name'; +export const ORGANIZATION_FILTER_KEY = 'organization_id'; +export const REGION_FILTER_KEY = 'organization.region_id'; + +export interface ResultShallow { + id: string; + name?: string; + ip?: string; +} + +interface Props { + addFilter: ( + name: string, + value: string, + filterType: 'all' | 'any' | 'none' + ) => void; + removeFilter: ( + name: string, + value: string, + filterType: 'all' | 'any' | 'none' + ) => void; + filters: any[]; + search_field: string; +} + +export const DomainAndIPFilter: React.FC = ({ + addFilter, + removeFilter, + filters, + search_field +}) => { + const { apiPost } = useAuthContext(); + const [domainResults, setDomainResults] = useState([]); + const [domainSearchTerm, setDomainSearchTerm] = useState(''); + const [ipResults, setIpResults] = useState([]); + const [ipSearchTerm, setIpSearchTerm] = useState(''); + const search_term = search_field === 'name' ? domainSearchTerm : ipSearchTerm; + const [selectedDomain, setSelectedDomain] = useState( + null + ); + const [selectedIp, setSelectedIp] = useState(null); + + const theme = useTheme(); + + const compareIp = (a: string, b: string) => { + const aParts = a.split('.').map(Number); + const bParts = b.split('.').map(Number); + for (let i = 0; i < 4; i++) { + if (aParts[i] !== bParts[i]) { + return aParts[i] - bParts[i]; + } + } + return 0; + }; + + const isIp = (str: string) => { + return /^\d{1,3}(\.\d{1,3}){3}$/.test(str); + }; + + const searchDomainsAndIPs = useCallback( + async ( + search_term: string, + search_field: string, + regions: string[], + organizations: string[] + ) => { + try { + const results = await apiPost<{ + body: { + hits: { + hits: { _source: ResultShallow }[]; + }; + }; + }>(ENDPOINTS.DOMAIN_IP_SEARCH_ES, { + body: { search_term, search_field, regions, organizations } + }); + const body = results?.body?.hits?.hits; + if (search_field === 'name') { + const domains = body.filter((hit) => !!hit._source.name); + const filteredDomains = domains.filter((hit) => { + const isFiltered = !!filters.find( + (filter) => + filter.field === DOMAIN_FILTER_KEY && + filter.values.includes(hit._source.name) + ); + return !isFiltered; + }); + const sortedDomains = filteredDomains.sort((a, b) => { + const aName = a._source.name ?? ''; + const bName = b._source.name ?? ''; + if (isIp(aName) && isIp(bName)) { + return compareIp(aName, bName); + } + return aName.localeCompare(bName); + }); + setDomainResults( + sortedDomains.map((hit) => ({ + id: hit._source.id, + name: hit._source.name + })) + ); + } else if (search_field === 'ip') { + const ips = body.filter((hit) => !!hit._source.ip); + const filteredIps = ips.filter((hit) => { + const isFiltered = !!filters.find( + (filter) => + filter.field === 'ip' && filter.values.includes(hit._source.ip) + ); + return !isFiltered; + }); + + const sortedIps = filteredIps.sort((a, b) => + compareIp(a._source.ip ?? '', b._source.ip ?? '') + ); + setIpResults( + sortedIps.map((hit) => ({ + id: hit._source.id, + ip: hit._source.ip + })) + ); + } else { + setDomainResults([]); + setIpResults([]); + return []; + } + } catch (error) { + logger.error('Error fetching domain and IP search results:', error); + setDomainResults([]); + setIpResults([]); + } + }, + [apiPost, filters] + ); + + const regionFilterValues = useMemo(() => { + const regionFilter = filters.find((f) => f.field === REGION_FILTER_KEY); + return regionFilter ? (regionFilter.values as string[]) : []; + }, [filters]); + + const orgFilterValues = useMemo(() => { + const orgFilters = filters.find((f) => f.field === ORGANIZATION_FILTER_KEY); + if (!orgFilters) return []; + + const orgFiltersId = orgFilters.values?.map((val: any) => val.id); + return orgFilters ? (orgFiltersId as string[]) : []; + }, [filters]); + + const domainsInFilters = useMemo(() => { + const domainFilters = filters.find( + (filter) => filter.field === DOMAIN_FILTER_KEY + ); + return domainFilters ? domainFilters.values : []; + }, [filters]); + + const ipsInFilters = useMemo(() => { + const ipFilters = filters.find( + (filter) => filter.field === 'ip' && filter.values.length > 0 + ); + return ipFilters ? ipFilters.values : []; + }, [filters]); + + const handleUseDomainResult = (result: ResultShallow | null) => { + if (result && result.name) { + addFilter('name', result.name, 'any'); + setDomainSearchTerm(''); + setSelectedDomain(null); + } + }; + + const handleUseIpResult = (result: ResultShallow | null) => { + if (result && result.ip) { + addFilter('ip', result.ip, 'any'); + setIpSearchTerm(''); + setSelectedIp(null); + } + }; + + const handleDomainTextChange = (text: string) => { + setDomainSearchTerm(text); + }; + + const handleIpTextChange = (text: string) => { + setIpSearchTerm(text); + }; + + useEffect(() => { + searchDomainsAndIPs( + search_term, + search_field, + regionFilterValues, + orgFilterValues + ); + }, [ + search_term, + search_field, + searchDomainsAndIPs, + regionFilterValues, + orgFilterValues + ]); + + return ( + + { + setTimeout(() => { + if (search_field === 'name') { + setSelectedDomain(v as ResultShallow | null); + handleUseDomainResult(v as ResultShallow | null); + } else { + setSelectedIp(v as ResultShallow | null); + handleUseIpResult(v as ResultShallow | null); + } + }, 250); + return; + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + if (search_field === 'name') { + setSelectedDomain(selectedDomain); + handleUseDomainResult(selectedDomain); + } else { + setSelectedIp(selectedIp); + handleUseIpResult(selectedIp); + } + } + }} + onInputChange={(e, v, reason) => { + // Only update the input value when the user types (reason === 'input'). + // This prevents selection/programmatic events from repopulating the input. + if (reason === 'input') { + if (search_field === 'name') { + handleDomainTextChange(v); + } else { + handleIpTextChange(v); + } + } + }} + // freeSolo + disableClearable + options={search_field === 'name' ? domainResults : ipResults} + getOptionLabel={(option) => option.name ?? option.ip ?? ''} + slotProps={{ + listbox: { + sx: { + ':active': { + bgcolor: 'transparent' + }, + overflow: 'auto', + overscrollBehavior: 'contain' + } + } + }} + renderOption={(params, option) => { + return ( +
  • + +
  • + ); + }} + isOptionEqualToValue={(option, value) => { + if (option.id !== value.id) return false; + if (search_field === 'name') { + return option.name === value.name; + } else { + return option.ip === value.ip; + } + }} + renderInput={(params) => ( + + )} + /> + + + {search_field === 'name' && + domainsInFilters?.map((resultName: string, id: string) => { + return ( + + + + {resultName} + + } + control={ + + } + checked={true} + onChange={() => { + const exists = domainsInFilters.find( + (v: string) => v === resultName + ); + if (exists) { + removeFilter(DOMAIN_FILTER_KEY, resultName, 'any'); + } else { + addFilter(DOMAIN_FILTER_KEY, resultName, 'any'); + } + }} + /> + + + ); + })} + {search_field === 'ip' && + ipsInFilters?.map((ip: string, index: number) => { + return ( + + + + {ip} + + } + control={ + + } + checked={true} + onChange={() => { + const exists = ipsInFilters.find((v: string) => v === ip); + if (exists) { + removeFilter('ip', ip, 'any'); + } else { + addFilter('ip', ip, 'any'); + } + }} + /> + + + ); + })} + +
    + ); +}; From 36bd7a53cfc38be5eef5be4de6ba6f2e54c68fba Mon Sep 17 00:00:00 2001 From: Thomas Date: Mon, 22 Jun 2026 15:41:13 -0400 Subject: [PATCH 2/9] CVE-based edits to es_client.py: - define CVE_INDEX - define cve_mapping - define sync_cve_index function - define update_cves function - define search_cves function --- .../src/xfd_django/xfd_api/tasks/es_client.py | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/backend/src/xfd_django/xfd_api/tasks/es_client.py b/backend/src/xfd_django/xfd_api/tasks/es_client.py index 4dd6f9c22..a4b1f4fb7 100644 --- a/backend/src/xfd_django/xfd_api/tasks/es_client.py +++ b/backend/src/xfd_django/xfd_api/tasks/es_client.py @@ -11,6 +11,7 @@ # Constants DOMAINS_INDEX = "domains-5" ORGANIZATIONS_INDEX = "organizations-1" +CVE_INDEX = "cve-1" # Define mappings organization_mapping = { @@ -26,6 +27,12 @@ "suggest": {"type": "completion"}, } } +cve_mapping = { + "properties": { + "name": {"type": "text"}, + "suggest": {"type": "completion"}, + } +} LOGGER = logging.getLogger(__name__) # Raise log level for Elasticsearch client to WARNING to suppress request logs logging.getLogger("elasticsearch").setLevel(logging.WARNING) @@ -63,6 +70,25 @@ def sync_organizations_index(self): LOGGER.error("Error syncing organizations index: %s", e) raise e + def sync_cve_index(self): + """Create or updates the CVE index with mappings.""" + try: + if not self.client.indices.exists(index=CVE_INDEX): + LOGGER.info("Creating index %s...", CVE_INDEX) + self.client.indices.create( + index=CVE_INDEX, + body={ + "mappings": cve_mapping, + "settings": {"number_of_shards": 2}, + }, + ) + else: + LOGGER.info("Updating index %s...", CVE_INDEX) + self.client.indices.put_mapping(index=CVE_INDEX, body=cve_mapping) + except Exception as e: + LOGGER.error("Error syncing CVE index: %s", e) + raise e + def sync_domains_index(self): """Create or updates the domains index with mappings.""" try: @@ -102,6 +128,20 @@ def update_organizations(self, organizations): ] self._bulk_update(actions) + def update_cves(self, cves): + """Update or inserts CVEs into Elasticsearch.""" + actions = [ + { + "_op_type": "update", + "_index": CVE_INDEX, + "_id": cve["id"], + "doc": {**cve, "suggest": [{"input": cve["name"], "weight": 1}]}, + "doc_as_upsert": True, + } + for cve in cves + ] + self._bulk_update(actions) + def update_domains(self, domains, max_retries=5, backoff_base=2): """Update or insert domains into Elasticsearch with retry and backoff.""" actions = [ @@ -189,6 +229,10 @@ def search_organizations(self, body): """Search organizations index with specified query body.""" return self.client.search(index=ORGANIZATIONS_INDEX, body=body) + def search_cves(self, body): + """Search CVE index with specified query body.""" + return self.client.search(index=CVE_INDEX, body=body) + def _bulk_update(self, actions): """Update to Elasticsearch.""" try: From 0472591563869470a9509c83fd4ab7767b1df227 Mon Sep 17 00:00:00 2001 From: Thomas Date: Mon, 22 Jun 2026 15:47:43 -0400 Subject: [PATCH 3/9] update CVE_INDEX to cves-1 --- backend/src/xfd_django/xfd_api/tasks/es_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/xfd_django/xfd_api/tasks/es_client.py b/backend/src/xfd_django/xfd_api/tasks/es_client.py index a4b1f4fb7..55938236f 100644 --- a/backend/src/xfd_django/xfd_api/tasks/es_client.py +++ b/backend/src/xfd_django/xfd_api/tasks/es_client.py @@ -11,7 +11,7 @@ # Constants DOMAINS_INDEX = "domains-5" ORGANIZATIONS_INDEX = "organizations-1" -CVE_INDEX = "cve-1" +CVE_INDEX = "cves-1" # Define mappings organization_mapping = { From 42c88be64219de2d7fe5a307244b50c2cdba6282 Mon Sep 17 00:00:00 2001 From: Thomas Date: Wed, 24 Jun 2026 14:21:27 -0400 Subject: [PATCH 4/9] Updates to CVE ES index and api method: - modified cve_mapping in es_client.py to include organization. - modified cve.py to only return CVEs to Standard Users that match the organization of the user making the request. - modified CveSearchBody model to remove regions field since it is not used in the CVE search. - modified syncmdl.py to include a call to sync_es_cves() after syncing organizations in ES. This ensures that CVEs are synced after organizations, which is important for maintaining the correct relationships between CVEs and organizations in the ES index. - this may not be strictly necessary since sync_es_domains() already syncs CVEs, but it is included for completeness and to ensure that CVEs are always synced after organizations. - code comments have been added to indicate that this step may not be strictly necessary, but it is included for completeness. - added sync_es_cves() function to es_sync.py to sync CVEs from the database to the ES index. This function retrieves all CVEs from the database and indexes them in ES, ensuring that the ES index is up-to-date with the latest CVE data. - removed unnecessary fields from the CVE list dictionary in es_sync.py to only include the fields that are needed for the CVE ES index. This reduces the amount of data that is indexed in ES and improves performance when searching for CVEs. - added sync_es_cves to searchSync.py to ensure that CVEs are synced when the search index is updated. This ensures that the CVE data in ES is always up-to-date and consistent with the database. --- .../src/xfd_django/xfd_api/api_methods/cve.py | 79 ++++++++++++++++++- .../xfd_api/management/commands/syncmdl.py | 8 +- .../xfd_django/xfd_api/schema_models/cve.py | 6 ++ .../src/xfd_django/xfd_api/tasks/es_client.py | 4 +- .../tasks/helpers/syncdb_helpers/es_sync.py | 72 ++++++++++++++++- .../xfd_django/xfd_api/tasks/searchSync.py | 6 +- backend/src/xfd_django/xfd_api/views.py | 22 +++++- 7 files changed, 188 insertions(+), 9 deletions(-) diff --git a/backend/src/xfd_django/xfd_api/api_methods/cve.py b/backend/src/xfd_django/xfd_api/api_methods/cve.py index 6490a512a..5f201465c 100644 --- a/backend/src/xfd_django/xfd_api/api_methods/cve.py +++ b/backend/src/xfd_django/xfd_api/api_methods/cve.py @@ -1,15 +1,26 @@ """Cve API.""" # Standard Python Libraries import datetime -from typing import Optional +import logging +from typing import Any, Dict, Optional # Third-Party Libraries from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator from django.db.models import Q from fastapi import HTTPException, status from xfd_mini_dl.models import Cve as CveModel +from xfd_mini_dl.models import User, UserType -from ..auth import is_global_write_admin +from ..api_methods.organization import escape_special_characters +from ..auth import ( + get_org_memberships, + is_global_view_admin, + is_global_write_admin, + is_regional_admin, +) +from ..tasks.es_client import ESClient + +LOGGER = logging.getLogger(__name__) def get_cves_by_id(cve_id): @@ -87,3 +98,67 @@ async def get_all_cves( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"DB error: {e}", ) + + +def search_cves_task(search_body, current_user: User): + """ + Search CVEs in Elasticsearch. + + Args: + search_body (dict): The search query body. + current_user: The current user object. + + Returns: + dict: The CVE search results with Organization IDs from Elasticsearch. + """ + try: + # Check if user is GlobalViewAdmin or has memberships + if not ( + is_global_view_admin(current_user) or is_regional_admin(current_user) + ) and not get_org_memberships(current_user): + return [] + + # Initialize Elasticsearch client + client = ESClient() + + # Construct the Elasticsearch query + query_body: Dict[str, Any] = {"query": {"bool": {"must": [], "filter": []}}} + + # Use match_all if searchTerm is empty + if search_body.search_term.strip(): + sanitized_search_term = escape_special_characters(search_body.search_term) + query_body["query"]["bool"]["must"].append( + { + "query_string": { + "query": "*{}*".format(sanitized_search_term), + "fields": ["name"], + "fuzziness": "AUTO", + "analyze_wildcard": True, + } + } + ) + else: + query_body["query"]["bool"]["must"].append({"match_all": {}}) + + # For standard users, only show CVEs affecting their organization + if current_user.user_type == UserType.STANDARD: + org_ids = get_org_memberships(current_user) + if not org_ids: + return [] + query_body["query"]["bool"]["filter"].append( + {"terms": {"organization_ids": org_ids}} + ) + + # Log the query for debugging + LOGGER.debug("Query body: %s", query_body) + + # Execute the search + search_results = client.search_cves(query_body) + + return {"body": search_results} + + except HTTPException as http_exc: + raise http_exc + except Exception as e: + LOGGER.exception("Error occurred while searching CVEs: %s", e) + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR) diff --git a/backend/src/xfd_django/xfd_api/management/commands/syncmdl.py b/backend/src/xfd_django/xfd_api/management/commands/syncmdl.py index 9a69af38e..c04c7b77d 100644 --- a/backend/src/xfd_django/xfd_api/management/commands/syncmdl.py +++ b/backend/src/xfd_django/xfd_api/management/commands/syncmdl.py @@ -12,6 +12,7 @@ ) from xfd_api.tasks.helpers.syncdb_helpers.es_sync import ( manage_elasticsearch_indices, + sync_es_cves, sync_es_organizations, ) from xfd_api.tasks.helpers.syncdb_helpers.fill_static_tables import ( @@ -144,10 +145,13 @@ def handle(self, *args, **options): # pylint: disable=R0915 # Step 4.1: Sync domains in ES sync_es_domains({}) - # Step 5: Sync organizations in ES + # Step 5: Sync organizations in ES - may not be needed since sync_es_domains() already syncs organizations, but keeping it for completeness sync_es_organizations() - # Step 6: Populate Scan Results + # Step 6: Sync CVEs in ES - may not be needed since sync_es_domains() already syncs CVEs, but keeping it for completeness + sync_es_cves() + + # Step 7: Populate Scan Results if metrics: self.stdout.write("Generating scan results...") populate_scan_results() diff --git a/backend/src/xfd_django/xfd_api/schema_models/cve.py b/backend/src/xfd_django/xfd_api/schema_models/cve.py index fbf249ea1..389889d6d 100644 --- a/backend/src/xfd_django/xfd_api/schema_models/cve.py +++ b/backend/src/xfd_django/xfd_api/schema_models/cve.py @@ -49,3 +49,9 @@ class GetAllCvesResponse(BaseModel): status: str payload: List[Cve] + + +class CveSearchBody(BaseModel): + """Elastic search CVE request model.""" + + search_term: str = "" diff --git a/backend/src/xfd_django/xfd_api/tasks/es_client.py b/backend/src/xfd_django/xfd_api/tasks/es_client.py index 55938236f..a8eae22fa 100644 --- a/backend/src/xfd_django/xfd_api/tasks/es_client.py +++ b/backend/src/xfd_django/xfd_api/tasks/es_client.py @@ -31,8 +31,10 @@ "properties": { "name": {"type": "text"}, "suggest": {"type": "completion"}, + "organization_ids": {"type": "keyword"}, } } + LOGGER = logging.getLogger(__name__) # Raise log level for Elasticsearch client to WARNING to suppress request logs logging.getLogger("elasticsearch").setLevel(logging.WARNING) @@ -70,7 +72,7 @@ def sync_organizations_index(self): LOGGER.error("Error syncing organizations index: %s", e) raise e - def sync_cve_index(self): + def sync_cves_index(self): """Create or updates the CVE index with mappings.""" try: if not self.client.indices.exists(index=CVE_INDEX): diff --git a/backend/src/xfd_django/xfd_api/tasks/helpers/syncdb_helpers/es_sync.py b/backend/src/xfd_django/xfd_api/tasks/helpers/syncdb_helpers/es_sync.py index c4f2015a5..f4998d4e5 100644 --- a/backend/src/xfd_django/xfd_api/tasks/helpers/syncdb_helpers/es_sync.py +++ b/backend/src/xfd_django/xfd_api/tasks/helpers/syncdb_helpers/es_sync.py @@ -5,7 +5,8 @@ # Third-Party Libraries from xfd_api.tasks.es_client import ESClient -from xfd_mini_dl.models import Organization +from xfd_mini_dl.models import Cve as CveModel +from xfd_mini_dl.models import Organization, Vulnerability # Elasticsearch client es_client = ESClient() @@ -23,6 +24,7 @@ def manage_elasticsearch_indices(dangerouslyforce): es_client.delete_all() es_client.sync_organizations_index() es_client.sync_domains_index() + es_client.sync_cves_index() LOGGER.info("Elasticsearch indices synchronized.") except Exception as e: LOGGER.error("Error managing Elasticsearch indices: %s", e) @@ -73,3 +75,71 @@ def sync_es_organizations(): except Exception as e: LOGGER.exception("Error syncing organizations: %s", e) raise e + + +def sync_es_cves(): + """Sync elastic search CVEs.""" + try: + # Fetch all CVEs with their affected organizations + cves_with_orgs = {} + + # Get unique CVE-Organization pairs from vulnerabilities + vulns = ( + Vulnerability.objects.filter(cve__isnull=False) + .values("cve", "organization_id") + .distinct() + ) + + for vuln in vulns: + cve_name = vuln["cve"] + org_id = vuln["organization_id"] + if cve_name not in cves_with_orgs: + cves_with_orgs[cve_name] = [] + if org_id: + cves_with_orgs[cve_name].append(str(org_id)) + + # Fetch all CVEs + cves = list( + CveModel.objects.all().values( + "id", + "name", + "published_at", + "modified_at", + "status", + "description", + # "cvss_v2_source", + # "cvss_v2_type", + # "cvss_v2_vector_string", + # "cvss_v2_base_severity", + # "cvss_v2_exploitability_score", + # "cvss_v2_impact_score", + # "cvss_v3_source", + # "cvss_v3_type", + # "cvss_v3_vector_string", + # "cvss_v3_base_severity", + # "cvss_v3_exploitability_score", + # "cvss_v3_impact_score", + # "cvss_v4_source", + # "cvss_v4_type", + # "cvss_v4_vector_string", + # "cvss_v4_base_severity", + # "cvss_v4_exploitability_score", + # "cvss_v4_impact_score", + ) + ) + + # Add organization IDs to each CVE + for cve in cves: + cve["organization_ids"] = cves_with_orgs.get(cve["name"], []) + + LOGGER.info("Found %d CVEs to sync.", len(cves)) + + if cves: + # Update Elasticsearch with CVEs + es_client.update_cves(cves) + LOGGER.info("CVE sync complete.") + else: + LOGGER.info("No CVEs to sync.") + except Exception as e: + LOGGER.exception("Error syncing CVEs: %s", e) + raise e diff --git a/backend/src/xfd_django/xfd_api/tasks/searchSync.py b/backend/src/xfd_django/xfd_api/tasks/searchSync.py index 2fbb3d20d..f9351e517 100644 --- a/backend/src/xfd_django/xfd_api/tasks/searchSync.py +++ b/backend/src/xfd_django/xfd_api/tasks/searchSync.py @@ -10,7 +10,7 @@ from xfd_mini_dl.models import Domain, Ip, SubDomains from .es_client import ESClient -from .helpers.syncdb_helpers.es_sync import sync_es_organizations +from .helpers.syncdb_helpers.es_sync import sync_es_cves, sync_es_organizations # Set up logging LOGGER = logging.getLogger(__name__) @@ -186,3 +186,7 @@ def handler(command_options): LOGGER.info("Syncing organizations..") sync_es_organizations() LOGGER.info("Organization sync complete.") + + LOGGER.info("Syncing CVEs..") + sync_es_cves() + LOGGER.info("CVE sync complete.") diff --git a/backend/src/xfd_django/xfd_api/views.py b/backend/src/xfd_django/xfd_api/views.py index d346116c4..74823db27 100644 --- a/backend/src/xfd_django/xfd_api/views.py +++ b/backend/src/xfd_django/xfd_api/views.py @@ -37,7 +37,12 @@ from .api_methods import organization, proxy, scan, scan_tasks, user from .api_methods.blocklist import handle_bulk_check_ips from .api_methods.cpe import get_cpes_by_id -from .api_methods.cve import get_all_cves, get_cves_by_id, get_cves_by_name +from .api_methods.cve import ( + get_all_cves, + get_cves_by_id, + get_cves_by_name, + search_cves_task, +) from .api_methods.dmz_sync import CybersixSyncParams from .api_methods.dns_twist_sync import dns_twist_sync_post from .api_methods.domain import ( @@ -123,7 +128,7 @@ ) from .schema_models.cpe import Cpe as CpeSchema from .schema_models.cve import Cve as CveSchema -from .schema_models.cve import GetAllCvesResponse +from .schema_models.cve import CveSearchBody, GetAllCvesResponse from .schema_models.dmz_sync import ( AsmSyncRequest, AsmSyncResponse, @@ -435,6 +440,19 @@ async def get_call_all_cves( ) +@api_router.post( + "/search/cves", + dependencies=[Depends(get_current_active_user)], + tags=["CVEs"], +) +async def search_cves( + search_body: CveSearchBody, + current_user: User = Depends(get_current_active_user), +): + """Search CVEs in Elasticsearch.""" + return search_cves_task(search_body, current_user) + + # ======================================== # New Export Endpoint # ======================================== From 68dd7f3c884aa89016d84a1ef6286777365f514f Mon Sep 17 00:00:00 2001 From: Thomas Date: Thu, 25 Jun 2026 15:58:24 -0400 Subject: [PATCH 5/9] Frontend & Backend changes for CVE Autocomplete: - Backend: - Updated the Elasticsearch mapping for CVEs to use "keyword" type for the "name" field instead of "text". This change allows for exact matching and better performance in autocomplete scenarios. - Added escape_wildcard_query to the CVE search query to handle special characters, but leave dashes, in the search term. - It also uppercases the search term to ensure case-insensitive matching, as CVE names are typically uppercase. - Frontend: - Updated the CVEFilter component to handle the new autocomplete functionality. - Changed the mapping of CVE names in the filter to use a number as the key instead of a string, which is more appropriate for list rendering in React. - Removed commented-out code related to domain and IP search, as it is not relevant to the CVE autocomplete feature. - Adjusted the useEffect hook to ensure that the searchCVEs function is called whenever the search term or filters change, ensuring that the displayed results are always up-to-date. - Added endpoint constants for the CVE search API to maintain consistency and avoid hardcoding URLs in the component. - Rendered CVEFilter component inside the DrawerInterior component, which is responsible for displaying the filter options in the UI. - Added a List component to display the filtered CVE results, allowing users to easily see and deselect CVEs chosen from the autocomplete suggestions. --- .../src/xfd_django/xfd_api/api_methods/cve.py | 38 +- .../src/xfd_django/xfd_api/tasks/es_client.py | 4 +- .../src/components/FilterDrawer/CVEFilter.tsx | 361 +++++------------- .../FilterDrawer/DrawerInterior.tsx | 8 + frontend/src/constants/endpoints.ts | 1 + 5 files changed, 129 insertions(+), 283 deletions(-) diff --git a/backend/src/xfd_django/xfd_api/api_methods/cve.py b/backend/src/xfd_django/xfd_api/api_methods/cve.py index 5f201465c..091f2854e 100644 --- a/backend/src/xfd_django/xfd_api/api_methods/cve.py +++ b/backend/src/xfd_django/xfd_api/api_methods/cve.py @@ -11,7 +11,6 @@ from xfd_mini_dl.models import Cve as CveModel from xfd_mini_dl.models import User, UserType -from ..api_methods.organization import escape_special_characters from ..auth import ( get_org_memberships, is_global_view_admin, @@ -23,6 +22,23 @@ LOGGER = logging.getLogger(__name__) +def escape_wildcard_query(search_term: str) -> str: + """Escape wildcard metacharacters in search term for wildcard queries. + + Only escape backslash, asterisk, and question mark which have special meaning + in Elasticsearch wildcard queries. Everything else (including dashes) is literal. + Makes search case-insensitive by converting to uppercase to match stored CVE names. + """ + # Convert to uppercase to match stored CVE names (CVE-2016-... format) + search_term = search_term.upper() + # Escape backslash first to avoid double-escaping + result = search_term.replace("\\", "\\\\") + # Escape wildcard characters + result = result.replace("*", "\\*") + result = result.replace("?", "\\?") + return result + + def get_cves_by_id(cve_id): """ Get Cve by id. @@ -126,16 +142,11 @@ def search_cves_task(search_body, current_user: User): # Use match_all if searchTerm is empty if search_body.search_term.strip(): - sanitized_search_term = escape_special_characters(search_body.search_term) + # Use wildcard query on name.keyword (non-tokenized) to preserve dashes in CVE names + # Only escape wildcard metacharacters (* and ?), leave dashes and other chars literal + sanitized_search_term = escape_wildcard_query(search_body.search_term) query_body["query"]["bool"]["must"].append( - { - "query_string": { - "query": "*{}*".format(sanitized_search_term), - "fields": ["name"], - "fuzziness": "AUTO", - "analyze_wildcard": True, - } - } + {"wildcard": {"name.keyword": "*{}*".format(sanitized_search_term)}} ) else: query_body["query"]["bool"]["must"].append({"match_all": {}}) @@ -150,10 +161,15 @@ def search_cves_task(search_body, current_user: User): ) # Log the query for debugging - LOGGER.debug("Query body: %s", query_body) + LOGGER.info("CVE Search Query: %s", query_body) + LOGGER.info("Search term: %s", search_body.search_term) # Execute the search search_results = client.search_cves(query_body) + LOGGER.info( + "CVE Search Results: %d hits", + len(search_results.get("hits", {}).get("hits", [])), + ) return {"body": search_results} diff --git a/backend/src/xfd_django/xfd_api/tasks/es_client.py b/backend/src/xfd_django/xfd_api/tasks/es_client.py index a8eae22fa..a9c7019f1 100644 --- a/backend/src/xfd_django/xfd_api/tasks/es_client.py +++ b/backend/src/xfd_django/xfd_api/tasks/es_client.py @@ -29,7 +29,9 @@ } cve_mapping = { "properties": { - "name": {"type": "text"}, + "name": { + "type": "keyword", + }, "suggest": {"type": "completion"}, "organization_ids": {"type": "keyword"}, } diff --git a/frontend/src/components/FilterDrawer/CVEFilter.tsx b/frontend/src/components/FilterDrawer/CVEFilter.tsx index f2b9905f8..1810a67ba 100644 --- a/frontend/src/components/FilterDrawer/CVEFilter.tsx +++ b/frontend/src/components/FilterDrawer/CVEFilter.tsx @@ -14,14 +14,11 @@ import { useAuthContext } from '@/context'; import { logger } from '@/utils/logger'; import { ENDPOINTS } from '@/constants/endpoints'; -export const DOMAIN_FILTER_KEY = 'name'; -export const ORGANIZATION_FILTER_KEY = 'organization_id'; -export const REGION_FILTER_KEY = 'organization.region_id'; +export const CVE_FILTER_KEY = 'vulnerabilities.cve'; -export interface ResultShallow { +export interface CVEResult { id: string; name?: string; - ip?: string; } interface Props { @@ -36,241 +33,112 @@ interface Props { filterType: 'all' | 'any' | 'none' ) => void; filters: any[]; - search_field: string; } -export const DomainAndIPFilter: React.FC = ({ +export const CVEFilter: React.FC = ({ addFilter, removeFilter, - filters, - search_field + filters }) => { const { apiPost } = useAuthContext(); - const [domainResults, setDomainResults] = useState([]); - const [domainSearchTerm, setDomainSearchTerm] = useState(''); - const [ipResults, setIpResults] = useState([]); - const [ipSearchTerm, setIpSearchTerm] = useState(''); - const search_term = search_field === 'name' ? domainSearchTerm : ipSearchTerm; - const [selectedDomain, setSelectedDomain] = useState( - null + const [cveResults, setCveResults] = useState([]); + const [searchTerm, setSearchTerm] = useState(''); + const [selectedCVE, setSelectedCVE] = useState( + undefined ); - const [selectedIp, setSelectedIp] = useState(null); const theme = useTheme(); - const compareIp = (a: string, b: string) => { - const aParts = a.split('.').map(Number); - const bParts = b.split('.').map(Number); - for (let i = 0; i < 4; i++) { - if (aParts[i] !== bParts[i]) { - return aParts[i] - bParts[i]; - } - } - return 0; - }; - - const isIp = (str: string) => { - return /^\d{1,3}(\.\d{1,3}){3}$/.test(str); - }; - - const searchDomainsAndIPs = useCallback( - async ( - search_term: string, - search_field: string, - regions: string[], - organizations: string[] - ) => { + const searchCVEs = useCallback( + async (search_term: string) => { try { const results = await apiPost<{ body: { hits: { - hits: { _source: ResultShallow }[]; + hits: { _source: CVEResult }[]; }; }; - }>(ENDPOINTS.DOMAIN_IP_SEARCH_ES, { - body: { search_term, search_field, regions, organizations } + }>(ENDPOINTS.CVE_SEARCH_ES, { + body: { search_term } }); const body = results?.body?.hits?.hits; - if (search_field === 'name') { - const domains = body.filter((hit) => !!hit._source.name); - const filteredDomains = domains.filter((hit) => { + if (body) { + const rawCVEResults = body + .filter((hit) => !!hit._source.name) + .map((hit) => hit._source); + + const filteredCveResults = rawCVEResults.filter((hit) => { const isFiltered = !!filters.find( (filter) => - filter.field === DOMAIN_FILTER_KEY && - filter.values.includes(hit._source.name) + filter.field === CVE_FILTER_KEY && + filter.values.includes(hit.name) ); return !isFiltered; }); - const sortedDomains = filteredDomains.sort((a, b) => { - const aName = a._source.name ?? ''; - const bName = b._source.name ?? ''; - if (isIp(aName) && isIp(bName)) { - return compareIp(aName, bName); - } + + const sortedCveResults = filteredCveResults.sort((a, b) => { + const aName = a.name ?? ''; + const bName = b.name ?? ''; return aName.localeCompare(bName); }); - setDomainResults( - sortedDomains.map((hit) => ({ - id: hit._source.id, - name: hit._source.name - })) - ); - } else if (search_field === 'ip') { - const ips = body.filter((hit) => !!hit._source.ip); - const filteredIps = ips.filter((hit) => { - const isFiltered = !!filters.find( - (filter) => - filter.field === 'ip' && filter.values.includes(hit._source.ip) - ); - return !isFiltered; - }); - const sortedIps = filteredIps.sort((a, b) => - compareIp(a._source.ip ?? '', b._source.ip ?? '') - ); - setIpResults( - sortedIps.map((hit) => ({ - id: hit._source.id, - ip: hit._source.ip - })) - ); + setCveResults(sortedCveResults); } else { - setDomainResults([]); - setIpResults([]); - return []; + setCveResults([]); } } catch (error) { - logger.error('Error fetching domain and IP search results:', error); - setDomainResults([]); - setIpResults([]); + logger.error('Error fetching CVE search results:', error); + setCveResults([]); } }, [apiPost, filters] ); - const regionFilterValues = useMemo(() => { - const regionFilter = filters.find((f) => f.field === REGION_FILTER_KEY); - return regionFilter ? (regionFilter.values as string[]) : []; - }, [filters]); - - const orgFilterValues = useMemo(() => { - const orgFilters = filters.find((f) => f.field === ORGANIZATION_FILTER_KEY); - if (!orgFilters) return []; - - const orgFiltersId = orgFilters.values?.map((val: any) => val.id); - return orgFilters ? (orgFiltersId as string[]) : []; - }, [filters]); - - const domainsInFilters = useMemo(() => { - const domainFilters = filters.find( - (filter) => filter.field === DOMAIN_FILTER_KEY - ); - return domainFilters ? domainFilters.values : []; - }, [filters]); - - const ipsInFilters = useMemo(() => { - const ipFilters = filters.find( - (filter) => filter.field === 'ip' && filter.values.length > 0 - ); - return ipFilters ? ipFilters.values : []; + const cvesInFilters = useMemo(() => { + return filters + .filter((f) => f.field === CVE_FILTER_KEY) + .flatMap((f) => f.values); }, [filters]); - const handleUseDomainResult = (result: ResultShallow | null) => { + const handleUseCVEResult = (result: CVEResult | undefined) => { if (result && result.name) { - addFilter('name', result.name, 'any'); - setDomainSearchTerm(''); - setSelectedDomain(null); + addFilter(CVE_FILTER_KEY, result.name, 'any'); + setSearchTerm(''); + setSelectedCVE(undefined); } }; - - const handleUseIpResult = (result: ResultShallow | null) => { - if (result && result.ip) { - addFilter('ip', result.ip, 'any'); - setIpSearchTerm(''); - setSelectedIp(null); - } - }; - - const handleDomainTextChange = (text: string) => { - setDomainSearchTerm(text); - }; - - const handleIpTextChange = (text: string) => { - setIpSearchTerm(text); + const handleTextChange = (text: string) => { + setSearchTerm(text); }; useEffect(() => { - searchDomainsAndIPs( - search_term, - search_field, - regionFilterValues, - orgFilterValues - ); - }, [ - search_term, - search_field, - searchDomainsAndIPs, - regionFilterValues, - orgFilterValues - ]); + searchCVEs(searchTerm); + }, [searchCVEs, searchTerm, filters]); return ( { setTimeout(() => { - if (search_field === 'name') { - setSelectedDomain(v as ResultShallow | null); - handleUseDomainResult(v as ResultShallow | null); - } else { - setSelectedIp(v as ResultShallow | null); - handleUseIpResult(v as ResultShallow | null); - } + setSelectedCVE(v as CVEResult | undefined); + handleUseCVEResult(v as CVEResult | undefined); }, 250); return; }} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - if (search_field === 'name') { - setSelectedDomain(selectedDomain); - handleUseDomainResult(selectedDomain); - } else { - setSelectedIp(selectedIp); - handleUseIpResult(selectedIp); - } - } - }} onInputChange={(e, v, reason) => { // Only update the input value when the user types (reason === 'input'). // This prevents selection/programmatic events from repopulating the input. if (reason === 'input') { - if (search_field === 'name') { - handleDomainTextChange(v); - } else { - handleIpTextChange(v); - } + handleTextChange(v); } }} // freeSolo disableClearable - options={search_field === 'name' ? domainResults : ipResults} - getOptionLabel={(option) => option.name ?? option.ip ?? ''} + options={cveResults} + getOptionLabel={(option) => option.name ?? ''} slotProps={{ listbox: { sx: { @@ -307,118 +175,69 @@ export const DomainAndIPFilter: React.FC = ({ id="search-results-button" onClick={() => setTimeout(() => { - if (search_field === 'name' && option.name) { - setSelectedDomain(option); - handleUseDomainResult(option); - } else if (search_field === 'ip' && option.ip) { - setSelectedIp(option); - handleUseIpResult(option); - } + setSelectedCVE(option); + handleUseCVEResult(option); }, 250) } > - {'name' in option ? option.name : option.ip} + {option.name} ); }} isOptionEqualToValue={(option, value) => { if (option.id !== value.id) return false; - if (search_field === 'name') { - return option.name === value.name; - } else { - return option.ip === value.ip; - } + return true; }} renderInput={(params) => ( )} /> - {search_field === 'name' && - domainsInFilters?.map((resultName: string, id: string) => { - return ( - - - - {resultName} - - } - control={ - - } - checked={true} - onChange={() => { - const exists = domainsInFilters.find( - (v: string) => v === resultName - ); - if (exists) { - removeFilter(DOMAIN_FILTER_KEY, resultName, 'any'); - } else { - addFilter(DOMAIN_FILTER_KEY, resultName, 'any'); - } - }} - /> - - - ); - })} - {search_field === 'ip' && - ipsInFilters?.map((ip: string, index: number) => { - return ( - - - - {ip} - - } - control={ - + {cvesInFilters?.map((cveName: string, id: number) => { + return ( + + + + {cveName} + + } + control={ + + } + checked={true} + onChange={() => { + const exists = cvesInFilters.find( + (v: string) => v === cveName + ); + if (exists) { + removeFilter(CVE_FILTER_KEY, cveName, 'any'); + } else { + addFilter(CVE_FILTER_KEY, cveName, 'any'); } - checked={true} - onChange={() => { - const exists = ipsInFilters.find((v: string) => v === ip); - if (exists) { - removeFilter('ip', ip, 'any'); - } else { - addFilter('ip', ip, 'any'); - } - }} - /> - - - ); - })} + }} + /> + + + ); + })} ); diff --git a/frontend/src/components/FilterDrawer/DrawerInterior.tsx b/frontend/src/components/FilterDrawer/DrawerInterior.tsx index b0d487b1a..2187178ed 100644 --- a/frontend/src/components/FilterDrawer/DrawerInterior.tsx +++ b/frontend/src/components/FilterDrawer/DrawerInterior.tsx @@ -25,6 +25,7 @@ import { SaveSearchModal } from '../SaveSearchModal/SaveSearchModal'; import { ENDPOINTS } from '@/constants/endpoints'; import { logger } from '@/utils/logger'; import { DomainAndIPFilter } from './DomainAndIPFilter'; +import { CVEFilter } from './CVEFilter'; interface Props { addFilter: ContextType['addFilter']; @@ -392,6 +393,13 @@ export const DrawerInterior: React.FC = (props) => { )} + + + Date: Thu, 25 Jun 2026 16:32:09 -0400 Subject: [PATCH 6/9] debugging: - adding sync_es_cves to syncmdl command may be causing issues with github actions, so commenting it out for now. It may not be needed since sync_es_domains() already syncs CVEs, but keeping it for completeness. --- .../src/xfd_django/xfd_api/management/commands/syncmdl.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/backend/src/xfd_django/xfd_api/management/commands/syncmdl.py b/backend/src/xfd_django/xfd_api/management/commands/syncmdl.py index c04c7b77d..183b3ee77 100644 --- a/backend/src/xfd_django/xfd_api/management/commands/syncmdl.py +++ b/backend/src/xfd_django/xfd_api/management/commands/syncmdl.py @@ -10,9 +10,8 @@ populate_sample_data, populate_scan_results, ) -from xfd_api.tasks.helpers.syncdb_helpers.es_sync import ( +from xfd_api.tasks.helpers.syncdb_helpers.es_sync import ( # sync_es_cves, manage_elasticsearch_indices, - sync_es_cves, sync_es_organizations, ) from xfd_api.tasks.helpers.syncdb_helpers.fill_static_tables import ( @@ -149,7 +148,7 @@ def handle(self, *args, **options): # pylint: disable=R0915 sync_es_organizations() # Step 6: Sync CVEs in ES - may not be needed since sync_es_domains() already syncs CVEs, but keeping it for completeness - sync_es_cves() + # sync_es_cves() # Step 7: Populate Scan Results if metrics: From 1d0c458f0661df85508d202bea2d46dea229c755 Mon Sep 17 00:00:00 2001 From: Thomas Date: Fri, 26 Jun 2026 11:54:13 -0400 Subject: [PATCH 7/9] Edit syncmdl.py: - Moved call to sync_es_cves inside the populate block to ensure CVEs are synced inside the populate block to avoid erroring out when no Vulnerability Materialized Views exist yet. --- .../src/xfd_django/xfd_api/management/commands/syncmdl.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/src/xfd_django/xfd_api/management/commands/syncmdl.py b/backend/src/xfd_django/xfd_api/management/commands/syncmdl.py index 183b3ee77..d80c9500f 100644 --- a/backend/src/xfd_django/xfd_api/management/commands/syncmdl.py +++ b/backend/src/xfd_django/xfd_api/management/commands/syncmdl.py @@ -144,13 +144,13 @@ def handle(self, *args, **options): # pylint: disable=R0915 # Step 4.1: Sync domains in ES sync_es_domains({}) + # Step 4.2: Sync organizations in ES - may not be needed since sync_es_domains() already syncs organizations, but keeping it for completeness + sync_es_organizations() + # Step 5: Sync organizations in ES - may not be needed since sync_es_domains() already syncs organizations, but keeping it for completeness sync_es_organizations() - # Step 6: Sync CVEs in ES - may not be needed since sync_es_domains() already syncs CVEs, but keeping it for completeness - # sync_es_cves() - - # Step 7: Populate Scan Results + # Step 6: Populate Scan Results if metrics: self.stdout.write("Generating scan results...") populate_scan_results() From f4b6b48fb8461ee19abd18807d2c8a211893baa6 Mon Sep 17 00:00:00 2001 From: Thomas Date: Fri, 26 Jun 2026 11:59:37 -0400 Subject: [PATCH 8/9] Edit syncmdl.py: - Moved call to sync_es_cves inside the populate block to ensure CVEs are synced inside the populate block to avoid erroring out when no Vulnerability Materialized Views exist yet. --- .../src/xfd_django/xfd_api/management/commands/syncmdl.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/backend/src/xfd_django/xfd_api/management/commands/syncmdl.py b/backend/src/xfd_django/xfd_api/management/commands/syncmdl.py index d80c9500f..36dfad540 100644 --- a/backend/src/xfd_django/xfd_api/management/commands/syncmdl.py +++ b/backend/src/xfd_django/xfd_api/management/commands/syncmdl.py @@ -12,6 +12,7 @@ ) from xfd_api.tasks.helpers.syncdb_helpers.es_sync import ( # sync_es_cves, manage_elasticsearch_indices, + sync_es_cves, sync_es_organizations, ) from xfd_api.tasks.helpers.syncdb_helpers.fill_static_tables import ( @@ -144,8 +145,8 @@ def handle(self, *args, **options): # pylint: disable=R0915 # Step 4.1: Sync domains in ES sync_es_domains({}) - # Step 4.2: Sync organizations in ES - may not be needed since sync_es_domains() already syncs organizations, but keeping it for completeness - sync_es_organizations() + # Step 4.2: Sync CVEs in ES - moved here to ensure CVEs are synced inside the populate block to avoid erroring out when no Vulnerability Materialized Views exist yet. + sync_es_cves() # Step 5: Sync organizations in ES - may not be needed since sync_es_domains() already syncs organizations, but keeping it for completeness sync_es_organizations() From a69c2266da26e5504cc160546ded3696f1fb3f06 Mon Sep 17 00:00:00 2001 From: Thomas Date: Fri, 26 Jun 2026 13:03:08 -0400 Subject: [PATCH 9/9] Cleaned up commented-out code --- .../tasks/helpers/syncdb_helpers/es_sync.py | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/backend/src/xfd_django/xfd_api/tasks/helpers/syncdb_helpers/es_sync.py b/backend/src/xfd_django/xfd_api/tasks/helpers/syncdb_helpers/es_sync.py index f4998d4e5..8615d700d 100644 --- a/backend/src/xfd_django/xfd_api/tasks/helpers/syncdb_helpers/es_sync.py +++ b/backend/src/xfd_django/xfd_api/tasks/helpers/syncdb_helpers/es_sync.py @@ -107,24 +107,6 @@ def sync_es_cves(): "modified_at", "status", "description", - # "cvss_v2_source", - # "cvss_v2_type", - # "cvss_v2_vector_string", - # "cvss_v2_base_severity", - # "cvss_v2_exploitability_score", - # "cvss_v2_impact_score", - # "cvss_v3_source", - # "cvss_v3_type", - # "cvss_v3_vector_string", - # "cvss_v3_base_severity", - # "cvss_v3_exploitability_score", - # "cvss_v3_impact_score", - # "cvss_v4_source", - # "cvss_v4_type", - # "cvss_v4_vector_string", - # "cvss_v4_base_severity", - # "cvss_v4_exploitability_score", - # "cvss_v4_impact_score", ) )