aboutsummaryrefslogtreecommitdiffhomepage
path: root/src
diff options
context:
space:
mode:
authorJán Jančár2025-02-02 16:54:22 +0100
committerGitHub2025-02-02 16:54:22 +0100
commit1329b41f3cdacfeb67961638015e4ee9f1d47aad (patch)
tree94a9bf4c5befdfe88c05b8d6ccd118a024dd7ddd /src
parentd640ad1f1510f0dc6fa5d6e329e079256ef8d1be (diff)
parent996759d9461c6c261c41abda28a9184a5a5a7cbe (diff)
downloadsec-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')
-rw-r--r--src/sec_certs/cli.py22
-rw-r--r--src/sec_certs/configuration.py18
-rw-r--r--src/sec_certs/constants.py39
-rw-r--r--src/sec_certs/dataset/auxiliary_dataset_handling.py282
-rw-r--r--src/sec_certs/dataset/cc.py513
-rw-r--r--src/sec_certs/dataset/cc_scheme.py8
-rw-r--r--src/sec_certs/dataset/cpe.py10
-rw-r--r--src/sec_certs/dataset/dataset.py411
-rw-r--r--src/sec_certs/dataset/fips.py168
-rw-r--r--src/sec_certs/dataset/fips_iut.py2
-rw-r--r--src/sec_certs/dataset/fips_mip.py2
-rw-r--r--src/sec_certs/dataset/protection_profile.py422
-rw-r--r--src/sec_certs/heuristics/cc.py117
-rw-r--r--src/sec_certs/heuristics/common.py132
-rw-r--r--src/sec_certs/heuristics/fips.py42
-rw-r--r--src/sec_certs/sample/cc.py153
-rw-r--r--src/sec_certs/sample/certificate.py7
-rw-r--r--src/sec_certs/sample/document_state.py61
-rw-r--r--src/sec_certs/sample/fips_iut.py24
-rw-r--r--src/sec_certs/sample/fips_mip.py21
-rw-r--r--src/sec_certs/sample/protection_profile.py387
-rw-r--r--src/sec_certs/utils/cc_html_parsing.py38
-rw-r--r--src/sec_certs/utils/helpers.py19
-rw-r--r--src/sec_certs/utils/label_studio_utils.py78
-rw-r--r--src/sec_certs/utils/nvd_dataset_builder.py12
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": {}})