aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/sec_certs/sample
diff options
context:
space:
mode:
authoradamjanovsky2022-12-09 17:10:19 +0100
committerGitHub2022-12-09 17:10:19 +0100
commit73b3b0c361f9545450fa188bec50606d64bb1afd (patch)
tree0a1f9034c309ba88e5f72a31634b014c23a57df5 /src/sec_certs/sample
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/sec_certs/sample')
-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
13 files changed, 2870 insertions, 0 deletions
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)