Compare commits

...

15 Commits

Author SHA1 Message Date
Hugo P.Brito e6fa47be67 fix(ui): refine recommendation link handling
- Move recommendation URL labels to shared UI utilities

- Remove unrelated resource navigation from the drawer diff

- Keep URL-only remediation cards labeled and update tests
2026-05-04 09:32:15 +01:00
Hugo P.Brito 545c14d08c Merge remote-tracking branch 'origin/master' into fix/ui-cve-fix-available-links
# Conflicts:
#	ui/CHANGELOG.md
2026-04-30 12:26:35 +01:00
Hugo P.Brito de0ddf5c48 docs: move CVE link entries to next unreleased version
- Move SDK entry from 5.25.0 to 5.26.0 (Prowler UNRELEASED)
- Add a 1.26.0 (Prowler UNRELEASED) section in the UI changelog and move the entry there
2026-04-30 12:16:03 +01:00
Hugo P.Brito 5310b9e402 fix(ui): label remediation action by destination
- Render "View in Prowler Hub" for hub.prowler.com URLs and "View Advisory" for GitHub Security Advisory URLs alongside the existing "View CVE" action
- Fall back to a generic "View Reference" for other destinations
- Cover the new label with a unit test; refresh changelog entry
2026-04-29 16:11:50 +01:00
Hugo P.Brito ab6f36455f fix(sdk): route Trivy findings to real CVE, Hub and Advisory URLs
- Add `build_finding_reference_url` mapping a finding ID to its canonical reference (`cve.org`, `github.com/advisories`, or `hub.prowler.com/check`)
- IAC provider falls back to the helper when no canonical CVE URL is resolved, so misconfigs and non-CVE vulns get a working remediation link instead of an Aqua URL
- Strip leading `AVD-` so Prowler Hub URLs resolve, since Hub indexes Trivy rules without the prefix
- Cover the helper and IAC behavior with unit tests; refresh changelog entry
2026-04-29 16:09:49 +01:00
Hugo P.Brito 67472fa378 chore: merge master into CVE link PR 2026-04-29 11:33:09 +01:00
Hugo P.Brito a22db1172a docs(sdk): add CVE link changelog entry 2026-04-29 11:31:05 +01:00
Hugo P.Brito 9b50dbceb3 fix(ui): label remediation links by destination
- Use recommendation URLs as the single CTA source

- Keep Prowler Hub and CVE labels distinct

- Assert official CVE references are rendered without Aqua URLs
2026-04-29 11:30:35 +01:00
Hugo P.Brito 9645544552 fix(sdk): use official CVE URLs for Trivy findings
- Resolve CVE recommendations from Trivy references

- Remove Aqua advisory URLs from provider metadata

- Preserve Prowler Hub remediation links for IaC checks

