Files
prowler/mcp_server/prowler_mcp_server/prowler_app/models/findings.py
2025-12-04 11:00:19 +01:00

334 lines
11 KiB
Python

"""Pydantic models for simplified security findings responses."""
from typing import Literal
from prowler_mcp_server.prowler_app.models.base import MinimalSerializerMixin
from pydantic import BaseModel, ConfigDict, Field
class CheckRemediation(MinimalSerializerMixin, BaseModel):
"""Remediation information for a security check."""
model_config = ConfigDict(frozen=True)
cli: str | None = Field(
default=None,
description="Command-line interface commands for remediation",
)
terraform: str | None = Field(
default=None,
description="Terraform code snippet with best practices for remediation",
)
recommendation_text: str | None = Field(
default=None, description="Text description with best practices"
)
recommendation_url: str | None = Field(
default=None,
description="URL to external remediation documentation",
)
class CheckMetadata(MinimalSerializerMixin, BaseModel):
"""Essential metadata for a security check."""
model_config = ConfigDict(frozen=True)
check_id: str = Field(
description="Unique provider identifier for the security check (e.g., 's3_bucket_public_access')",
)
title: str = Field(
description="Human-readable title of the security check",
)
description: str = Field(
description="Detailed description of what the check validates",
)
provider: str = Field(
description="Prowler provider this check belongs to (e.g., 'aws', 'azure', 'gcp')",
)
service: str = Field(
description="Prowler service being checked (e.g., 's3', 'ec2', 'keyvault')",
)
resource_type: str = Field(
description="Type of resource being evaluated (e.g., 'AwsS3Bucket')",
)
risk: str | None = Field(
default=None,
description="Risk description if the check fails",
)
remediation: CheckRemediation | None = Field(
default=None,
description="Remediation guidance including CLI commands and recommendations",
)
related_url: str | None = Field(
default=None,
description="URL to additional documentation or references",
)
categories: list[str] = Field(
default_factory=list,
description="Categories this check belongs to (e.g., ['encryption', 'logging'])",
)
@classmethod
def from_api_response(cls, data: dict) -> "CheckMetadata":
"""Transform API check_metadata to simplified format."""
remediation_data = data.get("remediation")
remediation = None
if remediation_data:
code = remediation_data.get("code", {})
recommendation = remediation_data.get("recommendation", {})
remediation = CheckRemediation(
cli=code.get("cli"),
terraform=code.get("terraform"),
recommendation_text=recommendation.get("text"),
recommendation_url=recommendation.get("url"),
)
return cls(
check_id=data["checkid"],
title=data["checktitle"],
description=data["description"],
provider=data["provider"],
risk=data.get("risk"),
service=data["servicename"],
resource_type=data["resourcetype"],
remediation=remediation,
related_url=data.get("relatedurl"),
categories=data.get("categories", []),
)
class SimplifiedFinding(MinimalSerializerMixin, BaseModel):
"""Simplified security finding with only LLM-relevant information."""
model_config = ConfigDict(frozen=True)
id: str = Field(
description="Unique UUIDv4 identifier for this finding in Prowler database"
)
uid: str = Field(
description="Human-readable unique identifier assigned by Prowler. Format: prowler-{provider}-{check_id}-{account_uid}-{region}-{resource_name}",
)
status: Literal["FAIL", "PASS", "MANUAL"] = Field(
description="Result status: FAIL (security issue found), PASS (no issue), MANUAL (requires manual verification)",
)
severity: Literal["critical", "high", "medium", "low", "informational"] = Field(
description="Severity level of the finding",
)
check_metadata: CheckMetadata = Field(
description="Metadata about the security check that generated this finding",
)
status_extended: str = Field(
description="Extended status information providing additional context",
)
delta: Literal["new", "changed"] = Field(
description="Change status: 'new' (not seen before), 'changed' (modified since last scan), or None (unchanged)",
)
muted: bool = Field(
description="Whether this finding has been muted/suppressed by the user",
)
muted_reason: str = Field(
default=None,
description="Reason provided when muting this finding (3-500 chars if muted)",
)
@classmethod
def from_api_response(cls, data: dict) -> "SimplifiedFinding":
"""Transform JSON:API finding response to simplified format."""
attributes = data["attributes"]
check_metadata = attributes["check_metadata"]
return cls(
id=data["id"],
uid=attributes["uid"],
status=attributes["status"],
severity=attributes["severity"],
check_metadata=CheckMetadata.from_api_response(check_metadata),
status_extended=attributes["status_extended"],
delta=attributes["delta"],
muted=attributes["muted"],
muted_reason=attributes["muted_reason"],
)
class DetailedFinding(SimplifiedFinding):
"""Detailed security finding with comprehensive information for deep analysis.
Extends SimplifiedFinding with temporal metadata and relationships to scans and resources.
Use this when you need complete context about a specific finding.
"""
model_config = ConfigDict(frozen=True)
inserted_at: str = Field(
description="ISO 8601 timestamp when this finding was first inserted into the database",
)
updated_at: str = Field(
description="ISO 8601 timestamp when this finding was last updated",
)
first_seen_at: str | None = Field(
default=None,
description="ISO 8601 timestamp when this finding was first detected across all scans",
)
scan_id: str | None = Field(
default=None,
description="UUID of the scan that generated this finding",
)
resource_ids: list[str] = Field(
default_factory=list,
description="List of UUIDs for cloud resources associated with this finding",
)
@classmethod
def from_api_response(cls, data: dict) -> "DetailedFinding":
"""Transform JSON:API finding response to detailed format."""
attributes = data["attributes"]
check_metadata = attributes["check_metadata"]
relationships = data.get("relationships", {})
# Parse scan relationship
scan_id = None
scan_data = relationships.get("scan", {}).get("data")
if scan_data:
scan_id = scan_data["id"]
# Parse resources relationship
resource_ids = []
resources_data = relationships.get("resources", {}).get("data", [])
if resources_data:
resource_ids = [r["id"] for r in resources_data]
return cls(
id=data["id"],
uid=attributes["uid"],
status=attributes["status"],
severity=attributes["severity"],
check_metadata=CheckMetadata.from_api_response(check_metadata),
status_extended=attributes.get("status_extended"),
delta=attributes.get("delta"),
muted=attributes["muted"],
muted_reason=attributes.get("muted_reason"),
inserted_at=attributes["inserted_at"],
updated_at=attributes["updated_at"],
first_seen_at=attributes.get("first_seen_at"),
scan_id=scan_id,
resource_ids=resource_ids,
)
class FindingsListResponse(BaseModel):
"""Simplified response for findings list queries."""
model_config = ConfigDict(frozen=True)
findings: list[SimplifiedFinding] = Field(
description="List of security findings matching the query",
)
total_num_finding: int = Field(
description="Total number of findings matching the query across all pages",
ge=0,
)
total_num_pages: int = Field(
description="Total number of pages available",
ge=0,
)
current_page: int = Field(
description="Current page number (1-indexed)",
ge=1,
)
@classmethod
def from_api_response(cls, response: dict) -> "FindingsListResponse":
"""Transform JSON:API response to simplified format."""
data = response["data"]
meta = response["meta"]
pagination = meta["pagination"]
findings = [SimplifiedFinding.from_api_response(item) for item in data]
return cls(
findings=findings,
total_num_finding=pagination["count"],
total_num_pages=pagination["pages"],
current_page=pagination["page"],
)
class FindingsOverview(BaseModel):
"""Simplified findings overview with aggregate statistics."""
model_config = ConfigDict(frozen=True)
total: int = Field(
description="Total number of findings",
ge=0,
)
fail: int = Field(
description="Total number of failed security checks",
ge=0,
)
passed: int = ( # Using 'passed' instead of 'pass' since 'pass' is a Python keyword
Field(
description="Total number of passed security checks",
ge=0,
)
)
muted: int = Field(
description="Total number of muted findings",
ge=0,
)
new: int = Field(
description="Total number of new findings (not seen in previous scans)",
ge=0,
)
changed: int = Field(
description="Total number of changed findings (modified since last scan)",
ge=0,
)
fail_new: int = Field(
description="Number of new findings with FAIL status",
ge=0,
)
fail_changed: int = Field(
description="Number of changed findings with FAIL status",
ge=0,
)
pass_new: int = Field(
description="Number of new findings with PASS status",
ge=0,
)
pass_changed: int = Field(
description="Number of changed findings with PASS status",
ge=0,
)
muted_new: int = Field(
description="Number of new muted findings",
ge=0,
)
muted_changed: int = Field(
description="Number of changed muted findings",
ge=0,
)
@classmethod
def from_api_response(cls, response: dict) -> "FindingsOverview":
"""Transform JSON:API overview response to simplified format."""
data = response["data"]
attributes = data["attributes"]
return cls(
total=attributes["total"],
fail=attributes["fail"],
passed=attributes["pass"],
muted=attributes["muted"],
new=attributes["new"],
changed=attributes["changed"],
fail_new=attributes["fail_new"],
fail_changed=attributes["fail_changed"],
pass_new=attributes["pass_new"],
pass_changed=attributes["pass_changed"],
muted_new=attributes["muted_new"],
muted_changed=attributes["muted_changed"],
)