From 9db545dbb2b644a01b8e49b4b2506ea03ec36b00 Mon Sep 17 00:00:00 2001 From: Adam Janovsky Date: Fri, 20 May 2022 17:20:48 +0200 Subject: Docs: add some examples --- docs/api/dataset.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 docs/api/dataset.md (limited to 'docs/api/dataset.md') diff --git a/docs/api/dataset.md b/docs/api/dataset.md new file mode 100644 index 00000000..ed92b31e --- /dev/null +++ b/docs/api/dataset.md @@ -0,0 +1,18 @@ +# Dataset package + +```{eval-rst} +.. automodule:: sec_certs.dataset + :no-members: +``` + +```{tip} +The examples related to this package can be found at [common criteria notebook](./../notebooks/examples/common_criteria.ipynb) and [fips notebook](./../notebooks/examples/fips.ipynb). +``` + +## CCDataset + +```{eval-rst} +.. currentmodule:: sec_certs.dataset +.. autoclass:: CCDataset + :members: +``` \ No newline at end of file -- cgit v1.3.1 From 5d1e1e8346489911eda02892e67823738dfb9e47 Mon Sep 17 00:00:00 2001 From: Adam Janovsky Date: Sun, 22 May 2022 13:43:36 +0200 Subject: add fipsdataset docs --- docs/api/dataset.md | 12 ++- sec_certs/dataset/common_criteria.py | 8 +- sec_certs/dataset/cpe.py | 47 ++++++++-- sec_certs/dataset/dataset.py | 4 +- sec_certs/dataset/fips.py | 168 ++++++++++++++++++----------------- 5 files changed, 147 insertions(+), 92 deletions(-) (limited to 'docs/api/dataset.md') diff --git a/docs/api/dataset.md b/docs/api/dataset.md index ed92b31e..168fa12a 100644 --- a/docs/api/dataset.md +++ b/docs/api/dataset.md @@ -5,6 +5,8 @@ :no-members: ``` +This documentation doesn't provide full API reference for all members of `dataset` package. Instead, it concentrattes on the Dataset that are immediately exposed to the users. Namely, we focus on `CCDataset` and `FIPSDataset`. + ```{tip} The examples related to this package can be found at [common criteria notebook](./../notebooks/examples/common_criteria.ipynb) and [fips notebook](./../notebooks/examples/fips.ipynb). ``` @@ -15,4 +17,12 @@ The examples related to this package can be found at [common criteria notebook]( .. currentmodule:: sec_certs.dataset .. autoclass:: CCDataset :members: -``` \ No newline at end of file +``` + +## FIPSDataset + +```{eval-rst} +.. currentmodule:: sec_certs.dataset +.. autoclass:: FIPSDataset + :members: +``` diff --git a/sec_certs/dataset/common_criteria.py b/sec_certs/dataset/common_criteria.py index 7bdb5ae1..76bd9f94 100644 --- a/sec_certs/dataset/common_criteria.py +++ b/sec_certs/dataset/common_criteria.py @@ -89,7 +89,7 @@ class CCDataset(Dataset[CommonCriteriaCert], ComplexSerializableType): def root_dir(self, new_dir: Union[str, Path]) -> None: old_dset = copy.deepcopy(self) Dataset.root_dir.fset(self, new_dir) # type: ignore - self.set_local_paths() + self._set_local_paths() if self.state and old_dset.root_dir != Path(".."): logger.info(f"Changing root dir of partially processed dataset. All contents will get copied to {new_dir}") @@ -220,7 +220,7 @@ class CCDataset(Dataset[CommonCriteriaCert], ComplexSerializableType): return all_cert_ids - def set_local_paths(self): + def _set_local_paths(self): for cert in self: cert.set_local_paths(self.reports_pdf_dir, self.targets_pdf_dir, self.reports_txt_dir, self.targets_txt_dir) @@ -299,7 +299,7 @@ class CCDataset(Dataset[CommonCriteriaCert], ComplexSerializableType): if not keep_metadata: shutil.rmtree(self.web_dir) - self.set_local_paths() + self._set_local_paths() self.state.meta_sources_parsed = True def _get_all_certs_from_csv(self, get_active: bool, get_archived: bool) -> Dict[str, "CommonCriteriaCert"]: @@ -791,7 +791,6 @@ class CCDataset(Dataset[CommonCriteriaCert], ComplexSerializableType): update_dset: CCDatasetMaintenanceUpdates = CCDatasetMaintenanceUpdates( {x.dgst: x for x in updates}, root_dir=self.mu_dataset_path, name="Maintenance updates" ) - update_dset.set_local_paths() update_dset.download_all_pdfs() update_dset.convert_all_pdfs() update_dset._extract_data() @@ -822,6 +821,7 @@ class CCDatasetMaintenanceUpdates(CCDataset, ComplexSerializableType): ): super().__init__(certs, root_dir, name, description, state) # type: ignore self.state.meta_sources_parsed = True + self._set_local_paths() @property def certs_dir(self) -> Path: diff --git a/sec_certs/dataset/cpe.py b/sec_certs/dataset/cpe.py index 43ae3365..0ae35081 100644 --- a/sec_certs/dataset/cpe.py +++ b/sec_certs/dataset/cpe.py @@ -20,6 +20,10 @@ logger = logging.getLogger(__name__) @dataclass class CPEDataset(ComplexSerializableType): + """ + Dataset of CPE records. Includes look-up dictionaries for fast search. + """ + was_enhanced_with_vuln_cpes: bool json_path: Path cpes: Dict[str, CPE] @@ -68,7 +72,7 @@ class CPEDataset(ComplexSerializableType): def build_lookup_dicts(self) -> None: """ - Will build look-up dictionaries that are used for fast matching + Will build look-up dictionaries that are used for fast matching. """ logger.info("CPE dataset: building lookup dictionaries.") self.vendor_to_versions = {x.vendor: set() for x in self} @@ -90,6 +94,13 @@ class CPEDataset(ComplexSerializableType): @classmethod def from_web(cls, json_path: Union[str, Path], init_lookup_dicts: bool = True) -> "CPEDataset": + """ + Creates CPEDataset from NIST resources published on-line + + :param Union[str, Path] json_path: Path to store the dataset to + :param bool init_lookup_dicts: If dictionaries for fast matching should be computed, defaults to True + :return CPEDataset: The resulting dataset + """ with tempfile.TemporaryDirectory() as tmp_dir: xml_path = Path(tmp_dir) / cls.cpe_xml_basename zip_path = Path(tmp_dir) / (cls.cpe_xml_basename + ".zip") @@ -98,10 +109,10 @@ class CPEDataset(ComplexSerializableType): with zipfile.ZipFile(zip_path, "r") as zip_ref: zip_ref.extractall(tmp_dir) - return cls.from_xml(xml_path, json_path, init_lookup_dicts) + return cls._from_xml(xml_path, json_path, init_lookup_dicts) @classmethod - def from_xml( + def _from_xml( cls, xml_path: Union[str, Path], json_path: Union[str, Path], init_lookup_dicts: bool = True ) -> "CPEDataset": logger.info("Loading CPE dataset from XML.") @@ -128,22 +139,48 @@ class CPEDataset(ComplexSerializableType): @classmethod def from_json(cls, input_path: Union[str, Path]) -> "CPEDataset": + """ + Loads dataset from json + + :param Union[str, Path] input_path: Path to the serialized json dataset + :return CPEDataset: the resulting dataset. + """ dset = cast("CPEDataset", ComplexSerializableType.from_json(input_path)) dset.json_path = Path(input_path) return dset @classmethod def from_dict(cls, dct: Dict[str, Any], init_lookup_dicts: bool = True) -> "CPEDataset": + """ + Loads dataset from dictionary. + + :param Dict[str, Any] dct: Dictionary that holds the dataset + :param bool init_lookup_dicts: Whether look-up dicts should be computed as a part of initialization, defaults to True + :return CPEDataset: the resulting dataset. + """ return cls(dct["was_enhanced_with_vuln_cpes"], Path("../"), dct["cpes"], init_lookup_dicts) def to_pandas(self) -> pd.DataFrame: + """ + Turns the dataset into pandas DataFrame. Each CPE record forms a row. + + :return pd.DataFrame: the resulting DataFrame + """ df = pd.DataFrame([x.pandas_tuple for x in self], columns=CPE.pandas_columns) df = df.set_index("uri") return df @serialize def enhance_with_cpes_from_cve_dataset(self, cve_dset: Union[CVEDataset, str, Path]) -> None: - def adding_condition( + """ + Some CPEs are present only in the CVEDataset and are missing from the CPE Dataset. + This method goes through the provided CVEDataset and enriches self with CPEs from + the CVEDataset. + + :param Union[CVEDataset, str, Path] cve_dset: CVEDataset of a path to it. + """ + + def _adding_condition( considered_cpe: CPE, vndr_item_lookup: Set[Tuple[str, str]], vndr_item_version_lookup: Set[Tuple[str, str, str]], @@ -174,7 +211,7 @@ class CPEDataset(ComplexSerializableType): vendor_item_lookup = {(cpe.vendor, cpe.item_name) for cpe in self} vendor_item_version_lookup = {(cpe.vendor, cpe.item_name, cpe.version) for cpe in self} for cpe in helpers.tqdm(all_cpes_in_cve_dset, desc="Enriching CPE dataset with new CPEs"): - if adding_condition(cpe, vendor_item_lookup, vendor_item_version_lookup): + if _adding_condition(cpe, vendor_item_lookup, vendor_item_version_lookup): new_cpe = copy.deepcopy(cpe) new_cpe.start_version = None new_cpe.end_version = None diff --git a/sec_certs/dataset/dataset.py b/sec_certs/dataset/dataset.py index e63aea9f..34dc9003 100644 --- a/sec_certs/dataset/dataset.py +++ b/sec_certs/dataset/dataset.py @@ -128,10 +128,10 @@ class Dataset(Generic[CertSubType], ABC): def from_json(cls: Type[DatasetSubType], input_path: Union[str, Path]) -> DatasetSubType: dset = cast("DatasetSubType", ComplexSerializableType.from_json(input_path)) dset.root_dir = Path(input_path).parent.absolute() - dset.set_local_paths() + dset._set_local_paths() return dset - def set_local_paths(self) -> None: + def _set_local_paths(self) -> None: raise NotImplementedError("Not meant to be implemented by the base class.") @abstractmethod diff --git a/sec_certs/dataset/fips.py b/sec_certs/dataset/fips.py index 8cef1520..44c6ebdf 100644 --- a/sec_certs/dataset/fips.py +++ b/sec_certs/dataset/fips.py @@ -1,14 +1,11 @@ import logging -import os import tempfile -from itertools import groupby from pathlib import Path -from typing import Any, Dict, List, Optional, Set, Tuple +from typing import Any, Dict, List, Optional, Set from bs4 import BeautifulSoup, NavigableString from graphviz import Digraph -from sec_certs import constants as constants from sec_certs import helpers as helpers from sec_certs import parallel_processing as cert_processing from sec_certs.config.configuration import config @@ -23,6 +20,10 @@ logger = logging.getLogger(__name__) class FIPSDataset(Dataset[FIPSCertificate], ComplexSerializableType): + """ + Class for processing of FIPSCertificate samples. Inherits from `ComplexSerializableType` and base abstract `Dataset` class. + """ + def __init__( self, certs: Dict[str, FIPSCertificate] = dict(), @@ -36,48 +37,36 @@ class FIPSDataset(Dataset[FIPSCertificate], ComplexSerializableType): self.new_files = 0 @property - def results_dir(self) -> Path: - return self.root_dir / "results" - - @property - def policies_dir(self) -> Path: + def _policies_dir(self) -> Path: return self.root_dir / "security_policies" @property - def fragments_dir(self) -> Path: + def _fragments_dir(self) -> Path: return self.root_dir / "fragments" @property - def algs_dir(self) -> Path: + def _algs_dir(self) -> Path: return self.web_dir / "algorithms" - # After web scan, there should be a FIPSCertificate object created for every entry - @property - def successful_web_scan(self) -> bool: - return all(self.certs) and all(cert.web_scan for cert in self.certs.values() if cert is not None) - - @property - def successful_pdf_scan(self) -> bool: - return all(cert.pdf_scan for cert in self.certs.values() if cert is not None) - def get_certs_from_name(self, module_name: str) -> List[FIPSCertificate]: - return [crt for crt in self if crt.web_scan.module_name == module_name] + """ + Returns list of certificates that match given name. - def find_empty_pdfs(self) -> Tuple[List, List]: - missing = [] - not_available = [] - for i in self.certs: - if not (self.policies_dir / f"{i}.pdf").exists(): - missing.append(i) - elif os.path.getsize(self.policies_dir / f"{i}.pdf") < constants.FIPS_NOT_AVAILABLE_CERT_SIZE: - not_available.append(i) - return missing, not_available + :param str module_name: name to search for + :return List[FIPSCertificate]: List of certificates with web_scan.module_name == module_name + """ + return [crt for crt in self if crt.web_scan.module_name == module_name] @serialize def pdf_scan(self, redo: bool = False) -> None: + """ + Extracts data from pdf files + + :param bool redo: Whether to try again with failed files, defaults to False + """ logger.info("Entering PDF scan.") - self.fragments_dir.mkdir(parents=True, exist_ok=True) + self._fragments_dir.mkdir(parents=True, exist_ok=True) keywords = cert_processing.process_parallel( FIPSCertificate.find_keywords, @@ -89,7 +78,7 @@ class FIPSDataset(Dataset[FIPSCertificate], ComplexSerializableType): for keyword, cert in keywords: self.certs[cert.dgst].pdf_scan.keywords = keyword - def match_algs(self) -> Dict[str, int]: + def _match_algs(self) -> Dict[str, int]: output = {} for cert in self.certs.values(): # if the pdf has not been processed, no matching can be done @@ -103,18 +92,24 @@ class FIPSDataset(Dataset[FIPSCertificate], ComplexSerializableType): return output def download_all_pdfs(self, cert_ids: Optional[Set[str]] = None) -> None: + """ + Downloads all pdf files related to the certificates specified with cert_ids. + + :param Optional[Set[str]] cert_ids: cert_ids to download the pdfs foor, defaults to None + :raises RuntimeError: If no cert_ids are specified, raises. + """ sp_paths, sp_urls = [], [] - self.policies_dir.mkdir(exist_ok=True) + self._policies_dir.mkdir(exist_ok=True) if cert_ids is None: raise RuntimeError("You need to provide cert ids to FIPS download PDFs functionality.") for cert_id in cert_ids: - if not (self.policies_dir / f"{cert_id}.pdf").exists() or ( + if not (self._policies_dir / f"{cert_id}.pdf").exists() or ( fips_dgst(cert_id) in self.certs and not self.certs[fips_dgst(cert_id)].state.txt_state ): sp_urls.append( f"https://csrc.nist.gov/CSRC/media/projects/cryptographic-module-validation-program/documents/security-policies/140sp{cert_id}.pdf" ) - sp_paths.append(self.policies_dir / f"{cert_id}.pdf") + sp_paths.append(self._policies_dir / f"{cert_id}.pdf") logger.info(f"downloading {len(sp_urls)} module pdf files") cert_processing.process_parallel( FIPSCertificate.download_security_policy, @@ -124,7 +119,7 @@ class FIPSDataset(Dataset[FIPSCertificate], ComplexSerializableType): ) self.new_files += len(sp_urls) - def download_all_htmls(self, cert_ids: Set[str]) -> List[str]: + def _download_all_htmls(self, cert_ids: Set[str]) -> List[str]: html_paths, html_urls = [], [] new_files = [] self.web_dir.mkdir(exist_ok=True) @@ -158,17 +153,20 @@ class FIPSDataset(Dataset[FIPSCertificate], ComplexSerializableType): @serialize def convert_all_pdfs(self) -> None: + """ + Converts all pdfs to text files + """ logger.info("Converting FIPS sample reports to .txt") tuples = [ - (cert, self.policies_dir / f"{cert.cert_id}.pdf", self.policies_dir / f"{cert.cert_id}.pdf.txt") + (cert, self._policies_dir / f"{cert.cert_id}.pdf", self._policies_dir / f"{cert.cert_id}.pdf.txt") for cert in self.certs.values() - if not cert.state.txt_state and (self.policies_dir / f"{cert.cert_id}.pdf").exists() + if not cert.state.txt_state and (self._policies_dir / f"{cert.cert_id}.pdf").exists() ] cert_processing.process_parallel( FIPSCertificate.convert_pdf_file, tuples, config.n_threads, progress_bar_desc="Converting to txt" ) - def prepare_dataset(self, test: Optional[Path] = None, update: bool = False) -> Set[str]: + def _prepare_dataset(self, test: Optional[Path] = None, update: bool = False) -> Set[str]: if test: html_files = [test] else: @@ -197,8 +195,8 @@ class FIPSDataset(Dataset[FIPSCertificate], ComplexSerializableType): return cert_ids - def download_neccessary_files(self, cert_ids: Set[str]) -> None: - self.download_all_htmls(cert_ids) + def _download_neccessary_files(self, cert_ids: Set[str]) -> None: + self._download_all_htmls(cert_ids) self.download_all_pdfs(cert_ids) def _get_certificates_from_html(self, html_file: Path, update: bool = False) -> Set[str]: @@ -220,15 +218,21 @@ class FIPSDataset(Dataset[FIPSCertificate], ComplexSerializableType): @serialize def web_scan(self, cert_ids: Set[int], redo: bool = False) -> None: + """ + Creates FIPSCertificate object from the relevant html file that must be downlaoded. + + :param Set[int] cert_ids: Cert ids to create FIPSCertificate objects for. + :param bool redo: whether to re-attempt with failed certificates, defaults to False + """ logger.info("Entering web scan.") for cert_id in cert_ids: dgst = fips_dgst(cert_id) self.certs[dgst] = FIPSCertificate.html_from_file( self.web_dir / f"{cert_id}.html", FIPSCertificate.State( - (self.policies_dir / str(cert_id)).with_suffix(".pdf"), + (self._policies_dir / str(cert_id)).with_suffix(".pdf"), (self.web_dir / str(cert_id)).with_suffix(".html"), - (self.fragments_dir / str(cert_id)).with_suffix(".txt"), + (self._fragments_dir / str(cert_id)).with_suffix(".txt"), False, None, False, @@ -239,6 +243,9 @@ class FIPSDataset(Dataset[FIPSCertificate], ComplexSerializableType): @classmethod def from_web_latest(cls) -> "FIPSDataset": + """ + Fetches the fresh snapshot of FIPSDataset from mirror. + """ with tempfile.TemporaryDirectory() as tmp_dir: dset_path = Path(tmp_dir) / "fips_latest_dataset.json" logger.info("Downloading the latest FIPS dataset.") @@ -259,10 +266,10 @@ class FIPSDataset(Dataset[FIPSCertificate], ComplexSerializableType): # dset.finalize_results() return dset - def set_local_paths(self) -> None: + def _set_local_paths(self) -> None: cert: FIPSCertificate for cert in self.certs.values(): - cert.set_local_paths(self.policies_dir, self.web_dir, self.fragments_dir) + cert.set_local_paths(self._policies_dir, self.web_dir, self._fragments_dir) @serialize def get_certs_from_web( @@ -285,11 +292,11 @@ class FIPSDataset(Dataset[FIPSCertificate], ComplexSerializableType): logger.info("Downloading required html files") self.web_dir.mkdir(parents=True, exist_ok=True) - self.policies_dir.mkdir(exist_ok=True) - self.algs_dir.mkdir(exist_ok=True) + self._policies_dir.mkdir(exist_ok=True) + self._algs_dir.mkdir(exist_ok=True) # Download files containing all available module certs (always) - cert_ids = self.prepare_dataset(test, update) + cert_ids = self._prepare_dataset(test, update) if not no_download_algorithms: aset = FIPSAlgorithmDataset({}, Path(self.root_dir / "web" / "algorithms"), "algorithms", "sample algs") @@ -299,22 +306,10 @@ class FIPSDataset(Dataset[FIPSCertificate], ComplexSerializableType): self.algorithms = aset logger.info("Downloading certificate html and security policies") - self.download_neccessary_files(cert_ids) + self._download_neccessary_files(cert_ids) self.web_scan(cert_ids, redo=redo_web_scan) - @serialize - def deprocess(self) -> None: - # TODO think of a better way to make resulting json smaller and easier to share - logger.info( - "Removing 'heuristics' field. This dataset can be used to be uploaded and later downloaded using latest_snapshot() or something" - ) - cert: FIPSCertificate - for cert in self.certs.values(): - cert.heuristics = FIPSCertificate.FIPSHeuristics(dict(), [], 0) - - self.match_algs() - @serialize def extract_certs_from_tables(self, high_precision: bool) -> List[Path]: """ @@ -342,11 +337,11 @@ class FIPSDataset(Dataset[FIPSCertificate], ComplexSerializableType): certificate.pdf_scan.algorithms += algorithms return not_decoded - def remove_algorithms_from_extracted_data(self) -> None: + def _remove_algorithms_from_extracted_data(self) -> None: for cert in self.certs.values(): cert.remove_algorithms() - def unify_algorithms(self) -> None: + def _unify_algorithms(self) -> None: for certificate in self.certs.values(): new_algorithms: List[Dict] = [] united_algorithms = [ @@ -440,7 +435,7 @@ class FIPSDataset(Dataset[FIPSCertificate], ComplexSerializableType): return False return True - def validate_results(self) -> None: + def _validate_results(self) -> None: """ Function that validates results and finds the final connection output """ @@ -482,10 +477,16 @@ class FIPSDataset(Dataset[FIPSCertificate], ComplexSerializableType): @serialize def finalize_results(self, use_nist_cpe_matching_dict: bool = True, perform_cpe_heuristics: bool = True): + """ + Performs processing of extracted data. Computes all heuristics. + + :param bool use_nist_cpe_matching_dict: If NIST CPE matching dictionary shall be used to drive computing related CVEs, defaults to True + :param bool perform_cpe_heuristics: If CPE heuristics shall be computed, defaults to True + """ logger.info("Entering 'analysis' and building connections between certificates.") - self.unify_algorithms() - self.remove_algorithms_from_extracted_data() - self.validate_results() + self._unify_algorithms() + self._remove_algorithms_from_extracted_data() + self._validate_results() if perform_cpe_heuristics: _, _, cve_dset = self.compute_cpe_heuristics() self.compute_related_cves(use_nist_cpe_matching_dict=use_nist_cpe_matching_dict, cve_dset=cve_dset) @@ -521,7 +522,7 @@ class FIPSDataset(Dataset[FIPSCertificate], ComplexSerializableType): attr = {"pdf": "pdf_scan", "web": "web_scan", "heuristics": "heuristics"}[connection_list] return getattr(self.certs[dgst], attr).connections - def create_dot_graph( + def _create_dot_graph( self, output_file_name: str, connection_list: str = "heuristics", @@ -586,6 +587,11 @@ class FIPSDataset(Dataset[FIPSCertificate], ComplexSerializableType): single_dot.render(self.root_dir / (str(output_file_name) + "_single"), view=show) def to_dict(self) -> Dict[str, Any]: + """ + Serializes dataset into a dictionary + + :return Dict[str, Any]: Dictionary that holds the whole dataset. + """ return { "timestamp": self.timestamp, "sha256_digest": self.sha256_digest, @@ -598,6 +604,12 @@ class FIPSDataset(Dataset[FIPSCertificate], ComplexSerializableType): @classmethod def from_dict(cls, dct: Dict[str, Any]) -> "FIPSDataset": + """ + Reconstructs the original dataset from a dictionary + + :param Dict[str, Any] dct: Dictionary that holds the serialized dataset + :return FIPSDataset: Deserialized FIPSDataset that corresponds to `dct` contents + """ certs = dct["certs"] dset = cls(certs, Path("./"), dct["name"], dct["description"]) dset.algorithms = dct["algs"] @@ -607,16 +619,12 @@ class FIPSDataset(Dataset[FIPSCertificate], ComplexSerializableType): ) return dset - def group_vendors(self) -> Dict[str, List[str]]: - vendors: Dict[str, List[str]] = {} - v = {x.web_scan.vendor.lower() for x in self.certs.values() if x is not None and x.web_scan.vendor is not None} - v_sorted = sorted(v, key=FIPSCertificate.get_compare) - for prefix, a in groupby(v_sorted, key=FIPSCertificate.get_compare): - vendors[prefix] = list(a) - - return vendors - def plot_graphs(self, show: bool = False) -> None: - self.create_dot_graph("full_graph", show=show) - self.create_dot_graph("web_only_graph", "web", show=show) - self.create_dot_graph("pdf_only_graph", "pdf", show=show) + """ + Plots FIPS graphs. + # TODO: Currently broken, see https://github.com/crocs-muni/sec-certs/issues/211 + :param bool show: If plots should be showed with .show() method, defaults to False + """ + self._create_dot_graph("full_graph", show=show) + self._create_dot_graph("web_only_graph", "web", show=show) + self._create_dot_graph("pdf_only_graph", "pdf", show=show) -- cgit v1.3.1