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

View File

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

View File

@@ -2418,3 +2418,249 @@ class TestCheck:
msg = str(excinfo.value) msg = str(excinfo.value)
assert "!= class name" in msg assert "!= class name" in msg
assert "!= file 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.image_sha == "c1aabb73d233"
assert report.resource_details == "alpine:3.18 (alpine 3.18.0)" assert report.resource_details == "alpine:3.18 (alpine 3.18.0)"
assert report.region == "container" assert report.region == "container"
assert report.check_metadata.Categories == ["vulnerability"] assert report.check_metadata.Categories == ["vulnerabilities"]
assert report.check_metadata.RelatedUrl == "" assert report.check_metadata.RelatedUrl == ""
def test_process_finding_secret(self): def test_process_finding_secret(self):