mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-11 22:14:17 +00:00
Merge branch 'master' into PROWLER-690-feature-add-batch-provider-creation-endpoint-api
This commit is contained in:
103 files changed
+16732
-6236
No files matched your search
@@ -104,16 +104,17 @@ Every AWS provider scan will enqueue an Attack Paths ingestion job automatically
|
||||
|
||||
| Provider | Checks | Services | [Compliance Frameworks](https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/compliance/) | [Categories](https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/misc/#categories) | Support | Interface |
|
||||
|---|---|---|---|---|---|---|
|
||||
| AWS | 584 | 85 | 40 | 17 | Official | UI, API, CLI |
|
||||
| GCP | 89 | 17 | 14 | 5 | Official | UI, API, CLI |
|
||||
| Azure | 169 | 22 | 15 | 8 | Official | UI, API, CLI |
|
||||
| Kubernetes | 84 | 7 | 6 | 9 | Official | UI, API, CLI |
|
||||
| AWS | 584 | 84 | 40 | 17 | Official | UI, API, CLI |
|
||||
| Azure | 169 | 22 | 16 | 12 | Official | UI, API, CLI |
|
||||
| GCP | 100 | 17 | 14 | 7 | Official | UI, API, CLI |
|
||||
| Kubernetes | 84 | 7 | 7 | 9 | Official | UI, API, CLI |
|
||||
| GitHub | 20 | 2 | 1 | 2 | Official | UI, API, CLI |
|
||||
| M365 | 70 | 7 | 3 | 2 | Official | UI, API, CLI |
|
||||
| OCI | 52 | 15 | 1 | 12 | Official | UI, API, CLI |
|
||||
| Alibaba Cloud | 63 | 10 | 1 | 9 | Official | CLI |
|
||||
| M365 | 71 | 7 | 4 | 3 | Official | UI, API, CLI |
|
||||
| OCI | 52 | 14 | 1 | 12 | Official | UI, API, CLI |
|
||||
| Alibaba Cloud | 64 | 9 | 2 | 9 | Official | UI, API, CLI |
|
||||
| Cloudflare | 23 | 2 | 0 | 5 | Official | CLI |
|
||||
| IaC | [See `trivy` docs.](https://trivy.dev/latest/docs/coverage/iac/) | N/A | N/A | N/A | Official | UI, API, CLI |
|
||||
| MongoDB Atlas | 10 | 4 | 0 | 3 | Official | UI, API, CLI |
|
||||
| MongoDB Atlas | 10 | 3 | 0 | 3 | Official | UI, API, CLI |
|
||||
| LLM | [See `promptfoo` docs.](https://www.promptfoo.dev/docs/red-team/plugins/) | N/A | N/A | N/A | Official | CLI |
|
||||
| NHN | 6 | 2 | 1 | 0 | Unofficial | CLI |
|
||||
|
||||
|
||||
@@ -4,6 +4,12 @@ All notable changes to the **Prowler API** are documented in this file.
|
||||
|
||||
## [1.19.0] (Prowler UNRELEASED)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
- Attack Paths: Bedrock Code Interpreter and AttachRolePolicy privilege escalation queries [(#9885)](https://github.com/prowler-cloud/prowler/pull/9885)
|
||||
- Added memory optimizations for large compliance report generation [(#9444)](https://github.com/prowler-cloud/prowler/pull/9444)
|
||||
- `GET /api/v1/resources/{id}/events` endpoint to retrieve AWS resource modification history from CloudTrail [(#9101)](https://github.com/prowler-cloud/prowler/pull/9101)
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
- Lazy-load providers and compliance data to reduce API/worker startup memory and time [(#9857)](https://github.com/prowler-cloud/prowler/pull/9857)
|
||||
|
||||
@@ -504,6 +504,185 @@ _QUERY_DEFINITIONS: dict[str, list[AttackPathsQueryDefinition]] = {
|
||||
""",
|
||||
parameters=[],
|
||||
),
|
||||
AttackPathsQueryDefinition(
|
||||
id="aws-iam-privesc-attach-role-policy-assume-role",
|
||||
name="Privilege Escalation: iam:AttachRolePolicy + sts:AssumeRole",
|
||||
description="Detect principals who can both attach policies to roles AND assume those roles. This two-step attack allows modifying a role's permissions then assuming it to gain elevated access. This is a principal-access escalation path (pathfinding.cloud: iam-014).",
|
||||
provider="aws",
|
||||
cypher="""
|
||||
// Create a virtual escalation outcome node (styled like a finding)
|
||||
CALL apoc.create.vNode(['PrivilegeEscalation'], {
|
||||
id: 'effective-administrator',
|
||||
check_title: 'Privilege Escalation',
|
||||
name: 'Effective Administrator',
|
||||
status: 'FAIL',
|
||||
severity: 'critical'
|
||||
})
|
||||
YIELD node AS admin_outcome
|
||||
|
||||
WITH admin_outcome
|
||||
|
||||
// Find principals in the account
|
||||
MATCH path_principal = (aws:AWSAccount {id: $provider_uid})--(principal:AWSPrincipal)
|
||||
|
||||
// Find statements granting iam:AttachRolePolicy
|
||||
MATCH path_attach = (principal)--(attach_policy:AWSPolicy)--(stmt_attach:AWSPolicyStatement)
|
||||
WHERE stmt_attach.effect = 'Allow'
|
||||
AND any(action IN stmt_attach.action WHERE
|
||||
toLower(action) = 'iam:attachrolepolicy'
|
||||
OR toLower(action) = 'iam:*'
|
||||
OR action = '*'
|
||||
)
|
||||
|
||||
// Find statements granting sts:AssumeRole
|
||||
MATCH path_assume = (principal)--(assume_policy:AWSPolicy)--(stmt_assume:AWSPolicyStatement)
|
||||
WHERE stmt_assume.effect = 'Allow'
|
||||
AND any(action IN stmt_assume.action WHERE
|
||||
toLower(action) = 'sts:assumerole'
|
||||
OR toLower(action) = 'sts:*'
|
||||
OR action = '*'
|
||||
)
|
||||
|
||||
// Find target roles that the principal can both modify AND assume
|
||||
MATCH path_target = (aws)--(target_role:AWSRole)
|
||||
WHERE target_role.arn CONTAINS $provider_uid
|
||||
// Can attach policy to this role
|
||||
AND any(resource IN stmt_attach.resource WHERE
|
||||
resource = '*'
|
||||
OR target_role.arn CONTAINS resource
|
||||
OR resource CONTAINS target_role.name
|
||||
)
|
||||
// Can assume this role
|
||||
AND any(resource IN stmt_assume.resource WHERE
|
||||
resource = '*'
|
||||
OR target_role.arn CONTAINS resource
|
||||
OR resource CONTAINS target_role.name
|
||||
)
|
||||
|
||||
// Deduplicate before creating virtual relationships
|
||||
WITH DISTINCT admin_outcome, aws, principal, target_role
|
||||
|
||||
// Create virtual relationships showing the attack path
|
||||
CALL apoc.create.vRelationship(principal, 'CAN_MODIFY', {
|
||||
via: 'iam:AttachRolePolicy'
|
||||
}, target_role)
|
||||
YIELD rel AS modify_rel
|
||||
|
||||
CALL apoc.create.vRelationship(target_role, 'LEADS_TO', {
|
||||
technique: 'iam:AttachRolePolicy + sts:AssumeRole',
|
||||
via: 'sts:AssumeRole',
|
||||
reference: 'https://pathfinding.cloud/paths/iam-014'
|
||||
}, admin_outcome)
|
||||
YIELD rel AS escalation_rel
|
||||
|
||||
// Re-match paths for visualization
|
||||
MATCH path_principal = (aws)--(principal)
|
||||
MATCH path_target = (aws)--(target_role)
|
||||
|
||||
UNWIND nodes(path_principal) + nodes(path_target) as n
|
||||
OPTIONAL MATCH (n)-[pfr]-(pf:ProwlerFinding)
|
||||
WHERE pf.status = 'FAIL'
|
||||
|
||||
RETURN path_principal, path_target,
|
||||
admin_outcome, modify_rel, escalation_rel,
|
||||
collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
|
||||
""",
|
||||
parameters=[],
|
||||
),
|
||||
AttackPathsQueryDefinition(
|
||||
id="aws-bedrock-privesc-passrole-code-interpreter",
|
||||
name="Privilege Escalation: Bedrock Code Interpreter with PassRole",
|
||||
description="Detect principals that can escalate privileges by passing a role to a Bedrock AgentCore Code Interpreter. The attacker creates a code interpreter with an arbitrary role, then invokes it to execute code with those credentials.",
|
||||
provider="aws",
|
||||
cypher="""
|
||||
CALL apoc.create.vNode(['PrivilegeEscalation'], {
|
||||
id: 'effective-administrator-bedrock',
|
||||
check_title: 'Privilege Escalation',
|
||||
name: 'Effective Administrator (Bedrock)',
|
||||
status: 'FAIL',
|
||||
severity: 'critical'
|
||||
})
|
||||
YIELD node AS escalation_outcome
|
||||
|
||||
WITH escalation_outcome
|
||||
|
||||
// Find principals in the account
|
||||
MATCH path_principal = (aws:AWSAccount {id: $provider_uid})--(principal:AWSPrincipal)
|
||||
|
||||
// Principal can assume roles (up to 2 hops)
|
||||
OPTIONAL MATCH path_assume = (principal)-[:STS_ASSUMEROLE_ALLOW*0..2]->(acting_as:AWSRole)
|
||||
WITH escalation_outcome, aws, principal, path_principal, path_assume,
|
||||
CASE WHEN path_assume IS NULL THEN principal ELSE acting_as END AS effective_principal
|
||||
|
||||
// Find iam:PassRole permission
|
||||
MATCH path_passrole = (effective_principal)--(passrole_policy:AWSPolicy)--(passrole_stmt:AWSPolicyStatement)
|
||||
WHERE passrole_stmt.effect = 'Allow'
|
||||
AND any(action IN passrole_stmt.action WHERE toLower(action) = 'iam:passrole' OR action = '*')
|
||||
|
||||
// Find Bedrock AgentCore permissions
|
||||
MATCH (effective_principal)--(bedrock_policy:AWSPolicy)--(bedrock_stmt:AWSPolicyStatement)
|
||||
WHERE bedrock_stmt.effect = 'Allow'
|
||||
AND (
|
||||
any(action IN bedrock_stmt.action WHERE toLower(action) = 'bedrock-agentcore:createcodeinterpreter' OR action = '*' OR toLower(action) = 'bedrock-agentcore:*')
|
||||
)
|
||||
AND (
|
||||
any(action IN bedrock_stmt.action WHERE toLower(action) = 'bedrock-agentcore:startsession' OR action = '*' OR toLower(action) = 'bedrock-agentcore:*')
|
||||
)
|
||||
AND (
|
||||
any(action IN bedrock_stmt.action WHERE toLower(action) = 'bedrock-agentcore:invoke' OR action = '*' OR toLower(action) = 'bedrock-agentcore:*')
|
||||
)
|
||||
|
||||
// Find target roles with elevated permissions that could be passed
|
||||
MATCH (aws)--(target_role:AWSRole)--(target_policy:AWSPolicy)--(target_stmt:AWSPolicyStatement)
|
||||
WHERE target_stmt.effect = 'Allow'
|
||||
AND (
|
||||
any(action IN target_stmt.action WHERE action = '*')
|
||||
OR any(action IN target_stmt.action WHERE toLower(action) = 'iam:*')
|
||||
)
|
||||
|
||||
// Deduplicate per (principal, target_role) pair
|
||||
WITH DISTINCT escalation_outcome, aws, principal, target_role
|
||||
|
||||
// Group by principal, collect target_roles
|
||||
WITH escalation_outcome, aws, principal,
|
||||
collect(DISTINCT target_role) AS target_roles,
|
||||
count(DISTINCT target_role) AS target_count
|
||||
|
||||
// Create single virtual Bedrock node per principal
|
||||
CALL apoc.create.vNode(['BedrockCodeInterpreter'], {
|
||||
name: 'New Code Interpreter',
|
||||
description: toString(target_count) + ' admin role(s) can be passed',
|
||||
id: principal.arn,
|
||||
target_role_count: target_count
|
||||
})
|
||||
YIELD node AS bedrock_agent
|
||||
|
||||
// Connect from principal (not effective_principal) to keep graph connected
|
||||
CALL apoc.create.vRelationship(principal, 'CREATES_INTERPRETER', {
|
||||
permissions: ['iam:PassRole', 'bedrock-agentcore:CreateCodeInterpreter', 'bedrock-agentcore:StartSession', 'bedrock-agentcore:Invoke'],
|
||||
technique: 'new-passrole'
|
||||
}, bedrock_agent)
|
||||
YIELD rel AS create_rel
|
||||
|
||||
// UNWIND target_roles to show which roles can be passed
|
||||
UNWIND target_roles AS target_role
|
||||
|
||||
CALL apoc.create.vRelationship(bedrock_agent, 'PASSES_ROLE', {}, target_role)
|
||||
YIELD rel AS pass_rel
|
||||
|
||||
CALL apoc.create.vRelationship(target_role, 'GRANTS_ACCESS', {
|
||||
reference: 'https://pathfinding.cloud/paths/bedrock-001'
|
||||
}, escalation_outcome)
|
||||
YIELD rel AS grants_rel
|
||||
|
||||
// Re-match path for visualization
|
||||
MATCH path_principal = (aws)--(principal)
|
||||
|
||||
RETURN path_principal,
|
||||
bedrock_agent, target_role, escalation_outcome, create_rel, pass_rel, grants_rel, target_count
|
||||
""",
|
||||
parameters=[],
|
||||
),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -107,3 +107,105 @@ class ConflictException(APIException):
|
||||
error_detail["source"] = {"pointer": pointer}
|
||||
|
||||
super().__init__(detail=[error_detail])
|
||||
|
||||
|
||||
# Upstream Provider Errors (for external API calls like CloudTrail)
|
||||
# These indicate issues with the provider, not with the user's API authentication
|
||||
|
||||
|
||||
class UpstreamAuthenticationError(APIException):
|
||||
"""Provider credentials are invalid or expired (502 Bad Gateway).
|
||||
|
||||
Used when AWS/Azure/GCP credentials fail to authenticate with the upstream
|
||||
provider. This is NOT the user's API authentication failing.
|
||||
"""
|
||||
|
||||
status_code = status.HTTP_502_BAD_GATEWAY
|
||||
default_detail = (
|
||||
"Provider credentials are invalid or expired. Please reconnect the provider."
|
||||
)
|
||||
default_code = "upstream_auth_failed"
|
||||
|
||||
def __init__(self, detail=None):
|
||||
super().__init__(
|
||||
detail=[
|
||||
{
|
||||
"detail": detail or self.default_detail,
|
||||
"status": str(self.status_code),
|
||||
"code": self.default_code,
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class UpstreamAccessDeniedError(APIException):
|
||||
"""Provider credentials lack required permissions (502 Bad Gateway).
|
||||
|
||||
Used when credentials are valid but don't have the IAM permissions
|
||||
needed for the requested operation (e.g., cloudtrail:LookupEvents).
|
||||
This is 502 (not 403) because it's an upstream/gateway error - the USER
|
||||
authenticated fine, but the PROVIDER's credentials are misconfigured.
|
||||
"""
|
||||
|
||||
status_code = status.HTTP_502_BAD_GATEWAY
|
||||
default_detail = (
|
||||
"Access denied. The provider credentials do not have the required permissions."
|
||||
)
|
||||
default_code = "upstream_access_denied"
|
||||
|
||||
def __init__(self, detail=None):
|
||||
super().__init__(
|
||||
detail=[
|
||||
{
|
||||
"detail": detail or self.default_detail,
|
||||
"status": str(self.status_code),
|
||||
"code": self.default_code,
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class UpstreamServiceUnavailableError(APIException):
|
||||
"""Provider service is unavailable (503 Service Unavailable).
|
||||
|
||||
Used when the upstream provider API returns an error or is unreachable.
|
||||
"""
|
||||
|
||||
status_code = status.HTTP_503_SERVICE_UNAVAILABLE
|
||||
default_detail = "Unable to communicate with the provider. Please try again later."
|
||||
default_code = "service_unavailable"
|
||||
|
||||
def __init__(self, detail=None):
|
||||
super().__init__(
|
||||
detail=[
|
||||
{
|
||||
"detail": detail or self.default_detail,
|
||||
"status": str(self.status_code),
|
||||
"code": self.default_code,
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class UpstreamInternalError(APIException):
|
||||
"""Unexpected error communicating with provider (500 Internal Server Error).
|
||||
|
||||
Used as a catch-all for unexpected errors during provider communication.
|
||||
"""
|
||||
|
||||
status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
default_detail = (
|
||||
"An unexpected error occurred while communicating with the provider."
|
||||
)
|
||||
default_code = "internal_error"
|
||||
|
||||
def __init__(self, detail=None):
|
||||
super().__init__(
|
||||
detail=[
|
||||
{
|
||||
"detail": detail or self.default_detail,
|
||||
"status": str(self.status_code),
|
||||
"code": self.default_code,
|
||||
}
|
||||
]
|
||||
)
|
||||
@@ -9079,6 +9079,64 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ResourceResponse'
|
||||
description: ''
|
||||
/api/v1/resources/{id}/events:
|
||||
get:
|
||||
operationId: resources_events_list
|
||||
description: |-
|
||||
Retrieve events showing modification history for a resource. Returns who modified the resource and when. Currently only available for AWS resources.
|
||||
|
||||
**Note:** Some events may not appear due to CloudTrail indexing limitations. Not all AWS API calls record the resource identifier in a searchable format.
|
||||
summary: Get events for a resource
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
description: A UUID string identifying this resource.
|
||||
required: true
|
||||
- in: query
|
||||
name: include_read_events
|
||||
schema:
|
||||
type: boolean
|
||||
description: 'Include read-only events (Describe*, Get*, List*, etc.). Default:
|
||||
false. Set to true to include all events.'
|
||||
- in: query
|
||||
name: lookback_days
|
||||
schema:
|
||||
type: integer
|
||||
description: 'Number of days to look back (default: 90, min: 1, max: 90).'
|
||||
- name: page[number]
|
||||
required: false
|
||||
in: query
|
||||
description: A page number within the paginated result set.
|
||||
schema:
|
||||
type: integer
|
||||
- in: query
|
||||
name: page[size]
|
||||
schema:
|
||||
type: integer
|
||||
description: 'Maximum number of events to return (default: 50, min: 1, max:
|
||||
50).'
|
||||
tags:
|
||||
- Resource
|
||||
security:
|
||||
- JWT or API Key: []
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/vnd.api+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PaginatedResourceEventList'
|
||||
description: ''
|
||||
'400':
|
||||
description: Invalid provider or parameters
|
||||
'500':
|
||||
description: Unexpected error retrieving events
|
||||
'502':
|
||||
description: Provider credentials invalid, expired, or lack required permissions
|
||||
'503':
|
||||
description: Provider service unavailable
|
||||
/api/v1/resources/latest:
|
||||
get:
|
||||
operationId: resources_latest_retrieve
|
||||
@@ -16462,6 +16520,15 @@ components:
|
||||
$ref: '#/components/schemas/ProviderSecret'
|
||||
required:
|
||||
- data
|
||||
PaginatedResourceEventList:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ResourceEvent'
|
||||
required:
|
||||
- data
|
||||
PaginatedResourceGroupOverviewList:
|
||||
type: object
|
||||
properties:
|
||||
@@ -20544,6 +20611,63 @@ components:
|
||||
readOnly: true
|
||||
required:
|
||||
- provider
|
||||
ResourceEvent:
|
||||
type: object
|
||||
required:
|
||||
- type
|
||||
- id
|
||||
additionalProperties: false
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
description: The [type](https://jsonapi.org/format/#document-resource-object-identification)
|
||||
member is used to describe resource objects that share common attributes
|
||||
and relationships.
|
||||
enum:
|
||||
- resource-events
|
||||
id: {}
|
||||
attributes:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
event_time:
|
||||
type: string
|
||||
format: date-time
|
||||
event_name:
|
||||
type: string
|
||||
event_source:
|
||||
type: string
|
||||
actor:
|
||||
type: string
|
||||
actor_uid:
|
||||
type: string
|
||||
nullable: true
|
||||
actor_type:
|
||||
type: string
|
||||
nullable: true
|
||||
source_ip_address:
|
||||
type: string
|
||||
nullable: true
|
||||
user_agent:
|
||||
type: string
|
||||
nullable: true
|
||||
request_data:
|
||||
nullable: true
|
||||
response_data:
|
||||
nullable: true
|
||||
error_code:
|
||||
type: string
|
||||
nullable: true
|
||||
error_message:
|
||||
type: string
|
||||
nullable: true
|
||||
required:
|
||||
- id
|
||||
- event_time
|
||||
- event_name
|
||||
- event_source
|
||||
- actor
|
||||
ResourceGroupOverview:
|
||||
type: object
|
||||
required:
|
||||
|
||||
@@ -4356,6 +4356,661 @@ class TestResourceViewSet:
|
||||
assert attributes["types"] == [latest_scan_resource.type]
|
||||
assert "groups" in attributes
|
||||
|
||||
# Events endpoint tests
|
||||
def test_events_non_aws_provider(self, authenticated_client, providers_fixture):
|
||||
"""Test events endpoint rejects non-AWS providers."""
|
||||
from api.models import Resource
|
||||
|
||||
azure_provider = providers_fixture[4] # Azure provider from fixture
|
||||
|
||||
resource = Resource.objects.create(
|
||||
uid="test-resource-id",
|
||||
name="Test Resource",
|
||||
type="test-type",
|
||||
region="us-east-1",
|
||||
service="test-service",
|
||||
provider=azure_provider,
|
||||
tenant_id=azure_provider.tenant_id,
|
||||
)
|
||||
|
||||
response = authenticated_client.get(
|
||||
reverse("resource-events", kwargs={"pk": resource.id})
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
# Verify JSON:API error structure
|
||||
error = response.json()["errors"][0]
|
||||
assert error["code"] == "invalid_provider"
|
||||
assert error["status"] == "400" # Must be string per JSON:API spec
|
||||
assert error["source"]["pointer"] == "/data/attributes/provider"
|
||||
assert "AWS" in error["detail"]
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"lookback_days,expected_status,expected_code,expected_detail_contains",
|
||||
[
|
||||
("abc", status.HTTP_400_BAD_REQUEST, "invalid", "valid integer"),
|
||||
("0", status.HTTP_400_BAD_REQUEST, "out_of_range", "between 1 and 90"),
|
||||
("91", status.HTTP_400_BAD_REQUEST, "out_of_range", "between 1 and 90"),
|
||||
("-5", status.HTTP_400_BAD_REQUEST, "out_of_range", "between 1 and 90"),
|
||||
],
|
||||
)
|
||||
def test_events_invalid_lookback_days(
|
||||
self,
|
||||
authenticated_client,
|
||||
providers_fixture,
|
||||
lookback_days,
|
||||
expected_status,
|
||||
expected_code,
|
||||
expected_detail_contains,
|
||||
):
|
||||
"""Test events endpoint validates lookback_days with JSON:API compliant errors."""
|
||||
from api.models import Resource
|
||||
|
||||
aws_provider = providers_fixture[0] # AWS provider from fixture
|
||||
|
||||
resource = Resource.objects.create(
|
||||
uid="arn:aws:ec2:us-east-1:123456789012:instance/i-test",
|
||||
name="Test Instance",
|
||||
type="instance",
|
||||
region="us-east-1",
|
||||
service="ec2",
|
||||
provider=aws_provider,
|
||||
tenant_id=aws_provider.tenant_id,
|
||||
)
|
||||
|
||||
response = authenticated_client.get(
|
||||
reverse("resource-events", kwargs={"pk": resource.id}),
|
||||
{"lookback_days": lookback_days},
|
||||
)
|
||||
|
||||
assert response.status_code == expected_status
|
||||
|
||||
# Verify JSON:API error structure
|
||||
error = response.json()["errors"][0]
|
||||
assert error["code"] == expected_code
|
||||
assert error["status"] == "400" # Must be string per JSON:API spec
|
||||
assert error["source"]["parameter"] == "lookback_days"
|
||||
assert expected_detail_contains in error["detail"]
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"page_size,expected_status,expected_code,expected_detail_contains",
|
||||
[
|
||||
("abc", status.HTTP_400_BAD_REQUEST, "invalid", "valid integer"),
|
||||
("0", status.HTTP_400_BAD_REQUEST, "out_of_range", "between 1 and 50"),
|
||||
("51", status.HTTP_400_BAD_REQUEST, "out_of_range", "between 1 and 50"),
|
||||
("-1", status.HTTP_400_BAD_REQUEST, "out_of_range", "between 1 and 50"),
|
||||
],
|
||||
)
|
||||
def test_events_invalid_page_size(
|
||||
self,
|
||||
authenticated_client,
|
||||
providers_fixture,
|
||||
page_size,
|
||||
expected_status,
|
||||
expected_code,
|
||||
expected_detail_contains,
|
||||
):
|
||||
"""Test events endpoint validates page[size] with JSON:API compliant errors."""
|
||||
from api.models import Resource
|
||||
|
||||
aws_provider = providers_fixture[0] # AWS provider from fixture
|
||||
|
||||
resource = Resource.objects.create(
|
||||
uid="arn:aws:ec2:us-east-1:123456789012:instance/i-pagesize-test",
|
||||
name="Test Instance",
|
||||
type="instance",
|
||||
region="us-east-1",
|
||||
service="ec2",
|
||||
provider=aws_provider,
|
||||
tenant_id=aws_provider.tenant_id,
|
||||
)
|
||||
|
||||
response = authenticated_client.get(
|
||||
reverse("resource-events", kwargs={"pk": resource.id}),
|
||||
{"page[size]": page_size},
|
||||
)
|
||||
|
||||
assert response.status_code == expected_status
|
||||
|
||||
# Verify JSON:API error structure
|
||||
error = response.json()["errors"][0]
|
||||
assert error["code"] == expected_code
|
||||
assert error["status"] == "400" # Must be string per JSON:API spec
|
||||
assert error["source"]["parameter"] == "page[size]"
|
||||
assert expected_detail_contains in error["detail"]
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"invalid_params,expected_invalid_param",
|
||||
[
|
||||
({"filter[service]": "ec2"}, "filter[service]"),
|
||||
({"filter[region]": "us-east-1"}, "filter[region]"),
|
||||
({"sort": "-name"}, "sort"),
|
||||
({"unknown_param": "value"}, "unknown_param"),
|
||||
({"filter[servic]": "ec2"}, "filter[servic]"), # Typo in filter name
|
||||
],
|
||||
)
|
||||
def test_events_invalid_query_parameter(
|
||||
self,
|
||||
authenticated_client,
|
||||
providers_fixture,
|
||||
invalid_params,
|
||||
expected_invalid_param,
|
||||
):
|
||||
"""Test events endpoint rejects unknown query parameters with JSON:API compliant errors."""
|
||||
from api.models import Resource
|
||||
|
||||
aws_provider = providers_fixture[0] # AWS provider from fixture
|
||||
|
||||
resource = Resource.objects.create(
|
||||
uid="arn:aws:ec2:us-east-1:123456789012:instance/i-test",
|
||||
name="Test Instance",
|
||||
type="instance",
|
||||
region="us-east-1",
|
||||
service="ec2",
|
||||
provider=aws_provider,
|
||||
tenant_id=aws_provider.tenant_id,
|
||||
)
|
||||
|
||||
response = authenticated_client.get(
|
||||
reverse("resource-events", kwargs={"pk": resource.id}),
|
||||
invalid_params,
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
# Verify JSON:API error structure
|
||||
errors = response.json()["errors"]
|
||||
assert len(errors) >= 1
|
||||
|
||||
# Find the error for our expected invalid param
|
||||
error = next(
|
||||
(e for e in errors if e["source"]["parameter"] == expected_invalid_param),
|
||||
None,
|
||||
)
|
||||
assert (
|
||||
error is not None
|
||||
), f"Expected error for parameter '{expected_invalid_param}'"
|
||||
assert error["code"] == "invalid"
|
||||
assert error["status"] == "400" # Must be string per JSON:API spec
|
||||
assert expected_invalid_param in error["detail"]
|
||||
|
||||
def test_events_multiple_invalid_query_parameters(
|
||||
self,
|
||||
authenticated_client,
|
||||
providers_fixture,
|
||||
):
|
||||
"""Test events endpoint returns error for first unknown parameter."""
|
||||
from api.models import Resource
|
||||
|
||||
aws_provider = providers_fixture[0]
|
||||
|
||||
resource = Resource.objects.create(
|
||||
uid="arn:aws:ec2:us-east-1:123456789012:instance/i-test",
|
||||
name="Test Instance",
|
||||
type="instance",
|
||||
region="us-east-1",
|
||||
service="ec2",
|
||||
provider=aws_provider,
|
||||
tenant_id=aws_provider.tenant_id,
|
||||
)
|
||||
|
||||
# Send multiple invalid parameters - only first one triggers error
|
||||
response = authenticated_client.get(
|
||||
reverse("resource-events", kwargs={"pk": resource.id}),
|
||||
{"filter[service]": "ec2", "sort": "-name", "unknown": "value"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
# Should have one error for the first invalid parameter encountered
|
||||
errors = response.json()["errors"]
|
||||
assert len(errors) == 1
|
||||
assert errors[0]["code"] == "invalid"
|
||||
assert errors[0]["status"] == "400"
|
||||
assert errors[0]["source"]["parameter"] in {
|
||||
"filter[service]",
|
||||
"sort",
|
||||
"unknown",
|
||||
}
|
||||
|
||||
@patch("api.v1.views.initialize_prowler_provider")
|
||||
@patch("api.v1.views.CloudTrailTimeline")
|
||||
def test_events_success(
|
||||
self,
|
||||
mock_cloudtrail_timeline,
|
||||
mock_initialize_provider,
|
||||
authenticated_client,
|
||||
providers_fixture,
|
||||
):
|
||||
"""Test successful events retrieval."""
|
||||
from api.models import Resource
|
||||
|
||||
aws_provider = providers_fixture[0] # AWS provider from fixture
|
||||
|
||||
# Create test resource
|
||||
resource = Resource.objects.create(
|
||||
uid="arn:aws:ec2:us-east-1:123456789012:instance/i-test123",
|
||||
name="Test EC2 Instance",
|
||||
type="instance",
|
||||
region="us-east-1",
|
||||
service="ec2",
|
||||
provider=aws_provider,
|
||||
tenant_id=aws_provider.tenant_id,
|
||||
)
|
||||
|
||||
# Mock provider session
|
||||
mock_session = Mock()
|
||||
mock_provider = Mock()
|
||||
mock_provider._session.current_session = mock_session
|
||||
mock_initialize_provider.return_value = mock_provider
|
||||
|
||||
# Mock CloudTrail timeline response - events need event_id for serializer
|
||||
mock_timeline_instance = Mock()
|
||||
mock_events = [
|
||||
{
|
||||
"event_id": "event-1-id",
|
||||
"event_time": "2024-01-15T10:30:00Z",
|
||||
"event_name": "RunInstances",
|
||||
"event_source": "ec2.amazonaws.com",
|
||||
"actor": "admin@example.com",
|
||||
"actor_type": "IAMUser",
|
||||
"source_ip_address": "203.0.113.1",
|
||||
"user_agent": "aws-cli/2.0.0",
|
||||
},
|
||||
{
|
||||
"event_id": "event-2-id",
|
||||
"event_time": "2024-01-16T14:20:00Z",
|
||||
"event_name": "StopInstances",
|
||||
"event_source": "ec2.amazonaws.com",
|
||||
"actor": "operator@example.com",
|
||||
"actor_type": "IAMUser",
|
||||
},
|
||||
]
|
||||
mock_timeline_instance.get_resource_timeline.return_value = mock_events
|
||||
mock_cloudtrail_timeline.return_value = mock_timeline_instance
|
||||
|
||||
# Make request with lookback_days parameter
|
||||
response = authenticated_client.get(
|
||||
reverse("resource-events", kwargs={"pk": resource.id}),
|
||||
{"lookback_days": "30"},
|
||||
)
|
||||
|
||||
# Assertions - response is wrapped by JSON:API renderer
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
response_data = response.json()
|
||||
events = response_data["data"]
|
||||
|
||||
assert len(events) == 2
|
||||
|
||||
# Verify JSON:API structure: type and id are present
|
||||
assert events[0]["type"] == "resource-events"
|
||||
assert events[0]["id"] == "event-1-id"
|
||||
assert events[1]["type"] == "resource-events"
|
||||
assert events[1]["id"] == "event-2-id"
|
||||
|
||||
# Verify attributes
|
||||
assert events[0]["attributes"]["event_name"] == "RunInstances"
|
||||
assert events[0]["attributes"]["actor"] == "admin@example.com"
|
||||
assert events[1]["attributes"]["event_name"] == "StopInstances"
|
||||
|
||||
# Verify CloudTrail was called with correct parameters
|
||||
mock_cloudtrail_timeline.assert_called_once_with(
|
||||
session=mock_session,
|
||||
lookback_days=30,
|
||||
max_results=50, # Default page size
|
||||
write_events_only=True, # Default: exclude read events
|
||||
)
|
||||
mock_timeline_instance.get_resource_timeline.assert_called_once_with(
|
||||
region=resource.region,
|
||||
resource_uid=resource.uid,
|
||||
)
|
||||
|
||||
@patch("api.v1.views.initialize_prowler_provider")
|
||||
@patch("api.v1.views.CloudTrailTimeline")
|
||||
def test_events_default_lookback_days(
|
||||
self,
|
||||
mock_cloudtrail_timeline,
|
||||
mock_initialize_provider,
|
||||
authenticated_client,
|
||||
providers_fixture,
|
||||
):
|
||||
"""Test events uses default lookback_days (90) when not provided."""
|
||||
from api.models import Resource
|
||||
|
||||
aws_provider = providers_fixture[0] # AWS provider from fixture
|
||||
|
||||
resource = Resource.objects.create(
|
||||
uid="arn:aws:s3:::test-bucket",
|
||||
name="Test Bucket",
|
||||
type="bucket",
|
||||
region="us-east-1",
|
||||
service="s3",
|
||||
provider=aws_provider,
|
||||
tenant_id=aws_provider.tenant_id,
|
||||
)
|
||||
|
||||
# Mock provider session
|
||||
mock_session = Mock()
|
||||
mock_provider = Mock()
|
||||
mock_provider._session.current_session = mock_session
|
||||
mock_initialize_provider.return_value = mock_provider
|
||||
|
||||
# Mock CloudTrail timeline response
|
||||
mock_timeline_instance = Mock()
|
||||
mock_timeline_instance.get_resource_timeline.return_value = []
|
||||
mock_cloudtrail_timeline.return_value = mock_timeline_instance
|
||||
|
||||
response = authenticated_client.get(
|
||||
reverse("resource-events", kwargs={"pk": resource.id})
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
# Verify default lookback_days (90) was used
|
||||
mock_cloudtrail_timeline.assert_called_once_with(
|
||||
session=mock_session,
|
||||
lookback_days=90, # Default
|
||||
max_results=50,
|
||||
write_events_only=True,
|
||||
)
|
||||
|
||||
@patch("api.v1.views.initialize_prowler_provider")
|
||||
def test_events_no_credentials_error(
|
||||
self, mock_initialize_provider, authenticated_client, providers_fixture
|
||||
):
|
||||
"""Test events handles missing credentials errors."""
|
||||
from api.models import Resource
|
||||
|
||||
aws_provider = providers_fixture[0] # AWS provider from fixture
|
||||
|
||||
resource = Resource.objects.create(
|
||||
uid="arn:aws:rds:us-west-2:123456789012:db:test-db",
|
||||
name="Test Database",
|
||||
type="db-instance",
|
||||
region="us-west-2",
|
||||
service="rds",
|
||||
provider=aws_provider,
|
||||
tenant_id=aws_provider.tenant_id,
|
||||
)
|
||||
|
||||
mock_initialize_provider.side_effect = NoCredentialsError()
|
||||
|
||||
response = authenticated_client.get(
|
||||
reverse("resource-events", kwargs={"pk": resource.id})
|
||||
)
|
||||
|
||||
# 502 because this is an upstream auth failure, not API auth failure
|
||||
assert response.status_code == status.HTTP_502_BAD_GATEWAY
|
||||
|
||||
# Verify JSON:API error structure
|
||||
error = response.json()["errors"][0]
|
||||
assert error["code"] == "upstream_auth_failed"
|
||||
assert error["status"] == "502" # Must be string per JSON:API spec
|
||||
assert "detail" in error
|
||||
|
||||
@patch("api.v1.views.initialize_prowler_provider")
|
||||
@patch("api.v1.views.CloudTrailTimeline")
|
||||
def test_events_access_denied_error(
|
||||
self,
|
||||
mock_cloudtrail_timeline,
|
||||
mock_initialize_provider,
|
||||
authenticated_client,
|
||||
providers_fixture,
|
||||
):
|
||||
"""Test events handles AccessDenied errors from AWS."""
|
||||
from api.models import Resource
|
||||
|
||||
aws_provider = providers_fixture[0] # AWS provider from fixture
|
||||
|
||||
resource = Resource.objects.create(
|
||||
uid="arn:aws:lambda:eu-west-1:123456789012:function:test-func",
|
||||
name="Test Function",
|
||||
type="function",
|
||||
region="eu-west-1",
|
||||
service="lambda",
|
||||
provider=aws_provider,
|
||||
tenant_id=aws_provider.tenant_id,
|
||||
)
|
||||
|
||||
# Mock provider
|
||||
mock_session = Mock()
|
||||
mock_provider = Mock()
|
||||
mock_provider._session.current_session = mock_session
|
||||
mock_initialize_provider.return_value = mock_provider
|
||||
|
||||
# Mock ClientError with AccessDenied
|
||||
mock_timeline_instance = Mock()
|
||||
mock_timeline_instance.get_resource_timeline.side_effect = ClientError(
|
||||
{"Error": {"Code": "AccessDenied", "Message": "Access denied"}},
|
||||
"LookupEvents",
|
||||
)
|
||||
mock_cloudtrail_timeline.return_value = mock_timeline_instance
|
||||
|
||||
response = authenticated_client.get(
|
||||
reverse("resource-events", kwargs={"pk": resource.id})
|
||||
)
|
||||
|
||||
# AccessDenied returns 502 (upstream error, not user's fault)
|
||||
assert response.status_code == status.HTTP_502_BAD_GATEWAY
|
||||
|
||||
# Verify JSON:API error structure
|
||||
error = response.json()["errors"][0]
|
||||
assert error["code"] == "upstream_access_denied"
|
||||
assert error["status"] == "502" # Must be string per JSON:API spec
|
||||
assert "detail" in error
|
||||
|
||||
@patch("api.v1.views.initialize_prowler_provider")
|
||||
@patch("api.v1.views.CloudTrailTimeline")
|
||||
def test_events_service_unavailable_error(
|
||||
self,
|
||||
mock_cloudtrail_timeline,
|
||||
mock_initialize_provider,
|
||||
authenticated_client,
|
||||
providers_fixture,
|
||||
):
|
||||
"""Test events handles generic AWS API errors as 503."""
|
||||
from api.models import Resource
|
||||
|
||||
aws_provider = providers_fixture[0] # AWS provider from fixture
|
||||
|
||||
resource = Resource.objects.create(
|
||||
uid="arn:aws:lambda:eu-west-1:123456789012:function:test-func2",
|
||||
name="Test Function 2",
|
||||
type="function",
|
||||
region="eu-west-1",
|
||||
service="lambda",
|
||||
provider=aws_provider,
|
||||
tenant_id=aws_provider.tenant_id,
|
||||
)
|
||||
|
||||
# Mock provider
|
||||
mock_session = Mock()
|
||||
mock_provider = Mock()
|
||||
mock_provider._session.current_session = mock_session
|
||||
mock_initialize_provider.return_value = mock_provider
|
||||
|
||||
# Mock ClientError with non-AccessDenied error
|
||||
mock_timeline_instance = Mock()
|
||||
mock_timeline_instance.get_resource_timeline.side_effect = ClientError(
|
||||
{"Error": {"Code": "ServiceUnavailable", "Message": "Service unavailable"}},
|
||||
"LookupEvents",
|
||||
)
|
||||
mock_cloudtrail_timeline.return_value = mock_timeline_instance
|
||||
|
||||
response = authenticated_client.get(
|
||||
reverse("resource-events", kwargs={"pk": resource.id})
|
||||
)
|
||||
|
||||
# Non-AccessDenied errors return 503
|
||||
assert response.status_code == status.HTTP_503_SERVICE_UNAVAILABLE
|
||||
|
||||
# Verify JSON:API error structure
|
||||
error = response.json()["errors"][0]
|
||||
assert error["code"] == "service_unavailable"
|
||||
assert error["status"] == "503" # Must be string per JSON:API spec
|
||||
assert "detail" in error
|
||||
|
||||
def test_events_unauthenticated_returns_401(self, providers_fixture):
|
||||
"""Test events endpoint returns 401 when no credentials are provided.
|
||||
|
||||
This ensures the endpoint follows API conventions where missing authentication
|
||||
returns 401 Unauthorized, not 404 Not Found.
|
||||
"""
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from api.models import Resource
|
||||
|
||||
aws_provider = providers_fixture[0] # AWS provider from fixture
|
||||
|
||||
resource = Resource.objects.create(
|
||||
uid="arn:aws:ec2:us-east-1:123456789012:instance/i-unauth-test",
|
||||
name="Test Instance",
|
||||
type="instance",
|
||||
region="us-east-1",
|
||||
service="ec2",
|
||||
provider=aws_provider,
|
||||
tenant_id=aws_provider.tenant_id,
|
||||
)
|
||||
|
||||
# Use unauthenticated client (no JWT token)
|
||||
unauthenticated_client = APIClient()
|
||||
|
||||
response = unauthenticated_client.get(
|
||||
reverse("resource-events", kwargs={"pk": resource.id})
|
||||
)
|
||||
|
||||
# Must return 401 Unauthorized, not 404 Not Found
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED, (
|
||||
f"Expected 401 Unauthorized but got {response.status_code}. "
|
||||
"Unauthenticated requests should return 401, not 404."
|
||||
)
|
||||
|
||||
def test_events_cross_tenant_returns_404(
|
||||
self, authenticated_client, tenants_fixture
|
||||
):
|
||||
"""Test events endpoint returns 404 for resources in other tenants (RLS).
|
||||
|
||||
Users cannot access resources belonging to other tenants due to
|
||||
Row-Level Security. The resource should appear to not exist.
|
||||
"""
|
||||
from api.models import Provider, Resource
|
||||
|
||||
# tenant3 (tenants_fixture[2]) has no membership for the test user
|
||||
isolated_tenant = tenants_fixture[2]
|
||||
|
||||
# Create provider in the isolated tenant
|
||||
other_tenant_provider = Provider.objects.create(
|
||||
provider="aws",
|
||||
uid="999999999999",
|
||||
alias="other_tenant_aws",
|
||||
tenant_id=isolated_tenant.id,
|
||||
)
|
||||
|
||||
# Create resource in the OTHER tenant (not the authenticated user's tenant)
|
||||
resource = Resource.objects.create(
|
||||
uid="arn:aws:ec2:us-east-1:999999999999:instance/i-other-tenant",
|
||||
name="Other Tenant Resource",
|
||||
type="instance",
|
||||
region="us-east-1",
|
||||
service="ec2",
|
||||
provider=other_tenant_provider,
|
||||
tenant_id=isolated_tenant.id,
|
||||
)
|
||||
|
||||
response = authenticated_client.get(
|
||||
reverse("resource-events", kwargs={"pk": resource.id})
|
||||
)
|
||||
|
||||
# RLS hides resources from other tenants - should appear as not found
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
def test_events_expired_token_returns_401(self, providers_fixture, tenants_fixture):
|
||||
"""Test events endpoint returns 401 when JWT token is expired.
|
||||
|
||||
Expired tokens should return 401 Unauthorized, not 404 Not Found.
|
||||
This ensures authentication errors are properly distinguished from
|
||||
resource not found errors.
|
||||
"""
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from api.models import Resource
|
||||
|
||||
aws_provider = providers_fixture[0]
|
||||
|
||||
resource = Resource.objects.create(
|
||||
uid="arn:aws:ec2:us-east-1:123456789012:instance/i-expired-test",
|
||||
name="Test Instance",
|
||||
type="instance",
|
||||
region="us-east-1",
|
||||
service="ec2",
|
||||
provider=aws_provider,
|
||||
tenant_id=aws_provider.tenant_id,
|
||||
)
|
||||
|
||||
# Create an expired JWT token
|
||||
tenant = tenants_fixture[0]
|
||||
expired_payload = {
|
||||
"token_type": "access",
|
||||
"exp": datetime.now(timezone.utc)
|
||||
- timedelta(hours=1), # Expired 1 hour ago
|
||||
"iat": datetime.now(timezone.utc) - timedelta(hours=2),
|
||||
"jti": str(uuid4()),
|
||||
"user_id": str(uuid4()),
|
||||
"tenant_id": str(tenant.id),
|
||||
}
|
||||
expired_token = jwt.encode(
|
||||
expired_payload, settings.SECRET_KEY, algorithm="HS256"
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {expired_token}")
|
||||
|
||||
response = client.get(reverse("resource-events", kwargs={"pk": resource.id}))
|
||||
|
||||
# Must return 401 Unauthorized, not 404 Not Found
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED, (
|
||||
f"Expected 401 Unauthorized but got {response.status_code}. "
|
||||
"Expired tokens should return 401, not 404."
|
||||
)
|
||||
|
||||
def test_events_invalid_token_returns_401(self, providers_fixture):
|
||||
"""Test events endpoint returns 401 when JWT token is completely invalid.
|
||||
|
||||
Malformed or invalid tokens should return 401 Unauthorized, not 404 Not Found.
|
||||
"""
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from api.models import Resource
|
||||
|
||||
aws_provider = providers_fixture[0]
|
||||
|
||||
resource = Resource.objects.create(
|
||||
uid="arn:aws:ec2:us-east-1:123456789012:instance/i-invalid-test",
|
||||
name="Test Instance",
|
||||
type="instance",
|
||||
region="us-east-1",
|
||||
service="ec2",
|
||||
provider=aws_provider,
|
||||
tenant_id=aws_provider.tenant_id,
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
|
||||
# Test with completely malformed token
|
||||
client.credentials(HTTP_AUTHORIZATION="Bearer not.a.valid.jwt.token")
|
||||
response = client.get(reverse("resource-events", kwargs={"pk": resource.id}))
|
||||
assert (
|
||||
response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
), f"Expected 401 for malformed token but got {response.status_code}"
|
||||
|
||||
# Test with empty bearer token
|
||||
client.credentials(HTTP_AUTHORIZATION="Bearer ")
|
||||
response = client.get(reverse("resource-events", kwargs={"pk": resource.id}))
|
||||
assert (
|
||||
response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
), f"Expected 401 for empty bearer token but got {response.status_code}"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestFindingViewSet:
|
||||
|
||||
@@ -4699,3 +4699,31 @@ class ThreatScoreSnapshotSerializer(RLSSerializer):
|
||||
if getattr(obj, "_aggregated", False):
|
||||
return "n/a"
|
||||
return str(obj.id)
|
||||
|
||||
|
||||
# Resource Events Serializers
|
||||
|
||||
|
||||
class ResourceEventSerializer(BaseSerializerV1):
|
||||
"""Serializer for resource events (CloudTrail modification history).
|
||||
|
||||
NOTE: drf-spectacular auto-generates fields[resource-events] sparse fieldsets
|
||||
parameter in the OpenAPI schema. This endpoint does not support sparse fieldsets.
|
||||
"""
|
||||
|
||||
id = serializers.CharField(source="event_id")
|
||||
event_time = serializers.DateTimeField()
|
||||
event_name = serializers.CharField()
|
||||
event_source = serializers.CharField()
|
||||
actor = serializers.CharField()
|
||||
actor_uid = serializers.CharField(allow_null=True, required=False)
|
||||
actor_type = serializers.CharField(allow_null=True, required=False)
|
||||
source_ip_address = serializers.CharField(allow_null=True, required=False)
|
||||
user_agent = serializers.CharField(allow_null=True, required=False)
|
||||
request_data = serializers.JSONField(allow_null=True, required=False)
|
||||
response_data = serializers.JSONField(allow_null=True, required=False)
|
||||
error_code = serializers.CharField(allow_null=True, required=False)
|
||||
error_message = serializers.CharField(allow_null=True, required=False)
|
||||
|
||||
class Meta:
|
||||
resource_name = "resource-events"
|
||||
@@ -102,7 +102,13 @@ from api.compliance import (
|
||||
)
|
||||
from api.db_router import MainRouter
|
||||
from api.db_utils import rls_transaction
|
||||
from api.exceptions import TaskFailedException
|
||||
from api.exceptions import (
|
||||
TaskFailedException,
|
||||
UpstreamAccessDeniedError,
|
||||
UpstreamAuthenticationError,
|
||||
UpstreamInternalError,
|
||||
UpstreamServiceUnavailableError,
|
||||
)
|
||||
from api.filters import (
|
||||
AttackPathsScanFilter,
|
||||
AttackSurfaceOverviewFilter,
|
||||
@@ -186,6 +192,7 @@ from api.rls import Tenant
|
||||
from api.utils import (
|
||||
CustomOAuth2Client,
|
||||
get_findings_metadata_no_aggregations,
|
||||
initialize_prowler_provider,
|
||||
validate_invitation,
|
||||
)
|
||||
from api.uuid_utils import datetime_to_uuid7, uuid7_start
|
||||
@@ -251,6 +258,7 @@ from api.v1.serializers import (
|
||||
ProviderSecretUpdateSerializer,
|
||||
ProviderSerializer,
|
||||
ProviderUpdateSerializer,
|
||||
ResourceEventSerializer,
|
||||
ResourceGroupOverviewSerializer,
|
||||
ResourceMetadataSerializer,
|
||||
ResourceSerializer,
|
||||
@@ -281,6 +289,10 @@ from api.v1.serializers import (
|
||||
UserSerializer,
|
||||
UserUpdateSerializer,
|
||||
)
|
||||
from prowler.providers.aws.exceptions.exceptions import AWSCredentialsError
|
||||
from prowler.providers.aws.lib.cloudtrail_timeline.cloudtrail_timeline import (
|
||||
CloudTrailTimeline,
|
||||
)
|
||||
|
||||
|
||||
class JSONAPIRawParser(JSONParser):
|
||||
@@ -293,7 +305,6 @@ class JSONAPIRawParser(JSONParser):
|
||||
|
||||
media_type = "application/vnd.api+json"
|
||||
|
||||
|
||||
logger = logging.getLogger(BackendLogger.API)
|
||||
|
||||
CACHE_DECORATOR = cache_control(
|
||||
@@ -2721,6 +2732,20 @@ class ResourceViewSet(PaginateByPkMixin, BaseRLSViewSet):
|
||||
http_method_names = ["get"]
|
||||
filterset_class = ResourceFilter
|
||||
ordering = ["-failed_findings_count", "-updated_at"]
|
||||
|
||||
# Events endpoint constants (currently AWS-only, limited to 90 days by CloudTrail Event History)
|
||||
EVENTS_DEFAULT_LOOKBACK_DAYS = 90
|
||||
EVENTS_MIN_LOOKBACK_DAYS = 1
|
||||
EVENTS_MAX_LOOKBACK_DAYS = 90
|
||||
# Page size controls how many events CloudTrail returns (prepares for API pagination)
|
||||
EVENTS_DEFAULT_PAGE_SIZE = 50
|
||||
EVENTS_MIN_PAGE_SIZE = 1
|
||||
EVENTS_MAX_PAGE_SIZE = 50 # CloudTrail lookup_events max is 50
|
||||
# Allowed query parameters for the events endpoint
|
||||
EVENTS_ALLOWED_PARAMS = frozenset(
|
||||
{"lookback_days", "page[size]", "include_read_events"}
|
||||
)
|
||||
|
||||
ordering_fields = [
|
||||
"provider_uid",
|
||||
"uid",
|
||||
@@ -2796,6 +2821,8 @@ class ResourceViewSet(PaginateByPkMixin, BaseRLSViewSet):
|
||||
def get_serializer_class(self):
|
||||
if self.action in ["metadata", "metadata_latest"]:
|
||||
return ResourceMetadataSerializer
|
||||
if self.action == "events":
|
||||
return ResourceEventSerializer
|
||||
return super().get_serializer_class()
|
||||
|
||||
def get_filterset_class(self):
|
||||
@@ -2804,8 +2831,8 @@ class ResourceViewSet(PaginateByPkMixin, BaseRLSViewSet):
|
||||
return ResourceFilter
|
||||
|
||||
def filter_queryset(self, queryset):
|
||||
# Do not apply filters when retrieving specific resource
|
||||
if self.action == "retrieve":
|
||||
# Do not apply filters when retrieving specific resource or events
|
||||
if self.action in ["retrieve", "events"]:
|
||||
return queryset
|
||||
return super().filter_queryset(queryset)
|
||||
|
||||
@@ -3045,6 +3072,217 @@ class ResourceViewSet(PaginateByPkMixin, BaseRLSViewSet):
|
||||
serializer.is_valid(raise_exception=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@extend_schema(
|
||||
tags=["Resource"],
|
||||
summary="Get events for a resource",
|
||||
description=(
|
||||
"Retrieve events showing modification history for a resource. "
|
||||
"Returns who modified the resource and when. Currently only available for AWS resources.\n\n"
|
||||
"**Note:** Some events may not appear due to CloudTrail indexing limitations. "
|
||||
"Not all AWS API calls record the resource identifier in a searchable format."
|
||||
),
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="lookback_days",
|
||||
type=OpenApiTypes.INT,
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Number of days to look back (default: 90, min: 1, max: 90).",
|
||||
required=False,
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="page[size]",
|
||||
type=OpenApiTypes.INT,
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Maximum number of events to return (default: 50, min: 1, max: 50).",
|
||||
required=False,
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="include_read_events",
|
||||
type=OpenApiTypes.BOOL,
|
||||
location=OpenApiParameter.QUERY,
|
||||
description=(
|
||||
"Include read-only events (Describe*, Get*, List*, etc.). "
|
||||
"Default: false. Set to true to include all events."
|
||||
),
|
||||
required=False,
|
||||
),
|
||||
# NOTE: drf-spectacular auto-generates page[number] and fields[resource-events]
|
||||
# parameters. This endpoint does not support pagination (results are limited by
|
||||
# page[size] only) nor sparse fieldsets.
|
||||
],
|
||||
responses={
|
||||
200: ResourceEventSerializer(many=True),
|
||||
400: OpenApiResponse(description="Invalid provider or parameters"),
|
||||
500: OpenApiResponse(description="Unexpected error retrieving events"),
|
||||
502: OpenApiResponse(
|
||||
description="Provider credentials invalid, expired, or lack required permissions"
|
||||
),
|
||||
503: OpenApiResponse(description="Provider service unavailable"),
|
||||
},
|
||||
)
|
||||
@action(
|
||||
detail=True,
|
||||
methods=["get"],
|
||||
url_name="events",
|
||||
filter_backends=[], # Disable filters - we're calling external API, not filtering queryset
|
||||
)
|
||||
def events(self, request, pk=None):
|
||||
"""Get events for a resource."""
|
||||
resource = self.get_object()
|
||||
|
||||
# Validate query parameters - reject unknown parameters
|
||||
for param in request.query_params.keys():
|
||||
if param not in self.EVENTS_ALLOWED_PARAMS:
|
||||
raise ValidationError(
|
||||
[
|
||||
{
|
||||
"detail": f"invalid parameter '{param}'",
|
||||
"status": "400",
|
||||
"source": {"parameter": param},
|
||||
"code": "invalid",
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
# Validate provider - currently only AWS CloudTrail is supported
|
||||
if resource.provider.provider != Provider.ProviderChoices.AWS:
|
||||
raise ValidationError(
|
||||
[
|
||||
{
|
||||
"detail": "Events are only available for AWS resources",
|
||||
"status": "400",
|
||||
"source": {"pointer": "/data/attributes/provider"},
|
||||
"code": "invalid_provider",
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
# Validate and parse lookback_days from query params
|
||||
lookback_days_str = request.query_params.get("lookback_days")
|
||||
if lookback_days_str is None:
|
||||
lookback_days = self.EVENTS_DEFAULT_LOOKBACK_DAYS
|
||||
else:
|
||||
try:
|
||||
lookback_days = int(lookback_days_str)
|
||||
except (ValueError, TypeError):
|
||||
raise ValidationError(
|
||||
[
|
||||
{
|
||||
"detail": "lookback_days must be a valid integer",
|
||||
"status": "400",
|
||||
"source": {"parameter": "lookback_days"},
|
||||
"code": "invalid",
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
if not (
|
||||
self.EVENTS_MIN_LOOKBACK_DAYS
|
||||
<= lookback_days
|
||||
<= self.EVENTS_MAX_LOOKBACK_DAYS
|
||||
):
|
||||
raise ValidationError(
|
||||
[
|
||||
{
|
||||
"detail": (
|
||||
f"lookback_days must be between {self.EVENTS_MIN_LOOKBACK_DAYS} "
|
||||
f"and {self.EVENTS_MAX_LOOKBACK_DAYS}"
|
||||
),
|
||||
"status": "400",
|
||||
"source": {"parameter": "lookback_days"},
|
||||
"code": "out_of_range",
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
# Validate and parse page[size] from query params (JSON:API pagination)
|
||||
page_size_str = request.query_params.get("page[size]")
|
||||
if page_size_str is None:
|
||||
page_size = self.EVENTS_DEFAULT_PAGE_SIZE
|
||||
else:
|
||||
try:
|
||||
page_size = int(page_size_str)
|
||||
except (ValueError, TypeError):
|
||||
raise ValidationError(
|
||||
[
|
||||
{
|
||||
"detail": "page[size] must be a valid integer",
|
||||
"status": "400",
|
||||
"source": {"parameter": "page[size]"},
|
||||
"code": "invalid",
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
if not (
|
||||
self.EVENTS_MIN_PAGE_SIZE <= page_size <= self.EVENTS_MAX_PAGE_SIZE
|
||||
):
|
||||
raise ValidationError(
|
||||
[
|
||||
{
|
||||
"detail": (
|
||||
f"page[size] must be between {self.EVENTS_MIN_PAGE_SIZE} "
|
||||
f"and {self.EVENTS_MAX_PAGE_SIZE}"
|
||||
),
|
||||
"status": "400",
|
||||
"source": {"parameter": "page[size]"},
|
||||
"code": "out_of_range",
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
# Parse include_read_events (default: false)
|
||||
include_read_events = (
|
||||
request.query_params.get("include_read_events", "").lower() == "true"
|
||||
)
|
||||
|
||||
try:
|
||||
# Initialize Prowler provider using existing utility
|
||||
prowler_provider = initialize_prowler_provider(resource.provider)
|
||||
|
||||
# Get the boto3 session from the Prowler provider
|
||||
session = prowler_provider._session.current_session
|
||||
|
||||
# Create timeline service (currently only AWS/CloudTrail is supported)
|
||||
timeline_service = CloudTrailTimeline(
|
||||
session=session,
|
||||
lookback_days=lookback_days,
|
||||
max_results=page_size,
|
||||
write_events_only=not include_read_events,
|
||||
)
|
||||
|
||||
# Get timeline events
|
||||
events = timeline_service.get_resource_timeline(
|
||||
region=resource.region,
|
||||
resource_uid=resource.uid,
|
||||
)
|
||||
|
||||
serializer = ResourceEventSerializer(events, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
except NoCredentialsError:
|
||||
# 502 because this is an upstream auth failure, not API auth failure
|
||||
raise UpstreamAuthenticationError(
|
||||
detail="Credentials not found for this provider. Please reconnect the provider."
|
||||
)
|
||||
except AWSCredentialsError:
|
||||
# Handles expired tokens, invalid keys, profile not found, etc.
|
||||
raise UpstreamAuthenticationError()
|
||||
except ClientError as e:
|
||||
error_code = e.response.get("Error", {}).get("Code", "")
|
||||
logger.error(
|
||||
f"Provider API error retrieving events: {str(e)}",
|
||||
exc_info=True,
|
||||
)
|
||||
# AccessDenied means the credentials don't have the required permissions
|
||||
if error_code in ("AccessDenied", "AccessDeniedException"):
|
||||
raise UpstreamAccessDeniedError()
|
||||
|
||||
raise UpstreamServiceUnavailableError()
|
||||
except Exception as e:
|
||||
sentry_sdk.capture_exception(e)
|
||||
raise UpstreamInternalError(detail="Failed to retrieve events")
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
list=extend_schema(
|
||||
|
||||
@@ -276,7 +276,7 @@ FINDINGS_MAX_DAYS_IN_RANGE = env.int("DJANGO_FINDINGS_MAX_DAYS_IN_RANGE", 7)
|
||||
DJANGO_TMP_OUTPUT_DIRECTORY = env.str(
|
||||
"DJANGO_TMP_OUTPUT_DIRECTORY", "/tmp/prowler_api_output"
|
||||
)
|
||||
DJANGO_FINDINGS_BATCH_SIZE = env.str("DJANGO_FINDINGS_BATCH_SIZE", 1000)
|
||||
DJANGO_FINDINGS_BATCH_SIZE = env.int("DJANGO_FINDINGS_BATCH_SIZE", 1000)
|
||||
|
||||
DJANGO_OUTPUT_S3_AWS_OUTPUT_BUCKET = env.str("DJANGO_OUTPUT_S3_AWS_OUTPUT_BUCKET", "")
|
||||
DJANGO_OUTPUT_S3_AWS_ACCESS_KEY_ID = env.str("DJANGO_OUTPUT_S3_AWS_ACCESS_KEY_ID", "")
|
||||
|
||||
+155
-3536
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,186 @@
|
||||
# Base classes and data structures
|
||||
from .base import (
|
||||
BaseComplianceReportGenerator,
|
||||
ComplianceData,
|
||||
RequirementData,
|
||||
create_pdf_styles,
|
||||
get_requirement_metadata,
|
||||
)
|
||||
|
||||
# Chart functions
|
||||
from .charts import (
|
||||
create_horizontal_bar_chart,
|
||||
create_pie_chart,
|
||||
create_radar_chart,
|
||||
create_stacked_bar_chart,
|
||||
create_vertical_bar_chart,
|
||||
get_chart_color_for_percentage,
|
||||
)
|
||||
|
||||
# Reusable components
|
||||
# Reusable components: Color helpers, Badge components, Risk component,
|
||||
# Table components, Section components
|
||||
from .components import (
|
||||
ColumnConfig,
|
||||
create_badge,
|
||||
create_data_table,
|
||||
create_findings_table,
|
||||
create_info_table,
|
||||
create_multi_badge_row,
|
||||
create_risk_component,
|
||||
create_section_header,
|
||||
create_status_badge,
|
||||
create_summary_table,
|
||||
get_color_for_compliance,
|
||||
get_color_for_risk_level,
|
||||
get_color_for_weight,
|
||||
get_status_color,
|
||||
)
|
||||
|
||||
# Framework configuration: Main configuration, Color constants, ENS colors,
|
||||
# NIS2 colors, Chart colors, ENS constants, Section constants, Layout constants
|
||||
from .config import (
|
||||
CHART_COLOR_BLUE,
|
||||
CHART_COLOR_GREEN_1,
|
||||
CHART_COLOR_GREEN_2,
|
||||
CHART_COLOR_ORANGE,
|
||||
CHART_COLOR_RED,
|
||||
CHART_COLOR_YELLOW,
|
||||
COL_WIDTH_LARGE,
|
||||
COL_WIDTH_MEDIUM,
|
||||
COL_WIDTH_SMALL,
|
||||
COL_WIDTH_XLARGE,
|
||||
COL_WIDTH_XXLARGE,
|
||||
COLOR_BG_BLUE,
|
||||
COLOR_BG_LIGHT_BLUE,
|
||||
COLOR_BLUE,
|
||||
COLOR_DARK_GRAY,
|
||||
COLOR_ENS_ALTO,
|
||||
COLOR_ENS_BAJO,
|
||||
COLOR_ENS_MEDIO,
|
||||
COLOR_ENS_OPCIONAL,
|
||||
COLOR_GRAY,
|
||||
COLOR_HIGH_RISK,
|
||||
COLOR_LIGHT_BLUE,
|
||||
COLOR_LIGHT_GRAY,
|
||||
COLOR_LIGHTER_BLUE,
|
||||
COLOR_LOW_RISK,
|
||||
COLOR_MEDIUM_RISK,
|
||||
COLOR_NIS2_PRIMARY,
|
||||
COLOR_NIS2_SECONDARY,
|
||||
COLOR_PROWLER_DARK_GREEN,
|
||||
COLOR_SAFE,
|
||||
COLOR_WHITE,
|
||||
DIMENSION_KEYS,
|
||||
DIMENSION_MAPPING,
|
||||
DIMENSION_NAMES,
|
||||
ENS_NIVEL_ORDER,
|
||||
ENS_TIPO_ORDER,
|
||||
FRAMEWORK_REGISTRY,
|
||||
NIS2_SECTION_TITLES,
|
||||
NIS2_SECTIONS,
|
||||
PADDING_LARGE,
|
||||
PADDING_MEDIUM,
|
||||
PADDING_SMALL,
|
||||
PADDING_XLARGE,
|
||||
THREATSCORE_SECTIONS,
|
||||
TIPO_ICONS,
|
||||
FrameworkConfig,
|
||||
get_framework_config,
|
||||
)
|
||||
|
||||
# Framework-specific generators
|
||||
from .ens import ENSReportGenerator
|
||||
from .nis2 import NIS2ReportGenerator
|
||||
from .threatscore import ThreatScoreReportGenerator
|
||||
|
||||
__all__ = [
|
||||
# Base classes
|
||||
"BaseComplianceReportGenerator",
|
||||
"ComplianceData",
|
||||
"RequirementData",
|
||||
"create_pdf_styles",
|
||||
"get_requirement_metadata",
|
||||
# Framework-specific generators
|
||||
"ThreatScoreReportGenerator",
|
||||
"ENSReportGenerator",
|
||||
"NIS2ReportGenerator",
|
||||
# Configuration
|
||||
"FrameworkConfig",
|
||||
"FRAMEWORK_REGISTRY",
|
||||
"get_framework_config",
|
||||
# Color constants
|
||||
"COLOR_BLUE",
|
||||
"COLOR_LIGHT_BLUE",
|
||||
"COLOR_LIGHTER_BLUE",
|
||||
"COLOR_BG_BLUE",
|
||||
"COLOR_BG_LIGHT_BLUE",
|
||||
"COLOR_GRAY",
|
||||
"COLOR_LIGHT_GRAY",
|
||||
"COLOR_DARK_GRAY",
|
||||
"COLOR_WHITE",
|
||||
"COLOR_HIGH_RISK",
|
||||
"COLOR_MEDIUM_RISK",
|
||||
"COLOR_LOW_RISK",
|
||||
"COLOR_SAFE",
|
||||
"COLOR_PROWLER_DARK_GREEN",
|
||||
"COLOR_ENS_ALTO",
|
||||
"COLOR_ENS_MEDIO",
|
||||
"COLOR_ENS_BAJO",
|
||||
"COLOR_ENS_OPCIONAL",
|
||||
"COLOR_NIS2_PRIMARY",
|
||||
"COLOR_NIS2_SECONDARY",
|
||||
"CHART_COLOR_BLUE",
|
||||
"CHART_COLOR_GREEN_1",
|
||||
"CHART_COLOR_GREEN_2",
|
||||
"CHART_COLOR_YELLOW",
|
||||
"CHART_COLOR_ORANGE",
|
||||
"CHART_COLOR_RED",
|
||||
# ENS constants
|
||||
"DIMENSION_MAPPING",
|
||||
"DIMENSION_NAMES",
|
||||
"DIMENSION_KEYS",
|
||||
"ENS_NIVEL_ORDER",
|
||||
"ENS_TIPO_ORDER",
|
||||
"TIPO_ICONS",
|
||||
# Section constants
|
||||
"THREATSCORE_SECTIONS",
|
||||
"NIS2_SECTIONS",
|
||||
"NIS2_SECTION_TITLES",
|
||||
# Layout constants
|
||||
"COL_WIDTH_SMALL",
|
||||
"COL_WIDTH_MEDIUM",
|
||||
"COL_WIDTH_LARGE",
|
||||
"COL_WIDTH_XLARGE",
|
||||
"COL_WIDTH_XXLARGE",
|
||||
"PADDING_SMALL",
|
||||
"PADDING_MEDIUM",
|
||||
"PADDING_LARGE",
|
||||
"PADDING_XLARGE",
|
||||
# Color helpers
|
||||
"get_color_for_risk_level",
|
||||
"get_color_for_weight",
|
||||
"get_color_for_compliance",
|
||||
"get_status_color",
|
||||
# Badge components
|
||||
"create_badge",
|
||||
"create_status_badge",
|
||||
"create_multi_badge_row",
|
||||
# Risk component
|
||||
"create_risk_component",
|
||||
# Table components
|
||||
"create_info_table",
|
||||
"create_data_table",
|
||||
"create_findings_table",
|
||||
"ColumnConfig",
|
||||
# Section components
|
||||
"create_section_header",
|
||||
"create_summary_table",
|
||||
# Chart functions
|
||||
"get_chart_color_for_percentage",
|
||||
"create_vertical_bar_chart",
|
||||
"create_horizontal_bar_chart",
|
||||
"create_radar_chart",
|
||||
"create_pie_chart",
|
||||
"create_stacked_bar_chart",
|
||||
]
|
||||
@@ -0,0 +1,911 @@
|
||||
import gc
|
||||
import os
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from celery.utils.log import get_task_logger
|
||||
from reportlab.lib.enums import TA_CENTER
|
||||
from reportlab.lib.pagesizes import letter
|
||||
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
|
||||
from reportlab.lib.units import inch
|
||||
from reportlab.pdfbase import pdfmetrics
|
||||
from reportlab.pdfbase.ttfonts import TTFont
|
||||
from reportlab.pdfgen import canvas
|
||||
from reportlab.platypus import Image, PageBreak, Paragraph, SimpleDocTemplate, Spacer
|
||||
from tasks.jobs.threatscore_utils import (
|
||||
_aggregate_requirement_statistics_from_database,
|
||||
_calculate_requirements_data_from_statistics,
|
||||
_load_findings_for_requirement_checks,
|
||||
)
|
||||
|
||||
from api.db_router import READ_REPLICA_ALIAS
|
||||
from api.db_utils import rls_transaction
|
||||
from api.models import Provider, StatusChoices
|
||||
from api.utils import initialize_prowler_provider
|
||||
from prowler.lib.check.compliance_models import Compliance
|
||||
from prowler.lib.outputs.finding import Finding as FindingOutput
|
||||
|
||||
from .components import (
|
||||
ColumnConfig,
|
||||
create_data_table,
|
||||
create_info_table,
|
||||
create_status_badge,
|
||||
)
|
||||
from .config import (
|
||||
COLOR_BG_BLUE,
|
||||
COLOR_BG_LIGHT_BLUE,
|
||||
COLOR_BLUE,
|
||||
COLOR_BORDER_GRAY,
|
||||
COLOR_GRAY,
|
||||
COLOR_LIGHT_BLUE,
|
||||
COLOR_LIGHTER_BLUE,
|
||||
COLOR_PROWLER_DARK_GREEN,
|
||||
PADDING_LARGE,
|
||||
PADDING_SMALL,
|
||||
FrameworkConfig,
|
||||
)
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
|
||||
# Register fonts (done once at module load)
|
||||
_fonts_registered: bool = False
|
||||
|
||||
|
||||
def _register_fonts() -> None:
|
||||
"""Register custom fonts for PDF generation.
|
||||
|
||||
Uses a module-level flag to ensure fonts are only registered once,
|
||||
avoiding duplicate registration errors from reportlab.
|
||||
"""
|
||||
global _fonts_registered
|
||||
if _fonts_registered:
|
||||
return
|
||||
|
||||
fonts_dir = os.path.join(os.path.dirname(__file__), "../../assets/fonts")
|
||||
|
||||
pdfmetrics.registerFont(
|
||||
TTFont(
|
||||
"PlusJakartaSans",
|
||||
os.path.join(fonts_dir, "PlusJakartaSans-Regular.ttf"),
|
||||
)
|
||||
)
|
||||
|
||||
pdfmetrics.registerFont(
|
||||
TTFont(
|
||||
"FiraCode",
|
||||
os.path.join(fonts_dir, "FiraCode-Regular.ttf"),
|
||||
)
|
||||
)
|
||||
|
||||
_fonts_registered = True
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Data Classes
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@dataclass
|
||||
class RequirementData:
|
||||
"""Data for a single compliance requirement.
|
||||
|
||||
Attributes:
|
||||
id: Requirement identifier
|
||||
description: Requirement description
|
||||
status: Compliance status (PASS, FAIL, MANUAL)
|
||||
passed_findings: Number of passed findings
|
||||
failed_findings: Number of failed findings
|
||||
total_findings: Total number of findings
|
||||
checks: List of check IDs associated with this requirement
|
||||
attributes: Framework-specific requirement attributes
|
||||
"""
|
||||
|
||||
id: str
|
||||
description: str
|
||||
status: str
|
||||
passed_findings: int = 0
|
||||
failed_findings: int = 0
|
||||
total_findings: int = 0
|
||||
checks: list[str] = field(default_factory=list)
|
||||
attributes: Any = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ComplianceData:
|
||||
"""Aggregated compliance data for report generation.
|
||||
|
||||
This dataclass holds all the data needed to generate a compliance report,
|
||||
including compliance framework metadata, requirements, and findings.
|
||||
|
||||
Attributes:
|
||||
tenant_id: Tenant identifier
|
||||
scan_id: Scan identifier
|
||||
provider_id: Provider identifier
|
||||
compliance_id: Compliance framework identifier
|
||||
framework: Framework name (e.g., "CIS", "ENS")
|
||||
name: Full compliance framework name
|
||||
version: Framework version
|
||||
description: Framework description
|
||||
requirements: List of RequirementData objects
|
||||
attributes_by_requirement_id: Mapping of requirement IDs to their attributes
|
||||
findings_by_check_id: Mapping of check IDs to their findings
|
||||
provider_obj: Provider model object
|
||||
prowler_provider: Initialized Prowler provider
|
||||
"""
|
||||
|
||||
tenant_id: str
|
||||
scan_id: str
|
||||
provider_id: str
|
||||
compliance_id: str
|
||||
framework: str
|
||||
name: str
|
||||
version: str
|
||||
description: str
|
||||
requirements: list[RequirementData] = field(default_factory=list)
|
||||
attributes_by_requirement_id: dict[str, dict] = field(default_factory=dict)
|
||||
findings_by_check_id: dict[str, list[FindingOutput]] = field(default_factory=dict)
|
||||
provider_obj: Provider | None = None
|
||||
prowler_provider: Any = None
|
||||
|
||||
|
||||
def get_requirement_metadata(
|
||||
requirement_id: str,
|
||||
attributes_by_requirement_id: dict[str, dict],
|
||||
) -> Any | None:
|
||||
"""Get the first requirement metadata object from attributes.
|
||||
|
||||
This helper function extracts the requirement metadata (req_attributes)
|
||||
from the attributes dictionary. It's a common pattern used across all
|
||||
report generators.
|
||||
|
||||
Args:
|
||||
requirement_id: The requirement ID to look up.
|
||||
attributes_by_requirement_id: Mapping of requirement IDs to their attributes.
|
||||
|
||||
Returns:
|
||||
The first requirement attribute object, or None if not found.
|
||||
|
||||
Example:
|
||||
>>> meta = get_requirement_metadata(req.id, data.attributes_by_requirement_id)
|
||||
>>> if meta:
|
||||
... section = getattr(meta, "Section", "Unknown")
|
||||
"""
|
||||
req_attrs = attributes_by_requirement_id.get(requirement_id, {})
|
||||
meta_list = req_attrs.get("attributes", {}).get("req_attributes", [])
|
||||
if meta_list:
|
||||
return meta_list[0]
|
||||
return None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# PDF Styles Cache
|
||||
# =============================================================================
|
||||
|
||||
_PDF_STYLES_CACHE: dict[str, ParagraphStyle] | None = None
|
||||
|
||||
|
||||
def create_pdf_styles() -> dict[str, ParagraphStyle]:
|
||||
"""Create and return PDF paragraph styles used throughout the report.
|
||||
|
||||
Styles are cached on first call to improve performance.
|
||||
|
||||
Returns:
|
||||
Dictionary containing the following styles:
|
||||
- 'title': Title style with prowler green color
|
||||
- 'h1': Heading 1 style with blue color and background
|
||||
- 'h2': Heading 2 style with light blue color
|
||||
- 'h3': Heading 3 style for sub-headings
|
||||
- 'normal': Normal text style with left indent
|
||||
- 'normal_center': Normal text style without indent
|
||||
"""
|
||||
global _PDF_STYLES_CACHE
|
||||
|
||||
if _PDF_STYLES_CACHE is not None:
|
||||
return _PDF_STYLES_CACHE
|
||||
|
||||
_register_fonts()
|
||||
styles = getSampleStyleSheet()
|
||||
|
||||
title_style = ParagraphStyle(
|
||||
"CustomTitle",
|
||||
parent=styles["Title"],
|
||||
fontSize=24,
|
||||
textColor=COLOR_PROWLER_DARK_GREEN,
|
||||
spaceAfter=20,
|
||||
fontName="PlusJakartaSans",
|
||||
alignment=TA_CENTER,
|
||||
)
|
||||
|
||||
h1 = ParagraphStyle(
|
||||
"CustomH1",
|
||||
parent=styles["Heading1"],
|
||||
fontSize=18,
|
||||
textColor=COLOR_BLUE,
|
||||
spaceBefore=20,
|
||||
spaceAfter=12,
|
||||
fontName="PlusJakartaSans",
|
||||
leftIndent=0,
|
||||
borderWidth=2,
|
||||
borderColor=COLOR_BLUE,
|
||||
borderPadding=PADDING_LARGE,
|
||||
backColor=COLOR_BG_BLUE,
|
||||
)
|
||||
|
||||
h2 = ParagraphStyle(
|
||||
"CustomH2",
|
||||
parent=styles["Heading2"],
|
||||
fontSize=14,
|
||||
textColor=COLOR_LIGHT_BLUE,
|
||||
spaceBefore=15,
|
||||
spaceAfter=8,
|
||||
fontName="PlusJakartaSans",
|
||||
leftIndent=10,
|
||||
borderWidth=1,
|
||||
borderColor=COLOR_BORDER_GRAY,
|
||||
borderPadding=5,
|
||||
backColor=COLOR_BG_LIGHT_BLUE,
|
||||
)
|
||||
|
||||
h3 = ParagraphStyle(
|
||||
"CustomH3",
|
||||
parent=styles["Heading3"],
|
||||
fontSize=12,
|
||||
textColor=COLOR_LIGHTER_BLUE,
|
||||
spaceBefore=10,
|
||||
spaceAfter=6,
|
||||
fontName="PlusJakartaSans",
|
||||
leftIndent=20,
|
||||
)
|
||||
|
||||
normal = ParagraphStyle(
|
||||
"CustomNormal",
|
||||
parent=styles["Normal"],
|
||||
fontSize=10,
|
||||
textColor=COLOR_GRAY,
|
||||
spaceBefore=PADDING_SMALL,
|
||||
spaceAfter=PADDING_SMALL,
|
||||
leftIndent=30,
|
||||
fontName="PlusJakartaSans",
|
||||
)
|
||||
|
||||
normal_center = ParagraphStyle(
|
||||
"CustomNormalCenter",
|
||||
parent=styles["Normal"],
|
||||
fontSize=10,
|
||||
textColor=COLOR_GRAY,
|
||||
fontName="PlusJakartaSans",
|
||||
)
|
||||
|
||||
_PDF_STYLES_CACHE = {
|
||||
"title": title_style,
|
||||
"h1": h1,
|
||||
"h2": h2,
|
||||
"h3": h3,
|
||||
"normal": normal,
|
||||
"normal_center": normal_center,
|
||||
}
|
||||
|
||||
return _PDF_STYLES_CACHE
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Base Report Generator
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class BaseComplianceReportGenerator(ABC):
|
||||
"""Abstract base class for compliance PDF report generators.
|
||||
|
||||
This class implements the Template Method pattern, providing a common
|
||||
structure for all compliance reports while allowing subclasses to
|
||||
customize specific sections.
|
||||
|
||||
Subclasses must implement:
|
||||
- create_executive_summary()
|
||||
- create_charts_section()
|
||||
- create_requirements_index()
|
||||
|
||||
Optionally, subclasses can override:
|
||||
- create_cover_page()
|
||||
- create_detailed_findings()
|
||||
- get_footer_text()
|
||||
"""
|
||||
|
||||
def __init__(self, config: FrameworkConfig):
|
||||
"""Initialize the report generator.
|
||||
|
||||
Args:
|
||||
config: Framework configuration
|
||||
"""
|
||||
self.config = config
|
||||
self.styles = create_pdf_styles()
|
||||
|
||||
# =========================================================================
|
||||
# Template Method
|
||||
# =========================================================================
|
||||
|
||||
def generate(
|
||||
self,
|
||||
tenant_id: str,
|
||||
scan_id: str,
|
||||
compliance_id: str,
|
||||
output_path: str,
|
||||
provider_id: str,
|
||||
provider_obj: Provider | None = None,
|
||||
requirement_statistics: dict[str, dict[str, int]] | None = None,
|
||||
findings_cache: dict[str, list[FindingOutput]] | None = None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
"""Generate the PDF compliance report.
|
||||
|
||||
This is the template method that orchestrates the report generation.
|
||||
It calls abstract methods that subclasses must implement.
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant identifier for RLS context
|
||||
scan_id: Scan identifier
|
||||
compliance_id: Compliance framework identifier
|
||||
output_path: Path where the PDF will be saved
|
||||
provider_id: Provider identifier
|
||||
provider_obj: Optional pre-fetched Provider object
|
||||
requirement_statistics: Optional pre-aggregated statistics
|
||||
findings_cache: Optional pre-loaded findings cache
|
||||
**kwargs: Additional framework-specific arguments
|
||||
"""
|
||||
logger.info(
|
||||
"Generating %s report for scan %s", self.config.display_name, scan_id
|
||||
)
|
||||
|
||||
try:
|
||||
# 1. Load compliance data
|
||||
data = self._load_compliance_data(
|
||||
tenant_id=tenant_id,
|
||||
scan_id=scan_id,
|
||||
compliance_id=compliance_id,
|
||||
provider_id=provider_id,
|
||||
provider_obj=provider_obj,
|
||||
requirement_statistics=requirement_statistics,
|
||||
findings_cache=findings_cache,
|
||||
)
|
||||
|
||||
# 2. Create PDF document
|
||||
doc = self._create_document(output_path, data)
|
||||
|
||||
# 3. Build report elements incrementally to manage memory
|
||||
# We collect garbage after heavy sections to prevent OOM on large reports
|
||||
elements = []
|
||||
|
||||
# Cover page (lightweight)
|
||||
elements.extend(self.create_cover_page(data))
|
||||
elements.append(PageBreak())
|
||||
|
||||
# Executive summary (framework-specific)
|
||||
elements.extend(self.create_executive_summary(data))
|
||||
|
||||
# Body sections (charts + requirements index)
|
||||
# Override _build_body_sections() in subclasses to change section order
|
||||
elements.extend(self._build_body_sections(data))
|
||||
|
||||
# Detailed findings - heaviest section, loads findings on-demand
|
||||
logger.info("Building detailed findings section...")
|
||||
elements.extend(self.create_detailed_findings(data, **kwargs))
|
||||
gc.collect() # Free findings data after processing
|
||||
|
||||
# 4. Build the PDF
|
||||
logger.info("Building PDF document with %d elements...", len(elements))
|
||||
self._build_pdf(doc, elements, data)
|
||||
|
||||
# Final cleanup
|
||||
del elements
|
||||
gc.collect()
|
||||
|
||||
logger.info("Successfully generated report at %s", output_path)
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
tb_lineno = e.__traceback__.tb_lineno if e.__traceback__ else "unknown"
|
||||
logger.error("Error generating report, line %s -- %s", tb_lineno, e)
|
||||
logger.error("Full traceback:\n%s", traceback.format_exc())
|
||||
raise
|
||||
|
||||
def _build_body_sections(self, data: ComplianceData) -> list:
|
||||
"""Build the body sections between executive summary and detailed findings.
|
||||
|
||||
Override in subclasses to change section order.
|
||||
|
||||
Args:
|
||||
data: Aggregated compliance data.
|
||||
|
||||
Returns:
|
||||
List of ReportLab elements.
|
||||
"""
|
||||
elements = []
|
||||
|
||||
# Charts section (framework-specific) - heavy on memory due to matplotlib
|
||||
elements.extend(self.create_charts_section(data))
|
||||
elements.append(PageBreak())
|
||||
gc.collect() # Free matplotlib resources
|
||||
|
||||
# Requirements index (framework-specific)
|
||||
elements.extend(self.create_requirements_index(data))
|
||||
elements.append(PageBreak())
|
||||
|
||||
return elements
|
||||
|
||||
# =========================================================================
|
||||
# Abstract Methods (must be implemented by subclasses)
|
||||
# =========================================================================
|
||||
|
||||
@abstractmethod
|
||||
def create_executive_summary(self, data: ComplianceData) -> list:
|
||||
"""Create the executive summary section.
|
||||
|
||||
This section typically includes:
|
||||
- Overall compliance score/metrics
|
||||
- High-level statistics
|
||||
- Critical findings summary
|
||||
|
||||
Args:
|
||||
data: Aggregated compliance data
|
||||
|
||||
Returns:
|
||||
List of ReportLab elements
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def create_charts_section(self, data: ComplianceData) -> list:
|
||||
"""Create the charts and visualizations section.
|
||||
|
||||
This section typically includes:
|
||||
- Compliance score charts by section
|
||||
- Distribution charts
|
||||
- Trend visualizations
|
||||
|
||||
Args:
|
||||
data: Aggregated compliance data
|
||||
|
||||
Returns:
|
||||
List of ReportLab elements
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def create_requirements_index(self, data: ComplianceData) -> list:
|
||||
"""Create the requirements index/table of contents.
|
||||
|
||||
This section typically includes:
|
||||
- Hierarchical list of requirements
|
||||
- Status indicators
|
||||
- Section groupings
|
||||
|
||||
Args:
|
||||
data: Aggregated compliance data
|
||||
|
||||
Returns:
|
||||
List of ReportLab elements
|
||||
"""
|
||||
|
||||
# =========================================================================
|
||||
# Common Methods (can be overridden by subclasses)
|
||||
# =========================================================================
|
||||
|
||||
def create_cover_page(self, data: ComplianceData) -> list:
|
||||
"""Create the report cover page.
|
||||
|
||||
Args:
|
||||
data: Aggregated compliance data
|
||||
|
||||
Returns:
|
||||
List of ReportLab elements
|
||||
"""
|
||||
elements = []
|
||||
|
||||
# Prowler logo
|
||||
logo_path = os.path.join(
|
||||
os.path.dirname(__file__), "../../assets/img/prowler_logo.png"
|
||||
)
|
||||
if os.path.exists(logo_path):
|
||||
logo = Image(logo_path, width=5 * inch, height=1 * inch)
|
||||
elements.append(logo)
|
||||
|
||||
elements.append(Spacer(1, 0.5 * inch))
|
||||
|
||||
# Title
|
||||
title_text = f"{self.config.display_name} Report"
|
||||
elements.append(Paragraph(title_text, self.styles["title"]))
|
||||
elements.append(Spacer(1, 0.5 * inch))
|
||||
|
||||
# Compliance info table
|
||||
info_rows = self._build_info_rows(data, language=self.config.language)
|
||||
|
||||
info_table = create_info_table(
|
||||
rows=info_rows,
|
||||
label_width=2 * inch,
|
||||
value_width=4 * inch,
|
||||
normal_style=self.styles["normal_center"],
|
||||
)
|
||||
elements.append(info_table)
|
||||
|
||||
return elements
|
||||
|
||||
def _build_info_rows(
|
||||
self, data: ComplianceData, language: str = "en"
|
||||
) -> list[tuple[str, str]]:
|
||||
"""Build the standard info rows for the cover page table.
|
||||
|
||||
This helper method creates the common metadata rows used in all
|
||||
report cover pages. Subclasses can use this to maintain consistency
|
||||
while customizing other aspects of the cover page.
|
||||
|
||||
Args:
|
||||
data: Aggregated compliance data.
|
||||
language: Language for labels ("en" or "es").
|
||||
|
||||
Returns:
|
||||
List of (label, value) tuples for the info table.
|
||||
"""
|
||||
# Labels based on language
|
||||
labels = {
|
||||
"en": {
|
||||
"framework": "Framework:",
|
||||
"id": "ID:",
|
||||
"name": "Name:",
|
||||
"version": "Version:",
|
||||
"provider": "Provider:",
|
||||
"account_id": "Account ID:",
|
||||
"alias": "Alias:",
|
||||
"scan_id": "Scan ID:",
|
||||
"description": "Description:",
|
||||
},
|
||||
"es": {
|
||||
"framework": "Framework:",
|
||||
"id": "ID:",
|
||||
"name": "Nombre:",
|
||||
"version": "Versión:",
|
||||
"provider": "Proveedor:",
|
||||
"account_id": "Account ID:",
|
||||
"alias": "Alias:",
|
||||
"scan_id": "Scan ID:",
|
||||
"description": "Descripción:",
|
||||
},
|
||||
}
|
||||
lang_labels = labels.get(language, labels["en"])
|
||||
|
||||
info_rows = [
|
||||
(lang_labels["framework"], data.framework),
|
||||
(lang_labels["id"], data.compliance_id),
|
||||
(lang_labels["name"], data.name),
|
||||
(lang_labels["version"], data.version),
|
||||
]
|
||||
|
||||
# Add provider info if available
|
||||
if data.provider_obj:
|
||||
info_rows.append(
|
||||
(lang_labels["provider"], data.provider_obj.provider.upper())
|
||||
)
|
||||
info_rows.append(
|
||||
(lang_labels["account_id"], data.provider_obj.uid or "N/A")
|
||||
)
|
||||
info_rows.append((lang_labels["alias"], data.provider_obj.alias or "N/A"))
|
||||
|
||||
info_rows.append((lang_labels["scan_id"], data.scan_id))
|
||||
|
||||
if data.description:
|
||||
info_rows.append((lang_labels["description"], data.description))
|
||||
|
||||
return info_rows
|
||||
|
||||
def create_detailed_findings(self, data: ComplianceData, **kwargs) -> list:
|
||||
"""Create the detailed findings section.
|
||||
|
||||
This default implementation creates a requirement-by-requirement
|
||||
breakdown with findings tables. Subclasses can override for
|
||||
framework-specific presentation.
|
||||
|
||||
This method implements on-demand loading of findings using the shared
|
||||
findings cache to minimize database queries and memory usage.
|
||||
|
||||
Args:
|
||||
data: Aggregated compliance data
|
||||
**kwargs: Framework-specific options (e.g., only_failed)
|
||||
|
||||
Returns:
|
||||
List of ReportLab elements
|
||||
"""
|
||||
elements = []
|
||||
only_failed = kwargs.get("only_failed", True)
|
||||
include_manual = kwargs.get("include_manual", False)
|
||||
|
||||
# Filter requirements if needed
|
||||
requirements = data.requirements
|
||||
if only_failed:
|
||||
# Include FAIL requirements, and optionally MANUAL if include_manual is True
|
||||
if include_manual:
|
||||
requirements = [
|
||||
r
|
||||
for r in requirements
|
||||
if r.status in (StatusChoices.FAIL, StatusChoices.MANUAL)
|
||||
]
|
||||
else:
|
||||
requirements = [
|
||||
r for r in requirements if r.status == StatusChoices.FAIL
|
||||
]
|
||||
|
||||
# Collect all check IDs for requirements that will be displayed
|
||||
# This allows us to load only the findings we actually need (memory optimization)
|
||||
check_ids_to_load = []
|
||||
for req in requirements:
|
||||
check_ids_to_load.extend(req.checks)
|
||||
|
||||
# Load findings on-demand only for the checks that will be displayed
|
||||
# Uses the shared findings cache to avoid duplicate queries across reports
|
||||
logger.info("Loading findings on-demand for %d requirements", len(requirements))
|
||||
findings_by_check_id = _load_findings_for_requirement_checks(
|
||||
data.tenant_id,
|
||||
data.scan_id,
|
||||
check_ids_to_load,
|
||||
data.prowler_provider,
|
||||
data.findings_by_check_id, # Pass the cache to update it
|
||||
)
|
||||
|
||||
for req in requirements:
|
||||
# Requirement header
|
||||
elements.append(
|
||||
Paragraph(
|
||||
f"{req.id}: {req.description}",
|
||||
self.styles["h1"],
|
||||
)
|
||||
)
|
||||
|
||||
# Status badge
|
||||
elements.append(create_status_badge(req.status))
|
||||
elements.append(Spacer(1, 0.1 * inch))
|
||||
|
||||
# Findings for this requirement
|
||||
for check_id in req.checks:
|
||||
elements.append(Paragraph(f"Check: {check_id}", self.styles["h2"]))
|
||||
|
||||
findings = findings_by_check_id.get(check_id, [])
|
||||
if not findings:
|
||||
elements.append(
|
||||
Paragraph(
|
||||
"- No information for this finding currently",
|
||||
self.styles["normal"],
|
||||
)
|
||||
)
|
||||
else:
|
||||
# Create findings table
|
||||
findings_table = self._create_findings_table(findings)
|
||||
elements.append(findings_table)
|
||||
|
||||
elements.append(Spacer(1, 0.1 * inch))
|
||||
|
||||
elements.append(PageBreak())
|
||||
|
||||
return elements
|
||||
|
||||
def get_footer_text(self, page_num: int) -> tuple[str, str]:
|
||||
"""Get footer text for a page.
|
||||
|
||||
Args:
|
||||
page_num: Current page number
|
||||
|
||||
Returns:
|
||||
Tuple of (left_text, right_text) for the footer
|
||||
"""
|
||||
if self.config.language == "es":
|
||||
page_text = f"Página {page_num}"
|
||||
else:
|
||||
page_text = f"Page {page_num}"
|
||||
|
||||
return page_text, "Powered by Prowler"
|
||||
|
||||
# =========================================================================
|
||||
# Private Helper Methods
|
||||
# =========================================================================
|
||||
|
||||
def _load_compliance_data(
|
||||
self,
|
||||
tenant_id: str,
|
||||
scan_id: str,
|
||||
compliance_id: str,
|
||||
provider_id: str,
|
||||
provider_obj: Provider | None,
|
||||
requirement_statistics: dict | None,
|
||||
findings_cache: dict | None,
|
||||
) -> ComplianceData:
|
||||
"""Load and aggregate compliance data from the database.
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant identifier
|
||||
scan_id: Scan identifier
|
||||
compliance_id: Compliance framework identifier
|
||||
provider_id: Provider identifier
|
||||
provider_obj: Optional pre-fetched Provider
|
||||
requirement_statistics: Optional pre-aggregated statistics
|
||||
findings_cache: Optional pre-loaded findings
|
||||
|
||||
Returns:
|
||||
Aggregated ComplianceData object
|
||||
"""
|
||||
with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS):
|
||||
# Load provider
|
||||
if provider_obj is None:
|
||||
provider_obj = Provider.objects.get(id=provider_id)
|
||||
|
||||
prowler_provider = initialize_prowler_provider(provider_obj)
|
||||
provider_type = provider_obj.provider
|
||||
|
||||
# Load compliance framework
|
||||
frameworks_bulk = Compliance.get_bulk(provider_type)
|
||||
compliance_obj = frameworks_bulk.get(compliance_id)
|
||||
|
||||
if not compliance_obj:
|
||||
raise ValueError(f"Compliance framework not found: {compliance_id}")
|
||||
|
||||
framework = getattr(compliance_obj, "Framework", "N/A")
|
||||
name = getattr(compliance_obj, "Name", "N/A")
|
||||
version = getattr(compliance_obj, "Version", "N/A")
|
||||
description = getattr(compliance_obj, "Description", "")
|
||||
|
||||
# Aggregate requirement statistics
|
||||
if requirement_statistics is None:
|
||||
logger.info("Aggregating requirement statistics for scan %s", scan_id)
|
||||
requirement_statistics = _aggregate_requirement_statistics_from_database(
|
||||
tenant_id, scan_id
|
||||
)
|
||||
else:
|
||||
logger.info("Reusing pre-aggregated statistics for scan %s", scan_id)
|
||||
|
||||
# Calculate requirements data
|
||||
attributes_by_requirement_id, requirements_list = (
|
||||
_calculate_requirements_data_from_statistics(
|
||||
compliance_obj, requirement_statistics
|
||||
)
|
||||
)
|
||||
|
||||
# Convert to RequirementData objects
|
||||
requirements = []
|
||||
for req_dict in requirements_list:
|
||||
req = RequirementData(
|
||||
id=req_dict["id"],
|
||||
description=req_dict["attributes"].get("description", ""),
|
||||
status=req_dict["attributes"].get("status", StatusChoices.MANUAL),
|
||||
passed_findings=req_dict["attributes"].get("passed_findings", 0),
|
||||
failed_findings=req_dict["attributes"].get("failed_findings", 0),
|
||||
total_findings=req_dict["attributes"].get("total_findings", 0),
|
||||
checks=attributes_by_requirement_id.get(req_dict["id"], {})
|
||||
.get("attributes", {})
|
||||
.get("checks", []),
|
||||
)
|
||||
requirements.append(req)
|
||||
|
||||
return ComplianceData(
|
||||
tenant_id=tenant_id,
|
||||
scan_id=scan_id,
|
||||
provider_id=provider_id,
|
||||
compliance_id=compliance_id,
|
||||
framework=framework,
|
||||
name=name,
|
||||
version=version,
|
||||
description=description,
|
||||
requirements=requirements,
|
||||
attributes_by_requirement_id=attributes_by_requirement_id,
|
||||
findings_by_check_id=findings_cache if findings_cache is not None else {},
|
||||
provider_obj=provider_obj,
|
||||
prowler_provider=prowler_provider,
|
||||
)
|
||||
|
||||
def _create_document(
|
||||
self, output_path: str, data: ComplianceData
|
||||
) -> SimpleDocTemplate:
|
||||
"""Create the PDF document template.
|
||||
|
||||
Args:
|
||||
output_path: Path for the output PDF
|
||||
data: Compliance data for metadata
|
||||
|
||||
Returns:
|
||||
Configured SimpleDocTemplate
|
||||
"""
|
||||
return SimpleDocTemplate(
|
||||
output_path,
|
||||
pagesize=letter,
|
||||
title=f"{self.config.display_name} Report - {data.framework}",
|
||||
author="Prowler",
|
||||
subject=f"Compliance Report for {data.framework}",
|
||||
creator="Prowler Engineering Team",
|
||||
keywords=f"compliance,{data.framework},security,framework,prowler",
|
||||
)
|
||||
|
||||
def _build_pdf(
|
||||
self,
|
||||
doc: SimpleDocTemplate,
|
||||
elements: list,
|
||||
data: ComplianceData,
|
||||
) -> None:
|
||||
"""Build the final PDF with footers.
|
||||
|
||||
Args:
|
||||
doc: Document template
|
||||
elements: List of ReportLab elements
|
||||
data: Compliance data
|
||||
"""
|
||||
|
||||
def add_footer(
|
||||
canvas_obj: canvas.Canvas,
|
||||
doc_template: SimpleDocTemplate,
|
||||
) -> None:
|
||||
canvas_obj.saveState()
|
||||
width, _ = doc_template.pagesize
|
||||
left_text, right_text = self.get_footer_text(doc_template.page)
|
||||
|
||||
canvas_obj.setFont("PlusJakartaSans", 9)
|
||||
canvas_obj.setFillColorRGB(0.4, 0.4, 0.4)
|
||||
canvas_obj.drawString(30, 20, left_text)
|
||||
|
||||
text_width = canvas_obj.stringWidth(right_text, "PlusJakartaSans", 9)
|
||||
canvas_obj.drawString(width - text_width - 30, 20, right_text)
|
||||
canvas_obj.restoreState()
|
||||
|
||||
doc.build(
|
||||
elements,
|
||||
onFirstPage=add_footer,
|
||||
onLaterPages=add_footer,
|
||||
)
|
||||
|
||||
def _create_findings_table(self, findings: list[FindingOutput]) -> Any:
|
||||
"""Create a findings table.
|
||||
|
||||
Args:
|
||||
findings: List of finding objects
|
||||
|
||||
Returns:
|
||||
ReportLab Table element
|
||||
"""
|
||||
|
||||
def get_finding_title(f):
|
||||
metadata = getattr(f, "metadata", None)
|
||||
if metadata:
|
||||
return getattr(metadata, "CheckTitle", getattr(f, "check_id", ""))
|
||||
return getattr(f, "check_id", "")
|
||||
|
||||
def get_resource_name(f):
|
||||
name = getattr(f, "resource_name", "")
|
||||
if not name:
|
||||
name = getattr(f, "resource_uid", "")
|
||||
return name
|
||||
|
||||
def get_severity(f):
|
||||
metadata = getattr(f, "metadata", None)
|
||||
if metadata:
|
||||
return getattr(metadata, "Severity", "").capitalize()
|
||||
return ""
|
||||
|
||||
# Convert findings to dicts for the table
|
||||
data = []
|
||||
for f in findings:
|
||||
item = {
|
||||
"title": get_finding_title(f),
|
||||
"resource_name": get_resource_name(f),
|
||||
"severity": get_severity(f),
|
||||
"status": getattr(f, "status", "").upper(),
|
||||
"region": getattr(f, "region", "global"),
|
||||
}
|
||||
data.append(item)
|
||||
|
||||
columns = [
|
||||
ColumnConfig("Finding", 2.5 * inch, "title"),
|
||||
ColumnConfig("Resource", 3 * inch, "resource_name"),
|
||||
ColumnConfig("Severity", 0.9 * inch, "severity"),
|
||||
ColumnConfig("Status", 0.9 * inch, "status"),
|
||||
ColumnConfig("Region", 0.9 * inch, "region"),
|
||||
]
|
||||
|
||||
return create_data_table(
|
||||
data=data,
|
||||
columns=columns,
|
||||
header_color=self.config.primary_color,
|
||||
normal_style=self.styles["normal_center"],
|
||||
)
|
||||
@@ -0,0 +1,404 @@
|
||||
import gc
|
||||
import io
|
||||
import math
|
||||
from typing import Callable
|
||||
|
||||
import matplotlib
|
||||
|
||||
# Use non-interactive Agg backend for memory efficiency in server environments
|
||||
# This MUST be set before importing pyplot
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt # noqa: E402
|
||||
|
||||
from .config import ( # noqa: E402
|
||||
CHART_COLOR_BLUE,
|
||||
CHART_COLOR_GREEN_1,
|
||||
CHART_COLOR_GREEN_2,
|
||||
CHART_COLOR_ORANGE,
|
||||
CHART_COLOR_RED,
|
||||
CHART_COLOR_YELLOW,
|
||||
CHART_DPI_DEFAULT,
|
||||
)
|
||||
|
||||
# Use centralized DPI setting from config
|
||||
DEFAULT_CHART_DPI = CHART_DPI_DEFAULT
|
||||
|
||||
|
||||
def get_chart_color_for_percentage(percentage: float) -> str:
|
||||
"""Get chart color string based on percentage.
|
||||
|
||||
Args:
|
||||
percentage: Value between 0 and 100
|
||||
|
||||
Returns:
|
||||
Hex color string for matplotlib
|
||||
"""
|
||||
if percentage >= 80:
|
||||
return CHART_COLOR_GREEN_1
|
||||
if percentage >= 60:
|
||||
return CHART_COLOR_GREEN_2
|
||||
if percentage >= 40:
|
||||
return CHART_COLOR_YELLOW
|
||||
if percentage >= 20:
|
||||
return CHART_COLOR_ORANGE
|
||||
return CHART_COLOR_RED
|
||||
|
||||
|
||||
def create_vertical_bar_chart(
|
||||
labels: list[str],
|
||||
values: list[float],
|
||||
ylabel: str = "Compliance Score (%)",
|
||||
xlabel: str = "Section",
|
||||
title: str | None = None,
|
||||
color_func: Callable[[float], str] | None = None,
|
||||
colors: list[str] | None = None,
|
||||
figsize: tuple[int, int] = (10, 6),
|
||||
dpi: int = DEFAULT_CHART_DPI,
|
||||
y_limit: tuple[float, float] = (0, 100),
|
||||
show_labels: bool = True,
|
||||
rotation: int = 45,
|
||||
) -> io.BytesIO:
|
||||
"""Create a vertical bar chart.
|
||||
|
||||
Args:
|
||||
labels: X-axis labels
|
||||
values: Bar heights (numeric values)
|
||||
ylabel: Y-axis label
|
||||
xlabel: X-axis label
|
||||
title: Optional chart title
|
||||
color_func: Function to determine bar color based on value
|
||||
colors: Explicit list of colors (overrides color_func)
|
||||
figsize: Figure size (width, height) in inches
|
||||
dpi: Resolution for output image
|
||||
y_limit: Y-axis limits (min, max)
|
||||
show_labels: Whether to show value labels on bars
|
||||
rotation: X-axis label rotation angle
|
||||
|
||||
Returns:
|
||||
BytesIO buffer containing the PNG image
|
||||
"""
|
||||
if color_func is None:
|
||||
color_func = get_chart_color_for_percentage
|
||||
|
||||
fig, ax = plt.subplots(figsize=figsize)
|
||||
|
||||
# Determine colors
|
||||
if colors is None:
|
||||
colors_list = [color_func(v) for v in values]
|
||||
else:
|
||||
colors_list = colors
|
||||
|
||||
bars = ax.bar(labels, values, color=colors_list)
|
||||
|
||||
ax.set_ylabel(ylabel, fontsize=12)
|
||||
ax.set_xlabel(xlabel, fontsize=12)
|
||||
ax.set_ylim(*y_limit)
|
||||
|
||||
if title:
|
||||
ax.set_title(title, fontsize=14, fontweight="bold")
|
||||
|
||||
# Add value labels on bars
|
||||
if show_labels:
|
||||
for bar_item, value in zip(bars, values):
|
||||
height = bar_item.get_height()
|
||||
ax.text(
|
||||
bar_item.get_x() + bar_item.get_width() / 2.0,
|
||||
height + 1,
|
||||
f"{value:.1f}%",
|
||||
ha="center",
|
||||
va="bottom",
|
||||
fontweight="bold",
|
||||
)
|
||||
|
||||
plt.xticks(rotation=rotation, ha="right")
|
||||
ax.grid(True, alpha=0.3, axis="y")
|
||||
plt.tight_layout()
|
||||
|
||||
buffer = io.BytesIO()
|
||||
try:
|
||||
fig.savefig(buffer, format="png", dpi=dpi, bbox_inches="tight")
|
||||
buffer.seek(0)
|
||||
finally:
|
||||
plt.close(fig)
|
||||
gc.collect() # Force garbage collection after heavy matplotlib operation
|
||||
|
||||
return buffer
|
||||
|
||||
|
||||
def create_horizontal_bar_chart(
|
||||
labels: list[str],
|
||||
values: list[float],
|
||||
xlabel: str = "Compliance (%)",
|
||||
title: str | None = None,
|
||||
color_func: Callable[[float], str] | None = None,
|
||||
colors: list[str] | None = None,
|
||||
figsize: tuple[int, int] | None = None,
|
||||
dpi: int = DEFAULT_CHART_DPI,
|
||||
x_limit: tuple[float, float] = (0, 100),
|
||||
show_labels: bool = True,
|
||||
label_fontsize: int = 16,
|
||||
) -> io.BytesIO:
|
||||
"""Create a horizontal bar chart.
|
||||
|
||||
Args:
|
||||
labels: Y-axis labels (bar names)
|
||||
values: Bar widths (numeric values)
|
||||
xlabel: X-axis label
|
||||
title: Optional chart title
|
||||
color_func: Function to determine bar color based on value
|
||||
colors: Explicit list of colors (overrides color_func)
|
||||
figsize: Figure size (auto-calculated if None based on label count)
|
||||
dpi: Resolution for output image
|
||||
x_limit: X-axis limits (min, max)
|
||||
show_labels: Whether to show value labels on bars
|
||||
label_fontsize: Font size for y-axis labels
|
||||
|
||||
Returns:
|
||||
BytesIO buffer containing the PNG image
|
||||
"""
|
||||
if color_func is None:
|
||||
color_func = get_chart_color_for_percentage
|
||||
|
||||
# Auto-calculate figure size based on number of items
|
||||
if figsize is None:
|
||||
figsize = (10, max(6, int(len(labels) * 0.4)))
|
||||
|
||||
fig, ax = plt.subplots(figsize=figsize)
|
||||
|
||||
# Determine colors
|
||||
if colors is None:
|
||||
colors_list = [color_func(v) for v in values]
|
||||
else:
|
||||
colors_list = colors
|
||||
|
||||
y_pos = range(len(labels))
|
||||
bars = ax.barh(y_pos, values, color=colors_list)
|
||||
|
||||
ax.set_yticks(y_pos)
|
||||
ax.set_yticklabels(labels, fontsize=label_fontsize)
|
||||
ax.set_xlabel(xlabel, fontsize=14)
|
||||
ax.set_xlim(*x_limit)
|
||||
|
||||
if title:
|
||||
ax.set_title(title, fontsize=14, fontweight="bold")
|
||||
|
||||
# Add value labels
|
||||
if show_labels:
|
||||
for bar_item, value in zip(bars, values):
|
||||
width = bar_item.get_width()
|
||||
ax.text(
|
||||
width + 1,
|
||||
bar_item.get_y() + bar_item.get_height() / 2.0,
|
||||
f"{value:.1f}%",
|
||||
ha="left",
|
||||
va="center",
|
||||
fontweight="bold",
|
||||
fontsize=10,
|
||||
)
|
||||
|
||||
ax.grid(True, alpha=0.3, axis="x")
|
||||
plt.tight_layout()
|
||||
|
||||
buffer = io.BytesIO()
|
||||
try:
|
||||
fig.savefig(buffer, format="png", dpi=dpi, bbox_inches="tight")
|
||||
buffer.seek(0)
|
||||
finally:
|
||||
plt.close(fig)
|
||||
gc.collect() # Force garbage collection after heavy matplotlib operation
|
||||
|
||||
return buffer
|
||||
|
||||
|
||||
def create_radar_chart(
|
||||
labels: list[str],
|
||||
values: list[float],
|
||||
color: str = CHART_COLOR_BLUE,
|
||||
fill_alpha: float = 0.25,
|
||||
figsize: tuple[int, int] = (8, 8),
|
||||
dpi: int = DEFAULT_CHART_DPI,
|
||||
y_limit: tuple[float, float] = (0, 100),
|
||||
y_ticks: list[int] | None = None,
|
||||
label_fontsize: int = 14,
|
||||
title: str | None = None,
|
||||
) -> io.BytesIO:
|
||||
"""Create a radar/spider chart.
|
||||
|
||||
Args:
|
||||
labels: Category names around the chart
|
||||
values: Values for each category (should have same length as labels)
|
||||
color: Line and fill color
|
||||
fill_alpha: Transparency of the fill (0-1)
|
||||
figsize: Figure size (width, height) in inches
|
||||
dpi: Resolution for output image
|
||||
y_limit: Radial axis limits (min, max)
|
||||
y_ticks: Custom tick values for radial axis
|
||||
label_fontsize: Font size for category labels
|
||||
title: Optional chart title
|
||||
|
||||
Returns:
|
||||
BytesIO buffer containing the PNG image
|
||||
"""
|
||||
num_vars = len(labels)
|
||||
angles = [n / float(num_vars) * 2 * math.pi for n in range(num_vars)]
|
||||
|
||||
# Close the polygon
|
||||
values_closed = list(values) + [values[0]]
|
||||
angles_closed = angles + [angles[0]]
|
||||
|
||||
fig, ax = plt.subplots(figsize=figsize, subplot_kw={"projection": "polar"})
|
||||
|
||||
ax.plot(angles_closed, values_closed, "o-", linewidth=2, color=color)
|
||||
ax.fill(angles_closed, values_closed, alpha=fill_alpha, color=color)
|
||||
|
||||
ax.set_xticks(angles)
|
||||
ax.set_xticklabels(labels, fontsize=label_fontsize)
|
||||
ax.set_ylim(*y_limit)
|
||||
|
||||
if y_ticks is None:
|
||||
y_ticks = [20, 40, 60, 80, 100]
|
||||
ax.set_yticks(y_ticks)
|
||||
ax.set_yticklabels([f"{t}%" for t in y_ticks], fontsize=12)
|
||||
|
||||
ax.grid(True, alpha=0.3)
|
||||
|
||||
if title:
|
||||
ax.set_title(title, fontsize=14, fontweight="bold", y=1.08)
|
||||
|
||||
plt.tight_layout()
|
||||
|
||||
buffer = io.BytesIO()
|
||||
try:
|
||||
fig.savefig(buffer, format="png", dpi=dpi, bbox_inches="tight")
|
||||
buffer.seek(0)
|
||||
finally:
|
||||
plt.close(fig)
|
||||
gc.collect() # Force garbage collection after heavy matplotlib operation
|
||||
|
||||
return buffer
|
||||
|
||||
|
||||
def create_pie_chart(
|
||||
labels: list[str],
|
||||
values: list[float],
|
||||
colors: list[str] | None = None,
|
||||
figsize: tuple[int, int] = (6, 6),
|
||||
dpi: int = DEFAULT_CHART_DPI,
|
||||
autopct: str = "%1.1f%%",
|
||||
startangle: int = 90,
|
||||
title: str | None = None,
|
||||
) -> io.BytesIO:
|
||||
"""Create a pie chart.
|
||||
|
||||
Args:
|
||||
labels: Slice labels
|
||||
values: Slice values
|
||||
colors: Optional list of colors for slices
|
||||
figsize: Figure size (width, height) in inches
|
||||
dpi: Resolution for output image
|
||||
autopct: Format string for percentage labels
|
||||
startangle: Starting angle for first slice
|
||||
title: Optional chart title
|
||||
|
||||
Returns:
|
||||
BytesIO buffer containing the PNG image
|
||||
"""
|
||||
fig, ax = plt.subplots(figsize=figsize)
|
||||
|
||||
_, _, autotexts = ax.pie(
|
||||
values,
|
||||
labels=labels,
|
||||
colors=colors,
|
||||
autopct=autopct,
|
||||
startangle=startangle,
|
||||
)
|
||||
|
||||
# Style the text
|
||||
for autotext in autotexts:
|
||||
autotext.set_fontweight("bold")
|
||||
|
||||
if title:
|
||||
ax.set_title(title, fontsize=14, fontweight="bold")
|
||||
|
||||
plt.tight_layout()
|
||||
|
||||
buffer = io.BytesIO()
|
||||
try:
|
||||
fig.savefig(buffer, format="png", dpi=dpi, bbox_inches="tight")
|
||||
buffer.seek(0)
|
||||
finally:
|
||||
plt.close(fig)
|
||||
gc.collect() # Force garbage collection after heavy matplotlib operation
|
||||
|
||||
return buffer
|
||||
|
||||
|
||||
def create_stacked_bar_chart(
|
||||
labels: list[str],
|
||||
data_series: dict[str, list[float]],
|
||||
colors: dict[str, str] | None = None,
|
||||
xlabel: str = "",
|
||||
ylabel: str = "Count",
|
||||
title: str | None = None,
|
||||
figsize: tuple[int, int] = (10, 6),
|
||||
dpi: int = DEFAULT_CHART_DPI,
|
||||
rotation: int = 45,
|
||||
show_legend: bool = True,
|
||||
) -> io.BytesIO:
|
||||
"""Create a stacked bar chart.
|
||||
|
||||
Args:
|
||||
labels: X-axis labels
|
||||
data_series: Dictionary mapping series name to list of values
|
||||
colors: Dictionary mapping series name to color
|
||||
xlabel: X-axis label
|
||||
ylabel: Y-axis label
|
||||
title: Optional chart title
|
||||
figsize: Figure size (width, height) in inches
|
||||
dpi: Resolution for output image
|
||||
rotation: X-axis label rotation angle
|
||||
show_legend: Whether to show the legend
|
||||
|
||||
Returns:
|
||||
BytesIO buffer containing the PNG image
|
||||
"""
|
||||
fig, ax = plt.subplots(figsize=figsize)
|
||||
|
||||
# Default colors if not provided
|
||||
default_colors = {
|
||||
"Pass": CHART_COLOR_GREEN_1,
|
||||
"Fail": CHART_COLOR_RED,
|
||||
"Manual": CHART_COLOR_YELLOW,
|
||||
}
|
||||
if colors is None:
|
||||
colors = default_colors
|
||||
|
||||
bottom = [0] * len(labels)
|
||||
for series_name, values in data_series.items():
|
||||
color = colors.get(series_name, CHART_COLOR_BLUE)
|
||||
ax.bar(labels, values, bottom=bottom, label=series_name, color=color)
|
||||
bottom = [b + v for b, v in zip(bottom, values)]
|
||||
|
||||
ax.set_xlabel(xlabel, fontsize=12)
|
||||
ax.set_ylabel(ylabel, fontsize=12)
|
||||
|
||||
if title:
|
||||
ax.set_title(title, fontsize=14, fontweight="bold")
|
||||
|
||||
plt.xticks(rotation=rotation, ha="right")
|
||||
|
||||
if show_legend:
|
||||
ax.legend()
|
||||
|
||||
ax.grid(True, alpha=0.3, axis="y")
|
||||
plt.tight_layout()
|
||||
|
||||
buffer = io.BytesIO()
|
||||
try:
|
||||
fig.savefig(buffer, format="png", dpi=dpi, bbox_inches="tight")
|
||||
buffer.seek(0)
|
||||
finally:
|
||||
plt.close(fig)
|
||||
gc.collect() # Force garbage collection after heavy matplotlib operation
|
||||
|
||||
return buffer
|
||||
@@ -0,0 +1,599 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable
|
||||
|
||||
from reportlab.lib import colors
|
||||
from reportlab.lib.styles import ParagraphStyle
|
||||
from reportlab.lib.units import inch
|
||||
from reportlab.platypus import LongTable, Paragraph, Spacer, Table, TableStyle
|
||||
|
||||
from .config import (
|
||||
ALTERNATE_ROWS_MAX_SIZE,
|
||||
COLOR_BLUE,
|
||||
COLOR_BORDER_GRAY,
|
||||
COLOR_DARK_GRAY,
|
||||
COLOR_GRID_GRAY,
|
||||
COLOR_HIGH_RISK,
|
||||
COLOR_LIGHT_GRAY,
|
||||
COLOR_LOW_RISK,
|
||||
COLOR_MEDIUM_RISK,
|
||||
COLOR_SAFE,
|
||||
COLOR_WHITE,
|
||||
LONG_TABLE_THRESHOLD,
|
||||
PADDING_LARGE,
|
||||
PADDING_MEDIUM,
|
||||
PADDING_SMALL,
|
||||
PADDING_XLARGE,
|
||||
)
|
||||
|
||||
|
||||
def get_color_for_risk_level(risk_level: int) -> colors.Color:
|
||||
"""
|
||||
Get color based on risk level.
|
||||
|
||||
Args:
|
||||
risk_level (int): Numeric risk level (0-5).
|
||||
|
||||
Returns:
|
||||
colors.Color: Appropriate color for the risk level.
|
||||
"""
|
||||
if risk_level >= 4:
|
||||
return COLOR_HIGH_RISK
|
||||
if risk_level >= 3:
|
||||
return COLOR_MEDIUM_RISK
|
||||
if risk_level >= 2:
|
||||
return COLOR_LOW_RISK
|
||||
return COLOR_SAFE
|
||||
|
||||
|
||||
def get_color_for_weight(weight: int) -> colors.Color:
|
||||
"""
|
||||
Get color based on weight value.
|
||||
|
||||
Args:
|
||||
weight (int): Numeric weight value.
|
||||
|
||||
Returns:
|
||||
colors.Color: Appropriate color for the weight.
|
||||
"""
|
||||
if weight > 100:
|
||||
return COLOR_HIGH_RISK
|
||||
if weight > 50:
|
||||
return COLOR_LOW_RISK
|
||||
return COLOR_SAFE
|
||||
|
||||
|
||||
def get_color_for_compliance(percentage: float) -> colors.Color:
|
||||
"""
|
||||
Get color based on compliance percentage.
|
||||
|
||||
Args:
|
||||
percentage (float): Compliance percentage (0-100).
|
||||
|
||||
Returns:
|
||||
colors.Color: Appropriate color for the compliance level.
|
||||
"""
|
||||
if percentage >= 80:
|
||||
return COLOR_SAFE
|
||||
if percentage >= 60:
|
||||
return COLOR_LOW_RISK
|
||||
return COLOR_HIGH_RISK
|
||||
|
||||
|
||||
def get_status_color(status: str) -> colors.Color:
|
||||
"""
|
||||
Get color for a status value.
|
||||
|
||||
Args:
|
||||
status (str): Status string (PASS, FAIL, MANUAL, etc.).
|
||||
|
||||
Returns:
|
||||
colors.Color: Appropriate color for the status.
|
||||
"""
|
||||
status_upper = status.upper()
|
||||
if status_upper == "PASS":
|
||||
return COLOR_SAFE
|
||||
if status_upper == "FAIL":
|
||||
return COLOR_HIGH_RISK
|
||||
return COLOR_DARK_GRAY
|
||||
|
||||
|
||||
def create_badge(
|
||||
text: str,
|
||||
bg_color: colors.Color,
|
||||
text_color: colors.Color = COLOR_WHITE,
|
||||
width: float = 1.4 * inch,
|
||||
font: str = "FiraCode",
|
||||
font_size: int = 11,
|
||||
) -> Table:
|
||||
"""
|
||||
Create a generic colored badge component.
|
||||
|
||||
Args:
|
||||
text (str): Text to display in the badge.
|
||||
bg_color (colors.Color): Background color.
|
||||
text_color (colors.Color): Text color (default white).
|
||||
width (float): Badge width in inches.
|
||||
font (str): Font name to use.
|
||||
font_size (int): Font size.
|
||||
|
||||
Returns:
|
||||
Table: A Table object styled as a badge.
|
||||
"""
|
||||
data = [[text]]
|
||||
table = Table(data, colWidths=[width])
|
||||
|
||||
table.setStyle(
|
||||
TableStyle(
|
||||
[
|
||||
("BACKGROUND", (0, 0), (0, 0), bg_color),
|
||||
("TEXTCOLOR", (0, 0), (0, 0), text_color),
|
||||
("FONTNAME", (0, 0), (0, 0), font),
|
||||
("ALIGN", (0, 0), (-1, -1), "CENTER"),
|
||||
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
||||
("FONTSIZE", (0, 0), (-1, -1), font_size),
|
||||
("GRID", (0, 0), (-1, -1), 0.5, colors.black),
|
||||
("LEFTPADDING", (0, 0), (-1, -1), PADDING_LARGE),
|
||||
("RIGHTPADDING", (0, 0), (-1, -1), PADDING_LARGE),
|
||||
("TOPPADDING", (0, 0), (-1, -1), PADDING_LARGE),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), PADDING_LARGE),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
return table
|
||||
|
||||
|
||||
def create_status_badge(status: str) -> Table:
|
||||
"""
|
||||
Create a PASS/FAIL/MANUAL status badge.
|
||||
|
||||
Args:
|
||||
status (str): Status value (e.g., "PASS", "FAIL", "MANUAL").
|
||||
|
||||
Returns:
|
||||
Table: A styled Table badge for the status.
|
||||
"""
|
||||
status_upper = status.upper()
|
||||
status_color = get_status_color(status_upper)
|
||||
|
||||
data = [["State:", status_upper]]
|
||||
table = Table(data, colWidths=[0.6 * inch, 0.8 * inch])
|
||||
|
||||
table.setStyle(
|
||||
TableStyle(
|
||||
[
|
||||
("BACKGROUND", (0, 0), (0, 0), COLOR_LIGHT_GRAY),
|
||||
("FONTNAME", (0, 0), (0, 0), "PlusJakartaSans"),
|
||||
("BACKGROUND", (1, 0), (1, 0), status_color),
|
||||
("TEXTCOLOR", (1, 0), (1, 0), COLOR_WHITE),
|
||||
("FONTNAME", (1, 0), (1, 0), "FiraCode"),
|
||||
("ALIGN", (0, 0), (-1, -1), "CENTER"),
|
||||
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
||||
("FONTSIZE", (0, 0), (-1, -1), 12),
|
||||
("GRID", (0, 0), (-1, -1), 0.5, colors.black),
|
||||
("LEFTPADDING", (0, 0), (-1, -1), PADDING_LARGE),
|
||||
("RIGHTPADDING", (0, 0), (-1, -1), PADDING_LARGE),
|
||||
("TOPPADDING", (0, 0), (-1, -1), PADDING_XLARGE),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), PADDING_XLARGE),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
return table
|
||||
|
||||
|
||||
def create_multi_badge_row(
|
||||
badges: list[tuple[str, colors.Color]],
|
||||
badge_width: float = 0.4 * inch,
|
||||
font: str = "FiraCode",
|
||||
) -> Table:
|
||||
"""
|
||||
Create a row of multiple small badges.
|
||||
|
||||
Args:
|
||||
badges (list[tuple[str, colors.Color]]): List of (text, color) tuples for each badge.
|
||||
badge_width (float): Width of each badge.
|
||||
font (str): Font name to use.
|
||||
|
||||
Returns:
|
||||
Table: A Table with multiple colored badges in a row.
|
||||
"""
|
||||
if not badges:
|
||||
data = [["N/A"]]
|
||||
table = Table(data, colWidths=[1 * inch])
|
||||
table.setStyle(
|
||||
TableStyle(
|
||||
[
|
||||
("BACKGROUND", (0, 0), (0, 0), COLOR_LIGHT_GRAY),
|
||||
("ALIGN", (0, 0), (-1, -1), "CENTER"),
|
||||
("FONTSIZE", (0, 0), (-1, -1), 10),
|
||||
]
|
||||
)
|
||||
)
|
||||
return table
|
||||
|
||||
data = [[text for text, _ in badges]]
|
||||
col_widths = [badge_width] * len(badges)
|
||||
table = Table(data, colWidths=col_widths)
|
||||
|
||||
styles = [
|
||||
("ALIGN", (0, 0), (-1, -1), "CENTER"),
|
||||
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
||||
("FONTNAME", (0, 0), (-1, -1), font),
|
||||
("FONTSIZE", (0, 0), (-1, -1), 10),
|
||||
("TEXTCOLOR", (0, 0), (-1, -1), COLOR_WHITE),
|
||||
("GRID", (0, 0), (-1, -1), 0.5, colors.black),
|
||||
("LEFTPADDING", (0, 0), (-1, -1), PADDING_SMALL),
|
||||
("RIGHTPADDING", (0, 0), (-1, -1), PADDING_SMALL),
|
||||
("TOPPADDING", (0, 0), (-1, -1), PADDING_MEDIUM),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), PADDING_MEDIUM),
|
||||
]
|
||||
|
||||
for idx, (_, badge_color) in enumerate(badges):
|
||||
styles.append(("BACKGROUND", (idx, 0), (idx, 0), badge_color))
|
||||
|
||||
table.setStyle(TableStyle(styles))
|
||||
return table
|
||||
|
||||
|
||||
def create_risk_component(
|
||||
risk_level: int,
|
||||
weight: int,
|
||||
score: int = 0,
|
||||
) -> Table:
|
||||
"""
|
||||
Create a visual risk component showing risk level, weight, and score.
|
||||
|
||||
Args:
|
||||
risk_level (int): The risk level (0-5).
|
||||
weight (int): The weight value.
|
||||
score (int): The calculated score (default 0).
|
||||
|
||||
Returns:
|
||||
Table: A styled Table showing risk metrics.
|
||||
"""
|
||||
risk_color = get_color_for_risk_level(risk_level)
|
||||
weight_color = get_color_for_weight(weight)
|
||||
|
||||
data = [
|
||||
[
|
||||
"Risk Level:",
|
||||
str(risk_level),
|
||||
"Weight:",
|
||||
str(weight),
|
||||
"Score:",
|
||||
str(score),
|
||||
]
|
||||
]
|
||||
|
||||
table = Table(
|
||||
data,
|
||||
colWidths=[
|
||||
0.8 * inch,
|
||||
0.4 * inch,
|
||||
0.6 * inch,
|
||||
0.4 * inch,
|
||||
0.5 * inch,
|
||||
0.4 * inch,
|
||||
],
|
||||
)
|
||||
|
||||
table.setStyle(
|
||||
TableStyle(
|
||||
[
|
||||
("BACKGROUND", (0, 0), (0, 0), COLOR_LIGHT_GRAY),
|
||||
("BACKGROUND", (1, 0), (1, 0), risk_color),
|
||||
("TEXTCOLOR", (1, 0), (1, 0), COLOR_WHITE),
|
||||
("FONTNAME", (1, 0), (1, 0), "FiraCode"),
|
||||
("BACKGROUND", (2, 0), (2, 0), COLOR_LIGHT_GRAY),
|
||||
("BACKGROUND", (3, 0), (3, 0), weight_color),
|
||||
("TEXTCOLOR", (3, 0), (3, 0), COLOR_WHITE),
|
||||
("FONTNAME", (3, 0), (3, 0), "FiraCode"),
|
||||
("BACKGROUND", (4, 0), (4, 0), COLOR_LIGHT_GRAY),
|
||||
("BACKGROUND", (5, 0), (5, 0), COLOR_DARK_GRAY),
|
||||
("TEXTCOLOR", (5, 0), (5, 0), COLOR_WHITE),
|
||||
("FONTNAME", (5, 0), (5, 0), "FiraCode"),
|
||||
("ALIGN", (0, 0), (-1, -1), "CENTER"),
|
||||
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
||||
("FONTSIZE", (0, 0), (-1, -1), 10),
|
||||
("GRID", (0, 0), (-1, -1), 0.5, colors.black),
|
||||
("LEFTPADDING", (0, 0), (-1, -1), PADDING_MEDIUM),
|
||||
("RIGHTPADDING", (0, 0), (-1, -1), PADDING_MEDIUM),
|
||||
("TOPPADDING", (0, 0), (-1, -1), PADDING_LARGE),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), PADDING_LARGE),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
return table
|
||||
|
||||
|
||||
def create_info_table(
|
||||
rows: list[tuple[str, Any]],
|
||||
label_width: float = 2 * inch,
|
||||
value_width: float = 4 * inch,
|
||||
label_color: colors.Color = COLOR_BLUE,
|
||||
value_bg_color: colors.Color | None = None,
|
||||
normal_style: ParagraphStyle | None = None,
|
||||
) -> Table:
|
||||
"""
|
||||
Create a key-value information table.
|
||||
|
||||
Args:
|
||||
rows (list[tuple[str, Any]]): List of (label, value) tuples.
|
||||
label_width (float): Width of the label column.
|
||||
value_width (float): Width of the value column.
|
||||
label_color (colors.Color): Background color for labels.
|
||||
value_bg_color (colors.Color | None): Background color for values (optional).
|
||||
normal_style (ParagraphStyle | None): ParagraphStyle for wrapping long values.
|
||||
|
||||
Returns:
|
||||
Table: A styled Table with key-value pairs.
|
||||
"""
|
||||
from .config import COLOR_BG_BLUE
|
||||
|
||||
if value_bg_color is None:
|
||||
value_bg_color = COLOR_BG_BLUE
|
||||
|
||||
# Handle empty rows case - Table requires at least one row
|
||||
if not rows:
|
||||
table = Table([["", ""]], colWidths=[label_width, value_width])
|
||||
table.setStyle(TableStyle([("FONTSIZE", (0, 0), (-1, -1), 0)]))
|
||||
return table
|
||||
|
||||
# Process rows - wrap long values in Paragraph if style provided
|
||||
table_data = []
|
||||
for label, value in rows:
|
||||
if normal_style and isinstance(value, str) and len(value) > 50:
|
||||
value = Paragraph(value, normal_style)
|
||||
table_data.append([label, value])
|
||||
|
||||
table = Table(table_data, colWidths=[label_width, value_width])
|
||||
|
||||
table.setStyle(
|
||||
TableStyle(
|
||||
[
|
||||
("BACKGROUND", (0, 0), (0, -1), label_color),
|
||||
("TEXTCOLOR", (0, 0), (0, -1), COLOR_WHITE),
|
||||
("FONTNAME", (0, 0), (0, -1), "FiraCode"),
|
||||
("BACKGROUND", (1, 0), (1, -1), value_bg_color),
|
||||
("TEXTCOLOR", (1, 0), (1, -1), COLOR_DARK_GRAY),
|
||||
("FONTNAME", (1, 0), (1, -1), "PlusJakartaSans"),
|
||||
("ALIGN", (0, 0), (-1, -1), "LEFT"),
|
||||
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
||||
("FONTSIZE", (0, 0), (-1, -1), 11),
|
||||
("GRID", (0, 0), (-1, -1), 1, COLOR_BORDER_GRAY),
|
||||
("LEFTPADDING", (0, 0), (-1, -1), PADDING_XLARGE),
|
||||
("RIGHTPADDING", (0, 0), (-1, -1), PADDING_XLARGE),
|
||||
("TOPPADDING", (0, 0), (-1, -1), PADDING_LARGE),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), PADDING_LARGE),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
return table
|
||||
|
||||
|
||||
@dataclass
|
||||
class ColumnConfig:
|
||||
"""
|
||||
Configuration for a table column.
|
||||
|
||||
Attributes:
|
||||
header (str): Column header text.
|
||||
width (float): Column width in inches.
|
||||
field (str | Callable[[Any], str]): Field name or callable to extract value from data.
|
||||
align (str): Text alignment (LEFT, CENTER, RIGHT).
|
||||
"""
|
||||
|
||||
header: str
|
||||
width: float
|
||||
field: str | Callable[[Any], str]
|
||||
align: str = "CENTER"
|
||||
|
||||
|
||||
def create_data_table(
|
||||
data: list[dict[str, Any]],
|
||||
columns: list[ColumnConfig],
|
||||
header_color: colors.Color = COLOR_BLUE,
|
||||
alternate_rows: bool = True,
|
||||
normal_style: ParagraphStyle | None = None,
|
||||
) -> Table | LongTable:
|
||||
"""
|
||||
Create a data table with configurable columns.
|
||||
|
||||
Uses LongTable for large datasets (>50 rows) for better memory efficiency
|
||||
and page splitting. LongTable repeats headers on each page and has
|
||||
optimized memory handling for large tables.
|
||||
|
||||
Args:
|
||||
data (list[dict[str, Any]]): List of data dictionaries.
|
||||
columns (list[ColumnConfig]): Column configuration list.
|
||||
header_color (colors.Color): Background color for header row.
|
||||
alternate_rows (bool): Whether to alternate row backgrounds.
|
||||
normal_style (ParagraphStyle | None): ParagraphStyle for cell values.
|
||||
|
||||
Returns:
|
||||
Table or LongTable: A styled table with data.
|
||||
"""
|
||||
# Build header row
|
||||
header_row = [col.header for col in columns]
|
||||
table_data = [header_row]
|
||||
|
||||
# Build data rows
|
||||
for item in data:
|
||||
row = []
|
||||
for col in columns:
|
||||
if callable(col.field):
|
||||
value = col.field(item)
|
||||
else:
|
||||
value = item.get(col.field, "")
|
||||
|
||||
if normal_style and isinstance(value, str):
|
||||
value = Paragraph(value, normal_style)
|
||||
row.append(value)
|
||||
table_data.append(row)
|
||||
|
||||
col_widths = [col.width for col in columns]
|
||||
|
||||
# Use LongTable for large datasets - it handles page breaks better
|
||||
# and has optimized memory handling for tables with many rows
|
||||
use_long_table = len(data) > LONG_TABLE_THRESHOLD
|
||||
if use_long_table:
|
||||
table = LongTable(table_data, colWidths=col_widths, repeatRows=1)
|
||||
else:
|
||||
table = Table(table_data, colWidths=col_widths)
|
||||
|
||||
styles = [
|
||||
("BACKGROUND", (0, 0), (-1, 0), header_color),
|
||||
("TEXTCOLOR", (0, 0), (-1, 0), COLOR_WHITE),
|
||||
("FONTNAME", (0, 0), (-1, 0), "FiraCode"),
|
||||
("FONTSIZE", (0, 0), (-1, 0), 10),
|
||||
("FONTSIZE", (0, 1), (-1, -1), 9),
|
||||
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
||||
("GRID", (0, 0), (-1, -1), 1, COLOR_GRID_GRAY),
|
||||
("LEFTPADDING", (0, 0), (-1, -1), PADDING_MEDIUM),
|
||||
("RIGHTPADDING", (0, 0), (-1, -1), PADDING_MEDIUM),
|
||||
("TOPPADDING", (0, 0), (-1, -1), PADDING_MEDIUM),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), PADDING_MEDIUM),
|
||||
]
|
||||
|
||||
# Apply column alignments
|
||||
for idx, col in enumerate(columns):
|
||||
styles.append(("ALIGN", (idx, 0), (idx, -1), col.align))
|
||||
|
||||
# Alternate row backgrounds - skip for very large tables as it adds memory overhead
|
||||
if (
|
||||
alternate_rows
|
||||
and len(table_data) > 1
|
||||
and len(table_data) <= ALTERNATE_ROWS_MAX_SIZE
|
||||
):
|
||||
for i in range(1, len(table_data)):
|
||||
if i % 2 == 0:
|
||||
styles.append(
|
||||
("BACKGROUND", (0, i), (-1, i), colors.Color(0.98, 0.98, 0.98))
|
||||
)
|
||||
|
||||
table.setStyle(TableStyle(styles))
|
||||
return table
|
||||
|
||||
|
||||
def create_findings_table(
|
||||
findings: list[Any],
|
||||
columns: list[ColumnConfig] | None = None,
|
||||
header_color: colors.Color = COLOR_BLUE,
|
||||
normal_style: ParagraphStyle | None = None,
|
||||
) -> Table:
|
||||
"""
|
||||
Create a findings table with default or custom columns.
|
||||
|
||||
Args:
|
||||
findings (list[Any]): List of finding objects.
|
||||
columns (list[ColumnConfig] | None): Optional column configuration (defaults to standard columns).
|
||||
header_color (colors.Color): Background color for header row.
|
||||
normal_style (ParagraphStyle | None): ParagraphStyle for cell values.
|
||||
|
||||
Returns:
|
||||
Table: A styled Table with findings data.
|
||||
"""
|
||||
if columns is None:
|
||||
columns = [
|
||||
ColumnConfig("Finding", 2.5 * inch, "title"),
|
||||
ColumnConfig("Resource", 3 * inch, "resource_name"),
|
||||
ColumnConfig("Severity", 0.9 * inch, "severity"),
|
||||
ColumnConfig("Status", 0.9 * inch, "status"),
|
||||
ColumnConfig("Region", 0.9 * inch, "region"),
|
||||
]
|
||||
|
||||
# Convert findings to dicts
|
||||
data = []
|
||||
for finding in findings:
|
||||
item = {}
|
||||
for col in columns:
|
||||
if callable(col.field):
|
||||
item[col.header.lower()] = col.field(finding)
|
||||
elif hasattr(finding, col.field):
|
||||
item[col.field] = getattr(finding, col.field, "")
|
||||
elif isinstance(finding, dict):
|
||||
item[col.field] = finding.get(col.field, "")
|
||||
data.append(item)
|
||||
|
||||
return create_data_table(
|
||||
data=data,
|
||||
columns=columns,
|
||||
header_color=header_color,
|
||||
alternate_rows=True,
|
||||
normal_style=normal_style,
|
||||
)
|
||||
|
||||
|
||||
def create_section_header(
|
||||
text: str,
|
||||
style: ParagraphStyle,
|
||||
add_spacer: bool = True,
|
||||
spacer_height: float = 0.2,
|
||||
) -> list:
|
||||
"""
|
||||
Create a section header with optional spacer.
|
||||
|
||||
Args:
|
||||
text (str): Header text.
|
||||
style (ParagraphStyle): ParagraphStyle to apply.
|
||||
add_spacer (bool): Whether to add a spacer after the header.
|
||||
spacer_height (float): Height of the spacer in inches.
|
||||
|
||||
Returns:
|
||||
list: List of elements (Paragraph and optional Spacer).
|
||||
"""
|
||||
elements = [Paragraph(text, style)]
|
||||
if add_spacer:
|
||||
elements.append(Spacer(1, spacer_height * inch))
|
||||
return elements
|
||||
|
||||
|
||||
def create_summary_table(
|
||||
label: str,
|
||||
value: str,
|
||||
value_color: colors.Color,
|
||||
label_width: float = 2.5 * inch,
|
||||
value_width: float = 2 * inch,
|
||||
) -> Table:
|
||||
"""
|
||||
Create a summary metric table (e.g., for ThreatScore display).
|
||||
|
||||
Args:
|
||||
label (str): Label text (e.g., "ThreatScore:").
|
||||
value (str): Value text (e.g., "85.5%").
|
||||
value_color (colors.Color): Background color for the value cell.
|
||||
label_width (float): Width of the label column.
|
||||
value_width (float): Width of the value column.
|
||||
|
||||
Returns:
|
||||
Table: A styled summary Table.
|
||||
"""
|
||||
data = [[label, value]]
|
||||
table = Table(data, colWidths=[label_width, value_width])
|
||||
|
||||
table.setStyle(
|
||||
TableStyle(
|
||||
[
|
||||
("BACKGROUND", (0, 0), (0, 0), colors.Color(0.1, 0.3, 0.5)),
|
||||
("TEXTCOLOR", (0, 0), (0, 0), COLOR_WHITE),
|
||||
("FONTNAME", (0, 0), (0, 0), "FiraCode"),
|
||||
("FONTSIZE", (0, 0), (0, 0), 12),
|
||||
("BACKGROUND", (1, 0), (1, 0), value_color),
|
||||
("TEXTCOLOR", (1, 0), (1, 0), COLOR_WHITE),
|
||||
("FONTNAME", (1, 0), (1, 0), "FiraCode"),
|
||||
("FONTSIZE", (1, 0), (1, 0), 16),
|
||||
("ALIGN", (0, 0), (-1, -1), "CENTER"),
|
||||
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
||||
("GRID", (0, 0), (-1, -1), 1.5, colors.Color(0.5, 0.6, 0.7)),
|
||||
("LEFTPADDING", (0, 0), (-1, -1), 12),
|
||||
("RIGHTPADDING", (0, 0), (-1, -1), 12),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 10),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 10),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
return table
|
||||
@@ -0,0 +1,286 @@
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from reportlab.lib import colors
|
||||
from reportlab.lib.units import inch
|
||||
|
||||
# =============================================================================
|
||||
# Performance & Memory Optimization Settings
|
||||
# =============================================================================
|
||||
# These settings control memory usage and performance for large reports.
|
||||
# Adjust these values if workers are running out of memory.
|
||||
|
||||
# Chart settings - lower DPI = less memory, 150 is good quality for PDF
|
||||
CHART_DPI_DEFAULT = 150
|
||||
|
||||
# LongTable threshold - use LongTable for tables with more rows than this
|
||||
# LongTable handles page breaks better and has optimized memory for large tables
|
||||
LONG_TABLE_THRESHOLD = 50
|
||||
|
||||
# Skip alternating row colors for tables larger than this (reduces memory)
|
||||
ALTERNATE_ROWS_MAX_SIZE = 200
|
||||
|
||||
# Database query batch size for findings (matches Django settings)
|
||||
# Larger = fewer queries but more memory per batch
|
||||
FINDINGS_BATCH_SIZE = 2000
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Base colors
|
||||
# =============================================================================
|
||||
COLOR_PROWLER_DARK_GREEN = colors.Color(0.1, 0.5, 0.2)
|
||||
COLOR_BLUE = colors.Color(0.2, 0.4, 0.6)
|
||||
COLOR_LIGHT_BLUE = colors.Color(0.3, 0.5, 0.7)
|
||||
COLOR_LIGHTER_BLUE = colors.Color(0.4, 0.6, 0.8)
|
||||
COLOR_BG_BLUE = colors.Color(0.95, 0.97, 1.0)
|
||||
COLOR_BG_LIGHT_BLUE = colors.Color(0.98, 0.99, 1.0)
|
||||
COLOR_GRAY = colors.Color(0.2, 0.2, 0.2)
|
||||
COLOR_LIGHT_GRAY = colors.Color(0.9, 0.9, 0.9)
|
||||
COLOR_BORDER_GRAY = colors.Color(0.7, 0.8, 0.9)
|
||||
COLOR_GRID_GRAY = colors.Color(0.7, 0.7, 0.7)
|
||||
COLOR_DARK_GRAY = colors.Color(0.4, 0.4, 0.4)
|
||||
COLOR_HEADER_DARK = colors.Color(0.1, 0.3, 0.5)
|
||||
COLOR_HEADER_MEDIUM = colors.Color(0.15, 0.35, 0.55)
|
||||
COLOR_WHITE = colors.white
|
||||
|
||||
# Risk and status colors
|
||||
COLOR_HIGH_RISK = colors.Color(0.8, 0.2, 0.2)
|
||||
COLOR_MEDIUM_RISK = colors.Color(0.9, 0.6, 0.2)
|
||||
COLOR_LOW_RISK = colors.Color(0.9, 0.9, 0.2)
|
||||
COLOR_SAFE = colors.Color(0.2, 0.8, 0.2)
|
||||
|
||||
# ENS specific colors
|
||||
COLOR_ENS_ALTO = colors.Color(0.8, 0.2, 0.2)
|
||||
COLOR_ENS_MEDIO = colors.Color(0.98, 0.75, 0.13)
|
||||
COLOR_ENS_BAJO = colors.Color(0.06, 0.72, 0.51)
|
||||
COLOR_ENS_OPCIONAL = colors.Color(0.42, 0.45, 0.50)
|
||||
COLOR_ENS_TIPO = colors.Color(0.2, 0.4, 0.6)
|
||||
COLOR_ENS_AUTO = colors.Color(0.30, 0.69, 0.31)
|
||||
COLOR_ENS_MANUAL = colors.Color(0.96, 0.60, 0.0)
|
||||
|
||||
# NIS2 specific colors
|
||||
COLOR_NIS2_PRIMARY = colors.Color(0.12, 0.23, 0.54)
|
||||
COLOR_NIS2_SECONDARY = colors.Color(0.23, 0.51, 0.96)
|
||||
COLOR_NIS2_BG_BLUE = colors.Color(0.96, 0.97, 0.99)
|
||||
|
||||
# Chart colors (hex strings for matplotlib)
|
||||
CHART_COLOR_GREEN_1 = "#4CAF50"
|
||||
CHART_COLOR_GREEN_2 = "#8BC34A"
|
||||
CHART_COLOR_YELLOW = "#FFEB3B"
|
||||
CHART_COLOR_ORANGE = "#FF9800"
|
||||
CHART_COLOR_RED = "#F44336"
|
||||
CHART_COLOR_BLUE = "#2196F3"
|
||||
|
||||
# ENS dimension mappings: dimension name -> (abbreviation, color)
|
||||
DIMENSION_MAPPING = {
|
||||
"trazabilidad": ("T", colors.Color(0.26, 0.52, 0.96)),
|
||||
"autenticidad": ("A", colors.Color(0.30, 0.69, 0.31)),
|
||||
"integridad": ("I", colors.Color(0.61, 0.15, 0.69)),
|
||||
"confidencialidad": ("C", colors.Color(0.96, 0.26, 0.21)),
|
||||
"disponibilidad": ("D", colors.Color(1.0, 0.60, 0.0)),
|
||||
}
|
||||
|
||||
# ENS tipo icons
|
||||
TIPO_ICONS = {
|
||||
"requisito": "\u26a0\ufe0f",
|
||||
"refuerzo": "\U0001f6e1\ufe0f",
|
||||
"recomendacion": "\U0001f4a1",
|
||||
"medida": "\U0001f4cb",
|
||||
}
|
||||
|
||||
# Dimension names for charts (Spanish)
|
||||
DIMENSION_NAMES = [
|
||||
"Trazabilidad",
|
||||
"Autenticidad",
|
||||
"Integridad",
|
||||
"Confidencialidad",
|
||||
"Disponibilidad",
|
||||
]
|
||||
|
||||
DIMENSION_KEYS = [
|
||||
"trazabilidad",
|
||||
"autenticidad",
|
||||
"integridad",
|
||||
"confidencialidad",
|
||||
"disponibilidad",
|
||||
]
|
||||
|
||||
# ENS nivel and tipo order
|
||||
ENS_NIVEL_ORDER = ["alto", "medio", "bajo", "opcional"]
|
||||
ENS_TIPO_ORDER = ["requisito", "refuerzo", "recomendacion", "medida"]
|
||||
|
||||
# ThreatScore sections
|
||||
THREATSCORE_SECTIONS = [
|
||||
"1. IAM",
|
||||
"2. Attack Surface",
|
||||
"3. Logging and Monitoring",
|
||||
"4. Encryption",
|
||||
]
|
||||
|
||||
# NIS2 sections
|
||||
NIS2_SECTIONS = [
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"4",
|
||||
"5",
|
||||
"6",
|
||||
"7",
|
||||
"9",
|
||||
"11",
|
||||
"12",
|
||||
]
|
||||
|
||||
NIS2_SECTION_TITLES = {
|
||||
"1": "1. Policy on Security",
|
||||
"2": "2. Risk Management",
|
||||
"3": "3. Incident Handling",
|
||||
"4": "4. Business Continuity",
|
||||
"5": "5. Supply Chain",
|
||||
"6": "6. Acquisition & Dev",
|
||||
"7": "7. Effectiveness",
|
||||
"9": "9. Cryptography",
|
||||
"11": "11. Access Control",
|
||||
"12": "12. Asset Management",
|
||||
}
|
||||
|
||||
# Table column widths
|
||||
COL_WIDTH_SMALL = 0.4 * inch
|
||||
COL_WIDTH_MEDIUM = 0.9 * inch
|
||||
COL_WIDTH_LARGE = 1.5 * inch
|
||||
COL_WIDTH_XLARGE = 2 * inch
|
||||
COL_WIDTH_XXLARGE = 3 * inch
|
||||
|
||||
# Common padding values
|
||||
PADDING_SMALL = 4
|
||||
PADDING_MEDIUM = 6
|
||||
PADDING_LARGE = 8
|
||||
PADDING_XLARGE = 10
|
||||
|
||||
|
||||
@dataclass
|
||||
class FrameworkConfig:
|
||||
"""
|
||||
Configuration for a compliance framework PDF report.
|
||||
|
||||
This dataclass defines all the configurable aspects of a compliance framework
|
||||
report, including visual styling, metadata fields, and feature flags.
|
||||
|
||||
Attributes:
|
||||
name (str): Internal framework identifier (e.g., "prowler_threatscore").
|
||||
display_name (str): Human-readable framework name for the report title.
|
||||
logo_filename (str | None): Optional filename of the framework logo in assets/img/.
|
||||
primary_color (colors.Color): Main color used for headers and important elements.
|
||||
secondary_color (colors.Color): Secondary color for sub-headers and accents.
|
||||
bg_color (colors.Color): Background color for highlighted sections.
|
||||
attribute_fields (list[str]): List of metadata field names to extract from requirements.
|
||||
sections (list[str] | None): Optional ordered list of section names for grouping.
|
||||
language (str): Report language ("en" for English, "es" for Spanish).
|
||||
has_risk_levels (bool): Whether the framework uses numeric risk levels.
|
||||
has_dimensions (bool): Whether the framework uses security dimensions (ENS).
|
||||
has_niveles (bool): Whether the framework uses nivel classification (ENS).
|
||||
has_weight (bool): Whether requirements have weight values.
|
||||
"""
|
||||
|
||||
name: str
|
||||
display_name: str
|
||||
logo_filename: str | None = None
|
||||
primary_color: colors.Color = field(default_factory=lambda: COLOR_BLUE)
|
||||
secondary_color: colors.Color = field(default_factory=lambda: COLOR_LIGHT_BLUE)
|
||||
bg_color: colors.Color = field(default_factory=lambda: COLOR_BG_BLUE)
|
||||
attribute_fields: list[str] = field(default_factory=list)
|
||||
sections: list[str] | None = None
|
||||
language: str = "en"
|
||||
has_risk_levels: bool = False
|
||||
has_dimensions: bool = False
|
||||
has_niveles: bool = False
|
||||
has_weight: bool = False
|
||||
|
||||
|
||||
FRAMEWORK_REGISTRY: dict[str, FrameworkConfig] = {
|
||||
"prowler_threatscore": FrameworkConfig(
|
||||
name="prowler_threatscore",
|
||||
display_name="Prowler ThreatScore",
|
||||
logo_filename=None,
|
||||
primary_color=COLOR_BLUE,
|
||||
secondary_color=COLOR_LIGHT_BLUE,
|
||||
bg_color=COLOR_BG_BLUE,
|
||||
attribute_fields=[
|
||||
"Title",
|
||||
"Section",
|
||||
"SubSection",
|
||||
"LevelOfRisk",
|
||||
"Weight",
|
||||
"AttributeDescription",
|
||||
"AdditionalInformation",
|
||||
],
|
||||
sections=THREATSCORE_SECTIONS,
|
||||
language="en",
|
||||
has_risk_levels=True,
|
||||
has_weight=True,
|
||||
),
|
||||
"ens": FrameworkConfig(
|
||||
name="ens",
|
||||
display_name="ENS RD2022",
|
||||
logo_filename="ens_logo.png",
|
||||
primary_color=COLOR_ENS_ALTO,
|
||||
secondary_color=COLOR_ENS_MEDIO,
|
||||
bg_color=COLOR_BG_BLUE,
|
||||
attribute_fields=[
|
||||
"IdGrupoControl",
|
||||
"Marco",
|
||||
"Categoria",
|
||||
"DescripcionControl",
|
||||
"Tipo",
|
||||
"Nivel",
|
||||
"Dimensiones",
|
||||
"ModoEjecucion",
|
||||
],
|
||||
sections=None,
|
||||
language="es",
|
||||
has_risk_levels=False,
|
||||
has_dimensions=True,
|
||||
has_niveles=True,
|
||||
has_weight=False,
|
||||
),
|
||||
"nis2": FrameworkConfig(
|
||||
name="nis2",
|
||||
display_name="NIS2 Directive",
|
||||
logo_filename="nis2_logo.png",
|
||||
primary_color=COLOR_NIS2_PRIMARY,
|
||||
secondary_color=COLOR_NIS2_SECONDARY,
|
||||
bg_color=COLOR_NIS2_BG_BLUE,
|
||||
attribute_fields=[
|
||||
"Section",
|
||||
"SubSection",
|
||||
"Description",
|
||||
],
|
||||
sections=NIS2_SECTIONS,
|
||||
language="en",
|
||||
has_risk_levels=False,
|
||||
has_dimensions=False,
|
||||
has_niveles=False,
|
||||
has_weight=False,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def get_framework_config(compliance_id: str) -> FrameworkConfig | None:
|
||||
"""
|
||||
Get framework configuration based on compliance ID.
|
||||
|
||||
Args:
|
||||
compliance_id (str): The compliance framework identifier (e.g., "prowler_threatscore_aws").
|
||||
|
||||
Returns:
|
||||
FrameworkConfig | None: The framework configuration if found, None otherwise.
|
||||
"""
|
||||
compliance_lower = compliance_id.lower()
|
||||
|
||||
if "threatscore" in compliance_lower:
|
||||
return FRAMEWORK_REGISTRY["prowler_threatscore"]
|
||||
if "ens" in compliance_lower:
|
||||
return FRAMEWORK_REGISTRY["ens"]
|
||||
if "nis2" in compliance_lower:
|
||||
return FRAMEWORK_REGISTRY["nis2"]
|
||||
|
||||
return None
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,471 @@
|
||||
import os
|
||||
from collections import defaultdict
|
||||
|
||||
from reportlab.lib.units import inch
|
||||
from reportlab.platypus import Image, PageBreak, Paragraph, Spacer, Table, TableStyle
|
||||
|
||||
from api.models import StatusChoices
|
||||
|
||||
from .base import (
|
||||
BaseComplianceReportGenerator,
|
||||
ComplianceData,
|
||||
get_requirement_metadata,
|
||||
)
|
||||
from .charts import create_horizontal_bar_chart, get_chart_color_for_percentage
|
||||
from .config import (
|
||||
COLOR_BORDER_GRAY,
|
||||
COLOR_DARK_GRAY,
|
||||
COLOR_GRAY,
|
||||
COLOR_GRID_GRAY,
|
||||
COLOR_HIGH_RISK,
|
||||
COLOR_NIS2_BG_BLUE,
|
||||
COLOR_NIS2_PRIMARY,
|
||||
COLOR_SAFE,
|
||||
COLOR_WHITE,
|
||||
NIS2_SECTION_TITLES,
|
||||
NIS2_SECTIONS,
|
||||
)
|
||||
|
||||
|
||||
def _extract_section_number(section_string: str) -> str:
|
||||
"""Extract the section number from a full NIS2 section title.
|
||||
|
||||
NIS2 section strings are formatted like:
|
||||
"1 POLICY ON THE SECURITY OF NETWORK AND INFORMATION SYSTEMS..."
|
||||
|
||||
This function extracts just the leading number.
|
||||
|
||||
Args:
|
||||
section_string: Full section title string.
|
||||
|
||||
Returns:
|
||||
Section number as string (e.g., "1", "2", "11").
|
||||
"""
|
||||
if not section_string:
|
||||
return "Other"
|
||||
parts = section_string.split()
|
||||
if parts and parts[0].isdigit():
|
||||
return parts[0]
|
||||
return "Other"
|
||||
|
||||
|
||||
class NIS2ReportGenerator(BaseComplianceReportGenerator):
|
||||
"""
|
||||
PDF report generator for NIS2 Directive (EU) 2022/2555.
|
||||
|
||||
This generator creates comprehensive PDF reports containing:
|
||||
- Cover page with both Prowler and NIS2 logos
|
||||
- Executive summary with overall compliance score
|
||||
- Section analysis with horizontal bar chart
|
||||
- SubSection breakdown table
|
||||
- Critical failed requirements
|
||||
- Requirements index organized by section and subsection
|
||||
- Detailed findings for failed requirements
|
||||
"""
|
||||
|
||||
def create_cover_page(self, data: ComplianceData) -> list:
|
||||
"""
|
||||
Create the NIS2 report cover page with both logos.
|
||||
|
||||
Args:
|
||||
data: Aggregated compliance data.
|
||||
|
||||
Returns:
|
||||
List of ReportLab elements.
|
||||
"""
|
||||
elements = []
|
||||
|
||||
# Create logos side by side
|
||||
prowler_logo_path = os.path.join(
|
||||
os.path.dirname(__file__), "../../assets/img/prowler_logo.png"
|
||||
)
|
||||
nis2_logo_path = os.path.join(
|
||||
os.path.dirname(__file__), "../../assets/img/nis2_logo.png"
|
||||
)
|
||||
|
||||
prowler_logo = Image(prowler_logo_path, width=3.5 * inch, height=0.7 * inch)
|
||||
nis2_logo = Image(nis2_logo_path, width=2.3 * inch, height=1.5 * inch)
|
||||
|
||||
logos_table = Table(
|
||||
[[prowler_logo, nis2_logo]], colWidths=[4 * inch, 2.5 * inch]
|
||||
)
|
||||
logos_table.setStyle(
|
||||
TableStyle(
|
||||
[
|
||||
("ALIGN", (0, 0), (0, 0), "LEFT"),
|
||||
("ALIGN", (1, 0), (1, 0), "RIGHT"),
|
||||
("VALIGN", (0, 0), (0, 0), "MIDDLE"),
|
||||
("VALIGN", (1, 0), (1, 0), "MIDDLE"),
|
||||
]
|
||||
)
|
||||
)
|
||||
elements.append(logos_table)
|
||||
elements.append(Spacer(1, 0.3 * inch))
|
||||
|
||||
# Title
|
||||
title = Paragraph(
|
||||
"NIS2 Compliance Report<br/>Directive (EU) 2022/2555",
|
||||
self.styles["title"],
|
||||
)
|
||||
elements.append(title)
|
||||
elements.append(Spacer(1, 0.3 * inch))
|
||||
|
||||
# Compliance metadata table - use base class helper for consistency
|
||||
info_rows = self._build_info_rows(data, language="en")
|
||||
# Convert tuples to lists and wrap long text in Paragraphs
|
||||
metadata_data = []
|
||||
for label, value in info_rows:
|
||||
if label in ("Name:", "Description:") and value:
|
||||
metadata_data.append(
|
||||
[label, Paragraph(value, self.styles["normal_center"])]
|
||||
)
|
||||
else:
|
||||
metadata_data.append([label, value])
|
||||
|
||||
metadata_table = Table(metadata_data, colWidths=[2 * inch, 4 * inch])
|
||||
metadata_table.setStyle(
|
||||
TableStyle(
|
||||
[
|
||||
("BACKGROUND", (0, 0), (0, -1), COLOR_NIS2_PRIMARY),
|
||||
("TEXTCOLOR", (0, 0), (0, -1), COLOR_WHITE),
|
||||
("FONTNAME", (0, 0), (0, -1), "FiraCode"),
|
||||
("BACKGROUND", (1, 0), (1, -1), COLOR_NIS2_BG_BLUE),
|
||||
("TEXTCOLOR", (1, 0), (1, -1), COLOR_GRAY),
|
||||
("FONTNAME", (1, 0), (1, -1), "PlusJakartaSans"),
|
||||
("ALIGN", (0, 0), (-1, -1), "LEFT"),
|
||||
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
||||
("FONTSIZE", (0, 0), (-1, -1), 11),
|
||||
("GRID", (0, 0), (-1, -1), 1, COLOR_BORDER_GRAY),
|
||||
("LEFTPADDING", (0, 0), (-1, -1), 10),
|
||||
("RIGHTPADDING", (0, 0), (-1, -1), 10),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 8),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 8),
|
||||
]
|
||||
)
|
||||
)
|
||||
elements.append(metadata_table)
|
||||
|
||||
return elements
|
||||
|
||||
def create_executive_summary(self, data: ComplianceData) -> list:
|
||||
"""
|
||||
Create the executive summary with compliance metrics.
|
||||
|
||||
Args:
|
||||
data: Aggregated compliance data.
|
||||
|
||||
Returns:
|
||||
List of ReportLab elements.
|
||||
"""
|
||||
elements = []
|
||||
|
||||
elements.append(Paragraph("Executive Summary", self.styles["h1"]))
|
||||
elements.append(Spacer(1, 0.1 * inch))
|
||||
|
||||
# Calculate statistics
|
||||
total = len(data.requirements)
|
||||
passed = sum(1 for r in data.requirements if r.status == StatusChoices.PASS)
|
||||
failed = sum(1 for r in data.requirements if r.status == StatusChoices.FAIL)
|
||||
manual = sum(1 for r in data.requirements if r.status == StatusChoices.MANUAL)
|
||||
|
||||
# Calculate compliance excluding manual
|
||||
evaluated = passed + failed
|
||||
overall_compliance = (passed / evaluated * 100) if evaluated > 0 else 100
|
||||
|
||||
# Summary statistics table
|
||||
summary_data = [
|
||||
["Metric", "Value"],
|
||||
["Total Requirements", str(total)],
|
||||
["Passed ✓", str(passed)],
|
||||
["Failed ✗", str(failed)],
|
||||
["Manual ⊙", str(manual)],
|
||||
["Overall Compliance", f"{overall_compliance:.1f}%"],
|
||||
]
|
||||
|
||||
summary_table = Table(summary_data, colWidths=[3 * inch, 2 * inch])
|
||||
summary_table.setStyle(
|
||||
TableStyle(
|
||||
[
|
||||
("BACKGROUND", (0, 0), (-1, 0), COLOR_NIS2_PRIMARY),
|
||||
("TEXTCOLOR", (0, 0), (-1, 0), COLOR_WHITE),
|
||||
("BACKGROUND", (0, 2), (0, 2), COLOR_SAFE),
|
||||
("TEXTCOLOR", (0, 2), (0, 2), COLOR_WHITE),
|
||||
("BACKGROUND", (0, 3), (0, 3), COLOR_HIGH_RISK),
|
||||
("TEXTCOLOR", (0, 3), (0, 3), COLOR_WHITE),
|
||||
("BACKGROUND", (0, 4), (0, 4), COLOR_DARK_GRAY),
|
||||
("TEXTCOLOR", (0, 4), (0, 4), COLOR_WHITE),
|
||||
("ALIGN", (0, 0), (-1, -1), "CENTER"),
|
||||
("FONTNAME", (0, 0), (-1, 0), "PlusJakartaSans"),
|
||||
("FONTSIZE", (0, 0), (-1, 0), 12),
|
||||
("FONTSIZE", (0, 1), (-1, -1), 10),
|
||||
("BOTTOMPADDING", (0, 0), (-1, 0), 10),
|
||||
("GRID", (0, 0), (-1, -1), 0.5, COLOR_BORDER_GRAY),
|
||||
(
|
||||
"ROWBACKGROUNDS",
|
||||
(1, 1),
|
||||
(1, -1),
|
||||
[COLOR_WHITE, COLOR_NIS2_BG_BLUE],
|
||||
),
|
||||
]
|
||||
)
|
||||
)
|
||||
elements.append(summary_table)
|
||||
|
||||
return elements
|
||||
|
||||
def create_charts_section(self, data: ComplianceData) -> list:
|
||||
"""
|
||||
Create the charts section with section analysis.
|
||||
|
||||
Args:
|
||||
data: Aggregated compliance data.
|
||||
|
||||
Returns:
|
||||
List of ReportLab elements.
|
||||
"""
|
||||
elements = []
|
||||
|
||||
# Section chart
|
||||
elements.append(Paragraph("Compliance by Section", self.styles["h1"]))
|
||||
elements.append(Spacer(1, 0.1 * inch))
|
||||
elements.append(
|
||||
Paragraph(
|
||||
"The following chart shows compliance percentage for each main section "
|
||||
"of the NIS2 directive:",
|
||||
self.styles["normal_center"],
|
||||
)
|
||||
)
|
||||
elements.append(Spacer(1, 0.1 * inch))
|
||||
|
||||
chart_buffer = self._create_section_chart(data)
|
||||
chart_buffer.seek(0)
|
||||
chart_image = Image(chart_buffer, width=6.5 * inch, height=5 * inch)
|
||||
elements.append(chart_image)
|
||||
elements.append(PageBreak())
|
||||
|
||||
# SubSection breakdown table
|
||||
elements.append(Paragraph("SubSection Breakdown", self.styles["h1"]))
|
||||
elements.append(Spacer(1, 0.1 * inch))
|
||||
|
||||
subsection_table = self._create_subsection_table(data)
|
||||
elements.append(subsection_table)
|
||||
|
||||
return elements
|
||||
|
||||
def create_requirements_index(self, data: ComplianceData) -> list:
|
||||
"""
|
||||
Create the requirements index organized by section and subsection.
|
||||
|
||||
Args:
|
||||
data: Aggregated compliance data.
|
||||
|
||||
Returns:
|
||||
List of ReportLab elements.
|
||||
"""
|
||||
elements = []
|
||||
|
||||
elements.append(Paragraph("Requirements Index", self.styles["h1"]))
|
||||
elements.append(Spacer(1, 0.1 * inch))
|
||||
|
||||
# Organize by section number and subsection
|
||||
sections = {}
|
||||
for req in data.requirements:
|
||||
m = get_requirement_metadata(req.id, data.attributes_by_requirement_id)
|
||||
if m:
|
||||
full_section = getattr(m, "Section", "Other")
|
||||
# Extract section number from full title (e.g., "1 POLICY..." -> "1")
|
||||
section_num = _extract_section_number(full_section)
|
||||
subsection = getattr(m, "SubSection", "")
|
||||
description = getattr(m, "Description", req.description)
|
||||
|
||||
if section_num not in sections:
|
||||
sections[section_num] = {}
|
||||
if subsection not in sections[section_num]:
|
||||
sections[section_num][subsection] = []
|
||||
|
||||
sections[section_num][subsection].append(
|
||||
{
|
||||
"id": req.id,
|
||||
"description": description,
|
||||
"status": req.status,
|
||||
}
|
||||
)
|
||||
|
||||
# Sort by NIS2 section order
|
||||
for section in NIS2_SECTIONS:
|
||||
if section not in sections:
|
||||
continue
|
||||
|
||||
section_title = NIS2_SECTION_TITLES.get(section, f"Section {section}")
|
||||
elements.append(Paragraph(section_title, self.styles["h2"]))
|
||||
|
||||
for subsection_name, reqs in sections[section].items():
|
||||
if subsection_name:
|
||||
# Truncate long subsection names for display
|
||||
display_subsection = (
|
||||
subsection_name[:80] + "..."
|
||||
if len(subsection_name) > 80
|
||||
else subsection_name
|
||||
)
|
||||
elements.append(Paragraph(display_subsection, self.styles["h3"]))
|
||||
|
||||
for req in reqs:
|
||||
status_indicator = (
|
||||
"✓" if req["status"] == StatusChoices.PASS else "✗"
|
||||
)
|
||||
if req["status"] == StatusChoices.MANUAL:
|
||||
status_indicator = "⊙"
|
||||
|
||||
desc = (
|
||||
req["description"][:60] + "..."
|
||||
if len(req["description"]) > 60
|
||||
else req["description"]
|
||||
)
|
||||
elements.append(
|
||||
Paragraph(
|
||||
f"{status_indicator} {req['id']}: {desc}",
|
||||
self.styles["normal"],
|
||||
)
|
||||
)
|
||||
|
||||
elements.append(Spacer(1, 0.1 * inch))
|
||||
|
||||
return elements
|
||||
|
||||
def _create_section_chart(self, data: ComplianceData):
|
||||
"""
|
||||
Create the section compliance chart.
|
||||
|
||||
Args:
|
||||
data: Aggregated compliance data.
|
||||
|
||||
Returns:
|
||||
BytesIO buffer containing the chart image.
|
||||
"""
|
||||
section_scores = defaultdict(lambda: {"passed": 0, "total": 0})
|
||||
|
||||
for req in data.requirements:
|
||||
if req.status == StatusChoices.MANUAL:
|
||||
continue
|
||||
|
||||
m = get_requirement_metadata(req.id, data.attributes_by_requirement_id)
|
||||
if m:
|
||||
full_section = getattr(m, "Section", "Other")
|
||||
# Extract section number from full title (e.g., "1 POLICY..." -> "1")
|
||||
section_num = _extract_section_number(full_section)
|
||||
section_scores[section_num]["total"] += 1
|
||||
if req.status == StatusChoices.PASS:
|
||||
section_scores[section_num]["passed"] += 1
|
||||
|
||||
# Build labels and values in NIS2 section order
|
||||
labels = []
|
||||
values = []
|
||||
for section in NIS2_SECTIONS:
|
||||
if section in section_scores and section_scores[section]["total"] > 0:
|
||||
scores = section_scores[section]
|
||||
pct = (scores["passed"] / scores["total"]) * 100
|
||||
section_title = NIS2_SECTION_TITLES.get(section, f"Section {section}")
|
||||
labels.append(section_title)
|
||||
values.append(pct)
|
||||
|
||||
return create_horizontal_bar_chart(
|
||||
labels=labels,
|
||||
values=values,
|
||||
xlabel="Compliance (%)",
|
||||
color_func=get_chart_color_for_percentage,
|
||||
)
|
||||
|
||||
def _create_subsection_table(self, data: ComplianceData) -> Table:
|
||||
"""
|
||||
Create the subsection breakdown table.
|
||||
|
||||
Args:
|
||||
data: Aggregated compliance data.
|
||||
|
||||
Returns:
|
||||
ReportLab Table element.
|
||||
"""
|
||||
subsection_scores = defaultdict(lambda: {"passed": 0, "failed": 0, "manual": 0})
|
||||
|
||||
for req in data.requirements:
|
||||
m = get_requirement_metadata(req.id, data.attributes_by_requirement_id)
|
||||
if m:
|
||||
full_section = getattr(m, "Section", "")
|
||||
subsection = getattr(m, "SubSection", "")
|
||||
# Use section number + subsection for grouping
|
||||
section_num = _extract_section_number(full_section)
|
||||
# Create a shorter key using section number
|
||||
if subsection:
|
||||
# Extract subsection number if present (e.g., "1.1 Policy..." -> "1.1")
|
||||
subsection_parts = subsection.split()
|
||||
if subsection_parts:
|
||||
key = subsection_parts[0] # Just the number like "1.1"
|
||||
else:
|
||||
key = f"{section_num}"
|
||||
else:
|
||||
key = section_num
|
||||
|
||||
if req.status == StatusChoices.PASS:
|
||||
subsection_scores[key]["passed"] += 1
|
||||
elif req.status == StatusChoices.FAIL:
|
||||
subsection_scores[key]["failed"] += 1
|
||||
else:
|
||||
subsection_scores[key]["manual"] += 1
|
||||
|
||||
table_data = [["Section", "Passed", "Failed", "Manual", "Compliance"]]
|
||||
for key, scores in sorted(
|
||||
subsection_scores.items(), key=lambda x: self._sort_section_key(x[0])
|
||||
):
|
||||
total = scores["passed"] + scores["failed"]
|
||||
pct = (scores["passed"] / total * 100) if total > 0 else 100
|
||||
table_data.append(
|
||||
[
|
||||
key,
|
||||
str(scores["passed"]),
|
||||
str(scores["failed"]),
|
||||
str(scores["manual"]),
|
||||
f"{pct:.1f}%",
|
||||
]
|
||||
)
|
||||
|
||||
table = Table(
|
||||
table_data,
|
||||
colWidths=[1.2 * inch, 0.9 * inch, 0.9 * inch, 0.9 * inch, 1.2 * inch],
|
||||
)
|
||||
table.setStyle(
|
||||
TableStyle(
|
||||
[
|
||||
("BACKGROUND", (0, 0), (-1, 0), COLOR_NIS2_PRIMARY),
|
||||
("TEXTCOLOR", (0, 0), (-1, 0), COLOR_WHITE),
|
||||
("FONTNAME", (0, 0), (-1, 0), "FiraCode"),
|
||||
("FONTSIZE", (0, 0), (-1, 0), 10),
|
||||
("ALIGN", (0, 0), (-1, -1), "CENTER"),
|
||||
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
||||
("FONTSIZE", (0, 1), (-1, -1), 9),
|
||||
("GRID", (0, 0), (-1, -1), 0.5, COLOR_GRID_GRAY),
|
||||
("LEFTPADDING", (0, 0), (-1, -1), 6),
|
||||
("RIGHTPADDING", (0, 0), (-1, -1), 6),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 4),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
|
||||
(
|
||||
"ROWBACKGROUNDS",
|
||||
(0, 1),
|
||||
(-1, -1),
|
||||
[COLOR_WHITE, COLOR_NIS2_BG_BLUE],
|
||||
),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
return table
|
||||
|
||||
def _sort_section_key(self, key: str) -> tuple:
|
||||
"""Sort section keys numerically (e.g., 1, 1.1, 1.2, 2, 11)."""
|
||||
parts = key.split(".")
|
||||
result = []
|
||||
for part in parts:
|
||||
try:
|
||||
result.append(int(part))
|
||||
except ValueError:
|
||||
result.append(float("inf"))
|
||||
return tuple(result)
|
||||
@@ -0,0 +1,509 @@
|
||||
import gc
|
||||
|
||||
from reportlab.lib import colors
|
||||
from reportlab.lib.styles import ParagraphStyle
|
||||
from reportlab.lib.units import inch
|
||||
from reportlab.platypus import Image, PageBreak, Paragraph, Spacer, Table, TableStyle
|
||||
|
||||
from api.models import StatusChoices
|
||||
|
||||
from .base import (
|
||||
BaseComplianceReportGenerator,
|
||||
ComplianceData,
|
||||
get_requirement_metadata,
|
||||
)
|
||||
from .charts import create_vertical_bar_chart, get_chart_color_for_percentage
|
||||
from .components import get_color_for_compliance, get_color_for_weight
|
||||
from .config import COLOR_HIGH_RISK, COLOR_WHITE
|
||||
|
||||
|
||||
class ThreatScoreReportGenerator(BaseComplianceReportGenerator):
|
||||
"""
|
||||
PDF report generator for Prowler ThreatScore framework.
|
||||
|
||||
This generator creates comprehensive PDF reports containing:
|
||||
- Compliance overview and metadata
|
||||
- Section-by-section compliance scores with charts
|
||||
- Overall ThreatScore calculation
|
||||
- Critical failed requirements
|
||||
- Detailed findings for each requirement
|
||||
"""
|
||||
|
||||
def create_executive_summary(self, data: ComplianceData) -> list:
|
||||
"""
|
||||
Create the executive summary section with ThreatScore calculation.
|
||||
|
||||
Args:
|
||||
data: Aggregated compliance data.
|
||||
|
||||
Returns:
|
||||
List of ReportLab elements.
|
||||
"""
|
||||
elements = []
|
||||
|
||||
elements.append(Paragraph("Compliance Score by Sections", self.styles["h1"]))
|
||||
elements.append(Spacer(1, 0.2 * inch))
|
||||
|
||||
# Create section score chart
|
||||
chart_buffer = self._create_section_score_chart(data)
|
||||
chart_image = Image(chart_buffer, width=7 * inch, height=5.5 * inch)
|
||||
elements.append(chart_image)
|
||||
|
||||
# Calculate overall ThreatScore
|
||||
overall_compliance = self._calculate_threatscore(data)
|
||||
|
||||
elements.append(Spacer(1, 0.3 * inch))
|
||||
|
||||
# Summary table
|
||||
summary_data = [["ThreatScore:", f"{overall_compliance:.2f}%"]]
|
||||
compliance_color = get_color_for_compliance(overall_compliance)
|
||||
|
||||
summary_table = Table(summary_data, colWidths=[2.5 * inch, 2 * inch])
|
||||
summary_table.setStyle(
|
||||
TableStyle(
|
||||
[
|
||||
("BACKGROUND", (0, 0), (0, 0), colors.Color(0.1, 0.3, 0.5)),
|
||||
("TEXTCOLOR", (0, 0), (0, 0), colors.white),
|
||||
("FONTNAME", (0, 0), (0, 0), "FiraCode"),
|
||||
("FONTSIZE", (0, 0), (0, 0), 12),
|
||||
("BACKGROUND", (1, 0), (1, 0), compliance_color),
|
||||
("TEXTCOLOR", (1, 0), (1, 0), colors.white),
|
||||
("FONTNAME", (1, 0), (1, 0), "FiraCode"),
|
||||
("FONTSIZE", (1, 0), (1, 0), 16),
|
||||
("ALIGN", (0, 0), (-1, -1), "CENTER"),
|
||||
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
||||
("GRID", (0, 0), (-1, -1), 1.5, colors.Color(0.5, 0.6, 0.7)),
|
||||
("LEFTPADDING", (0, 0), (-1, -1), 12),
|
||||
("RIGHTPADDING", (0, 0), (-1, -1), 12),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 10),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 10),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
elements.append(summary_table)
|
||||
|
||||
return elements
|
||||
|
||||
def _build_body_sections(self, data: ComplianceData) -> list:
|
||||
"""Override section order: Requirements Index before Critical Requirements."""
|
||||
elements = []
|
||||
|
||||
# Page break to separate from executive summary
|
||||
elements.append(PageBreak())
|
||||
|
||||
# Requirements index first
|
||||
elements.extend(self.create_requirements_index(data))
|
||||
|
||||
# Critical requirements section (already starts with PageBreak internally)
|
||||
elements.extend(self.create_charts_section(data))
|
||||
elements.append(PageBreak())
|
||||
gc.collect()
|
||||
|
||||
return elements
|
||||
|
||||
def create_charts_section(self, data: ComplianceData) -> list:
|
||||
"""
|
||||
Create the critical failed requirements section.
|
||||
|
||||
Args:
|
||||
data: Aggregated compliance data.
|
||||
|
||||
Returns:
|
||||
List of ReportLab elements.
|
||||
"""
|
||||
elements = []
|
||||
min_risk_level = getattr(self, "_min_risk_level", 4)
|
||||
|
||||
# Start on a new page
|
||||
elements.append(PageBreak())
|
||||
elements.append(
|
||||
Paragraph("Top Requirements by Level of Risk", self.styles["h1"])
|
||||
)
|
||||
elements.append(Spacer(1, 0.1 * inch))
|
||||
elements.append(
|
||||
Paragraph(
|
||||
f"Critical Failed Requirements (Risk Level ≥ {min_risk_level})",
|
||||
self.styles["h2"],
|
||||
)
|
||||
)
|
||||
elements.append(Spacer(1, 0.2 * inch))
|
||||
|
||||
critical_failed = self._get_critical_failed_requirements(data, min_risk_level)
|
||||
|
||||
if not critical_failed:
|
||||
elements.append(
|
||||
Paragraph(
|
||||
"✅ No critical failed requirements found. Great job!",
|
||||
self.styles["normal"],
|
||||
)
|
||||
)
|
||||
else:
|
||||
elements.append(
|
||||
Paragraph(
|
||||
f"Found {len(critical_failed)} critical failed requirements "
|
||||
"that require immediate attention:",
|
||||
self.styles["normal"],
|
||||
)
|
||||
)
|
||||
elements.append(Spacer(1, 0.5 * inch))
|
||||
|
||||
table = self._create_critical_requirements_table(critical_failed)
|
||||
elements.append(table)
|
||||
|
||||
# Immediate action required banner
|
||||
elements.append(Spacer(1, 0.3 * inch))
|
||||
elements.append(self._create_action_required_banner())
|
||||
|
||||
return elements
|
||||
|
||||
def create_requirements_index(self, data: ComplianceData) -> list:
|
||||
"""
|
||||
Create the requirements index organized by section and subsection.
|
||||
|
||||
Args:
|
||||
data: Aggregated compliance data.
|
||||
|
||||
Returns:
|
||||
List of ReportLab elements.
|
||||
"""
|
||||
elements = []
|
||||
|
||||
elements.append(Paragraph("Requirements Index", self.styles["h1"]))
|
||||
|
||||
# Organize requirements by section and subsection
|
||||
sections = {}
|
||||
for req_id in data.attributes_by_requirement_id:
|
||||
m = get_requirement_metadata(req_id, data.attributes_by_requirement_id)
|
||||
if m:
|
||||
section = getattr(m, "Section", "N/A")
|
||||
subsection = getattr(m, "SubSection", "N/A")
|
||||
title = getattr(m, "Title", "N/A")
|
||||
|
||||
if section not in sections:
|
||||
sections[section] = {}
|
||||
if subsection not in sections[section]:
|
||||
sections[section][subsection] = []
|
||||
|
||||
sections[section][subsection].append({"id": req_id, "title": title})
|
||||
|
||||
section_num = 1
|
||||
for section_name, subsections in sections.items():
|
||||
elements.append(
|
||||
Paragraph(f"{section_num}. {section_name}", self.styles["h2"])
|
||||
)
|
||||
|
||||
for subsection_name, requirements in subsections.items():
|
||||
elements.append(Paragraph(f"{subsection_name}", self.styles["h3"]))
|
||||
|
||||
for req in requirements:
|
||||
elements.append(
|
||||
Paragraph(
|
||||
f"{req['id']} - {req['title']}", self.styles["normal"]
|
||||
)
|
||||
)
|
||||
|
||||
section_num += 1
|
||||
elements.append(Spacer(1, 0.1 * inch))
|
||||
|
||||
return elements
|
||||
|
||||
def _create_section_score_chart(self, data: ComplianceData):
|
||||
"""
|
||||
Create the section compliance score chart using weighted ThreatScore formula.
|
||||
|
||||
The section score uses the same weighted formula as the overall ThreatScore:
|
||||
Score = Σ(rate_i * total_findings_i * weight_i * rfac_i) / Σ(total_findings_i * weight_i * rfac_i)
|
||||
Where rfac_i = 1 + 0.25 * risk_level
|
||||
|
||||
Sections without findings are shown with 100% score.
|
||||
|
||||
Args:
|
||||
data: Aggregated compliance data.
|
||||
|
||||
Returns:
|
||||
BytesIO buffer containing the chart image.
|
||||
"""
|
||||
# First, collect ALL sections from requirements (including those without findings)
|
||||
all_sections = set()
|
||||
sections_data = {}
|
||||
|
||||
for req in data.requirements:
|
||||
m = get_requirement_metadata(req.id, data.attributes_by_requirement_id)
|
||||
if m:
|
||||
section = getattr(m, "Section", "Other")
|
||||
all_sections.add(section)
|
||||
|
||||
# Only calculate scores for requirements with findings
|
||||
if req.total_findings == 0:
|
||||
continue
|
||||
|
||||
risk_level_raw = getattr(m, "LevelOfRisk", 0)
|
||||
weight_raw = getattr(m, "Weight", 0)
|
||||
# Ensure numeric types for calculations (compliance data may have str)
|
||||
try:
|
||||
risk_level = int(risk_level_raw) if risk_level_raw else 0
|
||||
except (ValueError, TypeError):
|
||||
risk_level = 0
|
||||
try:
|
||||
weight = int(weight_raw) if weight_raw else 0
|
||||
except (ValueError, TypeError):
|
||||
weight = 0
|
||||
|
||||
# ThreatScore formula components
|
||||
rate_i = req.passed_findings / req.total_findings
|
||||
rfac_i = 1 + 0.25 * risk_level
|
||||
|
||||
if section not in sections_data:
|
||||
sections_data[section] = {
|
||||
"numerator": 0,
|
||||
"denominator": 0,
|
||||
}
|
||||
|
||||
sections_data[section]["numerator"] += (
|
||||
rate_i * req.total_findings * weight * rfac_i
|
||||
)
|
||||
sections_data[section]["denominator"] += (
|
||||
req.total_findings * weight * rfac_i
|
||||
)
|
||||
|
||||
# Calculate percentages for all sections
|
||||
labels = []
|
||||
values = []
|
||||
for section in sorted(all_sections):
|
||||
if section in sections_data and sections_data[section]["denominator"] > 0:
|
||||
pct = (
|
||||
sections_data[section]["numerator"]
|
||||
/ sections_data[section]["denominator"]
|
||||
) * 100
|
||||
else:
|
||||
# Sections without findings get 100%
|
||||
pct = 100.0
|
||||
labels.append(section)
|
||||
values.append(pct)
|
||||
|
||||
return create_vertical_bar_chart(
|
||||
labels=labels,
|
||||
values=values,
|
||||
ylabel="Compliance Score (%)",
|
||||
xlabel="",
|
||||
color_func=get_chart_color_for_percentage,
|
||||
rotation=0,
|
||||
)
|
||||
|
||||
def _calculate_threatscore(self, data: ComplianceData) -> float:
|
||||
"""
|
||||
Calculate the overall ThreatScore using the weighted formula.
|
||||
|
||||
Args:
|
||||
data: Aggregated compliance data.
|
||||
|
||||
Returns:
|
||||
Overall ThreatScore percentage.
|
||||
"""
|
||||
numerator = 0
|
||||
denominator = 0
|
||||
has_findings = False
|
||||
|
||||
for req in data.requirements:
|
||||
if req.total_findings == 0:
|
||||
continue
|
||||
|
||||
has_findings = True
|
||||
m = get_requirement_metadata(req.id, data.attributes_by_requirement_id)
|
||||
|
||||
if m:
|
||||
risk_level_raw = getattr(m, "LevelOfRisk", 0)
|
||||
weight_raw = getattr(m, "Weight", 0)
|
||||
# Ensure numeric types for calculations (compliance data may have str)
|
||||
try:
|
||||
risk_level = int(risk_level_raw) if risk_level_raw else 0
|
||||
except (ValueError, TypeError):
|
||||
risk_level = 0
|
||||
try:
|
||||
weight = int(weight_raw) if weight_raw else 0
|
||||
except (ValueError, TypeError):
|
||||
weight = 0
|
||||
|
||||
rate_i = req.passed_findings / req.total_findings
|
||||
rfac_i = 1 + 0.25 * risk_level
|
||||
|
||||
numerator += rate_i * req.total_findings * weight * rfac_i
|
||||
denominator += req.total_findings * weight * rfac_i
|
||||
|
||||
if not has_findings:
|
||||
return 100.0
|
||||
if denominator > 0:
|
||||
return (numerator / denominator) * 100
|
||||
return 0.0
|
||||
|
||||
def _get_critical_failed_requirements(
|
||||
self, data: ComplianceData, min_risk_level: int
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Get critical failed requirements sorted by risk level and weight.
|
||||
|
||||
Args:
|
||||
data: Aggregated compliance data.
|
||||
min_risk_level: Minimum risk level threshold.
|
||||
|
||||
Returns:
|
||||
List of critical failed requirement dictionaries.
|
||||
"""
|
||||
critical = []
|
||||
|
||||
for req in data.requirements:
|
||||
if req.status != StatusChoices.FAIL:
|
||||
continue
|
||||
|
||||
m = get_requirement_metadata(req.id, data.attributes_by_requirement_id)
|
||||
|
||||
if m:
|
||||
risk_level_raw = getattr(m, "LevelOfRisk", 0)
|
||||
weight_raw = getattr(m, "Weight", 0)
|
||||
# Ensure numeric types for calculations (compliance data may have str)
|
||||
try:
|
||||
risk_level = int(risk_level_raw) if risk_level_raw else 0
|
||||
except (ValueError, TypeError):
|
||||
risk_level = 0
|
||||
try:
|
||||
weight = int(weight_raw) if weight_raw else 0
|
||||
except (ValueError, TypeError):
|
||||
weight = 0
|
||||
|
||||
if risk_level >= min_risk_level:
|
||||
critical.append(
|
||||
{
|
||||
"id": req.id,
|
||||
"risk_level": risk_level,
|
||||
"weight": weight,
|
||||
"title": getattr(m, "Title", "N/A"),
|
||||
"section": getattr(m, "Section", "N/A"),
|
||||
}
|
||||
)
|
||||
|
||||
critical.sort(key=lambda x: (x["risk_level"], x["weight"]), reverse=True)
|
||||
return critical
|
||||
|
||||
def _create_critical_requirements_table(self, critical_requirements: list) -> Table:
|
||||
"""
|
||||
Create the critical requirements table.
|
||||
|
||||
Args:
|
||||
critical_requirements: List of critical requirement dictionaries.
|
||||
|
||||
Returns:
|
||||
ReportLab Table element.
|
||||
"""
|
||||
table_data = [["Risk", "Weight", "Requirement ID", "Title", "Section"]]
|
||||
|
||||
for req in critical_requirements:
|
||||
title = req["title"]
|
||||
if len(title) > 50:
|
||||
title = title[:47] + "..."
|
||||
|
||||
table_data.append(
|
||||
[
|
||||
str(req["risk_level"]),
|
||||
str(req["weight"]),
|
||||
req["id"],
|
||||
title,
|
||||
req["section"],
|
||||
]
|
||||
)
|
||||
|
||||
table = Table(
|
||||
table_data,
|
||||
colWidths=[0.7 * inch, 0.9 * inch, 1.3 * inch, 3.1 * inch, 1.5 * inch],
|
||||
)
|
||||
|
||||
table.setStyle(
|
||||
TableStyle(
|
||||
[
|
||||
("BACKGROUND", (0, 0), (-1, 0), COLOR_HIGH_RISK),
|
||||
("TEXTCOLOR", (0, 0), (-1, 0), COLOR_WHITE),
|
||||
("FONTNAME", (0, 0), (-1, 0), "FiraCode"),
|
||||
("FONTSIZE", (0, 0), (-1, 0), 10),
|
||||
("BACKGROUND", (0, 1), (0, -1), COLOR_HIGH_RISK),
|
||||
("TEXTCOLOR", (0, 1), (0, -1), COLOR_WHITE),
|
||||
("FONTNAME", (0, 1), (0, -1), "FiraCode"),
|
||||
("ALIGN", (0, 1), (0, -1), "CENTER"),
|
||||
("FONTSIZE", (0, 1), (0, -1), 12),
|
||||
("ALIGN", (1, 1), (1, -1), "CENTER"),
|
||||
("FONTNAME", (1, 1), (1, -1), "FiraCode"),
|
||||
("FONTNAME", (2, 1), (2, -1), "FiraCode"),
|
||||
("FONTSIZE", (2, 1), (2, -1), 9),
|
||||
("FONTNAME", (3, 1), (-1, -1), "PlusJakartaSans"),
|
||||
("FONTSIZE", (3, 1), (-1, -1), 8),
|
||||
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
||||
("GRID", (0, 0), (-1, -1), 1, colors.Color(0.7, 0.7, 0.7)),
|
||||
("LEFTPADDING", (0, 0), (-1, -1), 6),
|
||||
("RIGHTPADDING", (0, 0), (-1, -1), 6),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 8),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 8),
|
||||
("BACKGROUND", (1, 1), (-1, -1), colors.Color(0.98, 0.98, 0.98)),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
# Color weight column based on value
|
||||
for idx, req in enumerate(critical_requirements):
|
||||
row_idx = idx + 1
|
||||
weight_color = get_color_for_weight(req["weight"])
|
||||
table.setStyle(
|
||||
TableStyle(
|
||||
[
|
||||
("BACKGROUND", (1, row_idx), (1, row_idx), weight_color),
|
||||
("TEXTCOLOR", (1, row_idx), (1, row_idx), COLOR_WHITE),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
return table
|
||||
|
||||
def _create_action_required_banner(self) -> Table:
|
||||
"""
|
||||
Create the 'Immediate Action Required' banner for critical requirements.
|
||||
|
||||
Returns:
|
||||
ReportLab Table element styled as a red-bordered alert banner.
|
||||
"""
|
||||
banner_style = ParagraphStyle(
|
||||
"ActionRequired",
|
||||
fontName="PlusJakartaSans",
|
||||
fontSize=11,
|
||||
textColor=COLOR_HIGH_RISK,
|
||||
leading=16,
|
||||
)
|
||||
|
||||
banner_content = Paragraph(
|
||||
"<b>IMMEDIATE ACTION REQUIRED:</b><br/>"
|
||||
"These requirements have the highest risk levels and have failed "
|
||||
"compliance checks. Please prioritize addressing these issues to "
|
||||
"improve your security posture.",
|
||||
banner_style,
|
||||
)
|
||||
|
||||
banner_table = Table(
|
||||
[[banner_content]],
|
||||
colWidths=[6.5 * inch],
|
||||
)
|
||||
banner_table.setStyle(
|
||||
TableStyle(
|
||||
[
|
||||
(
|
||||
"BACKGROUND",
|
||||
(0, 0),
|
||||
(0, 0),
|
||||
colors.Color(0.98, 0.92, 0.92),
|
||||
),
|
||||
("BOX", (0, 0), (0, 0), 2, COLOR_HIGH_RISK),
|
||||
("LEFTPADDING", (0, 0), (0, 0), 20),
|
||||
("RIGHTPADDING", (0, 0), (0, 0), 20),
|
||||
("TOPPADDING", (0, 0), (0, 0), 15),
|
||||
("BOTTOMPADDING", (0, 0), (0, 0), 15),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
return banner_table
|
||||
@@ -131,9 +131,11 @@ def compute_threatscore_metrics(
|
||||
continue
|
||||
|
||||
m = metadata[0]
|
||||
risk_level = getattr(m, "LevelOfRisk", 0)
|
||||
weight = getattr(m, "Weight", 0)
|
||||
risk_level_raw = getattr(m, "LevelOfRisk", 0)
|
||||
weight_raw = getattr(m, "Weight", 0)
|
||||
section = getattr(m, "Section", "Unknown")
|
||||
risk_level = int(risk_level_raw) if risk_level_raw else 0
|
||||
weight = int(weight_raw) if weight_raw else 0
|
||||
|
||||
# Calculate ThreatScore components using formula from UI
|
||||
rate_i = req_passed_findings / req_total_findings
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
from collections import defaultdict
|
||||
|
||||
from celery.utils.log import get_task_logger
|
||||
from config.django.base import DJANGO_FINDINGS_BATCH_SIZE
|
||||
from django.db.models import Count, Q
|
||||
from tasks.utils import batched
|
||||
|
||||
from api.db_router import READ_REPLICA_ALIAS
|
||||
from api.db_utils import rls_transaction
|
||||
@@ -154,6 +151,12 @@ def _load_findings_for_requirement_checks(
|
||||
Supports optional caching to avoid duplicate queries when generating multiple
|
||||
reports for the same scan.
|
||||
|
||||
Memory optimizations:
|
||||
- Uses database iterator with chunk_size for streaming large result sets
|
||||
- Shares references between cache and return dict (no duplication)
|
||||
- Only selects required fields from database
|
||||
- Processes findings in batches to reduce memory pressure
|
||||
|
||||
Args:
|
||||
tenant_id (str): The tenant ID for Row-Level Security context.
|
||||
scan_id (str): The ID of the scan to retrieve findings for.
|
||||
@@ -171,69 +174,73 @@ def _load_findings_for_requirement_checks(
|
||||
'aws_s3_bucket_public_access': [FindingOutput(...)]
|
||||
}
|
||||
"""
|
||||
findings_by_check_id = defaultdict(list)
|
||||
|
||||
if not check_ids:
|
||||
return dict(findings_by_check_id)
|
||||
return {}
|
||||
|
||||
# Initialize cache if not provided
|
||||
if findings_cache is None:
|
||||
findings_cache = {}
|
||||
|
||||
# Deduplicate check_ids to avoid redundant processing
|
||||
unique_check_ids = list(set(check_ids))
|
||||
|
||||
# Separate cached and non-cached check_ids
|
||||
check_ids_to_load = []
|
||||
cache_hits = 0
|
||||
cache_misses = 0
|
||||
|
||||
for check_id in check_ids:
|
||||
for check_id in unique_check_ids:
|
||||
if check_id in findings_cache:
|
||||
# Reuse from cache
|
||||
findings_by_check_id[check_id] = findings_cache[check_id]
|
||||
cache_hits += 1
|
||||
else:
|
||||
# Need to load from database
|
||||
check_ids_to_load.append(check_id)
|
||||
cache_misses += 1
|
||||
|
||||
if cache_hits > 0:
|
||||
total_checks = len(unique_check_ids)
|
||||
logger.info(
|
||||
f"Findings cache: {cache_hits} hits, {cache_misses} misses "
|
||||
f"({cache_hits / (cache_hits + cache_misses) * 100:.1f}% hit rate)"
|
||||
f"Findings cache: {cache_hits}/{total_checks} hits "
|
||||
f"({cache_hits / total_checks * 100:.1f}% hit rate)"
|
||||
)
|
||||
|
||||
# If all check_ids were in cache, return early
|
||||
if not check_ids_to_load:
|
||||
return dict(findings_by_check_id)
|
||||
|
||||
logger.info(f"Loading findings for {len(check_ids_to_load)} checks on-demand")
|
||||
|
||||
findings_queryset = (
|
||||
Finding.all_objects.filter(
|
||||
tenant_id=tenant_id, scan_id=scan_id, check_id__in=check_ids_to_load
|
||||
# Load missing check_ids from database
|
||||
if check_ids_to_load:
|
||||
logger.info(
|
||||
f"Loading findings for {len(check_ids_to_load)} checks from database"
|
||||
)
|
||||
.order_by("uid")
|
||||
.iterator()
|
||||
)
|
||||
|
||||
with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS):
|
||||
for batch, is_last_batch in batched(
|
||||
findings_queryset, DJANGO_FINDINGS_BATCH_SIZE
|
||||
):
|
||||
for finding_model in batch:
|
||||
with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS):
|
||||
# Use iterator with chunk_size for memory-efficient streaming
|
||||
# chunk_size controls how many rows Django fetches from DB at once
|
||||
findings_queryset = (
|
||||
Finding.all_objects.filter(
|
||||
tenant_id=tenant_id,
|
||||
scan_id=scan_id,
|
||||
check_id__in=check_ids_to_load,
|
||||
)
|
||||
.order_by("check_id", "uid")
|
||||
.iterator(chunk_size=DJANGO_FINDINGS_BATCH_SIZE)
|
||||
)
|
||||
|
||||
# Pre-initialize empty lists for all check_ids to load
|
||||
# This avoids repeated dict lookups and 'if not in' checks
|
||||
for check_id in check_ids_to_load:
|
||||
findings_cache[check_id] = []
|
||||
|
||||
findings_count = 0
|
||||
for finding_model in findings_queryset:
|
||||
finding_output = FindingOutput.transform_api_finding(
|
||||
finding_model, prowler_provider
|
||||
)
|
||||
findings_by_check_id[finding_output.check_id].append(finding_output)
|
||||
# Update cache with newly loaded findings
|
||||
if finding_output.check_id not in findings_cache:
|
||||
findings_cache[finding_output.check_id] = []
|
||||
findings_cache[finding_output.check_id].append(finding_output)
|
||||
findings_count += 1
|
||||
|
||||
total_findings_loaded = sum(
|
||||
len(findings) for findings in findings_by_check_id.values()
|
||||
)
|
||||
logger.info(
|
||||
f"Loaded {total_findings_loaded} findings for {len(findings_by_check_id)} checks"
|
||||
)
|
||||
logger.info(
|
||||
f"Loaded {findings_count} findings for {len(check_ids_to_load)} checks"
|
||||
)
|
||||
|
||||
return dict(findings_by_check_id)
|
||||
# Build result dict using cache references (no data duplication)
|
||||
# This shares the same list objects between cache and result
|
||||
result = {
|
||||
check_id: findings_cache.get(check_id, []) for check_id in unique_check_ids
|
||||
}
|
||||
|
||||
return result
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,410 @@
|
||||
import uuid
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import matplotlib
|
||||
import pytest
|
||||
from reportlab.lib import colors
|
||||
from tasks.jobs.report import generate_compliance_reports, generate_threatscore_report
|
||||
from tasks.jobs.reports import (
|
||||
CHART_COLOR_GREEN_1,
|
||||
CHART_COLOR_GREEN_2,
|
||||
CHART_COLOR_ORANGE,
|
||||
CHART_COLOR_RED,
|
||||
CHART_COLOR_YELLOW,
|
||||
COLOR_BLUE,
|
||||
COLOR_ENS_ALTO,
|
||||
COLOR_HIGH_RISK,
|
||||
COLOR_LOW_RISK,
|
||||
COLOR_MEDIUM_RISK,
|
||||
COLOR_NIS2_PRIMARY,
|
||||
COLOR_SAFE,
|
||||
create_pdf_styles,
|
||||
get_chart_color_for_percentage,
|
||||
get_color_for_compliance,
|
||||
get_color_for_risk_level,
|
||||
get_color_for_weight,
|
||||
)
|
||||
from tasks.jobs.threatscore_utils import (
|
||||
_aggregate_requirement_statistics_from_database,
|
||||
_load_findings_for_requirement_checks,
|
||||
)
|
||||
|
||||
from api.models import Finding, StatusChoices
|
||||
from prowler.lib.check.models import Severity
|
||||
|
||||
matplotlib.use("Agg") # Use non-interactive backend for tests
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestAggregateRequirementStatistics:
|
||||
"""Test suite for _aggregate_requirement_statistics_from_database function."""
|
||||
|
||||
def test_aggregates_findings_correctly(self, tenants_fixture, scans_fixture):
|
||||
"""Verify correct pass/total counts per check are aggregated from database."""
|
||||
tenant = tenants_fixture[0]
|
||||
scan = scans_fixture[0]
|
||||
|
||||
Finding.objects.create(
|
||||
tenant_id=tenant.id,
|
||||
scan=scan,
|
||||
uid="finding-1",
|
||||
check_id="check_1",
|
||||
status=StatusChoices.PASS,
|
||||
severity=Severity.high,
|
||||
impact=Severity.high,
|
||||
check_metadata={},
|
||||
raw_result={},
|
||||
)
|
||||
Finding.objects.create(
|
||||
tenant_id=tenant.id,
|
||||
scan=scan,
|
||||
uid="finding-2",
|
||||
check_id="check_1",
|
||||
status=StatusChoices.FAIL,
|
||||
severity=Severity.high,
|
||||
impact=Severity.high,
|
||||
check_metadata={},
|
||||
raw_result={},
|
||||
)
|
||||
Finding.objects.create(
|
||||
tenant_id=tenant.id,
|
||||
scan=scan,
|
||||
uid="finding-3",
|
||||
check_id="check_2",
|
||||
status=StatusChoices.PASS,
|
||||
severity=Severity.medium,
|
||||
impact=Severity.medium,
|
||||
check_metadata={},
|
||||
raw_result={},
|
||||
)
|
||||
|
||||
result = _aggregate_requirement_statistics_from_database(
|
||||
str(tenant.id), str(scan.id)
|
||||
)
|
||||
|
||||
assert "check_1" in result
|
||||
assert result["check_1"]["passed"] == 1
|
||||
assert result["check_1"]["total"] == 2
|
||||
|
||||
assert "check_2" in result
|
||||
assert result["check_2"]["passed"] == 1
|
||||
assert result["check_2"]["total"] == 1
|
||||
|
||||
def test_handles_empty_scan(self, tenants_fixture, scans_fixture):
|
||||
"""Verify empty result is returned for scan with no findings."""
|
||||
tenant = tenants_fixture[0]
|
||||
scan = scans_fixture[0]
|
||||
|
||||
result = _aggregate_requirement_statistics_from_database(
|
||||
str(tenant.id), str(scan.id)
|
||||
)
|
||||
|
||||
assert result == {}
|
||||
|
||||
def test_only_failed_findings(self, tenants_fixture, scans_fixture):
|
||||
"""Verify correct counts when all findings are FAIL."""
|
||||
tenant = tenants_fixture[0]
|
||||
scan = scans_fixture[0]
|
||||
|
||||
Finding.objects.create(
|
||||
tenant_id=tenant.id,
|
||||
scan=scan,
|
||||
uid="finding-1",
|
||||
check_id="check_1",
|
||||
status=StatusChoices.FAIL,
|
||||
severity=Severity.high,
|
||||
impact=Severity.high,
|
||||
check_metadata={},
|
||||
raw_result={},
|
||||
)
|
||||
Finding.objects.create(
|
||||
tenant_id=tenant.id,
|
||||
scan=scan,
|
||||
uid="finding-2",
|
||||
check_id="check_1",
|
||||
status=StatusChoices.FAIL,
|
||||
severity=Severity.high,
|
||||
impact=Severity.high,
|
||||
check_metadata={},
|
||||
raw_result={},
|
||||
)
|
||||
|
||||
result = _aggregate_requirement_statistics_from_database(
|
||||
str(tenant.id), str(scan.id)
|
||||
)
|
||||
|
||||
assert result["check_1"]["passed"] == 0
|
||||
assert result["check_1"]["total"] == 2
|
||||
|
||||
def test_multiple_findings_same_check(self, tenants_fixture, scans_fixture):
|
||||
"""Verify multiple findings for same check are correctly aggregated."""
|
||||
tenant = tenants_fixture[0]
|
||||
scan = scans_fixture[0]
|
||||
|
||||
for i in range(5):
|
||||
Finding.objects.create(
|
||||
tenant_id=tenant.id,
|
||||
scan=scan,
|
||||
uid=f"finding-{i}",
|
||||
check_id="check_1",
|
||||
status=StatusChoices.PASS if i % 2 == 0 else StatusChoices.FAIL,
|
||||
severity=Severity.high,
|
||||
impact=Severity.high,
|
||||
check_metadata={},
|
||||
raw_result={},
|
||||
)
|
||||
|
||||
result = _aggregate_requirement_statistics_from_database(
|
||||
str(tenant.id), str(scan.id)
|
||||
)
|
||||
|
||||
assert result["check_1"]["passed"] == 3
|
||||
assert result["check_1"]["total"] == 5
|
||||
|
||||
def test_mixed_statuses(self, tenants_fixture, scans_fixture):
|
||||
"""Verify MANUAL status is counted in total but not passed."""
|
||||
tenant = tenants_fixture[0]
|
||||
scan = scans_fixture[0]
|
||||
|
||||
Finding.objects.create(
|
||||
tenant_id=tenant.id,
|
||||
scan=scan,
|
||||
uid="finding-1",
|
||||
check_id="check_1",
|
||||
status=StatusChoices.PASS,
|
||||
severity=Severity.high,
|
||||
impact=Severity.high,
|
||||
check_metadata={},
|
||||
raw_result={},
|
||||
)
|
||||
Finding.objects.create(
|
||||
tenant_id=tenant.id,
|
||||
scan=scan,
|
||||
uid="finding-2",
|
||||
check_id="check_1",
|
||||
status=StatusChoices.MANUAL,
|
||||
severity=Severity.high,
|
||||
impact=Severity.high,
|
||||
check_metadata={},
|
||||
raw_result={},
|
||||
)
|
||||
|
||||
result = _aggregate_requirement_statistics_from_database(
|
||||
str(tenant.id), str(scan.id)
|
||||
)
|
||||
|
||||
# MANUAL findings are excluded from the aggregation query
|
||||
# since it only counts PASS and FAIL statuses
|
||||
assert result["check_1"]["passed"] == 1
|
||||
assert result["check_1"]["total"] == 1
|
||||
|
||||
|
||||
class TestColorHelperFunctions:
|
||||
"""Test suite for color helper functions."""
|
||||
|
||||
def test_get_color_for_risk_level_high(self):
|
||||
"""Test high risk level returns correct color."""
|
||||
result = get_color_for_risk_level(5)
|
||||
assert result == COLOR_HIGH_RISK
|
||||
|
||||
def test_get_color_for_risk_level_medium_high(self):
|
||||
"""Test risk level 4 returns high risk color."""
|
||||
result = get_color_for_risk_level(4)
|
||||
assert result == COLOR_HIGH_RISK # >= 4 is high risk
|
||||
|
||||
def test_get_color_for_risk_level_medium(self):
|
||||
"""Test risk level 3 returns medium risk color."""
|
||||
result = get_color_for_risk_level(3)
|
||||
assert result == COLOR_MEDIUM_RISK # >= 3 is medium risk
|
||||
|
||||
def test_get_color_for_risk_level_low(self):
|
||||
"""Test low risk level returns safe color."""
|
||||
result = get_color_for_risk_level(1)
|
||||
assert result == COLOR_SAFE # < 2 is safe
|
||||
|
||||
def test_get_color_for_weight_high(self):
|
||||
"""Test high weight returns correct color."""
|
||||
result = get_color_for_weight(150)
|
||||
assert result == COLOR_HIGH_RISK # > 100 is high risk
|
||||
|
||||
def test_get_color_for_weight_medium(self):
|
||||
"""Test medium weight returns low risk color."""
|
||||
result = get_color_for_weight(100)
|
||||
assert result == COLOR_LOW_RISK # 51-100 is low risk
|
||||
|
||||
def test_get_color_for_weight_low(self):
|
||||
"""Test low weight returns safe color."""
|
||||
result = get_color_for_weight(50)
|
||||
assert result == COLOR_SAFE # <= 50 is safe
|
||||
|
||||
def test_get_color_for_compliance_high(self):
|
||||
"""Test high compliance returns green color."""
|
||||
result = get_color_for_compliance(85)
|
||||
assert result == COLOR_SAFE
|
||||
|
||||
def test_get_color_for_compliance_medium(self):
|
||||
"""Test medium compliance returns yellow color."""
|
||||
result = get_color_for_compliance(70)
|
||||
assert result == COLOR_LOW_RISK
|
||||
|
||||
def test_get_color_for_compliance_low(self):
|
||||
"""Test low compliance returns red color."""
|
||||
result = get_color_for_compliance(50)
|
||||
assert result == COLOR_HIGH_RISK
|
||||
|
||||
def test_get_chart_color_for_percentage_excellent(self):
|
||||
"""Test excellent percentage returns correct chart color."""
|
||||
result = get_chart_color_for_percentage(90)
|
||||
assert result == CHART_COLOR_GREEN_1
|
||||
|
||||
def test_get_chart_color_for_percentage_good(self):
|
||||
"""Test good percentage returns correct chart color."""
|
||||
result = get_chart_color_for_percentage(70)
|
||||
assert result == CHART_COLOR_GREEN_2
|
||||
|
||||
def test_get_chart_color_for_percentage_fair(self):
|
||||
"""Test fair percentage returns correct chart color."""
|
||||
result = get_chart_color_for_percentage(50)
|
||||
assert result == CHART_COLOR_YELLOW
|
||||
|
||||
def test_get_chart_color_for_percentage_poor(self):
|
||||
"""Test poor percentage returns correct chart color."""
|
||||
result = get_chart_color_for_percentage(30)
|
||||
assert result == CHART_COLOR_ORANGE
|
||||
|
||||
def test_get_chart_color_for_percentage_critical(self):
|
||||
"""Test critical percentage returns correct chart color."""
|
||||
result = get_chart_color_for_percentage(10)
|
||||
assert result == CHART_COLOR_RED
|
||||
|
||||
|
||||
class TestPDFStylesCreation:
|
||||
"""Test suite for PDF styles creation."""
|
||||
|
||||
def test_create_pdf_styles_returns_dict(self):
|
||||
"""Test that create_pdf_styles returns a dictionary."""
|
||||
result = create_pdf_styles()
|
||||
assert isinstance(result, dict)
|
||||
|
||||
def test_create_pdf_styles_caches_result(self):
|
||||
"""Test that create_pdf_styles caches the result."""
|
||||
result1 = create_pdf_styles()
|
||||
result2 = create_pdf_styles()
|
||||
assert result1 is result2
|
||||
|
||||
def test_pdf_styles_have_correct_keys(self):
|
||||
"""Test that PDF styles dictionary has expected keys."""
|
||||
result = create_pdf_styles()
|
||||
expected_keys = ["title", "h1", "h2", "h3", "normal", "normal_center"]
|
||||
for key in expected_keys:
|
||||
assert key in result
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestLoadFindingsForChecks:
|
||||
"""Test suite for _load_findings_for_requirement_checks function."""
|
||||
|
||||
def test_empty_check_ids_returns_empty(self, tenants_fixture, providers_fixture):
|
||||
"""Test that empty check_ids list returns empty dict."""
|
||||
tenant = tenants_fixture[0]
|
||||
|
||||
mock_prowler_provider = Mock()
|
||||
mock_prowler_provider.identity.account = "test-account"
|
||||
|
||||
result = _load_findings_for_requirement_checks(
|
||||
str(tenant.id), str(uuid.uuid4()), [], mock_prowler_provider
|
||||
)
|
||||
|
||||
assert result == {}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestGenerateThreatscoreReportFunction:
|
||||
"""Test suite for generate_threatscore_report function."""
|
||||
|
||||
@patch("tasks.jobs.reports.base.initialize_prowler_provider")
|
||||
def test_generate_threatscore_report_exception_handling(
|
||||
self,
|
||||
mock_initialize_provider,
|
||||
tenants_fixture,
|
||||
scans_fixture,
|
||||
providers_fixture,
|
||||
):
|
||||
"""Test that exceptions during report generation are properly handled."""
|
||||
tenant = tenants_fixture[0]
|
||||
scan = scans_fixture[0]
|
||||
provider = providers_fixture[0]
|
||||
|
||||
mock_initialize_provider.side_effect = Exception("Test exception")
|
||||
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
generate_threatscore_report(
|
||||
tenant_id=str(tenant.id),
|
||||
scan_id=str(scan.id),
|
||||
compliance_id="prowler_threatscore_aws",
|
||||
output_path="/tmp/test_report.pdf",
|
||||
provider_id=str(provider.id),
|
||||
)
|
||||
|
||||
assert "Test exception" in str(exc_info.value)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestGenerateComplianceReportsOptimized:
|
||||
"""Test suite for generate_compliance_reports function."""
|
||||
|
||||
@patch("tasks.jobs.report._upload_to_s3")
|
||||
@patch("tasks.jobs.report.generate_threatscore_report")
|
||||
@patch("tasks.jobs.report.generate_ens_report")
|
||||
@patch("tasks.jobs.report.generate_nis2_report")
|
||||
def test_no_findings_returns_early_for_both_reports(
|
||||
self,
|
||||
mock_nis2,
|
||||
mock_ens,
|
||||
mock_threatscore,
|
||||
mock_upload,
|
||||
tenants_fixture,
|
||||
scans_fixture,
|
||||
providers_fixture,
|
||||
):
|
||||
"""Test that function returns early when scan has no findings."""
|
||||
tenant = tenants_fixture[0]
|
||||
scan = scans_fixture[0]
|
||||
provider = providers_fixture[0]
|
||||
|
||||
result = generate_compliance_reports(
|
||||
tenant_id=str(tenant.id),
|
||||
scan_id=str(scan.id),
|
||||
provider_id=str(provider.id),
|
||||
generate_threatscore=True,
|
||||
generate_ens=True,
|
||||
generate_nis2=True,
|
||||
)
|
||||
|
||||
assert result["threatscore"]["upload"] is False
|
||||
assert result["ens"]["upload"] is False
|
||||
assert result["nis2"]["upload"] is False
|
||||
|
||||
mock_threatscore.assert_not_called()
|
||||
mock_ens.assert_not_called()
|
||||
mock_nis2.assert_not_called()
|
||||
|
||||
|
||||
class TestOptimizationImprovements:
|
||||
"""Test suite for optimization-related functionality."""
|
||||
|
||||
def test_chart_color_constants_are_strings(self):
|
||||
"""Verify chart color constants are valid hex color strings."""
|
||||
assert CHART_COLOR_GREEN_1.startswith("#")
|
||||
assert CHART_COLOR_GREEN_2.startswith("#")
|
||||
assert CHART_COLOR_YELLOW.startswith("#")
|
||||
assert CHART_COLOR_ORANGE.startswith("#")
|
||||
assert CHART_COLOR_RED.startswith("#")
|
||||
|
||||
def test_color_constants_are_color_objects(self):
|
||||
"""Verify color constants are Color objects."""
|
||||
assert isinstance(COLOR_BLUE, colors.Color)
|
||||
assert isinstance(COLOR_HIGH_RISK, colors.Color)
|
||||
assert isinstance(COLOR_SAFE, colors.Color)
|
||||
assert isinstance(COLOR_ENS_ALTO, colors.Color)
|
||||
assert isinstance(COLOR_NIS2_PRIMARY, colors.Color)
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -31,12 +31,13 @@ The supported providers right now are:
|
||||
| [Kubernetes](/user-guide/providers/kubernetes/getting-started-k8s) | Official | Clusters | UI, API, CLI |
|
||||
| [M365](/user-guide/providers/microsoft365/getting-started-m365) | Official | Tenants | UI, API, CLI |
|
||||
| [Github](/user-guide/providers/github/getting-started-github) | Official | Organizations / Repositories | UI, API, CLI |
|
||||
| [Oracle Cloud](/user-guide/providers/oci/getting-started-oci) | Official | Tenancies / Compartments | UI, API, CLI |
|
||||
| [Cloudflare](/user-guide/providers/cloudflare/getting-started-cloudflare) | Official | Accounts | CLI |
|
||||
| [Infra as Code](/user-guide/providers/iac/getting-started-iac) | Official | Repositories | UI, API, CLI |
|
||||
| [MongoDB Atlas](/user-guide/providers/mongodbatlas/getting-started-mongodbatlas) | Official | Organizations | UI, API, CLI |
|
||||
| [LLM](/user-guide/providers/llm/getting-started-llm) | Official | Models | CLI |
|
||||
| **NHN** | Unofficial | Tenants | CLI |
|
||||
| [Oracle Cloud](/user-guide/providers/oci/getting-started-oci) | Official | Tenancies / Compartments | UI, API, CLI |
|
||||
| [Alibaba Cloud](/user-guide/providers/alibabacloud/getting-started-alibabacloud) | Official | Accounts | UI, API, CLI |
|
||||
| [Cloudflare](/user-guide/providers/cloudflare/getting-started-cloudflare) | Official | Accounts | CLI |
|
||||
| [Infra as Code](/user-guide/providers/iac/getting-started-iac) | Official | Repositories | UI, API, CLI |
|
||||
| [MongoDB Atlas](/user-guide/providers/mongodbatlas/getting-started-mongodbatlas) | Official | Organizations | UI, API, CLI |
|
||||
| [LLM](/user-guide/providers/llm/getting-started-llm) | Official | Models | CLI |
|
||||
| **NHN** | Unofficial | Tenants | CLI |
|
||||
|
||||
For more information about the checks and compliance of each provider visit [Prowler Hub](https://hub.prowler.com).
|
||||
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST:
|
||||
|
||||
## Project Overview
|
||||
|
||||
The Prowler SDK is the core Python engine powering cloud security assessments across AWS, Azure, GCP, Kubernetes, GitHub, M365, and more. It includes 1000+ security checks and 30+ compliance frameworks.
|
||||
The Prowler SDK is the core Python engine powering cloud security assessments across AWS, Azure, GCP, Kubernetes, GitHub, M365, and more. It includes 1100+ security checks and 85+ compliance frameworks.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -4,12 +4,14 @@ All notable changes to the **Prowler SDK** are documented in this file.
|
||||
|
||||
## [5.18.0] (Prowler UNRELEASED)
|
||||
|
||||
### Added
|
||||
### 🚀 Added
|
||||
|
||||
- `defender_zap_for_teams_enabled` check for M365 provider [(#9838)](https://github.com/prowler-cloud/prowler/pull/9838)
|
||||
- `compute_instance_suspended_without_persistent_disks` check for GCP provider [(#9747)](https://github.com/prowler-cloud/prowler/pull/9747)
|
||||
- `codebuild_project_webhook_filters_use_anchored_patterns` check for AWS provider to detect CodeBreach vulnerability [(#9840)](https://github.com/prowler-cloud/prowler/pull/9840)
|
||||
- `exchange_shared_mailbox_sign_in_disabled` check for M365 provider [(#9828)](https://github.com/prowler-cloud/prowler/pull/9828)
|
||||
- CloudTrail Timeline abstraction for querying resource modification history [(#9101)](https://github.com/prowler-cloud/prowler/pull/9101)
|
||||
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -49,6 +51,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
|
||||
- CIS 6.0 for M365 provider [(#9779)](https://github.com/prowler-cloud/prowler/pull/9779)
|
||||
- CIS 5.0 compliance framework for the Azure provider [(#9777)](https://github.com/prowler-cloud/prowler/pull/9777)
|
||||
- `Cloudflare` Bot protection, WAF, Privacy, Anti-Scraping and Zone configuration checks [(#9425)](https://github.com/prowler-cloud/prowler/pull/9425)
|
||||
- `Cloudflare` `waf` and `dns record` checks [(#9426)](https://github.com/prowler-cloud/prowler/pull/9426)
|
||||
|
||||
### Changed
|
||||
|
||||
|
||||
@@ -779,7 +779,10 @@ class CheckReportCloudflare(Check_Report):
|
||||
|
||||
@property
|
||||
def zone_name(self) -> str:
|
||||
"""Zone name."""
|
||||
"""Zone name - for DNS records use zone_name attribute, for zones use name."""
|
||||
zone_name = getattr(self._zone, "zone_name", None)
|
||||
if zone_name:
|
||||
return zone_name
|
||||
return getattr(self._zone, "name", "")
|
||||
|
||||
@property
|
||||
@@ -792,7 +795,10 @@ class CheckReportCloudflare(Check_Report):
|
||||
|
||||
@property
|
||||
def region(self) -> str:
|
||||
"""Cloudflare is a global service."""
|
||||
"""Return zone_name as region for zone-scoped resources, otherwise global."""
|
||||
zone_name = getattr(self._zone, "zone_name", None)
|
||||
if zone_name:
|
||||
return zone_name
|
||||
return "global"
|
||||
|
||||
|
||||
|
||||
Whitespace-only changes.
@@ -0,0 +1,27 @@
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from pydantic.v1 import BaseModel
|
||||
|
||||
|
||||
class TimelineEvent(BaseModel):
|
||||
"""A timeline event representing a resource modification.
|
||||
|
||||
Provider-agnostic model that can be used by any timeline implementation
|
||||
(AWS CloudTrail, Azure Activity Logs, GCP Audit Logs, etc.).
|
||||
"""
|
||||
|
||||
event_id: str
|
||||
event_time: datetime
|
||||
event_name: str
|
||||
event_source: str
|
||||
actor: str
|
||||
actor_uid: Optional[str] = None
|
||||
actor_type: Optional[str] = None
|
||||
source_ip_address: Optional[str] = None
|
||||
user_agent: Optional[str] = None
|
||||
request_data: Optional[Dict[str, Any]] = None
|
||||
response_data: Optional[Dict[str, Any]] = None
|
||||
error_code: Optional[str] = None
|
||||
error_message: Optional[str] = None
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Abstract base class for timeline services."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
class TimelineService(ABC):
|
||||
"""Abstract base class for provider-specific timeline implementations.
|
||||
|
||||
Subclasses should implement the get_resource_timeline method to query
|
||||
their provider's audit/activity log service (e.g., AWS CloudTrail,
|
||||
Azure Activity Logs, GCP Audit Logs).
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def get_resource_timeline(
|
||||
self,
|
||||
region: Optional[str] = None,
|
||||
resource_id: Optional[str] = None,
|
||||
resource_uid: Optional[str] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Get timeline events for a resource.
|
||||
|
||||
Args:
|
||||
region: Region/location where the resource exists. Implementations
|
||||
may provide a sensible default for global/regionless resources.
|
||||
resource_id: Provider-specific resource ID (e.g., bucket name, instance ID)
|
||||
resource_uid: Provider-specific unique identifier (e.g., AWS ARN, Azure Resource ID)
|
||||
|
||||
Returns:
|
||||
List of timeline event dictionaries
|
||||
|
||||
Raises:
|
||||
ValueError: If neither resource_id nor resource_uid is provided
|
||||
"""
|
||||
raise NotImplementedError
|
||||
Whitespace-only changes.
@@ -0,0 +1,218 @@
|
||||
"""CloudTrail timeline service for AWS.
|
||||
|
||||
Queries AWS CloudTrail to retrieve timeline events for resources,
|
||||
showing who performed actions and when.
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from botocore.exceptions import ClientError
|
||||
|
||||
from prowler.lib.logger import logger
|
||||
from prowler.lib.timeline.models import TimelineEvent
|
||||
from prowler.lib.timeline.timeline import TimelineService
|
||||
|
||||
|
||||
class CloudTrailTimeline(TimelineService):
|
||||
"""AWS CloudTrail implementation of TimelineService.
|
||||
|
||||
Args:
|
||||
session: boto3.Session for AWS API calls
|
||||
lookback_days: Number of days to look back (default 90, max 90 for Event History)
|
||||
max_results: Maximum number of events to return
|
||||
write_events_only: If True, filter out read-only events (Describe*, Get*, List*, etc.)
|
||||
"""
|
||||
|
||||
MAX_LOOKBACK_DAYS = 90
|
||||
|
||||
DEFAULT_MAX_RESULTS = 50 # Default page size for CloudTrail queries
|
||||
|
||||
# Prefixes for read-only API operations that don't modify resources
|
||||
READ_ONLY_PREFIXES = (
|
||||
"Describe",
|
||||
"Get",
|
||||
"List",
|
||||
"Head",
|
||||
"Check",
|
||||
"Lookup",
|
||||
"Search",
|
||||
"Scan",
|
||||
"Query",
|
||||
"BatchGet",
|
||||
"Select",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session,
|
||||
lookback_days: int = 90,
|
||||
max_results: Optional[int] = None,
|
||||
write_events_only: bool = True,
|
||||
):
|
||||
self._session = session
|
||||
self._lookback_days = min(lookback_days, self.MAX_LOOKBACK_DAYS)
|
||||
self._max_results = max_results or self.DEFAULT_MAX_RESULTS
|
||||
self._write_events_only = write_events_only
|
||||
self._clients: Dict[str, Any] = {}
|
||||
|
||||
DEFAULT_REGION = "us-east-1" # Default for global resources in commercial partition
|
||||
|
||||
def get_resource_timeline(
|
||||
self,
|
||||
region: Optional[str] = None,
|
||||
resource_id: Optional[str] = None,
|
||||
resource_uid: Optional[str] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Get CloudTrail timeline events for a resource.
|
||||
|
||||
Args:
|
||||
region: AWS region to query. Defaults to us-east-1 for global resources
|
||||
(IAM, S3, Route53, etc.) in the commercial partition. Caller
|
||||
should provide the correct region for regional resources.
|
||||
resource_id: AWS resource ID (e.g., sg-1234567890abcdef0)
|
||||
resource_uid: AWS resource ARN (unique identifier)
|
||||
|
||||
Returns:
|
||||
List of timeline event dictionaries
|
||||
|
||||
Raises:
|
||||
ValueError: If neither resource_id nor resource_uid is provided
|
||||
ClientError: If AWS API call fails
|
||||
"""
|
||||
resource_identifier = resource_uid or resource_id
|
||||
if not resource_identifier:
|
||||
raise ValueError("Either resource_id or resource_uid must be provided")
|
||||
|
||||
region = region or self.DEFAULT_REGION
|
||||
|
||||
try:
|
||||
raw_events = self._lookup_events(resource_identifier, region)
|
||||
|
||||
events = []
|
||||
for raw_event in raw_events:
|
||||
# Filter read-only events if write_events_only is enabled
|
||||
if self._write_events_only:
|
||||
event_name = raw_event.get("EventName", "")
|
||||
if self._is_read_only_event(event_name):
|
||||
continue
|
||||
|
||||
parsed = self._parse_event(raw_event)
|
||||
if parsed:
|
||||
events.append(parsed)
|
||||
|
||||
return events
|
||||
|
||||
except ClientError as e:
|
||||
logger.error(
|
||||
f"CloudTrail timeline error for {resource_identifier} in {region}: "
|
||||
f"{e.response['Error']['Code']} - {e.response['Error']['Message']}"
|
||||
)
|
||||
raise
|
||||
except Exception as e:
|
||||
lineno = e.__traceback__.tb_lineno if e.__traceback__ else "?"
|
||||
logger.error(
|
||||
f"CloudTrail timeline unexpected error: "
|
||||
f"{e.__class__.__name__}[{lineno}]: {e}"
|
||||
)
|
||||
return []
|
||||
|
||||
def _is_read_only_event(self, event_name: str) -> bool:
|
||||
"""Check if an event is a read-only operation."""
|
||||
return event_name.startswith(self.READ_ONLY_PREFIXES)
|
||||
|
||||
def _get_client(self, region: str):
|
||||
"""Get or create a CloudTrail client for the specified region."""
|
||||
if region not in self._clients:
|
||||
self._clients[region] = self._session.client(
|
||||
"cloudtrail", region_name=region
|
||||
)
|
||||
return self._clients[region]
|
||||
|
||||
def _lookup_events(
|
||||
self, resource_identifier: str, region: str
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Query CloudTrail for events related to a specific resource.
|
||||
|
||||
Uses MaxResults to limit the number of events returned, preparing
|
||||
for API-level pagination. Currently returns up to max_results events
|
||||
from the first page only.
|
||||
"""
|
||||
client = self._get_client(region)
|
||||
start_time = datetime.now(timezone.utc) - timedelta(days=self._lookback_days)
|
||||
|
||||
# Use direct API call with MaxResults instead of paginator
|
||||
# This limits CloudTrail to return only max_results events
|
||||
response = client.lookup_events(
|
||||
LookupAttributes=[
|
||||
{"AttributeKey": "ResourceName", "AttributeValue": resource_identifier}
|
||||
],
|
||||
StartTime=start_time,
|
||||
MaxResults=self._max_results,
|
||||
)
|
||||
|
||||
return response.get("Events", [])
|
||||
|
||||
def _parse_event(self, raw_event: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
"""Parse a raw CloudTrail event into a TimelineEvent dictionary."""
|
||||
try:
|
||||
cloud_trail_event = raw_event.get("CloudTrailEvent", "{}")
|
||||
if isinstance(cloud_trail_event, str):
|
||||
details = json.loads(cloud_trail_event)
|
||||
else:
|
||||
details = cloud_trail_event
|
||||
|
||||
user_identity = details.get("userIdentity", {})
|
||||
|
||||
event = TimelineEvent(
|
||||
event_id=raw_event.get("EventId"),
|
||||
event_time=raw_event["EventTime"],
|
||||
event_name=raw_event.get("EventName", "Unknown"),
|
||||
event_source=raw_event.get("EventSource", "Unknown"),
|
||||
actor=self._extract_actor(user_identity),
|
||||
actor_uid=user_identity.get("arn"),
|
||||
actor_type=user_identity.get("type"),
|
||||
source_ip_address=details.get("sourceIPAddress"),
|
||||
user_agent=details.get("userAgent"),
|
||||
request_data=details.get("requestParameters"),
|
||||
response_data=details.get("responseElements"),
|
||||
error_code=details.get("errorCode"),
|
||||
error_message=details.get("errorMessage"),
|
||||
)
|
||||
|
||||
return event.dict()
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"CloudTrail timeline: failed to parse event: "
|
||||
f"{e.__class__.__name__}: {e}"
|
||||
)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _extract_actor(user_identity: Dict[str, Any]) -> str:
|
||||
"""Extract a human-readable actor name from CloudTrail userIdentity."""
|
||||
# Try ARN first - most reliable
|
||||
if arn := user_identity.get("arn"):
|
||||
if "/" in arn:
|
||||
parts = arn.split("/")
|
||||
# For assumed-role, return the role name (second-to-last part)
|
||||
if "assumed-role" in arn and len(parts) >= 2:
|
||||
return parts[-2]
|
||||
return parts[-1]
|
||||
return arn.split(":")[-1]
|
||||
|
||||
# Fall back to userName
|
||||
if username := user_identity.get("userName"):
|
||||
return username
|
||||
|
||||
# Fall back to principalId
|
||||
if principal_id := user_identity.get("principalId"):
|
||||
return principal_id
|
||||
|
||||
# For service-invoked actions
|
||||
if invoking_service := user_identity.get("invokedBy"):
|
||||
return invoking_service
|
||||
|
||||
return "Unknown"
|
||||
Whitespace-only changes.
+36
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"Provider": "cloudflare",
|
||||
"CheckID": "dns_record_cname_target_valid",
|
||||
"CheckTitle": "DNS records pointing to hostnames have valid targets without takeover risk",
|
||||
"CheckType": [],
|
||||
"ServiceName": "dns",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "high",
|
||||
"ResourceType": "DNSRecord",
|
||||
"ResourceGroup": "network",
|
||||
"Description": "**Cloudflare DNS records** (CNAME, MX, NS, SRV) that point to hostnames are assessed for **dangling record** vulnerabilities by checking if the target domain resolves to a valid address, preventing **subdomain takeover**, **mail interception**, and **service hijacking** attacks.",
|
||||
"Risk": "Dangling **DNS records** pointing to non-existent targets create multiple vulnerabilities.\n- **Confidentiality**: dangling CNAME/NS allows subdomain takeover; dangling MX allows mail interception\n- **Integrity**: attackers can impersonate your organization, intercept emails, or hijack services\n- **Availability**: legitimate services may be disrupted or redirected to attacker-controlled infrastructure",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://developers.cloudflare.com/dns/manage-dns-records/how-to/create-dns-records/"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Log in to the Cloudflare dashboard and select your account and domain\n2. Go to DNS > Records\n3. Identify CNAME, MX, NS, or SRV records with dangling targets\n4. Either update the record to point to a valid target or delete the record\n5. If the target service was decommissioned, remove the DNS record",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Remove or update **dangling DNS records** to prevent takeover and interception attacks.\n- Regularly audit DNS records when decommissioning services\n- Remove CNAME, MX, NS, and SRV records pointing to deprovisioned resources\n- Monitor for unauthorized changes to DNS records\n- Consider using DNS monitoring tools to detect dangling records",
|
||||
"Url": "https://hub.prowler.com/checks/cloudflare/dns_record_cname_target_valid"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"internet-exposed"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": "Subdomain takeover occurs when a CNAME or NS record points to a service that has been deprovisioned, allowing attackers to claim that service and control the subdomain. Similarly, dangling MX records can allow mail interception, and dangling SRV records can expose service discovery vulnerabilities."
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
import socket
|
||||
|
||||
from prowler.lib.check.models import Check, CheckReportCloudflare
|
||||
from prowler.providers.cloudflare.services.dns.dns_client import dns_client
|
||||
|
||||
# Record types that point to hostnames and can be dangling:
|
||||
# - CNAME: Alias to another hostname
|
||||
# - MX: Mail server hostname (dangling = potential mail interception)
|
||||
# - NS: Nameserver delegation (dangling = subdomain takeover)
|
||||
# - SRV: Service location hostname
|
||||
DANGLING_RISK_TYPES = {"CNAME", "MX", "NS", "SRV"}
|
||||
|
||||
# Risk descriptions for each record type
|
||||
RISK_DESCRIPTIONS = {
|
||||
"CNAME": "subdomain takeover risk",
|
||||
"MX": "potential mail interception risk",
|
||||
"NS": "subdomain delegation takeover risk",
|
||||
"SRV": "service discovery vulnerability",
|
||||
}
|
||||
|
||||
|
||||
class dns_record_cname_target_valid(Check):
|
||||
"""Ensure that DNS records pointing to hostnames have valid, resolvable targets.
|
||||
|
||||
Dangling DNS records that point to non-existent or unresolvable targets pose
|
||||
significant security risks. CNAME and NS records can lead to subdomain takeover,
|
||||
MX records can allow mail interception, and SRV records can expose service
|
||||
vulnerabilities. Attackers can claim orphaned target resources and serve
|
||||
malicious content, intercept email, or hijack services under your domain.
|
||||
"""
|
||||
|
||||
def execute(self) -> list[CheckReportCloudflare]:
|
||||
"""Execute the dangling DNS record validation check.
|
||||
|
||||
Iterates through all DNS records that point to hostnames (CNAME, MX, NS, SRV)
|
||||
and attempts to resolve their targets using DNS lookup. Records pointing to
|
||||
unresolvable targets are flagged as potential security risks.
|
||||
|
||||
Returns:
|
||||
A list of CheckReportCloudflare objects with PASS status if the
|
||||
target resolves successfully, or FAIL status if the target
|
||||
cannot be resolved (dangling record).
|
||||
"""
|
||||
findings = []
|
||||
|
||||
for record in dns_client.records:
|
||||
# Check record types that point to hostnames
|
||||
if record.type not in DANGLING_RISK_TYPES:
|
||||
continue
|
||||
|
||||
report = CheckReportCloudflare(
|
||||
metadata=self.metadata(),
|
||||
resource=record,
|
||||
)
|
||||
|
||||
target = self._extract_target(record.type, record.content)
|
||||
is_valid = self._check_target_resolves(target)
|
||||
risk_desc = RISK_DESCRIPTIONS.get(record.type, "security risk")
|
||||
|
||||
if is_valid:
|
||||
report.status = "PASS"
|
||||
report.status_extended = f"{record.type} record {record.name} points to valid target {target}."
|
||||
else:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"{record.type} record {record.name} points to potentially dangling "
|
||||
f"target {target} - {risk_desc}."
|
||||
)
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
|
||||
def _extract_target(self, record_type: str, content: str) -> str:
|
||||
"""Extract the target hostname from record content.
|
||||
|
||||
Different record types have different content formats:
|
||||
- CNAME: hostname
|
||||
- MX: priority hostname (e.g., "10 mail.example.com")
|
||||
- NS: hostname
|
||||
- SRV: Cloudflare returns "weight port hostname" (e.g., "5 80 sip.example.com")
|
||||
"""
|
||||
if record_type == "MX":
|
||||
# MX format: "priority hostname"
|
||||
parts = content.split(None, 1)
|
||||
return parts[1] if len(parts) > 1 else content
|
||||
elif record_type == "SRV":
|
||||
# SRV format from Cloudflare: "weight port hostname"
|
||||
parts = content.split()
|
||||
# Target is the last part (hostname)
|
||||
return parts[-1] if parts else content
|
||||
else:
|
||||
# CNAME and NS are just hostnames
|
||||
return content
|
||||
|
||||
def _check_target_resolves(self, target: str) -> bool:
|
||||
"""Check if target hostname resolves to a valid address."""
|
||||
# Remove trailing dot if present
|
||||
target = target.rstrip(".")
|
||||
|
||||
try:
|
||||
# Attempt DNS resolution
|
||||
socket.getaddrinfo(target, None, socket.AF_UNSPEC)
|
||||
return True
|
||||
except socket.gaierror:
|
||||
# DNS resolution failed - potential dangling record
|
||||
return False
|
||||
except Exception:
|
||||
# On any other error, assume valid to avoid false positives
|
||||
return True
|
||||
Whitespace-only changes.
+36
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"Provider": "cloudflare",
|
||||
"CheckID": "dns_record_no_internal_ip",
|
||||
"CheckTitle": "DNS records do not expose internal IP addresses",
|
||||
"CheckType": [],
|
||||
"ServiceName": "dns",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "high",
|
||||
"ResourceType": "DNSRecord",
|
||||
"ResourceGroup": "network",
|
||||
"Description": "**Cloudflare DNS records** are assessed for **internal IP exposure** by checking if A or AAAA records point to private, loopback, or reserved IP addresses which could **leak internal network structure**.",
|
||||
"Risk": "DNS records exposing **internal IP addresses** leak sensitive network information.\n- **Confidentiality**: reveals internal network topology and addressing schemes to attackers\n- **Integrity**: provides reconnaissance data for targeted attacks on internal infrastructure\n- **Availability**: internal IPs in public DNS may indicate misconfiguration affecting service routing",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://developers.cloudflare.com/dns/manage-dns-records/how-to/create-dns-records/"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Log in to the Cloudflare dashboard and select your account and domain\n2. Go to DNS > Records\n3. Identify A/AAAA records pointing to internal IP addresses\n4. Update records to point to public IP addresses or remove if not needed\n5. Use split-horizon DNS if internal resolution is required",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Remove **internal IP addresses** from public DNS records.\n- Use split-horizon DNS for internal service resolution\n- Ensure DNS records only contain publicly routable IP addresses\n- Review DNS records after network changes or migrations\n- Consider using Cloudflare Access for secure internal service access",
|
||||
"Url": "https://hub.prowler.com/checks/cloudflare/dns_record_no_internal_ip"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"internet-exposed"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": "Internal IP ranges include: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 (IPv4), fc00::/7 (IPv6 ULA), and loopback addresses. These should not appear in public DNS records."
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
import ipaddress
|
||||
|
||||
from prowler.lib.check.models import Check, CheckReportCloudflare
|
||||
from prowler.providers.cloudflare.services.dns.dns_client import dns_client
|
||||
|
||||
|
||||
class dns_record_no_internal_ip(Check):
|
||||
"""Ensure that DNS records do not expose internal or private IP addresses.
|
||||
|
||||
Public DNS records should only contain publicly routable IP addresses.
|
||||
Exposing internal, private, loopback, or link-local addresses in DNS records
|
||||
can leak information about internal network infrastructure, potentially
|
||||
aiding attackers in reconnaissance and targeted attacks against internal
|
||||
systems.
|
||||
"""
|
||||
|
||||
def execute(self) -> list[CheckReportCloudflare]:
|
||||
"""Execute the internal IP address exposure check.
|
||||
|
||||
Iterates through all A and AAAA DNS records and checks if they contain
|
||||
private, loopback, link-local, or reserved IP addresses that should not
|
||||
be exposed publicly.
|
||||
|
||||
Returns:
|
||||
A list of CheckReportCloudflare objects with PASS status if the
|
||||
record points to a public IP address, or FAIL status if it exposes
|
||||
an internal IP address.
|
||||
"""
|
||||
findings = []
|
||||
|
||||
for record in dns_client.records:
|
||||
# Only check A and AAAA records
|
||||
if record.type not in ("A", "AAAA"):
|
||||
continue
|
||||
|
||||
report = CheckReportCloudflare(
|
||||
metadata=self.metadata(),
|
||||
resource=record,
|
||||
)
|
||||
|
||||
is_internal = self._is_internal_ip(record.content)
|
||||
|
||||
if not is_internal:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"DNS record {record.name} ({record.type}) points to "
|
||||
f"public IP address {record.content}."
|
||||
)
|
||||
else:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"DNS record {record.name} ({record.type}) exposes "
|
||||
f"internal IP address {record.content} - information disclosure risk."
|
||||
)
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
|
||||
def _is_internal_ip(self, ip_str: str) -> bool:
|
||||
"""Check if IP address is internal/private."""
|
||||
try:
|
||||
ip = ipaddress.ip_address(ip_str)
|
||||
# Check for private, loopback, link-local, or reserved addresses
|
||||
return (
|
||||
ip.is_private
|
||||
or ip.is_loopback
|
||||
or ip.is_link_local
|
||||
or ip.is_reserved
|
||||
or ip.is_unspecified
|
||||
)
|
||||
except ValueError:
|
||||
# Invalid IP format, assume not internal
|
||||
return False
|
||||
Whitespace-only changes.
+36
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"Provider": "cloudflare",
|
||||
"CheckID": "dns_record_no_wildcard",
|
||||
"CheckTitle": "DNS records do not use wildcard entries",
|
||||
"CheckType": [],
|
||||
"ServiceName": "dns",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "DNSRecord",
|
||||
"ResourceGroup": "network",
|
||||
"Description": "**Cloudflare DNS records** are assessed for **wildcard usage** by checking if A, AAAA, CNAME, MX, or SRV records use wildcard entries (*.example.com) which can **increase attack surface**, expose unintended services, or allow mail interception.",
|
||||
"Risk": "**Wildcard DNS records** can expose unintended services and increase attack surface.\n- **Confidentiality**: any subdomain resolves, potentially exposing internal naming conventions; wildcard MX allows mail interception\n- **Integrity**: attackers can access unintended services via arbitrary subdomains\n- **Availability**: wildcard records may route traffic or services not designed for public access",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://developers.cloudflare.com/dns/manage-dns-records/how-to/create-dns-records/"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Log in to the Cloudflare dashboard and select your account and domain\n2. Go to DNS > Records\n3. Identify wildcard DNS records (starting with *.)\n4. Evaluate if the wildcard is necessary for your use case\n5. Replace wildcard records with specific subdomain records where possible",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Avoid using **wildcard DNS records** unless absolutely necessary.\n- Use specific subdomain records instead of wildcards\n- If wildcards are required, ensure the target service handles unknown subdomains securely\n- Document the business justification for any wildcard records\n- Combine with proper web server configuration to reject unknown hosts",
|
||||
"Url": "https://hub.prowler.com/checks/cloudflare/dns_record_no_wildcard"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"internet-exposed"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": "Wildcard DNS records (*.example.com) cause any subdomain query to resolve. While useful for some applications, they can expose services unintentionally and make subdomain enumeration easier for attackers. Wildcard MX records can accept mail for any subdomain, and wildcard SRV records can expose services on arbitrary subdomains."
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
from prowler.lib.check.models import Check, CheckReportCloudflare
|
||||
from prowler.providers.cloudflare.services.dns.dns_client import dns_client
|
||||
|
||||
# Record types where wildcards pose security risks:
|
||||
# - A, AAAA: Wildcard resolves any subdomain to an IP, exposing services
|
||||
# - CNAME: Wildcard aliases any subdomain, potential for subdomain takeover
|
||||
# - MX: Wildcard accepts mail for any subdomain, potential mail interception
|
||||
# - SRV: Wildcard exposes services on any subdomain
|
||||
WILDCARD_RISK_TYPES = {"A", "AAAA", "CNAME", "MX", "SRV"}
|
||||
|
||||
|
||||
class dns_record_no_wildcard(Check):
|
||||
"""Ensure that wildcard DNS records are not configured for the zone.
|
||||
|
||||
Wildcard DNS records (*.domain.com) match any subdomain that doesn't have
|
||||
an explicit record, which can unintentionally expose services or create
|
||||
security risks. Attackers may discover hidden services, and wildcard
|
||||
certificates combined with wildcard DNS can increase the attack surface
|
||||
for subdomain takeover vulnerabilities. Wildcard MX records can allow
|
||||
mail interception for arbitrary subdomains.
|
||||
"""
|
||||
|
||||
def execute(self) -> list[CheckReportCloudflare]:
|
||||
"""Execute the wildcard DNS record check.
|
||||
|
||||
Iterates through all security-relevant DNS records (A, AAAA, CNAME, MX, SRV)
|
||||
and identifies those configured as wildcard records (starting with *.).
|
||||
Wildcard records may expose unintended services or create security risks.
|
||||
|
||||
Returns:
|
||||
A list of CheckReportCloudflare objects with PASS status if the
|
||||
record is not a wildcard, or FAIL status if it is a wildcard record.
|
||||
"""
|
||||
findings = []
|
||||
|
||||
for record in dns_client.records:
|
||||
# Check record types where wildcards pose security risks
|
||||
if record.type not in WILDCARD_RISK_TYPES:
|
||||
continue
|
||||
|
||||
report = CheckReportCloudflare(
|
||||
metadata=self.metadata(),
|
||||
resource=record,
|
||||
)
|
||||
|
||||
# Check if record name starts with wildcard
|
||||
is_wildcard = record.name.startswith("*.")
|
||||
|
||||
if not is_wildcard:
|
||||
report.status = "PASS"
|
||||
report.status_extended = f"DNS record {record.name} ({record.type}) is not a wildcard record."
|
||||
else:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"DNS record {record.name} ({record.type}) is a wildcard record - "
|
||||
f"may expose unintended services."
|
||||
)
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
Whitespace-only changes.
+36
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"Provider": "cloudflare",
|
||||
"CheckID": "dns_record_proxied",
|
||||
"CheckTitle": "Cloudflare proxy is enabled for applicable DNS records",
|
||||
"CheckType": [],
|
||||
"ServiceName": "dns",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "DNSRecord",
|
||||
"ResourceGroup": "network",
|
||||
"Description": "**Cloudflare DNS records** are assessed for **proxy configuration** by checking if A, AAAA, and CNAME records are proxied through Cloudflare to benefit from **DDoS protection**, **WAF**, and **caching** capabilities.",
|
||||
"Risk": "Unproxied **DNS records** expose origin server IP addresses directly to the internet.\n- **Confidentiality**: origin IP exposure enables targeted reconnaissance and attacks\n- **Integrity**: direct access to origin bypasses WAF and security controls\n- **Availability**: origin is exposed to DDoS attacks without Cloudflare protection",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://developers.cloudflare.com/dns/manage-dns-records/reference/proxied-dns-records/"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Log in to the Cloudflare dashboard and select your account and domain\n2. Go to DNS > Records\n3. For each A, AAAA, or CNAME record that should be protected\n4. Click Edit and toggle Proxy status to Proxied (orange cloud)\n5. Save the changes and verify traffic flows through Cloudflare",
|
||||
"Terraform": "```hcl\n# Enable Cloudflare proxy for DNS records\nresource \"cloudflare_record\" \"proxied_record\" {\n zone_id = \"<ZONE_ID>\"\n name = \"www\"\n content = \"192.0.2.1\"\n type = \"A\"\n proxied = true # Critical: enables DDoS protection, WAF, and caching\n}\n```"
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Enable the **Cloudflare proxy** (orange cloud) for DNS records that should be protected.\n- Proxied records benefit from DDoS protection, WAF, and caching\n- Origin server IP addresses are hidden from public DNS queries\n- Apply defense in depth by combining proxy protection with origin hardening\n- Some record types (MX, TXT) cannot be proxied by design",
|
||||
"Url": "https://hub.prowler.com/checks/cloudflare/dns_record_proxied"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"internet-exposed"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": "Only A, AAAA, and CNAME records can be proxied. MX, TXT, and other record types are always DNS-only. Some services may require DNS-only mode for specific use cases."
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
from prowler.lib.check.models import Check, CheckReportCloudflare
|
||||
from prowler.providers.cloudflare.services.dns.dns_client import dns_client
|
||||
|
||||
PROXYABLE_TYPES = {"A", "AAAA", "CNAME"}
|
||||
|
||||
|
||||
class dns_record_proxied(Check):
|
||||
"""Ensure that DNS records are proxied through Cloudflare.
|
||||
|
||||
Proxying DNS records through Cloudflare hides the origin server's IP address
|
||||
and provides DDoS protection, WAF capabilities, and performance optimizations.
|
||||
Non-proxied (DNS-only) records expose the origin IP directly, bypassing
|
||||
Cloudflare's security features and making the origin vulnerable to direct
|
||||
attacks.
|
||||
"""
|
||||
|
||||
def execute(self) -> list[CheckReportCloudflare]:
|
||||
"""Execute the DNS record proxy status check.
|
||||
|
||||
Iterates through all proxyable DNS records (A, AAAA, CNAME) and verifies
|
||||
that they are configured to be proxied through Cloudflare. Non-proxied
|
||||
records bypass Cloudflare's security and performance features.
|
||||
|
||||
Returns:
|
||||
A list of CheckReportCloudflare objects with PASS status if the
|
||||
record is proxied through Cloudflare, or FAIL status if it is
|
||||
DNS-only (not proxied).
|
||||
"""
|
||||
findings = []
|
||||
|
||||
for record in dns_client.records:
|
||||
# Only check proxyable record types
|
||||
if record.type not in PROXYABLE_TYPES:
|
||||
continue
|
||||
|
||||
report = CheckReportCloudflare(
|
||||
metadata=self.metadata(),
|
||||
resource=record,
|
||||
)
|
||||
|
||||
if record.proxied:
|
||||
report.status = "PASS"
|
||||
report.status_extended = f"DNS record {record.name} ({record.type}) is proxied through Cloudflare."
|
||||
else:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = f"DNS record {record.name} ({record.type}) is not proxied through Cloudflare."
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
@@ -4,7 +4,6 @@ from pydantic import BaseModel
|
||||
|
||||
from prowler.lib.logger import logger
|
||||
from prowler.providers.cloudflare.lib.service.service import CloudflareService
|
||||
from prowler.providers.cloudflare.services.zone.zone_client import zone_client
|
||||
|
||||
|
||||
class DNS(CloudflareService):
|
||||
@@ -19,10 +18,13 @@ class DNS(CloudflareService):
|
||||
"""List DNS records for all zones."""
|
||||
logger.info("DNS - Listing DNS records...")
|
||||
try:
|
||||
for zone in zone_client.zones.values():
|
||||
# Get zones directly from API to avoid circular dependency with zone_client
|
||||
zones = self._get_zones()
|
||||
|
||||
for zone_id, zone_name in zones.items():
|
||||
seen_record_ids: set[str] = set()
|
||||
try:
|
||||
for record in self.client.dns.records.list(zone_id=zone.id):
|
||||
for record in self.client.dns.records.list(zone_id=zone_id):
|
||||
record_id = getattr(record, "id", None)
|
||||
# Prevent infinite loop
|
||||
if record_id in seen_record_ids:
|
||||
@@ -32,8 +34,8 @@ class DNS(CloudflareService):
|
||||
self.records.append(
|
||||
CloudflareDNSRecord(
|
||||
id=record_id,
|
||||
zone_id=zone.id,
|
||||
zone_name=zone.name,
|
||||
zone_id=zone_id,
|
||||
zone_name=zone_name,
|
||||
name=getattr(record, "name", None),
|
||||
type=getattr(record, "type", None),
|
||||
content=getattr(record, "content", ""),
|
||||
@@ -43,13 +45,57 @@ class DNS(CloudflareService):
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{zone.id} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
f"{zone_id} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
|
||||
def _get_zones(self) -> dict[str, str]:
|
||||
"""Get zones directly from Cloudflare API.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping zone_id to zone_name.
|
||||
"""
|
||||
zones = {}
|
||||
audited_accounts = self.provider.identity.audited_accounts
|
||||
filter_zones = self.provider.filter_zones
|
||||
seen_zone_ids: set[str] = set()
|
||||
|
||||
try:
|
||||
for zone in self.client.zones.list():
|
||||
zone_id = getattr(zone, "id", None)
|
||||
# Prevent infinite loop - skip if we've seen this zone
|
||||
if zone_id in seen_zone_ids:
|
||||
break
|
||||
seen_zone_ids.add(zone_id)
|
||||
|
||||
zone_account = getattr(zone, "account", None)
|
||||
account_id = getattr(zone_account, "id", None) if zone_account else None
|
||||
|
||||
# Filter by audited accounts
|
||||
if audited_accounts and account_id not in audited_accounts:
|
||||
continue
|
||||
|
||||
zone_name = getattr(zone, "name", None)
|
||||
|
||||
# Apply zone filter if specified via --region
|
||||
if (
|
||||
filter_zones
|
||||
and zone_id not in filter_zones
|
||||
and zone_name not in filter_zones
|
||||
):
|
||||
continue
|
||||
|
||||
zones[zone_id] = zone_name
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
|
||||
return zones
|
||||
|
||||
|
||||
class CloudflareDNSRecord(BaseModel):
|
||||
"""Cloudflare DNS record representation."""
|
||||
|
||||
Whitespace-only changes.
@@ -0,0 +1,4 @@
|
||||
from prowler.providers.cloudflare.services.firewall.firewall_service import Firewall
|
||||
from prowler.providers.common.provider import Provider
|
||||
|
||||
firewall_client = Firewall(Provider.get_global_provider())
|
||||
@@ -0,0 +1,123 @@
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from prowler.lib.logger import logger
|
||||
from prowler.providers.cloudflare.lib.service.service import CloudflareService
|
||||
|
||||
|
||||
class Firewall(CloudflareService):
|
||||
"""Retrieve Cloudflare firewall rules for all zones."""
|
||||
|
||||
def __init__(self, provider):
|
||||
super().__init__(__class__.__name__, provider)
|
||||
self.rules: list["CloudflareFirewallRule"] = []
|
||||
self._list_rulesets()
|
||||
|
||||
def _list_rulesets(self) -> None:
|
||||
"""List firewall rulesets for all zones."""
|
||||
logger.info("Firewall - Listing firewall rulesets...")
|
||||
try:
|
||||
# Get zones directly from API to avoid circular dependency with zone_client
|
||||
zones = self._get_zones()
|
||||
|
||||
for zone_id, zone_name in zones.items():
|
||||
try:
|
||||
# Get all rulesets for the zone
|
||||
rulesets = self.client.rulesets.list(zone_id=zone_id)
|
||||
for ruleset in rulesets:
|
||||
ruleset_id = getattr(ruleset, "id", None)
|
||||
phase = getattr(ruleset, "phase", None)
|
||||
if not ruleset_id:
|
||||
continue
|
||||
|
||||
# Get rules within each ruleset
|
||||
try:
|
||||
ruleset_detail = self.client.rulesets.get(
|
||||
ruleset_id=ruleset_id, zone_id=zone_id
|
||||
)
|
||||
rules = getattr(ruleset_detail, "rules", []) or []
|
||||
for rule in rules:
|
||||
self.rules.append(
|
||||
CloudflareFirewallRule(
|
||||
id=getattr(rule, "id", None),
|
||||
zone_id=zone_id,
|
||||
zone_name=zone_name,
|
||||
ruleset_id=ruleset_id,
|
||||
phase=phase,
|
||||
action=getattr(rule, "action", None),
|
||||
expression=getattr(rule, "expression", None),
|
||||
description=getattr(rule, "description", None),
|
||||
enabled=getattr(rule, "enabled", True),
|
||||
)
|
||||
)
|
||||
except Exception as error:
|
||||
logger.debug(
|
||||
f"{zone_id} ruleset {ruleset_id} -- {error.__class__.__name__}: {error}"
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{zone_id} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
|
||||
def _get_zones(self) -> dict[str, str]:
|
||||
"""Get zones directly from Cloudflare API.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping zone_id to zone_name.
|
||||
"""
|
||||
zones = {}
|
||||
audited_accounts = self.provider.identity.audited_accounts
|
||||
filter_zones = self.provider.filter_zones
|
||||
seen_zone_ids: set[str] = set()
|
||||
|
||||
try:
|
||||
for zone in self.client.zones.list():
|
||||
zone_id = getattr(zone, "id", None)
|
||||
# Prevent infinite loop - skip if we've seen this zone
|
||||
if zone_id in seen_zone_ids:
|
||||
break
|
||||
seen_zone_ids.add(zone_id)
|
||||
|
||||
zone_account = getattr(zone, "account", None)
|
||||
account_id = getattr(zone_account, "id", None) if zone_account else None
|
||||
|
||||
# Filter by audited accounts
|
||||
if audited_accounts and account_id not in audited_accounts:
|
||||
continue
|
||||
|
||||
zone_name = getattr(zone, "name", None)
|
||||
|
||||
# Apply zone filter if specified via --region
|
||||
if (
|
||||
filter_zones
|
||||
and zone_id not in filter_zones
|
||||
and zone_name not in filter_zones
|
||||
):
|
||||
continue
|
||||
|
||||
zones[zone_id] = zone_name
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
|
||||
return zones
|
||||
|
||||
|
||||
class CloudflareFirewallRule(BaseModel):
|
||||
"""Cloudflare firewall rule representation."""
|
||||
|
||||
id: Optional[str] = None
|
||||
zone_id: str
|
||||
zone_name: str
|
||||
ruleset_id: Optional[str] = None
|
||||
phase: Optional[str] = None
|
||||
action: Optional[str] = None
|
||||
expression: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
enabled: bool = True
|
||||
Whitespace-only changes.
+36
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"Provider": "cloudflare",
|
||||
"CheckID": "zone_firewall_blocking_rules_configured",
|
||||
"CheckTitle": "Cloudflare Zone Firewall Rules Use Blocking Actions to Protect Against Threats",
|
||||
"CheckType": [],
|
||||
"ServiceName": "zone",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "Zone",
|
||||
"ResourceGroup": "network",
|
||||
"Description": "**Cloudflare zones** are assessed for **firewall blocking rules** by checking if custom rules use block, challenge, js_challenge, or managed_challenge actions to actively protect against threats rather than only logging.",
|
||||
"Risk": "Firewall rules configured only for **logging** provide visibility but no protection.\n- **Confidentiality**: malicious traffic can access and exfiltrate sensitive data\n- **Integrity**: application exploits can modify data without being blocked\n- **Availability**: credential stuffing and abuse attacks reach the origin unimpeded",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://developers.cloudflare.com/waf/custom-rules/"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Log in to the Cloudflare dashboard and select your account and domain\n2. Go to Security > WAF > Custom rules\n3. Review existing rules and their actions\n4. Update rules to use blocking actions (Block, Challenge, JS Challenge, Managed Challenge)\n5. Test rules in log mode first, then enable blocking actions",
|
||||
"Terraform": "```hcl\n# Configure firewall rule with blocking action\nresource \"cloudflare_ruleset\" \"blocking_rule\" {\n zone_id = \"<ZONE_ID>\"\n name = \"Block malicious requests\"\n kind = \"zone\"\n phase = \"http_request_firewall_custom\"\n rules {\n action = \"block\" # Actively blocks matching traffic\n expression = \"(ip.geoip.country eq \\\"XX\\\")\"\n description = \"Block traffic from high-risk country\"\n }\n}\n```"
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Configure **firewall rules** with blocking actions to enforce security policies.\n- Use challenge actions for suspicious traffic to verify human visitors\n- Use block actions for known malicious patterns and high-risk sources\n- Test rules in log mode before enabling blocking to avoid false positives\n- Follow the principle of least privilege in rule configuration",
|
||||
"Url": "https://hub.prowler.com/checks/cloudflare/zone_firewall_blocking_rules_configured"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"internet-exposed"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": "Blocking actions include: block, challenge, js_challenge, managed_challenge. Log-only rules provide visibility but do not prevent attacks."
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
from prowler.lib.check.models import Check, CheckReportCloudflare
|
||||
from prowler.providers.cloudflare.services.zone.zone_client import zone_client
|
||||
|
||||
BLOCKING_ACTIONS = {"block", "challenge", "js_challenge", "managed_challenge"}
|
||||
|
||||
|
||||
class zone_firewall_blocking_rules_configured(Check):
|
||||
"""Ensure that firewall rules with blocking actions are configured for Cloudflare zones.
|
||||
|
||||
Firewall rules should use blocking actions (block, challenge, js_challenge,
|
||||
managed_challenge) to actively protect against threats rather than only logging
|
||||
traffic. Without blocking actions, malicious requests can reach the origin server
|
||||
and potentially compromise the application's security.
|
||||
"""
|
||||
|
||||
def execute(self) -> list[CheckReportCloudflare]:
|
||||
"""Execute the firewall blocking rules configured check.
|
||||
|
||||
Iterates through all Cloudflare zones and verifies that at least one
|
||||
firewall rule exists with a blocking action. Blocking actions include
|
||||
block, challenge, js_challenge, and managed_challenge.
|
||||
|
||||
Returns:
|
||||
A list of CheckReportCloudflare objects with PASS status if blocking
|
||||
rules are configured, or FAIL status if no blocking rules exist.
|
||||
"""
|
||||
findings = []
|
||||
|
||||
for zone in zone_client.zones.values():
|
||||
report = CheckReportCloudflare(
|
||||
metadata=self.metadata(),
|
||||
resource=zone,
|
||||
)
|
||||
|
||||
# Find blocking rules for this zone
|
||||
blocking_rules = [
|
||||
rule for rule in zone.firewall_rules if rule.action in BLOCKING_ACTIONS
|
||||
]
|
||||
|
||||
if blocking_rules:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Zone {zone.name} has firewall rules with blocking actions "
|
||||
f"({len(blocking_rules)} rule(s))."
|
||||
)
|
||||
else:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Zone {zone.name} has no firewall rules with blocking actions."
|
||||
)
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
@@ -17,6 +17,31 @@ class CloudflareRateLimitRule(BaseModel):
|
||||
expression: Optional[str] = None
|
||||
|
||||
|
||||
class CloudflareFirewallRule(BaseModel):
|
||||
"""Represents a firewall rule from custom rulesets."""
|
||||
|
||||
id: str
|
||||
name: str = ""
|
||||
description: Optional[str] = None
|
||||
action: Optional[str] = None
|
||||
enabled: bool = True
|
||||
expression: Optional[str] = None
|
||||
phase: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
arbitrary_types_allowed = True
|
||||
|
||||
|
||||
class CloudflareWAFRuleset(BaseModel):
|
||||
"""Represents a WAF ruleset (managed rules) for a zone."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
kind: Optional[str] = None
|
||||
phase: Optional[str] = None
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class Zone(CloudflareService):
|
||||
"""Retrieve Cloudflare zones with security-relevant settings."""
|
||||
|
||||
@@ -29,6 +54,8 @@ class Zone(CloudflareService):
|
||||
self._get_zones_universal_ssl()
|
||||
self._get_zones_rate_limit_rules()
|
||||
self._get_zones_bot_management()
|
||||
self._get_zones_firewall_rules()
|
||||
self._get_zones_waf_rulesets()
|
||||
|
||||
def _list_zones(self) -> None:
|
||||
"""List all Cloudflare zones with their basic information."""
|
||||
@@ -191,6 +218,109 @@ class Zone(CloudflareService):
|
||||
f"{zone.id} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
|
||||
def _get_zones_firewall_rules(self) -> None:
|
||||
"""Get firewall rules for all zones."""
|
||||
logger.info("Zones - Getting firewall rules...")
|
||||
for zone in self.zones.values():
|
||||
try:
|
||||
self._get_zone_firewall_rules(zone)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{zone.id} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
|
||||
def _get_zone_firewall_rules(self, zone: "CloudflareZone") -> None:
|
||||
"""List firewall rules from custom rulesets for a zone."""
|
||||
seen_ruleset_ids: set[str] = set()
|
||||
try:
|
||||
for ruleset in self.client.rulesets.list(zone_id=zone.id):
|
||||
ruleset_id = getattr(ruleset, "id", "")
|
||||
if ruleset_id in seen_ruleset_ids:
|
||||
break
|
||||
seen_ruleset_ids.add(ruleset_id)
|
||||
|
||||
ruleset_phase = getattr(ruleset, "phase", "")
|
||||
if ruleset_phase in [
|
||||
"http_request_firewall_custom",
|
||||
"http_ratelimit",
|
||||
"http_request_firewall_managed",
|
||||
]:
|
||||
try:
|
||||
ruleset_detail = self.client.rulesets.get(
|
||||
ruleset_id=ruleset_id, zone_id=zone.id
|
||||
)
|
||||
rules = getattr(ruleset_detail, "rules", []) or []
|
||||
seen_rule_ids: set[str] = set()
|
||||
for rule in rules:
|
||||
rule_id = getattr(rule, "id", "")
|
||||
if rule_id in seen_rule_ids:
|
||||
break
|
||||
seen_rule_ids.add(rule_id)
|
||||
try:
|
||||
zone.firewall_rules.append(
|
||||
CloudflareFirewallRule(
|
||||
id=rule_id,
|
||||
name=getattr(rule, "description", "")
|
||||
or rule_id,
|
||||
description=getattr(rule, "description", None),
|
||||
action=getattr(rule, "action", None),
|
||||
enabled=getattr(rule, "enabled", True),
|
||||
expression=getattr(rule, "expression", None),
|
||||
phase=ruleset_phase,
|
||||
)
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
|
||||
def _get_zones_waf_rulesets(self) -> None:
|
||||
"""Get WAF rulesets for all zones."""
|
||||
logger.info("Zones - Getting WAF rulesets...")
|
||||
for zone in self.zones.values():
|
||||
try:
|
||||
self._get_zone_waf_rulesets(zone)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{zone.id} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
|
||||
def _get_zone_waf_rulesets(self, zone: "CloudflareZone") -> None:
|
||||
"""List WAF rulesets for a zone using the rulesets API."""
|
||||
seen_ids: set[str] = set()
|
||||
try:
|
||||
for ruleset in self.client.rulesets.list(zone_id=zone.id):
|
||||
ruleset_id = getattr(ruleset, "id", "")
|
||||
if ruleset_id in seen_ids:
|
||||
break
|
||||
seen_ids.add(ruleset_id)
|
||||
try:
|
||||
zone.waf_rulesets.append(
|
||||
CloudflareWAFRuleset(
|
||||
id=ruleset_id,
|
||||
name=getattr(ruleset, "name", ""),
|
||||
kind=getattr(ruleset, "kind", None),
|
||||
phase=getattr(ruleset, "phase", None),
|
||||
enabled=True,
|
||||
)
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
|
||||
def _get_zone_setting(self, zone_id: str, setting_id: str):
|
||||
"""Get a single zone setting by ID."""
|
||||
try:
|
||||
@@ -326,3 +456,5 @@ class CloudflareZone(BaseModel):
|
||||
settings: CloudflareZoneSettings = Field(default_factory=CloudflareZoneSettings)
|
||||
dnssec_status: Optional[str] = None
|
||||
rate_limit_rules: list[CloudflareRateLimitRule] = Field(default_factory=list)
|
||||
firewall_rules: list[CloudflareFirewallRule] = Field(default_factory=list)
|
||||
waf_rulesets: list[CloudflareWAFRuleset] = Field(default_factory=list)
|
||||
Whitespace-only changes.
+36
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"Provider": "cloudflare",
|
||||
"CheckID": "zone_waf_owasp_ruleset_enabled",
|
||||
"CheckTitle": "Cloudflare Zone OWASP Managed WAF Rulesets Are Enabled",
|
||||
"CheckType": [],
|
||||
"ServiceName": "zone",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "high",
|
||||
"ResourceType": "Zone",
|
||||
"ResourceGroup": "network",
|
||||
"Description": "**Cloudflare zones** are assessed for **OWASP managed rulesets** by checking if they are enabled to protect against common web application vulnerabilities including **SQL injection**, **XSS**, and other **OWASP Top 10** threats.",
|
||||
"Risk": "Without **OWASP managed rulesets**, web applications are exposed to well-known attack vectors.\n- **Confidentiality**: SQL injection attacks can exfiltrate sensitive database contents\n- **Integrity**: XSS attacks can modify page content and steal session tokens\n- **Availability**: remote code execution can compromise server availability",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://developers.cloudflare.com/waf/managed-rules/"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Log in to the Cloudflare dashboard and select your account and domain\n2. Go to Security > WAF > Managed rules\n3. Enable the Cloudflare OWASP Core Ruleset\n4. Review and configure rule sensitivity based on your application\n5. Monitor WAF analytics to tune rules and reduce false positives",
|
||||
"Terraform": "```hcl\n# Enable OWASP managed WAF rulesets\nresource \"cloudflare_ruleset\" \"waf_owasp\" {\n zone_id = \"<ZONE_ID>\"\n name = \"OWASP Managed Rules\"\n kind = \"zone\"\n phase = \"http_request_firewall_managed\"\n rules {\n action = \"execute\"\n action_parameters {\n id = \"4814384a9e5d4991b9815dcfc25d2f1f\" # Cloudflare OWASP Core Ruleset\n }\n expression = \"true\"\n description = \"Execute Cloudflare OWASP Core Ruleset\"\n }\n}\n```"
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Enable **OWASP Core Ruleset** managed rules as part of a defense in depth strategy.\n- Protects against OWASP Top 10 vulnerabilities including SQLi and XSS\n- Regularly review and tune rule sensitivity based on application requirements\n- Monitor WAF analytics to identify and address false positives\n- Combine with custom rules for application-specific protection",
|
||||
"Url": "https://hub.prowler.com/checks/cloudflare/zone_waf_owasp_ruleset_enabled"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"vulnerabilities"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": "OWASP managed rulesets are available on Pro, Business, and Enterprise plans. The Cloudflare OWASP Core Ruleset provides protection against common web application vulnerabilities."
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
from prowler.lib.check.models import Check, CheckReportCloudflare
|
||||
from prowler.providers.cloudflare.services.zone.zone_client import zone_client
|
||||
|
||||
|
||||
class zone_waf_owasp_ruleset_enabled(Check):
|
||||
"""Ensure that OWASP managed WAF rulesets are enabled for Cloudflare zones.
|
||||
|
||||
The OWASP Core Ruleset provides protection against common web application
|
||||
vulnerabilities including SQL injection, cross-site scripting (XSS), and other
|
||||
OWASP Top 10 threats. These managed rulesets are essential for defense in depth
|
||||
and protecting applications from well-known attack vectors.
|
||||
"""
|
||||
|
||||
def execute(self) -> list[CheckReportCloudflare]:
|
||||
"""Execute the OWASP WAF ruleset enabled check.
|
||||
|
||||
Iterates through all Cloudflare zones and verifies that OWASP managed
|
||||
WAF rulesets are enabled. The check identifies OWASP rulesets by name
|
||||
containing "owasp" or by the http_request_firewall_managed phase.
|
||||
|
||||
Returns:
|
||||
A list of CheckReportCloudflare objects with PASS status if OWASP
|
||||
rulesets are enabled, or FAIL status if no OWASP protection exists.
|
||||
"""
|
||||
findings = []
|
||||
|
||||
for zone in zone_client.zones.values():
|
||||
report = CheckReportCloudflare(
|
||||
metadata=self.metadata(),
|
||||
resource=zone,
|
||||
)
|
||||
|
||||
# Find OWASP managed rulesets for this zone
|
||||
# Only match rulesets that explicitly contain "owasp" in the name
|
||||
# The phase check was too broad as it matched any managed ruleset
|
||||
owasp_rulesets = [
|
||||
ruleset
|
||||
for ruleset in zone.waf_rulesets
|
||||
if "owasp" in (ruleset.name or "").lower()
|
||||
]
|
||||
|
||||
if owasp_rulesets:
|
||||
report.status = "PASS"
|
||||
ruleset_descriptions = ", ".join(
|
||||
ruleset.name for ruleset in owasp_rulesets
|
||||
)
|
||||
report.status_extended = (
|
||||
f"Zone {zone.name} has OWASP managed WAF ruleset enabled: "
|
||||
f"{ruleset_descriptions}."
|
||||
)
|
||||
else:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Zone {zone.name} does not have OWASP managed WAF ruleset enabled."
|
||||
)
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
@@ -56,15 +56,18 @@ allowed-tools: Read, Edit, Write, Glob, Grep, Bash
|
||||
```markdown
|
||||
### Added
|
||||
|
||||
- First entry [(#XXXX)](https://github.com/prowler-cloud/prowler/pull/XXXX)
|
||||
- Second entry [(#YYYY)](https://github.com/prowler-cloud/prowler/pull/YYYY)
|
||||
- Existing entry one [(#XXXX)](https://github.com/prowler-cloud/prowler/pull/XXXX)
|
||||
- Existing entry two [(#YYYY)](https://github.com/prowler-cloud/prowler/pull/YYYY)
|
||||
- NEW ENTRY GOES HERE at the BOTTOM [(#ZZZZ)](https://github.com/prowler-cloud/prowler/pull/ZZZZ)
|
||||
|
||||
### Changed
|
||||
|
||||
- Another entry [(#ZZZZ)](https://github.com/prowler-cloud/prowler/pull/ZZZZ)
|
||||
- Existing change [(#AAAA)](https://github.com/prowler-cloud/prowler/pull/AAAA)
|
||||
- NEW CHANGE ENTRY at BOTTOM [(#BBBB)](https://github.com/prowler-cloud/prowler/pull/BBBB)
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
- **ADD NEW ENTRIES AT THE BOTTOM of each section** (before next section header or `---`)
|
||||
- **Blank line after section header** before first entry
|
||||
- **Blank line between sections**
|
||||
- Be specific: what changed, not why (that's in the PR)
|
||||
@@ -138,20 +141,24 @@ git diff main...HEAD --name-only
|
||||
| Feature removal | ❌ Removed |
|
||||
| Deprecation notice | ⚠️ Deprecated |
|
||||
|
||||
### Step 3: Add Entry to UNRELEASED Section
|
||||
### Step 3: Add Entry at BOTTOM of Appropriate Section
|
||||
|
||||
```bash
|
||||
# Example: Adding a fix to UI changelog
|
||||
```
|
||||
**CRITICAL:** Add new entries at the BOTTOM of each section, NOT at the top.
|
||||
|
||||
```markdown
|
||||
## [1.17.0] (Prowler UNRELEASED)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- Button alignment in dashboard header [(#9999)](https://github.com/prowler-cloud/prowler/pull/9999)
|
||||
- Existing fix one [(#9997)](https://github.com/prowler-cloud/prowler/pull/9997)
|
||||
- Existing fix two [(#9998)](https://github.com/prowler-cloud/prowler/pull/9998)
|
||||
- Button alignment in dashboard header [(#9999)](https://github.com/prowler-cloud/prowler/pull/9999) ← NEW ENTRY AT BOTTOM
|
||||
|
||||
### 🔐 Security
|
||||
```
|
||||
|
||||
This maintains chronological order within each section (oldest at top, newest at bottom).
|
||||
|
||||
## Examples
|
||||
|
||||
### Good Entries
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# Changelog Entry Templates
|
||||
|
||||
## Entry Placement Rule
|
||||
|
||||
**CRITICAL:** Always add new entries at the **BOTTOM** of each section (before the next section header or `---`).
|
||||
|
||||
This maintains chronological order: oldest entries at top, newest at bottom.
|
||||
|
||||
## Section Headers
|
||||
|
||||
```markdown
|
||||
@@ -68,18 +74,24 @@
|
||||
|
||||
- Search bar when adding a provider [(#9634)](https://github.com/prowler-cloud/prowler/pull/9634)
|
||||
- New findings table UI with new design system components [(#9699)](https://github.com/prowler-cloud/prowler/pull/9699)
|
||||
- YOUR NEW ENTRY GOES HERE AT BOTTOM [(#XXXX)](https://github.com/prowler-cloud/prowler/pull/XXXX)
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
- Lighthouse AI MCP tool filtering from blacklist to whitelist approach [(#9802)](https://github.com/prowler-cloud/prowler/pull/9802)
|
||||
- YOUR NEW CHANGE GOES HERE AT BOTTOM [(#XXXX)](https://github.com/prowler-cloud/prowler/pull/XXXX)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- OCI update credentials form failing silently due to missing provider UID [(#9746)](https://github.com/prowler-cloud/prowler/pull/9746)
|
||||
- YOUR NEW FIX GOES HERE AT BOTTOM [(#XXXX)](https://github.com/prowler-cloud/prowler/pull/XXXX)
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
- Node.js from 20.x to 24.13.0 LTS, patching 8 CVEs [(#9797)](https://github.com/prowler-cloud/prowler/pull/9797)
|
||||
- YOUR NEW SECURITY FIX GOES HERE AT BOTTOM [(#XXXX)](https://github.com/prowler-cloud/prowler/pull/XXXX)
|
||||
|
||||
---
|
||||
```
|
||||
|
||||
> **Remember:** Each new entry is added at the BOTTOM of its section to maintain chronological order.
|
||||
@@ -34,6 +34,7 @@ Frameworks are JSON files located in: `prowler/compliance/{provider}/{framework_
|
||||
- `github` - GitHub
|
||||
- `m365` - Microsoft 365
|
||||
- `alibabacloud` - Alibaba Cloud
|
||||
- `cloudflare` - Cloudflare
|
||||
- `oraclecloud` - Oracle Cloud
|
||||
- `oci` - Oracle Cloud Infrastructure
|
||||
- `nhn` - NHN Cloud
|
||||
|
||||
@@ -117,6 +117,7 @@ Current providers:
|
||||
- M365 (Microsoft 365)
|
||||
- OracleCloud (Oracle Cloud Infrastructure)
|
||||
- AlibabaCloud
|
||||
- Cloudflare
|
||||
- MongoDB Atlas
|
||||
- NHN (NHN Cloud)
|
||||
- LLM (Language Model providers)
|
||||
|
||||
@@ -26,11 +26,43 @@ ui/tests/
|
||||
└── {page-name}/
|
||||
├── {page-name}-page.ts # Page Object Model
|
||||
├── {page-name}.spec.ts # ALL tests (single file per feature)
|
||||
└── {page-name}.md # Test documentation
|
||||
└── {page-name}.md # Test documentation (MANDATORY - sync with spec.ts)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## MANDATORY Checklist (Create or Modify Tests)
|
||||
|
||||
**⚠️ ALWAYS verify BEFORE completing any E2E task:**
|
||||
|
||||
### When CREATING new tests:
|
||||
- [ ] `{page-name}-page.ts` - Page Object created/updated
|
||||
- [ ] `{page-name}.spec.ts` - Tests added with correct tags (@TEST-ID)
|
||||
- [ ] `{page-name}.md` - Documentation created with ALL test cases
|
||||
- [ ] Test IDs in `.md` match tags in `.spec.ts`
|
||||
|
||||
### When MODIFYING existing tests:
|
||||
- [ ] `{page-name}.md` MUST be updated if:
|
||||
- Test cases were added/removed
|
||||
- Test flow changed (steps)
|
||||
- Preconditions or expected results changed
|
||||
- Tags or priorities changed
|
||||
- [ ] Test IDs synchronized between `.md` and `.spec.ts`
|
||||
|
||||
### Quick validation:
|
||||
```bash
|
||||
# Verify .md exists for each test folder
|
||||
ls ui/tests/{feature}/{feature}.md
|
||||
|
||||
# Verify test IDs match
|
||||
grep -o "@[A-Z]*-E2E-[0-9]*" ui/tests/{feature}/{feature}.spec.ts | sort -u
|
||||
grep -o "\`[A-Z]*-E2E-[0-9]*\`" ui/tests/{feature}/{feature}.md | sort -u
|
||||
```
|
||||
|
||||
**❌ An E2E change is NOT considered complete without updating the corresponding .md file**
|
||||
|
||||
---
|
||||
|
||||
## MCP Workflow - CRITICAL
|
||||
|
||||
**⚠️ MANDATORY: If Playwright MCP tools are available, ALWAYS use them BEFORE creating tests.**
|
||||
@@ -45,6 +77,33 @@ ui/tests/
|
||||
|
||||
---
|
||||
|
||||
## Wait Strategies (CRITICAL)
|
||||
|
||||
**⚠️ NEVER use `networkidle` - it causes flaky tests!**
|
||||
|
||||
| Strategy | Use Case |
|
||||
|----------|----------|
|
||||
| ❌ `networkidle` | NEVER - flaky with polling/WebSockets |
|
||||
| ⚠️ `load` | Only when absolutely necessary |
|
||||
| ✅ `expect(element).toBeVisible()` | PREFERRED - wait for specific UI state |
|
||||
| ✅ `page.waitForURL()` | Wait for navigation |
|
||||
| ✅ `pageObject.verifyPageLoaded()` | BEST - encapsulated verification |
|
||||
|
||||
**GOOD:**
|
||||
```typescript
|
||||
await homePage.verifyPageLoaded();
|
||||
await expect(page).toHaveURL("/dashboard");
|
||||
await expect(page.getByRole("heading", { name: "Overview" })).toBeVisible();
|
||||
```
|
||||
|
||||
**BAD:**
|
||||
```typescript
|
||||
await page.waitForLoadState("networkidle"); // ❌ FLAKY
|
||||
await page.waitForTimeout(2000); // ❌ ARBITRARY WAIT
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Prowler Base Page
|
||||
|
||||
```typescript
|
||||
@@ -55,11 +114,12 @@ export class BasePage {
|
||||
|
||||
async goto(path: string): Promise<void> {
|
||||
await this.page.goto(path);
|
||||
await this.page.waitForLoadState("networkidle");
|
||||
// Child classes should override verifyPageLoaded() to wait for specific elements
|
||||
}
|
||||
|
||||
async waitForPageLoad(): Promise<void> {
|
||||
await this.page.waitForLoadState("networkidle");
|
||||
// Override in child classes to wait for page-specific elements
|
||||
async verifyPageLoaded(): Promise<void> {
|
||||
await expect(this.page.locator("main")).toBeVisible();
|
||||
}
|
||||
|
||||
// Prowler-specific: notification handling
|
||||
@@ -78,6 +138,33 @@ export class BasePage {
|
||||
|
||||
---
|
||||
|
||||
## Page Navigation Verification Pattern
|
||||
|
||||
**⚠️ URL assertions belong in Page Objects, NOT in tests!**
|
||||
|
||||
When verifying redirects or page navigation, create dedicated methods in the target Page Object:
|
||||
|
||||
```typescript
|
||||
// ✅ GOOD - In SignInPage
|
||||
async verifyOnSignInPage(): Promise<void> {
|
||||
await expect(this.page).toHaveURL(/\/sign-in/);
|
||||
await expect(this.pageTitle).toBeVisible();
|
||||
}
|
||||
|
||||
// ✅ GOOD - In test
|
||||
await homePage.goto(); // Try to access protected route
|
||||
await signInPage.verifyOnSignInPage(); // Verify redirect
|
||||
|
||||
// ❌ BAD - Direct assertions in test
|
||||
await homePage.goto();
|
||||
await expect(page).toHaveURL(/\/sign-in/); // Should be in Page Object
|
||||
await expect(page.getByText("Sign in")).toBeVisible();
|
||||
```
|
||||
|
||||
**Naming convention:** `verifyOn{PageName}Page()` for redirect verification methods.
|
||||
|
||||
---
|
||||
|
||||
## Prowler-Specific Pages
|
||||
|
||||
### Providers Page
|
||||
|
||||
@@ -46,7 +46,7 @@ docker-compose up -d
|
||||
|
||||
## Providers
|
||||
|
||||
AWS, Azure, GCP, Kubernetes, GitHub, M365, OCI, AlibabaCloud, MongoDB Atlas, IaC
|
||||
AWS, Azure, GCP, Kubernetes, GitHub, M365, OCI, AlibabaCloud, Cloudflare, MongoDB Atlas, NHN, LLM, IaC
|
||||
|
||||
## Commit Style
|
||||
|
||||
|
||||
Whitespace-only changes.
@@ -0,0 +1,163 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from prowler.lib.timeline.models import TimelineEvent
|
||||
|
||||
|
||||
class TestTimelineEvent:
|
||||
"""Tests for TimelineEvent model."""
|
||||
|
||||
def test_minimal_event(self):
|
||||
"""Test creating an event with only required fields."""
|
||||
event = TimelineEvent(
|
||||
event_id="test-event-id-123",
|
||||
event_time=datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc),
|
||||
event_name="CreateResource",
|
||||
event_source="service.example.com",
|
||||
actor="user@example.com",
|
||||
actor_type="User",
|
||||
)
|
||||
|
||||
assert event.event_id == "test-event-id-123"
|
||||
assert event.event_time == datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc)
|
||||
assert event.event_name == "CreateResource"
|
||||
assert event.event_source == "service.example.com"
|
||||
assert event.actor == "user@example.com"
|
||||
assert event.actor_type == "User"
|
||||
# Optional fields should be None
|
||||
assert event.actor_uid is None
|
||||
assert event.source_ip_address is None
|
||||
assert event.user_agent is None
|
||||
assert event.request_data is None
|
||||
assert event.response_data is None
|
||||
assert event.error_code is None
|
||||
assert event.error_message is None
|
||||
|
||||
def test_full_event(self):
|
||||
"""Test creating an event with all fields populated."""
|
||||
event = TimelineEvent(
|
||||
event_id="full-event-id-456",
|
||||
event_time=datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc),
|
||||
event_name="ModifyResource",
|
||||
event_source="storage.example.com",
|
||||
actor="admin-role",
|
||||
actor_uid="arn:aws:sts::123456789012:assumed-role/admin-role/session",
|
||||
actor_type="AssumedRole",
|
||||
source_ip_address="192.168.1.100",
|
||||
user_agent="aws-cli/2.0.0",
|
||||
request_data={"bucket": "my-bucket", "acl": "private"},
|
||||
response_data={"status": "success"},
|
||||
error_code=None,
|
||||
error_message=None,
|
||||
)
|
||||
|
||||
assert event.event_id == "full-event-id-456"
|
||||
assert (
|
||||
event.actor_uid
|
||||
== "arn:aws:sts::123456789012:assumed-role/admin-role/session"
|
||||
)
|
||||
assert event.source_ip_address == "192.168.1.100"
|
||||
assert event.user_agent == "aws-cli/2.0.0"
|
||||
assert event.request_data == {"bucket": "my-bucket", "acl": "private"}
|
||||
assert event.response_data == {"status": "success"}
|
||||
|
||||
def test_error_event(self):
|
||||
"""Test creating an event that represents a failed operation."""
|
||||
event = TimelineEvent(
|
||||
event_id="error-event-id-789",
|
||||
event_time=datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc),
|
||||
event_name="DeleteResource",
|
||||
event_source="storage.example.com",
|
||||
actor="unauthorized-user",
|
||||
actor_type="User",
|
||||
error_code="AccessDenied",
|
||||
error_message="User does not have permission to delete this resource",
|
||||
)
|
||||
|
||||
assert event.error_code == "AccessDenied"
|
||||
assert (
|
||||
event.error_message
|
||||
== "User does not have permission to delete this resource"
|
||||
)
|
||||
|
||||
def test_event_to_dict(self):
|
||||
"""Test that event can be serialized to dictionary."""
|
||||
event = TimelineEvent(
|
||||
event_id="dict-test-id",
|
||||
event_time=datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc),
|
||||
event_name="CreateResource",
|
||||
event_source="service.example.com",
|
||||
actor="user@example.com",
|
||||
actor_type="User",
|
||||
)
|
||||
|
||||
event_dict = event.dict()
|
||||
|
||||
assert event_dict["event_id"] == "dict-test-id"
|
||||
assert event_dict["event_name"] == "CreateResource"
|
||||
assert event_dict["actor"] == "user@example.com"
|
||||
assert event_dict["actor_type"] == "User"
|
||||
|
||||
def test_event_from_dict(self):
|
||||
"""Test creating an event from a dictionary."""
|
||||
data = {
|
||||
"event_id": "from-dict-id",
|
||||
"event_time": datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc),
|
||||
"event_name": "UpdateResource",
|
||||
"event_source": "compute.example.com",
|
||||
"actor": "service-account",
|
||||
"actor_type": "ServiceAccount",
|
||||
}
|
||||
|
||||
event = TimelineEvent(**data)
|
||||
|
||||
assert event.event_id == "from-dict-id"
|
||||
assert event.event_name == "UpdateResource"
|
||||
assert event.actor == "service-account"
|
||||
assert event.actor_type == "ServiceAccount"
|
||||
|
||||
def test_required_fields_validation(self):
|
||||
"""Test that missing required fields raise validation error."""
|
||||
with pytest.raises(Exception): # Pydantic validation error
|
||||
TimelineEvent(
|
||||
event_id="validation-test",
|
||||
event_time=datetime.now(timezone.utc),
|
||||
event_name="CreateResource",
|
||||
# Missing: event_source, actor, actor_type
|
||||
)
|
||||
|
||||
def test_actor_types_are_flexible(self):
|
||||
"""Test that actor_type accepts any string value (provider-agnostic)."""
|
||||
# AWS-style
|
||||
aws_event = TimelineEvent(
|
||||
event_id="aws-event-id",
|
||||
event_time=datetime.now(timezone.utc),
|
||||
event_name="CreateBucket",
|
||||
event_source="s3.amazonaws.com",
|
||||
actor="arn:aws:iam::123456789012:user/admin",
|
||||
actor_type="IAMUser",
|
||||
)
|
||||
assert aws_event.actor_type == "IAMUser"
|
||||
|
||||
# Azure-style
|
||||
azure_event = TimelineEvent(
|
||||
event_id="azure-event-id",
|
||||
event_time=datetime.now(timezone.utc),
|
||||
event_name="CreateStorageAccount",
|
||||
event_source="Microsoft.Storage",
|
||||
actor="user@contoso.com",
|
||||
actor_type="User",
|
||||
)
|
||||
assert azure_event.actor_type == "User"
|
||||
|
||||
# GCP-style
|
||||
gcp_event = TimelineEvent(
|
||||
event_id="gcp-event-id",
|
||||
event_time=datetime.now(timezone.utc),
|
||||
event_name="storage.buckets.create",
|
||||
event_source="storage.googleapis.com",
|
||||
actor="service-account@project.iam.gserviceaccount.com",
|
||||
actor_type="serviceAccount",
|
||||
)
|
||||
assert gcp_event.actor_type == "serviceAccount"
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Tests for prowler.lib.timeline.timeline module."""
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import pytest
|
||||
|
||||
from prowler.lib.timeline.timeline import TimelineService
|
||||
|
||||
|
||||
class ConcreteTimelineService(TimelineService):
|
||||
"""Concrete implementation for testing the abstract base class."""
|
||||
|
||||
def __init__(self, mock_events: Optional[List[Dict[str, Any]]] = None):
|
||||
self.mock_events = mock_events or []
|
||||
self.last_call_args = None
|
||||
|
||||
def get_resource_timeline(
|
||||
self,
|
||||
region: Optional[str] = None,
|
||||
resource_id: Optional[str] = None,
|
||||
resource_uid: Optional[str] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Return mock events for testing."""
|
||||
if not resource_id and not resource_uid:
|
||||
raise ValueError("Either resource_id or resource_uid must be provided")
|
||||
|
||||
self.last_call_args = {
|
||||
"region": region,
|
||||
"resource_id": resource_id,
|
||||
"resource_uid": resource_uid,
|
||||
}
|
||||
return self.mock_events
|
||||
|
||||
|
||||
class TestTimelineServiceAbstract:
|
||||
"""Tests for TimelineService abstract base class."""
|
||||
|
||||
def test_cannot_instantiate_abstract_class(self):
|
||||
"""Test that TimelineService cannot be instantiated directly."""
|
||||
with pytest.raises(TypeError) as exc_info:
|
||||
TimelineService()
|
||||
|
||||
assert "abstract" in str(exc_info.value).lower()
|
||||
|
||||
def test_concrete_implementation_can_be_instantiated(self):
|
||||
"""Test that a concrete implementation can be instantiated."""
|
||||
service = ConcreteTimelineService()
|
||||
assert service is not None
|
||||
|
||||
def test_get_resource_timeline_with_resource_id(self):
|
||||
"""Test calling get_resource_timeline with resource_id."""
|
||||
service = ConcreteTimelineService(mock_events=[{"event": "test"}])
|
||||
|
||||
result = service.get_resource_timeline(
|
||||
region="us-east-1", resource_id="res-123"
|
||||
)
|
||||
|
||||
assert result == [{"event": "test"}]
|
||||
assert service.last_call_args["region"] == "us-east-1"
|
||||
assert service.last_call_args["resource_id"] == "res-123"
|
||||
assert service.last_call_args["resource_uid"] is None
|
||||
|
||||
def test_get_resource_timeline_with_resource_uid(self):
|
||||
"""Test calling get_resource_timeline with resource_uid."""
|
||||
service = ConcreteTimelineService(mock_events=[{"event": "test"}])
|
||||
|
||||
result = service.get_resource_timeline(
|
||||
region="eu-west-1",
|
||||
resource_uid="arn:aws:s3:::my-bucket",
|
||||
)
|
||||
|
||||
assert result == [{"event": "test"}]
|
||||
assert service.last_call_args["region"] == "eu-west-1"
|
||||
assert service.last_call_args["resource_id"] is None
|
||||
assert service.last_call_args["resource_uid"] == "arn:aws:s3:::my-bucket"
|
||||
|
||||
def test_get_resource_timeline_with_both_identifiers(self):
|
||||
"""Test calling get_resource_timeline with both resource_id and resource_uid."""
|
||||
service = ConcreteTimelineService(mock_events=[])
|
||||
|
||||
service.get_resource_timeline(
|
||||
region="ap-south-1",
|
||||
resource_id="res-123",
|
||||
resource_uid="arn:aws:ec2:ap-south-1:123456789012:instance/i-12345",
|
||||
)
|
||||
|
||||
assert service.last_call_args["resource_id"] == "res-123"
|
||||
assert (
|
||||
service.last_call_args["resource_uid"]
|
||||
== "arn:aws:ec2:ap-south-1:123456789012:instance/i-12345"
|
||||
)
|
||||
|
||||
def test_get_resource_timeline_missing_identifiers_raises(self):
|
||||
"""Test that missing both identifiers raises ValueError."""
|
||||
service = ConcreteTimelineService()
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
service.get_resource_timeline(region="us-west-2")
|
||||
|
||||
assert "resource_id" in str(exc_info.value) or "resource_uid" in str(
|
||||
exc_info.value
|
||||
)
|
||||
|
||||
def test_return_type_is_list_of_dicts(self):
|
||||
"""Test that get_resource_timeline returns list of dicts (not TimelineEvent)."""
|
||||
# The abstract interface returns list[dict] to allow flexibility
|
||||
# Concrete implementations convert to TimelineEvent as needed
|
||||
mock_events = [
|
||||
{"event_name": "CreateBucket", "actor": "user1"},
|
||||
{"event_name": "PutBucketPolicy", "actor": "user2"},
|
||||
]
|
||||
service = ConcreteTimelineService(mock_events=mock_events)
|
||||
|
||||
result = service.get_resource_timeline(
|
||||
region="us-east-1", resource_id="my-bucket"
|
||||
)
|
||||
|
||||
assert isinstance(result, list)
|
||||
assert all(isinstance(event, dict) for event in result)
|
||||
|
||||
|
||||
class TestTimelineServiceInheritance:
|
||||
"""Tests for proper inheritance of TimelineService."""
|
||||
|
||||
def test_is_abstract_base_class(self):
|
||||
"""Test that TimelineService is an ABC."""
|
||||
from abc import ABC
|
||||
|
||||
assert issubclass(TimelineService, ABC)
|
||||
|
||||
def test_get_resource_timeline_is_abstract(self):
|
||||
"""Test that get_resource_timeline is an abstract method."""
|
||||
|
||||
method = getattr(TimelineService, "get_resource_timeline")
|
||||
assert getattr(method, "__isabstractmethod__", False)
|
||||
|
||||
def test_subclass_must_implement_abstract_method(self):
|
||||
"""Test that subclass without implementation cannot be instantiated."""
|
||||
|
||||
class IncompleteService(TimelineService):
|
||||
"""Subclass that doesn't implement abstract methods."""
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
IncompleteService()
|
||||
Whitespace-only changes.
@@ -0,0 +1,608 @@
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from botocore.exceptions import ClientError
|
||||
|
||||
from prowler.providers.aws.lib.cloudtrail_timeline.cloudtrail_timeline import (
|
||||
CloudTrailTimeline,
|
||||
)
|
||||
|
||||
|
||||
class TestCloudTrailTimeline:
|
||||
@pytest.fixture
|
||||
def mock_session(self):
|
||||
return MagicMock()
|
||||
|
||||
@pytest.fixture
|
||||
def sample_cloudtrail_event(self):
|
||||
return {
|
||||
"EventId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||
"EventTime": datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc),
|
||||
"EventName": "RunInstances",
|
||||
"EventSource": "ec2.amazonaws.com",
|
||||
"CloudTrailEvent": json.dumps(
|
||||
{
|
||||
"userIdentity": {
|
||||
"type": "IAMUser",
|
||||
"arn": "arn:aws:iam::123456789012:user/admin",
|
||||
"userName": "admin",
|
||||
},
|
||||
"sourceIPAddress": "203.0.113.1",
|
||||
"userAgent": "aws-cli/2.0.0",
|
||||
"requestParameters": {"instanceType": "t3.micro"},
|
||||
"responseElements": {
|
||||
"instancesSet": {"items": [{"instanceId": "i-1234"}]}
|
||||
},
|
||||
}
|
||||
),
|
||||
}
|
||||
|
||||
def test_init_default_lookback(self, mock_session):
|
||||
timeline = CloudTrailTimeline(session=mock_session)
|
||||
assert timeline._lookback_days == 90
|
||||
|
||||
def test_init_custom_lookback(self, mock_session):
|
||||
timeline = CloudTrailTimeline(session=mock_session, lookback_days=30)
|
||||
assert timeline._lookback_days == 30
|
||||
|
||||
def test_init_lookback_capped_at_max(self, mock_session):
|
||||
timeline = CloudTrailTimeline(session=mock_session, lookback_days=365)
|
||||
assert timeline._lookback_days == 90
|
||||
|
||||
def test_init_default_max_results(self, mock_session):
|
||||
timeline = CloudTrailTimeline(session=mock_session)
|
||||
assert timeline._max_results == 50
|
||||
|
||||
def test_init_custom_max_results(self, mock_session):
|
||||
timeline = CloudTrailTimeline(session=mock_session, max_results=10)
|
||||
assert timeline._max_results == 10
|
||||
|
||||
def test_init_default_write_events_only(self, mock_session):
|
||||
timeline = CloudTrailTimeline(session=mock_session)
|
||||
assert timeline._write_events_only is True
|
||||
|
||||
def test_init_write_events_only_disabled(self, mock_session):
|
||||
timeline = CloudTrailTimeline(session=mock_session, write_events_only=False)
|
||||
assert timeline._write_events_only is False
|
||||
|
||||
def test_get_resource_timeline_defaults_to_us_east_1(self, mock_session):
|
||||
"""When no region is provided, should default to us-east-1 for global resources."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.lookup_events.return_value = {"Events": []}
|
||||
mock_session.client.return_value = mock_client
|
||||
|
||||
timeline = CloudTrailTimeline(session=mock_session)
|
||||
timeline.get_resource_timeline(
|
||||
resource_uid="arn:aws:iam::123456789012:user/admin"
|
||||
)
|
||||
|
||||
# Verify us-east-1 was used as the default region
|
||||
mock_session.client.assert_called_with("cloudtrail", region_name="us-east-1")
|
||||
|
||||
def test_get_resource_timeline_missing_identifier_raises(self, mock_session):
|
||||
timeline = CloudTrailTimeline(session=mock_session)
|
||||
with pytest.raises(ValueError, match="Either resource_id or resource_uid"):
|
||||
timeline.get_resource_timeline(region="us-east-1")
|
||||
|
||||
def test_get_resource_timeline_with_resource_id(
|
||||
self, mock_session, sample_cloudtrail_event
|
||||
):
|
||||
mock_client = MagicMock()
|
||||
mock_client.lookup_events.return_value = {"Events": [sample_cloudtrail_event]}
|
||||
mock_session.client.return_value = mock_client
|
||||
|
||||
timeline = CloudTrailTimeline(session=mock_session)
|
||||
result = timeline.get_resource_timeline(
|
||||
region="us-east-1", resource_id="i-1234567890abcdef0"
|
||||
)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0]["event_name"] == "RunInstances"
|
||||
assert result[0]["actor"] == "admin"
|
||||
assert result[0]["source_ip_address"] == "203.0.113.1"
|
||||
|
||||
def test_get_resource_timeline_with_resource_uid(
|
||||
self, mock_session, sample_cloudtrail_event
|
||||
):
|
||||
mock_client = MagicMock()
|
||||
mock_client.lookup_events.return_value = {"Events": [sample_cloudtrail_event]}
|
||||
mock_session.client.return_value = mock_client
|
||||
|
||||
timeline = CloudTrailTimeline(session=mock_session)
|
||||
result = timeline.get_resource_timeline(
|
||||
region="us-east-1",
|
||||
resource_uid="arn:aws:ec2:us-east-1:123456789012:instance/i-1234567890abcdef0",
|
||||
)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0]["event_name"] == "RunInstances"
|
||||
|
||||
def test_get_resource_timeline_prefers_uid_over_id(self, mock_session):
|
||||
"""When both resource_id and resource_uid are provided, UID should be used."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.lookup_events.return_value = {"Events": []}
|
||||
mock_session.client.return_value = mock_client
|
||||
|
||||
timeline = CloudTrailTimeline(session=mock_session)
|
||||
timeline.get_resource_timeline(
|
||||
region="us-east-1",
|
||||
resource_id="i-1234",
|
||||
resource_uid="arn:aws:ec2:us-east-1:123:instance/i-1234",
|
||||
)
|
||||
|
||||
# Verify UID was used in the lookup
|
||||
call_args = mock_client.lookup_events.call_args
|
||||
lookup_attrs = call_args.kwargs["LookupAttributes"]
|
||||
assert (
|
||||
lookup_attrs[0]["AttributeValue"]
|
||||
== "arn:aws:ec2:us-east-1:123:instance/i-1234"
|
||||
)
|
||||
|
||||
def test_get_resource_timeline_client_error(self, mock_session):
|
||||
mock_client = MagicMock()
|
||||
mock_client.lookup_events.side_effect = ClientError(
|
||||
{"Error": {"Code": "AccessDenied", "Message": "Access denied"}},
|
||||
"LookupEvents",
|
||||
)
|
||||
mock_session.client.return_value = mock_client
|
||||
|
||||
timeline = CloudTrailTimeline(session=mock_session)
|
||||
with pytest.raises(ClientError):
|
||||
timeline.get_resource_timeline(region="us-east-1", resource_id="i-1234")
|
||||
|
||||
def test_get_resource_timeline_multiple_events(self, mock_session):
|
||||
events = [
|
||||
{
|
||||
"EventId": "event-1-id",
|
||||
"EventTime": datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc),
|
||||
"EventName": "RunInstances",
|
||||
"EventSource": "ec2.amazonaws.com",
|
||||
"CloudTrailEvent": json.dumps(
|
||||
{
|
||||
"userIdentity": {
|
||||
"type": "IAMUser",
|
||||
"arn": "arn:aws:iam::123456789012:user/admin",
|
||||
}
|
||||
}
|
||||
),
|
||||
},
|
||||
{
|
||||
"EventId": "event-2-id",
|
||||
"EventTime": datetime(2024, 1, 15, 11, 30, 0, tzinfo=timezone.utc),
|
||||
"EventName": "StopInstances",
|
||||
"EventSource": "ec2.amazonaws.com",
|
||||
"CloudTrailEvent": json.dumps(
|
||||
{
|
||||
"userIdentity": {
|
||||
"type": "IAMUser",
|
||||
"arn": "arn:aws:iam::123456789012:user/ops",
|
||||
}
|
||||
}
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.lookup_events.return_value = {"Events": events}
|
||||
mock_session.client.return_value = mock_client
|
||||
|
||||
timeline = CloudTrailTimeline(session=mock_session)
|
||||
result = timeline.get_resource_timeline(
|
||||
region="us-east-1", resource_id="i-1234"
|
||||
)
|
||||
|
||||
assert len(result) == 2
|
||||
assert result[0]["event_name"] == "RunInstances"
|
||||
assert result[1]["event_name"] == "StopInstances"
|
||||
|
||||
def test_get_resource_timeline_uses_max_results(self, mock_session):
|
||||
"""Verify MaxResults is passed to lookup_events."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.lookup_events.return_value = {"Events": []}
|
||||
mock_session.client.return_value = mock_client
|
||||
|
||||
timeline = CloudTrailTimeline(session=mock_session, max_results=25)
|
||||
timeline.get_resource_timeline(region="us-east-1", resource_id="i-1234")
|
||||
|
||||
# Verify MaxResults was passed to lookup_events
|
||||
call_args = mock_client.lookup_events.call_args
|
||||
assert call_args.kwargs["MaxResults"] == 25
|
||||
|
||||
def test_get_resource_timeline_filters_read_only_events(self, mock_session):
|
||||
"""Verify read-only events are filtered when write_events_only=True."""
|
||||
events = [
|
||||
{
|
||||
"EventId": "write-event-id",
|
||||
"EventTime": datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc),
|
||||
"EventName": "CreateSecurityGroup",
|
||||
"EventSource": "ec2.amazonaws.com",
|
||||
"CloudTrailEvent": json.dumps(
|
||||
{"userIdentity": {"type": "IAMUser", "userName": "admin"}}
|
||||
),
|
||||
},
|
||||
{
|
||||
"EventId": "read-event-id",
|
||||
"EventTime": datetime(2024, 1, 15, 10, 31, 0, tzinfo=timezone.utc),
|
||||
"EventName": "DescribeSecurityGroups",
|
||||
"EventSource": "ec2.amazonaws.com",
|
||||
"CloudTrailEvent": json.dumps(
|
||||
{"userIdentity": {"type": "IAMUser", "userName": "admin"}}
|
||||
),
|
||||
},
|
||||
{
|
||||
"EventId": "another-read-id",
|
||||
"EventTime": datetime(2024, 1, 15, 10, 32, 0, tzinfo=timezone.utc),
|
||||
"EventName": "GetSecurityGroupRules",
|
||||
"EventSource": "ec2.amazonaws.com",
|
||||
"CloudTrailEvent": json.dumps(
|
||||
{"userIdentity": {"type": "IAMUser", "userName": "admin"}}
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.lookup_events.return_value = {"Events": events}
|
||||
mock_session.client.return_value = mock_client
|
||||
|
||||
# Default: write_events_only=True
|
||||
timeline = CloudTrailTimeline(session=mock_session)
|
||||
result = timeline.get_resource_timeline(
|
||||
region="us-east-1", resource_id="sg-123"
|
||||
)
|
||||
|
||||
# Only the write event should be returned
|
||||
assert len(result) == 1
|
||||
assert result[0]["event_name"] == "CreateSecurityGroup"
|
||||
|
||||
def test_get_resource_timeline_includes_read_events_when_disabled(
|
||||
self, mock_session
|
||||
):
|
||||
"""Verify all events returned when write_events_only=False."""
|
||||
events = [
|
||||
{
|
||||
"EventId": "write-event-id",
|
||||
"EventTime": datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc),
|
||||
"EventName": "CreateSecurityGroup",
|
||||
"EventSource": "ec2.amazonaws.com",
|
||||
"CloudTrailEvent": json.dumps(
|
||||
{"userIdentity": {"type": "IAMUser", "userName": "admin"}}
|
||||
),
|
||||
},
|
||||
{
|
||||
"EventId": "read-event-id",
|
||||
"EventTime": datetime(2024, 1, 15, 10, 31, 0, tzinfo=timezone.utc),
|
||||
"EventName": "DescribeSecurityGroups",
|
||||
"EventSource": "ec2.amazonaws.com",
|
||||
"CloudTrailEvent": json.dumps(
|
||||
{"userIdentity": {"type": "IAMUser", "userName": "admin"}}
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.lookup_events.return_value = {"Events": events}
|
||||
mock_session.client.return_value = mock_client
|
||||
|
||||
# Disable filtering
|
||||
timeline = CloudTrailTimeline(session=mock_session, write_events_only=False)
|
||||
result = timeline.get_resource_timeline(
|
||||
region="us-east-1", resource_id="sg-123"
|
||||
)
|
||||
|
||||
# All events should be returned
|
||||
assert len(result) == 2
|
||||
assert result[0]["event_name"] == "CreateSecurityGroup"
|
||||
assert result[1]["event_name"] == "DescribeSecurityGroups"
|
||||
|
||||
|
||||
class TestExtractActor:
|
||||
def test_extract_actor_iam_user(self):
|
||||
user_identity = {
|
||||
"type": "IAMUser",
|
||||
"arn": "arn:aws:iam::123456789012:user/alice",
|
||||
"userName": "alice",
|
||||
}
|
||||
assert CloudTrailTimeline._extract_actor(user_identity) == "alice"
|
||||
|
||||
def test_extract_actor_assumed_role(self):
|
||||
user_identity = {
|
||||
"type": "AssumedRole",
|
||||
"arn": "arn:aws:sts::123456789012:assumed-role/MyRole/session-name",
|
||||
}
|
||||
assert CloudTrailTimeline._extract_actor(user_identity) == "MyRole"
|
||||
|
||||
def test_extract_actor_root(self):
|
||||
user_identity = {"type": "Root", "arn": "arn:aws:iam::123456789012:root"}
|
||||
assert CloudTrailTimeline._extract_actor(user_identity) == "root"
|
||||
|
||||
def test_extract_actor_service(self):
|
||||
user_identity = {
|
||||
"type": "AWSService",
|
||||
"invokedBy": "elasticloadbalancing.amazonaws.com",
|
||||
}
|
||||
assert (
|
||||
CloudTrailTimeline._extract_actor(user_identity)
|
||||
== "elasticloadbalancing.amazonaws.com"
|
||||
)
|
||||
|
||||
def test_extract_actor_fallback_to_principal_id(self):
|
||||
user_identity = {"type": "Unknown", "principalId": "AROAEXAMPLEID:session"}
|
||||
assert (
|
||||
CloudTrailTimeline._extract_actor(user_identity) == "AROAEXAMPLEID:session"
|
||||
)
|
||||
|
||||
def test_extract_actor_unknown(self):
|
||||
assert CloudTrailTimeline._extract_actor({}) == "Unknown"
|
||||
|
||||
def test_extract_actor_federated_user(self):
|
||||
user_identity = {
|
||||
"type": "FederatedUser",
|
||||
"arn": "arn:aws:sts::123456789012:federated-user/developer",
|
||||
}
|
||||
assert CloudTrailTimeline._extract_actor(user_identity) == "developer"
|
||||
|
||||
|
||||
class TestParseEvent:
|
||||
@pytest.fixture
|
||||
def mock_session(self):
|
||||
return MagicMock()
|
||||
|
||||
@pytest.fixture
|
||||
def sample_cloudtrail_event(self):
|
||||
return {
|
||||
"EventId": "b2c3d4e5-f6a7-8901-bcde-f23456789012",
|
||||
"EventTime": datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc),
|
||||
"EventName": "RunInstances",
|
||||
"EventSource": "ec2.amazonaws.com",
|
||||
"CloudTrailEvent": json.dumps(
|
||||
{
|
||||
"userIdentity": {
|
||||
"type": "IAMUser",
|
||||
"arn": "arn:aws:iam::123456789012:user/admin",
|
||||
"userName": "admin",
|
||||
},
|
||||
"sourceIPAddress": "203.0.113.1",
|
||||
"userAgent": "aws-cli/2.0.0",
|
||||
"requestParameters": {"instanceType": "t3.micro"},
|
||||
"responseElements": {
|
||||
"instancesSet": {"items": [{"instanceId": "i-1234"}]}
|
||||
},
|
||||
}
|
||||
),
|
||||
}
|
||||
|
||||
def test_parse_event_success(self, mock_session, sample_cloudtrail_event):
|
||||
timeline = CloudTrailTimeline(session=mock_session)
|
||||
result = timeline._parse_event(sample_cloudtrail_event)
|
||||
|
||||
assert result is not None
|
||||
assert result["event_name"] == "RunInstances"
|
||||
assert result["event_source"] == "ec2.amazonaws.com"
|
||||
assert result["actor"] == "admin"
|
||||
assert result["actor_uid"] == "arn:aws:iam::123456789012:user/admin"
|
||||
assert result["actor_type"] == "IAMUser"
|
||||
|
||||
def test_parse_event_malformed_json(self, mock_session):
|
||||
event = {
|
||||
"EventId": "malformed-event-id",
|
||||
"EventTime": datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc),
|
||||
"EventName": "RunInstances",
|
||||
"EventSource": "ec2.amazonaws.com",
|
||||
"CloudTrailEvent": "not valid json",
|
||||
}
|
||||
timeline = CloudTrailTimeline(session=mock_session)
|
||||
assert timeline._parse_event(event) is None
|
||||
|
||||
def test_parse_event_with_error_fields(self, mock_session):
|
||||
event = {
|
||||
"EventId": "error-event-id",
|
||||
"EventTime": datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc),
|
||||
"EventName": "CreateBucket",
|
||||
"EventSource": "s3.amazonaws.com",
|
||||
"CloudTrailEvent": json.dumps(
|
||||
{
|
||||
"userIdentity": {"type": "IAMUser", "userName": "developer"},
|
||||
"errorCode": "AccessDenied",
|
||||
"errorMessage": "Access Denied",
|
||||
}
|
||||
),
|
||||
}
|
||||
timeline = CloudTrailTimeline(session=mock_session)
|
||||
result = timeline._parse_event(event)
|
||||
|
||||
assert result is not None
|
||||
assert result["error_code"] == "AccessDenied"
|
||||
assert result["error_message"] == "Access Denied"
|
||||
|
||||
def test_parse_event_dict_cloud_trail_event(self, mock_session):
|
||||
"""Test parsing when CloudTrailEvent is already a dict (not JSON string)."""
|
||||
event = {
|
||||
"EventId": "dict-event-id",
|
||||
"EventTime": datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc),
|
||||
"EventName": "RunInstances",
|
||||
"EventSource": "ec2.amazonaws.com",
|
||||
"CloudTrailEvent": {
|
||||
"userIdentity": {"type": "IAMUser", "userName": "admin"},
|
||||
},
|
||||
}
|
||||
timeline = CloudTrailTimeline(session=mock_session)
|
||||
result = timeline._parse_event(event)
|
||||
|
||||
assert result is not None
|
||||
assert result["event_name"] == "RunInstances"
|
||||
assert result["actor"] == "admin"
|
||||
|
||||
def test_parse_event_missing_event_id(self, mock_session):
|
||||
"""Test parsing event without EventId returns None (event_id is required)."""
|
||||
event = {
|
||||
# Missing EventId
|
||||
"EventTime": datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc),
|
||||
"EventName": "RunInstances",
|
||||
"EventSource": "ec2.amazonaws.com",
|
||||
"CloudTrailEvent": json.dumps(
|
||||
{"userIdentity": {"type": "IAMUser", "userName": "admin"}}
|
||||
),
|
||||
}
|
||||
timeline = CloudTrailTimeline(session=mock_session)
|
||||
result = timeline._parse_event(event)
|
||||
|
||||
# Should return None because event_id is required by TimelineEvent model
|
||||
assert result is None
|
||||
|
||||
def test_parse_event_uses_request_data_and_response_data_fields(self, mock_session):
|
||||
"""Test that parsed event uses request_data and response_data field names."""
|
||||
event = {
|
||||
"EventId": "field-names-test-id",
|
||||
"EventTime": datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc),
|
||||
"EventName": "CreateBucket",
|
||||
"EventSource": "s3.amazonaws.com",
|
||||
"CloudTrailEvent": json.dumps(
|
||||
{
|
||||
"userIdentity": {"type": "IAMUser", "userName": "admin"},
|
||||
"requestParameters": {"bucketName": "my-bucket", "acl": "private"},
|
||||
"responseElements": {
|
||||
"location": "http://my-bucket.s3.amazonaws.com"
|
||||
},
|
||||
}
|
||||
),
|
||||
}
|
||||
timeline = CloudTrailTimeline(session=mock_session)
|
||||
result = timeline._parse_event(event)
|
||||
|
||||
assert result is not None
|
||||
# Verify field names are request_data/response_data (not request_parameters/response_elements)
|
||||
assert "request_data" in result
|
||||
assert "response_data" in result
|
||||
assert "request_parameters" not in result
|
||||
assert "response_elements" not in result
|
||||
# Verify the data is correctly mapped
|
||||
assert result["request_data"] == {"bucketName": "my-bucket", "acl": "private"}
|
||||
assert result["response_data"] == {
|
||||
"location": "http://my-bucket.s3.amazonaws.com"
|
||||
}
|
||||
|
||||
def test_parse_event_missing_actor_type(self, mock_session):
|
||||
"""Test parsing event where userIdentity has no type field."""
|
||||
event = {
|
||||
"EventId": "no-actor-type-id",
|
||||
"EventTime": datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc),
|
||||
"EventName": "RunInstances",
|
||||
"EventSource": "ec2.amazonaws.com",
|
||||
"CloudTrailEvent": json.dumps(
|
||||
{
|
||||
"userIdentity": {
|
||||
# No "type" field
|
||||
"arn": "arn:aws:iam::123456789012:user/admin",
|
||||
"userName": "admin",
|
||||
},
|
||||
"sourceIPAddress": "203.0.113.1",
|
||||
}
|
||||
),
|
||||
}
|
||||
timeline = CloudTrailTimeline(session=mock_session)
|
||||
result = timeline._parse_event(event)
|
||||
|
||||
assert result is not None
|
||||
assert result["event_name"] == "RunInstances"
|
||||
assert result["actor"] == "admin"
|
||||
# actor_type should be None when not present in userIdentity
|
||||
assert result["actor_type"] is None
|
||||
|
||||
def test_parse_event_empty_request_response(self, mock_session):
|
||||
"""Test parsing event with no requestParameters or responseElements."""
|
||||
event = {
|
||||
"EventId": "no-params-id",
|
||||
"EventTime": datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc),
|
||||
"EventName": "DescribeInstances",
|
||||
"EventSource": "ec2.amazonaws.com",
|
||||
"CloudTrailEvent": json.dumps(
|
||||
{
|
||||
"userIdentity": {"type": "IAMUser", "userName": "reader"},
|
||||
# No requestParameters or responseElements
|
||||
}
|
||||
),
|
||||
}
|
||||
timeline = CloudTrailTimeline(session=mock_session)
|
||||
result = timeline._parse_event(event)
|
||||
|
||||
assert result is not None
|
||||
assert result["request_data"] is None
|
||||
assert result["response_data"] is None
|
||||
|
||||
|
||||
class TestClientCaching:
|
||||
def test_client_cached_per_region(self):
|
||||
mock_session = MagicMock()
|
||||
mock_client = MagicMock()
|
||||
mock_session.client.return_value = mock_client
|
||||
|
||||
timeline = CloudTrailTimeline(session=mock_session)
|
||||
|
||||
# Get client twice for same region
|
||||
client1 = timeline._get_client("us-east-1")
|
||||
client2 = timeline._get_client("us-east-1")
|
||||
|
||||
# Should only create client once
|
||||
assert mock_session.client.call_count == 1
|
||||
assert client1 is client2
|
||||
|
||||
def test_different_clients_per_region(self):
|
||||
mock_session = MagicMock()
|
||||
|
||||
timeline = CloudTrailTimeline(session=mock_session)
|
||||
|
||||
timeline._get_client("us-east-1")
|
||||
timeline._get_client("eu-west-1")
|
||||
|
||||
# Should create client for each region
|
||||
assert mock_session.client.call_count == 2
|
||||
|
||||
|
||||
class TestIsReadOnlyEvent:
|
||||
"""Tests for _is_read_only_event method."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_session(self):
|
||||
return MagicMock()
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"event_name",
|
||||
[
|
||||
"DescribeSecurityGroups",
|
||||
"GetBucketPolicy",
|
||||
"ListBuckets",
|
||||
"HeadObject",
|
||||
"CheckAccessNotGranted",
|
||||
"LookupEvents",
|
||||
"SearchResources",
|
||||
"ScanOnDemand",
|
||||
"QueryObjects",
|
||||
"BatchGetItem",
|
||||
"SelectObjectContent",
|
||||
],
|
||||
)
|
||||
def test_read_only_events_detected(self, mock_session, event_name):
|
||||
"""Verify various read-only event prefixes are correctly identified."""
|
||||
timeline = CloudTrailTimeline(session=mock_session)
|
||||
assert timeline._is_read_only_event(event_name) is True
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"event_name",
|
||||
[
|
||||
"CreateSecurityGroup",
|
||||
"DeleteSecurityGroup",
|
||||
"ModifySecurityGroupRules",
|
||||
"PutBucketPolicy",
|
||||
"RunInstances",
|
||||
"TerminateInstances",
|
||||
"UpdateFunction",
|
||||
"AttachRolePolicy",
|
||||
"AuthorizeSecurityGroupIngress",
|
||||
],
|
||||
)
|
||||
def test_write_events_not_filtered(self, mock_session, event_name):
|
||||
"""Verify write events are not marked as read-only."""
|
||||
timeline = CloudTrailTimeline(session=mock_session)
|
||||
assert timeline._is_read_only_event(event_name) is False
|
||||
+425
@@ -0,0 +1,425 @@
|
||||
from typing import Optional
|
||||
from unittest import mock
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from tests.providers.cloudflare.cloudflare_fixtures import (
|
||||
ZONE_ID,
|
||||
ZONE_NAME,
|
||||
set_mocked_cloudflare_provider,
|
||||
)
|
||||
|
||||
|
||||
class CloudflareDNSRecord(BaseModel):
|
||||
"""Cloudflare DNS record representation for testing."""
|
||||
|
||||
id: str
|
||||
zone_id: str
|
||||
zone_name: str
|
||||
name: Optional[str] = None
|
||||
type: Optional[str] = None
|
||||
content: str = ""
|
||||
ttl: Optional[int] = None
|
||||
proxied: bool = False
|
||||
|
||||
|
||||
class Test_dns_record_cname_target_valid:
|
||||
def test_no_records(self):
|
||||
dns_client = mock.MagicMock
|
||||
dns_client.records = []
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_cloudflare_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.cloudflare.services.dns.dns_record_cname_target_valid.dns_record_cname_target_valid.dns_client",
|
||||
new=dns_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.cloudflare.services.dns.dns_record_cname_target_valid.dns_record_cname_target_valid import (
|
||||
dns_record_cname_target_valid,
|
||||
)
|
||||
|
||||
check = dns_record_cname_target_valid()
|
||||
result = check.execute()
|
||||
assert len(result) == 0
|
||||
|
||||
def test_non_cname_record(self):
|
||||
dns_client = mock.MagicMock
|
||||
dns_client.records = [
|
||||
CloudflareDNSRecord(
|
||||
id="record-1",
|
||||
zone_id=ZONE_ID,
|
||||
zone_name=ZONE_NAME,
|
||||
name="www.example.com",
|
||||
type="A",
|
||||
content="192.0.2.1",
|
||||
)
|
||||
]
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_cloudflare_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.cloudflare.services.dns.dns_record_cname_target_valid.dns_record_cname_target_valid.dns_client",
|
||||
new=dns_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.cloudflare.services.dns.dns_record_cname_target_valid.dns_record_cname_target_valid import (
|
||||
dns_record_cname_target_valid,
|
||||
)
|
||||
|
||||
check = dns_record_cname_target_valid()
|
||||
result = check.execute()
|
||||
assert len(result) == 0
|
||||
|
||||
def test_cname_record_valid_target(self):
|
||||
dns_client = mock.MagicMock
|
||||
dns_client.records = [
|
||||
CloudflareDNSRecord(
|
||||
id="record-1",
|
||||
zone_id=ZONE_ID,
|
||||
zone_name=ZONE_NAME,
|
||||
name="www.example.com",
|
||||
type="CNAME",
|
||||
content="example.com",
|
||||
)
|
||||
]
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_cloudflare_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.cloudflare.services.dns.dns_record_cname_target_valid.dns_record_cname_target_valid.dns_client",
|
||||
new=dns_client,
|
||||
),
|
||||
mock.patch(
|
||||
"socket.getaddrinfo",
|
||||
return_value=[("", "", "", "", ("192.0.2.1", 0))],
|
||||
),
|
||||
):
|
||||
from prowler.providers.cloudflare.services.dns.dns_record_cname_target_valid.dns_record_cname_target_valid import (
|
||||
dns_record_cname_target_valid,
|
||||
)
|
||||
|
||||
check = dns_record_cname_target_valid()
|
||||
result = check.execute()
|
||||
assert len(result) == 1
|
||||
assert result[0].resource_id == "record-1"
|
||||
assert result[0].resource_name == "www.example.com"
|
||||
assert result[0].status == "PASS"
|
||||
assert "points to valid target" in result[0].status_extended
|
||||
|
||||
def test_cname_record_dangling_target(self):
|
||||
import socket
|
||||
|
||||
dns_client = mock.MagicMock
|
||||
dns_client.records = [
|
||||
CloudflareDNSRecord(
|
||||
id="record-1",
|
||||
zone_id=ZONE_ID,
|
||||
zone_name=ZONE_NAME,
|
||||
name="old.example.com",
|
||||
type="CNAME",
|
||||
content="nonexistent.example.com",
|
||||
)
|
||||
]
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_cloudflare_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.cloudflare.services.dns.dns_record_cname_target_valid.dns_record_cname_target_valid.dns_client",
|
||||
new=dns_client,
|
||||
),
|
||||
mock.patch(
|
||||
"socket.getaddrinfo",
|
||||
side_effect=socket.gaierror("Name or service not known"),
|
||||
),
|
||||
):
|
||||
from prowler.providers.cloudflare.services.dns.dns_record_cname_target_valid.dns_record_cname_target_valid import (
|
||||
dns_record_cname_target_valid,
|
||||
)
|
||||
|
||||
check = dns_record_cname_target_valid()
|
||||
result = check.execute()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert "potentially dangling target" in result[0].status_extended
|
||||
assert "subdomain takeover risk" in result[0].status_extended
|
||||
|
||||
def test_cname_record_with_trailing_dot(self):
|
||||
dns_client = mock.MagicMock
|
||||
dns_client.records = [
|
||||
CloudflareDNSRecord(
|
||||
id="record-1",
|
||||
zone_id=ZONE_ID,
|
||||
zone_name=ZONE_NAME,
|
||||
name="www.example.com",
|
||||
type="CNAME",
|
||||
content="example.com.",
|
||||
)
|
||||
]
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_cloudflare_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.cloudflare.services.dns.dns_record_cname_target_valid.dns_record_cname_target_valid.dns_client",
|
||||
new=dns_client,
|
||||
),
|
||||
mock.patch(
|
||||
"socket.getaddrinfo",
|
||||
return_value=[("", "", "", "", ("192.0.2.1", 0))],
|
||||
),
|
||||
):
|
||||
from prowler.providers.cloudflare.services.dns.dns_record_cname_target_valid.dns_record_cname_target_valid import (
|
||||
dns_record_cname_target_valid,
|
||||
)
|
||||
|
||||
check = dns_record_cname_target_valid()
|
||||
result = check.execute()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
|
||||
def test_mx_record_valid_target(self):
|
||||
dns_client = mock.MagicMock
|
||||
dns_client.records = [
|
||||
CloudflareDNSRecord(
|
||||
id="record-1",
|
||||
zone_id=ZONE_ID,
|
||||
zone_name=ZONE_NAME,
|
||||
name="example.com",
|
||||
type="MX",
|
||||
content="10 mail.example.com",
|
||||
)
|
||||
]
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_cloudflare_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.cloudflare.services.dns.dns_record_cname_target_valid.dns_record_cname_target_valid.dns_client",
|
||||
new=dns_client,
|
||||
),
|
||||
mock.patch(
|
||||
"socket.getaddrinfo",
|
||||
return_value=[("", "", "", "", ("192.0.2.1", 0))],
|
||||
),
|
||||
):
|
||||
from prowler.providers.cloudflare.services.dns.dns_record_cname_target_valid.dns_record_cname_target_valid import (
|
||||
dns_record_cname_target_valid,
|
||||
)
|
||||
|
||||
check = dns_record_cname_target_valid()
|
||||
result = check.execute()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
assert "MX record" in result[0].status_extended
|
||||
assert "points to valid target" in result[0].status_extended
|
||||
|
||||
def test_mx_record_dangling_target(self):
|
||||
import socket
|
||||
|
||||
dns_client = mock.MagicMock
|
||||
dns_client.records = [
|
||||
CloudflareDNSRecord(
|
||||
id="record-1",
|
||||
zone_id=ZONE_ID,
|
||||
zone_name=ZONE_NAME,
|
||||
name="example.com",
|
||||
type="MX",
|
||||
content="10 nonexistent-mail.example.com",
|
||||
)
|
||||
]
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_cloudflare_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.cloudflare.services.dns.dns_record_cname_target_valid.dns_record_cname_target_valid.dns_client",
|
||||
new=dns_client,
|
||||
),
|
||||
mock.patch(
|
||||
"socket.getaddrinfo",
|
||||
side_effect=socket.gaierror("Name or service not known"),
|
||||
),
|
||||
):
|
||||
from prowler.providers.cloudflare.services.dns.dns_record_cname_target_valid.dns_record_cname_target_valid import (
|
||||
dns_record_cname_target_valid,
|
||||
)
|
||||
|
||||
check = dns_record_cname_target_valid()
|
||||
result = check.execute()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert "MX record" in result[0].status_extended
|
||||
assert "mail interception risk" in result[0].status_extended
|
||||
|
||||
def test_ns_record_valid_target(self):
|
||||
dns_client = mock.MagicMock
|
||||
dns_client.records = [
|
||||
CloudflareDNSRecord(
|
||||
id="record-1",
|
||||
zone_id=ZONE_ID,
|
||||
zone_name=ZONE_NAME,
|
||||
name="sub.example.com",
|
||||
type="NS",
|
||||
content="ns1.example.com",
|
||||
)
|
||||
]
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_cloudflare_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.cloudflare.services.dns.dns_record_cname_target_valid.dns_record_cname_target_valid.dns_client",
|
||||
new=dns_client,
|
||||
),
|
||||
mock.patch(
|
||||
"socket.getaddrinfo",
|
||||
return_value=[("", "", "", "", ("192.0.2.1", 0))],
|
||||
),
|
||||
):
|
||||
from prowler.providers.cloudflare.services.dns.dns_record_cname_target_valid.dns_record_cname_target_valid import (
|
||||
dns_record_cname_target_valid,
|
||||
)
|
||||
|
||||
check = dns_record_cname_target_valid()
|
||||
result = check.execute()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
assert "NS record" in result[0].status_extended
|
||||
|
||||
def test_ns_record_dangling_target(self):
|
||||
import socket
|
||||
|
||||
dns_client = mock.MagicMock
|
||||
dns_client.records = [
|
||||
CloudflareDNSRecord(
|
||||
id="record-1",
|
||||
zone_id=ZONE_ID,
|
||||
zone_name=ZONE_NAME,
|
||||
name="sub.example.com",
|
||||
type="NS",
|
||||
content="nonexistent-ns.example.com",
|
||||
)
|
||||
]
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_cloudflare_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.cloudflare.services.dns.dns_record_cname_target_valid.dns_record_cname_target_valid.dns_client",
|
||||
new=dns_client,
|
||||
),
|
||||
mock.patch(
|
||||
"socket.getaddrinfo",
|
||||
side_effect=socket.gaierror("Name or service not known"),
|
||||
),
|
||||
):
|
||||
from prowler.providers.cloudflare.services.dns.dns_record_cname_target_valid.dns_record_cname_target_valid import (
|
||||
dns_record_cname_target_valid,
|
||||
)
|
||||
|
||||
check = dns_record_cname_target_valid()
|
||||
result = check.execute()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert "NS record" in result[0].status_extended
|
||||
assert "subdomain delegation takeover risk" in result[0].status_extended
|
||||
|
||||
def test_srv_record_valid_target(self):
|
||||
dns_client = mock.MagicMock
|
||||
dns_client.records = [
|
||||
CloudflareDNSRecord(
|
||||
id="record-1",
|
||||
zone_id=ZONE_ID,
|
||||
zone_name=ZONE_NAME,
|
||||
name="_sip._tcp.example.com",
|
||||
type="SRV",
|
||||
content="10 5 5060 sip.example.com",
|
||||
)
|
||||
]
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_cloudflare_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.cloudflare.services.dns.dns_record_cname_target_valid.dns_record_cname_target_valid.dns_client",
|
||||
new=dns_client,
|
||||
),
|
||||
mock.patch(
|
||||
"socket.getaddrinfo",
|
||||
return_value=[("", "", "", "", ("192.0.2.1", 0))],
|
||||
),
|
||||
):
|
||||
from prowler.providers.cloudflare.services.dns.dns_record_cname_target_valid.dns_record_cname_target_valid import (
|
||||
dns_record_cname_target_valid,
|
||||
)
|
||||
|
||||
check = dns_record_cname_target_valid()
|
||||
result = check.execute()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
assert "SRV record" in result[0].status_extended
|
||||
|
||||
def test_srv_record_dangling_target(self):
|
||||
import socket
|
||||
|
||||
dns_client = mock.MagicMock
|
||||
dns_client.records = [
|
||||
CloudflareDNSRecord(
|
||||
id="record-1",
|
||||
zone_id=ZONE_ID,
|
||||
zone_name=ZONE_NAME,
|
||||
name="_sip._tcp.example.com",
|
||||
type="SRV",
|
||||
content="10 5 5060 nonexistent-sip.example.com",
|
||||
)
|
||||
]
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_cloudflare_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.cloudflare.services.dns.dns_record_cname_target_valid.dns_record_cname_target_valid.dns_client",
|
||||
new=dns_client,
|
||||
),
|
||||
mock.patch(
|
||||
"socket.getaddrinfo",
|
||||
side_effect=socket.gaierror("Name or service not known"),
|
||||
),
|
||||
):
|
||||
from prowler.providers.cloudflare.services.dns.dns_record_cname_target_valid.dns_record_cname_target_valid import (
|
||||
dns_record_cname_target_valid,
|
||||
)
|
||||
|
||||
check = dns_record_cname_target_valid()
|
||||
result = check.execute()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert "SRV record" in result[0].status_extended
|
||||
assert "service discovery vulnerability" in result[0].status_extended
|
||||
+312
@@ -0,0 +1,312 @@
|
||||
from typing import Optional
|
||||
from unittest import mock
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from tests.providers.cloudflare.cloudflare_fixtures import (
|
||||
ZONE_ID,
|
||||
ZONE_NAME,
|
||||
set_mocked_cloudflare_provider,
|
||||
)
|
||||
|
||||
|
||||
class CloudflareDNSRecord(BaseModel):
|
||||
"""Cloudflare DNS record representation for testing."""
|
||||
|
||||
id: str
|
||||
zone_id: str
|
||||
zone_name: str
|
||||
name: Optional[str] = None
|
||||
type: Optional[str] = None
|
||||
content: str = ""
|
||||
ttl: Optional[int] = None
|
||||
proxied: bool = False
|
||||
|
||||
|
||||
class Test_dns_record_no_internal_ip:
|
||||
def test_no_records(self):
|
||||
dns_client = mock.MagicMock
|
||||
dns_client.records = []
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_cloudflare_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.cloudflare.services.dns.dns_record_no_internal_ip.dns_record_no_internal_ip.dns_client",
|
||||
new=dns_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.cloudflare.services.dns.dns_record_no_internal_ip.dns_record_no_internal_ip import (
|
||||
dns_record_no_internal_ip,
|
||||
)
|
||||
|
||||
check = dns_record_no_internal_ip()
|
||||
result = check.execute()
|
||||
assert len(result) == 0
|
||||
|
||||
def test_non_ip_record(self):
|
||||
dns_client = mock.MagicMock
|
||||
dns_client.records = [
|
||||
CloudflareDNSRecord(
|
||||
id="record-1",
|
||||
zone_id=ZONE_ID,
|
||||
zone_name=ZONE_NAME,
|
||||
name="www.example.com",
|
||||
type="CNAME",
|
||||
content="example.com",
|
||||
)
|
||||
]
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_cloudflare_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.cloudflare.services.dns.dns_record_no_internal_ip.dns_record_no_internal_ip.dns_client",
|
||||
new=dns_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.cloudflare.services.dns.dns_record_no_internal_ip.dns_record_no_internal_ip import (
|
||||
dns_record_no_internal_ip,
|
||||
)
|
||||
|
||||
check = dns_record_no_internal_ip()
|
||||
result = check.execute()
|
||||
assert len(result) == 0
|
||||
|
||||
def test_a_record_public_ip(self):
|
||||
dns_client = mock.MagicMock
|
||||
dns_client.records = [
|
||||
CloudflareDNSRecord(
|
||||
id="record-1",
|
||||
zone_id=ZONE_ID,
|
||||
zone_name=ZONE_NAME,
|
||||
name="www.example.com",
|
||||
type="A",
|
||||
content="8.8.8.8", # Google DNS - a truly public IP
|
||||
)
|
||||
]
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_cloudflare_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.cloudflare.services.dns.dns_record_no_internal_ip.dns_record_no_internal_ip.dns_client",
|
||||
new=dns_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.cloudflare.services.dns.dns_record_no_internal_ip.dns_record_no_internal_ip import (
|
||||
dns_record_no_internal_ip,
|
||||
)
|
||||
|
||||
check = dns_record_no_internal_ip()
|
||||
result = check.execute()
|
||||
assert len(result) == 1
|
||||
assert result[0].resource_id == "record-1"
|
||||
assert result[0].resource_name == "www.example.com"
|
||||
assert result[0].status == "PASS"
|
||||
assert "public IP address" in result[0].status_extended
|
||||
|
||||
def test_a_record_private_ip_10(self):
|
||||
dns_client = mock.MagicMock
|
||||
dns_client.records = [
|
||||
CloudflareDNSRecord(
|
||||
id="record-1",
|
||||
zone_id=ZONE_ID,
|
||||
zone_name=ZONE_NAME,
|
||||
name="internal.example.com",
|
||||
type="A",
|
||||
content="10.0.0.1",
|
||||
)
|
||||
]
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_cloudflare_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.cloudflare.services.dns.dns_record_no_internal_ip.dns_record_no_internal_ip.dns_client",
|
||||
new=dns_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.cloudflare.services.dns.dns_record_no_internal_ip.dns_record_no_internal_ip import (
|
||||
dns_record_no_internal_ip,
|
||||
)
|
||||
|
||||
check = dns_record_no_internal_ip()
|
||||
result = check.execute()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert "internal IP address" in result[0].status_extended
|
||||
assert "information disclosure risk" in result[0].status_extended
|
||||
|
||||
def test_a_record_private_ip_172(self):
|
||||
dns_client = mock.MagicMock
|
||||
dns_client.records = [
|
||||
CloudflareDNSRecord(
|
||||
id="record-1",
|
||||
zone_id=ZONE_ID,
|
||||
zone_name=ZONE_NAME,
|
||||
name="internal.example.com",
|
||||
type="A",
|
||||
content="172.16.0.1",
|
||||
)
|
||||
]
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_cloudflare_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.cloudflare.services.dns.dns_record_no_internal_ip.dns_record_no_internal_ip.dns_client",
|
||||
new=dns_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.cloudflare.services.dns.dns_record_no_internal_ip.dns_record_no_internal_ip import (
|
||||
dns_record_no_internal_ip,
|
||||
)
|
||||
|
||||
check = dns_record_no_internal_ip()
|
||||
result = check.execute()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert "internal IP address" in result[0].status_extended
|
||||
|
||||
def test_a_record_private_ip_192(self):
|
||||
dns_client = mock.MagicMock
|
||||
dns_client.records = [
|
||||
CloudflareDNSRecord(
|
||||
id="record-1",
|
||||
zone_id=ZONE_ID,
|
||||
zone_name=ZONE_NAME,
|
||||
name="internal.example.com",
|
||||
type="A",
|
||||
content="192.168.1.1",
|
||||
)
|
||||
]
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_cloudflare_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.cloudflare.services.dns.dns_record_no_internal_ip.dns_record_no_internal_ip.dns_client",
|
||||
new=dns_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.cloudflare.services.dns.dns_record_no_internal_ip.dns_record_no_internal_ip import (
|
||||
dns_record_no_internal_ip,
|
||||
)
|
||||
|
||||
check = dns_record_no_internal_ip()
|
||||
result = check.execute()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert "internal IP address" in result[0].status_extended
|
||||
|
||||
def test_a_record_loopback(self):
|
||||
dns_client = mock.MagicMock
|
||||
dns_client.records = [
|
||||
CloudflareDNSRecord(
|
||||
id="record-1",
|
||||
zone_id=ZONE_ID,
|
||||
zone_name=ZONE_NAME,
|
||||
name="localhost.example.com",
|
||||
type="A",
|
||||
content="127.0.0.1",
|
||||
)
|
||||
]
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_cloudflare_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.cloudflare.services.dns.dns_record_no_internal_ip.dns_record_no_internal_ip.dns_client",
|
||||
new=dns_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.cloudflare.services.dns.dns_record_no_internal_ip.dns_record_no_internal_ip import (
|
||||
dns_record_no_internal_ip,
|
||||
)
|
||||
|
||||
check = dns_record_no_internal_ip()
|
||||
result = check.execute()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert "internal IP address" in result[0].status_extended
|
||||
|
||||
def test_aaaa_record_public_ip(self):
|
||||
dns_client = mock.MagicMock
|
||||
dns_client.records = [
|
||||
CloudflareDNSRecord(
|
||||
id="record-1",
|
||||
zone_id=ZONE_ID,
|
||||
zone_name=ZONE_NAME,
|
||||
name="www.example.com",
|
||||
type="AAAA",
|
||||
content="2001:db8::1",
|
||||
)
|
||||
]
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_cloudflare_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.cloudflare.services.dns.dns_record_no_internal_ip.dns_record_no_internal_ip.dns_client",
|
||||
new=dns_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.cloudflare.services.dns.dns_record_no_internal_ip.dns_record_no_internal_ip import (
|
||||
dns_record_no_internal_ip,
|
||||
)
|
||||
|
||||
check = dns_record_no_internal_ip()
|
||||
result = check.execute()
|
||||
assert len(result) == 1
|
||||
# 2001:db8:: is documentation prefix and is reserved
|
||||
assert result[0].status == "FAIL"
|
||||
|
||||
def test_aaaa_record_link_local(self):
|
||||
dns_client = mock.MagicMock
|
||||
dns_client.records = [
|
||||
CloudflareDNSRecord(
|
||||
id="record-1",
|
||||
zone_id=ZONE_ID,
|
||||
zone_name=ZONE_NAME,
|
||||
name="internal.example.com",
|
||||
type="AAAA",
|
||||
content="fe80::1",
|
||||
)
|
||||
]
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_cloudflare_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.cloudflare.services.dns.dns_record_no_internal_ip.dns_record_no_internal_ip.dns_client",
|
||||
new=dns_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.cloudflare.services.dns.dns_record_no_internal_ip.dns_record_no_internal_ip import (
|
||||
dns_record_no_internal_ip,
|
||||
)
|
||||
|
||||
check = dns_record_no_internal_ip()
|
||||
result = check.execute()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert "internal IP address" in result[0].status_extended
|
||||
+345
@@ -0,0 +1,345 @@
|
||||
from typing import Optional
|
||||
from unittest import mock
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from tests.providers.cloudflare.cloudflare_fixtures import (
|
||||
ZONE_ID,
|
||||
ZONE_NAME,
|
||||
set_mocked_cloudflare_provider,
|
||||
)
|
||||
|
||||
|
||||
class CloudflareDNSRecord(BaseModel):
|
||||
"""Cloudflare DNS record representation for testing."""
|
||||
|
||||
id: str
|
||||
zone_id: str
|
||||
zone_name: str
|
||||
name: Optional[str] = None
|
||||
type: Optional[str] = None
|
||||
content: str = ""
|
||||
ttl: Optional[int] = None
|
||||
proxied: bool = False
|
||||
|
||||
|
||||
class Test_dns_record_no_wildcard:
|
||||
def test_no_records(self):
|
||||
dns_client = mock.MagicMock
|
||||
dns_client.records = []
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_cloudflare_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.cloudflare.services.dns.dns_record_no_wildcard.dns_record_no_wildcard.dns_client",
|
||||
new=dns_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.cloudflare.services.dns.dns_record_no_wildcard.dns_record_no_wildcard import (
|
||||
dns_record_no_wildcard,
|
||||
)
|
||||
|
||||
check = dns_record_no_wildcard()
|
||||
result = check.execute()
|
||||
assert len(result) == 0
|
||||
|
||||
def test_non_ip_record(self):
|
||||
dns_client = mock.MagicMock
|
||||
dns_client.records = [
|
||||
CloudflareDNSRecord(
|
||||
id="record-1",
|
||||
zone_id=ZONE_ID,
|
||||
zone_name=ZONE_NAME,
|
||||
name="example.com",
|
||||
type="TXT",
|
||||
content="v=spf1 include:_spf.google.com ~all",
|
||||
)
|
||||
]
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_cloudflare_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.cloudflare.services.dns.dns_record_no_wildcard.dns_record_no_wildcard.dns_client",
|
||||
new=dns_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.cloudflare.services.dns.dns_record_no_wildcard.dns_record_no_wildcard import (
|
||||
dns_record_no_wildcard,
|
||||
)
|
||||
|
||||
check = dns_record_no_wildcard()
|
||||
result = check.execute()
|
||||
assert len(result) == 0
|
||||
|
||||
def test_a_record_not_wildcard(self):
|
||||
dns_client = mock.MagicMock
|
||||
dns_client.records = [
|
||||
CloudflareDNSRecord(
|
||||
id="record-1",
|
||||
zone_id=ZONE_ID,
|
||||
zone_name=ZONE_NAME,
|
||||
name="www.example.com",
|
||||
type="A",
|
||||
content="8.8.8.8",
|
||||
)
|
||||
]
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_cloudflare_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.cloudflare.services.dns.dns_record_no_wildcard.dns_record_no_wildcard.dns_client",
|
||||
new=dns_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.cloudflare.services.dns.dns_record_no_wildcard.dns_record_no_wildcard import (
|
||||
dns_record_no_wildcard,
|
||||
)
|
||||
|
||||
check = dns_record_no_wildcard()
|
||||
result = check.execute()
|
||||
assert len(result) == 1
|
||||
assert result[0].resource_id == "record-1"
|
||||
assert result[0].resource_name == "www.example.com"
|
||||
assert result[0].status == "PASS"
|
||||
assert "is not a wildcard record" in result[0].status_extended
|
||||
|
||||
def test_a_record_wildcard(self):
|
||||
dns_client = mock.MagicMock
|
||||
dns_client.records = [
|
||||
CloudflareDNSRecord(
|
||||
id="record-1",
|
||||
zone_id=ZONE_ID,
|
||||
zone_name=ZONE_NAME,
|
||||
name="*.example.com",
|
||||
type="A",
|
||||
content="8.8.8.8",
|
||||
)
|
||||
]
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_cloudflare_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.cloudflare.services.dns.dns_record_no_wildcard.dns_record_no_wildcard.dns_client",
|
||||
new=dns_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.cloudflare.services.dns.dns_record_no_wildcard.dns_record_no_wildcard import (
|
||||
dns_record_no_wildcard,
|
||||
)
|
||||
|
||||
check = dns_record_no_wildcard()
|
||||
result = check.execute()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert "is a wildcard record" in result[0].status_extended
|
||||
assert "may expose unintended services" in result[0].status_extended
|
||||
|
||||
def test_aaaa_record_wildcard(self):
|
||||
dns_client = mock.MagicMock
|
||||
dns_client.records = [
|
||||
CloudflareDNSRecord(
|
||||
id="record-1",
|
||||
zone_id=ZONE_ID,
|
||||
zone_name=ZONE_NAME,
|
||||
name="*.example.com",
|
||||
type="AAAA",
|
||||
content="2001:db8::1",
|
||||
)
|
||||
]
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_cloudflare_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.cloudflare.services.dns.dns_record_no_wildcard.dns_record_no_wildcard.dns_client",
|
||||
new=dns_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.cloudflare.services.dns.dns_record_no_wildcard.dns_record_no_wildcard import (
|
||||
dns_record_no_wildcard,
|
||||
)
|
||||
|
||||
check = dns_record_no_wildcard()
|
||||
result = check.execute()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert "is a wildcard record" in result[0].status_extended
|
||||
|
||||
def test_cname_record_wildcard(self):
|
||||
dns_client = mock.MagicMock
|
||||
dns_client.records = [
|
||||
CloudflareDNSRecord(
|
||||
id="record-1",
|
||||
zone_id=ZONE_ID,
|
||||
zone_name=ZONE_NAME,
|
||||
name="*.example.com",
|
||||
type="CNAME",
|
||||
content="example.com",
|
||||
)
|
||||
]
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_cloudflare_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.cloudflare.services.dns.dns_record_no_wildcard.dns_record_no_wildcard.dns_client",
|
||||
new=dns_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.cloudflare.services.dns.dns_record_no_wildcard.dns_record_no_wildcard import (
|
||||
dns_record_no_wildcard,
|
||||
)
|
||||
|
||||
check = dns_record_no_wildcard()
|
||||
result = check.execute()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert "is a wildcard record" in result[0].status_extended
|
||||
|
||||
def test_cname_record_not_wildcard(self):
|
||||
dns_client = mock.MagicMock
|
||||
dns_client.records = [
|
||||
CloudflareDNSRecord(
|
||||
id="record-1",
|
||||
zone_id=ZONE_ID,
|
||||
zone_name=ZONE_NAME,
|
||||
name="www.example.com",
|
||||
type="CNAME",
|
||||
content="example.com",
|
||||
)
|
||||
]
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_cloudflare_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.cloudflare.services.dns.dns_record_no_wildcard.dns_record_no_wildcard.dns_client",
|
||||
new=dns_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.cloudflare.services.dns.dns_record_no_wildcard.dns_record_no_wildcard import (
|
||||
dns_record_no_wildcard,
|
||||
)
|
||||
|
||||
check = dns_record_no_wildcard()
|
||||
result = check.execute()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
assert "is not a wildcard record" in result[0].status_extended
|
||||
|
||||
def test_mx_record_wildcard(self):
|
||||
dns_client = mock.MagicMock
|
||||
dns_client.records = [
|
||||
CloudflareDNSRecord(
|
||||
id="record-1",
|
||||
zone_id=ZONE_ID,
|
||||
zone_name=ZONE_NAME,
|
||||
name="*.example.com",
|
||||
type="MX",
|
||||
content="10 mail.example.com",
|
||||
)
|
||||
]
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_cloudflare_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.cloudflare.services.dns.dns_record_no_wildcard.dns_record_no_wildcard.dns_client",
|
||||
new=dns_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.cloudflare.services.dns.dns_record_no_wildcard.dns_record_no_wildcard import (
|
||||
dns_record_no_wildcard,
|
||||
)
|
||||
|
||||
check = dns_record_no_wildcard()
|
||||
result = check.execute()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert "is a wildcard record" in result[0].status_extended
|
||||
|
||||
def test_mx_record_not_wildcard(self):
|
||||
dns_client = mock.MagicMock
|
||||
dns_client.records = [
|
||||
CloudflareDNSRecord(
|
||||
id="record-1",
|
||||
zone_id=ZONE_ID,
|
||||
zone_name=ZONE_NAME,
|
||||
name="example.com",
|
||||
type="MX",
|
||||
content="10 mail.example.com",
|
||||
)
|
||||
]
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_cloudflare_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.cloudflare.services.dns.dns_record_no_wildcard.dns_record_no_wildcard.dns_client",
|
||||
new=dns_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.cloudflare.services.dns.dns_record_no_wildcard.dns_record_no_wildcard import (
|
||||
dns_record_no_wildcard,
|
||||
)
|
||||
|
||||
check = dns_record_no_wildcard()
|
||||
result = check.execute()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
assert "is not a wildcard record" in result[0].status_extended
|
||||
|
||||
def test_srv_record_wildcard(self):
|
||||
dns_client = mock.MagicMock
|
||||
dns_client.records = [
|
||||
CloudflareDNSRecord(
|
||||
id="record-1",
|
||||
zone_id=ZONE_ID,
|
||||
zone_name=ZONE_NAME,
|
||||
name="*._tcp.example.com",
|
||||
type="SRV",
|
||||
content="10 5 5060 sip.example.com",
|
||||
)
|
||||
]
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_cloudflare_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.cloudflare.services.dns.dns_record_no_wildcard.dns_record_no_wildcard.dns_client",
|
||||
new=dns_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.cloudflare.services.dns.dns_record_no_wildcard.dns_record_no_wildcard import (
|
||||
dns_record_no_wildcard,
|
||||
)
|
||||
|
||||
check = dns_record_no_wildcard()
|
||||
result = check.execute()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert "is a wildcard record" in result[0].status_extended
|
||||
+288
@@ -0,0 +1,288 @@
|
||||
from typing import Optional
|
||||
from unittest import mock
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from tests.providers.cloudflare.cloudflare_fixtures import (
|
||||
ZONE_ID,
|
||||
ZONE_NAME,
|
||||
set_mocked_cloudflare_provider,
|
||||
)
|
||||
|
||||
|
||||
class CloudflareDNSRecord(BaseModel):
|
||||
"""Cloudflare DNS record representation for testing."""
|
||||
|
||||
id: str
|
||||
zone_id: str
|
||||
zone_name: str
|
||||
name: Optional[str] = None
|
||||
type: Optional[str] = None
|
||||
content: str = ""
|
||||
ttl: Optional[int] = None
|
||||
proxied: bool = False
|
||||
|
||||
|
||||
class Test_dns_record_proxied:
|
||||
def test_no_records(self):
|
||||
dns_client = mock.MagicMock
|
||||
dns_client.records = []
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_cloudflare_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.cloudflare.services.dns.dns_record_proxied.dns_record_proxied.dns_client",
|
||||
new=dns_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.cloudflare.services.dns.dns_record_proxied.dns_record_proxied import (
|
||||
dns_record_proxied,
|
||||
)
|
||||
|
||||
check = dns_record_proxied()
|
||||
result = check.execute()
|
||||
assert len(result) == 0
|
||||
|
||||
def test_non_proxyable_record(self):
|
||||
dns_client = mock.MagicMock
|
||||
dns_client.records = [
|
||||
CloudflareDNSRecord(
|
||||
id="record-1",
|
||||
zone_id=ZONE_ID,
|
||||
zone_name=ZONE_NAME,
|
||||
name="example.com",
|
||||
type="TXT",
|
||||
content="v=spf1 include:_spf.google.com ~all",
|
||||
proxied=False,
|
||||
)
|
||||
]
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_cloudflare_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.cloudflare.services.dns.dns_record_proxied.dns_record_proxied.dns_client",
|
||||
new=dns_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.cloudflare.services.dns.dns_record_proxied.dns_record_proxied import (
|
||||
dns_record_proxied,
|
||||
)
|
||||
|
||||
check = dns_record_proxied()
|
||||
result = check.execute()
|
||||
assert len(result) == 0
|
||||
|
||||
def test_a_record_proxied(self):
|
||||
dns_client = mock.MagicMock
|
||||
dns_client.records = [
|
||||
CloudflareDNSRecord(
|
||||
id="record-1",
|
||||
zone_id=ZONE_ID,
|
||||
zone_name=ZONE_NAME,
|
||||
name="www.example.com",
|
||||
type="A",
|
||||
content="8.8.8.8",
|
||||
proxied=True,
|
||||
)
|
||||
]
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_cloudflare_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.cloudflare.services.dns.dns_record_proxied.dns_record_proxied.dns_client",
|
||||
new=dns_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.cloudflare.services.dns.dns_record_proxied.dns_record_proxied import (
|
||||
dns_record_proxied,
|
||||
)
|
||||
|
||||
check = dns_record_proxied()
|
||||
result = check.execute()
|
||||
assert len(result) == 1
|
||||
assert result[0].resource_id == "record-1"
|
||||
assert result[0].resource_name == "www.example.com"
|
||||
assert result[0].status == "PASS"
|
||||
assert "is proxied through Cloudflare" in result[0].status_extended
|
||||
# DNS records should have zone_name as region
|
||||
assert result[0].region == ZONE_NAME
|
||||
assert result[0].zone_name == ZONE_NAME
|
||||
|
||||
def test_a_record_not_proxied(self):
|
||||
dns_client = mock.MagicMock
|
||||
dns_client.records = [
|
||||
CloudflareDNSRecord(
|
||||
id="record-1",
|
||||
zone_id=ZONE_ID,
|
||||
zone_name=ZONE_NAME,
|
||||
name="www.example.com",
|
||||
type="A",
|
||||
content="8.8.8.8",
|
||||
proxied=False,
|
||||
)
|
||||
]
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_cloudflare_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.cloudflare.services.dns.dns_record_proxied.dns_record_proxied.dns_client",
|
||||
new=dns_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.cloudflare.services.dns.dns_record_proxied.dns_record_proxied import (
|
||||
dns_record_proxied,
|
||||
)
|
||||
|
||||
check = dns_record_proxied()
|
||||
result = check.execute()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert "is not proxied through Cloudflare" in result[0].status_extended
|
||||
|
||||
def test_aaaa_record_proxied(self):
|
||||
dns_client = mock.MagicMock
|
||||
dns_client.records = [
|
||||
CloudflareDNSRecord(
|
||||
id="record-1",
|
||||
zone_id=ZONE_ID,
|
||||
zone_name=ZONE_NAME,
|
||||
name="www.example.com",
|
||||
type="AAAA",
|
||||
content="2001:db8::1",
|
||||
proxied=True,
|
||||
)
|
||||
]
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_cloudflare_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.cloudflare.services.dns.dns_record_proxied.dns_record_proxied.dns_client",
|
||||
new=dns_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.cloudflare.services.dns.dns_record_proxied.dns_record_proxied import (
|
||||
dns_record_proxied,
|
||||
)
|
||||
|
||||
check = dns_record_proxied()
|
||||
result = check.execute()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
assert "is proxied through Cloudflare" in result[0].status_extended
|
||||
|
||||
def test_aaaa_record_not_proxied(self):
|
||||
dns_client = mock.MagicMock
|
||||
dns_client.records = [
|
||||
CloudflareDNSRecord(
|
||||
id="record-1",
|
||||
zone_id=ZONE_ID,
|
||||
zone_name=ZONE_NAME,
|
||||
name="www.example.com",
|
||||
type="AAAA",
|
||||
content="2001:db8::1",
|
||||
proxied=False,
|
||||
)
|
||||
]
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_cloudflare_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.cloudflare.services.dns.dns_record_proxied.dns_record_proxied.dns_client",
|
||||
new=dns_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.cloudflare.services.dns.dns_record_proxied.dns_record_proxied import (
|
||||
dns_record_proxied,
|
||||
)
|
||||
|
||||
check = dns_record_proxied()
|
||||
result = check.execute()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert "is not proxied through Cloudflare" in result[0].status_extended
|
||||
|
||||
def test_cname_record_proxied(self):
|
||||
dns_client = mock.MagicMock
|
||||
dns_client.records = [
|
||||
CloudflareDNSRecord(
|
||||
id="record-1",
|
||||
zone_id=ZONE_ID,
|
||||
zone_name=ZONE_NAME,
|
||||
name="www.example.com",
|
||||
type="CNAME",
|
||||
content="example.com",
|
||||
proxied=True,
|
||||
)
|
||||
]
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_cloudflare_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.cloudflare.services.dns.dns_record_proxied.dns_record_proxied.dns_client",
|
||||
new=dns_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.cloudflare.services.dns.dns_record_proxied.dns_record_proxied import (
|
||||
dns_record_proxied,
|
||||
)
|
||||
|
||||
check = dns_record_proxied()
|
||||
result = check.execute()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
assert "is proxied through Cloudflare" in result[0].status_extended
|
||||
|
||||
def test_cname_record_not_proxied(self):
|
||||
dns_client = mock.MagicMock
|
||||
dns_client.records = [
|
||||
CloudflareDNSRecord(
|
||||
id="record-1",
|
||||
zone_id=ZONE_ID,
|
||||
zone_name=ZONE_NAME,
|
||||
name="www.example.com",
|
||||
type="CNAME",
|
||||
content="example.com",
|
||||
proxied=False,
|
||||
)
|
||||
]
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_cloudflare_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.cloudflare.services.dns.dns_record_proxied.dns_record_proxied.dns_client",
|
||||
new=dns_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.cloudflare.services.dns.dns_record_proxied.dns_record_proxied import (
|
||||
dns_record_proxied,
|
||||
)
|
||||
|
||||
check = dns_record_proxied()
|
||||
result = check.execute()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert "is not proxied through Cloudflare" in result[0].status_extended
|
||||
@@ -0,0 +1,84 @@
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from tests.providers.cloudflare.cloudflare_fixtures import ZONE_ID, ZONE_NAME
|
||||
|
||||
|
||||
class CloudflareFirewallRule(BaseModel):
|
||||
"""Cloudflare firewall rule representation for testing."""
|
||||
|
||||
id: Optional[str] = None
|
||||
zone_id: str
|
||||
zone_name: str
|
||||
ruleset_id: Optional[str] = None
|
||||
phase: Optional[str] = None
|
||||
action: Optional[str] = None
|
||||
expression: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class TestFirewallService:
|
||||
def test_cloudflare_firewall_rule_model(self):
|
||||
rule = CloudflareFirewallRule(
|
||||
id="rule-123",
|
||||
zone_id=ZONE_ID,
|
||||
zone_name=ZONE_NAME,
|
||||
ruleset_id="ruleset-456",
|
||||
phase="http_ratelimit",
|
||||
action="block",
|
||||
expression="(http.request.uri.path contains '/api/')",
|
||||
description="Rate limit API requests",
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
assert rule.id == "rule-123"
|
||||
assert rule.zone_id == ZONE_ID
|
||||
assert rule.zone_name == ZONE_NAME
|
||||
assert rule.ruleset_id == "ruleset-456"
|
||||
assert rule.phase == "http_ratelimit"
|
||||
assert rule.action == "block"
|
||||
assert rule.expression == "(http.request.uri.path contains '/api/')"
|
||||
assert rule.description == "Rate limit API requests"
|
||||
assert rule.enabled is True
|
||||
|
||||
def test_cloudflare_firewall_rule_defaults(self):
|
||||
rule = CloudflareFirewallRule(
|
||||
zone_id=ZONE_ID,
|
||||
zone_name=ZONE_NAME,
|
||||
)
|
||||
|
||||
assert rule.id is None
|
||||
assert rule.zone_id == ZONE_ID
|
||||
assert rule.zone_name == ZONE_NAME
|
||||
assert rule.ruleset_id is None
|
||||
assert rule.phase is None
|
||||
assert rule.action is None
|
||||
assert rule.expression is None
|
||||
assert rule.description is None
|
||||
assert rule.enabled is True
|
||||
|
||||
def test_cloudflare_firewall_rule_disabled(self):
|
||||
rule = CloudflareFirewallRule(
|
||||
id="rule-disabled",
|
||||
zone_id=ZONE_ID,
|
||||
zone_name=ZONE_NAME,
|
||||
phase="http_ratelimit",
|
||||
enabled=False,
|
||||
)
|
||||
|
||||
assert rule.enabled is False
|
||||
|
||||
def test_cloudflare_firewall_rule_custom_phase(self):
|
||||
rule = CloudflareFirewallRule(
|
||||
id="rule-custom",
|
||||
zone_id=ZONE_ID,
|
||||
zone_name=ZONE_NAME,
|
||||
phase="http_request_firewall_custom",
|
||||
action="challenge",
|
||||
expression="(cf.threat_score > 10)",
|
||||
)
|
||||
|
||||
assert rule.phase == "http_request_firewall_custom"
|
||||
assert rule.action == "challenge"
|
||||
+250
@@ -0,0 +1,250 @@
|
||||
from unittest import mock
|
||||
|
||||
from prowler.providers.cloudflare.services.zone.zone_service import (
|
||||
CloudflareFirewallRule,
|
||||
CloudflareZone,
|
||||
CloudflareZoneSettings,
|
||||
)
|
||||
from tests.providers.cloudflare.cloudflare_fixtures import (
|
||||
ZONE_ID,
|
||||
ZONE_NAME,
|
||||
set_mocked_cloudflare_provider,
|
||||
)
|
||||
|
||||
|
||||
class Test_zone_firewall_blocking_rules_configured:
|
||||
def test_no_zones(self):
|
||||
zone_client = mock.MagicMock
|
||||
zone_client.zones = {}
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_cloudflare_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.cloudflare.services.zone.zone_firewall_blocking_rules_configured.zone_firewall_blocking_rules_configured.zone_client",
|
||||
new=zone_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.cloudflare.services.zone.zone_firewall_blocking_rules_configured.zone_firewall_blocking_rules_configured import (
|
||||
zone_firewall_blocking_rules_configured,
|
||||
)
|
||||
|
||||
check = zone_firewall_blocking_rules_configured()
|
||||
result = check.execute()
|
||||
assert len(result) == 0
|
||||
|
||||
def test_zone_with_blocking_rules(self):
|
||||
zone_client = mock.MagicMock
|
||||
zone_client.zones = {
|
||||
ZONE_ID: CloudflareZone(
|
||||
id=ZONE_ID,
|
||||
name=ZONE_NAME,
|
||||
status="active",
|
||||
paused=False,
|
||||
settings=CloudflareZoneSettings(),
|
||||
firewall_rules=[
|
||||
CloudflareFirewallRule(
|
||||
id="rule-1",
|
||||
name="Block bad actors",
|
||||
action="block",
|
||||
enabled=True,
|
||||
),
|
||||
CloudflareFirewallRule(
|
||||
id="rule-2",
|
||||
name="Challenge suspicious",
|
||||
action="challenge",
|
||||
enabled=True,
|
||||
),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_cloudflare_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.cloudflare.services.zone.zone_firewall_blocking_rules_configured.zone_firewall_blocking_rules_configured.zone_client",
|
||||
new=zone_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.cloudflare.services.zone.zone_firewall_blocking_rules_configured.zone_firewall_blocking_rules_configured import (
|
||||
zone_firewall_blocking_rules_configured,
|
||||
)
|
||||
|
||||
check = zone_firewall_blocking_rules_configured()
|
||||
result = check.execute()
|
||||
assert len(result) == 1
|
||||
assert result[0].resource_id == ZONE_ID
|
||||
assert result[0].resource_name == ZONE_NAME
|
||||
assert result[0].status == "PASS"
|
||||
assert (
|
||||
"has firewall rules with blocking actions" in result[0].status_extended
|
||||
)
|
||||
assert "2 rule(s)" in result[0].status_extended
|
||||
|
||||
def test_zone_without_blocking_rules(self):
|
||||
zone_client = mock.MagicMock
|
||||
zone_client.zones = {
|
||||
ZONE_ID: CloudflareZone(
|
||||
id=ZONE_ID,
|
||||
name=ZONE_NAME,
|
||||
status="active",
|
||||
paused=False,
|
||||
settings=CloudflareZoneSettings(),
|
||||
firewall_rules=[
|
||||
CloudflareFirewallRule(
|
||||
id="rule-1",
|
||||
name="Log traffic",
|
||||
action="log",
|
||||
enabled=True,
|
||||
),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_cloudflare_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.cloudflare.services.zone.zone_firewall_blocking_rules_configured.zone_firewall_blocking_rules_configured.zone_client",
|
||||
new=zone_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.cloudflare.services.zone.zone_firewall_blocking_rules_configured.zone_firewall_blocking_rules_configured import (
|
||||
zone_firewall_blocking_rules_configured,
|
||||
)
|
||||
|
||||
check = zone_firewall_blocking_rules_configured()
|
||||
result = check.execute()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert (
|
||||
"has no firewall rules with blocking actions"
|
||||
in result[0].status_extended
|
||||
)
|
||||
|
||||
def test_zone_with_no_firewall_rules(self):
|
||||
zone_client = mock.MagicMock
|
||||
zone_client.zones = {
|
||||
ZONE_ID: CloudflareZone(
|
||||
id=ZONE_ID,
|
||||
name=ZONE_NAME,
|
||||
status="active",
|
||||
paused=False,
|
||||
settings=CloudflareZoneSettings(),
|
||||
firewall_rules=[],
|
||||
)
|
||||
}
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_cloudflare_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.cloudflare.services.zone.zone_firewall_blocking_rules_configured.zone_firewall_blocking_rules_configured.zone_client",
|
||||
new=zone_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.cloudflare.services.zone.zone_firewall_blocking_rules_configured.zone_firewall_blocking_rules_configured import (
|
||||
zone_firewall_blocking_rules_configured,
|
||||
)
|
||||
|
||||
check = zone_firewall_blocking_rules_configured()
|
||||
result = check.execute()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert (
|
||||
"has no firewall rules with blocking actions"
|
||||
in result[0].status_extended
|
||||
)
|
||||
|
||||
def test_zone_with_js_challenge_rule(self):
|
||||
zone_client = mock.MagicMock
|
||||
zone_client.zones = {
|
||||
ZONE_ID: CloudflareZone(
|
||||
id=ZONE_ID,
|
||||
name=ZONE_NAME,
|
||||
status="active",
|
||||
paused=False,
|
||||
settings=CloudflareZoneSettings(),
|
||||
firewall_rules=[
|
||||
CloudflareFirewallRule(
|
||||
id="rule-1",
|
||||
name="JS Challenge",
|
||||
action="js_challenge",
|
||||
enabled=True,
|
||||
),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_cloudflare_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.cloudflare.services.zone.zone_firewall_blocking_rules_configured.zone_firewall_blocking_rules_configured.zone_client",
|
||||
new=zone_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.cloudflare.services.zone.zone_firewall_blocking_rules_configured.zone_firewall_blocking_rules_configured import (
|
||||
zone_firewall_blocking_rules_configured,
|
||||
)
|
||||
|
||||
check = zone_firewall_blocking_rules_configured()
|
||||
result = check.execute()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
assert (
|
||||
"has firewall rules with blocking actions" in result[0].status_extended
|
||||
)
|
||||
|
||||
def test_zone_with_managed_challenge_rule(self):
|
||||
zone_client = mock.MagicMock
|
||||
zone_client.zones = {
|
||||
ZONE_ID: CloudflareZone(
|
||||
id=ZONE_ID,
|
||||
name=ZONE_NAME,
|
||||
status="active",
|
||||
paused=False,
|
||||
settings=CloudflareZoneSettings(),
|
||||
firewall_rules=[
|
||||
CloudflareFirewallRule(
|
||||
id="rule-1",
|
||||
name="Managed Challenge",
|
||||
action="managed_challenge",
|
||||
enabled=True,
|
||||
),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_cloudflare_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.cloudflare.services.zone.zone_firewall_blocking_rules_configured.zone_firewall_blocking_rules_configured.zone_client",
|
||||
new=zone_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.cloudflare.services.zone.zone_firewall_blocking_rules_configured.zone_firewall_blocking_rules_configured import (
|
||||
zone_firewall_blocking_rules_configured,
|
||||
)
|
||||
|
||||
check = zone_firewall_blocking_rules_configured()
|
||||
result = check.execute()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
assert (
|
||||
"has firewall rules with blocking actions" in result[0].status_extended
|
||||
)
|
||||
+254
@@ -0,0 +1,254 @@
|
||||
from unittest import mock
|
||||
|
||||
from prowler.providers.cloudflare.services.zone.zone_service import (
|
||||
CloudflareWAFRuleset,
|
||||
CloudflareZone,
|
||||
CloudflareZoneSettings,
|
||||
)
|
||||
from tests.providers.cloudflare.cloudflare_fixtures import (
|
||||
ZONE_ID,
|
||||
ZONE_NAME,
|
||||
set_mocked_cloudflare_provider,
|
||||
)
|
||||
|
||||
|
||||
class Test_zone_waf_owasp_ruleset_enabled:
|
||||
def test_no_zones(self):
|
||||
zone_client = mock.MagicMock
|
||||
zone_client.zones = {}
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_cloudflare_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.cloudflare.services.zone.zone_waf_owasp_ruleset_enabled.zone_waf_owasp_ruleset_enabled.zone_client",
|
||||
new=zone_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.cloudflare.services.zone.zone_waf_owasp_ruleset_enabled.zone_waf_owasp_ruleset_enabled import (
|
||||
zone_waf_owasp_ruleset_enabled,
|
||||
)
|
||||
|
||||
check = zone_waf_owasp_ruleset_enabled()
|
||||
result = check.execute()
|
||||
assert len(result) == 0
|
||||
|
||||
def test_zone_with_owasp_ruleset_by_name(self):
|
||||
zone_client = mock.MagicMock
|
||||
zone_client.zones = {
|
||||
ZONE_ID: CloudflareZone(
|
||||
id=ZONE_ID,
|
||||
name=ZONE_NAME,
|
||||
status="active",
|
||||
paused=False,
|
||||
settings=CloudflareZoneSettings(),
|
||||
waf_rulesets=[
|
||||
CloudflareWAFRuleset(
|
||||
id="ruleset-1",
|
||||
name="Cloudflare OWASP Core Ruleset",
|
||||
kind="managed",
|
||||
phase="http_request_firewall_managed",
|
||||
enabled=True,
|
||||
),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_cloudflare_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.cloudflare.services.zone.zone_waf_owasp_ruleset_enabled.zone_waf_owasp_ruleset_enabled.zone_client",
|
||||
new=zone_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.cloudflare.services.zone.zone_waf_owasp_ruleset_enabled.zone_waf_owasp_ruleset_enabled import (
|
||||
zone_waf_owasp_ruleset_enabled,
|
||||
)
|
||||
|
||||
check = zone_waf_owasp_ruleset_enabled()
|
||||
result = check.execute()
|
||||
assert len(result) == 1
|
||||
assert result[0].resource_id == ZONE_ID
|
||||
assert result[0].resource_name == ZONE_NAME
|
||||
assert result[0].status == "PASS"
|
||||
assert "has OWASP managed WAF ruleset enabled" in result[0].status_extended
|
||||
assert "Cloudflare OWASP Core Ruleset" in result[0].status_extended
|
||||
|
||||
def test_zone_with_managed_ruleset_without_owasp_name(self):
|
||||
"""Test that a managed ruleset without 'owasp' in name does NOT pass."""
|
||||
zone_client = mock.MagicMock
|
||||
zone_client.zones = {
|
||||
ZONE_ID: CloudflareZone(
|
||||
id=ZONE_ID,
|
||||
name=ZONE_NAME,
|
||||
status="active",
|
||||
paused=False,
|
||||
settings=CloudflareZoneSettings(),
|
||||
waf_rulesets=[
|
||||
CloudflareWAFRuleset(
|
||||
id="ruleset-1",
|
||||
name="Managed Rules",
|
||||
kind="managed",
|
||||
phase="http_request_firewall_managed",
|
||||
enabled=True,
|
||||
),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_cloudflare_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.cloudflare.services.zone.zone_waf_owasp_ruleset_enabled.zone_waf_owasp_ruleset_enabled.zone_client",
|
||||
new=zone_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.cloudflare.services.zone.zone_waf_owasp_ruleset_enabled.zone_waf_owasp_ruleset_enabled import (
|
||||
zone_waf_owasp_ruleset_enabled,
|
||||
)
|
||||
|
||||
check = zone_waf_owasp_ruleset_enabled()
|
||||
result = check.execute()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert (
|
||||
"does not have OWASP managed WAF ruleset enabled"
|
||||
in result[0].status_extended
|
||||
)
|
||||
|
||||
def test_zone_without_owasp_ruleset(self):
|
||||
zone_client = mock.MagicMock
|
||||
zone_client.zones = {
|
||||
ZONE_ID: CloudflareZone(
|
||||
id=ZONE_ID,
|
||||
name=ZONE_NAME,
|
||||
status="active",
|
||||
paused=False,
|
||||
settings=CloudflareZoneSettings(),
|
||||
waf_rulesets=[
|
||||
CloudflareWAFRuleset(
|
||||
id="ruleset-1",
|
||||
name="Custom Rules",
|
||||
kind="custom",
|
||||
phase="http_request_firewall_custom",
|
||||
enabled=True,
|
||||
),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_cloudflare_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.cloudflare.services.zone.zone_waf_owasp_ruleset_enabled.zone_waf_owasp_ruleset_enabled.zone_client",
|
||||
new=zone_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.cloudflare.services.zone.zone_waf_owasp_ruleset_enabled.zone_waf_owasp_ruleset_enabled import (
|
||||
zone_waf_owasp_ruleset_enabled,
|
||||
)
|
||||
|
||||
check = zone_waf_owasp_ruleset_enabled()
|
||||
result = check.execute()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert (
|
||||
"does not have OWASP managed WAF ruleset enabled"
|
||||
in result[0].status_extended
|
||||
)
|
||||
|
||||
def test_zone_with_no_waf_rulesets(self):
|
||||
zone_client = mock.MagicMock
|
||||
zone_client.zones = {
|
||||
ZONE_ID: CloudflareZone(
|
||||
id=ZONE_ID,
|
||||
name=ZONE_NAME,
|
||||
status="active",
|
||||
paused=False,
|
||||
settings=CloudflareZoneSettings(),
|
||||
waf_rulesets=[],
|
||||
)
|
||||
}
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_cloudflare_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.cloudflare.services.zone.zone_waf_owasp_ruleset_enabled.zone_waf_owasp_ruleset_enabled.zone_client",
|
||||
new=zone_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.cloudflare.services.zone.zone_waf_owasp_ruleset_enabled.zone_waf_owasp_ruleset_enabled import (
|
||||
zone_waf_owasp_ruleset_enabled,
|
||||
)
|
||||
|
||||
check = zone_waf_owasp_ruleset_enabled()
|
||||
result = check.execute()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert (
|
||||
"does not have OWASP managed WAF ruleset enabled"
|
||||
in result[0].status_extended
|
||||
)
|
||||
|
||||
def test_zone_with_multiple_owasp_rulesets(self):
|
||||
zone_client = mock.MagicMock
|
||||
zone_client.zones = {
|
||||
ZONE_ID: CloudflareZone(
|
||||
id=ZONE_ID,
|
||||
name=ZONE_NAME,
|
||||
status="active",
|
||||
paused=False,
|
||||
settings=CloudflareZoneSettings(),
|
||||
waf_rulesets=[
|
||||
CloudflareWAFRuleset(
|
||||
id="ruleset-1",
|
||||
name="Cloudflare OWASP Core Ruleset",
|
||||
kind="managed",
|
||||
phase="http_request_firewall_managed",
|
||||
enabled=True,
|
||||
),
|
||||
CloudflareWAFRuleset(
|
||||
id="ruleset-2",
|
||||
name="Custom OWASP Rules",
|
||||
kind="managed",
|
||||
phase="http_request_firewall_managed",
|
||||
enabled=True,
|
||||
),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_cloudflare_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.cloudflare.services.zone.zone_waf_owasp_ruleset_enabled.zone_waf_owasp_ruleset_enabled.zone_client",
|
||||
new=zone_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.cloudflare.services.zone.zone_waf_owasp_ruleset_enabled.zone_waf_owasp_ruleset_enabled import (
|
||||
zone_waf_owasp_ruleset_enabled,
|
||||
)
|
||||
|
||||
check = zone_waf_owasp_ruleset_enabled()
|
||||
result = check.execute()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
assert "Cloudflare OWASP Core Ruleset" in result[0].status_extended
|
||||
assert "Custom OWASP Rules" in result[0].status_extended
|
||||
+4
-4
@@ -15,10 +15,10 @@
|
||||
"format:check": "./node_modules/.bin/prettier --check ./app",
|
||||
"format:write": "./node_modules/.bin/prettier --config .prettierrc.json --write ./app",
|
||||
"prepare": "husky",
|
||||
"test:e2e": "playwright test --project=chromium --project=sign-up --project=providers --project=invitations --project=scans",
|
||||
"test:e2e:ui": "playwright test --project=chromium --project=sign-up --project=providers --project=invitations --project=scans --ui",
|
||||
"test:e2e:debug": "playwright test --project=chromium --project=sign-up --project=providers --project=invitations --project=scans --debug",
|
||||
"test:e2e:headed": "playwright test --project=chromium --project=sign-up --project=providers --project=invitations --project=scans --headed",
|
||||
"test:e2e": "playwright test --project=auth --project=sign-up --project=providers --project=invitations --project=scans",
|
||||
"test:e2e:ui": "playwright test --project=auth --project=sign-up --project=providers --project=invitations --project=scans --ui",
|
||||
"test:e2e:debug": "playwright test --project=auth --project=sign-up --project=providers --project=invitations --project=scans --debug",
|
||||
"test:e2e:headed": "playwright test --project=auth --project=sign-up --project=providers --project=invitations --project=scans --headed",
|
||||
"test:e2e:report": "playwright show-report",
|
||||
"test:e2e:install": "playwright install",
|
||||
"audit:fix": "pnpm audit fix"
|
||||
|
||||
+13
-4
@@ -88,10 +88,18 @@ export default defineConfig({
|
||||
// Test Suite Projects
|
||||
// ===========================================
|
||||
// These projects run the actual test suites
|
||||
|
||||
// This project runs the sign-in-base test suite (form, navigation, accessibility)
|
||||
{
|
||||
name: "chromium",
|
||||
name: "sign-in-base",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
testMatch: "auth-login.spec.ts",
|
||||
testMatch: /sign-in-base\/.*\.spec\.ts/,
|
||||
},
|
||||
// This project runs the auth test suite (middleware, session, token refresh)
|
||||
{
|
||||
name: "auth",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
testMatch: /auth\/.*\.spec\.ts/,
|
||||
},
|
||||
// This project runs the sign-up test suite
|
||||
{
|
||||
@@ -129,8 +137,9 @@ export default defineConfig({
|
||||
AUTH_SECRET: process.env.AUTH_SECRET || "fallback-ci-secret-for-testing",
|
||||
AUTH_TRUST_HOST: process.env.AUTH_TRUST_HOST || "true",
|
||||
NEXTAUTH_URL: process.env.NEXTAUTH_URL || "http://localhost:3000",
|
||||
E2E_USER: process.env.E2E_USER || "e2e@prowler.com",
|
||||
E2E_PASSWORD: process.env.E2E_PASSWORD || "Thisisapassword123@",
|
||||
E2E_ADMIN_USER: process.env.E2E_ADMIN_USER || "e2e@prowler.com",
|
||||
E2E_ADMIN_PASSWORD:
|
||||
process.env.E2E_ADMIN_PASSWORD || "Thisisapassword123@",
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,92 +0,0 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import {
|
||||
goToLogin,
|
||||
login,
|
||||
verifySuccessfulLogin,
|
||||
verifySessionValid,
|
||||
TEST_CREDENTIALS,
|
||||
URLS,
|
||||
} from "./helpers";
|
||||
|
||||
test.describe("Middleware Error Handling", () => {
|
||||
test("should allow access to public routes without session", async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
// Ensure no session exists
|
||||
await context.clearCookies();
|
||||
|
||||
// Try to access login page (public route)
|
||||
await page.goto(URLS.LOGIN);
|
||||
await expect(page).toHaveURL(URLS.LOGIN);
|
||||
await expect(page.getByText("Sign in", { exact: true })).toBeVisible();
|
||||
|
||||
// Try to access sign-up page (public route)
|
||||
await page.goto(URLS.SIGNUP);
|
||||
await expect(page).toHaveURL(URLS.SIGNUP);
|
||||
});
|
||||
|
||||
test("should maintain protection after session error", async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
// Login
|
||||
await goToLogin(page);
|
||||
await login(page, TEST_CREDENTIALS.VALID);
|
||||
await verifySuccessfulLogin(page);
|
||||
|
||||
// Navigate to a protected page
|
||||
await page.goto("/providers");
|
||||
await expect(page).toHaveURL("/providers");
|
||||
|
||||
// Simulate session error by corrupting cookie
|
||||
const cookies = await context.cookies();
|
||||
const sessionCookie = cookies.find((c) =>
|
||||
c.name.includes("authjs.session-token"),
|
||||
);
|
||||
|
||||
if (sessionCookie) {
|
||||
await context.clearCookies();
|
||||
await context.addCookies([
|
||||
{
|
||||
...sessionCookie,
|
||||
value: "invalid-session-token",
|
||||
},
|
||||
]);
|
||||
|
||||
// Try to navigate to another protected page
|
||||
await page.goto("/scans", { waitUntil: "networkidle" });
|
||||
|
||||
// Should be redirected to login (may include callbackUrl)
|
||||
await expect(page).toHaveURL(/\/sign-in/);
|
||||
}
|
||||
});
|
||||
|
||||
test("should handle permission-based redirects", async ({ page }) => {
|
||||
// Login with valid credentials
|
||||
await goToLogin(page);
|
||||
await login(page, TEST_CREDENTIALS.VALID);
|
||||
await verifySuccessfulLogin(page);
|
||||
|
||||
// Get user permissions using helper
|
||||
const session = await verifySessionValid(page);
|
||||
const permissions = session.user.permissions;
|
||||
|
||||
// Test billing route if user doesn't have permission
|
||||
if (!permissions.manage_billing) {
|
||||
await page.goto("/billing", { waitUntil: "networkidle" });
|
||||
|
||||
// Should be redirected to profile (as per middleware logic)
|
||||
await expect(page).toHaveURL("/profile");
|
||||
}
|
||||
|
||||
// Test integrations route if user doesn't have permission
|
||||
if (!permissions.manage_integrations) {
|
||||
await page.goto("/integrations", { waitUntil: "networkidle" });
|
||||
|
||||
// Should be redirected to profile (as per middleware logic)
|
||||
await expect(page).toHaveURL("/profile");
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
@@ -1,124 +0,0 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import {
|
||||
goToLogin,
|
||||
login,
|
||||
verifySuccessfulLogin,
|
||||
getSession,
|
||||
verifySessionValid,
|
||||
TEST_CREDENTIALS,
|
||||
URLS,
|
||||
} from "./helpers";
|
||||
|
||||
test.describe("Token Refresh Flow", () => {
|
||||
test("should refresh access token when expired", async ({ page }) => {
|
||||
// Login first
|
||||
await goToLogin(page);
|
||||
await login(page, TEST_CREDENTIALS.VALID);
|
||||
await verifySuccessfulLogin(page);
|
||||
|
||||
// Get initial session using helper
|
||||
const initialSession = await verifySessionValid(page);
|
||||
const initialAccessToken = initialSession.accessToken;
|
||||
|
||||
// Wait for some time to allow token to potentially expire
|
||||
// In a real scenario, you might want to manipulate the token expiry
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Make a request that requires authentication
|
||||
// This should trigger token refresh if needed
|
||||
await page.reload();
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Verify we're still authenticated
|
||||
await expect(page).toHaveURL(URLS.DASHBOARD);
|
||||
|
||||
// Get session after potential refresh using helper
|
||||
const refreshedSession = await verifySessionValid(page);
|
||||
|
||||
// User data should be maintained
|
||||
expect(refreshedSession.user.email).toBe(initialSession.user.email);
|
||||
expect(refreshedSession.userId).toBe(initialSession.userId);
|
||||
expect(refreshedSession.tenantId).toBe(initialSession.tenantId);
|
||||
});
|
||||
|
||||
test("should handle concurrent requests with token refresh", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Login
|
||||
await goToLogin(page);
|
||||
await login(page, TEST_CREDENTIALS.VALID);
|
||||
await verifySuccessfulLogin(page);
|
||||
|
||||
// Make multiple concurrent requests to the API
|
||||
const requests = Array(5)
|
||||
.fill(null)
|
||||
.map(() => page.request.get("/api/auth/session"));
|
||||
|
||||
const responses = await Promise.all(requests);
|
||||
|
||||
// All requests should succeed - verify using helper
|
||||
for (const response of responses) {
|
||||
expect(response.ok()).toBeTruthy();
|
||||
const session = await response.json();
|
||||
|
||||
// Validate session structure
|
||||
expect(session).toBeTruthy();
|
||||
expect(session.user).toBeTruthy();
|
||||
expect(session.accessToken).toBeTruthy();
|
||||
expect(session.refreshToken).toBeTruthy();
|
||||
expect(session.error).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
test("should preserve user permissions after token refresh", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Login
|
||||
await goToLogin(page);
|
||||
await login(page, TEST_CREDENTIALS.VALID);
|
||||
await verifySuccessfulLogin(page);
|
||||
|
||||
// Get initial session with permissions using helper
|
||||
const initialSession = await verifySessionValid(page);
|
||||
const initialPermissions = initialSession.user.permissions;
|
||||
|
||||
// Reload page to potentially trigger token refresh
|
||||
await page.reload();
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Get session after reload using helper
|
||||
const refreshedSession = await verifySessionValid(page);
|
||||
|
||||
// Permissions should be preserved
|
||||
expect(refreshedSession.user.permissions).toEqual(initialPermissions);
|
||||
|
||||
// All user data should be preserved
|
||||
expect(refreshedSession.user.email).toBe(initialSession.user.email);
|
||||
expect(refreshedSession.user.name).toBe(initialSession.user.name);
|
||||
expect(refreshedSession.user.companyName).toBe(
|
||||
initialSession.user.companyName,
|
||||
);
|
||||
});
|
||||
|
||||
test("should clear session when cookies are removed", async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
// Login
|
||||
await goToLogin(page);
|
||||
await login(page, TEST_CREDENTIALS.VALID);
|
||||
await verifySuccessfulLogin(page);
|
||||
|
||||
// Verify session is valid using helper
|
||||
await verifySessionValid(page);
|
||||
|
||||
// Clear all cookies to simulate complete session expiry
|
||||
await context.clearCookies();
|
||||
|
||||
// Verify session is null after clearing cookies
|
||||
const expiredSession = await getSession(page);
|
||||
expect(expiredSession).toBeNull();
|
||||
|
||||
// Note: Middleware redirect behavior is tested in auth-middleware-error.spec.ts
|
||||
});
|
||||
});
|
||||
@@ -1,102 +0,0 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import {
|
||||
goToLogin,
|
||||
login,
|
||||
verifySuccessfulLogin,
|
||||
TEST_CREDENTIALS,
|
||||
URLS,
|
||||
} from "./helpers";
|
||||
|
||||
test.describe("Session Error Messages", () => {
|
||||
test("should show RefreshAccessTokenError message", async ({ page }) => {
|
||||
// Navigate to sign-in with RefreshAccessTokenError query param
|
||||
await page.goto("/sign-in?error=RefreshAccessTokenError");
|
||||
|
||||
// Wait for toast notification
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
// Verify error toast appears
|
||||
const toast = page.locator('[role="status"], [role="alert"]').first();
|
||||
|
||||
const isVisible = await toast.isVisible().catch(() => false);
|
||||
|
||||
if (isVisible) {
|
||||
const text = await toast.textContent();
|
||||
expect(text).toContain("Session Expired");
|
||||
expect(text).toContain("Please sign in again");
|
||||
}
|
||||
|
||||
// Verify sign-in form is displayed
|
||||
await expect(page.getByLabel("Email")).toBeVisible();
|
||||
await expect(page.getByLabel("Password")).toBeVisible();
|
||||
});
|
||||
|
||||
test("should show MissingRefreshToken error message", async ({ page }) => {
|
||||
// Navigate to sign-in with MissingRefreshToken query param
|
||||
await page.goto("/sign-in?error=MissingRefreshToken");
|
||||
|
||||
// Wait for toast notification
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
// Verify error toast appears
|
||||
const toast = page.locator('[role="status"], [role="alert"]').first();
|
||||
|
||||
const isVisible = await toast.isVisible().catch(() => false);
|
||||
|
||||
if (isVisible) {
|
||||
const text = await toast.textContent();
|
||||
expect(text).toContain("Session Error");
|
||||
}
|
||||
|
||||
// Verify sign-in form is displayed
|
||||
await expect(page.getByLabel("Email")).toBeVisible();
|
||||
});
|
||||
|
||||
test("should show generic error for unknown error types", async ({ page }) => {
|
||||
// Navigate to sign-in with unknown error type
|
||||
await page.goto("/sign-in?error=UnknownError");
|
||||
|
||||
// Wait for toast notification
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
// Verify generic error toast appears
|
||||
const toast = page.locator('[role="status"], [role="alert"]').first();
|
||||
|
||||
const isVisible = await toast.isVisible().catch(() => false);
|
||||
|
||||
if (isVisible) {
|
||||
const text = await toast.textContent();
|
||||
expect(text).toContain("Authentication Error");
|
||||
expect(text).toContain("Please sign in again");
|
||||
}
|
||||
});
|
||||
|
||||
test("should include callbackUrl in redirect", async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
// Login first
|
||||
await goToLogin(page);
|
||||
await login(page, TEST_CREDENTIALS.VALID);
|
||||
await verifySuccessfulLogin(page);
|
||||
|
||||
// Navigate to a specific page
|
||||
await page.goto("/scans");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Clear cookies to simulate session expiry
|
||||
await context.clearCookies();
|
||||
|
||||
// Try to navigate to a different protected route
|
||||
await page.goto("/providers");
|
||||
|
||||
// Should be redirected to login with callbackUrl
|
||||
await expect(page).toHaveURL(/\/sign-in\?.*callbackUrl=/);
|
||||
|
||||
// Verify callbackUrl contains the attempted route
|
||||
const url = new URL(page.url());
|
||||
const callbackUrl = url.searchParams.get("callbackUrl");
|
||||
expect(callbackUrl).toBe("/providers");
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { test } from "@playwright/test";
|
||||
|
||||
import { TEST_CREDENTIALS } from "../helpers";
|
||||
import { ProvidersPage } from "../providers/providers-page";
|
||||
import { ScansPage } from "../scans/scans-page";
|
||||
import { SignInPage } from "../sign-in-base/sign-in-base-page";
|
||||
import { SignUpPage } from "../sign-up/sign-up-page";
|
||||
|
||||
test.describe("Middleware Error Handling", () => {
|
||||
// Increase timeout for tests that involve multiple navigations under load
|
||||
test.setTimeout(60000);
|
||||
|
||||
test(
|
||||
"should allow access to public routes without session",
|
||||
{ tag: ["@e2e", "@auth", "@middleware", "@AUTH-MW-E2E-001"] },
|
||||
async ({ page, context }) => {
|
||||
const signInPage = new SignInPage(page);
|
||||
const signUpPage = new SignUpPage(page);
|
||||
|
||||
await context.clearCookies();
|
||||
|
||||
await signInPage.goto();
|
||||
await signInPage.verifyOnSignInPage();
|
||||
|
||||
await signUpPage.goto();
|
||||
await signUpPage.verifyPageLoaded();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
"should maintain protection after session error",
|
||||
{ tag: ["@e2e", "@auth", "@middleware", "@AUTH-MW-E2E-002"] },
|
||||
async ({ page, context }) => {
|
||||
const signInPage = new SignInPage(page);
|
||||
const providersPage = new ProvidersPage(page);
|
||||
const scansPage = new ScansPage(page);
|
||||
|
||||
await signInPage.loginAndVerify(TEST_CREDENTIALS.VALID);
|
||||
|
||||
await providersPage.goto();
|
||||
await providersPage.verifyPageLoaded();
|
||||
|
||||
const cookies = await context.cookies();
|
||||
const sessionCookie = cookies.find((c) =>
|
||||
c.name.includes("authjs.session-token"),
|
||||
);
|
||||
|
||||
if (sessionCookie) {
|
||||
await context.clearCookies();
|
||||
await context.addCookies([
|
||||
{
|
||||
...sessionCookie,
|
||||
value: "invalid-session-token",
|
||||
},
|
||||
]);
|
||||
|
||||
await scansPage.goto();
|
||||
// With invalid session, should redirect to sign-in
|
||||
await signInPage.verifyOnSignInPage();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Note: Billing and integrations permission tests removed
|
||||
// These features only exist in Prowler Cloud, not in the open-source version
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { TEST_CREDENTIALS } from "../helpers";
|
||||
import { ProvidersPage } from "../providers/providers-page";
|
||||
import { ScansPage } from "../scans/scans-page";
|
||||
import { SignInPage } from "../sign-in-base/sign-in-base-page";
|
||||
|
||||
test.describe("Session Error Messages", () => {
|
||||
// Increase timeout for tests that involve session operations under load
|
||||
test.setTimeout(60000);
|
||||
|
||||
test(
|
||||
"should show RefreshAccessTokenError message",
|
||||
{ tag: ["@e2e", "@auth", "@session", "@AUTH-SESSION-E2E-001"] },
|
||||
async ({ page }) => {
|
||||
const signInPage = new SignInPage(page);
|
||||
|
||||
await signInPage.gotoWithError("RefreshAccessTokenError");
|
||||
|
||||
const { isVisible, text } = await signInPage.waitForToast();
|
||||
if (isVisible && text) {
|
||||
expect(text).toContain("Session Expired");
|
||||
expect(text).toContain("Please sign in again");
|
||||
}
|
||||
|
||||
await signInPage.verifyFormElements();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
"should show MissingRefreshToken error message",
|
||||
{ tag: ["@e2e", "@auth", "@session", "@AUTH-SESSION-E2E-002"] },
|
||||
async ({ page }) => {
|
||||
const signInPage = new SignInPage(page);
|
||||
|
||||
await signInPage.gotoWithError("MissingRefreshToken");
|
||||
|
||||
const { isVisible, text } = await signInPage.waitForToast();
|
||||
if (isVisible && text) {
|
||||
expect(text).toContain("Session Error");
|
||||
}
|
||||
|
||||
await expect(signInPage.emailInput).toBeVisible();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
"should show generic error for unknown error types",
|
||||
{ tag: ["@e2e", "@auth", "@session", "@AUTH-SESSION-E2E-003"] },
|
||||
async ({ page }) => {
|
||||
const signInPage = new SignInPage(page);
|
||||
|
||||
await signInPage.gotoWithError("UnknownError");
|
||||
|
||||
const { isVisible, text } = await signInPage.waitForToast();
|
||||
if (isVisible && text) {
|
||||
expect(text).toContain("Authentication Error");
|
||||
expect(text).toContain("Please sign in again");
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
"should include callbackUrl in redirect",
|
||||
{ tag: ["@e2e", "@auth", "@session", "@AUTH-SESSION-E2E-004"] },
|
||||
async ({ page, context }) => {
|
||||
const signInPage = new SignInPage(page);
|
||||
const scansPage = new ScansPage(page);
|
||||
const providersPage = new ProvidersPage(page);
|
||||
|
||||
await signInPage.loginAndVerify(TEST_CREDENTIALS.VALID);
|
||||
|
||||
// Navigate to a specific page (just need to be on a protected route)
|
||||
await scansPage.goto();
|
||||
await expect(page.locator("main")).toBeVisible();
|
||||
|
||||
// Clear cookies to simulate session expiry
|
||||
await context.clearCookies();
|
||||
|
||||
// Try to navigate to a different protected route
|
||||
// Use gotoFresh to ensure middleware runs without cached state
|
||||
await providersPage.gotoFresh("/providers");
|
||||
|
||||
// Should be redirected to login with callbackUrl
|
||||
await signInPage.verifyRedirectWithCallback("/providers");
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { getSession, TEST_CREDENTIALS, verifySessionValid } from "../helpers";
|
||||
import { HomePage } from "../home/home-page";
|
||||
import { SignInPage } from "../sign-in-base/sign-in-base-page";
|
||||
|
||||
// Note: HomePage is still needed for verifyPageLoaded after reload in some tests
|
||||
|
||||
test.describe("Token Refresh Flow", () => {
|
||||
// Increase timeout for tests that involve session operations under load
|
||||
test.setTimeout(60000);
|
||||
|
||||
test(
|
||||
"should refresh access token when expired",
|
||||
{ tag: ["@e2e", "@auth", "@token", "@AUTH-TOKEN-E2E-001"] },
|
||||
async ({ page }) => {
|
||||
const signInPage = new SignInPage(page);
|
||||
const homePage = new HomePage(page);
|
||||
|
||||
await signInPage.loginAndVerify(TEST_CREDENTIALS.VALID);
|
||||
|
||||
const initialSession = await verifySessionValid(page);
|
||||
|
||||
await page.reload();
|
||||
await homePage.verifyPageLoaded();
|
||||
|
||||
const refreshedSession = await verifySessionValid(page);
|
||||
|
||||
expect(refreshedSession.user.email).toBe(initialSession.user.email);
|
||||
expect(refreshedSession.userId).toBe(initialSession.userId);
|
||||
expect(refreshedSession.tenantId).toBe(initialSession.tenantId);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
"should preserve user permissions after token refresh",
|
||||
{ tag: ["@e2e", "@auth", "@token", "@AUTH-TOKEN-E2E-002"] },
|
||||
async ({ page }) => {
|
||||
const signInPage = new SignInPage(page);
|
||||
const homePage = new HomePage(page);
|
||||
|
||||
await signInPage.loginAndVerify(TEST_CREDENTIALS.VALID);
|
||||
|
||||
const initialSession = await verifySessionValid(page);
|
||||
const initialPermissions = initialSession.user.permissions;
|
||||
|
||||
await page.reload();
|
||||
await homePage.verifyPageLoaded();
|
||||
|
||||
const refreshedSession = await verifySessionValid(page);
|
||||
|
||||
expect(refreshedSession.user.permissions).toEqual(initialPermissions);
|
||||
|
||||
expect(refreshedSession.user.email).toBe(initialSession.user.email);
|
||||
expect(refreshedSession.user.name).toBe(initialSession.user.name);
|
||||
expect(refreshedSession.user.companyName).toBe(
|
||||
initialSession.user.companyName,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
"should clear session when cookies are removed",
|
||||
{ tag: ["@e2e", "@auth", "@token", "@AUTH-TOKEN-E2E-003"] },
|
||||
async ({ page, context }) => {
|
||||
const signInPage = new SignInPage(page);
|
||||
|
||||
await signInPage.loginAndVerify(TEST_CREDENTIALS.VALID);
|
||||
|
||||
await verifySessionValid(page);
|
||||
|
||||
await context.clearCookies();
|
||||
|
||||
const expiredSession = await getSession(page);
|
||||
expect(expiredSession).toBeNull();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
### E2E Tests: Authentication System
|
||||
|
||||
**Suite ID:** `AUTH-E2E`
|
||||
**Feature:** Authentication middleware, session management, and token refresh.
|
||||
|
||||
---
|
||||
|
||||
## Test Case: `AUTH-MW-E2E-001` - Allow access to public routes without session
|
||||
|
||||
**Priority:** `normal`
|
||||
|
||||
**Tags:**
|
||||
- type: @e2e
|
||||
- feature: @auth, @middleware
|
||||
|
||||
**Description/Objective:** Verify public routes are accessible without authentication.
|
||||
|
||||
**Preconditions:**
|
||||
- Application is running.
|
||||
- No active session (cookies cleared).
|
||||
|
||||
### Flow Steps:
|
||||
1. Clear all cookies.
|
||||
2. Navigate to /sign-in.
|
||||
3. Verify page loads.
|
||||
4. Navigate to /sign-up.
|
||||
5. Verify page loads.
|
||||
|
||||
### Expected Result:
|
||||
- Public routes are accessible without authentication.
|
||||
|
||||
---
|
||||
|
||||
## Test Case: `AUTH-MW-E2E-002` - Maintain protection after session error
|
||||
|
||||
**Priority:** `normal`
|
||||
|
||||
**Tags:**
|
||||
- type: @e2e
|
||||
- feature: @auth, @middleware
|
||||
|
||||
**Description/Objective:** Verify protected routes remain protected after session invalidation.
|
||||
|
||||
**Preconditions:**
|
||||
- Application is running.
|
||||
|
||||
### Flow Steps:
|
||||
1. Log in with valid credentials.
|
||||
2. Navigate to a protected route.
|
||||
3. Invalidate session (replace cookie with invalid token).
|
||||
4. Navigate to another protected route.
|
||||
5. Verify redirect to sign-in.
|
||||
|
||||
### Expected Result:
|
||||
- Invalid session results in redirect to sign-in.
|
||||
|
||||
---
|
||||
|
||||
## Test Case: `AUTH-SESSION-E2E-001` - Show RefreshAccessTokenError message
|
||||
|
||||
**Priority:** `normal`
|
||||
|
||||
**Tags:**
|
||||
- type: @e2e
|
||||
- feature: @auth, @session
|
||||
|
||||
**Description/Objective:** Verify that RefreshAccessTokenError displays appropriate toast message.
|
||||
|
||||
**Preconditions:**
|
||||
- Application is running.
|
||||
|
||||
### Flow Steps:
|
||||
1. Navigate to /sign-in with error=RefreshAccessTokenError query parameter.
|
||||
2. Check for toast notification.
|
||||
3. Verify form elements are still visible.
|
||||
|
||||
### Expected Result:
|
||||
- Toast shows "Session Expired" message with "Please sign in again".
|
||||
- Sign-in form is displayed and functional.
|
||||
|
||||
---
|
||||
|
||||
## Test Case: `AUTH-SESSION-E2E-002` - Show MissingRefreshToken error message
|
||||
|
||||
**Priority:** `normal`
|
||||
|
||||
**Tags:**
|
||||
- type: @e2e
|
||||
- feature: @auth, @session
|
||||
|
||||
**Description/Objective:** Verify that MissingRefreshToken error displays appropriate toast message.
|
||||
|
||||
**Preconditions:**
|
||||
- Application is running.
|
||||
|
||||
### Flow Steps:
|
||||
1. Navigate to /sign-in with error=MissingRefreshToken query parameter.
|
||||
2. Check for toast notification.
|
||||
3. Verify email input is visible.
|
||||
|
||||
### Expected Result:
|
||||
- Toast shows "Session Error" message.
|
||||
- Sign-in form is displayed.
|
||||
|
||||
---
|
||||
|
||||
## Test Case: `AUTH-SESSION-E2E-003` - Show generic error for unknown error types
|
||||
|
||||
**Priority:** `normal`
|
||||
|
||||
**Tags:**
|
||||
- type: @e2e
|
||||
- feature: @auth, @session
|
||||
|
||||
**Description/Objective:** Verify that unknown error types display a generic authentication error message.
|
||||
|
||||
**Preconditions:**
|
||||
- Application is running.
|
||||
|
||||
### Flow Steps:
|
||||
1. Navigate to /sign-in with error=UnknownError query parameter.
|
||||
2. Check for toast notification.
|
||||
|
||||
### Expected Result:
|
||||
- Toast shows "Authentication Error" message with "Please sign in again".
|
||||
|
||||
---
|
||||
|
||||
## Test Case: `AUTH-SESSION-E2E-004` - Include callbackUrl in redirect
|
||||
|
||||
**Priority:** `normal`
|
||||
|
||||
**Tags:**
|
||||
- type: @e2e
|
||||
- feature: @auth, @session
|
||||
|
||||
**Description/Objective:** Verify that callbackUrl is preserved when redirecting to sign-in after session expiry.
|
||||
|
||||
**Preconditions:**
|
||||
- Application is running.
|
||||
- Valid test user credentials.
|
||||
|
||||
### Flow Steps:
|
||||
1. Log in with valid credentials.
|
||||
2. Navigate to a protected route (/scans).
|
||||
3. Clear cookies to simulate session expiry.
|
||||
4. Navigate to another protected route (/providers).
|
||||
5. Verify redirect to sign-in includes callbackUrl parameter.
|
||||
|
||||
### Expected Result:
|
||||
- URL contains callbackUrl=/providers parameter.
|
||||
- User can sign in and be redirected back to the original destination.
|
||||
|
||||
---
|
||||
|
||||
## Test Case: `AUTH-TOKEN-E2E-001` - Refresh access token when expired
|
||||
|
||||
**Priority:** `normal`
|
||||
|
||||
**Tags:**
|
||||
- type: @e2e
|
||||
- feature: @auth, @token
|
||||
|
||||
**Description/Objective:** Verify that session is maintained after page reload (token refresh).
|
||||
|
||||
**Preconditions:**
|
||||
- Application is running.
|
||||
- Valid test user credentials.
|
||||
|
||||
### Flow Steps:
|
||||
1. Log in with valid credentials.
|
||||
2. Verify home page is loaded.
|
||||
3. Capture initial session data.
|
||||
4. Reload the page.
|
||||
5. Verify session is still valid with same user data.
|
||||
|
||||
### Expected Result:
|
||||
- Session persists after reload.
|
||||
- User email, userId, and tenantId remain the same.
|
||||
|
||||
---
|
||||
|
||||
## Test Case: `AUTH-TOKEN-E2E-002` - Preserve user permissions after token refresh
|
||||
|
||||
**Priority:** `normal`
|
||||
|
||||
**Tags:**
|
||||
- type: @e2e
|
||||
- feature: @auth, @token
|
||||
|
||||
**Description/Objective:** Verify that user permissions are preserved after token refresh.
|
||||
|
||||
**Preconditions:**
|
||||
- Application is running.
|
||||
- Valid test user credentials.
|
||||
|
||||
### Flow Steps:
|
||||
1. Log in with valid credentials.
|
||||
2. Capture initial session with permissions.
|
||||
3. Reload the page.
|
||||
4. Verify permissions match initial session.
|
||||
|
||||
### Expected Result:
|
||||
- User permissions are identical before and after refresh.
|
||||
- User profile data (email, name, companyName) is preserved.
|
||||
|
||||
---
|
||||
|
||||
## Test Case: `AUTH-TOKEN-E2E-003` - Clear session when cookies are removed
|
||||
|
||||
**Priority:** `normal`
|
||||
|
||||
**Tags:**
|
||||
- type: @e2e
|
||||
- feature: @auth, @token
|
||||
|
||||
**Description/Objective:** Verify that session is cleared when cookies are removed.
|
||||
|
||||
**Preconditions:**
|
||||
- Application is running.
|
||||
- Valid test user credentials.
|
||||
|
||||
### Flow Steps:
|
||||
1. Log in with valid credentials.
|
||||
2. Verify session is valid.
|
||||
3. Clear all cookies.
|
||||
4. Check session status.
|
||||
|
||||
### Expected Result:
|
||||
- Session returns null after cookies are cleared.
|
||||
- User is effectively logged out.
|
||||
+13
-5
@@ -26,6 +26,14 @@ export abstract class BasePage {
|
||||
await this.page.goto(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to URL waiting only for server response (commit).
|
||||
* Use this after clearing cookies to ensure middleware runs fresh.
|
||||
*/
|
||||
async gotoFresh(url: string): Promise<void> {
|
||||
await this.page.goto(url, { waitUntil: "commit" });
|
||||
}
|
||||
|
||||
async refresh(): Promise<void> {
|
||||
await this.page.reload();
|
||||
}
|
||||
@@ -130,28 +138,28 @@ export abstract class BasePage {
|
||||
|
||||
// Common wait methods
|
||||
async waitForElement(element: Locator, timeout: number = 5000): Promise<void> {
|
||||
|
||||
|
||||
await element.waitFor({ timeout });
|
||||
}
|
||||
|
||||
async waitForElementToDisappear(element: Locator, timeout: number = 5000): Promise<void> {
|
||||
|
||||
|
||||
await element.waitFor({ state: "hidden", timeout });
|
||||
}
|
||||
|
||||
async waitForUrl(expectedUrl: string | RegExp, timeout: number = 5000): Promise<void> {
|
||||
|
||||
|
||||
await this.page.waitForURL(expectedUrl, { timeout });
|
||||
}
|
||||
|
||||
// Common screenshot methods
|
||||
async takeScreenshot(name: string): Promise<void> {
|
||||
|
||||
|
||||
await this.page.screenshot({ path: `screenshots/${name}.png` });
|
||||
}
|
||||
|
||||
async takeElementScreenshot(element: Locator, name: string): Promise<void> {
|
||||
|
||||
|
||||
await element.screenshot({ path: `screenshots/${name}.png` });
|
||||
}
|
||||
}
|
||||
+2
-140
@@ -1,5 +1,4 @@
|
||||
import { Locator, Page, expect } from "@playwright/test";
|
||||
import { SignInPage, SignInCredentials } from "./sign-in/sign-in-page";
|
||||
import { AWSProviderCredential, AWSProviderData, AWS_CREDENTIAL_OPTIONS, ProvidersPage } from "./providers/providers-page";
|
||||
import { ScansPage } from "./scans/scans-page";
|
||||
|
||||
@@ -18,8 +17,8 @@ export const URLS = {
|
||||
|
||||
export const TEST_CREDENTIALS = {
|
||||
VALID: {
|
||||
email: process.env.E2E_USER || "e2e@prowler.com",
|
||||
password: process.env.E2E_PASSWORD || "Thisisapassword123@",
|
||||
email: process.env.E2E_ADMIN_USER || "e2e@prowler.com",
|
||||
password: process.env.E2E_ADMIN_PASSWORD || "Thisisapassword123@",
|
||||
},
|
||||
INVALID: {
|
||||
email: "invalid@example.com",
|
||||
@@ -31,143 +30,6 @@ export const TEST_CREDENTIALS = {
|
||||
},
|
||||
} as const;
|
||||
|
||||
export async function goToLogin(page: Page) {
|
||||
await page.goto("/sign-in");
|
||||
}
|
||||
|
||||
export async function goToSignUp(page: Page) {
|
||||
await page.goto("/sign-up");
|
||||
}
|
||||
|
||||
export async function fillLoginForm(
|
||||
page: Page,
|
||||
email: string,
|
||||
password: string,
|
||||
) {
|
||||
await page.getByLabel("Email").fill(email);
|
||||
await page.getByLabel("Password").fill(password);
|
||||
}
|
||||
|
||||
export async function submitLoginForm(page: Page) {
|
||||
await page.getByRole("button", { name: "Log in" }).click();
|
||||
}
|
||||
|
||||
export async function login(
|
||||
page: Page,
|
||||
credentials: { email: string; password: string } = TEST_CREDENTIALS.VALID,
|
||||
) {
|
||||
await fillLoginForm(page, credentials.email, credentials.password);
|
||||
await submitLoginForm(page);
|
||||
}
|
||||
|
||||
export async function verifySuccessfulLogin(page: Page) {
|
||||
await expect(page).toHaveURL("/");
|
||||
await expect(page.locator("main")).toBeVisible();
|
||||
await expect(
|
||||
page
|
||||
.getByLabel("Breadcrumbs")
|
||||
.getByRole("heading", { name: "Overview", exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
export async function verifyLoginError(
|
||||
page: Page,
|
||||
errorMessage = "Invalid email or password",
|
||||
) {
|
||||
// There may be multiple field-level errors with the same text; assert at least one is visible
|
||||
await expect(page.getByText(errorMessage).first()).toBeVisible();
|
||||
await expect(page).toHaveURL("/sign-in");
|
||||
}
|
||||
|
||||
export async function toggleSamlMode(page: Page) {
|
||||
await page.getByText("Continue with SAML SSO").click();
|
||||
}
|
||||
|
||||
export async function goBackFromSaml(page: Page) {
|
||||
await page.getByText("Back").click();
|
||||
}
|
||||
|
||||
export async function verifySamlModeActive(page: Page) {
|
||||
await expect(page.getByText("Sign in with SAML SSO")).toBeVisible();
|
||||
await expect(page.getByLabel("Password")).not.toBeVisible();
|
||||
await expect(page.getByText("Back")).toBeVisible();
|
||||
}
|
||||
|
||||
export async function verifyNormalModeActive(page: Page) {
|
||||
await expect(page.getByText("Sign in", { exact: true })).toBeVisible();
|
||||
await expect(page.getByLabel("Password")).toBeVisible();
|
||||
}
|
||||
|
||||
export async function logout(page: Page) {
|
||||
const navbar = page.locator("header");
|
||||
await navbar.waitFor({ state: "visible" });
|
||||
await navbar.getByRole("button", { name: "Sign out" }).click();
|
||||
}
|
||||
|
||||
export async function verifyLogoutSuccess(page: Page) {
|
||||
await expect(page).toHaveURL(/\/sign-in/);
|
||||
await expect(page.getByText("Sign in", { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function verifyLoadingState(page: Page) {
|
||||
const submitButton = page.getByRole("button", { name: "Log in" });
|
||||
await expect(submitButton).toHaveAttribute("aria-disabled", "true");
|
||||
await expect(page.getByText("Loading")).toBeVisible();
|
||||
}
|
||||
|
||||
export async function verifyLoginFormElements(page: Page) {
|
||||
await expect(page).toHaveTitle(/Prowler/);
|
||||
await expect(page.locator('svg[width="300"]')).toBeVisible();
|
||||
|
||||
// Verify form elements
|
||||
await expect(page.getByText("Sign in", { exact: true })).toBeVisible();
|
||||
await expect(page.getByLabel("Email")).toBeVisible();
|
||||
await expect(page.getByLabel("Password")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Log in" })).toBeVisible();
|
||||
|
||||
// Verify OAuth buttons
|
||||
await expect(page.getByText("Continue with Google")).toBeVisible();
|
||||
await expect(page.getByText("Continue with Github")).toBeVisible();
|
||||
await expect(page.getByText("Continue with SAML SSO")).toBeVisible();
|
||||
|
||||
// Verify navigation links
|
||||
await expect(page.getByText("Need to create an account?")).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "Sign up" })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function waitForPageLoad(page: Page) {
|
||||
await page.waitForLoadState("networkidle");
|
||||
}
|
||||
|
||||
export async function verifyDashboardRoute(page: Page) {
|
||||
await expect(page).toHaveURL("/");
|
||||
}
|
||||
|
||||
export async function authenticateAndSaveState(
|
||||
page: Page,
|
||||
email: string,
|
||||
password: string,
|
||||
storagePath: string,
|
||||
) {
|
||||
if (!email || !password) {
|
||||
throw new Error(
|
||||
"Email and password are required for authentication and save state",
|
||||
);
|
||||
}
|
||||
|
||||
// Create SignInPage instance
|
||||
const signInPage = new SignInPage(page);
|
||||
const credentials: SignInCredentials = { email, password };
|
||||
|
||||
// Perform authentication steps using Page Object Model
|
||||
await signInPage.goto();
|
||||
await signInPage.login(credentials);
|
||||
await signInPage.verifySuccessfulLogin();
|
||||
|
||||
// Save authentication state
|
||||
await page.context().storageState({ path: storagePath });
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random base36 suffix of specified length
|
||||
* Used for creating unique test data to avoid conflicts
|
||||
|
||||
+13
-14
@@ -2,41 +2,42 @@ import { Page, Locator, expect } from "@playwright/test";
|
||||
import { BasePage } from "../base-page";
|
||||
|
||||
export class HomePage extends BasePage {
|
||||
|
||||
|
||||
// Main content elements
|
||||
readonly mainContent: Locator;
|
||||
readonly breadcrumbs: Locator;
|
||||
readonly overviewHeading: Locator;
|
||||
|
||||
|
||||
// Navigation elements
|
||||
readonly navigationMenu: Locator;
|
||||
readonly userMenu: Locator;
|
||||
readonly signOutButton: Locator;
|
||||
|
||||
|
||||
// Dashboard elements
|
||||
readonly dashboardCards: Locator;
|
||||
readonly overviewSection: Locator;
|
||||
|
||||
|
||||
// UI elements
|
||||
readonly logo: Locator;
|
||||
|
||||
constructor(page: Page) {
|
||||
super(page);
|
||||
|
||||
|
||||
// Main content elements
|
||||
this.mainContent = page.locator("main");
|
||||
this.breadcrumbs = page.getByRole("navigation", { name: "Breadcrumbs" });
|
||||
this.overviewHeading = page.getByRole("heading", { name: "Overview", exact: true });
|
||||
|
||||
|
||||
// Navigation elements
|
||||
this.navigationMenu = page.locator("nav");
|
||||
this.userMenu = page.getByRole("button", { name: /user menu/i });
|
||||
// Sign out is a direct button, not inside a menu
|
||||
this.userMenu = page.getByRole("button", { name: "Sign out" });
|
||||
this.signOutButton = page.getByRole("button", { name: "Sign out" });
|
||||
|
||||
|
||||
// Dashboard elements
|
||||
this.dashboardCards = page.locator('[data-testid="dashboard-card"]');
|
||||
this.overviewSection = page.locator('[data-testid="overview-section"]');
|
||||
|
||||
|
||||
// UI elements
|
||||
this.logo = page.locator('svg[width="300"]');
|
||||
}
|
||||
@@ -67,12 +68,10 @@ export class HomePage extends BasePage {
|
||||
await this.overviewHeading.click();
|
||||
}
|
||||
|
||||
async openUserMenu(): Promise<void> {
|
||||
await this.userMenu.click();
|
||||
}
|
||||
|
||||
async signOut(): Promise<void> {
|
||||
await this.openUserMenu();
|
||||
// Wait for navbar to be visible before clicking sign out
|
||||
const navbar = this.page.locator("header");
|
||||
await navbar.waitFor({ state: "visible" });
|
||||
await this.signOutButton.click();
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { test } from "@playwright/test";
|
||||
import { InvitationsPage } from "./invitations-page";
|
||||
import { makeSuffix } from "../helpers";
|
||||
import { SignUpPage } from "../sign-up/sign-up-page";
|
||||
import { SignInPage } from "../sign-in/sign-in-page";
|
||||
import { SignInPage } from "../sign-in-base/sign-in-base-page";
|
||||
import { UserProfilePage } from "../profile/profile-page";
|
||||
|
||||
test.describe("New user invitation", () => {
|
||||
|
||||
@@ -15,11 +15,18 @@ export class UserProfilePage extends BasePage {
|
||||
}
|
||||
|
||||
async goto(): Promise<void> {
|
||||
// Navigate to the user profile page
|
||||
|
||||
await super.goto("/profile");
|
||||
}
|
||||
|
||||
async verifyPageLoaded(): Promise<void> {
|
||||
await expect(this.page).toHaveURL("/profile");
|
||||
await expect(this.pageHeadingUserProfile).toBeVisible();
|
||||
}
|
||||
|
||||
async verifyOnProfilePage(): Promise<void> {
|
||||
await expect(this.page).toHaveURL("/profile");
|
||||
}
|
||||
|
||||
async verifyOrganizationId(organizationId: string): Promise<void> {
|
||||
// Verify the organization ID is visible
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test as authAdminSetup } from '@playwright/test';
|
||||
import { authenticateAndSaveState } from '@/tests/helpers';
|
||||
import { SignInPage } from '../sign-in-base/sign-in-base-page';
|
||||
|
||||
const adminUserFile = 'playwright/.auth/admin_user.json';
|
||||
|
||||
@@ -12,5 +12,6 @@ authAdminSetup('authenticate as admin e2e user', async ({ page }) => {
|
||||
throw new Error('E2E_ADMIN_USER and E2E_ADMIN_PASSWORD environment variables are required');
|
||||
}
|
||||
|
||||
await authenticateAndSaveState(page, adminEmail, adminPassword, adminUserFile);
|
||||
const signInPage = new SignInPage(page);
|
||||
await signInPage.authenticateAndSaveState({ email: adminEmail, password: adminPassword }, adminUserFile);
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test as authInviteAndManageUsersSetup } from '@playwright/test';
|
||||
import { authenticateAndSaveState } from '@/tests/helpers';
|
||||
import { SignInPage } from '../sign-in-base/sign-in-base-page';
|
||||
|
||||
const inviteAndManageUsersUserFile = 'playwright/.auth/invite_and_manage_users_user.json';
|
||||
|
||||
@@ -11,5 +11,6 @@ authInviteAndManageUsersSetup('authenticate as invite and manage users e2e user'
|
||||
throw new Error('E2E_INVITE_AND_MANAGE_USERS_USER and E2E_INVITE_AND_MANAGE_USERS_PASSWORD environment variables are required');
|
||||
}
|
||||
|
||||
await authenticateAndSaveState(page, inviteAndManageUsersEmail, inviteAndManageUsersPassword, inviteAndManageUsersUserFile);
|
||||
const signInPage = new SignInPage(page);
|
||||
await signInPage.authenticateAndSaveState({ email: inviteAndManageUsersEmail, password: inviteAndManageUsersPassword }, inviteAndManageUsersUserFile);
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test as authManageAccountSetup } from '@playwright/test';
|
||||
import { authenticateAndSaveState } from '@/tests/helpers';
|
||||
import { SignInPage } from '../sign-in-base/sign-in-base-page';
|
||||
|
||||
const manageAccountUserFile = 'playwright/.auth/manage_account_user.json';
|
||||
|
||||
@@ -11,5 +11,6 @@ authManageAccountSetup('authenticate as manage account e2e user', async ({ page
|
||||
throw new Error('E2E_MANAGE_ACCOUNT_USER and E2E_MANAGE_ACCOUNT_PASSWORD environment variables are required');
|
||||
}
|
||||
|
||||
await authenticateAndSaveState(page, accountEmail, accountPassword, manageAccountUserFile);
|
||||
const signInPage = new SignInPage(page);
|
||||
await signInPage.authenticateAndSaveState({ email: accountEmail, password: accountPassword }, manageAccountUserFile);
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test as authManageCloudProvidersSetup } from '@playwright/test';
|
||||
import { authenticateAndSaveState } from '@/tests/helpers';
|
||||
import { SignInPage } from '../sign-in-base/sign-in-base-page';
|
||||
|
||||
const manageCloudProvidersUserFile = 'playwright/.auth/manage_cloud_providers_user.json';
|
||||
|
||||
@@ -11,5 +11,6 @@ authManageCloudProvidersSetup('authenticate as manage cloud providers e2e user',
|
||||
throw new Error('E2E_MANAGE_CLOUD_PROVIDERS_USER and E2E_MANAGE_CLOUD_PROVIDERS_PASSWORD environment variables are required');
|
||||
}
|
||||
|
||||
await authenticateAndSaveState(page, cloudProvidersEmail, cloudProvidersPassword, manageCloudProvidersUserFile);
|
||||
const signInPage = new SignInPage(page);
|
||||
await signInPage.authenticateAndSaveState({ email: cloudProvidersEmail, password: cloudProvidersPassword }, manageCloudProvidersUserFile);
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test as authManageIntegrationsSetup } from '@playwright/test';
|
||||
import { authenticateAndSaveState } from '@/tests/helpers';
|
||||
import { SignInPage } from '../sign-in-base/sign-in-base-page';
|
||||
|
||||
const manageIntegrationsUserFile = 'playwright/.auth/manage_integrations_user.json';
|
||||
|
||||
@@ -11,5 +11,6 @@ authManageIntegrationsSetup('authenticate as integrations e2e user', async ({ p
|
||||
throw new Error('E2E_MANAGE_INTEGRATIONS_USER and E2E_MANAGE_INTEGRATIONS_PASSWORD environment variables are required');
|
||||
}
|
||||
|
||||
await authenticateAndSaveState(page, integrationsEmail, integrationsPassword, manageIntegrationsUserFile);
|
||||
const signInPage = new SignInPage(page);
|
||||
await signInPage.authenticateAndSaveState({ email: integrationsEmail, password: integrationsPassword }, manageIntegrationsUserFile);
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test as authManageScansSetup } from '@playwright/test';
|
||||
import { authenticateAndSaveState } from '@/tests/helpers';
|
||||
import { SignInPage } from '../sign-in-base/sign-in-base-page';
|
||||
|
||||
const manageScansUserFile = 'playwright/.auth/manage_scans_user.json';
|
||||
|
||||
@@ -11,5 +11,6 @@ authManageScansSetup('authenticate as scans e2e user', async ({ page }) => {
|
||||
throw new Error('E2E_MANAGE_SCANS_USER and E2E_MANAGE_SCANS_PASSWORD environment variables are required');
|
||||
}
|
||||
|
||||
await authenticateAndSaveState(page, scansEmail, scansPassword, manageScansUserFile);
|
||||
const signInPage = new SignInPage(page);
|
||||
await signInPage.authenticateAndSaveState({ email: scansEmail, password: scansPassword }, manageScansUserFile);
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test as authUnlimitedVisibilitySetup } from '@playwright/test';
|
||||
import { authenticateAndSaveState } from '@/tests/helpers';
|
||||
import { SignInPage } from '../sign-in-base/sign-in-base-page';
|
||||
|
||||
const unlimitedVisibilityUserFile = 'playwright/.auth/unlimited_visibility_user.json';
|
||||
|
||||
@@ -11,5 +11,6 @@ authUnlimitedVisibilitySetup('authenticate as unlimited visibility e2e user', a
|
||||
throw new Error('E2E_UNLIMITED_VISIBILITY_USER and E2E_UNLIMITED_VISIBILITY_PASSWORD environment variables are required');
|
||||
}
|
||||
|
||||
await authenticateAndSaveState(page, unlimitedVisibilityEmail, unlimitedVisibilityPassword, unlimitedVisibilityUserFile);
|
||||
const signInPage = new SignInPage(page);
|
||||
await signInPage.authenticateAndSaveState({ email: unlimitedVisibilityEmail, password: unlimitedVisibilityPassword }, unlimitedVisibilityUserFile);
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Page, Locator, expect } from "@playwright/test";
|
||||
import { expect, Locator, Page } from "@playwright/test";
|
||||
|
||||
import { BasePage } from "../base-page";
|
||||
import { HomePage } from "../home/home-page";
|
||||
|
||||
@@ -14,28 +15,29 @@ export interface SocialAuthConfig {
|
||||
|
||||
export class SignInPage extends BasePage {
|
||||
readonly homePage: HomePage;
|
||||
|
||||
|
||||
// Form elements
|
||||
readonly emailInput: Locator;
|
||||
readonly passwordInput: Locator;
|
||||
readonly loginButton: Locator;
|
||||
readonly form: Locator;
|
||||
|
||||
|
||||
// Social authentication buttons
|
||||
readonly googleButton: Locator;
|
||||
readonly githubButton: Locator;
|
||||
readonly samlButton: Locator;
|
||||
|
||||
|
||||
// Navigation elements
|
||||
readonly signUpLink: Locator;
|
||||
readonly backButton: Locator;
|
||||
|
||||
|
||||
// UI elements
|
||||
readonly logo: Locator;
|
||||
|
||||
readonly pageTitle: Locator;
|
||||
|
||||
// Error messages
|
||||
readonly errorMessages: Locator;
|
||||
|
||||
|
||||
// SAML specific elements
|
||||
readonly samlModeTitle: Locator;
|
||||
readonly samlEmailInput: Locator;
|
||||
@@ -43,30 +45,42 @@ export class SignInPage extends BasePage {
|
||||
constructor(page: Page) {
|
||||
super(page);
|
||||
this.homePage = new HomePage(page);
|
||||
|
||||
|
||||
// Form elements
|
||||
this.emailInput = page.getByRole("textbox", { name: "Email" });
|
||||
this.passwordInput = page.getByRole("textbox", { name: "Password" });
|
||||
this.loginButton = page.getByRole("button", { name: "Log in" });
|
||||
this.form = page.locator("form");
|
||||
|
||||
|
||||
// Social authentication buttons
|
||||
this.googleButton = page.getByRole("button", { name: "Continue with Google" });
|
||||
this.githubButton = page.getByRole("button", { name: "Continue with Github" });
|
||||
this.samlButton = page.getByRole("button", { name: "Continue with SAML SSO" });
|
||||
|
||||
this.googleButton = page.getByRole("button", {
|
||||
name: "Continue with Google",
|
||||
});
|
||||
this.githubButton = page.getByRole("button", {
|
||||
name: "Continue with Github",
|
||||
});
|
||||
this.samlButton = page.getByRole("button", {
|
||||
name: "Continue with SAML SSO",
|
||||
});
|
||||
|
||||
// Navigation elements
|
||||
this.signUpLink = page.getByRole("link", { name: "Sign up" });
|
||||
this.backButton = page.getByRole("button", { name: "Back" });
|
||||
|
||||
// UI elements
|
||||
|
||||
// UI elements - title is a <p> element, not a heading
|
||||
this.logo = page.locator('svg[width="300"]');
|
||||
|
||||
// Error messages
|
||||
this.errorMessages = page.locator('[role="alert"], .error-message, [data-testid="error"]');
|
||||
|
||||
// SAML specific elements
|
||||
this.samlModeTitle = page.getByRole("heading", { name: "Sign in with SAML SSO" });
|
||||
// Use text matching with exact=true to avoid matching other elements
|
||||
this.pageTitle = page.getByText("Sign in", { exact: true });
|
||||
|
||||
// Error messages - form validation errors appear as <p> with specific classes
|
||||
this.errorMessages = page.locator(
|
||||
"p.text-destructive, p.text-sm.text-destructive",
|
||||
);
|
||||
|
||||
// SAML specific elements - use text matching
|
||||
this.samlModeTitle = page.getByText("Sign in with SAML SSO", {
|
||||
exact: true,
|
||||
});
|
||||
this.samlEmailInput = page.getByRole("textbox", { name: "Email" });
|
||||
}
|
||||
|
||||
@@ -75,6 +89,14 @@ export class SignInPage extends BasePage {
|
||||
await super.goto("/sign-in");
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to sign-in page with an error query parameter
|
||||
* Used for testing error message display (e.g., session expiry)
|
||||
*/
|
||||
async gotoWithError(errorType: string): Promise<void> {
|
||||
await this.page.goto(`/sign-in?error=${errorType}`);
|
||||
}
|
||||
|
||||
// Form interaction methods
|
||||
async fillEmail(email: string): Promise<void> {
|
||||
await this.emailInput.fill(email);
|
||||
@@ -137,7 +159,8 @@ export class SignInPage extends BasePage {
|
||||
async verifyPageLoaded(): Promise<void> {
|
||||
await expect(this.page).toHaveTitle(/Prowler/);
|
||||
await expect(this.logo).toBeVisible();
|
||||
await expect(this.page.getByRole("heading", { name: "Sign in", exact: true })).toBeVisible();
|
||||
await expect(this.pageTitle).toBeVisible();
|
||||
await expect(this.pageTitle).toHaveText("Sign in");
|
||||
}
|
||||
|
||||
async verifyFormElements(): Promise<void> {
|
||||
@@ -157,7 +180,9 @@ export class SignInPage extends BasePage {
|
||||
}
|
||||
|
||||
async verifyNavigationLinks(): Promise<void> {
|
||||
await expect(this.page.getByRole('link', { name: /Need to create an account\?/i })).toBeVisible();
|
||||
await expect(
|
||||
this.page.getByText("Need to create an account?"),
|
||||
).toBeVisible();
|
||||
await expect(this.signUpLink).toBeVisible();
|
||||
}
|
||||
|
||||
@@ -165,8 +190,21 @@ export class SignInPage extends BasePage {
|
||||
await this.homePage.verifyPageLoaded();
|
||||
}
|
||||
|
||||
async verifyLoginError(errorMessage: string = "Invalid email or password"): Promise<void> {
|
||||
await expect(this.page.getByRole("alert", { name: errorMessage })).toBeVisible();
|
||||
/**
|
||||
* Complete login flow: navigates to sign-in, logs in, and verifies success.
|
||||
* Use this when you need to authenticate from scratch in a test.
|
||||
*/
|
||||
async loginAndVerify(credentials: SignInCredentials): Promise<void> {
|
||||
await this.goto();
|
||||
await this.login(credentials);
|
||||
await this.verifySuccessfulLogin();
|
||||
}
|
||||
|
||||
async verifyLoginError(
|
||||
errorMessage: string = "Invalid email or password",
|
||||
): Promise<void> {
|
||||
// Error messages appear as <p> elements with destructive styling
|
||||
await expect(this.page.getByText(errorMessage).first()).toBeVisible();
|
||||
await expect(this.page).toHaveURL("/sign-in");
|
||||
}
|
||||
|
||||
@@ -177,22 +215,24 @@ export class SignInPage extends BasePage {
|
||||
}
|
||||
|
||||
async verifyNormalModeActive(): Promise<void> {
|
||||
await expect(this.page.getByRole("heading", { name: "Sign in", exact: true })).toBeVisible();
|
||||
await expect(this.pageTitle).toHaveText("Sign in");
|
||||
await expect(this.passwordInput).toBeVisible();
|
||||
}
|
||||
|
||||
async verifyLoadingState(): Promise<void> {
|
||||
// Check that button shows loading text or is disabled
|
||||
await expect(this.loginButton).toHaveAttribute("aria-disabled", "true");
|
||||
await super.verifyLoadingState();
|
||||
}
|
||||
|
||||
async verifyFormValidation(): Promise<void> {
|
||||
// Check for common validation messages
|
||||
const emailError = this.page.getByRole("alert", { name: "Please enter a valid email address." });
|
||||
const passwordError = this.page.getByRole("alert", { name: "Password is required." });
|
||||
|
||||
// Check for common validation messages - they appear as <p> elements
|
||||
const emailError = this.page.getByText(
|
||||
"Please enter a valid email address.",
|
||||
);
|
||||
const passwordError = this.page.getByText("Password is required.");
|
||||
|
||||
// At least one validation error should be visible
|
||||
await expect(emailError.or(passwordError)).toBeVisible();
|
||||
await expect(emailError.or(passwordError).first()).toBeVisible();
|
||||
}
|
||||
|
||||
// Accessibility methods
|
||||
@@ -211,9 +251,15 @@ export class SignInPage extends BasePage {
|
||||
}
|
||||
|
||||
async verifyAriaLabels(): Promise<void> {
|
||||
await expect(this.page.getByRole("textbox", { name: "Email" })).toBeVisible();
|
||||
await expect(this.page.getByRole("textbox", { name: "Password" })).toBeVisible();
|
||||
await expect(this.page.getByRole("button", { name: "Log in" })).toBeVisible();
|
||||
await expect(
|
||||
this.page.getByRole("textbox", { name: "Email" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
this.page.getByRole("textbox", { name: "Password" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
this.page.getByRole("button", { name: "Log in" }),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
// Utility methods
|
||||
@@ -228,16 +274,62 @@ export class SignInPage extends BasePage {
|
||||
return emailValue.length > 0 && passwordValue.length > 0;
|
||||
}
|
||||
|
||||
// Browser interaction methods
|
||||
|
||||
// Session management methods
|
||||
async logout(): Promise<void> {
|
||||
await this.homePage.signOut();
|
||||
}
|
||||
|
||||
async verifyLogoutSuccess(): Promise<void> {
|
||||
/**
|
||||
* Verifies we are on the sign-in page (URL + title visible)
|
||||
* Use this when redirected to sign-in from protected routes
|
||||
*/
|
||||
async verifyOnSignInPage(): Promise<void> {
|
||||
await expect(this.page).toHaveURL(/\/sign-in/);
|
||||
await expect(this.pageTitle).toBeVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies we stayed on the sign-in page (exact URL match)
|
||||
* Use after form validation errors
|
||||
*/
|
||||
async verifyStaysOnSignInPage(): Promise<void> {
|
||||
await expect(this.page).toHaveURL("/sign-in");
|
||||
await expect(this.page.getByRole("heading", { name: "Sign in", exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies we are NOT on the sign-in page
|
||||
* Use after successful login to confirm redirect
|
||||
*/
|
||||
async verifyNotOnSignInPage(): Promise<void> {
|
||||
await expect(this.page).not.toHaveURL(/\/sign-in/);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies redirect to sign-in includes a callbackUrl parameter
|
||||
*/
|
||||
async verifyRedirectWithCallback(expectedCallback: string): Promise<void> {
|
||||
await expect(this.page).toHaveURL(/\/sign-in\?.*callbackUrl=/);
|
||||
const url = new URL(this.page.url());
|
||||
const callbackUrl = url.searchParams.get("callbackUrl");
|
||||
expect(callbackUrl).toBe(expectedCallback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for toast notification to appear (used for error messages)
|
||||
*/
|
||||
async waitForToast(): Promise<{ isVisible: boolean; text: string | null }> {
|
||||
const toast = this.page.locator('[role="status"], [role="alert"]').first();
|
||||
try {
|
||||
await toast.waitFor({ state: "visible", timeout: 2000 });
|
||||
const text = await toast.textContent();
|
||||
return { isVisible: true, text };
|
||||
} catch {
|
||||
return { isVisible: false, text: null };
|
||||
}
|
||||
}
|
||||
|
||||
async verifyLogoutSuccess(): Promise<void> {
|
||||
await this.verifyOnSignInPage();
|
||||
}
|
||||
|
||||
// Advanced interaction methods
|
||||
@@ -245,7 +337,7 @@ export class SignInPage extends BasePage {
|
||||
// Fill email first and check for validation
|
||||
await this.fillEmail(credentials.email);
|
||||
await this.page.keyboard.press("Tab"); // Trigger validation
|
||||
|
||||
|
||||
// Fill password
|
||||
await this.fillPassword(credentials.password);
|
||||
}
|
||||
@@ -260,10 +352,11 @@ export class SignInPage extends BasePage {
|
||||
|
||||
// Error handling methods
|
||||
async handleSamlError(): Promise<void> {
|
||||
const samlError = this.page.getByRole("alert", { name: "SAML Authentication Error" });
|
||||
const samlError = this.page.getByRole("alert", {
|
||||
name: "SAML Authentication Error",
|
||||
});
|
||||
if (await samlError.isVisible()) {
|
||||
// Handle SAML error if present
|
||||
console.log("SAML authentication error detected");
|
||||
// SAML error detected - test may need to handle this case
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,4 +371,29 @@ export class SignInPage extends BasePage {
|
||||
async waitForRedirect(expectedUrl: string): Promise<void> {
|
||||
await this.page.waitForURL(expectedUrl);
|
||||
}
|
||||
|
||||
// ===========================================
|
||||
// Authentication Setup Methods
|
||||
// ===========================================
|
||||
|
||||
/**
|
||||
* Authenticate and save the browser state to a file.
|
||||
* Used by auth setup projects to create reusable authenticated sessions.
|
||||
*
|
||||
* @param credentials - User credentials (email and password)
|
||||
* @param storagePath - Path to save the authentication state file
|
||||
*/
|
||||
async authenticateAndSaveState(
|
||||
credentials: SignInCredentials,
|
||||
storagePath: string,
|
||||
): Promise<void> {
|
||||
if (!credentials.email || !credentials.password) {
|
||||
throw new Error(
|
||||
"Email and password are required for authentication and save state",
|
||||
);
|
||||
}
|
||||
|
||||
await this.loginAndVerify(credentials);
|
||||
await this.page.context().storageState({ path: storagePath });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
### E2E Tests: User Sign-In
|
||||
|
||||
**Suite ID:** `SIGN-IN-BASE-E2E`
|
||||
**Feature:** User sign-in form and navigation.
|
||||
|
||||
---
|
||||
|
||||
## Test Case: `SIGN-IN-BASE-E2E-001` - Display login form elements
|
||||
|
||||
**Priority:** `normal`
|
||||
|
||||
**Tags:**
|
||||
- type: @e2e
|
||||
- feature: @sign-in-base
|
||||
|
||||
**Description/Objective:** Verify that all login form elements are displayed correctly.
|
||||
|
||||
**Preconditions:**
|
||||
- Application is running.
|
||||
|
||||
### Flow Steps:
|
||||
1. Navigate to the Sign in page.
|
||||
2. Verify page is loaded.
|
||||
3. Verify form elements (email, password, login button).
|
||||
4. Verify social buttons (Google, GitHub).
|
||||
5. Verify navigation links.
|
||||
|
||||
### Expected Result:
|
||||
- All form elements are visible and properly labeled.
|
||||
|
||||
---
|
||||
|
||||
## Test Case: `SIGN-IN-BASE-E2E-002` - Successful login with valid credentials
|
||||
|
||||
**Priority:** `critical`
|
||||
|
||||
**Tags:**
|
||||
- type: @e2e, @critical
|
||||
- feature: @sign-in-base
|
||||
|
||||
**Description/Objective:** Verify that a user can successfully log in with valid credentials.
|
||||
|
||||
**Preconditions:**
|
||||
- Application is running.
|
||||
- Valid test user credentials are configured via `ADMIN_USER` and `ADMIN_PASSWORD` environment variables.
|
||||
|
||||
### Flow Steps:
|
||||
1. Navigate to the Sign in page.
|
||||
2. Enter valid email and password.
|
||||
3. Click the login button.
|
||||
4. Verify successful redirect to home page.
|
||||
|
||||
### Expected Result:
|
||||
- User is authenticated and redirected to the home page.
|
||||
|
||||
---
|
||||
|
||||
## Test Case: `SIGN-IN-BASE-E2E-003` - Show error with invalid credentials
|
||||
|
||||
**Priority:** `normal`
|
||||
|
||||
**Tags:**
|
||||
- type: @e2e
|
||||
- feature: @sign-in-base
|
||||
|
||||
**Description/Objective:** Verify that an error message is shown when invalid credentials are provided.
|
||||
|
||||
**Preconditions:**
|
||||
- Application is running.
|
||||
|
||||
### Flow Steps:
|
||||
1. Navigate to the Sign in page.
|
||||
2. Enter invalid email and password.
|
||||
3. Click the login button.
|
||||
4. Verify error message is displayed.
|
||||
|
||||
### Expected Result:
|
||||
- Error message "Invalid email or password" is displayed.
|
||||
- User remains on the sign-in page.
|
||||
|
||||
---
|
||||
|
||||
## Test Case: `SIGN-IN-BASE-E2E-004` - Handle empty form submission
|
||||
|
||||
**Priority:** `normal`
|
||||
|
||||
**Tags:**
|
||||
- type: @e2e
|
||||
- feature: @sign-in-base
|
||||
|
||||
**Description/Objective:** Verify form validation when submitting an empty form.
|
||||
|
||||
**Preconditions:**
|
||||
- Application is running.
|
||||
|
||||
### Flow Steps:
|
||||
1. Navigate to the Sign in page.
|
||||
2. Click the login button without filling any fields.
|
||||
3. Verify validation errors are displayed.
|
||||
|
||||
### Expected Result:
|
||||
- Form validation errors are shown.
|
||||
- User remains on the sign-in page.
|
||||
|
||||
---
|
||||
|
||||
## Test Case: `SIGN-IN-BASE-E2E-005` - Validate email format
|
||||
|
||||
**Priority:** `normal`
|
||||
|
||||
**Tags:**
|
||||
- type: @e2e
|
||||
- feature: @sign-in-base
|
||||
|
||||
**Description/Objective:** Verify that invalid email formats are rejected.
|
||||
|
||||
**Preconditions:**
|
||||
- Application is running.
|
||||
|
||||
### Flow Steps:
|
||||
1. Navigate to the Sign in page.
|
||||
2. Enter an invalid email format.
|
||||
3. Submit the form.
|
||||
4. Verify validation error is displayed.
|
||||
|
||||
### Expected Result:
|
||||
- Email format validation error is shown.
|
||||
|
||||
---
|
||||
|
||||
## Test Case: `SIGN-IN-BASE-E2E-006` - Require password when email is filled
|
||||
|
||||
**Priority:** `normal`
|
||||
|
||||
**Tags:**
|
||||
- type: @e2e
|
||||
- feature: @sign-in-base
|
||||
|
||||
**Description/Objective:** Verify that password is required when email is provided.
|
||||
|
||||
**Preconditions:**
|
||||
- Application is running.
|
||||
|
||||
### Flow Steps:
|
||||
1. Navigate to the Sign in page.
|
||||
2. Fill only the email field.
|
||||
3. Submit the form.
|
||||
4. Verify password required error is displayed.
|
||||
|
||||
### Expected Result:
|
||||
- "Password is required" error is shown.
|
||||
|
||||
---
|
||||
|
||||
## Test Case: `SIGN-IN-BASE-E2E-007` - Toggle SAML SSO mode
|
||||
|
||||
**Priority:** `normal`
|
||||
|
||||
**Tags:**
|
||||
- type: @e2e
|
||||
- feature: @sign-in-base
|
||||
|
||||
**Description/Objective:** Verify SAML SSO mode can be toggled on and off.
|
||||
|
||||
**Preconditions:**
|
||||
- Application is running.
|
||||
|
||||
### Flow Steps:
|
||||
1. Navigate to the Sign in page.
|
||||
2. Click "Continue with SAML SSO" button.
|
||||
3. Verify SAML mode is active (password field hidden).
|
||||
4. Click back button.
|
||||
5. Verify normal mode is restored.
|
||||
|
||||
### Expected Result:
|
||||
- SAML mode toggles correctly.
|
||||
- Password field visibility changes accordingly.
|
||||
|
||||
---
|
||||
|
||||
## Test Case: `SIGN-IN-BASE-E2E-008` - Show loading state during form submission
|
||||
|
||||
**Priority:** `normal`
|
||||
|
||||
**Tags:**
|
||||
- type: @e2e
|
||||
- feature: @sign-in-base
|
||||
|
||||
**Description/Objective:** Verify loading state is shown during form submission.
|
||||
|
||||
**Preconditions:**
|
||||
- Application is running.
|
||||
|
||||
### Flow Steps:
|
||||
1. Navigate to the Sign in page.
|
||||
2. Fill valid credentials.
|
||||
3. Submit the form.
|
||||
4. Verify loading state on button.
|
||||
|
||||
### Expected Result:
|
||||
- Login button shows loading state (disabled with aria-disabled).
|
||||
|
||||
---
|
||||
|
||||
## Test Case: `SIGN-IN-BASE-E2E-009` - Handle SAML authentication flow
|
||||
|
||||
**Priority:** `normal`
|
||||
|
||||
**Tags:**
|
||||
- type: @e2e
|
||||
- feature: @sign-in-base
|
||||
|
||||
**Description/Objective:** Verify SAML authentication flow initiation.
|
||||
|
||||
**Preconditions:**
|
||||
- Application is running.
|
||||
|
||||
### Flow Steps:
|
||||
1. Navigate to the Sign in page.
|
||||
2. Toggle SAML mode.
|
||||
3. Enter SAML email.
|
||||
4. Submit the form.
|
||||
|
||||
### Expected Result:
|
||||
- SAML flow is initiated (would redirect to IdP in real scenario).
|
||||
|
||||
---
|
||||
|
||||
## Test Case: `SIGN-IN-BASE-E2E-010` - Maintain session after browser refresh
|
||||
|
||||
**Priority:** `critical`
|
||||
|
||||
**Tags:**
|
||||
- type: @e2e, @critical
|
||||
- feature: @sign-in-base
|
||||
|
||||
**Description/Objective:** Verify that user session persists after page refresh.
|
||||
|
||||
**Preconditions:**
|
||||
- Application is running.
|
||||
- Valid test user credentials.
|
||||
|
||||
### Flow Steps:
|
||||
1. Log in with valid credentials.
|
||||
2. Verify successful login.
|
||||
3. Refresh the page.
|
||||
4. Verify user is still logged in.
|
||||
|
||||
### Expected Result:
|
||||
- Session persists after refresh.
|
||||
- User remains on the authenticated page.
|
||||
|
||||
---
|
||||
|
||||
## Test Case: `SIGN-IN-BASE-E2E-011` - Redirect to login for protected routes
|
||||
|
||||
**Priority:** `normal`
|
||||
|
||||
**Tags:**
|
||||
- type: @e2e
|
||||
- feature: @sign-in-base
|
||||
|
||||
**Description/Objective:** Verify unauthenticated users are redirected to login when accessing protected routes.
|
||||
|
||||
**Preconditions:**
|
||||
- Application is running.
|
||||
- No active session.
|
||||
|
||||
### Flow Steps:
|
||||
1. Navigate directly to a protected route (e.g., /providers).
|
||||
2. Verify redirect to sign-in page.
|
||||
|
||||
### Expected Result:
|
||||
- User is redirected to /sign-in.
|
||||
|
||||
---
|
||||
|
||||
## Test Case: `SIGN-IN-BASE-E2E-012` - Logout successfully
|
||||
|
||||
**Priority:** `critical`
|
||||
|
||||
**Tags:**
|
||||
- type: @e2e, @critical
|
||||
- feature: @sign-in-base
|
||||
|
||||
**Description/Objective:** Verify user can log out successfully.
|
||||
|
||||
**Preconditions:**
|
||||
- Application is running.
|
||||
- User is logged in.
|
||||
|
||||
### Flow Steps:
|
||||
1. Log in with valid credentials.
|
||||
2. Click logout/sign out.
|
||||
3. Verify redirect to sign-in page.
|
||||
4. Attempt to access protected route.
|
||||
5. Verify redirect to sign-in.
|
||||
|
||||
### Expected Result:
|
||||
- User is logged out.
|
||||
- Session is invalidated.
|
||||
- Protected routes are no longer accessible.
|
||||
|
||||
---
|
||||
|
||||
## Test Case: `SIGN-IN-BASE-E2E-013` - Handle session timeout gracefully
|
||||
|
||||
**Priority:** `normal`
|
||||
|
||||
**Tags:**
|
||||
- type: @e2e
|
||||
- feature: @sign-in-base
|
||||
|
||||
**Description/Objective:** Verify session isolation between browser contexts.
|
||||
|
||||
**Preconditions:**
|
||||
- Application is running.
|
||||
|
||||
### Flow Steps:
|
||||
1. Create authenticated context and log in.
|
||||
2. Verify session exists.
|
||||
3. Create new unauthenticated context.
|
||||
4. Verify new context has no session.
|
||||
5. Verify new context is redirected to sign-in.
|
||||
|
||||
### Expected Result:
|
||||
- Sessions are isolated between contexts.
|
||||
- Unauthenticated context cannot access protected routes.
|
||||
|
||||
---
|
||||
|
||||
## Test Case: `SIGN-IN-BASE-E2E-014` - Navigate to sign up page
|
||||
|
||||
**Priority:** `normal`
|
||||
|
||||
**Tags:**
|
||||
- type: @e2e
|
||||
- feature: @sign-in-base
|
||||
|
||||
**Description/Objective:** Verify navigation from sign-in to sign-up page.
|
||||
|
||||
**Preconditions:**
|
||||
- Application is running.
|
||||
|
||||
### Flow Steps:
|
||||
1. Navigate to the Sign in page.
|
||||
2. Click the "Sign up" link.
|
||||
3. Verify redirect to sign-up page.
|
||||
|
||||
### Expected Result:
|
||||
- User is navigated to /sign-up.
|
||||
|
||||
---
|
||||
|
||||
## Test Case: `SIGN-IN-BASE-E2E-015` - Navigate from sign up back to sign in
|
||||
|
||||
**Priority:** `normal`
|
||||
|
||||
**Tags:**
|
||||
- type: @e2e
|
||||
- feature: @sign-in-base
|
||||
|
||||
**Description/Objective:** Verify navigation from sign-up back to sign-in page.
|
||||
|
||||
**Preconditions:**
|
||||
- Application is running.
|
||||
|
||||
### Flow Steps:
|
||||
1. Navigate to the Sign up page.
|
||||
2. Click the login link.
|
||||
3. Verify redirect to sign-in page.
|
||||
|
||||
### Expected Result:
|
||||
- User is navigated to /sign-in.
|
||||
|
||||
---
|
||||
|
||||
## Test Case: `SIGN-IN-BASE-E2E-016` - Handle browser back button correctly
|
||||
|
||||
**Priority:** `normal`
|
||||
|
||||
**Tags:**
|
||||
- type: @e2e
|
||||
- feature: @sign-in-base
|
||||
|
||||
**Description/Objective:** Verify browser back button navigation works correctly.
|
||||
|
||||
**Preconditions:**
|
||||
- Application is running.
|
||||
|
||||
### Flow Steps:
|
||||
1. Navigate to the Sign in page.
|
||||
2. Navigate to the Sign up page.
|
||||
3. Click browser back button.
|
||||
4. Verify return to sign-in page.
|
||||
|
||||
### Expected Result:
|
||||
- Browser history navigation works correctly.
|
||||
|
||||
---
|
||||
|
||||
## Test Case: `SIGN-IN-BASE-E2E-017` - Keyboard navigation
|
||||
|
||||
**Priority:** `normal`
|
||||
|
||||
**Tags:**
|
||||
- type: @e2e
|
||||
- feature: @sign-in-base, @accessibility
|
||||
|
||||
**Description/Objective:** Verify form is navigable with keyboard.
|
||||
|
||||
**Preconditions:**
|
||||
- Application is running.
|
||||
|
||||
### Flow Steps:
|
||||
1. Navigate to the Sign in page.
|
||||
2. Use Tab key to navigate through form elements.
|
||||
3. Verify focus moves correctly through elements.
|
||||
|
||||
### Expected Result:
|
||||
- All interactive elements are reachable via keyboard.
|
||||
- Focus order is logical.
|
||||
|
||||
---
|
||||
|
||||
## Test Case: `SIGN-IN-BASE-E2E-018` - Proper ARIA labels
|
||||
|
||||
**Priority:** `normal`
|
||||
|
||||
**Tags:**
|
||||
- type: @e2e
|
||||
- feature: @sign-in-base, @accessibility
|
||||
|
||||
**Description/Objective:** Verify form elements have proper ARIA labels.
|
||||
|
||||
**Preconditions:**
|
||||
- Application is running.
|
||||
|
||||
### Flow Steps:
|
||||
1. Navigate to the Sign in page.
|
||||
2. Verify ARIA labels on form elements.
|
||||
|
||||
### Expected Result:
|
||||
- Email input has proper label.
|
||||
- Password input has proper label.
|
||||
- Login button has proper label.
|
||||
@@ -0,0 +1,262 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { ERROR_MESSAGES, getSession, TEST_CREDENTIALS, URLS } from "../helpers";
|
||||
import { HomePage } from "../home/home-page";
|
||||
import { SignUpPage } from "../sign-up/sign-up-page";
|
||||
import { SignInPage } from "./sign-in-base-page";
|
||||
|
||||
test.describe("Login Flow", () => {
|
||||
let signInPage: SignInPage;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
signInPage = new SignInPage(page);
|
||||
await signInPage.goto();
|
||||
});
|
||||
|
||||
test(
|
||||
"should display login form elements",
|
||||
{ tag: ["@e2e", "@sign-in-base", "@SIGN-IN-BASE-E2E-001"] },
|
||||
async () => {
|
||||
await signInPage.verifyPageLoaded();
|
||||
await signInPage.verifyFormElements();
|
||||
await signInPage.verifySocialButtons({
|
||||
googleEnabled: true,
|
||||
githubEnabled: true,
|
||||
});
|
||||
await signInPage.verifyNavigationLinks();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
"should successfully login with valid credentials",
|
||||
{ tag: ["@critical", "@e2e", "@sign-in-base", "@SIGN-IN-BASE-E2E-002"] },
|
||||
async () => {
|
||||
await signInPage.login(TEST_CREDENTIALS.VALID);
|
||||
await signInPage.verifySuccessfulLogin();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
"should show error message with invalid credentials",
|
||||
{ tag: ["@e2e", "@sign-in-base", "@SIGN-IN-BASE-E2E-003"] },
|
||||
async () => {
|
||||
await signInPage.login(TEST_CREDENTIALS.INVALID);
|
||||
await signInPage.verifyLoginError(ERROR_MESSAGES.INVALID_CREDENTIALS);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
"should handle empty form submission",
|
||||
{ tag: ["@e2e", "@sign-in-base", "@SIGN-IN-BASE-E2E-004"] },
|
||||
async () => {
|
||||
await signInPage.submitForm();
|
||||
await signInPage.verifyFormValidation();
|
||||
await signInPage.verifyStaysOnSignInPage();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
"should validate email format",
|
||||
{ tag: ["@e2e", "@sign-in-base", "@SIGN-IN-BASE-E2E-005"] },
|
||||
async () => {
|
||||
await signInPage.login(TEST_CREDENTIALS.INVALID_EMAIL_FORMAT);
|
||||
await signInPage.verifyFormValidation();
|
||||
await signInPage.verifyStaysOnSignInPage();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
"should require password when email is filled",
|
||||
{ tag: ["@e2e", "@sign-in-base", "@SIGN-IN-BASE-E2E-006"] },
|
||||
async () => {
|
||||
await signInPage.fillEmail(TEST_CREDENTIALS.VALID.email);
|
||||
await signInPage.submitForm();
|
||||
await expect(
|
||||
signInPage.page.getByText(ERROR_MESSAGES.PASSWORD_REQUIRED),
|
||||
).toBeVisible();
|
||||
await signInPage.verifyStaysOnSignInPage();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
"should toggle SAML SSO mode",
|
||||
{ tag: ["@e2e", "@sign-in-base", "@SIGN-IN-BASE-E2E-007"] },
|
||||
async () => {
|
||||
await signInPage.toggleSamlMode();
|
||||
await signInPage.verifySamlModeActive();
|
||||
await signInPage.goBackFromSaml();
|
||||
await signInPage.verifyNormalModeActive();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
"should show loading state during form submission",
|
||||
{ tag: ["@e2e", "@sign-in-base", "@SIGN-IN-BASE-E2E-008"] },
|
||||
async () => {
|
||||
await signInPage.fillCredentials(TEST_CREDENTIALS.VALID);
|
||||
await signInPage.submitForm();
|
||||
await signInPage.verifyLoadingState();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
"should handle SAML authentication flow",
|
||||
{ tag: ["@e2e", "@sign-in-base", "@SIGN-IN-BASE-E2E-009"] },
|
||||
async () => {
|
||||
const samlEmail = "user@saml-domain.com";
|
||||
await signInPage.toggleSamlMode();
|
||||
await signInPage.fillSamlEmail(samlEmail);
|
||||
await signInPage.submitSamlForm();
|
||||
// Note: In a real scenario, this would redirect to IdP
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test.describe("Session Persistence", () => {
|
||||
test(
|
||||
"should maintain session after browser refresh",
|
||||
{ tag: ["@critical", "@e2e", "@sign-in-base", "@SIGN-IN-BASE-E2E-010"] },
|
||||
async ({ page }) => {
|
||||
const signInPage = new SignInPage(page);
|
||||
const homePage = new HomePage(page);
|
||||
|
||||
await signInPage.loginAndVerify(TEST_CREDENTIALS.VALID);
|
||||
|
||||
await page.reload();
|
||||
await homePage.verifyPageLoaded();
|
||||
await signInPage.verifyNotOnSignInPage();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
"should redirect to login when accessing protected route without session",
|
||||
{ tag: ["@e2e", "@sign-in-base", "@SIGN-IN-BASE-E2E-011"] },
|
||||
async ({ page }) => {
|
||||
const homePage = new HomePage(page);
|
||||
const signInPage = new SignInPage(page);
|
||||
|
||||
await homePage.goto();
|
||||
await signInPage.verifyOnSignInPage();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
"should logout successfully",
|
||||
{ tag: ["@critical", "@e2e", "@sign-in-base", "@SIGN-IN-BASE-E2E-012"] },
|
||||
async ({ page }) => {
|
||||
const signInPage = new SignInPage(page);
|
||||
const homePage = new HomePage(page);
|
||||
|
||||
await signInPage.loginAndVerify(TEST_CREDENTIALS.VALID);
|
||||
|
||||
await homePage.signOut();
|
||||
await signInPage.verifyLogoutSuccess();
|
||||
|
||||
await homePage.goto();
|
||||
await signInPage.verifyOnSignInPage();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
"should handle session timeout gracefully",
|
||||
{ tag: ["@e2e", "@sign-in-base", "@SIGN-IN-BASE-E2E-013"] },
|
||||
async ({ browser }) => {
|
||||
const authContext = await browser.newContext();
|
||||
const authPage = await authContext.newPage();
|
||||
|
||||
const signInPage = new SignInPage(authPage);
|
||||
await signInPage.loginAndVerify(TEST_CREDENTIALS.VALID);
|
||||
|
||||
const authSession = await getSession(authPage);
|
||||
expect(authSession).toBeTruthy();
|
||||
expect(authSession.user).toBeTruthy();
|
||||
|
||||
const unauthContext = await browser.newContext();
|
||||
const unauthPage = await unauthContext.newPage();
|
||||
const unauthSignInPage = new SignInPage(unauthPage);
|
||||
|
||||
await unauthPage.goto(URLS.PROFILE);
|
||||
await unauthSignInPage.verifyOnSignInPage();
|
||||
|
||||
const unauthSession = await getSession(unauthPage);
|
||||
expect(unauthSession).toBeNull();
|
||||
|
||||
await authPage.close();
|
||||
await authContext.close();
|
||||
await unauthPage.close();
|
||||
await unauthContext.close();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test.describe("Navigation", () => {
|
||||
test(
|
||||
"should navigate to sign up page",
|
||||
{ tag: ["@e2e", "@sign-in-base", "@SIGN-IN-BASE-E2E-014"] },
|
||||
async ({ page }) => {
|
||||
const signInPage = new SignInPage(page);
|
||||
const signUpPage = new SignUpPage(page);
|
||||
|
||||
await signInPage.goto();
|
||||
await signInPage.goToSignUp();
|
||||
await signUpPage.verifyOnSignUpPage();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
"should navigate from sign up back to sign in",
|
||||
{ tag: ["@e2e", "@sign-in-base", "@SIGN-IN-BASE-E2E-015"] },
|
||||
async ({ page }) => {
|
||||
const signInPage = new SignInPage(page);
|
||||
const signUpPage = new SignUpPage(page);
|
||||
|
||||
await signUpPage.goto();
|
||||
await signUpPage.loginLink.click();
|
||||
await signInPage.verifyOnSignInPage();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
"should handle browser back button correctly",
|
||||
{ tag: ["@e2e", "@sign-in-base", "@SIGN-IN-BASE-E2E-016"] },
|
||||
async ({ page }) => {
|
||||
const signInPage = new SignInPage(page);
|
||||
const signUpPage = new SignUpPage(page);
|
||||
|
||||
await signInPage.goto();
|
||||
await signInPage.goToSignUp();
|
||||
await signUpPage.verifyOnSignUpPage();
|
||||
await page.goBack();
|
||||
await signInPage.verifyOnSignInPage();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test.describe("Accessibility", () => {
|
||||
let signInPage: SignInPage;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
signInPage = new SignInPage(page);
|
||||
await signInPage.goto();
|
||||
});
|
||||
|
||||
test(
|
||||
"should be navigable with keyboard",
|
||||
{
|
||||
tag: ["@e2e", "@sign-in-base", "@accessibility", "@SIGN-IN-BASE-E2E-017"],
|
||||
},
|
||||
async () => {
|
||||
await signInPage.verifyKeyboardNavigation();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
"should have proper ARIA labels",
|
||||
{
|
||||
tag: ["@e2e", "@sign-in-base", "@accessibility", "@SIGN-IN-BASE-E2E-018"],
|
||||
},
|
||||
async () => {
|
||||
await signInPage.verifyAriaLabels();
|
||||
},
|
||||
);
|
||||
});
|
||||
Loaded 100 of 103 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user