diff --git a/docs/content/en/connecting_your_tools/parsers/file/openreports.md b/docs/content/en/connecting_your_tools/parsers/file/openreports.md new file mode 100644 index 00000000000..c3ec62d9a42 --- /dev/null +++ b/docs/content/en/connecting_your_tools/parsers/file/openreports.md @@ -0,0 +1,136 @@ +--- +title: "OpenReports" +toc_hide: true +--- + +Import vulnerability scan reports formatted as [OpenReports](https://github.com/openreports/reports-api). + +OpenReports is a Kubernetes-native reporting framework that aggregates vulnerability scan results and compliance checks from various security tools into a unified format. It provides a standardized API for collecting and reporting security findings across your Kubernetes infrastructure. + +### File Types + +DefectDojo parser accepts a .json file. + +### Exporting Reports from Kubernetes + +To export OpenReports from your Kubernetes cluster, use kubectl: + +```bash +kubectl get reports -A -ojson > reports.json +``` + +This command retrieves all Report objects from all namespaces and saves them in JSON format. You can then import the `reports.json` file into DefectDojo. + +To export reports from a specific namespace: + +```bash +kubectl get reports -n -ojson > reports.json +``` + +### Report Formats + +The parser supports multiple input formats: + +- Single Report object +- Array of Report objects +- Kubernetes List object containing Report items + +### Sample Scan Data + +Sample OpenReports scans can be found in the [unittests/scans/openreports directory](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/openreports). + +### Supported Fields + +The parser extracts the following information from OpenReports JSON: + +- **Metadata**: Report name, namespace, UID for stable deduplication +- **Scope**: Kubernetes resource information (kind, name, namespace) +- **Results**: Individual security findings with: + - Message and description + - Policy ID (e.g., CVE identifiers) + - Severity (critical, high, medium, low, info) + - Category (e.g., "vulnerability scan", "compliance check") + - Source scanner information + - Package details (name, installed version, fixed version) + - References and URLs + +### Severity Mapping + +OpenReports severity levels are mapped to DefectDojo as follows: + +| OpenReports Severity | DefectDojo Severity | +|----------------------|---------------------| +| critical | Critical | +| high | High | +| medium | Medium | +| low | Low | +| info | Info | + +### Result Status Mapping + +The `result` field in OpenReports is mapped to DefectDojo finding status: + +| OpenReports Result | Active | Verified | Description | +|--------------------|--------|----------|------------------------------------------------| +| fail | True | True | Finding requires attention | +| warn | True | True | Warning-level finding | +| pass | False | False | Check passed, no vulnerability found | +| skip | False | False | Check was skipped | + +### Features + +**CVE Tracking**: Findings with CVE policy IDs are automatically tagged with vulnerability identifiers. + +**Fix Availability**: The parser automatically sets the `fix_available` flag when a fixed version is provided. + +**Service Mapping**: Findings are mapped to services based on Kubernetes scope (namespace/kind/name). + +**Stable Deduplication**: Uses report UID from metadata for consistent deduplication across reimports. + +**Tagging**: Findings are automatically tagged with category, source scanner, and Kubernetes resource kind. + +### Example JSON Format + +```json +{ + "apiVersion": "openreports.io/v1alpha1", + "kind": "Report", + "metadata": { + "name": "deployment-test-app-630fc", + "namespace": "test", + "uid": "b1fcca57-2efd-44d3-89e9-949e29b61936" + }, + "scope": { + "kind": "Deployment", + "name": "test-app" + }, + "results": [ + { + "category": "vulnerability scan", + "message": "openssl: Out-of-bounds read in HTTP client", + "policy": "CVE-2025-9232", + "properties": { + "fixedVersion": "3.5.4-r0", + "installedVersion": "3.5.2-r1", + "pkgName": "libcrypto3", + "primaryURL": "https://avd.aquasec.com/nvd/cve-2025-9232" + }, + "result": "warn", + "severity": "low", + "source": "image-scanner" + } + ] +} +``` + +### Default Deduplication Hashcode Fields + +By default, DefectDojo identifies duplicate Findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- unique_id_from_tool (format: `report_uid:policy:package_name`) +- title +- severity +- vulnerability ids (for CVE findings) +- description + +The parser uses the report UID from metadata to create a stable `unique_id_from_tool` that persists across reimports. diff --git a/docs/content/supported_tools/parsers/file/openreports.md b/docs/content/supported_tools/parsers/file/openreports.md new file mode 100644 index 00000000000..d19f81c1d4e --- /dev/null +++ b/docs/content/supported_tools/parsers/file/openreports.md @@ -0,0 +1,21 @@ +--- +title: "OpenReports" +toc_hide: true +--- +Import JSON reports from [OpenReports](https://github.com/openreports/reports-api). + +### File Types + +DefectDojo parser accepts a .json file. + +OpenReports JSON files can be exported from Kubernetes clusters using kubectl: + +```bash +kubectl get reports -A -ojson > reports.json +``` + +The parser supports single Report objects, arrays of Reports, or Kubernetes List objects. + +### Sample Scan Data + +Sample OpenReports scans can be found in the [unittests/scans/openreports directory](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/openreports). diff --git a/dojo/settings/settings.dist.py b/dojo/settings/settings.dist.py index 359b6c8edde..c93a1d39fe0 100644 --- a/dojo/settings/settings.dist.py +++ b/dojo/settings/settings.dist.py @@ -1414,6 +1414,7 @@ def saml2_attrib_map_format(din): "Cycognito Scan": ["title", "severity"], "OpenVAS Parser v2": ["title", "severity", "vuln_id_from_tool", "endpoints"], "Snyk Issue API Scan": ["vuln_id_from_tool", "file_path"], + "OpenReports": ["vulnerability_ids", "component_name", "component_version", "severity"], "n0s1 Scanner": ["description"], } @@ -1487,6 +1488,7 @@ def saml2_attrib_map_format(din): "AWS Inspector2 Scan": True, "Cyberwatch scan (Galeax)": True, "OpenVAS Parser v2": True, + "OpenReports": True, } # List of fields that are known to be usable in hash_code computation) @@ -1677,6 +1679,7 @@ def saml2_attrib_map_format(din): "Cyberwatch scan (Galeax)": DEDUPE_ALGO_HASH_CODE, "OpenVAS Parser v2": DEDUPE_ALGO_HASH_CODE, "Snyk Issue API Scan": DEDUPE_ALGO_HASH_CODE, + "OpenReports": DEDUPE_ALGO_HASH_CODE, } # Override the hardcoded settings here via the env var diff --git a/dojo/tools/openreports/__init__.py b/dojo/tools/openreports/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/tools/openreports/parser.py b/dojo/tools/openreports/parser.py new file mode 100644 index 00000000000..e222676fcea --- /dev/null +++ b/dojo/tools/openreports/parser.py @@ -0,0 +1,285 @@ +"""Parser for OpenReports (https://github.com/openreports/reports-api) vulnerability scan reports""" + +import json +import logging + +from dojo.models import Finding +from dojo.tools.parser_test import ParserTest + +logger = logging.getLogger(__name__) + + +OPENREPORTS_SEVERITIES = { + "critical": "Critical", + "high": "High", + "medium": "Medium", + "low": "Low", + "info": "Info", +} + +DESCRIPTION_TEMPLATE = """{message} + +**Category:** {category} +**Policy:** {policy} +**Result:** {result} +**Source:** {source} +**Package Name:** {pkg_name} +**Installed Version:** {installed_version} +**Primary URL:** {primary_url} +""" + + +class OpenreportsParser: + def get_scan_types(self): + return ["OpenReports"] + + def get_label_for_scan_types(self, scan_type): + return "OpenReports" + + def get_description_for_scan_types(self, scan_type): + return "Import OpenReports JSON report." + + def get_findings(self, scan_file, test): + scan_data = scan_file.read() + + try: + data = json.loads(str(scan_data, "utf-8")) + except Exception: + data = json.loads(scan_data) + + if data is None: + return [] + + findings = [] + + # Handle both single report and list of reports + reports = [] + if isinstance(data, dict): + # Check if it's a Kubernetes List object + if data.get("kind") == "List" and "items" in data: + reports = data["items"] + # Check if it's a single Report object + elif data.get("kind") == "Report": + reports = [data] + elif isinstance(data, list): + reports = data + + for report in reports: + if not isinstance(report, dict) or report.get("kind") != "Report": + continue + + findings.extend(self._parse_report(test, report)) + + return findings + + def get_tests(self, scan_type, handle): + try: + data = json.load(handle) + except Exception: + handle.seek(0) + scan_data = handle.read() + try: + data = json.loads(str(scan_data, "utf-8")) + except Exception: + data = json.loads(scan_data) + + if data is None: + return [] + + # Handle both single report and list of reports + reports = [] + if isinstance(data, dict): + if data.get("kind") == "List" and "items" in data: + reports = data["items"] + elif data.get("kind") == "Report": + reports = [data] + elif isinstance(data, list): + reports = data + + # Find all unique sources across all reports + sources_found = set() + for report in reports: + if not isinstance(report, dict) or report.get("kind") != "Report": + continue + for result in report.get("results", []): + source = result.get("source", "OpenReports") + sources_found.add(source) + + # Create a ParserTest for each source + tests = [] + for source in sorted(sources_found): + test = ParserTest( + name=source, + parser_type=source, + version=None, + ) + test.findings = [] + + # Parse all reports and filter findings by source + for report in reports: + if not isinstance(report, dict) or report.get("kind") != "Report": + continue + + findings = self._parse_report_for_source(test, report, source) + test.findings.extend(findings) + + tests.append(test) + + return tests + + def _parse_report(self, test, report): + findings = [] + + # Extract metadata + metadata = report.get("metadata", {}) + report_name = metadata.get("name", "") + namespace = metadata.get("namespace", "") + report_uid = metadata.get("uid", "") + + # Extract scope information + scope = report.get("scope", {}) + scope_kind = scope.get("kind", "") + scope_name = scope.get("name", "") + + # Create service identifier from scope and metadata + service_name = f"{namespace}/{scope_kind}/{scope_name}" if namespace else f"{scope_kind}/{scope_name}" + + # Extract results + results = report.get("results", []) + + for result in results: + if not isinstance(result, dict): + continue + + finding = self._create_finding_from_result(test, result, service_name, report_name, report_uid) + if finding: + findings.append(finding) + + return findings + + def _parse_report_for_source(self, test, report, source_filter): + findings = [] + + # Extract metadata + metadata = report.get("metadata", {}) + report_name = metadata.get("name", "") + namespace = metadata.get("namespace", "") + report_uid = metadata.get("uid", "") + + # Extract scope information + scope = report.get("scope", {}) + scope_kind = scope.get("kind", "") + scope_name = scope.get("name", "") + + # Create service identifier from scope and metadata + service_name = f"{namespace}/{scope_kind}/{scope_name}" if namespace else f"{scope_kind}/{scope_name}" + + # Extract results + results = report.get("results", []) + + for result in results: + if not isinstance(result, dict): + continue + + # Filter by source + result_source = result.get("source", "OpenReports") + if result_source != source_filter: + continue + + finding = self._create_finding_from_result(None, result, service_name, report_name, report_uid) + if finding: + findings.append(finding) + + return findings + + def _create_finding_from_result(self, test, result, service_name, report_name, report_uid): + try: + # Extract basic fields + message = result.get("message", "") + category = result.get("category", "") + policy = result.get("policy", "") + result_status = result.get("result", "") + severity = result.get("severity", "info").lower() + source = result.get("source", "") + + # Extract properties + properties = result.get("properties", {}) + pkg_name = properties.get("pkgName", "") + installed_version = properties.get("installedVersion", "") + fixed_version = properties.get("fixedVersion", "") + primary_url = properties.get("primaryURL", "") + + # Convert severity to DefectDojo format + severity_normalized = OPENREPORTS_SEVERITIES.get(severity, "Info") + + # Create title + title = f"{policy} in {pkg_name}" if policy.startswith("CVE-") else f"{policy}: {message}" + + # Create description + description = DESCRIPTION_TEMPLATE.format( + message=message, + category=category, + policy=policy, + result=result_status, + source=source, + pkg_name=pkg_name, + installed_version=installed_version, + primary_url=primary_url, + ) + + # Determine if fix is available + fix_available = bool(fixed_version and fixed_version.strip()) + + # Set mitigation based on fixed version + mitigation = f"Upgrade to version: {fixed_version}" if fixed_version else "" + + # Set references + references = primary_url or "" + + # Determine active status based on result + active = result_status not in {"skip", "pass"} + verified = result_status in {"fail", "warn"} + + # Create finding + finding = Finding( + test=test, + title=title, + description=description, + severity=severity_normalized, + references=references, + mitigation=mitigation, + component_name=pkg_name, + component_version=installed_version, + service=service_name, + active=active, + verified=verified, + static_finding=True, + dynamic_finding=False, + fix_available=fix_available, + fix_version=fixed_version or None, + ) + + # Create tags + tags = [category, source] + scope_kind = service_name.split("/")[1] if "/" in service_name else "" + if scope_kind: + tags.append(scope_kind) + + # Set unsaved_tags attribute + finding.unsaved_tags = tags + + # Add vulnerability ID if it's a CVE + if policy.startswith("CVE-"): + finding.unsaved_vulnerability_ids = [policy] + + # Set vuln_id_from_tool to policy field for display + finding.vuln_id_from_tool = policy + + return finding # noqa: TRY300 - This is intentional + + except KeyError as exc: + logger.warning("Failed to parse OpenReports result due to missing key: %r", exc) + return None + except Exception as exc: + logger.warning("Failed to parse OpenReports result: %r", exc) + return None diff --git a/unittests/scans/openreports/openreports_list_format.json b/unittests/scans/openreports/openreports_list_format.json new file mode 100644 index 00000000000..957ed4f2d8f --- /dev/null +++ b/unittests/scans/openreports/openreports_list_format.json @@ -0,0 +1,127 @@ +{ + "apiVersion": "v1", + "items": [ + { + "apiVersion": "openreports.io/v1alpha1", + "kind": "Report", + "metadata": { + "creationTimestamp": "2025-10-27T08:28:32Z", + "generation": 3, + "labels": { + "app.kubernetes.io/managed-by": "image-scanner" + }, + "name": "deployment-app1-630fc", + "namespace": "test", + "ownerReferences": [ + { + "apiVersion": "stas.statnett.no/v1alpha1", + "blockOwnerDeletion": true, + "controller": true, + "kind": "ContainerImageScan", + "name": "deployment-app1-630fc", + "uid": "1bd065b0-4272-4a1b-9596-8010e256f3c6" + } + ], + "resourceVersion": "4932284", + "uid": "b1fcca57-2efd-44d3-89e9-949e29b61936" + }, + "results": [ + { + "category": "vulnerability scan", + "message": "openssl: Out-of-bounds read in HTTP client no_proxy handling", + "policy": "CVE-2025-9232", + "properties": { + "fixedVersion": "3.5.4-r0", + "installedVersion": "3.5.2-r1", + "pkgName": "libcrypto3", + "primaryURL": "https://avd.aquasec.com/nvd/cve-2025-9232" + }, + "result": "warn", + "severity": "low", + "source": "image-scanner" + } + ], + "scope": { + "apiVersion": "apps/v1", + "kind": "Deployment", + "name": "app1", + "uid": "d0cbd625-d495-415e-bf39-b4e3c4f4366e" + }, + "summary": { + "fail": 0, + "skip": 0, + "warn": 1 + } + }, + { + "apiVersion": "openreports.io/v1alpha1", + "kind": "Report", + "metadata": { + "creationTimestamp": "2025-10-27T08:26:35Z", + "generation": 1, + "labels": { + "app.kubernetes.io/managed-by": "image-scanner" + }, + "name": "deployment-app2-630fc", + "namespace": "test", + "ownerReferences": [ + { + "apiVersion": "stas.statnett.no/v1alpha1", + "blockOwnerDeletion": true, + "controller": true, + "kind": "ContainerImageScan", + "name": "deployment-app2-630fc", + "uid": "fe6e485f-cf48-4274-b4ef-b6405b791646" + } + ], + "resourceVersion": "4269625", + "uid": "f6d3c38b-f36c-4853-a21a-c08955371c64" + }, + "results": [ + { + "category": "vulnerability scan", + "message": "database/sql: Postgres Scan Race Condition", + "policy": "CVE-2025-47907", + "properties": { + "fixedVersion": "1.23.12, 1.24.6", + "installedVersion": "v1.24.5", + "pkgName": "stdlib", + "primaryURL": "https://avd.aquasec.com/nvd/cve-2025-47907" + }, + "result": "fail", + "severity": "high", + "source": "image-scanner" + }, + { + "category": "configuration scan", + "message": "Container running as root user", + "policy": "SECURITY-001", + "properties": { + "fixedVersion": "", + "installedVersion": "latest", + "pkgName": "container-config", + "primaryURL": "https://security.example.com/policies/SECURITY-001" + }, + "result": "warn", + "severity": "medium", + "source": "policy-scanner" + } + ], + "scope": { + "apiVersion": "apps/v1", + "kind": "Deployment", + "name": "app2", + "uid": "71331981-7efa-4a56-925c-e7c861731ae6" + }, + "summary": { + "fail": 1, + "skip": 0, + "warn": 1 + } + } + ], + "kind": "List", + "metadata": { + "resourceVersion": "" + } +} \ No newline at end of file diff --git a/unittests/scans/openreports/openreports_no_results.json b/unittests/scans/openreports/openreports_no_results.json new file mode 100644 index 00000000000..ebebb36afc8 --- /dev/null +++ b/unittests/scans/openreports/openreports_no_results.json @@ -0,0 +1,36 @@ +{ + "apiVersion": "openreports.io/v1alpha1", + "kind": "Report", + "metadata": { + "creationTimestamp": "2025-10-27T08:26:27Z", + "generation": 1, + "labels": { + "app.kubernetes.io/managed-by": "image-scanner" + }, + "name": "deployment-clean-app-b2131", + "namespace": "test", + "ownerReferences": [ + { + "apiVersion": "stas.statnett.no/v1alpha1", + "blockOwnerDeletion": true, + "controller": true, + "kind": "ContainerImageScan", + "name": "deployment-clean-app-b2131", + "uid": "cdc1999a-2e70-4917-b606-e137be3c2aad" + } + ], + "resourceVersion": "4269547", + "uid": "f06c27ce-9ef6-418b-8049-3a5be737da35" + }, + "scope": { + "apiVersion": "apps/v1", + "kind": "Deployment", + "name": "clean-app", + "uid": "f9ca3c43-302e-46ba-a19e-d2651c8d941b" + }, + "summary": { + "fail": 0, + "skip": 0, + "warn": 0 + } +} \ No newline at end of file diff --git a/unittests/scans/openreports/openreports_single_report.json b/unittests/scans/openreports/openreports_single_report.json new file mode 100644 index 00000000000..59fd2855db9 --- /dev/null +++ b/unittests/scans/openreports/openreports_single_report.json @@ -0,0 +1,80 @@ +{ + "apiVersion": "openreports.io/v1alpha1", + "kind": "Report", + "metadata": { + "creationTimestamp": "2025-10-27T08:28:32Z", + "generation": 3, + "labels": { + "app.kubernetes.io/managed-by": "image-scanner" + }, + "name": "deployment-test-app-630fc", + "namespace": "test", + "ownerReferences": [ + { + "apiVersion": "stas.statnett.no/v1alpha1", + "blockOwnerDeletion": true, + "controller": true, + "kind": "ContainerImageScan", + "name": "deployment-test-app-630fc", + "uid": "1bd065b0-4272-4a1b-9596-8010e256f3c6" + } + ], + "resourceVersion": "4932284", + "uid": "b1fcca57-2efd-44d3-89e9-949e29b61936" + }, + "results": [ + { + "category": "vulnerability scan", + "message": "openssl: Out-of-bounds read in HTTP client no_proxy handling", + "policy": "CVE-2025-9232", + "properties": { + "fixedVersion": "3.5.4-r0", + "installedVersion": "3.5.2-r1", + "pkgName": "libcrypto3", + "primaryURL": "https://avd.aquasec.com/nvd/cve-2025-9232" + }, + "result": "warn", + "severity": "low", + "source": "image-scanner" + }, + { + "category": "vulnerability scan", + "message": "database/sql: Postgres Scan Race Condition", + "policy": "CVE-2025-47907", + "properties": { + "fixedVersion": "1.23.12, 1.24.6", + "installedVersion": "v1.24.4", + "pkgName": "stdlib", + "primaryURL": "https://avd.aquasec.com/nvd/cve-2025-47907" + }, + "result": "fail", + "severity": "high", + "source": "image-scanner" + }, + { + "category": "compliance check", + "message": "Missing security headers in HTTP response", + "policy": "CIS-BENCH-001", + "properties": { + "fixedVersion": "Configure proper security headers", + "installedVersion": "N/A", + "pkgName": "web-server", + "primaryURL": "https://www.cisecurity.org/benchmark/docker" + }, + "result": "fail", + "severity": "low", + "source": "compliance-scanner" + } + ], + "scope": { + "apiVersion": "apps/v1", + "kind": "Deployment", + "name": "test-app", + "uid": "d0cbd625-d495-415e-bf39-b4e3c4f4366e" + }, + "summary": { + "fail": 2, + "skip": 0, + "warn": 1 + } +} \ No newline at end of file diff --git a/unittests/tools/test_openreports_parser.py b/unittests/tools/test_openreports_parser.py new file mode 100644 index 00000000000..480722b9152 --- /dev/null +++ b/unittests/tools/test_openreports_parser.py @@ -0,0 +1,164 @@ +from dojo.models import Test +from dojo.tools.openreports.parser import OpenreportsParser +from unittests.dojo_test_case import DojoTestCase, get_unit_tests_scans_path + + +def sample_path(file_name): + return get_unit_tests_scans_path("openreports") / file_name + + +class TestOpenreportsParser(DojoTestCase): + def test_no_results(self): + with sample_path("openreports_no_results.json").open(encoding="utf-8") as test_file: + parser = OpenreportsParser() + findings = parser.get_findings(test_file, Test()) + self.assertEqual(len(findings), 0) + + def test_single_report(self): + with sample_path("openreports_single_report.json").open(encoding="utf-8") as test_file: + parser = OpenreportsParser() + findings = parser.get_findings(test_file, Test()) + self.assertEqual(len(findings), 3) + + # Test first finding (warn/low severity) + finding1 = findings[0] + self.assertEqual("CVE-2025-9232 in libcrypto3", finding1.title) + self.assertEqual("Low", finding1.severity) + self.assertEqual("libcrypto3", finding1.component_name) + self.assertEqual("3.5.2-r1", finding1.component_version) + self.assertEqual("Upgrade to version: 3.5.4-r0", finding1.mitigation) + self.assertEqual("https://avd.aquasec.com/nvd/cve-2025-9232", finding1.references) + self.assertEqual("test/Deployment/test-app", finding1.service) + self.assertTrue(finding1.active) + self.assertTrue(finding1.verified) + self.assertTrue(finding1.fix_available) + self.assertEqual(1, len(finding1.unsaved_vulnerability_ids)) + self.assertEqual("CVE-2025-9232", finding1.unsaved_vulnerability_ids[0]) + self.assertEqual("CVE-2025-9232", finding1.vuln_id_from_tool) + self.assertIn("vulnerability scan", finding1.unsaved_tags) + self.assertIn("image-scanner", finding1.unsaved_tags) + self.assertIn("Deployment", finding1.unsaved_tags) + + # Test second finding (fail/high severity) + finding2 = findings[1] + self.assertEqual("CVE-2025-47907 in stdlib", finding2.title) + self.assertEqual("High", finding2.severity) + self.assertEqual("stdlib", finding2.component_name) + self.assertEqual("v1.24.4", finding2.component_version) + self.assertEqual("Upgrade to version: 1.23.12, 1.24.6", finding2.mitigation) + self.assertEqual("https://avd.aquasec.com/nvd/cve-2025-47907", finding2.references) + self.assertEqual("test/Deployment/test-app", finding2.service) + self.assertTrue(finding2.active) + self.assertTrue(finding2.verified) + self.assertTrue(finding2.fix_available) + self.assertEqual(1, len(finding2.unsaved_vulnerability_ids)) + self.assertEqual("CVE-2025-47907", finding2.unsaved_vulnerability_ids[0]) + self.assertEqual("CVE-2025-47907", finding2.vuln_id_from_tool) + + # Test third finding (non-CVE policy, fail/low severity) + finding3 = findings[2] + self.assertEqual("CIS-BENCH-001: Missing security headers in HTTP response", finding3.title) + self.assertEqual("Low", finding3.severity) + self.assertEqual("web-server", finding3.component_name) + self.assertEqual("N/A", finding3.component_version) + self.assertEqual("Upgrade to version: Configure proper security headers", finding3.mitigation) + self.assertEqual("https://www.cisecurity.org/benchmark/docker", finding3.references) + self.assertEqual("test/Deployment/test-app", finding3.service) + self.assertTrue(finding3.active) + self.assertTrue(finding3.verified) + self.assertTrue(finding3.fix_available) + # Non-CVE policies should not have vulnerability IDs + self.assertIsNone(finding3.unsaved_vulnerability_ids) + self.assertEqual("CIS-BENCH-001", finding3.vuln_id_from_tool) + self.assertIn("compliance check", finding3.unsaved_tags) + self.assertIn("compliance-scanner", finding3.unsaved_tags) + self.assertIn("Deployment", finding3.unsaved_tags) + + def test_list_format(self): + with sample_path("openreports_list_format.json").open(encoding="utf-8") as test_file: + parser = OpenreportsParser() + findings = parser.get_findings(test_file, Test()) + self.assertEqual(len(findings), 3) + + # Verify findings from different reports have different services + services = {finding.service for finding in findings} + self.assertEqual(len(services), 2) + self.assertIn("test/Deployment/app1", services) + self.assertIn("test/Deployment/app2", services) + + # Verify CVE IDs - only findings with CVE policies should have vulnerability IDs + cve_findings = [finding for finding in findings if finding.unsaved_vulnerability_ids] + self.assertEqual(len(cve_findings), 2) + cve_ids = [finding.unsaved_vulnerability_ids[0] for finding in cve_findings] + self.assertIn("CVE-2025-9232", cve_ids) + self.assertIn("CVE-2025-47907", cve_ids) + + # Verify there's at least one non-CVE finding + non_cve_findings = [finding for finding in findings if not finding.unsaved_vulnerability_ids] + self.assertEqual(len(non_cve_findings), 1) + non_cve_finding = non_cve_findings[0] + self.assertEqual("SECURITY-001: Container running as root user", non_cve_finding.title) + + def test_parser_metadata(self): + parser = OpenreportsParser() + scan_types = parser.get_scan_types() + self.assertEqual(["OpenReports"], scan_types) + + label = parser.get_label_for_scan_types("OpenReports") + self.assertEqual("OpenReports", label) + + description = parser.get_description_for_scan_types("OpenReports") + self.assertEqual("Import OpenReports JSON report.", description) + + def test_get_tests_single_source(self): + with sample_path("openreports_single_report.json").open(encoding="utf-8") as test_file: + parser = OpenreportsParser() + tests = parser.get_tests("OpenReports", test_file) + + # Should have two tests for the two sources + self.assertEqual(len(tests), 2) + + # Verify test names + test_names = {test.name for test in tests} + self.assertIn("image-scanner", test_names) + self.assertIn("compliance-scanner", test_names) + + # Find the image-scanner test + image_scanner_test = next(t for t in tests if t.name == "image-scanner") + self.assertEqual("image-scanner", image_scanner_test.type) + self.assertIsNone(image_scanner_test.version) + self.assertEqual(2, len(image_scanner_test.findings)) + + # Verify findings are properly created + finding1 = image_scanner_test.findings[0] + self.assertEqual("CVE-2025-9232 in libcrypto3", finding1.title) + self.assertEqual("Low", finding1.severity) + # Verify test is not set - check using hasattr to avoid RelatedObjectDoesNotExist + self.assertFalse(hasattr(finding1, "test") and finding1.test is not None) + + def test_get_tests_multiple_sources(self): + with sample_path("openreports_list_format.json").open(encoding="utf-8") as test_file: + parser = OpenreportsParser() + tests = parser.get_tests("OpenReports", test_file) + + # Should have two tests for the two different sources + self.assertEqual(len(tests), 2) + + # Verify test names + test_names = {test.name for test in tests} + self.assertIn("policy-scanner", test_names) + self.assertIn("image-scanner", test_names) + + # Find the image-scanner test + image_scanner_test = next(t for t in tests if t.name == "image-scanner") + self.assertEqual(2, len(image_scanner_test.findings)) + + # Find the policy-scanner test + policy_scanner_test = next(t for t in tests if t.name == "policy-scanner") + self.assertEqual(1, len(policy_scanner_test.findings)) + + # Verify findings have no test set + for test in tests: + for finding in test.findings: + # Check using hasattr to avoid RelatedObjectDoesNotExist + self.assertFalse(hasattr(finding, "test") and finding.test is not None)