From 65e745d77930cd076e7e4a6826356addf371f709 Mon Sep 17 00:00:00 2001 From: Andoni Alonso <14891798+andoniaf@users.noreply.github.com> Date: Wed, 18 Mar 2026 09:11:39 +0100 Subject: [PATCH] fix(sdk): skip strict CheckMetadata validators for external tool providers (#10363) --- prowler/CHANGELOG.md | 1 + prowler/lib/check/models.py | 65 ++--- prowler/providers/image/image_provider.py | 7 +- tests/lib/check/models_test.py | 246 +++++++++++++++++++ tests/providers/image/image_provider_test.py | 2 +- 5 files changed, 289 insertions(+), 32 deletions(-) diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index d84169771f..b8cc8b8f04 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -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) --- diff --git a/prowler/lib/check/models.py b/prowler/lib/check/models.py index 6711026023..524248d26f 100644 --- a/prowler/lib/check/models.py +++ b/prowler/lib/check/models.py @@ -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) diff --git a/prowler/providers/image/image_provider.py b/prowler/providers/image/image_provider.py index 4beb5eca4e..d724542e28 100644 --- a/prowler/providers/image/image_provider.py +++ b/prowler/providers/image/image_provider.py @@ -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": "", diff --git a/tests/lib/check/models_test.py b/tests/lib/check/models_test.py index 8efa307c54..815479cdfa 100644 --- a/tests/lib/check/models_test.py +++ b/tests/lib/check/models_test.py @@ -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 diff --git a/tests/providers/image/image_provider_test.py b/tests/providers/image/image_provider_test.py index ab9358b002..2db7a8fe48 100644 --- a/tests/providers/image/image_provider_test.py +++ b/tests/providers/image/image_provider_test.py @@ -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):