fix(sdk): skip strict CheckMetadata validators for external tool providers (#10363)

This commit is contained in:
Andoni Alonso
2026-03-18 09:11:39 +01:00
committed by GitHub
parent 907664093f
commit 65e745d779
5 changed files with 289 additions and 32 deletions

View File

@@ -31,6 +31,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
- Route53 dangling IP check false positive when using `--region` flag [(#9952)](https://github.com/prowler-cloud/prowler/pull/9952)
- RBI compliance framework support on Prowler Dashboard for the Azure provider [(#10360)](https://github.com/prowler-cloud/prowler/pull/10360)
- CheckMetadata strict validators rejecting valid external tool provider data (image, iac, llm) [(#10363)](https://github.com/prowler-cloud/prowler/pull/10363)
---

View File

@@ -245,7 +245,7 @@ class CheckMetadata(BaseModel):
Compliance: Optional[list[Any]] = Field(default_factory=list)
@validator("Categories", each_item=True, pre=True, always=True)
def valid_category(value):
def valid_category(cls, value, values):
if not isinstance(value, str):
raise ValueError("Categories must be a list of strings")
value_lower = value.lower()
@@ -253,7 +253,10 @@ class CheckMetadata(BaseModel):
raise ValueError(
f"Invalid category: {value}. Categories can only contain lowercase letters, numbers and hyphen '-'"
)
if value_lower not in VALID_CATEGORIES:
if (
value_lower not in VALID_CATEGORIES
and values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS
):
raise ValueError(
f"Invalid category: '{value_lower}'. Must be one of: {', '.join(sorted(VALID_CATEGORIES))}."
)
@@ -306,30 +309,32 @@ class CheckMetadata(BaseModel):
return check_id
@validator("CheckTitle", pre=True, always=True)
def validate_check_title(cls, check_title):
if len(check_title) > 150:
raise ValueError(
f"CheckTitle must not exceed 150 characters, got {len(check_title)} characters"
)
if check_title.startswith("Ensure"):
raise ValueError(
"CheckTitle must not start with 'Ensure'. Use a descriptive title that focuses on the security state."
)
def validate_check_title(cls, check_title, values):
if values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS:
if len(check_title) > 150:
raise ValueError(
f"CheckTitle must not exceed 150 characters, got {len(check_title)} characters"
)
if check_title.startswith("Ensure"):
raise ValueError(
"CheckTitle must not start with 'Ensure'. Use a descriptive title that focuses on the security state."
)
return check_title
@validator("RelatedUrl", pre=True, always=True)
def validate_related_url(cls, related_url):
if related_url:
def validate_related_url(cls, related_url, values):
if related_url and values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS:
raise ValueError("RelatedUrl must be empty. This field is deprecated.")
return related_url
@validator("Remediation")
def validate_recommendation_url(remediation):
url = remediation.Recommendation.Url
if url and not url.startswith("https://hub.prowler.com/"):
raise ValueError(
f"Remediation Recommendation URL must point to Prowler Hub (https://hub.prowler.com/...), got '{url}'."
)
def validate_recommendation_url(cls, remediation, values):
if values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS:
url = remediation.Recommendation.Url
if url and not url.startswith("https://hub.prowler.com/"):
raise ValueError(
f"Remediation Recommendation URL must point to Prowler Hub (https://hub.prowler.com/...), got '{url}'."
)
return remediation
@validator("CheckType", pre=True, always=True)
@@ -362,19 +367,21 @@ class CheckMetadata(BaseModel):
return check_type
@validator("Description", pre=True, always=True)
def validate_description(cls, description):
if len(description) > 400:
raise ValueError(
f"Description must not exceed 400 characters, got {len(description)} characters"
)
def validate_description(cls, description, values):
if values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS:
if len(description) > 400:
raise ValueError(
f"Description must not exceed 400 characters, got {len(description)} characters"
)
return description
@validator("Risk", pre=True, always=True)
def validate_risk(cls, risk):
if len(risk) > 400:
raise ValueError(
f"Risk must not exceed 400 characters, got {len(risk)} characters"
)
def validate_risk(cls, risk, values):
if values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS:
if len(risk) > 400:
raise ValueError(
f"Risk must not exceed 400 characters, got {len(risk)} characters"
)
return risk
@validator("ResourceGroup", pre=True, always=True)

View File

@@ -383,7 +383,7 @@ class ImageProvider(Provider):
"Description", finding.get("Title", "")
)
finding_status = "FAIL"
finding_categories = ["vulnerability"]
finding_categories = ["vulnerabilities"]
elif "RuleID" in finding:
# Secret finding
finding_id = finding["RuleID"]
@@ -433,10 +433,13 @@ class ImageProvider(Provider):
},
"Recommendation": {
"Text": remediation_text,
"Url": finding.get("PrimaryURL", ""),
"Url": "",
},
},
"Categories": finding_categories,
"AdditionalURLs": (
[url] if (url := finding.get("PrimaryURL", "")) else []
),
"DependsOn": [],
"RelatedTo": [],
"Notes": "",

View File

@@ -2418,3 +2418,249 @@ class TestCheck:
msg = str(excinfo.value)
assert "!= class name" in msg
assert "!= file name" in msg
class TestExternalToolProviderValidatorBypass:
"""Validators skip strict rules for external tool providers (image, iac, llm)."""
EXTERNAL_METADATA_BASE = {
"Provider": "image",
"CheckID": "CVE-2024-1234",
"CheckTitle": "OpenSSL Buffer Overflow",
"CheckType": ["Container Image Security"],
"ServiceName": "container-image",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "high",
"ResourceType": "container-image",
"ResourceGroup": "container",
"Description": "A buffer overflow vulnerability.",
"Risk": "Remote code execution.",
"RelatedUrl": "",
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "",
"Terraform": "",
},
"Recommendation": {
"Text": "Upgrade openssl",
"Url": "https://avd.aquasec.com/nvd/cve-2024-1234",
},
},
"Categories": ["vulnerability"],
"DependsOn": [],
"RelatedTo": [],
"Notes": "",
}
def test_external_provider_allows_non_hub_recommendation_url(self):
metadata = CheckMetadata(**self.EXTERNAL_METADATA_BASE)
assert (
metadata.Remediation.Recommendation.Url
== "https://avd.aquasec.com/nvd/cve-2024-1234"
)
def test_native_provider_rejects_non_hub_recommendation_url(self):
data = {
**self.EXTERNAL_METADATA_BASE,
"Provider": "azure",
"CheckID": "test_check",
"ServiceName": "test",
"CheckType": [],
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "",
"Terraform": "",
},
"Recommendation": {
"Text": "Fix it",
"Url": "https://avd.aquasec.com/nvd/cve-2024-1234",
},
},
}
with pytest.raises(ValidationError) as exc_info:
CheckMetadata(**data)
assert "Prowler Hub" in str(exc_info.value)
def test_external_provider_allows_long_description(self):
data = {**self.EXTERNAL_METADATA_BASE, "Description": "A" * 500}
metadata = CheckMetadata(**data)
assert len(metadata.Description) == 500
def test_native_provider_rejects_long_description(self):
data = {
**self.EXTERNAL_METADATA_BASE,
"Provider": "azure",
"CheckID": "test_check",
"ServiceName": "test",
"CheckType": [],
"Categories": ["encryption"],
"Description": "A" * 401,
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "",
"Terraform": "",
},
"Recommendation": {
"Text": "",
"Url": "",
},
},
}
with pytest.raises(ValidationError) as exc_info:
CheckMetadata(**data)
assert "Description must not exceed 400 characters" in str(exc_info.value)
def test_external_provider_allows_long_risk(self):
data = {**self.EXTERNAL_METADATA_BASE, "Risk": "R" * 500}
metadata = CheckMetadata(**data)
assert len(metadata.Risk) == 500
def test_native_provider_rejects_long_risk(self):
data = {
**self.EXTERNAL_METADATA_BASE,
"Provider": "azure",
"CheckID": "test_check",
"ServiceName": "test",
"CheckType": [],
"Categories": ["encryption"],
"Risk": "R" * 401,
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "",
"Terraform": "",
},
"Recommendation": {
"Text": "",
"Url": "",
},
},
}
with pytest.raises(ValidationError) as exc_info:
CheckMetadata(**data)
assert "Risk must not exceed 400 characters" in str(exc_info.value)
def test_external_provider_allows_long_check_title(self):
data = {**self.EXTERNAL_METADATA_BASE, "CheckTitle": "T" * 200}
metadata = CheckMetadata(**data)
assert len(metadata.CheckTitle) == 200
def test_native_provider_rejects_long_check_title(self):
data = {
**self.EXTERNAL_METADATA_BASE,
"Provider": "azure",
"CheckID": "test_check",
"ServiceName": "test",
"CheckType": [],
"Categories": ["encryption"],
"CheckTitle": "T" * 151,
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "",
"Terraform": "",
},
"Recommendation": {
"Text": "",
"Url": "",
},
},
}
with pytest.raises(ValidationError) as exc_info:
CheckMetadata(**data)
assert "CheckTitle must not exceed 150 characters" in str(exc_info.value)
def test_external_provider_allows_non_standard_category(self):
data = {**self.EXTERNAL_METADATA_BASE, "Categories": ["vulnerability"]}
metadata = CheckMetadata(**data)
assert metadata.Categories == ["vulnerability"]
def test_native_provider_rejects_non_standard_category(self):
data = {
**self.EXTERNAL_METADATA_BASE,
"Provider": "azure",
"CheckID": "test_check",
"ServiceName": "test",
"CheckType": [],
"Categories": ["vulnerability"],
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "",
"Terraform": "",
},
"Recommendation": {
"Text": "",
"Url": "",
},
},
}
with pytest.raises(ValidationError) as exc_info:
CheckMetadata(**data)
assert "Invalid category" in str(exc_info.value)
def test_external_provider_allows_ensure_prefix_in_title(self):
data = {
**self.EXTERNAL_METADATA_BASE,
"CheckTitle": "Ensure containers run as non-root",
}
metadata = CheckMetadata(**data)
assert metadata.CheckTitle == "Ensure containers run as non-root"
def test_external_provider_allows_non_empty_related_url(self):
data = {
**self.EXTERNAL_METADATA_BASE,
"RelatedUrl": "https://avd.aquasec.com/nvd/cve-2024-1234",
}
metadata = CheckMetadata(**data)
assert metadata.RelatedUrl == "https://avd.aquasec.com/nvd/cve-2024-1234"
def test_native_provider_rejects_non_empty_related_url(self):
data = {
**self.EXTERNAL_METADATA_BASE,
"Provider": "azure",
"CheckID": "test_check",
"ServiceName": "test",
"CheckType": [],
"Categories": ["encryption"],
"RelatedUrl": "https://example.com",
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "",
"Terraform": "",
},
"Recommendation": {
"Text": "",
"Url": "",
},
},
}
with pytest.raises(ValidationError) as exc_info:
CheckMetadata(**data)
assert "RelatedUrl must be empty" in str(exc_info.value)
def test_all_external_providers_bypass(self):
for provider in ("image", "iac", "llm"):
data = {
**self.EXTERNAL_METADATA_BASE,
"Provider": provider,
"Description": "D" * 500,
"Risk": "R" * 500,
"CheckTitle": "T" * 200,
"Categories": ["vulnerability"],
"RelatedUrl": "https://example.com/vuln",
}
metadata = CheckMetadata(**data)
assert metadata.Provider == provider

View File

@@ -143,7 +143,7 @@ class TestImageProvider:
assert report.image_sha == "c1aabb73d233"
assert report.resource_details == "alpine:3.18 (alpine 3.18.0)"
assert report.region == "container"
assert report.check_metadata.Categories == ["vulnerability"]
assert report.check_metadata.Categories == ["vulnerabilities"]
assert report.check_metadata.RelatedUrl == ""
def test_process_finding_secret(self):