- Cover CVE fallback and non-CVE advisory cases
2026-04-29 11:29:01 +01:00
Hugo P.Brito e970a26ad8 Merge remote-tracking branch 'origin/master' into pr-10853-conflicts
# Conflicts:
#	ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx
2026-04-27 11:06:53 +01:00
Hugo P.Brito 5da90f9ea3 refactor(ui): drop status-extended linkification, keep CVE action only 2026-04-23 11:29:27 +01:00
Hugo P.Brito e06a682cef fix(ui): show CVE action in remediation drawer
- Use external CVE references when a finding has no Hub recommendation
- Keep the remediation CTA slot as View in Prowler Hub or View CVE
- Cover the CVE drawer behavior with resource detail tests
2026-04-23 11:12:11 +01:00
Hugo P.Brito dd45eeea48 Merge remote-tracking branch 'origin/master' into fix/ui-cve-fix-available-links 2026-04-23 11:08:56 +01:00
Hugo P.Brito ca7bba28c4 docs(ui): link changelog entry to PR 2026-04-22 15:28:09 +01:00
Hugo P.Brito 4205ebda04 fix(ui): link CVE fix-available versions
- Link fix available versions in finding details for external CVE advisories
- Keep Prowler Hub-backed checks on the existing remediation path
- Cover the drawer behavior with focused UI tests
2026-04-22 15:26:03 +01:00
14 changed files with 887 additions and 62 deletions
+8
View File
@@ -24,6 +24,14 @@ All notable changes to the **Prowler SDK** are documented in this file.
---
## [5.25.2] (Prowler UNRELEASED)
### 🐞 Fixed
- 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)
---
## [5.25.1] (Prowler v5.25.1)
### 🐞 Fixed
@@ -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/<id>, 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]
+21 -3
View File
@@ -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": recommendation_url,
"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": "",
+20 -4
View File
@@ -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 (
@@ -385,6 +388,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(
@@ -392,17 +397,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 = ""
@@ -441,13 +459,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": "",
@@ -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"]
+40 -1
View File
@@ -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": [
+112 -3
View File
@@ -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 == expected_url
assert report.check_metadata.AdditionalURLs == [expected_url]
def test_iac_provider_process_finding_passed(self):
"""Test processing a passed finding"""
@@ -79,6 +84,110 @@ 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
== "https://www.cve.org/CVERecord?id=CVE-2023-1234"
)
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
== "https://www.cve.org/CVERecord?id=CVE-2023-5678"
)
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
== "https://www.cve.org/CVERecord?id=CVE-2023-9012"
)
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 == expected_url
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"""
+50
View File
@@ -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"
+75 -1
View File
@@ -7,6 +7,7 @@ from unittest.mock import MagicMock, patch
import pytest
from prowler.lib.check.models import CheckReportImage
from prowler.providers.common.provider import Provider
from prowler.providers.image.exceptions.exceptions import (
ImageInvalidConfigScannerError,
ImageInvalidNameError,
@@ -20,14 +21,16 @@ from prowler.providers.image.exceptions.exceptions import (
ImageScanError,
ImageTrivyBinaryNotFoundError,
)
from prowler.providers.common.provider import Provider
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()
+8
View File
@@ -2,6 +2,14 @@
All notable changes to the **Prowler UI** are documented in this file.
## [1.25.2] (Prowler UNRELEASED)
### 🐞 Fixed
- 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)
---
## [1.25.1] (Prowler v5.25.1)
### 🐞 Fixed
@@ -408,7 +408,7 @@ const mockFinding: ResourceDrawerFinding = {
};
describe("ResourceDetailDrawerContent — resource navigation", () => {
it("should render a View Resource link below the resource actions menu", () => {
it("should not render resource navigation from the recommendation drawer", () => {
// Given
render(
<ResourceDetailDrawerContent
@@ -425,25 +425,10 @@ describe("ResourceDetailDrawerContent — resource navigation", () => {
/>,
);
// When
const viewResourceLink = screen.getByRole("link", {
name: "View Resource",
});
const resourceActionsMenu = screen.getByRole("menu", {
name: "Resource actions",
});
// Then
expect(viewResourceLink).toHaveAttribute(
"href",
"/resources?resourceId=res-1",
);
expect(viewResourceLink).toHaveAttribute("target", "_blank");
expect(viewResourceLink).toHaveAttribute("rel", "noopener noreferrer");
expect(
resourceActionsMenu.compareDocumentPosition(viewResourceLink) &
Node.DOCUMENT_POSITION_FOLLOWING,
).not.toBe(0);
screen.queryByRole("link", { name: "View Resource" }),
).not.toBeInTheDocument();
});
});
const mockResourceRow: FindingResourceRow = {
@@ -651,6 +636,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(
<ResourceDetailDrawerContent
isLoading={false}
isNavigating={false}
checkMeta={cveCheckMeta}
currentIndex={0}
totalResources={1}
currentFinding={cveFinding}
otherFindings={[]}
onNavigatePrev={vi.fn()}
onNavigateNext={vi.fn()}
onMuteComplete={vi.fn()}
/>,
);
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(
<ResourceDetailDrawerContent
isLoading={false}
isNavigating={false}
checkMeta={hubCheckMeta}
currentIndex={0}
totalResources={1}
currentFinding={hubFinding}
otherFindings={[]}
onNavigatePrev={vi.fn()}
onNavigateNext={vi.fn()}
onMuteComplete={vi.fn()}
/>,
);
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(
<ResourceDetailDrawerContent
isLoading={false}
isNavigating={false}
checkMeta={cveCheckMeta}
currentIndex={0}
totalResources={1}
currentFinding={cveFinding}
otherFindings={[]}
onNavigatePrev={vi.fn()}
onNavigateNext={vi.fn()}
onMuteComplete={vi.fn()}
/>,
);
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(
<ResourceDetailDrawerContent
isLoading={false}
isNavigating={false}
checkMeta={advisoryCheckMeta}
currentIndex={0}
totalResources={1}
currentFinding={advisoryFinding}
otherFindings={[]}
onNavigatePrev={vi.fn()}
onNavigateNext={vi.fn()}
onMuteComplete={vi.fn()}
/>,
);
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(
<ResourceDetailDrawerContent
isLoading={false}
isNavigating={false}
checkMeta={cveCheckMeta}
currentIndex={0}
totalResources={1}
currentFinding={cveFinding}
otherFindings={[]}
onNavigatePrev={vi.fn()}
onNavigateNext={vi.fn()}
onMuteComplete={vi.fn()}
/>,
);
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
// ---------------------------------------------------------------------------
@@ -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;
@@ -299,12 +304,6 @@ function buildComplianceDetailHref({
return `/compliance/${encodeURIComponent(framework)}?${params.toString()}`;
}
function buildResourceDetailHref(resourceId: string): string {
const params = new URLSearchParams();
params.set("resourceId", resourceId);
return `/resources?${params.toString()}`;
}
interface ResourceDetailDrawerContentProps {
isLoading: boolean;
isNavigating: boolean;
@@ -422,8 +421,18 @@ export function ResourceDetailDrawerContent({
const nativeIacConfig = resolveNativeIacConfig(providerType);
const showOverviewCheckMetaContent = showCheckMetaContent;
const showOverviewFindingContent = Boolean(f);
const resourceDetailHref = f?.resourceId
? buildResourceDetailHref(f.resourceId)
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);
@@ -766,21 +775,6 @@ export function ResourceDetailDrawerContent({
)}
</div>
</div>
{resourceDetailHref && (
<div className="border-border-neutral-secondary flex justify-end border-t pt-3">
<Button variant="link" size="link-sm" asChild>
<Link
href={resourceDetailHref}
target="_blank"
rel="noopener noreferrer"
>
View Resource
<ExternalLink className="size-3" />
</Link>
</Button>
</div>
)}
</>
)}
@@ -852,28 +846,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) && (
<Card variant="inner">
{checkMeta.remediation.recommendation.text && (
{(checkMeta.remediation.recommendation.text ||
recommendationLink) && (
<div className="flex flex-col gap-1">
<span className="text-text-neutral-secondary text-xs">
Remediation:
</span>
<div className="flex items-start gap-3">
<div className="text-text-neutral-primary flex-1 text-sm">
<MarkdownContainer>
{checkMeta.remediation.recommendation.text}
</MarkdownContainer>
</div>
{checkMeta.remediation.recommendation.url && (
{checkMeta.remediation.recommendation.text && (
<div className="text-text-neutral-primary flex-1 text-sm">
<MarkdownContainer>
{checkMeta.remediation.recommendation.text}
</MarkdownContainer>
</div>
)}
{recommendationLink && (
<CustomLink
href={checkMeta.remediation.recommendation.url}
href={recommendationLink.href}
size="sm"
className="shrink-0"
>
View in Prowler Hub
{recommendationLink.label}
</CustomLink>
)}
</div>
+41
View File
@@ -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");
});
});
+56
View File
@@ -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
);
}