diff --git a/rapidfort-pr-description.md b/rapidfort-pr-description.md new file mode 100644 index 000000000..833891cb0 --- /dev/null +++ b/rapidfort-pr-description.md @@ -0,0 +1,87 @@ +# feat: add RapidFort vulnerability provider + +## Summary + +Adds a new `rapidfort` provider that ingests security advisory data from the +[RapidFort security-advisories](https://github.com/rapidfort/security-advisories) +GitHub repository and normalizes it into vunnel's OSSchema format for consumption +by Grype. + +RapidFort advisories are intended for use when scanning RapidFort-curated images +(identified via maintainer metadata) to apply RapidFort-specific version checks +that differ from upstream distro advisories. + +## What's included + +### New provider — `src/vunnel/providers/rapidfort/` + +| File | Purpose | +|------|---------| +| `__init__.py` | `Provider` and `Config` classes; registered in the global provider registry | +| `parser.py` | Advisory loading, normalization, and event-range processing | +| `git.py` | Shallow-clone wrapper (`--depth=1`) with retry-backoff for the advisory repo | + +**Supported OS types and version formats:** + +| OS | Version format | Example namespace | +|----|---------------|-------------------| +| Ubuntu | `dpkg` | `rapidfort-ubuntu:22.04` | +| Alpine | `apk` | `rapidfort-alpine:3.15` | +| Red Hat | `rpm` | `rapidfort-redhat:el9` | + +**Namespace isolation:** advisories are stored under `rapidfort-{os}:{version}` +(e.g. `rapidfort-ubuntu:20.04`) so Grype keeps them separate from standard +upstream distro scans. + +### Key design decisions + +- **Event-based version ranges** (mirrors GHSA semantics): each `introduced`/`fixed` + pair in an advisory event becomes a separate `FixedIn` entry with a + `VulnerableRange` field. A single CVE can produce multiple `FixedIn` entries + when it affects more than one release branch. +- **Release identifiers**: Red Hat advisories carry per-event identifiers (e.g. + `el9`, `fc36`) that are preserved in the `Identifier` field and reflected in + `VendorAdvisory.AdvisorySummary`. +- **Fix availability**: integrates with the existing `fixdate` system to populate + the `Available` field on each `FixedIn` entry. +- **Merge across packages**: when the same CVE appears in multiple package files, + `FixedIn` entries are merged into a single vulnerability record. + +### Registration + +- `src/vunnel/providers/__init__.py` — provider added to the global registry +- `src/vunnel/cli/config.py` — `Config` added to the `Providers` dataclass for + CLI/YAML configuration + +### Tests — `tests/unit/providers/rapidfort/` + +| Test class / function | What it covers | +|-----------------------|----------------| +| `TestEventsToRangePairs` | Range-pair conversion: single, multi-range, introduced-only, fixed-only, deduplication, identifier preservation | +| `TestNormalize` | Multi-range CVE produces multiple `FixedIn` entries; `Available` field present; Red Hat per-range identifiers and `VendorAdvisory` | +| `TestMergeIntoNamespace` | Same CVE in two packages merges into one record; distinct CVEs stay separate | +| `TestMapSeverity` | Case-insensitive known severities; `Unknown` for `None`, empty string, unrecognized values | +| `test_provider_schema` | Full provider output validates against `schema-1.1.0.json` | +| `test_provider_via_snapshot` | Regression snapshots for Ubuntu, Alpine, and Red Hat advisory output | + +**Test fixtures** cover all three supported OS types: + +``` +test-fixtures/ +├── input/rapidfort-advisories/OS/ +│ ├── ubuntu/curl_advisory.json # multi-range CVE (2 events) +│ ├── alpine/zlib_advisory.json # single-range CVE, apk format +│ └── redhat/curl_advisory.json # per-release identifiers (el9/fc36/fc37) +└── snapshots/ + ├── rapidfort-ubuntu:20.04/ + ├── rapidfort-alpine:3.15/ + └── rapidfort-redhat:el9/ +``` + +## Test plan + +- [ ] `pytest tests/unit/providers/rapidfort/` — all 17 tests pass +- [ ] Verify `vunnel run rapidfort` clones the advisory repo and writes results +- [ ] Confirm `vunnel list` shows `rapidfort` in the provider list +- [ ] Confirm Grype resolves `rapidfort-ubuntu:*` / `rapidfort-alpine:*` / + `rapidfort-redhat:*` namespaces when scanning RapidFort-curated images diff --git a/src/vunnel/cli/config.py b/src/vunnel/cli/config.py index 1211a680c..829ba1a5b 100644 --- a/src/vunnel/cli/config.py +++ b/src/vunnel/cli/config.py @@ -62,6 +62,7 @@ class Providers: minimos: providers.minimos.Config = field(default_factory=providers.minimos.Config) nvd: providers.nvd.Config = field(default_factory=providers.nvd.Config) oracle: providers.oracle.Config = field(default_factory=providers.oracle.Config) + rapidfort: providers.rapidfort.Config = field(default_factory=providers.rapidfort.Config) rhel: providers.rhel.Config = field(default_factory=providers.rhel.Config) rocky: providers.rocky.Config = field(default_factory=providers.rocky.Config) secureos: providers.secureos.Config = field(default_factory=providers.secureos.Config) diff --git a/src/vunnel/providers/__init__.py b/src/vunnel/providers/__init__.py index 244cd8bc2..a9301a6b3 100644 --- a/src/vunnel/providers/__init__.py +++ b/src/vunnel/providers/__init__.py @@ -24,6 +24,7 @@ nvd, oracle, photon, + rapidfort, rhel, rocky, secureos, @@ -52,6 +53,7 @@ nvd.Provider.name(): nvd.Provider, oracle.Provider.name(): oracle.Provider, photon.Provider.name(): photon.Provider, + rapidfort.Provider.name(): rapidfort.Provider, rhel.Provider.name(): rhel.Provider, rocky.Provider.name(): rocky.Provider, secureos.Provider.name(): secureos.Provider, diff --git a/src/vunnel/providers/rapidfort/__init__.py b/src/vunnel/providers/rapidfort/__init__.py new file mode 100644 index 000000000..a2d638b04 --- /dev/null +++ b/src/vunnel/providers/rapidfort/__init__.py @@ -0,0 +1,74 @@ +"""RapidFort security advisories provider. + +Ingests vulnerability data from the RapidFort security-advisories GitHub repo +for Ubuntu and Alpine. Used when scanning RapidFort-curated images (identified +via maintainer metadata) to apply RapidFort-specific advisory and version checks. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +from vunnel import provider, result, schema +from vunnel.utils import timer + +from .parser import Parser + +if TYPE_CHECKING: + import datetime + + +@dataclass +class Config: + runtime: provider.RuntimeConfig = field( + default_factory=lambda: provider.RuntimeConfig( + result_store=result.StoreStrategy.SQLITE, + existing_results=result.ResultStatePolicy.DELETE_BEFORE_WRITE, + ), + ) + request_timeout: int = 125 + + +class Provider(provider.Provider): + __schema__ = schema.OSSchema() + __distribution_version__ = int(__schema__.major_version) + + def __init__(self, root: str, config: Config | None = None): + if not config: + config = Config() + super().__init__(root, runtime_cfg=config.runtime) + self.config = config + + self.logger.debug("config: %s", self.config) + + self.parser = Parser( + workspace=self.workspace, + logger=self.logger, + ) + + provider.disallow_existing_input_policy(config.runtime) + + @classmethod + def name(cls) -> str: + return "rapidfort" + + @classmethod + def tags(cls) -> list[str]: + return ["vulnerability", "os"] + + def update(self, last_updated: datetime.datetime | None) -> tuple[list[str], int]: + with timer(self.name(), self.logger): + with self.results_writer() as writer, self.parser: + for namespace, vuln_dict in self.parser.get(): + namespace = namespace.lower() + for vuln_id, record in vuln_dict.items(): + vuln_id = vuln_id.lower() + writer.write( + identifier=os.path.join(namespace, vuln_id), + schema=self.__schema__, + payload=record, + ) + + return self.parser.urls, len(writer) diff --git a/src/vunnel/providers/rapidfort/git.py b/src/vunnel/providers/rapidfort/git.py new file mode 100644 index 000000000..e6b5b8cf5 --- /dev/null +++ b/src/vunnel/providers/rapidfort/git.py @@ -0,0 +1,66 @@ +"""Git wrapper for cloning RapidFort security-advisories repo.""" + +from __future__ import annotations + +import logging +import os +import shlex +import shutil +import subprocess +import tempfile +from dataclasses import dataclass + +from vunnel import utils + + +class GitWrapper: + _check_cmd_ = "git --version" + _clone_cmd_ = "git clone --depth=1 -b {branch} {src} {dest}" + + def __init__( + self, + source: str, + branch: str, + checkout_dest: str, + logger: logging.Logger | None = None, + ): + self.src = source + self.branch = branch + self.dest = checkout_dest + self.workspace = tempfile.gettempdir() + + if not logger: + logger = logging.getLogger(self.__class__.__name__) + self.logger = logger + + try: + out = self._exec_cmd(self._check_cmd_) + self.logger.trace("git executable verified using cmd: %s, output: %s", self._check_cmd_, out) # type: ignore[attr-defined] + except Exception: + self.logger.exception('could not find required "git" executable. Please install git on host') + raise + + def delete_repo(self) -> None: + if os.path.exists(self.dest): + self.logger.debug("deleting existing repository") + shutil.rmtree(self.dest, ignore_errors=True) + + @utils.retry_with_backoff() + def clone_repo(self) -> None: + try: + self.logger.info("cloning git repository %s branch %s to %s", self.src, self.branch, self.dest) + cmd = self._clone_cmd_.format(src=self.src, dest=self.dest, branch=self.branch) + out = self._exec_cmd(cmd) + self.logger.debug("initialized git repo, cmd: %s, output: %s", cmd, out) + except Exception: + self.logger.exception("failed to clone git repository %s branch %s to %s", self.src, self.branch, self.dest) + raise + + def _exec_cmd(self, cmd: str) -> str: + try: + self.logger.trace("running: %s", cmd) # type: ignore[attr-defined] + cmd_list = shlex.split(cmd) + return subprocess.check_output(cmd_list, text=True, stderr=subprocess.PIPE) # noqa: S603 + except Exception: + self.logger.exception("error executing command: %s", cmd) + raise diff --git a/src/vunnel/providers/rapidfort/parser.py b/src/vunnel/providers/rapidfort/parser.py new file mode 100644 index 000000000..ac1b2adb0 --- /dev/null +++ b/src/vunnel/providers/rapidfort/parser.py @@ -0,0 +1,406 @@ +"""RapidFort security advisories parser. + +Reads RapidFort advisory data and normalizes to vunnel OSSchema format. +Supports Ubuntu (dpkg), Alpine (apk), and Red Hat (rpm). + +Supports two input formats: +1. Vuln-list format: {os}/{version}/{package}.json with package_name, distro_version, advisories +2. Source format: OS/{os}/{package}.json with package_name, advisory: {version: {CVE: ...}} +""" + +from __future__ import annotations + +import copy +import logging +import os +from typing import TYPE_CHECKING, Any + +import orjson # type: ignore + +from vunnel.tool import fixdate +from vunnel.utils import vulnerability + +from .git import GitWrapper + +if TYPE_CHECKING: + from collections.abc import Generator + from types import TracebackType + + from vunnel.workspace import Workspace + +namespace = "rapidfort" +default_repo_url = "https://github.com/rapidfort/security-advisories.git" +repo_branch = "main" +repo_os_path = "OS" # OS/{osName}/{package}.json (source format) +default_supported_oses = ("ubuntu", "alpine", "redhat") + +# Version format per base OS +version_formats = { + "ubuntu": "dpkg", + "alpine": "apk", + "redhat": "rpm", +} + + +def _events_to_range_pairs(events: list[dict[str, Any]]) -> list[tuple[str, str, str | None]]: + """Convert RapidFort events into (range_str, fix_version, identifier) tuples. + + Mirrors GHSA vulnerableVersionRange semantics: + - introduced + fixed => ">= introduced, < fixed" + - introduced only => ">= introduced" (open-ended) + - fixed only => "< fixed" (rare) + + Deduplicates while preserving order. + """ + seen: set[tuple[str, str, str | None]] = set() + result: list[tuple[str, str, str | None]] = [] + + for ev in events: + if not isinstance(ev, dict): + continue + introduced = ev.get("introduced") + fixed = ev.get("fixed") + identifier = ev.get("identifier") + if identifier is not None: + identifier = str(identifier) + + if introduced and fixed: + range_str = f">= {introduced}, < {fixed}" + fix_version = str(fixed) + key = (str(introduced), str(fixed), identifier) + elif introduced: + range_str = f">= {introduced}" + fix_version = "None" + key = (str(introduced), "", identifier) + elif fixed: + range_str = f"< {fixed}" + fix_version = str(fixed) + key = ("", str(fixed), identifier) + else: + continue + + if key not in seen: + seen.add(key) + result.append((range_str, fix_version, identifier)) + + return result + + +def _advisory_url(os_name: str, pkg_name: str) -> str: + return f"https://github.com/rapidfort/security-advisories/tree/main/OS/{os_name}/{pkg_name}.json" + + +def _vendor_advisory(os_name: str, pkg_name: str, identifier: str | None) -> dict[str, Any]: + advisory_summary: list[dict[str, str]] = [ + {"ID": pkg_name, "Link": _advisory_url(os_name, pkg_name)}, + ] + if identifier: + advisory_summary.append( + { + "ID": f"release-identifier:{identifier}", + "Link": _advisory_url(os_name, pkg_name), + }, + ) + return { + "NoAdvisory": False, + "AdvisorySummary": advisory_summary, + } + + +class Parser: + """Parser for RapidFort security advisories.""" + + def __init__( + self, + workspace: Workspace, + fixdater: fixdate.Finder | None = None, + logger: logging.Logger | None = None, + repo_url: str | None = None, + supported_oses: tuple[str, ...] | None = None, + ): + if not fixdater: + fixdater = fixdate.default_finder(workspace) + self.fixdater = fixdater + self.workspace = workspace + self.repo_url = repo_url or default_repo_url + self.supported_oses = supported_oses or default_supported_oses + self.urls = [self.repo_url] + + if not logger: + logger = logging.getLogger(self.__class__.__name__) + self.logger = logger + + self._checkout_dest = os.path.join(self.workspace.input_path, "rapidfort-advisories") + + self.git_wrapper = GitWrapper( + source=self.repo_url, + branch=repo_branch, + checkout_dest=self._checkout_dest, + logger=self.logger, + ) + + self.security_reference_url = "https://github.com/rapidfort/security-advisories/tree/main/OS" + + def __enter__(self) -> Parser: + self.fixdater.__enter__() + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + self.fixdater.__exit__(exc_type, exc_val, exc_tb) + + def _load_package_file(self, file_path: str, os_name: str) -> Generator[tuple[str, str, str, dict[str, Any]]]: + try: + with open(file_path, "rb") as f: + data = orjson.loads(f.read()) + except Exception: + self.logger.warning("Failed to parse %s", file_path, exc_info=True) + return + + pkg_name = data.get("package_name") + if not pkg_name: + self.logger.warning("Missing package_name in %s, skipping", file_path) + return + + advisory = data.get("advisory") + if not isinstance(advisory, dict): + return + + for os_version, cve_map in advisory.items(): + if isinstance(cve_map, dict) and cve_map: + yield os_name, os_version, pkg_name, cve_map + + def _load_os(self, base_dir: str, os_name: str) -> Generator[tuple[str, str, str, dict[str, Any]]]: + src_dir = os.path.join(base_dir, os_name) + + if not os.path.isdir(src_dir): + self.logger.debug("RapidFort OS dir not found, skipping: %s", src_dir) + return + + for entry in sorted(os.listdir(src_dir)): + if not entry.endswith(".json"): + continue + + file_path = os.path.join(src_dir, entry) + if os.path.isfile(file_path): + yield from self._load_package_file(file_path, os_name) + + def _load(self) -> Generator[tuple[str, str, str, dict[str, Any]]]: + """Yield (os_name, os_version, package_name, cve_map) from OS/{os}/{package}.json.""" + base_dir = os.path.join(self._checkout_dest, repo_os_path) + + if not os.path.isdir(base_dir): + self.logger.warning("RapidFort OS root not found: %s", base_dir) + return + + for os_name in self.supported_oses: + yield from self._load_os(base_dir, os_name) + + def _get_valid_cve_entry( + self, + cve_id: str, + cve_entry: Any, + ) -> tuple[str | None, dict[str, Any] | None]: + """Return normalized CVE ID and entry if valid.""" + if not isinstance(cve_entry, dict): + return None, None + + vid = cve_entry.get("cve_id") or cve_id + if not vid: + return None, None + + return str(vid), cve_entry + + def _get_or_create_vuln_record( + self, + vuln_dict: dict[str, dict[str, Any]], + vid: str, + cve_entry: dict[str, Any], + ecosystem: str, + ) -> dict[str, Any]: + """Return an existing vulnerability record or create a new one.""" + if vid in vuln_dict: + return vuln_dict[vid] + + vuln_record = copy.deepcopy(vulnerability.vulnerability_element) + reference_links = vulnerability.build_reference_links(vid) + + vuln_record["Vulnerability"]["Name"] = vid + vuln_record["Vulnerability"]["NamespaceName"] = ecosystem + + if reference_links: + vuln_record["Vulnerability"]["Link"] = reference_links[0] + + vuln_record["Vulnerability"]["Severity"] = self._map_severity( + cve_entry.get("severity"), + ) + + description = cve_entry.get("description") + if description: + vuln_record["Vulnerability"]["Description"] = str(description) + + vuln_dict[vid] = vuln_record + return vuln_record + + def _get_fix_availability( + self, + vid: str, + pkg_name: str, + fix_version: str, + ecosystem: str, + ) -> dict[str, str] | None: + """Return fix availability metadata for a fixed version, if known.""" + if fix_version == "None": + return None + + result = self.fixdater.best( + vuln_id=vid, + cpe_or_package=pkg_name, + fix_version=fix_version, + ecosystem=ecosystem, + ) + if not result: + return None + + return { + "Date": result.date.isoformat(), + "Kind": result.kind, + } + + def _build_fixed_in_elements( + self, + vid: str, + os_name: str, + pkg_name: str, + range_pairs: list[tuple[str, str, str | None]], + ecosystem: str, + version_format: str, + ) -> list[dict[str, Any]]: + """Build FixedIn entries from pre-computed advisory event range pairs.""" + + fixed_elements: list[dict[str, Any]] = [] + for range_str, fix_version, identifier in range_pairs: + fixed_el = { + "Name": pkg_name, + "NamespaceName": ecosystem, + "VersionFormat": version_format, + "Version": fix_version, + "VulnerableRange": range_str, + "VendorAdvisory": _vendor_advisory(os_name, pkg_name, identifier), + } + if identifier: + fixed_el["Identifier"] = identifier + + availability = self._get_fix_availability( + vid=vid, + pkg_name=pkg_name, + fix_version=fix_version, + ecosystem=ecosystem, + ) + if availability: + fixed_el["Available"] = availability + + fixed_elements.append(fixed_el) + + return fixed_elements + + def _normalize( + self, + os_name: str, + os_version: str, + pkg_name: str, + cve_map: dict[str, Any], + ) -> dict[str, dict[str, Any]]: + """Convert RapidFort advisory to vunnel OSSchema vulnerability records. + + Uses grype-compatible namespace format: provider-distroType:version + (e.g. rapidfort-ubuntu:22.04) so grype stores RF advisories under a provider- + prefixed OS name that is isolated from standard distro scans. + """ + vuln_dict: dict[str, dict[str, Any]] = {} + version_format = version_formats.get(os_name.lower(), "dpkg") + ecosystem = f"{namespace}-{os_name}:{os_version}" + + for cve_id, cve_entry in cve_map.items(): + vid, entry = self._get_valid_cve_entry(cve_id, cve_entry) + if not vid: + continue + + # Build FixedIn from events (one per introduced/fixed pair, like GHSA) + events = cve_entry.get("events") or [] + range_pairs = _events_to_range_pairs(events) + + if not range_pairs: + continue + + vuln_record = self._get_or_create_vuln_record( + vuln_dict=vuln_dict, + vid=vid, + cve_entry=entry, + ecosystem=ecosystem, + ) + + for fixed_el in self._build_fixed_in_elements( + vid=vid, + os_name=os_name, + pkg_name=pkg_name, + range_pairs=range_pairs, + ecosystem=ecosystem, + version_format=version_format, + ): + vuln_record["Vulnerability"]["FixedIn"].append(fixed_el) + + return vuln_dict + + def _map_severity(self, severity: Any) -> str: + """Map RapidFort severity to vunnel severity string.""" + if not severity: + return "Unknown" + s = str(severity).strip().upper() + for valid in ("Critical", "High", "Medium", "Low", "Negligible"): + if s == valid.upper(): + return valid + return "Unknown" + + def _merge_into_namespace( + self, + namespace_vulns: dict[str, dict[str, dict[str, Any]]], + ns: str, + normalized: dict[str, dict[str, Any]], + ) -> None: + """Merge normalized vulnerability records into namespace_vulns. + + For the same CVE across different packages, FixedIn entries are extended. + """ + if ns not in namespace_vulns: + namespace_vulns[ns] = {} + + for vid, record in normalized.items(): + if vid in namespace_vulns[ns]: + existing = namespace_vulns[ns][vid] + existing["Vulnerability"]["FixedIn"].extend( + record["Vulnerability"]["FixedIn"], + ) + else: + namespace_vulns[ns][vid] = record + + def get(self) -> Generator[tuple[str, dict[str, dict[str, Any]]]]: + """Clone repo, load advisories, normalize and yield (namespace, vuln_dict).""" + self.git_wrapper.delete_repo() + self.git_wrapper.clone_repo() + + self.fixdater.download() + + namespace_vulns: dict[str, dict[str, dict[str, Any]]] = {} + + for os_name, version, pkg_name, cve_map in self._load(): + ns = f"{namespace}-{os_name}:{version}" + normalized = self._normalize(os_name, version, pkg_name, cve_map) + self._merge_into_namespace(namespace_vulns, ns, normalized) + + for ns, vuln_dict in namespace_vulns.items(): + yield ns, vuln_dict diff --git a/tests/unit/providers/rapidfort/test-fixtures/input/rapidfort-advisories/OS/alpine/zlib_advisory.json b/tests/unit/providers/rapidfort/test-fixtures/input/rapidfort-advisories/OS/alpine/zlib_advisory.json new file mode 100644 index 000000000..db20e7cf8 --- /dev/null +++ b/tests/unit/providers/rapidfort/test-fixtures/input/rapidfort-advisories/OS/alpine/zlib_advisory.json @@ -0,0 +1,20 @@ +{ + "package_name": "zlib", + "advisory": { + "3.15": { + "CVE-2022-37434": { + "cve_id": "CVE-2022-37434", + "title": "zlib heap-based buffer over-read or overflow in inflate", + "description": "zlib through 1.2.12 has a heap-based buffer over-read or buffer overflow in inflate in inflate.c via a large gzip header extra field.", + "severity": "CRITICAL", + "status": "fixed", + "events": [ + { + "introduced": "1.2.11-r3", + "fixed": "1.2.12-r2" + } + ] + } + } + } +} diff --git a/tests/unit/providers/rapidfort/test-fixtures/input/rapidfort-advisories/OS/redhat/curl_advisory.json b/tests/unit/providers/rapidfort/test-fixtures/input/rapidfort-advisories/OS/redhat/curl_advisory.json new file mode 100644 index 000000000..093a3e186 --- /dev/null +++ b/tests/unit/providers/rapidfort/test-fixtures/input/rapidfort-advisories/OS/redhat/curl_advisory.json @@ -0,0 +1,30 @@ +{ + "package_name": "curl", + "advisory": { + "9": { + "CVE-2014-0139": { + "cve_id": "CVE-2014-0139", + "title": "cURL and libcurl wildcard certificate validation issue", + "description": "cURL and libcurl before 7.36.0 may recognize a wildcard IP address in the certificate Common Name field, allowing spoofing of arbitrary SSL servers.", + "severity": "LOW", + "status": "open", + "events": [ + { + "introduced": "0", + "identifier": "el9" + }, + { + "introduced": "0", + "fixed": "7.78.0-4.fc36", + "identifier": "fc36" + }, + { + "introduced": "0", + "fixed": "7.81.0-3.fc37", + "identifier": "fc37" + } + ] + } + } + } +} diff --git a/tests/unit/providers/rapidfort/test-fixtures/input/rapidfort-advisories/OS/ubuntu/curl_advisory.json b/tests/unit/providers/rapidfort/test-fixtures/input/rapidfort-advisories/OS/ubuntu/curl_advisory.json new file mode 100644 index 000000000..da83fa169 --- /dev/null +++ b/tests/unit/providers/rapidfort/test-fixtures/input/rapidfort-advisories/OS/ubuntu/curl_advisory.json @@ -0,0 +1,37 @@ +{ + "package_name": "curl", + "advisory": { + "20.04": { + "CVE-2022-22576": { + "cve_id": "CVE-2022-22576", + "title": "An improper authentication vulnerability exists in curl 7.33.0 to ...", + "description": "An improper authentication vulnerability exists in curl 7.33.0 to and including 7.82.0 which might allow reuse OAUTH2-authenticated connections without properly making sure that the connection was authenticated with the same credentials as set for this transfer.", + "severity": "HIGH", + "status": "fixed", + "events": [ + { + "introduced": "7.68.0", + "fixed": "7.68.0-1ubuntu2.10" + }, + { + "introduced": "7.81.0", + "fixed": "7.81.0-1ubuntu1.1" + } + ] + }, + "CVE-2020-8169": { + "cve_id": "CVE-2020-8169", + "title": "curl 7.62.0 through 7.70.0 is vulnerable to an information disclosure vulnerability", + "description": "curl 7.62.0 through 7.70.0 is vulnerable to an information disclosure vulnerability that can lead to a partial password being leaked over the network.", + "severity": "HIGH", + "status": "fixed", + "events": [ + { + "introduced": "7.68.0", + "fixed": "7.68.0-1ubuntu2.1" + } + ] + } + } + } +} diff --git a/tests/unit/providers/rapidfort/test-fixtures/snapshots/rapidfort-alpine:3.15/cve-2022-37434.json b/tests/unit/providers/rapidfort/test-fixtures/snapshots/rapidfort-alpine:3.15/cve-2022-37434.json new file mode 100644 index 000000000..dbd237624 --- /dev/null +++ b/tests/unit/providers/rapidfort/test-fixtures/snapshots/rapidfort-alpine:3.15/cve-2022-37434.json @@ -0,0 +1,37 @@ +{ + "identifier": "rapidfort-alpine:3.15/cve-2022-37434", + "item": { + "Vulnerability": { + "CVSS": [], + "Description": "zlib through 1.2.12 has a heap-based buffer over-read or buffer overflow in inflate in inflate.c via a large gzip header extra field.", + "FixedIn": [ + { + "Available": { + "Date": "2024-01-01", + "Kind": "first-observed" + }, + "Name": "zlib", + "NamespaceName": "rapidfort-alpine:3.15", + "VendorAdvisory": { + "AdvisorySummary": [ + { + "ID": "zlib", + "Link": "https://github.com/rapidfort/security-advisories/tree/main/OS/alpine/zlib.json" + } + ], + "NoAdvisory": false + }, + "Version": "1.2.12-r2", + "VersionFormat": "apk", + "VulnerableRange": ">= 1.2.11-r3, < 1.2.12-r2" + } + ], + "Link": "https://www.cve.org/CVERecord?id=CVE-2022-37434", + "Metadata": {}, + "Name": "CVE-2022-37434", + "NamespaceName": "rapidfort-alpine:3.15", + "Severity": "Critical" + } + }, + "schema": "https://raw.githubusercontent.com/anchore/vunnel/main/schema/vulnerability/os/schema-1.1.0.json" +} diff --git a/tests/unit/providers/rapidfort/test-fixtures/snapshots/rapidfort-redhat:9/cve-2014-0139.json b/tests/unit/providers/rapidfort/test-fixtures/snapshots/rapidfort-redhat:9/cve-2014-0139.json new file mode 100644 index 000000000..13c9b7887 --- /dev/null +++ b/tests/unit/providers/rapidfort/test-fixtures/snapshots/rapidfort-redhat:9/cve-2014-0139.json @@ -0,0 +1,88 @@ +{ + "identifier": "rapidfort-redhat:9/cve-2014-0139", + "item": { + "Vulnerability": { + "CVSS": [], + "Description": "cURL and libcurl before 7.36.0 may recognize a wildcard IP address in the certificate Common Name field, allowing spoofing of arbitrary SSL servers.", + "FixedIn": [ + { + "Identifier": "el9", + "Name": "curl", + "NamespaceName": "rapidfort-redhat:9", + "VendorAdvisory": { + "AdvisorySummary": [ + { + "ID": "curl", + "Link": "https://github.com/rapidfort/security-advisories/tree/main/OS/redhat/curl.json" + }, + { + "ID": "release-identifier:el9", + "Link": "https://github.com/rapidfort/security-advisories/tree/main/OS/redhat/curl.json" + } + ], + "NoAdvisory": false + }, + "Version": "None", + "VersionFormat": "rpm", + "VulnerableRange": ">= 0" + }, + { + "Available": { + "Date": "2024-01-01", + "Kind": "first-observed" + }, + "Identifier": "fc36", + "Name": "curl", + "NamespaceName": "rapidfort-redhat:9", + "VendorAdvisory": { + "AdvisorySummary": [ + { + "ID": "curl", + "Link": "https://github.com/rapidfort/security-advisories/tree/main/OS/redhat/curl.json" + }, + { + "ID": "release-identifier:fc36", + "Link": "https://github.com/rapidfort/security-advisories/tree/main/OS/redhat/curl.json" + } + ], + "NoAdvisory": false + }, + "Version": "7.78.0-4.fc36", + "VersionFormat": "rpm", + "VulnerableRange": ">= 0, < 7.78.0-4.fc36" + }, + { + "Available": { + "Date": "2024-01-01", + "Kind": "first-observed" + }, + "Identifier": "fc37", + "Name": "curl", + "NamespaceName": "rapidfort-redhat:9", + "VendorAdvisory": { + "AdvisorySummary": [ + { + "ID": "curl", + "Link": "https://github.com/rapidfort/security-advisories/tree/main/OS/redhat/curl.json" + }, + { + "ID": "release-identifier:fc37", + "Link": "https://github.com/rapidfort/security-advisories/tree/main/OS/redhat/curl.json" + } + ], + "NoAdvisory": false + }, + "Version": "7.81.0-3.fc37", + "VersionFormat": "rpm", + "VulnerableRange": ">= 0, < 7.81.0-3.fc37" + } + ], + "Link": "https://www.cve.org/CVERecord?id=CVE-2014-0139", + "Metadata": {}, + "Name": "CVE-2014-0139", + "NamespaceName": "rapidfort-redhat:9", + "Severity": "Low" + } + }, + "schema": "https://raw.githubusercontent.com/anchore/vunnel/main/schema/vulnerability/os/schema-1.1.0.json" +} diff --git a/tests/unit/providers/rapidfort/test-fixtures/snapshots/rapidfort-ubuntu:20.04/cve-2020-8169.json b/tests/unit/providers/rapidfort/test-fixtures/snapshots/rapidfort-ubuntu:20.04/cve-2020-8169.json new file mode 100644 index 000000000..64bc3fbce --- /dev/null +++ b/tests/unit/providers/rapidfort/test-fixtures/snapshots/rapidfort-ubuntu:20.04/cve-2020-8169.json @@ -0,0 +1,37 @@ +{ + "identifier": "rapidfort-ubuntu:20.04/cve-2020-8169", + "item": { + "Vulnerability": { + "CVSS": [], + "Description": "curl 7.62.0 through 7.70.0 is vulnerable to an information disclosure vulnerability that can lead to a partial password being leaked over the network.", + "FixedIn": [ + { + "Available": { + "Date": "2024-01-01", + "Kind": "first-observed" + }, + "Name": "curl", + "NamespaceName": "rapidfort-ubuntu:20.04", + "VendorAdvisory": { + "AdvisorySummary": [ + { + "ID": "curl", + "Link": "https://github.com/rapidfort/security-advisories/tree/main/OS/ubuntu/curl.json" + } + ], + "NoAdvisory": false + }, + "Version": "7.68.0-1ubuntu2.1", + "VersionFormat": "dpkg", + "VulnerableRange": ">= 7.68.0, < 7.68.0-1ubuntu2.1" + } + ], + "Link": "https://www.cve.org/CVERecord?id=CVE-2020-8169", + "Metadata": {}, + "Name": "CVE-2020-8169", + "NamespaceName": "rapidfort-ubuntu:20.04", + "Severity": "High" + } + }, + "schema": "https://raw.githubusercontent.com/anchore/vunnel/main/schema/vulnerability/os/schema-1.1.0.json" +} diff --git a/tests/unit/providers/rapidfort/test-fixtures/snapshots/rapidfort-ubuntu:20.04/cve-2022-22576.json b/tests/unit/providers/rapidfort/test-fixtures/snapshots/rapidfort-ubuntu:20.04/cve-2022-22576.json new file mode 100644 index 000000000..120d6474e --- /dev/null +++ b/tests/unit/providers/rapidfort/test-fixtures/snapshots/rapidfort-ubuntu:20.04/cve-2022-22576.json @@ -0,0 +1,57 @@ +{ + "identifier": "rapidfort-ubuntu:20.04/cve-2022-22576", + "item": { + "Vulnerability": { + "CVSS": [], + "Description": "An improper authentication vulnerability exists in curl 7.33.0 to and including 7.82.0 which might allow reuse OAUTH2-authenticated connections without properly making sure that the connection was authenticated with the same credentials as set for this transfer.", + "FixedIn": [ + { + "Available": { + "Date": "2024-01-01", + "Kind": "first-observed" + }, + "Name": "curl", + "NamespaceName": "rapidfort-ubuntu:20.04", + "VendorAdvisory": { + "AdvisorySummary": [ + { + "ID": "curl", + "Link": "https://github.com/rapidfort/security-advisories/tree/main/OS/ubuntu/curl.json" + } + ], + "NoAdvisory": false + }, + "Version": "7.68.0-1ubuntu2.10", + "VersionFormat": "dpkg", + "VulnerableRange": ">= 7.68.0, < 7.68.0-1ubuntu2.10" + }, + { + "Available": { + "Date": "2024-01-01", + "Kind": "first-observed" + }, + "Name": "curl", + "NamespaceName": "rapidfort-ubuntu:20.04", + "VendorAdvisory": { + "AdvisorySummary": [ + { + "ID": "curl", + "Link": "https://github.com/rapidfort/security-advisories/tree/main/OS/ubuntu/curl.json" + } + ], + "NoAdvisory": false + }, + "Version": "7.81.0-1ubuntu1.1", + "VersionFormat": "dpkg", + "VulnerableRange": ">= 7.81.0, < 7.81.0-1ubuntu1.1" + } + ], + "Link": "https://www.cve.org/CVERecord?id=CVE-2022-22576", + "Metadata": {}, + "Name": "CVE-2022-22576", + "NamespaceName": "rapidfort-ubuntu:20.04", + "Severity": "High" + } + }, + "schema": "https://raw.githubusercontent.com/anchore/vunnel/main/schema/vulnerability/os/schema-1.1.0.json" +} diff --git a/tests/unit/providers/rapidfort/test_parser.py b/tests/unit/providers/rapidfort/test_parser.py new file mode 100644 index 000000000..4e6333931 --- /dev/null +++ b/tests/unit/providers/rapidfort/test_parser.py @@ -0,0 +1,355 @@ +"""Tests for RapidFort parser: schema compliance and multi-range CVE handling.""" + +from __future__ import annotations + +import pytest +from vunnel import result, workspace +from vunnel.providers.rapidfort.parser import Parser, _events_to_range_pairs + + +class TestEventsToRangePairs: + """Tests for _events_to_range_pairs helper.""" + + def test_single_event(self): + events = [{"introduced": "7.68.0", "fixed": "7.68.0-1ubuntu2.1"}] + pairs = _events_to_range_pairs(events) + assert len(pairs) == 1 + assert pairs[0] == (">= 7.68.0, < 7.68.0-1ubuntu2.1", "7.68.0-1ubuntu2.1", None) + + def test_multi_range_cve_2022_22576(self): + """CVE-2022-22576 has two events (two branches: 7.68.0 and 7.81.0).""" + events = [ + {"introduced": "7.68.0", "fixed": "7.68.0-1ubuntu2.10"}, + {"introduced": "7.81.0", "fixed": "7.81.0-1ubuntu1.1"}, + ] + pairs = _events_to_range_pairs(events) + assert len(pairs) == 2 + assert pairs[0] == (">= 7.68.0, < 7.68.0-1ubuntu2.10", "7.68.0-1ubuntu2.10", None) + assert pairs[1] == (">= 7.81.0, < 7.81.0-1ubuntu1.1", "7.81.0-1ubuntu1.1", None) + + def test_deduplication(self): + """Duplicate events should be deduplicated.""" + events = [ + {"introduced": "7.68.0", "fixed": "7.68.0-1ubuntu2.10"}, + {"introduced": "7.68.0", "fixed": "7.68.0-1ubuntu2.10"}, + ] + pairs = _events_to_range_pairs(events) + assert len(pairs) == 1 + + def test_introduced_only(self): + events = [{"introduced": "7.68.0"}] + pairs = _events_to_range_pairs(events) + assert len(pairs) == 1 + assert pairs[0] == (">= 7.68.0", "None", None) + + def test_fixed_only(self): + events = [{"fixed": "7.68.0-1ubuntu2.1"}] + pairs = _events_to_range_pairs(events) + assert len(pairs) == 1 + assert pairs[0] == ("< 7.68.0-1ubuntu2.1", "7.68.0-1ubuntu2.1", None) + + def test_identifier_is_preserved_and_part_of_dedup_key(self): + events = [ + {"introduced": "0", "fixed": "7.78.0-4.fc36", "identifier": "fc36"}, + {"introduced": "0", "fixed": "7.81.0-3.fc37", "identifier": "fc37"}, + {"introduced": "0", "fixed": "7.78.0-4.fc36", "identifier": "fc36"}, + ] + pairs = _events_to_range_pairs(events) + assert pairs == [ + (">= 0, < 7.78.0-4.fc36", "7.78.0-4.fc36", "fc36"), + (">= 0, < 7.81.0-3.fc37", "7.81.0-3.fc37", "fc37"), + ] + + +class TestNormalize: + """Tests for _normalize with multi-range CVEs.""" + + def test_multi_range_cve_produces_two_fixed_in_entries( + self, tmpdir, auto_fake_fixdate_finder + ): + """CVE-2022-22576 must produce exactly 2 FixedIn entries with correct ranges.""" + ws = workspace.Workspace(tmpdir, "test", create=True) + parser = Parser(workspace=ws) + + cve_map = { + "CVE-2022-22576": { + "cve_id": "CVE-2022-22576", + "description": "Test description", + "severity": "HIGH", + "events": [ + {"introduced": "7.68.0", "fixed": "7.68.0-1ubuntu2.10"}, + {"introduced": "7.81.0", "fixed": "7.81.0-1ubuntu1.1"}, + ], + }, + } + + with parser: + vuln_dict = parser._normalize("ubuntu", "20.04", "curl", cve_map) + + assert "CVE-2022-22576" in vuln_dict + record = vuln_dict["CVE-2022-22576"] + fixed_in = record["Vulnerability"]["FixedIn"] + + assert len(fixed_in) == 2, "Multi-range CVE must produce 2 FixedIn entries" + + fixed_in_sorted = sorted(fixed_in, key=lambda x: x["Version"]) + assert fixed_in_sorted[0]["Version"] == "7.68.0-1ubuntu2.10" + assert fixed_in_sorted[0]["VulnerableRange"] == ">= 7.68.0, < 7.68.0-1ubuntu2.10", ( + fixed_in_sorted[0]["VulnerableRange"] + ) + assert fixed_in_sorted[0]["VendorAdvisory"]["AdvisorySummary"] == [ + { + "ID": "curl", + "Link": "https://github.com/rapidfort/security-advisories/tree/main/OS/ubuntu/curl.json", + }, + ] + assert fixed_in_sorted[1]["Version"] == "7.81.0-1ubuntu1.1" + assert fixed_in_sorted[1]["VulnerableRange"] == ">= 7.81.0, < 7.81.0-1ubuntu1.1", ( + fixed_in_sorted[1]["VulnerableRange"] + ) + + def test_fix_availability_field_present( + self, tmpdir, auto_fake_fixdate_finder + ): + """Output must include 'Available' field (matching grype OSFixedIn struct and all other providers).""" + ws = workspace.Workspace(tmpdir, "test", create=True) + parser = Parser(workspace=ws) + + cve_map = { + "CVE-2020-8169": { + "cve_id": "CVE-2020-8169", + "description": "Test description", + "severity": "HIGH", + "events": [{"introduced": "7.68.0", "fixed": "7.68.0-1ubuntu2.1"}], + }, + } + + with parser: + vuln_dict = parser._normalize("ubuntu", "20.04", "curl", cve_map) + + record = vuln_dict["CVE-2020-8169"] + fixed_in = record["Vulnerability"]["FixedIn"] + assert len(fixed_in) == 1 + assert "Available" in fixed_in[0], "Must use 'Available' to match grype OSFixedIn struct" + assert fixed_in[0]["Available"]["Date"] == "2024-01-01" + assert fixed_in[0]["Available"]["Kind"] == "first-observed" + + def test_redhat_events_keep_outer_os_version_and_per_range_identifier( + self, tmpdir, auto_fake_fixdate_finder + ): + ws = workspace.Workspace(tmpdir, "test", create=True) + parser = Parser(workspace=ws) + + cve_map = { + "CVE-2014-0139": { + "cve_id": "CVE-2014-0139", + "description": "Test description", + "severity": "LOW", + "events": [ + {"introduced": "0", "identifier": "el9"}, + {"introduced": "0", "fixed": "7.78.0-4.fc36", "identifier": "fc36"}, + {"introduced": "0", "fixed": "7.81.0-3.fc37", "identifier": "fc37"}, + ], + }, + } + + with parser: + vuln_dict = parser._normalize("redhat", "9", "curl", cve_map) + + record = vuln_dict["CVE-2014-0139"] + assert record["Vulnerability"]["NamespaceName"] == "rapidfort-redhat:9" + + fixed_in = sorted( + record["Vulnerability"]["FixedIn"], + key=lambda x: (x["Identifier"], x["Version"]), + ) + + assert len(fixed_in) == 3 + assert fixed_in[0]["Identifier"] == "el9" + assert fixed_in[0]["NamespaceName"] == "rapidfort-redhat:9" + assert fixed_in[0]["VersionFormat"] == "rpm" + assert fixed_in[0]["Version"] == "None" + assert fixed_in[0]["VulnerableRange"] == ">= 0" + assert fixed_in[0]["VendorAdvisory"]["AdvisorySummary"] == [ + { + "ID": "curl", + "Link": "https://github.com/rapidfort/security-advisories/tree/main/OS/redhat/curl.json", + }, + { + "ID": "release-identifier:el9", + "Link": "https://github.com/rapidfort/security-advisories/tree/main/OS/redhat/curl.json", + }, + ] + assert fixed_in[1]["Identifier"] == "fc36" + assert fixed_in[1]["Version"] == "7.78.0-4.fc36" + assert fixed_in[1]["VulnerableRange"] == ">= 0, < 7.78.0-4.fc36" + assert fixed_in[1]["VendorAdvisory"]["AdvisorySummary"] == [ + { + "ID": "curl", + "Link": "https://github.com/rapidfort/security-advisories/tree/main/OS/redhat/curl.json", + }, + { + "ID": "release-identifier:fc36", + "Link": "https://github.com/rapidfort/security-advisories/tree/main/OS/redhat/curl.json", + }, + ] + assert fixed_in[2]["Identifier"] == "fc37" + assert fixed_in[2]["Version"] == "7.81.0-3.fc37" + assert fixed_in[2]["VulnerableRange"] == ">= 0, < 7.81.0-3.fc37" + assert fixed_in[2]["VendorAdvisory"]["AdvisorySummary"] == [ + { + "ID": "curl", + "Link": "https://github.com/rapidfort/security-advisories/tree/main/OS/redhat/curl.json", + }, + { + "ID": "release-identifier:fc37", + "Link": "https://github.com/rapidfort/security-advisories/tree/main/OS/redhat/curl.json", + }, + ] + + +def test_provider_schema(helpers, disable_get_requests, monkeypatch, auto_fake_fixdate_finder): + """Provider output must validate against schema-1.1.0.json.""" + ws = helpers.provider_workspace_helper( + name="rapidfort", + input_fixture="test-fixtures/input", + ) + + from vunnel.providers.rapidfort import Config, Provider + + # Patch git operations so we use pre-populated fixtures instead of cloning + def noop(*args, **kwargs): + pass + + c = Config() + c.runtime.result_store = result.StoreStrategy.FLAT_FILE + p = Provider(root=str(ws.root), config=c) + monkeypatch.setattr(p.parser.git_wrapper, "delete_repo", noop) + monkeypatch.setattr(p.parser.git_wrapper, "clone_repo", noop) + + p.update(None) + + assert ws.num_result_entries() >= 2 + assert ws.result_schemas_valid(require_entries=True) + + +def test_provider_via_snapshot(helpers, disable_get_requests, monkeypatch, auto_fake_fixdate_finder): + """Snapshot test for multi-range CVE regression.""" + ws = helpers.provider_workspace_helper( + name="rapidfort", + input_fixture="test-fixtures/input", + ) + + from vunnel.providers.rapidfort import Config, Provider + + def noop(*args, **kwargs): + pass + + c = Config() + c.runtime.result_store = result.StoreStrategy.FLAT_FILE + p = Provider(root=str(ws.root), config=c) + monkeypatch.setattr(p.parser.git_wrapper, "delete_repo", noop) + monkeypatch.setattr(p.parser.git_wrapper, "clone_repo", noop) + + p.update(None) + + ws.assert_result_snapshots() + + +class TestMergeIntoNamespace: + """Tests for _merge_into_namespace: same CVE in multiple packages.""" + + def test_same_cve_in_two_packages_merges_fixed_in(self, tmpdir, auto_fake_fixdate_finder): + """Same CVE appearing in curl and libcurl4 must produce one record with two FixedIn entries.""" + ws = workspace.Workspace(tmpdir, "test", create=True) + parser = Parser(workspace=ws) + + cve_map = { + "CVE-2022-22576": { + "cve_id": "CVE-2022-22576", + "description": "Test", + "severity": "HIGH", + "events": [{"introduced": "1.0.0", "fixed": "1.0.1"}], + }, + } + + ns = "rapidfort-ubuntu:20.04" + namespace_vulns: dict = {} + + with parser: + curl_vulns = parser._normalize("ubuntu", "20.04", "curl", cve_map) + libcurl_vulns = parser._normalize("ubuntu", "20.04", "libcurl4", cve_map) + + parser._merge_into_namespace(namespace_vulns, ns, curl_vulns) + parser._merge_into_namespace(namespace_vulns, ns, libcurl_vulns) + + assert len(namespace_vulns[ns]) == 1, "same CVE must produce one vuln record" + fixed_in = namespace_vulns[ns]["CVE-2022-22576"]["Vulnerability"]["FixedIn"] + assert len(fixed_in) == 2, "FixedIn must have one entry per package" + package_names = {f["Name"] for f in fixed_in} + assert package_names == {"curl", "libcurl4"} + + def test_distinct_cves_are_not_merged(self, tmpdir, auto_fake_fixdate_finder): + """Different CVEs in the same package must remain as separate records.""" + ws = workspace.Workspace(tmpdir, "test", create=True) + parser = Parser(workspace=ws) + + cve_map_a = { + "CVE-2022-00001": { + "cve_id": "CVE-2022-00001", + "severity": "HIGH", + "events": [{"introduced": "1.0.0", "fixed": "1.0.1"}], + }, + } + cve_map_b = { + "CVE-2022-00002": { + "cve_id": "CVE-2022-00002", + "severity": "LOW", + "events": [{"introduced": "2.0.0", "fixed": "2.0.1"}], + }, + } + + ns = "rapidfort-ubuntu:20.04" + namespace_vulns: dict = {} + + with parser: + vulns_a = parser._normalize("ubuntu", "20.04", "curl", cve_map_a) + vulns_b = parser._normalize("ubuntu", "20.04", "curl", cve_map_b) + + parser._merge_into_namespace(namespace_vulns, ns, vulns_a) + parser._merge_into_namespace(namespace_vulns, ns, vulns_b) + + assert len(namespace_vulns[ns]) == 2, "distinct CVEs must remain as separate records" + + +class TestMapSeverity: + """Tests for _map_severity helper.""" + + def test_known_severities_case_insensitive(self, tmpdir, auto_fake_fixdate_finder): + ws = workspace.Workspace(tmpdir, "test", create=True) + parser = Parser(workspace=ws) + + assert parser._map_severity("critical") == "Critical" + assert parser._map_severity("HIGH") == "High" + assert parser._map_severity("medium") == "Medium" + assert parser._map_severity("Low") == "Low" + assert parser._map_severity("NEGLIGIBLE") == "Negligible" + + def test_unknown_on_none(self, tmpdir, auto_fake_fixdate_finder): + ws = workspace.Workspace(tmpdir, "test", create=True) + parser = Parser(workspace=ws) + + assert parser._map_severity(None) == "Unknown" + + def test_unknown_on_empty_string(self, tmpdir, auto_fake_fixdate_finder): + ws = workspace.Workspace(tmpdir, "test", create=True) + parser = Parser(workspace=ws) + + assert parser._map_severity("") == "Unknown" + + def test_unknown_on_unrecognized_value(self, tmpdir, auto_fake_fixdate_finder): + ws = workspace.Workspace(tmpdir, "test", create=True) + parser = Parser(workspace=ws) + + assert parser._map_severity("invalid") == "Unknown" + assert parser._map_severity("NONE") == "Unknown"