Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
4205ebd
fix(ui): link CVE fix-available versions
Apr 22, 2026
ca7bba2
docs(ui): link changelog entry to PR
Apr 22, 2026
dd45eee
Merge remote-tracking branch 'origin/master' into fix/ui-cve-fix-avai…
HugoPBrito Apr 23, 2026
e06a682
fix(ui): show CVE action in remediation drawer
HugoPBrito Apr 23, 2026
5da90f9
refactor(ui): drop status-extended linkification, keep CVE action only
HugoPBrito Apr 23, 2026
e970a26
Merge remote-tracking branch 'origin/master' into pr-10853-conflicts
HugoPBrito Apr 27, 2026
9645544
fix(sdk): use official CVE URLs for Trivy findings
HugoPBrito Apr 29, 2026
9b50dbc
fix(ui): label remediation links by destination
HugoPBrito Apr 29, 2026
a22db11
docs(sdk): add CVE link changelog entry
HugoPBrito Apr 29, 2026
67472fa
chore: merge master into CVE link PR
HugoPBrito Apr 29, 2026
ab6f364
fix(sdk): route Trivy findings to real CVE, Hub and Advisory URLs
HugoPBrito Apr 29, 2026
5310b9e
fix(ui): label remediation action by destination
HugoPBrito Apr 29, 2026
de0ddf5
docs: move CVE link entries to next unreleased version
HugoPBrito Apr 30, 2026
545c14d
Merge remote-tracking branch 'origin/master' into fix/ui-cve-fix-avai…
HugoPBrito Apr 30, 2026
e6fa47b
fix(ui): refine recommendation link handling
HugoPBrito May 4, 2026
64df3a2
chore: merge master into PR 10853
HugoPBrito May 6, 2026
4dbb7c7
fix(ui): restore View Resource action in findings drawer
HugoPBrito May 6, 2026
8408d57
chore: update changelog
HugoPBrito May 6, 2026
5fd5f25
Clear RelatedUrl for findings without recommendations
HugoPBrito May 7, 2026
27f4d0c
test(sdk): align iac related URL expectations
HugoPBrito May 7, 2026
3d75111
chore: update sdk CHANGELOG.md
HugoPBrito May 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions prowler/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
### 🐞 Fixed

- Alibaba Cloud CS service SDK compatibility, harden other services and improve documentation [(#10871)](https://github.com/prowler-cloud/prowler/pull/10871)
- 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)
- AWS Organizations metadata retrieval for delegated administrator scans by using the assumed role session instead of the pre-assume credentials [(#10894)](https://github.com/prowler-cloud/prowler/pull/10894)
- `admincenter_groups_not_public_visibility` check for M365 provider evaluating Security and Distribution groups, now restricted to Microsoft 365 (Unified) groups per CIS M365 Foundations 1.2.1 [(#10899)](https://github.com/prowler-cloud/prowler/pull/10899)
- Google Workspace check reports now store the actual domain or account resource subject instead of `provider.identity` [(#10901)](https://github.com/prowler-cloud/prowler/pull/10901)
Expand Down
90 changes: 90 additions & 0 deletions prowler/lib/utils/vulnerability_references.py
Original file line number Diff line number Diff line change
@@ -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]
24 changes: 21 additions & 3 deletions prowler/providers/iac/iac_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@
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

Expand Down Expand Up @@ -189,14 +193,28 @@ def _process_finding(
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",
Expand All @@ -210,7 +228,7 @@ def _process_finding(
"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": "",
Expand All @@ -220,11 +238,11 @@ def _process_finding(
},
"Recommendation": {
"Text": finding.get("Resolution", ""),
"Url": finding.get("PrimaryURL", ""),
"Url": recommendation_url,
},
},
"Categories": [],
"AdditionalURLs": [],
"AdditionalURLs": additional_urls,
"DependsOn": [],
"RelatedTo": [],
"Notes": "",
Expand Down
24 changes: 20 additions & 4 deletions prowler/providers/image/image_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@
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 (
Expand Down Expand Up @@ -385,24 +388,39 @@ def _process_finding(
"""
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(
"Description", finding.get("Title", "")
)
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 = ""
Expand Down Expand Up @@ -441,13 +459,11 @@ def _process_finding(
},
"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": "",
Expand Down
91 changes: 91 additions & 0 deletions tests/lib/utils/test_vulnerability_references.py
Original file line number Diff line number Diff line change
@@ -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"]
41 changes: 40 additions & 1 deletion tests/providers/iac/iac_fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,13 @@
"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": [],
Expand All @@ -268,6 +274,39 @@
]
}

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": [
Expand Down
Loading
Loading