diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index a08bd2688a..3a1dac3732 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -19,6 +19,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - AWS CodeBuild service now batches `BatchGetProjects` and `BatchGetBuilds` calls per region (up to 100 items per call) to reduce API call volume and prevent throttling-induced false positives in `codebuild_project_not_publicly_accessible` [(#10639)](https://github.com/prowler-cloud/prowler/pull/10639) - `display_compliance_table` dispatch switched from substring `in` checks to `startswith` to prevent false matches between similarly named frameworks (e.g. `cisa` vs `cis`) [(#10301)](https://github.com/prowler-cloud/prowler/pull/10301) - Restore the `ec2-imdsv1` category for EC2 IMDS checks to keep Attack Surface and findings filters aligned [(#10998)](https://github.com/prowler-cloud/prowler/pull/10998) +- Container image CVE findings and IaC findings now use official CVE, Prowler Hub, or GitHub Security Advisory URLs instead of Aqua advisory URLs in remediation and references; Trivy rule IDs map to Prowler Hub without the `AVD-` prefix so links resolve [(#10853)](https://github.com/prowler-cloud/prowler/pull/10853) ### 🐞 Fixed diff --git a/prowler/lib/utils/vulnerability_references.py b/prowler/lib/utils/vulnerability_references.py new file mode 100644 index 0000000000..63afe3c2c6 --- /dev/null +++ b/prowler/lib/utils/vulnerability_references.py @@ -0,0 +1,90 @@ +import re +from urllib.parse import parse_qs, urlparse + +AQUA_REFERENCE_HOST = "avd.aquasec.com" +GITHUB_ADVISORY_URL = "https://github.com/advisories/{advisory_id}" +PROWLER_HUB_CHECK_URL = "https://hub.prowler.com/check/{check_id}" +_CVE_ID_PATTERN = re.compile(r"^CVE-\d{4}-\d+$", re.IGNORECASE) +_GHSA_ID_PATTERN = re.compile(r"^GHSA(?:-[a-z0-9]{4}){3}$", re.IGNORECASE) + + +def _dedupe_preserve_order(urls: list[str]) -> list[str]: + seen: set[str] = set() + ordered_urls: list[str] = [] + + for url in urls: + if not url or not url.strip(): + continue + + normalized_url = url.strip() + if normalized_url in seen: + continue + + seen.add(normalized_url) + ordered_urls.append(normalized_url) + + return ordered_urls + + +def _is_aqua_reference(url: str) -> bool: + return AQUA_REFERENCE_HOST in urlparse(url).netloc.lower() + + +def _build_cve_org_url(vulnerability_id: str) -> str: + return f"https://www.cve.org/CVERecord?id={vulnerability_id.upper()}" + + +def build_finding_reference_url(finding_id: str) -> str: + """Map a Trivy finding ID to a stable, real reference URL. + + - CVE-XXXX-NNNN → cve.org record + - GHSA-… → github.com/advisories + - everything else → hub.prowler.com/check/, stripping a leading + "AVD-" prefix because Prowler Hub indexes Trivy rules by the + non-prefixed ID (e.g., "AWS-0001" not "AVD-AWS-0001"). + """ + normalized = finding_id.strip().upper() + if _CVE_ID_PATTERN.match(normalized): + return _build_cve_org_url(normalized) + if _GHSA_ID_PATTERN.match(normalized): + return GITHUB_ADVISORY_URL.format(advisory_id=normalized) + hub_id = normalized[4:] if normalized.startswith("AVD-") else normalized + return PROWLER_HUB_CHECK_URL.format(check_id=hub_id) + + +def _is_cve_org_url(url: str, vulnerability_id: str) -> bool: + parsed_url = urlparse(url) + if parsed_url.netloc.lower() != "www.cve.org": + return False + + query_value = parse_qs(parsed_url.query).get("id", [""])[0] + return query_value.upper() == vulnerability_id.upper() + + +def resolve_vulnerability_reference_urls( + vulnerability_id: str, + references: list[str] | None = None, + primary_url: str = "", +) -> tuple[str, list[str]]: + """Resolve non-Aqua vulnerability URLs, prioritizing official CVE destinations.""" + + candidate_urls = list(references or []) + if primary_url and primary_url not in candidate_urls: + candidate_urls.append(primary_url) + + filtered_urls = _dedupe_preserve_order( + [url for url in candidate_urls if not _is_aqua_reference(url)] + ) + + if not _CVE_ID_PATTERN.match(vulnerability_id): + return "", filtered_urls + + cve_org_urls = [ + url for url in filtered_urls if _is_cve_org_url(url, vulnerability_id) + ] + + recommendation_url = ( + cve_org_urls[0] if cve_org_urls else _build_cve_org_url(vulnerability_id) + ) + + return recommendation_url, [recommendation_url] diff --git a/prowler/providers/iac/iac_provider.py b/prowler/providers/iac/iac_provider.py index 5b3d898fb3..b91fdf070e 100644 --- a/prowler/providers/iac/iac_provider.py +++ b/prowler/providers/iac/iac_provider.py @@ -18,6 +18,10 @@ from prowler.config.config import ( from prowler.lib.check.models import CheckReportIAC from prowler.lib.logger import logger from prowler.lib.utils.utils import print_boxes +from prowler.lib.utils.vulnerability_references import ( + build_finding_reference_url, + resolve_vulnerability_reference_urls, +) from prowler.providers.common.models import Audit_Metadata, Connection from prowler.providers.common.provider import Provider @@ -189,14 +193,28 @@ class IacProvider(Provider): finding_id = finding["VulnerabilityID"] finding_description = finding["Description"] finding_status = finding.get("Status", "FAIL") + recommendation_url, additional_urls = ( + resolve_vulnerability_reference_urls( + vulnerability_id=finding_id, + references=finding.get("References"), + primary_url=finding.get("PrimaryURL", ""), + ) + ) + if not recommendation_url: + recommendation_url = build_finding_reference_url(finding_id) + additional_urls = [recommendation_url] elif "RuleID" in finding: finding_id = finding["RuleID"] finding_description = finding["Title"] finding_status = finding.get("Status", "FAIL") + recommendation_url = build_finding_reference_url(finding_id) + additional_urls = [recommendation_url] else: finding_id = finding["ID"] finding_description = finding["Description"] finding_status = finding["Status"] + recommendation_url = build_finding_reference_url(finding_id) + additional_urls = [recommendation_url] metadata_dict = { "Provider": "iac", @@ -210,7 +228,7 @@ class IacProvider(Provider): "ResourceType": "iac", "Description": finding_description, "Risk": "This provider has not defined a risk for this check.", - "RelatedUrl": finding.get("PrimaryURL", ""), + "RelatedUrl": "", "Remediation": { "Code": { "NativeIaC": "", @@ -220,11 +238,11 @@ class IacProvider(Provider): }, "Recommendation": { "Text": finding.get("Resolution", ""), - "Url": finding.get("PrimaryURL", ""), + "Url": recommendation_url, }, }, "Categories": [], - "AdditionalURLs": [], + "AdditionalURLs": additional_urls, "DependsOn": [], "RelatedTo": [], "Notes": "", diff --git a/prowler/providers/image/image_provider.py b/prowler/providers/image/image_provider.py index 07d02c04c9..b142240867 100644 --- a/prowler/providers/image/image_provider.py +++ b/prowler/providers/image/image_provider.py @@ -18,6 +18,9 @@ from prowler.config.config import ( from prowler.lib.check.models import CheckReportImage from prowler.lib.logger import logger from prowler.lib.utils.utils import print_boxes +from prowler.lib.utils.vulnerability_references import ( + resolve_vulnerability_reference_urls, +) from prowler.providers.common.models import Audit_Metadata, Connection from prowler.providers.common.provider import Provider from prowler.providers.image.exceptions.exceptions import ( @@ -395,6 +398,8 @@ class ImageProvider(Provider): """ try: # Determine finding ID and category based on type + recommendation_url = "" + additional_urls: list[str] = [] if "VulnerabilityID" in finding: finding_id = finding["VulnerabilityID"] finding_description = finding.get( @@ -402,17 +407,30 @@ class ImageProvider(Provider): ) finding_status = "FAIL" finding_categories = ["vulnerabilities"] + recommendation_url, additional_urls = ( + resolve_vulnerability_reference_urls( + vulnerability_id=finding_id, + references=finding.get("References"), + primary_url=finding.get("PrimaryURL", ""), + ) + ) elif "RuleID" in finding: # Secret finding finding_id = finding["RuleID"] finding_description = finding.get("Title", "Secret detected") finding_status = "FAIL" finding_categories = ["secrets"] + additional_urls = ( + [url] if (url := finding.get("PrimaryURL", "")) else [] + ) else: finding_id = finding.get("ID", "UNKNOWN") finding_description = finding.get("Description", "") finding_status = finding.get("Status", "FAIL") finding_categories = [] + additional_urls = ( + [url] if (url := finding.get("PrimaryURL", "")) else [] + ) # Build remediation text for vulnerabilities remediation_text = "" @@ -451,13 +469,11 @@ class ImageProvider(Provider): }, "Recommendation": { "Text": remediation_text, - "Url": "", + "Url": recommendation_url, }, }, "Categories": finding_categories, - "AdditionalURLs": ( - [url] if (url := finding.get("PrimaryURL", "")) else [] - ), + "AdditionalURLs": additional_urls, "DependsOn": [], "RelatedTo": [], "Notes": "", diff --git a/tests/lib/utils/test_vulnerability_references.py b/tests/lib/utils/test_vulnerability_references.py new file mode 100644 index 0000000000..8610980b51 --- /dev/null +++ b/tests/lib/utils/test_vulnerability_references.py @@ -0,0 +1,91 @@ +from prowler.lib.utils.vulnerability_references import ( + build_finding_reference_url, + resolve_vulnerability_reference_urls, +) + + +class TestBuildFindingReferenceUrl: + def test_cve_id_returns_cve_org_url(self): + assert ( + build_finding_reference_url("CVE-2023-1234") + == "https://www.cve.org/CVERecord?id=CVE-2023-1234" + ) + + def test_lowercase_cve_id_is_normalized(self): + assert ( + build_finding_reference_url("cve-2024-9999") + == "https://www.cve.org/CVERecord?id=CVE-2024-9999" + ) + + def test_ghsa_id_returns_github_advisory_url(self): + assert ( + build_finding_reference_url("GHSA-abcd-1234-efgh") + == "https://github.com/advisories/GHSA-ABCD-1234-EFGH" + ) + + def test_avd_prefixed_id_strips_prefix_for_hub(self): + assert ( + build_finding_reference_url("AVD-AWS-0001") + == "https://hub.prowler.com/check/AWS-0001" + ) + + def test_clean_trivy_id_uses_hub_directly(self): + assert ( + build_finding_reference_url("AWS-0104") + == "https://hub.prowler.com/check/AWS-0104" + ) + + def test_kubernetes_id_uses_hub(self): + assert ( + build_finding_reference_url("AVD-K8S-0001") + == "https://hub.prowler.com/check/K8S-0001" + ) + + def test_dockerfile_id_uses_hub(self): + assert ( + build_finding_reference_url("AVD-DOCKER-0001") + == "https://hub.prowler.com/check/DOCKER-0001" + ) + + def test_whitespace_is_trimmed(self): + assert ( + build_finding_reference_url(" AZU-0013 ") + == "https://hub.prowler.com/check/AZU-0013" + ) + + +class TestResolveVulnerabilityReferenceUrls: + def test_cve_with_cve_org_reference_uses_it(self): + recommendation_url, additional_urls = resolve_vulnerability_reference_urls( + vulnerability_id="CVE-2023-1234", + references=[ + "https://avd.aquasec.com/nvd/cve-2023-1234", + "https://www.cve.org/CVERecord?id=CVE-2023-1234", + "https://nvd.nist.gov/vuln/detail/CVE-2023-1234", + ], + primary_url="https://avd.aquasec.com/nvd/cve-2023-1234", + ) + + assert recommendation_url == "https://www.cve.org/CVERecord?id=CVE-2023-1234" + assert additional_urls == ["https://www.cve.org/CVERecord?id=CVE-2023-1234"] + + def test_cve_without_cve_org_reference_builds_url(self): + recommendation_url, additional_urls = resolve_vulnerability_reference_urls( + vulnerability_id="CVE-2023-5678", + references=["https://nvd.nist.gov/vuln/detail/CVE-2023-5678"], + ) + + assert recommendation_url == "https://www.cve.org/CVERecord?id=CVE-2023-5678" + assert additional_urls == ["https://www.cve.org/CVERecord?id=CVE-2023-5678"] + + def test_non_cve_id_returns_filtered_references(self): + recommendation_url, additional_urls = resolve_vulnerability_reference_urls( + vulnerability_id="GHSA-abcd-1234-efgh", + references=[ + "https://avd.aquasec.com/nvd/ghsa-abcd-1234-efgh", + "https://github.com/advisories/GHSA-abcd-1234-efgh", + ], + ) + + assert recommendation_url == "" + assert additional_urls == ["https://github.com/advisories/GHSA-abcd-1234-efgh"] diff --git a/tests/providers/iac/iac_fixtures.py b/tests/providers/iac/iac_fixtures.py index 2e769a732a..b2d205940e 100644 --- a/tests/providers/iac/iac_fixtures.py +++ b/tests/providers/iac/iac_fixtures.py @@ -259,7 +259,13 @@ SAMPLE_TRIVY_VULNERABILITY_OUTPUT = { "Title": "Example vulnerability", "Description": "This is an example vulnerability", "Severity": "high", - "PrimaryURL": "https://example.com/cve-2023-1234", + "PrimaryURL": "https://avd.aquasec.com/nvd/cve-2023-1234", + "References": [ + "https://avd.aquasec.com/nvd/cve-2023-1234", + "https://nvd.nist.gov/vuln/detail/CVE-2023-1234", + "https://www.cve.org/CVERecord?id=CVE-2023-1234", + "https://security.example.com/advisories/CVE-2023-1234", + ], } ], "Secrets": [], @@ -268,6 +274,39 @@ SAMPLE_TRIVY_VULNERABILITY_OUTPUT = { ] } +SAMPLE_TRIVY_VULNERABILITY_WITHOUT_CVE_ORG_REFERENCE = { + "VulnerabilityID": "CVE-2023-5678", + "Title": "Vulnerability without cve.org reference", + "Description": "This vulnerability includes references but no cve.org reference", + "Severity": "high", + "PrimaryURL": "https://avd.aquasec.com/nvd/cve-2023-5678", + "References": [ + "https://avd.aquasec.com/nvd/cve-2023-5678", + "https://nvd.nist.gov/vuln/detail/CVE-2023-5678", + "https://security.example.com/advisories/CVE-2023-5678", + ], +} + +SAMPLE_TRIVY_VULNERABILITY_WITHOUT_REFERENCES = { + "VulnerabilityID": "CVE-2023-9012", + "Title": "Fallback CVE vulnerability", + "Description": "This vulnerability requires building the URL from VulnerabilityID", + "Severity": "medium", + "PrimaryURL": "https://avd.aquasec.com/nvd/cve-2023-9012", +} + +SAMPLE_TRIVY_NON_CVE_VULNERABILITY = { + "VulnerabilityID": "GHSA-abcd-1234-efgh", + "Title": "Non-CVE vulnerability", + "Description": "This advisory has no CVE identifier", + "Severity": "high", + "PrimaryURL": "https://avd.aquasec.com/nvd/ghsa-abcd-1234-efgh", + "References": [ + "https://avd.aquasec.com/nvd/ghsa-abcd-1234-efgh", + "https://github.com/advisories/GHSA-abcd-1234-efgh", + ], +} + # Sample Trivy output with secrets SAMPLE_TRIVY_SECRET_OUTPUT = { "Results": [ diff --git a/tests/providers/iac/iac_provider_test.py b/tests/providers/iac/iac_provider_test.py index fc98c097d8..8b4b51000a 100644 --- a/tests/providers/iac/iac_provider_test.py +++ b/tests/providers/iac/iac_provider_test.py @@ -20,6 +20,9 @@ from tests.providers.iac.iac_fixtures import ( SAMPLE_KUBERNETES_CHECK, SAMPLE_PASSED_CHECK, SAMPLE_SKIPPED_CHECK, + SAMPLE_TRIVY_NON_CVE_VULNERABILITY, + SAMPLE_TRIVY_VULNERABILITY_WITHOUT_CVE_ORG_REFERENCE, + SAMPLE_TRIVY_VULNERABILITY_WITHOUT_REFERENCES, SAMPLE_YAML_CHECK, get_empty_trivy_output, get_invalid_trivy_output, @@ -57,13 +60,15 @@ class TestIacProvider: assert isinstance(report, CheckReportIAC) assert report.status == "FAIL" + # Trivy emits "AVD-AWS-0001"; Hub indexes it without the AVD- prefix. + expected_url = "https://hub.prowler.com/check/AWS-0001" assert report.check_metadata.Provider == "iac" assert report.check_metadata.CheckID == SAMPLE_FAILED_CHECK["ID"] assert report.check_metadata.CheckTitle == SAMPLE_FAILED_CHECK["Title"] assert report.check_metadata.Severity == "low" - assert report.check_metadata.RelatedUrl == SAMPLE_FAILED_CHECK.get( - "PrimaryURL", "" - ) + assert report.check_metadata.Remediation.Recommendation.Url == expected_url + assert report.check_metadata.RelatedUrl == "" + assert report.check_metadata.AdditionalURLs == [expected_url] def test_iac_provider_process_finding_passed(self): """Test processing a passed finding""" @@ -79,6 +84,101 @@ class TestIacProvider: assert report.check_metadata.CheckTitle == SAMPLE_PASSED_CHECK["Title"] assert report.check_metadata.Severity == "low" + def test_iac_provider_process_vulnerability_prefers_cve_reference_and_filters_aqua( + self, + ): + """Test CVE findings use cve.org and exclude Aqua references.""" + provider = IacProvider() + + report = provider._process_finding( + { + "VulnerabilityID": "CVE-2023-1234", + "Title": "Example vulnerability", + "Description": "This is an example vulnerability", + "Severity": "high", + "PrimaryURL": "https://avd.aquasec.com/nvd/cve-2023-1234", + "References": [ + "https://avd.aquasec.com/nvd/cve-2023-1234", + "https://nvd.nist.gov/vuln/detail/CVE-2023-1234", + "https://www.cve.org/CVERecord?id=CVE-2023-1234", + "https://security.example.com/advisories/CVE-2023-1234", + ], + }, + "package.json", + "nodejs", + ) + + assert ( + report.check_metadata.Remediation.Recommendation.Url + == "https://www.cve.org/CVERecord?id=CVE-2023-1234" + ) + assert report.check_metadata.RelatedUrl == "" + assert report.check_metadata.AdditionalURLs == [ + "https://www.cve.org/CVERecord?id=CVE-2023-1234" + ] + + def test_iac_provider_process_vulnerability_builds_cve_org_for_nvd_reference( + self, + ): + """Test official CVE URL is built when only NVD is provided.""" + provider = IacProvider() + + report = provider._process_finding( + SAMPLE_TRIVY_VULNERABILITY_WITHOUT_CVE_ORG_REFERENCE, + "package.json", + "nodejs", + ) + + assert ( + report.check_metadata.Remediation.Recommendation.Url + == "https://www.cve.org/CVERecord?id=CVE-2023-5678" + ) + assert report.check_metadata.RelatedUrl == "" + assert report.check_metadata.AdditionalURLs == [ + "https://www.cve.org/CVERecord?id=CVE-2023-5678" + ] + + def test_iac_provider_process_vulnerability_builds_cve_org_when_references_missing( + self, + ): + """Test CVE URL is built from VulnerabilityID when references are absent.""" + provider = IacProvider() + + report = provider._process_finding( + SAMPLE_TRIVY_VULNERABILITY_WITHOUT_REFERENCES, + "package.json", + "nodejs", + ) + + assert ( + report.check_metadata.Remediation.Recommendation.Url + == "https://www.cve.org/CVERecord?id=CVE-2023-9012" + ) + assert report.check_metadata.RelatedUrl == "" + assert report.check_metadata.AdditionalURLs == [ + "https://www.cve.org/CVERecord?id=CVE-2023-9012" + ] + + def test_iac_provider_process_non_cve_vulnerability_falls_back_to_github_advisory( + self, + ): + """Non-CVE vulnerabilities (GHSA-…) point to GitHub Security Advisories.""" + provider = IacProvider() + + report = provider._process_finding( + SAMPLE_TRIVY_NON_CVE_VULNERABILITY, + "package.json", + "nodejs", + ) + + expected_url = ( + "https://github.com/advisories/" + f"{SAMPLE_TRIVY_NON_CVE_VULNERABILITY['VulnerabilityID'].upper()}" + ) + assert report.check_metadata.Remediation.Recommendation.Url == expected_url + assert report.check_metadata.RelatedUrl == "" + assert report.check_metadata.AdditionalURLs == [expected_url] + @patch("subprocess.run") def test_iac_provider_run_scan_success(self, mock_subprocess): """Test successful IAC scan with Trivy""" diff --git a/tests/providers/image/image_fixtures.py b/tests/providers/image/image_fixtures.py index 920a7f225a..bf5e12df32 100644 --- a/tests/providers/image/image_fixtures.py +++ b/tests/providers/image/image_fixtures.py @@ -11,6 +11,12 @@ SAMPLE_VULNERABILITY_FINDING = { "Title": "OpenSSL Buffer Overflow", "Description": "A buffer overflow vulnerability in OpenSSL allows remote attackers to execute arbitrary code.", "PrimaryURL": "https://avd.aquasec.com/nvd/cve-2024-1234", + "References": [ + "https://avd.aquasec.com/nvd/cve-2024-1234", + "https://nvd.nist.gov/vuln/detail/CVE-2024-1234", + "https://www.cve.org/CVERecord?id=CVE-2024-1234", + "https://security.alpinelinux.org/vuln/CVE-2024-1234", + ], } # Sample secret finding from Trivy @@ -45,6 +51,50 @@ SAMPLE_UNKNOWN_SEVERITY_FINDING = { "Description": "An issue with unknown severity.", } +SAMPLE_VULNERABILITY_WITHOUT_CVE_ORG_REFERENCE = { + "VulnerabilityID": "CVE-2024-5678", + "PkgID": "libcrypto3@3.3.2-r0", + "PkgName": "libcrypto3", + "InstalledVersion": "3.3.2-r0", + "FixedVersion": "3.3.2-r1", + "Severity": "HIGH", + "Title": "OpenSSL advisory without cve.org reference", + "Description": "A vulnerability with references but no cve.org reference.", + "PrimaryURL": "https://avd.aquasec.com/nvd/cve-2024-5678", + "References": [ + "https://avd.aquasec.com/nvd/cve-2024-5678", + "https://nvd.nist.gov/vuln/detail/CVE-2024-5678", + "https://security.alpinelinux.org/vuln/CVE-2024-5678", + ], +} + +SAMPLE_CVE_WITHOUT_REFERENCES_FINDING = { + "VulnerabilityID": "CVE-2024-9012", + "PkgID": "busybox@1.36.1-r8", + "PkgName": "busybox", + "InstalledVersion": "1.36.1-r8", + "FixedVersion": "1.36.1-r9", + "Severity": "MEDIUM", + "Title": "Busybox fallback CVE", + "Description": "A vulnerability without explicit references.", + "PrimaryURL": "https://avd.aquasec.com/nvd/cve-2024-9012", +} + +SAMPLE_NON_CVE_VULNERABILITY_FINDING = { + "VulnerabilityID": "GHSA-abcd-1234-efgh", + "PkgID": "custompkg@0.0.1", + "PkgName": "custompkg", + "InstalledVersion": "0.0.1", + "Severity": "HIGH", + "Title": "Non-CVE advisory", + "Description": "An advisory without a CVE identifier.", + "PrimaryURL": "https://avd.aquasec.com/nvd/ghsa-abcd-1234-efgh", + "References": [ + "https://avd.aquasec.com/nvd/ghsa-abcd-1234-efgh", + "https://github.com/advisories/GHSA-abcd-1234-efgh", + ], +} + # Sample image SHA for testing (first 12 chars of a sha256 digest) SAMPLE_IMAGE_SHA = "c1aabb73d233" SAMPLE_IMAGE_ID = f"sha256:{SAMPLE_IMAGE_SHA}abcdef1234567890" diff --git a/tests/providers/image/image_provider_test.py b/tests/providers/image/image_provider_test.py index 4462df6beb..92c4236dd8 100644 --- a/tests/providers/image/image_provider_test.py +++ b/tests/providers/image/image_provider_test.py @@ -23,11 +23,14 @@ from prowler.providers.image.exceptions.exceptions import ( ) from prowler.providers.image.image_provider import ImageProvider from tests.providers.image.image_fixtures import ( + SAMPLE_CVE_WITHOUT_REFERENCES_FINDING, SAMPLE_IMAGE_SHA, SAMPLE_MISCONFIGURATION_FINDING, + SAMPLE_NON_CVE_VULNERABILITY_FINDING, SAMPLE_SECRET_FINDING, SAMPLE_UNKNOWN_SEVERITY_FINDING, SAMPLE_VULNERABILITY_FINDING, + SAMPLE_VULNERABILITY_WITHOUT_CVE_ORG_REFERENCE, get_empty_trivy_output, get_invalid_trivy_output, get_multi_type_trivy_output, @@ -148,6 +151,77 @@ class TestImageProvider: assert report.check_metadata.Categories == ["vulnerabilities"] assert report.check_metadata.RelatedUrl == "" + def test_process_finding_vulnerability_prefers_cve_reference_and_filters_aqua(self): + """Test CVE findings use cve.org and exclude Aqua references.""" + provider = _make_provider() + + report = provider._process_finding( + SAMPLE_VULNERABILITY_FINDING, + "alpine:3.18", + "alpine:3.18 (alpine 3.18.0)", + ) + + assert ( + report.check_metadata.Remediation.Recommendation.Url + == "https://www.cve.org/CVERecord?id=CVE-2024-1234" + ) + assert report.check_metadata.AdditionalURLs == [ + "https://www.cve.org/CVERecord?id=CVE-2024-1234" + ] + + def test_process_finding_vulnerability_builds_cve_org_when_only_nvd_reference( + self, + ): + """Test official CVE URL is built when only NVD is provided.""" + provider = _make_provider() + + report = provider._process_finding( + SAMPLE_VULNERABILITY_WITHOUT_CVE_ORG_REFERENCE, + "alpine:3.18", + "alpine:3.18 (alpine 3.18.0)", + ) + + assert ( + report.check_metadata.Remediation.Recommendation.Url + == "https://www.cve.org/CVERecord?id=CVE-2024-5678" + ) + assert report.check_metadata.AdditionalURLs == [ + "https://www.cve.org/CVERecord?id=CVE-2024-5678" + ] + + def test_process_finding_vulnerability_builds_cve_org_when_references_missing(self): + """Test CVE URL is built from VulnerabilityID when references are absent.""" + provider = _make_provider() + + report = provider._process_finding( + SAMPLE_CVE_WITHOUT_REFERENCES_FINDING, + "alpine:3.18", + "alpine:3.18 (alpine 3.18.0)", + ) + + assert ( + report.check_metadata.Remediation.Recommendation.Url + == "https://www.cve.org/CVERecord?id=CVE-2024-9012" + ) + assert report.check_metadata.AdditionalURLs == [ + "https://www.cve.org/CVERecord?id=CVE-2024-9012" + ] + + def test_process_finding_non_cve_vulnerability_does_not_fallback_to_aqua(self): + """Test non-CVE vulnerabilities do not keep Aqua links.""" + provider = _make_provider() + + report = provider._process_finding( + SAMPLE_NON_CVE_VULNERABILITY_FINDING, + "alpine:3.18", + "alpine:3.18 (alpine 3.18.0)", + ) + + assert report.check_metadata.Remediation.Recommendation.Url == "" + assert report.check_metadata.AdditionalURLs == [ + "https://github.com/advisories/GHSA-abcd-1234-efgh" + ] + def test_process_finding_secret(self): """Test processing a secret finding (identified by RuleID).""" provider = _make_provider() diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 109fc46bfb..acd5a3d2dc 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to the **Prowler UI** are documented in this file. ### 🔄 Changed - Standardized "Providers" wording across UI and documentation, replacing legacy "Cloud Providers" / "Accounts" / "Account Groups" copy [(#10971)](https://github.com/prowler-cloud/prowler/pull/10971) +- Finding detail drawer now labels remediation actions from finding-level recommendation URLs by destination: "View CVE", "View in Prowler Hub", "View Advisory", or "View Reference", while keeping URL-only remediation cards labeled [(#10853)](https://github.com/prowler-cloud/prowler/pull/10853) --- diff --git a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.test.tsx b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.test.tsx index cd68b2d34b..1e451b6a34 100644 --- a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.test.tsx +++ b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.test.tsx @@ -674,6 +674,248 @@ describe("ResourceDetailDrawerContent — Fix 2: Remediation heading labels", () }); }); +describe("ResourceDetailDrawerContent — CVE recommendation button", () => { + const statusExtendedWithFixVersions = + "framework.security.spring-security-web@5.8.7 (fix available: 5.7.13, 5.8.15, 6.2.7, 6.0.13, 6.1.11, 6.3.4)"; + const externalCveUrl = "https://www.cve.org/CVERecord?id=CVE-2026-12345"; + + it("should render a View CVE button when the recommendation URL points to an external CVE advisory and keep status extended as plain text", () => { + const cveCheckMeta: CheckMeta = { + ...mockCheckMeta, + remediation: { + ...mockCheckMeta.remediation, + recommendation: { + text: "Review the advisory", + url: externalCveUrl, + }, + }, + }; + const cveFinding: ResourceDrawerFinding = { + ...mockFinding, + statusExtended: statusExtendedWithFixVersions, + remediation: { + ...mockFinding.remediation, + recommendation: { + text: "Review the advisory", + url: externalCveUrl, + }, + }, + }; + + render( + , + ); + + expect(screen.getByRole("link", { name: "View CVE" })).toHaveAttribute( + "href", + externalCveUrl, + ); + expect(screen.getByText(statusExtendedWithFixVersions)).toBeInTheDocument(); + expect( + screen.queryByRole("link", { name: "View in Prowler Hub" }), + ).not.toBeInTheDocument(); + }); + + it("should show View in Prowler Hub when the recommendation URL points to Prowler Hub", () => { + const hubCheckMeta: CheckMeta = { + ...mockCheckMeta, + remediation: { + ...mockCheckMeta.remediation, + recommendation: { + text: "Open the check in Hub", + url: "https://hub.prowler.com/check/image_vulnerability", + }, + }, + }; + const hubFinding: ResourceDrawerFinding = { + ...mockFinding, + statusExtended: statusExtendedWithFixVersions, + remediation: { + ...mockFinding.remediation, + recommendation: { + text: "Open the check in Hub", + url: "https://hub.prowler.com/check/image_vulnerability", + }, + }, + }; + + render( + , + ); + + expect(screen.getByText(statusExtendedWithFixVersions)).toBeInTheDocument(); + expect( + screen.getByRole("link", { name: "View in Prowler Hub" }), + ).toHaveAttribute( + "href", + "https://hub.prowler.com/check/image_vulnerability", + ); + }); + + it("should render the official CVE reference", () => { + const cveCheckMeta: CheckMeta = { + ...mockCheckMeta, + remediation: { + ...mockCheckMeta.remediation, + recommendation: { + text: "Review the advisory", + url: externalCveUrl, + }, + }, + additionalUrls: [externalCveUrl], + }; + const cveFinding: ResourceDrawerFinding = { + ...mockFinding, + statusExtended: statusExtendedWithFixVersions, + remediation: { + ...mockFinding.remediation, + recommendation: { + text: "Review the advisory", + url: externalCveUrl, + }, + }, + additionalUrls: [externalCveUrl], + }; + + render( + , + ); + + expect(screen.getByRole("link", { name: "View CVE" })).toHaveAttribute( + "href", + externalCveUrl, + ); + expect(screen.getByRole("link", { name: externalCveUrl })).toHaveAttribute( + "href", + externalCveUrl, + ); + }); + + it("should render View Advisory when the recommendation URL points to GitHub Security Advisories", () => { + const advisoryUrl = "https://github.com/advisories/GHSA-abcd-1234-efgh"; + const advisoryCheckMeta: CheckMeta = { + ...mockCheckMeta, + remediation: { + ...mockCheckMeta.remediation, + recommendation: { + text: "Review the advisory", + url: advisoryUrl, + }, + }, + }; + const advisoryFinding: ResourceDrawerFinding = { + ...mockFinding, + remediation: { + ...mockFinding.remediation, + recommendation: { + text: "Review the advisory", + url: advisoryUrl, + }, + }, + }; + + render( + , + ); + + expect(screen.getByRole("link", { name: "View Advisory" })).toHaveAttribute( + "href", + advisoryUrl, + ); + expect( + screen.queryByRole("link", { name: "View CVE" }), + ).not.toBeInTheDocument(); + }); + + it("should render a remediation label when the only remediation content is a recommendation link", () => { + const cveCheckMeta: CheckMeta = { + ...mockCheckMeta, + remediation: { + ...mockCheckMeta.remediation, + recommendation: { + text: "", + url: externalCveUrl, + }, + }, + }; + const cveFinding: ResourceDrawerFinding = { + ...mockFinding, + remediation: { + ...mockFinding.remediation, + recommendation: { + text: "", + url: externalCveUrl, + }, + }, + }; + + render( + , + ); + + expect(screen.getByText("Remediation:")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "View CVE" })).toHaveAttribute( + "href", + externalCveUrl, + ); + }); +}); + // --------------------------------------------------------------------------- // Fix 5 & 6: Risk section has danger styling, sections have separators and bigger headings // --------------------------------------------------------------------------- diff --git a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx index 9ccfdc9a41..2a8745fd4f 100644 --- a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx +++ b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx @@ -70,6 +70,7 @@ import { getFailingForLabel } from "@/lib/date-utils"; import { formatDuration } from "@/lib/date-utils"; import { getRegionFlag } from "@/lib/region-flags"; import { cn } from "@/lib/utils"; +import { getRecommendationLinkLabel } from "@/lib/vulnerability-references"; import type { ComplianceOverviewData } from "@/types/compliance"; import type { FindingResourceRow } from "@/types/findings-table"; @@ -87,6 +88,10 @@ function stripCodeFences(code: string): string { .trim(); } +function isNonEmptyString(value: string | null | undefined): value is string { + return typeof value === "string" && value.trim().length > 0; +} + function resolveNativeIacConfig(providerType: string | undefined): { label: string; language: QueryEditorLanguage; @@ -428,6 +433,19 @@ export function ResourceDetailDrawerContent({ const resourceDetailHref = f?.resourceId ? buildResourceDetailHref(f.resourceId) : null; + const findingRecommendationUrl = f?.remediation.recommendation.url; + const checkRecommendationUrl = checkMeta.remediation.recommendation.url; + const recommendationUrl = isNonEmptyString(findingRecommendationUrl) + ? findingRecommendationUrl + : isNonEmptyString(checkRecommendationUrl) + ? checkRecommendationUrl + : null; + const recommendationLink = recommendationUrl + ? { + href: recommendationUrl, + label: getRecommendationLinkLabel(recommendationUrl), + } + : null; const overviewStatusExtended = f?.statusExtended; const showOverviewStatusExtended = Boolean(overviewStatusExtended); @@ -855,28 +873,32 @@ export function ResourceDetailDrawerContent({ {/* Card 2: Remediation + Commands */} {(checkMeta.remediation.recommendation.text || + recommendationLink || checkMeta.remediation.code.cli || checkMeta.remediation.code.terraform || checkMeta.remediation.code.nativeiac) && ( - {checkMeta.remediation.recommendation.text && ( + {(checkMeta.remediation.recommendation.text || + recommendationLink) && (
Remediation:
-
- - {checkMeta.remediation.recommendation.text} - -
- {checkMeta.remediation.recommendation.url && ( + {checkMeta.remediation.recommendation.text && ( +
+ + {checkMeta.remediation.recommendation.text} + +
+ )} + {recommendationLink && ( - View in Prowler Hub + {recommendationLink.label} )}
diff --git a/ui/lib/vulnerability-references.test.ts b/ui/lib/vulnerability-references.test.ts new file mode 100644 index 0000000000..4eabfa40a3 --- /dev/null +++ b/ui/lib/vulnerability-references.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; + +import { getRecommendationLinkLabel } from "./vulnerability-references"; + +describe("getRecommendationLinkLabel", () => { + it("returns View CVE for known CVE hosts", () => { + expect( + getRecommendationLinkLabel("https://www.cve.org/CVERecord?id=CVE-2026-1"), + ).toBe("View CVE"); + expect( + getRecommendationLinkLabel("https://cve.org/CVERecord?id=CVE-2026-1"), + ).toBe("View CVE"); + expect( + getRecommendationLinkLabel("https://cve.mitre.org/cgi-bin/cvename.cgi"), + ).toBe("View CVE"); + }); + + it("returns View in Prowler Hub only for the exact Hub hostname", () => { + expect( + getRecommendationLinkLabel("https://hub.prowler.com/check/example"), + ).toBe("View in Prowler Hub"); + expect( + getRecommendationLinkLabel("https://hub.prowler.com.evil.com/check"), + ).toBe("View Reference"); + }); + + it("returns View Advisory for GitHub Security Advisory URLs", () => { + expect( + getRecommendationLinkLabel( + "https://github.com/advisories/GHSA-abcd-1234-efgh", + ), + ).toBe("View Advisory"); + }); + + it("returns View Reference for unknown or malformed URLs", () => { + expect(getRecommendationLinkLabel("https://example.com/advisory")).toBe( + "View Reference", + ); + expect(getRecommendationLinkLabel("not-a-url")).toBe("View Reference"); + }); +}); diff --git a/ui/lib/vulnerability-references.ts b/ui/lib/vulnerability-references.ts new file mode 100644 index 0000000000..43370e51c6 --- /dev/null +++ b/ui/lib/vulnerability-references.ts @@ -0,0 +1,56 @@ +const RECOMMENDATION_LINK_LABEL = { + CVE: "View CVE", + HUB: "View in Prowler Hub", + ADVISORY: "View Advisory", + REFERENCE: "View Reference", +} as const; + +export type RecommendationLinkLabel = + (typeof RECOMMENDATION_LINK_LABEL)[keyof typeof RECOMMENDATION_LINK_LABEL]; + +const CVE_HOSTS = new Set(["www.cve.org", "cve.org", "cve.mitre.org"]); + +interface RecommendationLinkRule { + label: RecommendationLinkLabel; + matches: (url: URL) => boolean; +} + +const RECOMMENDATION_LINK_RULES: RecommendationLinkRule[] = [ + { + label: RECOMMENDATION_LINK_LABEL.CVE, + matches: (url) => CVE_HOSTS.has(url.hostname.toLowerCase()), + }, + { + label: RECOMMENDATION_LINK_LABEL.HUB, + matches: (url) => url.hostname.toLowerCase() === "hub.prowler.com", + }, + { + label: RECOMMENDATION_LINK_LABEL.ADVISORY, + matches: (url) => + url.hostname.toLowerCase() === "github.com" && + url.pathname.startsWith("/advisories/"), + }, +]; + +function safeParseUrl(url: string): URL | null { + try { + return new URL(url); + } catch { + return null; + } +} + +export function getRecommendationLinkLabel( + url: string, +): RecommendationLinkLabel { + const parsedUrl = safeParseUrl(url); + + if (!parsedUrl) { + return RECOMMENDATION_LINK_LABEL.REFERENCE; + } + + return ( + RECOMMENDATION_LINK_RULES.find((rule) => rule.matches(parsedUrl))?.label ?? + RECOMMENDATION_LINK_LABEL.REFERENCE + ); +}