diff options
| author | Ján Jančár | 2025-02-02 16:54:22 +0100 |
|---|---|---|
| committer | GitHub | 2025-02-02 16:54:22 +0100 |
| commit | 1329b41f3cdacfeb67961638015e4ee9f1d47aad (patch) | |
| tree | 94a9bf4c5befdfe88c05b8d6ccd118a024dd7ddd /src | |
| parent | d640ad1f1510f0dc6fa5d6e329e079256ef8d1be (diff) | |
| parent | 996759d9461c6c261c41abda28a9184a5a5a7cbe (diff) | |
| download | sec-certs-1329b41f3cdacfeb67961638015e4ee9f1d47aad.tar.gz sec-certs-1329b41f3cdacfeb67961638015e4ee9f1d47aad.tar.zst sec-certs-1329b41f3cdacfeb67961638015e4ee9f1d47aad.zip | |
Merge pull request #469 from crocs-muni/feat/pp-processing
Add PP processing (rebased)
Diffstat (limited to 'src')
25 files changed, 1859 insertions, 1129 deletions
diff --git a/src/sec_certs/cli.py b/src/sec_certs/cli.py index 4b613af4..6276aa2f 100644 --- a/src/sec_certs/cli.py +++ b/src/sec_certs/cli.py @@ -15,6 +15,7 @@ from sec_certs.configuration import config from sec_certs.dataset.cc import CCDataset from sec_certs.dataset.dataset import Dataset from sec_certs.dataset.fips import FIPSDataset +from sec_certs.dataset.protection_profile import ProtectionProfileDataset from sec_certs.utils.helpers import warn_if_missing_poppler, warn_if_missing_tesseract logger = logging.getLogger(__name__) @@ -38,7 +39,7 @@ class ProcessingStep: if not hasattr(Dataset.DatasetInternalState, condition): raise ValueError(f"Precondition attribute {condition} is not member of `Dataset.DatasetInternalState`.") - def run(self, dset: CCDataset | FIPSDataset) -> None: + def run(self, dset: Dataset) -> None: for condition in self.preconditions: if not getattr(dset.state, condition): err_msg = ( @@ -59,14 +60,21 @@ def warn_missing_libs(): warn_if_missing_tesseract() +FRAMEWORK_TO_CONSTRUCTOR: dict[str, type[Dataset]] = { + "cc": CCDataset, + "fips": FIPSDataset, + "pp": ProtectionProfileDataset, +} + + def build_or_load_dataset( framework: str, inputpath: Path | None, to_build: bool, outputpath: Path | None, -) -> CCDataset | FIPSDataset: - constructor: type[CCDataset] | type[FIPSDataset] = CCDataset if framework == "cc" else FIPSDataset - dset: CCDataset | FIPSDataset +) -> Dataset: + constructor: type[Dataset] = FRAMEWORK_TO_CONSTRUCTOR[framework] + dset: Dataset if to_build: if not outputpath: @@ -138,7 +146,7 @@ steps = [ "framework", required=True, nargs=1, - type=click.Choice(["cc", "fips"], case_sensitive=False), + type=click.Choice(["cc", "fips", "pp"], case_sensitive=False), ) @click.argument( "actions", @@ -166,7 +174,7 @@ steps = [ "--input", "inputpath", type=click.Path(file_okay=True, dir_okay=False, writable=True, readable=True), - help="If set, the actions will be performed on a CC dataset loaded from JSON from the input path.", + help="If set, the actions will be performed on a dataset loaded from JSON from the input path.", ) @click.option("-q", "--quiet", is_flag=True, help="If set, will not print to stdout") def main( @@ -202,8 +210,6 @@ def main( ) dset = build_or_load_dataset(framework, inputpath, "build" in actions_set, outputpath) - aux_dsets_to_handle = "PP, Maintenance updates" if framework == "cc" else "Algorithms" - aux_dsets_to_handle += "CPE, CVE" processing_step: ProcessingStep for processing_step in [x for x in steps if x.name in actions_set]: diff --git a/src/sec_certs/configuration.py b/src/sec_certs/configuration.py index 09e24539..17db9318 100644 --- a/src/sec_certs/configuration.py +++ b/src/sec_certs/configuration.py @@ -54,12 +54,20 @@ class Configuration(BaseSettings): "https://sec-certs.org/cc/cc.tar.gz", description="URL from where to fetch the latest full archive of fully processed CC dataset.", ) + cc_maintenances_latest_full_archive: AnyHttpUrl = Field( + "https://sec-certs.org/cc/cc_mu.tar.gz", + description="URL from where to fetch the latest full archive of fully processed CC Maintenace updates dataset", + ) cc_maintenances_latest_snapshot: AnyHttpUrl = Field( "https://sec-certs.org/cc/maintenance_updates.json", description="URL from where to fetch the latest snapshot of CC maintenance updates", ) + pp_latest_full_archive: AnyHttpUrl = Field( + "https://sec-certs.org/pp/pp.tar.gz", + description="URL from where to fetch the latest full archive of fully processed PP dataset.", + ) pp_latest_snapshot: AnyHttpUrl = Field( - "https://sec-certs.org/static/pp.json", + "https://sec-certs.org/pp/dataset.json", description="URL from where to fetch the latest snapshot of the PP dataset.", ) fips_latest_snapshot: AnyHttpUrl = Field( @@ -134,11 +142,11 @@ class Configuration(BaseSettings): 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( + preferred_source_remote_datasets: Literal["sec-certs", "origin"] = Field( "sec-certs", - description="If set to `sec-certs`, will fetch CPE and CVE datasets from sec-certs.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 `api`.", + description="If set to `sec-certs`, will fetch remote datasets from sec-certs.org." + + " If set to `origin`, will fetch these resources from their origin URL. It is advised to set an" + + " `nvd_api_key` when setting this to `origin`.", ) def _get_nondefault_keys(self) -> set[str]: diff --git a/src/sec_certs/constants.py b/src/sec_certs/constants.py index d134b3fd..125c2265 100644 --- a/src/sec_certs/constants.py +++ b/src/sec_certs/constants.py @@ -22,11 +22,50 @@ MIN_CORRECT_CERT_SIZE = 5000 MIN_FIPS_HTML_SIZE = 64000 MIN_CC_HTML_SIZE = 5000000 +MIN_PP_HTML_SIZE = 200000 MIN_CC_CSV_SIZE = 700000 MIN_CC_PP_DATASET_SIZE = 2500000 CPE_VERSION_NA = "-" +CC_CAT_ABBREVIATIONS = [ + "AC", + "BD", + "BP", + "DP", + "DB", + "DD", + "IC", + "KM", + "MD", + "MF", + "NS", + "OS", + "OD", + "DG", + "TC", +] + +CC_CATEGORIES = [ + "Access Control Devices and Systems", + "Biometric Systems and Devices", + "Boundary Protection Devices and Systems", + "Data Protection", + "Databases", + "Detection Devices and Systems", + "ICs, Smart Cards and Smart Card-Related Devices and Systems", + "Key Management Systems", + "Mobility", + "Multi-Function Devices", + "Network and Network-Related Devices and Systems", + "Operating Systems", + "Other Devices and Systems", + "Products for Digital Signatures", + "Trusted Computing", +] + +CC_PORTAL_BASE_URL = "https://www.commoncriteriaportal.org" + RELEASE_CANDIDATE_REGEX: re.Pattern = re.compile(r"rc\d{0,2}$", re.IGNORECASE) FIPS_BASE_URL = "https://csrc.nist.gov" diff --git a/src/sec_certs/dataset/auxiliary_dataset_handling.py b/src/sec_certs/dataset/auxiliary_dataset_handling.py new file mode 100644 index 00000000..11608a2e --- /dev/null +++ b/src/sec_certs/dataset/auxiliary_dataset_handling.py @@ -0,0 +1,282 @@ +import gzip +import itertools +import json +import logging +import tempfile +from abc import ABC, abstractmethod +from collections.abc import Iterable +from pathlib import Path +from typing import Any, ClassVar + +from sec_certs import constants +from sec_certs.configuration import config +from sec_certs.dataset.cc_scheme import CCSchemeDataset +from sec_certs.dataset.cpe import CPEDataset +from sec_certs.dataset.cve import CVEDataset +from sec_certs.dataset.fips_algorithm import FIPSAlgorithmDataset +from sec_certs.sample.cc import CCCertificate +from sec_certs.sample.cc_maintenance_update import CCMaintenanceUpdate +from sec_certs.utils import helpers +from sec_certs.utils.nvd_dataset_builder import CpeMatchNvdDatasetBuilder, CpeNvdDatasetBuilder, CveNvdDatasetBuilder +from sec_certs.utils.profiling import staged + +logger = logging.getLogger(__name__) + + +class AuxiliaryDatasetHandler(ABC): + RELATIVE_DIR: ClassVar[str | None] = None + aux_datasets_dir: Path + dset: Any + + def __init__(self, aux_datasets_dir: str | Path) -> None: + self.aux_datasets_dir = Path(aux_datasets_dir) + + @property + def root_dir(self) -> Path: + if self.RELATIVE_DIR: + return self.aux_datasets_dir / Path(self.RELATIVE_DIR) + return self.aux_datasets_dir + + @property + @abstractmethod + def dset_path(self) -> Path: + raise NotImplementedError("Not meant to be implemented by base class") + + def set_local_paths(self, aux_datasets_dir: str | Path) -> None: + self.aux_datasets_dir = Path(aux_datasets_dir) + + def process_dataset(self, download_fresh: bool = False) -> None: + self.root_dir.mkdir(parents=True, exist_ok=True) + self._process_dataset_body(download_fresh) + + @abstractmethod + def load_dataset(self) -> None: + raise NotImplementedError("Not meant to be implemented by base class") + + @abstractmethod + def _process_dataset_body(self, download_fresh: bool = False) -> None: + raise NotImplementedError("Not meant to be implemented by base class") + + +class CPEDatasetHandler(AuxiliaryDatasetHandler): + @property + def dset_path(self) -> Path: + return self.root_dir / "cpe_dataset.json" + + @staged(logger, "Processing CPE dataset") + def _process_dataset_body(self, download_fresh: bool = False) -> None: + if not download_fresh and self.dset_path.exists(): + logger.info("Preparing CPEDataset from json.") + self.load_dataset() + return + + if config.preferred_source_remote_datasets == "origin": + logger.info("Fetching new CPE records from NVD API") + with CpeNvdDatasetBuilder(api_key=config.nvd_api_key) as builder: + self.dset = builder.build_dataset() + else: + logger.info("Preparing CPEDataset from sec-certs.org.") + self.dset = CPEDataset.from_web(self.dset_path) + + self.dset.to_json() + self.dset.json_path = self.dset_path + + def load_dataset(self) -> None: + self.dset = CPEDataset.from_json(self.dset_path) + + +class CVEDatasetHandler(AuxiliaryDatasetHandler): + @property + def dset_path(self) -> Path: + return self.root_dir / "cve_dataset.json" + + @staged(logger, "Processing CVE dataset") + def _process_dataset_body(self, download_fresh: bool = False) -> None: + if not download_fresh and self.dset_path.exists(): + logger.info("Preparing CVEDataset from json.") + self.load_dataset() + return + + if config.preferred_source_remote_datasets == "origin": + logger.info("Fetching new CVE records from NVD API.") + with CveNvdDatasetBuilder(api_key=config.nvd_api_key) as builder: + self.dset = builder.build_dataset() + else: + logger.info("Preparing CVEDataset from sec-certs.org.") + self.dset = CVEDataset.from_web(self.dset_path) + + self.dset.to_json() + self.dset.json_path = self.dset_path + + def load_dataset(self): + self.dset = CVEDataset.from_json(self.dset_path) + + +class CPEMatchDictHandler(AuxiliaryDatasetHandler): + @property + def dset_path(self) -> Path: + return self.root_dir / "cpe_match.json" + + @staged(logger, "Processing CPE Match dictionary") + def _process_dataset_body(self, download_fresh: bool = False) -> None: + if not download_fresh and self.dset_path.exists(): + logger.info("Preparing CPE Match feed from json.") + self.load_dataset() + return + + if config.preferred_source_remote_datasets == "origin": + logger.info("Fetchnig CPE Match feed from NVD APi.") + with CpeMatchNvdDatasetBuilder(api_key=config.nvd_api_key) as builder: + self.dset = builder.build_dataset() + else: + logger.info("Preparing CPE Match feed from sec-certs.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") + self.dset = json.loads(json_str) + + with self.dset_path.open("w") as handle: + json.dump(self.dset, handle, indent=4) + + def load_dataset(self): + with self.dset_path.open("r") as handle: + self.dset = json.load(handle) + + +class FIPSAlgorithmDatasetHandler(AuxiliaryDatasetHandler): + @property + def dset_path(self) -> Path: + return self.root_dir / "algorithms.json" + + @staged(logger, "Processing FIPS Algorithms") + def _process_dataset_body(self, download_fresh: bool = False) -> None: + if not download_fresh and self.dset_path.exists(): + logger.info("Preparing FIPSAlgorithmDataset from json.") + self.load_dataset() + return + + self.dset = FIPSAlgorithmDataset.from_web(self.dset_path) + self.dset.to_json() + self.dset.json_path = self.dset_path + + def load_dataset(self): + self.dset = FIPSAlgorithmDataset.from_json(self.dset_path) + + +class CCSchemeDatasetHandler(AuxiliaryDatasetHandler): + def __init__( + self, + aux_datasets_dir: str | Path = constants.DUMMY_NONEXISTING_PATH, + only_schemes: set[str] | None = None, + ): + super().__init__(aux_datasets_dir) + self.only_schemes = only_schemes + + @property + def dset_path(self) -> Path: + return self.root_dir / "cc_scheme.json" + + @staged(logger, "Processing CC Schemes") + def _process_dataset_body(self, download_fresh: bool = False) -> None: + if not download_fresh and self.dset_path.exists(): + logger.info("Preparing CCSchemeDataset from json.") + self.load_dataset() + return + + self.dset = CCSchemeDataset.from_web(self.dset_path, self.only_schemes) + self.dset.to_json() + self.dset.json_path = self.dset_path + + def load_dataset(self): + self.dset = CCSchemeDataset.from_json(self.dset_path) + + +class CCMaintenanceUpdateDatasetHandler(AuxiliaryDatasetHandler): + RELATIVE_DIR: ClassVar[str] = "maintenances" + certs_with_updates: Iterable[CCCertificate] + + def __init__( + self, + aux_datasets_dir: str | Path = constants.DUMMY_NONEXISTING_PATH, + certs_with_updates: Iterable[CCCertificate] | None = None, + ) -> None: + super().__init__(aux_datasets_dir) + if certs_with_updates is None: + self.certs_with_updates = [] + else: + self.certs_with_updates = certs_with_updates + + @property + def dset_path(self) -> Path: + return self.root_dir / "maintenance_updates.json" + + def load_dataset(self) -> None: + from sec_certs.dataset.cc import CCDatasetMaintenanceUpdates + + self.dset = CCDatasetMaintenanceUpdates.from_json(self.dset_path) + + @staged(logger, "Processing CC Maintenance updates") + def _process_dataset_body(self, download_fresh: bool = False): + from sec_certs.dataset.cc import CCDatasetMaintenanceUpdates + + if not download_fresh and self.dset_path.exists(): + logger.info("Preparing CCDatasetMaintenanceUpdates from json.") + self.load_dataset() + return + + updates = list( + itertools.chain.from_iterable( + CCMaintenanceUpdate.get_updates_from_cc_cert(x) for x in self.certs_with_updates + ) + ) + self.dset = CCDatasetMaintenanceUpdates( + {x.dgst: x for x in updates}, + root_dir=self.dset_path.parent, + name="maintenance_updates", + ) + self.dset.download_all_artifacts() + self.dset.convert_all_pdfs() + self.dset.extract_data() + self.dset.to_json() + + +class ProtectionProfileDatasetHandler(AuxiliaryDatasetHandler): + RELATIVE_DIR: ClassVar[str] = "protection_profiles" + + def __init__(self, aux_datasets_dir: str | Path = constants.DUMMY_NONEXISTING_PATH): + super().__init__(aux_datasets_dir) + + @property + def dset_path(self) -> Path: + return self.root_dir / "pp.json" + + def load_dataset(self) -> None: + from sec_certs.dataset.protection_profile import ProtectionProfileDataset + + self.dset = ProtectionProfileDataset.from_json(self.dset_path) + + @staged(logger, "Processing Protection profiles") + def _process_dataset_body(self, download_fresh: bool = False): + from sec_certs.dataset.protection_profile import ProtectionProfileDataset + + if not download_fresh and self.dset_path.exists(): + logger.info("Preparing ProtectionProfileDataset from json.") + self.load_dataset() + return + + self.dset_path.parent.mkdir(exist_ok=True, parents=True) + self.dset = ProtectionProfileDataset(root_dir=self.dset_path.parent) + self.dset.get_certs_from_web() + self.dset.download_all_artifacts() + self.dset.convert_all_pdfs() + self.dset.analyze_certificates() diff --git a/src/sec_certs/dataset/cc.py b/src/sec_certs/dataset/cc.py index 568e68ce..b6f4caa5 100644 --- a/src/sec_certs/dataset/cc.py +++ b/src/sec_certs/dataset/cc.py @@ -1,11 +1,8 @@ from __future__ import annotations -import itertools import locale import shutil -import tempfile from collections.abc import Iterator -from dataclasses import dataclass from datetime import datetime from pathlib import Path from typing import ClassVar, cast @@ -13,47 +10,85 @@ from typing import ClassVar, cast import numpy as np import pandas as pd from bs4 import BeautifulSoup, Tag +from pydantic import AnyHttpUrl -import sec_certs.utils.sanitization from sec_certs import constants from sec_certs.configuration import config -from sec_certs.dataset.cc_scheme import CCSchemeDataset -from sec_certs.dataset.cpe import CPEDataset -from sec_certs.dataset.cve import CVEDataset -from sec_certs.dataset.dataset import AuxiliaryDatasets, Dataset, logger -from sec_certs.dataset.protection_profile import ProtectionProfileDataset -from sec_certs.model import ( - ReferenceFinder, - SARTransformer, - TransitiveVulnerabilityFinder, +from sec_certs.dataset.auxiliary_dataset_handling import ( + AuxiliaryDatasetHandler, + CCMaintenanceUpdateDatasetHandler, + CCSchemeDatasetHandler, + CPEDatasetHandler, + CPEMatchDictHandler, + CVEDatasetHandler, + ProtectionProfileDatasetHandler, ) -from sec_certs.model.cc_matching import CCSchemeMatcher +from sec_certs.dataset.dataset import Dataset, logger +from sec_certs.heuristics.cc import ( + compute_cert_labs, + compute_eals, + compute_normalized_cert_ids, + compute_references, + compute_sars, + compute_scheme_data, + link_to_protection_profiles, +) +from sec_certs.heuristics.common import compute_cpe_heuristics, compute_related_cves, compute_transitive_vulnerabilities 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.cc_scheme import EntryType -from sec_certs.sample.protection_profile import ProtectionProfile from sec_certs.serialization.json import ComplexSerializableType, serialize from sec_certs.utils import helpers, sanitization from sec_certs.utils import parallel_processing as cert_processing from sec_certs.utils.profiling import staged -@dataclass -class CCAuxiliaryDatasets(AuxiliaryDatasets): - cpe_dset: CPEDataset | None = None - cve_dset: CVEDataset | None = None - pp_dset: ProtectionProfileDataset | None = None - mu_dset: CCDatasetMaintenanceUpdates | None = None - scheme_dset: CCSchemeDataset | None = None - - -class CCDataset(Dataset[CCCertificate, CCAuxiliaryDatasets], ComplexSerializableType): +class CCDataset(Dataset[CCCertificate], ComplexSerializableType): """ - Class that holds CCCertificate. Serializable into json, pandas, dictionary. Conveys basic certificate manipulations + Class that holds :class:`sec_certs.sample.cc.CCCertificate` samples. + + Serializable into json, pandas, dictionary. Conveys basic certificate manipulations and dataset transformations. Many private methods that perform internal operations, feel free to exploit them. + + The dataset directory looks like this: + + ├── auxiliary_datasets + │ ├── cpe_dataset.json + │ ├── cve_dataset.json + │ ├── cpe_match.json + │ ├── cc_scheme.json + │ ├── protection_profiles + │ │ ├── reports + │ │ │ ├── pdf + │ │ │ └── txt + │ │ ├── pps + │ │ │ ├── pdf + │ │ │ └── txt + │ │ └── pp.json + │ └── maintenances + │ ├── certs + │ │ ├── reports + │ │ │ ├── pdf + │ │ │ └── txt + │ │ └── targets + │ │ ├── pdf + │ │ └── txt + │ └── maintenance_updates.json + ├── certs + │ ├── reports + │ │ ├── pdf + │ │ └── txt + │ ├── targets + │ │ ├── pdf + │ │ └── txt + │ └── certificates + │ ├── pdf + │ └── txt + └── dataset.json """ + FULL_ARCHIVE_URL: ClassVar[AnyHttpUrl] = config.cc_latest_full_archive + SNAPSHOT_URL: ClassVar[AnyHttpUrl] = config.cc_latest_snapshot + def __init__( self, certs: dict[str, CCCertificate] = {}, @@ -61,7 +96,7 @@ class CCDataset(Dataset[CCCertificate, CCAuxiliaryDatasets], ComplexSerializable name: str | None = None, description: str = "", state: Dataset.DatasetInternalState | None = None, - auxiliary_datasets: CCAuxiliaryDatasets | None = None, + aux_handlers: dict[type[AuxiliaryDatasetHandler], AuxiliaryDatasetHandler] = {}, ): self.certs = certs self.timestamp = datetime.now() @@ -69,13 +104,21 @@ class CCDataset(Dataset[CCCertificate, CCAuxiliaryDatasets], ComplexSerializable self.name = name if name else type(self).__name__ + " dataset" self.description = description if description else datetime.now().strftime("%d/%m/%Y %H:%M:%S") self.state = state if state else self.DatasetInternalState() - - self.auxiliary_datasets: CCAuxiliaryDatasets = ( - auxiliary_datasets if auxiliary_datasets else CCAuxiliaryDatasets() - ) - + self.aux_handlers = aux_handlers self.root_dir = Path(root_dir) + if not self.aux_handlers: + self.aux_handlers[CPEDatasetHandler] = CPEDatasetHandler(self.auxiliary_datasets_dir) + self.aux_handlers[CVEDatasetHandler] = CVEDatasetHandler(self.auxiliary_datasets_dir) + self.aux_handlers[CPEMatchDictHandler] = CPEMatchDictHandler(self.auxiliary_datasets_dir) + self.aux_handlers[CCSchemeDatasetHandler] = CCSchemeDatasetHandler(self.auxiliary_datasets_dir) + self.aux_handlers[ProtectionProfileDatasetHandler] = ProtectionProfileDatasetHandler( + self.auxiliary_datasets_dir / "protection_profiles" + ) + self.aux_handlers[CCMaintenanceUpdateDatasetHandler] = CCMaintenanceUpdateDatasetHandler( + self.auxiliary_datasets_dir / "maintenances" + ) + def to_pandas(self) -> pd.DataFrame: """ Return self serialized into pandas DataFrame @@ -173,56 +216,26 @@ class CCDataset(Dataset[CCCertificate, CCAuxiliaryDatasets], ComplexSerializable return self.certificates_dir / "txt" @property - def pp_dataset_path(self) -> Path: - """ - Returns a path to the dataset of Protection Profiles - """ - return self.auxiliary_datasets_dir / "pp_dataset.json" - - @property - def mu_dataset_dir(self) -> Path: - """ - Returns directory that holds dataset of maintenance updates - """ - return self.auxiliary_datasets_dir / "maintenances" - - @property - def mu_dataset_path(self) -> Path: - """ - Returns a path to the dataset of maintenance updates - """ - return self.mu_dataset_dir / "maintenance_updates.json" - - @property def reference_annotator_dir(self) -> Path: return self.root_dir / "reference_annotator" - @property - def scheme_dataset_path(self) -> Path: - """ - Returns a path to the scheme dataset - """ - return self.auxiliary_datasets_dir / "scheme_dataset.json" - - BASE_URL: ClassVar[str] = "https://www.commoncriteriaportal.org" - HTML_PRODUCTS_URL = { - "cc_products_active.html": BASE_URL + "/products/index.cfm", - "cc_products_archived.html": BASE_URL + "/products/index.cfm?archived=1", + "cc_products_active.html": constants.CC_PORTAL_BASE_URL + "/products/index.cfm", + "cc_products_archived.html": constants.CC_PORTAL_BASE_URL + "/products/index.cfm?archived=1", } - HTML_LABS_URL = {"cc_labs.html": BASE_URL + "/labs"} + HTML_LABS_URL = {"cc_labs.html": constants.CC_PORTAL_BASE_URL + "/labs"} CSV_PRODUCTS_URL = { - "cc_products_active.csv": BASE_URL + "/products/certified_products.csv", - "cc_products_archived.csv": BASE_URL + "/products/certified_products-archived.csv", + "cc_products_active.csv": constants.CC_PORTAL_BASE_URL + "/products/certified_products.csv", + "cc_products_archived.csv": constants.CC_PORTAL_BASE_URL + "/products/certified_products-archived.csv", } PP_URL = { - "cc_pp_active.html": BASE_URL + "/pps/", - "cc_pp_collaborative.html": BASE_URL + "/pps/collaborativePP.cfm?cpp=1", - "cc_pp_archived.html": BASE_URL + "/pps/index.cfm?archived=1", + "cc_pp_active.html": constants.CC_PORTAL_BASE_URL + "/pps/", + "cc_pp_collaborative.html": constants.CC_PORTAL_BASE_URL + "/pps/collaborativePP.cfm?cpp=1", + "cc_pp_archived.html": constants.CC_PORTAL_BASE_URL + "/pps/index.cfm?archived=1", } PP_CSV = { - "cc_pp_active.csv": BASE_URL + "/pps/pps.csv", - "cc_pp_archived.csv": BASE_URL + "/pps/pps-archived.csv", + "cc_pp_active.csv": constants.CC_PORTAL_BASE_URL + "/pps/pps.csv", + "cc_pp_archived.csv": constants.CC_PORTAL_BASE_URL + "/pps/pps-archived.csv", } @property @@ -257,46 +270,9 @@ class CCDataset(Dataset[CCCertificate, CCAuxiliaryDatasets], ComplexSerializable """ return [(x, self.web_dir / y) for y, x in self.CSV_PRODUCTS_URL.items() if "archived" in y] - @classmethod - def from_web_latest( - cls, - path: str | Path | None = None, - auxiliary_datasets: bool = False, - artifacts: bool = False, - ) -> CCDataset: - """ - Fetches the fresh snapshot of CCDataset from sec-certs.org. - - Optionally stores it at the given path (a directory) and also downloads auxiliary datasets and artifacts (PDFs). - - .. note:: - Note that including the auxiliary datasets adds several gigabytes and including artifacts adds tens of gigabytes. - - :param path: Path to a directory where to store the dataset, or `None` if it should not be stored. - :param auxiliary_datasets: Whether to also download auxiliary datasets (CVE, CPE, CPEMatch datasets). - :param artifacts: Whether to also download artifacts (i.e. PDFs). - """ - return cls.from_web( - config.cc_latest_full_archive, - config.cc_latest_snapshot, - "Downloading CC", - path, - auxiliary_datasets, - artifacts, - ) - def _set_local_paths(self): super()._set_local_paths() - if self.auxiliary_datasets.pp_dset: - self.auxiliary_datasets.pp_dset.json_path = self.pp_dataset_path - - if self.auxiliary_datasets.mu_dset: - self.auxiliary_datasets.mu_dset.root_dir = self.mu_dataset_dir - - if self.auxiliary_datasets.scheme_dset: - self.auxiliary_datasets.scheme_dset.json_path = self.scheme_dataset_path - for cert in self: cert.set_local_paths( self.reports_pdf_dir, @@ -307,6 +283,23 @@ class CCDataset(Dataset[CCCertificate, CCAuxiliaryDatasets], ComplexSerializable self.certificates_txt_dir, ) + def process_auxiliary_datasets( + self, + download_fresh: bool = False, + skip_schemes: bool = False, + **kwargs, + ) -> None: + if CCMaintenanceUpdateDatasetHandler in self.aux_handlers: + self.aux_handlers[CCMaintenanceUpdateDatasetHandler].certs_with_updates = [ # type: ignore + x for x in self if x.maintenance_updates + ] + if CCSchemeDatasetHandler in self.aux_handlers: + self.aux_handlers[CCSchemeDatasetHandler].only_schemes = {x.scheme for x in self} # type: ignore + + if skip_schemes: + self.aux_handlers[CCSchemeDatasetHandler].only_schemes = {} # type: ignore + super().process_auxiliary_datasets(download_fresh, **kwargs) + def _merge_certs(self, certs: dict[str, CCCertificate], cert_source: str | None = None) -> None: """ Merges dictionary of certificates into the dataset. Assuming they all are CommonCriteria certificates @@ -403,7 +396,7 @@ class CCDataset(Dataset[CCCertificate, CCAuxiliaryDatasets], ComplexSerializable return url tokens = url.split("/") relative_path = "/" + "/".join(tokens[3:]) - return CCDataset.BASE_URL + relative_path + return constants.CC_PORTAL_BASE_URL + relative_path def _get_primary_key_str(row: Tag): return "|".join( @@ -474,13 +467,6 @@ class CCDataset(Dataset[CCCertificate, CCAuxiliaryDatasets], ComplexSerializable df_base = df_base.drop_duplicates(subset=["dgst"]) df_main = df_main.drop_duplicates() - profiles = { - x.dgst: { - ProtectionProfile(pp_name=y, pp_eal=None) - for y in sec_certs.utils.sanitization.sanitize_protection_profiles(x.protection_profiles) - } - for x in df_base.itertuples() - } updates: dict[str, set] = {x.dgst: set() for x in df_base.itertuples()} for x in df_main.itertuples(): updates[x.dgst].add( @@ -506,7 +492,7 @@ class CCDataset(Dataset[CCCertificate, CCAuxiliaryDatasets], ComplexSerializable x.st_link, None, None, - profiles.get(x.dgst, None), + None, updates.get(x.dgst, None), None, None, @@ -551,9 +537,9 @@ class CCDataset(Dataset[CCCertificate, CCAuxiliaryDatasets], ComplexSerializable ) -> dict[str, CCCertificate]: tables = soup.find_all("table", id=table_id) - if not len(tables) <= 1: + if len(tables) > 1: raise ValueError( - f'The "{file.name}" was expected to contain <1 <table> element. Instead, it contains: {len(tables)} <table> elements.' + f'The "{file.name}" was expected to contain 0-1 <table> element. Instead, it contains: {len(tables)} <table> elements.' ) if not tables: @@ -582,40 +568,8 @@ class CCDataset(Dataset[CCCertificate, CCAuxiliaryDatasets], ComplexSerializable cert_status = "active" if "active" in str(file) else "archived" - cc_cat_abbreviations = [ - "AC", - "BP", - "DP", - "DB", - "DD", - "IC", - "KM", - "MD", - "MF", - "NS", - "OS", - "OD", - "DG", - "TC", - ] - cc_table_ids = ["tbl" + x for x in cc_cat_abbreviations] - cc_categories = [ - "Access Control Devices and Systems", - "Boundary Protection Devices and Systems", - "Data Protection", - "Databases", - "Detection Devices and Systems", - "ICs, Smart Cards and Smart Card-Related Devices and Systems", - "Key Management Systems", - "Mobility", - "Multi-Function Devices", - "Network and Network-Related Devices and Systems", - "Operating Systems", - "Other Devices and Systems", - "Products for Digital Signatures", - "Trusted Computing", - ] - cat_dict = dict(zip(cc_table_ids, cc_categories)) + cc_table_ids = ["tbl" + x for x in constants.CC_CAT_ABBREVIATIONS] + cat_dict = dict(zip(cc_table_ids, constants.CC_CATEGORIES)) with file.open("r") as handle: soup = BeautifulSoup(handle, "html5lib") @@ -833,202 +787,25 @@ class CCDataset(Dataset[CCCertificate, CCAuxiliaryDatasets], ComplexSerializable self._extract_pdf_frontpage() self._extract_pdf_keywords() - @staged( - logger, - "Computing heuristics: Deriving information about laboratories involved in certification.", - ) - def _compute_cert_labs(self) -> None: - certs_to_process = [x for x in self if x.state.report.is_ok_to_analyze()] - for cert in certs_to_process: - cert.compute_heuristics_cert_lab() - - @staged( - logger, - "Computing heuristics: Deriving information about certificate ids from artifacts.", - ) - def _compute_normalized_cert_ids(self) -> None: - for cert in self: - cert.compute_heuristics_cert_id() - - @staged( - logger, - "Computing heuristics: Transitive vulnerabilities in referenc(ed/ing) certificates.", - ) - def _compute_transitive_vulnerabilities(self): - transitive_cve_finder = TransitiveVulnerabilityFinder(lambda cert: cert.heuristics.cert_id) - transitive_cve_finder.fit(self.certs, lambda cert: cert.heuristics.report_references) - - for dgst in self.certs: - transitive_cve = transitive_cve_finder.predict_single_cert(dgst) - - self.certs[dgst].heuristics.direct_transitive_cves = transitive_cve.direct_transitive_cves - self.certs[dgst].heuristics.indirect_transitive_cves = transitive_cve.indirect_transitive_cves - - @staged(logger, "Computing heuristics: Matching scheme data.") - def _compute_scheme_data(self): - if self.auxiliary_datasets.scheme_dset: - for scheme in self.auxiliary_datasets.scheme_dset: - if certified := scheme.lists.get(EntryType.Certified): - certs = [cert for cert in self if cert.status == "active"] - matches, scores = CCSchemeMatcher.match_all(certified, scheme.country, certs) - for dgst, match in matches.items(): - self[dgst].heuristics.scheme_data = match - if archived := scheme.lists.get(EntryType.Archived): - certs = [cert for cert in self if cert.status == "archived"] - matches, scores = CCSchemeMatcher.match_all(archived, scheme.country, certs) - for dgst, match in matches.items(): - self[dgst].heuristics.scheme_data = match - - @staged(logger, "Computing heuristics: SARs") - def _compute_sars(self) -> None: - transformer = SARTransformer().fit(self.certs.values()) - for cert in self: - cert.heuristics.extracted_sars = transformer.transform_single_cert(cert) - - @staged(logger, "Computing heuristics: certificate versions") - def _compute_cert_versions(self) -> None: - cert_ids = { - cert.dgst: CertificateId(cert.scheme, cert.heuristics.cert_id) - if cert.heuristics.cert_id is not None - else None - for cert in self - } - for cert in self: - cert.compute_heuristics_cert_versions(cert_ids) - - def _compute_heuristics(self) -> None: - self._compute_normalized_cert_ids() - super()._compute_heuristics() - self._compute_scheme_data() - self._compute_cert_versions() - self._compute_cert_labs() - self._compute_sars() - - @staged(logger, "Computing heuristics: references between certificates.") - def _compute_references(self) -> None: - def ref_lookup(kw_attr): - def func(cert): - kws = getattr(cert.pdf_data, kw_attr) - if not kws: - return set() - res = set() - for scheme, matches in kws["cc_cert_id"].items(): - for match in matches: - try: - canonical = CertificateId(scheme, match).canonical - res.add(canonical) - except Exception: - res.add(match) - return res - - return func - - for ref_source in ("report", "st"): - kw_source = f"{ref_source}_keywords" - dep_attr = f"{ref_source}_references" - - finder = ReferenceFinder() - finder.fit(self.certs, lambda cert: cert.heuristics.cert_id, ref_lookup(kw_source)) # type: ignore - - for dgst in self.certs: - setattr( - self.certs[dgst].heuristics, - dep_attr, - finder.predict_single_cert(dgst, keep_unknowns=False), - ) - - @serialize - def process_auxiliary_datasets(self, download_fresh: bool = False) -> None: - """ - Processes all auxiliary datasets needed during computation. On top of base-class processing, - CC handles protection profiles, maintenance updates and schemes. - """ - super().process_auxiliary_datasets(download_fresh) - self.auxiliary_datasets.pp_dset = self.process_protection_profiles(to_download=download_fresh) - self.auxiliary_datasets.mu_dset = self.process_maintenance_updates(to_download=download_fresh) - self.auxiliary_datasets.scheme_dset = self.process_schemes( - to_download=download_fresh, only_schemes={cert.scheme for cert in self} + def _compute_heuristics_body(self, skip_schemes: bool = False) -> None: + link_to_protection_profiles(self.certs.values(), self.aux_handlers[ProtectionProfileDatasetHandler].dset) + compute_cpe_heuristics(self.aux_handlers[CPEDatasetHandler].dset, self.certs.values()) + compute_related_cves( + self.aux_handlers[CPEDatasetHandler].dset, + self.aux_handlers[CVEDatasetHandler].dset, + self.aux_handlers[CPEMatchDictHandler].dset, + self.certs.values(), ) + compute_normalized_cert_ids(self.certs.values()) + compute_references(self.certs) + compute_transitive_vulnerabilities(self.certs) - @staged(logger, "Processing protection profiles.") - def process_protection_profiles( - self, to_download: bool = True, keep_metadata: bool = True - ) -> ProtectionProfileDataset: - """ - Downloads new snapshot of dataset with processed protection profiles (if it doesn't exist) and links PPs - with certificates within self. Assigns PPs to all certificates, based on name and fname match. - - :param bool to_download: If dataset should be downloaded or fetched from json, defaults to True - :param bool keep_metadata: If json related to the PP dataset should be kept on drive, defaults to True - :raises RuntimeError: When building of PPDataset fails - """ - - self.auxiliary_datasets_dir.mkdir(parents=True, exist_ok=True) + if not skip_schemes: + compute_scheme_data(self.aux_handlers[CCSchemeDatasetHandler].dset, self.certs) - if to_download or not self.pp_dataset_path.exists(): - pp_dataset = ProtectionProfileDataset.from_web(self.pp_dataset_path) - else: - pp_dataset = ProtectionProfileDataset.from_json(self.pp_dataset_path) - - # Map protection profiles to their name and file name for matching to certs. - pps = {(pp.pp_name, sanitization.sanitize_link_fname(pp.pp_link)): pp for pp in pp_dataset} - - for cert in self: - if cert.protection_profiles is None: - raise RuntimeError("Building of the dataset probably failed - this should not be happening.") - cert.protection_profiles = { - pps.get((x.pp_name, sanitization.sanitize_link_fname(x.pp_link)), x) for x in cert.protection_profiles - } - - if not keep_metadata: - self.pp_dataset_path.unlink() - - return pp_dataset - - @staged(logger, "Processing maintenace updates.") - def process_maintenance_updates(self, to_download: bool = True) -> CCDatasetMaintenanceUpdates: - """ - Downloads or loads from json a dataset of maintenance updates. Runs analysis on that dataset if it's not completed. - :return CCDatasetMaintenanceUpdates: the resulting dataset of maintenance updates - """ - self.mu_dataset_dir.mkdir(parents=True, exist_ok=True) - - if to_download or not self.mu_dataset_path.exists(): - maintained_certs: list[CCCertificate] = [x for x in self if x.maintenance_updates] - updates = list( - itertools.chain.from_iterable(CCMaintenanceUpdate.get_updates_from_cc_cert(x) for x in maintained_certs) - ) - update_dset = CCDatasetMaintenanceUpdates( - {x.dgst: x for x in updates}, - root_dir=self.mu_dataset_dir, - name="maintenance_updates", - ) - else: - update_dset = CCDatasetMaintenanceUpdates.from_json(self.mu_dataset_path) - - if not update_dset.state.artifacts_downloaded: - update_dset.download_all_artifacts() - if not update_dset.state.pdfs_converted: - update_dset.convert_all_pdfs() - if not update_dset.state.certs_analyzed: - update_dset.extract_data() - - return update_dset - - @staged(logger, "Processing CC scheme dataset.") - def process_schemes(self, to_download: bool = True, only_schemes: set[str] | None = None) -> CCSchemeDataset: - """ - Downloads or loads from json a dataset of CC scheme data. - """ - self.auxiliary_datasets_dir.mkdir(parents=True, exist_ok=True) - - if to_download or not self.scheme_dataset_path.exists(): - scheme_dset = CCSchemeDataset.from_web(only_schemes) - scheme_dset.to_json(self.scheme_dataset_path) - else: - scheme_dset = CCSchemeDataset.from_json(self.scheme_dataset_path) - - return scheme_dset + compute_cert_labs(self.certs.values()) + compute_eals(self.certs.values(), self.aux_handlers[ProtectionProfileDatasetHandler].dset) + compute_sars(self.certs.values()) class CCDatasetMaintenanceUpdates(CCDataset, ComplexSerializableType): @@ -1037,6 +814,9 @@ class CCDatasetMaintenanceUpdates(CCDataset, ComplexSerializableType): Should be used merely for actions related to Maintenance updates: download pdfs, convert pdfs, extract data from pdfs """ + FULL_ARCHIVE_URL: ClassVar[AnyHttpUrl] = config.cc_maintenances_latest_full_archive + SNAPSHOT_URL: ClassVar[AnyHttpUrl] = config.cc_maintenances_latest_snapshot + # Quite difficult to achieve correct behaviour with MyPy here, opting for ignore def __init__( self, @@ -1047,6 +827,7 @@ class CCDatasetMaintenanceUpdates(CCDataset, ComplexSerializableType): state: CCDataset.DatasetInternalState | None = None, ): super().__init__(certs, root_dir, name, description, state) # type: ignore + self.aux_handlers = {} self.state.meta_sources_parsed = True @property @@ -1056,13 +837,18 @@ class CCDatasetMaintenanceUpdates(CCDataset, ComplexSerializableType): def __iter__(self) -> Iterator[CCMaintenanceUpdate]: yield from self.certs.values() # type: ignore - def _compute_heuristics(self) -> None: + def _compute_heuristics_body(self, skip_schemes: bool = False) -> None: raise NotImplementedError def compute_related_cves(self) -> None: raise NotImplementedError - def process_auxiliary_datasets(self, download_fresh: bool = False) -> None: + def process_auxiliary_datasets( + self, + download_fresh: bool = False, + skip_schemes: bool = False, + **kwargs, + ) -> None: raise NotImplementedError def analyze_certificates(self) -> None: @@ -1097,31 +883,6 @@ class CCDatasetMaintenanceUpdates(CCDataset, ComplexSerializableType): df.maintenance_date = pd.to_datetime(df.maintenance_date, errors="coerce") return df.fillna(value=np.nan) - @classmethod - def from_web_latest( - cls, - path: str | Path | None = None, - auxiliary_datasets: bool = False, - artifacts: bool = False, - ) -> CCDatasetMaintenanceUpdates: - if auxiliary_datasets or artifacts: - raise ValueError( - "Maintenance update dataset does not support downloading artifacts or other auxiliary datasets." - ) - if path: - path = Path(path) - if not path.exists(): - path.mkdir(parents=True) - if not path.is_dir(): - raise ValueError("Path needs to be a directory.") - with tempfile.TemporaryDirectory() as tmp_dir: - dset_path = Path(tmp_dir) / "maintenance_updates.json" - helpers.download_file(config.cc_maintenances_latest_snapshot, dset_path) - dset = cls.from_json(dset_path) - if path: - dset.move_dataset(path) - return dset - def get_n_maintenances_df(self) -> pd.DataFrame: """ Returns a DataFrame with CCCertificate digest as an index, and number of registered maintenances as a value diff --git a/src/sec_certs/dataset/cc_scheme.py b/src/sec_certs/dataset/cc_scheme.py index 19f1a3de..e7f4433e 100644 --- a/src/sec_certs/dataset/cc_scheme.py +++ b/src/sec_certs/dataset/cc_scheme.py @@ -50,7 +50,11 @@ class CCSchemeDataset(JSONPathDataset, ComplexSerializableType): @classmethod def from_web( - cls, only_schemes: set[str] | None = None, enhanced: bool | None = None, artifacts: bool | None = None + cls, + json_path: str | Path = constants.DUMMY_NONEXISTING_PATH, + only_schemes: set[str] | None = None, + enhanced: bool | None = None, + artifacts: bool | None = None, ) -> CCSchemeDataset: schemes = {} for scheme, sources in CCScheme.methods.items(): @@ -60,4 +64,4 @@ class CCSchemeDataset(JSONPathDataset, ComplexSerializableType): schemes[scheme] = CCScheme.from_web(scheme, sources.keys(), enhanced=enhanced, artifacts=artifacts) except Exception as e: logger.warning(f"Could not download CC scheme: {scheme} due to error {e}.") - return cls(schemes) + return cls(schemes, json_path=json_path) diff --git a/src/sec_certs/dataset/cpe.py b/src/sec_certs/dataset/cpe.py index efcb3ded..c93ddd71 100644 --- a/src/sec_certs/dataset/cpe.py +++ b/src/sec_certs/dataset/cpe.py @@ -9,8 +9,8 @@ from typing import Any import pandas as pd -import sec_certs.configuration as config_module from sec_certs import constants +from sec_certs.configuration import config from sec_certs.dataset.json_path_dataset import JSONPathDataset from sec_certs.sample.cpe import CPE from sec_certs.serialization.json import ComplexSerializableType @@ -19,6 +19,10 @@ from sec_certs.utils import helpers logger = logging.getLogger(__name__) +class CPEMatchDict(dict): + pass + + class CPEDataset(JSONPathDataset, ComplexSerializableType): """ Dataset of CPE records. Includes look-up dictionaries for fast search. @@ -78,13 +82,13 @@ class CPEDataset(JSONPathDataset, ComplexSerializableType): dset_path = Path(tmp_dir) / "cpe_dataset.json.gz" if ( not helpers.download_file( - config_module.config.cpe_latest_snapshot, + 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}.") + raise RuntimeError(f"Could not download CPEDataset from {config.cpe_latest_snapshot}.") dset = cls.from_json(dset_path, is_compressed=True) dset.json_path = json_path diff --git a/src/sec_certs/dataset/dataset.py b/src/sec_certs/dataset/dataset.py index 8caeafcf..7a9c92ed 100644 --- a/src/sec_certs/dataset/dataset.py +++ b/src/sec_certs/dataset/dataset.py @@ -1,10 +1,6 @@ from __future__ import annotations -import gzip -import itertools -import json import logging -import re import shutil import tarfile import tempfile @@ -13,51 +9,37 @@ from collections.abc import Iterator from dataclasses import dataclass from datetime import datetime from pathlib import Path -from typing import Any, Generic, TypeVar, cast +from typing import Any, ClassVar, Generic, TypeVar, cast import pandas as pd +from pydantic import AnyHttpUrl from sec_certs import constants -from sec_certs.configuration import config -from sec_certs.dataset.cpe import CPEDataset -from sec_certs.dataset.cve import CVEDataset -from sec_certs.model.cpe_matching import CPEClassifier +from sec_certs.dataset.auxiliary_dataset_handling import AuxiliaryDatasetHandler 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.profiling import staged -from sec_certs.utils.tqdm import tqdm logger = logging.getLogger(__name__) - -@dataclass -class AuxiliaryDatasets: - cpe_dset: CPEDataset | None = None - cve_dset: CVEDataset | None = None - - CertSubType = TypeVar("CertSubType", bound=Certificate) -AuxiliaryDatasetsSubType = TypeVar("AuxiliaryDatasetsSubType", bound=AuxiliaryDatasets) DatasetSubType = TypeVar("DatasetSubType", bound="Dataset") -class Dataset(Generic[CertSubType, AuxiliaryDatasetsSubType], ComplexSerializableType, ABC): +class Dataset(Generic[CertSubType], ComplexSerializableType, ABC): """ Base class for dataset of certificates from CC and FIPS 140 schemes. Layouts public functions, the processing pipeline and common operations on the dataset and certs. """ + FULL_ARCHIVE_URL: ClassVar[AnyHttpUrl] + SNAPSHOT_URL: ClassVar[AnyHttpUrl] + @dataclass class DatasetInternalState(ComplexSerializableType): meta_sources_parsed: bool = False @@ -73,7 +55,7 @@ class Dataset(Generic[CertSubType, AuxiliaryDatasetsSubType], ComplexSerializabl name: str | None = None, description: str = "", state: DatasetInternalState | None = None, - auxiliary_datasets: AuxiliaryDatasetsSubType | None = None, + aux_handlers: dict[type[AuxiliaryDatasetHandler], AuxiliaryDatasetHandler] = {}, ): self.certs = certs @@ -82,12 +64,7 @@ class Dataset(Generic[CertSubType, AuxiliaryDatasetsSubType], ComplexSerializabl self.name = name if name else type(self).__name__.lower() + "_dataset" self.description = description if description else "No description provided" self.state = state if state else self.DatasetInternalState() - - if not auxiliary_datasets: - self.auxiliary_datasets = AuxiliaryDatasets() - else: - self.auxiliary_datasets = auxiliary_datasets - + self.aux_handlers = aux_handlers self.root_dir = Path(root_dir) @property @@ -98,7 +75,7 @@ class Dataset(Generic[CertSubType, AuxiliaryDatasetsSubType], ComplexSerializabl return self._root_dir @root_dir.setter - def root_dir(self: DatasetSubType, new_dir: str | Path) -> None: + def root_dir(self, new_dir: str | Path) -> None: """ This setter will only set the root dir and all internal paths so that they point to the new root dir. No data is being moved around. @@ -132,22 +109,6 @@ class Dataset(Generic[CertSubType, AuxiliaryDatasetsSubType], ComplexSerializabl return self.root_dir / "certs" @property - def cpe_dataset_path(self) -> Path: - 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" - - @property - def nist_cve_cpe_matching_dset_path(self) -> Path: - return self.auxiliary_datasets_dir / "nvdcpematch-1.0.json" - - @property def json_path(self) -> Path: return self.root_dir / (self.name + ".json") @@ -181,9 +142,9 @@ class Dataset(Generic[CertSubType, AuxiliaryDatasetsSubType], ComplexSerializabl @classmethod def from_web( # noqa cls: type[DatasetSubType], - archive_url: str, - snapshot_url: str, - progress_bar_desc: str, + archive_url: AnyHttpUrl | None = None, + snapshot_url: AnyHttpUrl | None = None, + progress_bar_desc: str | None = None, path: None | str | Path = None, auxiliary_datasets: bool = False, artifacts: bool = False, @@ -196,13 +157,20 @@ class Dataset(Generic[CertSubType, AuxiliaryDatasetsSubType], ComplexSerializabl .. note:: Note that including the auxiliary datasets adds several gigabytes and including artifacts adds tens of gigabytes. - :param archive_url: The URL of the full dataset archive. - :param snapshot_url: The URL of the full dataset snapshot. - :param progress_bar_desc: Description of the download progress bar. + :param archive_url: The URL of the full dataset archive. If `None` provided, defaults to `cls.FULL_ARCHIVE_URL`. + :param snapshot_url: The URL of the full dataset snapshot. If `None` provided, defaults to `cls.SNAPSHOT_URL`. + :param progress_bar_desc: Description of the download progress bar. If `None`, will pick reasonable default. :param path: Path to a directory where to store the dataset, or `None` if it should not be stored. :param auxiliary_datasets: Whether to also download auxiliary datasets (CVE, CPE, CPEMatch datasets). :param artifacts: Whether to also download artifacts (i.e. PDFs). """ + if not archive_url: + archive_url = cls.FULL_ARCHIVE_URL + if not snapshot_url: + snapshot_url = cls.SNAPSHOT_URL + if not progress_bar_desc: + progress_bar_desc = f"Downloading: {type(cls).__name__}" + if (artifacts or auxiliary_datasets) and path is None: raise ValueError("Path needs to be defined if artifacts or auxiliary datasets are to be downloaded.") if artifacts and not auxiliary_datasets: @@ -259,7 +227,7 @@ class Dataset(Generic[CertSubType, AuxiliaryDatasetsSubType], ComplexSerializabl } @classmethod - def from_dict(cls: type[DatasetSubType], dct: dict) -> DatasetSubType: + def from_dict(cls, dct: dict) -> Dataset: certs = {x.dgst: x for x in dct["certs"]} dset = cls(certs, name=dct["name"], description=dct["description"], state=dct["state"]) if len(dset) != (claimed := dct["n_certs"]): @@ -279,14 +247,13 @@ class Dataset(Generic[CertSubType, AuxiliaryDatasetsSubType], ComplexSerializabl return dset def _set_local_paths(self) -> None: - if self.auxiliary_datasets.cpe_dset: - self.auxiliary_datasets.cpe_dset.json_path = self.cpe_dataset_path - if self.auxiliary_datasets.cve_dset: - self.auxiliary_datasets.cve_dset.json_path = self.cve_dataset_path + for handler in self.aux_handlers.values(): + handler.set_local_paths(self.auxiliary_datasets_dir) def move_dataset(self, new_root_dir: str | Path) -> None: """ Moves all dataset files to `new_root_dir` and adjusts all paths internally. Deletes the artifacts from the original location. + :param str | Path new_root_dir: path to directory where the new dataset shall be stored. """ new_root_dir = Path(new_root_dir) @@ -301,6 +268,7 @@ class Dataset(Generic[CertSubType, AuxiliaryDatasetsSubType], ComplexSerializabl def copy_dataset(self, new_root_dir: str | Path) -> None: """ Copies all dataset files to `new_root_dir` and adjusts all paths internally. Keeps the artifacts from the original location. + :param str | Path new_root_dir: path to directory where the new dataset shall be stored. """ new_root_dir = Path(new_root_dir) @@ -311,7 +279,7 @@ class Dataset(Generic[CertSubType, AuxiliaryDatasetsSubType], ComplexSerializabl shutil.copytree(str(self.root_dir), str(new_root_dir), dirs_exist_ok=True) self.root_dir = new_root_dir - def _get_certs_by_name(self, name: str) -> set[CertSubType]: + def get_certs_by_name(self, name: str) -> set[CertSubType]: """ Returns list of certificates that match given name. """ @@ -321,22 +289,28 @@ class Dataset(Generic[CertSubType, AuxiliaryDatasetsSubType], ComplexSerializabl def get_certs_from_web(self) -> None: raise NotImplementedError("Not meant to be implemented by the base class.") + @staged(logger, "Processing auxiliary datasets") @serialize - @abstractmethod - def process_auxiliary_datasets(self, download_fresh: bool = False) -> None: + def process_auxiliary_datasets(self, download_fresh: bool = False, **kwargs) -> None: """ Processes all auxiliary datasets (CPE, CVE, ...) that are required during computation. """ 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) - - if download_fresh or not self.cpe_match_json_path.exists(): - self._prepare_cpe_match_dict(download_fresh=download_fresh) - + for handler in self.aux_handlers.values(): + handler.process_dataset(download_fresh) self.state.auxiliary_datasets_processed = True + def load_auxiliary_datasets(self) -> None: + logger.info("Loading auxiliary datasets into memory.") + for handler in self.aux_handlers.values(): + if not hasattr(handler, "dset"): + try: + handler.load_dataset() + except Exception: + logger.warning( + f"Failed to load auxiliary dataset bound to {handler}, some functionality may not work." + ) + @serialize def download_all_artifacts(self, fresh: bool = True) -> None: """ @@ -397,307 +371,24 @@ class Dataset(Generic[CertSubType, AuxiliaryDatasetsSubType], ComplexSerializabl self.state.certs_analyzed = True def _analyze_certificates_body(self) -> None: + logger.info("Extracting data and heuristics") self.extract_data() - self._compute_heuristics() + self.compute_heuristics() @abstractmethod def extract_data(self) -> None: raise NotImplementedError("Not meant to be implemented by the base class.") - def _compute_heuristics(self) -> None: + @serialize + def compute_heuristics(self) -> None: logger.info("Computing various heuristics from the certificates.") - self.compute_cpe_heuristics() - self.compute_related_cves() - self._compute_references() - self._compute_transitive_vulnerabilities() + self.load_auxiliary_datasets() + self._compute_heuristics_body() @abstractmethod - def _compute_references(self) -> None: + def _compute_heuristics_body(self) -> None: raise NotImplementedError("Not meant to be implemented by the base class.") - @abstractmethod - def _compute_transitive_vulnerabilities(self) -> None: - raise NotImplementedError("Not meant to be implemented by the base class.") - - @staged(logger, "Processing CPEDataset.") - 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 self.cpe_dataset_path.exists(): - logger.info("Preparing CPEDataset from json.") - cpe_dataset = CPEDataset.from_json(self.cpe_dataset_path) - else: - 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) - cpe_dataset.to_json() - else: - logger.info("Preparing CPEDataset from sec-certs.org.") - cpe_dataset = CPEDataset.from_web(self.cpe_dataset_path) - - return cpe_dataset - - @staged(logger, "Processing CVEDataset.") - 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 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) - cve_dataset.to_json() - else: - logger.info("Preparing CVEDataset from sec-certs.org") - cve_dataset = CVEDataset.from_web(self.cve_dataset_path) - - return cve_dataset - - @staged(logger, "Processing CPE match dict.") - 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("Fetching 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 sec-certs.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 - @staged(logger, "Computing heuristics: Finding CPE matches for certificates") - 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", - ), - CPE( - "", - "cpe:2.3:o:microsoft:windows:-:*:*:*:*:*:x86:*", - "Microsoft Windows on X86", - ), - } - - def filter_condition(cpe: CPE) -> bool: - """ - Filters out very weak CPE matches that don't improve our database. - """ - if ( - cpe.title - and (cpe.version == "-" or cpe.version == "*") - and not any(char.isdigit() for char in cpe.title) - ): - return False - if ( - not cpe.title - and cpe.item_name - and (cpe.version == "-" or cpe.version == "*") - and not any(char.isdigit() for char in cpe.item_name) - ): - return False - if re.match(constants.RELEASE_CANDIDATE_REGEX, cpe.update): - return False - return cpe not in WINDOWS_WEAK_CPES - - 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) - - if self.auxiliary_datasets.cpe_dset is None: - raise ValueError("CPE dataset cannot be None") - - clf.fit([x for x in self.auxiliary_datasets.cpe_dset if filter_condition(x)]) - - cert: CertSubType - for cert in tqdm(self, desc="Predicting CPE matches with the classifier"): - cert.compute_heuristics_version() - - cert.heuristics.cpe_matches = ( - clf.predict_single_cert(cert.manufacturer, cert.name, cert.heuristics.extracted_versions) - if cert.name - else None - ) - - 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 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)) - options = ["option_" + str(x) for x in range(1, config.cpe_n_max_matches)] - dct.update(dict(zip(options, candidates))) - lst.append(dct) - - with Path(output_path).open("w") as handle: - json.dump(lst, handle, indent=4) - - @serialize - def load_label_studio_labels(self, input_path: str | Path) -> set[str]: - with Path(input_path).open("r") as handle: - 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.") - for annotation in tqdm(data, desc="Translating label studio matches"): - cpe_candidate_keys = {key for key in annotation if "option_" in key and annotation[key] != "No good match"} - - if "verified_cpe_match" not in annotation: - incorrect_keys: set[str] = set() - elif isinstance(annotation["verified_cpe_match"], str): - incorrect_keys = {annotation["verified_cpe_match"]} - else: - incorrect_keys = set(annotation["verified_cpe_match"]["choices"]) - - incorrect_keys = {x.lstrip("$") for x in incorrect_keys} - predicted_annotations = {annotation[x] for x in cpe_candidate_keys - incorrect_keys} - - cpes: set[CPE] = set() - for x in predicted_annotations: - if x not in title_to_cpes_dict: - logger.error(f"{x} not in dataset") - else: - to_update = title_to_cpes_dict[x] - if to_update and not cpes: - cpes = to_update - elif to_update and cpes: - cpes.update(to_update) - - # distinguish between FIPS and CC - if "\n" in annotation["text"]: - cert_name = annotation["text"].split("\nModule name: ")[1].split("\n")[0] - else: - cert_name = annotation["text"] - - certs = self._get_certs_by_name(cert_name) - labeled_cert_digests.update({x.dgst for x in certs}) - - for c in certs: - c.heuristics.verified_cpe_matches = {x.uri for x in cpes if x is not None} if cpes else None - - return labeled_cert_digests - - def enrich_automated_cpes_with_manual_labels(self) -> None: - """ - Prior to CVE matching, it is wise to expand the database of automatic CPE matches with those that were manually assigned. - """ - for cert in cast(Iterator[Certificate], self): - if not cert.heuristics.cpe_matches and cert.heuristics.verified_cpe_matches: - cert.heuristics.cpe_matches = cert.heuristics.verified_cpe_matches - elif cert.heuristics.cpe_matches and cert.heuristics.verified_cpe_matches: - cert.heuristics.cpe_matches = set(cert.heuristics.cpe_matches).union( - 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 - @staged(logger, "Computing heuristics: CVEs in certificates.") - def compute_related_cves(self) -> None: - """ - Computes CVEs for the certificates, given their CPE matches. - """ - - 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 self.auxiliary_datasets.cve_dset is None: - raise ValueError("CVE dataset cannot be None") - - 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] - - if not cpe_rich_certs: - logger.error( - "No certificates with verified CPE match detected. You must run dset.manually_verify_cpe_matches() first. Returning." - ) - return - - cert: Certificate - for cert in tqdm(cpe_rich_certs, desc="Computing related CVES"): - 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]) - n_vulnerabilities = sum([len(x.heuristics.related_cves) for x in cpe_rich_certs if x.heuristics.related_cves]) - logger.info( - f"In total, we identified {n_vulnerabilities} vulnerabilities in {n_vulnerable} vulnerable certificates." - ) - def get_keywords_df(self, var: str) -> pd.DataFrame: """ Get dataframe of keyword hits for attribute (var) that is member of PdfData class. diff --git a/src/sec_certs/dataset/fips.py b/src/sec_certs/dataset/fips.py index 0feb920f..34b1147d 100644 --- a/src/sec_certs/dataset/fips.py +++ b/src/sec_certs/dataset/fips.py @@ -5,22 +5,25 @@ import itertools import logging import shutil from pathlib import Path -from typing import Final +from typing import ClassVar, Final import numpy as np import pandas as pd from bs4 import BeautifulSoup, NavigableString +from pydantic import AnyHttpUrl from sec_certs import constants from sec_certs.configuration import config -from sec_certs.dataset.cpe import CPEDataset -from sec_certs.dataset.cve import CVEDataset -from sec_certs.dataset.dataset import AuxiliaryDatasets, Dataset -from sec_certs.dataset.fips_algorithm import FIPSAlgorithmDataset -from sec_certs.model.reference_finder import ReferenceFinder -from sec_certs.model.transitive_vulnerability_finder import ( - TransitiveVulnerabilityFinder, +from sec_certs.dataset.auxiliary_dataset_handling import ( + AuxiliaryDatasetHandler, + CPEDatasetHandler, + CPEMatchDictHandler, + CVEDatasetHandler, + FIPSAlgorithmDatasetHandler, ) +from sec_certs.dataset.dataset import Dataset +from sec_certs.heuristics.common import compute_cpe_heuristics, compute_related_cves, compute_transitive_vulnerabilities +from sec_certs.heuristics.fips import compute_references from sec_certs.sample.fips import FIPSCertificate from sec_certs.serialization.json import ComplexSerializableType, serialize from sec_certs.utils import helpers @@ -31,17 +34,29 @@ from sec_certs.utils.profiling import staged logger = logging.getLogger(__name__) -class FIPSAuxiliaryDatasets(AuxiliaryDatasets): - cpe_dset: CPEDataset | None = None - cve_dset: CVEDataset | None = None - algorithm_dset: FIPSAlgorithmDataset | None = None +class FIPSDataset(Dataset[FIPSCertificate], ComplexSerializableType): + """ + Class for processing of :class:`sec_certs.sample.fips.FIPSCertificate` samples. + Inherits from `ComplexSerializableType` and base abstract `Dataset` class. -class FIPSDataset(Dataset[FIPSCertificate, FIPSAuxiliaryDatasets], ComplexSerializableType): - """ - Class for processing of FIPSCertificate samples. Inherits from `ComplexSerializableType` and base abstract `Dataset` class. + The dataset directory looks like this: + + ├── auxiliary_datasets + │ ├── cpe_dataset.json + │ ├── cve_dataset.json + │ ├── cpe_match.json + │ └── algorithms.json + ├── certs + │ └── targets + │ ├── pdf + │ └── txt + └── dataset.json """ + FULL_ARCHIVE_URL: ClassVar[AnyHttpUrl] = config.fips_latest_full_archive + SNAPSHOT_URL: ClassVar[AnyHttpUrl] = config.fips_latest_snapshot + def __init__( self, certs: dict[str, FIPSCertificate] = {}, @@ -49,7 +64,7 @@ class FIPSDataset(Dataset[FIPSCertificate, FIPSAuxiliaryDatasets], ComplexSerial name: str | None = None, description: str = "", state: Dataset.DatasetInternalState | None = None, - auxiliary_datasets: FIPSAuxiliaryDatasets | None = None, + aux_handlers: dict[type[AuxiliaryDatasetHandler], AuxiliaryDatasetHandler] = {}, ): self.certs = certs self.timestamp = datetime.datetime.now() @@ -57,12 +72,15 @@ class FIPSDataset(Dataset[FIPSCertificate, FIPSAuxiliaryDatasets], ComplexSerial self.name = name if name else type(self).__name__ + " dataset" self.description = description if description else datetime.datetime.now().strftime("%d/%m/%Y %H:%M:%S") self.state = state if state else self.DatasetInternalState() - self.auxiliary_datasets: FIPSAuxiliaryDatasets = ( - auxiliary_datasets if auxiliary_datasets else FIPSAuxiliaryDatasets() - ) - + self.aux_handlers = aux_handlers self.root_dir = Path(root_dir) + if not self.aux_handlers: + self.aux_handlers[CPEDatasetHandler] = CPEDatasetHandler(self.auxiliary_datasets_dir) + self.aux_handlers[CVEDatasetHandler] = CVEDatasetHandler(self.auxiliary_datasets_dir) + self.aux_handlers[FIPSAlgorithmDatasetHandler] = FIPSAlgorithmDatasetHandler(self.auxiliary_datasets_dir) + self.aux_handlers[CPEMatchDictHandler] = CPEMatchDictHandler(self.auxiliary_datasets_dir) + LIST_OF_CERTS_HTML: Final[dict[str, str]] = { "fips_modules_active.html": constants.FIPS_ACTIVE_MODULES_URL, "fips_modules_historical.html": constants.FIPS_HISTORICAL_MODULES_URL, @@ -85,10 +103,6 @@ class FIPSDataset(Dataset[FIPSCertificate, FIPSAuxiliaryDatasets], ComplexSerial def module_dir(self) -> Path: return self.certs_dir / "modules" - @property - def algorithm_dataset_path(self) -> Path: - return self.auxiliary_datasets_dir / "algorithms.json" - def __getitem__(self, item: str) -> FIPSCertificate: try: return super().__getitem__(item) @@ -110,6 +124,17 @@ class FIPSDataset(Dataset[FIPSCertificate, FIPSAuxiliaryDatasets], ComplexSerial ) self.update_with_certs(processed_certs) + def _compute_heuristics_body(self): + compute_cpe_heuristics(self.aux_handlers[CPEDatasetHandler].dset, self.certs.values()) + compute_related_cves( + self.aux_handlers[CPEDatasetHandler].dset, + self.aux_handlers[CVEDatasetHandler].dset, + self.aux_handlers[CPEMatchDictHandler].dset, + self.certs.values(), + ) + compute_references(self.certs) + compute_transitive_vulnerabilities(self.certs) + @serialize def extract_data(self) -> None: logger.info("Extracting various data from certification artifacts.") @@ -216,41 +241,9 @@ class FIPSDataset(Dataset[FIPSCertificate, FIPSAuxiliaryDatasets], ComplexSerial return [FIPSCertificate(int(cert_id)) for cert_id in cert_ids] - @classmethod - def from_web_latest( - cls, - path: str | Path | None = None, - auxiliary_datasets: bool = False, - artifacts: bool = False, - ) -> FIPSDataset: - """ - Fetches the fresh snapshot of FIPSDataset from sec-certs.org. - - Optionally stores it at the given path (a directory) and also downloads auxiliary datasets and artifacts (PDFs). - - .. note:: - Note that including the auxiliary datasets adds several gigabytes and including artifacts adds tens of gigabytes. - - :param path: Path to a directory where to store the dataset, or `None` if it should not be stored. - :param auxiliary_datasets: Whether to also download auxiliary datasets (CVE, CPE, CPEMatch datasets). - :param artifacts: Whether to also download artifacts (i.e. PDFs). - """ - return cls.from_web( - config.fips_latest_full_archive, - config.fips_latest_snapshot, - "Downloading FIPS", - path, - auxiliary_datasets, - artifacts, - ) - def _set_local_paths(self) -> None: super()._set_local_paths() - if self.auxiliary_datasets.algorithm_dset: - self.auxiliary_datasets.algorithm_dset.json_path = self.algorithm_dataset_path - - cert: FIPSCertificate - for cert in self.certs.values(): + for cert in self: cert.set_local_paths(self.policies_pdf_dir, self.policies_txt_dir, self.module_dir) @serialize @@ -270,21 +263,6 @@ class FIPSDataset(Dataset[FIPSCertificate, FIPSAuxiliaryDatasets], ComplexSerial self._set_local_paths() self.state.meta_sources_parsed = True - @serialize - def process_auxiliary_datasets(self, download_fresh: bool = False) -> None: - super().process_auxiliary_datasets(download_fresh) - self.auxiliary_datasets.algorithm_dset = self._prepare_algorithm_dataset(download_fresh) - - @staged(logger, "Processing FIPSAlgorithm dataset.") - def _prepare_algorithm_dataset(self, download_fresh_algs: bool = False) -> FIPSAlgorithmDataset: - if not self.algorithm_dataset_path.exists() or download_fresh_algs: - alg_dset = FIPSAlgorithmDataset.from_web(self.algorithm_dataset_path) - alg_dset.to_json() - else: - alg_dset = FIPSAlgorithmDataset.from_json(self.algorithm_dataset_path) - - return alg_dset - @staged(logger, "Extracting Algorithms from policy tables") def _extract_algorithms_from_policy_tables(self): certs_to_process = [x for x in self if x.state.policy_is_ok_to_analyze()] @@ -306,52 +284,6 @@ class FIPSDataset(Dataset[FIPSCertificate, FIPSAuxiliaryDatasets], ComplexSerial ) self.update_with_certs(processed_certs) - @staged( - logger, - "Computing heuristics: Transitive vulnerabilities in referenc(ed/ing) certificates.", - ) - def _compute_transitive_vulnerabilities(self) -> None: - transitive_cve_finder = TransitiveVulnerabilityFinder(lambda cert: str(cert.cert_id)) - transitive_cve_finder.fit(self.certs, lambda cert: cert.heuristics.policy_processed_references) - - for dgst in self.certs: - transitive_cve = transitive_cve_finder.predict_single_cert(dgst) - self.certs[dgst].heuristics.direct_transitive_cves = transitive_cve.direct_transitive_cves - self.certs[dgst].heuristics.indirect_transitive_cves = transitive_cve.indirect_transitive_cves - - @staged(logger, "Computing heuristics: references between certificates.") - def _compute_references(self, keep_unknowns: bool = False) -> None: - # Previously, a following procedure was used to prune reference_candidates: - # - A set of algorithms was obtained via self.auxiliary_datasets.algorithm_dset.get_algorithms_by_id(reference_candidate) - # - If any of these algorithms had the same vendor as the reference_candidate, the candidate was rejected - # - The rationale is that if an ID appears in a certificate s.t. an algorithm with the same ID was produced by the same vendor, the reference likely refers to alg. - # - Such reference should then be discarded. - # - We are uncertain of the effectivity of such measure, disabling it for now. - for cert in self: - cert.prune_referenced_cert_ids() - - policy_reference_finder = ReferenceFinder() - policy_reference_finder.fit( - self.certs, - lambda cert: str(cert.cert_id), - lambda cert: cert.heuristics.policy_prunned_references, - ) - - module_reference_finder = ReferenceFinder() - module_reference_finder.fit( - self.certs, - lambda cert: str(cert.cert_id), - lambda cert: cert.heuristics.module_prunned_references, - ) - - for cert in self: - cert.heuristics.policy_processed_references = policy_reference_finder.predict_single_cert( - cert.dgst, keep_unknowns - ) - cert.heuristics.module_processed_references = module_reference_finder.predict_single_cert( - cert.dgst, keep_unknowns - ) - def to_pandas(self) -> pd.DataFrame: df = pd.DataFrame( [x.pandas_tuple for x in self.certs.values()], diff --git a/src/sec_certs/dataset/fips_iut.py b/src/sec_certs/dataset/fips_iut.py index 369d81bf..494358f6 100644 --- a/src/sec_certs/dataset/fips_iut.py +++ b/src/sec_certs/dataset/fips_iut.py @@ -54,7 +54,7 @@ class IUTDataset(JSONPathDataset, ComplexSerializableType): return cls(dct["snapshots"]) @classmethod - def from_web_latest(cls) -> IUTDataset: + def from_web(cls) -> IUTDataset: """ Get the IUTDataset from sec-certs.org """ diff --git a/src/sec_certs/dataset/fips_mip.py b/src/sec_certs/dataset/fips_mip.py index 934cc84d..a5af3f4a 100644 --- a/src/sec_certs/dataset/fips_mip.py +++ b/src/sec_certs/dataset/fips_mip.py @@ -56,7 +56,7 @@ class MIPDataset(JSONPathDataset, ComplexSerializableType): return cls(dct["snapshots"]) @classmethod - def from_web_latest(cls) -> MIPDataset: + def from_web(cls) -> MIPDataset: """ Get the MIPDataset from sec-certs.org """ diff --git a/src/sec_certs/dataset/protection_profile.py b/src/sec_certs/dataset/protection_profile.py index af7733a8..2295191a 100644 --- a/src/sec_certs/dataset/protection_profile.py +++ b/src/sec_certs/dataset/protection_profile.py @@ -1,97 +1,389 @@ -from __future__ import annotations - -import json -import logging import shutil -import tempfile -from dataclasses import dataclass +from datetime import datetime from pathlib import Path +from typing import ClassVar, Literal + +from bs4 import BeautifulSoup +from pydantic import AnyHttpUrl from sec_certs import constants from sec_certs.configuration import config +from sec_certs.dataset.auxiliary_dataset_handling import AuxiliaryDatasetHandler +from sec_certs.dataset.dataset import Dataset, logger from sec_certs.sample.protection_profile import ProtectionProfile -from sec_certs.serialization.json import get_class_fullname +from sec_certs.serialization.json import ComplexSerializableType, serialize from sec_certs.utils import helpers +from sec_certs.utils import parallel_processing as cert_processing +from sec_certs.utils.profiling import staged + + +class ProtectionProfileDataset(Dataset[ProtectionProfile], ComplexSerializableType): + """ + Class for processing :class:`sec_certs.sample.protection_profile.ProtectionProfile` samples. -logger = logging.getLogger(__name__) + Inherits from `ComplexSerializableType` and base abstract `Dataset` class. + The dataset directory looks like this: -@dataclass -class ProtectionProfileDataset: - pps: dict[tuple[str, str | None], ProtectionProfile] - _json_path: Path + ├── reports + │ ├── pdf + │ └── txt + ├── pps + │ ├── pdf + │ └── txt + └── pp.json + """ + + FULL_ARCHIVE_URL: ClassVar[AnyHttpUrl] = config.pp_latest_full_archive + SNAPSHOT_URL: ClassVar[AnyHttpUrl] = config.pp_latest_snapshot def __init__( self, - pps: dict[tuple[str, str | None], ProtectionProfile], - json_path: str | Path = constants.DUMMY_NONEXISTING_PATH, - ) -> None: - self.pps = pps - self.json_path = Path(json_path) + certs: dict[str, ProtectionProfile] = {}, + root_dir: str | Path = constants.DUMMY_NONEXISTING_PATH, + name: str | None = None, + description: str = "", + state: Dataset.DatasetInternalState | None = None, + aux_handlers: dict[type[AuxiliaryDatasetHandler], AuxiliaryDatasetHandler] = {}, + ): + self.certs = certs + self.timestamp = datetime.now() + self.sha256_digest = "not implemented" + self.name = name if name else type(self).__name__ + " dataset" + self.description = description if description else datetime.now().strftime("%d/%m/%Y %H:%M:%S") + self.state = state if state else self.DatasetInternalState() + self.aux_handlers = aux_handlers + self.root_dir = Path(root_dir) + + @property + def json_path(self) -> Path: + return self.root_dir / "pp.json" + + @property + def reports_dir(self) -> Path: + """ + Path to protection profile reports. + """ + return self.root_dir / "reports" + + @property + def pps_dir(self) -> Path: + """ + Path to actual protection profiles. + """ + return self.root_dir / "pps" + + @property + def reports_pdf_dir(self) -> Path: + """ + Path to pdfs of protection profile reports. + """ + return self.reports_dir / "pdf" + + @property + def reports_txt_dir(self) -> Path: + """ + Path to txts of protection profile reports. + """ + return self.reports_dir / "txt" + + @property + def pps_pdf_dir(self) -> Path: + """ + Path to pdfs of protection profiles + """ + return self.pps_dir / "pdf" + + @property + def pps_txt_dir(self) -> Path: + """ + Path to txts of protection profiles. + """ + return self.pps_dir / "txt" + + def _compute_heuristics_body(self): + logger.info("Protection profile dataset has no heuristics to compute, skipping.") + + @property + def web_dir(self) -> Path: + """ + Path to directory with html sources downloaded from commoncriteriaportal.org + """ + return self.root_dir / "web" + + HTML_URL = { + "pp_active.html": constants.CC_PORTAL_BASE_URL + "/pps/index.cfm", + "pp_archived.html": constants.CC_PORTAL_BASE_URL + "/pps/index.cfm?archived=1", + "pp_collaborative.html": constants.CC_PORTAL_BASE_URL + "/pps/collaborativePP.cfm?cpp=1", + } + + @property + def active_html_tuples(self) -> list[tuple[str, Path]]: + return [(x, self.web_dir / y) for y, x in self.HTML_URL.items() if "active" in y] + + @property + def archived_html_tuples(self) -> list[tuple[str, Path]]: + return [(x, self.web_dir / y) for y, x in self.HTML_URL.items() if "archived" in y] @property - def json_path(self): - return self._json_path + def collaborative_html_tuples(self) -> list[tuple[str, Path]]: + return [(x, self.web_dir / y) for y, x in self.HTML_URL.items() if "collaborative" in y] + + @serialize + @staged(logger, "Downloading and processing CSV and HTML files of certificates.") + def get_certs_from_web( + self, + to_download: bool = True, + keep_metadata: bool = True, + get_active: bool = True, + get_archived: bool = True, + get_collaborative: bool = True, + ) -> None: + """ + Fetches list of protection profiles together with metadata from commoncriteriaportal.org + """ + if to_download: + self._download_html_resources(get_active, get_archived, get_collaborative) + + logger.info("Adding HTML certificates to ProtectionProfile dataset.") + self.certs = self._get_all_certs_from_html(get_active, get_archived, get_collaborative) + logger.info(f"The resulting dataset has {len(self)} certificates.") + + if not keep_metadata: + shutil.rmtree(self.web_dir) + + self._set_local_paths() + self.state.meta_sources_parsed = True + + def _get_all_certs_from_html( + self, get_active: bool = True, get_archived: bool = True, get_collaborative: bool = True + ) -> dict[str, ProtectionProfile]: + html_sources = [] + if get_active: + html_sources.extend([x for x in self.HTML_URL if "active" in x]) + if get_archived: + html_sources.extend([x for x in self.HTML_URL if "archived" in x]) + if get_collaborative: + html_sources.extend([x for x in self.HTML_URL if "collaborative" in x]) + + new_certs = {} + for file in html_sources: + partial_certs = self._parse_single_html(self.web_dir / file) + logger.info(f"Parsed {len(partial_certs)} protection profiles from: {file}.") + new_certs.update(partial_certs) + return new_certs + + def _download_html_resources( + self, get_active: bool = True, get_archived: bool = True, get_collaborative: bool = True + ) -> None: + self.web_dir.mkdir(parents=True, exist_ok=True) + html_items = [] + if get_active: + html_items.extend(self.active_html_tuples) + if get_archived: + html_items.extend(self.archived_html_tuples) + if get_collaborative: + html_items.extend(self.collaborative_html_tuples) + + html_urls, html_paths = [x[0] for x in html_items], [x[1] for x in html_items] + + logger.info("Downloading required csv and html files.") + helpers.download_parallel(html_urls, html_paths) + + @staticmethod + def _parse_single_html(file: Path) -> dict[str, ProtectionProfile]: + def _parse_table( + soup: BeautifulSoup, + cert_status: Literal["active", "archived"], + table_id: str, + category_string: str, + is_collaborative: bool, + ) -> dict[str, ProtectionProfile]: + tables = soup.find_all("table", id=table_id) + if len(tables) > 1: + raise ValueError( + f'The "{file.name}" was expected to contain 0-1 <table> element. Instead, it contains: {len(tables)} <table> elements.' + ) + + if not tables: + return {} + + body = list(tables[0].find_all("tr"))[1:] + table_certs = {} + for row in body: + try: + pp = ProtectionProfile.from_html_row(row, cert_status, category_string, is_collaborative) + table_certs[pp.dgst] = pp + except ValueError as e: + logger.error(f"Error when creating ProtectionProfile object: {e}") + + return table_certs + + cert_status: Literal["active", "archived"] = "active" if "active" in file.name else "archived" + is_collaborative = "collaborative" in file.name + cc_table_ids = ["tbl" + x for x in constants.CC_CAT_ABBREVIATIONS] + if is_collaborative: + cc_table_ids = [x + "1" for x in cc_table_ids] + cat_dict = dict(zip(cc_table_ids, constants.CC_CATEGORIES)) + + with file.open("r") as handle: + soup = BeautifulSoup(handle, "html5lib") + + certs = {} + for key, val in cat_dict.items(): + certs.update(_parse_table(soup, cert_status, key, val, is_collaborative)) + + return certs + + def _convert_all_pdfs_body(self, fresh=True): + self._convert_reports_to_txt(fresh) + self._convert_pps_to_txt(fresh) + + @staged(logger, "Converting PDFs of PP certification reports to text.") + def _convert_reports_to_txt(self, fresh: bool = True): + self.reports_txt_dir.mkdir(parents=True, exist_ok=True) + certs_to_process = [x for x in self if x.state.report.is_ok_to_convert(fresh)] + + if not fresh and certs_to_process: + logger.info( + f"Converting {len(certs_to_process)} PDFs of PP certification reports to text for which previous conversion failed." + ) + + cert_processing.process_parallel( + ProtectionProfile.convert_report_pdf, + certs_to_process, + progress_bar_desc="Converting PDFs of PP certification reports to text.", + ) + + @staged(logger, "Converting PDFs of actual Protection Profiles to text.") + def _convert_pps_to_txt(self, fresh: bool = True): + self.pps_txt_dir.mkdir(parents=True, exist_ok=True) + certs_to_process = [x for x in self if x.state.pp.is_ok_to_convert(fresh)] + + if not fresh and certs_to_process: + logger.info( + f"Converting {len(certs_to_process)} PDFs of actual Protection Profiles to text for which previous conversion failed." + ) + + cert_processing.process_parallel( + ProtectionProfile.convert_pp_pdf, + certs_to_process, + progress_bar_desc="Converting PDFs of actual Protection Profiles to text.", + ) + + def _download_all_artifacts_body(self, fresh=True): + self._download_reports(fresh) + self._download_pps(fresh) - @json_path.setter - def json_path(self, new_path: str | Path): - new_path = Path(new_path) - if new_path.is_dir(): - raise ValueError(f"Json path of {get_class_fullname(self)} cannot be a directory.") + @staged(logger, "Downloading PDFs of PP certification reports.") + def _download_reports(self, fresh: bool = True): + self.reports_pdf_dir.mkdir(parents=True, exist_ok=True) + certs_to_process = [x for x in self if x.state.report.is_ok_to_download(fresh) and x.web_data.report_link] - self._json_path = new_path + if not fresh and certs_to_process: + logger.info( + f"Downloading {len(certs_to_process)} PDFs of PP certification reports for which previous download failed." + ) - def move_dataset(self, new_json_path: str | Path) -> None: - logger.info(f"Moving {get_class_fullname(self)} dataset to {new_json_path}") - new_json_path = Path(new_json_path) - new_json_path.parent.mkdir(parents=True, exist_ok=True) + cert_processing.process_parallel( + ProtectionProfile.download_pdf_report, + certs_to_process, + progress_bar_desc="Downloading PDFs of PP certification reports.", + ) - if not self.json_path.exists(): - raise ValueError("Cannot move the PPDataset if the json path does not exist.") + @staged(logger, "Downloading PDFs of actual Protection Profiles.") + def _download_pps(self, fresh: bool = True): + self.pps_pdf_dir.mkdir(parents=True, exist_ok=True) + certs_to_process = [x for x in self if x.state.pp.is_ok_to_download(fresh) and x.web_data.pp_link] - shutil.move(str(self.json_path), str(new_json_path)) - self.json_path = new_json_path + if not fresh and certs_to_process: + logger.info( + f"Downloading {len(certs_to_process)} PDFs of actual Protection Profiles for which previous download failed." + ) - def __iter__(self): - yield from self.pps.values() + cert_processing.process_parallel( + ProtectionProfile.download_pdf_pp, + certs_to_process, + progress_bar_desc="Downloading PDFs of actual Protection Profiles.", + ) - def __getitem__(self, item: tuple[str, str | None]) -> ProtectionProfile: - return self.pps.__getitem__(item) + def extract_data(self): + """ + Extracts pdf metadata and keywords from converted text documents. + """ + logger.info("Extracting various data from certification artifacts.") + self._extract_pdf_metadata() + self._extract_pdf_keywords() - def __setitem__(self, key: tuple[str, str | None], value: ProtectionProfile): - self.pps.__setitem__(key, value) + @staged(logger, "Extracting metadata from certification artifacts.") + def _extract_pdf_metadata(self): + self._extract_report_metadata() + self._extract_pp_metadata() - def __contains__(self, key): - return key in self.pps + @staged(logger, "Extracting keywords from certification artifacts.") + def _extract_pdf_keywords(self): + self._extract_report_keywords() + self._extract_pp_keywords() - def __len__(self) -> int: - return len(self.pps) + def _extract_report_metadata(self): + certs_to_process = [x for x in self if x.state.report.is_ok_to_analyze()] + processed_certs = cert_processing.process_parallel( + ProtectionProfile.extract_report_pdf_metadata, + certs_to_process, + use_threading=False, + progress_bar_desc="Extracting metadata from PP certification reports.", + ) + self.update_with_certs(processed_certs) - @classmethod - def from_json(cls, json_path: str | Path): - with Path(json_path).open("r") as handle: - data = json.load(handle) - pps = [ProtectionProfile.from_old_api_dict(x) for x in data.values()] + def _extract_pp_metadata(self): + certs_to_process = [x for x in self if x.state.pp.is_ok_to_analyze()] + processed_certs = cert_processing.process_parallel( + ProtectionProfile.extract_pp_pdf_metadata, + certs_to_process, + use_threading=False, + progress_bar_desc="Extracting metadata from actual Protection Profiles.", + ) + self.update_with_certs(processed_certs) - dct = {} - for item in pps: - if (item.pp_name, item.pp_link) in dct: - logger.warning(f"Duplicate entry in PP dataset: {(item.pp_name, item.pp_link)}") - dct[(item.pp_name, item.pp_link)] = item + def _extract_report_keywords(self): + certs_to_process = [x for x in self if x.state.report.is_ok_to_analyze()] + processed_certs = cert_processing.process_parallel( + ProtectionProfile.extract_report_pdf_keywords, + certs_to_process, + use_threading=False, + progress_bar_desc="Extracting keywords from PP certification reports.", + ) + self.update_with_certs(processed_certs) - return cls(dct) + def _extract_pp_keywords(self): + certs_to_process = [x for x in self if x.state.pp.is_ok_to_analyze()] + processed_certs = cert_processing.process_parallel( + ProtectionProfile.extract_pp_pdf_keywords, + certs_to_process, + use_threading=False, + progress_bar_desc="Extracting keywords from actual Protection Profiles.", + ) + self.update_with_certs(processed_certs) - @classmethod - def from_web(cls, store_dataset_path: Path | None = None): - logger.info(f"Downloading static PP dataset from: {config.pp_latest_snapshot}") - if not store_dataset_path: - tmp = tempfile.TemporaryDirectory() - store_dataset_path = Path(tmp.name) / "pp_dataset.json" + def _set_local_paths(self): + super()._set_local_paths() - helpers.download_file(config.pp_latest_snapshot, store_dataset_path) - obj = cls.from_json(store_dataset_path) + for cert in self: + cert.set_local_paths(self.reports_pdf_dir, self.pps_pdf_dir, self.reports_txt_dir, self.pps_txt_dir) - if not store_dataset_path: - tmp.cleanup() + def process_auxiliary_datasets(self, **kwargs) -> None: + """ + Dummy method to adhere to `Dataset` interface. `ProtectionProfile` dataset has currently no auxiliary datasets. + This will just set the state `auxiliary_datasets_processed = True` + """ + logger.info("Protection Profile dataset has no auxiliary datasets to process, skipping.") + self.state.auxiliary_datasets_processed = True - return obj + def get_pp_by_pp_link(self, pp_link: str) -> ProtectionProfile | None: + """ + Given URL to PP pdf, will retrieve `ProtectionProfile` object in the dataset with the link, if such exists. + """ + for pp in self: + if pp.web_data.pp_link == pp_link: + return pp + return None diff --git a/src/sec_certs/heuristics/cc.py b/src/sec_certs/heuristics/cc.py new file mode 100644 index 00000000..435c69ed --- /dev/null +++ b/src/sec_certs/heuristics/cc.py @@ -0,0 +1,117 @@ +import logging +import re +from collections.abc import Iterable + +from sec_certs.cert_rules import security_level_csv_scan +from sec_certs.dataset.cc_scheme import CCSchemeDataset +from sec_certs.dataset.protection_profile import ProtectionProfileDataset +from sec_certs.model.cc_matching import CCSchemeMatcher +from sec_certs.model.reference_finder import ReferenceFinder +from sec_certs.model.sar_transformer import SARTransformer +from sec_certs.sample.cc import CCCertificate +from sec_certs.sample.cc_certificate_id import CertificateId +from sec_certs.sample.cc_scheme import EntryType +from sec_certs.utils.helpers import choose_lowest_eal +from sec_certs.utils.profiling import staged + +logger = logging.getLogger(__name__) + + +@staged(logger, "Computing heuristics: Linking certificates to protection profiles") +def link_to_protection_profiles( + certs: Iterable[CCCertificate], + pp_dset: ProtectionProfileDataset, +) -> None: + for cert in certs: + if cert.protection_profile_links: + pps = [pp_dset.get_pp_by_pp_link(x) for x in cert.protection_profile_links] + pp_digests = {x.dgst for x in pps if x} + cert.heuristics.protection_profiles = pp_digests if pp_digests else None + logger.info( + f"Linked {len([x for x in certs if x.heuristics.protection_profiles])} certificates to their protection profiles." + ) + + +@staged(logger, "Computing heuristics: references between certificates.") +def compute_references(certs: dict[str, CCCertificate]) -> None: + def ref_lookup(kw_attr): + def func(cert): + kws = getattr(cert.pdf_data, kw_attr) + if not kws: + return set() + res = set() + for scheme, matches in kws["cc_cert_id"].items(): + for match in matches: + try: + canonical = CertificateId(scheme, match).canonical + res.add(canonical) + except Exception: + res.add(match) + return res + + return func + + for ref_source in ("report", "st"): + kw_source = f"{ref_source}_keywords" + dep_attr = f"{ref_source}_references" + + finder = ReferenceFinder() + finder.fit(certs, lambda cert: cert.heuristics.cert_id, ref_lookup(kw_source)) # type: ignore + + for dgst in certs: + setattr(certs[dgst].heuristics, dep_attr, finder.predict_single_cert(dgst, keep_unknowns=False)) + + +@staged(logger, "Computing heuristics: Deriving information about certificate ids from artifacts.") +def compute_normalized_cert_ids(certs: Iterable[CCCertificate]) -> None: + for cert in certs: + cert.compute_heuristics_cert_id() + + +@staged(logger, "Computing heuristics: Matching scheme data.") +def compute_scheme_data(scheme_dset: CCSchemeDataset, certs: dict[str, CCCertificate]): + for scheme in scheme_dset: + if certified := scheme.lists.get(EntryType.Certified): + active_certs = [cert for cert in certs.values() if cert.status == "active"] + matches, _ = CCSchemeMatcher.match_all(certified, scheme.country, active_certs) + for dgst, match in matches.items(): + certs[dgst].heuristics.scheme_data = match + if archived := scheme.lists.get(EntryType.Archived): + archived_certs = [cert for cert in certs.values() if cert.status == "archived"] + matches, _ = CCSchemeMatcher.match_all(archived, scheme.country, archived_certs) + for dgst, match in matches.items(): + certs[dgst].heuristics.scheme_data = match + + +@staged(logger, "Computing heuristics: Deriving information about laboratories involved in certification.") +def compute_cert_labs(certs: Iterable[CCCertificate]) -> None: + for cert in certs: + cert.compute_heuristics_cert_lab() + + +@staged(logger, "Computing heuristics: SARs") +def compute_sars(certs: Iterable[CCCertificate]) -> None: + transformer = SARTransformer().fit(certs) + for cert in certs: + cert.heuristics.extracted_sars = transformer.transform_single_cert(cert) + + +@staged(logger, "Computing heuristics: EALs") +def compute_eals(certs: Iterable[CCCertificate], pp_dataset: ProtectionProfileDataset) -> None: + def compute_cert_eal(cert: CCCertificate) -> str | None: + res = [x for x in cert.security_level if re.match(security_level_csv_scan, x)] + if res and len(res) == 1: + return res[0] + elif res and len(res) > 1: + raise ValueError(f"Expected single EAL in security_level field, got: {res}") + else: + if cert.heuristics.protection_profiles: + eals: set[str] = { + eal for x in cert.heuristics.protection_profiles if (eal := pp_dataset[x].web_data.eal) is not None + } + return choose_lowest_eal(eals) + else: + return None + + for cert in certs: + cert.heuristics.eal = compute_cert_eal(cert) diff --git a/src/sec_certs/heuristics/common.py b/src/sec_certs/heuristics/common.py new file mode 100644 index 00000000..8a2b4769 --- /dev/null +++ b/src/sec_certs/heuristics/common.py @@ -0,0 +1,132 @@ +import itertools +import logging +import re +from collections.abc import Iterable + +from tqdm import tqdm + +from sec_certs import constants +from sec_certs.configuration import config +from sec_certs.dataset.cpe import CPEDataset +from sec_certs.dataset.cve import CVEDataset +from sec_certs.dataset.dataset import CertSubType +from sec_certs.model.cpe_matching import CPEClassifier +from sec_certs.model.transitive_vulnerability_finder import TransitiveVulnerabilityFinder +from sec_certs.sample.cc import CCCertificate +from sec_certs.sample.certificate import Certificate +from sec_certs.sample.cpe import CPE +from sec_certs.sample.fips import FIPSCertificate +from sec_certs.utils.profiling import staged + +logger = logging.getLogger(__name__) + + +@staged(logger, "Computing heuristics: Finding CPE matches for certificates") +def compute_cpe_heuristics(cpe_dataset: CPEDataset, certs: Iterable[CertSubType]) -> None: + """ + Computes matching CPEs for the certificates. + """ + WINDOWS_WEAK_CPES: set[CPE] = { + 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: + """ + Filters out very weak CPE matches that don't improve our database. + """ + if cpe.title and (cpe.version == "-" or cpe.version == "*") and not any(char.isdigit() for char in cpe.title): + return False + if ( + not cpe.title + and cpe.item_name + and (cpe.version == "-" or cpe.version == "*") + and not any(char.isdigit() for char in cpe.item_name) + ): + return False + if re.match(constants.RELEASE_CANDIDATE_REGEX, cpe.update): + return False + return cpe not in WINDOWS_WEAK_CPES + + logger.info("Computing CPE heuristics.") + clf = CPEClassifier(config.cpe_matching_threshold, config.cpe_n_max_matches) + clf.fit([x for x in cpe_dataset if filter_condition(x)]) + + for cert in tqdm(certs, desc="Predicting CPE matches with the classifier"): + cert.compute_heuristics_version() + cert.heuristics.cpe_matches = ( + clf.predict_single_cert(cert.manufacturer, cert.name, cert.heuristics.extracted_versions) + if cert.name + else None + ) + + +def get_all_cpes_in_dataset(cpe_dset: CPEDataset, certs: Iterable[Certificate]) -> set[CPE]: + cpe_matches = [[cpe_dset.cpes[y] for y in x.heuristics.cpe_matches] for x in certs if x.heuristics.cpe_matches] + return set(itertools.chain.from_iterable(cpe_matches)) + + +def enrich_automated_cpes_with_manual_labels(certs: Iterable[Certificate]) -> None: + """ + Prior to CVE matching, it is wise to expand the database of automatic CPE matches with those that were manually assigned. + """ + for cert in certs: + if not cert.heuristics.cpe_matches and cert.heuristics.verified_cpe_matches: + cert.heuristics.cpe_matches = cert.heuristics.verified_cpe_matches + elif cert.heuristics.cpe_matches and cert.heuristics.verified_cpe_matches: + cert.heuristics.cpe_matches = set(cert.heuristics.cpe_matches).union( + set(cert.heuristics.verified_cpe_matches) + ) + + +@staged(logger, "Computing heuristics: CVEs in certificates.") +def compute_related_cves( + cpe_dset: CPEDataset, cve_dset: CVEDataset, cpe_match_dict: dict, certs: Iterable[Certificate] +) -> None: + """ + Computes CVEs for the certificates, given their CPE matches. + """ + + logger.info("Computing related CVEs") + if not cve_dset.look_up_dicts_built: + all_cpes = get_all_cpes_in_dataset(cpe_dset, certs) + cve_dset.build_lookup_dict(cpe_match_dict, all_cpes) + + enrich_automated_cpes_with_manual_labels(certs) + cpe_rich_certs = [x for x in certs if x.heuristics.cpe_matches] + + for cert in tqdm(cpe_rich_certs, desc="Computing related CVES"): + related_cves = 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]) + n_vulnerabilities = sum([len(x.heuristics.related_cves) for x in cpe_rich_certs if x.heuristics.related_cves]) + logger.info( + f"In total, we identified {n_vulnerabilities} vulnerabilities in {n_vulnerable} vulnerable certificates." + ) + + +@staged( + logger, + "Computing heuristics: Transitive vulnerabilities in referenc(ed/ing) certificates.", +) +def compute_transitive_vulnerabilities(certs: dict[str, CertSubType]) -> None: + logger.info("Computing transitive vulnerabilities") + if not certs: + return + + some_cert = next(iter(certs.values())) + + if isinstance(some_cert, FIPSCertificate): + transitive_cve_finder = TransitiveVulnerabilityFinder(lambda cert: str(cert.cert_id)) + transitive_cve_finder.fit(certs, lambda cert: cert.heuristics.policy_processed_references) + elif isinstance(some_cert, CCCertificate): + transitive_cve_finder = TransitiveVulnerabilityFinder(lambda cert: str(cert.heuristics.cert_id)) + transitive_cve_finder.fit(certs, lambda cert: cert.heuristics.report_references) + else: + raise ValueError("Members of `certs` object must be either FIPSCertificate or CCCertificate instances.") + + for cert in certs.values(): + transitive_cve = transitive_cve_finder.predict_single_cert(cert.dgst) + cert.heuristics.direct_transitive_cves = transitive_cve.direct_transitive_cves + cert.heuristics.indirect_transitive_cves = transitive_cve.indirect_transitive_cves diff --git a/src/sec_certs/heuristics/fips.py b/src/sec_certs/heuristics/fips.py new file mode 100644 index 00000000..4abecc4e --- /dev/null +++ b/src/sec_certs/heuristics/fips.py @@ -0,0 +1,42 @@ +import logging + +from sec_certs.model.reference_finder import ReferenceFinder +from sec_certs.sample.fips import FIPSCertificate +from sec_certs.utils.profiling import staged + +logger = logging.getLogger(__name__) + + +@staged(logger, "Computing heuristics: references between certificates.") +def compute_references(certs: dict[str, FIPSCertificate], keep_unknowns: bool = False) -> None: + # Previously, a following procedure was used to prune reference_candidates: + # - A set of algorithms was obtained via self.auxiliary_datasets.algorithm_dset.get_algorithms_by_id(reference_candidate) + # - If any of these algorithms had the same vendor as the reference_candidate, the candidate was rejected + # - The rationale is that if an ID appears in a certificate s.t. an algorithm with the same ID was produced by the same vendor, the reference likely refers to alg. + # - Such reference should then be discarded. + # - We are uncertain of the effectivity of such measure, disabling it for now. + logger.info("Computing references") + for cert in certs.values(): + cert.prune_referenced_cert_ids() + + policy_reference_finder = ReferenceFinder() + policy_reference_finder.fit( + certs, + lambda cert: str(cert.cert_id), + lambda cert: cert.heuristics.policy_prunned_references, + ) + + module_reference_finder = ReferenceFinder() + module_reference_finder.fit( + certs, + lambda cert: str(cert.cert_id), + lambda cert: cert.heuristics.module_prunned_references, + ) + + for cert in certs.values(): + cert.heuristics.policy_processed_references = policy_reference_finder.predict_single_cert( + cert.dgst, keep_unknowns + ) + cert.heuristics.module_processed_references = module_reference_finder.predict_single_cert( + cert.dgst, keep_unknowns + ) diff --git a/src/sec_certs/sample/cc.py b/src/sec_certs/sample/cc.py index a9aa2262..ca096a6c 100644 --- a/src/sec_certs/sample/cc.py +++ b/src/sec_certs/sample/cc.py @@ -17,13 +17,13 @@ from bs4 import Tag import sec_certs.utils.extract import sec_certs.utils.pdf from sec_certs import constants -from sec_certs.cert_rules import SARS_IMPLIED_FROM_EAL, cc_rules, rules, security_level_csv_scan +from sec_certs.cert_rules import SARS_IMPLIED_FROM_EAL, cc_rules, rules from sec_certs.configuration import config from sec_certs.sample.cc_certificate_id import CertificateId, canonicalize, schemes from sec_certs.sample.certificate import Certificate, References, logger from sec_certs.sample.certificate import Heuristics as BaseHeuristics from sec_certs.sample.certificate import PdfData as BasePdfData -from sec_certs.sample.protection_profile import ProtectionProfile +from sec_certs.sample.document_state import DocumentState from sec_certs.sample.sar import SAR from sec_certs.serialization.json import ComplexSerializableType from sec_certs.serialization.pandas import PandasSerializableType @@ -43,8 +43,6 @@ class CCCertificate( the certificate can handle itself. `CCDataset` class then instrument this functionality. """ - cc_url = "https://www.commoncriteriaportal.org" - @dataclass(eq=True, frozen=True) class MaintenanceReport(ComplexSerializableType): """ @@ -76,86 +74,15 @@ class CCCertificate( return self.maintenance_date < other.maintenance_date @dataclass - class DocumentState(ComplexSerializableType): - download_ok: bool = False # Whether download went OK - convert_garbage: bool = False # Whether initial conversion resulted in garbage - convert_ok: bool = False # Whether overall conversion went OK (either pdftotext or via OCR) - extract_ok: bool = False # Whether extraction went OK - - pdf_hash: str | None = None - txt_hash: str | None = None - - _pdf_path: Path | None = None - _txt_path: Path | None = None - - def is_ok_to_download(self, fresh: bool = True) -> bool: - return True if fresh else not self.download_ok - - def is_ok_to_convert(self, fresh: bool = True) -> bool: - return self.download_ok if fresh else self.download_ok and not self.convert_ok - - def is_ok_to_analyze(self, fresh: bool = True) -> bool: - if fresh: - return self.download_ok and self.convert_ok - else: - return self.download_ok and self.convert_ok and not self.extract_ok - - @property - def pdf_path(self) -> Path: - if not self._pdf_path: - raise ValueError(f"pdf_path not set on {type(self)}") - return self._pdf_path - - @pdf_path.setter - def pdf_path(self, pth: str | Path | None) -> None: - self._pdf_path = Path(pth) if pth else None - - @property - def txt_path(self) -> Path: - if not self._txt_path: - raise ValueError(f"txt_path not set on {type(self)}") - return self._txt_path - - @txt_path.setter - def txt_path(self, pth: str | Path | None) -> None: - self._txt_path = Path(pth) if pth else None - - @property - def serialized_attributes(self) -> list[str]: - return [ - "download_ok", - "convert_garbage", - "convert_ok", - "extract_ok", - "pdf_hash", - "txt_hash", - ] - - @dataclass(init=False) class InternalState(ComplexSerializableType): """ Holds internal state of the certificate, whether downloads and converts of individual components succeeded. Also holds information about errors and paths to the files. """ - report: CCCertificate.DocumentState - st: CCCertificate.DocumentState - cert: CCCertificate.DocumentState - - def __init__( - self, - report: CCCertificate.DocumentState | None = None, - st: CCCertificate.DocumentState | None = None, - cert: CCCertificate.DocumentState | None = None, - ): - super().__init__() - self.report = report if report is not None else CCCertificate.DocumentState() - self.st = st if st is not None else CCCertificate.DocumentState() - self.cert = cert if cert is not None else CCCertificate.DocumentState() - - @property - def serialized_attributes(self) -> list[str]: - return ["report", "st", "cert"] + report: DocumentState = field(default_factory=DocumentState) + st: DocumentState = field(default_factory=DocumentState) + cert: DocumentState = field(default_factory=DocumentState) @dataclass class PdfData(BasePdfData, ComplexSerializableType): @@ -350,7 +277,6 @@ class CCCertificate( next_certificates: list[str] | None = field(default=None) st_references: References = field(default_factory=References) report_references: References = field(default_factory=References) - # Contains direct outward references merged from both st, and report sources, annotated with ReferenceAnnotator # TODO: Reference meanings as Enum if we work with it further. annotated_references: dict[str, str] | None = field(default=None) @@ -358,6 +284,8 @@ class CCCertificate( direct_transitive_cves: set[str] | None = field(default=None) indirect_transitive_cves: set[str] | None = field(default=None) scheme_data: dict[str, Any] | None = field(default=None) + protection_profiles: set[str] | None = field(default=None) + eal: str | None = field(default=None) @property def serialized_attributes(self) -> list[str]: @@ -388,6 +316,7 @@ class CCCertificate( "directly_referencing", "indirectly_referencing", "extracted_sars", + "protection_profile_links", "protection_profiles", "cert_lab", ] @@ -406,7 +335,7 @@ class CCCertificate( st_link: str | None, cert_link: str | None, manufacturer_web: str | None, - protection_profiles: set[ProtectionProfile] | None, + protection_profile_links: set[str] | None, maintenance_updates: set[MaintenanceReport] | None, state: InternalState | None, pdf_data: PdfData | None, @@ -430,7 +359,7 @@ class CCCertificate( self.st_link = sanitization.sanitize_link(st_link) self.cert_link = sanitization.sanitize_link(cert_link) self.manufacturer_web = sanitization.sanitize_link(manufacturer_web) - self.protection_profiles = protection_profiles + self.protection_profile_links = protection_profile_links self.maintenance_updates = maintenance_updates self.state = state if state else self.InternalState() self.pdf_data = pdf_data if pdf_data else self.PdfData() @@ -469,22 +398,6 @@ class CCCertificate( return helpers.get_first_16_bytes_sha256(self.category + self.name + self.report_link) @property - def eal(self) -> str | None: - """ - Returns EAL of certificate if it was extracted, None otherwise. - """ - res = [x for x in self.security_level if re.match(security_level_csv_scan, x)] - if res and len(res) == 1: - return res[0] - if res and len(res) > 1: - raise ValueError(f"Expected single EAL in security_level field, got: {res}") - else: - if self.protection_profiles: - return helpers.choose_lowest_eal({x.pp_eal for x in self.protection_profiles if x.pp_eal}) - else: - return None - - @property def actual_sars(self) -> set[SAR] | None: """ Computes actual SARs. First, SARs implied by EAL are computed. Then, these are augmented with heuristically extracted SARs. @@ -492,8 +405,8 @@ class CCCertificate( :return Optional[Set[SAR]]: Set of actual SARs of a certificate, None if empty """ sars = {} - if self.eal: - sars = {x[0]: SAR(x[0], x[1]) for x in SARS_IMPLIED_FROM_EAL[self.eal[:4]]} + if self.heuristics.eal: + sars = {x[0]: SAR(x[0], x[1]) for x in SARS_IMPLIED_FROM_EAL[self.heuristics.eal[:4]]} if self.heuristics.extracted_sars: for sar in self.heuristics.extracted_sars: @@ -520,7 +433,7 @@ class CCCertificate( self.manufacturer, self.scheme, self.security_level, - self.eal, + self.heuristics.eal, self.not_valid_before, self.not_valid_after, self.report_link, @@ -536,7 +449,8 @@ class CCCertificate( self.heuristics.report_references.directly_referencing, self.heuristics.report_references.indirectly_referencing, self.heuristics.extracted_sars, - [x.pp_name for x in self.protection_profiles] if self.protection_profiles else np.nan, + self.protection_profile_links if self.protection_profile_links else np.nan, + self.heuristics.protection_profiles if self.heuristics.protection_profiles else np.nan, self.heuristics.cert_lab[0] if (self.heuristics.cert_lab and self.heuristics.cert_lab[0]) else np.nan, ) @@ -557,7 +471,13 @@ class CCCertificate( # Prefer some values from the HTML # Links in CSV are currently (13.08.2024) broken. - html_preferred_attrs = {"protection_profiles", "maintenance_updates", "cert_link", "report_link", "st_link"} + html_preferred_attrs = { + "protection_profile_links", + "maintenance_updates", + "cert_link", + "report_link", + "st_link", + } for att, val in vars(self).items(): if (not val) or (other_source == "html" and att in html_preferred_attrs) or (att == "state"): @@ -575,7 +495,8 @@ class CCCertificate( """ new_dct = dct.copy() new_dct["maintenance_updates"] = set(dct["maintenance_updates"]) - new_dct["protection_profiles"] = set(dct["protection_profiles"]) + if dct["protection_profile_links"]: + new_dct["protection_profile_links"] = set(dct["protection_profile_links"]) new_dct["not_valid_before"] = ( date.fromisoformat(dct["not_valid_before"]) if isinstance(dct["not_valid_before"], str) @@ -615,16 +536,12 @@ class CCCertificate( return None @staticmethod - def _html_row_get_protection_profiles(cell: Tag) -> set: - protection_profiles = set() + def _html_row_get_protection_profile_links(cell: Tag) -> set: + protection_profile_links = set() for link in list(cell.find_all("a")): if link.get("href") is not None and "/ppfiles/" in link.get("href"): - protection_profiles.add( - ProtectionProfile( - pp_name=str(link.contents[0]), pp_eal=None, pp_link=CCCertificate.cc_url + link.get("href") - ) - ) - return protection_profiles + protection_profile_links.add(constants.CC_PORTAL_BASE_URL + link.get("href")) + return protection_profile_links @staticmethod def _html_row_get_date(cell: Tag) -> date | None: @@ -643,16 +560,16 @@ class CCCertificate( if not title: continue if title.startswith("Certification Report"): - report_link = CCCertificate.cc_url + link.get("href") + report_link = constants.CC_PORTAL_BASE_URL + link.get("href") elif title.startswith("Security Target"): - security_target_link = CCCertificate.cc_url + link.get("href") + security_target_link = constants.CC_PORTAL_BASE_URL + link.get("href") return report_link, security_target_link @staticmethod def _html_row_get_cert_link(cell: Tag) -> str | None: links = cell.find_all("a") - return CCCertificate.cc_url + links[0].get("href") if links else None + return constants.CC_PORTAL_BASE_URL + links[0].get("href") if links else None @staticmethod def _html_row_get_maintenance_div(cell: Tag) -> Tag | None: @@ -675,9 +592,9 @@ class CCCertificate( links = u.find_all("a") for link in links: if link.get("title").startswith("Maintenance Report:"): - main_report_link = CCCertificate.cc_url + link.get("href") + main_report_link = constants.CC_PORTAL_BASE_URL + link.get("href") elif link.get("title").startswith("Maintenance ST"): - main_st_link = CCCertificate.cc_url + link.get("href") + main_st_link = constants.CC_PORTAL_BASE_URL + link.get("href") else: logger.error("Unknown link in Maintenance part!") maintenance_updates.add( @@ -700,7 +617,7 @@ class CCCertificate( manufacturer_web = CCCertificate._html_row_get_manufacturer_web(cells[1]) scheme = CCCertificate._html_row_get_scheme(cells[6]) security_level = CCCertificate._html_row_get_security_level(cells[5]) - protection_profiles = CCCertificate._html_row_get_protection_profiles(cells[0]) + protection_profile_links = CCCertificate._html_row_get_protection_profile_links(cells[0]) not_valid_before = CCCertificate._html_row_get_date(cells[3]) not_valid_after = CCCertificate._html_row_get_date(cells[4]) report_link, st_link = CCCertificate._html_row_get_report_st_links(cells[0]) @@ -721,7 +638,7 @@ class CCCertificate( st_link, cert_link, manufacturer_web, - protection_profiles, + protection_profile_links, maintenances, None, None, diff --git a/src/sec_certs/sample/certificate.py b/src/sec_certs/sample/certificate.py index 74b1af96..6fbf8af4 100644 --- a/src/sec_certs/sample/certificate.py +++ b/src/sec_certs/sample/certificate.py @@ -30,8 +30,7 @@ class References(ComplexSerializableType): class Heuristics: - cpe_matches: set[str] | None - related_cves: set[str] | None + pass class PdfData: @@ -87,7 +86,3 @@ class Certificate(Generic[T, H, P], ABC, ComplexSerializableType): def from_dict(cls: type[T], dct: dict) -> T: dct.pop("dgst") return cls(**dct) - - @abstractmethod - def compute_heuristics_version(self) -> None: - raise NotImplementedError("Not meant to be implemented") diff --git a/src/sec_certs/sample/document_state.py b/src/sec_certs/sample/document_state.py new file mode 100644 index 00000000..a2cf1769 --- /dev/null +++ b/src/sec_certs/sample/document_state.py @@ -0,0 +1,61 @@ +from dataclasses import dataclass +from pathlib import Path + +from sec_certs.serialization.json import ComplexSerializableType + + +@dataclass +class DocumentState(ComplexSerializableType): + download_ok: bool = False # Whether download went OK + convert_garbage: bool = False # Whether initial conversion resulted in garbage + convert_ok: bool = False # Whether overall conversion went OK (either pdftotext or via OCR) + extract_ok: bool = False # Whether extraction went OK + + pdf_hash: str | None = None + txt_hash: str | None = None + + _pdf_path: Path | None = None + _txt_path: Path | None = None + + def is_ok_to_download(self, fresh: bool = True) -> bool: + return True if fresh else not self.download_ok + + def is_ok_to_convert(self, fresh: bool = True) -> bool: + return self.download_ok if fresh else self.download_ok and not self.convert_ok + + def is_ok_to_analyze(self, fresh: bool = True) -> bool: + if fresh: + return self.download_ok and self.convert_ok + else: + return self.download_ok and self.convert_ok and not self.extract_ok + + @property + def pdf_path(self) -> Path: + if not self._pdf_path: + raise ValueError(f"pdf_path not set on {type(self)}") + return self._pdf_path + + @pdf_path.setter + def pdf_path(self, pth: str | Path | None) -> None: + self._pdf_path = Path(pth) if pth else None + + @property + def txt_path(self) -> Path: + if not self._txt_path: + raise ValueError(f"txt_path not set on {type(self)}") + return self._txt_path + + @txt_path.setter + def txt_path(self, pth: str | Path | None) -> None: + self._txt_path = Path(pth) if pth else None + + @property + def serialized_attributes(self) -> list[str]: + return [ + "download_ok", + "convert_garbage", + "convert_ok", + "extract_ok", + "pdf_hash", + "txt_hash", + ] diff --git a/src/sec_certs/sample/fips_iut.py b/src/sec_certs/sample/fips_iut.py index 32c159f1..6d979dde 100644 --- a/src/sec_certs/sample/fips_iut.py +++ b/src/sec_certs/sample/fips_iut.py @@ -143,7 +143,7 @@ class IUTSnapshot(ComplexSerializableType): return cls.from_page(content, snapshot_date) @classmethod - def from_web(cls) -> IUTSnapshot: + def from_nist_web(cls) -> IUTSnapshot: """ Get an IUT snapshot from the FIPS website right now. """ @@ -155,13 +155,19 @@ class IUTSnapshot(ComplexSerializableType): return cls.from_page(iut_resp.content, snapshot_date) @classmethod - def from_web_latest(cls) -> IUTSnapshot: + def from_web(cls) -> IUTSnapshot: """ - Get a IUT snapshot from sec-certs.org. + Fetch a fresh snapshot from sec-certs.org, if the `preferred_source_remote_datasets` config + entry is equal to "sec-certs". + + Otherwise, the same as `from_nist_web`. """ - iut_resp = requests.get(config.fips_iut_latest_snapshot) - if iut_resp.status_code != 200: - raise ValueError(f"Getting MIP snapshot failed: {iut_resp.status_code}") - with NamedTemporaryFile() as tmpfile: - tmpfile.write(iut_resp.content) - return cls.from_json(tmpfile.name) + if config.preferred_source_remote_datasets == "origin": + return cls.from_nist_web() + else: + iut_resp = requests.get(config.fips_iut_latest_snapshot) + if iut_resp.status_code != 200: + raise ValueError(f"Getting IUT snapshot failed: {iut_resp.status_code}") + with NamedTemporaryFile() as tmpfile: + tmpfile.write(iut_resp.content) + return cls.from_json(tmpfile.name) diff --git a/src/sec_certs/sample/fips_mip.py b/src/sec_certs/sample/fips_mip.py index c3fb177d..c7d84ad1 100644 --- a/src/sec_certs/sample/fips_mip.py +++ b/src/sec_certs/sample/fips_mip.py @@ -246,7 +246,7 @@ class MIPSnapshot(ComplexSerializableType): return cls.from_page(content, snapshot_date) @classmethod - def from_web(cls) -> MIPSnapshot: + def from_nist_web(cls) -> MIPSnapshot: """ Get a MIP snapshot from the FIPS website right now. """ @@ -258,13 +258,16 @@ class MIPSnapshot(ComplexSerializableType): return cls.from_page(mip_resp.content, snapshot_date) @classmethod - def from_web_latest(cls) -> MIPSnapshot: + def from_web(cls) -> MIPSnapshot: """ - Get a MIP snapshot from sec-certs.org. + Get a MIP snapshot from the FIPS website right now. """ - mip_resp = requests.get(config.fips_mip_latest_snapshot) - if mip_resp.status_code != 200: - raise ValueError(f"Getting MIP snapshot failed: {mip_resp.status_code}") - with NamedTemporaryFile() as tmpfile: - tmpfile.write(mip_resp.content) - return cls.from_json(tmpfile.name) + if config.preferred_source_remote_datasets == "origin": + return cls.from_nist_web() + else: + mip_resp = requests.get(config.fips_mip_latest_snapshot) + if mip_resp.status_code != 200: + raise ValueError(f"Getting MIP snapshot failed: {mip_resp.status_code}") + with NamedTemporaryFile() as tmpfile: + tmpfile.write(mip_resp.content) + return cls.from_json(tmpfile.name) diff --git a/src/sec_certs/sample/protection_profile.py b/src/sec_certs/sample/protection_profile.py index 4c26a1c7..ad5cea25 100644 --- a/src/sec_certs/sample/protection_profile.py +++ b/src/sec_certs/sample/protection_profile.py @@ -1,55 +1,366 @@ from __future__ import annotations -import copy -import logging -from dataclasses import dataclass -from typing import Any +from dataclasses import dataclass, field +from datetime import date, datetime +from pathlib import Path +from typing import Any, Literal +from urllib.parse import unquote_plus, urlparse +import requests +from bs4 import Tag + +import sec_certs.utils.extract +import sec_certs.utils.pdf +from sec_certs import constants +from sec_certs.cert_rules import cc_rules +from sec_certs.configuration import config +from sec_certs.sample.certificate import Certificate, logger +from sec_certs.sample.certificate import Heuristics as BaseHeuristics +from sec_certs.sample.certificate import PdfData as BasePdfData +from sec_certs.sample.document_state import DocumentState from sec_certs.serialization.json import ComplexSerializableType -from sec_certs.utils import sanitization +from sec_certs.utils import cc_html_parsing, helpers, sanitization -logger = logging.getLogger(__name__) +class ProtectionProfile( + Certificate["ProtectionProfile", "ProtectionProfile.Heuristics", "ProtectionProfile.PdfData"], + ComplexSerializableType, +): + @dataclass + class Heuristics(BaseHeuristics, ComplexSerializableType): + pass -@dataclass(frozen=True) -class ProtectionProfile(ComplexSerializableType): - """ - Object for holding protection profiles. - """ + @dataclass + class PdfData(BasePdfData, ComplexSerializableType): + """ + Class to hold data related to PDF and txt files related to protection profiles. + """ - pp_name: str - pp_eal: str | None - pp_link: str | None = None - pp_ids: frozenset[str] | None = None + report_metadata: dict[str, Any] | None = field(default=None) + pp_metadata: dict[str, Any] | None = field(default=None) + report_keywords: dict[str, Any] | None = field(default=None) + pp_keywords: dict[str, Any] | None = field(default=None) + report_filename: str | None = field(default=None) + pp_filename: str | None = field(default=None) - def __post_init__(self): - super().__setattr__("pp_name", sanitization.sanitize_string(self.pp_name)) - super().__setattr__("pp_link", sanitization.sanitize_link(self.pp_link)) + def __bool__(self) -> bool: + return any(x is not None for x in vars(self)) - @classmethod - def from_dict(cls, dct: dict[str, Any]) -> ProtectionProfile: - new_dct = copy.deepcopy(dct) - new_dct["pp_ids"] = frozenset(new_dct["pp_ids"]) if new_dct["pp_ids"] else None - return cls(*tuple(new_dct.values())) + @dataclass(eq=True) + class WebData(ComplexSerializableType): + """ + Class to hold metadata about protection profiles found on commoncriteriaportal.org + """ + + category: str + status: Literal["active", "archived"] + is_collaborative: bool + name: str + version: str + security_level: set[str] + not_valid_before: date | None + not_valid_after: date | None + report_link: str | None + pp_link: str | None + scheme: str | None + maintenances: list[tuple[Any]] + + @property + def eal(self) -> str | None: + return helpers.choose_lowest_eal(self.security_level) + + @classmethod + def from_html_row( + cls, row: Tag, status: Literal["active", "archived"], category: str, is_collaborative: bool + ) -> ProtectionProfile.WebData: + """ + Given bs4 tag of html row (fetched from cc portal), will build the object. + """ + if is_collaborative: + return cls._from_html_row_collaborative(row, category) + return cls._from_html_row_classic_pp(row, status, category) + + @classmethod + def _from_html_row_classic_pp( + cls, row: Tag, status: Literal["active", "archived"], category: str + ) -> ProtectionProfile.WebData: + cells = list(row.find_all("td")) + if status == "active" and len(cells) != 6: + raise ValueError( + f"Unexpected number of <td> elements in PP html row. Expected: 6, actual: {len(cells)}" + ) + if status == "archived" and len(cells) != 7: + raise ValueError( + f"Unexpected number of <td> elements in PP html row. Expected: 6, actual: {len(cells)}" + ) + + pp_link = cls._html_row_get_link(cells[0]) + pp_name = cls._html_row_get_name(cells[0]) + if not sanitization.sanitize_cc_link(pp_link): + raise ValueError(f"pp_link for PP {pp_name} is empty, cannot create PP record") + + mu_div = cc_html_parsing.html_row_get_maintenance_div(row) + maintenance_updates = cc_html_parsing.parse_maintenance_div(mu_div) if mu_div else [] + if maintenance_updates: + # Drop ST links, not filled in for PPs + maintenance_updates = [x[:3] for x in maintenance_updates] + + return cls( + category, + status, + False, + pp_name, + cls._html_row_get_version(cells[1]), + cls._html_row_get_security_level(cells[2]), + cls._html_row_get_date(cells[3]), + None if status == "active" else cls._html_row_get_date(cells[4]), + cls._html_row_get_link(cells[-1]), + pp_link, + cls._html_row_get_scheme(cells[-2]), + maintenance_updates, + ) + + @classmethod + def _from_html_row_collaborative(cls, row: Tag, category: str) -> ProtectionProfile.WebData: + cells = list(row.find_all("td")) + if len(cells) != 5: + raise ValueError( + f"Unexpected number of <td> elements in collaborative PP html row. Expected: 5, actual: {len(cells)}" + ) + + pp_link = cls._html_row_get_collaborative_pp_link(cells[0]) + pp_name = cls._html_row_get_collaborative_name(cells[0]) + if not sanitization.sanitize_cc_link(pp_link): + raise ValueError(f"pp_link for PP {pp_name} is empty, cannot create PP record") + + return cls( + category, + "active", + True, + pp_name, + cls._html_row_get_version(cells[1]), + cls._html_row_get_security_level(cells[2]), + cls._html_row_get_date(cells[3]), + None, + cls._html_row_get_link(cells[-1]), + pp_link, + None, + [], + ) + + @staticmethod + def _html_row_get_date(cell: Tag) -> date | None: + text = cell.get_text() + extracted_date = datetime.strptime(text, "%Y-%m-%d").date() if text else None + return extracted_date + + @staticmethod + def _html_row_get_name(cell: Tag) -> str: + return cell.find_all("a")[0].string + + @staticmethod + def _html_row_get_link(cell: Tag) -> str: + return constants.CC_PORTAL_BASE_URL + cell.find_all("a")[0].get("href") + + @staticmethod + def _html_row_get_version(cell: Tag) -> str: + return cell.text + + @staticmethod + def _html_row_get_security_level(cell: Tag) -> set[str]: + return set(cell.stripped_strings) + + @staticmethod + def _html_row_get_scheme(cell: Tag) -> str | None: + schemes = list(cell.stripped_strings) + return schemes[0] if schemes else None + + @staticmethod + def _html_row_get_collaborative_name(cell: Tag) -> str: + return list(cell.stripped_strings)[0] + + @staticmethod + def _html_row_get_collaborative_pp_link(cell: Tag) -> str: + return constants.CC_PORTAL_BASE_URL + [x for x in cell.find_all("a") if x.string == "Protection Profile"][ + 0 + ].get("href") + + @dataclass + class InternalState(ComplexSerializableType): + """ + Class to hold internal state for each of the documents. + """ + + pp: DocumentState = field(default_factory=DocumentState) + report: DocumentState = field(default_factory=DocumentState) + + def __init__( + self, + web_data: WebData, + pdf_data: PdfData | None = None, + heuristics: Heuristics | None = None, + state: InternalState | None = None, + ): + super().__init__() + self.web_data: ProtectionProfile.WebData = web_data + self.pdf_data: ProtectionProfile.PdfData = pdf_data if pdf_data else ProtectionProfile.PdfData() + self.heuristics: ProtectionProfile.Heuristics = heuristics if heuristics else ProtectionProfile.Heuristics() + self.state: ProtectionProfile.InternalState = state if state else ProtectionProfile.InternalState() + + @property + def dgst(self) -> str: + """ + digest of thwe protection profile, formed as first 16 bytes of `category|name|version` fields from `WebData` object. + """ + return helpers.get_first_16_bytes_sha256( + "|".join([self.web_data.category, self.web_data.name, self.web_data.version]) + ) + + @property + def label_studio_title(self) -> str: + return self.web_data.name + + def merge(self, other: ProtectionProfile, other_source: str | None = None) -> None: + raise ValueError("Merging of PPs not implemented.") + + def set_local_paths( + self, + report_pdf_dir: str | Path | None, + pp_pdf_dir: str | Path | None, + report_txt_dir: str | Path | None, + pp_txt_dir: str | Path | None, + ) -> None: + """ + Adjusts local paths for various files. + """ + if report_pdf_dir: + self.state.report.pdf_path = Path(report_pdf_dir) / f"{self.dgst}.pdf" + if pp_pdf_dir: + self.state.pp.pdf_path = Path(pp_pdf_dir) / f"{self.dgst}.pdf" + if report_txt_dir: + self.state.report.txt_path = Path(report_txt_dir) / f"{self.dgst}.txt" + if pp_txt_dir: + self.state.pp.txt_path = Path(pp_txt_dir) / f"{self.dgst}.txt" @classmethod - def from_old_api_dict(cls, dct: dict[str, Any]) -> ProtectionProfile: - pp_name = sanitization.sanitize_string(dct["csv_scan"]["cc_pp_name"]) - pp_link = sanitization.sanitize_link(dct["csv_scan"]["link_pp_document"]) - pp_ids = frozenset(dct["processed"]["cc_pp_csvid"]) if dct["processed"]["cc_pp_csvid"] else None - eal_set = sanitization.sanitize_security_levels(dct["csv_scan"]["cc_security_level"]) + def from_html_row( + cls, row: Tag, status: Literal["active", "archived"], category: str, is_collaborative: bool + ) -> ProtectionProfile: + """ + Builds a `ProtectionProfile` object from html row obtained from cc portal html source. + """ + return cls(ProtectionProfile.WebData.from_html_row(row, status, category, is_collaborative)) + + @staticmethod + def download_pdf_report(cert: ProtectionProfile) -> ProtectionProfile: + """ + Downloads pdf of certification report for the given protection profile. + """ + exit_code: str | int + if not cert.web_data.report_link: + exit_code = "No link" + else: + exit_code = helpers.download_file( + cert.web_data.report_link, cert.state.report.pdf_path, proxy=config.cc_use_proxy + ) + if exit_code != requests.codes.ok: + error_msg = f"failed to download report from {cert.web_data.report_link}, code: {exit_code}" + logger.error(f"Cert dgst: {cert.dgst} " + error_msg) + cert.state.report.download_ok = False + else: + cert.state.report.download_ok = True + cert.state.report.pdf_hash = helpers.get_sha256_filepath(cert.state.report.pdf_path) + cert.pdf_data.report_filename = unquote_plus(str(urlparse(cert.web_data.report_link).path).split("/")[-1]) + return cert + + @staticmethod + def download_pdf_pp(cert: ProtectionProfile) -> ProtectionProfile: + """ + Downloads actual pdf of the given protection profile. + """ + exit_code: str | int + if not cert.web_data.pp_link: + exit_code = "No link" + else: + exit_code = helpers.download_file(cert.web_data.pp_link, cert.state.pp.pdf_path, proxy=config.cc_use_proxy) + if exit_code != requests.codes.ok: + error_msg = f"failed to download PP from {cert.web_data.pp_link}, code: {exit_code}" + logger.error(f"Cert dgst: {cert.dgst} " + error_msg) + cert.state.pp.download_ok = False + else: + cert.state.pp.download_ok = True + cert.state.pp.pdf_hash = helpers.get_sha256_filepath(cert.state.pp.pdf_path) + cert.pdf_data.pp_filename = unquote_plus(str(urlparse(cert.web_data.pp_link).path).split("/")[-1]) + return cert + + @staticmethod + def convert_report_pdf(cert: ProtectionProfile) -> ProtectionProfile: + """ + Converts certification reports from pdf to txt. + """ + ocr_done, ok_result = sec_certs.utils.pdf.convert_pdf_file( + cert.state.report.pdf_path, cert.state.report.txt_path + ) + cert.state.report.convert_garbage = ocr_done + cert.state.report.convert_ok = ok_result + if not ok_result: + logger.error(f"Cert dgst: {cert.dgst} failed to convert report pdf to txt") + else: + cert.state.report.txt_hash = helpers.get_sha256_filepath(cert.state.report.txt_path) + return cert - if not len(eal_set) <= 1: - raise ValueError("EAL field should have single value or should be empty.") + @staticmethod + def convert_pp_pdf(cert: ProtectionProfile) -> ProtectionProfile: + """ + Converts the actual protection profile from pdf to txt. + """ + ocr_done, ok_result = sec_certs.utils.pdf.convert_pdf_file(cert.state.pp.pdf_path, cert.state.pp.txt_path) + cert.state.pp.convert_garbage = ocr_done + cert.state.pp.convert_ok = ok_result + if not ok_result: + logger.error(f"Cert dgst: {cert.dgst} failed to convert PP pdf to txt") + else: + cert.state.pp.txt_hash = helpers.get_sha256_filepath(cert.state.pp.txt_path) + return cert - eal_str = list(eal_set)[0] if eal_set else None + @staticmethod + def extract_report_pdf_metadata(cert: ProtectionProfile) -> ProtectionProfile: + """ + Extracts various pdf metadata from the certification report. + """ + response, cert.pdf_data.report_metadata = sec_certs.utils.pdf.extract_pdf_metadata(cert.state.report.pdf_path) + cert.state.report.extract_ok = response == constants.RETURNCODE_OK + return cert - return cls(pp_name, eal_str, pp_link, pp_ids) + @staticmethod + def extract_pp_pdf_metadata(cert: ProtectionProfile) -> ProtectionProfile: + """ + Extracts various pdf metadata from the actual protection profile. + """ + response, cert.pdf_data.pp_metadata = sec_certs.utils.pdf.extract_pdf_metadata(cert.state.pp.pdf_path) + cert.state.pp.extract_ok = response == constants.RETURNCODE_OK + return cert - def __eq__(self, other: object) -> bool: - if not isinstance(other, ProtectionProfile): - return False - return self.pp_name == other.pp_name and self.pp_link == other.pp_link + @staticmethod + def extract_report_pdf_keywords(cert: ProtectionProfile) -> ProtectionProfile: + """ + Extracts keywords using regexes from the certification report. + """ + report_keywords = sec_certs.utils.extract.extract_keywords(cert.state.report.txt_path, cc_rules) + if report_keywords is None: + cert.state.report.extract_ok = False + else: + cert.pdf_data.report_keywords = report_keywords + return cert - def __lt__(self, other: ProtectionProfile) -> bool: - return self.pp_name < other.pp_name + @staticmethod + def extract_pp_pdf_keywords(cert: ProtectionProfile) -> ProtectionProfile: + """ + Extracts keywords using regexes from the actual protection profile. + """ + pp_keywords = sec_certs.utils.extract.extract_keywords(cert.state.pp.txt_path, cc_rules) + if pp_keywords is None: + cert.state.pp.extract_ok = False + else: + cert.pdf_data.pp_keywords = pp_keywords + return cert diff --git a/src/sec_certs/utils/cc_html_parsing.py b/src/sec_certs/utils/cc_html_parsing.py new file mode 100644 index 00000000..956353bf --- /dev/null +++ b/src/sec_certs/utils/cc_html_parsing.py @@ -0,0 +1,38 @@ +import logging +from datetime import datetime +from typing import Any + +from bs4 import Tag + +from sec_certs import constants + +logger = logging.getLogger(__name__) + + +def html_row_get_maintenance_div(cell: Tag) -> Tag | None: + divs = cell.find_all("div") + for d in divs: + if d.find("div") and d.stripped_strings and list(d.stripped_strings)[0] == "Maintenance Report(s)": + return d + return None + + +def parse_maintenance_div(main_div: Tag) -> list[tuple[Any, ...]]: + possible_updates = list(main_div.find_all("li")) + maintenance_updates = set() + for u in possible_updates: + text = list(u.stripped_strings)[0] + main_date = datetime.strptime(text.split(" ")[0], "%Y-%m-%d").date() if text else None + main_title = text.split("– ")[1] + main_report_link = None + main_st_link = None + links = u.find_all("a") + for link in links: + if link.get("title").startswith("Maintenance Report:"): + main_report_link = constants.CC_PORTAL_BASE_URL + link.get("href") + elif link.get("title").startswith("Maintenance ST"): + main_st_link = constants.CC_PORTAL_BASE_URL + link.get("href") + else: + logger.error("Unknown link in Maintenance part!") + maintenance_updates.add((main_date, main_title, main_report_link, main_st_link)) + return list(maintenance_updates) diff --git a/src/sec_certs/utils/helpers.py b/src/sec_certs/utils/helpers.py index 6a6a9954..d34e9f0e 100644 --- a/src/sec_certs/utils/helpers.py +++ b/src/sec_certs/utils/helpers.py @@ -264,7 +264,18 @@ def choose_lowest_eal(eals: set[str] | None) -> str | None: if not eals: return None - matches = [(re.search(r"\d+", x)) for x in eals] - min_number = min([int(x.group()) for x in matches if x]) - candidates = [x for x in eals if str(min_number) in x] - return "EAL" + str(min_number) if len(candidates) == 2 else candidates[0] + eal_pattern = re.compile(r"(EAL(\d+)\+?)") + eal_entries = [] + + for s in eals: + match = eal_pattern.search(s) + if match: + full_match = match.group(1) + number = int(match.group(2)) + has_plus = "+" in full_match + eal_entries.append((number, has_plus, full_match)) + + if eal_entries: + eal_entries.sort(key=lambda x: (x[0], x[1])) + return eal_entries[0][2] + return None diff --git a/src/sec_certs/utils/label_studio_utils.py b/src/sec_certs/utils/label_studio_utils.py new file mode 100644 index 00000000..9c70b938 --- /dev/null +++ b/src/sec_certs/utils/label_studio_utils.py @@ -0,0 +1,78 @@ +import json +import logging +from pathlib import Path + +from tqdm import tqdm + +from sec_certs.configuration import config +from sec_certs.dataset.auxiliary_dataset_handling import CPEDatasetHandler +from sec_certs.dataset.dataset import Dataset +from sec_certs.sample.cpe import CPE + +logger = logging.getLogger(__name__) + + +def to_label_studio_json(dataset: Dataset, output_path: str | Path) -> None: + dataset.load_auxiliary_datasets() + cpe_dset = dataset.aux_handlers[CPEDatasetHandler].dset + + lst = [] + for cert in [x for x in dataset 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)) + options = ["option_" + str(x) for x in range(1, config.cpe_n_max_matches)] + dct.update(dict(zip(options, candidates))) + lst.append(dct) + + with Path(output_path).open("w") as handle: + json.dump(lst, handle, indent=4) + + +def load_label_studio_labels(dataset: Dataset, input_path: str | Path) -> set[str]: + with Path(input_path).open("r") as handle: + data = json.load(handle) + + dataset.load_auxiliary_datasets() + cpe_dset = dataset.aux_handlers[CPEDatasetHandler].dset + 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.") + for annotation in tqdm(data, desc="Translating label studio matches"): + cpe_candidate_keys = {key for key in annotation if "option_" in key and annotation[key] != "No good match"} + + if "verified_cpe_match" not in annotation: + incorrect_keys: set[str] = set() + elif isinstance(annotation["verified_cpe_match"], str): + incorrect_keys = {annotation["verified_cpe_match"]} + else: + incorrect_keys = set(annotation["verified_cpe_match"]["choices"]) + + incorrect_keys = {x.lstrip("$") for x in incorrect_keys} + predicted_annotations = {annotation[x] for x in cpe_candidate_keys - incorrect_keys} + + cpes: set[CPE] = set() + for x in predicted_annotations: + if x not in title_to_cpes_dict: + logger.error(f"{x} not in dataset") + else: + to_update = title_to_cpes_dict[x] + if to_update and not cpes: + cpes = to_update + elif to_update and cpes: + cpes.update(to_update) + + # distinguish between FIPS and CC + if "\n" in annotation["text"]: + cert_name = annotation["text"].split("\nModule name: ")[1].split("\n")[0] + else: + cert_name = annotation["text"] + + certs = dataset.get_certs_by_name(cert_name) + labeled_cert_digests.update({x.dgst for x in certs}) + + for c in certs: + c.heuristics.verified_cpe_matches = {x.uri for x in cpes if x is not None} if cpes else None + + return labeled_cert_digests diff --git a/src/sec_certs/utils/nvd_dataset_builder.py b/src/sec_certs/utils/nvd_dataset_builder.py index 08f65d30..4e7162eb 100644 --- a/src/sec_certs/utils/nvd_dataset_builder.py +++ b/src/sec_certs/utils/nvd_dataset_builder.py @@ -16,13 +16,13 @@ import requests from requests import RequestException, Response from sec_certs import constants -from sec_certs.dataset.cpe import CPEDataset +from sec_certs.dataset.cpe import CPEDataset, CPEMatchDict 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) +DatasetType = TypeVar("DatasetType", CPEDataset, CVEDataset, CPEMatchDict) @dataclass @@ -320,7 +320,7 @@ class CveNvdDatasetBuilder(NvdDatasetBuilder[CVEDataset]): return CVEDataset() -class CpeMatchNvdDatasetBuilder(NvdDatasetBuilder[dict]): +class CpeMatchNvdDatasetBuilder(NvdDatasetBuilder[CPEMatchDict]): _ENDPOINT: Final[str] = "CPEMatch" _ENDPOINT_URL: Final[str] = "https://services.nvd.nist.gov/rest/json/cpematch/2.0" _RESULTS_PER_PAGE: Final[int] = 500 @@ -331,7 +331,7 @@ class CpeMatchNvdDatasetBuilder(NvdDatasetBuilder[dict]): "versionEndExcluding", ] - def _process_responses(self, responses: list[Response], dataset_to_fill: dict) -> dict: + def _process_responses(self, responses: list[Response], dataset_to_fill: CPEMatchDict) -> CPEMatchDict: 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 @@ -361,5 +361,5 @@ class CpeMatchNvdDatasetBuilder(NvdDatasetBuilder[dict]): return datetime.fromisoformat(previous_data["timestamp"]) @staticmethod - def _init_new_dataset() -> dict: - return {"timestamp": datetime.fromtimestamp(0).isoformat(), "match_strings": {}} + def _init_new_dataset() -> CPEMatchDict: + return CPEMatchDict({"timestamp": datetime.fromtimestamp(0).isoformat(), "match_strings": {}}) |
