aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/sec_certs
diff options
context:
space:
mode:
authorJán Jančár2023-04-24 20:10:38 +0200
committerGitHub2023-04-24 20:10:38 +0200
commit8b0600e0e057e7afcf8529c96a4fb5961e18e8a0 (patch)
treed0ca8870cb899fe48a2cb1bf2775ac8660055a7f /src/sec_certs
parent9b7535aefcb88a5427cf22b55204205036c3fe59 (diff)
parentd4825d1650776735ddab3cedcc6f0c34d5aedb95 (diff)
downloadsec-certs-8b0600e0e057e7afcf8529c96a4fb5961e18e8a0.tar.gz
sec-certs-8b0600e0e057e7afcf8529c96a4fb5961e18e8a0.tar.zst
sec-certs-8b0600e0e057e7afcf8529c96a4fb5961e18e8a0.zip
Merge pull request #328 from crocs-muni/issue/324-Switch-from-NVD-data-feeds-to-API
Switch from NVD json feeds to API
Diffstat (limited to 'src/sec_certs')
-rw-r--r--src/sec_certs/configuration.py19
-rw-r--r--src/sec_certs/constants.py5
-rw-r--r--src/sec_certs/dataset/cc.py17
-rw-r--r--src/sec_certs/dataset/cpe.py171
-rw-r--r--src/sec_certs/dataset/cve.py294
-rw-r--r--src/sec_certs/dataset/dataset.py188
-rw-r--r--src/sec_certs/dataset/json_path_dataset.py8
-rw-r--r--src/sec_certs/model/cpe_matching.py5
-rw-r--r--src/sec_certs/sample/__init__.py3
-rw-r--r--src/sec_certs/sample/cpe.py137
-rw-r--r--src/sec_certs/sample/cve.py229
-rw-r--r--src/sec_certs/sample/fips.py3
-rw-r--r--src/sec_certs/serialization/json.py44
-rw-r--r--src/sec_certs/utils/nvd_dataset_builder.py365
-rw-r--r--src/sec_certs/utils/pandas.py4
-rw-r--r--src/sec_certs/utils/pdf.py11
16 files changed, 937 insertions, 566 deletions
diff --git a/src/sec_certs/configuration.py b/src/sec_certs/configuration.py
index c189981a..f4883dbd 100644
--- a/src/sec_certs/configuration.py
+++ b/src/sec_certs/configuration.py
@@ -2,7 +2,7 @@ from __future__ import annotations
import json
from pathlib import Path
-from typing import Optional
+from typing import Literal, Optional
import yaml
from pydantic import AnyHttpUrl, BaseSettings, Field
@@ -69,6 +69,16 @@ class Configuration(BaseSettings):
fips_mip_latest_snapshot: AnyHttpUrl = Field(
"https://seccerts.org/fips/mip/latest.json", description="URL for the latest snapshot of FIPS MIP data"
)
+ cpe_latest_snapshot: AnyHttpUrl = Field(
+ "https://seccerts.org/cpe/cpe_dataset.json.gz", description="URL for the latest snapshot of CPEDataset."
+ )
+ cve_latest_snapshot: AnyHttpUrl = Field(
+ "https://seccerts.org/cve/cve_dataset.json.gz", description="URL for the latest snapshot of CVEDataset."
+ )
+ cpe_match_latest_snapshot: AnyHttpUrl = Field(
+ "https://seccerts.org/cpe/cpe_match_dataset.json.gz",
+ description="URL for the latest snapshot of cpe match json.",
+ )
fips_matching_threshold: int = Field(
90,
description="Level of required similarity before FIPS IUT/MIP entry is considered to match a FIPS certificate.",
@@ -99,6 +109,13 @@ class Configuration(BaseSettings):
enable_progress_bars: bool = Field(
True, description="If true, progress bars will be printed to stdout during computation."
)
+ nvd_api_key: Optional[str] = Field(None, description="NVD API key for access to CVEs and CPEs.") # noqa: UP007
+ preferred_source_nvd_datasets: Literal["sec-certs", "api"] = Field(
+ "sec-certs",
+ description="If set to `sec-certs`, will fetch CPE and CVE datasets from seccerts.org."
+ + " If set to `api`, will fetch these resources from NVD API. It is advised to set an"
+ + " `nvd_api_key` when setting this to `nvd`.",
+ )
def _get_nondefault_keys(self) -> set[str]:
"""
diff --git a/src/sec_certs/constants.py b/src/sec_certs/constants.py
index 8f3ab05b..b28b925d 100644
--- a/src/sec_certs/constants.py
+++ b/src/sec_certs/constants.py
@@ -1,12 +1,15 @@
import re
from pathlib import Path
+from typing import Final
DUMMY_NONEXISTING_PATH = Path("/this/is/dummy/nonexisting/path")
RESPONSE_OK = 200
RETURNCODE_OK = "ok"
RETURNCODE_NOK = "nok"
-REQUEST_TIMEOUT = 10
+REQUEST_TIMEOUT = 20
+
+INCREMENTAL_NVD_UPDATE_MAX_INTERVAL_DAYS: Final[int] = 120
MIN_CORRECT_CERT_SIZE = 5000
diff --git a/src/sec_certs/dataset/cc.py b/src/sec_certs/dataset/cc.py
index a962bb9c..161c8f65 100644
--- a/src/sec_certs/dataset/cc.py
+++ b/src/sec_certs/dataset/cc.py
@@ -1,14 +1,13 @@
from __future__ import annotations
import itertools
-import json
import locale
import shutil
import tempfile
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
-from typing import ClassVar, Iterator
+from typing import ClassVar, Iterator, cast
import numpy as np
import pandas as pd
@@ -31,7 +30,7 @@ from sec_certs.sample.cc_certificate_id import CertificateId
from sec_certs.sample.cc_maintenance_update import CCMaintenanceUpdate
from sec_certs.sample.cc_scheme import EntryType
from sec_certs.sample.protection_profile import ProtectionProfile
-from sec_certs.serialization.json import ComplexSerializableType, CustomJSONDecoder, serialize
+from sec_certs.serialization.json import ComplexSerializableType, serialize
from sec_certs.utils import helpers
from sec_certs.utils import parallel_processing as cert_processing
@@ -355,7 +354,7 @@ class CCDataset(Dataset[CCCertificate, CCAuxiliaryDatasets], ComplexSerializable
# TODO: Now skipping bad lines, smarter heuristics to be built for dumb files
df = pd.read_csv(file, engine="python", encoding="windows-1252", on_bad_lines="skip")
- df = df.rename(columns={x: y for (x, y) in zip(list(df.columns), csv_header)})
+ df = df.rename(columns=dict(zip(list(df.columns), csv_header)))
df["is_maintenance"] = ~df.maintenance_title.isnull()
df = df.fillna(value="")
@@ -506,7 +505,7 @@ class CCDataset(Dataset[CCCertificate, CCAuxiliaryDatasets], ComplexSerializable
"Products for Digital Signatures",
"Trusted Computing",
]
- cat_dict = {x: y for (x, y) in zip(cc_table_ids, cc_categories)}
+ cat_dict = dict(zip(cc_table_ids, cc_categories))
with file.open("r") as handle:
soup = BeautifulSoup(handle, "html5lib")
@@ -882,11 +881,9 @@ class CCDatasetMaintenanceUpdates(CCDataset, ComplexSerializableType):
raise NotImplementedError
@classmethod
- def from_json(cls, input_path: str | Path) -> CCDatasetMaintenanceUpdates:
- input_path = Path(input_path)
- with input_path.open("r") as handle:
- dset = json.load(handle, cls=CustomJSONDecoder)
- dset._root_dir = Path(input_path).parent
+ def from_json(cls, input_path: str | Path, is_compressed: bool = False) -> CCDatasetMaintenanceUpdates:
+ dset = cast(CCDatasetMaintenanceUpdates, ComplexSerializableType.from_json(input_path, is_compressed))
+ dset._root_dir = Path(input_path).parent.absolute()
return dset
def to_pandas(self) -> pd.DataFrame:
diff --git a/src/sec_certs/dataset/cpe.py b/src/sec_certs/dataset/cpe.py
index 1a20c71e..aadac766 100644
--- a/src/sec_certs/dataset/cpe.py
+++ b/src/sec_certs/dataset/cpe.py
@@ -1,23 +1,19 @@
from __future__ import annotations
-import copy
-import itertools
import logging
import tempfile
-import xml.etree.ElementTree as ET
-import zipfile
+from datetime import datetime
from pathlib import Path
-from typing import ClassVar, Iterator
+from typing import Any, Iterator
import pandas as pd
+import sec_certs.configuration as config_module
from sec_certs import constants
-from sec_certs.dataset.cve import CVEDataset
from sec_certs.dataset.json_path_dataset import JSONPathDataset
-from sec_certs.sample.cpe import CPE, cached_cpe
-from sec_certs.serialization.json import ComplexSerializableType, serialize
+from sec_certs.sample.cpe import CPE
+from sec_certs.serialization.json import ComplexSerializableType
from sec_certs.utils import helpers
-from sec_certs.utils.tqdm import tqdm
logger = logging.getLogger(__name__)
@@ -27,25 +23,15 @@ class CPEDataset(JSONPathDataset, ComplexSerializableType):
Dataset of CPE records. Includes look-up dictionaries for fast search.
"""
- CPE_XML_BASENAME: ClassVar[str] = "official-cpe-dictionary_v2.3.xml"
- CPE_URL: ClassVar[str] = "https://nvd.nist.gov/feeds/xml/cpe/dictionary/" + CPE_XML_BASENAME + ".zip"
-
def __init__(
self,
- was_enhanced_with_vuln_cpes: bool,
- cpes: dict[str, CPE],
+ cpes: dict[str, CPE] = {},
json_path: str | Path = constants.DUMMY_NONEXISTING_PATH,
+ last_update_timestamp: datetime = datetime.fromtimestamp(0),
):
- self.was_enhanced_with_vuln_cpes = was_enhanced_with_vuln_cpes
self.cpes = cpes
self.json_path = Path(json_path)
-
- self.vendor_to_versions: dict[str, set[str]] = {}
- self.vendor_version_to_cpe: dict[tuple[str, str], set[CPE]] = {}
- self.title_to_cpes: dict[str, set[CPE]] = {}
- self.vendors: set[str] = set()
-
- self.build_lookup_dicts()
+ self.last_update_timestamp = last_update_timestamp
def __iter__(self) -> Iterator[CPE]:
yield from self.cpes.values()
@@ -56,6 +42,9 @@ class CPEDataset(JSONPathDataset, ComplexSerializableType):
def __setitem__(self, key: str, value: CPE) -> None:
self.cpes.__setitem__(key.lower(), value)
+ def __delitem__(self, key: str) -> None:
+ del self.cpes[key]
+
def __len__(self) -> int:
return len(self.cpes)
@@ -69,29 +58,12 @@ class CPEDataset(JSONPathDataset, ComplexSerializableType):
@property
def serialized_attributes(self) -> list[str]:
- return ["was_enhanced_with_vuln_cpes", "cpes"]
-
- def build_lookup_dicts(self) -> None:
- """
- Will build look-up dictionaries that are used for fast matching.
- """
- logger.info("CPE dataset: building lookup dictionaries.")
- self.vendor_to_versions = {x.vendor: set() for x in self}
- self.vendor_version_to_cpe = {}
- self.title_to_cpes = {}
- self.vendors = set(self.vendor_to_versions.keys())
- for cpe in self:
- self.vendor_to_versions[cpe.vendor].add(cpe.version)
- if (cpe.vendor, cpe.version) not in self.vendor_version_to_cpe:
- self.vendor_version_to_cpe[(cpe.vendor, cpe.version)] = {cpe}
- else:
- self.vendor_version_to_cpe[(cpe.vendor, cpe.version)].add(cpe)
+ return ["last_update_timestamp", "was_enhanced_with_vuln_cpes", "cpes"]
- if cpe.title:
- if cpe.title not in self.title_to_cpes:
- self.title_to_cpes[cpe.title] = {cpe}
- else:
- self.title_to_cpes[cpe.title].add(cpe)
+ @classmethod
+ def from_dict(cls, dct: dict[str, Any]) -> CPEDataset:
+ dct["last_update_timestamp"] = datetime.fromisoformat(dct["last_update_timestamp"])
+ return cls(**dct)
@classmethod
def from_web(cls, json_path: str | Path = constants.DUMMY_NONEXISTING_PATH) -> CPEDataset:
@@ -102,38 +74,39 @@ class CPEDataset(JSONPathDataset, ComplexSerializableType):
:return CPEDataset: The resulting dataset
"""
with tempfile.TemporaryDirectory() as tmp_dir:
- xml_path = Path(tmp_dir) / cls.CPE_XML_BASENAME
- zip_path = Path(tmp_dir) / (cls.CPE_XML_BASENAME + ".zip")
- helpers.download_file(cls.CPE_URL, zip_path)
-
- with zipfile.ZipFile(zip_path, "r") as zip_ref:
- zip_ref.extractall(tmp_dir)
+ dset_path = Path(tmp_dir) / "cpe_dataset.json.gz"
+ if (
+ not helpers.download_file(
+ config_module.config.cpe_latest_snapshot,
+ dset_path,
+ progress_bar_desc="Downloading CPEDataset from web",
+ )
+ == constants.RESPONSE_OK
+ ):
+ raise RuntimeError(f"Could not download CPEDataset from {config_module.config.cpe_latest_snapshot}.")
+ dset = cls.from_json(dset_path, is_compressed=True)
- return cls._from_xml(xml_path, json_path)
+ dset.json_path = json_path
+ dset.to_json()
+ return dset
- @classmethod
- def _from_xml(cls, xml_path: str | Path, json_path: str | Path = constants.DUMMY_NONEXISTING_PATH) -> CPEDataset:
- logger.info("Loading CPE dataset from XML.")
- root = ET.parse(xml_path).getroot()
- dct = {}
- for cpe_item in root.findall("{http://cpe.mitre.org/dictionary/2.0}cpe-item"):
- found_title = cpe_item.find("{http://cpe.mitre.org/dictionary/2.0}title")
- if found_title is None:
- raise RuntimeError(
- "Title is not found during building CPE dataset from xml - this should not be happening"
- )
- title = found_title.text
+ def enhance_with_nvd_data(self, nvd_data: dict[Any, Any]) -> None:
+ self.last_update_timestamp = datetime.fromisoformat(nvd_data["timestamp"])
+ cpes_to_deprecate: set[str] = set()
- found_cpe_uri = cpe_item.find("{http://scap.nist.gov/schema/cpe-extension/2.3}cpe23-item")
- if found_cpe_uri is None:
- raise RuntimeError(
- "CPE uri is not found during building CPE dataset from xml - this should not be happening"
- )
- cpe_uri = found_cpe_uri.attrib["name"]
+ for cpe in nvd_data["products"]:
+ if cpe["cpe"]["deprecated"]:
+ cpes_to_deprecate.add(cpe["cpe"]["cpeNameId"])
+ else:
+ new_cpe = CPE.from_nvd_dict(cpe["cpe"])
+ self.cpes[new_cpe.uri] = new_cpe
- dct[cpe_uri] = cached_cpe(cpe_uri, title)
+ uris_to_delete = self._find_uris_for_ids(cpes_to_deprecate)
+ for uri in uris_to_delete:
+ del self[uri]
- return cls(False, dct, json_path)
+ def _find_uris_for_ids(self, ids: set[str]) -> set[str]:
+ return {x.uri for x in self if x.uri in ids}
def to_pandas(self) -> pd.DataFrame:
"""
@@ -143,53 +116,9 @@ class CPEDataset(JSONPathDataset, ComplexSerializableType):
"""
return pd.DataFrame([x.pandas_tuple for x in self], columns=CPE.pandas_columns).set_index("uri")
- @serialize
- def enhance_with_cpes_from_cve_dataset(self, cve_dset: CVEDataset | str | Path) -> None:
- """
- Some CPEs are present only in the CVEDataset and are missing from the CPE Dataset.
- This method goes through the provided CVEDataset and enriches self with CPEs from
- the CVEDataset.
-
- :param Union[CVEDataset, str, Path] cve_dset: CVEDataset of a path to it.
- """
-
- def _adding_condition(
- considered_cpe: CPE,
- vndr_item_lookup: set[tuple[str, str]],
- vndr_item_version_lookup: set[tuple[str, str, str]],
- ) -> bool:
- if (
- considered_cpe.version == constants.CPE_VERSION_NA
- and (considered_cpe.vendor, considered_cpe.item_name) not in vndr_item_lookup
- ):
- return True
- if (
- considered_cpe.version != constants.CPE_VERSION_NA
- and (considered_cpe.vendor, considered_cpe.item_name, considered_cpe.version)
- not in vndr_item_version_lookup
- ):
- return True
- return False
-
- if isinstance(cve_dset, (str, Path)):
- cve_dset = CVEDataset.from_json(cve_dset)
-
- if not isinstance(cve_dset, CVEDataset):
- raise RuntimeError("Conversion of CVE dataset did not work.")
- all_cpes_in_cve_dset = set(itertools.chain.from_iterable(cve.vulnerable_cpes for cve in cve_dset))
-
- old_len = len(self.cpes)
-
- # We only enrich if tuple (vendor, item_name) is not already in the dataset
- vendor_item_lookup = {(cpe.vendor, cpe.item_name) for cpe in self}
- vendor_item_version_lookup = {(cpe.vendor, cpe.item_name, cpe.version) for cpe in self}
- for cpe in tqdm(all_cpes_in_cve_dset, desc="Enriching CPE dataset with new CPEs"):
- if _adding_condition(cpe, vendor_item_lookup, vendor_item_version_lookup):
- new_cpe = copy.deepcopy(cpe)
- new_cpe.start_version = None
- new_cpe.end_version = None
- self[new_cpe.uri] = new_cpe
- self.build_lookup_dicts()
-
- logger.info(f"Enriched the CPE dataset with {len(self.cpes) - old_len} new CPE records.")
- self.was_enhanced_with_vuln_cpes = True
+ def get_title_to_cpes_dict(self) -> dict[str, set[CPE]]:
+ title_to_cpes_dict: dict[str, set[CPE]] = {}
+ for cpe in self:
+ if cpe.title:
+ title_to_cpes_dict.setdefault(cpe.title, set()).add(cpe)
+ return title_to_cpes_dict
diff --git a/src/sec_certs/dataset/cve.py b/src/sec_certs/dataset/cve.py
index 7217e1c9..fbf2883a 100644
--- a/src/sec_certs/dataset/cve.py
+++ b/src/sec_certs/dataset/cve.py
@@ -1,27 +1,22 @@
from __future__ import annotations
-import collections
-import datetime
-import glob
import itertools
-import json
import logging
-import shutil
import tempfile
-import zipfile
+from datetime import datetime
from pathlib import Path
-from typing import ClassVar
+from typing import Any, ClassVar
import numpy as np
import pandas as pd
+import sec_certs.configuration as config_module
from sec_certs import constants
from sec_certs.dataset.json_path_dataset import JSONPathDataset
-from sec_certs.sample.cpe import CPE, cached_cpe
+from sec_certs.sample.cpe import CPE
from sec_certs.sample.cve import CVE
from sec_certs.serialization.json import ComplexSerializableType
from sec_certs.utils import helpers
-from sec_certs.utils.parallel_processing import process_parallel
from sec_certs.utils.tqdm import tqdm
logger = logging.getLogger(__name__)
@@ -31,15 +26,21 @@ class CVEDataset(JSONPathDataset, ComplexSerializableType):
CVE_URL: ClassVar[str] = "https://nvd.nist.gov/feeds/json/cve/1.1/nvdcve-1.1-"
CPE_MATCH_FEED_URL: ClassVar[str] = "https://nvd.nist.gov/feeds/json/cpematch/1.0/nvdcpematch-1.0.json.zip"
- def __init__(self, cves: dict[str, CVE], json_path: str | Path = constants.DUMMY_NONEXISTING_PATH):
+ def __init__(
+ self,
+ cves: dict[str, CVE] = {},
+ json_path: str | Path = constants.DUMMY_NONEXISTING_PATH,
+ last_update_timestamp: datetime = datetime.fromtimestamp(0),
+ ):
self.cves = cves
self.json_path = Path(json_path)
- self.cpe_to_cve_ids_lookup: dict[str, set[str]] = {}
- self.cves_with_vulnerable_configurations: list[CVE] = []
+ self._cpe_uri_to_cve_ids_lookup: dict[str, set[str]] = {}
+ self._cves_with_vulnerable_configurations: list[CVE] = []
+ self.last_update_timestamp = last_update_timestamp
@property
def serialized_attributes(self) -> list[str]:
- return ["cves"]
+ return ["last_update_timestamp", "cves"]
def __iter__(self):
yield from self.cves.values()
@@ -48,7 +49,7 @@ class CVEDataset(JSONPathDataset, ComplexSerializableType):
return self.cves.__getitem__(item.upper())
def __setitem__(self, key: str, value: CVE):
- self.cves.__setitem__(key.lower(), value)
+ self.cves.__setitem__(key.upper(), value)
def __len__(self) -> int:
return len(self.cves)
@@ -56,210 +57,121 @@ class CVEDataset(JSONPathDataset, ComplexSerializableType):
def __eq__(self, other: object):
return isinstance(other, CVEDataset) and self.cves == other.cves
- def _filter_cves_with_cpe_configurations(self) -> None:
- """
- Method filters the subset of CVE dataset thah contain at least one CPE configuration in the CVE.
- """
- self.cves_with_vulnerable_configurations = [cve for cve in self if cve.vulnerable_cpe_configurations]
+ @property
+ def look_up_dicts_built(self) -> bool:
+ return bool(self._cpe_uri_to_cve_ids_lookup)
- def build_lookup_dict(
- self,
- use_nist_mapping: bool = True,
- nist_matching_filepath: Path | None = None,
- limit_to_cpes: set[CPE] | None = None,
- ):
- """
- Builds look-up dictionary CPE -> Set[CVE] and filter the CVEs which contain CPE configurations.
- Developer's note: There are 3 CPEs that are present in the cpe matching feed, but are badly processed by CVE
- feed, in which case they won't be found as a key in the dictionary. We intentionally ignore those. Feel free
- to add corner cases and manual fixes. According to our investigation, the suffereing CPEs are:
- - CPE(uri='cpe:2.3:a:arubanetworks:airwave:*:*:*:*:*:*:*:*', title=None, version='*', vendor='arubanetworks', item_name='airwave', start_version=None, end_version=('excluding', '8.2.0.0'))
- - CPE(uri='cpe:2.3:a:bayashi:dopvcomet\\*:0009:b:*:*:*:*:*:*', title=None, version='0009', vendor='bayashi', item_name='dopvcomet\\*', start_version=None, end_version=None)
- - CPE(uri='cpe:2.3:a:bayashi:dopvstar\\*:0091:*:*:*:*:*:*:*', title=None, version='0091', vendor='bayashi', item_name='dopvstar\\*', start_version=None, end_version=None)
+ @classmethod
+ def from_web(cls, json_path: str | Path = constants.DUMMY_NONEXISTING_PATH) -> CVEDataset:
"""
- self.cpe_to_cve_ids_lookup = {}
- self.cves = {x.cve_id.upper(): x for x in self}
+ Creates CVEDataset from NIST resources published on-line
- logger.info("Getting CPE matching dictionary from NIST.gov")
-
- if use_nist_mapping:
- matching_dict = self.get_nist_cpe_matching_dict(nist_matching_filepath)
-
- cve: CVE
- for cve in tqdm(self, desc="Building-up lookup dictionaries for fast CVE matching"):
- # Filter to CVEs that contain relevant CPEs
- if limit_to_cpes and not (
- set(cve.vulnerable_cpes).union(
- set(itertools.chain.from_iterable(x.get_all_cpes() for x in cve.vulnerable_cpe_configurations))
- )
- ).intersection(limit_to_cpes):
- continue
-
- # See note above, we use matching_dict.get(cpe, []) instead of matching_dict[cpe] as would be expected
- if use_nist_mapping:
- vulnerable_configurations = list(
- itertools.chain.from_iterable(matching_dict.get(cpe, []) for cpe in cve.vulnerable_cpes)
+ :param Union[str, Path] json_path: Path to store the dataset to
+ :return CVEDataset: The resulting dataset
+ """
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ dset_path = Path(tmp_dir) / "cve_dataset.json.gz"
+ if (
+ not helpers.download_file(
+ config_module.config.cve_latest_snapshot,
+ dset_path,
+ progress_bar_desc="Downloading CVEDataset from web",
)
- else:
- vulnerable_configurations = cve.vulnerable_cpes
- for cpe in vulnerable_configurations:
- if cpe.uri not in self.cpe_to_cve_ids_lookup:
- self.cpe_to_cve_ids_lookup[cpe.uri] = {cve.cve_id}
- else:
- self.cpe_to_cve_ids_lookup[cpe.uri].add(cve.cve_id)
+ == constants.RESPONSE_OK
+ ):
+ raise RuntimeError(f"Could not download CVEDataset from {config_module.config.cve_latest_snapshot}.")
+ dset = cls.from_json(dset_path, is_compressed=True)
- self._filter_cves_with_cpe_configurations()
+ dset.json_path = json_path
+ dset.to_json()
+ return dset
- @classmethod
- def download_cves(cls, output_path_str: str, start_year: int, end_year: int):
- output_path = Path(output_path_str)
- if not output_path.exists():
- output_path.mkdir()
-
- urls = [cls.CVE_URL + str(x) + ".json.zip" for x in range(start_year, end_year + 1)]
-
- logger.info(f"Identified {len(urls)} CVE files to fetch from nist.gov. Downloading them into {output_path}")
- with tempfile.TemporaryDirectory() as tmp_dir:
- outpaths = [Path(tmp_dir) / Path(x).name.rstrip(".zip") for x in urls]
- responses = helpers.download_parallel(urls, outpaths, "Downloading CVEs resources from NVD")
+ def _get_cves_with_criteria_configurations(self) -> None:
+ """
+ Method filters the subset of CVE dataset thah contain at least one CPE criteria configuration in the CVE.
+ """
+ self._cves_with_vulnerable_configurations = [cve for cve in self if cve.vulnerable_criteria_configurations]
- for o, r in zip(outpaths, responses):
- if r == constants.RESPONSE_OK:
- with zipfile.ZipFile(o, "r") as zip_handle:
- zip_handle.extractall(output_path)
+ def _expand_criteria_configurations(self, matching_dict: dict, relevant_cpe_uris: set[str] | None = None) -> None:
+ indices_to_delete = []
+ cve: CVE
+ for index, cve in enumerate(
+ tqdm(self._cves_with_vulnerable_configurations, desc="Expanding and filtering criteria configurations")
+ ):
+ can_be_matched = []
+ for configuration in cve.vulnerable_criteria_configurations:
+ configuration.expand_and_filter(matching_dict, relevant_cpe_uris)
+ can_be_matched.append(not any(len(component) == 0 for component in configuration._expanded_components))
+ if not any(can_be_matched):
+ indices_to_delete.append(index)
- @classmethod
- def from_nist_json(cls, input_path: str) -> CVEDataset:
- with Path(input_path).open("r") as handle:
- data = json.load(handle)
- cves = [CVE.from_nist_dict(x) for x in data["CVE_Items"]]
- return cls({x.cve_id: x for x in cves})
+ for index in sorted(indices_to_delete, reverse=True):
+ del self._cves_with_vulnerable_configurations[index]
- @classmethod
- def from_web(
- cls,
- start_year: int = 2002,
- end_year: int = datetime.datetime.now().year,
- json_path: str | Path = constants.DUMMY_NONEXISTING_PATH,
+ def build_lookup_dict(
+ self,
+ cpe_match_feed: dict,
+ limit_to_cpes: set[CPE] = set(),
):
- logger.info("Building CVE dataset from nist.gov website.")
- with tempfile.TemporaryDirectory() as tmp_dir:
- cls.download_cves(tmp_dir, start_year, end_year)
- json_files = glob.glob(tmp_dir + "/*.json")
+ self._cpe_uri_to_cve_ids_lookup = {}
+ cpe_uris_of_interest = {x.uri for x in limit_to_cpes} if limit_to_cpes else None
+ self._get_cves_with_criteria_configurations()
+ self._expand_criteria_configurations(cpe_match_feed, cpe_uris_of_interest)
+
+ logger.info("Building lookup dictionaries.")
+ cve: CVE
+ for cve in tqdm(self, desc="Building-up lookup dictionaries for fast CVE matching"):
+ vulnerable_cpe_uris: set[str] = set()
+ for x in cve.vulnerable_criteria:
+ if x.criteria_id not in cpe_match_feed["match_strings"]:
+ # This happens when there's no `matches` key in the original dict. In such case, the whole key got
+ # discarded. Statistically, approx. 13% of criteria match to no CPEs and are used solely as criteria.
+ continue
+ matches = cpe_match_feed["match_strings"][x.criteria_id]["matches"]
+ vulnerable_cpe_uris = vulnerable_cpe_uris.union(x["cpeName"] for x in matches)
- logger.info("Downloaded required resources. Building CVEDataset from jsons.")
- results = process_parallel(
- cls.from_nist_json,
- json_files,
- use_threading=False,
- progress_bar_desc="Building CVEDataset from jsons",
- )
- return cls(dict(collections.ChainMap(*(x.cves for x in results))), json_path)
+ if (
+ cpe_uris_of_interest
+ and not cve.vulnerable_criteria_configurations
+ and not any(x in cpe_uris_of_interest for x in vulnerable_cpe_uris)
+ ):
+ continue
- def _get_cve_ids_for_cpe_uri(self, cpe_uri: str) -> set[str]:
- return self.cpe_to_cve_ids_lookup.get(cpe_uri, set())
+ for cpe_uri in vulnerable_cpe_uris:
+ if not cpe_uris_of_interest or cpe_uri in cpe_uris_of_interest:
+ self._cpe_uri_to_cve_ids_lookup.setdefault(cpe_uri, set()).add(cve.cve_id)
def _get_cves_from_exactly_matched_cpes(self, cpe_uris: set[str]) -> set[str]:
- return set(itertools.chain.from_iterable([self._get_cve_ids_for_cpe_uri(cpe_uri) for cpe_uri in cpe_uris]))
+ return set(
+ itertools.chain.from_iterable([self._cpe_uri_to_cve_ids_lookup.get(cpe_uri, set()) for cpe_uri in cpe_uris])
+ )
- def _get_cves_from_cpe_configurations(self, cpe_uris: set[str]) -> set[str]:
+ def _get_cves_from_criteria_configurations(self, cpe_uris: set[str]) -> set[str]:
return {
cve.cve_id
- for cve in self.cves_with_vulnerable_configurations
- if any(configuration.matches(cpe_uris) for configuration in cve.vulnerable_cpe_configurations)
+ for cve in self._cves_with_vulnerable_configurations
+ if any(configuration.matches(cpe_uris) for configuration in cve.vulnerable_criteria_configurations)
}
- def get_cves_from_matched_cpes(self, cpe_uris: set[str]) -> set[str]:
+ def get_cves_from_matched_cpe_uris(self, cpe_uris: set[str]) -> set[str]:
"""
- Method returns the set of CVEs which are matched to the set of CPEs.
- First are matched the classic CPEs to CVEs with lookup dict and then are matched the
- 'AND' type CPEs containing platform.
+ Method returns the set of CVEs which are matched to the set of CPE uris.
"""
return {
*self._get_cves_from_exactly_matched_cpes(cpe_uris),
- *self._get_cves_from_cpe_configurations(cpe_uris),
+ *self._get_cves_from_criteria_configurations(cpe_uris),
}
- def filter_related_cpes(self, relevant_cpes: set[CPE]):
- """
- Since each of the CVEs is related to many CPEs, the dataset size explodes (serialized). For certificates,
- only CPEs within sample dataset are relevant. This function modifies all CVE elements. Specifically, it
- deletes all CPE records unless they are part of relevant_cpe_uris.
- :param relevant_cpes: List of relevant CPEs to keep in CVE dataset.
- """
- total_deleted_cpes = 0
- cve_ids_to_delete = []
- for cve in self:
- n_cpes_orig = len(cve.vulnerable_cpes)
- cve.vulnerable_cpes = [x for x in cve.vulnerable_cpes if x in relevant_cpes]
- cve.vulnerable_cpe_configurations = [
- x
- for x in cve.vulnerable_cpe_configurations
- if x.platform.uri in relevant_cpes and any(y.uri in relevant_cpes for y in x.cpes)
- ]
-
- total_deleted_cpes += n_cpes_orig - len(cve.vulnerable_cpes)
- if not cve.vulnerable_cpes:
- cve_ids_to_delete.append(cve.cve_id)
-
- for cve_id in cve_ids_to_delete:
- del self.cves[cve_id]
- logger.info(
- f"Totally deleted {total_deleted_cpes} irrelevant CPEs and {len(cve_ids_to_delete)} CVEs from CVEDataset."
- )
-
def to_pandas(self) -> pd.DataFrame:
df = pd.DataFrame([x.pandas_tuple for x in self], columns=CVE.pandas_columns)
df.cwe_ids = df.cwe_ids.map(lambda x: x if x else np.nan)
return df.set_index("cve_id")
- def get_nist_cpe_matching_dict(self, input_filepath: Path | None) -> dict[CPE, list[CPE]]:
- """
- Computes dictionary that maps complex CPEs to list of simple CPEs.
- """
-
- def parse_key_cpe(field: dict) -> CPE:
- start_version = None
- if "versionStartIncluding" in field:
- start_version = ("including", field["versionStartIncluding"])
- elif "versionStartExcluding" in field:
- start_version = ("excluding", field["versionStartExcluding"])
-
- end_version = None
- if "versionEndIncluding" in field:
- end_version = ("including", field["versionEndIncluding"])
- elif "versionEndExcluding" in field:
- end_version = ("excluding", field["versionEndExcluding"])
-
- return cached_cpe(field["cpe23Uri"], start_version=start_version, end_version=end_version)
-
- def parse_values_cpe(field: dict) -> list[CPE]:
- return [cached_cpe(x["cpe23Uri"]) for x in field["cpe_name"]]
-
- logger.debug("Attempting to get NIST mapping file.")
- if not input_filepath or not input_filepath.is_file():
- logger.debug("NIST mapping file not available, going to download.")
- with tempfile.TemporaryDirectory() as tmp_dir:
- filename = Path(self.CPE_MATCH_FEED_URL).name
- download_path = Path(tmp_dir) / filename
- unzipped_path = Path(tmp_dir) / filename.rstrip(".zip")
- helpers.download_file(self.CPE_MATCH_FEED_URL, download_path)
-
- with zipfile.ZipFile(download_path, "r") as zip_handle:
- zip_handle.extractall(tmp_dir)
- with unzipped_path.open("r") as handle:
- match_data = json.load(handle)
- if input_filepath:
- logger.debug(f"Copying attained NIST mapping file to {input_filepath}")
- shutil.move(str(unzipped_path), str(input_filepath))
- else:
- with input_filepath.open("r") as handle:
- match_data = json.load(handle)
-
- mapping_dict = {}
- for match in tqdm(match_data["matches"], desc="parsing cpe matching (by NIST) dictionary"):
- key = parse_key_cpe(match)
- value = parse_values_cpe(match)
- mapping_dict[key] = value if value else [key]
-
- return mapping_dict
+ def enhance_with_nvd_data(self, data: dict[str, Any]) -> CVEDataset:
+ self.last_update_timestamp = datetime.fromisoformat(data["timestamp"])
+ for vuln in data["vulnerabilities"]:
+ # https://nvd.nist.gov/vuln/vulnerability-status#divNvdStatus
+ if vuln["cve"]["vulnStatus"] in {"Analyzed", "Modified"}:
+ cve = CVE.from_nist_dict(vuln["cve"])
+ self[cve.cve_id] = cve
+ return self
diff --git a/src/sec_certs/dataset/dataset.py b/src/sec_certs/dataset/dataset.py
index d388db2e..b544294e 100644
--- a/src/sec_certs/dataset/dataset.py
+++ b/src/sec_certs/dataset/dataset.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+import gzip
import itertools
import json
import logging
@@ -23,6 +24,7 @@ from sec_certs.sample.certificate import Certificate
from sec_certs.sample.cpe import CPE
from sec_certs.serialization.json import ComplexSerializableType, get_class_fullname, serialize
from sec_certs.utils import helpers
+from sec_certs.utils.nvd_dataset_builder import CpeMatchNvdDatasetBuilder, CpeNvdDatasetBuilder, CveNvdDatasetBuilder
from sec_certs.utils.tqdm import tqdm
logger = logging.getLogger(__name__)
@@ -123,6 +125,10 @@ class Dataset(Generic[CertSubType, AuxiliaryDatasetsSubType], ComplexSerializabl
return self.auxiliary_datasets_dir / "cpe_dataset.json"
@property
+ def cpe_match_json_path(self) -> Path:
+ return self.auxiliary_datasets_dir / "cpe_match_feed.json"
+
+ @property
def cve_dataset_path(self) -> Path:
return self.auxiliary_datasets_dir / "cve_dataset.json"
@@ -195,8 +201,8 @@ class Dataset(Generic[CertSubType, AuxiliaryDatasetsSubType], ComplexSerializabl
return dset
@classmethod
- def from_json(cls: type[DatasetSubType], input_path: str | Path) -> DatasetSubType:
- dset = cast("DatasetSubType", ComplexSerializableType.from_json(input_path))
+ def from_json(cls: type[DatasetSubType], input_path: str | Path, is_compressed: bool = False) -> DatasetSubType:
+ dset = cast("DatasetSubType", ComplexSerializableType.from_json(input_path, is_compressed))
dset._root_dir = Path(input_path).parent.absolute()
dset._set_local_paths()
return dset
@@ -253,9 +259,11 @@ class Dataset(Generic[CertSubType, AuxiliaryDatasetsSubType], ComplexSerializabl
logger.info("Processing auxiliary datasets.")
self.auxiliary_datasets_dir.mkdir(parents=True, exist_ok=True)
self.auxiliary_datasets.cpe_dset = self._prepare_cpe_dataset(download_fresh)
- self.auxiliary_datasets.cve_dset = self._prepare_cve_dataset(
- download_fresh_cves=download_fresh, build_lookup_dict=False
- )
+ self.auxiliary_datasets.cve_dset = self._prepare_cve_dataset(download_fresh)
+
+ if download_fresh or not self.cpe_match_json_path.exists():
+ self._prepare_cpe_match_dict(download_fresh=download_fresh)
+
self.state.auxiliary_datasets_processed = True
@serialize
@@ -329,23 +337,7 @@ class Dataset(Generic[CertSubType, AuxiliaryDatasetsSubType], ComplexSerializabl
def _compute_heuristics(self) -> None:
logger.info("Computing various heuristics from the certificates.")
-
- if not self.auxiliary_datasets.cpe_dset:
- self.auxiliary_datasets.cpe_dset = self._prepare_cpe_dataset()
-
self.compute_cpe_heuristics()
-
- cpe_rich = [
- set(map(self.auxiliary_datasets.cpe_dset.cpes.get, x.heuristics.cpe_matches))
- for x in self
- if x.heuristics.cpe_matches is not None
- ]
- all_cpes = set(itertools.chain.from_iterable(cpe_rich))
-
- if not self.auxiliary_datasets.cve_dset:
- self.auxiliary_datasets.cve_dset = self._prepare_cve_dataset(build_lookup_dict=False)
- self.auxiliary_datasets.cve_dset.build_lookup_dict(use_nist_mapping=True, limit_to_cpes=all_cpes) # type: ignore
-
self.compute_related_cves()
self._compute_references()
self._compute_transitive_vulnerabilities()
@@ -358,44 +350,98 @@ class Dataset(Generic[CertSubType, AuxiliaryDatasetsSubType], ComplexSerializabl
def _compute_transitive_vulnerabilities(self) -> None:
raise NotImplementedError("Not meant to be implemented by the base class.")
- def _prepare_cpe_dataset(self, download_fresh_cpes: bool = False) -> CPEDataset:
- logger.info("Preparing CPE dataset.")
+ def _prepare_cpe_dataset(self, download_fresh: bool = False) -> CPEDataset:
if not self.auxiliary_datasets_dir.exists():
self.auxiliary_datasets_dir.mkdir(parents=True)
- if not self.cpe_dataset_path.exists() or download_fresh_cpes is True:
- cpe_dataset = CPEDataset.from_web(self.cpe_dataset_path)
- cpe_dataset.to_json()
+ if self.cpe_dataset_path.exists():
+ logger.info("Preparing CPEDataset from json.")
+ cpe_dataset = CPEDataset.from_json(self.cpe_dataset_path)
else:
- cpe_dataset = CPEDataset.from_json(str(self.cpe_dataset_path))
+ cpe_dataset = CPEDataset(json_path=self.cpe_dataset_path)
+ download_fresh = True
+
+ if download_fresh:
+ if config.preferred_source_nvd_datasets == "api":
+ logger.info("Fetching new CPE records from NVD API.")
+ with CpeNvdDatasetBuilder(api_key=config.nvd_api_key) as builder:
+ cpe_dataset = builder.build_dataset(cpe_dataset)
+ else:
+ logger.info("Preparing CPEDataset from seccerts.org.")
+ cpe_dataset = CPEDataset.from_web(self.cpe_dataset_path)
+ cpe_dataset.to_json()
return cpe_dataset
- def _prepare_cve_dataset(
- self, download_fresh_cves: bool = False, use_nist_cpe_matching_dict: bool = True, build_lookup_dict: bool = True
- ) -> CVEDataset:
- logger.info("Preparing CVE dataset.")
+ def _prepare_cve_dataset(self, download_fresh: bool = False) -> CVEDataset:
if not self.auxiliary_datasets_dir.exists():
+ logger.info("Loading CVEDataset from json.")
self.auxiliary_datasets_dir.mkdir(parents=True)
- if not self.cve_dataset_path.exists() or download_fresh_cves is True:
- cve_dataset = CVEDataset.from_web(json_path=self.cve_dataset_path)
- cve_dataset.to_json()
- else:
+ if self.cve_dataset_path.exists():
+ logger.info("Preparing CVEDataset from json.")
cve_dataset = CVEDataset.from_json(self.cve_dataset_path)
+ else:
+ cve_dataset = CVEDataset(json_path=self.cve_dataset_path)
+ download_fresh = True
+
+ if download_fresh:
+ if config.preferred_source_nvd_datasets == "api":
+ logger.info("Fetching new CVE records from NVD API.")
+ with CveNvdDatasetBuilder(api_key=config.nvd_api_key) as builder:
+ cve_dataset = builder.build_dataset(cve_dataset)
+ else:
+ logger.info("Preparing CVEDataset from seccerts.org")
+ cve_dataset = CVEDataset.from_web(self.cve_dataset_path)
+ cve_dataset.to_json()
- if build_lookup_dict:
- cve_dataset.build_lookup_dict(use_nist_cpe_matching_dict, self.nist_cve_cpe_matching_dset_path)
return cve_dataset
+ def _prepare_cpe_match_dict(self, download_fresh: bool = False) -> dict:
+ if self.cpe_match_json_path.exists():
+ logger.info("Preparing CPE Match feed from json.")
+ with self.cpe_match_json_path.open("r") as handle:
+ cpe_match_dict = json.load(handle)
+ else:
+ cpe_match_dict = CpeMatchNvdDatasetBuilder._init_new_dataset()
+ download_fresh = True
+
+ if download_fresh:
+ if config.preferred_source_nvd_datasets == "api":
+ logger.info("Fetchnig CPE Match feed from NVD APi.")
+ with CpeMatchNvdDatasetBuilder(api_key=config.nvd_api_key) as builder:
+ cpe_match_dict = builder.build_dataset(cpe_match_dict)
+ else:
+ logger.info("Preparing CPE Match feed from seccerts.org.")
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ dset_path = Path(tmp_dir) / "cpe_match_feed.json.gz"
+ if (
+ not helpers.download_file(
+ config.cpe_match_latest_snapshot,
+ dset_path,
+ progress_bar_desc="Downloading CPE Match feed from web",
+ )
+ == constants.RESPONSE_OK
+ ):
+ raise RuntimeError(
+ f"Could not download CPE Match feed from {config.cpe_match_latest_snapshot}."
+ )
+ with gzip.open(str(dset_path)) as handle:
+ json_str = handle.read().decode("utf-8")
+ cpe_match_dict = json.loads(json_str)
+ with self.cpe_match_json_path.open("w") as handle:
+ json.dump(cpe_match_dict, handle, indent=4)
+
+ return cpe_match_dict
+
@serialize
- def compute_cpe_heuristics(self, download_fresh_cpes: bool = False) -> CPEClassifier:
+ def compute_cpe_heuristics(self) -> CPEClassifier:
"""
Computes matching CPEs for the certificates.
"""
WINDOWS_WEAK_CPES: set[CPE] = {
- CPE("cpe:2.3:o:microsoft:windows:-:*:*:*:*:*:x64:*", "Microsoft Windows on X64", None, None),
- CPE("cpe:2.3:o:microsoft:windows:-:*:*:*:*:*:x86:*", "Microsoft Windows on X86", None, None),
+ CPE("", "cpe:2.3:o:microsoft:windows:-:*:*:*:*:*:x64:*", "Microsoft Windows on X64"),
+ CPE("", "cpe:2.3:o:microsoft:windows:-:*:*:*:*:*:x86:*", "Microsoft Windows on X86"),
}
def filter_condition(cpe: CPE) -> bool:
@@ -422,15 +468,8 @@ class Dataset(Generic[CertSubType, AuxiliaryDatasetsSubType], ComplexSerializabl
return True
logger.info("Computing heuristics: Finding CPE matches for certificates")
- if not self.auxiliary_datasets.cpe_dset or download_fresh_cpes:
- self.auxiliary_datasets.cpe_dset = self._prepare_cpe_dataset(download_fresh_cpes)
-
- # Temporarily disabled, see: https://github.com/crocs-muni/sec-certs/issues/173
- # if not cpe_dset.was_enhanced_with_vuln_cpes:
- # self.auxiliary_datasets.cve_dset = self._prepare_cve_dataset(download_fresh_cves=False)
- # self.auxiliary_datasets.cpe_dset.enhance_with_cpes_from_cve_dataset(cve_dset) # this also calls build_lookup_dicts() on cpe_dset
- # else:
- # self.auxiliary_datasets.cpe_dset.build_lookup_dicts()
+ if not self.auxiliary_datasets.cpe_dset:
+ self.auxiliary_datasets.cpe_dset = self._prepare_cpe_dataset()
clf = CPEClassifier(config.cpe_matching_threshold, config.cpe_n_max_matches)
clf.fit([x for x in self.auxiliary_datasets.cpe_dset if filter_condition(x)])
@@ -447,11 +486,12 @@ class Dataset(Generic[CertSubType, AuxiliaryDatasetsSubType], ComplexSerializabl
return clf
+ @serialize
def to_label_studio_json(self, output_path: str | Path) -> None:
cpe_dset = self._prepare_cpe_dataset()
lst = []
- for cert in [x for x in cast(Iterator[Certificate], self) if x.heuristics.cpe_matches]:
+ for cert in [x for x in self if x.heuristics.cpe_matches]:
dct = {"text": cert.label_studio_title}
candidates = [cpe_dset[x].title for x in cert.heuristics.cpe_matches]
candidates += ["No good match"] * (config.cpe_n_max_matches - len(candidates))
@@ -468,6 +508,7 @@ class Dataset(Generic[CertSubType, AuxiliaryDatasetsSubType], ComplexSerializabl
data = json.load(handle)
cpe_dset = self._prepare_cpe_dataset()
+ title_to_cpes_dict = cpe_dset.get_title_to_cpes_dict()
labeled_cert_digests: set[str] = set()
logger.info("Translating label studio matches into their CPE representations and assigning to certificates.")
@@ -486,10 +527,10 @@ class Dataset(Generic[CertSubType, AuxiliaryDatasetsSubType], ComplexSerializabl
cpes: set[CPE] = set()
for x in predicted_annotations:
- if x not in cpe_dset.title_to_cpes:
+ if x not in title_to_cpes_dict:
logger.error(f"{x} not in dataset")
else:
- to_update = cpe_dset.title_to_cpes[x]
+ to_update = title_to_cpes_dict[x]
if to_update and not cpes:
cpes = to_update
elif to_update and cpes:
@@ -521,22 +562,37 @@ class Dataset(Generic[CertSubType, AuxiliaryDatasetsSubType], ComplexSerializabl
set(cert.heuristics.verified_cpe_matches)
)
+ def _get_all_cpes_in_dataset(self) -> set[CPE]:
+ if not self.auxiliary_datasets.cpe_dset:
+ raise ValueError(
+ "Cannot retrieve all cpes in dataset when cpe_dset is not set. You can prepare it with obj._prepare_cpe_dataset()"
+ )
+
+ cpe_matches = [
+ [self.auxiliary_datasets.cpe_dset.cpes[y] for y in x.heuristics.cpe_matches]
+ for x in self
+ if x.heuristics.cpe_matches
+ ]
+ return set(itertools.chain.from_iterable(cpe_matches))
+
@serialize
- def compute_related_cves(
- self,
- download_fresh_cves: bool = False,
- use_nist_cpe_matching_dict: bool = True,
- ) -> None:
+ def compute_related_cves(self) -> None:
"""
Computes CVEs for the certificates, given their CPE matches.
"""
- logger.info("Retrieving related CVEs to verified CPE matches")
- if download_fresh_cves or not self.auxiliary_datasets.cve_dset:
- self.auxiliary_datasets.cve_dset = self._prepare_cve_dataset(
- download_fresh_cves, use_nist_cpe_matching_dict
- )
-
logger.info("Computing heuristics: CVEs in certificates.")
+
+ if not self.auxiliary_datasets.cpe_dset:
+ self.auxiliary_datasets.cpe_dset = self._prepare_cpe_dataset()
+
+ if not self.auxiliary_datasets.cve_dset:
+ self.auxiliary_datasets.cve_dset = self._prepare_cve_dataset()
+
+ if not self.auxiliary_datasets.cve_dset.look_up_dicts_built:
+ cpe_match_dict = self._prepare_cpe_match_dict()
+ all_cpes = self._get_all_cpes_in_dataset()
+ self.auxiliary_datasets.cve_dset.build_lookup_dict(cpe_match_dict, all_cpes)
+
self.enrich_automated_cpes_with_manual_labels()
cpe_rich_certs = [x for x in cast(Iterator[Certificate], self) if x.heuristics.cpe_matches]
@@ -546,13 +602,9 @@ class Dataset(Generic[CertSubType, AuxiliaryDatasetsSubType], ComplexSerializabl
)
return
- # The following lines don't bring any speed-up. They may potentially save memory if rest of CVEs is cleaned explicitly
- # relevant_cpes = set(itertools.chain.from_iterable(x.heuristics.cpe_matches for x in cpe_rich_certs))
- # self.auxiliary_datasets.cve_dset.filter_related_cpes(relevant_cpes)
-
cert: Certificate
for cert in tqdm(cpe_rich_certs, desc="Computing related CVES"):
- related_cves = self.auxiliary_datasets.cve_dset.get_cves_from_matched_cpes(cert.heuristics.cpe_matches)
+ related_cves = self.auxiliary_datasets.cve_dset.get_cves_from_matched_cpe_uris(cert.heuristics.cpe_matches)
cert.heuristics.related_cves = related_cves if related_cves else None
n_vulnerable = len([x for x in cpe_rich_certs if x.heuristics.related_cves])
diff --git a/src/sec_certs/dataset/json_path_dataset.py b/src/sec_certs/dataset/json_path_dataset.py
index 6eb4d968..bfd71ddd 100644
--- a/src/sec_certs/dataset/json_path_dataset.py
+++ b/src/sec_certs/dataset/json_path_dataset.py
@@ -1,12 +1,11 @@
from __future__ import annotations
-import json
import logging
import shutil
from abc import ABC
from pathlib import Path
-from sec_certs.serialization.json import ComplexSerializableType, CustomJSONDecoder, get_class_fullname
+from sec_certs.serialization.json import ComplexSerializableType, get_class_fullname
logger = logging.getLogger(__name__)
@@ -39,8 +38,7 @@ class JSONPathDataset(ComplexSerializableType, ABC):
self.to_json()
@classmethod
- def from_json(cls, input_path: str | Path):
- with Path(input_path).open("r") as handle:
- dset = json.load(handle, cls=CustomJSONDecoder)
+ def from_json(cls, input_path: str | Path, is_compressed: bool = False):
+ dset = super().from_json(input_path, is_compressed)
dset.json_path = Path(input_path)
return dset
diff --git a/src/sec_certs/model/cpe_matching.py b/src/sec_certs/model/cpe_matching.py
index b274e3ed..c331a3b9 100644
--- a/src/sec_certs/model/cpe_matching.py
+++ b/src/sec_certs/model/cpe_matching.py
@@ -75,10 +75,7 @@ class CPEClassifier:
for cpe in tqdm(sufficiently_long_cpes, desc="Fitting the CPE classifier"):
self.vendor_to_versions_[cpe.vendor].add(cpe.version)
- if (cpe.vendor, cpe.version) not in self.vendor_version_to_cpe_:
- self.vendor_version_to_cpe_[(cpe.vendor, cpe.version)] = {cpe}
- else:
- self.vendor_version_to_cpe_[(cpe.vendor, cpe.version)].add(cpe)
+ self.vendor_version_to_cpe_.setdefault((cpe.vendor, cpe.version), set()).add(cpe)
def predict(self, X: list[tuple[str, str, str]]) -> list[set[str] | None]:
"""
diff --git a/src/sec_certs/sample/__init__.py b/src/sec_certs/sample/__init__.py
index 595315ae..ef2340a4 100644
--- a/src/sec_certs/sample/__init__.py
+++ b/src/sec_certs/sample/__init__.py
@@ -5,7 +5,7 @@ like CPE, CVE, etc. The objects mostly hold data and allow for serialization, bu
from sec_certs.sample.cc import CCCertificate
from sec_certs.sample.cc_certificate_id import CertificateId
from sec_certs.sample.cc_maintenance_update import CCMaintenanceUpdate
-from sec_certs.sample.cpe import CPE, CPEConfiguration, cached_cpe
+from sec_certs.sample.cpe import CPE
from sec_certs.sample.cve import CVE
from sec_certs.sample.fips import FIPSCertificate
from sec_certs.sample.fips_algorithm import FIPSAlgorithm
@@ -20,7 +20,6 @@ __all__ = [
"CCCertificate",
"CPE",
"CPEConfiguration",
- "cached_cpe",
"CVE",
"FIPSCertificate",
"FIPSAlgorithm",
diff --git a/src/sec_certs/sample/cpe.py b/src/sec_certs/sample/cpe.py
index d56535c2..5e9e8b65 100644
--- a/src/sec_certs/sample/cpe.py
+++ b/src/sec_certs/sample/cpe.py
@@ -1,7 +1,6 @@
from __future__ import annotations
-from dataclasses import dataclass
-from functools import lru_cache
+from dataclasses import dataclass, field
from typing import Any, ClassVar
from sec_certs import constants
@@ -11,45 +10,101 @@ from sec_certs.utils import helpers
@dataclass
-class CPEConfiguration(ComplexSerializableType):
- __slots__ = ["platform", "cpes"]
+class CPEMatchCriteria(ComplexSerializableType):
+ vulnerable: bool
+ criteria: str
+ criteria_id: str
+ version_start: tuple[str, str] | None
+ version_end: tuple[str, str] | None
- platform: CPE
- cpes: list[CPE]
+ __slots__ = ["vulnerable", "criteria", "criteria_id", "version_start", "version_end"]
+ # We cannot use frozen=True. It does not work with __slots__ prior to Python 3.10 dataclasses
+ # Hence we manually provide __hash__ and __eq__ despite not guaranteeing immutability
def __hash__(self) -> int:
- return hash(self.platform) + sum([hash(cpe) for cpe in self.cpes])
+ return hash(self.criteria_id)
+
+ def __eq__(self, other: object) -> bool:
+ return isinstance(other, CPEMatchCriteria) and self.criteria_id == other.criteria_id
+
+ def __lt__(self, other: CPEMatchCriteria) -> bool:
+ return self.criteria_id < other.criteria_id
+
+ @classmethod
+ def from_nist_dict(cls, dct: dict[str, Any]) -> CPEMatchCriteria:
+ if dct.get("versionStartIncluding", None):
+ version_start = ("including", dct["versionStartIncluding"])
+ elif dct.get("versionStartExcluding"):
+ version_start = ("excluding", dct["versionStartExcluding"])
+ else:
+ version_start = None
+
+ if dct.get("versionEndIncluding", None):
+ version_end = ("including", dct["versionEndIncluding"])
+ elif dct.get("versionEndExcluding", None):
+ version_end = ("excluding", dct["versionEndExcluding"])
+ else:
+ version_end = None
- def __lt__(self, other: CPEConfiguration) -> bool:
- return self.platform < other.platform
+ return cls(dct["vulnerable"], dct["criteria"], dct["matchCriteriaId"], version_start, version_end)
- def __eq__(self, other: Any) -> bool:
- return (
- isinstance(other, self.__class__) and self.platform == other.platform and set(self.cpes) == set(other.cpes)
- )
- def matches(self, other_cpe_uris: set[str]) -> bool:
+@dataclass
+class CPEMatchCriteriaConfiguration(ComplexSerializableType):
+ """
+ This class represents a list of lists of `CPEMatchCriteria` objects, where there's an OR relation between the
+ elements of the inner list and AND relation between the elements of the outer list.
+ Our experiments confirm that there are only 3 distinct CVEs in the database that allow AND configuration between
+ the elements. Simplyfing to ORs enables much more simple implementation.
+ """
+
+ components: list[list[CPEMatchCriteria]]
+ _expanded_components: list[list[str]] = field(default_factory=list)
+
+ def matches(self, cpe_uris: set[str]) -> bool:
"""
- For a given set of CPEs method returns boolean if the CPE configuration is
- matched or not.
+ Returns if given set of cpe_ids matches this configuration.
"""
- return self.platform.uri in other_cpe_uris and any(x.uri in other_cpe_uris for x in self.cpes)
+ if not self._expanded_components:
+ raise ValueError(
+ "Cannot match to CPEMatchConfiguration when attribute _expanded_components was not filled-in. That attribute is prepared by `CVEDataset.build_lookup_dict()`."
+ )
+ return all(any(x in component for x in cpe_uris) for component in self._expanded_components)
+
+ @property
+ def serialized_attributes(self) -> list[str]:
+ return ["components"]
- def get_all_cpes(self) -> set[CPE]:
- return {self.platform}.union(self.cpes)
+ def expand_and_filter(self, match_dict: dict, relevant_cpe_uris: set[str] | None):
+ """
+ Expands the components to actual CPE records that are held in `_expanded_components` attribute.
+ Additionally, this filters the elements of the expanded components only to `relevant_cpe_uris`, which speeds-up
+ the computation.
+ """
+ self._expanded_components = []
+ for component in self.components:
+ expanded_component: list[str] = []
+ for criteria in component:
+ if criteria.criteria_id not in match_dict["match_strings"]:
+ continue
+ expanded_component.extend(
+ x["cpeName"] for x in match_dict["match_strings"][criteria.criteria_id]["matches"]
+ )
+ if relevant_cpe_uris:
+ expanded_component = [x for x in expanded_component if x in relevant_cpe_uris]
+ self._expanded_components.append(expanded_component)
@dataclass
class CPE(PandasSerializableType, ComplexSerializableType):
+ cpe_id: str
uri: str
version: str
vendor: str
item_name: str
title: str | None
- start_version: tuple[str, str] | None
- end_version: tuple[str, str] | None
- __slots__ = ["uri", "version", "vendor", "item_name", "title", "start_version", "end_version"]
+ __slots__ = ["cpe_id", "uri", "version", "vendor", "item_name", "title"]
pandas_columns: ClassVar[list[str]] = [
"uri",
@@ -61,12 +116,12 @@ class CPE(PandasSerializableType, ComplexSerializableType):
def __init__(
self,
+ cpe_id: str,
uri: str,
title: str | None = None,
- start_version: tuple[str, str] | None = None,
- end_version: tuple[str, str] | None = None,
):
super().__init__()
+ self.cpe_id = cpe_id
self.uri = uri
splitted = helpers.split_unescape(self.uri, ":")
@@ -74,8 +129,14 @@ class CPE(PandasSerializableType, ComplexSerializableType):
self.item_name = " ".join(splitted[4].split("_"))
self.version = self.normalize_version(" ".join(splitted[5].split("_")))
self.title = title
- self.start_version = start_version
- self.end_version = end_version
+
+ # We cannot use frozen=True. It does not work with __slots__ prior to Python 3.10 dataclasses
+ # Hence we manually provide __hash__ and __eq__ despite not guaranteeing immutability
+ def __hash__(self) -> int:
+ return hash(self.uri)
+
+ def __eq__(self, other: object) -> bool:
+ return isinstance(other, self.__class__) and self.uri == other.uri
def __lt__(self, other: CPE) -> bool:
return self.uri < other.uri
@@ -90,16 +151,13 @@ class CPE(PandasSerializableType, ComplexSerializableType):
return version
@classmethod
- def from_dict(cls, dct: dict[str, Any]) -> CPE:
- if isinstance(dct["start_version"], list):
- dct["start_version"] = tuple(dct["start_version"])
- if isinstance(dct["end_version"], list):
- dct["end_version"] = tuple(dct["end_version"])
- return super().from_dict(dct)
+ def from_nvd_dict(cls, dct: dict[str, Any]) -> CPE:
+ title = [x for x in dct["titles"] if x["lang"] == "en"][0]["title"]
+ return cls(dct["cpeNameId"], dct["cpeName"], title)
@property
def serialized_attributes(self) -> list[str]:
- return ["uri", "title", "start_version", "end_version"]
+ return ["cpe_id", "uri", "title"]
@property
def update(self) -> str:
@@ -116,16 +174,3 @@ class CPE(PandasSerializableType, ComplexSerializableType):
@property
def pandas_tuple(self) -> tuple:
return self.uri, self.vendor, self.item_name, self.version, self.title
-
- # We cannot use frozen=True. It does not work with __slots__ prior to Python 3.10 dataclasses
- # Hence we manually provide __hash__ and __eq__ despite not guaranteeing immutability
- def __hash__(self) -> int:
- return hash((self.uri, self.start_version, self.end_version))
-
- def __eq__(self, other: object) -> bool:
- return isinstance(other, self.__class__) and self.uri == other.uri
-
-
-@lru_cache(maxsize=4096)
-def cached_cpe(*args, **kwargs):
- return CPE(*args, **kwargs)
diff --git a/src/sec_certs/sample/cve.py b/src/sec_certs/sample/cve.py
index 2289b0e1..7f1a7cba 100644
--- a/src/sec_certs/sample/cve.py
+++ b/src/sec_certs/sample/cve.py
@@ -1,13 +1,12 @@
from __future__ import annotations
import datetime
-import itertools
from dataclasses import dataclass
-from typing import Any, ClassVar, Iterable
+from typing import Any, ClassVar
from dateutil.parser import isoparse
-from sec_certs.sample.cpe import CPE, CPEConfiguration, cached_cpe
+from sec_certs.sample.cpe import CPEMatchCriteria, CPEMatchCriteriaConfiguration
from sec_certs.serialization.json import ComplexSerializableType
from sec_certs.serialization.pandas import PandasSerializableType
@@ -15,7 +14,7 @@ from sec_certs.serialization.pandas import PandasSerializableType
@dataclass
class CVE(PandasSerializableType, ComplexSerializableType):
@dataclass
- class Impact(ComplexSerializableType):
+ class Metrics(ComplexSerializableType):
base_score: float
severity: str
exploitability_score: float
@@ -24,36 +23,76 @@ class CVE(PandasSerializableType, ComplexSerializableType):
__slots__ = ["base_score", "severity", "exploitability_score", "impact_score"]
@classmethod
- def from_nist_dict(cls, dct: dict[str, Any]) -> CVE.Impact:
+ def from_nist_dict(cls, dct: dict[str, Any]) -> CVE.Metrics:
"""
- Will load Impact from dictionary defined at https://nvd.nist.gov/feeds/json/cve/1.1
+ Loads metrics from dictionary
"""
- if not dct["impact"]:
+ if not dct["metrics"]:
return cls(0, "", 0, 0)
- elif "baseMetricV3" in dct["impact"]:
+ metric_dct = CVE.Metrics.find_metrics_to_use(dct["metrics"])
+ if not metric_dct:
+ raise ValueError(f"Metrics dictionary for cve {dct['id']} present, but no suitable entry found.")
+ return CVE.Metrics.from_metrics_dct(metric_dct)
+
+ @staticmethod
+ def find_metrics_to_use(dct: dict) -> dict | None:
+ """
+ any `Primary` entry available > any `nvd@nist.gov` entry available > just return the first entry if exists.
+ """
+ all_metrics = dct.get("cvssMetricV31", []) + dct.get("cvssMetricV30", []) + dct.get("cvssMetricV2", [])
+
+ for element in all_metrics:
+ if element["type"] == "Primary":
+ return element
+ for element in all_metrics:
+ if element["source"] == "nvd@nist.gov":
+ return element
+
+ if all_metrics:
+ return all_metrics[0]
+
+ return None
+
+ @classmethod
+ def from_metrics_dct(cls, dct: dict) -> CVE.Metrics:
+ if dct["cvssData"]["version"] == "3.1":
return cls(
- dct["impact"]["baseMetricV3"]["cvssV3"]["baseScore"],
- dct["impact"]["baseMetricV3"]["cvssV3"]["baseSeverity"],
- dct["impact"]["baseMetricV3"]["exploitabilityScore"],
- dct["impact"]["baseMetricV3"]["impactScore"],
+ dct["cvssData"]["baseScore"],
+ dct["cvssData"]["baseSeverity"],
+ dct["exploitabilityScore"],
+ dct["impactScore"],
)
- elif "baseMetricV2" in dct["impact"]:
+ if dct["cvssData"]["version"] == "3.0":
return cls(
- dct["impact"]["baseMetricV2"]["cvssV2"]["baseScore"],
- dct["impact"]["baseMetricV2"]["severity"],
- dct["impact"]["baseMetricV2"]["exploitabilityScore"],
- dct["impact"]["baseMetricV2"]["impactScore"],
+ dct["cvssData"]["baseScore"],
+ dct["cvssData"]["baseSeverity"],
+ dct["exploitabilityScore"],
+ dct["impactScore"],
)
- raise ValueError("NIST Dict for CVE Impact badly formatted.")
+ if dct["cvssData"]["version"] == "2.0":
+ return cls(
+ dct["cvssData"]["baseScore"],
+ dct["baseSeverity"],
+ dct["exploitabilityScore"],
+ dct["impactScore"],
+ )
+ raise ValueError(f"Unknown CVSS version occured ({dct['cvssData']['version']}) when parsing CVSS metrics.")
cve_id: str
- vulnerable_cpes: list[CPE]
- vulnerable_cpe_configurations: list[CPEConfiguration]
- impact: Impact
+ vulnerable_criteria: list[CPEMatchCriteria]
+ vulnerable_criteria_configurations: list[CPEMatchCriteriaConfiguration]
+ metrics: Metrics
published_date: datetime.datetime | None
cwe_ids: set[str] | None
- __slots__ = ["cve_id", "vulnerable_cpes", "vulnerable_cpe_configurations", "impact", "published_date", "cwe_ids"]
+ __slots__ = [
+ "cve_id",
+ "vulnerable_criteria",
+ "vulnerable_criteria_configurations",
+ "metrics",
+ "published_date",
+ "cwe_ids",
+ ]
pandas_columns: ClassVar[list[str]] = [
"cve_id",
@@ -88,11 +127,11 @@ class CVE(PandasSerializableType, ComplexSerializableType):
def pandas_tuple(self):
return (
self.cve_id,
- self.vulnerable_cpes,
- self.impact.base_score,
- self.impact.severity,
- self.impact.exploitability_score,
- self.impact.impact_score,
+ self.vulnerable_criteria,
+ self.metrics.base_score,
+ self.metrics.severity,
+ self.metrics.exploitability_score,
+ self.metrics.impact_score,
self.published_date,
self.cwe_ids,
)
@@ -100,9 +139,9 @@ class CVE(PandasSerializableType, ComplexSerializableType):
def to_dict(self) -> dict[str, Any]:
return {
"cve_id": self.cve_id,
- "vulnerable_cpes": self.vulnerable_cpes,
- "vulnerable_cpe_configurations": self.vulnerable_cpe_configurations,
- "impact": self.impact,
+ "vulnerable_cpes": self.vulnerable_criteria,
+ "vulnerable_criteria_configurations": self.vulnerable_criteria_configurations,
+ "impact": self.metrics,
"published_date": self.published_date.isoformat() if self.published_date else None,
"cwe_ids": self.cwe_ids,
}
@@ -115,7 +154,7 @@ class CVE(PandasSerializableType, ComplexSerializableType):
return cls(
dct["cve_id"],
dct["vulnerable_cpes"],
- dct["vulnerable_cpe_configurations"],
+ dct["vulnerable_criteria_configurations"],
dct["impact"],
date_to_take,
dct["cwe_ids"],
@@ -123,92 +162,78 @@ class CVE(PandasSerializableType, ComplexSerializableType):
@classmethod
def from_nist_dict(cls, dct: dict) -> CVE:
- cve_id = dct["cve"]["CVE_data_meta"]["ID"]
- impact = cls.Impact.from_nist_dict(dct)
- published_date = isoparse(dct["publishedDate"])
+ cve_id = dct["id"]
+ metrics = cls.Metrics.from_nist_dict(dct)
+ published_date = datetime.datetime.fromisoformat(dct["published"])
cwe_ids = cls.parse_cwe_data(dct)
- cpes, cpe_configurations = CVE.get_cpe_data_from_nodes_list(dct["configurations"]["nodes"])
-
- return cls(cve_id, cpes, cpe_configurations, impact, published_date, cwe_ids)
+ vulnerable_criteria, vulnerable_criteria_configurations = CVE.parse_configurations(dct)
+ return cls(cve_id, vulnerable_criteria, vulnerable_criteria_configurations, metrics, published_date, cwe_ids)
@staticmethod
- def _parse_nist_cpe_dicts(dictionaries: Iterable[dict[str, Any]]) -> list[CPE]:
- cpes: list[CPE] = []
-
- for x in dictionaries:
- cpe_uri = x["cpe23Uri"]
- version_start: tuple[str, str] | None
- version_end: tuple[str, str] | None
- if "versionStartIncluding" in x and x["versionStartIncluding"]:
- version_start = ("including", x["versionStartIncluding"])
- elif "versionStartExcluding" in x and x["versionStartExcluding"]:
- version_start = ("excluding", x["versionStartExcluding"])
- else:
- version_start = None
-
- if "versionEndIncluding" in x and x["versionEndIncluding"]:
- version_end = ("including", x["versionEndIncluding"])
- elif "versionEndExcluding" in x and x["versionEndExcluding"]:
- version_end = ("excluding", x["versionEndExcluding"])
- else:
- version_end = None
-
- cpes.append(cached_cpe(cpe_uri, start_version=version_start, end_version=version_end))
+ def parse_cwe_data(dct: dict) -> set[str] | None:
+ if "weaknesses" not in dct:
+ return None
- return cpes
+ descriptions = [x["description"] for x in dct["weaknesses"]]
+ cwes = {x["value"] for description in descriptions for x in description}
+ return cwes if cwes else None
@staticmethod
- def _parse_nist_dict(cpe_list: list[dict[str, Any]], parse_only_vulnerable_cpes: bool) -> list[CPE]:
- """
- Method parses list of CPE dicts to the list of CPE objects.
- The <parse_only_vulnerable_cpes> parameter specifies if we want to
- parse only vulnerable CPEs or not.
- """
- return CVE._parse_nist_cpe_dicts(dct for dct in cpe_list if dct["vulnerable"] or not parse_only_vulnerable_cpes)
+ def parse_configurations(
+ dct: dict[str, Any],
+ ) -> tuple[list[CPEMatchCriteria], list[CPEMatchCriteriaConfiguration]]:
+ criteria = []
+ criteria_configurations = []
+ configurations = dct.get("configurations", [])
- @staticmethod
- def parse_cwe_data(dct: dict) -> set[str] | None:
- descriptions = dct["cve"]["problemtype"]["problemtype_data"][0]["description"]
- return {x["value"] for x in descriptions} if descriptions else None
+ for conf in configurations:
+ new_criteria, new_criteria_configuration = CVE.parse_single_configuration(conf)
+ criteria.extend(new_criteria)
+ if new_criteria_configuration:
+ criteria_configurations.append(new_criteria_configuration)
+ return criteria, criteria_configurations
@staticmethod
- def get_cpe_data_from_nodes_list(lst: list) -> tuple[list[CPE], list[CPEConfiguration]]:
- or_nodes = [x for x in lst if x["operator"] == "OR"]
- and_nodes = [x for x in lst if x["operator"] == "AND"]
- return CVE.get_simple_cpes_from_nodes_list(or_nodes), CVE.get_cpe_configurations_from_node_list(and_nodes)
+ def parse_single_configuration(
+ configuration: dict[str, Any]
+ ) -> tuple[list[CPEMatchCriteria], CPEMatchCriteriaConfiguration | None]:
+ if CVE.configuration_is_simple(configuration):
+ return CVE.get_simple_criteria_from_cpe_matches(configuration["nodes"][0]["cpeMatch"]), None
+ else:
+ return [], CVE.get_configuration_criteria_from_configuration_nodes(configuration["nodes"])
@staticmethod
- def get_simple_cpes_from_nodes_list(lst: list) -> list[CPE]:
- return list(
- itertools.chain.from_iterable(
- CVE._parse_nist_dict(node["cpe_match"], parse_only_vulnerable_cpes=True) for node in lst
- )
+ def configuration_is_simple(configuration: dict) -> bool:
+ return (
+ len(configuration["nodes"]) == 1
+ and "cpeMatch" in configuration["nodes"][0]
+ and (configuration.get("operator", "OR") == "OR" or len(configuration["nodes"][0]["cpeMatch"]) == 1)
)
@staticmethod
- def get_cpe_configurations_from_node_list(lst: list) -> list[CPEConfiguration]:
- """
- Retrieves only running on/with configurations, not the advanced ones.
- See more at https://nvd.nist.gov/vuln/vulnerability-detail-pages, section `Configurations`
+ def get_configuration_criteria_from_configuration_nodes(
+ configuration_nodes: dict,
+ ) -> CPEMatchCriteriaConfiguration | None:
"""
- configurations = [CVE.get_cpe_confiugration_from_node(x) for x in lst]
- return [x for x in configurations if x]
-
- @staticmethod
- def get_cpe_confiugration_from_node(node: dict) -> CPEConfiguration | None:
- if node["children"]:
- if len(node["children"]) != 2:
- return None
+ Retrieves complex configuration criteria from a dictionary of configuration nodes.
+ It is aasserted that the dictionary has two layers at most, that the top-level children are in AND relationship,
+ and that the individual elements are in OR relationship (otherwise, they would be parsed by different method.)
- # Deep variant should have two children, get CPEs from the first one and declare that product, second is platform
- cpes = CVE._parse_nist_dict(node["children"][0]["cpe_match"], parse_only_vulnerable_cpes=True)
- platform = CVE._parse_nist_dict(node["children"][1]["cpe_match"], parse_only_vulnerable_cpes=False)
- return CPEConfiguration(platform[0], cpes)
- else:
- # Shallow variant should have exactly 2 matching CPEs, we declare one a platform, second one the vuln. thing
- cpes = CVE._parse_nist_dict(node["cpe_match"], parse_only_vulnerable_cpes=True)
+ We cannot process configuration when elements of a single component are in AND relationship.
+ Out of all configurations in dataset as of April 2023, only 3 were detected in the dataset.
+ We ignore those on purpose.
- if len(cpes) != 2:
- return None
+ :param dict configuration_nodes: _description_
+ :return CPEMatchCriteriaConfiguration | None: _description_
+ """
+ assert all("cpeMatch" in x for x in configuration_nodes) # the next layer are matches
+ nodes = [x for x in configuration_nodes if "operator" not in x or x["operator"] == "OR"]
+ if nodes:
+ return CPEMatchCriteriaConfiguration(
+ [CVE.get_simple_criteria_from_cpe_matches(x["cpeMatch"]) for x in nodes]
+ )
+ return None
- return CPEConfiguration(cpes[0], [cpes[1]])
+ @staticmethod
+ def get_simple_criteria_from_cpe_matches(cpe_matches: list[dict[str, Any]]) -> list[CPEMatchCriteria]:
+ return [CPEMatchCriteria.from_nist_dict(x) for x in cpe_matches]
diff --git a/src/sec_certs/sample/fips.py b/src/sec_certs/sample/fips.py
index 82aa1c18..d351c3ce 100644
--- a/src/sec_certs/sample/fips.py
+++ b/src/sec_certs/sample/fips.py
@@ -12,7 +12,6 @@ import numpy as np
import pandas as pd
import requests
from bs4 import BeautifulSoup, Tag
-from tabula import read_pdf
from sec_certs import constants
from sec_certs.cert_rules import FIPS_ALGS_IN_TABLE, fips_rules
@@ -632,6 +631,8 @@ class FIPSCertificate(
Retrieves IDs of algorithms from tables inside security policy pdfs.
External library is used to handle this.
"""
+ from tabula import read_pdf
+
if table_rich_page_numbers := tables.find_pages_with_tables(cert.state.policy_txt_path):
pdf.repair_pdf(cert.state.policy_pdf_path)
try:
diff --git a/src/sec_certs/serialization/json.py b/src/sec_certs/serialization/json.py
index 7b523b9b..314352bb 100644
--- a/src/sec_certs/serialization/json.py
+++ b/src/sec_certs/serialization/json.py
@@ -1,8 +1,9 @@
from __future__ import annotations
import copy
+import gzip
import json
-from datetime import date
+from datetime import date, datetime
from functools import wraps
from pathlib import Path
from typing import Any, Callable, TypeVar
@@ -44,7 +45,12 @@ class ComplexSerializableType:
except TypeError as e:
raise TypeError(f"Dict: {dct} on {cls.__mro__}") from e
- def to_json(self, output_path: str | Path | None = None) -> None:
+ def to_json(self, output_path: str | Path | None = None, compress: bool = False) -> None:
+ """
+ Serializes `ComplexSerializableType` instance to json file.
+ :param str | Path | None output_path: path where the file will be stored. If None, `obj.json_path` access is attempted, defaults to None
+ :param bool compress: if True, will be compress with gzip, defaults to False
+ """
if not output_path and (not hasattr(self, "json_path") or not self.json_path): # type: ignore
raise SerializationError(
f"The object {self} of type {self.__class__} does not have json_path attribute set but to_json() was called without an argument."
@@ -60,20 +66,36 @@ class ComplexSerializableType:
raise SerializationError("output path for json cannot be directory.")
# false positive MyPy warning, cannot be None
- with Path(output_path).open("w") as handle: # type: ignore
- json.dump(self, handle, indent=4, cls=CustomJSONEncoder, ensure_ascii=False)
+ if compress:
+ with gzip.open(str(output_path), "w") as handle: # type: ignore
+ json_str = json.dumps(self, indent=4, cls=CustomJSONEncoder, ensure_ascii=False)
+ handle.write(json_str.encode("utf-8"))
+ else:
+ with Path(output_path).open("w") as handle: # type: ignore
+ json.dump(self, handle, indent=4, cls=CustomJSONEncoder, ensure_ascii=False)
@classmethod
- def from_json(cls: type[T], input_path: str | Path) -> T:
- input_path = Path(input_path)
- with input_path.open("r") as handle:
- return json.load(handle, cls=CustomJSONDecoder)
+ def from_json(cls: type[T], input_path: str | Path, is_compressed: bool = False) -> T:
+ """
+ Will load `ComplexSerializableType` from json.
+ :param str | Path input_path: path to load the file from
+ :param bool is_compressed: if True, will decompress .gz first, defaults to False
+ :return T: the deserialized object
+ """
+ if is_compressed:
+ with gzip.open(str(input_path)) as handle:
+ json_str = handle.read().decode("utf-8")
+ return json.loads(json_str, cls=CustomJSONDecoder)
+ else:
+ input_path = Path(input_path)
+ with input_path.open("r") as handle:
+ return json.load(handle, cls=CustomJSONDecoder)
# Decorator for serialization
def serialize(func: Callable):
@wraps(func)
- def inner_func(*args, **kwargs):
+ def _serialize(*args, **kwargs):
if not args or not issubclass(type(args[0]), ComplexSerializableType):
raise ValueError(
"@serialize decorator is to be used only on instance methods of ComplexSerializableType child classes."
@@ -90,7 +112,7 @@ def serialize(func: Callable):
args[0].to_json()
return result
- return inner_func
+ return _serialize
def get_class_fullname(obj: Any) -> str:
@@ -113,6 +135,8 @@ class CustomJSONEncoder(json.JSONEncoder):
return sorted(obj)
if isinstance(obj, date):
return str(obj)
+ if isinstance(obj, datetime):
+ return obj.isoformat()
if isinstance(obj, Path):
return str(obj)
return super().default(obj)
diff --git a/src/sec_certs/utils/nvd_dataset_builder.py b/src/sec_certs/utils/nvd_dataset_builder.py
new file mode 100644
index 00000000..fe43bcb3
--- /dev/null
+++ b/src/sec_certs/utils/nvd_dataset_builder.py
@@ -0,0 +1,365 @@
+from __future__ import annotations
+
+import itertools
+import logging
+import math
+import random
+import time
+from abc import ABC, abstractmethod
+from dataclasses import dataclass, field
+from datetime import datetime
+from multiprocessing import cpu_count
+from typing import Any, Final, Generic, TypeVar
+
+import numpy as np
+import requests
+from requests import RequestException, Response
+
+from sec_certs import constants
+from sec_certs.dataset.cpe import CPEDataset
+from sec_certs.dataset.cve import CVEDataset
+from sec_certs.utils.parallel_processing import process_parallel
+
+logger = logging.getLogger(__name__)
+
+DatasetType = TypeVar("DatasetType", CPEDataset, CVEDataset, dict)
+
+
+@dataclass
+class NvdDatasetBuilder(Generic[DatasetType], ABC):
+ """
+ Abstract class to build new, or enrich existing, datasets with data from NVD, using their API.
+ Example:
+ ```
+ with CpeNvdDatasetBuilder(api_key=config.nvd_api_key) as builder:
+ cpe_dataset = builder.build_dataset()
+ ```
+ """
+
+ api_key: str | None = None
+ n_threads: int = -1
+ max_attempts: int = 5
+
+ _start_mod_date: datetime | None = field(init=False)
+ _end_mod_date: datetime | None = field(init=False)
+ _ok_responses: list[requests.Response] = field(init=False, default_factory=list)
+ _requests_to_process: list[tuple] = field(init=False, default_factory=list)
+ _attempts_left: int = field(init=False)
+
+ def __post_init__(self):
+ self.clear_state()
+ if not self.api_key:
+ logger.warning("No API key for NVD database was set, the ratelimit is just 5 requests per 30 seconds.")
+
+ def __enter__(self) -> NvdDatasetBuilder:
+ return self
+
+ def __exit__(self, exc_type, exc_value, traceback) -> None:
+ self.clear_state()
+
+ @property
+ @abstractmethod
+ def _RESULTS_PER_PAGE(self):
+ """
+ Specifies "resultsPerPage" parameter to the API
+ """
+ raise NotImplementedError
+
+ @property
+ @abstractmethod
+ def _ENDPOINT(self):
+ """
+ Specifies the endpoint, used mostly for logging
+ """
+ raise NotImplementedError
+
+ @property
+ @abstractmethod
+ def _ENDPOINT_URL(self):
+ """
+ Specifies the URL to send the requests to
+ """
+ raise NotImplementedError
+
+ def _get_last_update_from_previous_data(self, dataset: DatasetType) -> datetime:
+ """
+ Will retrieve timestamp of the last update from the dataset.
+ """
+ raise NotImplementedError
+
+ @staticmethod
+ def _init_new_dataset() -> DatasetType:
+ """
+ Will initialize new empty dataset.
+ """
+ raise NotImplementedError
+
+ def _process_responses(self, responses: list[Response], dataset_to_fill: DatasetType) -> DatasetType:
+ """
+ Will process the responses, construct objects and fill the `dataset_to_fill`
+ """
+ raise NotImplementedError
+
+ @property
+ def _actual_n_threads(self) -> int:
+ if self.n_threads == -1:
+ return cpu_count()
+ return self.n_threads
+
+ @property
+ def base_params(self) -> dict[str, Any]:
+ dct = {"resultsPerPage": self._RESULTS_PER_PAGE}
+
+ if self._start_mod_date and self._end_mod_date:
+ dct["startModDate"] = self._start_mod_date.isoformat()
+ dct["endModDate"] = self._end_mod_date.isoformat()
+
+ return dct
+
+ @property
+ def headers(self) -> dict[str, Any] | None:
+ dct = {"content-type": "application/json", "User-Agent": "sec-certs"}
+ if self.api_key:
+ dct["apiKey"] = self.api_key
+ return dct
+
+ @property
+ def _base_delay(self) -> int:
+ return 2 if self.api_key else 20
+
+ @staticmethod
+ def fetch_nvd_api(
+ url: str, params: dict[str, Any], headers: dict[str, Any] | None, delay: float = 0
+ ) -> requests.Response:
+ time.sleep(delay)
+ try:
+ response = requests.get(
+ url,
+ params=params,
+ headers=headers,
+ timeout=constants.REQUEST_TIMEOUT,
+ )
+ except requests.exceptions.Timeout:
+ response = requests.Response()
+ response.status_code = 403
+ except Exception:
+ response = requests.Response()
+ response.status_code = 500
+ return response
+
+ def clear_state(self) -> None:
+ """
+ Clears the internal state of the NvdDatasetBuilder. Try to avoid calling this method. Instead, use the class in
+ with statement: `with NvdDatasetBuilder(args) as fetcher: ...`
+ """
+ self._start_mod_date = None
+ self._end_mod_date = None
+ self._ok_responses = []
+ self._requests_to_process = []
+ self._attempts_left = self.max_attempts
+
+ def _fill_in_mod_dates(self, force_full_update: bool, last_update: datetime) -> None:
+ """
+ Decides how to set date arguments in the requests. Effectively this resolves whether the update will be full
+ or incremental.
+
+ :param bool force_full_update: If set to True, will always fetch all data
+ :param datetime last_update: Timestamp of the last update.
+ """
+ if force_full_update:
+ self._start_mod_date = None
+ self._end_mod_date = None
+ else:
+ current_timestamp = datetime.now()
+ # TOCTOU ignored
+ if (current_timestamp - last_update).days >= constants.INCREMENTAL_NVD_UPDATE_MAX_INTERVAL_DAYS:
+ self._start_mod_date = None
+ self._end_mod_date = None
+ logger.info(
+ f"Will fetch complete {self._ENDPOINT} data from NVD API as the last update was either done >120 days ago, or no previous data was provided."
+ )
+ else:
+ self._start_mod_date = last_update
+ self._end_mod_date = current_timestamp
+
+ def _get_n_total_results(self, fresh: bool = True) -> int:
+ if not fresh:
+ time.sleep(6)
+
+ response = NvdDatasetBuilder.fetch_nvd_api(
+ self._ENDPOINT_URL, params={**self.base_params, **{"resultsPerPage": 0}}, headers=self.headers
+ )
+ if response.status_code == 404:
+ # This is likely due to no CPEs to update, incremental update very soon.
+ return 0
+ if response.status_code != constants.RESPONSE_OK:
+ if fresh:
+ logger.warning(
+ f"Error when attempting to fetch number of pages to get from NVD API {self._ENDPOINT} endpoint, sleeping 6 seconds and repeating."
+ )
+ return self._get_n_total_results(fresh=False)
+ else:
+ logger.error(
+ f"Could not fetch the number of pages to get from NVD API {self._ENDPOINT} endpoint even after retry attempt, raising exception."
+ )
+ raise RequestException(
+ f"Could not fetch the number of pages to get from NVD API {self._ENDPOINT} endpoint even after retry attempt"
+ )
+ return response.json()["totalResults"]
+
+ def _build_arguments(self) -> None:
+ """
+ Makes an API call to NVD API to learn how many records in total will be fetch. Based on that, prepares
+ a list of tuples that parametrize the requests to be made.
+ """
+ n_requests = math.ceil(self._get_n_total_results() / self._RESULTS_PER_PAGE)
+ logger.info(
+ f"Building arguments for NVD requests to {self._ENDPOINT} endpoint. Will send {n_requests} requests."
+ )
+ offsets = [i * self._RESULTS_PER_PAGE for i in range(n_requests)]
+ delays = [self._base_delay * random.randint(1, 3) for _ in range(n_requests)] # Bulgarian constant
+ self._requests_to_process = [
+ (self._ENDPOINT_URL, {**self.base_params, **{"startIndex": offset}}, self.headers, delay)
+ for offset, delay in zip(offsets, delays)
+ ]
+
+ def _evaluate_responses(self, responses: list[Response]) -> None:
+ """
+ Will fetch successfull responses into self._ok_responses and prune self.requests_to_process accordingly
+ """
+ response_is_nok = np.array([x.status_code != constants.RESPONSE_OK for x in responses])
+ nok_indices = np.where(response_is_nok == True)[0] # noqa E712, doesn't work with `is True`
+ currently_ok = [x for x in responses if x.status_code == constants.RESPONSE_OK]
+
+ logger.info(
+ f"Attempt {self.max_attempts - self._attempts_left}/{self.max_attempts}: Successfully processed {len(currently_ok)}/{len(self._requests_to_process)} requests."
+ )
+
+ self._ok_responses.extend(currently_ok)
+ self._requests_to_process = [self._requests_to_process[x] for x in nok_indices]
+
+ if self._attempts_left == 0 and self._requests_to_process:
+ logger.warning(
+ f"Failed to process {len(self._requests_to_process)} requests in total, the dataset will be incomplete."
+ )
+
+ def _request_parallel_and_handle_responses(self):
+ """
+ Attempts to fetch the requests in the queue multiple times, and in parallel
+ """
+ if self._attempts_left > 0 and self._requests_to_process:
+ self._attempts_left -= 1
+ self._evaluate_responses(
+ process_parallel(
+ NvdDatasetBuilder.fetch_nvd_api,
+ self._requests_to_process,
+ max_workers=self._actual_n_threads,
+ unpack=True,
+ progress_bar_desc=f"Fetching data from {self._ENDPOINT} NVD endpoint",
+ )
+ )
+ self._request_parallel_and_handle_responses()
+
+ def build_dataset(self, dataset_to_fill: DatasetType | None = None, force_full_update: bool = False) -> DatasetType:
+ """
+ Will fetch the resource in a parallelized fashion. If possible, use this within a with statement.
+ E.g., `with NvdDatasetBuilder(args) as builder: builder.build_dataset()`
+ When used outside of the context manager, the caller is responsible for cleaning the state with
+ `self.clear_state()` after running this method.
+
+ :param DatasetType | None dataset_to_fill: Existing dataset to fill-in with new data, defaults to None
+ :param bool force_full_update: If True, will always fetch all data, defaults to False
+ :return DatasetType: Dataset enriched with the new records from NVD.
+ """
+ if dataset_to_fill is None:
+ dataset_to_fill = self._init_new_dataset()
+
+ last_update = self._get_last_update_from_previous_data(dataset_to_fill)
+ self._fill_in_mod_dates(force_full_update, last_update)
+ self._build_arguments()
+ self._request_parallel_and_handle_responses()
+
+ return self._process_responses(self._ok_responses, dataset_to_fill)
+
+
+class CpeNvdDatasetBuilder(NvdDatasetBuilder[CPEDataset]):
+ _ENDPOINT: Final[str] = "CPE"
+ _ENDPOINT_URL: Final[str] = "https://services.nvd.nist.gov/rest/json/cpes/2.0"
+ _RESULTS_PER_PAGE: Final[int] = 10000
+
+ def _process_responses(self, responses: list[requests.Response], cpe_dataset: CPEDataset) -> CPEDataset:
+ products = list(itertools.chain.from_iterable(response.json()["products"] for response in responses))
+ timestamp = self._end_mod_date.isoformat() if self._end_mod_date else responses[-1].json()["timestamp"]
+ cpe_dataset.enhance_with_nvd_data({"timestamp": timestamp, "products": products})
+ return cpe_dataset
+
+ def _get_last_update_from_previous_data(self, previous_data: CPEDataset) -> datetime:
+ return previous_data.last_update_timestamp
+
+ @staticmethod
+ def _init_new_dataset() -> CPEDataset:
+ return CPEDataset()
+
+
+class CveNvdDatasetBuilder(NvdDatasetBuilder[CVEDataset]):
+ _ENDPOINT: Final[str] = "CVE"
+ _ENDPOINT_URL: Final[str] = "https://services.nvd.nist.gov/rest/json/cves/2.0"
+ _RESULTS_PER_PAGE: Final[int] = 2000
+
+ def _process_responses(self, responses: list[Response], cve_dataset: CVEDataset) -> CVEDataset:
+ timestamp = self._end_mod_date.isoformat() if self._end_mod_date else responses[-1].json()["timestamp"]
+ vulns = list(itertools.chain.from_iterable(response.json()["vulnerabilities"] for response in responses))
+ cve_dataset.enhance_with_nvd_data({"timestamp": timestamp, "vulnerabilities": vulns})
+ return cve_dataset
+
+ def _get_last_update_from_previous_data(self, previous_data: CVEDataset) -> datetime:
+ return previous_data.last_update_timestamp
+
+ @staticmethod
+ def _init_new_dataset() -> CVEDataset:
+ return CVEDataset()
+
+
+class CpeMatchNvdDatasetBuilder(NvdDatasetBuilder[dict]):
+ _ENDPOINT: Final[str] = "CPEMatch"
+ _ENDPOINT_URL: Final[str] = "https://services.nvd.nist.gov/rest/json/cpematch/2.0"
+ _RESULTS_PER_PAGE: Final[int] = 5000
+ _VERSION_KEYS: Final[list[str]] = [
+ "versionStartIncluding",
+ "versionStartExcluding",
+ "versionEndIncluding",
+ "versionEndExcluding",
+ ]
+
+ def _process_responses(self, responses: list[Response], dataset_to_fill: dict) -> dict:
+ timestamp = self._end_mod_date.isoformat() if self._end_mod_date else responses[-1].json()["timestamp"]
+ match_strings = list(itertools.chain.from_iterable(response.json()["matchStrings"] for response in responses))
+ dataset_to_fill["timestamp"] = timestamp
+
+ inactive_criteria = set()
+ for m in match_strings:
+ if m["matchString"]["status"] == "Inactive":
+ inactive_criteria.add(m["matchString"]["matchCriteriaId"])
+ else:
+ if "matches" in m["matchString"]:
+ dataset_to_fill["match_strings"][m["matchString"]["matchCriteriaId"]] = {
+ "criteria": m["matchString"]["criteria"],
+ "matches": m["matchString"]["matches"],
+ }
+ for version_key in self._VERSION_KEYS:
+ if version_key in m["matchString"]:
+ dataset_to_fill["match_strings"][m["matchString"]["matchCriteriaId"]][version_key] = m[
+ "matchString"
+ ][version_key]
+
+ for inactive in inactive_criteria:
+ dataset_to_fill["match_strings"].pop(inactive, None)
+
+ return dataset_to_fill
+
+ def _get_last_update_from_previous_data(self, previous_data: dict) -> datetime:
+ return datetime.fromisoformat(previous_data["timestamp"])
+
+ @staticmethod
+ def _init_new_dataset() -> dict:
+ return {"timestamp": datetime.fromtimestamp(0).isoformat(), "match_strings": {}}
diff --git a/src/sec_certs/utils/pandas.py b/src/sec_certs/utils/pandas.py
index 4b8b9504..5b0a668d 100644
--- a/src/sec_certs/utils/pandas.py
+++ b/src/sec_certs/utils/pandas.py
@@ -292,7 +292,7 @@ def expand_df_with_cve_cols(df: pd.DataFrame, cve_dset: CVEDataset) -> pd.DataFr
df["earliest_cve"] = df.cve_published_dates.map(lambda x: min(x) if isinstance(x, list) else np.nan)
df["worst_cve_score"] = df.related_cves.map(
- lambda x: max([cve_dset[cve].impact.base_score for cve in x]) if not pd.isna(x) else np.nan
+ lambda x: max([cve_dset[cve].metrics.base_score for cve in x]) if not pd.isna(x) else np.nan
)
"""
@@ -302,7 +302,7 @@ def expand_df_with_cve_cols(df: pd.DataFrame, cve_dset: CVEDataset) -> pd.DataFr
To properly treat this, the average should be taken across CVEs with >0 base_socre.
"""
df["avg_cve_score"] = df.related_cves.map(
- lambda x: np.mean([cve_dset[cve].impact.base_score for cve in x]) if not pd.isna(x) else np.nan
+ lambda x: np.mean([cve_dset[cve].metrics.base_score for cve in x]) if not pd.isna(x) else np.nan
)
return df
diff --git a/src/sec_certs/utils/pdf.py b/src/sec_certs/utils/pdf.py
index edda0570..749a8a5a 100644
--- a/src/sec_certs/utils/pdf.py
+++ b/src/sec_certs/utils/pdf.py
@@ -11,8 +11,6 @@ from typing import Any
import pdftotext
import pikepdf
-from pypdf import PdfReader
-from pypdf.generic import BooleanObject, ByteStringObject, FloatObject, IndirectObject, NumberObject, TextStringObject
from sec_certs import constants
from sec_certs.constants import (
@@ -156,6 +154,15 @@ def extract_pdf_metadata(filepath: Path) -> tuple[str, dict[str, Any] | None]:
:param filepath: THe path to the PDF.
:return: A tuple of the result code (see constants) and the metadata dictionary.
"""
+ from pypdf import PdfReader
+ from pypdf.generic import (
+ BooleanObject,
+ ByteStringObject,
+ FloatObject,
+ IndirectObject,
+ NumberObject,
+ TextStringObject,
+ )
def map_metadata_value(val, nope_out=False):
if isinstance(val, BooleanObject):