aboutsummaryrefslogtreecommitdiffhomepage
path: root/src
diff options
context:
space:
mode:
authoradamjanovsky2022-12-09 17:10:19 +0100
committerGitHub2022-12-09 17:10:19 +0100
commit73b3b0c361f9545450fa188bec50606d64bb1afd (patch)
tree0a1f9034c309ba88e5f72a31634b014c23a57df5 /src
parent19338dc9fd9ab257c36cfa277994abe202e97de2 (diff)
downloadsec-certs-73b3b0c361f9545450fa188bec50606d64bb1afd.tar.gz
sec-certs-73b3b0c361f9545450fa188bec50606d64bb1afd.tar.zst
sec-certs-73b3b0c361f9545450fa188bec50606d64bb1afd.zip
flat -> src layout (#294)
- Some mypy fixes - Flat layout -> src layout - Ditch `setup.py` and `setup.cfg` in favour of `pyproject.toml` - Non-pinned requirements moved from `requirements/*.in` to `pyproject.toml`
Diffstat (limited to 'src')
-rw-r--r--src/sec_certs/__init__.py7
-rw-r--r--src/sec_certs/__main__.py6
-rw-r--r--src/sec_certs/cert_rules.py248
-rw-r--r--src/sec_certs/cli.py211
-rw-r--r--src/sec_certs/config/__init__.py0
-rw-r--r--src/sec_certs/config/configuration.py44
-rw-r--r--src/sec_certs/config/settings-schema.json182
-rw-r--r--src/sec_certs/config/settings.yaml59
-rw-r--r--src/sec_certs/constants.py121
-rw-r--r--src/sec_certs/dataset/__init__.py22
-rw-r--r--src/sec_certs/dataset/common_criteria.py1674
-rw-r--r--src/sec_certs/dataset/cpe.py197
-rw-r--r--src/sec_certs/dataset/cve.py221
-rw-r--r--src/sec_certs/dataset/dataset.py572
-rw-r--r--src/sec_certs/dataset/fips.py355
-rw-r--r--src/sec_certs/dataset/fips_algorithm.py124
-rw-r--r--src/sec_certs/dataset/fips_iut.py66
-rw-r--r--src/sec_certs/dataset/fips_mip.py66
-rw-r--r--src/sec_certs/dataset/json_path_dataset.py46
-rw-r--r--src/sec_certs/dataset/protection_profile.py98
-rw-r--r--src/sec_certs/model/__init__.py11
-rw-r--r--src/sec_certs/model/cpe_matching.py404
-rw-r--r--src/sec_certs/model/evaluation.py101
-rw-r--r--src/sec_certs/model/reference_finder.py223
-rw-r--r--src/sec_certs/model/sar_transformer.py159
-rw-r--r--src/sec_certs/model/transitive_vulnerability_finder.py149
-rw-r--r--src/sec_certs/rules.yaml1186
-rw-r--r--src/sec_certs/sample/__init__.py33
-rw-r--r--src/sec_certs/sample/cc_certificate_id.py151
-rw-r--r--src/sec_certs/sample/cc_maintenance_update.py99
-rw-r--r--src/sec_certs/sample/certificate.py90
-rw-r--r--src/sec_certs/sample/common_criteria.py986
-rw-r--r--src/sec_certs/sample/cpe.py100
-rw-r--r--src/sec_certs/sample/cve.py185
-rw-r--r--src/sec_certs/sample/fips.py654
-rw-r--r--src/sec_certs/sample/fips_algorithm.py50
-rw-r--r--src/sec_certs/sample/fips_iut.py166
-rw-r--r--src/sec_certs/sample/fips_mip.py242
-rw-r--r--src/sec_certs/sample/protection_profile.py55
-rw-r--r--src/sec_certs/sample/sar.py59
-rw-r--r--src/sec_certs/serialization/__init__.py0
-rw-r--r--src/sec_certs/serialization/json.py145
-rw-r--r--src/sec_certs/serialization/pandas.py16
-rw-r--r--src/sec_certs/utils/__init__.py0
-rw-r--r--src/sec_certs/utils/extract.py817
-rw-r--r--src/sec_certs/utils/helpers.py239
-rw-r--r--src/sec_certs/utils/pandas.py542
-rw-r--r--src/sec_certs/utils/parallel_processing.py43
-rw-r--r--src/sec_certs/utils/pdf.py275
-rw-r--r--src/sec_certs/utils/sanitization.py51
-rw-r--r--src/sec_certs/utils/tables.py62
-rw-r--r--src/sec_certs/utils/tqdm.py9
52 files changed, 11621 insertions, 0 deletions
diff --git a/src/sec_certs/__init__.py b/src/sec_certs/__init__.py
new file mode 100644
index 00000000..816ea2b8
--- /dev/null
+++ b/src/sec_certs/__init__.py
@@ -0,0 +1,7 @@
+"""
+Tool for analysis of security certificates and their security targets (Common Criteria, NIST FIPS140-2...).
+Contains three main sub-packages:
+- dataset - package that holds the respective datasets and performs all processing of them
+- sample - package that holds a single sample (e.g., Common Criteria certificate - CommonCriteriaCert). Mostly data structure, but can provide basic functionality.
+- model - package that provides data pipelines (transformers, classifiers, ...) for complex transformations of datasets.
+"""
diff --git a/src/sec_certs/__main__.py b/src/sec_certs/__main__.py
new file mode 100644
index 00000000..1d699b85
--- /dev/null
+++ b/src/sec_certs/__main__.py
@@ -0,0 +1,6 @@
+import sys
+
+from sec_certs.cli import main
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/src/sec_certs/cert_rules.py b/src/sec_certs/cert_rules.py
new file mode 100644
index 00000000..145566f7
--- /dev/null
+++ b/src/sec_certs/cert_rules.py
@@ -0,0 +1,248 @@
+from __future__ import annotations
+
+import re
+from pathlib import Path
+from typing import Final
+
+import yaml
+
+# This ignores ACM and AMA SARs that are present in CC version 2
+SARS_IMPLIED_FROM_EAL: dict[str, set[tuple[str, int]]] = {
+ "EAL1": {
+ ("ADV_FSP", 1),
+ ("AGD_OPE", 1),
+ ("AGD_PRE", 1),
+ ("ALC_CMC", 1),
+ ("ALC_CMS", 1),
+ ("ASE_CCL", 1),
+ ("ASE_ECD", 1),
+ ("ASE_INT", 1),
+ ("ASE_OBJ", 1),
+ ("ASE_REQ", 1),
+ ("ASE_TSS", 1),
+ ("ATE_IND", 1),
+ ("AVA_VAN", 1),
+ },
+ "EAL2": {
+ ("ADV_ARC", 1),
+ ("ADV_TDS", 1),
+ ("AGD_OPE", 1),
+ ("AGD_PRE", 1),
+ ("ALC_CMC", 2),
+ ("ALC_CMS", 2),
+ ("ALC_DEL", 1),
+ ("ASE_CCL", 1),
+ ("ASE_ECD", 1),
+ ("ASE_INT", 1),
+ ("ASE_OBJ", 2),
+ ("ASE_REQ", 2),
+ ("ASE_SPD", 1),
+ ("ASE_TSS", 1),
+ ("ATE_COV", 1),
+ ("ATE_FUN", 1),
+ ("ATE_IND", 2),
+ ("AVA_VAN", 2),
+ },
+ "EAL3": {
+ ("ADV_ARC", 1),
+ ("ADV_FSP", 3),
+ ("ADV_TDS", 2),
+ ("AGD_PRE", 1),
+ ("ALC_CMC", 3),
+ ("ALC_CMS", 3),
+ ("ALC_DEL", 1),
+ ("ALC_DVS", 1),
+ ("ALC_LCD", 1),
+ ("ASE_CCL", 1),
+ ("ASE_ECD", 1),
+ ("ASE_INT", 1),
+ ("ASE_OBJ", 2),
+ ("ASE_REQ", 2),
+ ("ASE_SPD", 1),
+ ("ASE_TSS", 1),
+ ("ATE_COV", 2),
+ ("ATE_DPT", 1),
+ ("ATE_FUN", 1),
+ ("ATE_IND", 2),
+ ("AVA_VAN", 2),
+ },
+ "EAL4": {
+ ("ADV_ARC", 1),
+ ("ADV_FSP", 4),
+ ("ADV_IMP", 1),
+ ("ADV_TDS", 3),
+ ("AGD_OPE", 1),
+ ("AGD_PRE", 1),
+ ("ALC_CMC", 4),
+ ("ALC_CMS", 4),
+ ("ALC_DEL", 1),
+ ("ALC_DVS", 1),
+ ("ALC_LCD", 1),
+ ("ALC_TAT", 1),
+ ("ASE_CCL", 1),
+ ("ASE_ECD", 1),
+ ("ASE_INT", 1),
+ ("ASE_OBJ", 2),
+ ("ASE_REQ", 2),
+ ("ASE_SPD", 1),
+ ("ASE_TSS", 1),
+ ("ATE_COV", 2),
+ ("ATE_DPT", 1),
+ ("ATE_FUN", 1),
+ ("ATE_IND", 2),
+ ("AVA_VAN", 3),
+ },
+ "EAL5": {
+ ("ADV_ARC", 1),
+ ("ADV_FSP", 5),
+ ("ADV_IMP", 1),
+ ("ADV_INT", 2),
+ ("ADV_TDS", 4),
+ ("AGD_OPE", 1),
+ ("AGD_PRE", 1),
+ ("ALC_CMC", 4),
+ ("ALC_CMS", 5),
+ ("ALC_DEL", 1),
+ ("ALC_DVS", 1),
+ ("ALC_LCD", 1),
+ ("ALC_TAT", 2),
+ ("ASE_CCL", 1),
+ ("ASE_ECD", 1),
+ ("ASE_INT", 1),
+ ("ASE_OBJ", 2),
+ ("ASE_REQ", 2),
+ ("ASE_SPD", 1),
+ ("ASE_TSS", 1),
+ ("ATE_COV", 2),
+ ("ATE_DPT", 3),
+ ("ATE_FUN", 1),
+ ("ATE_IND", 2),
+ ("AVA_VAN", 4),
+ },
+ "EAL6": {
+ ("ADV_ARC", 1),
+ ("ADV_FSP", 5),
+ ("ADV_IMP", 2),
+ ("ADV_INT", 3),
+ ("ADV_SPM", 1),
+ ("ADV_TDS", 5),
+ ("AGD_OPE", 1),
+ ("AGD_PRE", 1),
+ ("ALC_CMC", 5),
+ ("ALC_CMS", 5),
+ ("ALC_DEL", 1),
+ ("ALC_DVS", 2),
+ ("ALC_LCD", 1),
+ ("ALC_TAT", 3),
+ ("ASE_CCL", 1),
+ ("ASE_ECD", 1),
+ ("ASE_INT", 1),
+ ("ASE_OBJ", 2),
+ ("ASE_REQ", 2),
+ ("ASE_SPD", 1),
+ ("ASE_TSS", 1),
+ ("ATE_COV", 3),
+ ("ATE_DPT", 3),
+ ("ATE_FUN", 2),
+ ("ATE_IND", 2),
+ ("AVA_VAN", 5),
+ },
+ "EAL7": {
+ ("ADV_ARC", 1),
+ ("ADV_FSP", 6),
+ ("ADV_IMP", 2),
+ ("ADV_INT", 3),
+ ("ADV_SPM", 1),
+ ("ADV_TDS", 6),
+ ("AGD_OPE", 1),
+ ("AGD_PRE", 1),
+ ("ALC_CMC", 5),
+ ("ALC_CMS", 5),
+ ("ALC_DEL", 1),
+ ("ALC_DVS", 2),
+ ("ALC_LCD", 2),
+ ("ALC_TAT", 3),
+ ("ASE_CCL", 1),
+ ("ASE_ECD", 1),
+ ("ASE_INT", 1),
+ ("ASE_OBJ", 2),
+ ("ASE_REQ", 2),
+ ("ASE_SPD", 1),
+ ("ASE_TSS", 1),
+ ("ATE_COV", 3),
+ ("ATE_DPT", 4),
+ ("ATE_FUN", 2),
+ ("ATE_IND", 3),
+ ("AVA_VAN", 5),
+ },
+}
+
+security_level_csv_scan = r"EAL[1-7]\+?"
+
+
+REGEXEC_SEP = "[ ,;\\[\\]”\"')(.]"
+MATCH_START = "(?P<match>"
+MATCH_END = ")"
+REGEXEC_SEP_START = f"(?:^|{REGEXEC_SEP})"
+REGEXEC_SEP_END = f"(?:$|{REGEXEC_SEP})"
+
+
+SERVICE_PACK_RE = re.compile(r"(?:sp|service pack)\s{0,1}\d{1,2}", re.IGNORECASE)
+RELEASE_RE = re.compile(r"(?:r|release)\s{0,1}\d{1,2}", re.IGNORECASE)
+PLATFORM_REGEXES = {
+ "linux": re.compile(r"linux", re.IGNORECASE),
+ "mac_os": re.compile(r"mac\s?os\s?x?", re.IGNORECASE),
+ "windows": re.compile(r"windows", re.IGNORECASE),
+ "android": re.compile(r"android", re.IGNORECASE),
+ "ios": re.compile(r"(ios|iphone os)", re.IGNORECASE),
+}
+
+FIPS_ALGS_IN_TABLE = r"(?:#[CcAa]?\s?|(?:Cert)\.?[^. ]*?\s?)(?:[CcAa]\s)?(?P<id>\d+)"
+FIPS_LIST_OF_TABLES = re.compile(r"^(?:(?:[Tt]able\s|[Ll]ist\s)(?:[Oo]f\s))[Tt]ables[\s\S]+?\f", re.MULTILINE)
+
+
+def _load():
+ script_dir = Path(__file__).parent
+ filepath = script_dir / "rules.yaml"
+ with Path(filepath).open("r") as file:
+ loaded = yaml.load(file, Loader=yaml.FullLoader)
+ return loaded
+
+
+def _process(obj):
+ if isinstance(obj, dict):
+ return {k: _process(v) for k, v in obj.items()}
+ elif isinstance(obj, list):
+ return [
+ re.compile(
+ REGEXEC_SEP_START + MATCH_START + rule + MATCH_END + REGEXEC_SEP_END,
+ re.MULTILINE,
+ )
+ for rule in obj
+ ]
+
+
+rules = _load()
+
+cc_rules = {}
+for rule_group in rules["cc_rules"]:
+ cc_rules[rule_group] = _process(rules[rule_group])
+
+fips_rules = {}
+for rule_group in rules["fips_rules"]:
+ fips_rules[rule_group] = _process(rules[rule_group])
+
+
+PANDAS_KEYWORDS_CATEGORIES: Final[list[str]] = [
+ "symmetric_crypto",
+ "asymmetric_crypto",
+ "pq_crypto",
+ "hash_function",
+ "crypto_scheme",
+ "crypto_protocol",
+ "randomness",
+ "cipher_mode",
+ "ecc_curve",
+ "crypto_engine",
+ "crypto_library",
+]
diff --git a/src/sec_certs/cli.py b/src/sec_certs/cli.py
new file mode 100644
index 00000000..a25b55ee
--- /dev/null
+++ b/src/sec_certs/cli.py
@@ -0,0 +1,211 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import logging
+import sys
+from dataclasses import dataclass, field
+from datetime import datetime
+from pathlib import Path
+from typing import Callable
+
+import click
+
+from sec_certs import constants
+from sec_certs.config.configuration import config
+from sec_certs.dataset import CCDataset, FIPSDataset
+from sec_certs.utils.helpers import warn_if_missing_poppler, warn_if_missing_tesseract
+
+logger = logging.getLogger(__name__)
+
+EXIT_CODE_NOK: int = 1
+EXIT_CODE_OK: int = 0
+
+
+@dataclass
+class ProcessingStep:
+ name: str
+ processing_function_name: str
+ preconditions: list[str] = field(default_factory=list)
+ precondition_error_msg: str | None = field(default=None)
+ pre_callback_func: Callable | None = field(default=None)
+
+ def run(self, dset: CCDataset | FIPSDataset) -> None:
+ for condition in self.preconditions:
+ if not getattr(dset.state, condition):
+ err_msg = (
+ self.precondition_error_msg
+ if self.precondition_error_msg
+ else f"Error, precondition to run {self.name} not met, exiting."
+ )
+ click.echo(err_msg, err=True)
+ sys.exit(EXIT_CODE_NOK)
+ if self.pre_callback_func:
+ self.pre_callback_func()
+
+ getattr(dset, self.processing_function_name)()
+
+
+def warn_missing_libs():
+ warn_if_missing_poppler()
+ warn_if_missing_tesseract()
+
+
+def build_or_load_dataset(
+ framework: str,
+ inputpath: Path | None,
+ to_build: bool,
+ outputpath: Path = constants.DUMMY_NONEXISTING_PATH,
+) -> CCDataset | FIPSDataset:
+ constructor: type[CCDataset] | type[FIPSDataset] = CCDataset if framework == "cc" else FIPSDataset
+ dset: CCDataset | FIPSDataset
+
+ if to_build:
+ if inputpath:
+ print(
+ f"Warning: you wanted to build a dataset but you provided one in JSON -- that will be ignored. New one will be constructed at: {outputpath}"
+ )
+ dset = constructor(
+ certs={},
+ root_dir=outputpath,
+ name=framework + "_dataset",
+ description=f"Full {framework} dataset snapshot {datetime.now().date()}",
+ )
+ dset.get_certs_from_web()
+ else:
+ if inputpath:
+ dset = constructor.from_json(inputpath)
+ if outputpath and dset.root_dir != outputpath:
+ print(
+ "Warning: you provided both input and output paths. The dataset from input path will get copied to output path."
+ )
+ dset.copy_dataset(outputpath)
+ else:
+ click.echo(
+ "Error: If you do not use 'build' action, you must provide --input parameter to point to an existing dataset.",
+ err=True,
+ )
+ sys.exit(EXIT_CODE_NOK)
+
+ return dset
+
+
+@click.command()
+@click.argument(
+ "framework",
+ required=True,
+ nargs=1,
+ type=click.Choice(["cc", "fips"], case_sensitive=False),
+)
+@click.argument(
+ "actions",
+ required=True,
+ nargs=-1,
+ type=click.Choice(["all", "build", "process-aux-dsets", "download", "convert", "analyze"], case_sensitive=False),
+)
+@click.option(
+ "-o",
+ "--output",
+ type=click.Path(file_okay=False, dir_okay=True, writable=True, readable=True, resolve_path=True),
+ help="Path where the output of the experiment will be stored. May overwrite existing content.",
+ default=Path("./dataset/"),
+ show_default=True,
+)
+@click.option(
+ "-c",
+ "--config",
+ "configpath",
+ default=None,
+ type=click.Path(file_okay=True, dir_okay=False, writable=True, readable=True),
+ help="Path to your own config yaml file that will override the default one.",
+)
+@click.option(
+ "-i",
+ "--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.",
+)
+@click.option("-q", "--quiet", is_flag=True, help="If set, will not print to stdout")
+def main(
+ framework: str,
+ actions: list[str],
+ outputpath: Path,
+ configpath: str | None,
+ inputpath: Path | None,
+ silent: bool,
+):
+ try:
+ file_handler = logging.FileHandler(config.log_filepath)
+ stream_handler = logging.StreamHandler(sys.stderr)
+ formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
+ file_handler.setFormatter(formatter)
+ stream_handler.setFormatter(formatter)
+ handlers: list[logging.StreamHandler] = [file_handler] if silent else [file_handler, stream_handler]
+ logging.basicConfig(level=logging.INFO, handlers=handlers)
+ start = datetime.now()
+
+ if configpath:
+ try:
+ config.load(Path(configpath))
+ except FileNotFoundError:
+ click.echo("Error: Bad path to configuration file", err=True)
+ sys.exit(EXIT_CODE_NOK)
+ except ValueError as e:
+ click.echo(f"Error: Bad format of configuration file: {e}", err=True)
+ sys.exit(EXIT_CODE_NOK)
+
+ actions_set = (
+ {"build", "process-aux-dsets", "download", "convert", "analyze", "maintenances"}
+ if "all" in actions
+ else set(actions)
+ )
+
+ 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"
+ analysis_pre_callback = None
+
+ steps = [
+ ProcessingStep(
+ "process-aux-dsets",
+ "process_auxillary_datasets",
+ preconditions=["meta_sources_parsed"],
+ precondition_error_msg=f"Error: You want to process the auxillary datasets: {aux_dsets_to_handle} , but the data from cert. framework website was not parsed. You must use 'build' action first.",
+ pre_callback_func=None,
+ ),
+ ProcessingStep(
+ "download",
+ "download_all_artifacts",
+ preconditions=["meta_sources_parsed"],
+ precondition_error_msg="Error: You want to download all artifacts, but the data from the cert. framework website was not parsed. You must use 'build' action first.",
+ pre_callback_func=None,
+ ),
+ ProcessingStep(
+ "convert",
+ "convert_all_pdfs",
+ preconditions=["pdfs_downloaded"],
+ precondition_error_msg="Error: You want to convert pdfs -> txt, but the pdfs were not downloaded. You must use 'download' action first.",
+ pre_callback_func=warn_missing_libs,
+ ),
+ ProcessingStep(
+ "analyze",
+ "analyze_certificates",
+ preconditions=["pdfs_converted", "auxillary_datasets_processed"],
+ precondition_error_msg="Error: You want to process txt documents of certificates, but pdfs were not converted. You must use 'convert' action first.",
+ pre_callback_func=analysis_pre_callback,
+ ),
+ ]
+
+ processing_step: ProcessingStep
+ for processing_step in [x for x in steps if x in actions_set]:
+ processing_step.run(dset)
+
+ end = datetime.now()
+ logger.info(f"The computation took {(end-start)} seconds.")
+ except Exception:
+ return EXIT_CODE_NOK
+ return EXIT_CODE_OK
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/sec_certs/config/__init__.py b/src/sec_certs/config/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/src/sec_certs/config/__init__.py
diff --git a/src/sec_certs/config/configuration.py b/src/sec_certs/config/configuration.py
new file mode 100644
index 00000000..43cfe698
--- /dev/null
+++ b/src/sec_certs/config/configuration.py
@@ -0,0 +1,44 @@
+from __future__ import annotations
+
+import json
+from pathlib import Path
+from typing import Any
+
+import jsonschema
+import yaml
+
+
+class Configuration:
+ def load(self, filepath: str | Path) -> None:
+ with Path(filepath).open("r") as file:
+ state = yaml.load(file, Loader=yaml.FullLoader)
+
+ script_dir = Path(__file__).parent
+
+ with (Path(script_dir) / "settings-schema.json").open("r") as file:
+ schema = json.loads(file.read())
+
+ try:
+ jsonschema.validate(state, schema)
+ except jsonschema.exceptions.ValidationError as e:
+ print(f"{e}\n\nIn file {filepath}")
+
+ for k, v in state.items():
+ setattr(self, k, v)
+
+ def __getattribute__(self, key: str) -> Any:
+ res = object.__getattribute__(self, key)
+ if isinstance(res, dict) and "value" in res:
+ return res["value"]
+ return object.__getattribute__(self, key)
+
+ def get_desription(self, key: str) -> str | None:
+ res = object.__getattribute__(self, key)
+ if isinstance(res, dict) and "description" in res:
+ return res["description"]
+ return None
+
+
+DEFAULT_CONFIG_PATH = Path(__file__).parent / "settings.yaml"
+config = Configuration()
+config.load(DEFAULT_CONFIG_PATH)
diff --git a/src/sec_certs/config/settings-schema.json b/src/sec_certs/config/settings-schema.json
new file mode 100644
index 00000000..a31f6b3f
--- /dev/null
+++ b/src/sec_certs/config/settings-schema.json
@@ -0,0 +1,182 @@
+{
+ "title": "settings for sec-certs",
+ "type": "object",
+ "definitions": {
+ "settings_string_entry": {
+ "required": [
+ "description",
+ "value"
+ ],
+ "type": "object",
+ "properties": {
+ "description": {
+ "type": "string"
+ },
+ "value": {
+ "type": "string"
+ }
+ }
+ },
+ "settings_boolean_entry": {
+ "type": "object",
+ "required": [
+ "description",
+ "value"
+ ],
+ "properties": {
+ "description": {
+ "type": "string"
+ },
+ "value": {
+ "type": "boolean"
+ }
+ }
+ },
+ "settings_number_entry": {
+ "type": "object",
+ "required": [
+ "description",
+ "value"
+ ],
+ "properties": {
+ "description": {
+ "type": "string"
+ },
+ "value": {
+ "type": "number"
+ }
+ }
+ },
+ "settings_url_entry": {
+ "type": "object",
+ "required": [
+ "description",
+ "value"
+ ],
+ "properties": {
+ "description": {
+ "type": "string"
+ },
+ "value": {
+ "type": "string",
+ "format": "uri",
+ "pattern": "^(https?|http?)://",
+ "minLength": 1,
+ "maxLength": 255
+ }
+ }
+ }
+ },
+ "properties": {
+ "log_filepath": {
+ "$ref": "#/definitions/settings_string_entry"
+ },
+ "always_false_positive_fips_cert_id_threshold": {
+ "$ref": "#/definitions/settings_number_entry"
+ },
+ "year_difference_between_validations": {
+ "allOf": [
+ {
+ "$ref": "#/definitions/settings_number_entry"
+ },
+ {
+ "properties": {
+ "value": {
+ "minimum": 0
+ }
+ }
+ }
+ ]
+ },
+ "n_threads": {
+ "allOf": [
+ {
+ "$ref": "#/definitions/settings_number_entry"
+ },
+ {
+ "properties": {
+ "value": {
+ "minimum": 1
+ }
+ }
+ }
+ ]
+ },
+ "cpe_n_matching_threshold": {
+ "allOf": [
+ {
+ "$ref": "#/definitions/settings_number_entry"
+ },
+ {
+ "properties": {
+ "value": {
+ "minimum": 0,
+ "maximum": 100
+ }
+ }
+ }
+ ]
+ },
+ "cpe_n_max_matches": {
+ "allOf": [
+ {
+ "$ref": "#/definitions/settings_number_entry"
+ },
+ {
+ "properties": {
+ "value": {
+ "exclusiveMinimum": 0
+ }
+ }
+ }
+ ]
+ },
+ "cc_latest_snapshot": {
+ "$ref": "#/definitions/settings_url_entry"
+ },
+ "cc_maintenances_latest_snapshot": {
+ "$ref": "#/definitions/settings_url_entry"
+ },
+ "pp_latest_snapshot": {
+ "$ref": "#/definitions/settings_url_entry"
+ },
+ "ignore_first_page": {
+ "$ref": "#/definitions/settings_boolean_entry"
+ },
+ "cert_threshold": {
+ "allOf": [
+ {
+ "$ref": "#/definitions/settings_number_entry"
+ },
+ {
+ "properties": {
+ "value": {
+ "minimum": 0
+ }
+ }
+ }
+ ]
+ },
+ "fips_latest_snapshot": {
+ "$ref": "#/definitions/settings_url_entry"
+ },
+ "enable_progress_bars": {
+ "$ref": "#/definitions/settings_boolean_entry"
+ }
+ },
+ "required": [
+ "log_filepath",
+ "always_false_positive_fips_cert_id_threshold",
+ "year_difference_between_validations",
+ "n_threads",
+ "cpe_matching_threshold",
+ "cpe_n_max_matches",
+ "cc_latest_snapshot",
+ "cc_maintenances_latest_snapshot",
+ "pp_latest_snapshot",
+ "ignore_first_page",
+ "cert_threshold",
+ "fips_latest_snapshot",
+ "enable_progress_bars"
+ ]
+} \ No newline at end of file
diff --git a/src/sec_certs/config/settings.yaml b/src/sec_certs/config/settings.yaml
new file mode 100644
index 00000000..8d645758
--- /dev/null
+++ b/src/sec_certs/config/settings.yaml
@@ -0,0 +1,59 @@
+---
+log_filepath:
+ description: Path to the file, relative to working directory, where the log will be stored
+ value: ./cert_processing_log.txt
+always_false_positive_fips_cert_id_threshold:
+ description:
+ During validation we don't connect certificates with number lower than
+ _this_ to connections due to these numbers being typically false positives
+ value: 40
+year_difference_between_validations:
+ description:
+ During validation we don't connect certificates with validation dates
+ difference higher than _this_
+ value: 7
+n_threads:
+ description: How many threads to use for parallel computations
+ value: 8
+cpe_matching_threshold:
+ description: Level of required string similarity between CPE and certificate name on CC CPE matching, 0-100. Lower values yield more false negatives, higher values more false positives
+ value: 92
+cpe_n_max_matches:
+ description: Maximum number of candidate CPE items that may be related to given certificate, >0
+ value: 99
+cc_latest_snapshot:
+ description: URL from where to fetch the latest snapshot of fully processed CC dataset
+ value: https://seccerts.org/cc/dataset.json
+cc_maintenances_latest_snapshot:
+ description: URL from where to fetch the latest snapshot of CC maintenance updates
+ value: https://seccerts.org/cc/maintenance_updates.json
+pp_latest_snapshot:
+ description: URL from where to fetch the latest snapshot of the PP dataset
+ value: https://seccerts.org/static/pp.json
+ignore_first_page:
+ description: During keyword search, first page usually contains addresses - ignore it.
+ value: true
+cert_threshold:
+ description: Used with --higher-precision-results. Determines the amount of mismatched algorithms to be considered faulty.
+ value: 5
+fips_latest_snapshot:
+ description: URL for the latest snapshot of FIPS dataset
+ value: https://seccerts.org/fips/dataset.json
+fips_iut_dataset:
+ description: URL for the dataset of FIPS IUT data
+ value: https://seccerts.org/fips/iut/dataset.json
+fips_iut_latest_snapshot:
+ description: URL for the latest snapshot of FIPS IUT data
+ value: https://seccerts.org/fips/iut/latest.json
+fips_mip_dataset:
+ description: URL for the dataset of FIPS MIP data
+ value: https://seccerts.org/fips/mip/dataset.json
+fips_mip_latest_snapshot:
+ description: URL for the latest snapshot of FIPS MIP data
+ value: https://seccerts.org/fips/mip/latest.json
+minimal_token_length:
+ description: Minimal length of a string that will be considered as a token during keyword extraction in CVE matching
+ value: 3
+enable_progress_bars:
+ description: Whether to enable pretty-printed progress bars while processing.
+ value: true
diff --git a/src/sec_certs/constants.py b/src/sec_certs/constants.py
new file mode 100644
index 00000000..f71bb3c3
--- /dev/null
+++ b/src/sec_certs/constants.py
@@ -0,0 +1,121 @@
+import re
+from pathlib import Path
+
+DUMMY_NONEXISTING_PATH = Path("/this/is/dummy/nonexisting/path")
+
+RESPONSE_OK = 200
+RETURNCODE_OK = "ok"
+RETURNCODE_NOK = "nok"
+REQUEST_TIMEOUT = 10
+
+MIN_CORRECT_CERT_SIZE = 5000
+
+MIN_FIPS_HTML_SIZE = 64000
+
+MIN_CC_HTML_SIZE = 5000000
+MIN_CC_CSV_SIZE = 700000
+MIN_CC_PP_DATASET_SIZE = 2500000
+
+CPE_VERSION_NA = "-"
+
+RELEASE_CANDIDATE_REGEX: re.Pattern = re.compile(r"rc\d{0,2}$", re.IGNORECASE)
+
+FIPS_BASE_URL = "https://csrc.nist.gov"
+FIPS_CMVP_URL = FIPS_BASE_URL + "/projects/cryptographic-module-validation-program"
+FIPS_CAVP_URL = FIPS_BASE_URL + "/projects/Cryptographic-Algorithm-Validation-Program"
+FIPS_MODULE_URL = FIPS_CMVP_URL + "/certificate/{}"
+FIPS_ALG_SEARCH_URL = FIPS_CAVP_URL + "/validation-search?searchMode=implementation&page="
+FIPS_SP_URL = "https://csrc.nist.gov/CSRC/media/projects/cryptographic-module-validation-program/documents/security-policies/140sp{}.pdf"
+FIPS_ACTIVE_MODULES_URL = (
+ FIPS_CMVP_URL + "/validated-modules/search?SearchMode=Advanced&CertificateStatus=Active&ValidationYear=0"
+)
+FIPS_HISTORICAL_MODULES_URL = (
+ FIPS_CMVP_URL + "/validated-modules/search?SearchMode=Advanced&CertificateStatus=Historical&ValidationYear=0"
+)
+FIPS_REVOKED_MODULES_URL = (
+ FIPS_CMVP_URL + "/validated-modules/search?SearchMode=Advanced&CertificateStatus=Revoked&ValidationYear=0"
+)
+FIPS_ALG_URL = FIPS_CAVP_URL + "/details?source={}&number={}"
+FIPS_IUT_URL = "https://csrc.nist.gov/Projects/cryptographic-module-validation-program/modules-in-process/IUT-List"
+FIPS_MIP_URL = (
+ "https://csrc.nist.gov/Projects/cryptographic-module-validation-program/modules-in-process/Modules-In-Process-List"
+)
+
+FIPS_DOWNLOAD_DELAY = 1
+
+FIPS_MIP_STATUS_RE = re.compile(r"^(?P<status>[a-zA-Z ]+?) +\((?P<since>\d{1,2}/\d{1,2}/\d{4})\)$")
+
+TAG_CERT_ID = "cert_id"
+TAG_CC_SECURITY_LEVEL = "cc_security_level"
+TAG_CC_VERSION = "cc_version"
+TAG_CERT_LAB = "cert_lab"
+TAG_CERT_ITEM = "cert_item"
+TAG_CERT_ITEM_VERSION = "cert_item_version"
+TAG_DEVELOPER = "developer"
+TAG_REFERENCED_PROTECTION_PROFILES = "ref_protection_profiles"
+TAG_HEADER_MATCH_RULES = "match_rules"
+
+FILE_ERRORS_STRATEGY = "surrogateescape"
+MAX_ALLOWED_MATCH_LENGTH = 300
+LINE_SEPARATOR = " "
+
+GARBAGE_LINES_THRESHOLD = 30
+GARBAGE_SIZE_THRESHOLD = 1000
+GARBAGE_AVG_LLEN_THRESHOLD = 10
+GARBAGE_EVERY_SECOND_CHAR_THRESHOLD = 15
+GARBAGE_ALPHA_CHARS_THRESHOLD = 0.5
+
+CC_AUSTRALIA_BASE_URL = "https://www.cyber.gov.au"
+CC_AUSTRALIA_CERTIFIED_URL = (
+ CC_AUSTRALIA_BASE_URL + "/acsc/view-all-content/programs/australian-information-security-evaluation-program"
+)
+CC_CANADA_CERTIFIED_URL = "https://www.cyber.gc.ca/en/tools-services/common-criteria/certified-products"
+CC_CANADA_INEVAL_URL = "https://www.cyber.gc.ca/en/tools-services/common-criteria/products-evaluation"
+CC_ANSSI_BASE_URL = "https://www.ssi.gouv.fr"
+CC_ANSSI_CERTIFIED_URL = CC_ANSSI_BASE_URL + "/en/products/certified-products/"
+CC_BSI_BASE_URL = "https://www.bsi.bund.de/"
+CC_BSI_CERTIFIED_URL = CC_BSI_BASE_URL + "EN2021/Topics/Certification/certified_products/certified_products_node.html"
+CC_INDIA_CERTIFIED_URL = "https://www.commoncriteria-india.gov.in/product-certified"
+CC_INDIA_ARCHIVED_URL = "https://www.commoncriteria-india.gov.in/archived-prod-cer"
+CC_ITALY_BASE_URL = "https://www.ocsi.gov.it"
+CC_ITALY_CERTIFIED_URL = CC_ITALY_BASE_URL + "/index.php/elenchi-certificazioni/prodotti-certificati.html"
+CC_ITALY_INEVAL_URL = CC_ITALY_BASE_URL + "/index.php/elenchi-certificazioni/in-corso-di-valutazione.html"
+CC_JAPAN_BASE_URL = "https://www.ipa.go.jp/security/jisec/jisec_e"
+CC_JAPAN_CERT_BASE_URL = CC_JAPAN_BASE_URL + "/certified_products"
+CC_JAPAN_CERTIFIED_URL = CC_JAPAN_BASE_URL + "/certified_products/certfy_list_e31.html"
+CC_JAPAN_ARCHIVED_URL = CC_JAPAN_BASE_URL + "/certified_products/certfy_list_e_archive.html"
+CC_JAPAN_INEVAL_URL = CC_JAPAN_BASE_URL + "/prdct_in_eval.html"
+CC_MALAYSIA_BASE_URL = "https://iscb.cybersecurity.my"
+CC_MALAYSIA_CERTIFIED_URL = (
+ CC_MALAYSIA_BASE_URL + "/en/index.php/certification/product-certification/mycc/certified-products-and-systems"
+)
+CC_MALAYSIA_INEVAL_URL = (
+ CC_MALAYSIA_BASE_URL
+ + "/en/index.php/certification/product-certification/mycc/list-of-products-and-systems-under-evaluation-or-maintenance"
+)
+CC_NETHERLANDS_BASE_URL = "https://www.tuv-nederland.nl/common-criteria"
+CC_NETHERLANDS_CERTIFIED_URL = CC_NETHERLANDS_BASE_URL + "/certificates.html"
+CC_NETHERLANDS_INEVAL_URL = CC_NETHERLANDS_BASE_URL + "/ongoing-certifications.html"
+CC_NORWAY_CERTIFIED_URL = "https://sertit.no/certified-products/category1919.html"
+CC_NORWAY_ARCHIVED_URL = "https://sertit.no/certified-products/product-archive/"
+CC_KOREA_EN_URL = "https://itscc.kr/main/main.do?accessMode=home_en"
+CC_KOREA_CERTIFIED_URL = "https://itscc.kr/certprod/list.do"
+CC_KOREA_PRODUCT_URL = "https://itscc.kr/certprod/view.do?product_id={}&product_class=1"
+CC_SINGAPORE_BASE_URL = "https://www.csa.gov.sg"
+CC_SINGAPORE_CERTIFIED_URL = (
+ CC_SINGAPORE_BASE_URL + "/Programmes/certification-and-labelling-schemes/csa-common-criteria/product-list"
+)
+CC_SINGAPORE_ARCHIVED_URL = (
+ CC_SINGAPORE_BASE_URL + "/Programmes/certification-and-labelling-schemes/csa-common-criteria/product-archives"
+)
+CC_SPAIN_BASE_URL = "https://oc.ccn.cni.es"
+CC_SPAIN_CERTIFIED_URL = CC_SPAIN_BASE_URL + "/en/certified-products/certified-products"
+CC_SWEDEN_BASE_URL = "https://www.fmv.se"
+CC_SWEDEN_CERTIFIED_URL = CC_SWEDEN_BASE_URL + "/verksamhet/ovrig-verksamhet/csec/certifikat-utgivna-av-csec/"
+CC_SWEDEN_INEVAL_URL = CC_SWEDEN_BASE_URL + "/verksamhet/ovrig-verksamhet/csec/pagaende-certifieringar/"
+CC_SWEDEN_ARCHIVED_URL = CC_SWEDEN_BASE_URL + "/verksamhet/ovrig-verksamhet/csec/arkiverade-certifikat-aldre-an-5-ar/"
+CC_TURKEY_ARCHIVED_URL = "https://statik.tse.org.tr/upload/tr/dosya/icerikyonetimi/3300/03112021143434-2.pdf"
+CC_USA_BASE_URL = "https://www.niap-ccevs.org"
+CC_USA_CERTIFIED_URL = CC_USA_BASE_URL + "/Product/PCL.cfm"
+CC_USA_INEVAL_URL = CC_USA_BASE_URL + "/Product/PINE.cfm"
+CC_USA_ARCHIVED_URL = CC_USA_BASE_URL + "/Product/Archived.cfm"
diff --git a/src/sec_certs/dataset/__init__.py b/src/sec_certs/dataset/__init__.py
new file mode 100644
index 00000000..bace4ddd
--- /dev/null
+++ b/src/sec_certs/dataset/__init__.py
@@ -0,0 +1,22 @@
+"""This package exposes Datasets of various Samples, both primary (Common Criteria, FIPS) and auxillary (CVEs, CPEs, ...)"""
+
+from sec_certs.dataset.common_criteria import CCDataset, CCDatasetMaintenanceUpdates
+from sec_certs.dataset.cpe import CPEDataset
+from sec_certs.dataset.cve import CVEDataset
+from sec_certs.dataset.fips import FIPSDataset
+from sec_certs.dataset.fips_algorithm import FIPSAlgorithmDataset
+from sec_certs.dataset.fips_iut import IUTDataset
+from sec_certs.dataset.fips_mip import MIPDataset
+from sec_certs.dataset.protection_profile import ProtectionProfileDataset
+
+__all__ = [
+ "CCDataset",
+ "CCDatasetMaintenanceUpdates",
+ "CPEDataset",
+ "CVEDataset",
+ "FIPSDataset",
+ "FIPSAlgorithmDataset",
+ "IUTDataset",
+ "MIPDataset",
+ "ProtectionProfileDataset",
+]
diff --git a/src/sec_certs/dataset/common_criteria.py b/src/sec_certs/dataset/common_criteria.py
new file mode 100644
index 00000000..6b7b4279
--- /dev/null
+++ b/src/sec_certs/dataset/common_criteria.py
@@ -0,0 +1,1674 @@
+from __future__ import annotations
+
+import itertools
+import json
+import locale
+import shutil
+import tempfile
+from dataclasses import dataclass
+from datetime import datetime
+from pathlib import Path
+from typing import ClassVar, Iterator
+
+import numpy as np
+import pandas as pd
+import requests
+import tabula
+from bs4 import BeautifulSoup, NavigableString, Tag
+
+import sec_certs.utils.sanitization
+from sec_certs import constants
+from sec_certs.config.configuration import config
+from sec_certs.dataset.cpe import CPEDataset
+from sec_certs.dataset.cve import CVEDataset
+from sec_certs.dataset.dataset import AuxillaryDatasets, Dataset, logger
+from sec_certs.dataset.protection_profile import ProtectionProfileDataset
+from sec_certs.model.reference_finder import ReferenceFinder
+from sec_certs.model.sar_transformer import SARTransformer
+from sec_certs.model.transitive_vulnerability_finder import TransitiveVulnerabilityFinder
+from sec_certs.sample.cc_certificate_id import CertificateId
+from sec_certs.sample.cc_maintenance_update import CommonCriteriaMaintenanceUpdate
+from sec_certs.sample.common_criteria import CommonCriteriaCert
+from sec_certs.sample.protection_profile import ProtectionProfile
+from sec_certs.serialization.json import ComplexSerializableType, CustomJSONDecoder, serialize
+from sec_certs.utils import helpers as helpers
+from sec_certs.utils import parallel_processing as cert_processing
+from sec_certs.utils.sanitization import sanitize_navigable_string as sns
+
+
+@dataclass
+class CCAuxillaryDatasets(AuxillaryDatasets):
+ cpe_dset: CPEDataset | None = None
+ cve_dset: CVEDataset | None = None
+ pp_dset: ProtectionProfileDataset | None = None
+ mu_dset: CCDatasetMaintenanceUpdates | None = None
+
+
+class CCDataset(Dataset[CommonCriteriaCert, CCAuxillaryDatasets], ComplexSerializableType):
+ """
+ Class that holds CommonCriteriaCert. Serializable into json, pandas, dictionary. Conveys basic certificate manipulations
+ and dataset transformations. Many private methods that perform internal operations, feel free to exploit them.
+ """
+
+ def __init__(
+ self,
+ certs: dict[str, CommonCriteriaCert] = dict(),
+ root_dir: str | Path = constants.DUMMY_NONEXISTING_PATH,
+ name: str | None = None,
+ description: str = "",
+ state: Dataset.DatasetInternalState | None = None,
+ auxillary_datasets: CCAuxillaryDatasets | None = None,
+ ):
+ 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.auxillary_datasets: CCAuxillaryDatasets = (
+ auxillary_datasets if auxillary_datasets else CCAuxillaryDatasets()
+ )
+
+ self.root_dir = Path(root_dir)
+
+ def to_pandas(self) -> pd.DataFrame:
+ """
+ Return self serialized into pandas DataFrame
+ """
+ df = pd.DataFrame([x.pandas_tuple for x in self.certs.values()], columns=CommonCriteriaCert.pandas_columns)
+ df = df.set_index("dgst")
+
+ df.not_valid_before = pd.to_datetime(df.not_valid_before, infer_datetime_format=True)
+ df.not_valid_after = pd.to_datetime(df.not_valid_after, infer_datetime_format=True)
+ df = df.astype(
+ {"category": "category", "status": "category", "scheme": "category", "cert_lab": "category"}
+ ).fillna(value=np.nan)
+ df = df.loc[
+ ~df.manufacturer.isnull()
+ ] # Manually delete one certificate with None manufacturer (seems to have many blank fields)
+
+ # Categorize EAL
+ df.eal = df.eal.fillna(value=np.nan)
+ df.eal = pd.Categorical(df.eal, categories=sorted(df.eal.dropna().unique().tolist()), ordered=True)
+
+ # Introduce year when cert got valid
+ df["year_from"] = pd.DatetimeIndex(df.not_valid_before).year
+
+ return df
+
+ @property
+ def reports_dir(self) -> Path:
+ """
+ Returns directory that holds files associated with certification reports
+ """
+ return self.certs_dir / "reports"
+
+ @property
+ def reports_pdf_dir(self) -> Path:
+ """
+ Returns directory that holds PDFs associated with certification reports
+ """
+ return self.reports_dir / "pdf"
+
+ @property
+ def reports_txt_dir(self) -> Path:
+ """
+ Returns directory that holds TXTs associated with certification reports
+ """
+ return self.reports_dir / "txt"
+
+ @property
+ def targets_dir(self) -> Path:
+ """
+ Returns directory that holds files associated with security targets
+ """
+ return self.certs_dir / "targets"
+
+ @property
+ def targets_pdf_dir(self) -> Path:
+ """
+ Returns directory that holds PDFs associated with security targets
+ """
+ return self.targets_dir / "pdf"
+
+ @property
+ def targets_txt_dir(self) -> Path:
+ """
+ Returns directory that holds TXTs associated with security targets
+ """
+ return self.targets_dir / "txt"
+
+ @property
+ def pp_dataset_path(self) -> Path:
+ """
+ Returns directory that holds files associated with Protection profiles
+ """
+ return self.auxillary_datasets_dir / "pp_dataset.json"
+
+ @property
+ def mu_dataset_dir(self) -> Path:
+ """
+ Returns directory that holds dataset of maintenance updates
+ """
+ return self.auxillary_datasets_dir / "maintenances"
+
+ @property
+ def mu_dataset_path(self) -> Path:
+ """
+ Returns json that holds the datase of maintenance updates
+ """
+ return self.mu_dataset_dir / "maintenance_updates.json"
+
+ BASE_URL: ClassVar[str] = "https://www.commoncriteriaportal.org"
+
+ HTML_PRODUCTS_URL = {
+ "cc_products_active.html": BASE_URL + "/products/",
+ "cc_products_archived.html": BASE_URL + "/products/index.cfm?archived=1",
+ }
+ HTML_LABS_URL = {"cc_labs.html": 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",
+ }
+ 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",
+ }
+ PP_CSV = {"cc_pp_active.csv": BASE_URL + "/pps/pps.csv", "cc_pp_archived.csv": BASE_URL + "/pps/pps-archived.csv"}
+
+ @property
+ def active_html_tuples(self) -> list[tuple[str, Path]]:
+ """
+ Returns List Tuple[str, Path] where first element is name of html file and second element is its Path.
+ The files correspond to html files parsed from CC website that list all *active* certificates.
+ """
+ return [(x, self.web_dir / y) for y, x in self.HTML_PRODUCTS_URL.items() if "active" in y]
+
+ @property
+ def archived_html_tuples(self) -> list[tuple[str, Path]]:
+ """
+ Returns List Tuple[str, Path] where first element is name of html file and second element is its Path.
+ The files correspond to html files parsed from CC website that list all *archived* certificates.
+ """
+ return [(x, self.web_dir / y) for y, x in self.HTML_PRODUCTS_URL.items() if "archived" in y]
+
+ @property
+ def active_csv_tuples(self) -> list[tuple[str, Path]]:
+ """
+ Returns List Tuple[str, Path] where first element is name of csv file and second element is its Path.
+ The files correspond to csv files downloaded from CC website that list all *active* certificates.
+ """
+ return [(x, self.web_dir / y) for y, x in self.CSV_PRODUCTS_URL.items() if "active" in y]
+
+ @property
+ def archived_csv_tuples(self) -> list[tuple[str, Path]]:
+ """
+ Returns List Tuple[str, Path] where first element is name of csv file and second element is its Path.
+ The files correspond to csv files downloaded from CC website that list all *archived* certificates.
+ """
+ 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) -> CCDataset:
+ """
+ Fetches the fresh snapshot of CCDataset from seccerts.org
+ """
+ return cls.from_web(config.cc_latest_snapshot, "Downloading CC Dataset", "cc_latest_dataset.json")
+
+ def _set_local_paths(self):
+ super()._set_local_paths()
+
+ if self.auxillary_datasets.pp_dset:
+ self.auxillary_datasets.pp_dset.json_path = self.pp_dataset_path
+
+ if self.auxillary_datasets.mu_dset:
+ self.auxillary_datasets.mu_dset.root_dir = self.mu_dataset_dir
+
+ for cert in self:
+ cert.set_local_paths(self.reports_pdf_dir, self.targets_pdf_dir, self.reports_txt_dir, self.targets_txt_dir)
+ # TODO: This forgets to set local paths for other auxillary datasets
+
+ def _merge_certs(self, certs: dict[str, CommonCriteriaCert], cert_source: str | None = None) -> None:
+ """
+ Merges dictionary of certificates into the dataset. Assuming they all are CommonCriteria certificates
+ """
+ new_certs = {x.dgst: x for x in certs.values() if x not in self}
+ certs_to_merge = [x for x in certs.values() if x in self]
+ self.certs.update(new_certs)
+
+ for crt in certs_to_merge:
+ self[crt.dgst].merge(crt, cert_source)
+
+ logger.info(f"Added {len(new_certs)} new and merged further {len(certs_to_merge)} certificates to the dataset.")
+
+ def _download_csv_html_resources(self, get_active: bool = True, get_archived: bool = True) -> None:
+ self.web_dir.mkdir(parents=True, exist_ok=True)
+
+ html_items = []
+ csv_items = []
+ if get_active is True:
+ html_items.extend(self.active_html_tuples)
+ csv_items.extend(self.active_csv_tuples)
+ if get_archived is True:
+ html_items.extend(self.archived_html_tuples)
+ csv_items.extend(self.archived_csv_tuples)
+
+ html_urls, html_paths = [x[0] for x in html_items], [x[1] for x in html_items]
+ csv_urls, csv_paths = [x[0] for x in csv_items], [x[1] for x in csv_items]
+
+ logger.info("Downloading required csv and html files.")
+ helpers.download_parallel(html_urls, html_paths)
+ helpers.download_parallel(csv_urls, csv_paths)
+
+ @serialize
+ def get_certs_from_web(
+ self, to_download: bool = True, keep_metadata: bool = True, get_active: bool = True, get_archived: bool = True
+ ) -> None:
+ """
+ Downloads CSV and HTML files that hold lists of certificates from common criteria website. Parses these files
+ and constructs CommonCriteriaCert objects, fills the dataset with those.
+
+ :param bool to_download: If CSV and HTML files shall be downloaded (or existing files utilized), defaults to True
+ :param bool keep_metadata: If CSV and HTML files shall be kept on disk after download, defaults to True
+ :param bool get_active: If active certificates shall be parsed, defaults to True
+ :param bool get_archived: If archived certificates shall be parsed, defaults to True
+ """
+ if to_download is True:
+ self._download_csv_html_resources(get_active, get_archived)
+
+ logger.info("Adding CSV certificates to CommonCriteria dataset.")
+ csv_certs = self._get_all_certs_from_csv(get_active, get_archived)
+ self._merge_certs(csv_certs, cert_source="csv")
+
+ # Someway along the way, 3 certificates get lost.
+ logger.info("Adding HTML certificates to CommonCriteria dataset.")
+ html_certs = self._get_all_certs_from_html(get_active, get_archived)
+ self._merge_certs(html_certs, cert_source="html")
+
+ 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_csv(self, get_active: bool, get_archived: bool) -> dict[str, CommonCriteriaCert]:
+ """
+ Creates dictionary of new certificates from csv sources.
+ """
+ csv_sources = list(self.CSV_PRODUCTS_URL.keys())
+ csv_sources = [x for x in csv_sources if "active" not in x or get_active]
+ csv_sources = [x for x in csv_sources if "archived" not in x or get_archived]
+
+ new_certs = {}
+ for file in csv_sources:
+ partial_certs = self._parse_single_csv(self.web_dir / file)
+ logger.info(f"Parsed {len(partial_certs)} certificates from: {file}")
+ new_certs.update(partial_certs)
+ return new_certs
+
+ @staticmethod
+ def _parse_single_csv(file: Path) -> dict[str, CommonCriteriaCert]:
+ """
+ Using pandas, this parses a single CSV file.
+ """
+
+ def map_ip_to_hostname(url: str) -> str:
+ if not url:
+ return url
+ tokens = url.split("/")
+ relative_path = "/" + "/".join(tokens[3:])
+ return CCDataset.BASE_URL + relative_path
+
+ def _get_primary_key_str(row: Tag):
+ prim_key = row["category"] + row["cert_name"] + row["report_link"]
+ return prim_key
+
+ if "active" in str(file):
+ cert_status = "active"
+ else:
+ cert_status = "archived"
+
+ csv_header = [
+ "category",
+ "cert_name",
+ "manufacturer",
+ "scheme",
+ "security_level",
+ "protection_profiles",
+ "not_valid_before",
+ "not_valid_after",
+ "report_link",
+ "st_link",
+ "maintenance_date",
+ "maintenance_title",
+ "maintenance_report_link",
+ "maintenance_st_link",
+ ]
+
+ # TODO: Now skipping bad lines, smarter heuristics to be built for dumb files
+ df = pd.read_csv(file, engine="python", encoding="windows-1252", on_bad_lines="skip")
+ df = df.rename(columns={x: y for (x, y) in zip(list(df.columns), csv_header)})
+
+ df["is_maintenance"] = ~df.maintenance_title.isnull()
+ df = df.fillna(value="")
+
+ df[["not_valid_before", "not_valid_after", "maintenance_date"]] = df[
+ ["not_valid_before", "not_valid_after", "maintenance_date"]
+ ].apply(pd.to_datetime)
+
+ df["dgst"] = df.apply(lambda row: helpers.get_first_16_bytes_sha256(_get_primary_key_str(row)), axis=1)
+
+ df_base = df.loc[~df.is_maintenance].copy()
+ df_main = df.loc[df.is_maintenance].copy()
+
+ df_base.report_link = df_base.report_link.map(map_ip_to_hostname)
+ df_base.st_link = df_base.st_link.map(map_ip_to_hostname)
+
+ df_main.maintenance_report_link = df_main.maintenance_report_link.map(map_ip_to_hostname)
+ df_main.maintenance_st_link = df_main.maintenance_st_link.map(map_ip_to_hostname)
+
+ n_all = len(df_base)
+ n_deduplicated = len(df_base.drop_duplicates(subset=["dgst"]))
+ if (n_dup := n_all - n_deduplicated) > 0:
+ logger.warning(f"The CSV {file} contains {n_dup} duplicates by the primary key.")
+
+ 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(
+ CommonCriteriaCert.MaintenanceReport(
+ x.maintenance_date.date(), x.maintenance_title, x.maintenance_report_link, x.maintenance_st_link
+ )
+ )
+
+ certs = {
+ x.dgst: CommonCriteriaCert(
+ cert_status,
+ x.category,
+ x.cert_name,
+ x.manufacturer,
+ x.scheme,
+ x.security_level,
+ x.not_valid_before,
+ x.not_valid_after,
+ x.report_link,
+ x.st_link,
+ None,
+ None,
+ profiles.get(x.dgst, None),
+ updates.get(x.dgst, None),
+ None,
+ None,
+ None,
+ )
+ for x in df_base.itertuples()
+ }
+ return certs
+
+ def _get_all_certs_from_html(self, get_active: bool, get_archived: bool) -> dict[str, CommonCriteriaCert]:
+ """
+ Prepares dictionary of certificates from all html files.
+ """
+ html_sources = list(self.HTML_PRODUCTS_URL.keys())
+ if get_active is False:
+ html_sources = [x for x in html_sources if "active" not in x]
+ if get_archived is False:
+ html_sources = [x for x in html_sources if "archived" not 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)} certificates from: {file}")
+ new_certs.update(partial_certs)
+ return new_certs
+
+ @staticmethod
+ def _parse_single_html(file: Path) -> dict[str, CommonCriteriaCert]:
+ """
+ Prepares a dictionary of certificates from a single html file.
+ """
+
+ def _get_timestamp_from_footer(footer):
+ locale.setlocale(locale.LC_ALL, "en_US")
+ footer_text = list(footer.stripped_strings)[0]
+ date_string = footer_text.split(",")[1:3]
+ time_string = footer_text.split(",")[3].split(" at ")[1]
+ formatted_datetime = date_string[0] + date_string[1] + " " + time_string
+ return datetime.strptime(formatted_datetime, " %B %d %Y %I:%M %p")
+
+ def _parse_table(
+ soup: BeautifulSoup, cert_status: str, table_id: str, category_string: str
+ ) -> dict[str, CommonCriteriaCert]:
+ tables = soup.find_all("table", id=table_id)
+
+ if not len(tables) <= 1:
+ raise ValueError(
+ f'The "{file.name}" was expected to contain <1 <table> element. Instead, it contains: {len(tables)} <table> elements.'
+ )
+
+ if not tables:
+ return {}
+
+ table = tables[0]
+ rows = list(table.find_all("tr"))
+ # header, footer = rows[0], rows[1]
+ body = rows[2:]
+
+ # It's possible to obtain timestamp of the moment when the list was generated. It's identical for each table and should thus only be obtained once. Not necessarily in each table
+ # timestamp = _get_timestamp_from_footer(footer)
+
+ # The following unused snippet extracts expected number of certs from the table
+ # caption_str = str(table.findAll('caption'))
+ # n_expected_certs = int(caption_str.split(category_string + ' – ')[1].split(' Certified Products')[0])
+
+ try:
+ table_certs = {
+ x.dgst: x
+ for x in [CommonCriteriaCert.from_html_row(row, cert_status, category_string) for row in body]
+ }
+ except ValueError as e:
+ raise ValueError(f"Bad html file: {file.name} ({str(e)})") from e
+
+ return table_certs
+
+ if "active" in str(file):
+ cert_status = "active"
+ else:
+ cert_status = "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 = {x: y for (x, y) in zip(cc_table_ids, 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))
+
+ return certs
+
+ def _download_all_artifacts_body(self, fresh: bool = True) -> None:
+ self._download_reports(fresh)
+ self._download_targets(fresh)
+
+ def _download_reports(self, fresh: bool = True) -> None:
+ 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.report_link]
+
+ if fresh:
+ logger.info("Downloading PDFs of CC certification reports.")
+ if not fresh and certs_to_process:
+ logger.info(
+ f"Downloading {len(certs_to_process)} PDFs of CC certification reports for which previous download failed."
+ )
+
+ cert_processing.process_parallel(
+ CommonCriteriaCert.download_pdf_report,
+ certs_to_process,
+ config.n_threads,
+ progress_bar_desc="Downloading PDFs of CC certification reports",
+ )
+
+ def _download_targets(self, fresh: bool = True) -> None:
+ self.targets_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)]
+
+ if fresh:
+ logger.info("Downloading PDFs of CC security targets.")
+ if not fresh and certs_to_process:
+ logger.info(
+ f"Downloading {len(certs_to_process)} PDFs of CC security targets for which previous download failed.."
+ )
+
+ cert_processing.process_parallel(
+ CommonCriteriaCert.download_pdf_st,
+ certs_to_process,
+ config.n_threads,
+ progress_bar_desc="Downloading PDFs of CC security targets",
+ )
+
+ def _convert_reports_to_txt(self, fresh: bool = True) -> None:
+ 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 fresh:
+ logger.info("Converting PDFs of certification reports to txt.")
+ if not fresh and certs_to_process:
+ logger.info(
+ f"Converting {len(certs_to_process)} PDFs of certification reports to txt for which previous conversion failed."
+ )
+
+ cert_processing.process_parallel(
+ CommonCriteriaCert.convert_report_pdf,
+ certs_to_process,
+ config.n_threads,
+ progress_bar_desc="Converting PDFs of certification reports to txt",
+ )
+
+ def _convert_targets_to_txt(self, fresh: bool = True) -> None:
+ self.targets_txt_dir.mkdir(parents=True, exist_ok=True)
+ certs_to_process = [x for x in self if x.state.st_is_ok_to_convert(fresh)]
+
+ if fresh:
+ logger.info("Converting PDFs of security targets to txt.")
+ if not fresh and certs_to_process:
+ logger.info(
+ f"Converting {len(certs_to_process)} PDFs of security targets to txt for which previous conversion failed."
+ )
+
+ cert_processing.process_parallel(
+ CommonCriteriaCert.convert_st_pdf,
+ certs_to_process,
+ config.n_threads,
+ progress_bar_desc="Converting PDFs of security targets to txt",
+ )
+
+ def _convert_all_pdfs_body(self, fresh: bool = True) -> None:
+ self._convert_reports_to_txt(fresh)
+ self._convert_targets_to_txt(fresh)
+
+ def _extract_report_metadata(self) -> None:
+ logger.info("Extracting report metadata")
+ certs_to_process = [x for x in self if x.state.report_is_ok_to_analyze()]
+ processed_certs = cert_processing.process_parallel(
+ CommonCriteriaCert.extract_report_pdf_metadata,
+ certs_to_process,
+ config.n_threads,
+ use_threading=False,
+ progress_bar_desc="Extracting report metadata",
+ )
+ self.update_with_certs(processed_certs)
+
+ def _extract_targets_metadata(self) -> None:
+ logger.info("Extracting target metadata")
+ certs_to_process = [x for x in self if x.state.st_is_ok_to_analyze()]
+ processed_certs = cert_processing.process_parallel(
+ CommonCriteriaCert.extract_st_pdf_metadata,
+ certs_to_process,
+ config.n_threads,
+ use_threading=False,
+ progress_bar_desc="Extracting target metadata",
+ )
+ self.update_with_certs(processed_certs)
+
+ def _extract_pdf_metadata(self) -> None:
+ self._extract_report_metadata()
+ self._extract_targets_metadata()
+
+ def _extract_report_frontpage(self) -> None:
+ logger.info("Extracting report frontpages")
+ certs_to_process = [x for x in self if x.state.report_is_ok_to_analyze()]
+ processed_certs = cert_processing.process_parallel(
+ CommonCriteriaCert.extract_report_pdf_frontpage,
+ certs_to_process,
+ config.n_threads,
+ use_threading=False,
+ progress_bar_desc="Extracting report frontpages",
+ )
+ self.update_with_certs(processed_certs)
+
+ def _extract_targets_frontpage(self) -> None:
+ logger.info("Extracting target frontpages")
+ certs_to_process = [x for x in self if x.state.st_is_ok_to_analyze()]
+ processed_certs = cert_processing.process_parallel(
+ CommonCriteriaCert.extract_st_pdf_frontpage,
+ certs_to_process,
+ config.n_threads,
+ use_threading=False,
+ progress_bar_desc="Extracting target frontpages",
+ )
+ self.update_with_certs(processed_certs)
+
+ def _extract_pdf_frontpage(self) -> None:
+ self._extract_report_frontpage()
+ self._extract_targets_frontpage()
+
+ def _extract_report_keywords(self) -> None:
+ logger.info("Extracting report keywords")
+ certs_to_process = [x for x in self if x.state.report_is_ok_to_analyze()]
+ processed_certs = cert_processing.process_parallel(
+ CommonCriteriaCert.extract_report_pdf_keywords,
+ certs_to_process,
+ config.n_threads,
+ use_threading=False,
+ progress_bar_desc="Extracting report keywords",
+ )
+ self.update_with_certs(processed_certs)
+
+ def _extract_targets_keywords(self) -> None:
+ logger.info("Extracting target keywords")
+ certs_to_process = [x for x in self if x.state.st_is_ok_to_analyze()]
+ processed_certs = cert_processing.process_parallel(
+ CommonCriteriaCert.extract_st_pdf_keywords,
+ certs_to_process,
+ config.n_threads,
+ use_threading=False,
+ progress_bar_desc="Extracting target keywords",
+ )
+ self.update_with_certs(processed_certs)
+
+ def _extract_pdf_keywords(self) -> None:
+ self._extract_report_keywords()
+ self._extract_targets_keywords()
+
+ def extract_data(self) -> None:
+ logger.info("Extracting various data from certification artifacts")
+ self._extract_pdf_metadata()
+ self._extract_pdf_frontpage()
+ self._extract_pdf_keywords()
+
+ def _compute_cert_labs(self) -> None:
+ logger.info("Computing heuristics: Deriving information about laboratories involved in certification.")
+ 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()
+
+ def _compute_normalized_cert_ids(self) -> None:
+ logger.info("Computing heuristics: Deriving information about certificate ids from artifacts.")
+ for cert in self:
+ cert.compute_heuristics_cert_id()
+
+ def _compute_transitive_vulnerabilities(self):
+ logger.info("omputing heuristics: computing transitive vulnerabilities in referenc(ed/ing) certificates.")
+ 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
+
+ def _compute_heuristics(self) -> None:
+ self._compute_normalized_cert_ids()
+ super()._compute_heuristics()
+ self._compute_cert_labs()
+ self._compute_sars()
+
+ def _compute_sars(self) -> None:
+ logger.info("Computing heuristics: Computing SARs")
+ transformer = SARTransformer().fit(self.certs.values())
+ for cert in self:
+ cert.heuristics.extracted_sars = transformer.transform_single_cert(cert)
+
+ 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.keys():
+ try:
+ canonical = CertificateId(scheme, match).canonical
+ res.add(canonical)
+ except Exception:
+ res.add(match)
+ return res
+
+ return func
+
+ logger.info("omputing heuristics: references between certificates.")
+ 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))
+
+ def process_auxillary_datasets(self, download_fresh: bool = False) -> None:
+ """
+ Processes all auxillary datasets needed during computation. On top of base-class processing,
+ CC handles protection profiles and maintenance updates.
+ """
+ super().process_auxillary_datasets(download_fresh)
+ self.auxillary_datasets.pp_dset = self.process_protection_profiles(to_download=download_fresh)
+ self.auxillary_datasets.mu_dset = self.process_maintenance_updates(to_download=download_fresh)
+
+ @serialize
+ 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
+
+ :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
+ """
+ logger.info("Processing protection profiles.")
+
+ self.auxillary_datasets_dir.mkdir(parents=True, exist_ok=True)
+
+ 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)
+
+ 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 = {pp_dataset.pps.get((x.pp_name, x.pp_link), x) for x in cert.protection_profiles}
+
+ if not keep_metadata:
+ self.pp_dataset_path.unlink()
+
+ return pp_dataset
+
+ 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
+ """
+
+ logger.info("Processing maintenace updates")
+ self.mu_dataset_dir.mkdir(parents=True, exist_ok=True)
+
+ if to_download or not self.mu_dataset_path.exists():
+ maintained_certs: list[CommonCriteriaCert] = [x for x in self if x.maintenance_updates]
+ updates = list(
+ itertools.chain.from_iterable(
+ CommonCriteriaMaintenanceUpdate.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
+
+
+class CCDatasetMaintenanceUpdates(CCDataset, ComplexSerializableType):
+ """
+ Dataset of maintenance updates related to certificates of CCDataset dataset.
+ Should be used merely for actions related to Maintenance updates: download pdfs, convert pdfs, extract data from pdfs
+ """
+
+ # Quite difficult to achieve correct behaviour with MyPy here, opting for ignore
+ def __init__(
+ self,
+ certs: dict[str, CommonCriteriaMaintenanceUpdate] = dict(), # type: ignore
+ root_dir: Path = constants.DUMMY_NONEXISTING_PATH,
+ name: str = "dataset name",
+ description: str = "dataset_description",
+ state: CCDataset.DatasetInternalState | None = None,
+ ):
+ super().__init__(certs, root_dir, name, description, state) # type: ignore
+ self.state.meta_sources_parsed = True
+
+ @property
+ def certs_dir(self) -> Path:
+ return self.root_dir
+
+ def __iter__(self) -> Iterator[CommonCriteriaMaintenanceUpdate]:
+ yield from self.certs.values() # type: ignore
+
+ def _compute_heuristics(self) -> None:
+ raise NotImplementedError
+
+ def compute_related_cves(self) -> None:
+ raise NotImplementedError
+
+ def process_auxillary_datasets(self, download_fresh: bool = False) -> None:
+ raise NotImplementedError
+
+ def analyze_certificates(self) -> None:
+ raise NotImplementedError
+
+ def get_certs_from_web(
+ self, to_download: bool = True, keep_metadata: bool = True, get_active: bool = True, get_archived: bool = True
+ ) -> None:
+ raise NotImplementedError
+
+ @classmethod
+ def from_json(cls, input_path: str | Path) -> CCDatasetMaintenanceUpdates:
+ input_path = Path(input_path)
+ with input_path.open("r") as handle:
+ dset = json.load(handle, cls=CustomJSONDecoder)
+ dset._root_dir = Path(input_path).parent
+ return dset
+
+ def to_pandas(self) -> pd.DataFrame:
+ df = pd.DataFrame(
+ [x.pandas_tuple for x in self.certs.values()], columns=CommonCriteriaMaintenanceUpdate.pandas_columns
+ )
+ df = df.set_index("dgst")
+ df.index.name = "dgst"
+
+ df.maintenance_date = pd.to_datetime(df.maintenance_date, infer_datetime_format=True)
+ df = df.fillna(value=np.nan)
+
+ return df
+
+ @classmethod
+ def from_web_latest(cls) -> CCDatasetMaintenanceUpdates:
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ dset_path = Path(tmp_dir) / "cc_maintenances_latest_dataset.json"
+ helpers.download_file(config.cc_maintenances_latest_snapshot, dset_path)
+ return cls.from_json(dset_path)
+
+ def get_n_maintenances_df(self) -> pd.DataFrame:
+ """
+ Returns a DataFrame with CommonCriteriaCert digest as an index, and number of registered maintenances as a value
+ """
+ main_df = self.to_pandas()
+ main_df.maintenance_date = main_df.maintenance_date.dt.date
+ n_maintenances = (
+ main_df.groupby("related_cert_digest").name.count().rename("n_maintenances").fillna(0).astype("int32")
+ )
+
+ n_maintenances.index.name = "dgst"
+ return n_maintenances
+
+ def get_maintenance_dates_df(self) -> pd.DataFrame:
+ """
+ Returns a DataFrame with CommonCriteriaCert digest as an index, and all the maintenance dates as a value.
+ """
+ main_dates = self.to_pandas()
+ main_dates.maintenance_date = main_dates.maintenance_date.map(lambda x: [x])
+ main_dates.index.name = "dgst"
+ return main_dates.groupby("related_cert_digest").maintenance_date.agg("sum").rename("maintenance_dates")
+
+
+class CCSchemeDataset:
+ @staticmethod
+ def _download_page(url, session=None):
+ if session:
+ conn = session
+ else:
+ conn = requests
+ resp = conn.get(url, headers={"User-Agent": "seccerts.org"})
+ if resp.status_code != requests.codes.ok:
+ raise ValueError(f"Unable to download: status={resp.status_code}")
+ return BeautifulSoup(resp.content, "html5lib")
+
+ @staticmethod
+ def get_australia_in_evaluation():
+ # TODO: Information could be expanded by following url.
+ soup = CCSchemeDataset._download_page(constants.CC_AUSTRALIA_CERTIFIED_URL)
+ header = soup.find("h2", text="Products in evaluation")
+ table = header.find_next_sibling("table")
+ results = []
+ for tr in table.find_all("tr"):
+ tds = tr.find_all("td")
+ if not tds:
+ continue
+ cert = {
+ "vendor": sns(tds[0].text),
+ "product": sns(tds[1].text),
+ "url": constants.CC_AUSTRALIA_BASE_URL + tds[1].find("a")["href"],
+ "level": sns(tds[2].text),
+ }
+ results.append(cert)
+ return results
+
+ @staticmethod
+ def get_canada_certified():
+ soup = CCSchemeDataset._download_page(constants.CC_CANADA_CERTIFIED_URL)
+ tbody = soup.find("table").find("tbody")
+ results = []
+ for tr in tbody.find_all("tr"):
+ tds = tr.find_all("td")
+ if not tds:
+ continue
+ cert = {
+ "product": sns(tds[0].text),
+ "vendor": sns(tds[1].text),
+ "level": sns(tds[2].text),
+ "certification_date": sns(tds[3].text),
+ }
+ results.append(cert)
+ return results
+
+ @staticmethod
+ def get_canada_in_evaluation():
+ soup = CCSchemeDataset._download_page(constants.CC_CANADA_INEVAL_URL)
+ tbody = soup.find("table").find("tbody")
+ results = []
+ for tr in tbody.find_all("tr"):
+ tds = tr.find_all("td")
+ if not tds:
+ continue
+ cert = {
+ "product": sns(tds[0].text),
+ "vendor": sns(tds[1].text),
+ "level": sns(tds[2].text),
+ "cert_lab": sns(tds[3].text),
+ }
+ results.append(cert)
+ return results
+
+ @staticmethod
+ def get_france_certified():
+ # TODO: Information could be expanded by following product link.
+ base_soup = CCSchemeDataset._download_page(constants.CC_ANSSI_CERTIFIED_URL)
+ category_nav = base_soup.find("ul", class_="nav-categories")
+ results = []
+ for li in category_nav.find_all("li"):
+ a = li.find("a")
+ url = a["href"]
+ category_name = sns(a.text)
+ soup = CCSchemeDataset._download_page(constants.CC_ANSSI_BASE_URL + url)
+ table = soup.find("table", class_="produits-liste cc")
+ if not table:
+ continue
+ tbody = table.find("tbody")
+ for tr in tbody.find_all("tr"):
+ tds = tr.find_all("td")
+ if not tds:
+ continue
+ cert = {
+ "product": sns(tds[0].text),
+ "vendor": sns(tds[1].text),
+ "level": sns(tds[2].text),
+ "id": sns(tds[3].text),
+ "certification_date": sns(tds[4].text),
+ "category": category_name,
+ "url": constants.CC_ANSSI_BASE_URL + tds[0].find("a")["href"],
+ }
+ results.append(cert)
+ return results
+
+ @staticmethod
+ def get_germany_certified():
+ # TODO: Information could be expanded by following url.
+ base_soup = CCSchemeDataset._download_page(constants.CC_BSI_CERTIFIED_URL)
+ category_nav = base_soup.find("ul", class_="no-bullet row")
+ results = []
+ for li in category_nav.find_all("li"):
+ a = li.find("a")
+ url = a["href"]
+ category_name = sns(a.text)
+ soup = CCSchemeDataset._download_page(constants.CC_BSI_BASE_URL + url)
+ content = soup.find("div", class_="content").find("div", class_="column")
+ for table in content.find_all("table"):
+ tbody = table.find("tbody")
+ header = table.find_parent("div", class_="wrapperTable").find_previous_sibling("h2")
+ for tr in tbody.find_all("tr"):
+ tds = tr.find_all("td")
+ if len(tds) != 4:
+ continue
+ cert = {
+ "cert_id": sns(tds[0].text),
+ "product": sns(tds[1].text),
+ "vendor": sns(tds[2].text),
+ "certification_date": sns(tds[3].text),
+ "category": category_name,
+ "url": constants.CC_BSI_BASE_URL + tds[0].find("a")["href"],
+ }
+ if header is not None:
+ cert["subcategory"] = sns(header.text)
+ results.append(cert)
+ return results
+
+ @staticmethod
+ def get_india_certified():
+ pages = {0}
+ seen_pages = set()
+ results = []
+ while pages:
+ page = pages.pop()
+ seen_pages.add(page)
+ url = constants.CC_INDIA_CERTIFIED_URL + f"?page={page}"
+ soup = CCSchemeDataset._download_page(url)
+
+ # Update pages
+ pager = soup.find("ul", class_="pager")
+ for li in pager.find_all("li"):
+ try:
+ new_page = int(li.text)
+ except Exception:
+ continue
+ if new_page not in seen_pages:
+ pages.add(new_page)
+
+ # Parse table
+ tbody = soup.find("div", class_="content").find("table").find("tbody")
+ for tr in tbody.find_all("tr"):
+ tds = tr.find_all("td")
+ if not tds:
+ continue
+ report_a = tds[5].find("a")
+ target_a = tds[6].find("a")
+ cert_a = tds[7].find("a")
+ cert = {
+ "serial_number": sns(tds[0].text),
+ "product": sns(tds[1].text),
+ "sponsor": sns(tds[2].text),
+ "developer": sns(tds[3].text),
+ "level": sns(tds[4].text),
+ "report_link": report_a["href"],
+ "report_name": sns(report_a.text),
+ "target_link": target_a["href"],
+ "target_name": sns(target_a.text),
+ "cert_link": cert_a["href"],
+ "cert_name": sns(cert_a.text),
+ }
+ results.append(cert)
+ return results
+
+ @staticmethod
+ def get_india_archived():
+ pages = {0}
+ seen_pages = set()
+ results = []
+ while pages:
+ page = pages.pop()
+ seen_pages.add(page)
+ url = constants.CC_INDIA_ARCHIVED_URL + f"?page={page}"
+ soup = CCSchemeDataset._download_page(url)
+
+ # Update pages
+ pager = soup.find("ul", class_="pager")
+ for li in pager.find_all("li"):
+ try:
+ new_page = int(li.text)
+ except Exception:
+ continue
+ if new_page not in seen_pages:
+ pages.add(new_page)
+
+ # Parse table
+ tbody = soup.find("div", class_="content").find("table").find("tbody")
+ for tr in tbody.find_all("tr"):
+ tds = tr.find_all("td")
+ if not tds:
+ continue
+ report_a = tds[5].find("a")
+ target_a = tds[6].find("a")
+ cert_a = tds[7].find("a")
+ cert = {
+ "serial_number": sns(tds[0].text),
+ "product": sns(tds[1].text),
+ "sponsor": sns(tds[2].text),
+ "developer": sns(tds[3].text),
+ "level": sns(tds[4].text),
+ "report_link": report_a["href"],
+ "report_name": sns(report_a.text),
+ "target_link": target_a["href"],
+ "target_name": sns(target_a.text),
+ "cert_link": cert_a["href"],
+ "cert_name": sns(cert_a.text),
+ "certification_date": sns(tds[8].text),
+ }
+ results.append(cert)
+ return results
+
+ @staticmethod
+ def get_italy_certified(): # noqa: C901
+ soup = CCSchemeDataset._download_page(constants.CC_ITALY_CERTIFIED_URL)
+ div = soup.find("div", class_="certificati")
+ results = []
+ for cert_div in div.find_all("div", recursive=False):
+ title = cert_div.find("h3").text
+ data_div = cert_div.find("div", class_="collapse")
+ cert = {"title": title}
+ for data_p in data_div.find_all("p"):
+ p_text = sns(data_p.text)
+ if ":" not in p_text:
+ continue
+ p_name, p_data = p_text.split(":")
+ p_data = p_data
+ p_link = data_p.find("a")
+ if "Fornitore" in p_name:
+ cert["supplier"] = p_data
+ elif "Livello di garanzia" in p_name:
+ cert["level"] = p_data
+ elif "Data emissione certificato" in p_name:
+ cert["certification_date"] = p_data
+ elif "Data revisione" in p_name:
+ cert["revision_date"] = p_data
+ elif "Rapporto di Certificazione" in p_name and p_link:
+ cert["report_link_it"] = constants.CC_ITALY_BASE_URL + p_link["href"]
+ elif "Certification Report" in p_name and p_link:
+ cert["report_link_en"] = constants.CC_ITALY_BASE_URL + p_link["href"]
+ elif "Traguardo di Sicurezza" in p_name and p_link:
+ cert["target_link"] = constants.CC_ITALY_BASE_URL + p_link["href"]
+ elif "Nota su" in p_name and p_link:
+ cert["vulnerability_note_link"] = constants.CC_ITALY_BASE_URL + p_link["href"]
+ elif "Nota di chiarimento" in p_name and p_link:
+ cert["clarification_note_link"] = constants.CC_ITALY_BASE_URL + p_link["href"]
+ results.append(cert)
+ return results
+
+ @staticmethod
+ def get_italy_in_evaluation():
+ soup = CCSchemeDataset._download_page(constants.CC_ITALY_INEVAL_URL)
+ div = soup.find("div", class_="valutazioni")
+ results = []
+ for cert_div in div.find_all("div", recursive=False):
+ title = cert_div.find("h3").text
+ data_div = cert_div.find("div", class_="collapse")
+ cert = {"title": title}
+ for data_p in data_div.find_all("p"):
+ p_text = sns(data_p.text)
+ if ":" not in p_text:
+ continue
+ p_name, p_data = p_text.split(":")
+ p_data = p_data
+ if "Committente" in p_name:
+ cert["client"] = p_data
+ elif "Livello di garanzia" in p_name:
+ cert["level"] = p_data
+ elif "Tipologia prodotto" in p_name:
+ cert["product_type"] = p_data
+ results.append(cert)
+ return results
+
+ @staticmethod
+ def get_japan_certified():
+ # TODO: Information could be expanded by following toe link.
+ soup = CCSchemeDataset._download_page(constants.CC_JAPAN_CERTIFIED_URL)
+ table = soup.find("div", id="cert_list").find("table")
+ results = []
+ trs = list(table.find_all("tr"))
+ for tr in trs:
+ tds = tr.find_all("td")
+ if not tds:
+ continue
+ if len(tds) == 6:
+ cert = {
+ "cert_id": sns(tds[0].text),
+ "supplier": sns(tds[1].text),
+ "toe_overseas_name": sns(tds[2].text),
+ "certification_date": sns(tds[3].text),
+ "claim": sns(tds[4].text),
+ }
+ toe_a = tds[2].find("a")
+ if toe_a and "href" in toe_a.attrs:
+ cert["toe_overseas_link"] = constants.CC_JAPAN_CERT_BASE_URL + "/" + toe_a["href"]
+ results.append(cert)
+ if len(tds) == 1:
+ cert = results[-1]
+ cert["toe_japan_name"] = sns(tds[0].text)
+ toe_a = tds[0].find("a")
+ if toe_a and "href" in toe_a.attrs:
+ cert["toe_japan_link"] = constants.CC_JAPAN_CERT_BASE_URL + "/" + toe_a["href"]
+ return results
+
+ @staticmethod
+ def get_japan_archived():
+ # TODO: Information could be expanded by following toe link.
+ soup = CCSchemeDataset._download_page(constants.CC_JAPAN_ARCHIVED_URL)
+ table = soup.find("table")
+ results = []
+ trs = list(table.find_all("tr"))
+ for tr in trs:
+ tds = tr.find_all("td")
+ if not tds:
+ continue
+ if len(tds) == 6:
+ cert = {
+ "cert_id": sns(tds[0].text),
+ "supplier": sns(tds[1].text),
+ "toe_overseas_name": sns(tds[2].text),
+ "certification_date": sns(tds[3].text),
+ "claim": sns(tds[4].text),
+ }
+ toe_a = tds[2].find("a")
+ if toe_a and "href" in toe_a.attrs:
+ cert["toe_overseas_link"] = constants.CC_JAPAN_CERT_BASE_URL + "/" + toe_a["href"]
+ results.append(cert)
+ if len(tds) == 1:
+ cert = results[-1]
+ cert["toe_japan_name"] = sns(tds[0].text)
+ toe_a = tds[0].find("a")
+ if toe_a and "href" in toe_a.attrs:
+ cert["toe_japan_link"] = constants.CC_JAPAN_CERT_BASE_URL + "/" + toe_a["href"]
+ return results
+
+ @staticmethod
+ def get_japan_in_evaluation():
+ # TODO: Information could be expanded by following toe link.
+ soup = CCSchemeDataset._download_page(constants.CC_JAPAN_INEVAL_URL)
+ table = soup.find("table")
+ results = []
+ for tr in table.find_all("tr"):
+ tds = tr.find_all("td")
+ if not tds:
+ continue
+ toe_a = tds[1].find("a")
+ cert = {
+ "supplier": sns(tds[0].text),
+ "toe_name": sns(toe_a.text),
+ "toe_link": constants.CC_JAPAN_BASE_URL + "/" + toe_a["href"],
+ "claim": sns(tds[2].text),
+ }
+ results.append(cert)
+ return results
+
+ @staticmethod
+ def get_malaysia_certified():
+ soup = CCSchemeDataset._download_page(constants.CC_MALAYSIA_CERTIFIED_URL)
+ main_div = soup.find("div", attrs={"itemprop": "articleBody"})
+ tables = main_div.find_all("table", recursive=False)
+ results = []
+ for table in tables:
+ category_name = sns(table.find_previous_sibling("h3").text)
+ for tr in table.find_all("tr")[1:]:
+ tds = tr.find_all("td")
+ if len(tds) != 6:
+ continue
+ cert = {
+ "category": category_name,
+ "level": sns(tds[0].text),
+ "cert_id": sns(tds[1].text),
+ "certification_date": sns(tds[2].text),
+ "product": sns(tds[3].text),
+ "developer": sns(tds[4].text),
+ }
+ results.append(cert)
+ return results
+
+ @staticmethod
+ def get_malaysia_in_evaluation():
+ soup = CCSchemeDataset._download_page(constants.CC_MALAYSIA_INEVAL_URL)
+ main_div = soup.find("div", attrs={"itemprop": "articleBody"})
+ tables = main_div.find_all("table", recursive=False)
+ results = []
+ for table in tables:
+ category_name = sns(table.find_previous_sibling("h3").text)
+ for tr in table.find_all("tr")[1:]:
+ tds = tr.find_all("td")
+ if len(tds) != 5:
+ continue
+ cert = {
+ "category": category_name,
+ "level": sns(tds[0].text),
+ "project_id": sns(tds[1].text),
+ "toe_name": sns(tds[2].text),
+ "developer": sns(tds[3].text),
+ "expected_completion": sns(tds[4].text),
+ }
+ results.append(cert)
+ return results
+
+ @staticmethod
+ def get_netherlands_certified():
+ soup = CCSchemeDataset._download_page(constants.CC_NETHERLANDS_CERTIFIED_URL)
+ main_div = soup.select("body > main > div > div > div > div:nth-child(2) > div.col-lg-9 > div:nth-child(3)")[0]
+ rows = main_div.find_all("div", class_="row", recursive=False)
+ modals = main_div.find_all("div", class_="modal", recursive=False)
+ results = []
+ for row, modal in zip(rows, modals):
+ row_entries = row.find_all("a")
+ modal_trs = modal.find_all("tr")
+ cert = {
+ "manufacturer": sns(row_entries[0].text),
+ "product": sns(row_entries[1].text),
+ "scheme": sns(row_entries[2].text),
+ "cert_id": sns(row_entries[3].text),
+ }
+ for tr in modal_trs:
+ th_text = tr.find("th").text
+ td = tr.find("td")
+ if "Manufacturer website" in th_text:
+ cert["manufacturer_link"] = td.find("a")["href"]
+ elif "Assurancelevel" in th_text:
+ cert["level"] = sns(td.text)
+ elif "Certificate" in th_text:
+ cert["cert_link"] = constants.CC_NETHERLANDS_BASE_URL + td.find("a")["href"]
+ elif "Certificationreport" in th_text:
+ cert["report_link"] = constants.CC_NETHERLANDS_BASE_URL + td.find("a")["href"]
+ elif "Securitytarget" in th_text:
+ cert["target_link"] = constants.CC_NETHERLANDS_BASE_URL + td.find("a")["href"]
+ elif "Maintenance report" in th_text:
+ cert["maintenance_link"] = constants.CC_NETHERLANDS_BASE_URL + td.find("a")["href"]
+ results.append(cert)
+ return results
+
+ @staticmethod
+ def get_netherlands_in_evaluation():
+ soup = CCSchemeDataset._download_page(constants.CC_NETHERLANDS_INEVAL_URL)
+ table = soup.find("table")
+ results = []
+ for tr in table.find_all("tr")[1:]:
+ tds = tr.find_all("td")
+ cert = {
+ "developer": sns(tds[0].text),
+ "product": sns(tds[1].text),
+ "category": sns(tds[2].text),
+ "level": sns(tds[3].text),
+ "certification_id": sns(tds[4].text),
+ }
+ results.append(cert)
+ return results
+
+ @staticmethod
+ def _get_norway(url):
+ # TODO: Information could be expanded by following product link.
+ soup = CCSchemeDataset._download_page(url)
+ results = []
+ for tr in soup.find_all("tr", class_="certified-product"):
+ tds = tr.find_all("td")
+ cert = {
+ "product": sns(tds[0].text),
+ "product_link": tds[0].find("a")["href"],
+ "category": sns(tds[1].find("p", class_="value").text),
+ "developer": sns(tds[2].find("p", class_="value").text),
+ "certification_date": sns(tds[3].find("time").text),
+ }
+ results.append(cert)
+ return results
+
+ @staticmethod
+ def get_norway_certified():
+ return CCSchemeDataset._get_norway(constants.CC_NORWAY_CERTIFIED_URL)
+
+ @staticmethod
+ def get_norway_archived():
+ return CCSchemeDataset._get_norway(constants.CC_NORWAY_ARCHIVED_URL)
+
+ @staticmethod
+ def _get_korea(product_class):
+ # TODO: Information could be expanded by following product link.
+ session = requests.session()
+ session.get(constants.CC_KOREA_EN_URL)
+ # Get base page
+ url = constants.CC_KOREA_CERTIFIED_URL + f"?product_class={product_class}"
+ soup = CCSchemeDataset._download_page(url, session=session)
+ seen_pages = set()
+ pages = {1}
+ results = []
+ while pages:
+ page = pages.pop()
+ csrf = soup.find("form", id="fm").find("input", attrs={"name": "csrf"})["value"]
+ resp = session.post(url, data={"csrf": csrf, "selectPage": page, "product_class": product_class})
+ soup = BeautifulSoup(resp.content, "html5lib")
+ tbody = soup.find("table", class_="cpl").find("tbody")
+ for tr in tbody.find_all("tr"):
+ tds = tr.find_all("td")
+ if len(tds) != 6:
+ continue
+ link = tds[0].find("a")
+ id = link["id"].split("-")[1]
+ cert = {
+ "product": sns(tds[0].text),
+ "cert_id": sns(tds[1].text),
+ "product_link": constants.CC_KOREA_PRODUCT_URL.format(id),
+ "vendor": sns(tds[2].text),
+ "level": sns(tds[3].text),
+ "category": sns(tds[4].text),
+ "certification_date": sns(tds[5].text),
+ }
+ results.append(cert)
+ seen_pages.add(page)
+ page_links = soup.find("div", class_="paginate").find_all("a", class_="number_off")
+ for page_link in page_links:
+ try:
+ new_page = int(page_link.text)
+ if new_page not in seen_pages:
+ pages.add(new_page)
+ except Exception:
+ pass
+ return results
+
+ @staticmethod
+ def get_korea_certified():
+ return CCSchemeDataset._get_korea(product_class=1)
+
+ @staticmethod
+ def get_korea_suspended():
+ return CCSchemeDataset._get_korea(product_class=2)
+
+ @staticmethod
+ def get_korea_archived():
+ return CCSchemeDataset._get_korea(product_class=4)
+
+ @staticmethod
+ def _get_singapore(url):
+ soup = CCSchemeDataset._download_page(url)
+ table = soup.find("table")
+ skip = False
+ results = []
+ category_name = None
+ for tr in table.find_all("tr"):
+ if skip:
+ skip = False
+ continue
+ tds = tr.find_all("td")
+ if len(tds) == 1:
+ category_name = sns(tds[0].text)
+ skip = True
+ continue
+
+ cert = {
+ "product": sns(tds[0].text.split()[0]),
+ "vendor": sns(tds[1].text),
+ "level": sns(tds[2].text),
+ "certification_date": sns(tds[3].text),
+ "expiration_date": sns(tds[4].text),
+ "category": category_name,
+ }
+ for link in tds[0].find_all("a"):
+ link_text = sns(link.text)
+ if link_text == "Certificate":
+ cert["cert_link"] = constants.CC_SINGAPORE_BASE_URL + link["href"]
+ elif link_text in ("Certificate Report", "Certification Report"):
+ cert["report_link"] = constants.CC_SINGAPORE_BASE_URL + link["href"]
+ elif link_text == "Security Target":
+ cert["target_link"] = constants.CC_SINGAPORE_BASE_URL + link["href"]
+ results.append(cert)
+ return results
+
+ @staticmethod
+ def get_singapore_certified():
+ return CCSchemeDataset._get_singapore(constants.CC_SINGAPORE_CERTIFIED_URL)
+
+ @staticmethod
+ def get_singapore_in_evaluation():
+ soup = CCSchemeDataset._download_page(constants.CC_SINGAPORE_CERTIFIED_URL)
+ header = soup.find(lambda x: x.name == "h3" and x.text == "In Evaluation")
+ table = header.find_next("table")
+ results = []
+ for tr in table.find_all("tr")[1:]:
+ tds = tr.find_all("td")
+ cert = {
+ "name": sns(tds[0].text),
+ "vendor": sns(tds[1].text),
+ "level": sns(tds[2].text),
+ }
+ results.append(cert)
+ return results
+
+ @staticmethod
+ def get_singapore_archived():
+ return CCSchemeDataset._get_singapore(constants.CC_SINGAPORE_ARCHIVED_URL)
+
+ @staticmethod
+ def get_spain_certified():
+ soup = CCSchemeDataset._download_page(constants.CC_SPAIN_CERTIFIED_URL)
+ tbody = soup.find("table", class_="djc_items_table").find("tbody")
+ results = []
+ for tr in tbody.find_all("tr", recursive=False):
+ tds = tr.find_all("td")
+ cert = {
+ "product": sns(tds[0].text),
+ "product_link": constants.CC_SPAIN_BASE_URL + tds[0].find("a")["href"],
+ "category": sns(tds[1].text),
+ "manufacturer": sns(tds[2].text),
+ "certification_date": sns(tds[3].find("td", class_="djc_value").text),
+ }
+ results.append(cert)
+ return results
+
+ @staticmethod
+ def _get_sweden(url):
+ # TODO: Information could be expanded by following product link.
+ soup = CCSchemeDataset._download_page(url)
+ nav = soup.find("main").find("nav", class_="component-nav-box__list")
+ results = []
+ for link in nav.find_all("a"):
+ cert = {"product": sns(link.text), "product_link": constants.CC_SWEDEN_BASE_URL + link["href"]}
+ results.append(cert)
+ return results
+
+ @staticmethod
+ def get_sweden_certified():
+ return CCSchemeDataset._get_sweden(constants.CC_SWEDEN_CERTIFIED_URL)
+
+ @staticmethod
+ def get_sweden_in_evaluation():
+ return CCSchemeDataset._get_sweden(constants.CC_SWEDEN_INEVAL_URL)
+
+ @staticmethod
+ def get_sweden_archived():
+ return CCSchemeDataset._get_sweden(constants.CC_SWEDEN_ARCHIVED_URL)
+
+ @staticmethod
+ def get_turkey_certified():
+ results = []
+ with tempfile.TemporaryDirectory() as tmpdir:
+ pdf_path = Path(tmpdir) / "turkey.pdf"
+ resp = requests.get(constants.CC_TURKEY_ARCHIVED_URL)
+ if resp.status_code != requests.codes.ok:
+ raise ValueError(f"Unable to download: status={resp.status_code}")
+ with pdf_path.open("wb") as f:
+ f.write(resp.content)
+ dfs = tabula.read_pdf(str(pdf_path), pages="all")
+ for df in dfs:
+ for line in df.values:
+ cert = {
+ # TODO: Split item number and generate several dicts for a range they include.
+ "item_no": line[0],
+ "developer": line[1],
+ "product": line[2],
+ "cc_version": line[3],
+ "level": line[4],
+ "cert_lab": line[5],
+ "certification_date": line[6],
+ "expiration_date": line[7],
+ # TODO: Parse "Ongoing Evaluation" out of this field as well.
+ "archived": isinstance(line[9], str) and "Archived" in line[9],
+ }
+ results.append(cert)
+ return results
+
+ @staticmethod
+ def get_usa_certified():
+ # TODO: Information could be expanded by following product link.
+ # TODO: Information could be expanded by following the cc_claims (has links to protection profiles).
+ soup = CCSchemeDataset._download_page(constants.CC_USA_CERTIFIED_URL)
+ tbody = soup.find("table", class_="tablesorter").find("tbody")
+ results = []
+ for tr in tbody.find_all("tr"):
+ tds = tr.find_all("td")
+ vendor_span = tds[0].find("span", class_="b u")
+ product_link = tds[0].find("a")
+ scheme_img = tds[6].find("img")
+ # Only return the US certifications.
+ if scheme_img["title"] != "USA":
+ continue
+ cert = {
+ "product": sns(product_link.text),
+ "vendor": sns(vendor_span.text),
+ "product_link": product_link["href"],
+ "id": sns(tds[1].text),
+ "cc_claim": sns(tds[2].text),
+ "cert_lab": sns(tds[3].text),
+ "certification_date": sns(tds[4].text),
+ "assurance_maintenance_date": sns(tds[5].text),
+ }
+ results.append(cert)
+ return results
+
+ @staticmethod
+ def get_usa_in_evaluation():
+ # TODO: Information could be expanded by following the cc_claims (has links to protection profiles).
+ soup = CCSchemeDataset._download_page(constants.CC_USA_INEVAL_URL)
+ tbody = soup.find("table", class_="tablesorter").find("tbody")
+ results = []
+ for tr in tbody.find_all("tr"):
+ tds = tr.find_all("td")
+ vendor_span = tds[0].find("span", class_="b u")
+ product_name = None
+ for child in tds[0].children:
+ if isinstance(child, NavigableString):
+ product_name = sns(child)
+ break
+ cert = {
+ "vendor": sns(vendor_span.text),
+ "id": sns(tds[1].text),
+ "cc_claim": sns(tds[2].text),
+ "cert_lab": sns(tds[3].text),
+ "kickoff_date": sns(tds[4].text),
+ }
+ if product_name:
+ cert["product"] = product_name
+ results.append(cert)
+ return results
+
+ @staticmethod
+ def get_usa_archived():
+ # TODO: Information could be expanded by following the cc_claims (has links to protection profiles).
+ soup = CCSchemeDataset._download_page(constants.CC_USA_ARCHIVED_URL)
+ tbody = soup.find("table", class_="tablesorter").find("tbody")
+ results = []
+ for tr in tbody.find_all("tr"):
+ tds = tr.find_all("td")
+ scheme_img = tds[5].find("img")
+ # Only return the US certifications.
+ if scheme_img["title"] != "USA":
+ continue
+ vendor_span = tds[0].find("span", class_="b u")
+ product_name = None
+ for child in tds[0].children:
+ if isinstance(child, NavigableString):
+ product_name = sns(child)
+ break
+ cert = {
+ "vendor": sns(vendor_span.text),
+ "id": sns(tds[1].text),
+ "cc_claim": sns(tds[2].text),
+ "cert_lab": sns(tds[3].text),
+ "certification_date": sns(tds[4].text),
+ }
+ if product_name:
+ cert["product"] = product_name
+ results.append(cert)
+ return results
diff --git a/src/sec_certs/dataset/cpe.py b/src/sec_certs/dataset/cpe.py
new file mode 100644
index 00000000..927ce674
--- /dev/null
+++ b/src/sec_certs/dataset/cpe.py
@@ -0,0 +1,197 @@
+from __future__ import annotations
+
+import copy
+import itertools
+import logging
+import tempfile
+import xml.etree.ElementTree as ET
+import zipfile
+from pathlib import Path
+from typing import ClassVar, Iterator
+
+import pandas as pd
+
+from sec_certs import constants
+from sec_certs.dataset.cve import CVEDataset
+from sec_certs.dataset.json_path_dataset import JSONPathDataset
+from sec_certs.sample.cpe import CPE, cached_cpe
+from sec_certs.serialization.json import ComplexSerializableType, serialize
+from sec_certs.utils import helpers
+from sec_certs.utils.tqdm import tqdm
+
+logger = logging.getLogger(__name__)
+
+
+class CPEDataset(JSONPathDataset, ComplexSerializableType):
+ """
+ Dataset of CPE records. Includes look-up dictionaries for fast search.
+ """
+
+ CPE_XML_BASENAME: ClassVar[str] = "official-cpe-dictionary_v2.3.xml"
+ CPE_URL: ClassVar[str] = "https://nvd.nist.gov/feeds/xml/cpe/dictionary/" + CPE_XML_BASENAME + ".zip"
+
+ def __init__(
+ self,
+ was_enhanced_with_vuln_cpes: bool,
+ cpes: dict[str, CPE],
+ json_path: str | Path = constants.DUMMY_NONEXISTING_PATH,
+ ):
+ self.was_enhanced_with_vuln_cpes = was_enhanced_with_vuln_cpes
+ self.cpes = cpes
+ self.json_path = Path(json_path)
+
+ self.vendor_to_versions: dict[str, set[str]] = dict()
+ self.vendor_version_to_cpe: dict[tuple[str, str], set[CPE]] = dict()
+ self.title_to_cpes: dict[str, set[CPE]] = dict()
+ self.vendors: set[str] = set()
+
+ self.build_lookup_dicts()
+
+ def __iter__(self) -> Iterator[CPE]:
+ yield from self.cpes.values()
+
+ def __getitem__(self, item: str) -> CPE:
+ return self.cpes.__getitem__(item.lower())
+
+ def __setitem__(self, key: str, value: CPE) -> None:
+ self.cpes.__setitem__(key.lower(), value)
+
+ def __len__(self) -> int:
+ return len(self.cpes)
+
+ def __contains__(self, item: CPE) -> bool:
+ if not isinstance(item, CPE):
+ raise ValueError(f"{item} is not of CPE class")
+ return item.uri in self.cpes.keys() and self.cpes[item.uri] == item
+
+ def __eq__(self, other: object) -> bool:
+ return isinstance(other, CPEDataset) and self.cpes == other.cpes
+
+ @property
+ def serialized_attributes(self) -> list[str]:
+ return ["was_enhanced_with_vuln_cpes", "cpes"]
+
+ def build_lookup_dicts(self) -> None:
+ """
+ Will build look-up dictionaries that are used for fast matching.
+ """
+ logger.info("CPE dataset: building lookup dictionaries.")
+ self.vendor_to_versions = {x.vendor: set() for x in self}
+ self.vendor_version_to_cpe = dict()
+ self.title_to_cpes = dict()
+ self.vendors = set(self.vendor_to_versions.keys())
+ for cpe in self:
+ self.vendor_to_versions[cpe.vendor].add(cpe.version)
+ if (cpe.vendor, cpe.version) not in self.vendor_version_to_cpe:
+ self.vendor_version_to_cpe[(cpe.vendor, cpe.version)] = {cpe}
+ else:
+ self.vendor_version_to_cpe[(cpe.vendor, cpe.version)].add(cpe)
+
+ if cpe.title:
+ if cpe.title not in self.title_to_cpes:
+ self.title_to_cpes[cpe.title] = {cpe}
+ else:
+ self.title_to_cpes[cpe.title].add(cpe)
+
+ @classmethod
+ def from_web(cls, json_path: str | Path = constants.DUMMY_NONEXISTING_PATH) -> CPEDataset:
+ """
+ Creates CPEDataset from NIST resources published on-line
+
+ :param Union[str, Path] json_path: Path to store the dataset to
+ :return CPEDataset: The resulting dataset
+ """
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ xml_path = Path(tmp_dir) / cls.CPE_XML_BASENAME
+ zip_path = Path(tmp_dir) / (cls.CPE_XML_BASENAME + ".zip")
+ helpers.download_file(cls.CPE_URL, zip_path)
+
+ with zipfile.ZipFile(zip_path, "r") as zip_ref:
+ zip_ref.extractall(tmp_dir)
+
+ return cls._from_xml(xml_path, json_path)
+
+ @classmethod
+ def _from_xml(cls, xml_path: str | Path, json_path: str | Path = constants.DUMMY_NONEXISTING_PATH) -> CPEDataset:
+ logger.info("Loading CPE dataset from XML.")
+ root = ET.parse(xml_path).getroot()
+ dct = {}
+ for cpe_item in root.findall("{http://cpe.mitre.org/dictionary/2.0}cpe-item"):
+ found_title = cpe_item.find("{http://cpe.mitre.org/dictionary/2.0}title")
+ if found_title is None:
+ raise RuntimeError(
+ "Title is not found during building CPE dataset from xml - this should not be happening"
+ )
+ title = found_title.text
+
+ found_cpe_uri = cpe_item.find("{http://scap.nist.gov/schema/cpe-extension/2.3}cpe23-item")
+ if found_cpe_uri is None:
+ raise RuntimeError(
+ "CPE uri is not found during building CPE dataset from xml - this should not be happening"
+ )
+ cpe_uri = found_cpe_uri.attrib["name"]
+
+ dct[cpe_uri] = cached_cpe(cpe_uri, title)
+
+ return cls(False, dct, json_path)
+
+ def to_pandas(self) -> pd.DataFrame:
+ """
+ Turns the dataset into pandas DataFrame. Each CPE record forms a row.
+
+ :return pd.DataFrame: the resulting DataFrame
+ """
+ df = pd.DataFrame([x.pandas_tuple for x in self], columns=CPE.pandas_columns)
+ df = df.set_index("uri")
+ return df
+
+ @serialize
+ def enhance_with_cpes_from_cve_dataset(self, cve_dset: CVEDataset | str | Path) -> None:
+ """
+ Some CPEs are present only in the CVEDataset and are missing from the CPE Dataset.
+ This method goes through the provided CVEDataset and enriches self with CPEs from
+ the CVEDataset.
+
+ :param Union[CVEDataset, str, Path] cve_dset: CVEDataset of a path to it.
+ """
+
+ def _adding_condition(
+ considered_cpe: CPE,
+ vndr_item_lookup: set[tuple[str, str]],
+ vndr_item_version_lookup: set[tuple[str, str, str]],
+ ) -> bool:
+ if (
+ considered_cpe.version == constants.CPE_VERSION_NA
+ and (considered_cpe.vendor, considered_cpe.item_name) not in vndr_item_lookup
+ ):
+ return True
+ elif (
+ considered_cpe.version != constants.CPE_VERSION_NA
+ and (considered_cpe.vendor, considered_cpe.item_name, considered_cpe.version)
+ not in vndr_item_version_lookup
+ ):
+ return True
+ return False
+
+ if isinstance(cve_dset, (str, Path)):
+ cve_dset = CVEDataset.from_json(cve_dset)
+
+ if not isinstance(cve_dset, CVEDataset):
+ raise RuntimeError("Conversion of CVE dataset did not work.")
+ all_cpes_in_cve_dset = set(itertools.chain.from_iterable(cve.vulnerable_cpes for cve in cve_dset))
+
+ old_len = len(self.cpes)
+
+ # We only enrich if tuple (vendor, item_name) is not already in the dataset
+ vendor_item_lookup = {(cpe.vendor, cpe.item_name) for cpe in self}
+ vendor_item_version_lookup = {(cpe.vendor, cpe.item_name, cpe.version) for cpe in self}
+ for cpe in tqdm(all_cpes_in_cve_dset, desc="Enriching CPE dataset with new CPEs"):
+ if _adding_condition(cpe, vendor_item_lookup, vendor_item_version_lookup):
+ new_cpe = copy.deepcopy(cpe)
+ new_cpe.start_version = None
+ new_cpe.end_version = None
+ self[new_cpe.uri] = new_cpe
+ self.build_lookup_dicts()
+
+ logger.info(f"Enriched the CPE dataset with {len(self.cpes) - old_len} new CPE records.")
+ self.was_enhanced_with_vuln_cpes = True
diff --git a/src/sec_certs/dataset/cve.py b/src/sec_certs/dataset/cve.py
new file mode 100644
index 00000000..ebbd9171
--- /dev/null
+++ b/src/sec_certs/dataset/cve.py
@@ -0,0 +1,221 @@
+from __future__ import annotations
+
+import datetime
+import glob
+import itertools
+import json
+import logging
+import shutil
+import tempfile
+import zipfile
+from pathlib import Path
+from typing import ClassVar
+
+import numpy as np
+import pandas as pd
+
+import sec_certs.constants as constants
+import sec_certs.utils.helpers as helpers
+from sec_certs.config.configuration import config
+from sec_certs.dataset.json_path_dataset import JSONPathDataset
+from sec_certs.sample.cpe import CPE, cached_cpe
+from sec_certs.sample.cve import CVE
+from sec_certs.serialization.json import ComplexSerializableType
+from sec_certs.utils.parallel_processing import process_parallel
+from sec_certs.utils.tqdm import tqdm
+
+logger = logging.getLogger(__name__)
+
+
+class CVEDataset(JSONPathDataset, ComplexSerializableType):
+ CVE_URL: ClassVar[str] = "https://nvd.nist.gov/feeds/json/cve/1.1/nvdcve-1.1-"
+ CPE_MATCH_FEED_URL: ClassVar[str] = "https://nvd.nist.gov/feeds/json/cpematch/1.0/nvdcpematch-1.0.json.zip"
+
+ def __init__(self, cves: dict[str, CVE], json_path: str | Path = constants.DUMMY_NONEXISTING_PATH):
+ self.cves = cves
+ self.json_path = Path(json_path)
+ self.cpe_to_cve_ids_lookup: dict[str, set[str]] = dict()
+
+ @property
+ def serialized_attributes(self) -> list[str]:
+ return ["cves"]
+
+ def __iter__(self):
+ yield from self.cves.values()
+
+ def __getitem__(self, item: str) -> CVE:
+ return self.cves.__getitem__(item.upper())
+
+ def __setitem__(self, key: str, value: CVE):
+ self.cves.__setitem__(key.lower(), value)
+
+ def __len__(self) -> int:
+ return len(self.cves)
+
+ def __eq__(self, other: object):
+ return isinstance(other, CVEDataset) and self.cves == other.cves
+
+ def build_lookup_dict(self, use_nist_mapping: bool = True, nist_matching_filepath: Path | None = None):
+ """
+ Builds look-up dictionary CPE -> Set[CVE]
+ Developer's note: There are 3 CPEs that are present in the cpe matching feed, but are badly processed by CVE
+ feed, in which case they won't be found as a key in the dictionary. We intentionally ignore those. Feel free
+ to add corner cases and manual fixes. According to our investigation, the suffereing CPEs are:
+ - CPE(uri='cpe:2.3:a:arubanetworks:airwave:*:*:*:*:*:*:*:*', title=None, version='*', vendor='arubanetworks', item_name='airwave', start_version=None, end_version=('excluding', '8.2.0.0'))
+ - CPE(uri='cpe:2.3:a:bayashi:dopvcomet\\*:0009:b:*:*:*:*:*:*', title=None, version='0009', vendor='bayashi', item_name='dopvcomet\\*', start_version=None, end_version=None)
+ - CPE(uri='cpe:2.3:a:bayashi:dopvstar\\*:0091:*:*:*:*:*:*:*', title=None, version='0091', vendor='bayashi', item_name='dopvstar\\*', start_version=None, end_version=None)
+ """
+ self.cpe_to_cve_ids_lookup = dict()
+ self.cves = {x.cve_id.upper(): x for x in self}
+
+ logger.info("Getting CPE matching dictionary from NIST.gov")
+
+ if use_nist_mapping:
+ matching_dict = self.get_nist_cpe_matching_dict(nist_matching_filepath)
+
+ cve: CVE
+ for cve in tqdm(self, desc="Building-up lookup dictionaries for fast CVE matching"):
+ # See note above, we use matching_dict.get(cpe, []) instead of matching_dict[cpe] as would be expected
+ if use_nist_mapping:
+ vulnerable_configurations = list(
+ itertools.chain.from_iterable(matching_dict.get(cpe, []) for cpe in cve.vulnerable_cpes)
+ )
+ else:
+ vulnerable_configurations = cve.vulnerable_cpes
+ for cpe in vulnerable_configurations:
+ if cpe.uri not in self.cpe_to_cve_ids_lookup:
+ self.cpe_to_cve_ids_lookup[cpe.uri] = {cve.cve_id}
+ else:
+ self.cpe_to_cve_ids_lookup[cpe.uri].add(cve.cve_id)
+
+ @classmethod
+ def download_cves(cls, output_path_str: str, start_year: int, end_year: int):
+ output_path = Path(output_path_str)
+ if not output_path.exists():
+ output_path.mkdir()
+
+ urls = [cls.CVE_URL + str(x) + ".json.zip" for x in range(start_year, end_year + 1)]
+
+ logger.info(f"Identified {len(urls)} CVE files to fetch from nist.gov. Downloading them into {output_path}")
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ outpaths = [Path(tmp_dir) / Path(x).name.rstrip(".zip") for x in urls]
+ responses = helpers.download_parallel(urls, outpaths, "Downloading CVEs resources from NVD")
+
+ for o, r in zip(outpaths, responses):
+ if r == constants.RESPONSE_OK:
+ with zipfile.ZipFile(o, "r") as zip_handle:
+ zip_handle.extractall(output_path)
+
+ @classmethod
+ def from_nist_json(cls, input_path: str) -> CVEDataset:
+ with Path(input_path).open("r") as handle:
+ data = json.load(handle)
+ cves = [CVE.from_nist_dict(x) for x in data["CVE_Items"]]
+ return cls({x.cve_id: x for x in cves})
+
+ @classmethod
+ def from_web(
+ cls,
+ start_year: int = 2002,
+ end_year: int = datetime.datetime.now().year,
+ json_path: str | Path = constants.DUMMY_NONEXISTING_PATH,
+ ):
+ logger.info("Building CVE dataset from nist.gov website.")
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ cls.download_cves(tmp_dir, start_year, end_year)
+ json_files = glob.glob(tmp_dir + "/*.json")
+
+ all_cves = dict()
+ logger.info("Downloaded required resources. Building CVEDataset from jsons.")
+ results = process_parallel(
+ cls.from_nist_json,
+ json_files,
+ config.n_threads,
+ use_threading=False,
+ progress_bar_desc="Building CVEDataset from jsons",
+ )
+ for r in results:
+ all_cves.update(r.cves)
+
+ return cls(all_cves, json_path)
+
+ def get_cve_ids_for_cpe_uri(self, cpe_uri: str) -> set[str] | None:
+ return self.cpe_to_cve_ids_lookup.get(cpe_uri, None)
+
+ def filter_related_cpes(self, relevant_cpes: set[CPE]):
+ """
+ Since each of the CVEs is related to many CPEs, the dataset size explodes (serialized). For certificates,
+ only CPEs within sample dataset are relevant. This function modifies all CVE elements. Specifically, it
+ deletes all CPE records unless they are part of relevant_cpe_uris.
+ :param relevant_cpes: List of relevant CPEs to keep in CVE dataset.
+ """
+ total_deleted_cpes = 0
+ cve_ids_to_delete = []
+ for cve in self:
+ n_cpes_orig = len(cve.vulnerable_cpes)
+ cve.vulnerable_cpes = list(filter(lambda x: x in relevant_cpes, cve.vulnerable_cpes))
+ total_deleted_cpes += n_cpes_orig - len(cve.vulnerable_cpes)
+ if not cve.vulnerable_cpes:
+ cve_ids_to_delete.append(cve.cve_id)
+
+ for cve_id in cve_ids_to_delete:
+ del self.cves[cve_id]
+ logger.info(
+ f"Totally deleted {total_deleted_cpes} irrelevant CPEs and {len(cve_ids_to_delete)} CVEs from CVEDataset."
+ )
+
+ def to_pandas(self) -> pd.DataFrame:
+ df = pd.DataFrame([x.pandas_tuple for x in self], columns=CVE.pandas_columns)
+ df.cwe_ids = df.cwe_ids.map(lambda x: x if x else np.nan)
+ return df.set_index("cve_id")
+
+ def get_nist_cpe_matching_dict(self, input_filepath: Path | None) -> dict[CPE, list[CPE]]:
+ """
+ Computes dictionary that maps complex CPEs to list of simple CPEs.
+ """
+
+ def parse_key_cpe(field: dict) -> CPE:
+ start_version = None
+ if "versionStartIncluding" in field:
+ start_version = ("including", field["versionStartIncluding"])
+ elif "versionStartExcluding" in field:
+ start_version = ("excluding", field["versionStartExcluding"])
+
+ end_version = None
+ if "versionEndIncluding" in field:
+ end_version = ("including", field["versionEndIncluding"])
+ elif "versionEndExcluding" in field:
+ end_version = ("excluding", field["versionEndExcluding"])
+
+ return cached_cpe(field["cpe23Uri"], start_version=start_version, end_version=end_version)
+
+ def parse_values_cpe(field: dict) -> list[CPE]:
+ return [cached_cpe(x["cpe23Uri"]) for x in field["cpe_name"]]
+
+ logger.debug("Attempting to get NIST mapping file.")
+ if not input_filepath or not input_filepath.is_file():
+ logger.debug("NIST mapping file not available, going to download.")
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ filename = Path(self.CPE_MATCH_FEED_URL).name
+ download_path = Path(tmp_dir) / filename
+ unzipped_path = Path(tmp_dir) / filename.rstrip(".zip")
+ helpers.download_file(self.CPE_MATCH_FEED_URL, download_path)
+
+ with zipfile.ZipFile(download_path, "r") as zip_handle:
+ zip_handle.extractall(tmp_dir)
+ with unzipped_path.open("r") as handle:
+ match_data = json.load(handle)
+ if input_filepath:
+ logger.debug(f"Copying attained NIST mapping file to {input_filepath}")
+ shutil.move(str(unzipped_path), str(input_filepath))
+ else:
+ with input_filepath.open("r") as handle:
+ match_data = json.load(handle)
+
+ mapping_dict = dict()
+ for match in tqdm(match_data["matches"], desc="parsing cpe matching (by NIST) dictionary"):
+ key = parse_key_cpe(match)
+ value = parse_values_cpe(match)
+ mapping_dict[key] = value if value else [key]
+
+ return mapping_dict
diff --git a/src/sec_certs/dataset/dataset.py b/src/sec_certs/dataset/dataset.py
new file mode 100644
index 00000000..1c89e771
--- /dev/null
+++ b/src/sec_certs/dataset/dataset.py
@@ -0,0 +1,572 @@
+from __future__ import annotations
+
+import itertools
+import json
+import logging
+import re
+import shutil
+import tempfile
+from abc import ABC, abstractmethod
+from dataclasses import dataclass
+from datetime import datetime
+from pathlib import Path
+from typing import Any, Generic, Iterator, TypeVar, cast
+
+import pandas as pd
+
+import sec_certs.constants as constants
+import sec_certs.utils.helpers as helpers
+from sec_certs.config.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.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.tqdm import tqdm
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class AuxillaryDatasets:
+ cpe_dset: CPEDataset | None = None
+ cve_dset: CVEDataset | None = None
+
+
+CertSubType = TypeVar("CertSubType", bound=Certificate)
+AuxillaryDatasetsSubType = TypeVar("AuxillaryDatasetsSubType", bound=AuxillaryDatasets)
+DatasetSubType = TypeVar("DatasetSubType", bound="Dataset")
+
+
+class Dataset(Generic[CertSubType, AuxillaryDatasetsSubType], 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.
+ """
+
+ @dataclass
+ class DatasetInternalState(ComplexSerializableType):
+ meta_sources_parsed: bool = False
+ artifacts_downloaded: bool = False
+ pdfs_converted: bool = False
+ auxillary_datasets_processed: bool = False
+ certs_analyzed: bool = False
+
+ def __bool__(self):
+ return any(vars(self))
+
+ def __init__(
+ self,
+ certs: dict[str, CertSubType] = dict(),
+ root_dir: str | Path = constants.DUMMY_NONEXISTING_PATH,
+ name: str | None = None,
+ description: str = "",
+ state: DatasetInternalState | None = None,
+ auxillary_datasets: AuxillaryDatasetsSubType | None = None,
+ ):
+ self.certs = certs
+
+ self.timestamp = datetime.now()
+ self.sha256_digest = "not implemented"
+ 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 auxillary_datasets:
+ self.auxillary_datasets = AuxillaryDatasets()
+ else:
+ self.auxillary_datasets = auxillary_datasets
+
+ self.root_dir = Path(root_dir)
+
+ @property
+ def root_dir(self) -> Path:
+ """
+ Directory that will hold the serialized dataset files.
+ """
+ return self._root_dir
+
+ @root_dir.setter
+ def root_dir(self: DatasetSubType, 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.
+ """
+ new_dir = Path(new_dir)
+ if new_dir.is_file():
+ raise ValueError(f"Root dir of {get_class_fullname(self)} cannot be a file.")
+
+ self._root_dir = new_dir
+ self._set_local_paths()
+
+ @property
+ def web_dir(self) -> Path:
+ """
+ Path to certification-artifacts posted on web.
+ """
+ return self.root_dir / "web"
+
+ @property
+ def auxillary_datasets_dir(self) -> Path:
+ """
+ Path to directory with auxillary datasets.
+ """
+ return self.root_dir / "auxillary_datasets"
+
+ @property
+ def certs_dir(self) -> Path:
+ """
+ Returns directory that holds files associated with certificates
+ """
+ return self.root_dir / "certs"
+
+ @property
+ def cpe_dataset_path(self) -> Path:
+ return self.auxillary_datasets_dir / "cpe_dataset.json"
+
+ @property
+ def cve_dataset_path(self) -> Path:
+ return self.auxillary_datasets_dir / "cve_dataset.json"
+
+ @property
+ def nist_cve_cpe_matching_dset_path(self) -> Path:
+ return self.auxillary_datasets_dir / "nvdcpematch-1.0.json"
+
+ @property
+ def json_path(self) -> Path:
+ return self.root_dir / (self.name + ".json")
+
+ def __contains__(self, item: object) -> bool:
+ if not isinstance(item, Certificate):
+ raise TypeError(
+ f"You attempted to check if {type(item)} is member of {type(self)}, but only {type(Certificate)} are allowed to be members."
+ )
+ return item.dgst in self.certs
+
+ def __iter__(self) -> Iterator[CertSubType]:
+ yield from self.certs.values()
+
+ def __getitem__(self, item: str) -> CertSubType:
+ return self.certs.__getitem__(item.lower())
+
+ def __setitem__(self, key: str, value: CertSubType):
+ self.certs.__setitem__(key.lower(), value)
+
+ def __len__(self) -> int:
+ return len(self.certs)
+
+ def __eq__(self, other: object) -> bool:
+ if not isinstance(other, Dataset):
+ return NotImplemented
+ return self.certs == other.certs
+
+ def __str__(self) -> str:
+ return str(type(self).__name__) + ":" + self.name + ", " + str(len(self)) + " certificates"
+
+ @classmethod
+ def from_web(cls: type[DatasetSubType], url: str, progress_bar_desc: str, filename: str) -> DatasetSubType:
+ """
+ Fetches a fully processed dataset instance from static site that hosts it.
+ """
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ dset_path = Path(tmp_dir) / filename
+ helpers.download_file(url, dset_path, show_progress_bar=True, progress_bar_desc=progress_bar_desc)
+ dset = cls.from_json(dset_path)
+ dset.root_dir = constants.DUMMY_NONEXISTING_PATH
+ return dset
+
+ def to_dict(self) -> dict[str, Any]:
+ return {
+ "state": self.state,
+ "timestamp": self.timestamp,
+ "sha256_digest": self.sha256_digest,
+ "name": self.name,
+ "description": self.description,
+ "n_certs": len(self),
+ "certs": list(self.certs.values()),
+ }
+
+ @classmethod
+ def from_dict(cls: type[DatasetSubType], dct: dict) -> DatasetSubType:
+ 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"]):
+ logger.error(
+ f"The actual number of certs in dataset ({len(dset)}) does not match the claimed number ({claimed})."
+ )
+ return dset
+
+ @classmethod
+ def from_json(cls: type[DatasetSubType], input_path: str | Path) -> DatasetSubType:
+ dset = cast("DatasetSubType", ComplexSerializableType.from_json(input_path))
+ dset._root_dir = Path(input_path).parent.absolute()
+ dset._set_local_paths()
+ return dset
+
+ def _set_local_paths(self) -> None:
+ if self.auxillary_datasets.cpe_dset:
+ self.auxillary_datasets.cpe_dset.json_path = self.cpe_dataset_path
+ if self.auxillary_datasets.cve_dset:
+ self.auxillary_datasets.cve_dset.json_path = self.cve_dataset_path
+
+ 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)
+ if new_root_dir.is_file():
+ raise ValueError("New root dir must be a directory, not an existing file.")
+ new_root_dir.mkdir(parents=True, exist_ok=True)
+
+ shutil.copytree(str(self.root_dir), str(new_root_dir), dirs_exist_ok=True)
+ shutil.rmtree(self.root_dir)
+ self.root_dir = new_root_dir
+
+ 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)
+ if new_root_dir.is_file():
+ raise ValueError("New root dir must be a directory, not an existing file.")
+ new_root_dir.mkdir(parents=True, exist_ok=True)
+
+ 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]:
+ """
+ Returns list of certificates that match given name.
+ """
+ return {crt for crt in self if crt.name and crt.name == name}
+
+ @abstractmethod
+ def get_certs_from_web(self) -> None:
+ raise NotImplementedError("Not meant to be implemented by the base class.")
+
+ @serialize
+ @abstractmethod
+ def process_auxillary_datasets(self, download_fresh: bool = False) -> None:
+ """
+ Processes all auxillary datasets (CPE, CVE, ...) that are required during computation.
+ """
+ logger.info("Processing auxillary datasets.")
+ self.auxillary_datasets_dir.mkdir(parents=True, exist_ok=True)
+ self.auxillary_datasets.cpe_dset = self._prepare_cpe_dataset(download_fresh)
+ self.auxillary_datasets.cve_dset = self._prepare_cve_dataset(download_fresh_cves=download_fresh)
+ self.state.auxillary_datasets_processed = True
+
+ @serialize
+ def download_all_artifacts(self, fresh: bool = True) -> None:
+ """
+ Downloads all artifacts related to certification in the given scheme.
+ """
+ if not self.state.meta_sources_parsed:
+ logger.error("Attempting to download pdfs while not having csv/html meta-sources parsed. Returning.")
+ return
+
+ logger.info("Attempting to download certification artifacts.")
+ self._download_all_artifacts_body(fresh)
+ if fresh:
+ self._download_all_artifacts_body(False)
+
+ self.state.artifacts_downloaded = True
+
+ @abstractmethod
+ def _download_all_artifacts_body(self, fresh: bool = True) -> None:
+ raise NotImplementedError("Not meant to be implemented by the base class.")
+
+ @serialize
+ def convert_all_pdfs(self, fresh: bool = True) -> None:
+ """
+ Converts all pdf artifacts to txt, given the certification scheme.
+ """
+ if not self.state.artifacts_downloaded:
+ logger.error("Attempting to convert pdfs while not having the artifacts downloaded. Returning.")
+ return
+
+ logger.info("Converting all PDFs to txt")
+ self._convert_all_pdfs_body(fresh)
+ if fresh:
+ self._convert_all_pdfs_body(False)
+
+ self.state.pdfs_converted = True
+
+ @abstractmethod
+ def _convert_all_pdfs_body(self, fresh: bool = True) -> None:
+ raise NotImplementedError("Not meant to be implemented by the base class.")
+
+ @serialize
+ def analyze_certificates(self) -> None:
+ """
+ Does two things:
+ - Extracts data from certificates (keywords, etc.)
+ - Computes various heuristics on the certificates.
+ """
+ if not self.state.pdfs_converted:
+ logger.info(
+ "Attempting run analysis of txt files while not having the pdf->txt conversion done. Returning."
+ )
+ return
+ if not self.state.auxillary_datasets_processed:
+ logger.info(
+ "Attempting to run analysis of certifies while not having the auxillary datasets processed. Returning."
+ )
+
+ logger.info("Analyzing certificates.")
+ self._analyze_certificates_body()
+ self.state.certs_analyzed = True
+
+ def _analyze_certificates_body(self) -> None:
+ self.extract_data()
+ 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:
+ logger.info("Computing various heuristics from the certificates.")
+ self.compute_cpe_heuristics()
+ self.compute_related_cves()
+ self._compute_references()
+ self._compute_transitive_vulnerabilities()
+
+ @abstractmethod
+ def _compute_references(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.")
+
+ def _prepare_cpe_dataset(self, download_fresh_cpes: bool = False) -> CPEDataset:
+ logger.info("Preparing CPE dataset.")
+ if not self.auxillary_datasets_dir.exists():
+ self.auxillary_datasets_dir.mkdir(parents=True)
+
+ if not self.cpe_dataset_path.exists() or download_fresh_cpes is True:
+ cpe_dataset = CPEDataset.from_web(self.cpe_dataset_path)
+ cpe_dataset.to_json()
+ else:
+ cpe_dataset = CPEDataset.from_json(str(self.cpe_dataset_path))
+
+ return cpe_dataset
+
+ def _prepare_cve_dataset(
+ self, download_fresh_cves: bool = False, use_nist_cpe_matching_dict: bool = True
+ ) -> CVEDataset:
+ logger.info("Preparing CVE dataset.")
+ if not self.auxillary_datasets_dir.exists():
+ self.auxillary_datasets_dir.mkdir(parents=True)
+
+ if not self.cve_dataset_path.exists() or download_fresh_cves is True:
+ cve_dataset = CVEDataset.from_web(json_path=self.cve_dataset_path)
+ cve_dataset.to_json()
+ else:
+ cve_dataset = CVEDataset.from_json(self.cve_dataset_path)
+
+ cve_dataset.build_lookup_dict(use_nist_cpe_matching_dict, self.nist_cve_cpe_matching_dset_path)
+ return cve_dataset
+
+ @serialize
+ def compute_cpe_heuristics(self, download_fresh_cpes: bool = False) -> CPEClassifier:
+ """
+ Computes matching CPEs for the certificates.
+ """
+ WINDOWS_WEAK_CPES: set[CPE] = {
+ CPE("cpe:2.3:o:microsoft:windows:-:*:*:*:*:*:x64:*", "Microsoft Windows on X64", None, None),
+ CPE("cpe:2.3:o:microsoft:windows:-:*:*:*:*:*:x86:*", "Microsoft Windows on X86", None, None),
+ }
+
+ 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
+ elif (
+ 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
+ elif re.match(constants.RELEASE_CANDIDATE_REGEX, cpe.update):
+ return False
+ elif cpe in WINDOWS_WEAK_CPES:
+ return False
+ return True
+
+ logger.info("Computing heuristics: Finding CPE matches for certificates")
+ if not self.auxillary_datasets.cpe_dset or download_fresh_cpes:
+ self.auxillary_datasets.cpe_dset = self._prepare_cpe_dataset(download_fresh_cpes)
+
+ # Temporarily disabled, see: https://github.com/crocs-muni/sec-certs/issues/173
+ # if not cpe_dset.was_enhanced_with_vuln_cpes:
+ # self.auxillary_datasets.cve_dset = self._prepare_cve_dataset(download_fresh_cves=False)
+ # self.auxillary_datasets.cpe_dset.enhance_with_cpes_from_cve_dataset(cve_dset) # this also calls build_lookup_dicts() on cpe_dset
+ # else:
+ # self.auxillary_datasets.cpe_dset.build_lookup_dicts()
+
+ clf = CPEClassifier(config.cpe_matching_threshold, config.cpe_n_max_matches)
+ clf.fit([x for x in self.auxillary_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
+
+ def to_label_studio_json(self, output_path: str | Path) -> None:
+ cpe_dset = self._prepare_cpe_dataset()
+
+ lst = []
+ for cert in [x for x in cast(Iterator[Certificate], self) if x.heuristics.cpe_matches]:
+ 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({o: c for o, c in 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()
+ 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.keys() 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 cpe_dset.title_to_cpes:
+ logger.error(f"{x} not in dataset")
+ else:
+ to_update = cpe_dset.title_to_cpes[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)
+ )
+
+ @serialize
+ def compute_related_cves(
+ self,
+ download_fresh_cves: bool = False,
+ use_nist_cpe_matching_dict: bool = True,
+ ) -> None:
+ """
+ Computes CVEs for the certificates, given their CPE matches.
+ """
+ logger.info("Retrieving related CVEs to verified CPE matches")
+ if download_fresh_cves or not self.auxillary_datasets.cve_dset:
+ self.auxillary_datasets.cve_dset = self._prepare_cve_dataset(
+ download_fresh_cves, use_nist_cpe_matching_dict
+ )
+
+ logger.info("Computing heuristics: CVEs in certificates.")
+ 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
+
+ relevant_cpes = set(itertools.chain.from_iterable(x.heuristics.cpe_matches for x in cpe_rich_certs))
+ self.auxillary_datasets.cve_dset.filter_related_cpes(relevant_cpes)
+
+ cert: Certificate
+ for cert in tqdm(cpe_rich_certs, desc="Computing related CVES"):
+ if cert.heuristics.cpe_matches:
+ related_cves = [
+ self.auxillary_datasets.cve_dset.get_cve_ids_for_cpe_uri(x) for x in cert.heuristics.cpe_matches
+ ]
+ related_cves = list(filter(lambda x: x is not None, related_cves))
+ if related_cves:
+ cert.heuristics.related_cves = set(
+ itertools.chain.from_iterable(x for x in related_cves if x is not None)
+ )
+ else:
+ cert.heuristics.related_cves = 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.
+ """
+ data = [dict({"dgst": x.dgst}, **x.pdf_data.get_keywords_df_data(var)) for x in self]
+ return pd.DataFrame(data).set_index("dgst")
+
+ def update_with_certs(self, certs: list[CertSubType]) -> None:
+ """
+ Enriches the dataset with `certs`
+ :param List[CommonCriteriaCert] certs: new certs to include into the dataset.
+ """
+ if any([x not in self for x in certs]):
+ logger.warning("Updating dataset with certificates outside of the dataset!")
+ self.certs.update({x.dgst: x for x in certs})
diff --git a/src/sec_certs/dataset/fips.py b/src/sec_certs/dataset/fips.py
new file mode 100644
index 00000000..14571f93
--- /dev/null
+++ b/src/sec_certs/dataset/fips.py
@@ -0,0 +1,355 @@
+from __future__ import annotations
+
+import datetime
+import itertools
+import logging
+import shutil
+from pathlib import Path
+from typing import Final
+
+import numpy as np
+import pandas as pd
+from bs4 import BeautifulSoup, NavigableString
+
+from sec_certs import constants
+from sec_certs.config.configuration import config
+from sec_certs.dataset.cpe import CPEDataset
+from sec_certs.dataset.cve import CVEDataset
+from sec_certs.dataset.dataset import AuxillaryDatasets, 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.sample.fips import FIPSCertificate
+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.helpers import fips_dgst
+
+logger = logging.getLogger(__name__)
+
+
+class FIPSAuxillaryDatasets(AuxillaryDatasets):
+ cpe_dset: CPEDataset | None = None
+ cve_dset: CVEDataset | None = None
+ algorithm_dset: FIPSAlgorithmDataset | None = None
+
+
+class FIPSDataset(Dataset[FIPSCertificate, FIPSAuxillaryDatasets], ComplexSerializableType):
+ """
+ Class for processing of FIPSCertificate samples. Inherits from `ComplexSerializableType` and base abstract `Dataset` class.
+ """
+
+ def __init__(
+ self,
+ certs: dict[str, FIPSCertificate] = dict(),
+ root_dir: str | Path = constants.DUMMY_NONEXISTING_PATH,
+ name: str | None = None,
+ description: str = "",
+ state: Dataset.DatasetInternalState | None = None,
+ auxillary_datasets: FIPSAuxillaryDatasets | None = None,
+ ):
+ self.certs = certs
+ self.timestamp = datetime.datetime.now()
+ self.sha256_digest = "not implemented"
+ 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.auxillary_datasets: FIPSAuxillaryDatasets = (
+ auxillary_datasets if auxillary_datasets else FIPSAuxillaryDatasets()
+ )
+
+ self.root_dir = Path(root_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,
+ "fips_modules_revoked.html": constants.FIPS_REVOKED_MODULES_URL,
+ }
+
+ @property
+ def policies_dir(self) -> Path:
+ return self.certs_dir / "policies"
+
+ @property
+ def policies_pdf_dir(self) -> Path:
+ return self.policies_dir / "pdf"
+
+ @property
+ def policies_txt_dir(self) -> Path:
+ return self.policies_dir / "txt"
+
+ @property
+ def module_dir(self) -> Path:
+ return self.certs_dir / "modules"
+
+ @property
+ def algorithm_dataset_path(self) -> Path:
+ return self.auxillary_datasets_dir / "algorithms.json"
+
+ def __getitem__(self, item: str) -> FIPSCertificate:
+ try:
+ return super().__getitem__(item)
+ except KeyError:
+ return super().__getitem__(fips_dgst(item))
+
+ def _extract_data_from_html_modules(self) -> None:
+ """
+ Extracts data from html module file
+ :param bool fresh: if all certs should be processed, or only the failed ones. Defaults to True
+ """
+ logger.info("Extracting data from html modules.")
+ certs_to_process = [x for x in self if x.state.module_is_ok_to_analyze()]
+ processed_certs = cert_processing.process_parallel(
+ FIPSCertificate.parse_html_module,
+ certs_to_process,
+ config.n_threads,
+ use_threading=False,
+ progress_bar_desc="Extracting data from html modules",
+ )
+ self.update_with_certs(processed_certs)
+
+ @serialize
+ def extract_data(self) -> None:
+ logger.info("Extracting various data from certification artifacts.")
+ for cert in self:
+ cert.state.policy_extract_ok = True
+ cert.state.module_extract_ok = True
+
+ self._extract_data_from_html_modules()
+ self._extract_policy_pdf_metadata()
+ self._extract_policy_pdf_keywords()
+ self._extract_algorithms_from_policy_tables()
+
+ def _extract_policy_pdf_keywords(self) -> None:
+ logger.info("Extracting keywords from policy pdfs.")
+ certs_to_process = [x for x in self if x.state.policy_is_ok_to_analyze()]
+ processed_certs = cert_processing.process_parallel(
+ FIPSCertificate.extract_policy_pdf_keywords,
+ certs_to_process,
+ config.n_threads,
+ use_threading=False,
+ progress_bar_desc="Extracting keywords from policy pdfs",
+ )
+ self.update_with_certs(processed_certs)
+
+ def _download_all_artifacts_body(self, fresh: bool = True) -> None:
+ self._download_modules(fresh)
+ self._download_policies(fresh)
+
+ def _download_modules(self, fresh: bool = True) -> None:
+ self.module_dir.mkdir(parents=True, exist_ok=True)
+ certs_to_process = [x for x in self if x.state.module_is_ok_to_download(fresh)]
+
+ if fresh:
+ logger.info("Downloading HTML cryptographic modules.")
+ if not fresh and certs_to_process:
+ logger.info(f"Downloading {len(certs_to_process)} HTML modules for which download failed.")
+
+ cert_processing.process_parallel(
+ FIPSCertificate.download_module,
+ certs_to_process,
+ config.n_threads,
+ progress_bar_desc="Downloading HTML modules",
+ )
+
+ def _download_policies(self, fresh: bool = True) -> None:
+ self.policies_pdf_dir.mkdir(parents=True, exist_ok=True)
+ certs_to_process = [x for x in self if x.state.policy_is_ok_to_download(fresh)]
+
+ if fresh:
+ logger.info("Downloading PDF security policies.")
+ if not fresh and certs_to_process:
+ logger.info(f"Downloading {len(certs_to_process)} PDF security policies for which download failed.")
+
+ cert_processing.process_parallel(
+ FIPSCertificate.download_policy,
+ certs_to_process,
+ config.n_threads,
+ progress_bar_desc="Downloading PDF security policies",
+ )
+
+ def _convert_all_pdfs_body(self, fresh: bool = True) -> None:
+ self._convert_policies_to_txt(fresh)
+
+ def _convert_policies_to_txt(self, fresh: bool = True) -> None:
+ self.policies_txt_dir.mkdir(parents=True, exist_ok=True)
+ certs_to_process = [x for x in self if x.state.policy_is_ok_to_convert(fresh)]
+
+ if fresh:
+ logger.info("Converting FIPS security policies to .txt")
+ if not fresh and certs_to_process:
+ logger.info(
+ f"Converting {len(certs_to_process)} FIPS security polcies to .txt for which previous convert failed."
+ )
+
+ cert_processing.process_parallel(
+ FIPSCertificate.convert_policy_pdf,
+ certs_to_process,
+ config.n_threads,
+ progress_bar_desc="Converting policies to pdf",
+ )
+
+ def _download_html_resources(self) -> None:
+ logger.info("Downloading HTML files that list FIPS certificates.")
+ html_urls = list(FIPSDataset.LIST_OF_CERTS_HTML.values())
+ html_paths = [self.web_dir / x for x in FIPSDataset.LIST_OF_CERTS_HTML.keys()]
+ helpers.download_parallel(html_urls, html_paths)
+
+ def _get_all_certs_from_html_sources(self) -> list[FIPSCertificate]:
+ return list(
+ itertools.chain.from_iterable(
+ self._get_certificates_from_html(self.web_dir / x) for x in self.LIST_OF_CERTS_HTML.keys()
+ )
+ )
+
+ def _get_certificates_from_html(self, html_file: Path) -> list[FIPSCertificate]:
+ with open(html_file, encoding="utf-8") as handle:
+ html = BeautifulSoup(handle.read(), "html5lib")
+
+ table = [x for x in html.find(id="searchResultsTable").tbody.contents if x != "\n"]
+ cert_ids: set[str] = set()
+
+ for entry in table:
+ if isinstance(entry, NavigableString):
+ continue
+ cert_id = entry.find("a").text
+ if cert_id not in cert_ids:
+ cert_ids.add(cert_id)
+
+ return [FIPSCertificate(cert_id) for cert_id in cert_ids]
+
+ @classmethod
+ def from_web_latest(cls) -> FIPSDataset:
+ """
+ Fetches the fresh snapshot of FIPSDataset from mirror.
+ """
+ return cls.from_web(config.fips_latest_snapshot, "Downloading FIPS Dataset", "fips_latest_dataset.json")
+
+ def _set_local_paths(self) -> None:
+ super()._set_local_paths()
+ if self.auxillary_datasets.algorithm_dset:
+ self.auxillary_datasets.algorithm_dset.json_path = self.algorithm_dataset_path
+
+ cert: FIPSCertificate
+ for cert in self.certs.values():
+ cert.set_local_paths(self.policies_pdf_dir, self.policies_txt_dir, self.module_dir)
+
+ @serialize
+ def get_certs_from_web(self, to_download: bool = True, keep_metadata: bool = True) -> None:
+ self.web_dir.mkdir(parents=True, exist_ok=True)
+
+ if to_download:
+ self._download_html_resources()
+
+ logger.info("Adding unprocessed FIPS certificates into FIPSDataset.")
+ self.certs = {x.dgst: x for x in self._get_all_certs_from_html_sources()}
+ logger.info(f"The dataset now contains {len(self)} certificates.")
+
+ if not keep_metadata:
+ shutil.rmtree(self.web_dir)
+
+ self._set_local_paths()
+ self.state.meta_sources_parsed = True
+
+ @serialize
+ def process_auxillary_datasets(self, download_fresh: bool = False) -> None:
+ super().process_auxillary_datasets(download_fresh)
+ self.auxillary_datasets.algorithm_dset = self._prepare_algorithm_dataset(download_fresh)
+
+ def _prepare_algorithm_dataset(self, download_fresh_algs: bool = False) -> FIPSAlgorithmDataset:
+ logger.info("Preparing FIPSAlgorithm dataset.")
+ 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
+
+ def _extract_algorithms_from_policy_tables(self):
+ logger.info("Extracting Algorithms from policy tables")
+ certs_to_process = [x for x in self if x.state.policy_is_ok_to_analyze()]
+ cert_processing.process_parallel(
+ FIPSCertificate.get_algorithms_from_policy_tables,
+ certs_to_process,
+ config.n_threads,
+ use_threading=False,
+ progress_bar_desc="Extracting Algorithms from policy tables",
+ )
+
+ def _extract_policy_pdf_metadata(self) -> None:
+ logger.info("Extracting security policy metadata from the pdfs")
+ certs_to_process = [x for x in self if x.state.policy_is_ok_to_analyze()]
+ processed_certs = cert_processing.process_parallel(
+ FIPSCertificate.extract_policy_pdf_metadata,
+ certs_to_process,
+ config.n_threads,
+ use_threading=False,
+ progress_bar_desc="Extracting security policy metadata",
+ )
+ self.update_with_certs(processed_certs)
+
+ def _compute_transitive_vulnerabilities(self) -> None:
+ logger.info("Computing heuristics: Computing transitive vulnerabilities in referenc(ed/ing) certificates.")
+ transitive_cve_finder = TransitiveVulnerabilityFinder(lambda cert: 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
+
+ def _prune_reference_candidates(self) -> None:
+ for cert in self:
+ cert.prune_referenced_cert_ids()
+
+ # Previously, a following procedure was used to prune reference_candidates:
+ # - A set of algorithms was obtained via self.auxillary_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.
+
+ def _compute_references(self, keep_unknowns: bool = False) -> None:
+ logger.info("Computing heuristics: Recovering references between certificates")
+ self._prune_reference_candidates()
+
+ policy_reference_finder = ReferenceFinder()
+ policy_reference_finder.fit(
+ self.certs, lambda cert: cert.cert_id, lambda cert: cert.heuristics.policy_prunned_references
+ )
+
+ module_reference_finder = ReferenceFinder()
+ module_reference_finder.fit(
+ self.certs, lambda cert: 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()], columns=FIPSCertificate.pandas_columns)
+ df = df.set_index("dgst")
+
+ df.date_validation = pd.to_datetime(df.date_validation, infer_datetime_format=True)
+ df.date_sunset = pd.to_datetime(df.date_sunset, infer_datetime_format=True)
+
+ # Manually delete one certificate with bad embodiment (seems to have many blank fields)
+ df = df.loc[~(df.embodiment == "*")]
+
+ df = df.astype(
+ {"type": "category", "status": "category", "standard": "category", "embodiment": "category"}
+ ).fillna(value=np.nan)
+
+ df.level = df.level.fillna(value=np.nan).astype("float")
+ # df.level = pd.Categorical(df.level, categories=sorted(df.level.dropna().unique().tolist()), ordered=True)
+
+ # Introduce year when cert got valid
+ df["year_from"] = pd.DatetimeIndex(df.date_validation).year
+
+ return df
diff --git a/src/sec_certs/dataset/fips_algorithm.py b/src/sec_certs/dataset/fips_algorithm.py
new file mode 100644
index 00000000..c48cff07
--- /dev/null
+++ b/src/sec_certs/dataset/fips_algorithm.py
@@ -0,0 +1,124 @@
+from __future__ import annotations
+
+import itertools
+import logging
+import re
+from pathlib import Path
+from tempfile import TemporaryDirectory
+from typing import Iterator
+
+import pandas as pd
+from bs4 import BeautifulSoup
+
+from sec_certs import constants
+from sec_certs.dataset.json_path_dataset import JSONPathDataset
+from sec_certs.sample import FIPSAlgorithm
+from sec_certs.serialization.json import ComplexSerializableType
+from sec_certs.utils import helpers
+
+logger = logging.getLogger(__name__)
+
+
+class FIPSAlgorithmDataset(JSONPathDataset, ComplexSerializableType):
+ def __init__(
+ self, algs: dict[str, FIPSAlgorithm] = dict(), json_path: str | Path = constants.DUMMY_NONEXISTING_PATH
+ ):
+ self.algs = algs
+ self.json_path = Path(json_path)
+ self.alg_number_to_algs: dict[str, set[FIPSAlgorithm]] = dict()
+
+ self._build_lookup_dicts()
+
+ @property
+ def serialized_attributes(self) -> list[str]:
+ return ["algs"]
+
+ def __iter__(self) -> Iterator[FIPSAlgorithm]:
+ yield from self.algs.values()
+
+ def __getitem__(self, item: str) -> FIPSAlgorithm:
+ return self.algs.__getitem__(item)
+
+ def __setitem__(self, key: str, value: FIPSAlgorithm) -> None:
+ self.algs.__setitem__(key, value)
+
+ def __len__(self) -> int:
+ return len(self.algs)
+
+ def __contains__(self, item: FIPSAlgorithm) -> bool:
+ if not isinstance(item, FIPSAlgorithm):
+ raise ValueError(f"{item} is not of FIPSAlgorithm class")
+ return item.dgst in self.algs.keys() and self.algs[item.dgst] == item
+
+ def __eq__(self, other: object) -> bool:
+ return isinstance(other, FIPSAlgorithmDataset) and self.algs == other.algs
+
+ @classmethod
+ def from_web(cls, json_path: str | Path = constants.DUMMY_NONEXISTING_PATH) -> FIPSAlgorithmDataset:
+ with TemporaryDirectory() as tmp_dir:
+ htmls = FIPSAlgorithmDataset.download_alg_list_htmls(Path(tmp_dir))
+ algs = set(itertools.chain.from_iterable(FIPSAlgorithmDataset.parse_algorithms_from_html(x) for x in htmls))
+ return cls({x.dgst: x for x in algs}, json_path)
+
+ @staticmethod
+ def download_alg_list_htmls(output_dir: Path) -> list[Path]:
+ first_page_path = output_dir / "page1.html"
+ ITEMS_PER_PAGE = "ipp=250"
+
+ res = helpers.download_file(constants.FIPS_ALG_SEARCH_URL + "1&" + ITEMS_PER_PAGE, first_page_path)
+ if res != constants.RESPONSE_OK:
+ res = helpers.download_file(constants.FIPS_ALG_SEARCH_URL + "1&" + ITEMS_PER_PAGE, first_page_path)
+ if res != constants.RESPONSE_OK:
+ logger.error(f"Could not build Algorithm dataset, got server response: {res}")
+ raise ValueError(f"Could not build Algorithm dataset, got server response: {res}")
+
+ n_pages = FIPSAlgorithmDataset.get_number_of_html_pages(first_page_path)
+
+ urls = [constants.FIPS_ALG_SEARCH_URL + str(i) + "&" + ITEMS_PER_PAGE for i in range(2, n_pages + 1)]
+ paths = [output_dir / f"page{i}.html" for i in range(2, n_pages + 1)]
+ responses = helpers.download_parallel(urls, paths, progress_bar_desc="Downloading FIPS Algorithm HTMLs")
+
+ failed_tuples = [
+ (url, path) for url, path, resp in zip(urls, paths, responses) if resp != constants.RESPONSE_OK
+ ]
+ if failed_tuples:
+ failed_urls, failed_paths = zip(*failed_tuples)
+ responses = helpers.download_parallel(failed_urls, failed_paths)
+ if any([x != constants.RESPONSE_OK for x in responses]):
+ raise ValueError("Failed to download the algorithms HTML data, the dataset won't be constructed.")
+
+ return paths
+
+ @staticmethod
+ def get_number_of_html_pages(html_path: Path) -> int:
+ with html_path.open("r") as handle:
+ soup = BeautifulSoup(handle, "html5lib")
+ return int(soup.select("span[data-total-pages]")[0].attrs["data-total-pages"])
+
+ @staticmethod
+ def parse_algorithms_from_html(html_path: Path) -> set[FIPSAlgorithm]:
+ df = pd.read_html(html_path)[0]
+ df["alg_type"] = df["Validation Number"].map(lambda x: re.sub(r"[0-9\s]", "", x))
+ df["alg_number"] = df["Validation Number"].map(lambda x: re.sub(r"[^0-9]", "", x))
+ df["alg"] = df.apply(
+ lambda row: FIPSAlgorithm(
+ row["alg_number"], row["alg_type"], row["Vendor"], row["Implementation"], row["Validation Date"]
+ ),
+ axis=1,
+ )
+ return set(df["alg"])
+
+ def to_pandas(self) -> pd.DataFrame:
+ df = pd.DataFrame([x.pandas_tuple for x in self], columns=FIPSAlgorithm.pandas_columns)
+ df = df.set_index("dgst")
+ return df
+
+ def _build_lookup_dicts(self) -> None:
+ for alg in self:
+ if alg.alg_number not in self.alg_number_to_algs:
+ self.alg_number_to_algs[alg.alg_number] = {alg}
+ else:
+ self.alg_number_to_algs[alg.alg_number].add(alg)
+
+ def get_algorithms_by_id(self, alg_number: str) -> set[FIPSAlgorithm]:
+ return self.alg_number_to_algs.get(alg_number, set())
diff --git a/src/sec_certs/dataset/fips_iut.py b/src/sec_certs/dataset/fips_iut.py
new file mode 100644
index 00000000..ce0f2f76
--- /dev/null
+++ b/src/sec_certs/dataset/fips_iut.py
@@ -0,0 +1,66 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from pathlib import Path
+from tempfile import NamedTemporaryFile
+from typing import Iterator, Mapping
+
+import requests
+
+from sec_certs import constants
+from sec_certs.config.configuration import config
+from sec_certs.dataset.dataset import logger
+from sec_certs.dataset.json_path_dataset import JSONPathDataset
+from sec_certs.sample.fips_iut import IUTSnapshot
+from sec_certs.serialization.json import ComplexSerializableType
+from sec_certs.utils.tqdm import tqdm
+
+
+@dataclass
+class IUTDataset(JSONPathDataset, ComplexSerializableType):
+ snapshots: list[IUTSnapshot]
+ _json_path: Path
+
+ def __init__(self, snapshots: list[IUTSnapshot], json_path: str | Path = constants.DUMMY_NONEXISTING_PATH):
+ self.snapshots = snapshots
+ self.json_path = Path(json_path)
+
+ def __iter__(self) -> Iterator[IUTSnapshot]:
+ yield from self.snapshots
+
+ def __getitem__(self, item: int) -> IUTSnapshot:
+ return self.snapshots.__getitem__(item)
+
+ def __len__(self) -> int:
+ return len(self.snapshots)
+
+ @classmethod
+ def from_dumps(cls, dump_path: str | Path) -> IUTDataset:
+ directory = Path(dump_path)
+ fnames = list(directory.glob("*"))
+ snapshots = []
+ for dump_path in tqdm(sorted(fnames), total=len(fnames)):
+ try:
+ snapshots.append(IUTSnapshot.from_dump(dump_path))
+ except Exception as e:
+ logger.error(e)
+ return cls(snapshots)
+
+ def to_dict(self) -> dict[str, list[IUTSnapshot]]:
+ return {"snapshots": list(self.snapshots)}
+
+ @classmethod
+ def from_dict(cls, dct: Mapping) -> IUTDataset:
+ return cls(dct["snapshots"])
+
+ @classmethod
+ def from_web_latest(cls) -> IUTDataset:
+ """
+ Get the IUTDataset from seccerts.org
+ """
+ iut_resp = requests.get(config.fips_iut_dataset)
+ if iut_resp.status_code != 200:
+ raise ValueError(f"Getting IUT dataset 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/dataset/fips_mip.py b/src/sec_certs/dataset/fips_mip.py
new file mode 100644
index 00000000..05ca5854
--- /dev/null
+++ b/src/sec_certs/dataset/fips_mip.py
@@ -0,0 +1,66 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from pathlib import Path
+from tempfile import NamedTemporaryFile
+from typing import Iterator, Mapping
+
+import requests
+
+from sec_certs import constants
+from sec_certs.config.configuration import config
+from sec_certs.dataset.dataset import logger
+from sec_certs.dataset.json_path_dataset import JSONPathDataset
+from sec_certs.sample.fips_mip import MIPSnapshot
+from sec_certs.serialization.json import ComplexSerializableType
+from sec_certs.utils.tqdm import tqdm
+
+
+@dataclass
+class MIPDataset(JSONPathDataset, ComplexSerializableType):
+ snapshots: list[MIPSnapshot]
+ _json_path: Path
+
+ def __init__(self, snapshots: list[MIPSnapshot], json_path: str | Path = constants.DUMMY_NONEXISTING_PATH):
+ self.snapshots = snapshots
+ self.json_path = Path(json_path)
+
+ def __iter__(self) -> Iterator[MIPSnapshot]:
+ yield from self.snapshots
+
+ def __getitem__(self, item: int) -> MIPSnapshot:
+ return self.snapshots.__getitem__(item)
+
+ def __len__(self) -> int:
+ return len(self.snapshots)
+
+ @classmethod
+ def from_dumps(cls, dump_path: str | Path) -> MIPDataset:
+ directory = Path(dump_path)
+ fnames = list(directory.glob("*"))
+ snapshots = []
+ for dump_path in tqdm(sorted(fnames), total=len(fnames)):
+ try:
+ snapshots.append(MIPSnapshot.from_dump(dump_path))
+ except Exception as e:
+ logger.error(e)
+ return cls(snapshots)
+
+ def to_dict(self) -> dict[str, list[MIPSnapshot]]:
+ return {"snapshots": list(self.snapshots)}
+
+ @classmethod
+ def from_dict(cls, dct: Mapping) -> MIPDataset:
+ return cls(dct["snapshots"])
+
+ @classmethod
+ def from_web_latest(cls) -> MIPDataset:
+ """
+ Get the MIPDataset from seccerts.org
+ """
+ mip_resp = requests.get(config.fips_mip_dataset)
+ if mip_resp.status_code != 200:
+ raise ValueError(f"Getting MIP dataset 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/dataset/json_path_dataset.py b/src/sec_certs/dataset/json_path_dataset.py
new file mode 100644
index 00000000..6eb4d968
--- /dev/null
+++ b/src/sec_certs/dataset/json_path_dataset.py
@@ -0,0 +1,46 @@
+from __future__ import annotations
+
+import json
+import logging
+import shutil
+from abc import ABC
+from pathlib import Path
+
+from sec_certs.serialization.json import ComplexSerializableType, CustomJSONDecoder, get_class_fullname
+
+logger = logging.getLogger(__name__)
+
+
+class JSONPathDataset(ComplexSerializableType, ABC):
+ _json_path: Path
+
+ @property
+ def json_path(self) -> Path:
+ return self._json_path
+
+ @json_path.setter
+ def json_path(self, new_path: str | Path) -> None:
+ new_path = Path(new_path)
+ if new_path.is_dir():
+ raise ValueError(f"Json path of {get_class_fullname(self)} cannot be a directory.")
+
+ self._json_path = new_path
+
+ 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)
+
+ if self.json_path.exists():
+ shutil.move(str(self.json_path), str(new_json_path))
+ self.json_path = new_json_path
+ else:
+ self.json_path = new_json_path
+ self.to_json()
+
+ @classmethod
+ def from_json(cls, input_path: str | Path):
+ with Path(input_path).open("r") as handle:
+ dset = json.load(handle, cls=CustomJSONDecoder)
+ dset.json_path = Path(input_path)
+ return dset
diff --git a/src/sec_certs/dataset/protection_profile.py b/src/sec_certs/dataset/protection_profile.py
new file mode 100644
index 00000000..edfb6850
--- /dev/null
+++ b/src/sec_certs/dataset/protection_profile.py
@@ -0,0 +1,98 @@
+from __future__ import annotations
+
+import json
+import logging
+import shutil
+import tempfile
+from dataclasses import dataclass
+from pathlib import Path
+
+import sec_certs.utils.helpers as helpers
+from sec_certs import constants
+from sec_certs.config.configuration import config
+from sec_certs.sample.protection_profile import ProtectionProfile
+from sec_certs.serialization.json import get_class_fullname
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class ProtectionProfileDataset:
+ pps: dict[tuple[str, str | None], ProtectionProfile]
+ _json_path: Path
+
+ 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)
+
+ @property
+ def json_path(self):
+ return self._json_path
+
+ @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.")
+
+ self._json_path = new_path
+
+ 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)
+
+ if not self.json_path.exists():
+ raise ValueError("Cannot move the PPDataset if the json path does not exist.")
+
+ shutil.move(str(self.json_path), str(new_json_path))
+ self.json_path = new_json_path
+
+ def __iter__(self):
+ yield from self.pps.values()
+
+ def __getitem__(self, item: tuple[str, str | None]) -> ProtectionProfile:
+ return self.pps.__getitem__(item)
+
+ def __setitem__(self, key: tuple[str, str | None], value: ProtectionProfile):
+ self.pps.__setitem__(key, value)
+
+ def __contains__(self, key):
+ return key in self.pps
+
+ def __len__(self) -> int:
+ return len(self.pps)
+
+ @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()]
+
+ 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
+
+ return cls(dct)
+
+ @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"
+
+ helpers.download_file(config.pp_latest_snapshot, store_dataset_path)
+ obj = cls.from_json(store_dataset_path)
+
+ if not store_dataset_path:
+ tmp.cleanup()
+
+ return obj
diff --git a/src/sec_certs/model/__init__.py b/src/sec_certs/model/__init__.py
new file mode 100644
index 00000000..fe1024f9
--- /dev/null
+++ b/src/sec_certs/model/__init__.py
@@ -0,0 +1,11 @@
+"""
+This package exposes model (mostly transformers and classifiers) that apply complex transformations. These are to be
+leveraged by members of Dataset package and are directly applied on members of Sample class (or on built-in objects).
+"""
+
+from sec_certs.model.cpe_matching import CPEClassifier
+from sec_certs.model.reference_finder import ReferenceFinder
+from sec_certs.model.sar_transformer import SARTransformer
+from sec_certs.model.transitive_vulnerability_finder import TransitiveVulnerabilityFinder
+
+__all__ = ["CPEClassifier", "ReferenceFinder", "TransitiveVulnerabilityFinder", "SARTransformer"]
diff --git a/src/sec_certs/model/cpe_matching.py b/src/sec_certs/model/cpe_matching.py
new file mode 100644
index 00000000..0febea5d
--- /dev/null
+++ b/src/sec_certs/model/cpe_matching.py
@@ -0,0 +1,404 @@
+from __future__ import annotations
+
+import itertools
+import logging
+import operator
+import re
+from typing import Pattern
+
+import spacy
+from rapidfuzz import fuzz
+from sklearn.base import BaseEstimator
+
+from sec_certs import cert_rules, constants
+from sec_certs.sample.cpe import CPE
+from sec_certs.utils.tqdm import tqdm
+
+logger = logging.getLogger(__name__)
+
+
+class CPEClassifier(BaseEstimator):
+ """
+ Class that can predict CPE matches for certificate instances.
+ Adheres to sklearn BaseEstimator interface.
+ Fit method is called on list of CPEs and build two look-up dictionaries, see description of attributes.
+ """
+
+ vendor_to_versions_: dict[str, set[str]] # Key: CPE vendor, Value: versions of all CPE records of that vendor
+ vendor_version_to_cpe_: dict[tuple[str, str], set[CPE]] # Key: (CPE vendor, version), Value: CPEs (vendor, version)
+ vendors_: set[str]
+
+ def __init__(self, match_threshold: int = 80, n_max_matches: int = 10, spacy_model_to_use: str = "en_core_web_sm"):
+ self.match_threshold = match_threshold
+ self.n_max_matches = n_max_matches
+ self.nlp = spacy.load(spacy_model_to_use, disable=["parser", "ner"])
+
+ def fit(self, X: list[CPE], y: list[str] | None = None) -> CPEClassifier:
+ """
+ Just creates look-up structures from provided list of CPEs
+
+ :param List[CPE] X: List of CPEs that can be matched with predict()
+ :param Optional[List[str]] y: will be ignored, specified to adhere to sklearn BaseEstimator interface, defaults to None
+ :return CPEClassifier: return self to allow method chaining
+ """
+ self._build_lookup_structures(X)
+ return self
+
+ @staticmethod
+ def _filter_short_cpes(cpes: list[CPE]) -> list[CPE]:
+ """
+ Short CPE items are super easy to match with 100% rank, but they are hardly informative. This method discards them.
+
+ :param List[CPE] cpes: List of CPEs to filtered
+ :return List[CPE]: All CPEs in cpes variable which item name has at least 4 characters.
+ """
+ return list(filter(lambda x: x.item_name is not None and len(x.item_name) > 3, cpes))
+
+ def _build_lookup_structures(self, X: list[CPE]) -> None:
+ """
+ Builds several look-up dictionaries for fast matching.
+ - vendor_to_version_: each vendor is mapped to set of versions that appear in combination with vendor in CPE dataset
+ - vendor_version_to_cpe_: Each (vendor, version) tuple is mapped to a set of CPE items that appear in combination with this tuple in CPE dataset
+ - vendors_: Just aggregates set of vendors, used for prunning later on.
+
+ :param List[CPE] X: List of CPEs that will be used to build the dictionaries
+ """
+ sufficiently_long_cpes = self._filter_short_cpes(X)
+ self.vendor_to_versions_ = {x.vendor: set() for x in sufficiently_long_cpes}
+ self.vendors_ = set(self.vendor_to_versions_.keys())
+ self.vendor_version_to_cpe_ = dict()
+
+ for cpe in tqdm(sufficiently_long_cpes, desc="Fitting the CPE classifier"):
+ self.vendor_to_versions_[cpe.vendor].add(cpe.version)
+ if (cpe.vendor, cpe.version) not in self.vendor_version_to_cpe_:
+ self.vendor_version_to_cpe_[(cpe.vendor, cpe.version)] = {cpe}
+ else:
+ self.vendor_version_to_cpe_[(cpe.vendor, cpe.version)].add(cpe)
+
+ def predict(self, X: list[tuple[str, str, str]]) -> list[set[str] | None]:
+ """
+ Will predict CPE uris for List of Tuples (vendor, product name, identified versions in product name)
+
+ :param List[Tuple[str, str, str]] X: tuples (vendor, product name, identified versions in product name)
+ :return List[Optional[Set[str]]]: List of CPE uris that correspond to given input, None if nothing was found.
+ """
+ return [self.predict_single_cert(x[0], x[1], x[2]) for x in tqdm(X, desc="Predicting")]
+
+ def predict_single_cert(
+ self,
+ vendor: str | None,
+ product_name: str,
+ versions: set[str],
+ relax_version: bool = False,
+ relax_title: bool = False,
+ ) -> set[str] | None:
+ """
+ Predict List of CPE uris for triplet (vendor, product_name, list_of_versions). The prediction is made as follows:
+ 1. Sanitize vendor name, lemmatize product name.
+ 2. Find vendors in CPE dataset that are related to the certificate
+ 3. Based on (vendors, versions) find all CPE items that are considered as candidates for match
+ 4. Compute string similarity of the candidate CPE matches and certificate name
+ 5. Evaluate best string similarity, if above threshold, declare it a match.
+ 6. If no CPE item is matched, try again but relax version and check CPEs that don't have their version specified.
+ 7. (Also, search for 100% CPE matches on item name instead of title.)
+
+ :param Optional[str] vendor: manufacturer of the certificate
+ :param str product_name: name of the certificate
+ :param Set[str] versions: List of versions that appear in the certificate name
+ :param bool relax_version: See step 6 above., defaults to False
+ :param bool relax_title: See step 7 above, defaults to False
+ :return Optional[Set[str]]: Set of matching CPE uris, None if no matches found
+ """
+ lemmatized_product_name = self._lemmatize_product_name(product_name)
+ candidate_vendors = self._get_candidate_list_of_vendors(
+ CPEClassifier._discard_trademark_symbols(vendor).lower() if vendor else vendor
+ )
+ candidates = self._get_candidate_cpe_matches(candidate_vendors, versions)
+ candidates = self._filter_candidates_by_platform(candidates, product_name)
+ candidates = self._filter_candidates_by_update(candidates, lemmatized_product_name)
+
+ ratings = [
+ self._compute_best_match(cpe, lemmatized_product_name, candidate_vendors, versions, relax_title=relax_title)
+ for cpe in candidates
+ ]
+ threshold = self.match_threshold if not relax_version else 100
+ final_matches_aux: list[tuple[float, CPE]] = list(filter(lambda x: x[0] >= threshold, zip(ratings, candidates)))
+ final_matches_aux = sorted(final_matches_aux, key=operator.itemgetter(0, 1), reverse=True)
+ final_matches: set[str] | None = {
+ x[1].uri for x in final_matches_aux[: self.n_max_matches] if x[1].uri is not None
+ }
+
+ if not relax_title and not final_matches:
+ final_matches = self.predict_single_cert(
+ vendor, product_name, versions, relax_version=relax_version, relax_title=True
+ )
+
+ if not relax_version and not final_matches:
+ final_matches = self.predict_single_cert(
+ vendor, product_name, {constants.CPE_VERSION_NA}, relax_version=True, relax_title=relax_title
+ )
+
+ return final_matches if final_matches else None
+
+ def _filter_candidates_by_update(self, cpes: list[CPE], cert_title: str) -> list[CPE]:
+ """
+ Update means `service pack` or `release`.
+ """
+
+ def filter_condition(regex: Pattern, cpe: CPE, min_value: int, soft: bool = True):
+ if matches := re.findall(regex, cpe.update):
+ return int(re.findall(r"\d+", matches[0])[0]) >= min_value
+ return True if soft else False
+
+ update_regexes = [cert_rules.SERVICE_PACK_RE, cert_rules.RELEASE_RE]
+
+ for update_regex in update_regexes:
+ if matches := re.findall(update_regex, cert_title):
+ min_value = min([int(re.findall(r"\d+", x)[0]) for x in matches])
+ soft = not any(re.search(update_regex, cpe.update + str(cpe.title)) for cpe in cpes)
+ return [x for x in cpes if filter_condition(update_regex, x, min_value, soft)]
+
+ return cpes
+
+ def _filter_candidates_by_platform(self, cpes: list[CPE], cert_title: str) -> list[CPE]:
+ def filter_condition(cpe: CPE, cert_platforms: set[str]):
+ if not cert_platforms and cpe.target_hw == "*":
+ return True
+ if cert_platforms and cpe.target_hw == "*":
+ return any(re.search(cert_rules.PLATFORM_REGEXES[x], str(cpe.title)) for x in cert_platforms)
+ if not cert_platforms and cpe.target_hw != "*":
+ return False
+ if cert_platforms and cpe.target_hw != "*":
+ target_hw_platforms = [
+ platform
+ for platform, regex in cert_rules.PLATFORM_REGEXES.items()
+ if re.search(regex, cpe.target_hw)
+ ]
+ assert len(target_hw_platforms) <= 1
+ can_return_true = any(
+ re.search(cert_rules.PLATFORM_REGEXES[x], cpe.target_hw + str(cpe.title)) for x in cert_platforms
+ )
+ if not target_hw_platforms:
+ return can_return_true
+ else:
+ return can_return_true and target_hw_platforms[0] in cert_platforms
+
+ crt_platforms = {
+ platform for platform, regex in cert_rules.PLATFORM_REGEXES.items() if re.search(regex, cert_title)
+ }
+ return [x for x in cpes if filter_condition(x, crt_platforms)]
+
+ def _compute_best_match(
+ self,
+ cpe: CPE,
+ product_name: str,
+ candidate_vendors: set[str] | None,
+ versions: set[str],
+ relax_title: bool = False,
+ ) -> float:
+ """
+ Tries several different settings in which string similarity between CPE and certificate name is tested.
+ For definition of string similarity, see rapidfuzz package on GitHub. Both token set ratio and partial ratio are tested,
+ always both on CPE title and CPE item name.
+
+ :param CPE cpe: CPE to test
+ :param str product_name: name of the certificate
+ :param Optional[Set[str]] candidate_vendors: vendors that appear in the certificate
+ :param Set[str] versions: versions that appear in the certificate
+ :param bool relax_title: if to relax title or not, defaults to False
+ :return float: Maximal value of the four string similarities discussed above.
+ """
+ if relax_title:
+ sanitized_title = (
+ CPEClassifier._fully_sanitize_string(cpe.title)
+ if cpe.title
+ else CPEClassifier._fully_sanitize_string(
+ cpe.vendor + " " + cpe.item_name + " " + cpe.version + " " + cpe.update + " " + cpe.target_hw
+ )
+ )
+ else:
+ if cpe.title:
+ sanitized_title = CPEClassifier._fully_sanitize_string(cpe.title)
+ else:
+ return 0
+
+ # Sometimes, sanitization shortens CPE title to very short length. E.g., CPEs in Japanese unicode symbols that get all deteled.
+ if len(sanitized_title) < 5:
+ return 0
+
+ sanitized_item_name = CPEClassifier._fully_sanitize_string(cpe.item_name)
+ sanitized_cpe_stripped_manufacturer = re.sub(r"\b" + rf"{cpe.vendor}" + r"\b", "", sanitized_title)
+ standard_version_product_name = self._standardize_version_in_cert_name(product_name, versions)
+
+ # The expression below is currently unused, it could assist with some matches though
+ # cert_stripped = CPEClassifier._strip_manufacturer_and_version(product_name, candidate_vendors, versions)
+
+ # On some ratings, we require 100 match regardless of the treshold in settings.
+ ratings = [
+ fuzz.token_set_ratio(product_name, sanitized_title),
+ fuzz.token_set_ratio(standard_version_product_name, sanitized_title),
+ fuzz.partial_token_sort_ratio(product_name, sanitized_title, score_cutoff=100),
+ fuzz.partial_token_sort_ratio(standard_version_product_name, sanitized_title, score_cutoff=100),
+ fuzz.partial_ratio(product_name, sanitized_title, score_cutoff=100),
+ fuzz.partial_ratio(standard_version_product_name, sanitized_title, score_cutoff=100),
+ ]
+
+ # Big-IP has dumb CPEs that contain only that string in item name, which leads to false positives.
+ if relax_title and cpe.item_name != "big-ip":
+ ratings += [
+ fuzz.token_set_ratio(product_name, sanitized_cpe_stripped_manufacturer, score_cutoff=100),
+ fuzz.partial_ratio(product_name, sanitized_cpe_stripped_manufacturer, score_cutoff=100),
+ fuzz.token_set_ratio(product_name, sanitized_item_name, score_cutoff=100),
+ fuzz.partial_ratio(product_name, sanitized_item_name, score_cutoff=100),
+ ]
+
+ return max(ratings)
+
+ @staticmethod
+ def _fully_sanitize_string(string: str) -> str:
+ return CPEClassifier._replace_special_chars_with_space(
+ CPEClassifier._discard_trademark_symbols(string.lower())
+ ).strip()
+
+ @staticmethod
+ def _replace_special_chars_with_space(string: str) -> str:
+ return re.sub(r"[^a-zA-Z0-9 \n\.]", " ", string)
+
+ @staticmethod
+ def _discard_trademark_symbols(string: str) -> str:
+ return string.replace("®", "").replace("™", "")
+
+ @staticmethod
+ def _strip_manufacturer_and_version(string: str, manufacturers: set[str] | None, versions: set[str]) -> str:
+ to_strip = versions | manufacturers if manufacturers else versions
+ for x in to_strip:
+ string = string.lower().replace(CPEClassifier._replace_special_chars_with_space(x.lower()), " ").strip()
+ return string
+
+ @staticmethod
+ def _standardize_version_in_cert_name(string: str, detected_versions: set[str]) -> str:
+ for ver in detected_versions:
+ version_regex = r"(" + r"(\bversion)\s*" + ver + r"+) | (\bv\s*" + ver + r"+)"
+ string = re.sub(version_regex, " " + ver + " ", string, flags=re.IGNORECASE)
+ return string
+
+ def _process_manufacturer(self, manufacturer: str, result: set) -> set[str]:
+ tokenized = manufacturer.split()
+ if tokenized[0] in self.vendors_:
+ result.add(tokenized[0])
+ if len(tokenized) > 1 and tokenized[0] + tokenized[1] in self.vendors_:
+ result.add(tokenized[0] + tokenized[1])
+
+ # Below are completely manual fixes
+ if "hewlett" in tokenized or "hewlett-packard" in tokenized or manufacturer == "hewlett packard":
+ result.add("hp")
+ if "thales" in tokenized:
+ result.add("thalesesecurity")
+ result.add("thalesgroup")
+ if "stmicroelectronics" in tokenized:
+ result.add("st")
+ if "athena" in tokenized and "smartcard" in tokenized:
+ result.add("athena-scs")
+ if tokenized[0] == "the" and not result:
+ candidate_result = self._get_candidate_list_of_vendors(" ".join(tokenized[1:]))
+ return set(candidate_result) if candidate_result else set()
+
+ return set(result) if result else set()
+
+ def _get_candidate_list_of_vendors(self, manufacturer: str | None) -> set[str]:
+ """
+ Given manufacturer name, this method will find list of plausible vendors from CPE dataset that are likely related.
+
+ :param Optional[str] manufacturer: manufacturer
+ :return Set[str]: List of related manufacturers, None if nothing relevant is found.
+ """
+ result: set[str] = set()
+ if not manufacturer:
+ return result
+
+ splits = re.compile(r"[,/]").findall(manufacturer)
+
+ if splits:
+ vendor_tokens = set(
+ itertools.chain.from_iterable([x.strip() for x in manufacturer.split(s)] for s in splits)
+ )
+ result_aux = [self._get_candidate_list_of_vendors(x) for x in vendor_tokens]
+ result_used = set(set(itertools.chain.from_iterable(x for x in result_aux if x)))
+ return result_used if result_used else set()
+
+ if manufacturer in self.vendors_:
+ result.add(manufacturer)
+
+ return self._process_manufacturer(manufacturer, result)
+
+ def _get_candidate_vendor_version_pairs(
+ self, cert_candidate_cpe_vendors: set[str], cert_candidate_versions: set[str]
+ ) -> list[tuple[str, str]] | None:
+ """
+ Given parameters, will return Pairs (cpe_vendor, cpe_version) that are relevant to a given sample
+
+
+ :param Set[str] cert_candidate_cpe_vendors: list of CPE vendors relevant to a sample
+ :param Set[str] cert_candidate_versions: List of versions heuristically extracted from the sample name
+ :return Optional[List[Tuple[str, str]]]: List of tuples (cpe_vendor, cpe_version) that can be used in the lookup table to search the CPE dataset.
+ """
+
+ def is_cpe_version_among_cert_versions(cpe_version: str | None, cert_versions: set[str]) -> bool:
+ def simple_startswith(seeked_version: str, checked_string: str) -> bool:
+ if seeked_version == checked_string:
+ return True
+ else:
+ return (
+ checked_string.startswith(seeked_version) and not checked_string[len(seeked_version)].isdigit()
+ )
+
+ if not cpe_version:
+ return False
+ just_numbers = r"(\d{1,5})(\.\d{1,5})"
+
+ # This assures that on cert version with at least two tokens, we don't match only one-token CPE.
+ # E.g. cert with version 7.6 must not match CPE record of version 7
+ if len(cert_versions) == 1 and len(list(cert_versions)[0]) >= 3 and len(cpe_version) < 3:
+ return False
+
+ # Except from startswith stuff, this also mandates that for long enough cert vesions (e.g. `3.1`) we do not
+ # match too short CPE versions, e.g. `3`
+ for v in cert_versions:
+ if (
+ (simple_startswith(v, cpe_version) and re.search(just_numbers, cpe_version))
+ or simple_startswith(cpe_version, v)
+ ) and (len(v) < 3 or len(cpe_version) >= 3):
+ return True
+ return False
+
+ if not cert_candidate_cpe_vendors:
+ return None
+
+ candidate_vendor_version_pairs: list[tuple[str, str]] = []
+ for vendor in cert_candidate_cpe_vendors:
+ viable_cpe_versions = self.vendor_to_versions_.get(vendor, set())
+ matched_cpe_versions = [
+ x for x in viable_cpe_versions if is_cpe_version_among_cert_versions(x, cert_candidate_versions)
+ ]
+ candidate_vendor_version_pairs.extend([(vendor, x) for x in matched_cpe_versions])
+ return candidate_vendor_version_pairs
+
+ def _get_candidate_cpe_matches(self, candidate_vendors: set[str], candidate_versions: set[str]) -> list[CPE]:
+ """
+ Given List of candidate vendors and candidate versions found in certificate, candidate CPE matches are found
+
+ :param Set[str] candidate_vendors: List of version strings that were found in the certificate
+ :param Set[str] candidate_versions: List of vendor strings that were found in the certificate
+ :return List[CPE]: List of CPE records that could match, to be refined later
+ """
+ candidate_vendor_version_pairs = self._get_candidate_vendor_version_pairs(candidate_vendors, candidate_versions)
+ return (
+ list(itertools.chain.from_iterable(self.vendor_version_to_cpe_[x] for x in candidate_vendor_version_pairs))
+ if candidate_vendor_version_pairs
+ else []
+ )
+
+ def _lemmatize_product_name(self, product_name: str) -> str:
+ if not product_name:
+ return product_name
+ return " ".join([token.lemma_ for token in self.nlp(CPEClassifier._fully_sanitize_string(product_name))])
diff --git a/src/sec_certs/model/evaluation.py b/src/sec_certs/model/evaluation.py
new file mode 100644
index 00000000..dd5eaffd
--- /dev/null
+++ b/src/sec_certs/model/evaluation.py
@@ -0,0 +1,101 @@
+from __future__ import annotations
+
+import json
+import logging
+from pathlib import Path
+
+import numpy as np
+
+import sec_certs.utils.helpers as helpers
+from sec_certs.dataset.cpe import CPEDataset
+from sec_certs.sample.common_criteria import CommonCriteriaCert
+from sec_certs.sample.fips import FIPSCertificate
+from sec_certs.serialization.json import CustomJSONEncoder
+
+logger = logging.getLogger(__name__)
+
+
+def get_validation_dgsts(filepath: str | Path) -> set[str]:
+ with Path(filepath).open("r") as handle:
+ return set(json.load(handle))
+
+
+def compute_precision(y: np.ndarray, y_pred: np.ndarray, **kwargs) -> float:
+ prec = []
+ for true, pred in zip(y, y_pred):
+ set_pred = set(pred) if pred else set()
+ set_true = set(true) if true else set()
+ if not set_pred and not set_true:
+ pass
+ else:
+ prec.append(len(set_true) / len(set_true.union(set_pred)))
+ return np.mean(prec) # type: ignore
+
+
+def evaluate(
+ x_valid: list[CommonCriteriaCert | FIPSCertificate],
+ y_valid: list[set[str] | None],
+ outpath: Path | str | None,
+ cpe_dset: CPEDataset,
+) -> None:
+ y_pred = [x.heuristics.cpe_matches for x in x_valid]
+ precision = compute_precision(np.array(y_valid), np.array(y_pred))
+
+ correctly_classified = []
+ badly_classified = []
+ n_cpes_lost = 0
+ n_certs_has_lost_cpes = 0
+ n_certs_lost = 0
+
+ for cert, predicted_cpes, verified_cpes in zip(x_valid, y_pred, y_valid):
+ if not verified_cpes:
+ verified_cpes = set()
+ verified_cpes_dict = {x: cpe_dset[x].title if cpe_dset[x].title else x for x in verified_cpes}
+
+ if not predicted_cpes:
+ predicted_cpes = set()
+ predicted_cpes_dict = {x: cpe_dset[x].title if cpe_dset[x].title else x for x in predicted_cpes}
+
+ cert_name = cert.name if isinstance(cert, CommonCriteriaCert) else cert.web_data.module_name
+ vendor = cert.manufacturer if isinstance(cert, CommonCriteriaCert) else cert.web_data.vendor
+
+ should_be_removed = {x: cpe_dset[x].title if cpe_dset[x].title else x for x in predicted_cpes - verified_cpes}
+ should_be_added = {x: cpe_dset[x].title if cpe_dset[x].title else x for x in verified_cpes - predicted_cpes}
+
+ record = {
+ "certificate_name": cert_name,
+ "vendor": vendor,
+ "heuristic version": helpers.compute_heuristics_version(cert_name) if cert_name else None,
+ "predicted_cpes": predicted_cpes_dict,
+ "manually_assigned_cpes": verified_cpes_dict,
+ "should_be_removed": should_be_removed,
+ "should_be_added": should_be_added,
+ }
+
+ if not predicted_cpes and verified_cpes:
+ n_certs_lost += 1
+
+ if predicted_cpes.issubset(verified_cpes):
+ correctly_classified.append(record)
+ else:
+ badly_classified.append(record)
+
+ if should_be_added:
+ n_certs_has_lost_cpes += 1
+ n_cpes_lost += len(should_be_added)
+
+ results = {
+ "Precision": precision,
+ "n_cpes_lost": n_cpes_lost,
+ "n_certs_has_lost_cpes": n_certs_has_lost_cpes,
+ "n_certs_lost": n_certs_lost,
+ "correctly_classified": correctly_classified,
+ "badly_classified": badly_classified,
+ }
+ logger.info(
+ f"Precision: {precision}; the classifier now misses {n_cpes_lost} CPE matches from {n_certs_has_lost_cpes} certificates ({n_certs_lost} certificates no longer have a match). In total, {sum([len(x) for x in y_pred if x])} CPEs were matched in {len(y_pred)} certs."
+ )
+
+ if outpath:
+ with Path(outpath).open("w") as handle:
+ json.dump(results, handle, indent=4, cls=CustomJSONEncoder, sort_keys=True)
diff --git a/src/sec_certs/model/reference_finder.py b/src/sec_certs/model/reference_finder.py
new file mode 100644
index 00000000..117a8b9d
--- /dev/null
+++ b/src/sec_certs/model/reference_finder.py
@@ -0,0 +1,223 @@
+from __future__ import annotations
+
+from typing import Callable, Dict, List, Optional, Set, TypeVar
+
+from sec_certs.sample.certificate import Certificate, References
+
+CertSubType = TypeVar("CertSubType", bound=Certificate)
+Certificates = Dict[str, CertSubType]
+ReferencedByDirect = Dict[str, Set[str]]
+ReferencedByIndirect = Dict[str, Set[str]]
+ReferencesType = Dict[str, Dict[str, Optional[Set[str]]]]
+IDMapping = Dict[str, List[str]]
+UnknownReferences = Dict[str, Set[str]]
+IDLookupFunc = Callable[[CertSubType], str]
+ReferenceLookupFunc = Callable[[CertSubType], Set[str]]
+
+
+# TODO: All of this can and should be rewritten on top of networkx or some other graph library.
+class ReferenceFinder:
+ """
+ The class assigns references of other certificate instances for each instance.
+ Adheres to sklearn BaseEstimator interface.
+ The fit is called on a dictionary of certificates, builds a hashmap of references, and assigns references for each certificate in the dictionary.
+ """
+
+ def __init__(self):
+ self.references: ReferencesType = {}
+ self.id_mapping: IDMapping = {}
+ self._fitted: bool = False
+
+ def _create_id_mapping(self, certificates: Certificates, id_func: IDLookupFunc) -> None:
+ """
+ Create the ID mapping of certificate IDs to certificate digests.
+
+ Necessary for handling duplicates.
+ """
+ # Create a mapping of certificate ID to certificate digests with that ID.
+ for dgst in certificates:
+ cert_id = id_func(certificates[dgst])
+ c_list = self.id_mapping.setdefault(cert_id, [])
+ c_list.append(dgst)
+
+ # Sort digests in ID mapping to have deterministic behavior.
+ # The certificate with the first digest will be used with that ID, others will be discarded.
+ for digests in self.id_mapping.values():
+ digests.sort()
+
+ def _compute_indirect_references(self, referenced_by: ReferencedByDirect) -> ReferencedByIndirect:
+ """
+ Compute indirect references via a BFS algorithm.
+ """
+ referenced_by_indirect: ReferencedByIndirect = {}
+
+ # Populate with direct references.
+ certs_id_list = referenced_by.keys()
+ for cert_id in certs_id_list:
+ referenced_by_indirect[cert_id] = set()
+ for item in referenced_by[cert_id]:
+ referenced_by_indirect[cert_id].add(item)
+
+ # Flood in the indirect ones.
+ new_change_detected = True
+ while new_change_detected:
+ new_change_detected = False
+
+ for cert_id in certs_id_list:
+ tmp_referenced_by_indirect_nums = referenced_by_indirect[cert_id].copy()
+ for referencing in tmp_referenced_by_indirect_nums:
+ if referencing in certs_id_list:
+ tmp_referencing = referenced_by_indirect[referencing].copy()
+ newly_discovered_references = [
+ x for x in tmp_referencing if x not in referenced_by_indirect[cert_id]
+ ]
+ referenced_by_indirect[cert_id].update(newly_discovered_references)
+ if newly_discovered_references:
+ new_change_detected = True
+ return referenced_by_indirect
+
+ def _build_referenced_by(
+ self, certificates: Certificates, ref_lookup_func: ReferenceLookupFunc
+ ) -> tuple[ReferencedByDirect, ReferencedByIndirect]:
+ referenced_by: ReferencedByDirect = {}
+
+ for this_cert_id, cert_digests in self.id_mapping.items():
+ # Take the first certificate digest from the ID mapping (to ensure deterministic behavior and resolve duplicates).
+ # TODO: A better approach for handling duplicates in the future would be nice.
+ cert_dgst = cert_digests[0]
+ cert_obj = certificates[cert_dgst]
+
+ refs = ref_lookup_func(cert_obj)
+ if refs is None:
+ continue
+
+ # Process direct reference
+ # All are added here, the unknown ones are filtered later on.
+ for cert_id in refs:
+ if cert_id == this_cert_id:
+ continue
+ referenced_by.setdefault(cert_id, set())
+ referenced_by[cert_id].add(this_cert_id)
+
+ # Now do the indirect ones
+ referenced_by_indirect = self._compute_indirect_references(referenced_by)
+ return referenced_by, referenced_by_indirect
+
+ def _get_reverse_references(
+ self, cert_id: str, references: ReferencedByDirect | ReferencedByIndirect
+ ) -> set[str] | None:
+ result = set()
+
+ for other_id in references:
+ if cert_id in references[other_id]:
+ result.add(other_id)
+
+ return result if result else None
+
+ def _build_referencing(
+ self, referenced_by_direct: ReferencedByDirect, referenced_by_indirect: ReferencedByIndirect
+ ) -> None:
+ for cert_id, cert_digests in self.id_mapping.items():
+ cert_dgst = cert_digests[0]
+ self.references[cert_dgst] = {
+ "directly_referenced_by": referenced_by_direct.get(cert_id, None),
+ "indirectly_referenced_by": referenced_by_indirect.get(cert_id, None),
+ "directly_referencing": self._get_reverse_references(cert_id, referenced_by_direct),
+ "indirectly_referencing": self._get_reverse_references(cert_id, referenced_by_indirect),
+ }
+
+ def fit(self, certificates: Certificates, id_func: IDLookupFunc, ref_lookup_func: ReferenceLookupFunc) -> None:
+ """
+ Builds a list of references and assigns references for each certificate instance.
+
+ :param Certificates certificates: dictionary of certificates with hashes as key
+ :param IDLookupFunc id_func: lookup function for cert id
+ :param ReferenceLookupFunc ref_lookup_func: lookup for references
+ """
+ if self._fitted:
+ raise ValueError("Finder already fitted")
+ # Create the ID mapping first so that we can resolve duplicates.
+ self._create_id_mapping(certificates, id_func)
+
+ # Build the referenced_by first
+ referenced_by_direct, referenced_by_indirect = self._build_referenced_by(certificates, ref_lookup_func)
+
+ # Build the referencing second (this actually writes into self.references).
+ self._build_referencing(referenced_by_direct, referenced_by_indirect)
+ self._fitted = True
+
+ @property
+ def unknown_references(self) -> UnknownReferences:
+ """
+ Get the unknown references in the fitted dataset (to unknown certificate IDs, not in the dataset during fit).
+ """
+ if not self._fitted:
+ return {}
+ result = {}
+ for cert_id, digests in self.id_mapping.items():
+ cert_digest = digests[0]
+ cert_references = self.references[cert_digest]
+ direct_refs = cert_references["directly_referencing"]
+ if not direct_refs:
+ continue
+ unknowns = set(filter(lambda refd_cert_id: refd_cert_id not in self.id_mapping, direct_refs))
+ if unknowns:
+ result[cert_id] = unknowns
+ return result
+
+ @property
+ def duplicates(self) -> IDMapping:
+ """
+ Get the duplicates in the fitted dataset.
+
+ :return IDMapping: Mapping of certificate ID to digests that share it.
+ """
+ if not self._fitted:
+ return {}
+ return {cert_id: digests for cert_id, digests in self.id_mapping.items() if len(digests) > 1}
+
+ def predict_single_cert(self, dgst: str, keep_unknowns: bool = True) -> References:
+ """
+ Get the references object for specified certificate digest.
+
+ :param dgst: certificate digest
+ :param keep_unknowns: Whether to keep references to unknown certificate IDs
+ :return References: References object
+ """
+ if not self._fitted:
+ raise ValueError("Finder not yet fitted")
+
+ def wrap(res):
+ if not res:
+ return None
+ # If we do not want the unknown references, filter them here.
+ if not keep_unknowns:
+ res = set(filter(lambda cert_id: cert_id in self.id_mapping, res))
+ return set(res) if res else None
+
+ if dgst not in self.references:
+ return References()
+
+ return References(
+ wrap(self.references[dgst].get("directly_referenced_by", None)),
+ wrap(self.references[dgst].get("indirectly_referenced_by", None)),
+ wrap(self.references[dgst].get("directly_referencing", None)),
+ wrap(self.references[dgst].get("indirectly_referencing", None)),
+ )
+
+ def predict(self, dgst_list: list[str], keep_unknowns: bool = True) -> dict[str, References]:
+ """
+ Get the references for a list of certificate digests.
+
+ :param dgst_list: List of certificate digests.
+ :param keep_unknowns: Whether to keep references to and from unknown certificate IDs
+ :return Dict[str, References]: Dict with certificate hash and References object.
+ """
+ if not self._fitted:
+ raise ValueError("Finder not yet fitted")
+ cert_references = {}
+
+ for dgst in dgst_list:
+ cert_references[dgst] = self.predict_single_cert(dgst, keep_unknowns=keep_unknowns)
+
+ return cert_references
diff --git a/src/sec_certs/model/sar_transformer.py b/src/sec_certs/model/sar_transformer.py
new file mode 100644
index 00000000..a5bcff57
--- /dev/null
+++ b/src/sec_certs/model/sar_transformer.py
@@ -0,0 +1,159 @@
+from __future__ import annotations
+
+import logging
+from typing import Dict, Iterable, cast
+
+from sklearn.base import BaseEstimator, TransformerMixin
+
+from sec_certs.sample.common_criteria import CommonCriteriaCert
+from sec_certs.sample.sar import SAR, SAR_DICT_KEY
+
+logger = logging.getLogger(__name__)
+
+
+# TODO: Right now we ignore number of ocurrences for final SAR selection. If we keep it this way, we can discard that variable
+class SARTransformer(BaseEstimator, TransformerMixin):
+ """
+ Class for transforming SARs defined in st_keywords and report_keywords dictionaries into SAR objects.
+ This class implements sklearn transformer interface, so fit_transform() can be called on it.
+ """
+
+ def fit(self, certificates: Iterable[CommonCriteriaCert]) -> SARTransformer:
+ """
+ Just returns self, no fitting needed
+
+ :param Iterable[CommonCriteriaCert] certificates: Unused parameter
+ :return SARTransformer: return self
+ """
+ return self
+
+ def transform(self, certificates: Iterable[CommonCriteriaCert]) -> list[set[SAR] | None]:
+ """
+ Just a wrapper around transform_single_cert() called on an iterable of CommonCriteriaCert.
+
+ :param Iterable[CommonCriteriaCert] certificates: Iterable of CommonCriteriaCert objects to perform the extraction on.
+ :return List[Optional[Set[SAR]]]: Returns List of results from transform_single_cert().
+ """
+ return [self.transform_single_cert(cert) for cert in certificates]
+
+ def transform_single_cert(self, cert: CommonCriteriaCert) -> set[SAR] | None:
+ """
+ Given CommonCriteriaCert, will transform SAR keywords extracted from txt files
+ into a set of SAR objects. Also handles extractin of correct SAR levels, duplicities and filtering.
+ Uses three sources: CSV scan, security target, and certification report.
+ The caller should assure that the certificates have the keywords extracted.
+
+ :param CommonCriteriaCert cert: Certificate to extract SARs from
+ :return Optional[Set[SAR]]: Set of SARs, None if none were identified.
+ """
+ sec_level_candidates, st_candidates, report_candidates = self._collect_sar_candidates_from_all_sources(cert)
+ return self._resolve_candidate_conflicts(sec_level_candidates, st_candidates, report_candidates, cert.dgst)
+
+ @staticmethod
+ def _collect_sar_candidates_from_all_sources(cert: CommonCriteriaCert) -> tuple[set[SAR], set[SAR], set[SAR]]:
+ """
+ Parses SARs from three distinct sources and returns the results as a three tuple:
+ - Security level from CSV scan
+ - Keywords from Security target
+ - Keywords from Certification report
+ """
+
+ def st_keywords_may_have_sars(sample: CommonCriteriaCert):
+ return sample.pdf_data.st_keywords and SAR_DICT_KEY in sample.pdf_data.st_keywords
+
+ def report_keywords_may_have_sars(sample: CommonCriteriaCert):
+ return sample.pdf_data.report_keywords and SAR_DICT_KEY in sample.pdf_data.report_keywords
+
+ sec_level_sars = SARTransformer._parse_sars_from_security_level_list(cert.security_level)
+
+ if st_keywords_may_have_sars(cert):
+ st_dict: dict = cast(Dict, cert.pdf_data.st_keywords)
+ st_sars = SARTransformer._parse_sar_dict(st_dict[SAR_DICT_KEY], cert.dgst)
+ else:
+ st_sars = set()
+
+ if report_keywords_may_have_sars(cert):
+ report_dict: dict = cast(Dict, cert.pdf_data.report_keywords)
+ report_sars = SARTransformer._parse_sar_dict(report_dict[SAR_DICT_KEY], cert.dgst)
+ else:
+ report_sars = set()
+
+ return sec_level_sars, st_sars, report_sars
+
+ @staticmethod
+ def _resolve_candidate_conflicts(
+ sec_level_candidates: set[SAR], st_candidates: set[SAR], report_candidates: set[SAR], cert_dgst: str
+ ) -> set[SAR] | None:
+ final_candidates: dict[str, SAR] = {x.family: x for x in sec_level_candidates}
+ """
+ Given three parameters (SAR candidates from csv scan, ST and cert. report), builds final list of SARs in cert.
+ This is done as follows:
+ - All SARs from security level are added first
+ - Non-conflicting SARs from security target are added as well
+ - Non-conflicting SARs from ceritifcation report are added at last
+
+ Note: Conflict means an attempt to add SAR of family (but different level) that is already present in the set.
+
+ :return Set | None: Returns set of SARs or None if empty
+ """
+ for candidate in st_candidates:
+ if candidate.family in final_candidates and candidate != final_candidates[candidate.family]:
+ logger.debug(
+ f"Cert {cert_dgst} SAR conflict: Attempting to add {candidate} from ST, conflicts with {final_candidates[candidate.family]}"
+ )
+ else:
+ final_candidates[candidate.family] = candidate
+
+ for candidate in report_candidates:
+ if candidate.family in final_candidates and candidate != final_candidates[candidate.family]:
+ logger.debug(
+ f"Cert {cert_dgst} SAR conflict: Attempting to add {candidate} from cert. report, conflicts with {final_candidates[candidate.family]}"
+ )
+ else:
+ final_candidates[candidate.family] = candidate
+
+ return set(final_candidates.values()) if final_candidates else None
+
+ @staticmethod
+ def _parse_sar_dict(dct: dict[str, dict[str, int]], dgst: str) -> set[SAR]:
+ """
+ Accepts st_keywords or report_keywords dictionary. Will reconstruct SAR objects from it. Each SAR family can
+ appear multiple times in the dictionary (due to conflicts) with different levels. Iterated item will replace
+ existing record if:
+ - it has higher level than than the current SAR
+
+ Only SARs with recovered level are considered, e.g. ASE_REQ.2 is valid string while ASE_REQ is not.
+
+ :param dct: _description_
+ :param dgst: DIgest of the processed certificate.
+ :return: _description_
+ """
+ sars: dict[str, tuple[SAR, int]] = dict()
+ for sar_class, class_matches in dct.items():
+ for sar_string, n_occurences in class_matches.items():
+ try:
+ candidate = SAR.from_string(sar_string)
+ except ValueError as e:
+ logger.debug(f"Badly formatted SAR string {sar_string}, skipping: {e}")
+ continue
+
+ if candidate.family in sars:
+ logger.debug(
+ f"Cert {dgst} Attempting to add {candidate} while {sars[candidate.family]} already in SARS"
+ )
+
+ if (candidate.family not in sars) or (
+ candidate.family in sars and candidate.level > sars[candidate.family][0].level
+ ):
+ sars[candidate.family] = (candidate, n_occurences)
+ return {x[0] for x in sars.values()} if sars else set()
+
+ @staticmethod
+ def _parse_sars_from_security_level_list(lst: Iterable[str]) -> set[SAR]:
+ sars = set()
+ for element in lst:
+ try:
+ sars.add(SAR.from_string(element))
+ except ValueError:
+ continue
+ return sars
diff --git a/src/sec_certs/model/transitive_vulnerability_finder.py b/src/sec_certs/model/transitive_vulnerability_finder.py
new file mode 100644
index 00000000..1d4c8243
--- /dev/null
+++ b/src/sec_certs/model/transitive_vulnerability_finder.py
@@ -0,0 +1,149 @@
+from __future__ import annotations
+
+import logging
+from collections import Counter
+from dataclasses import dataclass, field
+from enum import Enum
+from typing import Callable, Dict, Optional, Set, TypeVar
+
+from sec_certs.sample.certificate import Certificate, References
+from sec_certs.serialization.json import ComplexSerializableType
+
+CertSubType = TypeVar("CertSubType", bound=Certificate)
+IDLookupFunc = Callable[[CertSubType], str]
+ReferenceLookupFunc = Callable[[CertSubType], References]
+
+
+class ReferenceType(Enum):
+ DIRECT = "direct"
+ INDIRECT = "indirect"
+
+
+@dataclass
+class TransitiveCVEs(ComplexSerializableType):
+ direct_transitive_cves: set[str] | None = field(default=None)
+ indirect_transitive_cves: set[str] | None = field(default=None)
+
+
+Certificates = Dict[str, CertSubType]
+Vulnerabilities = Dict[str, Dict[str, Optional[Set[str]]]]
+
+
+class TransitiveVulnerabilityFinder:
+ """
+ The class assigns vulnerabilities to each certificate instance caused by references among certificate instances.
+ Adheres to sklearn BaseEstimator interface.
+ """
+
+ def __init__(self, id_func: IDLookupFunc):
+ self.vulnerabilities: Vulnerabilities = {}
+ self.certificates: Certificates = {}
+ self._cert_id_counter: Counter = Counter()
+ self._fitted = False
+ self._id_func: IDLookupFunc = id_func
+
+ def _clear_state(self) -> None:
+ self.vulnerabilities = {}
+ self.certificates = {}
+ self._cert_id_counter = Counter()
+
+ def _fill_dataset_cert_ids_counter(self) -> None:
+ self._cert_id_counter = Counter([self._id_func(x) for x in self.certificates.values()])
+
+ def _get_cert_transitive_cves(
+ self, cert: CertSubType, reference_type: ReferenceType, ref_func: ReferenceLookupFunc
+ ) -> set[str] | None:
+
+ references = (
+ ref_func(cert).directly_referenced_by
+ if reference_type == ReferenceType.DIRECT
+ else ref_func(cert).indirectly_referenced_by
+ )
+
+ if not references:
+ return None
+
+ vulnerabilities = set()
+
+ for cert_id in references:
+ if self._cert_id_counter[cert_id] != 1:
+ continue
+
+ for cert in self.certificates.values():
+ cves = cert.heuristics.related_cves
+ if self._id_func(cert) == cert_id and cves:
+ vulnerabilities.update(cves)
+
+ return vulnerabilities if vulnerabilities else None
+
+ def fit(self, certificates: Certificates, ref_func: ReferenceLookupFunc) -> Vulnerabilities:
+ """
+ Method assigns each certificate vulnerabilities caused by references among certificates
+
+ :param Certificates certificates: Dictionary of certificates with digests
+ :return Vulnerabilities: Dictionary of vulnerabilities of certificate instances
+ """
+ self._clear_state()
+ self.certificates = certificates
+ self._fill_dataset_cert_ids_counter()
+
+ thrown_away_cert_counter = 0
+
+ for cert in self.certificates.values():
+ cert_id = self._id_func(cert)
+
+ if not cert_id:
+ continue
+ if self._cert_id_counter[cert_id] > 1:
+ thrown_away_cert_counter += 1
+ continue
+
+ self.vulnerabilities[cert.dgst] = dict()
+ self.vulnerabilities[cert.dgst][ReferenceType.DIRECT.value] = self._get_cert_transitive_cves(
+ cert, ReferenceType.DIRECT, ref_func
+ )
+ self.vulnerabilities[cert.dgst][ReferenceType.INDIRECT.value] = self._get_cert_transitive_cves(
+ cert, ReferenceType.INDIRECT, ref_func
+ )
+
+ if thrown_away_cert_counter > 0:
+ logging.warning("There were total of %s certificates skipped due to duplicity", thrown_away_cert_counter)
+
+ self._fitted = True
+
+ return self.vulnerabilities
+
+ def predict_single_cert(self, dgst: str) -> TransitiveCVEs:
+ """
+ Method returns vulnerabilities for certificate digest
+
+ :param str dgst: Digest of certificate
+ :return TransitiveCVE: TransitiveCVE object of certificate
+ """
+ if not self._fitted:
+ raise ValueError("Finder not yet fitted")
+
+ if not self.vulnerabilities.get(dgst):
+ return TransitiveCVEs(direct_transitive_cves=None, indirect_transitive_cves=None)
+
+ return TransitiveCVEs(
+ self.vulnerabilities[dgst][ReferenceType.DIRECT.value],
+ self.vulnerabilities[dgst][ReferenceType.INDIRECT.value],
+ )
+
+ def predict(self, dgst_list: list[str]) -> dict[str, TransitiveCVEs]:
+ """
+ Method returns vulnerabilities for a list of certificate digests
+
+ :param List[str] dgst_list: list of certificate digests
+ :return Dict[str, TransitiveCVE]: Dictionary of TransitiveCVE objects for specified certificate digests
+ """
+ if not self._fitted:
+ raise ValueError("Finder not yet fitted")
+
+ cert_vulnerabilities = {}
+
+ for dgst in dgst_list:
+ cert_vulnerabilities[dgst] = self.predict_single_cert(dgst)
+
+ return cert_vulnerabilities
diff --git a/src/sec_certs/rules.yaml b/src/sec_certs/rules.yaml
new file mode 100644
index 00000000..87589d72
--- /dev/null
+++ b/src/sec_certs/rules.yaml
@@ -0,0 +1,1186 @@
+---
+
+#####
+# Common Criteria certificate IDs, grouped by scheme (Alpha-2 ISO country code).
+#####
+cc_cert_id:
+ DE:
+ - "BSI-DSZ-CC-[0-9]+?-[0-9]+"
+ - "BSI-DSZ-CC-[0-9]+?-(?:V|v)[0-9]+-[0-9]+"
+ - "BSI-DSZ-CC-[0-9]+?-(?:V|v)[0-9]+"
+ - "BSI-DSZ-CC-[0-9]+-(?:V|v)[0-9]+(-[0-9][0-9][0-9][0-9])*" # German BSI (number + version + year or without year)
+ - "BSI-DSZ-CC-[0-9]+-[0-9][0-9][0-9][0-9]" # German BSI (number + year, no version)
+ - "BSI-DSZ-CC-[0-9]+-(?:V|v)[0-9]+(?!-)" # German BSI (number + version but no year => no - after version)
+ # - "BSI-DSZ-CC-[0-9]+" # Maybe?
+ FR:
+ - "ANSS[Ii](?:-|-CC-|-CC )[0-9]{4}/[0-9]+(v[1-9])?" # French
+ - "ANSS[Ii]-CC[ -][0-9]{4}[/-_][0-9][0-9]+(?!-M|-S|-R)" # French (/two or more digits then NOT -M or -S)
+ - "ANSS[Ii]-CC[ -][0-9]{4}[/-_][0-9]+(?:v[0-9])?[_/-][MSR][0-9]+" # French, maintenance or surveillance report (ANSSI-CC-2014_46_M01)
+ # 'ANSSI-CC-CER-F-.+?', # French
+ - "DCSS[Ii]-[0-9]+/[0-9]+" # French (DCSSI-2009/07)
+ - "Certification Report [0-9]+/[0-9]+" # French or Australia! Solved because we limit ourselves to scheme when doing heuristics.
+ - "Rapport de certification [0-9]+/[0-9]+" # French
+ NL:
+ - "NSCIB-CC-[0-9][0-9][0-9][0-9].+?" # Netherlands
+ - "NSCIB-CC-[0-9][0-9][0-9][0-9][0-9]*-CR" # Netherlands
+ - "NSCIB-CC-[0-9][0-9]-[0-9]+?-CR[0-9]+?" # Netherlands
+ - "NSCIB-CC-[0-9][0-9]-[0-9]+(-CR[0-9]+)*" # Netherlands (old number NSCIB-CC-05-6609 or NSCIB-CC-05-6609-CR)
+ - "NSCIB-CC-[0-9]+-CR[0-9]*" # Netherlands (new number NSCIB-CC-111441-CR NSCIB-CC-111441-CR1)
+ - "NSCIB-CC-[0-9]+-MA[0-9]*" # Netherlands (new number NSCIB-CC-222073-MA NSCIB-CC-200716-MA2)
+ - "NSCIB-CC-[0-9][0-9]-[0-9]+" # Netherlands (old number NSCIB-CC-05-6609)
+ - "NSCIB-CC-[0-9][0-9]-[0-9]+-CR[0-9]+" # Netherlands (NSCIB-CC-year2digits-number-CR)
+ "NO":
+ - "SERTIT-[0-9]+" # Norway
+ US:
+ - "CCEVS-VR-(?:CC-|VID)?[0-9]+-[0-9]+[a-z]?(?:-[0-9]+)?" # US NSA (CCEVS-VR-10884-2018 CCEVS-VR-VID10877-2018)
+ CA:
+ # '[0-9][0-9\-]+?-CR', # Canada
+ - "[0-9][0-9][0-9]-[347]-[0-9][0-9][0-9]?(?:-CR|P)?" # Canada xxx-{347}-xxx (383-4-438, 383-4-82-CR, 383-4-422P)
+ - "[0-9][0-9][0-9][ -](?:EWA|LSS|CCS)(?:[ -]20[0-9][0-9])?" # Canada (522-EWA-2020, 524 LSS 2020, 503-LSS)
+ - "[0-9][0-9][0-9](?:%20|-)(?:EWA|LSS|CCS)(?:%20|-)(?:20[0-9][0-9]%20|)CR%20v[0-9]\\.[0-9]" # Canada filename with space (518-LSS%20CR%20v1.0)
+ UK:
+ - "CRP[0-9]+[A-Z]?" # UK CESG
+ - "CERTIFICATION REPORT No. P[0-9]+[A-Z]?" # UK CESG
+ ES:
+ - "20[0-9][0-9][-‐][0-9]+[-‐]INF[-‐][0-9]+([-‐]?[ -‐](?:V|v)[0-9]+)?" # Spain ("2006-4-INF-98 v2" or "2006-4-INF-98-v2" or "2020-34-INF-3784- v1")
+ KR:
+ # Korea
+ # XXX: Do not use KECS-CR as those refer to the certificate report and do not represent the certificate id.
+ # - "KECS[-‐]CR[-‐][0-9]+[-‐][0-9]+" # Korea KECS-CR-20-61
+ - "KECS[-‐](?:ISIS|NISS|CISS)[-‐][0-9]+[-‐][0-9]{4}" # Korea KECS-ISIS-1234-2011
+ JP:
+ - "(?:CRP|ACR)-C[0-9]+-[0-9]+" # Japan (CRP-C0595-01 ACR-C0417-03)
+ - "JISEC-CC-CRP-C[0-9]+-[0-9]+-[0-9]+" # Japan (JISEC-CC-CRP-C0689-01-2020)
+ - "Certification No. [cC][0-9]+" # Japan (Certification No. C0090)
+ MY:
+ - "ISCB-[0-9]+-(?:RPT|FRM)-[CM][0-9]+[A-Z]?-(?:CR|AMR)(?:-[0-9])?-[vV][0-9](?:\\.[0-9])?[a-z]?" # Malaysia (ISCB-3-RPT-C092-CR-v1, ISCB-3-RPT-C068-CR-1-v1)
+ IT:
+ - "OCSI/CERT/.+?" # Italy
+ - "OCSI/CERT/.+?/20[0-9]+(?:\\w|/RC)" # Italy (OCSI/CERT/ATS/01/2018/RC)
+ TR:
+ - "[0-9\\.]+?/TSE-CCCS-[0-9]+" # Turkish CCCS (21.0.0sc/TSE-CCCS-75)
+ - "(?:[0-9]{1,2}\\.){2}[0-9]{1,2}/[0-9]{1,4}-[0-9]{3}" # 21.0.01/13-028
+ SE:
+ - "CSEC ?[0-9]{6,7}" # Sweden (CSEC2019015)
+ IN:
+ # India (IC3S/DEL01/VALIANT/EAL1/0317/0007/CR STQC/CC/14-15/12/ETR/0017 IC3S/MUM01/CISCO/cPP/0119/0016/CR)
+ # will miss STQC/CC/14-15/12/ETR/0017
+ - "(?:IC3S|STQC/CC)/[^ ]+? ?/CR"
+ SG:
+ - "CSA_CC_[0-9]+" # Singapore (CSA_CC_19001)
+ AU:
+ # Australia (EFS-T048 ETR 1.0, EFS-T056-ETR 1.0, DXC-EFC-T092-ETR 1.0)
+ # XXX: Do not use Australian ETR numbers, they are not certificate id.
+ # - "(?:EFS|EFT|DXC-EFC)-T[0-9]+(?: |-)ETR [0-9]+.[0-9]+"
+ - "Certificate Number: [0-9]{1,4}/[0-9]{1,4}"
+ - "Certification Report [0-9]+/[0-9]+"
+
+#####
+# Common Criteria protection profile IDs, grouped by certification body (e.g. BSI)
+#####
+cc_protection_profile_id:
+ BSI:
+ - "BSI-(?:CC[-_]|)PP[-_]*.+?"
+ - "BSI-CCPP-.+?"
+ ANSSI:
+ - "ANSSI-CC-PP.+?"
+ KECS:
+ - "KECS-PP-[0-9]+-[0-9]+"
+ other:
+ - "PP-SSCD.+?"
+ - "PP_DBMS_.+?"
+ - "WBIS_V[0-9]\\.[0-9]"
+ - "EHCT_V.+?"
+
+#####
+# Common Criteria security level (EAL or ITSEC).
+#####
+cc_security_level:
+ EAL:
+ - "EAL[ ]*[0-9+]+?"
+ - "EAL[ ]*[0-9] augmented"
+ ITSEC:
+ - "ITSEC[ ]*E[1-9]*.+?"
+
+#####
+# Common Criteria security assurance requirement (SAR) code, grouped by class (e.g. ACE, ACM, ...).
+#####
+cc_sar:
+ ACE:
+ - "ACE(?:_[A-Z]{3,4}){1,2}(?:\\.[0-9]){0,2}"
+ ACM:
+ - "ACM(?:_[A-Z]{3,4}){1,2}(?:\\.[0-9]){0,2}"
+ ACO:
+ - "ACO(?:_[A-Z]{3,4}){1,2}(?:\\.[0-9]){0,2}"
+ ADO:
+ - "ADO(?:_[A-Z]{3,4}){1,2}(?:\\.[0-9]){0,2}"
+ ADV:
+ - "ADV(?:_[A-Z]{3,4}){1,2}(?:\\.[0-9]){0,2}"
+ AGD:
+ - "AGD(?:_[A-Z]{3,4}){1,2}(?:\\.[0-9]){0,2}"
+ ALC:
+ - "ALC(?:_[A-Z]{3,4}){1,2}(?:\\.[0-9]){0,2}"
+ ATE:
+ - "ATE(?:_[A-Z]{3,4}){1,2}(?:\\.[0-9]){0,2}"
+ AVA:
+ - "AVA(?:_[A-Z]{3,4}){1,2}(?:\\.[0-9]){0,2}"
+ AMA:
+ - "AMA(?:_[A-Z]{3,4}){1,2}(?:\\.[0-9]){0,2}"
+ APE:
+ - "APE(?:_[A-Z]{3,4}){1,2}(?:\\.[0-9]){0,2}"
+ ASE:
+ - "ASE(?:_[A-Z]{3,4}){1,2}(?:\\.[0-9]){0,2}"
+
+#####
+# Common Criteria security functional requirement (SFR) code, grouped by class (e.g. FAU, FCO, ...).
+#####
+cc_sfr:
+ FAU:
+ - "FAU(?:_[A-Z]{3,4}){1,2}(?:\\.[0-9]){0,2}"
+ FCO:
+ - "FCO(?:_[A-Z]{3,4}){1,2}(?:\\.[0-9]){0,2}"
+ FCS:
+ - "FCS(?:_[A-Z]{3,4}){1,2}(?:\\.[0-9]){0,2}"
+ FDP:
+ - "FDP(?:_[A-Z]{3,4}){1,2}(?:\\.[0-9]){0,2}"
+ FIA:
+ - "FIA(?:_[A-Z]{3,4}){1,2}(?:\\.[0-9]){0,2}"
+ FMT:
+ - "FMT(?:_[A-Z]{3,4}){1,2}(?:\\.[0-9]){0,2}"
+ FPR:
+ - "FPR(?:_[A-Z]{3,4}){1,2}(?:\\.[0-9]){0,2}"
+ FPT:
+ - "FPT(?:_[A-Z]{3,4}){1,2}(?:\\.[0-9]){0,2}"
+ FRU:
+ - "FRU(?:_[A-Z]{3,4}){1,2}(?:\\.[0-9]){0,2}"
+ FTA:
+ - "FTA(?:_[A-Z]{3,4}){1,2}(?:\\.[0-9]){0,2}"
+ FTP:
+ - "FTP(?:_[A-Z]{3,4}){1,2}(?:\\.[0-9]){0,2}"
+
+#####
+# Common Criteria claim code, grouped by class (e.g. D, T, ...).
+#####
+cc_claims:
+ D:
+ - "D\\.[\\._\\-A-Z]+?"
+ O:
+ - "O\\.[\\._\\-A-Z]+?"
+ T:
+ - "T\\.[\\._\\-A-Z]+?"
+ A:
+ - "A\\.[\\._\\-A-Z]+?"
+ R:
+ - "R\\.[\\._\\-A-Z]+?"
+ OT:
+ - "OT\\.[\\._\\-A-Z]+?"
+ OP:
+ - "OP\\.[\\._\\-A-Z]+?"
+ OE:
+ - "OE\\.[\\._\\-A-Z]+?"
+ SA:
+ - "SA\\.[\\._\\-A-Z]+?"
+ OSP:
+ - "OSP\\.[\\._\\-A-Z]+?"
+
+#####
+# A generic vendor of a product, mostly has smartcard/secure hardware vendors or other large vendors of certified products.
+#####
+vendor:
+ NXP:
+ - "NXP( Semiconductors)?( N\\.V\\.)?"
+ Infineon:
+ - "Infineon( Technologies)?( AG)?"
+ Samsung:
+ - "Samsung"
+ STMicroelectronics:
+ - "STMicroelectronics|STM|STMicro( N\\.V\\.)?"
+ Feitian:
+ - "Feitian( Technologies)?( Co.)?"
+ Gemalto:
+ - "Gemalto( N\\.V\\.)?"
+ Gemplus:
+ - "Gemplus( International)?( SA)?"
+ Axalto:
+ - "Axalto"
+ Thales:
+ - "Thales( Group)?( SA)?"
+ Oberthur:
+ - "(Oberthur|OBERTHUR)( Technologies| Card Systems)?"
+ Idemia:
+ - "Idemia|IDEMIA"
+ Sagem:
+ - "Sagem|SAGEM"
+ Morpho:
+ - "Morpho( Systèmes)?"
+ GD:
+ - "G&D|G\\+D|Giesecke\\+Devrient|Giesecke & Devrient"
+ Philips:
+ - "(Koninklijke )?Philips( N\\.V\\.)?"
+ Qualcomm:
+ - "Qualcomm"
+ Broadcom:
+ - "Broadcom( Inc.)?"
+ Huawei:
+ - "Huawei( Technologies)?( Co.)?"
+ Microsoft:
+ - "Microsoft( Corporation)?"
+ Cisco:
+ - "Cisco( Systems)?(, Inc.)?"
+
+#####
+# Common Criteria evaluation facility, mostly from https://www.commoncriteriaportal.org/labs/, grouped roughly by facility.
+#####
+eval_facility:
+ Serma:
+ - "Serma Technologies|SERMA|Serma Safety & Security"
+ Thales:
+ - "THALES - CEACI|THALES/CNES"
+ Riscure:
+ - "Riscure"
+ SGS:
+ - "SGS"
+ - "SGS Bright[sS]ight" # SGS acquired BrightSight
+ BrightSight:
+ - "Bright[sS]ight"
+ Applus:
+ - "Applus Laboratories"
+ TUV:
+ - "(tuvit|TÜViT|TUViT|TÜV Informationstechnik|TUV Informationstechnik)"
+ CESTI:
+ - "CESTI"
+ DXC:
+ - "DXC Technology"
+ Teron:
+ - "Teron Labs"
+ EWA:
+ - "(EWA|EWA-Canada)"
+ Lightship:
+ - "Lightship Security"
+ AMOSSYS:
+ - "AMOSSYS"
+ CEA-LETI:
+ - "(CEA - LETI|CEA/LETI|CEA-LETI)"
+ OPPIDA:
+ - "OPPIDA"
+ TrustedLabs:
+ - "Trusted Labs"
+ atsec:
+ - "atsec"
+ DeutscheTelekom:
+ - "Deutsche Telekom Security"
+ DFKI:
+ - "(Deutsches Forschungszentrum für künstliche Intelligenz|dfki|DFKI)"
+ MTG:
+ - "MTG AG"
+ secuvera:
+ - "secuvera"
+ SRC:
+ - "SRC Security Research & Consulting"
+ Acucert:
+ - "Acucert Labs"
+ ERTL:
+ - "Common Criteria Test Laboratory,? ERTL"
+ ETDC:
+ - "Common Criteria Test Laboratory,? ETDC"
+ CCLab:
+ - "CCLab Software Laboratory"
+ Deeplab:
+ - "Deeplab"
+ IMQLPS:
+ - "IMQ/LPS"
+ LVSLeonardo:
+ - "LVS Leonardo"
+ LVSTechnisBlu:
+ - "LVS Technis Blu"
+ ECSEC:
+ - "ECSEC Laboratory"
+ ITSC:
+ - "Information Technology Security Center"
+ Acumen:
+ - "Acumen Security"
+ BoozAllenHamilton:
+ - "Booz Allen Hamilton"
+ Gossamer:
+ - "Gossamer Security"
+ Leidos:
+ - "Leidos"
+ UL:
+ - "UL Verification Services"
+ BEAM:
+ - "BEAM Teknoloji"
+ Certby:
+ - "Certby Lab"
+ DEKRA:
+ - "DEKRA Testing and Certification"
+ STMITSEF:
+ - "STM ITSEF"
+ TUBITAK-BILGEM:
+ - "TÜBİTAK BİLGEM"
+ Combitech:
+ - "Combitech AB"
+ Intertek:
+ - "Intertek"
+ Clover:
+ - "Clover Technologies"
+ LAYAKK:
+ - "LAYAKK SEGURIDAD INFORMATICA"
+ An:
+ - "An Security"
+ TSystems:
+ - "T-Systems International"
+ KISA:
+ - "KISA"
+ KOIST:
+ - "KOIST"
+ KSEL:
+ - "KSEL"
+ KOSYAS:
+ - "KOSYAS"
+ KTR:
+ - "KTR"
+ KTC:
+ - "KTC"
+ TTA:
+ - "TTA"
+ ADS:
+ - "Advanced Data Security"
+ Nemko:
+ - "Nemko System Sikkerhet"
+ Norconsult:
+ - "Norconsult( AS)?"
+ Secura:
+ - "Secura"
+ BAE:
+ - "BAE Applied Intelligence"
+
+#####
+# Symmetric crypto primitive (e.g. a block or stream cipher), grouped by competition/standardization effort.
+#####
+symmetric_crypto:
+ AES_competition:
+ AES:
+ - "AES-?(?P<bitsize>128|192|256)?"
+ Rijndael:
+ - "Rijndael"
+ Twofish:
+ - "Twofish"
+ Serpent:
+ - "Serpent"
+ MARS:
+ - "MARS"
+ HPC:
+ - "HPC"
+ FROG:
+ - "FROG"
+ CAST:
+ - "CAST-?(?P<bitsize>128|160|192|224|256|5)?"
+ RC:
+ - "RC(?P<version>[2456])"
+ CRYPTON:
+ - "CRYPTON"
+ DEAL:
+ - "DEAL"
+ E2:
+ - "E2"
+ LOKI97:
+ - "LOKI97"
+ MAGENTA:
+ - "MAGENTA"
+ SAFER:
+ - "SAFER\\+"
+ DES:
+ DES:
+ - "DE[SA]"
+ 3DES:
+ - "([3T]|Triple)-?DE[SA]"
+ Lucifer:
+ - "Lucifer"
+ djb:
+ ChaCha:
+ - "[XH]?ChaCha(?P<rounds>20|8)?"
+ Salsa:
+ - "[XH]?Salsa(?P<rounds>20(/8|/12)?)?"
+ Poly:
+ - "Poly1305"
+ LWC_competition:
+ ASCON:
+ - "(ASCON|Ascon)"
+ Elephant:
+ - "Elephant"
+ GIFT:
+ - "GIFT(-COFB)?"
+ Grain:
+ - "Grain128(-AEAD)?"
+ ISAP:
+ - "ISAP"
+ PhotonBeetle:
+ - "Photon-?Beetle"
+ Romulus:
+ - "Romulus"
+ Sparkle:
+ - "Sparkle"
+ TinyJambu:
+ - "TinyJambu"
+ Xoodyak:
+ - "Xoodyak"
+ Gimli:
+ - "Gimli"
+ eSTREAM:
+ HC:
+ - "HC-[0-9]{3}"
+ Rabbit:
+ - "Rabbit"
+ SOSEMANUK:
+ - "SOSEMANUK"
+ MICKEY:
+ - "MICKEY(-128)?"
+ Trivium:
+ - "Trivium"
+ CAESAR:
+ ACORN:
+ - "ACORN"
+ AEGIS:
+ - "AEGIS(-128)?"
+ Deoxys:
+ - "Deoxys(-2)?"
+ COLM:
+ - "COLM"
+ miscellaneous:
+ IDEA:
+ - "IDEA"
+ Blowfish:
+ - "Blowfish"
+ Camellia:
+ - "Camellia"
+ ARIA:
+ - "ARIA"
+ SM4:
+ - "SM4"
+ GOST:
+ - "GOST 28147-89"
+ - "Magma"
+ - "Kuznyechik"
+ SEED:
+ - "SEED"
+ Skipjack:
+ - "Skipjack"
+ Skinny:
+ - "Skinny|SKINNY"
+ constructions:
+ MAC:
+ - "HMAC"
+ - "HMAC-[A-Z]+?-(?:160|224|256|384|512)"
+ - "KMAC"
+ - "CMAC|CBC-MAC"
+
+#####
+# Asymmetric crypto primitive, grouped by type (RSA, ECC, FF).
+#####
+asymmetric_crypto:
+ RSA:
+ - "RSA[- ]?(?P<bitsize>512|768|1024|1280|1536|2048|3072|4096|8192)"
+ - "RSASSAPKCS1-[Vv]1_5"
+ - "RSA-?(OAEP|PSS|CRT)"
+ ECC:
+ ECDH:
+ - "ECDHE?"
+ ECDSA:
+ - "ECDSA"
+ EdDSA:
+ - "EdDSA"
+ ECIES:
+ - "ECIES"
+ ECC:
+ - "ECC"
+ FF:
+ DH:
+ - "Diffie-Hellman|DHE?"
+ DSA:
+ - "DSA"
+
+#####
+# Post-quantum crypto primitive, grouped by primitive, from NIST-PQC.
+#####
+pq_crypto:
+ ClassicMcEliece:
+ - "Classic[ -]McEliece"
+ Kyber:
+ - "(CRYSTALS-)?(Kyber|KYBER)"
+ NTRU:
+ - "NTRU"
+ NTRUPrime:
+ - "NTRU[ -]?Prime"
+ Saber:
+ - "Saber|SABER"
+ Dilithium:
+ - "(CRYSTALS-)?(Dilithium|DILITHIUM)"
+ Falcon:
+ - "Falcon|FALCON"
+ Rainbow:
+ - "Rainbow"
+ UOV:
+ - "UOV"
+ BIKE:
+ - "BIKE"
+ Frodo:
+ - "Frodo(KEM)?"
+ HQC:
+ - "HQC"
+ SIKE:
+ - "SIKE"
+ SIDH:
+ - "SIDH"
+ GeMSS:
+ - "GeMSS"
+ Picnic:
+ - "Picnic"
+ SPHINCS:
+ - "SPHINCS\\+"
+
+#####
+# Hash-function, grouped by hash-function class (SHA, MD) or competition (PHC).
+#####
+hash_function:
+ SHA:
+ SHA1:
+ - "SHA-?1"
+ SHA2:
+ - "SHA-?(?P<bitsize>160|224|256|384|512)"
+ - "SHA-?2"
+ SHA3:
+ - "SHA-?3(-[0-9]{3})?"
+ Keccak:
+ - "Keccak"
+ SHAKE:
+ - "SHAKE[0-9]{3}"
+ Groestl:
+ - "(Groestl|Grøstl)"
+ BLAKE:
+ - "(Blake|BLAKE)[23][sbX]?"
+ JH:
+ - "JH"
+ Skein:
+ - "Skein"
+ MD:
+ MD4:
+ - "MD4"
+ MD5:
+ - "MD5"
+ MD6:
+ - "MD6"
+ RIPEMD:
+ - "RIPEMD(-?[0-9]{3})?"
+ Streebog:
+ - "Streebog"
+ Whirpool:
+ - "Whirpool"
+ PHC_competition:
+ Argon:
+ - "Argon2?[id]?"
+ battcrypt:
+ - "battcrypt"
+ Catena:
+ - "Catena"
+ Lyra2:
+ - "Lyra2"
+ Makwa:
+ - "Makwa"
+ POMELO:
+ - "POMELO"
+ Pufferfish:
+ - "Pufferfish"
+ yescrypt:
+ - "yescrypt"
+ bcrypt:
+ - "bcrypt"
+ scrypt:
+ - "scrypt"
+ PBKDF:
+ - "PBKDF[12]?"
+
+#####
+# General cryptographic scheme.
+#####
+crypto_scheme:
+ MAC:
+ - "MAC"
+ KEM:
+ - "KEM"
+ PKE:
+ - "PKE"
+ KEX:
+ - "KEX|Key [eE]xchange"
+ KA:
+ - "KA|Key [aA]greement"
+ PAKE:
+ - "PAKE"
+ AEAD:
+ - "AEAD"
+
+#####
+# General cryptographic protocol.
+#####
+crypto_protocol:
+ SSH:
+ - "SSH"
+ TLS:
+ SSL:
+ - "SSL( ?v?[123]\\.0)?"
+ TLS:
+ - "TLS( ?v?1\\.[0123])?"
+ DTLS:
+ - "DTLS( ?v?1\\.[0123])?"
+ PACE:
+ - "PACE"
+ IKE:
+ - "IKE(v[12])?"
+ IPsec:
+ - "IPsec"
+ VPN:
+ - "VPN"
+ PGP:
+ - "PGP"
+
+#####
+# Random number generator.
+#####
+randomness:
+ DUAL_EC:
+ - "DUAL_EC_DRBG"
+ TRNG:
+ - "D?TRNG"
+ PRNG:
+ - "PRNG"
+ - "DRBG"
+ RNG:
+ - "RN[GD]"
+ - "RBG"
+
+#####
+# Block cipher mode.
+#####
+cipher_mode:
+ ECB:
+ - "ECB"
+ CBC:
+ - "CBC"
+ CTR:
+ - "CTR"
+ CFB:
+ - "CFB"
+ OFB:
+ - "OFB"
+ GCM:
+ - "GCM"
+ SIV:
+ - "SIV"
+ XTR:
+ - "XTR"
+ CCM:
+ - "CCM"
+ LRW:
+ - "LRW"
+ XEX:
+ - "XEX"
+ XTS:
+ - "XTS"
+
+#####
+# An elliptic curve, grouped by standardization body (e.g. NIST, Brainpool).
+# Note that multiple curve names may correspond to the same curve.
+#####
+ecc_curve:
+ NIST:
+ - "(?:Curve |curve |)P-(192|224|256|384|521)"
+ - "(?:ansit|ansip|ANSIP|ANSIT)[0-9]+?[rk][12]"
+ - "(NIST)? ?[PBK]-[0-9]{3}"
+ - "(?:secp|sect|SECP|SECT)[0-9]+?[rk][12]"
+ - "prime[0-9]{3}v[123]"
+ - "c2[pto]nb[0-9]{3}[vw][123]"
+ Brainpool:
+ - "(?:brainpool|BRAINPOOL)P[0-9]{3}[rkt][12]"
+ ANSSI:
+ - "(?:anssi|ANSSI)?[ ]*FRP[0-9]+?v1"
+ NUMS:
+ - "numsp[0-9]{3}[td]1"
+ Curve:
+ - "Curve(25519|1174|4417|22103|67254|383187|41417)"
+ Edwards:
+ - "Ed(25519|448)"
+ ssc:
+ - "ssc-(160|192|224|256|288|320|384|512)"
+ Tweedle:
+ - "Tweedle(dee|dum)"
+ Pasta:
+ - "(Pallas|Vesta)"
+ JubJub:
+ - "JubJub"
+ BLS:
+ - "BLS(12|24)-[0-9]{3}"
+ BN:
+ - "bn[0-9]{3}"
+
+#####
+# A cryptographic engine.
+#####
+crypto_engine:
+ TORNADO:
+ - "TORNADO"
+ SmartMX:
+ - "SmartMX2?"
+ NexCrypt:
+ - "NexCrypt"
+
+#####
+# TLS cipher suite name.
+#####
+tls_cipher_suite:
+ TLS:
+ - "TLS(_[A-Z0-9]+){1,3}_WITH(_[A-Z0-9]+){2,4}"
+
+#####
+# A cryptographic library, grouped by rough library category.
+#####
+crypto_library:
+ Neslib:
+ - "(?:NesLib|NESLIB) [v]*[0-9\\.]+"
+ AT1:
+ - "AT1 Secure .{1,30}? Library [v]*[0-9\\.]+"
+ - "AT1 Secure RSA/ECC/SHA library"
+ Generic:
+ - "Crypto Library [v]*[0-9\\.]+"
+ AtmelToolbox:
+ - "(ATMEL|Atmel) Toolbox [0-9\\.]+"
+ Infineon:
+ - "v1\\.02\\.013" # Infineon's ROCA-vulnerable library
+ OpenSSL:
+ - "OpenSSL"
+ LibreSSL:
+ - "LibreSSL"
+ BoringSSL:
+ - "BoringSSL"
+ MatrixSSL:
+ - "MatrixSSL"
+ Nettle:
+ - "Nettle"
+ GnuTLS:
+ - "GnuTLS"
+ libtomcrypt:
+ - "libtomcrypt"
+ BearSSL:
+ - "BearSSL"
+ Botan:
+ - "Botan"
+ Crypto++:
+ - "Crypto\\+\\+"
+ wolfSSL:
+ - "wolfSSL"
+ mbedTLS:
+ - "[mM]bedTLS"
+ s2n:
+ - "(Amazon )?s2n"
+ NSS:
+ - "NSS"
+ libgcrypt:
+ - "libgcrypt"
+ BouncyCastle:
+ - "BouncyCastle"
+ cryptlib:
+ - "cryptlib"
+ NaCl:
+ - "NaCl"
+ libsodium:
+ - "libsodium"
+ libsecp256k1:
+ - "libsecp256k1"
+
+#####
+# A vulnerability idenfitier or name (e.g. CVE-... but also Minerva, ROCA).
+#####
+vulnerability:
+ CVE:
+ - "CVE-[0-9]+?-[0-9]+?"
+ CWE:
+ - "CWE-[0-9]+?"
+ ROCA:
+ - "ROCA"
+ Minerva:
+ - "Minerva"
+ TPM-Fail:
+ - "TPM[\\.-]Fail"
+
+#####
+# A side-channel analysis related term, grouped into SCA, FI and other.
+#####
+side_channel_analysis:
+ SCA:
+ - "Leak-Inherent"
+ - "[Pp]hysical [Pp]robing"
+ - "[Ss]ide.channels?"
+ - "SPA|DPA"
+ - "[tT]iming [aA]ttacks?"
+ - "[Tt]emplate [aA]ttacks?"
+ - "[Pp]rofiled [aA]ttacks?"
+ - "[Cc]lustering [aA]ttacks?"
+ - "t-test|TVLA"
+ FI:
+ - "[pP]hysical [tT]ampering"
+ - "[Mm]alfunction"
+ - "DFA|SIFA"
+ - "[Ff]+ault [iI]nduction"
+ - "[Ff]+ault [iI]njection"
+ other:
+ - "[Dd]eep[ -][lL]earning"
+ - "[Cc]old [bB]oot"
+ - "[Rr]ow-?hammer"
+ - "[Rr]everse [eE]ngineering"
+ - "[Ll]attice [aA]ttacks?"
+ - "[Oo]racle [aA]ttacks?"
+ - "[Bb]leichenbacher [aA]ttacks?"
+ - "[Bb]ellcore [aA]ttacks?"
+ - "JIL(-(AAPS|COMP|AM|AAPHD|AMHD))?"
+ - "JHAS"
+
+#####
+# A term used in the certification process.
+#####
+certification_process:
+ OutOfScope:
+ - "[oO]ut of [sS]cope"
+ - "[\\.\\(].{0,100}?.[oO]ut of [sS]cope..{0,100}?[\\.\\)]"
+ - ".{0,100}[oO]ut of [sS]cope.{0,100}"
+ ConfidentialDocument:
+ - ".{0,100}confidential document.{0,100}"
+ SecurityFunction:
+ - "[sS]ecurity [fF]unction SF\\.[a-zA-Z0-9_]"
+
+#####
+# A technical report id, grouped by standardization body (e.g. BSI).
+#####
+technical_report_id:
+ BSI:
+ - "BSI[ ]*TR-[0-9]+?(?:-[0-9]+?|)"
+ - "BSI [0-9]+?" # German BSI document containing list of issued certificates in some period
+
+#####
+# A device model, grouped by manufacturer and subgroups into particular model families.
+#####
+device_model:
+ G87:
+ - "G87-.+?"
+ ATMEL:
+ - "ATMEL AT.+?"
+ STM:
+ STM32:
+ - "STM32[FGLHW][0-7][0-9]{1,2}[FGKTSCRVZI][468BCDEFGHI][PHUTY][67]"
+ Infineon:
+ SLE:
+ - "SLE[0-9]{2}[A-Z]{3}[0-9]{1-4}[A-Z]{1-3}"
+
+#####
+# A Trusted Execution Environment, grouped by manufacturer (e.g. Intel, ARM, ...).
+#####
+tee_name:
+ Intel:
+ - "(Intel )?SGX"
+ ARM:
+ - "(ARM )?TrustZone"
+ - "(ARM )?(Realm Management Extension|Confidential Compute Architecture)"
+ AMD:
+ - "(AMD )?(PSP|Platform Security Processor)"
+ - "(AMD )?(SEV|Secure Encrypted Virtualization)"
+ IBM:
+ - "(IBM )?(SSC|Secure Service Container)"
+ - "(IBM )?(SE|Secure Execution)"
+ other:
+ - "Cloud Link TEE"
+ - "iOS Secure Enclave"
+ - "iTrustee"
+ - "Trusty"
+ - "OPTEE"
+ - "QTEE"
+ - "TEEgris"
+ - "T6"
+ - "Kinibi"
+ - "SW TEE"
+ - "WatchTrust"
+ - "TEE"
+
+#####
+# An OS name, grouped by OS.
+#####
+os_name:
+ STARCOS:
+ - "STARCOS(?: [0-9\\.]+?|)"
+ JCOP:
+ - "JCOP[ ]*[0-9]"
+
+#####
+# CPLC data name..
+#####
+cplc_data:
+ ICFab:
+ - "IC[ \\.]*Fabricator"
+ ICType:
+ - "IC[ \\.]*Type"
+ ICVersion:
+ - "IC[ \\.]*Version"
+
+#####
+# An elementary data group.
+#####
+ic_data_group:
+ EF:
+ - "EF\\.DG[1-9][0-6]?"
+ - "EF\\.COM"
+ - "EF\\.CardAccess"
+ - "EF\\.SOD"
+ - "EF\\.ChipSecurity"
+
+#####
+# Standard ID, grouped by standardization body (e.g. FIPS, NIST, ISO).
+#####
+standard_id:
+ FIPS:
+ - "FIPS ?(?:PUB )?[0-9]+(-[0-9]+)?"
+ NIST:
+ - "(NIST )?SP [0-9]+-[0-9]+?[a-zA-Z]?"
+ PKCS:
+ - "PKCS[ #]*[1-9]+"
+ BSI:
+ - "BSI-AIS[ ]*[0-9]+?"
+ - "AIS[ ]*[0-9]+?"
+ RFC:
+ - "RFC[ -]?[0-9]+?"
+ ISO:
+ - "ISO/IEC[ ]*[0-9]+[-]*[0-9]*"
+ - "ISO/IEC[ ]*[0-9]+:[ 0-9]+"
+ - "ISO/IEC[ ]*[0-9]+"
+ ICAO:
+ - "ICAO(?:-SAC|)"
+ X509:
+ - "[Xx]\\.509"
+ SCP:
+ - "(?:SCP|scp)[ ']*[0-9][0-9]"
+ CC:
+ - "CC[I]*MB-20[0-9]+?-[0-9]+?-[0-9]+?" # Common Criteria methodology
+ - "CCIMB-9[0-9]-[0-9]+?" # Common Criteria methodology old
+
+#####
+# JavaCard version identifier.
+#####
+javacard_version:
+ JavaCard:
+ - "(?:Java Card|JavaCard) [2-3]\\.[0-9](?:\\.[0-9]|)"
+ - "JC[2-3]\\.[0-9](?:\\.[0-9]|)"
+ - "(?:Java Card|JavaCard) \\(version [2-3]\\.[0-9](?:\\.[0-9]|)\\)"
+ GlobalPlatform:
+ - "(?:Global Platform|GlobalPlatform) [2-3]\\.[0-9]\\.[0-9]"
+ - "(?:Global Platform|GlobalPlatform) \\(version [2-3]\\.[0-9]\\.[0-9]\\)"
+
+#####
+# JavaCard API constant, grouped into "ALG", "misc" and "curves".
+#####
+javacard_api_const:
+ ALG:
+ RNG:
+ - "ALG_(?:PSEUDO_RANDOM|SECURE_RANDOM|TRNG|PRESEEDED_DRBG|FAST|KEYGENERATION)"
+ DES:
+ - "ALG_DES_[A-Z_0-9]+"
+ RSA:
+ - "ALG_RSA_[A-Z_0-9]+"
+ DSA:
+ - "ALG_DSA_[A-Z_0-9]+"
+ ECDSA:
+ - "ALG_ECDSA_[A-Z_0-9]+"
+ AES:
+ - "ALG_AES_[A-Z_0-9]+"
+ HMAC:
+ - "ALG_HMAC_[A-Z_0-9]+"
+ KOREAN:
+ - "ALG_KOREAN_[A-Z_0-9]+"
+ EC:
+ - "ALG_EC_[A-Z_0-9]+?"
+ SHA:
+ - "ALG_SHA_[A-Z_0-9]+"
+ SHA3:
+ - "ALG_SHA3_[A-Z_0-9]+"
+ MD:
+ - "ALG_MD[A-Z_0-9]+"
+ RIPEMD:
+ - "ALG_RIPEMD[A-Z_0-9]+"
+ ISO3309:
+ - "ALG_ISO3309_[A-Z_0-9]+"
+ XDH:
+ - "ALG_XDH"
+ SM2:
+ - "ALG_SM2"
+ SM3:
+ - "ALG_SM3"
+ NULL:
+ - "ALG_NULL"
+ misc:
+ - "SIG_CIPHER_[A-Z_0-9]+"
+ - "CIPHER_[A-Z_0-9]+"
+ - "PAD_[A-Z_0-9]+"
+ - "TYPE_[A-Z_0-9]+"
+ - "LENGTH_[A-Z_0-9]+"
+ - "OWNER_PIN[A-Z_0-9]*"
+ curves:
+ - "BRAINPOOLP[A-Z_0-9]+(?:R|T)1"
+ - "ED25519"
+ - "ED448"
+ - "FRP256V1"
+ - "SECP[0-9]*R1"
+ - "SM2"
+ - "X25519"
+ - "X448"
+
+#####
+# JavaCard common package identifiers.
+#####
+javacard_packages:
+ java:
+ - "java\\.[a-z\\.]+"
+ javacard:
+ - "javacard\\.[a-z\\.]+"
+ javacardx:
+ - "javacardx\\.[a-z\\.]+"
+ org:
+ - "org\\.[0-9a-z\\.]+"
+ uicc:
+ - "uicc\\.[a-z\\.]+"
+ com:
+ - "com\\.[0-9a-z\\.]+"
+ de:
+ - "de\\.bsi\\.[a-z\\.]+"
+
+#####
+# FIPS 140 certificate id.
+#####
+fips_cert_id:
+ Cert:
+ - "(?:#[^\\S\\r\\n]?|Cert\\.?(?!.\\s)[^\\S\\r\\n]?|Certificate[^\\S\\r\\n]?)(?P<id>\\d{1,4})(?!\\d)"
+
+#####
+# FIPS 140 security level.
+#####
+fips_security_level:
+ Level:
+ - "[lL]evel (\\d)"
+
+#####
+# FIPS 140 "certlike" string, that needs to get removed from certificate id matches.
+#####
+fips_certlike:
+ Certlike:
+ # --- HMAC(-SHA)(-1) - (bits) (method) ((hardware/firmware cert) #id) ---
+ # + added (and #id) everywhere
+ - "HMAC(?:[- –]*SHA)?(?:[- –]*1)?[– -]*((?:;|\\/|160|224|256|384|512)?(?:;|\\/| |[Dd]ecrypt|[Ee]ncrypt|KAT)*?[, ]*?\\(?(?: |hardware|firmware)*?[\\s(\\[]*?(?:#|cert\\.?|Cert\\.?|Certificate|sample)?[\\s#]*?)?[\\s#]*?(\\d{1,4})(?:[\\s#]*and[\\s#]*\\d+)?"
+ # --- same as above, without hw or fw ---
+ - "HMAC(?:-SHA)?(?:-1)?[ -]*((?:;|\\/|160|224|256|384|512)?(?:;|\\/| |[Dd]ecrypt|[Ee]ncrypt|KAT)*?[, ]*?(?:#|cert\\.?|sample|Cert\\.?|Certificate)?[\\s#]*?)?[\\s#]*?(\\d{1,4})"
+ # --- SHS/A - (bits) (method) ((cert #) numbers) ---
+ - "SH[SA][-– 123]*(?:;|\\/|160|224|256|384|512)?(?:[\\s(\\[]*?(?:KAT|[Bb]yte [Oo]riented)*?[\\s,]*?[\\s(\\[]*?(?:#|cert\\.?|sample|Cert\\.?|Certificate)?[\\s#]*?)?[\\s#]*?(\\d{1,4})(?:\\)?\\[#?\\d+\\])?(?:[\\s#]*?and[\\s#]*?\\d+)?"
+ # --- RSA (bits) (method) ((cert #)) ---
+ - "RSA(?:[-– ]*(?:;|\\/|512|768|1024|1280|1536|2048|3072|4096|8192)\\s\\(\\[]*?(?:(?:;|\\/|KAT|Verify|PSS|\\s)*?)?[\\s,]*?[\\s(\\[]*?(?:#|cert\\.?|sample|Cert\\.?|Certificate)?[\\s#]*?)?[\\s#]*?(\\d{1,4})"
+ # --- RSA (SSA) (PKCS) (version) (#) ---
+ - "(?:RSA)?[-– ]?(?:SSA)?[- ]?PKCS\\s?#?\\d(?:-[Vv]1_5| [Vv]1[-_]5)?[\\s#]*?(\\d{1,4})?"
+ # --- AES (bits) (method) ((cert #)) ---
+ - "AES[-– ]*((?: |;|\\/|bit|key|128|192|256|CBC)*(?: |\\/|;|[Dd]ecrypt|[Ee]ncrypt|KAT|CMAC|CTR|GCM|IV|CBC)*?[,\\s(\\[]*?(?:#|cert\\.?|sample|Cert\\.?|Certificate)?[\\s#]*?)?[\\s#]*?(\\d{1,4})(?:\\)?[\\s#]*?\\[#?\\d+\\])?(?:[\\s#]*?and[\\s#]*?(\\d+))?"
+ # --- Diffie Helman (CVL) ((cert #)) ---
+ - "Diffie[-– ]*Hellman[,\\s(\\[]*?(?:CVL|\\s)*?(?:#|cert\\.?|sample|Cert\\.?|Certificate)?[\\s#]*?[\\s#]*?(\\d{1,4})"
+ # --- DRBG (bits) (method) (cert #) ---
+ - "DRBG[ –-]*((?:;|\\/|160|224|256|384|512)?(?:;|\\/| |[Dd]ecrypt|[Ee]ncrypt|KAT)*?[,\\s(\\[]*?(?:#|cert\\.?|sample|Cert\\.?|Certificate)?[\\s#]*?)?[\\s#]*?(\\d{1,4})"
+ # --- DES (bits) (method) (cert #)
+ - "DES[ –-]*((?:;|\\/|160|224|256|384|512)?(?:;|\\/| |[Dd]ecrypt|[Ee]ncrypt|KAT|CBC|(?:\\d(?: and \\d)? keying options?))*?[,\\s(\\[]*?(?:#|cert\\.?|sample|Cert\\.?|Certificate)*?[\\s#]*?)?[\\s#]*?(\\d{1,4})(?:[\\s#]*?and[\\s#]*?(\\d+))?"
+ # --- DSA (bits) (method) (cert #)
+ - "DSA[ –-]*((?:;|\\/|160|224|256|384|512)?(?: |[Dd]ecrypt|[Ee]ncrypt|KAT)*?[,\\s(\\[]*?(?:#|cert\\.?|sample|Cert\\.?|Certificate)?[\\s#]*?)?[\\s#]*?(\\d{1,4})"
+ # --- platforms (#)+ - this is used in modification history ---
+ - "[Pp]latforms? #\\d+(?:#\\d+|,| |-|and)*[^\\n]*"
+ # --- CVL (#) ---
+ - "CVL[\\s#]*?(\\d{1,4})"
+ # --- PAA (#) ---
+ - "PAA[: #]*?\\d{1,4}"
+ # --- (#) Type ---
+ - "(?:#|cert\\.?|sample|Cert\\.?|Certificate)[\\s#]*?(\\d+)?\\s*?(?:AES|SHS|SHA|RSA|HMAC|Diffie-Hellman|DRBG|DES|CVL)"
+ # --- PKCS (#) ---
+ - "PKCS[\\s]?#?\\d+"
+ - "PKSC[\\s]?#?\\d+" # typo, #625
+ # --- # C and # A (just in case) ---
+ - "#\\s+?[Cc]\\d+"
+ - "#\\s+?[Aa]\\d+"
+
+
+#####
+# Common Criteria rules.
+#####
+cc_rules:
+ - "cc_cert_id"
+ - "cc_protection_profile_id"
+ - "cc_security_level"
+ - "cc_sar"
+ - "cc_sfr"
+ - "cc_claims"
+ - "vendor"
+ - "eval_facility"
+ - "symmetric_crypto"
+ - "asymmetric_crypto"
+ - "pq_crypto"
+ - "hash_function"
+ - "crypto_scheme"
+ - "crypto_protocol"
+ - "randomness"
+ - "cipher_mode"
+ - "ecc_curve"
+ - "crypto_engine"
+ - "tls_cipher_suite"
+ - "crypto_library"
+ - "vulnerability"
+ - "side_channel_analysis"
+ - "technical_report_id"
+ - "device_model"
+ - "tee_name"
+ - "os_name"
+ - "cplc_data"
+ - "ic_data_group"
+ - "standard_id"
+ - "javacard_version"
+ - "javacard_api_const"
+ - "javacard_packages"
+ - "certification_process"
+
+
+#####
+# FIPS rules.
+#####
+fips_rules:
+ - "fips_cert_id"
+ - "fips_security_level"
+ - "fips_certlike"
+ - "vendor"
+ - "eval_facility"
+ - "symmetric_crypto"
+ - "asymmetric_crypto"
+ - "pq_crypto"
+ - "hash_function"
+ - "crypto_scheme"
+ - "crypto_protocol"
+ - "randomness"
+ - "cipher_mode"
+ - "ecc_curve"
+ - "crypto_engine"
+ - "tls_cipher_suite"
+ - "crypto_library"
+ - "vulnerability"
+ - "side_channel_analysis"
+ - "device_model"
+ - "tee_name"
+ - "os_name"
+ - "cplc_data"
+ - "ic_data_group"
+ - "standard_id"
+ - "javacard_version"
+ - "javacard_api_const"
+ - "javacard_packages"
+ - "certification_process"
diff --git a/src/sec_certs/sample/__init__.py b/src/sec_certs/sample/__init__.py
new file mode 100644
index 00000000..ecbbd541
--- /dev/null
+++ b/src/sec_certs/sample/__init__.py
@@ -0,0 +1,33 @@
+"""This package holds mostly data objects of primary interest (Common Criteria, FIPS), or assisting objects
+like CPE, CVE, etc. The objects mostly hold data and allow for serialization, but can also perform some basic transformations.
+"""
+
+from sec_certs.sample.cc_certificate_id import CertificateId
+from sec_certs.sample.cc_maintenance_update import CommonCriteriaMaintenanceUpdate
+from sec_certs.sample.common_criteria import CommonCriteriaCert
+from sec_certs.sample.cpe import CPE, cached_cpe
+from sec_certs.sample.cve import CVE
+from sec_certs.sample.fips import FIPSCertificate
+from sec_certs.sample.fips_algorithm import FIPSAlgorithm
+from sec_certs.sample.fips_iut import IUTEntry, IUTSnapshot
+from sec_certs.sample.fips_mip import MIPEntry, MIPSnapshot, MIPStatus
+from sec_certs.sample.protection_profile import ProtectionProfile
+from sec_certs.sample.sar import SAR
+
+__all__ = [
+ "CertificateId",
+ "CommonCriteriaMaintenanceUpdate",
+ "CommonCriteriaCert",
+ "CPE",
+ "cached_cpe",
+ "CVE",
+ "FIPSCertificate",
+ "FIPSAlgorithm",
+ "IUTEntry",
+ "IUTSnapshot",
+ "MIPEntry",
+ "MIPSnapshot",
+ "MIPStatus",
+ "ProtectionProfile",
+ "SAR",
+]
diff --git a/src/sec_certs/sample/cc_certificate_id.py b/src/sec_certs/sample/cc_certificate_id.py
new file mode 100644
index 00000000..428ca73b
--- /dev/null
+++ b/src/sec_certs/sample/cc_certificate_id.py
@@ -0,0 +1,151 @@
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass
+
+
+@dataclass(eq=True, frozen=True)
+class CertificateId:
+ """
+ A Common Criteria certificate id.
+ """
+
+ scheme: str
+ raw: str
+
+ def _canonical_fr(self) -> str:
+ new_cert_id = self.clean
+ rules = [
+ "(?:Rapport de certification|Certification Report) ([0-9]+[/-_][0-9]+(?:v[1-9])?(?:[_/-][MSR][0-9]+)?)",
+ "(?:ANSS[Ii]|DCSSI)(?:-CC)?[- ]([0-9]+[/-_][0-9]+(?:v[1-9])?(?:[_/-][MSR][0-9]+)?)",
+ "([0-9]+[/-_][0-9]+(?:v[1-9])?(?:[_/-][MSR][0-9]+)?)",
+ ]
+ for rule in rules:
+ if match := re.match(rule, new_cert_id):
+ return "ANSSI-CC-" + match.group(1).replace("_", "/")
+
+ return new_cert_id
+
+ def _canonical_de(self) -> str:
+ def extract_parts(bsi_parts: list[str]) -> tuple:
+ cert_num = None
+ cert_version = None
+ cert_year = None
+
+ if len(bsi_parts) > 3:
+ cert_num = bsi_parts[3]
+ if len(bsi_parts) > 4:
+ if bsi_parts[4].startswith("V") or bsi_parts[4].startswith("v"):
+ cert_version = bsi_parts[4].upper() # get version in uppercase
+ else:
+ cert_year = bsi_parts[4]
+ if len(bsi_parts) > 5:
+ cert_year = bsi_parts[5]
+
+ return cert_num, cert_version, cert_year
+
+ bsi_parts = self.clean.split("-")
+
+ cert_num, cert_version, cert_year = extract_parts(bsi_parts)
+
+ # reconstruct BSI number again
+ new_cert_id = "BSI-DSZ-CC"
+ if cert_num is not None:
+ new_cert_id += "-" + cert_num
+ if cert_version is not None:
+ new_cert_id += "-" + cert_version
+ if cert_year is not None:
+ new_cert_id += "-" + cert_year
+
+ return new_cert_id
+
+ def _canonical_es(self) -> str:
+ cert_id = self.clean
+ spain_parts = cert_id.split("-")
+ cert_year = spain_parts[0]
+ cert_batch = spain_parts[1].lstrip("0")
+ cert_num = spain_parts[3].lstrip("0")
+
+ if "v" in cert_num:
+ cert_num = cert_num[: cert_num.find("v")]
+ if "V" in cert_num:
+ cert_num = cert_num[: cert_num.find("V")]
+
+ new_cert_id = f"{cert_year}-{cert_batch}-INF-{cert_num.strip()}" # drop version # TODO: Maybe do not drop?
+
+ return new_cert_id
+
+ def _canonical_it(self):
+ new_cert_id = self.clean
+ if not new_cert_id.endswith("/RC"):
+ new_cert_id = new_cert_id + "/RC"
+
+ return new_cert_id
+
+ def _canonical_in(self):
+ return self.clean.replace(" ", "")
+
+ def _canonical_se(self):
+ return self.clean.replace(" ", "")
+
+ def _canonical_uk(self):
+ new_cert_id = self.clean
+ if match := re.match("CERTIFICATION REPORT No. P([0-9]+[A-Z]?)", new_cert_id):
+ new_cert_id = "CRP" + match.group(1)
+ return new_cert_id
+
+ def _canonical_ca(self):
+ new_cert_id = self.clean
+ if new_cert_id.endswith("-CR"):
+ new_cert_id = new_cert_id[:-3]
+ if new_cert_id.endswith("P"):
+ new_cert_id = new_cert_id[:-1]
+ return new_cert_id.replace(" ", "-")
+
+ def _canonical_jp(self):
+ new_cert_id = self.clean
+ if match := re.match("Certification No. (C[0-9]+)", new_cert_id):
+ return match.group(1)
+ if match := re.search("CRP-(C[0-9]+)-", new_cert_id):
+ return match.group(1)
+ return new_cert_id
+
+ def _canonical_no(self):
+ new_cert_id = self.clean
+ cert_num = int(new_cert_id.split("-")[1])
+ return f"SERTIT-{cert_num:03}"
+
+ @property
+ def clean(self) -> str:
+ """
+ The clean version of this certificate id.
+ """
+ return self.raw.replace("\N{HYPHEN}", "-").strip()
+
+ @property
+ def canonical(self) -> str:
+ """
+ The canonical version of this certificate id.
+ """
+ # We have rules for some schemes to make canonical cert_ids.
+ schemes = {
+ "FR": self._canonical_fr,
+ "DE": self._canonical_de,
+ "ES": self._canonical_es,
+ "IT": self._canonical_it,
+ "IN": self._canonical_in,
+ "SE": self._canonical_se,
+ "UK": self._canonical_uk,
+ "CA": self._canonical_ca,
+ "JP": self._canonical_jp,
+ "NO": self._canonical_no,
+ }
+
+ if self.scheme in schemes:
+ return schemes[self.scheme]()
+ else:
+ return self.clean
+
+
+def canonicalize(cert_id_str: str, scheme: str) -> str:
+ return CertificateId(scheme, cert_id_str).canonical
diff --git a/src/sec_certs/sample/cc_maintenance_update.py b/src/sec_certs/sample/cc_maintenance_update.py
new file mode 100644
index 00000000..abe3b176
--- /dev/null
+++ b/src/sec_certs/sample/cc_maintenance_update.py
@@ -0,0 +1,99 @@
+from __future__ import annotations
+
+import logging
+from datetime import date
+from typing import ClassVar
+
+import sec_certs.utils.helpers as helpers
+from sec_certs.sample.common_criteria import CommonCriteriaCert
+from sec_certs.serialization.json import ComplexSerializableType
+
+logger = logging.getLogger(__name__)
+
+
+class CommonCriteriaMaintenanceUpdate(CommonCriteriaCert, ComplexSerializableType):
+ pandas_columns: ClassVar[list[str]] = [
+ "dgst",
+ "name",
+ "report_link",
+ "st_link",
+ "related_cert_digest",
+ "maintenance_date",
+ ]
+
+ def __init__(
+ self,
+ name: str,
+ report_link: str,
+ st_link: str,
+ state: CommonCriteriaCert.InternalState | None,
+ pdf_data: CommonCriteriaCert.PdfData | None,
+ heuristics: CommonCriteriaCert.Heuristics | None,
+ related_cert_digest: str,
+ maintenance_date: date,
+ ):
+ super().__init__(
+ "",
+ "",
+ name,
+ "",
+ "",
+ "",
+ None,
+ None,
+ report_link,
+ st_link,
+ "",
+ "",
+ set(),
+ set(),
+ state,
+ pdf_data,
+ heuristics,
+ )
+ self.related_cert_digest = related_cert_digest
+ self.maintenance_date = maintenance_date
+
+ @property
+ def serialized_attributes(self) -> list[str]:
+ return ["dgst"] + list(self.__class__.__init__.__code__.co_varnames)[1:]
+
+ @property
+ def dgst(self) -> str:
+ if not self.name:
+ raise RuntimeError("MaintenanceUpdate digest can't be computed, because name of update is missing.")
+ return "cert_" + self.related_cert_digest + "_update_" + helpers.get_first_16_bytes_sha256(self.name)
+
+ @property
+ def pandas_tuple(self) -> tuple:
+ return tuple([getattr(self, x) for x in CommonCriteriaMaintenanceUpdate.pandas_columns])
+
+ @classmethod
+ def from_dict(cls, dct: dict) -> CommonCriteriaMaintenanceUpdate:
+ dct.pop("dgst")
+ return cls(*(tuple(dct.values())))
+
+ @classmethod
+ def get_updates_from_cc_cert(cls, cert: CommonCriteriaCert) -> list[CommonCriteriaMaintenanceUpdate]:
+ if cert.maintenance_updates is None:
+ raise RuntimeError("Dataset was probably not built correctly - this should not be happening.")
+
+ return [
+ cls(
+ x.maintenance_title,
+ x.maintenance_report_link,
+ x.maintenance_st_link,
+ None,
+ None,
+ None,
+ cert.dgst,
+ x.maintenance_date,
+ )
+ for x in cert.maintenance_updates
+ if (
+ x.maintenance_title is not None
+ and x.maintenance_report_link is not None
+ and x.maintenance_st_link is not None
+ and x.maintenance_date is not None
+ )
+ ]
diff --git a/src/sec_certs/sample/certificate.py b/src/sec_certs/sample/certificate.py
new file mode 100644
index 00000000..bb49c0df
--- /dev/null
+++ b/src/sec_certs/sample/certificate.py
@@ -0,0 +1,90 @@
+from __future__ import annotations
+
+import copy
+import logging
+from abc import ABC, abstractmethod
+from collections import ChainMap
+from dataclasses import dataclass, field
+from typing import Any, Generic, TypeVar
+
+import sec_certs.utils.extract
+from sec_certs.cert_rules import PANDAS_KEYWORDS_CATEGORIES
+from sec_certs.serialization.json import ComplexSerializableType
+
+logger = logging.getLogger(__name__)
+
+T = TypeVar("T", bound="Certificate")
+H = TypeVar("H", bound="Heuristics")
+P = TypeVar("P", bound="PdfData")
+
+
+@dataclass
+class References(ComplexSerializableType):
+ directly_referenced_by: set[str] | None = field(default=None)
+ indirectly_referenced_by: set[str] | None = field(default=None)
+ directly_referencing: set[str] | None = field(default=None)
+ indirectly_referencing: set[str] | None = field(default=None)
+
+
+class Heuristics:
+ cpe_matches: set[str] | None
+ related_cves: set[str] | None
+
+
+class PdfData:
+ def get_keywords_df_data(self, var: str) -> dict[str, float]:
+ data_dct = getattr(self, var)
+ return dict(
+ ChainMap(
+ *[
+ sec_certs.utils.extract.get_sums_for_rules_subset(data_dct, cat)
+ for cat in PANDAS_KEYWORDS_CATEGORIES
+ ]
+ )
+ )
+
+
+class Certificate(Generic[T, H, P], ABC, ComplexSerializableType):
+ manufacturer: str | None
+ name: str | None
+ pdf_data: P
+ heuristics: H
+
+ def __init__(self, *args, **kwargs):
+ pass
+
+ def __repr__(self) -> str:
+ return str(self.to_dict())
+
+ def __str__(self) -> str:
+ return "Not implemented"
+
+ @property
+ @abstractmethod
+ def dgst(self):
+ raise NotImplementedError("Not meant to be implemented")
+
+ @property
+ @abstractmethod
+ def label_studio_title(self):
+ raise NotImplementedError("Not meant to be implemented")
+
+ def __eq__(self, other: object) -> bool:
+ if not isinstance(other, Certificate):
+ return False
+ return self.dgst == other.dgst
+
+ def to_dict(self) -> dict[str, Any]:
+ return {
+ **{"dgst": self.dgst},
+ **{key: val for key, val in copy.deepcopy(self.__dict__).items() if key in self.serialized_attributes},
+ }
+
+ @classmethod
+ 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/common_criteria.py b/src/sec_certs/sample/common_criteria.py
new file mode 100644
index 00000000..acd54178
--- /dev/null
+++ b/src/sec_certs/sample/common_criteria.py
@@ -0,0 +1,986 @@
+from __future__ import annotations
+
+import copy
+import re
+from collections import Counter, defaultdict
+from dataclasses import dataclass, field
+from datetime import date, datetime
+from enum import Enum
+from pathlib import Path
+from typing import Any, ClassVar
+from urllib.parse import unquote_plus, urlparse
+
+import numpy as np
+import requests
+from bs4 import Tag
+
+import sec_certs.utils.extract
+import sec_certs.utils.pdf
+import sec_certs.utils.sanitization
+from sec_certs import constants as constants
+from sec_certs.cert_rules import SARS_IMPLIED_FROM_EAL, cc_rules, rules, security_level_csv_scan
+from sec_certs.sample.cc_certificate_id import canonicalize
+from sec_certs.sample.certificate import Certificate
+from sec_certs.sample.certificate import Heuristics as BaseHeuristics
+from sec_certs.sample.certificate import PdfData as BasePdfData
+from sec_certs.sample.certificate import References, logger
+from sec_certs.sample.protection_profile import ProtectionProfile
+from sec_certs.sample.sar import SAR
+from sec_certs.serialization.json import ComplexSerializableType
+from sec_certs.serialization.pandas import PandasSerializableType
+from sec_certs.utils import helpers
+from sec_certs.utils.extract import normalize_match_string
+
+HEADERS = {
+ "anssi": sec_certs.utils.extract.search_only_headers_anssi,
+ "bsi": sec_certs.utils.extract.search_only_headers_bsi,
+ "nscib": sec_certs.utils.extract.search_only_headers_nscib,
+ "niap": sec_certs.utils.extract.search_only_headers_niap,
+ "canada": sec_certs.utils.extract.search_only_headers_canada,
+}
+
+
+class ReferenceType(Enum):
+ DIRECT = "direct"
+ INDIRECT = "indirect"
+
+
+class CommonCriteriaCert(
+ Certificate["CommonCriteriaCert", "CommonCriteriaCert.Heuristics", "CommonCriteriaCert.PdfData"],
+ PandasSerializableType,
+ ComplexSerializableType,
+):
+ """
+ Data structure for common criteria certificate. Contains several inner classes that layer the data logic.
+ Can be serialized into/from json (`ComplexSerializableType`) or pandas (`PandasSerializableType)`.
+ Is basic element of `CCDataset`. The functionality is mostly related to holding data and transformations that
+ the certificate can handle itself. `CCDataset` class then instrument this functionality.
+ """
+
+ cc_url = "http://www.commoncriteriaportal.org"
+ empty_st_url = "http://www.commoncriteriaportal.org/files/epfiles/"
+
+ @dataclass(eq=True, frozen=True)
+ class MaintenanceReport(ComplexSerializableType):
+ """
+ Object for holding maintenance reports.
+ """
+
+ maintenance_date: date | None
+ maintenance_title: str | None
+ maintenance_report_link: str | None
+ maintenance_st_link: str | None
+
+ def __post_init__(self):
+ super().__setattr__(
+ "maintenance_report_link", sec_certs.utils.sanitization.sanitize_link(self.maintenance_report_link)
+ )
+ super().__setattr__(
+ "maintenance_st_link", sec_certs.utils.sanitization.sanitize_link(self.maintenance_st_link)
+ )
+ super().__setattr__(
+ "maintenance_title", sec_certs.utils.sanitization.sanitize_string(self.maintenance_title)
+ )
+ super().__setattr__("maintenance_date", sec_certs.utils.sanitization.sanitize_date(self.maintenance_date))
+
+ @classmethod
+ def from_dict(cls, dct: dict) -> CommonCriteriaCert.MaintenanceReport:
+ new_dct = dct.copy()
+ new_dct["maintenance_date"] = (
+ date.fromisoformat(dct["maintenance_date"])
+ if isinstance(dct["maintenance_date"], str)
+ else dct["maintenance_date"]
+ )
+ return super().from_dict(new_dct)
+
+ def __lt__(self, other):
+ return self.maintenance_date < other.maintenance_date
+
+ @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.
+ """
+
+ st_download_ok: bool # Whether target download went OK
+ report_download_ok: bool # Whether report download went OK
+ st_convert_garbage: bool # Whether initial target conversion resulted in garbage
+ report_convert_garbage: bool # Whether initial report conversion resulted in garbage
+ st_convert_ok: bool # Whether overall target conversion went OK (either pdftotext or via OCR)
+ report_convert_ok: bool # Whether overall report conversion went OK (either pdftotext or via OCR)
+ st_extract_ok: bool # Whether target extraction went OK
+ report_extract_ok: bool # Whether report extraction went OK
+
+ st_pdf_hash: str | None
+ report_pdf_hash: str | None
+ st_txt_hash: str | None
+ report_txt_hash: str | None
+
+ st_pdf_path: Path
+ report_pdf_path: Path
+ st_txt_path: Path
+ report_txt_path: Path
+
+ def __init__(
+ self,
+ st_download_ok: bool = False,
+ report_download_ok: bool = False,
+ st_convert_garbage: bool = False,
+ report_convert_garbage: bool = False,
+ st_convert_ok: bool = False,
+ report_convert_ok: bool = False,
+ st_extract_ok: bool = False,
+ report_extract_ok: bool = False,
+ st_pdf_hash: str | None = None,
+ report_pdf_hash: str | None = None,
+ st_txt_hash: str | None = None,
+ report_txt_hash: str | None = None,
+ ):
+ super().__init__()
+ self.st_download_ok = st_download_ok
+ self.report_download_ok = report_download_ok
+ self.st_convert_garbage = st_convert_garbage
+ self.report_convert_garbage = report_convert_garbage
+ self.st_convert_ok = st_convert_ok
+ self.report_convert_ok = report_convert_ok
+ self.st_extract_ok = st_extract_ok
+ self.report_extract_ok = report_extract_ok
+ self.st_pdf_hash = st_pdf_hash
+ self.report_pdf_hash = report_pdf_hash
+ self.st_txt_hash = st_txt_hash
+ self.report_txt_hash = report_txt_hash
+
+ @property
+ def serialized_attributes(self) -> list[str]:
+ return [
+ "st_download_ok",
+ "report_download_ok",
+ "st_convert_garbage",
+ "report_convert_garbage",
+ "st_convert_ok",
+ "report_convert_ok",
+ "st_extract_ok",
+ "report_extract_ok",
+ "st_pdf_hash",
+ "report_pdf_hash",
+ "st_txt_hash",
+ "report_txt_hash",
+ ]
+
+ def report_is_ok_to_download(self, fresh: bool = True) -> bool:
+ return True if fresh else not self.report_download_ok
+
+ def st_is_ok_to_download(self, fresh: bool = True) -> bool:
+ return True if fresh else not self.st_download_ok
+
+ def report_is_ok_to_convert(self, fresh: bool = True) -> bool:
+ return self.report_download_ok if fresh else self.report_download_ok and not self.report_convert_ok
+
+ def st_is_ok_to_convert(self, fresh: bool = True) -> bool:
+ return self.st_download_ok if fresh else self.st_download_ok and not self.st_convert_ok
+
+ def report_is_ok_to_analyze(self, fresh: bool = True) -> bool:
+ if fresh is True:
+ return self.report_download_ok and self.report_convert_ok
+ else:
+ return self.report_download_ok and self.report_convert_ok and not self.report_extract_ok
+
+ def st_is_ok_to_analyze(self, fresh: bool = True) -> bool:
+ if fresh is True:
+ return self.st_download_ok and self.st_convert_ok
+ else:
+ return self.st_download_ok and self.st_convert_ok and not self.st_extract_ok
+
+ @dataclass
+ class PdfData(BasePdfData, ComplexSerializableType):
+ """
+ Class that holds data extracted from pdf files.
+ """
+
+ report_metadata: dict[str, Any] | None = field(default=None)
+ st_metadata: dict[str, Any] | None = field(default=None)
+ report_frontpage: dict[str, dict[str, Any]] | None = field(default=None)
+ st_frontpage: dict[str, dict[str, Any]] | None = field(default=None)
+ report_keywords: dict[str, Any] | None = field(default=None)
+ st_keywords: dict[str, Any] | None = field(default=None)
+ report_filename: str | None = field(default=None)
+ st_filename: str | None = field(default=None)
+
+ def __bool__(self) -> bool:
+ return any([x is not None for x in vars(self)])
+
+ @property
+ def bsi_data(self) -> dict[str, Any] | None:
+ """
+ Returns frontpage data related to BSI-provided information
+ """
+ return self.report_frontpage.get("bsi", None) if self.report_frontpage else None
+
+ @property
+ def niap_data(self) -> dict[str, Any] | None:
+ """
+ Returns frontpage data related to niap-provided information
+ """
+ return self.report_frontpage.get("niap", None) if self.report_frontpage else None
+
+ @property
+ def nscib_data(self) -> dict[str, Any] | None:
+ """
+ Returns frontpage data related to nscib-provided information
+ """
+ return self.report_frontpage.get("nscib", None) if self.report_frontpage else None
+
+ @property
+ def canada_data(self) -> dict[str, Any] | None:
+ """
+ Returns frontpage data related to canada-provided information
+ """
+ return self.report_frontpage.get("canada", None) if self.report_frontpage else None
+
+ @property
+ def anssi_data(self) -> dict[str, Any] | None:
+ """
+ Returns frontpage data related to ANSSI-provided information
+ """
+ return self.report_frontpage.get("anssi", None) if self.report_frontpage else None
+
+ @property
+ def cert_lab(self) -> list[str] | None:
+ """
+ Returns labs for which certificate data was parsed.
+ """
+ labs = [
+ data["cert_lab"].split(" ")[0].upper()
+ for data in [self.bsi_data, self.anssi_data, self.niap_data, self.nscib_data, self.canada_data]
+ if data
+ ]
+ return labs if labs else None
+
+ @property
+ def bsi_cert_id(self) -> str | None:
+ return self.bsi_data.get("cert_id", None) if self.bsi_data else None
+
+ @property
+ def niap_cert_id(self) -> str | None:
+ return self.niap_data.get("cert_id", None) if self.niap_data else None
+
+ @property
+ def nscib_cert_id(self) -> str | None:
+ return self.nscib_data.get("cert_id", None) if self.nscib_data else None
+
+ @property
+ def canada_cert_id(self) -> str | None:
+ return self.canada_data.get("cert_id", None) if self.canada_data else None
+
+ @property
+ def anssi_cert_id(self) -> str | None:
+ return self.anssi_data.get("cert_id", None) if self.anssi_data else None
+
+ def frontpage_cert_id(self, scheme: str) -> dict[str, float]:
+ """
+ Get cert_id candidate from the frontpage of the report.
+ """
+ scheme_map = {
+ "DE": self.bsi_cert_id,
+ "US": self.niap_cert_id,
+ "NL": self.nscib_cert_id,
+ "CA": self.canada_cert_id,
+ "FR": self.anssi_cert_id,
+ }
+ if scheme in scheme_map and (candidate := scheme_map[scheme]):
+ return {candidate: 1.0}
+ return {}
+
+ def filename_cert_id(self, scheme: str) -> dict[str, float]:
+ """
+ Get cert_id candidates from the matches in the report filename.
+ """
+ if not self.report_filename:
+ return {}
+ scheme_rules = rules["cc_cert_id"][scheme]
+ matches: Counter = Counter()
+ for rule in scheme_rules:
+ match = re.search(rule, self.report_filename)
+ if match:
+ cert_id = normalize_match_string(match.group())
+ matches[cert_id] += 1
+ if not matches:
+ return {}
+ total = max(matches.values())
+ results = {}
+ for candidate, count in matches.items():
+ results[candidate] = count / total
+ # TODO count length in weight
+ return results
+
+ def keywords_cert_id(self, scheme: str) -> dict[str, float]:
+ """
+ Get cert_id candidates from the keywords matches in the report.
+ """
+ if not self.report_keywords:
+ return {}
+ cert_id_matches = self.report_keywords.get("cc_cert_id")
+ if not cert_id_matches:
+ return {}
+
+ if scheme not in cert_id_matches:
+ return {}
+ matches: Counter = Counter(cert_id_matches[scheme])
+ if not matches:
+ return {}
+ total = max(matches.values())
+ results = {}
+ for candidate, count in matches.items():
+ results[candidate] = count / total
+ # TODO count length in weight
+ return results
+
+ def metadata_cert_id(self, scheme: str) -> dict[str, float]:
+ """
+ Get cert_id candidates from the report metadata.
+ """
+ scheme_rules = rules["cc_cert_id"][scheme]
+ fields = ("/Title", "/Subject")
+ matches: Counter = Counter()
+ for meta_field in fields:
+ field_val = self.report_metadata.get(meta_field) if self.report_metadata else None
+ if not field_val:
+ continue
+ for rule in scheme_rules:
+ match = re.search(rule, field_val)
+ if match:
+ cert_id = normalize_match_string(match.group())
+ matches[cert_id] += 1
+ if not matches:
+ return {}
+ total = max(matches.values())
+ results = {}
+ for candidate, count in matches.items():
+ results[candidate] = count / total
+ # TODO count length in weight
+ return results
+
+ def candidate_cert_ids(self, scheme: str) -> dict[str, float]:
+ frontpage_id = self.frontpage_cert_id(scheme)
+ metadata_id = self.metadata_cert_id(scheme)
+ filename_id = self.filename_cert_id(scheme)
+ keywords_id = self.keywords_cert_id(scheme)
+
+ # Join them and weigh them, each is normalized with weights from 0 to 1 (if anything is returned)
+ candidates: dict[str, float] = defaultdict(lambda: 0.0)
+ # TODO: Add heuristic based on ordering of ids (and extracted year + increment)
+ # TODO: Add heuristic based on length
+ for candidate, count in frontpage_id.items():
+ candidates[canonicalize(candidate, scheme)] += count * 1.5
+ for candidate, count in metadata_id.items():
+ candidates[canonicalize(candidate, scheme)] += count * 1.2
+ for candidate, count in keywords_id.items():
+ candidates[canonicalize(candidate, scheme)] += count * 1.0
+ for candidate, count in filename_id.items():
+ candidates[canonicalize(candidate, scheme)] += count * 1.0
+ return candidates
+
+ @dataclass
+ class Heuristics(BaseHeuristics, ComplexSerializableType):
+ """
+ Class for various heuristics related to CommonCriteriaCert
+ """
+
+ extracted_versions: set[str] | None = field(default=None)
+ cpe_matches: set[str] | None = field(default=None)
+ verified_cpe_matches: set[str] | None = field(default=None)
+ related_cves: set[str] | None = field(default=None)
+ cert_lab: list[str] | None = field(default=None)
+ cert_id: str | None = field(default=None)
+ st_references: References = field(default_factory=References)
+ report_references: References = field(default_factory=References)
+ extracted_sars: set[SAR] | None = field(default=None)
+ direct_transitive_cves: set[str] | None = field(default=None)
+ indirect_transitive_cves: set[str] | None = field(default=None)
+
+ @property
+ def serialized_attributes(self) -> list[str]:
+ return copy.deepcopy(super().serialized_attributes)
+
+ pandas_columns: ClassVar[list[str]] = [
+ "dgst",
+ "cert_id",
+ "name",
+ "status",
+ "category",
+ "manufacturer",
+ "scheme",
+ "security_level",
+ "eal",
+ "not_valid_before",
+ "not_valid_after",
+ "report_link",
+ "st_link",
+ "cert_link",
+ "manufacturer_web",
+ "extracted_versions",
+ "cpe_matches",
+ "verified_cpe_matches",
+ "related_cves",
+ "directly_referenced_by",
+ "indirectly_referenced_by",
+ "directly_referencing",
+ "indirectly_referencing",
+ "extracted_sars",
+ "protection_profiles",
+ "cert_lab",
+ ]
+
+ def __init__(
+ self,
+ status: str,
+ category: str,
+ name: str,
+ manufacturer: str | None,
+ scheme: str,
+ security_level: str | set[str],
+ not_valid_before: date | None,
+ not_valid_after: date | None,
+ report_link: str,
+ st_link: str,
+ cert_link: str | None,
+ manufacturer_web: str | None,
+ protection_profiles: set[ProtectionProfile] | None,
+ maintenance_updates: set[MaintenanceReport] | None,
+ state: InternalState | None,
+ pdf_data: PdfData | None,
+ heuristics: Heuristics | None,
+ ):
+ super().__init__()
+
+ self.status = status
+ self.category = category
+ self.name = sec_certs.utils.sanitization.sanitize_string(name)
+
+ self.manufacturer = None
+ if manufacturer:
+ self.manufacturer = sec_certs.utils.sanitization.sanitize_string(manufacturer)
+
+ self.scheme = scheme
+ self.security_level = sec_certs.utils.sanitization.sanitize_security_levels(security_level)
+ self.not_valid_before = sec_certs.utils.sanitization.sanitize_date(not_valid_before)
+ self.not_valid_after = sec_certs.utils.sanitization.sanitize_date(not_valid_after)
+ self.report_link = sec_certs.utils.sanitization.sanitize_link(report_link)
+ self.st_link = sec_certs.utils.sanitization.sanitize_link(st_link)
+ self.cert_link = sec_certs.utils.sanitization.sanitize_link(cert_link)
+ self.manufacturer_web = sec_certs.utils.sanitization.sanitize_link(manufacturer_web)
+ self.protection_profiles = protection_profiles
+ self.maintenance_updates = maintenance_updates
+ self.state = self.InternalState() if not state else state
+ self.pdf_data = self.PdfData() if not pdf_data else pdf_data
+ self.heuristics: CommonCriteriaCert.Heuristics = self.Heuristics() if not heuristics else heuristics
+
+ @property
+ def dgst(self) -> str:
+ """
+ Computes the primary key of the sample using first 16 bytes of SHA-256 digest
+ """
+ if not (self.name is not None and self.report_link is not None and self.category is not None):
+ raise RuntimeError("Certificate digest can't be computed, because information is missing.")
+ 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
+ :return Optional[Set[SAR]]: Set of actual SARs of a certificate, None if empty
+ """
+ sars = dict()
+ if self.eal:
+ sars = {x[0]: SAR(x[0], x[1]) for x in SARS_IMPLIED_FROM_EAL[self.eal[:4]]}
+
+ if self.heuristics.extracted_sars:
+ for sar in self.heuristics.extracted_sars:
+ if sar not in sars or sar.level > sars[sar.family].level:
+ sars[sar.family] = sar
+
+ return set(sars.values()) if sars else None
+
+ @property
+ def label_studio_title(self) -> str | None:
+ return self.name
+
+ @property
+ def pandas_tuple(self) -> tuple:
+ """
+ Returns tuple of attributes meant for pandas serialization
+ """
+ return (
+ self.dgst,
+ self.heuristics.cert_id,
+ self.name,
+ self.status,
+ self.category,
+ self.manufacturer,
+ self.scheme,
+ self.security_level,
+ self.eal,
+ self.not_valid_before,
+ self.not_valid_after,
+ self.report_link,
+ self.st_link,
+ self.cert_link,
+ self.manufacturer_web,
+ self.heuristics.extracted_versions,
+ self.heuristics.cpe_matches,
+ self.heuristics.verified_cpe_matches,
+ self.heuristics.related_cves,
+ self.heuristics.report_references.directly_referenced_by,
+ self.heuristics.report_references.indirectly_referenced_by,
+ 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.heuristics.cert_lab[0] if (self.heuristics.cert_lab and self.heuristics.cert_lab[0]) else np.nan,
+ )
+
+ def __str__(self) -> str:
+ printed_manufacturer = self.manufacturer if self.manufacturer else "Unknown manufacturer"
+ return str(printed_manufacturer) + " " + str(self.name) + " dgst: " + self.dgst
+
+ def merge(self, other: CommonCriteriaCert, other_source: str | None = None) -> None:
+ """
+ Merges with other CC sample. Assuming they come from different sources, e.g., csv and html.
+ Assuming that html source has better protection profiles, they overwrite CSV info
+ On other values the sanity checks are made.
+ """
+ if self != other:
+ logger.warning(
+ f"Attempting to merge divergent certificates: self[dgst]={self.dgst}, other[dgst]={other.dgst}"
+ )
+
+ for att, val in vars(self).items():
+ if not val:
+ setattr(self, att, getattr(other, att))
+ elif other_source == "html" and att == "protection_profiles":
+ setattr(self, att, getattr(other, att))
+ elif other_source == "html" and att == "maintenance_updates":
+ setattr(self, att, getattr(other, att))
+ elif att == "state":
+ setattr(self, att, getattr(other, att))
+ else:
+ if getattr(self, att) != getattr(other, att):
+ logger.warning(
+ f"When merging certificates with dgst {self.dgst}, the following mismatch occured: Attribute={att}, self[{att}]={getattr(self, att)}, other[{att}]={getattr(other, att)}"
+ )
+
+ @classmethod
+ def from_dict(cls, dct: dict) -> CommonCriteriaCert:
+ """
+ Deserializes dictionary into `CommonCriteriaCert`
+ """
+ new_dct = dct.copy()
+ new_dct["maintenance_updates"] = set(dct["maintenance_updates"])
+ new_dct["protection_profiles"] = set(dct["protection_profiles"])
+ new_dct["not_valid_before"] = (
+ date.fromisoformat(dct["not_valid_before"])
+ if isinstance(dct["not_valid_before"], str)
+ else dct["not_valid_before"]
+ )
+ new_dct["not_valid_after"] = (
+ date.fromisoformat(dct["not_valid_after"])
+ if isinstance(dct["not_valid_after"], str)
+ else dct["not_valid_after"]
+ )
+ return super(cls, CommonCriteriaCert).from_dict(new_dct)
+
+ @staticmethod
+ def _html_row_get_name(cell: Tag) -> str:
+ return list(cell.stripped_strings)[0]
+
+ @staticmethod
+ def _html_row_get_manufacturer(cell: Tag) -> str | None:
+ if lst := list(cell.stripped_strings):
+ return lst[0]
+ else:
+ return None
+
+ @staticmethod
+ def _html_row_get_scheme(cell: Tag) -> str:
+ return list(cell.stripped_strings)[0]
+
+ @staticmethod
+ def _html_row_get_security_level(cell: Tag) -> set:
+ return set(cell.stripped_strings)
+
+ @staticmethod
+ def _html_row_get_manufacturer_web(cell: Tag) -> str | None:
+ for link in cell.find_all("a"):
+ if link is not None and link.get("title") == "Vendor's web site" and link.get("href") != "http://":
+ return link.get("href")
+ return None
+
+ @staticmethod
+ def _html_row_get_protection_profiles(cell: Tag) -> set:
+ protection_profiles = 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=CommonCriteriaCert.cc_url + link.get("href")
+ )
+ )
+ return protection_profiles
+
+ @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_report_st_links(cell: Tag) -> tuple[str, str]:
+ links = cell.find_all("a")
+ assert links[1].get("title").startswith("Certification Report")
+ assert links[2].get("title").startswith("Security Target")
+
+ report_link = CommonCriteriaCert.cc_url + links[1].get("href")
+ security_target_link = CommonCriteriaCert.cc_url + links[2].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 CommonCriteriaCert.cc_url + links[0].get("href") if links else None
+
+ @staticmethod
+ 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
+
+ @staticmethod
+ def _html_row_get_maintenance_updates(main_div: Tag) -> set[CommonCriteriaCert.MaintenanceReport]:
+ 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 = CommonCriteriaCert.cc_url + link.get("href")
+ elif link.get("title").startswith("Maintenance ST"):
+ main_st_link = CommonCriteriaCert.cc_url + link.get("href")
+ else:
+ logger.error("Unknown link in Maintenance part!")
+ maintenance_updates.add(
+ CommonCriteriaCert.MaintenanceReport(main_date, main_title, main_report_link, main_st_link)
+ )
+ return maintenance_updates
+
+ @classmethod
+ def from_html_row(cls, row: Tag, status: str, category: str) -> CommonCriteriaCert:
+ """
+ Creates a CC sample from html row of commoncriteria.org webpage.
+ """
+
+ cells = list(row.find_all("td"))
+ if len(cells) != 7:
+ raise ValueError(f"Unexpected number of <td> elements in CC html row. Expected: 7, actual: {len(cells)}")
+
+ name = CommonCriteriaCert._html_row_get_name(cells[0])
+ manufacturer = CommonCriteriaCert._html_row_get_manufacturer(cells[1])
+ manufacturer_web = CommonCriteriaCert._html_row_get_manufacturer_web(cells[1])
+ scheme = CommonCriteriaCert._html_row_get_scheme(cells[6])
+ security_level = CommonCriteriaCert._html_row_get_security_level(cells[5])
+ protection_profiles = CommonCriteriaCert._html_row_get_protection_profiles(cells[0])
+ not_valid_before = CommonCriteriaCert._html_row_get_date(cells[3])
+ not_valid_after = CommonCriteriaCert._html_row_get_date(cells[4])
+ report_link, st_link = CommonCriteriaCert._html_row_get_report_st_links(cells[0])
+ cert_link = CommonCriteriaCert._html_row_get_cert_link(cells[2])
+ maintenance_div = CommonCriteriaCert._html_row_get_maintenance_div(cells[0])
+ maintenances = (
+ CommonCriteriaCert._html_row_get_maintenance_updates(maintenance_div) if maintenance_div else set()
+ )
+
+ return cls(
+ status,
+ category,
+ name,
+ manufacturer,
+ scheme,
+ security_level,
+ not_valid_before,
+ not_valid_after,
+ report_link,
+ st_link,
+ cert_link,
+ manufacturer_web,
+ protection_profiles,
+ maintenances,
+ None,
+ None,
+ None,
+ )
+
+ def set_local_paths(
+ self,
+ report_pdf_dir: str | Path | None,
+ st_pdf_dir: str | Path | None,
+ report_txt_dir: str | Path | None,
+ st_txt_dir: str | Path | None,
+ ) -> None:
+ """
+ Sets paths to files given the requested directories
+
+ :param Optional[Union[str, Path]] report_pdf_dir: Directory where pdf reports shall be stored
+ :param Optional[Union[str, Path]] st_pdf_dir: Directory where pdf security targets shall be stored
+ :param Optional[Union[str, Path]] report_txt_dir: Directory where txt reports shall be stored
+ :param Optional[Union[str, Path]] st_txt_dir: Directory where txt security targets shall be stored
+ """
+ if report_pdf_dir is not None:
+ self.state.report_pdf_path = Path(report_pdf_dir) / (self.dgst + ".pdf")
+ if st_pdf_dir is not None:
+ self.state.st_pdf_path = Path(st_pdf_dir) / (self.dgst + ".pdf")
+ if report_txt_dir is not None:
+ self.state.report_txt_path = Path(report_txt_dir) / (self.dgst + ".txt")
+ if st_txt_dir is not None:
+ self.state.st_txt_path = Path(st_txt_dir) / (self.dgst + ".txt")
+
+ @staticmethod
+ def download_pdf_report(cert: CommonCriteriaCert) -> CommonCriteriaCert:
+ """
+ Downloads pdf of certification report given the certificate. Staticmethod to allow for parallelization.
+
+ :param CommonCriteriaCert cert: cert to download the pdf report for
+ :return CommonCriteriaCert: returns the modified certificate with updated state
+ """
+ exit_code: str | int
+ if not cert.report_link:
+ exit_code = "No link"
+ else:
+ exit_code = helpers.download_file(cert.report_link, cert.state.report_pdf_path)
+ if exit_code != requests.codes.ok:
+ error_msg = f"failed to download report from {cert.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.report_link).path).split("/")[-1])
+ return cert
+
+ @staticmethod
+ def download_pdf_st(cert: CommonCriteriaCert) -> CommonCriteriaCert:
+ """
+ Downloads pdf of security target given the certificate. Staticmethod to allow for parallelization.
+
+ :param CommonCriteriaCert cert: cert to download the pdf security target for
+ :return CommonCriteriaCert: returns the modified certificate with updated state
+ """
+ exit_code: str | int
+ if not cert.st_link:
+ exit_code = "No link"
+ else:
+ exit_code = helpers.download_file(cert.st_link, cert.state.st_pdf_path)
+ if exit_code != requests.codes.ok:
+ error_msg = f"failed to download ST from {cert.st_link}, code: {exit_code}"
+ logger.error(f"Cert dgst: {cert.dgst} " + error_msg)
+ cert.state.st_download_ok = False
+ else:
+ cert.state.st_download_ok = True
+ cert.state.st_pdf_hash = helpers.get_sha256_filepath(cert.state.st_pdf_path)
+ cert.pdf_data.st_filename = unquote_plus(str(urlparse(cert.st_link).path).split("/")[-1])
+ return cert
+
+ @staticmethod
+ def convert_report_pdf(cert: CommonCriteriaCert) -> CommonCriteriaCert:
+ """
+ Converts the pdf certification report to txt, given the certificate. Staticmethod to allow for parallelization.
+
+ :param CommonCriteriaCert cert: cert to download the pdf report for
+ :return CommonCriteriaCert: the modified certificate with updated state
+ """
+ ocr_done, ok_result = sec_certs.utils.pdf.convert_pdf_file(
+ cert.state.report_pdf_path, cert.state.report_txt_path
+ )
+ # If OCR was done the result was garbage
+ cert.state.report_convert_garbage = ocr_done
+ # And put the whole result into convert_ok
+ cert.state.report_convert_ok = ok_result
+ if not ok_result:
+ error_msg = "failed to convert report pdf->txt"
+ logger.error(f"Cert dgst: {cert.dgst} " + error_msg)
+ else:
+ cert.state.report_txt_hash = helpers.get_sha256_filepath(cert.state.report_txt_path)
+ return cert
+
+ @staticmethod
+ def convert_st_pdf(cert: CommonCriteriaCert) -> CommonCriteriaCert:
+ """
+ Converts the pdf security target to txt, given the certificate. Staticmethod to allow for parallelization.
+
+ :param CommonCriteriaCert cert: cert to download the pdf security target for
+ :return CommonCriteriaCert: the modified certificate with updated state
+ """
+ ocr_done, ok_result = sec_certs.utils.pdf.convert_pdf_file(cert.state.st_pdf_path, cert.state.st_txt_path)
+ # If OCR was done the result was garbage
+ cert.state.st_convert_garbage = ocr_done
+ # And put the whole result into convert_ok
+ cert.state.st_convert_ok = ok_result
+ if not ok_result:
+ error_msg = "failed to convert security target pdf->txt"
+ logger.error(f"Cert dgst: {cert.dgst} " + error_msg)
+ else:
+ cert.state.st_txt_hash = helpers.get_sha256_filepath(cert.state.st_txt_path)
+ return cert
+
+ @staticmethod
+ def extract_st_pdf_metadata(cert: CommonCriteriaCert) -> CommonCriteriaCert:
+ """
+ Extracts metadata from security target pdf given the certificate. Staticmethod to allow for parallelization.
+
+ :param CommonCriteriaCert cert: cert to extract the metadata for.
+ :return CommonCriteriaCert: the modified certificate with updated state
+ """
+ response, cert.pdf_data.st_metadata = sec_certs.utils.pdf.extract_pdf_metadata(cert.state.st_pdf_path)
+ if response != constants.RETURNCODE_OK:
+ cert.state.st_extract_ok = False
+ else:
+ cert.state.st_extract_ok = True
+ return cert
+
+ @staticmethod
+ def extract_report_pdf_metadata(cert: CommonCriteriaCert) -> CommonCriteriaCert:
+ """
+ Extracts metadata from certification report pdf given the certificate. Staticmethod to allow for parallelization.
+
+ :param CommonCriteriaCert cert: cert to extract the metadata for.
+ :return CommonCriteriaCert: the modified certificate with updated state
+ """
+ response, cert.pdf_data.report_metadata = sec_certs.utils.pdf.extract_pdf_metadata(cert.state.report_pdf_path)
+ if response != constants.RETURNCODE_OK:
+ cert.state.report_extract_ok = False
+ else:
+ cert.state.report_extract_ok = True
+ return cert
+
+ @staticmethod
+ def extract_st_pdf_frontpage(cert: CommonCriteriaCert) -> CommonCriteriaCert:
+ """
+ Extracts data from security target pdf frontpage given the certificate. Staticmethod to allow for parallelization.
+
+ :param CommonCriteriaCert cert: cert to extract the frontpage data for.
+ :return CommonCriteriaCert: the modified certificate with updated state
+ """
+ cert.pdf_data.st_frontpage = {}
+
+ for header_type, associated_header_func in HEADERS.items():
+ response, cert.pdf_data.st_frontpage[header_type] = associated_header_func(cert.state.st_txt_path)
+
+ if response != constants.RETURNCODE_OK:
+ cert.state.st_extract_ok = False
+ return cert
+
+ @staticmethod
+ def extract_report_pdf_frontpage(cert: CommonCriteriaCert) -> CommonCriteriaCert:
+ """
+ Extracts data from certification report pdf frontpage given the certificate. Staticmethod to allow for parallelization.
+
+ :param CommonCriteriaCert cert: cert to extract the frontpage data for.
+ :return CommonCriteriaCert: the modified certificate with updated state
+ """
+ cert.pdf_data.report_frontpage = {}
+
+ for header_type, associated_header_func in HEADERS.items():
+ response, cert.pdf_data.report_frontpage[header_type] = associated_header_func(cert.state.report_txt_path)
+
+ if response != constants.RETURNCODE_OK:
+ cert.state.report_extract_ok = False
+ return cert
+
+ @staticmethod
+ def extract_report_pdf_keywords(cert: CommonCriteriaCert) -> CommonCriteriaCert:
+ """
+ Matches regular expresions in txt obtained from certification report and extracts the matches into attribute.
+ Static method to allow for parallelization
+
+ :param CommonCriteriaCert cert: certificate to extract the keywords for.
+ :return CommonCriteriaCert: the modified certificate with extracted keywords.
+ """
+ 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
+
+ @staticmethod
+ def extract_st_pdf_keywords(cert: CommonCriteriaCert) -> CommonCriteriaCert:
+ """
+ Matches regular expresions in txt obtained from security target and extracts the matches into attribute.
+ Static method to allow for parallelization
+
+ :param CommonCriteriaCert cert: certificate to extract the keywords for.
+ :return CommonCriteriaCert: the modified certificate with extracted keywords.
+ """
+ st_keywords = sec_certs.utils.extract.extract_keywords(cert.state.st_txt_path, cc_rules)
+ if st_keywords is None:
+ cert.state.st_extract_ok = False
+ else:
+ cert.pdf_data.st_keywords = st_keywords
+ return cert
+
+ def compute_heuristics_version(self) -> None:
+ """
+ Fills in the heuristically obtained version of certified product into attribute in heuristics class.
+ """
+ self.heuristics.extracted_versions = helpers.compute_heuristics_version(self.name) if self.name else set()
+
+ def compute_heuristics_cert_lab(self) -> None:
+ """
+ Fills in the heuristically obtained evaluation laboratory into attribute in heuristics class.
+ """
+ if not self.pdf_data:
+ logger.error("Cannot compute sample lab when pdf files were not processed.")
+ return
+ self.heuristics.cert_lab = self.pdf_data.cert_lab
+
+ def compute_heuristics_cert_id(self):
+ """
+ Compute the heuristics cert_id of this cert, using several methods.
+
+ The candidate cert_ids are extracted from the frontpage, PDF metadata, filename, and keywords matches.
+
+ Finally, the cert_id is canonicalized.
+ """
+ if not self.pdf_data:
+ logger.warning("Cannot compute sample id when pdf files were not processed.")
+ return
+ # Extract candidate cert_ids
+ candidates = self.pdf_data.candidate_cert_ids(self.scheme)
+
+ if candidates:
+ max_weight = max(candidates.values())
+ max_candidates = list(filter(lambda x: candidates[x] == max_weight, candidates.keys()))
+ max_candidates.sort(key=len, reverse=True)
+ self.heuristics.cert_id = max_candidates[0]
diff --git a/src/sec_certs/sample/cpe.py b/src/sec_certs/sample/cpe.py
new file mode 100644
index 00000000..a7532f7f
--- /dev/null
+++ b/src/sec_certs/sample/cpe.py
@@ -0,0 +1,100 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from functools import lru_cache
+from typing import Any, ClassVar
+
+from sec_certs import constants
+from sec_certs.serialization.json import ComplexSerializableType
+from sec_certs.serialization.pandas import PandasSerializableType
+from sec_certs.utils import helpers
+
+
+@dataclass(init=False)
+class CPE(PandasSerializableType, ComplexSerializableType):
+ uri: str
+ version: str
+ vendor: str
+ item_name: str
+ title: str | None
+ start_version: tuple[str, str] | None
+ end_version: tuple[str, str] | None
+
+ __slots__ = ["uri", "version", "vendor", "item_name", "title", "start_version", "end_version"]
+
+ pandas_columns: ClassVar[list[str]] = [
+ "uri",
+ "vendor",
+ "item_name",
+ "version",
+ "title",
+ ]
+
+ def __init__(
+ self,
+ uri: str,
+ title: str | None = None,
+ start_version: tuple[str, str] | None = None,
+ end_version: tuple[str, str] | None = None,
+ ):
+ super().__init__()
+ self.uri = uri
+
+ splitted = helpers.split_unescape(self.uri, ":")
+ self.vendor = " ".join(splitted[3].split("_"))
+ self.item_name = " ".join(splitted[4].split("_"))
+ self.version = self.normalize_version(" ".join(splitted[5].split("_")))
+ self.title = title
+ self.start_version = start_version
+ self.end_version = end_version
+
+ def __lt__(self, other: CPE) -> bool:
+ return self.uri < other.uri
+
+ @staticmethod
+ def normalize_version(version: str) -> str:
+ """
+ Maps common empty versions (empty '', asterisk '*') to unified empty version (constants.CPE_VERSION_NA)
+ """
+ if version in {"", "*"}:
+ return constants.CPE_VERSION_NA
+ return version
+
+ @classmethod
+ def from_dict(cls, dct: dict[str, Any]) -> CPE:
+ if isinstance(dct["start_version"], list):
+ dct["start_version"] = tuple(dct["start_version"])
+ if isinstance(dct["end_version"], list):
+ dct["end_version"] = tuple(dct["end_version"])
+ return super().from_dict(dct)
+
+ @property
+ def serialized_attributes(self) -> list[str]:
+ return ["uri", "title", "start_version", "end_version"]
+
+ @property
+ def update(self) -> str:
+ if self.uri is None:
+ raise RuntimeError("URI is missing.")
+ return " ".join(self.uri.split(":")[6].split("_"))
+
+ @property
+ def target_hw(self) -> str:
+ if self.uri is None:
+ raise RuntimeError("URI is missing.")
+ return " ".join(self.uri.split(":")[10].split("_"))
+
+ @property
+ def pandas_tuple(self) -> tuple:
+ return self.uri, self.vendor, self.item_name, self.version, self.title
+
+ def __hash__(self) -> int:
+ return hash((self.uri, self.start_version, self.end_version))
+
+ def __eq__(self, other: object) -> bool:
+ return isinstance(other, self.__class__) and self.uri == other.uri
+
+
+@lru_cache(maxsize=4096)
+def cached_cpe(*args, **kwargs):
+ return CPE(*args, **kwargs)
diff --git a/src/sec_certs/sample/cve.py b/src/sec_certs/sample/cve.py
new file mode 100644
index 00000000..ec024d35
--- /dev/null
+++ b/src/sec_certs/sample/cve.py
@@ -0,0 +1,185 @@
+from __future__ import annotations
+
+import datetime
+import itertools
+from dataclasses import dataclass
+from typing import Any, ClassVar
+
+from dateutil.parser import isoparse
+
+from sec_certs.sample.cpe import CPE, cached_cpe
+from sec_certs.serialization.json import ComplexSerializableType
+from sec_certs.serialization.pandas import PandasSerializableType
+
+
+@dataclass(init=False)
+class CVE(PandasSerializableType, ComplexSerializableType):
+ @dataclass(eq=True)
+ class Impact(ComplexSerializableType):
+ base_score: float
+ severity: str
+ exploitability_score: float
+ impact_score: float
+
+ __slots__ = ["base_score", "severity", "exploitability_score", "impact_score"]
+
+ @classmethod
+ def from_nist_dict(cls, dct: dict[str, Any]) -> CVE.Impact:
+ """
+ Will load Impact from dictionary defined at https://nvd.nist.gov/feeds/json/cve/1.1
+ """
+ if not dct["impact"]:
+ return cls(0, "", 0, 0)
+ elif "baseMetricV3" in dct["impact"]:
+ return cls(
+ dct["impact"]["baseMetricV3"]["cvssV3"]["baseScore"],
+ dct["impact"]["baseMetricV3"]["cvssV3"]["baseSeverity"],
+ dct["impact"]["baseMetricV3"]["exploitabilityScore"],
+ dct["impact"]["baseMetricV3"]["impactScore"],
+ )
+ elif "baseMetricV2" in dct["impact"]:
+ return cls(
+ dct["impact"]["baseMetricV2"]["cvssV2"]["baseScore"],
+ dct["impact"]["baseMetricV2"]["severity"],
+ dct["impact"]["baseMetricV2"]["exploitabilityScore"],
+ dct["impact"]["baseMetricV2"]["impactScore"],
+ )
+ raise ValueError("NIST Dict for CVE Impact badly formatted.")
+
+ cve_id: str
+ vulnerable_cpes: list[CPE]
+ impact: Impact
+ published_date: datetime.datetime | None
+ cwe_ids: set[str] | None
+
+ __slots__ = ["cve_id", "vulnerable_cpes", "impact", "published_date", "cwe_ids"]
+
+ pandas_columns: ClassVar[list[str]] = [
+ "cve_id",
+ "vulnerable_cpes",
+ "base_score",
+ "severity",
+ "explotability_score",
+ "impact_score",
+ "published_date",
+ "cwe_ids",
+ ]
+
+ def __init__(
+ self, cve_id: str, vulnerable_cpes: list[CPE], impact: Impact, published_date: str, cwe_ids: set[str] | None
+ ):
+ super().__init__()
+ self.cve_id = cve_id
+ self.vulnerable_cpes = vulnerable_cpes
+ self.impact = impact
+ self.published_date = isoparse(published_date)
+ self.cwe_ids = cwe_ids
+
+ def __hash__(self) -> int:
+ return hash(self.cve_id)
+
+ def __eq__(self, other: object) -> bool:
+ if not isinstance(other, CVE):
+ return False
+ return self.cve_id == other.cve_id
+
+ def __lt__(self, other: object) -> bool:
+ if not isinstance(other, CVE):
+ raise ValueError(f"Cannot compare CVE with {type(other)} type.")
+ self_year = int(self.cve_id.split("-")[1])
+ self_id = int(self.cve_id.split("-")[2])
+ other_year = int(other.cve_id.split("-")[1])
+ other_id = int(other.cve_id.split("-")[2])
+
+ return self_year < other_year if self_year != other_year else self_id < other_id
+
+ @property
+ def pandas_tuple(self):
+ return (
+ self.cve_id,
+ self.vulnerable_cpes,
+ self.impact.base_score,
+ self.impact.severity,
+ self.impact.exploitability_score,
+ self.impact.impact_score,
+ self.published_date,
+ self.cwe_ids,
+ )
+
+ def to_dict(self) -> dict[str, Any]:
+ return {
+ "cve_id": self.cve_id,
+ "vulnerable_cpes": self.vulnerable_cpes,
+ "impact": self.impact,
+ "published_date": self.published_date.isoformat() if self.published_date else None,
+ "cwe_ids": self.cwe_ids,
+ }
+
+ @staticmethod
+ def _parse_nist_dict(lst: list) -> list[CPE]:
+ cpes: list[CPE] = []
+
+ for x in lst:
+ if x["vulnerable"]:
+ cpe_uri = x["cpe23Uri"]
+ version_start: tuple[str, str] | None
+ version_end: tuple[str, str] | None
+ if "versionStartIncluding" in x and x["versionStartIncluding"]:
+ version_start = ("including", x["versionStartIncluding"])
+ elif "versionStartExcluding" in x and x["versionStartExcluding"]:
+ version_start = ("excluding", x["versionStartExcluding"])
+ else:
+ version_start = None
+
+ if "versionEndIncluding" in x and x["versionEndIncluding"]:
+ version_end = ("including", x["versionEndIncluding"])
+ elif "versionEndExcluding" in x and x["versionEndExcluding"]:
+ version_end = ("excluding", x["versionEndExcluding"])
+ else:
+ version_end = None
+
+ cpes.append(cached_cpe(cpe_uri, start_version=version_start, end_version=version_end))
+
+ return cpes
+
+ @classmethod
+ def from_nist_dict(cls, dct: dict) -> CVE:
+ """
+ Will load CVE from dictionary defined at https://nvd.nist.gov/feeds/json/cve/1.1
+ """
+
+ def get_vulnerable_cpes_from_nist_dict(dct: dict) -> list[CPE]:
+ def get_vulnerable_cpes_from_node(node: dict) -> list[CPE]:
+ cpes: list[CPE] = []
+
+ if node["operator"] == "AND":
+ return cpes
+
+ if "children" in node:
+ for child in node["children"]:
+ cpes += get_vulnerable_cpes_from_node(child)
+
+ if "cpe_match" not in node:
+ return cpes
+
+ candidates = node["cpe_match"]
+ cpes += CVE._parse_nist_dict(candidates)
+
+ return cpes
+
+ return list(
+ itertools.chain.from_iterable(get_vulnerable_cpes_from_node(x) for x in dct["configurations"]["nodes"])
+ )
+
+ cve_id = dct["cve"]["CVE_data_meta"]["ID"]
+ impact = cls.Impact.from_nist_dict(dct)
+ vulnerable_cpes = get_vulnerable_cpes_from_nist_dict(dct)
+ published_date = dct["publishedDate"]
+ cwe_ids = cls.parse_cwe_data(dct)
+
+ return cls(cve_id, vulnerable_cpes, impact, published_date, cwe_ids)
+
+ @staticmethod
+ def parse_cwe_data(dct: dict) -> set[str] | None:
+ descriptions = dct["cve"]["problemtype"]["problemtype_data"][0]["description"]
+ return {x["value"] for x in descriptions} if descriptions else None
diff --git a/src/sec_certs/sample/fips.py b/src/sec_certs/sample/fips.py
new file mode 100644
index 00000000..aec791d4
--- /dev/null
+++ b/src/sec_certs/sample/fips.py
@@ -0,0 +1,654 @@
+from __future__ import annotations
+
+import itertools
+import re
+from dataclasses import dataclass, field
+from datetime import date
+from pathlib import Path
+from typing import Any, Callable, ClassVar, Final, Literal
+
+import dateutil
+import numpy as np
+import pandas as pd
+import requests
+from bs4 import BeautifulSoup, Tag
+from tabula import read_pdf
+
+import sec_certs.constants as constants
+import sec_certs.utils.extract
+import sec_certs.utils.helpers as helpers
+import sec_certs.utils.pdf
+import sec_certs.utils.pdf as pdf
+import sec_certs.utils.tables as tables
+from sec_certs.cert_rules import FIPS_ALGS_IN_TABLE, fips_rules
+from sec_certs.config.configuration import config
+from sec_certs.sample.certificate import Certificate
+from sec_certs.sample.certificate import Heuristics as BaseHeuristics
+from sec_certs.sample.certificate import PdfData as BasePdfData
+from sec_certs.sample.certificate import References, logger
+from sec_certs.sample.cpe import CPE
+from sec_certs.serialization.json import ComplexSerializableType
+from sec_certs.serialization.pandas import PandasSerializableType
+from sec_certs.utils.helpers import fips_dgst
+
+
+class FIPSHTMLParser:
+ def __init__(self, soup: BeautifulSoup):
+ self._soup = soup
+
+ def get_web_data_and_algorithms(self) -> tuple[set[str], FIPSCertificate.WebData]:
+ divs = self._soup.find_all("div", class_="panel panel-default")
+ details_div, vendor_div, related_files_div, validation_history_div = divs
+ details_dict = self._build_details_dict(details_div)
+
+ vendor_dict = self._build_vendor_dict(vendor_div)
+ related_files_dict = self._build_related_files_dict(related_files_div)
+ validation_history_dict = self._build_validation_history_dict(validation_history_div)
+
+ algorithms = set()
+ if "algorithms" in details_dict:
+ algorithms_data = details_dict.pop("algorithms")
+ for category, alg_ids in algorithms_data.items():
+ algorithms |= {category + x for x in alg_ids}
+
+ return algorithms, FIPSCertificate.WebData(
+ **{**details_dict, **vendor_dict, **related_files_dict, **validation_history_dict}
+ )
+
+ def _build_details_dict(self, details_div: Tag) -> dict[str, Any]:
+ def parse_single_detail_entry(key, entry):
+ normalized_key = DETAILS_KEY_NORMALIZATION_DICT[key]
+ normalization_func = DETAILS_KEY_TO_NORMALIZATION_FUNCTION.get(normalized_key, None)
+ normalized_entry = (
+ FIPSHTMLParser.normalize_string(entry.text) if not normalization_func else normalization_func(entry)
+ )
+ return normalized_key, normalized_entry
+
+ entries = details_div.find_all("div", class_="row padrow")
+ entries = zip(
+ [x.find("div", class_="col-md-3") for x in entries], [x.find("div", class_="col-md-9") for x in entries]
+ )
+ entries = [(FIPSHTMLParser.normalize_string(key.text), entry) for key, entry in entries]
+ entries = [parse_single_detail_entry(*x) for x in entries if x[0] in DETAILS_KEY_NORMALIZATION_DICT.keys()]
+ entries = {x: y for x, y in entries}
+
+ if "caveat" in entries:
+ entries["mentioned_certs"] = FIPSHTMLParser.get_mentioned_certs_from_caveat(entries["caveat"])
+
+ # Temporarily disabled, as this isn't extracting anything useful. Only UNKNOWN#1-9 algs were extracted over whole dataset.
+ # if "description" in entries:
+ # algs = FIPSHTMLParser.get_algs_from_description(entries["description"])
+ # if "algorithms" in entries:
+ # entries["algorithms"].update({"UNKNOWN": x for x in algs})
+ # else:
+ # entries["algorithms"] = {"UNKNOWN": x for x in algs}
+
+ return entries
+
+ @staticmethod
+ def _build_vendor_dict(vendor_div: Tag) -> dict[str, Any]:
+ if not (link := vendor_div.find("a")):
+ return {"vendor_url": None, "vendor": list(vendor_div.find("div", "panel-body").children)[0].strip()}
+ else:
+ return {"vendor_url": link.get("href"), "vendor": link.text.strip()}
+
+ @staticmethod
+ def _build_related_files_dict(related_files_div: Tag) -> dict[str, Any]:
+ if cert_link := [x for x in related_files_div.find_all("a") if "Certificate" in x.text]:
+ return {"certificate_pdf_url": constants.FIPS_BASE_URL + cert_link[0].get("href")}
+ else:
+ return {"certificate_pdf_url": None}
+
+ @staticmethod
+ def _build_validation_history_dict(validation_history_div: Tag) -> dict[str, Any]:
+ def parse_row(row):
+ validation_date, validation_type, lab = row.find_all("td")
+ return FIPSCertificate.ValidationHistoryEntry(
+ dateutil.parser.parse(validation_date.text).date(), validation_type.text, lab.text
+ )
+
+ rows = validation_history_div.find("tbody").find_all("tr")
+ history: list[FIPSCertificate.ValidationHistoryEntry] | None = [parse_row(x) for x in rows] if rows else None
+ return {"validation_history": history}
+
+ @staticmethod
+ def get_mentioned_certs_from_caveat(caveat: str) -> dict[str, int]:
+ ids_found: dict[str, int] = {}
+ r_key = r"(?P<word>\w+)?\s?(?:#\s?|Cert\.?(?!.\s)\s?|Certificate\s?)+(?P<id>\d+)"
+ for m in re.finditer(r_key, caveat):
+ if m.group("word") and m.group("word").lower() in {"rsa", "shs", "dsa", "pkcs", "aes"}:
+ continue
+ if m.group("id") in ids_found:
+ ids_found[m.group("id")] += 1
+ else:
+ ids_found[m.group("id")] = 1
+ return ids_found
+
+ @staticmethod
+ def get_algs_from_description(description: str) -> set[str]:
+ return {m.group() for m in re.finditer(FIPS_ALGS_IN_TABLE, description)}
+
+ @staticmethod
+ def parse_algorithms(algorithms_div: Tag) -> dict[str, set[str]]:
+ rows = algorithms_div.find("tbody").find_all("tr")
+ dct: dict[str, set[str]] = dict()
+ for row in rows:
+ cells = row.find_all("td")
+ dct[cells[0].text] = {m.group() for m in re.finditer(FIPS_ALGS_IN_TABLE, cells[1].text)}
+ return dct
+
+ @staticmethod
+ def normalize_string(string: str) -> str:
+ return " ".join(string.split())
+
+ @staticmethod
+ def parse_tested_configurations(tested_configurations: Tag) -> list[str] | None:
+ configurations = [y.text for y in tested_configurations.find_all("li")]
+ return configurations if not configurations == ["N/A"] else None
+
+ @staticmethod
+ def normalize_embodiment(embodiment_element: Tag) -> str:
+ text = FIPSHTMLParser.normalize_string(embodiment_element.text)
+ embodiment_normalization_dict = {
+ "Multi-chip embedded": "Multi-Chip Embedded",
+ "Multi-chip Standalone": "Multi-Chip Stand Alone",
+ "Multi-chip standalone": "Multi-Chip Stand Alone",
+ "Single-chip": "Single Chip",
+ }
+ return embodiment_normalization_dict.get(text, text)
+
+
+DETAILS_KEY_NORMALIZATION_DICT: Final[dict[str, str]] = {
+ "Module Name": "module_name",
+ "Standard": "standard",
+ "Status": "status",
+ "Sunset Date": "date_sunset",
+ "Validation Dates": "date_validation",
+ "Overall Level": "level",
+ "Caveat": "caveat",
+ "Security Level Exceptions": "exceptions",
+ "Module Type": "module_type",
+ "Embodiment": "embodiment",
+ "Approved Algorithms": "algorithms",
+ "Tested Configuration(s)": "tested_conf",
+ "Description": "description",
+ "Historical Reason": "historical_reason",
+ "Hardware Versions": "hw_versions",
+ "Firmware Versions": "fw_versions",
+ "Revoked Reason": "revoked_reason",
+ "Revoked Link": "revoked_link",
+ "Software Versions": "sw_versions",
+ "Product URL": "product_url",
+}
+
+DETAILS_KEY_TO_NORMALIZATION_FUNCTION: dict[str, Callable] = {
+ "date_sunset": lambda x: dateutil.parser.parse(x.text).date(),
+ "algorithms": getattr(FIPSHTMLParser, "parse_algorithms"),
+ "tested_conf": getattr(FIPSHTMLParser, "parse_tested_configurations"),
+ "exceptions": lambda x: [y.text for y in x.find_all("li")],
+ "status": lambda x: FIPSHTMLParser.normalize_string(x.text).lower(),
+ "level": lambda x: int(FIPSHTMLParser.normalize_string(x.text)),
+ "embodiment": getattr(FIPSHTMLParser, "normalize_embodiment"),
+}
+
+
+class FIPSCertificate(
+ Certificate["FIPSCertificate", "FIPSCertificate.Heuristics", "FIPSCertificate.PdfData"],
+ PandasSerializableType,
+ ComplexSerializableType,
+):
+ """
+ Data structure for common FIPS 140 certificate. Contains several inner classes that layer the data logic.
+ Can be serialized into/from json (`ComplexSerializableType`).
+ Is basic element of `FIPSDataset`. The functionality is mostly related to holding data and transformations that
+ the certificate can handle itself. `FIPSDataset` class then instrument this functionality.
+ """
+
+ pandas_columns: ClassVar[list[str]] = [
+ "dgst",
+ "cert_id",
+ "name",
+ "status",
+ "standard",
+ "type",
+ "level",
+ "embodiment",
+ "date_validation",
+ "date_sunset",
+ "algorithms",
+ "extracted_versions",
+ "cpe_matches",
+ "verified_cpe_matches",
+ "related_cves",
+ "module_directly_referenced_by",
+ "module_indirectly_referenced_by",
+ "module_directly_referencing",
+ "module_indirectly_referencing",
+ "policy_directly_referenced_by",
+ "policy_indirectly_referenced_by",
+ "policy_directly_referencing",
+ "policy_indirectly_referencing",
+ ]
+
+ @dataclass(eq=True)
+ class InternalState(ComplexSerializableType):
+ """
+ Holds state of the `FIPSCertificate`
+ """
+
+ module_download_ok: bool
+ policy_download_ok: bool
+
+ policy_convert_garbage: bool
+ policy_convert_ok: bool
+
+ module_extract_ok: bool
+ policy_extract_ok: bool
+
+ policy_pdf_hash: str | None
+ policy_txt_hash: str | None
+
+ policy_pdf_path: Path
+ policy_txt_path: Path
+ module_html_path: Path
+
+ def __init__(
+ self,
+ module_download_ok: bool = False,
+ policy_download_ok: bool = False,
+ policy_convert_garbage: bool = False,
+ policy_convert_ok: bool = False,
+ module_extract_ok: bool = False,
+ policy_extract_ok: bool = False,
+ policy_pdf_hash: str | None = None,
+ policy_txt_hash: str | None = None,
+ ):
+ self.module_download_ok = module_download_ok
+ self.policy_download_ok = policy_download_ok
+ self.policy_convert_garbage = policy_convert_garbage
+ self.policy_convert_ok = policy_convert_ok
+ self.module_extract_ok = module_extract_ok
+ self.policy_extract_ok = policy_extract_ok
+ self.policy_pdf_hash = policy_pdf_hash
+ self.policy_txt_hash = policy_txt_hash
+
+ @property
+ def serialized_attributes(self) -> list[str]:
+ return [
+ "module_download_ok",
+ "policy_download_ok",
+ "policy_convert_garbage",
+ "policy_convert_ok",
+ "module_extract_ok",
+ "policy_extract_ok",
+ "policy_pdf_hash",
+ "policy_txt_hash",
+ ]
+
+ def module_is_ok_to_download(self, fresh: bool = True) -> bool:
+ return True if fresh else not self.module_download_ok
+
+ def policy_is_ok_to_download(self, fresh: bool = True) -> bool:
+ return True if fresh else not self.policy_download_ok
+
+ def policy_is_ok_to_convert(self, fresh: bool = True) -> bool:
+ return self.policy_download_ok if fresh else self.policy_download_ok and not self.policy_convert_ok
+
+ def module_is_ok_to_analyze(self, fresh: bool = True) -> bool:
+ return (
+ self.module_download_ok and self.module_extract_ok
+ if fresh
+ else self.module_download_ok and not self.module_extract_ok
+ )
+
+ def policy_is_ok_to_analyze(self, fresh: bool = True) -> bool:
+ return (
+ self.policy_convert_ok and self.policy_extract_ok
+ if fresh
+ else self.policy_convert_ok and not self.policy_extract_ok
+ )
+
+ def set_local_paths(self, policies_pdf_dir: Path, policies_txt_dir: Path, modules_html_dir: Path) -> None:
+ self.state.policy_pdf_path = (policies_pdf_dir / str(self.dgst)).with_suffix(".pdf")
+ self.state.policy_txt_path = (policies_txt_dir / str(self.dgst)).with_suffix(".txt")
+ self.state.module_html_path = (modules_html_dir / str(self.dgst)).with_suffix(".html")
+
+ @dataclass(eq=True)
+ class ValidationHistoryEntry(ComplexSerializableType):
+ date: date
+ validation_type: Literal["initial", "update"]
+ lab: str
+
+ @classmethod
+ def from_dict(cls, dct: dict) -> FIPSCertificate.ValidationHistoryEntry:
+ new_dct = dct.copy()
+ new_dct["date"] = dateutil.parser.parse(dct["date"]).date()
+ return cls(**new_dct)
+
+ @dataclass(eq=True)
+ class WebData(ComplexSerializableType):
+ """
+ Data structure for data obtained from scanning certificate webpage at NIST.gov
+ """
+
+ module_name: str | None = field(default=None)
+ validation_history: list[FIPSCertificate.ValidationHistoryEntry] | None = field(default=None)
+ vendor_url: str | None = field(default=None)
+ vendor: str | None = field(default=None)
+ certificate_pdf_url: str | None = field(default=None)
+ module_type: str | None = field(default=None)
+ standard: str | None = field(default=None)
+ status: Literal["active", "historical", "revoked"] | None = field(default=None)
+ level: Literal[1, 2, 3, 4] | None = field(default=None)
+ caveat: str | None = field(default=None)
+ exceptions: list[str] | None = field(default=None)
+ embodiment: str | None = field(default=None)
+ description: str | None = field(default=None)
+ tested_conf: list[str] | None = field(default=None)
+ hw_versions: str | None = field(default=None)
+ fw_versions: str | None = field(default=None)
+ sw_versions: str | None = field(default=None)
+ mentioned_certs: dict[str, int] | None = field(default=None) # Cert_id: n_occurences
+ historical_reason: str | None = field(default=None)
+ date_sunset: date | None = field(default=None)
+ revoked_reason: str | None = field(default=None)
+ revoked_link: str | None = field(default=None)
+
+ # Those below are left unused at the moment
+ # product_url: Optional[str] = field(default=None)
+
+ def __repr__(self) -> str:
+ return (
+ self.module_name
+ if self.module_name is not None
+ else "" + " created by " + self.vendor
+ if self.vendor is not None
+ else ""
+ )
+
+ def __str__(self) -> str:
+ return repr(self)
+
+ @classmethod
+ def from_dict(cls, dct: dict) -> FIPSCertificate.WebData:
+ new_dct = dct.copy()
+ if new_dct["date_sunset"]:
+ new_dct["date_sunset"] = dateutil.parser.parse(new_dct["date_sunset"]).date()
+ return cls(**dct)
+
+ @dataclass(eq=True)
+ class PdfData(BasePdfData, ComplexSerializableType):
+ """
+ Data structure that holds data obtained from scanning pdf files (or their converted txt documents).
+ """
+
+ keywords: dict = field(default_factory=dict)
+ policy_metadata: dict[str, Any] = field(default_factory=dict)
+
+ @property
+ def certlike_algorithm_numbers(self) -> set[str]:
+ """Returns numbers of certificates from keywords["fips_certlike"]["Certlike"]"""
+ if self.keywords and "fips_certlike" in self.keywords:
+ fips_certlike = self.keywords["fips_certlike"].get("Certlike", dict())
+ matches = {re.search(r"#\s{0,1}\d{1,4}", x) for x in fips_certlike.keys()}
+ return {"".join([x for x in match.group() if x.isdigit()]) for match in matches if match}
+ else:
+ return set()
+
+ @dataclass(eq=True)
+ class Heuristics(BaseHeuristics, ComplexSerializableType):
+ """
+ Data structure that holds data obtained by processing the certificate and applying various heuristics.
+ """
+
+ algorithms: set[str] = field(default_factory=set)
+ extracted_versions: set[str] = field(default_factory=set)
+ cpe_matches: set[str] | None = field(default=None)
+ verified_cpe_matches: set[CPE] | None = field(default=None)
+ related_cves: set[str] | None = field(default=None)
+ policy_prunned_references: set[str] = field(default_factory=set)
+ module_prunned_references: set[str] = field(default_factory=set)
+ policy_processed_references: References = field(default_factory=References)
+ module_processed_references: References = field(default_factory=References)
+ direct_transitive_cves: set[str] | None = field(default=None)
+ indirect_transitive_cves: set[str] | None = field(default=None)
+
+ @property
+ def algorithm_numbers(self) -> set[str]:
+ """Returns numbers of algorithms"""
+
+ def alg_to_number(alg: str) -> str:
+ return "".join([x for x in alg.split("#")[1] if x.isdigit()])
+
+ return {alg_to_number(x) for x in self.algorithms}
+
+ @property
+ def dgst(self) -> str:
+ """
+ Returns primary key of the certificate, its id.
+ """
+ return fips_dgst(self.cert_id)
+
+ @property
+ def manufacturer(self) -> str | None: # type: ignore
+ return self.web_data.vendor
+
+ @property
+ def module_html_url(self) -> str:
+ return constants.FIPS_MODULE_URL.format(self.cert_id)
+
+ @property
+ def policy_pdf_url(self) -> str:
+ return constants.FIPS_SP_URL.format(self.cert_id)
+
+ @property
+ def name(self) -> str | None: # type: ignore
+ return self.web_data.module_name
+
+ @property
+ def label_studio_title(self) -> str:
+ return (
+ "Vendor: "
+ + str(self.web_data.vendor)
+ + "\n"
+ + "Module name: "
+ + str(self.web_data.module_name)
+ + "\n"
+ + "HW version: "
+ + str(self.web_data.hw_versions)
+ + "\n"
+ + "FW version: "
+ + str(self.web_data.fw_versions)
+ )
+
+ def __init__(
+ self,
+ cert_id: str,
+ web_data: FIPSCertificate.WebData | None = None,
+ pdf_data: FIPSCertificate.PdfData | None = None,
+ heuristics: FIPSCertificate.Heuristics | None = None,
+ state: InternalState | None = None,
+ ):
+ super().__init__()
+
+ self.cert_id = cert_id
+ self.web_data: FIPSCertificate.WebData = web_data if web_data else FIPSCertificate.WebData()
+ self.pdf_data: FIPSCertificate.PdfData = pdf_data if pdf_data else FIPSCertificate.PdfData()
+ self.heuristics: FIPSCertificate.Heuristics = heuristics if heuristics else FIPSCertificate.Heuristics()
+ self.state: FIPSCertificate.InternalState = state if state else FIPSCertificate.InternalState()
+
+ @property
+ def pandas_tuple(self) -> tuple:
+ return (
+ self.dgst,
+ self.cert_id,
+ self.web_data.module_name,
+ self.web_data.status,
+ self.web_data.standard,
+ self.web_data.module_type,
+ self.web_data.level,
+ self.web_data.embodiment,
+ self.web_data.validation_history[0].date if self.web_data.validation_history else np.nan,
+ self.web_data.date_sunset,
+ self.heuristics.algorithms,
+ self.heuristics.extracted_versions,
+ self.heuristics.cpe_matches,
+ self.heuristics.verified_cpe_matches,
+ self.heuristics.related_cves,
+ self.heuristics.module_processed_references.directly_referenced_by,
+ self.heuristics.module_processed_references.indirectly_referenced_by,
+ self.heuristics.module_processed_references.directly_referencing,
+ self.heuristics.module_processed_references.indirectly_referencing,
+ self.heuristics.policy_processed_references.directly_referenced_by,
+ self.heuristics.policy_processed_references.indirectly_referenced_by,
+ self.heuristics.policy_processed_references.directly_referencing,
+ self.heuristics.policy_processed_references.indirectly_referencing,
+ )
+
+ @staticmethod
+ def parse_html_module(cert: FIPSCertificate) -> FIPSCertificate:
+ with cert.state.module_html_path.open("r") as handle:
+ soup = BeautifulSoup(handle, "html5lib")
+
+ parser = FIPSHTMLParser(soup)
+ algorithms, cert.web_data = parser.get_web_data_and_algorithms()
+ cert.heuristics.algorithms |= algorithms
+ cert.state.module_extract_ok = True
+
+ return cert
+
+ @staticmethod
+ def download_module(cert: FIPSCertificate) -> FIPSCertificate:
+ if (exit_code := helpers.download_file(cert.module_html_url, cert.state.module_html_path)) != requests.codes.ok:
+ error_msg = f"failed to download html module from {cert.module_html_url}, code {exit_code}"
+ logger.error(f"Cert dgst: {cert.dgst} " + error_msg)
+ cert.state.module_download_ok = False
+ else:
+ cert.state.module_download_ok = True
+ return cert
+
+ @staticmethod
+ def download_policy(cert: FIPSCertificate) -> FIPSCertificate:
+ if (exit_code := helpers.download_file(cert.policy_pdf_url, cert.state.policy_pdf_path)) != requests.codes.ok:
+ error_msg = f"failed to download pdf policy from {cert.policy_pdf_url}, code {exit_code}"
+ logger.error(f"Cert dgst: {cert.dgst} " + error_msg)
+ cert.state.policy_download_ok = False
+ else:
+ cert.state.policy_download_ok = True
+ cert.state.policy_pdf_hash = helpers.get_sha256_filepath(cert.state.policy_pdf_path)
+ return cert
+
+ @staticmethod
+ def convert_policy_pdf(cert: FIPSCertificate) -> FIPSCertificate:
+ """
+ Converts policy pdf -> txt
+ """
+ ocr_done, ok_result = sec_certs.utils.pdf.convert_pdf_file(
+ cert.state.policy_pdf_path, cert.state.policy_txt_path
+ )
+
+ # If OCR was done and the result was garbage
+ cert.state.policy_convert_garbage = ocr_done
+ # And put the whole result into convert_ok
+ cert.state.policy_convert_ok = ok_result
+
+ if not ok_result:
+ error_msg = "Failed to convert policy pdf->txt"
+ logger.error(f"Cert dgst: {cert.dgst}" + error_msg)
+ else:
+ cert.state.policy_txt_hash = helpers.get_sha256_filepath(cert.state.policy_txt_path)
+
+ return cert
+
+ @staticmethod
+ def extract_policy_pdf_metadata(cert: FIPSCertificate) -> FIPSCertificate:
+ """
+ Extract the PDF metadata from the security policy.
+ """
+ _, metadata = sec_certs.utils.pdf.extract_pdf_metadata(cert.state.policy_pdf_path)
+
+ if metadata:
+ cert.pdf_data.policy_metadata = metadata
+ else:
+ cert.pdf_data.policy_metadata = dict()
+ cert.state.policy_extract_ok = False
+ return cert
+
+ @staticmethod
+ def extract_policy_pdf_keywords(cert: FIPSCertificate) -> FIPSCertificate:
+ """
+ Extract keywords from policy document
+ """
+ keywords = sec_certs.utils.extract.extract_keywords(cert.state.policy_txt_path, fips_rules)
+ if not keywords:
+ cert.state.policy_extract_ok = False
+ else:
+ cert.pdf_data.keywords = keywords
+ return cert
+
+ @staticmethod
+ def get_algorithms_from_policy_tables(cert: FIPSCertificate):
+ """
+ Retrieves IDs of algorithms from tables inside security policy pdfs.
+ External library is used to handle this.
+ """
+ if table_rich_page_numbers := tables.find_pages_with_tables(cert.state.policy_txt_path):
+ pdf.repair_pdf(cert.state.policy_pdf_path)
+ try:
+ tabular_data = read_pdf(cert.state.policy_pdf_path, pages=list(table_rich_page_numbers), silent=True)
+ cert.heuristics.algorithms |= set(
+ itertools.chain.from_iterable(
+ tables.get_algs_from_table(df.to_string())
+ for df in tabular_data
+ if isinstance(df, pd.DataFrame)
+ )
+ )
+ except Exception as e:
+ logger.warning(f"Error when parsing tables from {cert.dgst}: {e}")
+ cert.state.policy_extract_ok = False
+
+ def prune_referenced_cert_ids(self) -> None:
+ """
+ This method goes through all IDs (numbers) that correspond to FIPS Certificates and are stored in
+ pdf_data.keywords or web_data.mentioned_certs. It performs prunning of these attributes and fills attributes
+ heuristics.prunned_module_references and heuristics.prunned_policy_references. These variables are further
+ processed and Reference objects are created from them.
+ """
+ html_module_ids = set(self.web_data.mentioned_certs.keys()) if self.web_data.mentioned_certs else set()
+ self.heuristics.module_prunned_references = self._prune_reference_ids_variable(html_module_ids)
+
+ if self.pdf_data.keywords:
+ pdf_policy_ids = set(self.pdf_data.keywords["fips_cert_id"].get("Cert", dict()).keys())
+ pdf_policy_ids = {"".join([y for y in x if y.isdigit()]) for x in pdf_policy_ids}
+ else:
+ pdf_policy_ids = set()
+
+ self.heuristics.policy_prunned_references = self._prune_reference_ids_variable(pdf_policy_ids)
+
+ def compute_heuristics_version(self) -> None:
+ """
+ Heuristically computes the version of the product.
+ """
+ versions_for_extraction = ""
+ if self.web_data.module_name:
+ versions_for_extraction += f" {self.web_data.module_name}"
+ if self.web_data.hw_versions:
+ versions_for_extraction += f" {self.web_data.hw_versions}"
+ if self.web_data.fw_versions:
+ versions_for_extraction += f" {self.web_data.fw_versions}"
+ self.heuristics.extracted_versions = helpers.compute_heuristics_version(versions_for_extraction)
+
+ def _prune_reference_ids_variable(self, attribute_to_prune: set[str]) -> set[str]:
+ """
+ Prunnes cert_ids from variable "attribute_to_prune", return result. Steps:
+ 0. Consider only ids != self.cert_id
+ 1. Consider only ids > config.always_false_positive_fips_cert_id_threshold
+ 2. Consider only ids s.t. they don't appear in self.heuristics.algorithms
+ 3. Consider only ids s.t. they don't appear in self.pdf_data.keywords["fips_certlike"]["Certlike"]
+ """
+ prunned = {x for x in attribute_to_prune if x != self.cert_id}
+ prunned = {x for x in prunned if int(x) > config.always_false_positive_fips_cert_id_threshold}
+ prunned = {x for x in prunned if x not in self.heuristics.algorithm_numbers}
+ prunned = {x for x in prunned if x not in self.pdf_data.certlike_algorithm_numbers}
+
+ return prunned
diff --git a/src/sec_certs/sample/fips_algorithm.py b/src/sec_certs/sample/fips_algorithm.py
new file mode 100644
index 00000000..16f19e64
--- /dev/null
+++ b/src/sec_certs/sample/fips_algorithm.py
@@ -0,0 +1,50 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from datetime import date
+from typing import ClassVar
+
+from sec_certs import constants
+from sec_certs.serialization.json import ComplexSerializableType
+from sec_certs.serialization.pandas import PandasSerializableType
+
+
+@dataclass(eq=True, frozen=True)
+class FIPSAlgorithm(PandasSerializableType, ComplexSerializableType):
+ """
+ Data structure for algorithm of `FIPSCertificate`
+ """
+
+ alg_number: str
+ algorithm_type: str
+ vendor: str
+ implementation_name: str
+ validation_date: date
+
+ pandas_columns: ClassVar[list[str]] = [
+ "dgst",
+ "alg_number",
+ "algorithm_type",
+ "vendor",
+ "implementation_name",
+ "validation_date",
+ ]
+
+ @property
+ def pandas_tuple(self) -> tuple:
+ return (
+ self.dgst,
+ self.alg_number,
+ self.algorithm_type,
+ self.vendor,
+ self.implementation_name,
+ self.validation_date,
+ )
+
+ @property
+ def dgst(self) -> str:
+ return f"{self.algorithm_type}{self.alg_number}"
+
+ @property
+ def page_url(self) -> str:
+ return constants.FIPS_ALG_URL.format(self.algorithm_type, self.alg_number)
diff --git a/src/sec_certs/sample/fips_iut.py b/src/sec_certs/sample/fips_iut.py
new file mode 100644
index 00000000..cb521ee4
--- /dev/null
+++ b/src/sec_certs/sample/fips_iut.py
@@ -0,0 +1,166 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from datetime import date, datetime
+from pathlib import Path
+from tempfile import NamedTemporaryFile
+from typing import Iterator, Mapping
+
+import requests
+from bs4 import BeautifulSoup, Tag
+
+from sec_certs import constants
+from sec_certs.config.configuration import config
+from sec_certs.serialization.json import ComplexSerializableType
+from sec_certs.utils.helpers import to_utc
+
+
+@dataclass(frozen=True)
+class IUTEntry(ComplexSerializableType):
+ module_name: str
+ vendor_name: str
+ standard: str
+ iut_date: date
+
+ def to_dict(self) -> dict[str, str]:
+ return {**self.__dict__, "iut_date": self.iut_date.isoformat()}
+
+ @classmethod
+ def from_dict(cls, dct: Mapping) -> IUTEntry:
+ return cls(
+ dct["module_name"],
+ dct["vendor_name"],
+ dct["standard"],
+ date.fromisoformat(dct["iut_date"]),
+ )
+
+
+@dataclass
+class IUTSnapshot(ComplexSerializableType):
+ entries: set[IUTEntry]
+ timestamp: datetime
+ last_updated: date
+ displayed: int | None
+ not_displayed: int | None
+ total: int | None
+
+ def __len__(self) -> int:
+ return len(self.entries)
+
+ def __iter__(self) -> Iterator[IUTEntry]:
+ yield from self.entries
+
+ def to_dict(self) -> dict[str, int | None | list[IUTEntry] | str]:
+ return {
+ "entries": list(self.entries),
+ "timestamp": self.timestamp.isoformat(),
+ "last_updated": self.last_updated.isoformat(),
+ "displayed": self.displayed,
+ "not_displayed": self.not_displayed,
+ "total": self.total,
+ }
+
+ @classmethod
+ def from_dict(cls, dct: Mapping) -> IUTSnapshot:
+ return cls(
+ set(dct["entries"]),
+ datetime.fromisoformat(dct["timestamp"]),
+ date.fromisoformat(dct["last_updated"]),
+ dct["displayed"],
+ dct["not_displayed"],
+ dct["total"],
+ )
+
+ @classmethod
+ def from_page(cls, content: bytes, snapshot_date: datetime) -> IUTSnapshot:
+ """
+ Get an IUT snapshot from a HTML dump of the FIPS website.
+ """
+ if not content:
+ raise ValueError("Empty content in IUT.")
+ soup = BeautifulSoup(content, "html5lib")
+ tables = soup.find_all("table")
+ if len(tables) != 1:
+ raise ValueError("Not only a single table in IUT.")
+
+ last_updated_elem = next(
+ filter(
+ lambda e: isinstance(e, Tag) and e.name == "p",
+ soup.find(id="content").next_siblings,
+ )
+ )
+ last_updated_text = str(last_updated_elem.string).strip()
+ last_updated = datetime.strptime(last_updated_text, "Last Updated: %m/%d/%Y").date()
+ table = tables[0].find("tbody")
+ lines = table.find_all("tr")
+ entries = {
+ IUTEntry(
+ str(line[0].string),
+ str(line[1].string),
+ str(line[2].string),
+ datetime.strptime(str(line[3].string), "%m/%d/%Y").date(),
+ )
+ for line in map(lambda tr: tr.find_all("td"), lines)
+ }
+
+ # Parse footer
+ footer = soup.find(id="IUTFooter")
+ displayed: int | None
+ not_displayed: int | None
+ total: int | None
+
+ if footer:
+ footer_lines = footer.find_all("tr")
+ displayed = int(footer_lines[0].find_all("td")[1].text)
+ not_displayed = int(footer_lines[1].find_all("td")[1].text)
+ total = int(footer_lines[2].find_all("td")[1].text)
+ else:
+ displayed, not_displayed, total = (None, None, None)
+
+ return cls(
+ entries=entries,
+ timestamp=snapshot_date,
+ last_updated=last_updated,
+ displayed=displayed,
+ not_displayed=not_displayed,
+ total=total,
+ )
+
+ @classmethod
+ def from_dump(cls, dump_path: str | Path, snapshot_date: datetime | None = None) -> IUTSnapshot:
+ """
+ Get an IUT snapshot from a HTML file dump of the FIPS website.
+ """
+ dump_path = Path(dump_path)
+ if snapshot_date is None:
+ try:
+ snapshot_date = to_utc(datetime.fromisoformat(dump_path.name[len("fips_iut_") : -len(".html")]))
+ except Exception:
+ raise ValueError("snapshot_date not given and could not be inferred from filename.")
+ with dump_path.open("rb") as f:
+ content = f.read()
+ return cls.from_page(content, snapshot_date)
+
+ @classmethod
+ def from_web(cls) -> IUTSnapshot:
+ """
+ Get an IUT snapshot from the FIPS website right now.
+ """
+ iut_resp = requests.get(constants.FIPS_IUT_URL)
+ if iut_resp.status_code != 200:
+ raise ValueError(f"Getting IUT snapshot failed: {iut_resp.status_code}")
+
+ snapshot_date = to_utc(datetime.now())
+ return cls.from_page(iut_resp.content, snapshot_date)
+
+ @classmethod
+ def from_web_latest(cls) -> IUTSnapshot:
+ """
+ Get a IUT snapshot from seccerts.org.
+ """
+ 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)
diff --git a/src/sec_certs/sample/fips_mip.py b/src/sec_certs/sample/fips_mip.py
new file mode 100644
index 00000000..6918d2aa
--- /dev/null
+++ b/src/sec_certs/sample/fips_mip.py
@@ -0,0 +1,242 @@
+from __future__ import annotations
+
+import logging
+from dataclasses import dataclass
+from datetime import date, datetime
+from enum import Enum
+from pathlib import Path
+from tempfile import NamedTemporaryFile
+from typing import Iterator, Mapping
+
+import requests
+from bs4 import BeautifulSoup, Tag
+
+from sec_certs import constants
+from sec_certs.config.configuration import config
+from sec_certs.constants import FIPS_MIP_STATUS_RE
+from sec_certs.serialization.json import ComplexSerializableType
+from sec_certs.utils.helpers import to_utc
+
+logger = logging.getLogger(__name__)
+
+
+class MIPStatus(Enum):
+ IN_REVIEW = "In Review"
+ REVIEW_PENDING = "Review Pending"
+ COORDINATION = "Coordination"
+ FINALIZATION = "Finalization"
+
+
+@dataclass(frozen=True)
+class MIPEntry(ComplexSerializableType):
+ module_name: str
+ vendor_name: str
+ standard: str
+ status: MIPStatus | None
+ status_since: date | None
+
+ def to_dict(self) -> dict[str, str | MIPStatus | None | date | None]:
+ return {
+ **self.__dict__,
+ "status": self.status.value if self.status else None,
+ "status_since": self.status_since.isoformat() if self.status_since else None,
+ }
+
+ @classmethod
+ def from_dict(cls, dct: Mapping) -> MIPEntry:
+ return cls(
+ dct["module_name"],
+ dct["vendor_name"],
+ dct["standard"],
+ MIPStatus(dct["status"]) if dct["status"] else None,
+ date.fromisoformat(dct["status_since"]) if dct.get("status_since") else None,
+ )
+
+
+@dataclass
+class MIPSnapshot(ComplexSerializableType):
+ entries: set[MIPEntry]
+ timestamp: datetime
+ last_updated: date
+ displayed: int
+ not_displayed: int
+ total: int
+
+ def __len__(self) -> int:
+ return len(self.entries)
+
+ def __iter__(self) -> Iterator[MIPEntry]:
+ yield from self.entries
+
+ def to_dict(self) -> dict[str, int | str | list[MIPEntry]]:
+ return {
+ "entries": list(self.entries),
+ "timestamp": self.timestamp.isoformat(),
+ "last_updated": self.last_updated.isoformat(),
+ "displayed": self.displayed,
+ "not_displayed": self.not_displayed,
+ "total": self.total,
+ }
+
+ @classmethod
+ def from_dict(cls, dct: Mapping) -> MIPSnapshot:
+ return cls(
+ set(dct["entries"]),
+ datetime.fromisoformat(dct["timestamp"]),
+ date.fromisoformat(dct["last_updated"]),
+ dct["displayed"],
+ dct["not_displayed"],
+ dct["total"],
+ )
+
+ @classmethod
+ def _extract_entries_1(cls, lines):
+ """Works until 2020.10.28 (including)."""
+ entries = set()
+ for tr in lines:
+ tds = tr.find_all("td")
+ status = None
+ if "mip-highlight" in tds[-1]["class"]:
+ status = MIPStatus.FINALIZATION
+ elif "mip-highlight" in tds[-2]["class"]:
+ status = MIPStatus.COORDINATION
+ elif "mip-highlight" in tds[-3]["class"]:
+ status = MIPStatus.REVIEW_PENDING
+ elif "mip-highlight" in tds[-4]["class"]:
+ status = MIPStatus.IN_REVIEW
+ entries.add(MIPEntry(str(tds[0].string), str(tds[1].string), str(tds[2].string), status, None))
+ return entries
+
+ @classmethod
+ def _extract_entries_2(cls, lines):
+ """Works until 2021.04.20 (including)."""
+ return {
+ MIPEntry(
+ str(line[0].string), str(line[1].string), str(line[2].string), MIPStatus(str(line[3].string)), None
+ )
+ for line in map(lambda tr: tr.find_all("td"), lines)
+ }
+
+ @classmethod
+ def _extract_entries_3(cls, lines):
+ """Works until 2022.03.23 (including)."""
+ return {
+ MIPEntry(
+ str(line[0].string),
+ str(" ".join(line[1].find_all(text=True, recursive=False)).strip()),
+ str(line[2].string),
+ MIPStatus(str(line[3].string)),
+ None,
+ )
+ for line in map(lambda tr: tr.find_all("td"), lines)
+ }
+
+ @classmethod
+ def _extract_entries_4(cls, lines):
+ """Works now."""
+ entries = set()
+ for line in map(lambda tr: tr.find_all("td"), lines):
+ module_name = str(line[0].string)
+ vendor_name = str(" ".join(line[1].find_all(text=True, recursive=False)).strip())
+ standard = str(line[2].string)
+ status_line = FIPS_MIP_STATUS_RE.match(str(line[3].string))
+ if status_line is None:
+ raise ValueError("Cannot parse MIP status line.")
+ status = MIPStatus(status_line.group("status"))
+ since = datetime.strptime(status_line.group("since"), "%m/%d/%Y").date()
+ entries.add(MIPEntry(module_name, vendor_name, standard, status, since))
+ return entries
+
+ @classmethod
+ def _extract_entries(cls, lines, snapshot_date):
+ if snapshot_date <= datetime(2020, 10, 28):
+ entries = cls._extract_entries_1(lines)
+ elif snapshot_date <= datetime(2021, 4, 20):
+ entries = cls._extract_entries_2(lines)
+ elif snapshot_date <= datetime(2022, 3, 23):
+ entries = cls._extract_entries_3(lines)
+ else:
+ entries = cls._extract_entries_4(lines)
+ return entries
+
+ @classmethod
+ def from_page(cls, content: bytes, snapshot_date: datetime) -> MIPSnapshot:
+ """
+ Get a MIP snapshot from a HTML dump of the FIPS website.
+ """
+ if not content:
+ raise ValueError("Empty content in MIP.")
+ soup = BeautifulSoup(content, "html5lib")
+ tables = soup.find_all("table")
+ if len(tables) != 1:
+ raise ValueError("Not only a single table in MIP data.")
+
+ # Parse Last Updated
+ last_updated_elem = next(
+ filter(
+ lambda e: isinstance(e, Tag) and e.name == "p",
+ soup.find(id="content").next_siblings,
+ )
+ )
+ last_updated_text = str(last_updated_elem.string).strip()
+ last_updated = datetime.strptime(last_updated_text, "Last Updated: %m/%d/%Y").date()
+
+ # Parse entries
+ table = tables[0].find("tbody")
+ lines = table.find_all("tr")
+ entries = cls._extract_entries(lines, snapshot_date)
+
+ # Parse footer
+ footer = soup.find(id="MIPFooter")
+ footer_lines = footer.find_all("tr")
+ displayed = int(footer_lines[0].find_all("td")[1].text)
+ not_displayed = int(footer_lines[1].find_all("td")[1].text)
+ total = int(footer_lines[2].find_all("td")[1].text)
+
+ return cls(
+ entries=entries,
+ timestamp=snapshot_date,
+ last_updated=last_updated,
+ displayed=displayed,
+ not_displayed=not_displayed,
+ total=total,
+ )
+
+ @classmethod
+ def from_dump(cls, dump_path: str | Path, snapshot_date: datetime | None = None) -> MIPSnapshot:
+ """
+ Get a MIP snapshot from a HTML file dump of the FIPS website.
+ """
+ dump_path = Path(dump_path)
+ if snapshot_date is None:
+ try:
+ snapshot_date = to_utc(datetime.fromisoformat(dump_path.name[len("fips_mip_") : -len(".html")]))
+ except Exception:
+ raise ValueError("snapshot_date not given and could not be inferred from filename.")
+ with dump_path.open("rb") as f:
+ content = f.read()
+ return cls.from_page(content, snapshot_date)
+
+ @classmethod
+ def from_web(cls) -> MIPSnapshot:
+ """
+ Get a MIP snapshot from the FIPS website right now.
+ """
+ mip_resp = requests.get(constants.FIPS_MIP_URL)
+ if mip_resp.status_code != 200:
+ raise ValueError(f"Getting MIP snapshot failed: {mip_resp.status_code}")
+
+ snapshot_date = to_utc(datetime.now())
+ return cls.from_page(mip_resp.content, snapshot_date)
+
+ @classmethod
+ def from_web_latest(cls) -> MIPSnapshot:
+ """
+ Get a MIP snapshot from seccerts.org.
+ """
+ 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
new file mode 100644
index 00000000..b7c2ec34
--- /dev/null
+++ b/src/sec_certs/sample/protection_profile.py
@@ -0,0 +1,55 @@
+from __future__ import annotations
+
+import copy
+import logging
+from dataclasses import dataclass
+from typing import Any
+
+import sec_certs.utils.sanitization as sanitization
+from sec_certs.serialization.json import ComplexSerializableType
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass(frozen=True)
+class ProtectionProfile(ComplexSerializableType):
+ """
+ Object for holding protection profiles.
+ """
+
+ pp_name: str
+ pp_eal: str | None
+ pp_link: str | None = None
+ pp_ids: frozenset[str] | None = 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))
+
+ @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()))
+
+ @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"])
+
+ if not len(eal_set) <= 1:
+ raise ValueError("EAL field should have single value or should be empty.")
+
+ eal_str = list(eal_set)[0] if eal_set else None
+
+ return cls(pp_name, eal_str, pp_link, pp_ids)
+
+ 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
+
+ def __lt__(self, other: ProtectionProfile) -> bool:
+ return self.pp_name < other.pp_name
diff --git a/src/sec_certs/sample/sar.py b/src/sec_certs/sample/sar.py
new file mode 100644
index 00000000..31359299
--- /dev/null
+++ b/src/sec_certs/sample/sar.py
@@ -0,0 +1,59 @@
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass
+from typing import Any
+
+from sec_certs.serialization.json import ComplexSerializableType
+
+SAR_CLASS_MAPPING = {
+ "APE": "Protection Profile evaluation",
+ "ACE": "Protection Profile configuration evaluation",
+ "ASE": "Security Target evaluation",
+ "ADV": "Development",
+ "AGD": "Guidance documents",
+ "ALC": "Life-cycle support",
+ "ATE": "Tests",
+ "AVA": "Vulnerability assessment",
+ "ACO": "Comoposition",
+}
+
+SAR_CLASSES = {x for x in SAR_CLASS_MAPPING}
+SAR_DICT_KEY = "cc_sar"
+
+
+@dataclass(frozen=True, eq=True)
+class SAR(ComplexSerializableType):
+ family: str
+ level: int
+
+ @property
+ def assurance_class(self):
+ return SAR_CLASS_MAPPING.get(self.family.split("_")[0], None)
+
+ @classmethod
+ def from_string(cls, string: str) -> SAR:
+ if not cls.contains_level(string):
+ raise ValueError("SAR misses level integer")
+ if not cls.matches_re(string):
+ raise ValueError("SAR does not match any regular expression")
+ family = string.split(".")[0]
+ level = int(string.split(".")[1])
+ return cls(family, level)
+
+ @staticmethod
+ def contains_level(string: str) -> bool:
+ if len(string.split(".")) == 1:
+ return False
+ return True
+
+ @staticmethod
+ def matches_re(string: str) -> bool:
+ return any(
+ [re.match(sar_class + "(?:_[A-Z]{3,4}){1,2}(?:\\.[0-9]){0,2}", string) for sar_class in SAR_CLASS_MAPPING]
+ )
+
+ def __lt__(self, other: Any) -> bool:
+ if not isinstance(other, SAR):
+ raise ValueError(f"cannot compare {type(other)} with SAR.")
+ return str(self) < str(other)
diff --git a/src/sec_certs/serialization/__init__.py b/src/sec_certs/serialization/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/src/sec_certs/serialization/__init__.py
diff --git a/src/sec_certs/serialization/json.py b/src/sec_certs/serialization/json.py
new file mode 100644
index 00000000..69bbc2ff
--- /dev/null
+++ b/src/sec_certs/serialization/json.py
@@ -0,0 +1,145 @@
+from __future__ import annotations
+
+import copy
+import json
+from datetime import date
+from functools import wraps
+from pathlib import Path
+from typing import Any, Callable, TypeVar
+
+from sec_certs import constants
+
+T = TypeVar("T", bound="ComplexSerializableType")
+
+
+class SerializationError(Exception):
+ pass
+
+
+class ComplexSerializableType:
+ __slots__: tuple[str]
+
+ def __init__(self, *args, **kwargs):
+ pass
+
+ # Ideally, the serialized_fields would be an class variable referencing itself, but that it virtually impossible
+ # to achieve without using metaclasses. Not to complicate the code, we choose instance variable.
+ @property
+ def serialized_attributes(self) -> list[str]:
+ if hasattr(self, "__slots__") and self.__slots__:
+ return list(self.__slots__)
+ return list(self.__dict__.keys())
+
+ def to_dict(self) -> dict[str, Any]:
+ if hasattr(self, "__slots__") and self.__slots__:
+ return {
+ key: copy.deepcopy(getattr(self, key)) for key in self.__slots__ if key in self.serialized_attributes
+ }
+ return {key: val for key, val in copy.deepcopy(self.__dict__).items() if key in self.serialized_attributes}
+
+ @classmethod
+ def from_dict(cls: type[T], dct: dict) -> T:
+ try:
+ return cls(**dct)
+ except TypeError as e:
+ raise TypeError(f"Dict: {dct} on {cls.__mro__}") from e
+
+ def to_json(self, output_path: str | Path | None = None) -> None:
+ if not output_path and (not hasattr(self, "json_path") or not self.json_path): # type: ignore
+ raise SerializationError(
+ f"The object {self} of type {self.__class__} does not have json_path attribute set but to_json() was called without an argument."
+ )
+ if not output_path:
+ output_path = self.json_path # type: ignore
+ if self.json_path == constants.DUMMY_NONEXISTING_PATH: # type: ignore
+ raise SerializationError(f"json_path attribute for '{get_class_fullname(self)}' was not yet set.")
+ if hasattr(self, "root_dir") and self.root_dir == constants.DUMMY_NONEXISTING_PATH: # type: ignore
+ raise SerializationError(f"root_dir attribute for '{get_class_fullname(self)}' was not yet set.")
+
+ if Path(output_path).is_dir(): # type: ignore
+ raise SerializationError("output path for json cannot be directory.")
+
+ # false positive MyPy warning, cannot be None
+ with Path(output_path).open("w") as handle: # type: ignore
+ json.dump(self, handle, indent=4, cls=CustomJSONEncoder, ensure_ascii=False)
+
+ @classmethod
+ def from_json(cls: type[T], input_path: str | Path) -> T:
+ input_path = Path(input_path)
+ with input_path.open("r") as handle:
+ obj = json.load(handle, cls=CustomJSONDecoder)
+ return obj
+
+
+# Decorator for serialization
+def serialize(func: Callable):
+ @wraps(func)
+ def inner_func(*args, **kwargs):
+ if not args or not issubclass(type(args[0]), ComplexSerializableType):
+ raise ValueError(
+ "@serialize decorator is to be used only on instance methods of ComplexSerializableType child classes."
+ )
+
+ if hasattr(args[0], "_root_dir") and args[0]._root_dir == constants.DUMMY_NONEXISTING_PATH:
+ raise SerializationError(
+ "The invoked method requires dataset serialization. Cannot serialize without root_dir set. You can set it with obj.root_dir = ..."
+ )
+
+ update_json = kwargs.pop("update_json", True)
+ result = func(*args, **kwargs)
+ if update_json:
+ args[0].to_json()
+ return result
+
+ return inner_func
+
+
+def get_class_fullname(obj: Any) -> str:
+ if isinstance(obj, type):
+ klass = obj
+ else:
+ klass = obj.__class__
+ module = klass.__module__
+ if module == "builtins":
+ return klass.__qualname__
+ return module + "." + klass.__qualname__
+
+
+class CustomJSONEncoder(json.JSONEncoder):
+ def default(self, obj):
+ if isinstance(obj, ComplexSerializableType):
+ return {**{"_type": get_class_fullname(obj)}, **obj.to_dict()}
+ if isinstance(obj, dict):
+ return obj
+ if isinstance(obj, set):
+ return {"_type": "Set", "elements": sorted(list(obj))}
+ if isinstance(obj, frozenset):
+ return sorted(list(obj))
+ if isinstance(obj, date):
+ return str(obj)
+ if isinstance(obj, Path):
+ return str(obj)
+ return super().default(obj)
+
+
+class CustomJSONDecoder(json.JSONDecoder):
+ """
+ Custom JSONDecoder. Any complex object that should be de-serializable must inherit directly from class
+ ComplexSerializableType (nested inheritance does not currently work (because x.__subclassess__() prints only direct
+ subclasses. Any such class must implement methods to_dict() and from_dict(). These are used to drive serialization.
+ """
+
+ def __init__(self, *args, **kwargs):
+ json.JSONDecoder.__init__(self, object_hook=self.object_hook, *args, **kwargs)
+ self.serializable_complex_types = {get_class_fullname(x): x for x in ComplexSerializableType.__subclasses__()}
+
+ def object_hook(self, obj):
+ if "_type" in obj and obj["_type"] == "Set":
+ return set(obj["elements"])
+ if "_type" in obj and obj["_type"] in self.serializable_complex_types.keys():
+ complex_type = obj.pop("_type")
+ return self.serializable_complex_types[complex_type].from_dict(obj)
+ elif "_type" in obj:
+ raise SerializationError(f"JSONDecoder doesn't know how to handle {obj}")
+
+ return obj
diff --git a/src/sec_certs/serialization/pandas.py b/src/sec_certs/serialization/pandas.py
new file mode 100644
index 00000000..dc0aae18
--- /dev/null
+++ b/src/sec_certs/serialization/pandas.py
@@ -0,0 +1,16 @@
+from abc import ABC, abstractmethod
+
+
+class PandasSerializableType(ABC):
+ def __init__(self, *args, **kwargs):
+ pass
+
+ @property
+ @abstractmethod
+ def pandas_tuple(self):
+ raise NotImplementedError("Not meant to be implemented")
+
+ @property
+ @abstractmethod
+ def pandas_columns(self):
+ raise NotImplementedError("Not meant to be implemented")
diff --git a/src/sec_certs/utils/__init__.py b/src/sec_certs/utils/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/src/sec_certs/utils/__init__.py
diff --git a/src/sec_certs/utils/extract.py b/src/sec_certs/utils/extract.py
new file mode 100644
index 00000000..09460fb7
--- /dev/null
+++ b/src/sec_certs/utils/extract.py
@@ -0,0 +1,817 @@
+from __future__ import annotations
+
+import logging
+import os
+import re
+from collections import Counter
+from enum import Enum
+from pathlib import Path
+from typing import Any, Iterator
+
+import numpy as np
+
+from sec_certs import constants as constants
+from sec_certs.cert_rules import REGEXEC_SEP, cc_rules
+from sec_certs.constants import FILE_ERRORS_STRATEGY, LINE_SEPARATOR, MAX_ALLOWED_MATCH_LENGTH
+
+logger = logging.getLogger(__name__)
+
+
+def search_only_headers_anssi(filepath: Path): # noqa: C901
+ # TODO: Please, refactor me. I reallyyyyyyyyyyyyy need it!!!!!!
+ class HEADER_TYPE(Enum):
+ HEADER_FULL = 1
+ HEADER_MISSING_CERT_ITEM_VERSION = 2
+ HEADER_MISSING_PROTECTION_PROFILES = 3
+ HEADER_DUPLICITIES = 4
+
+ rules_certificate_preface = [
+ (
+ HEADER_TYPE.HEADER_FULL,
+ "Référence du rapport de certification(.+)Nom du produit(.+)Référence/version du produit(.*)Conformité à un profil de protection(.+)Critères d'évaluation et version(.+)Niveau d'évaluation(.+)Développeurs(.+)Centre d'évaluation(.+)Accords de reconnaissance applicables",
+ ),
+ (
+ HEADER_TYPE.HEADER_FULL,
+ "Référence du rapport de certification(.+)Nom du produit(.+)Référence/version du produit(.*)Conformité à un profil de protection(.+)Critères d’évaluation et version(.+)Niveau d’évaluation(.+)Développeurs(.+)Centre d’évaluation(.+)Accords de reconnaissance applicables",
+ ),
+ (
+ HEADER_TYPE.HEADER_FULL,
+ "Référence du rapport de certification(.+)Nom du produit(.+)()Conformité à un profil de protection(.+)Critères d'évaluation et version(.+)Niveau d'évaluation(.+)Développeur (.+)Centre d'évaluation(.+)Accords de reconnaissance applicables",
+ ),
+ (
+ HEADER_TYPE.HEADER_FULL,
+ "Référence du rapport de certification(.+)Nom des produits(.+)Référence/version des produits(.+)Conformité à un profil de protection(.+)Critères d'évaluation et version(.+)Niveau d'évaluation(.+)Développeur\\(s\\)(.+)Centre d'évaluation(.+)Accords de reconnaissance applicables",
+ ),
+ (
+ HEADER_TYPE.HEADER_FULL,
+ "Référence du rapport de certification(.+)Nom des produits(.+)Référence/version des produits(.+)Conformité à un profil de protection(.+)Critères d'évaluation et version(.+)Niveau d'évaluation(.+)Développeur (.+)Centre d'évaluation(.+)Accords de reconnaissance",
+ ),
+ (
+ HEADER_TYPE.HEADER_FULL,
+ "Référence du rapport de certification(.+)Nom du produit(.+)Référence/version du produit(.+)Conformité aux profils de protection(.+)Critères d’évaluation et version(.+)Niveau d’évaluation(.+)Développeur\\(s\\)(.+)Centre d’évaluation(.+)Accords de reconnaissance applicables",
+ ),
+ (
+ HEADER_TYPE.HEADER_FULL,
+ "Référence du rapport de certification(.+)Nom du produit(.+)Référence/version du produit(.+)Conformité à un profil de protection(.+)Critères d’évaluation et version(.+)Niveau d’évaluation(.+)Développeur\\(s\\)(.+)Centre d’évaluation(.+)Accords de reconnaissance applicables",
+ ),
+ (
+ HEADER_TYPE.HEADER_FULL,
+ "Référence du rapport de certification(.+)Nom du produit(.+)Référence/version du produit(.+)Conformité à un profil de protection(.+)Critères d’évaluation et version(.+)Niveau d’évaluation(.+)Développeur (.+)Centre d’évaluation(.+)Accords de reconnaissance applicables",
+ ),
+ (
+ HEADER_TYPE.HEADER_FULL,
+ "Référence du rapport de certification(.+)Nom du produit(.+)Référence/version du produit(.+)Conformité à des profils de protection(.+)Critères d’évaluation et version(.+)Niveau d’évaluation(.+)Développeurs(.+)Centre d’évaluation(.+)Accords de reconnaissance applicables",
+ ),
+ (
+ HEADER_TYPE.HEADER_FULL,
+ "Référence du rapport de certification(.+)Nom du produit(.+)Référence/version du produit(.+)Conformité aux profils de protection(.+)Critères d’évaluation et version(.+)Niveau d’évaluation(.+)Développeurs(.+)Centre d’évaluation(.+)Accords de reconnaissance applicables",
+ ),
+ (
+ HEADER_TYPE.HEADER_FULL,
+ "Référence du rapport de certification(.+)Nom du produit \\(référence/version\\)(.+)Nom de la TOE \\(référence/version\\)(.+)Conformité à un profil de protection(.+)Critères d’évaluation et version(.+)Niveau d’évaluation(.+)Développeurs(.+)Centre d’évaluation(.+)Accords de reconnaissance applicables",
+ ),
+ (
+ HEADER_TYPE.HEADER_FULL,
+ "Référence du rapport de certification(.+)Nom du produit(.+)Référence/version du produit(.+)Conformité aux profil de protection(.+)Critères d’évaluation et version(.+)Niveau d’évaluation(.+)Développeur\\(s\\)(.+)Centre d’évaluation(.+)Accords de reconnaissance applicables",
+ ),
+ (
+ HEADER_TYPE.HEADER_FULL,
+ "Référence du rapport de certification(.+)Nom du produit(.+)Référence/version du produit(.+)Conformité à un profil de protection(.+)Critères d'évaluation et version(.+)Niveau d'évaluation(.+)Développeur\\(s\\)(.+)Centre d'évaluation(.+)Accords de reconnaissance applicables",
+ ),
+ (
+ HEADER_TYPE.HEADER_FULL,
+ "Référence du rapport de certification(.+)Nom du produit \\(référence/version\\)(.+)Nom de la TOE \\(référence/version\\)(.+)Conformité à un profil de protection(.+)Critères d'évaluation et version(.+)Niveau d'évaluation(.+)Développeurs(.+)Centre d'évaluation(.+)Accords de reconnaissance applicables",
+ ),
+ (
+ HEADER_TYPE.HEADER_FULL,
+ "Référence du rapport de certification(.+)Nom du produit(.+)Référence du produit(.+)Conformité à un profil de protection(.+)Critères d'évaluation et version(.+)Niveau d'évaluation(.+)Développeurs(.+)Centre d'évaluation(.+)Accords de reconnaissance applicables",
+ ),
+ (
+ HEADER_TYPE.HEADER_FULL,
+ "Référence du rapport de certification(.+)Nom du produit(.+)Référence/version du produit(.+)Conformité aux profils de protection(.+)Critères d'évaluation et version(.+)Niveau d'évaluation(.+)Développeurs(.+)Centre d'évaluation(.+)Accords de reconnaissance applicables",
+ ),
+ (
+ HEADER_TYPE.HEADER_FULL,
+ "Référence du rapport de certification(.+)Nom du produit(.+)Référence/version du produit(.+)Conformité à un profil de protection(.+)Critères d’évaluation et version(.+)Niveau d’évaluation(.+)Développeurs(.+)Centre d’évaluation(.+)Accords de reconnaissance applicables",
+ ),
+ (
+ HEADER_TYPE.HEADER_FULL,
+ "Référence du rapport de certification(.+)Nom du produit(.+)Référence/version du produit(.+)Conformité à un profil de protection(.+)Critères d’évaluation et version(.+)Niveau d’évaluation(.+)Développeur\\(s\\)(.+)d’évaluation(.+)Accords de reconnaissance applicables",
+ ),
+ (
+ HEADER_TYPE.HEADER_FULL,
+ "Référence du rapport de certification(.+)Nom du produit(.+)Référence/version du produit(.+)Conformité à un profil de protection(.+)Critères d’évaluation et version(.+)Niveau d’évaluation(.+)Développeur (.+)Centre d’évaluation(.+)Accords de reconnaissance applicables",
+ ),
+ (
+ HEADER_TYPE.HEADER_FULL,
+ "Référence du rapport de certification(.+)Nom du produit(.+)Référence/version du produit(.+)Conformité à des profils de protection(.+)Critères d’évaluation et version(.+)Niveau d’évaluation(.+)Développeurs(.+)Centre d’évaluation(.+)Accords de reconnaissance applicables",
+ ),
+ (
+ HEADER_TYPE.HEADER_FULL,
+ "Référence du rapport de certification(.+)Nom du produit \\(référence/version\\)(.+)Nom de la TOE \\(référence/version\\)(.+)Conformité à un profil de protection(.+)Critères d’évaluation et version(.+)Niveau d’évaluation(.+)Développeurs(.+)Centre d’évaluation(.+)Accords de reconnaissance applicables",
+ ),
+ (
+ HEADER_TYPE.HEADER_FULL,
+ "Certification Report(.+)Nom du produit(.+)Référence/version du produit(.*)Conformité à un profil de protection(.+)Critères d'évaluation et version(.+)Niveau d'évaluation(.+)Développeurs(.+)Centre d'évaluation(.+)Accords de reconnaissance applicables",
+ ),
+ (
+ HEADER_TYPE.HEADER_FULL,
+ "Référence du rapport de certification(.+)Nom du produit(.+)Référence/version du produit(.+)Conformité aux profisl de protection(.+)Critères d’évaluation et version(.+)Niveau d’évaluation(.+)Développeurs(.+)Centre d’évaluation(.+)Accords de reconnaissance applicables",
+ ),
+ (
+ HEADER_TYPE.HEADER_FULL,
+ "Référence du rapport de certification(.+)Nom du produit(.+)Référence/version du produit(.+)Conformité à un profil de protection(.+)Critères d’évaluation et version(.+)Niveau d’évaluation(.+)Développeur (.+)Centres d’évaluation(.+)Accords de reconnaissance applicables",
+ ),
+ (
+ HEADER_TYPE.HEADER_FULL,
+ "Référence du rapport de certification(.+)Nom du produit(.+)Version du produit(.+)Conformité à un profil de protection(.+)Critères d’évaluation et version(.+)Niveau d’évaluation(.+)Développeur (.+)Centre d’évaluation(.+)Accords de reconnaissance applicables",
+ ),
+ (
+ HEADER_TYPE.HEADER_FULL,
+ "Référence du rapport de certification(.+)Nom du produit(.+)Référence/version du produit(.+)Conformité aux profils de protection(.+)Critères d’évaluation et version(.+)Niveau d’évaluation(.+)Développeur\\(s\\)(.+)Centre d’évaluation(.+)Accords de reconnaissance applicables",
+ ),
+ (
+ HEADER_TYPE.HEADER_FULL,
+ "Référence du rapport de certification(.+)Nom du produit(.+)Versions du produit(.+)Conformité à un profil de protection(.+)Critères d’évaluation et version(.+)Niveau d’évaluation(.+)Développeur (.+)Centre d’évaluation(.+)Accords de reconnaissance applicables",
+ ),
+ (
+ HEADER_TYPE.HEADER_FULL,
+ "Référence du rapport de certification(.+)Nom du produit(.+)Référence du produit(.+)Conformité à un profil de protection(.+)Critères d’évaluation et version(.+)Niveau d’évaluation(.+)Développeurs(.+)Centre d’évaluation(.+)Accords de reconnaissance applicables",
+ ),
+ (
+ HEADER_TYPE.HEADER_FULL,
+ "Certification report reference(.+)Product name(.+)Product reference(.+)Protection profile conformity(.+)Evaluation criteria and version(.+)Evaluation level(.+)Developer (.+)Evaluation facility(.+)Recognition arrangements",
+ ),
+ (
+ HEADER_TYPE.HEADER_FULL,
+ "Certification report reference(.+)Product name(.+)Product reference(.+)Protection profile conformity(.+)Evaluation criteria and version(.+)Evaluation level(.+)Developer (.+)Evaluation facility(.+)Mutual Recognition Agreements",
+ ),
+ (
+ HEADER_TYPE.HEADER_FULL,
+ "Certification report reference(.+)Product name(.+)Product reference(.+)Protection profile conformity(.+)Evaluation criteria and version(.+)Evaluation level(.+)Developers(.+)Evaluation facility(.+)Recognition arrangements",
+ ),
+ (
+ HEADER_TYPE.HEADER_FULL,
+ "Certification report reference(.+)Product name(.+)Product reference(.+)Protection profile conformity(.+)Evaluation criteria and version(.+)Evaluation level(.+)Developer\\(s\\)(.+)Evaluation facility(.+)Recognition arrangements",
+ ),
+ (
+ HEADER_TYPE.HEADER_FULL,
+ "Certification report reference(.+)Products names(.+)Products references(.+)protection profile conformity(.+)Evaluation criteria and version(.+)Evaluation level(.+)Developers(.+)Evaluation facility(.+)Recognition arrangements",
+ ),
+ (
+ HEADER_TYPE.HEADER_FULL,
+ "Certification report reference(.+)Product name \\(reference / version\\)(.+)TOE name \\(reference / version\\)(.+)Protection profile conformity(.+)Evaluation criteria and version(.+)Evaluation level(.+)Developers(.+)Evaluation facility(.+)Recognition arrangements",
+ ),
+ (
+ HEADER_TYPE.HEADER_FULL,
+ "Certification report reference(.+)TOE name(.+)Product's reference/ version(.+)TOE's reference/ version(.+)Conformité à un profil de protection(.+)Evaluation criteria and version(.+)Evaluation level(.+)Developer (.+)Evaluation facility(.+)Recognition arrangements",
+ ),
+ # corrupted text (duplicities)
+ (
+ HEADER_TYPE.HEADER_DUPLICITIES,
+ "Référencce du rapport de d certification n(.+)Nom du p produit(.+)Référencce/version du produit(.+)Conformiité à un profil de d protection(.+)Critères d d’évaluation ett version(.+)Niveau d’’évaluation(.+)Développ peurs(.+)Centre d’’évaluation(.+)Accords d de reconnaisssance applicab bles",
+ ),
+ # rules without product version
+ (
+ HEADER_TYPE.HEADER_MISSING_CERT_ITEM_VERSION,
+ "Référence du rapport de certification(.+)Nom et version du produit(.+)Conformité à un profil de protection(.+)Critères d'évaluation et version(.+)Niveau d'évaluation(.+)Développeurs(.+)Centre d'évaluation(.+)Accords de reconnaissance applicables",
+ ),
+ (
+ HEADER_TYPE.HEADER_MISSING_CERT_ITEM_VERSION,
+ "Référence du rapport de certification(.+)Nom et version du produit(.+)Conformité à un profil de protection(.+)Critères d'évaluation et version(.+)Niveau d'évaluation(.+)Développeur (.+)Centre d'évaluation(.+)Accords de reconnaissance applicables",
+ ),
+ (
+ HEADER_TYPE.HEADER_MISSING_CERT_ITEM_VERSION,
+ "Référence du rapport de certification(.+)Nom du produit(.+)Conformité à un profil de protection(.+)Critères d'évaluation et version(.+)Niveau d'évaluation(.+)Développeurs(.+)Centre d'évaluation(.+)Accords de reconnaissance applicables",
+ ),
+ # rules without protection profile
+ (
+ HEADER_TYPE.HEADER_MISSING_PROTECTION_PROFILES,
+ "Référence du rapport de certification(.+)Nom du produit(.+)Référence/version du produit(.+)Critères d'évaluation et version(.+)Niveau d'évaluation(.+)Développeurs(.+)Centre d'évaluation(.+)Accords de reconnaissance applicables",
+ ),
+ ]
+
+ # statistics about rules success rate
+ num_rules_hits = {}
+ for rule in rules_certificate_preface:
+ num_rules_hits[rule[1]] = 0
+
+ items_found = {} # type: ignore # noqa
+
+ try:
+ whole_text, whole_text_with_newlines, was_unicode_decode_error = load_text_file(filepath)
+
+ # for ANSII and DCSSI certificates, front page starts only on third page after 2 newpage signs
+ pos = whole_text.find(" ")
+ if pos != -1:
+ pos = whole_text.find(" ", pos)
+ if pos != -1:
+ whole_text = whole_text[pos:]
+
+ no_match_yet = True
+ other_rule_already_match = False
+ rule_index = -1
+ for rule in rules_certificate_preface:
+ rule_index += 1
+ rule_and_sep = rule[1] + REGEXEC_SEP
+
+ for m in re.finditer(rule_and_sep, whole_text):
+ if no_match_yet:
+ items_found[constants.TAG_HEADER_MATCH_RULES] = []
+ no_match_yet = False
+
+ # insert rule if at least one match for it was found
+ if rule not in items_found[constants.TAG_HEADER_MATCH_RULES]:
+ items_found[constants.TAG_HEADER_MATCH_RULES].append(rule[1])
+
+ if not other_rule_already_match:
+ other_rule_already_match = True
+ else:
+ logger.warning(f"WARNING: multiple rules are matching same certification document: {filepath}")
+
+ num_rules_hits[rule[1]] += 1 # add hit to this rule
+ match_groups = m.groups()
+ index_next_item = 0
+ items_found[constants.TAG_CERT_ID] = normalize_match_string(match_groups[index_next_item])
+ index_next_item += 1
+
+ items_found[constants.TAG_CERT_ITEM] = normalize_match_string(match_groups[index_next_item])
+ index_next_item += 1
+
+ if rule[0] == HEADER_TYPE.HEADER_MISSING_CERT_ITEM_VERSION:
+ items_found[constants.TAG_CERT_ITEM_VERSION] = ""
+ else:
+ items_found[constants.TAG_CERT_ITEM_VERSION] = normalize_match_string(match_groups[index_next_item])
+ index_next_item += 1
+
+ if rule[0] == HEADER_TYPE.HEADER_MISSING_PROTECTION_PROFILES:
+ items_found[constants.TAG_REFERENCED_PROTECTION_PROFILES] = ""
+ else:
+ items_found[constants.TAG_REFERENCED_PROTECTION_PROFILES] = normalize_match_string(
+ match_groups[index_next_item]
+ )
+ index_next_item += 1
+
+ items_found[constants.TAG_CC_VERSION] = normalize_match_string(match_groups[index_next_item])
+ index_next_item += 1
+
+ items_found[constants.TAG_CC_SECURITY_LEVEL] = normalize_match_string(match_groups[index_next_item])
+ index_next_item += 1
+
+ items_found[constants.TAG_DEVELOPER] = normalize_match_string(match_groups[index_next_item])
+ index_next_item += 1
+
+ items_found[constants.TAG_CERT_LAB] = normalize_match_string(match_groups[index_next_item])
+ index_next_item += 1
+ except Exception as e:
+ relative_filepath = "/".join(str(filepath).split("/")[-4:])
+ error_msg = f"Failed to parse ANSSI frontpage headers from {relative_filepath}; {e}"
+ logger.error(error_msg)
+ return error_msg, None
+
+ # if True:
+ # print('# hits for rule')
+ # sorted_rules = sorted(num_rules_hits.items(),
+ # key=operator.itemgetter(1), reverse=True)
+ # used_rules = []
+ # for rule in sorted_rules:
+ # print('{:4d} : {}'.format(rule[1], rule[0]))
+ # if rule[1] > 0:
+ # used_rules.append(rule[0])
+
+ return constants.RETURNCODE_OK, items_found
+
+
+def search_only_headers_bsi(filepath: Path): # noqa: C901
+ # TODO: Please, refactor me. I reallyyyyyyyyyyyyy need it!!!!!!
+ LINE_SEPARATOR_STRICT = " "
+ NUM_LINES_TO_INVESTIGATE = 15
+ rules_certificate_preface = [
+ "(BSI-DSZ-CC-.+?) (?:for|For) (.+?) from (.*)",
+ "(BSI-DSZ-CC-.+?) zu (.+?) der (.*)",
+ ]
+
+ items_found = {} # type: ignore # noqa
+ no_match_yet = True
+
+ try:
+ # Process front page with info: cert_id, certified_item and developer
+ whole_text, whole_text_with_newlines, was_unicode_decode_error = load_text_file(
+ filepath, NUM_LINES_TO_INVESTIGATE, LINE_SEPARATOR_STRICT
+ )
+
+ for rule in rules_certificate_preface:
+ rule_and_sep = rule + REGEXEC_SEP
+
+ for m in re.finditer(rule_and_sep, whole_text):
+ if no_match_yet:
+ items_found[constants.TAG_HEADER_MATCH_RULES] = []
+ no_match_yet = False
+
+ # insert rule if at least one match for it was found
+ if rule not in items_found[constants.TAG_HEADER_MATCH_RULES]:
+ items_found[constants.TAG_HEADER_MATCH_RULES].append(rule)
+
+ match_groups = m.groups()
+ cert_id = match_groups[0]
+ certified_item = match_groups[1]
+ developer = match_groups[2]
+
+ FROM_KEYWORD_LIST = [" from ", " der "]
+ for from_keyword in FROM_KEYWORD_LIST:
+ from_keyword_len = len(from_keyword)
+ if certified_item.find(from_keyword) != -1:
+ logger.warning(
+ f"string {from_keyword} detected in certified item - shall not be here, fixing..."
+ )
+ certified_item_first = certified_item[: certified_item.find(from_keyword)]
+ developer = certified_item[certified_item.find(from_keyword) + from_keyword_len :]
+ certified_item = certified_item_first
+ continue
+
+ end_pos = developer.find("\f-")
+ if end_pos == -1:
+ end_pos = developer.find("\fBSI")
+ if end_pos == -1:
+ end_pos = developer.find("Bundesamt")
+ if end_pos != -1:
+ developer = developer[:end_pos]
+
+ items_found[constants.TAG_CERT_ID] = normalize_match_string(cert_id)
+ items_found[constants.TAG_CERT_ITEM] = normalize_match_string(certified_item)
+ items_found[constants.TAG_DEVELOPER] = normalize_match_string(developer)
+ items_found[constants.TAG_CERT_LAB] = "BSI"
+
+ # Process page with more detailed sample info
+ # PP Conformance, Functionality, Assurance
+ rules_certificate_third = ["PP Conformance: (.+)Functionality: (.+)Assurance: (.+)The IT Product identified"]
+
+ whole_text, whole_text_with_newlines, was_unicode_decode_error = load_text_file(filepath)
+
+ for rule in rules_certificate_third:
+ rule_and_sep = rule + REGEXEC_SEP
+
+ for m in re.finditer(rule_and_sep, whole_text):
+ # check if previous rules had at least one match
+ if constants.TAG_CERT_ID not in items_found.keys():
+ logger.error(f"ERROR: front page not found for file: {filepath}")
+
+ match_groups = m.groups()
+ ref_protection_profiles = match_groups[0]
+ cc_version = match_groups[1]
+ cc_security_level = match_groups[2]
+
+ items_found[constants.TAG_REFERENCED_PROTECTION_PROFILES] = normalize_match_string(
+ ref_protection_profiles
+ )
+ items_found[constants.TAG_CC_VERSION] = normalize_match_string(cc_version)
+ items_found[constants.TAG_CC_SECURITY_LEVEL] = normalize_match_string(cc_security_level)
+
+ # print('\n*** Certificates without detected preface:')
+ # for file_name in files_without_match:
+ # print('No hits for {}'.format(file_name))
+ # print('Total no hits files: {}'.format(len(files_without_match)))
+ # print('\n**********************************')
+ except Exception as e:
+ relative_filepath = "/".join(str(filepath).split("/")[-4:])
+ error_msg = f"Failed to parse BSI headers from frontpage: {relative_filepath}; {e}"
+ logger.error(error_msg)
+ return error_msg, None
+
+ return constants.RETURNCODE_OK, items_found
+
+
+def search_only_headers_nscib(filepath: Path): # noqa: C901
+ # TODO: Please, refactor me. I reallyyyyyyyyyyyyy need it!!!!!!
+ LINE_SEPARATOR_STRICT = " "
+ NUM_LINES_TO_INVESTIGATE = 60
+ items_found: dict[str, str] = {}
+
+ try:
+ # Process front page with info: cert_id, certified_item and developer
+ whole_text, whole_text_with_newlines, was_unicode_decode_error = load_text_file(
+ filepath, NUM_LINES_TO_INVESTIGATE, LINE_SEPARATOR_STRICT
+ )
+
+ certified_item = ""
+ developer = ""
+ cert_lab = ""
+ cert_id = ""
+
+ lines = whole_text_with_newlines.splitlines()
+ no_match_yet = True
+ item_offset = -1
+
+ for line_index in range(0, len(lines)):
+ line = lines[line_index]
+
+ if "Certification Report" in line:
+ item_offset = line_index + 1
+ if "Assurance Continuity Maintenance Report" in line:
+ item_offset = line_index + 1
+
+ SPONSORDEVELOPER_STR = "Sponsor and developer:"
+
+ if SPONSORDEVELOPER_STR in line:
+ if no_match_yet:
+ items_found = {}
+ no_match_yet = False
+
+ # all lines above till 'Certification Report' or 'Assurance Continuity Maintenance Report'
+ certified_item = ""
+ for name_index in range(item_offset, line_index):
+ certified_item += lines[name_index] + " "
+ developer = line[line.find(SPONSORDEVELOPER_STR) + len(SPONSORDEVELOPER_STR) :]
+
+ SPONSOR_STR = "Sponsor:"
+
+ if SPONSOR_STR in line:
+ if no_match_yet:
+ items_found = {}
+ no_match_yet = False
+
+ # all lines above till 'Certification Report' or 'Assurance Continuity Maintenance Report'
+ certified_item = ""
+ for name_index in range(item_offset, line_index):
+ certified_item += lines[name_index] + " "
+
+ DEVELOPER_STR = "Developer:"
+ if DEVELOPER_STR in line:
+ developer = line[line.find(DEVELOPER_STR) + len(DEVELOPER_STR) :]
+
+ CERTLAB_STR = "Evaluation facility:"
+ if CERTLAB_STR in line:
+ cert_lab = line[line.find(CERTLAB_STR) + len(CERTLAB_STR) :]
+
+ REPORTNUM_STR = "Report number:"
+ if REPORTNUM_STR in line:
+ cert_id = line[line.find(REPORTNUM_STR) + len(REPORTNUM_STR) :]
+
+ if not no_match_yet:
+ items_found[constants.TAG_CERT_ID] = normalize_match_string(cert_id)
+ items_found[constants.TAG_CERT_ITEM] = normalize_match_string(certified_item)
+ items_found[constants.TAG_DEVELOPER] = normalize_match_string(developer)
+ items_found[constants.TAG_CERT_LAB] = cert_lab
+
+ except Exception as e:
+ error_msg = f"Failed to parse NSCIB headers from frontpage: {filepath}; {e}"
+ logger.error(error_msg)
+ return error_msg, None
+
+ return constants.RETURNCODE_OK, items_found
+
+
+def search_only_headers_niap(filepath: Path):
+ # TODO: Please, refactor me. I reallyyyyyyyyyyyyy need it!!!!!!
+ LINE_SEPARATOR_STRICT = " "
+ NUM_LINES_TO_INVESTIGATE = 15
+ items_found: dict[str, str] = {}
+
+ try:
+ # Process front page with info: cert_id, certified_item and developer
+ whole_text, whole_text_with_newlines, was_unicode_decode_error = load_text_file(
+ filepath, NUM_LINES_TO_INVESTIGATE, LINE_SEPARATOR_STRICT
+ )
+
+ certified_item = ""
+ cert_id = ""
+
+ lines = whole_text_with_newlines.splitlines()
+ no_match_yet = True
+ item_offset = -1
+
+ for line_index in range(0, len(lines)):
+ line = lines[line_index]
+
+ if "Validation Report" in line:
+ item_offset = line_index + 1
+
+ REPORTNUM_STR = "Report Number:"
+ if REPORTNUM_STR in line:
+ if no_match_yet:
+ items_found = {}
+ no_match_yet = False
+
+ # all lines above till 'Certification Report' or 'Assurance Continuity Maintenance Report'
+ certified_item = ""
+ for name_index in range(item_offset, line_index):
+ certified_item += lines[name_index] + " "
+ cert_id = line[line.find(REPORTNUM_STR) + len(REPORTNUM_STR) :]
+ break
+
+ if not no_match_yet:
+ items_found[constants.TAG_CERT_ID] = normalize_match_string(cert_id)
+ items_found[constants.TAG_CERT_ITEM] = normalize_match_string(certified_item)
+ items_found[constants.TAG_CERT_LAB] = "US NIAP"
+
+ except Exception as e:
+ error_msg = f"Failed to parse NIAP headers from frontpage: {filepath}; {e}"
+ logger.error(error_msg)
+ return error_msg, None
+
+ return constants.RETURNCODE_OK, items_found
+
+
+def search_only_headers_canada(filepath: Path): # noqa: C901
+ # TODO: Please, refactor me. I reallyyyyyyyyyyyyy need it!!!!!!
+ LINE_SEPARATOR_STRICT = " "
+ NUM_LINES_TO_INVESTIGATE = 20
+ items_found: dict[str, str] = {}
+ try:
+ whole_text, whole_text_with_newlines, was_unicode_decode_error = load_text_file(
+ filepath, NUM_LINES_TO_INVESTIGATE, LINE_SEPARATOR_STRICT
+ )
+
+ cert_id = ""
+
+ lines = whole_text_with_newlines.splitlines()
+ no_match_yet = True
+ for line_index in range(0, len(lines)):
+ line = lines[line_index]
+ if "Government of Canada, Communications Security Establishment" in line:
+ REPORTNUM_STR1 = "Evaluation number:"
+ REPORTNUM_STR2 = "Document number:"
+ matched_number_str = ""
+ line_certid = lines[line_index + 1]
+ if line_certid.startswith(REPORTNUM_STR1):
+ matched_number_str = REPORTNUM_STR1
+ if line_certid.startswith(REPORTNUM_STR2):
+ matched_number_str = REPORTNUM_STR2
+ if matched_number_str != "":
+ if no_match_yet:
+ items_found = {}
+ no_match_yet = False
+
+ cert_id = line_certid[line_certid.find(matched_number_str) + len(matched_number_str) :]
+ break
+
+ if (
+ "Government of Canada. This document is the property of the Government of Canada. It shall not be altered,"
+ in line
+ ):
+ REPORTNUM_STR = "Evaluation number:"
+ for offset in range(1, 20):
+ line_certid = lines[line_index + offset]
+ if "UNCLASSIFIED" in line_certid:
+ if no_match_yet:
+ items_found = {}
+ no_match_yet = False
+ line_certid = lines[line_index + offset - 4]
+ cert_id = line_certid[line_certid.find(REPORTNUM_STR) + len(REPORTNUM_STR) :]
+ break
+ if not no_match_yet:
+ break
+
+ if (
+ "UNCLASSIFIED / NON CLASSIFIÉ" in line
+ and "COMMON CRITERIA CERTIFICATION REPORT" in lines[line_index + 2]
+ ):
+ line_certid = lines[line_index + 1]
+ if no_match_yet:
+ items_found = {}
+ no_match_yet = False
+ cert_id = line_certid
+ break
+
+ if not no_match_yet and cert_id:
+ items_found[constants.TAG_CERT_ID] = normalize_match_string(cert_id)
+ items_found[constants.TAG_CERT_LAB] = "CANADA"
+
+ except Exception as e:
+ error_msg = f"Failed to parse Canada headers from frontpage: {filepath}; {e}"
+ logger.error(error_msg)
+ return error_msg, None
+
+ return constants.RETURNCODE_OK, items_found
+
+
+def search_files(folder: str | Path) -> Iterator[str]:
+ for root, _, files in os.walk(str(folder)):
+ yield from [os.path.join(root, x) for x in files]
+
+
+def flatten_matches(dct: dict) -> dict:
+ """
+ Function to flatten dictionary of matches.
+
+ Turns
+ ```
+ {"a": {"cc": 3}, "b": {}, "d": {"dd": 4, "cc": 2}}
+ ```
+ into
+ ```
+ {"cc": 5, "dd": 4}
+ ```
+
+ :param dct: Dictionary to flatten
+ :return: Flattened dictionary
+ """
+ result: Counter[Any] = Counter()
+ for key, value in dct.items():
+ if isinstance(value, dict):
+ result.update(flatten_matches(value))
+ else:
+ result[key] = value
+ return dict(result)
+
+
+def prune_matches(dct: dict) -> dict:
+ """
+ Prune a dictionary of matches.
+
+ Turns
+ ```
+ {"a": {"cc": 3}, "b": {"aa": {}, "bb": {}}, "d": {"dd": 4, "cc": 2}}
+ ```
+ into
+ ```
+ {"a": {"cc": 3}, "b": {}, "d": {"dd": 4, "cc": 2}}
+ ```
+
+ :param dct: The dictionary of matches.
+ :return: The pruned dictionary.
+ """
+
+ def walk(obj, depth):
+ if isinstance(obj, dict):
+ if not obj:
+ return None
+ res = {}
+ for k, v in obj.items():
+ r = walk(v, depth + 1)
+ if r is not None:
+ res[k] = r
+ return res if res or depth == 1 else None
+ else:
+ return obj
+
+ return walk(dct, 0)
+
+
+def extract_keywords(filepath: Path, search_rules) -> dict[str, dict[str, int]] | None:
+ """
+ Extract keywords from filepath using the search rules.
+
+ :param filepath:
+ :param search_rules:
+ :return:
+ """
+
+ try:
+ whole_text, whole_text_with_newlines, was_unicode_decode_error = load_text_file(filepath, -1, LINE_SEPARATOR)
+
+ def extract(rules):
+ if isinstance(rules, dict):
+ return {k: extract(v) for k, v in rules.items()}
+ elif isinstance(rules, list):
+ matches = [extract(rule) for rule in rules]
+ c = Counter()
+ for match_list in matches:
+ c += Counter(match_list)
+ return dict(c)
+ elif isinstance(rules, re.Pattern):
+ rule = rules
+ matches = []
+ for match in rule.finditer(whole_text):
+ match = match.group("match")
+ match = normalize_match_string(match)
+
+ match_len = len(match)
+ if match_len > MAX_ALLOWED_MATCH_LENGTH:
+ logger.warning(f"Excessive match with length of {match_len} detected for rule {rule.pattern}")
+ matches.append(match)
+ return matches
+
+ result = extract(search_rules)
+ return prune_matches(result)
+ except Exception as e:
+ relative_filepath = "/".join(str(filepath).split("/")[-4:])
+ error_msg = f"Failed to parse keywords from: {relative_filepath}; {e}"
+ logger.error(error_msg)
+ return None
+
+
+def normalize_match_string(match: str) -> str:
+ match = match.strip().strip("[];.”\"':)(,").rstrip(os.sep).replace(" ", " ")
+ return "".join(filter(str.isprintable, match))
+
+
+def load_text_file(
+ file_name: str | Path, limit_max_lines: int = -1, line_separator: str = LINE_SEPARATOR
+) -> tuple[str, str, bool]:
+ """
+ Load the text contents of a file at `file_name`, upto `limit_max_lines` of lines, replace
+ newlines in the text with `line_separator`.
+
+ :param file_name: The file_name to load.
+ :param limit_max_lines: The limit on number of lines to return.
+ :param line_separator: The string to replace newlines with.
+ :return: A tuple of three elements (the text with replaced newlines, the text and a boolean whether a unicode
+ decoding error happened).
+ """
+ lines = []
+ was_unicode_decode_error = False
+ with Path(file_name).open("r", errors=FILE_ERRORS_STRATEGY) as f:
+ try:
+ lines = f.readlines()
+ except UnicodeDecodeError:
+ was_unicode_decode_error = True
+ logger.warning("UnicodeDecodeError, opening as utf8")
+
+ if was_unicode_decode_error:
+ with open(file_name, encoding="utf8", errors=FILE_ERRORS_STRATEGY) as f2:
+ # coding failure, try line by line
+ line = " "
+ while line:
+ try:
+ line = f2.readline()
+ lines.append(line)
+ except UnicodeDecodeError:
+ # ignore error
+ continue
+
+ whole_text = ""
+ whole_text_with_newlines = ""
+ lines_included = 0
+ for line in lines:
+ if limit_max_lines != -1 and lines_included >= limit_max_lines:
+ break
+
+ whole_text_with_newlines += line
+ line = line.replace("\n", "")
+ whole_text += line
+ whole_text += line_separator
+ lines_included += 1
+
+ return whole_text, whole_text_with_newlines, was_unicode_decode_error
+
+
+def load_cert_html_file(file_name: str) -> str:
+ with open(file_name, errors=FILE_ERRORS_STRATEGY) as f:
+ try:
+ return f.read()
+ except UnicodeDecodeError:
+ logger.warning("UnicodeDecodeError, opening as utf8")
+
+ with open(file_name, encoding="utf8", errors=FILE_ERRORS_STRATEGY) as f2:
+ try:
+ return f2.read()
+ except UnicodeDecodeError:
+ logger.error(f"Failed to read file {file_name}")
+ return ""
+
+
+def rules_get_subset(desired_path: str) -> dict:
+ """
+ Recursively applies cc_certs.get(key) on tokens from desired_path,
+ returns the keys of the inner-most layer.
+ """
+ dct = cc_rules
+ for token in desired_path.split("."):
+ dct = dct[token]
+ return dct
+
+
+def extract_key_paths(dct: dict, current_path: str) -> list[str]:
+ """
+ Given subset of cc_rules dictionary, will compute full paths to all leafs
+ in the dictionaries, s.t. the final value of each path is a list of regex
+ matches in the keywords dictionary.
+ """
+ paths = []
+ for key in dct:
+ if isinstance(dct[key], dict):
+ paths.extend(extract_key_paths(dct[key], current_path + "." + key))
+ elif isinstance(dct[key], list):
+ paths.append(current_path + "." + key)
+ return paths
+
+
+def get_sum_of_values_from_dict_path(dct: dict | None, path: str, default: float = np.nan) -> float:
+ """
+ Given dictionary and path, will compute sum of occurences of values in the inner-most layer
+ of that path. If the key is missing from dict, return default value.
+ """
+ if not dct:
+ return np.nan
+
+ res = dct
+
+ try:
+ for token in path.split("."):
+ res = res[token]
+ except KeyError:
+ return default
+
+ return sum(res.values())
+
+
+def get_sums_for_rules_subset(dct: dict | None, path: str) -> dict[str, float]:
+ """
+ Given path to search in cc_rules (e.g., "symmetric_crypto"),
+ will get the finest resolution and count occurences of the keys in the
+ examined dictionary.
+ """
+ cc_rules_subset_to_search = rules_get_subset(path)
+ paths_to_search = extract_key_paths(cc_rules_subset_to_search, path)
+ return {x: get_sum_of_values_from_dict_path(dct, x, np.nan) for x in paths_to_search}
diff --git a/src/sec_certs/utils/helpers.py b/src/sec_certs/utils/helpers.py
new file mode 100644
index 00000000..302f4e6a
--- /dev/null
+++ b/src/sec_certs/utils/helpers.py
@@ -0,0 +1,239 @@
+from __future__ import annotations
+
+import hashlib
+import logging
+import re
+import time
+from contextlib import nullcontext
+from datetime import datetime
+from functools import partial
+from pathlib import Path
+from typing import Any, Collection
+
+import numpy as np
+import pkgconfig
+import requests
+
+import sec_certs.constants as constants
+from sec_certs.config.configuration import config
+from sec_certs.utils import parallel_processing
+from sec_certs.utils.tqdm import tqdm
+
+logger = logging.getLogger(__name__)
+
+
+def download_file(
+ url: str, output: Path, delay: float = 0, show_progress_bar: bool = False, progress_bar_desc: str | None = None
+) -> str | int:
+ try:
+ time.sleep(delay)
+ # See https://github.com/psf/requests/issues/3953 for header justification
+ r = requests.get(
+ url, allow_redirects=True, timeout=constants.REQUEST_TIMEOUT, stream=True, headers={"Accept-Encoding": None} # type: ignore
+ )
+ ctx: Any
+ if show_progress_bar:
+ ctx = partial(
+ tqdm,
+ total=int(r.headers.get("content-length", 0)),
+ unit="B",
+ unit_scale=True,
+ unit_divisor=1024,
+ desc=progress_bar_desc,
+ )
+ else:
+ ctx = nullcontext
+
+ if r.status_code == requests.codes.ok:
+ with ctx() as pbar:
+ with output.open("wb") as f:
+ for data in r.iter_content(1024):
+ f.write(data)
+ if show_progress_bar:
+ pbar.update(len(data))
+
+ return r.status_code
+ except requests.exceptions.Timeout:
+ return requests.codes.timeout
+ except Exception as e:
+ logger.error(f"Failed to download from {url}; {e}")
+ return constants.RETURNCODE_NOK
+ return constants.RETURNCODE_NOK
+
+
+def download_parallel(
+ urls: Collection[str], paths: Collection[Path], progress_bar_desc: str | None = None
+) -> list[int]:
+ exit_codes = parallel_processing.process_parallel(
+ download_file, list(zip(urls, paths)), config.n_threads, unpack=True, progress_bar_desc=progress_bar_desc
+ )
+ n_successful = len([e for e in exit_codes if e == requests.codes.ok])
+ logger.info(f"Successfully downloaded {n_successful} files, {len(exit_codes) - n_successful} failed.")
+
+ for url, e in zip(urls, exit_codes):
+ if e != requests.codes.ok:
+ logger.error(f"Failed to download {url}, exit code: {e}")
+
+ return exit_codes
+
+
+def fips_dgst(cert_id: int | str) -> str:
+ return get_first_16_bytes_sha256(str(cert_id))
+
+
+def get_first_16_bytes_sha256(string: str) -> str:
+ return hashlib.sha256(string.encode("utf-8")).hexdigest()[:16]
+
+
+def get_sha256_filepath(filepath: str | Path) -> str:
+ hash_sha256 = hashlib.sha256()
+ with Path(filepath).open("rb") as f:
+ for chunk in iter(lambda: f.read(4096), b""):
+ hash_sha256.update(chunk)
+ return hash_sha256.hexdigest()
+
+
+def to_utc(timestamp: datetime) -> datetime:
+ offset = timestamp.utcoffset()
+ if offset is None:
+ return timestamp
+ timestamp -= offset
+ timestamp = timestamp.replace(tzinfo=None)
+ return timestamp
+
+
+def is_in_dict(target_dict: dict, path: str) -> bool:
+ current_level = target_dict
+ for item in path:
+ if item not in current_level:
+ return False
+ else:
+ current_level = current_level[item]
+ return True
+
+
+def compute_heuristics_version(cert_name: str) -> set[str]:
+ """
+ Will extract possible versions from the name of sample
+ """
+ at_least_something = r"(\b(\d)+\b)"
+ just_numbers = r"(\d{1,5})(\.\d{1,5})"
+
+ without_version = r"(" + just_numbers + r"+)"
+ long_version = r"(" + r"(\bversion)\s*" + just_numbers + r"+)"
+ short_version = r"(" + r"\bv\s*" + just_numbers + r"+)"
+ full_regex_string = r"|".join([without_version, short_version, long_version])
+ normalizer = r"(\d+\.*)+"
+
+ matched_strings = [max(x, key=len) for x in re.findall(full_regex_string, cert_name, re.IGNORECASE)]
+ if not matched_strings:
+ matched_strings = [max(x, key=len) for x in re.findall(at_least_something, cert_name, re.IGNORECASE)]
+ # Only keep the first occurrence but keep order.
+ matches = []
+ for match in matched_strings:
+ if match not in matches:
+ matches.append(match)
+ # identified_versions = list(set([max(x, key=len) for x in re.findall(VERSION_PATTERN, cert_name, re.IGNORECASE | re.VERBOSE)]))
+ # return identified_versions if identified_versions else ['-']
+
+ if not matches:
+ return {constants.CPE_VERSION_NA}
+
+ matched = [re.search(normalizer, x) for x in matches]
+ return {x.group() for x in matched if x is not None}
+
+
+def tokenize_dataset(dset: list[str], keywords: set[str]) -> np.ndarray:
+ return np.array([tokenize(x, keywords) for x in dset])
+
+
+def tokenize(string: str, keywords: set[str]) -> str:
+ return " ".join([x for x in string.split() if x.lower() in keywords])
+
+
+def normalize_fips_vendor(string: str) -> str:
+ """
+ "Normalizes" FIPS vendor. Precisely:
+ - Removes some punctuation and non-alphanumerical symbols
+ - Returns only first 5 tokens
+ # TODO: The rationale of the steps outlined above should be investigatated
+ """
+ return " ".join(
+ string.replace("(R)", "").replace(",", "").replace("®", "").replace("-", " ").replace("+", " ").split()[:4]
+ )
+
+
+# Credit: https://stackoverflow.com/questions/18092354/
+def split_unescape(s: str, delim: str, escape: str = "\\", unescape: bool = True) -> list[str]:
+ """
+ >>> split_unescape('foo,bar', ',')
+ ['foo', 'bar']
+ >>> split_unescape('foo$,bar', ',', '$')
+ ['foo,bar']
+ >>> split_unescape('foo$$,bar', ',', '$', unescape=True)
+ ['foo$', 'bar']
+ >>> split_unescape('foo$$,bar', ',', '$', unescape=False)
+ ['foo$$', 'bar']
+ >>> split_unescape('foo$', ',', '$', unescape=True)
+ ['foo$']
+ """
+ ret = []
+ current = []
+ itr = iter(s)
+ for ch in itr:
+ if ch == escape:
+ try:
+ # skip the next character; it has been escaped!
+ if not unescape:
+ current.append(escape)
+ current.append(next(itr))
+ except StopIteration:
+ if unescape:
+ current.append(escape)
+ elif ch == delim:
+ # split! (add current to the list and reset it)
+ ret.append("".join(current))
+ current = []
+ else:
+ current.append(ch)
+ ret.append("".join(current))
+ return ret
+
+
+def warn_if_missing_poppler() -> None:
+ """
+ Warns user if he misses a poppler dependency
+ """
+ try:
+ if not pkgconfig.installed("poppler-cpp", ">=0.30"):
+ logger.warning(
+ "Attempting to run pipeline with pdf->txt conversion, but poppler-cpp dependency was not found."
+ )
+ except OSError:
+ logger.warning("Attempting to find poppler-cpp, but pkg-config was not found.")
+
+
+def warn_if_missing_tesseract() -> None:
+ """
+ Warns user if he misses a tesseract dependency
+ """
+ try:
+ if not pkgconfig.installed("tesseract", ">=5.0.0"):
+ logger.warning(
+ "Attempting to run pipeline with pdf->txt conversion, that requires tesseract, but tesseract was not found."
+ )
+ except OSError:
+ logger.warning("Attempting to find tesseract, but pkg-config was not found.")
+
+
+def choose_lowest_eal(eals: set[str] | None) -> str | None:
+ """
+ Given a set of EAL strings, chooses the lowest one.
+ """
+ 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]
diff --git a/src/sec_certs/utils/pandas.py b/src/sec_certs/utils/pandas.py
new file mode 100644
index 00000000..97068e77
--- /dev/null
+++ b/src/sec_certs/utils/pandas.py
@@ -0,0 +1,542 @@
+from __future__ import annotations
+
+import copy
+import functools
+import logging
+import tempfile
+import xml.etree.ElementTree as ET
+import zipfile
+from dataclasses import dataclass
+from pathlib import Path
+from shutil import copyfile
+from typing import Any, Final
+
+import numpy as np
+import pandas as pd
+from matplotlib import pyplot as plt
+from scipy import stats
+from tqdm.notebook import tqdm
+
+from sec_certs.dataset.cve import CVEDataset
+from sec_certs.sample.sar import SAR
+from sec_certs.utils import helpers
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass(eq=True, frozen=True)
+class SecondarySFPCluster:
+ name: str
+ children: frozenset[int]
+
+ @classmethod
+ def from_xml_id(cls, xml_categories: list[ET.Element], cwe_id: int):
+ cat = cls.find_correct_category(xml_categories, cwe_id)
+ name = cat.attrib["Name"]
+ members = cat.find("{http://cwe.mitre.org/cwe-6}Relationships")
+
+ assert members is not None
+ member_ids = frozenset(
+ int(x.attrib["CWE_ID"]) for x in members if x.tag == "{http://cwe.mitre.org/cwe-6}Has_Member"
+ )
+ return cls(name, member_ids)
+
+ @staticmethod
+ def find_correct_category(xml_categories: list[ET.Element], cwe_id: int) -> ET.Element:
+ for cat in xml_categories:
+ if cat.attrib["ID"] == str(cwe_id):
+ return cat
+ raise ValueError(f"Category with ID {cwe_id} found.")
+
+
+@dataclass(eq=True, frozen=True)
+class PrimarySFPCluster:
+ name: str
+ secondary_clusters: frozenset[SecondarySFPCluster]
+ cwe_ids: frozenset[int]
+
+ @classmethod
+ def from_xml(cls, xml_categories: list[ET.Element], primary_cluster_element: ET.Element):
+ name = primary_cluster_element.attrib["Name"].split("SFP Primary Cluster: ")[1]
+ members = primary_cluster_element.find("{http://cwe.mitre.org/cwe-6}Relationships")
+
+ assert members is not None
+ member_ids = {int(x.attrib["CWE_ID"]) for x in members if x.tag == "{http://cwe.mitre.org/cwe-6}Has_Member"}
+
+ secondary_clusters = []
+ cwe_ids = []
+ for member_id in member_ids:
+ try:
+ secondary_clusters.append(SecondarySFPCluster.from_xml_id(xml_categories, member_id))
+ except ValueError:
+ cwe_ids.append(member_id)
+
+ return cls(name, frozenset(secondary_clusters), frozenset(cwe_ids))
+
+
+class SFPModel:
+ URL: Final[str] = "https://cwe.mitre.org/data/xml/views/888.xml.zip"
+ XML_FILENAME: Final[str] = "888.xml"
+ XML_ZIP_NAME: Final[str] = "888.xml.zip"
+
+ def __init__(self, primary_clusters: frozenset[PrimarySFPCluster]):
+ self.primary_clusters = primary_clusters
+
+ @classmethod
+ def from_xml(cls, xml_filepath: str | Path):
+ tree = ET.parse(xml_filepath)
+ category_tag = tree.getroot().find("{http://cwe.mitre.org/cwe-6}Categories")
+
+ assert category_tag is not None
+ categories = category_tag.findall("{http://cwe.mitre.org/cwe-6}Category")
+
+ # The XML contains two weird primary clusters not specified in https://samate.nist.gov/BF/Enlightenment/SFP.html.
+ # After manual inspection, we skip those
+ primary_clusters = frozenset(
+ PrimarySFPCluster.from_xml(categories, x)
+ for x in categories
+ if (
+ "SFP Primary Cluster" in x.attrib["Name"]
+ and x.attrib["Name"] != "SFP Primary Cluster: Failure to Release Memory"
+ and x.attrib["Name"] != "SFP Primary Cluster: Faulty Resource Release"
+ )
+ )
+
+ return cls(primary_clusters)
+
+ @classmethod
+ def from_web(cls):
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ xml_zip_path = Path(tmp_dir) / cls.XML_ZIP_NAME
+ helpers.download_file(cls.URL, xml_zip_path)
+
+ with zipfile.ZipFile(xml_zip_path, "r") as zip_handle:
+ zip_handle.extractall(tmp_dir)
+
+ return cls.from_xml(Path(tmp_dir) / cls.XML_FILENAME)
+
+ def search_cwe(self, cwe_id: int) -> tuple[str | None, str | None]:
+ for primary in self.primary_clusters:
+ for secondary in primary.secondary_clusters:
+ if cwe_id in secondary.children:
+ return primary.name, secondary.name
+ if cwe_id in primary.cwe_ids:
+ return primary.name, None
+ return None, None
+
+
+def discover_sar_families(ser: pd.Series) -> list[str]:
+ """
+ Returns a list of all SAR families that occur in the pandas Series, where each entry is a set of SAR objects.
+ """
+ sars = ser.tolist()
+ families = set()
+ for cert in sars:
+ families |= {x.family for x in cert} if not pd.isnull(cert) else set()
+ return list(families)
+
+
+def get_sar_level_from_set(sars: set[SAR], sar_family: str) -> int | None:
+ """
+ Given a set of SARs and a family name, will return level of the seeked SAR from the set.
+ """
+ family_sars_dict = {x.family: x for x in sars} if (sars and not pd.isnull(sars)) else dict()
+ if sar_family not in family_sars_dict.keys():
+ return None
+ return family_sars_dict[sar_family].level
+
+
+def compute_cve_correlations(
+ df: pd.DataFrame,
+ exclude_vuln_free_certs: bool = False,
+ sar_families: list[str] | None = None,
+ output_path: str | Path | None = None,
+ filter_nans: bool = True,
+) -> pd.DataFrame:
+ """
+ Computes correlations of EAL and different SARs and two columns: (n_cves, worst_cve_score, avg_cve_score). Few assumptions about the passed dataframe:
+ - EAL column must be categorical data type
+ - SAR column must be a set of SARs
+ - `n_cves` and `worst_cve_score`, `avg_cve_score` columns must be present in the dataframe
+ Possibly, it can filter columns will both values NaN (due to division by zero or super low supports.)
+ To choose correct minimal support is tricky, this is because SAR levels often having huge support, but being imbalanced themselves heavily in the favor
+ of a single value that is rarely modified. We recommend choosing 100 and discarding any row where some column would result into NaN
+ """
+ df_sar = df.loc[:, ["eal", "extracted_sars", "worst_cve_score", "avg_cve_score", "n_cves", "category"]]
+ df_sar = df_sar.loc[df_sar.category != "ICs, Smart Cards and Smart Card-Related Devices and Systems"]
+
+ if exclude_vuln_free_certs:
+ df_sar = df_sar.loc[df_sar.n_cves > 0]
+
+ families = sar_families if sar_families else discover_sar_families(df_sar.extracted_sars)
+
+ spearmanr = functools.partial(stats.spearmanr, nan_policy="omit", alternative="less")
+
+ df_sar.eal = df_sar.eal.cat.codes
+ df_sar.eal = df_sar.eal.map(lambda x: np.NaN if x == -1 else x)
+
+ n_cves_eal_corr, n_cves_eal_pvalue = spearmanr(df_sar.eal, df_sar.n_cves)
+ n_cves_corrs = [n_cves_eal_corr]
+ n_cves_pvalues = [n_cves_eal_pvalue]
+
+ worst_cve_eal_corr, worst_cve_eal_pvalue = spearmanr(df_sar.eal, df_sar.worst_cve_score)
+ worst_cve_corrs = [worst_cve_eal_corr]
+ worst_cve_pvalues = [worst_cve_eal_pvalue]
+
+ avg_cve_eal_corr, avg_cve_eal_pvalue = spearmanr(df_sar.eal, df_sar.avg_cve_score)
+ avg_cve_corrs = [avg_cve_eal_corr]
+ avg_cve_pvalues = [avg_cve_eal_pvalue]
+
+ supports = [df_sar.loc[~df_sar["eal"].isnull()].shape[0]]
+
+ for family in tqdm(families):
+ df_sar[family] = df_sar.extracted_sars.map(lambda x: get_sar_level_from_set(x, family))
+
+ n_cves_corr, n_cves_pvalue = spearmanr(df_sar[family], df_sar.n_cves)
+ n_cves_corrs.append(n_cves_corr)
+ n_cves_pvalues.append(n_cves_pvalue)
+
+ worst_cve_corr, worst_cve_pvalue = spearmanr(df_sar[family], df_sar.worst_cve_score)
+ worst_cve_corrs.append(worst_cve_corr)
+ worst_cve_pvalues.append(worst_cve_pvalue)
+
+ avg_cve_corr, avg_cve_pvalue = spearmanr(df_sar[family], df_sar.avg_cve_score)
+ avg_cve_corrs.append(avg_cve_corr)
+ avg_cve_pvalues.append(avg_cve_pvalue)
+
+ supports.append(df_sar.loc[~df_sar[family].isnull()].shape[0])
+
+ df_sar = df_sar.copy()
+
+ tuples = list(
+ zip(n_cves_corrs, n_cves_pvalues, worst_cve_corrs, worst_cve_pvalues, avg_cve_corrs, avg_cve_pvalues, supports)
+ )
+ dct = {family: correlations for family, correlations in zip(["eal"] + families, tuples)}
+ df_corr = pd.DataFrame.from_dict(
+ dct,
+ orient="index",
+ columns=[
+ "n_cves_corr",
+ "n_cves_pvalue",
+ "worst_cve_score_corr",
+ "worst_cve_pvalue",
+ "avg_cve_score_corr",
+ "avg_cve_pvalue",
+ "support",
+ ],
+ )
+ df_corr.style.set_caption("Correlations between EAL, SARs and CVEs")
+ df_corr = df_corr.sort_values(by="support", ascending=False)
+
+ if filter_nans:
+ df_corr = df_corr.dropna(how="any", subset=["n_cves_corr", "worst_cve_score_corr", "avg_cve_score_corr"])
+
+ if output_path:
+ df_corr.to_csv(output_path)
+
+ return df_corr
+
+
+def find_earliest_maintenance_after_cve(row):
+ "Given dataframe row, will return first maintenance date succeeding first published CVE related to a certificate if exists, else np.nan"
+ maintenances_after_cve = [x for x in row["maintenance_dates"] if x > row["earliest_cve"]]
+ return min(maintenances_after_cve) if maintenances_after_cve else np.nan
+
+
+def filter_to_cves_within_validity_period(cc_df: pd.DataFrame, cve_dset: CVEDataset) -> pd.DataFrame:
+ """
+ Filters the column `related_cves` in `cc_df` DataFrame to CVEs that were published within validity period of the
+ studied certificate.
+ """
+
+ def filter_cves(
+ cve_dset: CVEDataset, cves: set[str], not_valid_before: pd.Timestamp, not_valid_after: pd.Timestamp
+ ) -> set[str] | float:
+
+ # Mypy is complaining, but the Optional date is resolved at the beginning of the and condition
+ result: set[str] = {
+ x
+ for x in cves
+ if cve_dset[x].published_date
+ and not_valid_before < pd.Timestamp(cve_dset[x].published_date.date()) # type: ignore
+ and not_valid_after > pd.Timestamp(cve_dset[x].published_date.date()) # type: ignore
+ }
+
+ return result if result else np.nan
+
+ if (
+ cc_df.loc[
+ (cc_df.related_cves.notnull()) & ((cc_df.not_valid_before.isna()) | (cc_df.not_valid_after.isna()))
+ ].shape[0]
+ > 0
+ ):
+ raise ValueError(
+ "Cannot filter CVEs on certificates that have NaNs in not_valid_after or not_valid_before fields."
+ )
+
+ cc_df["related_cves"] = cc_df.apply(
+ lambda row: filter_cves(cve_dset, row["related_cves"], row["not_valid_before"], row["not_valid_after"])
+ if not pd.isna(row["related_cves"])
+ else row["related_cves"],
+ axis=1,
+ )
+
+ return cc_df
+
+
+def expand_df_with_cve_cols(df: pd.DataFrame, cve_dset: CVEDataset) -> pd.DataFrame:
+ df = df.copy()
+
+ df["n_cves"] = df.related_cves.map(lambda x: len(x) if x is not np.nan else 0)
+ df["cve_published_dates"] = df.related_cves.map(
+ lambda x: [cve_dset[y].published_date.date() for y in x] if x is not np.nan else np.nan # type: ignore
+ )
+
+ df["earliest_cve"] = df.cve_published_dates.map(lambda x: min(x) if isinstance(x, list) else np.nan)
+ df["worst_cve_score"] = df.related_cves.map(
+ lambda x: max([cve_dset[cve].impact.base_score for cve in x]) if x is not np.nan else np.nan
+ )
+
+ """
+ Note: Technically, CVE can have 0 base score. This happens when the CVE is discarded from the database.
+ This could skew the results. During May 2022 analysis, we encountered a single CVE with such score.
+ Therefore, we do not treat this case.
+ To properly treat this, the average should be taken across CVEs with >0 base_socre.
+ """
+ df["avg_cve_score"] = df.related_cves.map(
+ lambda x: np.mean([cve_dset[cve].impact.base_score for cve in x]) if x is not np.nan else np.nan
+ )
+ return df
+
+
+def prepare_cwe_df(
+ cc_df: pd.DataFrame, cve_dset: CVEDataset, fine_grained: bool = False
+) -> tuple[pd.DataFrame, pd.DataFrame]:
+ """
+ This function does the following:
+ 1. Filter CC DF to columns relevant for CWE examination (eal, related_cves, category)
+ 2. Parses CWE webpage of CWE categories and weaknesses, fetches CWE descriptions and names from there
+ 3. Explodes the CC DF so that each row corresponds to single CVE
+ 4. Joins CC DF with CWE DF obtained from CVEDataset
+ 5. Explodes resulting DF again so that each row corresponds to single CWE
+
+ :param pd.DataFrame cc_df: DataFrame obtained from CCDataset, should be limited to rows with >0 vulnerabilities
+ :param CVEDataset cve_dset: CVEDataset instance to retrieve CWE data from
+ :param bool fine_grained: If se to True, CWEs won't be merged into weaknesses of higher abstraction
+ :return Tuple[pd.DataFrame, pd.DataFrame]: returns two dataframes:
+ - DF obtained from CC Dataset, fully exploded to CWEs
+ - DF obtained from CWE webpage, contains IDs, names, types, urls of all CWEs
+ """
+ # Explode CVE_IDs and CWE_IDs so that we have right counts on duplicated CVEs. Measure how much data for analysis we have left.
+ vulns = cve_dset.to_pandas()
+ df_cwe_relevant = (
+ cc_df[["eal", "related_cves", "category"]]
+ .explode(column="related_cves")
+ .rename(columns={"related_cves": "cve_id"})
+ )
+ df_cwe_relevant["cwe_ids"] = df_cwe_relevant.cve_id.map(lambda x: vulns.cwe_ids[x])
+ df_cwe_relevant = (
+ df_cwe_relevant.explode(column="cwe_ids")
+ .reset_index()
+ .rename(columns={"cwe_ids": "cwe_id", "index": "cert_dgst"})
+ )
+
+ df_cwe_relevant.cwe_id = df_cwe_relevant.cwe_id.replace(r"NVD-CWE-*", np.nan, regex=True)
+ print(
+ f"Filtering {df_cwe_relevant.loc[df_cwe_relevant.cwe_id.isna(), 'cve_id'].nunique()} CVEs that have no CWE assigned. This affects {df_cwe_relevant.loc[df_cwe_relevant.cwe_id.isna(), 'cert_dgst'].nunique()} certificates"
+ )
+ print(
+ f"Still left with analysis of {df_cwe_relevant.loc[~df_cwe_relevant.cwe_id.isna(), 'cve_id'].nunique()} CVEs in {df_cwe_relevant.loc[~df_cwe_relevant.cwe_id.isna(), 'cert_dgst'].nunique()} certificates."
+ )
+ df_cwe_relevant = df_cwe_relevant.dropna()
+
+ # Load CWE IDs and descriptions from CWE website
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ xml_zip_path = Path(tmp_dir) / "cwec_latest.xml.zip"
+ helpers.download_file("https://cwe.mitre.org/data/xml/cwec_latest.xml.zip", xml_zip_path)
+
+ with zipfile.ZipFile(xml_zip_path, "r") as zip_handle:
+ zip_handle.extractall(tmp_dir)
+ xml_filename = zip_handle.namelist()[0]
+
+ root = ET.parse(Path(tmp_dir) / xml_filename).getroot()
+
+ weaknesses = root.find("{http://cwe.mitre.org/cwe-6}Weaknesses")
+ categories = root.find("{http://cwe.mitre.org/cwe-6}Categories")
+ dct: dict[str, Any] = {
+ "cwe_id": [],
+ "cwe_name": [],
+ "cwe_description": [],
+ "type": [],
+ "child_of": [],
+ }
+
+ assert weaknesses
+ for weakness in weaknesses:
+ assert weakness
+ description = weakness.find("{http://cwe.mitre.org/cwe-6}Description")
+ related_weaknesses = weakness.find("{http://cwe.mitre.org/cwe-6}Related_Weaknesses")
+
+ dct["cwe_id"].append("CWE-" + weakness.attrib["ID"])
+ dct["cwe_name"].append(weakness.attrib["Name"])
+ dct["cwe_description"].append(description.text if description is not None else None)
+ dct["type"].append("weakness")
+
+ if related_weaknesses:
+ dct["child_of"].append(
+ {
+ "CWE-" + x.attrib["CWE_ID"]
+ for x in related_weaknesses
+ if x.tag == "{http://cwe.mitre.org/cwe-6}Related_Weakness" and x.attrib["Nature"] == "ChildOf"
+ }
+ )
+ else:
+ dct["child_of"].append(np.nan)
+
+ assert categories
+ for category in categories:
+ assert category
+ summary = category.find("{http://cwe.mitre.org/cwe-6}Summary")
+
+ dct["cwe_id"].append("CWE-" + category.attrib["ID"])
+ dct["cwe_name"].append(category.attrib["Name"])
+ dct["cwe_description"].append(summary.text if summary is not None else None)
+ dct["type"].append("category")
+ dct["child_of"].append(np.nan)
+
+ cwe_df = pd.DataFrame(dct).set_index("cwe_id")
+ cwe_df["url"] = cwe_df.index.map(lambda x: "https://cwe.mitre.org/data/definitions/" + x.split("-")[1] + ".html")
+ cwe_df = cwe_df.replace(r"\n", " ", regex=True)
+
+ if fine_grained:
+ return df_cwe_relevant, cwe_df
+ else:
+ return get_coarse_grained_cwes(df_cwe_relevant, cwe_df), cwe_df
+
+
+def get_coarse_grained_cwes(fine_grained_df: pd.DataFrame, cwe_df: pd.DataFrame) -> pd.DataFrame:
+ """
+ Oddly enough, NVD contains CWEs at different levels of abstraction, which makes it difficult to compare between them.
+ Among others, some three different CWEs appear in the CVEDataset: CWE-20, CWE-119, CWE-787. Problem is that CWE-787
+ is child of CWE-119, which in turn is child of CWE-20. It makes no sense to compute stats of most prevalent CWEs
+ unless categories are aligned to the top-most level.
+
+ This function aligns the categories to the top-most level. It works in loop. When an iteration is performed without
+ replacing any CWEs with their parents, the algorithm terminates.
+ The algorithm inspects every CWE and replaces it with all its parents on condition that they appear in the CVE Dataset.
+
+ :param pd.DataFrame fine_grained_df: First element of the output of `prepare_cwe_df` function
+ :param pd.DataFrame cwe_df: Second element of the output of `prepare_cwe_df` function
+ :return pd.DataFrame: DF obtained from CC Dataset, fully exploded to coarse-grained CWEs
+ """
+ all_cwes_in_original_df = set(fine_grained_df.cwe_id.unique())
+ parent_dict = cwe_df.child_of.to_dict()
+ new_set = set(fine_grained_df.cwe_id.unique())
+ mapping = {x: {x} for x in new_set}
+
+ while True:
+ old_set = copy.deepcopy(new_set)
+ for cwe in old_set:
+ parents = parent_dict[cwe]
+ if parents and parents is not np.nan and any(x in all_cwes_in_original_df for x in parents):
+ new_set.remove(cwe)
+ new_set.update({x for x in parents if x in all_cwes_in_original_df})
+ for val in mapping.values():
+ if cwe in val:
+ val.remove(cwe)
+ val.update({x for x in parents if x in all_cwes_in_original_df})
+ if new_set == old_set:
+ break
+
+ # Now we should have complete mapping of fine_grained -> coarse_grained CWEs
+ new_df = fine_grained_df.copy()
+ new_df.cwe_id = new_df.cwe_id.map(mapping)
+
+ return new_df.explode(column="cwe_id")
+
+
+def get_top_n_cwes(
+ df: pd.DataFrame, cwe_df: pd.DataFrame, category: str | None = None, eal: str | None = None, n_cwes: int = 10
+) -> pd.DataFrame:
+ """Fetches top-n CWEs, overall, per category, or per EAL"""
+ top_n = df.copy()
+
+ if category:
+ top_n = top_n.loc[top_n.category == category].copy()
+ if eal:
+ top_n = top_n.loc[top_n.eal == eal].copy()
+
+ top_n = (
+ top_n.cwe_id.value_counts()
+ .head(n_cwes)
+ .to_frame()
+ .rename(columns={"cwe_id": "frequency"})
+ .rename_axis("cwe_id")
+ )
+ top_n["cwe_name"] = top_n.index.map(lambda x: cwe_df.loc[x].cwe_name)
+ top_n["cwe_description"] = top_n.index.map(lambda x: cwe_df.loc[x].cwe_description)
+ top_n["url"] = top_n.index.map(lambda x: cwe_df.loc[x].url)
+ top_n["type"] = top_n.index.map(lambda x: cwe_df.loc[x].type)
+
+ return top_n
+
+
+def compute_maintenances_that_come_after_vulns(df: pd.DataFrame) -> pd.DataFrame:
+ """
+ Given pre-processed CCDataset DataFrame (expanded with MU & CVE cols), computes time to fix CVE and earliest CVE after some vuln.
+ """
+ df_fixed = df.loc[(df.n_cves > 0) & (df.n_maintenances > 0)].copy()
+ df_fixed.maintenance_dates = df_fixed.maintenance_dates.map(lambda x: [y.date() for y in x])
+ df_fixed.loc[:, "earliest_maintenance_after_vuln"] = df_fixed.apply(find_earliest_maintenance_after_cve, axis=1)
+ df_fixed.index.name = "dgst"
+ return df_fixed
+
+
+def move_fixing_mu_to_directory(
+ df_fixed: pd.DataFrame, main_df: pd.DataFrame, outdir: str | Path, inpath: str | Path
+) -> pd.DataFrame:
+ """
+ Localizes reports of maintenance updates that should fix some vulnerability and copies them into a directory.
+ df_fixed should be the output of compute_maintenances_that_come_after_vulns method.
+ """
+ fixed_df_index = (
+ df_fixed.loc[~df_fixed.earliest_maintenance_after_vuln.isnull()]
+ .reset_index()
+ .set_index(["dgst", "earliest_maintenance_after_vuln"])
+ .index.to_flat_index()
+ )
+ main_df.maintenance_date = main_df.maintenance_date.map(lambda x: x.date())
+ main_prefiltered = main_df.reset_index().set_index(["related_cert_digest", "maintenance_date"])
+ mu_filenames = main_prefiltered.loc[main_prefiltered.index.isin(fixed_df_index), "dgst"]
+ mu_filenames = mu_filenames.map(lambda x: x + ".pdf")
+
+ inpath = Path(inpath)
+ if not inpath.exists():
+ inpath.mkdir()
+
+ for i in mu_filenames:
+ copyfile(inpath / i, Path(outdir) / i)
+
+ return mu_filenames
+
+
+def plot_dataframe_graph(
+ data: dict,
+ label: str,
+ file_name: str,
+ density: bool = False,
+ cumulative: bool = False,
+ bins: int = 50,
+ log: bool = True,
+ show: bool = True,
+) -> None:
+ pd_data = pd.Series(data)
+ pd_data.hist(bins=bins, label=label, density=density, cumulative=cumulative)
+ plt.savefig(file_name)
+ if show:
+ plt.show()
+
+ if log:
+ sorted_data = pd_data.value_counts(ascending=True)
+
+ logger.info(sorted_data.where(sorted_data > 1).dropna())
diff --git a/src/sec_certs/utils/parallel_processing.py b/src/sec_certs/utils/parallel_processing.py
new file mode 100644
index 00000000..50806a67
--- /dev/null
+++ b/src/sec_certs/utils/parallel_processing.py
@@ -0,0 +1,43 @@
+from __future__ import annotations
+
+import time
+from multiprocessing.pool import ThreadPool
+from typing import Any, Callable, Iterable
+
+from billiard.pool import Pool
+
+from sec_certs.utils.tqdm import tqdm
+
+
+def process_parallel(
+ func: Callable,
+ items: Iterable,
+ max_workers: int,
+ callback: Callable | None = None,
+ use_threading: bool = True,
+ progress_bar: bool = True,
+ unpack: bool = False,
+ progress_bar_desc: str | None = None,
+) -> list[Any]:
+
+ pool: Pool | ThreadPool = ThreadPool(max_workers) if use_threading else Pool(max_workers)
+ results = (
+ [pool.apply_async(func, (*i,), callback=callback) for i in items]
+ if unpack
+ else [pool.apply_async(func, (i,), callback=callback) for i in items]
+ )
+
+ if progress_bar is True and items:
+ bar = tqdm(total=len(results), desc=progress_bar_desc)
+ while not all(all_done := [x.ready() for x in results]):
+ done_count = len(list(filter(lambda x: x, all_done)))
+ bar.update(done_count - bar.n)
+ time.sleep(1)
+ bar.update(len(results) - bar.n)
+ bar.close()
+
+ pool.close()
+ pool.join()
+ pool.terminate()
+
+ return [r.get() for r in results]
diff --git a/src/sec_certs/utils/pdf.py b/src/sec_certs/utils/pdf.py
new file mode 100644
index 00000000..1d04a697
--- /dev/null
+++ b/src/sec_certs/utils/pdf.py
@@ -0,0 +1,275 @@
+from __future__ import annotations
+
+import glob
+import logging
+import subprocess
+from datetime import datetime, timedelta, timezone
+from functools import reduce
+from pathlib import Path
+from tempfile import TemporaryDirectory
+from typing import Any
+
+import pdftotext
+import pikepdf
+from PyPDF2 import PdfFileReader
+from PyPDF2.generic import BooleanObject, ByteStringObject, FloatObject, IndirectObject, NumberObject, TextStringObject
+
+from sec_certs import constants as constants
+from sec_certs.constants import (
+ GARBAGE_ALPHA_CHARS_THRESHOLD,
+ GARBAGE_AVG_LLEN_THRESHOLD,
+ GARBAGE_EVERY_SECOND_CHAR_THRESHOLD,
+ GARBAGE_LINES_THRESHOLD,
+ GARBAGE_SIZE_THRESHOLD,
+)
+
+logger = logging.getLogger(__name__)
+
+
+def repair_pdf(file: Path) -> None:
+ """
+ Some pdfs can't be opened by PyPDF2 - opening them with pikepdf and then saving them fixes this issue.
+ By opening this file in a pdf reader, we can already extract number of pages.
+
+ :param file: file name
+ :return: number of pages in pdf file
+ """
+ pdf = pikepdf.Pdf.open(file, allow_overwriting_input=True)
+ pdf.save(file)
+
+
+def ocr_pdf_file(pdf_path: Path) -> str:
+ """
+ OCR a PDF file and return its text contents, uses `pdftoppm` and `tesseract`.
+
+ :param pdf_path: The PDF file to OCR.
+ :return: The text contents.
+ """
+ with TemporaryDirectory() as tmpdir:
+ tmppath = Path(tmpdir)
+ ppm = subprocess.run(
+ ["pdftoppm", pdf_path, tmppath / "image"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
+ )
+ if ppm.returncode != 0:
+ raise ValueError(f"pdftoppm failed: {ppm.returncode}")
+ for ppm_path in map(Path, glob.glob(str(tmppath / "image*.ppm"))):
+ base = ppm_path.with_suffix("")
+ tes = subprocess.run(
+ ["tesseract", "-l", "eng+deu+fra", ppm_path, base], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
+ )
+ if tes.returncode != 0:
+ raise ValueError(f"tesseract failed: {tes.returncode}")
+ contents = ""
+ txt_paths = list(glob.glob(str(tmppath / "image*.txt")))
+ for txt_path in map(Path, sorted(txt_paths, key=lambda fname: int(fname[6:-4]))):
+ with txt_path.open("r", encoding="utf-8") as f:
+ contents += f.read()
+ return contents
+
+
+def convert_pdf_file(pdf_path: Path, txt_path: Path) -> tuple[bool, bool]:
+ """
+ Convert a PDF tile to text and save it on the `txt_path`.
+
+ :param pdf_path: Path to the to-be-converted PDF file.
+ :param txt_path: Path to the resulting text file.
+ :return: A tuple of two results, whether OCR was done and what the complete result
+ was (OK/NOK).
+ """
+ txt = None
+ ok = False
+ ocr = False
+ try:
+ with pdf_path.open("rb") as pdf_handle:
+ pdf = pdftotext.PDF(pdf_handle, "", True) # No password, Raw=True
+ txt = "".join(pdf)
+ except Exception as e:
+ logger.error(f"Error when converting pdf->txt: {e}")
+
+ if txt is None or text_is_garbage(txt):
+ logger.warning(f"Detected garbage during conversion of {pdf_path}")
+ ocr = True
+ try:
+ txt = ocr_pdf_file(pdf_path)
+ logger.info(f"OCR OK for {pdf_path}")
+ except Exception as e:
+ logger.error(f"Error during OCR of {pdf_path}, using garbage: {e}")
+
+ if txt is not None:
+ ok = True
+ with txt_path.open("w", encoding="utf-8") as txt_handle:
+ txt_handle.write(txt)
+
+ return ocr, ok
+
+
+def parse_pdf_date(dateval: bytes | None) -> datetime | None:
+ """
+ Parse PDF metadata date format:
+
+ ```
+ parse_pdf_date(b"D:20110617082321-04'00'")
+ ```
+ into
+ ```
+ datetime.datetime(2011, 6, 17, 8, 23, 21, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=72000)))
+ ```
+
+ :param dateval: The date as in the PDF metadata.
+ :return: The parsed datetime, if successful, else `None`.
+ """
+ if dateval is None:
+ return None
+ clean = dateval.decode("utf-8").replace("D:", "")
+ tz = None
+ tzoff = None
+ if "+" in clean:
+ clean, tz = clean.split("+")
+ tzoff = 1
+ if "-" in clean:
+ clean, tz = clean.split("-")
+ tzoff = -1
+ elif "Z" in clean:
+ clean, tz = clean.split("Z")
+ tzoff = 1
+ try:
+ res_datetime = datetime.strptime(clean, "%Y%m%d%H%M%S")
+ if tz and tzoff:
+ tz_datetime = datetime.strptime(tz, "%H'%M'")
+ delta = tzoff * timedelta(hours=tz_datetime.hour, minutes=tz_datetime.minute)
+ res_tz = timezone(delta)
+ res_datetime = res_datetime.replace(tzinfo=res_tz)
+ return res_datetime
+ except ValueError:
+ return None
+
+
+def extract_pdf_metadata(filepath: Path) -> tuple[str, dict[str, Any] | None]: # noqa: C901
+ """
+ Extract PDF metadata, such as the number of pages, author, title, etc.
+
+ :param filepath: THe path to the PDF.
+ :return: A tuple of the result code (see constants) and the metadata dictionary.
+ """
+
+ def map_metadata_value(val, nope_out=False):
+ if isinstance(val, BooleanObject):
+ val = val.value
+ elif isinstance(val, FloatObject):
+ val = float(val)
+ elif isinstance(val, NumberObject):
+ val = int(val)
+ elif isinstance(val, IndirectObject) and not nope_out:
+ # Let's make sure to nope out in case of cycles
+ val = map_metadata_value(val.getObject(), nope_out=True)
+ elif isinstance(val, TextStringObject):
+ val = str(val)
+ elif isinstance(val, ByteStringObject):
+ try:
+ val = val.decode("utf-8")
+ except UnicodeDecodeError:
+ val = str(val)
+ else:
+ val = str(val)
+ return val
+
+ def resolve_indirect(val, bound=10):
+ if isinstance(val, list) and bound:
+ return [resolve_indirect(v, bound - 1) for v in val]
+ elif isinstance(val, IndirectObject) and bound:
+ return resolve_indirect(val.getObject(), bound - 1)
+ else:
+ return val
+
+ metadata: dict[str, Any] = dict()
+
+ try:
+ metadata["pdf_file_size_bytes"] = filepath.stat().st_size
+ with filepath.open("rb") as handle:
+ pdf = PdfFileReader(handle, strict=False)
+ metadata["pdf_is_encrypted"] = pdf.getIsEncrypted()
+
+ # see https://stackoverflow.com/questions/26242952/pypdf-2-decrypt-not-working
+ if metadata["pdf_is_encrypted"]:
+ pikepdf.open(filepath, allow_overwriting_input=True).save()
+
+ with filepath.open("rb") as handle:
+ pdf = PdfFileReader(handle, strict=False)
+ metadata["pdf_number_of_pages"] = pdf.getNumPages()
+ pdf_document_info = pdf.getDocumentInfo()
+
+ if pdf_document_info is None:
+ raise ValueError("PDF metadata unavailable")
+
+ for key, val in pdf_document_info.items():
+ metadata[str(key)] = map_metadata_value(val)
+
+ # Get the hyperlinks in the PDF
+ annots = [page.get("/Annots", []) for page in pdf.pages]
+ annots = reduce(lambda x, y: x + y, map(resolve_indirect, annots))
+ links = set()
+ for annot in annots:
+ try:
+ A = resolve_indirect(annot.get("/A", {}))
+ link = resolve_indirect(A.get("/URI"))
+ if link:
+ links.add(map_metadata_value(link))
+ except Exception:
+ pass
+ metadata["pdf_hyperlinks"] = links
+
+ except Exception as e:
+ relative_filepath = "/".join(str(filepath).split("/")[-4:])
+ error_msg = f"Failed to read metadata of {relative_filepath}, error: {e}"
+ logger.error(error_msg)
+ return error_msg, None
+
+ return constants.RETURNCODE_OK, metadata
+
+
+def text_is_garbage(text: str) -> bool:
+ """
+ Detect whether the given text is "garbage". A series of tests is applied,
+ using the number of lines, average line length, total size, every second character on a line
+ and the ratio of alphanumeric characters.
+
+ :param text: The tested text.
+ :return: Whether the text is a "garbage" result of pdftotext conversion.
+ """
+ size = len(text)
+ content_len = 0
+ lines = 0
+ every_second = 0
+ alpha_len = len("".join(filter(str.isalpha, text)))
+ for line in text.splitlines():
+ content_len += len(line)
+ lines += 1
+ if len(set(line[1::2])) > 1:
+ every_second += 1
+
+ if lines:
+ avg_line_len = content_len / lines
+ else:
+ avg_line_len = 0
+ if size:
+ alpha = alpha_len / size
+ else:
+ alpha = 0
+
+ # If number of lines is small, this is garbage.
+ if lines < GARBAGE_LINES_THRESHOLD:
+ return True
+ # If the file size is small, this is garbage.
+ if size < GARBAGE_SIZE_THRESHOLD:
+ return True
+ # If the average length of a line is small, this is garbage.
+ if avg_line_len < GARBAGE_AVG_LLEN_THRESHOLD:
+ return True
+ # If there a small amount of lines that have more than one character at every second character, this is garbage.
+ # This detects the ANSSI spacing issues.
+ if every_second < GARBAGE_EVERY_SECOND_CHAR_THRESHOLD:
+ return True
+ # If there is a small ratio of alphanumeric chars to all chars, this is garbage.
+ if alpha < GARBAGE_ALPHA_CHARS_THRESHOLD:
+ return True
+ return False
diff --git a/src/sec_certs/utils/sanitization.py b/src/sec_certs/utils/sanitization.py
new file mode 100644
index 00000000..2f9cd046
--- /dev/null
+++ b/src/sec_certs/utils/sanitization.py
@@ -0,0 +1,51 @@
+from __future__ import annotations
+
+import html
+import logging
+from datetime import date
+
+import numpy as np
+import pandas as pd
+from bs4 import NavigableString
+
+logger = logging.getLogger(__name__)
+
+
+def sanitize_navigable_string(string: NavigableString | str | None) -> str | None:
+ if not string:
+ return None
+ return str(string).strip().replace("\xad", "").replace("\xa0", "")
+
+
+def sanitize_link(record: str | None) -> str | None:
+ if not record:
+ return None
+ return record.replace(":443", "").replace(" ", "%20").replace("http://", "https://")
+
+
+def sanitize_date(record: pd.Timestamp | date | np.datetime64) -> date | None:
+ if pd.isnull(record):
+ return None
+ elif isinstance(record, pd.Timestamp):
+ return record.date()
+ elif isinstance(record, (date, type(None))):
+ return record
+ raise ValueError("Unsupported type given as input")
+
+
+def sanitize_string(record: str) -> str:
+ # There is a sample with name 'ATMEL Secure Microcontroller AT90SC12872RCFT &#x2f; AT90SC12836RCFT rev. I &amp;&#x23;38&#x3b; J' that has to be unescaped twice
+ string = html.unescape(html.unescape(record)).replace("\n", "")
+ return " ".join(string.split())
+
+
+def sanitize_security_levels(record: str | set[str]) -> set[str]:
+ if isinstance(record, str):
+ record = set(record.split(","))
+ return record - {"Basic", "ND-PP", "PP\xa0Compliant", "None", "Medium"}
+
+
+def sanitize_protection_profiles(record: str) -> list:
+ if not record:
+ return []
+ return record.split(",")
diff --git a/src/sec_certs/utils/tables.py b/src/sec_certs/utils/tables.py
new file mode 100644
index 00000000..29def971
--- /dev/null
+++ b/src/sec_certs/utils/tables.py
@@ -0,0 +1,62 @@
+from __future__ import annotations
+
+import logging
+import re
+from pathlib import Path
+
+from sec_certs.cert_rules import FIPS_LIST_OF_TABLES
+
+logger = logging.getLogger(__name__)
+
+
+def parse_list_of_tables(txt: str) -> set[int]:
+ """
+ Parses list of tables in policy txt, returns page numbers of tables that mention algorithms
+ """
+ rr = re.compile(r"^.+?(?:[Ff]unction|[Aa]lgorithm|[Ss]ecurity [Ff]unctions?).+?(?P<page_num>\d+)$", re.MULTILINE)
+ return {int(m.group("page_num")) for m in rr.finditer(txt)}
+
+
+def get_table_rich_page_numbers_from_footer(file_text: str) -> set[int]:
+ """
+ Parses page numbers of policy txt pages that may contain tables with algorithm data
+ """
+ current_page = 1
+ pages = set()
+
+ for line in file_text.split("\n"):
+ if "\f" in line:
+ current_page += 1
+ if line.startswith("Table ") or line.startswith("Exhibit"):
+ pages.add(current_page)
+ pages.add(current_page + 1)
+ if current_page > 2:
+ pages.add(current_page - 1)
+
+ for page in pages:
+ if page > current_page - 1:
+ return pages - {page}
+
+ return pages
+
+
+def find_pages_with_tables(txt_filepath: Path) -> set[int]:
+ """
+ Identifies pages in txt file that may contain tables. Return their page numbers.
+ """
+ with txt_filepath.open("r", encoding="utf-8") as handle:
+ txt = handle.read()
+
+ # Parse page numbers from list of tables if available
+ # Else look for "Table" in text and \f representing footer, then extract page number from footer
+ if list_of_tables := FIPS_LIST_OF_TABLES.search(txt):
+ result = parse_list_of_tables(list_of_tables.group())
+ else:
+ result = get_table_rich_page_numbers_from_footer(txt)
+
+ return result if result else set()
+
+
+def get_algs_from_table(dataframe_text: str) -> set[str]:
+ reg = r"(?:#?\s?|(?:Cert)\.?[^. ]*?\s?)(?:[CcAa]\s)?(?P<id>[CcAa]? ?\d+)"
+ return {m.group() for m in re.finditer(reg, dataframe_text)}
diff --git a/src/sec_certs/utils/tqdm.py b/src/sec_certs/utils/tqdm.py
new file mode 100644
index 00000000..77eeae94
--- /dev/null
+++ b/src/sec_certs/utils/tqdm.py
@@ -0,0 +1,9 @@
+from tqdm import tqdm as tqdm_original
+
+from sec_certs.config.configuration import config
+
+
+def tqdm(*args, **kwargs):
+ if "disable" in kwargs:
+ return tqdm_original(*args, **kwargs)
+ return tqdm_original(*args, **kwargs, disable=not config.enable_progress_bars)