mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-05-13 15:50:55 +00:00
Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4ec493ec5c | |||
| 85c1b85852 | |||
| 8f50c6d684 | |||
| 1fb6c6a0f0 | |||
| fc3a25d7a8 | |||
| 1a56087ea0 | |||
| 2fdc480beb | |||
| 8bfc1d85f5 | |||
| 57501e1864 | |||
| 02a83adfd4 | |||
| 98a1bca403 | |||
| 8aade7f024 | |||
| 43b50c4d6f | |||
| 578c354a69 | |||
| 02cdcb29db | |||
| 6e0d7866cd | |||
| 4b71f37c91 | |||
| cdfbe5b2e3 | |||
| 1b6a459df4 | |||
| 73c0305dc4 | |||
| 0e01e67257 | |||
| 1ad329f9cf | |||
| d03d1d2393 | |||
| 832516be2a | |||
| 34727a7237 | |||
| 4216a3e23a | |||
| a59192e6f5 | |||
| 592bc6f6a8 | |||
| 962ebac8e4 | |||
| 2c5d47a8cd |
@@ -145,7 +145,7 @@ SENTRY_RELEASE=local
|
||||
NEXT_PUBLIC_SENTRY_ENVIRONMENT=${SENTRY_ENVIRONMENT}
|
||||
|
||||
#### Prowler release version ####
|
||||
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.26.0
|
||||
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.26.2
|
||||
|
||||
# Social login credentials
|
||||
SOCIAL_GOOGLE_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/google"
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: [prowler-cloud]
|
||||
# patreon: # Replace with a single Patreon username
|
||||
# open_collective: # Replace with a single Open Collective username
|
||||
# ko_fi: # Replace with a single Ko-fi username
|
||||
# tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
# community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
# liberapay: # Replace with a single Liberapay username
|
||||
# issuehunt: # Replace with a single IssueHunt username
|
||||
# lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||
# polar: # Replace with a single Polar username
|
||||
# buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
|
||||
# thanks_dev: # Replace with a single thanks.dev username
|
||||
# custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
+18
-4
@@ -2,14 +2,28 @@
|
||||
|
||||
All notable changes to the **Prowler API** are documented in this file.
|
||||
|
||||
## [1.27.0] (Prowler UNRELEASED)
|
||||
## [1.27.2] (Prowler UNRELEASED)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- Attack Paths: BEDROCK-001 and BEDROCK-002 now target roles trusting `bedrock-agentcore.amazonaws.com` instead of `bedrock.amazonaws.com`, eliminating false positives against regular Bedrock service roles (Agents, Knowledge Bases, model invocation) [(#11141)](https://github.com/prowler-cloud/prowler/pull/11141)
|
||||
|
||||
---
|
||||
|
||||
## [1.27.1] (Prowler v5.26.1)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- `POST /api/v1/scans` was intermittently failing with `Scan matching query does not exist` in the `scan-perform` worker; the Celery task is now published via `transaction.on_commit` so the worker cannot read the Scan before the dispatch-wide transaction commits [(#11122)](https://github.com/prowler-cloud/prowler/pull/11122)
|
||||
|
||||
---
|
||||
|
||||
## [1.27.0] (Prowler v5.26.0)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
- New `scan-reset-ephemeral-resources` post-scan task zeroes `failed_findings_count` for resources missing from the latest full-scope scan, keeping ephemeral resources from polluting the Resources page sort [(#10929)](https://github.com/prowler-cloud/prowler/pull/10929)
|
||||
- ASD Essential Eight (AWS) compliance framework support [(#10982)](https://github.com/prowler-cloud/prowler/pull/10982)
|
||||
- `scan-reset-ephemeral-resources` post-scan task zeroes `failed_findings_count` for resources missing from the latest full-scope scan, keeping ephemeral resources from polluting the Resources page sort [(#10929)](https://github.com/prowler-cloud/prowler/pull/10929)
|
||||
- `GET /resources/{id}/events` now supports `Accept: text/plain` for LLM consumption [(#XXXXX)](https://github.com/prowler-cloud/prowler/pull/XXXXX)
|
||||
- ASD Essential Eight (AWS) compliance framework support [(#10982)](https://github.com/prowler-cloud/prowler/pull/10982)
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
|
||||
Generated
+3
-3
@@ -6754,8 +6754,8 @@ uuid6 = "2024.7.10"
|
||||
[package.source]
|
||||
type = "git"
|
||||
url = "https://github.com/prowler-cloud/prowler.git"
|
||||
reference = "master"
|
||||
resolved_reference = "16798e293da365965120961e6539e3a9756564f9"
|
||||
reference = "v5.26"
|
||||
resolved_reference = "02cdcb29dbcd8eb5ed442c1cd03830000324fb0f"
|
||||
|
||||
[[package]]
|
||||
name = "psutil"
|
||||
@@ -9424,4 +9424,4 @@ files = [
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = ">=3.11,<3.13"
|
||||
content-hash = "a3ab982d11a87d951ff15694d2ca7fd51f1f51a451abb0baa067ccf6966367a8"
|
||||
content-hash = "24f7a92f6c72a8207ab15f75c813a5a244c018afb0a582a5abf8c96e2c7faf12"
|
||||
|
||||
+2
-2
@@ -25,7 +25,7 @@ dependencies = [
|
||||
"defusedxml==0.7.1",
|
||||
"gunicorn==23.0.0",
|
||||
"lxml==5.3.2",
|
||||
"prowler @ git+https://github.com/prowler-cloud/prowler.git@master",
|
||||
"prowler @ git+https://github.com/prowler-cloud/prowler.git@v5.26",
|
||||
"psycopg2-binary==2.9.9",
|
||||
"pytest-celery[redis] (==1.3.0)",
|
||||
"sentry-sdk[django] (==2.56.0)",
|
||||
@@ -50,7 +50,7 @@ name = "prowler-api"
|
||||
package-mode = false
|
||||
# Needed for the SDK compatibility
|
||||
requires-python = ">=3.11,<3.13"
|
||||
version = "1.27.0"
|
||||
version = "1.27.2"
|
||||
|
||||
[project.scripts]
|
||||
celery = "src.backend.config.settings.celery"
|
||||
|
||||
@@ -484,8 +484,8 @@ AWS_BEDROCK_PRIVESC_PASSROLE_CODE_INTERPRETER = AttackPathsQueryDefinition(
|
||||
OR action = '*'
|
||||
)
|
||||
|
||||
// Find roles that trust Bedrock service (can be passed to Bedrock)
|
||||
MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'bedrock.amazonaws.com'}})
|
||||
// Find roles that trust the Bedrock AgentCore service (can be passed to a code interpreter)
|
||||
MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'bedrock-agentcore.amazonaws.com'}})
|
||||
WHERE any(resource IN stmt_passrole.resource WHERE
|
||||
resource = '*'
|
||||
OR target_role.arn CONTAINS resource
|
||||
@@ -536,8 +536,8 @@ AWS_BEDROCK_PRIVESC_INVOKE_CODE_INTERPRETER = AttackPathsQueryDefinition(
|
||||
OR action = '*'
|
||||
)
|
||||
|
||||
// Find roles that trust Bedrock service (already attached to existing code interpreters)
|
||||
MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'bedrock.amazonaws.com'}})
|
||||
// Find roles that trust the Bedrock AgentCore service (already attached to existing code interpreters)
|
||||
MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'bedrock-agentcore.amazonaws.com'}})
|
||||
|
||||
WITH collect(path_principal) + collect(path_target) AS paths
|
||||
UNWIND paths AS p
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
"""Helpers for serializing resource timeline events into LLM-friendly formats.
|
||||
|
||||
The text renderer is a 1:1 markdown projection of what the JSON endpoint
|
||||
returns: same events, same order, same fields. We do not infer sessions or
|
||||
relationships between events — grouping is left to the consumer.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Iterable
|
||||
|
||||
# Truncation thresholds for payload values. Strings longer than this are
|
||||
# clipped with an ellipsis; lists/dicts larger than this collapse to a count
|
||||
# placeholder. The goal is to bound a single event's token cost without
|
||||
# losing the API call's identity.
|
||||
MAX_STRING_LEN = 200
|
||||
MAX_LIST_INLINE = 5
|
||||
MAX_DICT_INLINE = 8
|
||||
|
||||
|
||||
def serialize_events_as_text(
|
||||
events: Iterable[dict[str, Any]],
|
||||
resource: Any,
|
||||
lookback_days: int,
|
||||
write_events_only: bool,
|
||||
) -> str:
|
||||
"""Render resource events as a flat markdown list of what the API returns."""
|
||||
events = list(events)
|
||||
lines: list[str] = []
|
||||
|
||||
lines.append("# Resource Events")
|
||||
lines.append(f"- Resource: {getattr(resource, 'uid', '')}")
|
||||
lines.append(f"- Region: {getattr(resource, 'region', '') or 'global'}")
|
||||
lines.append(f"- Lookback: {lookback_days} days")
|
||||
lines.append(f"- Write events only: {str(write_events_only).lower()}")
|
||||
lines.append(f"- Events: {len(events)}")
|
||||
lines.append("")
|
||||
|
||||
if not events:
|
||||
lines.append("No events recorded in the lookback window.")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
lines.append("## Events")
|
||||
lines.append("")
|
||||
|
||||
for index, event in enumerate(events, 1):
|
||||
lines.extend(_format_event(index, event))
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines).rstrip() + "\n"
|
||||
|
||||
|
||||
def _format_event(index: int, event: dict[str, Any]) -> list[str]:
|
||||
when = _format_time(_event_time(event))
|
||||
name = event.get("event_name") or "Unknown"
|
||||
source = event.get("event_source") or "unknown"
|
||||
error_code = event.get("error_code")
|
||||
status = f"ERROR({error_code})" if error_code else "ok"
|
||||
|
||||
lines = [f"### {index}. {name} at {when}"]
|
||||
lines.append(f"- Source: {source}")
|
||||
lines.append(f"- Status: {status}")
|
||||
|
||||
if event.get("actor"):
|
||||
lines.append(f"- Actor: {event['actor']}")
|
||||
if event.get("actor_type"):
|
||||
lines.append(f"- Actor type: {event['actor_type']}")
|
||||
if event.get("actor_uid"):
|
||||
lines.append(f"- Actor ARN: {event['actor_uid']}")
|
||||
if event.get("source_ip_address"):
|
||||
lines.append(f"- Source IP: {event['source_ip_address']}")
|
||||
if event.get("user_agent"):
|
||||
lines.append(f"- User agent: {event['user_agent']}")
|
||||
|
||||
request = _format_payload(event.get("request_data"))
|
||||
if request:
|
||||
lines.append(f"- Request: {request}")
|
||||
|
||||
response = _format_payload(event.get("response_data"))
|
||||
if response:
|
||||
lines.append(f"- Response: {response}")
|
||||
|
||||
if error_code and event.get("error_message"):
|
||||
lines.append(f"- Error: {event['error_message']}")
|
||||
|
||||
if event.get("event_id"):
|
||||
lines.append(f"- Event ID: {event['event_id']}")
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
def _format_payload(payload: Any) -> str:
|
||||
if not isinstance(payload, dict) or not payload:
|
||||
return ""
|
||||
return (
|
||||
"{" + ", ".join(f"{k}: {_summarize_value(v)}" for k, v in payload.items()) + "}"
|
||||
)
|
||||
|
||||
|
||||
def _summarize_value(value: Any) -> str:
|
||||
if isinstance(value, str):
|
||||
if len(value) <= MAX_STRING_LEN:
|
||||
return f'"{value}"'
|
||||
return f'"{value[: MAX_STRING_LEN - 3]}..."'
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
if value is None:
|
||||
return "null"
|
||||
if isinstance(value, (int, float)):
|
||||
return str(value)
|
||||
if isinstance(value, (list, tuple)):
|
||||
if len(value) > MAX_LIST_INLINE:
|
||||
return f"[{len(value)} items]"
|
||||
return "[" + ", ".join(_summarize_value(v) for v in value) + "]"
|
||||
if isinstance(value, dict):
|
||||
if len(value) > MAX_DICT_INLINE:
|
||||
return f"{{{len(value)} keys}}"
|
||||
return (
|
||||
"{"
|
||||
+ ", ".join(f"{k}: {_summarize_value(v)}" for k, v in value.items())
|
||||
+ "}"
|
||||
)
|
||||
return str(value)
|
||||
|
||||
|
||||
def _event_time(event: dict[str, Any]) -> datetime:
|
||||
value = event.get("event_time")
|
||||
if isinstance(value, datetime):
|
||||
return value if value.tzinfo else value.replace(tzinfo=timezone.utc)
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
|
||||
except ValueError:
|
||||
return datetime.min.replace(tzinfo=timezone.utc)
|
||||
return datetime.min.replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _format_time(value: datetime) -> str:
|
||||
if value.tzinfo is None:
|
||||
value = value.replace(tzinfo=timezone.utc)
|
||||
return value.strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Prowler API
|
||||
version: 1.27.0
|
||||
version: 1.27.2
|
||||
description: |-
|
||||
Prowler API specification.
|
||||
|
||||
|
||||
@@ -1,366 +0,0 @@
|
||||
"""Unit tests for api.events.views_helpers.
|
||||
|
||||
These tests exercise the text-renderer in isolation: no Django, no DRF, no DB.
|
||||
The behavior under test is the markdown shape, payload sanitization, and
|
||||
truncation rules.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from api.events import views_helpers
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def resource():
|
||||
return SimpleNamespace(
|
||||
uid="arn:aws:s3:::acme-prod-data",
|
||||
region="us-east-1",
|
||||
)
|
||||
|
||||
|
||||
def _event(**overrides):
|
||||
base = {
|
||||
"event_id": "evt-1",
|
||||
"event_time": datetime(2026, 5, 4, 16, 55, 1, tzinfo=timezone.utc),
|
||||
"event_name": "PutBucketPolicy",
|
||||
"event_source": "s3.amazonaws.com",
|
||||
"actor": "assumed-role/AdminRole/alice",
|
||||
"actor_uid": "arn:aws:sts::123:assumed-role/AdminRole/alice",
|
||||
"actor_type": "AssumedRole",
|
||||
"source_ip_address": "1.2.3.4",
|
||||
"user_agent": "aws-cli/2.15.30",
|
||||
"request_data": None,
|
||||
"response_data": None,
|
||||
"error_code": None,
|
||||
"error_message": None,
|
||||
}
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
class TestSerializeEventsAsTextHeader:
|
||||
def test_empty_events_renders_header_and_no_events_marker(self, resource):
|
||||
text = views_helpers.serialize_events_as_text(
|
||||
events=[],
|
||||
resource=resource,
|
||||
lookback_days=90,
|
||||
write_events_only=True,
|
||||
)
|
||||
|
||||
assert text.startswith("# Resource Events\n")
|
||||
assert "- Resource: arn:aws:s3:::acme-prod-data" in text
|
||||
assert "- Region: us-east-1" in text
|
||||
assert "- Lookback: 90 days" in text
|
||||
assert "- Write events only: true" in text
|
||||
assert "- Events: 0" in text
|
||||
assert "No events recorded in the lookback window." in text
|
||||
# No "## Events" section when empty
|
||||
assert "## Events" not in text
|
||||
|
||||
def test_missing_region_renders_global(self, resource):
|
||||
resource.region = ""
|
||||
|
||||
text = views_helpers.serialize_events_as_text(
|
||||
events=[], resource=resource, lookback_days=7, write_events_only=False
|
||||
)
|
||||
|
||||
assert "- Region: global" in text
|
||||
assert "- Write events only: false" in text
|
||||
assert "- Lookback: 7 days" in text
|
||||
|
||||
def test_resource_without_uid_attribute_renders_blank(self):
|
||||
text = views_helpers.serialize_events_as_text(
|
||||
events=[],
|
||||
resource=SimpleNamespace(),
|
||||
lookback_days=1,
|
||||
write_events_only=True,
|
||||
)
|
||||
|
||||
# getattr defaults to "" for both fields, no crash.
|
||||
assert "- Resource: \n" in text
|
||||
assert "- Region: global" in text
|
||||
|
||||
|
||||
class TestSerializeEventsAsTextBody:
|
||||
def test_single_event_renders_all_present_fields(self, resource):
|
||||
text = views_helpers.serialize_events_as_text(
|
||||
events=[_event()],
|
||||
resource=resource,
|
||||
lookback_days=90,
|
||||
write_events_only=True,
|
||||
)
|
||||
|
||||
assert "## Events" in text
|
||||
assert "### 1. PutBucketPolicy at 2026-05-04T16:55:01Z" in text
|
||||
assert "- Source: s3.amazonaws.com" in text
|
||||
assert "- Status: ok" in text
|
||||
assert "- Actor: assumed-role/AdminRole/alice" in text
|
||||
assert "- Actor type: AssumedRole" in text
|
||||
assert "- Actor ARN: arn:aws:sts::123:assumed-role/AdminRole/alice" in text
|
||||
assert "- Source IP: 1.2.3.4" in text
|
||||
assert "- User agent: aws-cli/2.15.30" in text
|
||||
assert "- Event ID: evt-1" in text
|
||||
|
||||
def test_optional_fields_are_omitted_when_absent(self, resource):
|
||||
text = views_helpers.serialize_events_as_text(
|
||||
events=[
|
||||
_event(
|
||||
actor_type=None,
|
||||
actor_uid=None,
|
||||
source_ip_address=None,
|
||||
user_agent=None,
|
||||
)
|
||||
],
|
||||
resource=resource,
|
||||
lookback_days=90,
|
||||
write_events_only=True,
|
||||
)
|
||||
|
||||
assert "- Actor type:" not in text
|
||||
assert "- Actor ARN:" not in text
|
||||
assert "- Source IP:" not in text
|
||||
assert "- User agent:" not in text
|
||||
# Required field still rendered
|
||||
assert "- Actor: assumed-role/AdminRole/alice" in text
|
||||
|
||||
def test_error_event_renders_error_code_and_message(self, resource):
|
||||
text = views_helpers.serialize_events_as_text(
|
||||
events=[
|
||||
_event(
|
||||
error_code="AccessDenied",
|
||||
error_message=(
|
||||
"User: arn:aws:sts::123:assumed-role/AdminRole/alice "
|
||||
"is not authorized to perform: s3:PutBucketAcl"
|
||||
),
|
||||
)
|
||||
],
|
||||
resource=resource,
|
||||
lookback_days=90,
|
||||
write_events_only=True,
|
||||
)
|
||||
|
||||
assert "- Status: ERROR(AccessDenied)" in text
|
||||
assert "- Error: User: arn:aws:sts::123:assumed-role/AdminRole/alice" in text
|
||||
|
||||
def test_error_message_omitted_when_no_error_code(self, resource):
|
||||
text = views_helpers.serialize_events_as_text(
|
||||
events=[
|
||||
_event(
|
||||
error_code=None,
|
||||
error_message="orphaned message that should be ignored",
|
||||
)
|
||||
],
|
||||
resource=resource,
|
||||
lookback_days=90,
|
||||
write_events_only=True,
|
||||
)
|
||||
|
||||
assert "- Status: ok" in text
|
||||
assert "orphaned message" not in text
|
||||
|
||||
def test_event_order_is_preserved_no_sorting(self, resource):
|
||||
# API returns CloudTrail events in its native order; the renderer
|
||||
# must NOT re-sort them.
|
||||
first = _event(
|
||||
event_id="newest",
|
||||
event_name="GetBucketPolicy",
|
||||
event_time=datetime(2026, 5, 4, 17, 2, 11, tzinfo=timezone.utc),
|
||||
)
|
||||
second = _event(
|
||||
event_id="middle",
|
||||
event_name="PutBucketAcl",
|
||||
event_time=datetime(2026, 5, 4, 16, 58, 33, tzinfo=timezone.utc),
|
||||
)
|
||||
third = _event(
|
||||
event_id="oldest",
|
||||
event_name="PutBucketPolicy",
|
||||
event_time=datetime(2026, 5, 4, 16, 55, 1, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
text = views_helpers.serialize_events_as_text(
|
||||
events=[first, second, third],
|
||||
resource=resource,
|
||||
lookback_days=90,
|
||||
write_events_only=True,
|
||||
)
|
||||
|
||||
idx_first = text.index("### 1. GetBucketPolicy")
|
||||
idx_second = text.index("### 2. PutBucketAcl")
|
||||
idx_third = text.index("### 3. PutBucketPolicy")
|
||||
assert idx_first < idx_second < idx_third
|
||||
|
||||
def test_event_count_in_header_matches_body(self, resource):
|
||||
events = [_event(event_id=f"e{i}") for i in range(3)]
|
||||
text = views_helpers.serialize_events_as_text(
|
||||
events=events,
|
||||
resource=resource,
|
||||
lookback_days=90,
|
||||
write_events_only=True,
|
||||
)
|
||||
|
||||
assert "- Events: 3" in text
|
||||
assert text.count("### ") == 3
|
||||
|
||||
|
||||
class TestPayloadFormatting:
|
||||
def test_request_data_renders_inline(self, resource):
|
||||
text = views_helpers.serialize_events_as_text(
|
||||
events=[
|
||||
_event(
|
||||
request_data={
|
||||
"bucketName": "acme-prod-data",
|
||||
"encrypted": True,
|
||||
}
|
||||
)
|
||||
],
|
||||
resource=resource,
|
||||
lookback_days=90,
|
||||
write_events_only=True,
|
||||
)
|
||||
|
||||
assert '- Request: {bucketName: "acme-prod-data", encrypted: true}' in text
|
||||
|
||||
def test_request_data_empty_dict_omits_line(self, resource):
|
||||
text = views_helpers.serialize_events_as_text(
|
||||
events=[_event(request_data={})],
|
||||
resource=resource,
|
||||
lookback_days=90,
|
||||
write_events_only=True,
|
||||
)
|
||||
|
||||
assert "- Request:" not in text
|
||||
|
||||
def test_response_data_renders_when_present(self, resource):
|
||||
text = views_helpers.serialize_events_as_text(
|
||||
events=[
|
||||
_event(
|
||||
response_data={"versionId": "abc123"},
|
||||
)
|
||||
],
|
||||
resource=resource,
|
||||
lookback_days=90,
|
||||
write_events_only=True,
|
||||
)
|
||||
|
||||
assert '- Response: {versionId: "abc123"}' in text
|
||||
|
||||
def test_long_strings_are_truncated(self, resource):
|
||||
long_policy = "x" * 500
|
||||
text = views_helpers.serialize_events_as_text(
|
||||
events=[_event(request_data={"policy": long_policy})],
|
||||
resource=resource,
|
||||
lookback_days=90,
|
||||
write_events_only=True,
|
||||
)
|
||||
|
||||
# 200-char threshold, with "..." marker on truncation
|
||||
assert "..." in text
|
||||
# The full 500-char value must NOT be present
|
||||
assert long_policy not in text
|
||||
|
||||
def test_large_list_summarized_as_count(self, resource):
|
||||
text = views_helpers.serialize_events_as_text(
|
||||
events=[_event(request_data={"tags": list(range(20))})],
|
||||
resource=resource,
|
||||
lookback_days=90,
|
||||
write_events_only=True,
|
||||
)
|
||||
|
||||
assert "tags: [20 items]" in text
|
||||
|
||||
def test_small_list_renders_inline(self, resource):
|
||||
text = views_helpers.serialize_events_as_text(
|
||||
events=[_event(request_data={"ports": [80, 443]})],
|
||||
resource=resource,
|
||||
lookback_days=90,
|
||||
write_events_only=True,
|
||||
)
|
||||
|
||||
assert "ports: [80, 443]" in text
|
||||
|
||||
def test_large_dict_summarized_as_count(self, resource):
|
||||
text = views_helpers.serialize_events_as_text(
|
||||
events=[
|
||||
_event(
|
||||
request_data={
|
||||
"config": {f"key{i}": i for i in range(15)},
|
||||
}
|
||||
)
|
||||
],
|
||||
resource=resource,
|
||||
lookback_days=90,
|
||||
write_events_only=True,
|
||||
)
|
||||
|
||||
assert "config: {15 keys}" in text
|
||||
|
||||
def test_bool_and_none_values_lowercased(self, resource):
|
||||
text = views_helpers.serialize_events_as_text(
|
||||
events=[
|
||||
_event(
|
||||
request_data={
|
||||
"publicAccess": True,
|
||||
"encryption": False,
|
||||
"kmsKey": None,
|
||||
}
|
||||
)
|
||||
],
|
||||
resource=resource,
|
||||
lookback_days=90,
|
||||
write_events_only=True,
|
||||
)
|
||||
|
||||
assert "publicAccess: true" in text
|
||||
assert "encryption: false" in text
|
||||
assert "kmsKey: null" in text
|
||||
|
||||
def test_request_data_non_dict_is_ignored(self, resource):
|
||||
text = views_helpers.serialize_events_as_text(
|
||||
events=[_event(request_data="not a dict")],
|
||||
resource=resource,
|
||||
lookback_days=90,
|
||||
write_events_only=True,
|
||||
)
|
||||
|
||||
assert "- Request:" not in text
|
||||
|
||||
|
||||
class TestTimeFormatting:
|
||||
def test_event_time_as_naive_datetime_is_treated_as_utc(self, resource):
|
||||
# Defensive: providers occasionally hand back naive datetimes;
|
||||
# they must be normalized rather than crashing the renderer.
|
||||
text = views_helpers.serialize_events_as_text(
|
||||
events=[
|
||||
_event(event_time=datetime(2026, 5, 4, 16, 55, 1)),
|
||||
],
|
||||
resource=resource,
|
||||
lookback_days=90,
|
||||
write_events_only=True,
|
||||
)
|
||||
|
||||
assert "### 1. PutBucketPolicy at 2026-05-04T16:55:01Z" in text
|
||||
|
||||
def test_event_time_as_iso_string_is_parsed(self, resource):
|
||||
text = views_helpers.serialize_events_as_text(
|
||||
events=[_event(event_time="2026-05-04T16:55:01Z")],
|
||||
resource=resource,
|
||||
lookback_days=90,
|
||||
write_events_only=True,
|
||||
)
|
||||
|
||||
assert "### 1. PutBucketPolicy at 2026-05-04T16:55:01Z" in text
|
||||
|
||||
def test_unparseable_event_time_does_not_crash(self, resource):
|
||||
text = views_helpers.serialize_events_as_text(
|
||||
events=[_event(event_time="garbage")],
|
||||
resource=resource,
|
||||
lookback_days=90,
|
||||
write_events_only=True,
|
||||
)
|
||||
|
||||
# Falls back to datetime.min — exact value isn't important, but
|
||||
# the renderer must not raise.
|
||||
assert "### 1. PutBucketPolicy at " in text
|
||||
@@ -1469,9 +1469,9 @@ class TestProviderViewSet:
|
||||
|
||||
included_data = response.json()["included"]
|
||||
for expected_type in expected_resources:
|
||||
assert any(d.get("type") == expected_type for d in included_data), (
|
||||
f"Expected type '{expected_type}' not found in included data"
|
||||
)
|
||||
assert any(
|
||||
d.get("type") == expected_type for d in included_data
|
||||
), f"Expected type '{expected_type}' not found in included data"
|
||||
|
||||
def test_providers_retrieve(self, authenticated_client, providers_fixture):
|
||||
provider1, *_ = providers_fixture
|
||||
@@ -5468,13 +5468,13 @@ class TestAttackPathsScanViewSet:
|
||||
content_type=API_JSON_CONTENT_TYPE,
|
||||
)
|
||||
if i < 10:
|
||||
assert response.status_code == status.HTTP_200_OK, (
|
||||
f"Request {i + 1} should succeed with 200 OK, got {response.status_code}"
|
||||
)
|
||||
assert (
|
||||
response.status_code == status.HTTP_200_OK
|
||||
), f"Request {i + 1} should succeed with 200 OK, got {response.status_code}"
|
||||
else:
|
||||
assert response.status_code == status.HTTP_429_TOO_MANY_REQUESTS, (
|
||||
f"Request {i + 1} should be throttled"
|
||||
)
|
||||
assert (
|
||||
response.status_code == status.HTTP_429_TOO_MANY_REQUESTS
|
||||
), f"Request {i + 1} should be throttled"
|
||||
|
||||
# -- Timeout simulation -------------------------------------------------------
|
||||
|
||||
@@ -5677,9 +5677,9 @@ class TestResourceViewSet:
|
||||
|
||||
included_data = response.json()["included"]
|
||||
for expected_type in expected_resources:
|
||||
assert any(d.get("type") == expected_type for d in included_data), (
|
||||
f"Expected type '{expected_type}' not found in included data"
|
||||
)
|
||||
assert any(
|
||||
d.get("type") == expected_type for d in included_data
|
||||
), f"Expected type '{expected_type}' not found in included data"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"filter_name, filter_value, expected_count",
|
||||
@@ -6228,9 +6228,9 @@ class TestResourceViewSet:
|
||||
(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 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"]
|
||||
@@ -6762,187 +6762,16 @@ class TestResourceViewSet:
|
||||
# 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}"
|
||||
)
|
||||
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}"
|
||||
)
|
||||
|
||||
@patch("api.v1.views.initialize_prowler_provider")
|
||||
@patch("api.v1.views.CloudTrailTimeline")
|
||||
def test_events_text_plain_renders_markdown(
|
||||
self,
|
||||
mock_cloudtrail_timeline,
|
||||
mock_initialize_provider,
|
||||
authenticated_client,
|
||||
providers_fixture,
|
||||
):
|
||||
"""`Accept: text/plain` returns a markdown report instead of JSON:API."""
|
||||
from api.models import Resource
|
||||
|
||||
aws_provider = providers_fixture[0]
|
||||
|
||||
resource = Resource.objects.create(
|
||||
uid="arn:aws:s3:::acme-prod-data",
|
||||
name="acme-prod-data",
|
||||
type="bucket",
|
||||
region="us-east-1",
|
||||
service="s3",
|
||||
provider=aws_provider,
|
||||
tenant_id=aws_provider.tenant_id,
|
||||
)
|
||||
|
||||
mock_session = Mock()
|
||||
mock_provider = Mock()
|
||||
mock_provider._session.current_session = mock_session
|
||||
mock_initialize_provider.return_value = mock_provider
|
||||
|
||||
mock_timeline_instance = Mock()
|
||||
mock_timeline_instance.get_resource_timeline.return_value = [
|
||||
{
|
||||
"event_id": "evt-1",
|
||||
"event_time": "2026-05-04T16:55:01Z",
|
||||
"event_name": "PutBucketPolicy",
|
||||
"event_source": "s3.amazonaws.com",
|
||||
"actor": "assumed-role/AdminRole/alice",
|
||||
"actor_uid": "arn:aws:sts::123:assumed-role/AdminRole/alice",
|
||||
"actor_type": "AssumedRole",
|
||||
"source_ip_address": "1.2.3.4",
|
||||
"user_agent": "aws-cli/2.15.30",
|
||||
"request_data": {"bucketName": "acme-prod-data"},
|
||||
},
|
||||
{
|
||||
"event_id": "evt-2",
|
||||
"event_time": "2026-05-04T16:58:33Z",
|
||||
"event_name": "PutBucketAcl",
|
||||
"event_source": "s3.amazonaws.com",
|
||||
"actor": "assumed-role/AdminRole/alice",
|
||||
"error_code": "AccessDenied",
|
||||
"error_message": "User not authorized",
|
||||
},
|
||||
]
|
||||
mock_cloudtrail_timeline.return_value = mock_timeline_instance
|
||||
|
||||
response = authenticated_client.get(
|
||||
reverse("resource-events", kwargs={"pk": resource.id}),
|
||||
HTTP_ACCEPT="text/plain",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response["Content-Type"].startswith("text/plain")
|
||||
|
||||
body = response.content.decode("utf-8")
|
||||
|
||||
# Header
|
||||
assert body.startswith("# Resource Events\n")
|
||||
assert "- Resource: arn:aws:s3:::acme-prod-data" in body
|
||||
assert "- Region: us-east-1" in body
|
||||
assert "- Events: 2" in body
|
||||
# Body
|
||||
assert "### 1. PutBucketPolicy at 2026-05-04T16:55:01Z" in body
|
||||
assert "- Status: ok" in body
|
||||
assert "- Status: ERROR(AccessDenied)" in body
|
||||
assert "- Error: User not authorized" in body
|
||||
assert '- Request: {bucketName: "acme-prod-data"}' in body
|
||||
|
||||
@patch("api.v1.views.initialize_prowler_provider")
|
||||
@patch("api.v1.views.CloudTrailTimeline")
|
||||
def test_events_default_accept_still_returns_json(
|
||||
self,
|
||||
mock_cloudtrail_timeline,
|
||||
mock_initialize_provider,
|
||||
authenticated_client,
|
||||
providers_fixture,
|
||||
):
|
||||
"""Adding text/plain renderer must not regress the default JSON:API path."""
|
||||
from api.models import Resource
|
||||
|
||||
aws_provider = providers_fixture[0]
|
||||
resource = Resource.objects.create(
|
||||
uid="arn:aws:ec2:us-east-1:123456789012:instance/i-default-accept",
|
||||
name="Default Accept Instance",
|
||||
type="instance",
|
||||
region="us-east-1",
|
||||
service="ec2",
|
||||
provider=aws_provider,
|
||||
tenant_id=aws_provider.tenant_id,
|
||||
)
|
||||
|
||||
mock_session = Mock()
|
||||
mock_provider = Mock()
|
||||
mock_provider._session.current_session = mock_session
|
||||
mock_initialize_provider.return_value = mock_provider
|
||||
|
||||
mock_timeline_instance = Mock()
|
||||
mock_timeline_instance.get_resource_timeline.return_value = [
|
||||
{
|
||||
"event_id": "evt-json",
|
||||
"event_time": "2026-05-04T16:55:01Z",
|
||||
"event_name": "RunInstances",
|
||||
"event_source": "ec2.amazonaws.com",
|
||||
"actor": "user/alice",
|
||||
}
|
||||
]
|
||||
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
|
||||
assert "json" in response["Content-Type"]
|
||||
payload = response.json()
|
||||
assert payload["data"][0]["type"] == "resource-events"
|
||||
assert payload["data"][0]["id"] == "evt-json"
|
||||
assert payload["data"][0]["attributes"]["event_name"] == "RunInstances"
|
||||
|
||||
@patch("api.v1.views.initialize_prowler_provider")
|
||||
@patch("api.v1.views.CloudTrailTimeline")
|
||||
def test_events_text_plain_no_events_renders_empty_marker(
|
||||
self,
|
||||
mock_cloudtrail_timeline,
|
||||
mock_initialize_provider,
|
||||
authenticated_client,
|
||||
providers_fixture,
|
||||
):
|
||||
"""Empty timeline still produces a valid text response, not a 500."""
|
||||
from api.models import Resource
|
||||
|
||||
aws_provider = providers_fixture[0]
|
||||
resource = Resource.objects.create(
|
||||
uid="arn:aws:ec2:us-east-1:123456789012:instance/i-empty",
|
||||
name="Empty Instance",
|
||||
type="instance",
|
||||
region="us-east-1",
|
||||
service="ec2",
|
||||
provider=aws_provider,
|
||||
tenant_id=aws_provider.tenant_id,
|
||||
)
|
||||
|
||||
mock_session = Mock()
|
||||
mock_provider = Mock()
|
||||
mock_provider._session.current_session = mock_session
|
||||
mock_initialize_provider.return_value = mock_provider
|
||||
|
||||
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}),
|
||||
HTTP_ACCEPT="text/plain",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response["Content-Type"].startswith("text/plain")
|
||||
body = response.content.decode("utf-8")
|
||||
assert "- Events: 0" in body
|
||||
assert "No events recorded in the lookback window." in body
|
||||
assert (
|
||||
response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
), f"Expected 401 for empty bearer token but got {response.status_code}"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@@ -7003,9 +6832,9 @@ class TestFindingViewSet:
|
||||
|
||||
included_data = response.json()["included"]
|
||||
for expected_type in expected_resources:
|
||||
assert any(d.get("type") == expected_type for d in included_data), (
|
||||
f"Expected type '{expected_type}' not found in included data"
|
||||
)
|
||||
assert any(
|
||||
d.get("type") == expected_type for d in included_data
|
||||
), f"Expected type '{expected_type}' not found in included data"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"filter_name, filter_value, expected_count",
|
||||
@@ -7544,9 +7373,9 @@ class TestJWTFields:
|
||||
reverse("token-obtain"), data, format="json"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK, (
|
||||
f"Unexpected status code: {response.status_code}"
|
||||
)
|
||||
assert (
|
||||
response.status_code == status.HTTP_200_OK
|
||||
), f"Unexpected status code: {response.status_code}"
|
||||
|
||||
access_token = response.data["attributes"]["access"]
|
||||
payload = jwt.decode(access_token, options={"verify_signature": False})
|
||||
@@ -7560,23 +7389,23 @@ class TestJWTFields:
|
||||
# Verify expected fields
|
||||
for field in expected_fields:
|
||||
assert field in payload, f"The field '{field}' is not in the JWT"
|
||||
assert payload[field] == expected_fields[field], (
|
||||
f"The value of '{field}' does not match"
|
||||
)
|
||||
assert (
|
||||
payload[field] == expected_fields[field]
|
||||
), f"The value of '{field}' does not match"
|
||||
|
||||
# Verify time fields are integers
|
||||
for time_field in ["exp", "iat", "nbf"]:
|
||||
assert time_field in payload, f"The field '{time_field}' is not in the JWT"
|
||||
assert isinstance(payload[time_field], int), (
|
||||
f"The field '{time_field}' is not an integer"
|
||||
)
|
||||
assert isinstance(
|
||||
payload[time_field], int
|
||||
), f"The field '{time_field}' is not an integer"
|
||||
|
||||
# Verify identification fields are non-empty strings
|
||||
for id_field in ["jti", "sub", "tenant_id"]:
|
||||
assert id_field in payload, f"The field '{id_field}' is not in the JWT"
|
||||
assert isinstance(payload[id_field], str) and payload[id_field], (
|
||||
f"The field '{id_field}' is not a valid string"
|
||||
)
|
||||
assert (
|
||||
isinstance(payload[id_field], str) and payload[id_field]
|
||||
), f"The field '{id_field}' is not a valid string"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@@ -11517,9 +11346,9 @@ class TestIntegrationViewSet:
|
||||
|
||||
included_data = response.json()["included"]
|
||||
for expected_type in expected_resources:
|
||||
assert any(d.get("type") == expected_type for d in included_data), (
|
||||
f"Expected type '{expected_type}' not found in included data"
|
||||
)
|
||||
assert any(
|
||||
d.get("type") == expected_type for d in included_data
|
||||
), f"Expected type '{expected_type}' not found in included data"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"integration_type, configuration, credentials",
|
||||
@@ -12956,9 +12785,9 @@ class TestLighthouseConfigViewSet:
|
||||
)
|
||||
# Check that API key is masked with asterisks only
|
||||
masked_api_key = data["attributes"]["api_key"]
|
||||
assert all(c == "*" for c in masked_api_key), (
|
||||
"API key should contain only asterisks"
|
||||
)
|
||||
assert all(
|
||||
c == "*" for c in masked_api_key
|
||||
), "API key should contain only asterisks"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field_name, invalid_value",
|
||||
@@ -16703,9 +16532,9 @@ class TestFindingGroupViewSet:
|
||||
assert len(data) == 2
|
||||
for item in data:
|
||||
resource = item["attributes"]["resource"]
|
||||
assert resource["resource_group"] == "storage", (
|
||||
"resource_group must be 'storage'"
|
||||
)
|
||||
assert (
|
||||
resource["resource_group"] == "storage"
|
||||
), "resource_group must be 'storage'"
|
||||
|
||||
def test_resources_name_icontains(
|
||||
self, authenticated_client, finding_groups_fixture
|
||||
@@ -17019,12 +16848,12 @@ class TestFindingGroupViewSet:
|
||||
assert response_p1.status_code == status.HTTP_200_OK
|
||||
p1_check_ids = {item["id"] for item in response_p1.json()["data"]}
|
||||
# Provider1 has scan1 with 4 checks
|
||||
assert len(p1_check_ids) == 4, (
|
||||
f"Provider1 should have 4 checks, got {len(p1_check_ids)}"
|
||||
)
|
||||
assert "cloudtrail_enabled" not in p1_check_ids, (
|
||||
"cloudtrail_enabled should NOT be in provider1"
|
||||
)
|
||||
assert (
|
||||
len(p1_check_ids) == 4
|
||||
), f"Provider1 should have 4 checks, got {len(p1_check_ids)}"
|
||||
assert (
|
||||
"cloudtrail_enabled" not in p1_check_ids
|
||||
), "cloudtrail_enabled should NOT be in provider1"
|
||||
|
||||
# Get finding groups for provider2 only
|
||||
response_p2 = authenticated_client.get(
|
||||
@@ -17034,12 +16863,12 @@ class TestFindingGroupViewSet:
|
||||
assert response_p2.status_code == status.HTTP_200_OK
|
||||
p2_check_ids = {item["id"] for item in response_p2.json()["data"]}
|
||||
# Provider2 has scan2 with 1 check
|
||||
assert len(p2_check_ids) == 1, (
|
||||
f"Provider2 should have 1 check, got {len(p2_check_ids)}"
|
||||
)
|
||||
assert "cloudtrail_enabled" in p2_check_ids, (
|
||||
"cloudtrail_enabled should be in provider2"
|
||||
)
|
||||
assert (
|
||||
len(p2_check_ids) == 1
|
||||
), f"Provider2 should have 1 check, got {len(p2_check_ids)}"
|
||||
assert (
|
||||
"cloudtrail_enabled" in p2_check_ids
|
||||
), "cloudtrail_enabled should be in provider2"
|
||||
|
||||
# Test provider_type filter actually filters data
|
||||
def test_finding_groups_provider_type_filter_actually_filters(
|
||||
@@ -17062,9 +16891,9 @@ class TestFindingGroupViewSet:
|
||||
{"filter[inserted_at]": TODAY, "filter[provider_type]": "gcp"},
|
||||
)
|
||||
assert response_gcp.status_code == status.HTTP_200_OK
|
||||
assert len(response_gcp.json()["data"]) == 0, (
|
||||
"GCP filter should return 0 results"
|
||||
)
|
||||
assert (
|
||||
len(response_gcp.json()["data"]) == 0
|
||||
), "GCP filter should return 0 results"
|
||||
|
||||
def test_finding_groups_pagination(
|
||||
self, authenticated_client, finding_groups_fixture
|
||||
|
||||
@@ -4,6 +4,7 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
from copy import deepcopy
|
||||
from datetime import datetime, timedelta, timezone
|
||||
@@ -16,7 +17,7 @@ from allauth.socialaccount.providers.github.views import GitHubOAuth2Adapter
|
||||
from allauth.socialaccount.providers.google.views import GoogleOAuth2Adapter
|
||||
from allauth.socialaccount.providers.saml.views import FinishACSView, LoginView
|
||||
from botocore.exceptions import ClientError, NoCredentialsError, ParamValidationError
|
||||
from celery import chain
|
||||
from celery import chain, states
|
||||
from celery.result import AsyncResult
|
||||
from config.custom_logging import BackendLogger
|
||||
from config.env import env
|
||||
@@ -60,6 +61,7 @@ from django.utils.dateparse import parse_date
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.cache import cache_control
|
||||
from django_celery_beat.models import PeriodicTask
|
||||
from django_celery_results.models import TaskResult
|
||||
from drf_spectacular.settings import spectacular_settings
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import (
|
||||
@@ -109,7 +111,6 @@ from tasks.tasks import (
|
||||
from api.attack_paths import database as graph_database
|
||||
from api.attack_paths import get_queries_for_provider, get_query_by_id
|
||||
from api.attack_paths import views_helpers as attack_paths_views_helpers
|
||||
from api.events import views_helpers as events_views_helpers
|
||||
from api.base_views import BaseRLSViewSet, BaseTenantViewset, BaseUserViewset
|
||||
from api.compliance import (
|
||||
PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE,
|
||||
@@ -423,7 +424,7 @@ class SchemaView(SpectacularAPIView):
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
spectacular_settings.TITLE = "Prowler API"
|
||||
spectacular_settings.VERSION = "1.27.0"
|
||||
spectacular_settings.VERSION = "1.27.2"
|
||||
spectacular_settings.DESCRIPTION = (
|
||||
"Prowler API specification.\n\nThis file is auto-generated."
|
||||
)
|
||||
@@ -2535,28 +2536,45 @@ class ScanViewSet(BaseRLSViewSet):
|
||||
def create(self, request, *args, **kwargs):
|
||||
input_serializer = self.get_serializer(data=request.data)
|
||||
input_serializer.is_valid(raise_exception=True)
|
||||
|
||||
# Broker publish is deferred to on_commit so the worker cannot read
|
||||
# Scan before BaseRLSViewSet's dispatch-wide atomic commits.
|
||||
pre_task_id = str(uuid.uuid4())
|
||||
|
||||
with transaction.atomic():
|
||||
scan = input_serializer.save()
|
||||
with transaction.atomic():
|
||||
task = perform_scan_task.apply_async(
|
||||
kwargs={
|
||||
"tenant_id": self.request.tenant_id,
|
||||
"scan_id": str(scan.id),
|
||||
"provider_id": str(scan.provider_id),
|
||||
# Disabled for now
|
||||
# checks_to_execute=scan.scanner_args.get("checks_to_execute")
|
||||
},
|
||||
scan.task_id = pre_task_id
|
||||
scan.save(update_fields=["task_id"])
|
||||
|
||||
attack_paths_db_utils.create_attack_paths_scan(
|
||||
tenant_id=self.request.tenant_id,
|
||||
scan_id=str(scan.id),
|
||||
provider_id=str(scan.provider_id),
|
||||
)
|
||||
|
||||
attack_paths_db_utils.create_attack_paths_scan(
|
||||
tenant_id=self.request.tenant_id,
|
||||
scan_id=str(scan.id),
|
||||
provider_id=str(scan.provider_id),
|
||||
)
|
||||
task_result, _ = TaskResult.objects.get_or_create(
|
||||
task_id=pre_task_id,
|
||||
defaults={"status": states.PENDING, "task_name": "scan-perform"},
|
||||
)
|
||||
prowler_task, _ = Task.objects.update_or_create(
|
||||
id=pre_task_id,
|
||||
tenant_id=self.request.tenant_id,
|
||||
defaults={"task_runner_task": task_result},
|
||||
)
|
||||
|
||||
prowler_task = Task.objects.get(id=task.id)
|
||||
scan.task_id = task.id
|
||||
scan.save(update_fields=["task_id"])
|
||||
scan_kwargs = {
|
||||
"tenant_id": self.request.tenant_id,
|
||||
"scan_id": str(scan.id),
|
||||
"provider_id": str(scan.provider_id),
|
||||
# Disabled for now
|
||||
# checks_to_execute=scan.scanner_args.get("checks_to_execute")
|
||||
}
|
||||
|
||||
transaction.on_commit(
|
||||
lambda: perform_scan_task.apply_async(
|
||||
kwargs=scan_kwargs, task_id=pre_task_id
|
||||
)
|
||||
)
|
||||
|
||||
self.response_serializer_class = TaskSerializer
|
||||
output_serializer = self.get_serializer(prowler_task)
|
||||
@@ -3391,9 +3409,6 @@ class ResourceViewSet(PaginateByPkMixin, BaseRLSViewSet):
|
||||
description=(
|
||||
"Retrieve events showing modification history for a resource. "
|
||||
"Returns who modified the resource and when. Currently only available for AWS resources.\n\n"
|
||||
"**Content negotiation:** send `Accept: text/plain` to receive a "
|
||||
"compact markdown report optimized for LLM consumption "
|
||||
"instead of the default JSON:API document.\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."
|
||||
),
|
||||
@@ -3441,7 +3456,6 @@ class ResourceViewSet(PaginateByPkMixin, BaseRLSViewSet):
|
||||
methods=["get"],
|
||||
url_name="events",
|
||||
filter_backends=[], # Disable filters - we're calling external API, not filtering queryset
|
||||
renderer_classes=[APIJSONRenderer, PlainTextRenderer],
|
||||
)
|
||||
def events(self, request, pk=None):
|
||||
"""Get events for a resource."""
|
||||
@@ -3574,15 +3588,6 @@ class ResourceViewSet(PaginateByPkMixin, BaseRLSViewSet):
|
||||
resource_uid=resource.uid,
|
||||
)
|
||||
|
||||
if isinstance(request.accepted_renderer, PlainTextRenderer):
|
||||
text = events_views_helpers.serialize_events_as_text(
|
||||
events,
|
||||
resource=resource,
|
||||
lookback_days=lookback_days,
|
||||
write_events_only=not include_read_events,
|
||||
)
|
||||
return Response(text)
|
||||
|
||||
serializer = ResourceEventSerializer(events, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
@@ -119,6 +119,7 @@
|
||||
"user-guide/tutorials/prowler-app-multi-tenant",
|
||||
"user-guide/tutorials/prowler-app-api-keys",
|
||||
"user-guide/tutorials/prowler-app-import-findings",
|
||||
"user-guide/tutorials/prowler-app-alerts",
|
||||
{
|
||||
"group": "Mutelist",
|
||||
"expanded": true,
|
||||
|
||||
@@ -121,8 +121,8 @@ To update the environment file:
|
||||
Edit the `.env` file and change version values:
|
||||
|
||||
```env
|
||||
PROWLER_UI_VERSION="5.25.2"
|
||||
PROWLER_API_VERSION="5.25.2"
|
||||
PROWLER_UI_VERSION="5.25.3"
|
||||
PROWLER_API_VERSION="5.25.3"
|
||||
```
|
||||
|
||||
<Note>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 257 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 399 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 425 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 88 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 222 KiB |
@@ -1,12 +1,17 @@
|
||||
export const VersionBadge = ({ version }) => {
|
||||
return (
|
||||
<code className="version-badge-container">
|
||||
<p className="version-badge">
|
||||
<span className="version-badge-label">Added in:</span>
|
||||
<code className="version-badge-version">{version}</code>
|
||||
</p>
|
||||
</code>
|
||||
|
||||
|
||||
<a
|
||||
href={`https://github.com/prowler-cloud/prowler/releases/tag/${version}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="version-badge-link"
|
||||
>
|
||||
<span className="version-badge-container">
|
||||
<span className="version-badge">
|
||||
<span className="version-badge-label">Added in:</span>
|
||||
<span className="version-badge-version">{version}</span>
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,21 @@
|
||||
/* Version Badge Styling */
|
||||
.version-badge-link,
|
||||
.version-badge-link:hover,
|
||||
.version-badge-link:focus,
|
||||
.version-badge-link:active,
|
||||
.version-badge-link:visited {
|
||||
display: inline-block;
|
||||
text-decoration: none !important;
|
||||
background-image: none !important;
|
||||
border-bottom: none !important;
|
||||
color: inherit;
|
||||
transition: opacity 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
.version-badge-link:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.version-badge-container {
|
||||
display: inline-block;
|
||||
margin: 0 0 1rem 0;
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
---
|
||||
title: 'Alerts'
|
||||
description: 'Create email alerts from Prowler Cloud findings to monitor relevant security changes after scans or in daily digests.'
|
||||
---
|
||||
|
||||
import { VersionBadge } from "/snippets/version-badge.mdx"
|
||||
|
||||
<VersionBadge version="5.26.0" />
|
||||
|
||||
Alerts notify recipients by email when security findings match saved filter conditions. Use Alerts to track high-priority findings, monitor specific providers or services, and keep teams informed about scan results that match defined criteria.
|
||||
|
||||
<Note>
|
||||
This feature is available exclusively in **Prowler Cloud** with a paid subscription.
|
||||
</Note>
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before creating Alerts, ensure that:
|
||||
|
||||
* At least one scan has completed and produced findings.
|
||||
* The user role includes the `manage_alerts` permission.
|
||||
|
||||
The `manage_alerts` permission is required to create, edit, test, enable, disable, and delete Alerts. See [RBAC Administrative Permissions](/user-guide/tutorials/prowler-app-rbac#rbac-administrative-permissions) for details.
|
||||
|
||||
## How Alerts Work
|
||||
|
||||
Alerts are created from Findings filters. When an Alert runs, Prowler Cloud evaluates the saved conditions against findings and sends an email digest when matching findings exist.
|
||||
|
||||
<Note>
|
||||
Alerts evaluate findings with status `FAIL` only. Findings with status `PASS` or `MANUAL`, and muted findings, never trigger an Alert regardless of the saved filters.
|
||||
</Note>
|
||||
|
||||
Alerts run on one of three schedules:
|
||||
|
||||
| Frequency | Description |
|
||||
|-----------|-------------|
|
||||
| After each scan | Evaluates the Alert after each completed scan. |
|
||||
| Daily digest | Evaluates the Alert once per day and sends a digest when findings match. |
|
||||
| After each scan and daily | Evaluates the Alert after every scan and in the daily digest. |
|
||||
|
||||
## Creating an Alert From Findings
|
||||
|
||||
To create an Alert:
|
||||
|
||||
1. Navigate to **Findings** in Prowler Cloud.
|
||||
2. Apply at least one [Alert-compatible filter](#alert-compatible-filters) to define the findings that should trigger the Alert.
|
||||
3. Click **Create Alert**.
|
||||
|
||||

|
||||
|
||||
4. Configure the Alert settings:
|
||||
* **Name:** Add a short, descriptive name.
|
||||
* **Description:** Add optional context for the Alert.
|
||||
* **Frequency:** Select when Prowler Cloud should evaluate the Alert.
|
||||
* **Recipients:** Select the recipients who should receive the email digest.
|
||||
|
||||

|
||||
|
||||
5. Click **Create**.
|
||||
|
||||
After the Alert is created, Prowler Cloud evaluates it based on the selected frequency.
|
||||
|
||||
## Alert-Compatible Filters
|
||||
|
||||
An **Alert-compatible filter** is a Findings-page filter that the Alert condition language can evaluate when the Alert runs. The Findings page exposes many filters, but only a specific subset can be saved into an Alert. Filters outside this subset, such as **Status**, free-text search, sort, or pagination, are ignored when seeding an Alert from the current Findings view.
|
||||
|
||||
When **Create Alert** is clicked on the Findings page, Prowler Cloud takes the active filters, keeps only the Alert-compatible ones, and uses them to build the Alert condition.
|
||||
|
||||
The following filters are Alert-compatible:
|
||||
|
||||
* Provider type
|
||||
* Provider
|
||||
* Severity
|
||||
* Delta (new findings since the previous scan)
|
||||
* Region
|
||||
* Service
|
||||
* Resource type
|
||||
* Category
|
||||
* Resource group
|
||||
|
||||
If only the **Status** filter is applied on the Findings page, Prowler Cloud substitutes all severities as the condition base so the Alert can still be created. Status itself never becomes part of the Alert condition.
|
||||
|
||||
## Managing Alerts
|
||||
|
||||
Navigate to **Alerts** to review and manage existing Alerts.
|
||||
|
||||

|
||||
|
||||
Each Alert provides these actions:
|
||||
|
||||
| Action | Description |
|
||||
|--------|-------------|
|
||||
| Edit | Update name, description, recipients, frequency, or filters. |
|
||||
| Enable/Disable | Start or stop Alert evaluation without deleting the Alert. |
|
||||
| Delete | Permanently remove the Alert. |
|
||||
|
||||
## Testing Alert Filters
|
||||
|
||||
When editing an Alert, click **Test** to preview whether the current filters match existing findings.
|
||||
|
||||
The test result indicates whether the filters match findings and includes a summary of the matching results.
|
||||
|
||||

|
||||
|
||||
<Warning>
|
||||
**The Test result is a snapshot, not a guarantee of future Alert triggers.**
|
||||
|
||||
The Test evaluates the current filters against existing findings at the moment **Test** is clicked. It does not predict whether the Alert will trigger on its next evaluation. The Alert trigger depends on the state at evaluation time:
|
||||
|
||||
* **After each scan:** The Alert is evaluated against the findings produced by that scan only. If the next scan produces no findings that match the filters, the Alert will not trigger, even if a Test run earlier in the day showed matches.
|
||||
* **Daily digest:** The Alert is evaluated against the findings present on the digest day. If no matching findings exist for that day, the Alert will not trigger, even if previous days had matches.
|
||||
|
||||
The reverse is also true: a Test showing no matches does not guarantee the Alert will stay silent. Future scans may produce matching findings.
|
||||
|
||||
Use **Test** to validate that the filters are well-formed and target the intended findings, not to forecast future Alert behavior.
|
||||
</Warning>
|
||||
|
||||
## Recipients
|
||||
|
||||
Alert recipients are selected from the email addresses available in the tenant. Recipients receive an email digest each time an Alert evaluates and matches findings.
|
||||
|
||||
<Note>
|
||||
By default, the **organization owner** receives a **daily digest** for **critical findings**. Adjust the recipient, frequency, or filters in the Alert configuration to change this behavior.
|
||||
</Note>
|
||||
|
||||
If a recipient unsubscribes from Alerts, that address stops receiving digests until it is reconfirmed.
|
||||
|
||||
## Email Notifications
|
||||
|
||||
When an Alert matches findings, Prowler Cloud sends a security alert email that summarizes the matching findings. The email includes:
|
||||
|
||||
* The scan name and evaluation time.
|
||||
* The total number of matching findings.
|
||||
* The number of Alert rules that triggered.
|
||||
* A preview of the affected findings, grouped by severity, with resource details and the originating rule.
|
||||
* A direct link to view all matching findings in Prowler Cloud.
|
||||
|
||||

|
||||
|
||||
## Best Practices
|
||||
|
||||
* **Start with focused filters:** Create Alerts for specific high-priority scopes, such as critical findings, production providers, or important services.
|
||||
* **Use clear names:** Choose names that explain the intent of the Alert.
|
||||
* **Review recipients regularly:** Keep recipient lists aligned with current ownership.
|
||||
* **Test before saving edits:** Use **Test** after changing filters to confirm that the Alert matches the expected findings.
|
||||
* **Disable instead of deleting during tuning:** Disable Alerts temporarily when adjusting filters or recipients.
|
||||
@@ -4,14 +4,6 @@ All notable changes to the **Prowler MCP Server** are documented in this file.
|
||||
|
||||
## [0.7.0] (Prowler UNRELEASED)
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
- `prowler_app_get_resource_events` now requests the API's `text/plain` representation and returns a markdown report [(#XXXXX)](https://github.com/prowler-cloud/prowler/pull/XXXXX)
|
||||
|
||||
### 🗑️ Removed
|
||||
|
||||
- `ResourceEvent`/`ResourceEventsResponse` models are removed since the tool no longer parses JSON:API for API `resources/{id}/events` [(#XXXXX)](https://github.com/prowler-cloud/prowler/pull/XXXXX)
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
- `cryptography` from 46.0.1 to 47.0.0 (transitive) for CVE-2026-39892 and CVE-2026-26007 / CVE-2026-34073 [(#10978)](https://github.com/prowler-cloud/prowler/pull/10978)
|
||||
|
||||
@@ -135,3 +135,48 @@ class ResourcesMetadataResponse(BaseModel):
|
||||
regions=attributes.get("regions"),
|
||||
types=attributes.get("types"),
|
||||
)
|
||||
|
||||
|
||||
class ResourceEvent(MinimalSerializerMixin, BaseModel):
|
||||
"""A cloud API action performed on a resource.
|
||||
|
||||
Sourced from cloud provider audit logs (AWS CloudTrail, Azure Activity Logs,
|
||||
GCP Audit Logs, etc.).
|
||||
"""
|
||||
|
||||
id: str
|
||||
event_time: str
|
||||
event_name: str
|
||||
event_source: str
|
||||
actor: str
|
||||
actor_uid: str | None = None
|
||||
actor_type: str | None = None
|
||||
source_ip_address: str | None = None
|
||||
user_agent: str | None = None
|
||||
request_data: dict | None = None
|
||||
response_data: dict | None = None
|
||||
error_code: str | None = None
|
||||
error_message: str | None = None
|
||||
|
||||
@classmethod
|
||||
def from_api_response(cls, data: dict) -> "ResourceEvent":
|
||||
"""Transform JSON:API resource event response."""
|
||||
return cls(id=data["id"], **data.get("attributes", {}))
|
||||
|
||||
|
||||
class ResourceEventsResponse(BaseModel):
|
||||
"""Response wrapper for resource events list."""
|
||||
|
||||
events: list[ResourceEvent]
|
||||
total_events: int
|
||||
|
||||
@classmethod
|
||||
def from_api_response(cls, response: dict) -> "ResourceEventsResponse":
|
||||
"""Transform JSON:API response to events list."""
|
||||
data = response.get("data", [])
|
||||
events = [ResourceEvent.from_api_response(item) for item in data]
|
||||
|
||||
return cls(
|
||||
events=events,
|
||||
total_events=len(events),
|
||||
)
|
||||
|
||||
@@ -8,6 +8,7 @@ from typing import Any
|
||||
|
||||
from prowler_mcp_server.prowler_app.models.resources import (
|
||||
DetailedResource,
|
||||
ResourceEventsResponse,
|
||||
ResourcesListResponse,
|
||||
ResourcesMetadataResponse,
|
||||
)
|
||||
@@ -370,13 +371,12 @@ class ResourcesTools(BaseTool):
|
||||
IMPORTANT: Currently only available for AWS resources. Uses CloudTrail to retrieve
|
||||
the modification history of a resource, showing who did what and when.
|
||||
|
||||
Returns a markdown report (via the API's `Accept: text/plain` representation)
|
||||
with one section per event, each containing:
|
||||
- What happened: event_name (e.g., PutBucketPolicy), event source
|
||||
Each event includes:
|
||||
- What happened: event_name (e.g., PutBucketPolicy), event_source (e.g., s3.amazonaws.com)
|
||||
- Who did it: actor, actor_type, actor_uid
|
||||
- From where: source_ip_address, user_agent
|
||||
- What changed: request_data, response_data (the API call payloads)
|
||||
- Errors: error code and message when the action failed
|
||||
- What changed: request_data, response_data (full API payloads)
|
||||
- Errors: error_code, error_message (if the action failed)
|
||||
|
||||
Use cases:
|
||||
- Investigating security incidents (who modified this resource?)
|
||||
@@ -396,14 +396,9 @@ class ResourcesTools(BaseTool):
|
||||
|
||||
clean_params = self.api_client.build_filter_params(params)
|
||||
|
||||
token = await self.api_client.auth_manager.get_valid_token()
|
||||
headers = self.api_client.auth_manager.get_headers(token)
|
||||
headers["Accept"] = "text/plain"
|
||||
response = await self.api_client.client.get(
|
||||
f"{self.api_client.auth_manager.base_url}/resources/{resource_id}/events",
|
||||
headers=headers,
|
||||
params=clean_params,
|
||||
api_response = await self.api_client.get(
|
||||
f"/resources/{resource_id}/events", params=clean_params
|
||||
)
|
||||
response.raise_for_status()
|
||||
events_response = ResourceEventsResponse.from_api_response(api_response)
|
||||
|
||||
return {"report": response.text}
|
||||
return events_response.model_dump()
|
||||
|
||||
@@ -11,7 +11,7 @@ description = "MCP server for Prowler ecosystem"
|
||||
name = "prowler-mcp"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
version = "0.7.0"
|
||||
version = "0.5.0"
|
||||
|
||||
[project.scripts]
|
||||
prowler-mcp = "prowler_mcp_server.main:main"
|
||||
|
||||
Generated
+3
-3
@@ -1009,7 +1009,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.32.5"
|
||||
version = "2.33.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
@@ -1017,9 +1017,9 @@ dependencies = [
|
||||
{ name = "idna" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+26
-5
@@ -2,15 +2,33 @@
|
||||
|
||||
All notable changes to the **Prowler SDK** are documented in this file.
|
||||
|
||||
## [5.26.0] (Prowler UNRELEASED)
|
||||
## [5.26.2] (Prowler UNRELEASED)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- `entra_users_mfa_capable` and `entra_break_glass_account_fido2_security_key_registered` report a preventive FAIL per affected user (with the missing permission named) when the M365 service principal lacks `AuditLog.Read.All`, instead of mass false positives [(#10907)](https://github.com/prowler-cloud/prowler/pull/10907)
|
||||
|
||||
---
|
||||
|
||||
## [5.26.1] (Prowler v5.26.1)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- `entra_users_mfa_capable` no longer flags disabled guest users by requesting `accountEnabled` and `userType` from Microsoft Graph via `$select` and using Graph as the source of truth for `account_enabled` (EXO `Get-User` does not return guest users) [(#11002)](https://github.com/prowler-cloud/prowler/pull/11002)
|
||||
|
||||
---
|
||||
|
||||
## [5.26.0] (Prowler v5.26.0)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
- `bedrock_guardrails_configured` check for AWS provider [(#10844)](https://github.com/prowler-cloud/prowler/pull/10844)
|
||||
- Universal compliance pipeline integrated into the CLI: `--list-compliance` and `--list-compliance-requirements` show universal frameworks, and CSV plus OCSF outputs are generated for any framework declaring a `TableConfig` [(#10301)](https://github.com/prowler-cloud/prowler/pull/10301)
|
||||
- Universal compliance with OCSF support [(#10301)](https://github.com/prowler-cloud/prowler/pull/10301)
|
||||
- ASD Essential Eight Maturity Model compliance framework for AWS (Maturity Level One, Nov 2023) [(#10808)](https://github.com/prowler-cloud/prowler/pull/10808)
|
||||
- Update Vercel checks to return personalized finding status extended depending on billing plan and classify them with billing-plan categories [(#10663)](https://github.com/prowler-cloud/prowler/pull/10663)
|
||||
- Vercel checks to return personalized finding status extended depending on billing plan and classify them with billing-plan categories [(#10663)](https://github.com/prowler-cloud/prowler/pull/10663)
|
||||
- `bedrock_prompt_management_exists` check for AWS provider [(#10878)](https://github.com/prowler-cloud/prowler/pull/10878)
|
||||
- 8 Gmail attachment safety and spoofing protection checks for Google Workspace provider using the Cloud Identity Policy API [(#10980)](https://github.com/prowler-cloud/prowler/pull/10980)
|
||||
- `bedrock_prompt_encrypted_with_cmk` check for AWS provider [(#10905)](https://github.com/prowler-cloud/prowler/pull/10905)
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
@@ -19,6 +37,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
|
||||
- AWS CodeBuild service now batches `BatchGetProjects` and `BatchGetBuilds` calls per region (up to 100 items per call) to reduce API call volume and prevent throttling-induced false positives in `codebuild_project_not_publicly_accessible` [(#10639)](https://github.com/prowler-cloud/prowler/pull/10639)
|
||||
- `display_compliance_table` dispatch switched from substring `in` checks to `startswith` to prevent false matches between similarly named frameworks (e.g. `cisa` vs `cis`) [(#10301)](https://github.com/prowler-cloud/prowler/pull/10301)
|
||||
- Restore the `ec2-imdsv1` category for EC2 IMDS checks to keep Attack Surface and findings filters aligned [(#10998)](https://github.com/prowler-cloud/prowler/pull/10998)
|
||||
- Container image CVE findings and IaC findings now use official CVE, Prowler Hub, or GitHub Security Advisory URLs instead of Aqua advisory URLs in remediation and references; Trivy rule IDs map to Prowler Hub without the `AVD-` prefix so links resolve [(#10853)](https://github.com/prowler-cloud/prowler/pull/10853)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
@@ -32,11 +51,13 @@ All notable changes to the **Prowler SDK** are documented in this file.
|
||||
- Parser-mismatch SSRF in image provider registry auth where crafted bearer-token realms and pagination links could force requests to internal addresses and leak credentials cross-origin [(#10945)](https://github.com/prowler-cloud/prowler/pull/10945)
|
||||
- `cryptography` from 46.0.6 to 46.0.7 and `trivy` binary from 0.69.2 to 0.70.0 in the SDK image for CVE-2026-39892 and CVE-2026-33186 [(#10978)](https://github.com/prowler-cloud/prowler/pull/10978)
|
||||
|
||||
## [5.25.3] (Prowler UNRELEASED)
|
||||
---
|
||||
|
||||
## [5.25.3] (Prowler v5.25.3)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- Oracle cloud identity scans now scan known or supplied regions to better support non ashburn tenancies [(#10529)](https://github.com/prowler-cloud/prowler/pull/10529)
|
||||
- Oracle Cloud identity scans known or supplied regions to better support non Ashburn tenancies [(#10529)](https://github.com/prowler-cloud/prowler/pull/10529)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -6473,6 +6473,7 @@
|
||||
"backup_recovery_point_encrypted",
|
||||
"backup_vaults_encrypted",
|
||||
"bedrock_model_invocation_logs_encryption_enabled",
|
||||
"bedrock_prompt_encrypted_with_cmk",
|
||||
"cloudfront_distributions_field_level_encryption_enabled",
|
||||
"cloudfront_distributions_origin_traffic_encrypted",
|
||||
"cloudtrail_kms_encryption_enabled",
|
||||
@@ -6730,6 +6731,7 @@
|
||||
"backup_recovery_point_encrypted",
|
||||
"backup_vaults_encrypted",
|
||||
"bedrock_model_invocation_logs_encryption_enabled",
|
||||
"bedrock_prompt_encrypted_with_cmk",
|
||||
"cloudfront_distributions_field_level_encryption_enabled",
|
||||
"cloudfront_distributions_origin_traffic_encrypted",
|
||||
"cloudtrail_kms_encryption_enabled",
|
||||
|
||||
@@ -1311,6 +1311,7 @@
|
||||
"glue_development_endpoints_job_bookmark_encryption_enabled",
|
||||
"glue_ml_transform_encrypted_at_rest",
|
||||
"bedrock_model_invocation_logs_encryption_enabled",
|
||||
"bedrock_prompt_encrypted_with_cmk",
|
||||
"codebuild_project_s3_logs_encrypted",
|
||||
"codebuild_report_group_export_encrypted"
|
||||
]
|
||||
|
||||
@@ -1767,6 +1767,7 @@
|
||||
"backup_recovery_point_encrypted",
|
||||
"backup_vaults_encrypted",
|
||||
"bedrock_model_invocation_logs_encryption_enabled",
|
||||
"bedrock_prompt_encrypted_with_cmk",
|
||||
"cloudfront_distributions_field_level_encryption_enabled",
|
||||
"cloudfront_distributions_origin_traffic_encrypted",
|
||||
"cloudtrail_kms_encryption_enabled",
|
||||
|
||||
@@ -2115,6 +2115,7 @@
|
||||
"Checks": [
|
||||
"backup_vaults_encrypted",
|
||||
"bedrock_model_invocation_logs_encryption_enabled",
|
||||
"bedrock_prompt_encrypted_with_cmk",
|
||||
"cloudtrail_kms_encryption_enabled",
|
||||
"cloudwatch_log_group_kms_encryption_enabled",
|
||||
"dynamodb_tables_kms_cmk_encryption_enabled",
|
||||
|
||||
@@ -2117,6 +2117,7 @@
|
||||
"Checks": [
|
||||
"backup_vaults_encrypted",
|
||||
"bedrock_model_invocation_logs_encryption_enabled",
|
||||
"bedrock_prompt_encrypted_with_cmk",
|
||||
"cloudtrail_kms_encryption_enabled",
|
||||
"cloudwatch_log_group_kms_encryption_enabled",
|
||||
"dynamodb_tables_kms_cmk_encryption_enabled",
|
||||
|
||||
@@ -903,6 +903,7 @@
|
||||
"Checks": [
|
||||
"backup_vaults_encrypted",
|
||||
"backup_recovery_point_encrypted",
|
||||
"bedrock_prompt_encrypted_with_cmk",
|
||||
"cloudtrail_kms_encryption_enabled",
|
||||
"cloudwatch_log_group_kms_encryption_enabled",
|
||||
"s3_bucket_kms_encryption",
|
||||
|
||||
@@ -653,7 +653,9 @@
|
||||
{
|
||||
"Id": "3.1.3.4.1.1",
|
||||
"Description": "Ensure protection against encrypted attachments from untrusted senders is enabled",
|
||||
"Checks": [],
|
||||
"Checks": [
|
||||
"gmail_encrypted_attachment_protection_enabled"
|
||||
],
|
||||
"Attributes": [
|
||||
{
|
||||
"Section": "3 Apps",
|
||||
@@ -674,7 +676,9 @@
|
||||
{
|
||||
"Id": "3.1.3.4.1.2",
|
||||
"Description": "Ensure protection against attachments with scripts from untrusted senders is enabled",
|
||||
"Checks": [],
|
||||
"Checks": [
|
||||
"gmail_script_attachment_protection_enabled"
|
||||
],
|
||||
"Attributes": [
|
||||
{
|
||||
"Section": "3 Apps",
|
||||
@@ -695,7 +699,9 @@
|
||||
{
|
||||
"Id": "3.1.3.4.1.3",
|
||||
"Description": "Ensure protection against anomalous attachment types in emails is enabled",
|
||||
"Checks": [],
|
||||
"Checks": [
|
||||
"gmail_anomalous_attachment_protection_enabled"
|
||||
],
|
||||
"Attributes": [
|
||||
{
|
||||
"Section": "3 Apps",
|
||||
@@ -785,7 +791,9 @@
|
||||
{
|
||||
"Id": "3.1.3.4.3.1",
|
||||
"Description": "Ensure protection against domain spoofing based on similar domain names is enabled",
|
||||
"Checks": [],
|
||||
"Checks": [
|
||||
"gmail_domain_spoofing_protection_enabled"
|
||||
],
|
||||
"Attributes": [
|
||||
{
|
||||
"Section": "3 Apps",
|
||||
@@ -806,7 +814,9 @@
|
||||
{
|
||||
"Id": "3.1.3.4.3.2",
|
||||
"Description": "Ensure protection against spoofing of employee names is enabled",
|
||||
"Checks": [],
|
||||
"Checks": [
|
||||
"gmail_employee_name_spoofing_protection_enabled"
|
||||
],
|
||||
"Attributes": [
|
||||
{
|
||||
"Section": "3 Apps",
|
||||
@@ -827,7 +837,9 @@
|
||||
{
|
||||
"Id": "3.1.3.4.3.3",
|
||||
"Description": "Ensure protection against inbound emails spoofing your domain is enabled",
|
||||
"Checks": [],
|
||||
"Checks": [
|
||||
"gmail_inbound_domain_spoofing_protection_enabled"
|
||||
],
|
||||
"Attributes": [
|
||||
{
|
||||
"Section": "3 Apps",
|
||||
@@ -848,7 +860,9 @@
|
||||
{
|
||||
"Id": "3.1.3.4.3.4",
|
||||
"Description": "Ensure protection against any unauthenticated emails is enabled",
|
||||
"Checks": [],
|
||||
"Checks": [
|
||||
"gmail_unauthenticated_email_protection_enabled"
|
||||
],
|
||||
"Attributes": [
|
||||
{
|
||||
"Section": "3 Apps",
|
||||
@@ -869,7 +883,9 @@
|
||||
{
|
||||
"Id": "3.1.3.4.3.5",
|
||||
"Description": "Ensure groups are protected from inbound emails spoofing your domain",
|
||||
"Checks": [],
|
||||
"Checks": [
|
||||
"gmail_groups_spoofing_protection_enabled"
|
||||
],
|
||||
"Attributes": [
|
||||
{
|
||||
"Section": "3 Apps",
|
||||
|
||||
@@ -649,7 +649,9 @@
|
||||
{
|
||||
"Id": "GWS.GMAIL.5.1",
|
||||
"Description": "Protect against encrypted attachments from untrusted senders SHALL be enabled",
|
||||
"Checks": [],
|
||||
"Checks": [
|
||||
"gmail_encrypted_attachment_protection_enabled"
|
||||
],
|
||||
"Attributes": [
|
||||
{
|
||||
"Section": "Gmail",
|
||||
@@ -662,7 +664,9 @@
|
||||
{
|
||||
"Id": "GWS.GMAIL.5.2",
|
||||
"Description": "Protect against attachments with scripts from untrusted senders SHALL be enabled",
|
||||
"Checks": [],
|
||||
"Checks": [
|
||||
"gmail_script_attachment_protection_enabled"
|
||||
],
|
||||
"Attributes": [
|
||||
{
|
||||
"Section": "Gmail",
|
||||
@@ -675,7 +679,9 @@
|
||||
{
|
||||
"Id": "GWS.GMAIL.5.3",
|
||||
"Description": "Protect against anomalous attachment types in emails SHALL be enabled",
|
||||
"Checks": [],
|
||||
"Checks": [
|
||||
"gmail_anomalous_attachment_protection_enabled"
|
||||
],
|
||||
"Attributes": [
|
||||
{
|
||||
"Section": "Gmail",
|
||||
@@ -798,7 +804,9 @@
|
||||
{
|
||||
"Id": "GWS.GMAIL.7.1",
|
||||
"Description": "Protect against domain spoofing based on similar domain names SHALL be enabled",
|
||||
"Checks": [],
|
||||
"Checks": [
|
||||
"gmail_domain_spoofing_protection_enabled"
|
||||
],
|
||||
"Attributes": [
|
||||
{
|
||||
"Section": "Gmail",
|
||||
@@ -811,7 +819,9 @@
|
||||
{
|
||||
"Id": "GWS.GMAIL.7.2",
|
||||
"Description": "Protect against spoofing of employee names SHALL be enabled",
|
||||
"Checks": [],
|
||||
"Checks": [
|
||||
"gmail_employee_name_spoofing_protection_enabled"
|
||||
],
|
||||
"Attributes": [
|
||||
{
|
||||
"Section": "Gmail",
|
||||
@@ -824,7 +834,9 @@
|
||||
{
|
||||
"Id": "GWS.GMAIL.7.3",
|
||||
"Description": "Protect against inbound emails spoofing your domain SHALL be enabled",
|
||||
"Checks": [],
|
||||
"Checks": [
|
||||
"gmail_inbound_domain_spoofing_protection_enabled"
|
||||
],
|
||||
"Attributes": [
|
||||
{
|
||||
"Section": "Gmail",
|
||||
@@ -837,7 +849,9 @@
|
||||
{
|
||||
"Id": "GWS.GMAIL.7.4",
|
||||
"Description": "Protect against any unauthenticated emails SHALL be enabled",
|
||||
"Checks": [],
|
||||
"Checks": [
|
||||
"gmail_unauthenticated_email_protection_enabled"
|
||||
],
|
||||
"Attributes": [
|
||||
{
|
||||
"Section": "Gmail",
|
||||
@@ -850,7 +864,9 @@
|
||||
{
|
||||
"Id": "GWS.GMAIL.7.5",
|
||||
"Description": "Protect your Groups from inbound emails spoofing your domain SHALL be enabled",
|
||||
"Checks": [],
|
||||
"Checks": [
|
||||
"gmail_groups_spoofing_protection_enabled"
|
||||
],
|
||||
"Attributes": [
|
||||
{
|
||||
"Section": "Gmail",
|
||||
|
||||
@@ -48,7 +48,7 @@ class _MutableTimestamp:
|
||||
|
||||
timestamp = _MutableTimestamp(datetime.today())
|
||||
timestamp_utc = _MutableTimestamp(datetime.now(timezone.utc))
|
||||
prowler_version = "5.26.0"
|
||||
prowler_version = "5.26.2"
|
||||
html_logo_url = "https://github.com/prowler-cloud/prowler/"
|
||||
square_logo_img = "https://raw.githubusercontent.com/prowler-cloud/prowler/dc7d2d5aeb92fdf12e8604f42ef6472cd3e8e889/docs/img/prowler-logo-black.png"
|
||||
aws_logo = "https://user-images.githubusercontent.com/38561120/235953920-3e3fba08-0795-41dc-b480-9bea57db9f2e.png"
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import re
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
AQUA_REFERENCE_HOST = "avd.aquasec.com"
|
||||
GITHUB_ADVISORY_URL = "https://github.com/advisories/{advisory_id}"
|
||||
PROWLER_HUB_CHECK_URL = "https://hub.prowler.com/check/{check_id}"
|
||||
_CVE_ID_PATTERN = re.compile(r"^CVE-\d{4}-\d+$", re.IGNORECASE)
|
||||
_GHSA_ID_PATTERN = re.compile(r"^GHSA(?:-[a-z0-9]{4}){3}$", re.IGNORECASE)
|
||||
|
||||
|
||||
def _dedupe_preserve_order(urls: list[str]) -> list[str]:
|
||||
seen: set[str] = set()
|
||||
ordered_urls: list[str] = []
|
||||
|
||||
for url in urls:
|
||||
if not url or not url.strip():
|
||||
continue
|
||||
|
||||
normalized_url = url.strip()
|
||||
if normalized_url in seen:
|
||||
continue
|
||||
|
||||
seen.add(normalized_url)
|
||||
ordered_urls.append(normalized_url)
|
||||
|
||||
return ordered_urls
|
||||
|
||||
|
||||
def _is_aqua_reference(url: str) -> bool:
|
||||
return AQUA_REFERENCE_HOST in urlparse(url).netloc.lower()
|
||||
|
||||
|
||||
def _build_cve_org_url(vulnerability_id: str) -> str:
|
||||
return f"https://www.cve.org/CVERecord?id={vulnerability_id.upper()}"
|
||||
|
||||
|
||||
def build_finding_reference_url(finding_id: str) -> str:
|
||||
"""Map a Trivy finding ID to a stable, real reference URL.
|
||||
|
||||
- CVE-XXXX-NNNN → cve.org record
|
||||
- GHSA-… → github.com/advisories
|
||||
- everything else → hub.prowler.com/check/<id>, stripping a leading
|
||||
"AVD-" prefix because Prowler Hub indexes Trivy rules by the
|
||||
non-prefixed ID (e.g., "AWS-0001" not "AVD-AWS-0001").
|
||||
"""
|
||||
normalized = finding_id.strip().upper()
|
||||
if _CVE_ID_PATTERN.match(normalized):
|
||||
return _build_cve_org_url(normalized)
|
||||
if _GHSA_ID_PATTERN.match(normalized):
|
||||
return GITHUB_ADVISORY_URL.format(advisory_id=normalized)
|
||||
hub_id = normalized[4:] if normalized.startswith("AVD-") else normalized
|
||||
return PROWLER_HUB_CHECK_URL.format(check_id=hub_id)
|
||||
|
||||
|
||||
def _is_cve_org_url(url: str, vulnerability_id: str) -> bool:
|
||||
parsed_url = urlparse(url)
|
||||
if parsed_url.netloc.lower() != "www.cve.org":
|
||||
return False
|
||||
|
||||
query_value = parse_qs(parsed_url.query).get("id", [""])[0]
|
||||
return query_value.upper() == vulnerability_id.upper()
|
||||
|
||||
|
||||
def resolve_vulnerability_reference_urls(
|
||||
vulnerability_id: str,
|
||||
references: list[str] | None = None,
|
||||
primary_url: str = "",
|
||||
) -> tuple[str, list[str]]:
|
||||
"""Resolve non-Aqua vulnerability URLs, prioritizing official CVE destinations."""
|
||||
|
||||
candidate_urls = list(references or [])
|
||||
if primary_url and primary_url not in candidate_urls:
|
||||
candidate_urls.append(primary_url)
|
||||
|
||||
filtered_urls = _dedupe_preserve_order(
|
||||
[url for url in candidate_urls if not _is_aqua_reference(url)]
|
||||
)
|
||||
|
||||
if not _CVE_ID_PATTERN.match(vulnerability_id):
|
||||
return "", filtered_urls
|
||||
|
||||
cve_org_urls = [
|
||||
url for url in filtered_urls if _is_cve_org_url(url, vulnerability_id)
|
||||
]
|
||||
|
||||
recommendation_url = (
|
||||
cve_org_urls[0] if cve_org_urls else _build_cve_org_url(vulnerability_id)
|
||||
)
|
||||
|
||||
return recommendation_url, [recommendation_url]
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"Provider": "aws",
|
||||
"CheckID": "bedrock_prompt_encrypted_with_cmk",
|
||||
"CheckTitle": "Amazon Bedrock prompt is encrypted at rest with a customer-managed KMS key",
|
||||
"CheckType": [
|
||||
"Software and Configuration Checks/AWS Security Best Practices"
|
||||
],
|
||||
"ServiceName": "bedrock",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "Other",
|
||||
"ResourceGroup": "ai_ml",
|
||||
"Description": "Bedrock prompts should be encrypted at rest with a **customer-managed KMS key (CMK)** rather than the AWS-owned default key. Prompts can contain sensitive instructions, business logic, and references to downstream tooling that warrant tenant-controlled key material and auditable access via AWS KMS.",
|
||||
"Risk": "A prompt encrypted only with the AWS-owned default key offers limited tenant control over key access and lifecycle: no customer KMS key policy to govern decrypt permissions, no control over rotation cadence or scheduled deletion, and gaps against frameworks (ISO 27001 A.8.24, NIST CSF PR.DS, KISA-ISMS-P 2.7.2) that require customer-managed keys for sensitive data at rest.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://docs.aws.amazon.com/bedrock/latest/userguide/prompt-management.html",
|
||||
"https://docs.aws.amazon.com/bedrock/latest/APIReference/API_agent_CreatePrompt.html",
|
||||
"https://docs.aws.amazon.com/bedrock/latest/APIReference/API_agent_UpdatePrompt.html"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "# Retrieve the current DRAFT prompt first and note the existing fields you want to preserve, such as description, defaultVariant, and variants:\naws bedrock-agent get-prompt --prompt-identifier <prompt_id> --prompt-version DRAFT --output json\n# Then update the prompt and include the existing fields you want to keep alongside the CMK change:\naws bedrock-agent update-prompt --prompt-identifier <prompt_id> --name <prompt_name> --description <current_or_new_description> --default-variant <current_default_variant> --variants <current_or_updated_variants_json> --customer-encryption-key-arn <kms_key_arn>",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Open the Amazon Bedrock console\n2. Navigate to Prompt management\n3. Select the prompt\n4. Edit the prompt and choose a customer-managed KMS key for encryption\n5. Save the prompt",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Encrypt every Bedrock prompt with a **customer-managed KMS key** to retain control over key access, rotation, and lifecycle. When using `update-prompt`, first retrieve the current draft and carry forward the fields you want to preserve, such as the existing description, `defaultVariant`, and `variants`, so the encryption change does not unintentionally overwrite prompt configuration.",
|
||||
"Url": "https://hub.prowler.com/check/bedrock_prompt_encrypted_with_cmk"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"gen-ai",
|
||||
"encryption"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [
|
||||
"bedrock_prompt_management_exists"
|
||||
],
|
||||
"Notes": ""
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
from prowler.lib.check.models import Check, Check_Report_AWS
|
||||
from prowler.providers.aws.services.bedrock.bedrock_agent_client import (
|
||||
bedrock_agent_client,
|
||||
)
|
||||
|
||||
|
||||
class bedrock_prompt_encrypted_with_cmk(Check):
|
||||
"""Ensure that Bedrock prompts are encrypted with a customer-managed KMS key.
|
||||
|
||||
This check evaluates whether each Bedrock prompt is encrypted at rest using
|
||||
a customer-managed KMS key (CMK) rather than the AWS-owned default key.
|
||||
- PASS: The Bedrock prompt is encrypted with a customer-managed KMS key.
|
||||
- FAIL: The Bedrock prompt is not encrypted with a customer-managed KMS key.
|
||||
"""
|
||||
|
||||
def execute(self) -> list[Check_Report_AWS]:
|
||||
"""Execute the Bedrock prompt CMK encryption check.
|
||||
|
||||
Returns:
|
||||
A list of reports containing the result of the check.
|
||||
"""
|
||||
findings = []
|
||||
for prompt in bedrock_agent_client.prompts.values():
|
||||
report = Check_Report_AWS(metadata=self.metadata(), resource=prompt)
|
||||
if prompt.customer_encryption_key_arn:
|
||||
report.status = "PASS"
|
||||
report.status_extended = f"Bedrock Prompt {prompt.name} is encrypted with a customer-managed KMS key."
|
||||
else:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = f"Bedrock Prompt {prompt.name} is not encrypted with a customer-managed KMS key."
|
||||
findings.append(report)
|
||||
return findings
|
||||
+3
-1
@@ -34,6 +34,8 @@
|
||||
"gen-ai"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"RelatedTo": [
|
||||
"bedrock_prompt_encrypted_with_cmk"
|
||||
],
|
||||
"Notes": "Results are generated per scanned region. Regions where `ListPrompts` cannot be queried are omitted from the findings."
|
||||
}
|
||||
|
||||
@@ -136,7 +136,10 @@ class Guardrail(BaseModel):
|
||||
|
||||
|
||||
class BedrockAgent(AWSService):
|
||||
"""Bedrock Agent service class for managing agents and prompts."""
|
||||
|
||||
def __init__(self, provider):
|
||||
"""Initialize the BedrockAgent service."""
|
||||
# Call AWSService's __init__
|
||||
super().__init__("bedrock-agent", provider)
|
||||
self.agents = {}
|
||||
@@ -144,6 +147,7 @@ class BedrockAgent(AWSService):
|
||||
self.prompt_scanned_regions: set = set()
|
||||
self.__threading_call__(self._list_agents)
|
||||
self.__threading_call__(self._list_prompts)
|
||||
self.__threading_call__(self._get_prompt, self.prompts.values())
|
||||
self.__threading_call__(self._list_tags_for_resource, self.agents.values())
|
||||
|
||||
def _list_agents(self, regional_client):
|
||||
@@ -171,29 +175,43 @@ class BedrockAgent(AWSService):
|
||||
)
|
||||
|
||||
def _list_prompts(self, regional_client):
|
||||
"""List all prompts in a region.
|
||||
|
||||
Prompt Management is evaluated as a region-level adoption signal, so
|
||||
prompt collection is intentionally not filtered by audit_resources.
|
||||
"""
|
||||
"""List all prompts in a region."""
|
||||
logger.info("Bedrock Agent - Listing Prompts...")
|
||||
try:
|
||||
paginator = regional_client.get_paginator("list_prompts")
|
||||
for page in paginator.paginate():
|
||||
for prompt in page.get("promptSummaries", []):
|
||||
prompt_arn = prompt.get("arn", "")
|
||||
self.prompts[prompt_arn] = Prompt(
|
||||
id=prompt.get("id", ""),
|
||||
name=prompt.get("name", ""),
|
||||
arn=prompt_arn,
|
||||
region=regional_client.region,
|
||||
)
|
||||
if not self.audit_resources or (
|
||||
is_resource_filtered(prompt_arn, self.audit_resources)
|
||||
):
|
||||
self.prompts[prompt_arn] = Prompt(
|
||||
id=prompt.get("id", ""),
|
||||
name=prompt.get("name", ""),
|
||||
arn=prompt_arn,
|
||||
region=regional_client.region,
|
||||
)
|
||||
self.prompt_scanned_regions.add(regional_client.region)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
|
||||
def _get_prompt(self, prompt):
|
||||
"""Get detailed prompt information including encryption configuration."""
|
||||
logger.info("Bedrock Agent - Getting Prompt...")
|
||||
try:
|
||||
prompt_info = self.regional_clients[prompt.region].get_prompt(
|
||||
promptIdentifier=prompt.id
|
||||
)
|
||||
prompt.customer_encryption_key_arn = prompt_info.get(
|
||||
"customerEncryptionKeyArn"
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{prompt.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
|
||||
def _list_tags_for_resource(self, resource):
|
||||
"""List tags for a Bedrock Agent resource."""
|
||||
logger.info("Bedrock Agent - Listing Tags for Resource...")
|
||||
@@ -212,6 +230,8 @@ class BedrockAgent(AWSService):
|
||||
|
||||
|
||||
class Agent(BaseModel):
|
||||
"""Model for a Bedrock Agent resource."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
arn: str
|
||||
@@ -227,3 +247,4 @@ class Prompt(BaseModel):
|
||||
name: str
|
||||
arn: str
|
||||
region: str
|
||||
customer_encryption_key_arn: Optional[str] = None
|
||||
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"Provider": "googleworkspace",
|
||||
"CheckID": "gmail_anomalous_attachment_protection_enabled",
|
||||
"CheckTitle": "Protection against anomalous attachment types in emails is enabled",
|
||||
"CheckType": [],
|
||||
"ServiceName": "gmail",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "NotDefined",
|
||||
"ResourceGroup": "collaboration",
|
||||
"Description": "Verifies that Gmail is configured to take a protective action (such as moving to spam, quarantining, or showing a warning) when emails contain anomalous attachment types. Unusual file types that are uncommon for the sender or organization may indicate an attempt to deliver malware through less-scrutinized formats.",
|
||||
"Risk": "Without protection against anomalous attachment types, users may receive **emails with unusual file formats** that are designed to bypass standard security filters. Attackers may use **uncommon file extensions or MIME types** to deliver malware that evades signature-based detection.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://support.google.com/a/answer/7676854",
|
||||
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Sign in to the Google **Admin console** at https://admin.google.com\n2. Navigate to **Apps** > **Google Workspace** > **Gmail**\n3. Click **Safety** > **Attachments**\n4. Check **Protect against anomalous attachment types in emails**\n5. Select the desired action (e.g., Move email to spam)\n6. Click **Save**",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Enable protection against anomalous attachment types in emails and configure an appropriate action such as moving to spam or quarantining.",
|
||||
"Url": "https://hub.prowler.com/check/gmail_anomalous_attachment_protection_enabled"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"email-security"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [
|
||||
"gmail_encrypted_attachment_protection_enabled",
|
||||
"gmail_script_attachment_protection_enabled"
|
||||
],
|
||||
"Notes": ""
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
from typing import List
|
||||
|
||||
from prowler.lib.check.models import Check, CheckReportGoogleWorkspace
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_client import gmail_client
|
||||
|
||||
|
||||
class gmail_anomalous_attachment_protection_enabled(Check):
|
||||
"""Check that protection against anomalous attachment types in emails is enabled.
|
||||
|
||||
This check verifies that Gmail is configured to take action on
|
||||
emails containing unusual attachment types, helping prevent
|
||||
malware delivery via uncommon file formats.
|
||||
"""
|
||||
|
||||
def execute(self) -> List[CheckReportGoogleWorkspace]:
|
||||
findings = []
|
||||
|
||||
if gmail_client.policies_fetched:
|
||||
report = CheckReportGoogleWorkspace(
|
||||
metadata=self.metadata(),
|
||||
resource=gmail_client.provider.domain_resource,
|
||||
)
|
||||
|
||||
enabled = gmail_client.policies.enable_anomalous_attachment_protection
|
||||
consequence = (
|
||||
gmail_client.policies.anomalous_attachment_protection_consequence
|
||||
)
|
||||
|
||||
if enabled is False:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Protection against anomalous attachment types in emails "
|
||||
f"is disabled in domain "
|
||||
f"{gmail_client.provider.identity.domain}. "
|
||||
f"Enable the protection and configure a protective action."
|
||||
)
|
||||
elif enabled is None:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Protection against anomalous attachment types in emails "
|
||||
f"is not configured and uses Google's insecure default "
|
||||
f"(disabled) in domain "
|
||||
f"{gmail_client.provider.identity.domain}. "
|
||||
f"Enable the protection and configure a protective action."
|
||||
)
|
||||
elif consequence == "NO_ACTION":
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Protection against anomalous attachment types in emails "
|
||||
f"is set to take no action in domain "
|
||||
f"{gmail_client.provider.identity.domain}. "
|
||||
f"A protective action should be configured."
|
||||
)
|
||||
elif consequence is None:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Protection against anomalous attachment types in emails "
|
||||
f"is enabled in domain "
|
||||
f"{gmail_client.provider.identity.domain}."
|
||||
)
|
||||
else:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Protection against anomalous attachment types in emails "
|
||||
f"is enabled with consequence '{consequence}' "
|
||||
f"in domain {gmail_client.provider.identity.domain}."
|
||||
)
|
||||
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"Provider": "googleworkspace",
|
||||
"CheckID": "gmail_domain_spoofing_protection_enabled",
|
||||
"CheckTitle": "Protection against domain spoofing based on similar domain names is enabled",
|
||||
"CheckType": [],
|
||||
"ServiceName": "gmail",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "NotDefined",
|
||||
"ResourceGroup": "collaboration",
|
||||
"Description": "Verifies that Gmail is configured to take a protective action (such as moving to spam, quarantining, or showing a warning) when emails appear to come from domain names that look similar to the organization's domain. Lookalike domains are a common phishing technique used to trick users into trusting malicious messages.",
|
||||
"Risk": "Without protection against domain spoofing based on similar domain names, users may receive **phishing emails from lookalike domains** (e.g., examp1e.com instead of example.com) that appear legitimate. This enables **credential theft, malware delivery, and business email compromise** attacks.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://support.google.com/a/answer/9157861",
|
||||
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Sign in to the Google **Admin console** at https://admin.google.com\n2. Navigate to **Apps** > **Google Workspace** > **Gmail**\n3. Click **Safety** > **Spoofing and authentication**\n4. Check **Protect against domain spoofing based on similar domain names**\n5. Select the desired action (e.g., Move email to spam)\n6. Click **Save**",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Enable protection against domain spoofing based on similar domain names and configure an appropriate action such as moving to spam or quarantining.",
|
||||
"Url": "https://hub.prowler.com/check/gmail_domain_spoofing_protection_enabled"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"email-security"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [
|
||||
"gmail_employee_name_spoofing_protection_enabled",
|
||||
"gmail_inbound_domain_spoofing_protection_enabled",
|
||||
"gmail_unauthenticated_email_protection_enabled",
|
||||
"gmail_groups_spoofing_protection_enabled"
|
||||
],
|
||||
"Notes": ""
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
from typing import List
|
||||
|
||||
from prowler.lib.check.models import Check, CheckReportGoogleWorkspace
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_client import gmail_client
|
||||
|
||||
|
||||
class gmail_domain_spoofing_protection_enabled(Check):
|
||||
"""Check that protection against domain spoofing based on similar domain names is enabled.
|
||||
|
||||
This check verifies that Gmail is configured to take action on
|
||||
emails that appear to come from similar-looking domain names,
|
||||
helping prevent phishing via domain impersonation.
|
||||
"""
|
||||
|
||||
def execute(self) -> List[CheckReportGoogleWorkspace]:
|
||||
findings = []
|
||||
|
||||
if gmail_client.policies_fetched:
|
||||
report = CheckReportGoogleWorkspace(
|
||||
metadata=self.metadata(),
|
||||
resource=gmail_client.provider.domain_resource,
|
||||
)
|
||||
|
||||
enabled = gmail_client.policies.detect_domain_name_spoofing
|
||||
consequence = gmail_client.policies.domain_spoofing_consequence
|
||||
|
||||
if enabled is False:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Protection against domain spoofing based on similar "
|
||||
f"domain names is disabled in domain "
|
||||
f"{gmail_client.provider.identity.domain}. "
|
||||
f"Enable the protection and configure a protective action."
|
||||
)
|
||||
elif consequence == "NO_ACTION":
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Protection against domain spoofing based on similar "
|
||||
f"domain names is set to take no action in domain "
|
||||
f"{gmail_client.provider.identity.domain}. "
|
||||
f"A protective action should be configured."
|
||||
)
|
||||
elif consequence is None:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Protection against domain spoofing based on similar "
|
||||
f"domain names uses Google's secure default configuration "
|
||||
f"(enabled) in domain "
|
||||
f"{gmail_client.provider.identity.domain}."
|
||||
)
|
||||
else:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Protection against domain spoofing based on similar "
|
||||
f"domain names is enabled with consequence "
|
||||
f"'{consequence}' in domain "
|
||||
f"{gmail_client.provider.identity.domain}."
|
||||
)
|
||||
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"Provider": "googleworkspace",
|
||||
"CheckID": "gmail_employee_name_spoofing_protection_enabled",
|
||||
"CheckTitle": "Protection against spoofing of employee names is enabled",
|
||||
"CheckType": [],
|
||||
"ServiceName": "gmail",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "NotDefined",
|
||||
"ResourceGroup": "collaboration",
|
||||
"Description": "Verifies that Gmail is configured to take a protective action (such as moving to spam, quarantining, or showing a warning) when the sender's display name matches an employee's name but the email comes from an external address. This is a common social engineering technique where attackers impersonate colleagues or executives.",
|
||||
"Risk": "Without protection against employee name spoofing, users may receive **emails that appear to come from colleagues or executives** but are actually from external attackers. This enables **business email compromise (BEC)**, **wire fraud**, and **social engineering attacks** that exploit trust relationships.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://support.google.com/a/answer/9157861",
|
||||
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Sign in to the Google **Admin console** at https://admin.google.com\n2. Navigate to **Apps** > **Google Workspace** > **Gmail**\n3. Click **Safety** > **Spoofing and authentication**\n4. Check **Protect against spoofing of employee names**\n5. Select the desired action (e.g., Move email to spam)\n6. Click **Save**",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Enable protection against spoofing of employee names and configure an appropriate action such as moving to spam or quarantining.",
|
||||
"Url": "https://hub.prowler.com/check/gmail_employee_name_spoofing_protection_enabled"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"email-security"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [
|
||||
"gmail_domain_spoofing_protection_enabled",
|
||||
"gmail_inbound_domain_spoofing_protection_enabled",
|
||||
"gmail_unauthenticated_email_protection_enabled",
|
||||
"gmail_groups_spoofing_protection_enabled"
|
||||
],
|
||||
"Notes": ""
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
from typing import List
|
||||
|
||||
from prowler.lib.check.models import Check, CheckReportGoogleWorkspace
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_client import gmail_client
|
||||
|
||||
|
||||
class gmail_employee_name_spoofing_protection_enabled(Check):
|
||||
"""Check that protection against spoofing of employee names is enabled.
|
||||
|
||||
This check verifies that Gmail is configured to take action on
|
||||
emails where the sender name matches an employee name but comes
|
||||
from an external address, helping prevent social engineering attacks.
|
||||
"""
|
||||
|
||||
def execute(self) -> List[CheckReportGoogleWorkspace]:
|
||||
findings = []
|
||||
|
||||
if gmail_client.policies_fetched:
|
||||
report = CheckReportGoogleWorkspace(
|
||||
metadata=self.metadata(),
|
||||
resource=gmail_client.provider.domain_resource,
|
||||
)
|
||||
|
||||
enabled = gmail_client.policies.detect_employee_name_spoofing
|
||||
consequence = gmail_client.policies.employee_name_spoofing_consequence
|
||||
|
||||
if enabled is False:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Protection against spoofing of employee names is "
|
||||
f"disabled in domain "
|
||||
f"{gmail_client.provider.identity.domain}. "
|
||||
f"Enable the protection and configure a protective action."
|
||||
)
|
||||
elif consequence == "NO_ACTION":
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Protection against spoofing of employee names is set "
|
||||
f"to take no action in domain "
|
||||
f"{gmail_client.provider.identity.domain}. "
|
||||
f"A protective action should be configured."
|
||||
)
|
||||
elif consequence is None:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Protection against spoofing of employee names uses "
|
||||
f"Google's secure default configuration (enabled) "
|
||||
f"in domain {gmail_client.provider.identity.domain}."
|
||||
)
|
||||
else:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Protection against spoofing of employee names is "
|
||||
f"enabled with consequence '{consequence}' in domain "
|
||||
f"{gmail_client.provider.identity.domain}."
|
||||
)
|
||||
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"Provider": "googleworkspace",
|
||||
"CheckID": "gmail_encrypted_attachment_protection_enabled",
|
||||
"CheckTitle": "Protection against encrypted attachments from untrusted senders is enabled",
|
||||
"CheckType": [],
|
||||
"ServiceName": "gmail",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "NotDefined",
|
||||
"ResourceGroup": "collaboration",
|
||||
"Description": "Verifies that Gmail is configured to take a protective action (such as moving to spam, quarantining, or showing a warning) when an encrypted attachment is received from an untrusted sender. Encrypted attachments cannot be scanned for malware by security filters, making them a common vector for delivering malicious payloads.",
|
||||
"Risk": "Without protection against encrypted attachments from untrusted senders, users may receive **password-protected archives containing malware** that bypass standard content scanning. Attackers commonly use encrypted attachments to evade detection and deliver **ransomware, trojans, or other malicious payloads**.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://support.google.com/a/answer/7676854",
|
||||
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Sign in to the Google **Admin console** at https://admin.google.com\n2. Navigate to **Apps** > **Google Workspace** > **Gmail**\n3. Click **Safety** > **Attachments**\n4. Check **Protect against encrypted attachments from untrusted senders**\n5. Select the desired action (e.g., Move email to spam)\n6. Click **Save**",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Enable protection against encrypted attachments from untrusted senders and configure an appropriate action such as moving to spam or quarantining.",
|
||||
"Url": "https://hub.prowler.com/check/gmail_encrypted_attachment_protection_enabled"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"email-security"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [
|
||||
"gmail_script_attachment_protection_enabled",
|
||||
"gmail_anomalous_attachment_protection_enabled"
|
||||
],
|
||||
"Notes": ""
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
from typing import List
|
||||
|
||||
from prowler.lib.check.models import Check, CheckReportGoogleWorkspace
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_client import gmail_client
|
||||
|
||||
|
||||
class gmail_encrypted_attachment_protection_enabled(Check):
|
||||
"""Check that protection against encrypted attachments from untrusted senders is enabled.
|
||||
|
||||
This check verifies that Gmail is configured to take action on
|
||||
encrypted attachments from untrusted senders, helping prevent
|
||||
malware delivery via password-protected archives.
|
||||
"""
|
||||
|
||||
def execute(self) -> List[CheckReportGoogleWorkspace]:
|
||||
findings = []
|
||||
|
||||
if gmail_client.policies_fetched:
|
||||
report = CheckReportGoogleWorkspace(
|
||||
metadata=self.metadata(),
|
||||
resource=gmail_client.provider.domain_resource,
|
||||
)
|
||||
|
||||
enabled = gmail_client.policies.enable_encrypted_attachment_protection
|
||||
consequence = (
|
||||
gmail_client.policies.encrypted_attachment_protection_consequence
|
||||
)
|
||||
|
||||
if enabled is False:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Protection against encrypted attachments from untrusted "
|
||||
f"senders is disabled in domain "
|
||||
f"{gmail_client.provider.identity.domain}. "
|
||||
f"Enable the protection and configure a protective action."
|
||||
)
|
||||
elif consequence == "NO_ACTION":
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Protection against encrypted attachments from untrusted "
|
||||
f"senders is set to take no action in domain "
|
||||
f"{gmail_client.provider.identity.domain}. "
|
||||
f"A protective action should be configured."
|
||||
)
|
||||
elif consequence is None:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Protection against encrypted attachments from untrusted "
|
||||
f"senders uses Google's secure default configuration "
|
||||
f"(enabled) in domain "
|
||||
f"{gmail_client.provider.identity.domain}."
|
||||
)
|
||||
else:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Protection against encrypted attachments from untrusted "
|
||||
f"senders is enabled with consequence '{consequence}' "
|
||||
f"in domain {gmail_client.provider.identity.domain}."
|
||||
)
|
||||
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"Provider": "googleworkspace",
|
||||
"CheckID": "gmail_groups_spoofing_protection_enabled",
|
||||
"CheckTitle": "Groups are protected from inbound emails spoofing your domain",
|
||||
"CheckType": [],
|
||||
"ServiceName": "gmail",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "NotDefined",
|
||||
"ResourceGroup": "collaboration",
|
||||
"Description": "Verifies that Gmail is configured to take a protective action (such as moving to spam, quarantining, or showing a warning) when groups receive inbound emails that spoof the organization's domain. Google Groups are a high-value target because a single spoofed message can reach many recipients at once.",
|
||||
"Risk": "Without protection of groups from domain-spoofing emails, attackers can send **spoofed messages to group mailboxes** that appear to originate from the organization. Since groups distribute to many recipients, a single spoofed email can enable **mass phishing, social engineering, or misinformation** campaigns across the organization.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://support.google.com/a/answer/9157861",
|
||||
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Sign in to the Google **Admin console** at https://admin.google.com\n2. Navigate to **Apps** > **Google Workspace** > **Gmail**\n3. Click **Safety** > **Spoofing and authentication**\n4. Check **Protect your Groups from inbound emails spoofing your domain**\n5. Select the desired action (e.g., Move email to spam)\n6. Click **Save**",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Enable protection of groups from inbound emails spoofing your domain and configure an appropriate action such as moving to spam or quarantining.",
|
||||
"Url": "https://hub.prowler.com/check/gmail_groups_spoofing_protection_enabled"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"email-security"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [
|
||||
"gmail_domain_spoofing_protection_enabled",
|
||||
"gmail_employee_name_spoofing_protection_enabled",
|
||||
"gmail_inbound_domain_spoofing_protection_enabled",
|
||||
"gmail_unauthenticated_email_protection_enabled"
|
||||
],
|
||||
"Notes": ""
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
from typing import List
|
||||
|
||||
from prowler.lib.check.models import Check, CheckReportGoogleWorkspace
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_client import gmail_client
|
||||
|
||||
|
||||
class gmail_groups_spoofing_protection_enabled(Check):
|
||||
"""Check that groups are protected from inbound emails spoofing your domain.
|
||||
|
||||
This check verifies that Gmail is configured to take action on
|
||||
inbound emails to groups that spoof the organization's domain,
|
||||
helping prevent impersonation attacks targeting group mailboxes.
|
||||
"""
|
||||
|
||||
def execute(self) -> List[CheckReportGoogleWorkspace]:
|
||||
findings = []
|
||||
|
||||
if gmail_client.policies_fetched:
|
||||
report = CheckReportGoogleWorkspace(
|
||||
metadata=self.metadata(),
|
||||
resource=gmail_client.provider.domain_resource,
|
||||
)
|
||||
|
||||
enabled = gmail_client.policies.detect_groups_spoofing
|
||||
consequence = gmail_client.policies.groups_spoofing_consequence
|
||||
visibility_type = gmail_client.policies.groups_spoofing_visibility_type
|
||||
|
||||
if enabled is False:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Protection of groups from inbound emails spoofing your "
|
||||
f"domain is disabled in domain "
|
||||
f"{gmail_client.provider.identity.domain}. "
|
||||
f"Enable the protection and configure a protective action."
|
||||
)
|
||||
elif enabled is None:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Protection of groups from inbound emails spoofing your "
|
||||
f"domain is not configured and uses Google's insecure "
|
||||
f"default (disabled) in domain "
|
||||
f"{gmail_client.provider.identity.domain}. "
|
||||
f"Enable the protection and configure a protective action."
|
||||
)
|
||||
elif consequence == "NO_ACTION":
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Protection of groups from inbound emails spoofing your "
|
||||
f"domain is set to take no action in domain "
|
||||
f"{gmail_client.provider.identity.domain}. "
|
||||
f"A protective action should be configured."
|
||||
)
|
||||
elif consequence is None:
|
||||
report.status = "PASS"
|
||||
scope = (
|
||||
"private groups only"
|
||||
if visibility_type == "PRIVATE_GROUPS_ONLY"
|
||||
else "all groups"
|
||||
)
|
||||
report.status_extended = (
|
||||
f"Protection of groups from inbound emails spoofing your "
|
||||
f"domain is enabled for {scope} in domain "
|
||||
f"{gmail_client.provider.identity.domain}."
|
||||
)
|
||||
else:
|
||||
report.status = "PASS"
|
||||
scope = (
|
||||
"private groups only"
|
||||
if visibility_type == "PRIVATE_GROUPS_ONLY"
|
||||
else "all groups"
|
||||
)
|
||||
report.status_extended = (
|
||||
f"Protection of groups from inbound emails spoofing your "
|
||||
f"domain is enabled for {scope} with consequence "
|
||||
f"'{consequence}' in domain "
|
||||
f"{gmail_client.provider.identity.domain}."
|
||||
)
|
||||
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"Provider": "googleworkspace",
|
||||
"CheckID": "gmail_inbound_domain_spoofing_protection_enabled",
|
||||
"CheckTitle": "Protection against inbound emails spoofing your domain is enabled",
|
||||
"CheckType": [],
|
||||
"ServiceName": "gmail",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "NotDefined",
|
||||
"ResourceGroup": "collaboration",
|
||||
"Description": "Verifies that Gmail is configured to take a protective action (such as moving to spam, quarantining, or showing a warning) when inbound emails spoof the organization's own domain. This protects against attackers sending emails that appear to originate from within the organization but are actually external.",
|
||||
"Risk": "Without protection against inbound domain spoofing, users may receive **emails that appear to come from their own organization** but are sent by external attackers. This enables **internal impersonation**, **phishing**, and **business email compromise** attacks that exploit trust in internal communications.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://support.google.com/a/answer/9157861",
|
||||
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Sign in to the Google **Admin console** at https://admin.google.com\n2. Navigate to **Apps** > **Google Workspace** > **Gmail**\n3. Click **Safety** > **Spoofing and authentication**\n4. Check **Protect against inbound emails spoofing your domain**\n5. Select the desired action (e.g., Move email to spam)\n6. Click **Save**",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Enable protection against inbound emails spoofing your domain and configure an appropriate action such as moving to spam or quarantining.",
|
||||
"Url": "https://hub.prowler.com/check/gmail_inbound_domain_spoofing_protection_enabled"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"email-security"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [
|
||||
"gmail_domain_spoofing_protection_enabled",
|
||||
"gmail_employee_name_spoofing_protection_enabled",
|
||||
"gmail_unauthenticated_email_protection_enabled",
|
||||
"gmail_groups_spoofing_protection_enabled"
|
||||
],
|
||||
"Notes": ""
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
from typing import List
|
||||
|
||||
from prowler.lib.check.models import Check, CheckReportGoogleWorkspace
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_client import gmail_client
|
||||
|
||||
|
||||
class gmail_inbound_domain_spoofing_protection_enabled(Check):
|
||||
"""Check that protection against inbound emails spoofing your domain is enabled.
|
||||
|
||||
This check verifies that Gmail is configured to take action on
|
||||
inbound emails that spoof the organization's own domain, helping
|
||||
prevent impersonation of internal senders.
|
||||
"""
|
||||
|
||||
def execute(self) -> List[CheckReportGoogleWorkspace]:
|
||||
findings = []
|
||||
|
||||
if gmail_client.policies_fetched:
|
||||
report = CheckReportGoogleWorkspace(
|
||||
metadata=self.metadata(),
|
||||
resource=gmail_client.provider.domain_resource,
|
||||
)
|
||||
|
||||
enabled = gmail_client.policies.detect_inbound_domain_spoofing
|
||||
consequence = gmail_client.policies.inbound_domain_spoofing_consequence
|
||||
|
||||
if enabled is False:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Protection against inbound emails spoofing your domain "
|
||||
f"is disabled in domain "
|
||||
f"{gmail_client.provider.identity.domain}. "
|
||||
f"Enable the protection and configure a protective action."
|
||||
)
|
||||
elif consequence == "NO_ACTION":
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Protection against inbound emails spoofing your domain "
|
||||
f"is set to take no action in domain "
|
||||
f"{gmail_client.provider.identity.domain}. "
|
||||
f"A protective action should be configured."
|
||||
)
|
||||
elif consequence is None:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Protection against inbound emails spoofing your domain "
|
||||
f"uses Google's secure default configuration (enabled) "
|
||||
f"in domain {gmail_client.provider.identity.domain}."
|
||||
)
|
||||
else:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Protection against inbound emails spoofing your domain "
|
||||
f"is enabled with consequence '{consequence}' "
|
||||
f"in domain {gmail_client.provider.identity.domain}."
|
||||
)
|
||||
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"Provider": "googleworkspace",
|
||||
"CheckID": "gmail_script_attachment_protection_enabled",
|
||||
"CheckTitle": "Protection against attachments with scripts from untrusted senders is enabled",
|
||||
"CheckType": [],
|
||||
"ServiceName": "gmail",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "NotDefined",
|
||||
"ResourceGroup": "collaboration",
|
||||
"Description": "Verifies that Gmail is configured to take a protective action (such as moving to spam, quarantining, or showing a warning) when an attachment containing scripts is received from an untrusted sender. Script-bearing attachments (e.g., .js, .vbs, .ps1) are a common malware delivery mechanism.",
|
||||
"Risk": "Without protection against script-bearing attachments from untrusted senders, users may receive **files containing malicious scripts** that can execute harmful code when opened. Attackers commonly use script attachments to deliver **malware, backdoors, or credential stealers**.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://support.google.com/a/answer/7676854",
|
||||
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Sign in to the Google **Admin console** at https://admin.google.com\n2. Navigate to **Apps** > **Google Workspace** > **Gmail**\n3. Click **Safety** > **Attachments**\n4. Check **Protect against attachments with scripts from untrusted senders**\n5. Select the desired action (e.g., Move email to spam)\n6. Click **Save**",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Enable protection against attachments with scripts from untrusted senders and configure an appropriate action such as moving to spam or quarantining.",
|
||||
"Url": "https://hub.prowler.com/check/gmail_script_attachment_protection_enabled"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"email-security"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [
|
||||
"gmail_encrypted_attachment_protection_enabled",
|
||||
"gmail_anomalous_attachment_protection_enabled"
|
||||
],
|
||||
"Notes": ""
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
from typing import List
|
||||
|
||||
from prowler.lib.check.models import Check, CheckReportGoogleWorkspace
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_client import gmail_client
|
||||
|
||||
|
||||
class gmail_script_attachment_protection_enabled(Check):
|
||||
"""Check that protection against attachments with scripts from untrusted senders is enabled.
|
||||
|
||||
This check verifies that Gmail is configured to take action on
|
||||
attachments containing scripts from untrusted senders, helping
|
||||
prevent malware delivery via script-bearing files.
|
||||
"""
|
||||
|
||||
def execute(self) -> List[CheckReportGoogleWorkspace]:
|
||||
findings = []
|
||||
|
||||
if gmail_client.policies_fetched:
|
||||
report = CheckReportGoogleWorkspace(
|
||||
metadata=self.metadata(),
|
||||
resource=gmail_client.provider.domain_resource,
|
||||
)
|
||||
|
||||
enabled = gmail_client.policies.enable_script_attachment_protection
|
||||
consequence = gmail_client.policies.script_attachment_protection_consequence
|
||||
|
||||
if enabled is False:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Protection against attachments with scripts from "
|
||||
f"untrusted senders is disabled in domain "
|
||||
f"{gmail_client.provider.identity.domain}. "
|
||||
f"Enable the protection and configure a protective action."
|
||||
)
|
||||
elif consequence == "NO_ACTION":
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Protection against attachments with scripts from "
|
||||
f"untrusted senders is set to take no action in domain "
|
||||
f"{gmail_client.provider.identity.domain}. "
|
||||
f"A protective action should be configured."
|
||||
)
|
||||
elif consequence is None:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Protection against attachments with scripts from "
|
||||
f"untrusted senders uses Google's secure default "
|
||||
f"configuration (enabled) in domain "
|
||||
f"{gmail_client.provider.identity.domain}."
|
||||
)
|
||||
else:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Protection against attachments with scripts from "
|
||||
f"untrusted senders is enabled with consequence "
|
||||
f"'{consequence}' in domain "
|
||||
f"{gmail_client.provider.identity.domain}."
|
||||
)
|
||||
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
@@ -57,12 +57,21 @@ class Gmail(GoogleWorkspaceService):
|
||||
logger.debug("Gmail mail delegation setting fetched.")
|
||||
|
||||
elif setting_type == "gmail.email_attachment_safety":
|
||||
self.policies.enable_encrypted_attachment_protection = (
|
||||
value.get("enableEncryptedAttachmentProtection")
|
||||
)
|
||||
self.policies.encrypted_attachment_protection_consequence = value.get(
|
||||
"encryptedAttachmentProtectionConsequence"
|
||||
)
|
||||
self.policies.enable_script_attachment_protection = (
|
||||
value.get("enableAttachmentWithScriptsProtection")
|
||||
)
|
||||
self.policies.script_attachment_protection_consequence = (
|
||||
value.get("scriptAttachmentProtectionConsequence")
|
||||
)
|
||||
self.policies.enable_anomalous_attachment_protection = (
|
||||
value.get("enableAnomalousAttachmentProtection")
|
||||
)
|
||||
self.policies.anomalous_attachment_protection_consequence = value.get(
|
||||
"anomalousAttachmentProtectionConsequence"
|
||||
)
|
||||
@@ -83,18 +92,36 @@ class Gmail(GoogleWorkspaceService):
|
||||
)
|
||||
|
||||
elif setting_type == "gmail.spoofing_and_authentication":
|
||||
self.policies.detect_domain_name_spoofing = value.get(
|
||||
"detectDomainNameSpoofing"
|
||||
)
|
||||
self.policies.domain_spoofing_consequence = value.get(
|
||||
"domainSpoofingConsequence"
|
||||
)
|
||||
self.policies.detect_employee_name_spoofing = value.get(
|
||||
"detectEmployeeNameSpoofing"
|
||||
)
|
||||
self.policies.employee_name_spoofing_consequence = (
|
||||
value.get("employeeNameSpoofingConsequence")
|
||||
)
|
||||
self.policies.detect_inbound_domain_spoofing = value.get(
|
||||
"detectDomainSpoofingFromUnauthenticatedSenders"
|
||||
)
|
||||
self.policies.inbound_domain_spoofing_consequence = (
|
||||
value.get("inboundDomainSpoofingConsequence")
|
||||
)
|
||||
self.policies.detect_unauthenticated_emails = value.get(
|
||||
"detectUnauthenticatedEmails"
|
||||
)
|
||||
self.policies.unauthenticated_email_consequence = value.get(
|
||||
"unauthenticatedEmailConsequence"
|
||||
)
|
||||
self.policies.detect_groups_spoofing = value.get(
|
||||
"detectGroupsSpoofing"
|
||||
)
|
||||
self.policies.groups_spoofing_visibility_type = value.get(
|
||||
"groupsSpoofingVisibilityType"
|
||||
)
|
||||
self.policies.groups_spoofing_consequence = value.get(
|
||||
"groupsSpoofingConsequence"
|
||||
)
|
||||
@@ -177,8 +204,11 @@ class GmailPolicies(BaseModel):
|
||||
enable_mail_delegation: Optional[bool] = None
|
||||
|
||||
# gmail.email_attachment_safety
|
||||
enable_encrypted_attachment_protection: Optional[bool] = None
|
||||
encrypted_attachment_protection_consequence: Optional[str] = None
|
||||
enable_script_attachment_protection: Optional[bool] = None
|
||||
script_attachment_protection_consequence: Optional[str] = None
|
||||
enable_anomalous_attachment_protection: Optional[bool] = None
|
||||
anomalous_attachment_protection_consequence: Optional[str] = None
|
||||
|
||||
# gmail.links_and_external_images
|
||||
@@ -187,10 +217,16 @@ class GmailPolicies(BaseModel):
|
||||
enable_aggressive_warnings_on_untrusted_links: Optional[bool] = None
|
||||
|
||||
# gmail.spoofing_and_authentication
|
||||
detect_domain_name_spoofing: Optional[bool] = None
|
||||
domain_spoofing_consequence: Optional[str] = None
|
||||
detect_employee_name_spoofing: Optional[bool] = None
|
||||
employee_name_spoofing_consequence: Optional[str] = None
|
||||
detect_inbound_domain_spoofing: Optional[bool] = None
|
||||
inbound_domain_spoofing_consequence: Optional[str] = None
|
||||
detect_unauthenticated_emails: Optional[bool] = None
|
||||
unauthenticated_email_consequence: Optional[str] = None
|
||||
detect_groups_spoofing: Optional[bool] = None
|
||||
groups_spoofing_visibility_type: Optional[str] = None
|
||||
groups_spoofing_consequence: Optional[str] = None
|
||||
|
||||
# gmail.pop_access
|
||||
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"Provider": "googleworkspace",
|
||||
"CheckID": "gmail_unauthenticated_email_protection_enabled",
|
||||
"CheckTitle": "Protection against any unauthenticated emails is enabled",
|
||||
"CheckType": [],
|
||||
"ServiceName": "gmail",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "NotDefined",
|
||||
"ResourceGroup": "collaboration",
|
||||
"Description": "Verifies that Gmail is configured to take a protective action (such as moving to spam, quarantining, or showing a warning) when emails are not authenticated via SPF or DKIM. Unauthenticated emails cannot be verified as originating from the claimed sender, making them more likely to be spoofed or forged.",
|
||||
"Risk": "Without protection against unauthenticated emails, users may receive **spoofed or forged messages** that fail SPF and DKIM checks but are still delivered normally. This enables **phishing**, **spam**, and **impersonation attacks** that exploit the lack of sender verification.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://support.google.com/a/answer/9157861",
|
||||
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Sign in to the Google **Admin console** at https://admin.google.com\n2. Navigate to **Apps** > **Google Workspace** > **Gmail**\n3. Click **Safety** > **Spoofing and authentication**\n4. Check **Protect against any unauthenticated emails**\n5. Select the desired action (e.g., Move email to spam)\n6. Click **Save**",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Enable protection against any unauthenticated emails and configure an appropriate action such as moving to spam or quarantining.",
|
||||
"Url": "https://hub.prowler.com/check/gmail_unauthenticated_email_protection_enabled"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"email-security"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [
|
||||
"gmail_domain_spoofing_protection_enabled",
|
||||
"gmail_employee_name_spoofing_protection_enabled",
|
||||
"gmail_inbound_domain_spoofing_protection_enabled",
|
||||
"gmail_groups_spoofing_protection_enabled"
|
||||
],
|
||||
"Notes": ""
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
from typing import List
|
||||
|
||||
from prowler.lib.check.models import Check, CheckReportGoogleWorkspace
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_client import gmail_client
|
||||
|
||||
|
||||
class gmail_unauthenticated_email_protection_enabled(Check):
|
||||
"""Check that protection against any unauthenticated emails is enabled.
|
||||
|
||||
This check verifies that Gmail is configured to take action on
|
||||
emails that are not authenticated via SPF or DKIM, helping prevent
|
||||
delivery of spoofed or forged messages.
|
||||
"""
|
||||
|
||||
def execute(self) -> List[CheckReportGoogleWorkspace]:
|
||||
findings = []
|
||||
|
||||
if gmail_client.policies_fetched:
|
||||
report = CheckReportGoogleWorkspace(
|
||||
metadata=self.metadata(),
|
||||
resource=gmail_client.provider.domain_resource,
|
||||
)
|
||||
|
||||
enabled = gmail_client.policies.detect_unauthenticated_emails
|
||||
consequence = gmail_client.policies.unauthenticated_email_consequence
|
||||
|
||||
if enabled is False:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Protection against unauthenticated emails is disabled "
|
||||
f"in domain {gmail_client.provider.identity.domain}. "
|
||||
f"Enable the protection and configure a protective action."
|
||||
)
|
||||
elif enabled is None:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Protection against unauthenticated emails is not "
|
||||
f"configured and uses Google's insecure default "
|
||||
f"(disabled) in domain "
|
||||
f"{gmail_client.provider.identity.domain}. "
|
||||
f"Enable the protection and configure a protective action."
|
||||
)
|
||||
elif consequence == "NO_ACTION":
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Protection against unauthenticated emails is set to "
|
||||
f"take no action in domain "
|
||||
f"{gmail_client.provider.identity.domain}. "
|
||||
f"A protective action should be configured."
|
||||
)
|
||||
elif consequence is None:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Protection against unauthenticated emails is enabled "
|
||||
f"in domain {gmail_client.provider.identity.domain}."
|
||||
)
|
||||
else:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Protection against unauthenticated emails is enabled "
|
||||
f"with consequence '{consequence}' in domain "
|
||||
f"{gmail_client.provider.identity.domain}."
|
||||
)
|
||||
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
+6
-4
@@ -32,11 +32,13 @@ class gmail_untrusted_link_warnings_enabled(Check):
|
||||
f"in domain {gmail_client.provider.identity.domain}."
|
||||
)
|
||||
elif warnings_enabled is None:
|
||||
report.status = "PASS"
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Warning prompts for clicks on untrusted domain links uses Google's "
|
||||
f"secure default configuration (enabled) "
|
||||
f"in domain {gmail_client.provider.identity.domain}."
|
||||
f"Warning prompts for clicks on untrusted domain links "
|
||||
f"are not configured and use Google's insecure default "
|
||||
f"(disabled) in domain "
|
||||
f"{gmail_client.provider.identity.domain}. "
|
||||
f"Untrusted link warnings should be enabled to protect users."
|
||||
)
|
||||
else:
|
||||
report.status = "FAIL"
|
||||
|
||||
@@ -18,6 +18,10 @@ from prowler.config.config import (
|
||||
from prowler.lib.check.models import CheckReportIAC
|
||||
from prowler.lib.logger import logger
|
||||
from prowler.lib.utils.utils import print_boxes
|
||||
from prowler.lib.utils.vulnerability_references import (
|
||||
build_finding_reference_url,
|
||||
resolve_vulnerability_reference_urls,
|
||||
)
|
||||
from prowler.providers.common.models import Audit_Metadata, Connection
|
||||
from prowler.providers.common.provider import Provider
|
||||
|
||||
@@ -189,14 +193,28 @@ class IacProvider(Provider):
|
||||
finding_id = finding["VulnerabilityID"]
|
||||
finding_description = finding["Description"]
|
||||
finding_status = finding.get("Status", "FAIL")
|
||||
recommendation_url, additional_urls = (
|
||||
resolve_vulnerability_reference_urls(
|
||||
vulnerability_id=finding_id,
|
||||
references=finding.get("References"),
|
||||
primary_url=finding.get("PrimaryURL", ""),
|
||||
)
|
||||
)
|
||||
if not recommendation_url:
|
||||
recommendation_url = build_finding_reference_url(finding_id)
|
||||
additional_urls = [recommendation_url]
|
||||
elif "RuleID" in finding:
|
||||
finding_id = finding["RuleID"]
|
||||
finding_description = finding["Title"]
|
||||
finding_status = finding.get("Status", "FAIL")
|
||||
recommendation_url = build_finding_reference_url(finding_id)
|
||||
additional_urls = [recommendation_url]
|
||||
else:
|
||||
finding_id = finding["ID"]
|
||||
finding_description = finding["Description"]
|
||||
finding_status = finding["Status"]
|
||||
recommendation_url = build_finding_reference_url(finding_id)
|
||||
additional_urls = [recommendation_url]
|
||||
|
||||
metadata_dict = {
|
||||
"Provider": "iac",
|
||||
@@ -210,7 +228,7 @@ class IacProvider(Provider):
|
||||
"ResourceType": "iac",
|
||||
"Description": finding_description,
|
||||
"Risk": "This provider has not defined a risk for this check.",
|
||||
"RelatedUrl": finding.get("PrimaryURL", ""),
|
||||
"RelatedUrl": "",
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"NativeIaC": "",
|
||||
@@ -220,11 +238,11 @@ class IacProvider(Provider):
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": finding.get("Resolution", ""),
|
||||
"Url": finding.get("PrimaryURL", ""),
|
||||
"Url": recommendation_url,
|
||||
},
|
||||
},
|
||||
"Categories": [],
|
||||
"AdditionalURLs": [],
|
||||
"AdditionalURLs": additional_urls,
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": "",
|
||||
|
||||
@@ -18,6 +18,9 @@ from prowler.config.config import (
|
||||
from prowler.lib.check.models import CheckReportImage
|
||||
from prowler.lib.logger import logger
|
||||
from prowler.lib.utils.utils import print_boxes
|
||||
from prowler.lib.utils.vulnerability_references import (
|
||||
resolve_vulnerability_reference_urls,
|
||||
)
|
||||
from prowler.providers.common.models import Audit_Metadata, Connection
|
||||
from prowler.providers.common.provider import Provider
|
||||
from prowler.providers.image.exceptions.exceptions import (
|
||||
@@ -395,6 +398,8 @@ class ImageProvider(Provider):
|
||||
"""
|
||||
try:
|
||||
# Determine finding ID and category based on type
|
||||
recommendation_url = ""
|
||||
additional_urls: list[str] = []
|
||||
if "VulnerabilityID" in finding:
|
||||
finding_id = finding["VulnerabilityID"]
|
||||
finding_description = finding.get(
|
||||
@@ -402,17 +407,30 @@ class ImageProvider(Provider):
|
||||
)
|
||||
finding_status = "FAIL"
|
||||
finding_categories = ["vulnerabilities"]
|
||||
recommendation_url, additional_urls = (
|
||||
resolve_vulnerability_reference_urls(
|
||||
vulnerability_id=finding_id,
|
||||
references=finding.get("References"),
|
||||
primary_url=finding.get("PrimaryURL", ""),
|
||||
)
|
||||
)
|
||||
elif "RuleID" in finding:
|
||||
# Secret finding
|
||||
finding_id = finding["RuleID"]
|
||||
finding_description = finding.get("Title", "Secret detected")
|
||||
finding_status = "FAIL"
|
||||
finding_categories = ["secrets"]
|
||||
additional_urls = (
|
||||
[url] if (url := finding.get("PrimaryURL", "")) else []
|
||||
)
|
||||
else:
|
||||
finding_id = finding.get("ID", "UNKNOWN")
|
||||
finding_description = finding.get("Description", "")
|
||||
finding_status = finding.get("Status", "FAIL")
|
||||
finding_categories = []
|
||||
additional_urls = (
|
||||
[url] if (url := finding.get("PrimaryURL", "")) else []
|
||||
)
|
||||
|
||||
# Build remediation text for vulnerabilities
|
||||
remediation_text = ""
|
||||
@@ -451,13 +469,11 @@ class ImageProvider(Provider):
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": remediation_text,
|
||||
"Url": "",
|
||||
"Url": recommendation_url,
|
||||
},
|
||||
},
|
||||
"Categories": finding_categories,
|
||||
"AdditionalURLs": (
|
||||
[url] if (url := finding.get("PrimaryURL", "")) else []
|
||||
),
|
||||
"AdditionalURLs": additional_urls,
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": "",
|
||||
|
||||
+9
@@ -85,6 +85,15 @@ class entra_break_glass_account_fido2_security_key_registered(Check):
|
||||
resource_id=user.id,
|
||||
)
|
||||
|
||||
if entra_client.user_registration_details_error:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Cannot verify FIDO2 security key registration for break glass account {user.name}: "
|
||||
f"{entra_client.user_registration_details_error}."
|
||||
)
|
||||
findings.append(report)
|
||||
continue
|
||||
|
||||
auth_methods = set(user.authentication_methods)
|
||||
has_fido2 = "fido2SecurityKey" in auth_methods
|
||||
has_passkey_device_bound = "passKeyDeviceBound" in auth_methods
|
||||
|
||||
@@ -2,13 +2,15 @@ import asyncio
|
||||
import json
|
||||
from asyncio import gather
|
||||
from enum import Enum
|
||||
from typing import Dict, List, Optional
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from uuid import UUID
|
||||
|
||||
from kiota_abstractions.base_request_configuration import RequestConfiguration
|
||||
from msgraph.generated.models.o_data_errors.o_data_error import ODataError
|
||||
from msgraph.generated.security.microsoft_graph_security_run_hunting_query.run_hunting_query_post_request_body import (
|
||||
RunHuntingQueryPostRequestBody,
|
||||
)
|
||||
from msgraph.generated.users.users_request_builder import UsersRequestBuilder
|
||||
from pydantic.v1 import BaseModel, validator
|
||||
|
||||
from prowler.lib.logger import logger
|
||||
@@ -71,6 +73,7 @@ class Entra(M365Service):
|
||||
)
|
||||
|
||||
self.tenant_domain = provider.identity.tenant_domain
|
||||
self.user_registration_details_error: Optional[str] = None
|
||||
attributes = loop.run_until_complete(
|
||||
gather(
|
||||
self._get_authorization_policy(),
|
||||
@@ -806,7 +809,29 @@ class Entra(M365Service):
|
||||
logger.info("Entra - Getting users...")
|
||||
users = {}
|
||||
try:
|
||||
users_response = await self.client.users.get()
|
||||
# Microsoft Graph's /users endpoint omits accountEnabled, userType and
|
||||
# onPremisesSyncEnabled from the default property set, so we must request
|
||||
# them explicitly via $select. Without this, disabled guest users surface
|
||||
# as account_enabled=True (Pydantic default) and user_type=None, which
|
||||
# bypasses the guest/disabled filters in checks like
|
||||
# entra_users_mfa_capable (CIS 5.2.3.4). See issue #10921.
|
||||
query_parameters = (
|
||||
UsersRequestBuilder.UsersRequestBuilderGetQueryParameters(
|
||||
select=[
|
||||
"id",
|
||||
"displayName",
|
||||
"userType",
|
||||
"accountEnabled",
|
||||
"onPremisesSyncEnabled",
|
||||
],
|
||||
)
|
||||
)
|
||||
request_configuration = RequestConfiguration(
|
||||
query_parameters=query_parameters,
|
||||
)
|
||||
users_response = await self.client.users.get(
|
||||
request_configuration=request_configuration,
|
||||
)
|
||||
directory_roles = await self.client.directory_roles.get()
|
||||
|
||||
async def fetch_role_members(directory_role):
|
||||
@@ -825,11 +850,26 @@ class Entra(M365Service):
|
||||
for member in members:
|
||||
user_roles_map.setdefault(member.id, []).append(role_template_id)
|
||||
|
||||
registration_details = await self._get_user_registration_details()
|
||||
registration_details, self.user_registration_details_error = (
|
||||
await self._get_user_registration_details()
|
||||
)
|
||||
|
||||
while users_response:
|
||||
for user in getattr(users_response, "value", []) or []:
|
||||
reg_info = registration_details.get(user.id, {})
|
||||
# Prefer Microsoft Graph as the source of truth for
|
||||
# accountEnabled: it covers every directory user including
|
||||
# guests, whereas EXO's Get-User only returns mail-enabled
|
||||
# accounts and silently drops disabled guests. Fall back to
|
||||
# the EXO PowerShell value only when Graph does not return a
|
||||
# value (e.g. older tenants or permission-restricted reads).
|
||||
graph_account_enabled = getattr(user, "account_enabled", None)
|
||||
if graph_account_enabled is None:
|
||||
account_enabled = not self.user_accounts_status.get(
|
||||
user.id, {}
|
||||
).get("AccountDisabled", False)
|
||||
else:
|
||||
account_enabled = bool(graph_account_enabled)
|
||||
users[user.id] = User(
|
||||
id=user.id,
|
||||
name=user.display_name,
|
||||
@@ -838,9 +878,7 @@ class Entra(M365Service):
|
||||
),
|
||||
directory_roles_ids=user_roles_map.get(user.id, []),
|
||||
is_mfa_capable=reg_info.get("is_mfa_capable", False),
|
||||
account_enabled=not self.user_accounts_status.get(
|
||||
user.id, {}
|
||||
).get("AccountDisabled", False),
|
||||
account_enabled=account_enabled,
|
||||
authentication_methods=reg_info.get(
|
||||
"authentication_methods", []
|
||||
),
|
||||
@@ -857,18 +895,24 @@ class Entra(M365Service):
|
||||
)
|
||||
return users
|
||||
|
||||
async def _get_user_registration_details(self):
|
||||
async def _get_user_registration_details(
|
||||
self,
|
||||
) -> Tuple[Dict[str, Dict[str, Any]], Optional[str]]:
|
||||
"""Retrieve user authentication method registration details.
|
||||
|
||||
Fetches registration details from the Microsoft Graph API, including
|
||||
MFA capability and the specific authentication methods each user has registered.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary mapping user IDs to their registration details,
|
||||
where each value is a dict with 'is_mfa_capable' (bool) and
|
||||
'authentication_methods' (list of str).
|
||||
A tuple containing:
|
||||
- A dictionary mapping user IDs to their registration details,
|
||||
where each value is a dict with 'is_mfa_capable' (bool) and
|
||||
'authentication_methods' (list of str), or an empty dict if
|
||||
retrieval fails.
|
||||
- An error message string if there was an access error, None otherwise.
|
||||
"""
|
||||
registration_details = {}
|
||||
error_message = None
|
||||
try:
|
||||
registration_builder = (
|
||||
self.client.reports.authentication_methods.user_registration_details
|
||||
@@ -893,16 +937,25 @@ class Entra(M365Service):
|
||||
next_link
|
||||
).get()
|
||||
|
||||
except Exception as error:
|
||||
if (
|
||||
error.__class__.__name__ == "ODataError"
|
||||
and error.__dict__.get("response_status_code", None) == 403
|
||||
):
|
||||
except ODataError as error:
|
||||
error_code = getattr(error.error, "code", None) if error.error else None
|
||||
if error_code == "Authorization_RequestDenied":
|
||||
error_message = "Insufficient privileges to read user registration details. Required permission: AuditLog.Read.All"
|
||||
logger.error(
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error_message}"
|
||||
)
|
||||
else:
|
||||
logger.error(
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
error_message = str(error)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
error_message = f"Failed to retrieve user registration details: {error}"
|
||||
|
||||
return registration_details
|
||||
return registration_details, error_message
|
||||
|
||||
async def _get_oauth_apps(self) -> Optional[Dict[str, "OAuthApp"]]:
|
||||
"""
|
||||
|
||||
+11
-1
@@ -13,6 +13,10 @@ class entra_users_mfa_capable(Check):
|
||||
("Ensure all member users are 'MFA capable'").
|
||||
|
||||
Guest users and disabled accounts are excluded from the evaluation.
|
||||
|
||||
- PASS: The member user is MFA capable.
|
||||
- FAIL: The member user is not MFA capable, or MFA capability cannot be
|
||||
verified due to insufficient permissions to read user registration details.
|
||||
"""
|
||||
|
||||
def execute(self) -> List[CheckReportM365]:
|
||||
@@ -42,7 +46,13 @@ class entra_users_mfa_capable(Check):
|
||||
resource_id=user.id,
|
||||
)
|
||||
|
||||
if not user.is_mfa_capable:
|
||||
if entra_client.user_registration_details_error:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Cannot verify MFA capability for user {user.name}: "
|
||||
f"{entra_client.user_registration_details_error}."
|
||||
)
|
||||
elif not user.is_mfa_capable:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = f"User {user.name} is not MFA capable."
|
||||
else:
|
||||
|
||||
+1
-1
@@ -95,7 +95,7 @@ maintainers = [{name = "Prowler Engineering", email = "engineering@prowler.com"}
|
||||
name = "prowler"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10,<3.13"
|
||||
version = "5.26.0"
|
||||
version = "5.26.2"
|
||||
|
||||
[project.scripts]
|
||||
prowler = "prowler.__main__:prowler"
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
from prowler.lib.utils.vulnerability_references import (
|
||||
build_finding_reference_url,
|
||||
resolve_vulnerability_reference_urls,
|
||||
)
|
||||
|
||||
|
||||
class TestBuildFindingReferenceUrl:
|
||||
def test_cve_id_returns_cve_org_url(self):
|
||||
assert (
|
||||
build_finding_reference_url("CVE-2023-1234")
|
||||
== "https://www.cve.org/CVERecord?id=CVE-2023-1234"
|
||||
)
|
||||
|
||||
def test_lowercase_cve_id_is_normalized(self):
|
||||
assert (
|
||||
build_finding_reference_url("cve-2024-9999")
|
||||
== "https://www.cve.org/CVERecord?id=CVE-2024-9999"
|
||||
)
|
||||
|
||||
def test_ghsa_id_returns_github_advisory_url(self):
|
||||
assert (
|
||||
build_finding_reference_url("GHSA-abcd-1234-efgh")
|
||||
== "https://github.com/advisories/GHSA-ABCD-1234-EFGH"
|
||||
)
|
||||
|
||||
def test_avd_prefixed_id_strips_prefix_for_hub(self):
|
||||
assert (
|
||||
build_finding_reference_url("AVD-AWS-0001")
|
||||
== "https://hub.prowler.com/check/AWS-0001"
|
||||
)
|
||||
|
||||
def test_clean_trivy_id_uses_hub_directly(self):
|
||||
assert (
|
||||
build_finding_reference_url("AWS-0104")
|
||||
== "https://hub.prowler.com/check/AWS-0104"
|
||||
)
|
||||
|
||||
def test_kubernetes_id_uses_hub(self):
|
||||
assert (
|
||||
build_finding_reference_url("AVD-K8S-0001")
|
||||
== "https://hub.prowler.com/check/K8S-0001"
|
||||
)
|
||||
|
||||
def test_dockerfile_id_uses_hub(self):
|
||||
assert (
|
||||
build_finding_reference_url("AVD-DOCKER-0001")
|
||||
== "https://hub.prowler.com/check/DOCKER-0001"
|
||||
)
|
||||
|
||||
def test_whitespace_is_trimmed(self):
|
||||
assert (
|
||||
build_finding_reference_url(" AZU-0013 ")
|
||||
== "https://hub.prowler.com/check/AZU-0013"
|
||||
)
|
||||
|
||||
|
||||
class TestResolveVulnerabilityReferenceUrls:
|
||||
def test_cve_with_cve_org_reference_uses_it(self):
|
||||
recommendation_url, additional_urls = resolve_vulnerability_reference_urls(
|
||||
vulnerability_id="CVE-2023-1234",
|
||||
references=[
|
||||
"https://avd.aquasec.com/nvd/cve-2023-1234",
|
||||
"https://www.cve.org/CVERecord?id=CVE-2023-1234",
|
||||
"https://nvd.nist.gov/vuln/detail/CVE-2023-1234",
|
||||
],
|
||||
primary_url="https://avd.aquasec.com/nvd/cve-2023-1234",
|
||||
)
|
||||
|
||||
assert recommendation_url == "https://www.cve.org/CVERecord?id=CVE-2023-1234"
|
||||
assert additional_urls == ["https://www.cve.org/CVERecord?id=CVE-2023-1234"]
|
||||
|
||||
def test_cve_without_cve_org_reference_builds_url(self):
|
||||
recommendation_url, additional_urls = resolve_vulnerability_reference_urls(
|
||||
vulnerability_id="CVE-2023-5678",
|
||||
references=["https://nvd.nist.gov/vuln/detail/CVE-2023-5678"],
|
||||
)
|
||||
|
||||
assert recommendation_url == "https://www.cve.org/CVERecord?id=CVE-2023-5678"
|
||||
assert additional_urls == ["https://www.cve.org/CVERecord?id=CVE-2023-5678"]
|
||||
|
||||
def test_non_cve_id_returns_filtered_references(self):
|
||||
recommendation_url, additional_urls = resolve_vulnerability_reference_urls(
|
||||
vulnerability_id="GHSA-abcd-1234-efgh",
|
||||
references=[
|
||||
"https://avd.aquasec.com/nvd/ghsa-abcd-1234-efgh",
|
||||
"https://github.com/advisories/GHSA-abcd-1234-efgh",
|
||||
],
|
||||
)
|
||||
|
||||
assert recommendation_url == ""
|
||||
assert additional_urls == ["https://github.com/advisories/GHSA-abcd-1234-efgh"]
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
from unittest import mock
|
||||
|
||||
import botocore
|
||||
|
||||
from tests.providers.aws.utils import (
|
||||
AWS_ACCOUNT_NUMBER,
|
||||
AWS_REGION_EU_WEST_1,
|
||||
AWS_REGION_US_EAST_1,
|
||||
set_mocked_aws_provider,
|
||||
)
|
||||
|
||||
make_api_call = botocore.client.BaseClient._make_api_call
|
||||
|
||||
PROMPT_ARN = (
|
||||
f"arn:aws:bedrock:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:prompt/test-prompt-id"
|
||||
)
|
||||
PROMPT_ID = "test-prompt-id"
|
||||
PROMPT_NAME = "test-prompt"
|
||||
KMS_KEY_ARN = (
|
||||
f"arn:aws:kms:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:key/"
|
||||
"12345678-1234-1234-1234-123456789012"
|
||||
)
|
||||
|
||||
|
||||
def mock_make_api_call_with_cmk(self, operation_name, kwarg):
|
||||
"""Mock API call returning a prompt encrypted with a customer-managed KMS key."""
|
||||
if operation_name == "ListPrompts":
|
||||
return {
|
||||
"promptSummaries": [
|
||||
{
|
||||
"id": PROMPT_ID,
|
||||
"name": PROMPT_NAME,
|
||||
"arn": PROMPT_ARN,
|
||||
}
|
||||
]
|
||||
}
|
||||
elif operation_name == "GetPrompt":
|
||||
return {
|
||||
"id": PROMPT_ID,
|
||||
"name": PROMPT_NAME,
|
||||
"arn": PROMPT_ARN,
|
||||
"customerEncryptionKeyArn": KMS_KEY_ARN,
|
||||
}
|
||||
return make_api_call(self, operation_name, kwarg)
|
||||
|
||||
|
||||
def mock_make_api_call_without_cmk(self, operation_name, kwarg):
|
||||
"""Mock API call returning a prompt without a customer-managed KMS key."""
|
||||
if operation_name == "ListPrompts":
|
||||
return {
|
||||
"promptSummaries": [
|
||||
{
|
||||
"id": PROMPT_ID,
|
||||
"name": PROMPT_NAME,
|
||||
"arn": PROMPT_ARN,
|
||||
}
|
||||
]
|
||||
}
|
||||
elif operation_name == "GetPrompt":
|
||||
return {
|
||||
"id": PROMPT_ID,
|
||||
"name": PROMPT_NAME,
|
||||
"arn": PROMPT_ARN,
|
||||
}
|
||||
return make_api_call(self, operation_name, kwarg)
|
||||
|
||||
|
||||
class Test_bedrock_prompt_encrypted_with_cmk:
|
||||
"""Test suite for the bedrock_prompt_encrypted_with_cmk check."""
|
||||
|
||||
@mock.patch(
|
||||
"botocore.client.BaseClient._make_api_call",
|
||||
new=lambda self, op, kwarg: make_api_call(self, op, kwarg),
|
||||
)
|
||||
def test_no_prompts(self):
|
||||
"""Test when no prompts exist."""
|
||||
from prowler.providers.aws.services.bedrock.bedrock_service import BedrockAgent
|
||||
|
||||
aws_provider = set_mocked_aws_provider(
|
||||
[AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1]
|
||||
)
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=aws_provider,
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.bedrock.bedrock_prompt_encrypted_with_cmk.bedrock_prompt_encrypted_with_cmk.bedrock_agent_client",
|
||||
new=BedrockAgent(aws_provider),
|
||||
),
|
||||
):
|
||||
from prowler.providers.aws.services.bedrock.bedrock_prompt_encrypted_with_cmk.bedrock_prompt_encrypted_with_cmk import (
|
||||
bedrock_prompt_encrypted_with_cmk,
|
||||
)
|
||||
|
||||
check = bedrock_prompt_encrypted_with_cmk()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 0
|
||||
|
||||
@mock.patch(
|
||||
"botocore.client.BaseClient._make_api_call",
|
||||
new=mock_make_api_call_with_cmk,
|
||||
)
|
||||
def test_prompt_encrypted_with_cmk(self):
|
||||
"""Test when a prompt is encrypted with a customer-managed KMS key."""
|
||||
from prowler.providers.aws.services.bedrock.bedrock_service import BedrockAgent
|
||||
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=aws_provider,
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.bedrock.bedrock_prompt_encrypted_with_cmk.bedrock_prompt_encrypted_with_cmk.bedrock_agent_client",
|
||||
new=BedrockAgent(aws_provider),
|
||||
),
|
||||
):
|
||||
from prowler.providers.aws.services.bedrock.bedrock_prompt_encrypted_with_cmk.bedrock_prompt_encrypted_with_cmk import (
|
||||
bedrock_prompt_encrypted_with_cmk,
|
||||
)
|
||||
|
||||
check = bedrock_prompt_encrypted_with_cmk()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
assert (
|
||||
result[0].status_extended
|
||||
== f"Bedrock Prompt {PROMPT_NAME} is encrypted with a customer-managed KMS key."
|
||||
)
|
||||
assert result[0].resource_id == PROMPT_ID
|
||||
assert result[0].resource_arn == PROMPT_ARN
|
||||
assert result[0].region == AWS_REGION_US_EAST_1
|
||||
|
||||
@mock.patch(
|
||||
"botocore.client.BaseClient._make_api_call",
|
||||
new=mock_make_api_call_without_cmk,
|
||||
)
|
||||
def test_prompt_not_encrypted_with_cmk(self):
|
||||
"""Test when a prompt is not encrypted with a customer-managed KMS key."""
|
||||
from prowler.providers.aws.services.bedrock.bedrock_service import BedrockAgent
|
||||
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=aws_provider,
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.bedrock.bedrock_prompt_encrypted_with_cmk.bedrock_prompt_encrypted_with_cmk.bedrock_agent_client",
|
||||
new=BedrockAgent(aws_provider),
|
||||
),
|
||||
):
|
||||
from prowler.providers.aws.services.bedrock.bedrock_prompt_encrypted_with_cmk.bedrock_prompt_encrypted_with_cmk import (
|
||||
bedrock_prompt_encrypted_with_cmk,
|
||||
)
|
||||
|
||||
check = bedrock_prompt_encrypted_with_cmk()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert (
|
||||
result[0].status_extended
|
||||
== f"Bedrock Prompt {PROMPT_NAME} is not encrypted with a customer-managed KMS key."
|
||||
)
|
||||
assert result[0].resource_id == PROMPT_ID
|
||||
assert result[0].resource_arn == PROMPT_ARN
|
||||
assert result[0].region == AWS_REGION_US_EAST_1
|
||||
@@ -406,12 +406,14 @@ class TestBedrockPromptPagination:
|
||||
regional_client.get_paginator.assert_called_once_with("list_prompts")
|
||||
paginator.paginate.assert_called_once()
|
||||
|
||||
def test_list_prompts_ignores_audit_resources_filter(self):
|
||||
"""Prompt collection is region-scoped and must ignore audit_resources."""
|
||||
def test_list_prompts_filters_audit_resources(self):
|
||||
"""Prompt collection must honor audit_resources when resource ARNs are scoped."""
|
||||
audit_info = MagicMock()
|
||||
audit_info.audited_partition = "aws"
|
||||
audit_info.audited_account = "123456789012"
|
||||
audit_info.audit_resources = ["arn:aws:s3:::unrelated-resource"]
|
||||
audit_info.audit_resources = [
|
||||
"arn:aws:bedrock:us-east-1:123456789012:prompt/prompt-1"
|
||||
]
|
||||
|
||||
regional_client = MagicMock()
|
||||
regional_client.region = "us-east-1"
|
||||
@@ -424,7 +426,12 @@ class TestBedrockPromptPagination:
|
||||
"id": "prompt-1",
|
||||
"name": "prompt-name-1",
|
||||
"arn": "arn:aws:bedrock:us-east-1:123456789012:prompt/prompt-1",
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "prompt-2",
|
||||
"name": "prompt-name-2",
|
||||
"arn": "arn:aws:bedrock:us-east-1:123456789012:prompt/prompt-2",
|
||||
},
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -438,6 +445,14 @@ class TestBedrockPromptPagination:
|
||||
bedrock_agent_service._list_prompts(regional_client)
|
||||
|
||||
assert len(bedrock_agent_service.prompts) == 1
|
||||
assert (
|
||||
"arn:aws:bedrock:us-east-1:123456789012:prompt/prompt-1"
|
||||
in bedrock_agent_service.prompts
|
||||
)
|
||||
assert (
|
||||
"arn:aws:bedrock:us-east-1:123456789012:prompt/prompt-2"
|
||||
not in bedrock_agent_service.prompts
|
||||
)
|
||||
assert "us-east-1" in bedrock_agent_service.prompt_scanned_regions
|
||||
|
||||
def test_list_prompts_error_does_not_mark_region_scanned(self):
|
||||
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_service import GmailPolicies
|
||||
from tests.providers.googleworkspace.googleworkspace_fixtures import (
|
||||
CUSTOMER_ID,
|
||||
DOMAIN,
|
||||
set_mocked_googleworkspace_provider,
|
||||
)
|
||||
|
||||
|
||||
class TestGmailAnomalousAttachmentProtectionEnabled:
|
||||
def test_pass(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_anomalous_attachment_protection_enabled.gmail_anomalous_attachment_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_anomalous_attachment_protection_enabled.gmail_anomalous_attachment_protection_enabled import (
|
||||
gmail_anomalous_attachment_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = True
|
||||
mock_client.policies = GmailPolicies(
|
||||
enable_anomalous_attachment_protection=True,
|
||||
anomalous_attachment_protection_consequence="WARNING",
|
||||
)
|
||||
|
||||
check = gmail_anomalous_attachment_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "PASS"
|
||||
assert "WARNING" in findings[0].status_extended
|
||||
assert findings[0].resource_name == DOMAIN
|
||||
assert findings[0].customer_id == CUSTOMER_ID
|
||||
|
||||
def test_fail_no_action(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_anomalous_attachment_protection_enabled.gmail_anomalous_attachment_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_anomalous_attachment_protection_enabled.gmail_anomalous_attachment_protection_enabled import (
|
||||
gmail_anomalous_attachment_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = True
|
||||
mock_client.policies = GmailPolicies(
|
||||
enable_anomalous_attachment_protection=True,
|
||||
anomalous_attachment_protection_consequence="NO_ACTION",
|
||||
)
|
||||
|
||||
check = gmail_anomalous_attachment_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "FAIL"
|
||||
assert "no action" in findings[0].status_extended
|
||||
|
||||
def test_fail_protection_disabled(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_anomalous_attachment_protection_enabled.gmail_anomalous_attachment_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_anomalous_attachment_protection_enabled.gmail_anomalous_attachment_protection_enabled import (
|
||||
gmail_anomalous_attachment_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = True
|
||||
mock_client.policies = GmailPolicies(
|
||||
enable_anomalous_attachment_protection=False,
|
||||
anomalous_attachment_protection_consequence="WARNING",
|
||||
)
|
||||
|
||||
check = gmail_anomalous_attachment_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "FAIL"
|
||||
assert "disabled" in findings[0].status_extended
|
||||
|
||||
def test_fail_no_policy_set(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_anomalous_attachment_protection_enabled.gmail_anomalous_attachment_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_anomalous_attachment_protection_enabled.gmail_anomalous_attachment_protection_enabled import (
|
||||
gmail_anomalous_attachment_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = True
|
||||
mock_client.policies = GmailPolicies()
|
||||
|
||||
check = gmail_anomalous_attachment_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "FAIL"
|
||||
assert "insecure default" in findings[0].status_extended
|
||||
|
||||
def test_no_findings_when_fetch_failed(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_anomalous_attachment_protection_enabled.gmail_anomalous_attachment_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_anomalous_attachment_protection_enabled.gmail_anomalous_attachment_protection_enabled import (
|
||||
gmail_anomalous_attachment_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = False
|
||||
mock_client.policies = GmailPolicies()
|
||||
|
||||
check = gmail_anomalous_attachment_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 0
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_service import GmailPolicies
|
||||
from tests.providers.googleworkspace.googleworkspace_fixtures import (
|
||||
CUSTOMER_ID,
|
||||
DOMAIN,
|
||||
set_mocked_googleworkspace_provider,
|
||||
)
|
||||
|
||||
|
||||
class TestGmailDomainSpoofingProtectionEnabled:
|
||||
def test_pass(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_domain_spoofing_protection_enabled.gmail_domain_spoofing_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_domain_spoofing_protection_enabled.gmail_domain_spoofing_protection_enabled import (
|
||||
gmail_domain_spoofing_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = True
|
||||
mock_client.policies = GmailPolicies(
|
||||
detect_domain_name_spoofing=True,
|
||||
domain_spoofing_consequence="SPAM_FOLDER",
|
||||
)
|
||||
|
||||
check = gmail_domain_spoofing_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "PASS"
|
||||
assert "SPAM_FOLDER" in findings[0].status_extended
|
||||
assert findings[0].resource_name == DOMAIN
|
||||
assert findings[0].customer_id == CUSTOMER_ID
|
||||
|
||||
def test_fail_no_action(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_domain_spoofing_protection_enabled.gmail_domain_spoofing_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_domain_spoofing_protection_enabled.gmail_domain_spoofing_protection_enabled import (
|
||||
gmail_domain_spoofing_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = True
|
||||
mock_client.policies = GmailPolicies(
|
||||
detect_domain_name_spoofing=True,
|
||||
domain_spoofing_consequence="NO_ACTION",
|
||||
)
|
||||
|
||||
check = gmail_domain_spoofing_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "FAIL"
|
||||
assert "no action" in findings[0].status_extended
|
||||
|
||||
def test_fail_protection_disabled(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_domain_spoofing_protection_enabled.gmail_domain_spoofing_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_domain_spoofing_protection_enabled.gmail_domain_spoofing_protection_enabled import (
|
||||
gmail_domain_spoofing_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = True
|
||||
mock_client.policies = GmailPolicies(
|
||||
detect_domain_name_spoofing=False,
|
||||
domain_spoofing_consequence="WARNING",
|
||||
)
|
||||
|
||||
check = gmail_domain_spoofing_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "FAIL"
|
||||
assert "disabled" in findings[0].status_extended
|
||||
|
||||
def test_pass_using_default(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_domain_spoofing_protection_enabled.gmail_domain_spoofing_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_domain_spoofing_protection_enabled.gmail_domain_spoofing_protection_enabled import (
|
||||
gmail_domain_spoofing_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = True
|
||||
mock_client.policies = GmailPolicies()
|
||||
|
||||
check = gmail_domain_spoofing_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "PASS"
|
||||
assert "secure default" in findings[0].status_extended
|
||||
|
||||
def test_no_findings_when_fetch_failed(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_domain_spoofing_protection_enabled.gmail_domain_spoofing_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_domain_spoofing_protection_enabled.gmail_domain_spoofing_protection_enabled import (
|
||||
gmail_domain_spoofing_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = False
|
||||
mock_client.policies = GmailPolicies()
|
||||
|
||||
check = gmail_domain_spoofing_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 0
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_service import GmailPolicies
|
||||
from tests.providers.googleworkspace.googleworkspace_fixtures import (
|
||||
CUSTOMER_ID,
|
||||
DOMAIN,
|
||||
set_mocked_googleworkspace_provider,
|
||||
)
|
||||
|
||||
|
||||
class TestGmailEmployeeNameSpoofingProtectionEnabled:
|
||||
def test_pass(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_employee_name_spoofing_protection_enabled.gmail_employee_name_spoofing_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_employee_name_spoofing_protection_enabled.gmail_employee_name_spoofing_protection_enabled import (
|
||||
gmail_employee_name_spoofing_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = True
|
||||
mock_client.policies = GmailPolicies(
|
||||
detect_employee_name_spoofing=True,
|
||||
employee_name_spoofing_consequence="SPAM_FOLDER",
|
||||
)
|
||||
|
||||
check = gmail_employee_name_spoofing_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "PASS"
|
||||
assert "SPAM_FOLDER" in findings[0].status_extended
|
||||
assert findings[0].resource_name == DOMAIN
|
||||
assert findings[0].customer_id == CUSTOMER_ID
|
||||
|
||||
def test_fail_no_action(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_employee_name_spoofing_protection_enabled.gmail_employee_name_spoofing_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_employee_name_spoofing_protection_enabled.gmail_employee_name_spoofing_protection_enabled import (
|
||||
gmail_employee_name_spoofing_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = True
|
||||
mock_client.policies = GmailPolicies(
|
||||
detect_employee_name_spoofing=True,
|
||||
employee_name_spoofing_consequence="NO_ACTION",
|
||||
)
|
||||
|
||||
check = gmail_employee_name_spoofing_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "FAIL"
|
||||
assert "no action" in findings[0].status_extended
|
||||
|
||||
def test_fail_protection_disabled(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_employee_name_spoofing_protection_enabled.gmail_employee_name_spoofing_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_employee_name_spoofing_protection_enabled.gmail_employee_name_spoofing_protection_enabled import (
|
||||
gmail_employee_name_spoofing_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = True
|
||||
mock_client.policies = GmailPolicies(
|
||||
detect_employee_name_spoofing=False,
|
||||
employee_name_spoofing_consequence="WARNING",
|
||||
)
|
||||
|
||||
check = gmail_employee_name_spoofing_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "FAIL"
|
||||
assert "disabled" in findings[0].status_extended
|
||||
|
||||
def test_pass_using_default(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_employee_name_spoofing_protection_enabled.gmail_employee_name_spoofing_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_employee_name_spoofing_protection_enabled.gmail_employee_name_spoofing_protection_enabled import (
|
||||
gmail_employee_name_spoofing_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = True
|
||||
mock_client.policies = GmailPolicies()
|
||||
|
||||
check = gmail_employee_name_spoofing_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "PASS"
|
||||
assert "secure default" in findings[0].status_extended
|
||||
|
||||
def test_no_findings_when_fetch_failed(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_employee_name_spoofing_protection_enabled.gmail_employee_name_spoofing_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_employee_name_spoofing_protection_enabled.gmail_employee_name_spoofing_protection_enabled import (
|
||||
gmail_employee_name_spoofing_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = False
|
||||
mock_client.policies = GmailPolicies()
|
||||
|
||||
check = gmail_employee_name_spoofing_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 0
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_service import GmailPolicies
|
||||
from tests.providers.googleworkspace.googleworkspace_fixtures import (
|
||||
CUSTOMER_ID,
|
||||
DOMAIN,
|
||||
set_mocked_googleworkspace_provider,
|
||||
)
|
||||
|
||||
|
||||
class TestGmailEncryptedAttachmentProtectionEnabled:
|
||||
def test_pass(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_encrypted_attachment_protection_enabled.gmail_encrypted_attachment_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_encrypted_attachment_protection_enabled.gmail_encrypted_attachment_protection_enabled import (
|
||||
gmail_encrypted_attachment_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = True
|
||||
mock_client.policies = GmailPolicies(
|
||||
enable_encrypted_attachment_protection=True,
|
||||
encrypted_attachment_protection_consequence="QUARANTINE",
|
||||
)
|
||||
|
||||
check = gmail_encrypted_attachment_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "PASS"
|
||||
assert "QUARANTINE" in findings[0].status_extended
|
||||
assert findings[0].resource_name == DOMAIN
|
||||
assert findings[0].customer_id == CUSTOMER_ID
|
||||
|
||||
def test_fail_no_action(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_encrypted_attachment_protection_enabled.gmail_encrypted_attachment_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_encrypted_attachment_protection_enabled.gmail_encrypted_attachment_protection_enabled import (
|
||||
gmail_encrypted_attachment_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = True
|
||||
mock_client.policies = GmailPolicies(
|
||||
enable_encrypted_attachment_protection=True,
|
||||
encrypted_attachment_protection_consequence="NO_ACTION",
|
||||
)
|
||||
|
||||
check = gmail_encrypted_attachment_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "FAIL"
|
||||
assert "no action" in findings[0].status_extended
|
||||
|
||||
def test_fail_protection_disabled(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_encrypted_attachment_protection_enabled.gmail_encrypted_attachment_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_encrypted_attachment_protection_enabled.gmail_encrypted_attachment_protection_enabled import (
|
||||
gmail_encrypted_attachment_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = True
|
||||
mock_client.policies = GmailPolicies(
|
||||
enable_encrypted_attachment_protection=False,
|
||||
encrypted_attachment_protection_consequence="WARNING",
|
||||
)
|
||||
|
||||
check = gmail_encrypted_attachment_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "FAIL"
|
||||
assert "disabled" in findings[0].status_extended
|
||||
|
||||
def test_pass_using_default(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_encrypted_attachment_protection_enabled.gmail_encrypted_attachment_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_encrypted_attachment_protection_enabled.gmail_encrypted_attachment_protection_enabled import (
|
||||
gmail_encrypted_attachment_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = True
|
||||
mock_client.policies = GmailPolicies()
|
||||
|
||||
check = gmail_encrypted_attachment_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "PASS"
|
||||
assert "secure default" in findings[0].status_extended
|
||||
|
||||
def test_no_findings_when_fetch_failed(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_encrypted_attachment_protection_enabled.gmail_encrypted_attachment_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_encrypted_attachment_protection_enabled.gmail_encrypted_attachment_protection_enabled import (
|
||||
gmail_encrypted_attachment_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = False
|
||||
mock_client.policies = GmailPolicies()
|
||||
|
||||
check = gmail_encrypted_attachment_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 0
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_service import GmailPolicies
|
||||
from tests.providers.googleworkspace.googleworkspace_fixtures import (
|
||||
CUSTOMER_ID,
|
||||
DOMAIN,
|
||||
set_mocked_googleworkspace_provider,
|
||||
)
|
||||
|
||||
|
||||
class TestGmailGroupsSpoofingProtectionEnabled:
|
||||
def test_pass(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_groups_spoofing_protection_enabled.gmail_groups_spoofing_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_groups_spoofing_protection_enabled.gmail_groups_spoofing_protection_enabled import (
|
||||
gmail_groups_spoofing_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = True
|
||||
mock_client.policies = GmailPolicies(
|
||||
detect_groups_spoofing=True,
|
||||
groups_spoofing_consequence="SPAM_FOLDER",
|
||||
)
|
||||
|
||||
check = gmail_groups_spoofing_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "PASS"
|
||||
assert "all groups" in findings[0].status_extended
|
||||
assert "SPAM_FOLDER" in findings[0].status_extended
|
||||
assert findings[0].resource_name == DOMAIN
|
||||
assert findings[0].customer_id == CUSTOMER_ID
|
||||
|
||||
def test_pass_private_groups_only(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_groups_spoofing_protection_enabled.gmail_groups_spoofing_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_groups_spoofing_protection_enabled.gmail_groups_spoofing_protection_enabled import (
|
||||
gmail_groups_spoofing_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = True
|
||||
mock_client.policies = GmailPolicies(
|
||||
detect_groups_spoofing=True,
|
||||
groups_spoofing_visibility_type="PRIVATE_GROUPS_ONLY",
|
||||
groups_spoofing_consequence="SPAM_FOLDER",
|
||||
)
|
||||
|
||||
check = gmail_groups_spoofing_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "PASS"
|
||||
assert "private groups only" in findings[0].status_extended
|
||||
assert "SPAM_FOLDER" in findings[0].status_extended
|
||||
|
||||
def test_fail_no_action(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_groups_spoofing_protection_enabled.gmail_groups_spoofing_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_groups_spoofing_protection_enabled.gmail_groups_spoofing_protection_enabled import (
|
||||
gmail_groups_spoofing_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = True
|
||||
mock_client.policies = GmailPolicies(
|
||||
detect_groups_spoofing=True,
|
||||
groups_spoofing_consequence="NO_ACTION",
|
||||
)
|
||||
|
||||
check = gmail_groups_spoofing_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "FAIL"
|
||||
assert "no action" in findings[0].status_extended
|
||||
|
||||
def test_fail_protection_disabled(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_groups_spoofing_protection_enabled.gmail_groups_spoofing_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_groups_spoofing_protection_enabled.gmail_groups_spoofing_protection_enabled import (
|
||||
gmail_groups_spoofing_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = True
|
||||
mock_client.policies = GmailPolicies(
|
||||
detect_groups_spoofing=False,
|
||||
groups_spoofing_consequence="WARNING",
|
||||
)
|
||||
|
||||
check = gmail_groups_spoofing_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "FAIL"
|
||||
assert "disabled" in findings[0].status_extended
|
||||
|
||||
def test_fail_no_policy_set(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_groups_spoofing_protection_enabled.gmail_groups_spoofing_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_groups_spoofing_protection_enabled.gmail_groups_spoofing_protection_enabled import (
|
||||
gmail_groups_spoofing_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = True
|
||||
mock_client.policies = GmailPolicies()
|
||||
|
||||
check = gmail_groups_spoofing_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "FAIL"
|
||||
assert "insecure default" in findings[0].status_extended
|
||||
|
||||
def test_no_findings_when_fetch_failed(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_groups_spoofing_protection_enabled.gmail_groups_spoofing_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_groups_spoofing_protection_enabled.gmail_groups_spoofing_protection_enabled import (
|
||||
gmail_groups_spoofing_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = False
|
||||
mock_client.policies = GmailPolicies()
|
||||
|
||||
check = gmail_groups_spoofing_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 0
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_service import GmailPolicies
|
||||
from tests.providers.googleworkspace.googleworkspace_fixtures import (
|
||||
CUSTOMER_ID,
|
||||
DOMAIN,
|
||||
set_mocked_googleworkspace_provider,
|
||||
)
|
||||
|
||||
|
||||
class TestGmailInboundDomainSpoofingProtectionEnabled:
|
||||
def test_pass(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_inbound_domain_spoofing_protection_enabled.gmail_inbound_domain_spoofing_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_inbound_domain_spoofing_protection_enabled.gmail_inbound_domain_spoofing_protection_enabled import (
|
||||
gmail_inbound_domain_spoofing_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = True
|
||||
mock_client.policies = GmailPolicies(
|
||||
detect_inbound_domain_spoofing=True,
|
||||
inbound_domain_spoofing_consequence="QUARANTINE",
|
||||
)
|
||||
|
||||
check = gmail_inbound_domain_spoofing_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "PASS"
|
||||
assert "QUARANTINE" in findings[0].status_extended
|
||||
assert findings[0].resource_name == DOMAIN
|
||||
assert findings[0].customer_id == CUSTOMER_ID
|
||||
|
||||
def test_fail_no_action(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_inbound_domain_spoofing_protection_enabled.gmail_inbound_domain_spoofing_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_inbound_domain_spoofing_protection_enabled.gmail_inbound_domain_spoofing_protection_enabled import (
|
||||
gmail_inbound_domain_spoofing_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = True
|
||||
mock_client.policies = GmailPolicies(
|
||||
detect_inbound_domain_spoofing=True,
|
||||
inbound_domain_spoofing_consequence="NO_ACTION",
|
||||
)
|
||||
|
||||
check = gmail_inbound_domain_spoofing_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "FAIL"
|
||||
assert "no action" in findings[0].status_extended
|
||||
|
||||
def test_fail_protection_disabled(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_inbound_domain_spoofing_protection_enabled.gmail_inbound_domain_spoofing_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_inbound_domain_spoofing_protection_enabled.gmail_inbound_domain_spoofing_protection_enabled import (
|
||||
gmail_inbound_domain_spoofing_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = True
|
||||
mock_client.policies = GmailPolicies(
|
||||
detect_inbound_domain_spoofing=False,
|
||||
inbound_domain_spoofing_consequence="WARNING",
|
||||
)
|
||||
|
||||
check = gmail_inbound_domain_spoofing_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "FAIL"
|
||||
assert "disabled" in findings[0].status_extended
|
||||
|
||||
def test_pass_using_default(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_inbound_domain_spoofing_protection_enabled.gmail_inbound_domain_spoofing_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_inbound_domain_spoofing_protection_enabled.gmail_inbound_domain_spoofing_protection_enabled import (
|
||||
gmail_inbound_domain_spoofing_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = True
|
||||
mock_client.policies = GmailPolicies()
|
||||
|
||||
check = gmail_inbound_domain_spoofing_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "PASS"
|
||||
assert "secure default" in findings[0].status_extended
|
||||
|
||||
def test_no_findings_when_fetch_failed(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_inbound_domain_spoofing_protection_enabled.gmail_inbound_domain_spoofing_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_inbound_domain_spoofing_protection_enabled.gmail_inbound_domain_spoofing_protection_enabled import (
|
||||
gmail_inbound_domain_spoofing_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = False
|
||||
mock_client.policies = GmailPolicies()
|
||||
|
||||
check = gmail_inbound_domain_spoofing_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 0
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_service import GmailPolicies
|
||||
from tests.providers.googleworkspace.googleworkspace_fixtures import (
|
||||
CUSTOMER_ID,
|
||||
DOMAIN,
|
||||
set_mocked_googleworkspace_provider,
|
||||
)
|
||||
|
||||
|
||||
class TestGmailScriptAttachmentProtectionEnabled:
|
||||
def test_pass(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_script_attachment_protection_enabled.gmail_script_attachment_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_script_attachment_protection_enabled.gmail_script_attachment_protection_enabled import (
|
||||
gmail_script_attachment_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = True
|
||||
mock_client.policies = GmailPolicies(
|
||||
enable_script_attachment_protection=True,
|
||||
script_attachment_protection_consequence="QUARANTINE",
|
||||
)
|
||||
|
||||
check = gmail_script_attachment_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "PASS"
|
||||
assert "QUARANTINE" in findings[0].status_extended
|
||||
assert findings[0].resource_name == DOMAIN
|
||||
assert findings[0].customer_id == CUSTOMER_ID
|
||||
|
||||
def test_fail_no_action(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_script_attachment_protection_enabled.gmail_script_attachment_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_script_attachment_protection_enabled.gmail_script_attachment_protection_enabled import (
|
||||
gmail_script_attachment_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = True
|
||||
mock_client.policies = GmailPolicies(
|
||||
enable_script_attachment_protection=True,
|
||||
script_attachment_protection_consequence="NO_ACTION",
|
||||
)
|
||||
|
||||
check = gmail_script_attachment_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "FAIL"
|
||||
assert "no action" in findings[0].status_extended
|
||||
|
||||
def test_fail_protection_disabled(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_script_attachment_protection_enabled.gmail_script_attachment_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_script_attachment_protection_enabled.gmail_script_attachment_protection_enabled import (
|
||||
gmail_script_attachment_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = True
|
||||
mock_client.policies = GmailPolicies(
|
||||
enable_script_attachment_protection=False,
|
||||
script_attachment_protection_consequence="WARNING",
|
||||
)
|
||||
|
||||
check = gmail_script_attachment_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "FAIL"
|
||||
assert "disabled" in findings[0].status_extended
|
||||
|
||||
def test_pass_using_default(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_script_attachment_protection_enabled.gmail_script_attachment_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_script_attachment_protection_enabled.gmail_script_attachment_protection_enabled import (
|
||||
gmail_script_attachment_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = True
|
||||
mock_client.policies = GmailPolicies()
|
||||
|
||||
check = gmail_script_attachment_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "PASS"
|
||||
assert "secure default" in findings[0].status_extended
|
||||
|
||||
def test_no_findings_when_fetch_failed(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_script_attachment_protection_enabled.gmail_script_attachment_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_script_attachment_protection_enabled.gmail_script_attachment_protection_enabled import (
|
||||
gmail_script_attachment_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = False
|
||||
mock_client.policies = GmailPolicies()
|
||||
|
||||
check = gmail_script_attachment_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 0
|
||||
@@ -34,8 +34,11 @@ class TestGmailService:
|
||||
"setting": {
|
||||
"type": "settings/gmail.email_attachment_safety",
|
||||
"value": {
|
||||
"enableEncryptedAttachmentProtection": True,
|
||||
"encryptedAttachmentProtectionConsequence": "SPAM_FOLDER",
|
||||
"enableAttachmentWithScriptsProtection": True,
|
||||
"scriptAttachmentProtectionConsequence": "QUARANTINE",
|
||||
"enableAnomalousAttachmentProtection": True,
|
||||
"anomalousAttachmentProtectionConsequence": "WARNING",
|
||||
},
|
||||
}
|
||||
@@ -54,10 +57,15 @@ class TestGmailService:
|
||||
"setting": {
|
||||
"type": "settings/gmail.spoofing_and_authentication",
|
||||
"value": {
|
||||
"detectDomainNameSpoofing": True,
|
||||
"domainSpoofingConsequence": "SPAM_FOLDER",
|
||||
"detectEmployeeNameSpoofing": True,
|
||||
"employeeNameSpoofingConsequence": "SPAM_FOLDER",
|
||||
"detectDomainSpoofingFromUnauthenticatedSenders": True,
|
||||
"inboundDomainSpoofingConsequence": "QUARANTINE",
|
||||
"detectUnauthenticatedEmails": True,
|
||||
"unauthenticatedEmailConsequence": "WARNING",
|
||||
"detectGroupsSpoofing": True,
|
||||
"groupsSpoofingConsequence": "SPAM_FOLDER",
|
||||
},
|
||||
}
|
||||
@@ -121,23 +129,31 @@ class TestGmailService:
|
||||
|
||||
assert gmail.policies_fetched is True
|
||||
assert gmail.policies.enable_mail_delegation is False
|
||||
assert gmail.policies.enable_encrypted_attachment_protection is True
|
||||
assert (
|
||||
gmail.policies.encrypted_attachment_protection_consequence
|
||||
== "SPAM_FOLDER"
|
||||
)
|
||||
assert gmail.policies.enable_script_attachment_protection is True
|
||||
assert (
|
||||
gmail.policies.script_attachment_protection_consequence == "QUARANTINE"
|
||||
)
|
||||
assert gmail.policies.enable_anomalous_attachment_protection is True
|
||||
assert (
|
||||
gmail.policies.anomalous_attachment_protection_consequence == "WARNING"
|
||||
)
|
||||
assert gmail.policies.enable_shortener_scanning is True
|
||||
assert gmail.policies.enable_external_image_scanning is True
|
||||
assert gmail.policies.enable_aggressive_warnings_on_untrusted_links is True
|
||||
assert gmail.policies.detect_domain_name_spoofing is True
|
||||
assert gmail.policies.domain_spoofing_consequence == "SPAM_FOLDER"
|
||||
assert gmail.policies.detect_employee_name_spoofing is True
|
||||
assert gmail.policies.employee_name_spoofing_consequence == "SPAM_FOLDER"
|
||||
assert gmail.policies.detect_inbound_domain_spoofing is True
|
||||
assert gmail.policies.inbound_domain_spoofing_consequence == "QUARANTINE"
|
||||
assert gmail.policies.detect_unauthenticated_emails is True
|
||||
assert gmail.policies.unauthenticated_email_consequence == "WARNING"
|
||||
assert gmail.policies.detect_groups_spoofing is True
|
||||
assert gmail.policies.groups_spoofing_consequence == "SPAM_FOLDER"
|
||||
assert gmail.policies.enable_pop_access is False
|
||||
assert gmail.policies.enable_imap_access is False
|
||||
@@ -464,16 +480,24 @@ class TestGmailService:
|
||||
|
||||
policies = GmailPolicies(
|
||||
enable_mail_delegation=False,
|
||||
enable_encrypted_attachment_protection=True,
|
||||
encrypted_attachment_protection_consequence="SPAM_FOLDER",
|
||||
enable_script_attachment_protection=True,
|
||||
script_attachment_protection_consequence="QUARANTINE",
|
||||
enable_anomalous_attachment_protection=True,
|
||||
anomalous_attachment_protection_consequence="WARNING",
|
||||
enable_shortener_scanning=True,
|
||||
enable_external_image_scanning=True,
|
||||
enable_aggressive_warnings_on_untrusted_links=True,
|
||||
detect_domain_name_spoofing=True,
|
||||
domain_spoofing_consequence="SPAM_FOLDER",
|
||||
detect_employee_name_spoofing=True,
|
||||
employee_name_spoofing_consequence="SPAM_FOLDER",
|
||||
detect_inbound_domain_spoofing=True,
|
||||
inbound_domain_spoofing_consequence="QUARANTINE",
|
||||
detect_unauthenticated_emails=True,
|
||||
unauthenticated_email_consequence="WARNING",
|
||||
detect_groups_spoofing=True,
|
||||
groups_spoofing_consequence="SPAM_FOLDER",
|
||||
enable_pop_access=False,
|
||||
enable_imap_access=False,
|
||||
@@ -484,8 +508,10 @@ class TestGmailService:
|
||||
)
|
||||
|
||||
assert policies.enable_mail_delegation is False
|
||||
assert policies.enable_encrypted_attachment_protection is True
|
||||
assert policies.encrypted_attachment_protection_consequence == "SPAM_FOLDER"
|
||||
assert policies.enable_shortener_scanning is True
|
||||
assert policies.detect_domain_name_spoofing is True
|
||||
assert policies.domain_spoofing_consequence == "SPAM_FOLDER"
|
||||
assert policies.enable_pop_access is False
|
||||
assert policies.enable_auto_forwarding is False
|
||||
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_service import GmailPolicies
|
||||
from tests.providers.googleworkspace.googleworkspace_fixtures import (
|
||||
CUSTOMER_ID,
|
||||
DOMAIN,
|
||||
set_mocked_googleworkspace_provider,
|
||||
)
|
||||
|
||||
|
||||
class TestGmailUnauthenticatedEmailProtectionEnabled:
|
||||
def test_pass(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_unauthenticated_email_protection_enabled.gmail_unauthenticated_email_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_unauthenticated_email_protection_enabled.gmail_unauthenticated_email_protection_enabled import (
|
||||
gmail_unauthenticated_email_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = True
|
||||
mock_client.policies = GmailPolicies(
|
||||
detect_unauthenticated_emails=True,
|
||||
unauthenticated_email_consequence="WARNING",
|
||||
)
|
||||
|
||||
check = gmail_unauthenticated_email_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "PASS"
|
||||
assert "WARNING" in findings[0].status_extended
|
||||
assert findings[0].resource_name == DOMAIN
|
||||
assert findings[0].customer_id == CUSTOMER_ID
|
||||
|
||||
def test_fail_no_action(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_unauthenticated_email_protection_enabled.gmail_unauthenticated_email_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_unauthenticated_email_protection_enabled.gmail_unauthenticated_email_protection_enabled import (
|
||||
gmail_unauthenticated_email_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = True
|
||||
mock_client.policies = GmailPolicies(
|
||||
detect_unauthenticated_emails=True,
|
||||
unauthenticated_email_consequence="NO_ACTION",
|
||||
)
|
||||
|
||||
check = gmail_unauthenticated_email_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "FAIL"
|
||||
assert "no action" in findings[0].status_extended
|
||||
|
||||
def test_fail_protection_disabled(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_unauthenticated_email_protection_enabled.gmail_unauthenticated_email_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_unauthenticated_email_protection_enabled.gmail_unauthenticated_email_protection_enabled import (
|
||||
gmail_unauthenticated_email_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = True
|
||||
mock_client.policies = GmailPolicies(
|
||||
detect_unauthenticated_emails=False,
|
||||
unauthenticated_email_consequence="WARNING",
|
||||
)
|
||||
|
||||
check = gmail_unauthenticated_email_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "FAIL"
|
||||
assert "disabled" in findings[0].status_extended
|
||||
|
||||
def test_fail_no_policy_set(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_unauthenticated_email_protection_enabled.gmail_unauthenticated_email_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_unauthenticated_email_protection_enabled.gmail_unauthenticated_email_protection_enabled import (
|
||||
gmail_unauthenticated_email_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = True
|
||||
mock_client.policies = GmailPolicies()
|
||||
|
||||
check = gmail_unauthenticated_email_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "FAIL"
|
||||
assert "insecure default" in findings[0].status_extended
|
||||
|
||||
def test_no_findings_when_fetch_failed(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_unauthenticated_email_protection_enabled.gmail_unauthenticated_email_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_unauthenticated_email_protection_enabled.gmail_unauthenticated_email_protection_enabled import (
|
||||
gmail_unauthenticated_email_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = False
|
||||
mock_client.policies = GmailPolicies()
|
||||
|
||||
check = gmail_unauthenticated_email_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 0
|
||||
+3
-3
@@ -69,7 +69,7 @@ class TestGmailUntrustedLinkWarningsEnabled:
|
||||
assert findings[0].status == "FAIL"
|
||||
assert "disabled" in findings[0].status_extended
|
||||
|
||||
def test_pass_using_default(self):
|
||||
def test_fail_insecure_default(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
@@ -95,8 +95,8 @@ class TestGmailUntrustedLinkWarningsEnabled:
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "PASS"
|
||||
assert "secure default" in findings[0].status_extended
|
||||
assert findings[0].status == "FAIL"
|
||||
assert "insecure default" in findings[0].status_extended
|
||||
|
||||
def test_no_findings_when_fetch_failed(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
@@ -259,7 +259,13 @@ SAMPLE_TRIVY_VULNERABILITY_OUTPUT = {
|
||||
"Title": "Example vulnerability",
|
||||
"Description": "This is an example vulnerability",
|
||||
"Severity": "high",
|
||||
"PrimaryURL": "https://example.com/cve-2023-1234",
|
||||
"PrimaryURL": "https://avd.aquasec.com/nvd/cve-2023-1234",
|
||||
"References": [
|
||||
"https://avd.aquasec.com/nvd/cve-2023-1234",
|
||||
"https://nvd.nist.gov/vuln/detail/CVE-2023-1234",
|
||||
"https://www.cve.org/CVERecord?id=CVE-2023-1234",
|
||||
"https://security.example.com/advisories/CVE-2023-1234",
|
||||
],
|
||||
}
|
||||
],
|
||||
"Secrets": [],
|
||||
@@ -268,6 +274,39 @@ SAMPLE_TRIVY_VULNERABILITY_OUTPUT = {
|
||||
]
|
||||
}
|
||||
|
||||
SAMPLE_TRIVY_VULNERABILITY_WITHOUT_CVE_ORG_REFERENCE = {
|
||||
"VulnerabilityID": "CVE-2023-5678",
|
||||
"Title": "Vulnerability without cve.org reference",
|
||||
"Description": "This vulnerability includes references but no cve.org reference",
|
||||
"Severity": "high",
|
||||
"PrimaryURL": "https://avd.aquasec.com/nvd/cve-2023-5678",
|
||||
"References": [
|
||||
"https://avd.aquasec.com/nvd/cve-2023-5678",
|
||||
"https://nvd.nist.gov/vuln/detail/CVE-2023-5678",
|
||||
"https://security.example.com/advisories/CVE-2023-5678",
|
||||
],
|
||||
}
|
||||
|
||||
SAMPLE_TRIVY_VULNERABILITY_WITHOUT_REFERENCES = {
|
||||
"VulnerabilityID": "CVE-2023-9012",
|
||||
"Title": "Fallback CVE vulnerability",
|
||||
"Description": "This vulnerability requires building the URL from VulnerabilityID",
|
||||
"Severity": "medium",
|
||||
"PrimaryURL": "https://avd.aquasec.com/nvd/cve-2023-9012",
|
||||
}
|
||||
|
||||
SAMPLE_TRIVY_NON_CVE_VULNERABILITY = {
|
||||
"VulnerabilityID": "GHSA-abcd-1234-efgh",
|
||||
"Title": "Non-CVE vulnerability",
|
||||
"Description": "This advisory has no CVE identifier",
|
||||
"Severity": "high",
|
||||
"PrimaryURL": "https://avd.aquasec.com/nvd/ghsa-abcd-1234-efgh",
|
||||
"References": [
|
||||
"https://avd.aquasec.com/nvd/ghsa-abcd-1234-efgh",
|
||||
"https://github.com/advisories/GHSA-abcd-1234-efgh",
|
||||
],
|
||||
}
|
||||
|
||||
# Sample Trivy output with secrets
|
||||
SAMPLE_TRIVY_SECRET_OUTPUT = {
|
||||
"Results": [
|
||||
|
||||
@@ -20,6 +20,9 @@ from tests.providers.iac.iac_fixtures import (
|
||||
SAMPLE_KUBERNETES_CHECK,
|
||||
SAMPLE_PASSED_CHECK,
|
||||
SAMPLE_SKIPPED_CHECK,
|
||||
SAMPLE_TRIVY_NON_CVE_VULNERABILITY,
|
||||
SAMPLE_TRIVY_VULNERABILITY_WITHOUT_CVE_ORG_REFERENCE,
|
||||
SAMPLE_TRIVY_VULNERABILITY_WITHOUT_REFERENCES,
|
||||
SAMPLE_YAML_CHECK,
|
||||
get_empty_trivy_output,
|
||||
get_invalid_trivy_output,
|
||||
@@ -57,13 +60,15 @@ class TestIacProvider:
|
||||
assert isinstance(report, CheckReportIAC)
|
||||
assert report.status == "FAIL"
|
||||
|
||||
# Trivy emits "AVD-AWS-0001"; Hub indexes it without the AVD- prefix.
|
||||
expected_url = "https://hub.prowler.com/check/AWS-0001"
|
||||
assert report.check_metadata.Provider == "iac"
|
||||
assert report.check_metadata.CheckID == SAMPLE_FAILED_CHECK["ID"]
|
||||
assert report.check_metadata.CheckTitle == SAMPLE_FAILED_CHECK["Title"]
|
||||
assert report.check_metadata.Severity == "low"
|
||||
assert report.check_metadata.RelatedUrl == SAMPLE_FAILED_CHECK.get(
|
||||
"PrimaryURL", ""
|
||||
)
|
||||
assert report.check_metadata.Remediation.Recommendation.Url == expected_url
|
||||
assert report.check_metadata.RelatedUrl == ""
|
||||
assert report.check_metadata.AdditionalURLs == [expected_url]
|
||||
|
||||
def test_iac_provider_process_finding_passed(self):
|
||||
"""Test processing a passed finding"""
|
||||
@@ -79,6 +84,101 @@ class TestIacProvider:
|
||||
assert report.check_metadata.CheckTitle == SAMPLE_PASSED_CHECK["Title"]
|
||||
assert report.check_metadata.Severity == "low"
|
||||
|
||||
def test_iac_provider_process_vulnerability_prefers_cve_reference_and_filters_aqua(
|
||||
self,
|
||||
):
|
||||
"""Test CVE findings use cve.org and exclude Aqua references."""
|
||||
provider = IacProvider()
|
||||
|
||||
report = provider._process_finding(
|
||||
{
|
||||
"VulnerabilityID": "CVE-2023-1234",
|
||||
"Title": "Example vulnerability",
|
||||
"Description": "This is an example vulnerability",
|
||||
"Severity": "high",
|
||||
"PrimaryURL": "https://avd.aquasec.com/nvd/cve-2023-1234",
|
||||
"References": [
|
||||
"https://avd.aquasec.com/nvd/cve-2023-1234",
|
||||
"https://nvd.nist.gov/vuln/detail/CVE-2023-1234",
|
||||
"https://www.cve.org/CVERecord?id=CVE-2023-1234",
|
||||
"https://security.example.com/advisories/CVE-2023-1234",
|
||||
],
|
||||
},
|
||||
"package.json",
|
||||
"nodejs",
|
||||
)
|
||||
|
||||
assert (
|
||||
report.check_metadata.Remediation.Recommendation.Url
|
||||
== "https://www.cve.org/CVERecord?id=CVE-2023-1234"
|
||||
)
|
||||
assert report.check_metadata.RelatedUrl == ""
|
||||
assert report.check_metadata.AdditionalURLs == [
|
||||
"https://www.cve.org/CVERecord?id=CVE-2023-1234"
|
||||
]
|
||||
|
||||
def test_iac_provider_process_vulnerability_builds_cve_org_for_nvd_reference(
|
||||
self,
|
||||
):
|
||||
"""Test official CVE URL is built when only NVD is provided."""
|
||||
provider = IacProvider()
|
||||
|
||||
report = provider._process_finding(
|
||||
SAMPLE_TRIVY_VULNERABILITY_WITHOUT_CVE_ORG_REFERENCE,
|
||||
"package.json",
|
||||
"nodejs",
|
||||
)
|
||||
|
||||
assert (
|
||||
report.check_metadata.Remediation.Recommendation.Url
|
||||
== "https://www.cve.org/CVERecord?id=CVE-2023-5678"
|
||||
)
|
||||
assert report.check_metadata.RelatedUrl == ""
|
||||
assert report.check_metadata.AdditionalURLs == [
|
||||
"https://www.cve.org/CVERecord?id=CVE-2023-5678"
|
||||
]
|
||||
|
||||
def test_iac_provider_process_vulnerability_builds_cve_org_when_references_missing(
|
||||
self,
|
||||
):
|
||||
"""Test CVE URL is built from VulnerabilityID when references are absent."""
|
||||
provider = IacProvider()
|
||||
|
||||
report = provider._process_finding(
|
||||
SAMPLE_TRIVY_VULNERABILITY_WITHOUT_REFERENCES,
|
||||
"package.json",
|
||||
"nodejs",
|
||||
)
|
||||
|
||||
assert (
|
||||
report.check_metadata.Remediation.Recommendation.Url
|
||||
== "https://www.cve.org/CVERecord?id=CVE-2023-9012"
|
||||
)
|
||||
assert report.check_metadata.RelatedUrl == ""
|
||||
assert report.check_metadata.AdditionalURLs == [
|
||||
"https://www.cve.org/CVERecord?id=CVE-2023-9012"
|
||||
]
|
||||
|
||||
def test_iac_provider_process_non_cve_vulnerability_falls_back_to_github_advisory(
|
||||
self,
|
||||
):
|
||||
"""Non-CVE vulnerabilities (GHSA-…) point to GitHub Security Advisories."""
|
||||
provider = IacProvider()
|
||||
|
||||
report = provider._process_finding(
|
||||
SAMPLE_TRIVY_NON_CVE_VULNERABILITY,
|
||||
"package.json",
|
||||
"nodejs",
|
||||
)
|
||||
|
||||
expected_url = (
|
||||
"https://github.com/advisories/"
|
||||
f"{SAMPLE_TRIVY_NON_CVE_VULNERABILITY['VulnerabilityID'].upper()}"
|
||||
)
|
||||
assert report.check_metadata.Remediation.Recommendation.Url == expected_url
|
||||
assert report.check_metadata.RelatedUrl == ""
|
||||
assert report.check_metadata.AdditionalURLs == [expected_url]
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_iac_provider_run_scan_success(self, mock_subprocess):
|
||||
"""Test successful IAC scan with Trivy"""
|
||||
|
||||
@@ -11,6 +11,12 @@ SAMPLE_VULNERABILITY_FINDING = {
|
||||
"Title": "OpenSSL Buffer Overflow",
|
||||
"Description": "A buffer overflow vulnerability in OpenSSL allows remote attackers to execute arbitrary code.",
|
||||
"PrimaryURL": "https://avd.aquasec.com/nvd/cve-2024-1234",
|
||||
"References": [
|
||||
"https://avd.aquasec.com/nvd/cve-2024-1234",
|
||||
"https://nvd.nist.gov/vuln/detail/CVE-2024-1234",
|
||||
"https://www.cve.org/CVERecord?id=CVE-2024-1234",
|
||||
"https://security.alpinelinux.org/vuln/CVE-2024-1234",
|
||||
],
|
||||
}
|
||||
|
||||
# Sample secret finding from Trivy
|
||||
@@ -45,6 +51,50 @@ SAMPLE_UNKNOWN_SEVERITY_FINDING = {
|
||||
"Description": "An issue with unknown severity.",
|
||||
}
|
||||
|
||||
SAMPLE_VULNERABILITY_WITHOUT_CVE_ORG_REFERENCE = {
|
||||
"VulnerabilityID": "CVE-2024-5678",
|
||||
"PkgID": "libcrypto3@3.3.2-r0",
|
||||
"PkgName": "libcrypto3",
|
||||
"InstalledVersion": "3.3.2-r0",
|
||||
"FixedVersion": "3.3.2-r1",
|
||||
"Severity": "HIGH",
|
||||
"Title": "OpenSSL advisory without cve.org reference",
|
||||
"Description": "A vulnerability with references but no cve.org reference.",
|
||||
"PrimaryURL": "https://avd.aquasec.com/nvd/cve-2024-5678",
|
||||
"References": [
|
||||
"https://avd.aquasec.com/nvd/cve-2024-5678",
|
||||
"https://nvd.nist.gov/vuln/detail/CVE-2024-5678",
|
||||
"https://security.alpinelinux.org/vuln/CVE-2024-5678",
|
||||
],
|
||||
}
|
||||
|
||||
SAMPLE_CVE_WITHOUT_REFERENCES_FINDING = {
|
||||
"VulnerabilityID": "CVE-2024-9012",
|
||||
"PkgID": "busybox@1.36.1-r8",
|
||||
"PkgName": "busybox",
|
||||
"InstalledVersion": "1.36.1-r8",
|
||||
"FixedVersion": "1.36.1-r9",
|
||||
"Severity": "MEDIUM",
|
||||
"Title": "Busybox fallback CVE",
|
||||
"Description": "A vulnerability without explicit references.",
|
||||
"PrimaryURL": "https://avd.aquasec.com/nvd/cve-2024-9012",
|
||||
}
|
||||
|
||||
SAMPLE_NON_CVE_VULNERABILITY_FINDING = {
|
||||
"VulnerabilityID": "GHSA-abcd-1234-efgh",
|
||||
"PkgID": "custompkg@0.0.1",
|
||||
"PkgName": "custompkg",
|
||||
"InstalledVersion": "0.0.1",
|
||||
"Severity": "HIGH",
|
||||
"Title": "Non-CVE advisory",
|
||||
"Description": "An advisory without a CVE identifier.",
|
||||
"PrimaryURL": "https://avd.aquasec.com/nvd/ghsa-abcd-1234-efgh",
|
||||
"References": [
|
||||
"https://avd.aquasec.com/nvd/ghsa-abcd-1234-efgh",
|
||||
"https://github.com/advisories/GHSA-abcd-1234-efgh",
|
||||
],
|
||||
}
|
||||
|
||||
# Sample image SHA for testing (first 12 chars of a sha256 digest)
|
||||
SAMPLE_IMAGE_SHA = "c1aabb73d233"
|
||||
SAMPLE_IMAGE_ID = f"sha256:{SAMPLE_IMAGE_SHA}abcdef1234567890"
|
||||
|
||||
@@ -23,11 +23,14 @@ from prowler.providers.image.exceptions.exceptions import (
|
||||
)
|
||||
from prowler.providers.image.image_provider import ImageProvider
|
||||
from tests.providers.image.image_fixtures import (
|
||||
SAMPLE_CVE_WITHOUT_REFERENCES_FINDING,
|
||||
SAMPLE_IMAGE_SHA,
|
||||
SAMPLE_MISCONFIGURATION_FINDING,
|
||||
SAMPLE_NON_CVE_VULNERABILITY_FINDING,
|
||||
SAMPLE_SECRET_FINDING,
|
||||
SAMPLE_UNKNOWN_SEVERITY_FINDING,
|
||||
SAMPLE_VULNERABILITY_FINDING,
|
||||
SAMPLE_VULNERABILITY_WITHOUT_CVE_ORG_REFERENCE,
|
||||
get_empty_trivy_output,
|
||||
get_invalid_trivy_output,
|
||||
get_multi_type_trivy_output,
|
||||
@@ -148,6 +151,77 @@ class TestImageProvider:
|
||||
assert report.check_metadata.Categories == ["vulnerabilities"]
|
||||
assert report.check_metadata.RelatedUrl == ""
|
||||
|
||||
def test_process_finding_vulnerability_prefers_cve_reference_and_filters_aqua(self):
|
||||
"""Test CVE findings use cve.org and exclude Aqua references."""
|
||||
provider = _make_provider()
|
||||
|
||||
report = provider._process_finding(
|
||||
SAMPLE_VULNERABILITY_FINDING,
|
||||
"alpine:3.18",
|
||||
"alpine:3.18 (alpine 3.18.0)",
|
||||
)
|
||||
|
||||
assert (
|
||||
report.check_metadata.Remediation.Recommendation.Url
|
||||
== "https://www.cve.org/CVERecord?id=CVE-2024-1234"
|
||||
)
|
||||
assert report.check_metadata.AdditionalURLs == [
|
||||
"https://www.cve.org/CVERecord?id=CVE-2024-1234"
|
||||
]
|
||||
|
||||
def test_process_finding_vulnerability_builds_cve_org_when_only_nvd_reference(
|
||||
self,
|
||||
):
|
||||
"""Test official CVE URL is built when only NVD is provided."""
|
||||
provider = _make_provider()
|
||||
|
||||
report = provider._process_finding(
|
||||
SAMPLE_VULNERABILITY_WITHOUT_CVE_ORG_REFERENCE,
|
||||
"alpine:3.18",
|
||||
"alpine:3.18 (alpine 3.18.0)",
|
||||
)
|
||||
|
||||
assert (
|
||||
report.check_metadata.Remediation.Recommendation.Url
|
||||
== "https://www.cve.org/CVERecord?id=CVE-2024-5678"
|
||||
)
|
||||
assert report.check_metadata.AdditionalURLs == [
|
||||
"https://www.cve.org/CVERecord?id=CVE-2024-5678"
|
||||
]
|
||||
|
||||
def test_process_finding_vulnerability_builds_cve_org_when_references_missing(self):
|
||||
"""Test CVE URL is built from VulnerabilityID when references are absent."""
|
||||
provider = _make_provider()
|
||||
|
||||
report = provider._process_finding(
|
||||
SAMPLE_CVE_WITHOUT_REFERENCES_FINDING,
|
||||
"alpine:3.18",
|
||||
"alpine:3.18 (alpine 3.18.0)",
|
||||
)
|
||||
|
||||
assert (
|
||||
report.check_metadata.Remediation.Recommendation.Url
|
||||
== "https://www.cve.org/CVERecord?id=CVE-2024-9012"
|
||||
)
|
||||
assert report.check_metadata.AdditionalURLs == [
|
||||
"https://www.cve.org/CVERecord?id=CVE-2024-9012"
|
||||
]
|
||||
|
||||
def test_process_finding_non_cve_vulnerability_does_not_fallback_to_aqua(self):
|
||||
"""Test non-CVE vulnerabilities do not keep Aqua links."""
|
||||
provider = _make_provider()
|
||||
|
||||
report = provider._process_finding(
|
||||
SAMPLE_NON_CVE_VULNERABILITY_FINDING,
|
||||
"alpine:3.18",
|
||||
"alpine:3.18 (alpine 3.18.0)",
|
||||
)
|
||||
|
||||
assert report.check_metadata.Remediation.Recommendation.Url == ""
|
||||
assert report.check_metadata.AdditionalURLs == [
|
||||
"https://github.com/advisories/GHSA-abcd-1234-efgh"
|
||||
]
|
||||
|
||||
def test_process_finding_secret(self):
|
||||
"""Test processing a secret finding (identified by RuleID)."""
|
||||
provider = _make_provider()
|
||||
|
||||
+124
@@ -67,6 +67,7 @@ class Test_entra_break_glass_account_fido2_security_key_registered:
|
||||
entra_client = mock.MagicMock
|
||||
entra_client.audited_tenant = "audited_tenant"
|
||||
entra_client.audited_domain = DOMAIN
|
||||
entra_client.user_registration_details_error = None
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
@@ -104,6 +105,7 @@ class Test_entra_break_glass_account_fido2_security_key_registered:
|
||||
entra_client = mock.MagicMock
|
||||
entra_client.audited_tenant = "audited_tenant"
|
||||
entra_client.audited_domain = DOMAIN
|
||||
entra_client.user_registration_details_error = None
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
@@ -142,6 +144,7 @@ class Test_entra_break_glass_account_fido2_security_key_registered:
|
||||
entra_client = mock.MagicMock
|
||||
entra_client.audited_tenant = "audited_tenant"
|
||||
entra_client.audited_domain = DOMAIN
|
||||
entra_client.user_registration_details_error = None
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
@@ -178,6 +181,7 @@ class Test_entra_break_glass_account_fido2_security_key_registered:
|
||||
entra_client = mock.MagicMock
|
||||
entra_client.audited_tenant = "audited_tenant"
|
||||
entra_client.audited_domain = DOMAIN
|
||||
entra_client.user_registration_details_error = None
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
@@ -228,6 +232,7 @@ class Test_entra_break_glass_account_fido2_security_key_registered:
|
||||
entra_client = mock.MagicMock
|
||||
entra_client.audited_tenant = "audited_tenant"
|
||||
entra_client.audited_domain = DOMAIN
|
||||
entra_client.user_registration_details_error = None
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
@@ -275,6 +280,7 @@ class Test_entra_break_glass_account_fido2_security_key_registered:
|
||||
entra_client = mock.MagicMock
|
||||
entra_client.audited_tenant = "audited_tenant"
|
||||
entra_client.audited_domain = DOMAIN
|
||||
entra_client.user_registration_details_error = None
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
@@ -321,6 +327,7 @@ class Test_entra_break_glass_account_fido2_security_key_registered:
|
||||
entra_client = mock.MagicMock
|
||||
entra_client.audited_tenant = "audited_tenant"
|
||||
entra_client.audited_domain = DOMAIN
|
||||
entra_client.user_registration_details_error = None
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
@@ -368,6 +375,7 @@ class Test_entra_break_glass_account_fido2_security_key_registered:
|
||||
entra_client = mock.MagicMock
|
||||
entra_client.audited_tenant = "audited_tenant"
|
||||
entra_client.audited_domain = DOMAIN
|
||||
entra_client.user_registration_details_error = None
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
@@ -422,6 +430,7 @@ class Test_entra_break_glass_account_fido2_security_key_registered:
|
||||
entra_client = mock.MagicMock
|
||||
entra_client.audited_tenant = "audited_tenant"
|
||||
entra_client.audited_domain = DOMAIN
|
||||
entra_client.user_registration_details_error = None
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
@@ -457,6 +466,7 @@ class Test_entra_break_glass_account_fido2_security_key_registered:
|
||||
entra_client = mock.MagicMock
|
||||
entra_client.audited_tenant = "audited_tenant"
|
||||
entra_client.audited_domain = DOMAIN
|
||||
entra_client.user_registration_details_error = None
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
@@ -500,3 +510,117 @@ class Test_entra_break_glass_account_fido2_security_key_registered:
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
assert result[0].resource_name == "BreakGlass1"
|
||||
|
||||
def test_user_registration_details_permission_error(self):
|
||||
"""Test FAIL when there's a permission error reading user registration details."""
|
||||
entra_client = mock.MagicMock
|
||||
entra_client.audited_tenant = "audited_tenant"
|
||||
entra_client.audited_domain = DOMAIN
|
||||
entra_client.user_registration_details_error = "Insufficient privileges to read user registration details. Required permission: AuditLog.Read.All"
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_m365_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
f"{CHECK_MODULE_PATH}.entra_client",
|
||||
new=entra_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.m365.services.entra.entra_break_glass_account_fido2_security_key_registered.entra_break_glass_account_fido2_security_key_registered import (
|
||||
entra_break_glass_account_fido2_security_key_registered,
|
||||
)
|
||||
|
||||
policy_id = str(uuid4())
|
||||
bg_user_id = str(uuid4())
|
||||
|
||||
entra_client.conditional_access_policies = {
|
||||
policy_id: _make_policy(policy_id, excluded_users=[bg_user_id]),
|
||||
}
|
||||
entra_client.users = {
|
||||
bg_user_id: User(
|
||||
id=bg_user_id,
|
||||
name="BreakGlass1",
|
||||
on_premises_sync_enabled=False,
|
||||
authentication_methods=[],
|
||||
),
|
||||
}
|
||||
|
||||
check = entra_break_glass_account_fido2_security_key_registered()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert (
|
||||
"Cannot verify FIDO2 security key registration for break glass account BreakGlass1"
|
||||
in result[0].status_extended
|
||||
)
|
||||
assert "AuditLog.Read.All" in result[0].status_extended
|
||||
assert result[0].resource_name == "BreakGlass1"
|
||||
assert result[0].resource_id == bg_user_id
|
||||
|
||||
def test_user_registration_details_permission_error_with_missing_user(self):
|
||||
"""Per-user emission and missing-user short-circuit on the error path.
|
||||
|
||||
Two break-glass user IDs are excluded from all CAPs, but only one is
|
||||
present in ``entra_client.users``. With ``user_registration_details_error``
|
||||
set, the present user must produce one preventive FAIL anchored to the
|
||||
real user; the missing user must be skipped by the existing
|
||||
``if not user: continue`` guard rather than crash or yield a synthetic
|
||||
finding.
|
||||
"""
|
||||
entra_client = mock.MagicMock
|
||||
entra_client.audited_tenant = "audited_tenant"
|
||||
entra_client.audited_domain = DOMAIN
|
||||
entra_client.user_registration_details_error = "Insufficient privileges to read user registration details. Required permission: AuditLog.Read.All"
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_m365_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
f"{CHECK_MODULE_PATH}.entra_client",
|
||||
new=entra_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.m365.services.entra.entra_break_glass_account_fido2_security_key_registered.entra_break_glass_account_fido2_security_key_registered import (
|
||||
entra_break_glass_account_fido2_security_key_registered,
|
||||
)
|
||||
|
||||
policy_id = str(uuid4())
|
||||
present_user_id = str(uuid4())
|
||||
missing_user_id = str(uuid4())
|
||||
|
||||
entra_client.conditional_access_policies = {
|
||||
policy_id: _make_policy(
|
||||
policy_id,
|
||||
excluded_users=[present_user_id, missing_user_id],
|
||||
),
|
||||
}
|
||||
entra_client.users = {
|
||||
present_user_id: User(
|
||||
id=present_user_id,
|
||||
name="BreakGlass1",
|
||||
on_premises_sync_enabled=False,
|
||||
authentication_methods=[],
|
||||
),
|
||||
# missing_user_id intentionally absent — exercises the
|
||||
# `if not user: continue` short-circuit inside the loop.
|
||||
}
|
||||
|
||||
check = entra_break_glass_account_fido2_security_key_registered()
|
||||
result = check.execute()
|
||||
|
||||
# One finding for the present user; the missing one is skipped.
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert (
|
||||
"Cannot verify FIDO2 security key registration for break glass account BreakGlass1"
|
||||
in result[0].status_extended
|
||||
)
|
||||
assert "AuditLog.Read.All" in result[0].status_extended
|
||||
assert result[0].resource == entra_client.users[present_user_id]
|
||||
assert result[0].resource_name == "BreakGlass1"
|
||||
assert result[0].resource_id == present_user_id
|
||||
|
||||
+131
@@ -11,6 +11,7 @@ class Test_entra_users_mfa_capable:
|
||||
entra_client = mock.MagicMock
|
||||
entra_client.audited_tenant = "audited_tenant"
|
||||
entra_client.audited_domain = DOMAIN
|
||||
entra_client.user_registration_details_error = None
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
@@ -53,6 +54,7 @@ class Test_entra_users_mfa_capable:
|
||||
entra_client = mock.MagicMock
|
||||
entra_client.audited_tenant = "audited_tenant"
|
||||
entra_client.audited_domain = DOMAIN
|
||||
entra_client.user_registration_details_error = None
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
@@ -95,6 +97,7 @@ class Test_entra_users_mfa_capable:
|
||||
entra_client = mock.MagicMock
|
||||
entra_client.audited_tenant = "audited_tenant"
|
||||
entra_client.audited_domain = DOMAIN
|
||||
entra_client.user_registration_details_error = None
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
@@ -153,6 +156,7 @@ class Test_entra_users_mfa_capable:
|
||||
entra_client = mock.MagicMock
|
||||
entra_client.audited_tenant = "audited_tenant"
|
||||
entra_client.audited_domain = DOMAIN
|
||||
entra_client.user_registration_details_error = None
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
@@ -191,6 +195,7 @@ class Test_entra_users_mfa_capable:
|
||||
entra_client = mock.MagicMock
|
||||
entra_client.audited_tenant = "audited_tenant"
|
||||
entra_client.audited_domain = DOMAIN
|
||||
entra_client.user_registration_details_error = None
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
@@ -248,6 +253,7 @@ class Test_entra_users_mfa_capable:
|
||||
entra_client = mock.MagicMock
|
||||
entra_client.audited_tenant = "audited_tenant"
|
||||
entra_client.audited_domain = DOMAIN
|
||||
entra_client.user_registration_details_error = None
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
@@ -286,6 +292,7 @@ class Test_entra_users_mfa_capable:
|
||||
entra_client = mock.MagicMock
|
||||
entra_client.audited_tenant = "audited_tenant"
|
||||
entra_client.audited_domain = DOMAIN
|
||||
entra_client.user_registration_details_error = None
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
@@ -324,6 +331,7 @@ class Test_entra_users_mfa_capable:
|
||||
entra_client = mock.MagicMock
|
||||
entra_client.audited_tenant = "audited_tenant"
|
||||
entra_client.audited_domain = DOMAIN
|
||||
entra_client.user_registration_details_error = None
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
@@ -383,6 +391,7 @@ class Test_entra_users_mfa_capable:
|
||||
entra_client = mock.MagicMock
|
||||
entra_client.audited_tenant = "audited_tenant"
|
||||
entra_client.audited_domain = DOMAIN
|
||||
entra_client.user_registration_details_error = None
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
@@ -420,3 +429,125 @@ class Test_entra_users_mfa_capable:
|
||||
assert result[0].resource == entra_client.users[user_id]
|
||||
assert result[0].resource_name == "Test User"
|
||||
assert result[0].resource_id == user_id
|
||||
|
||||
def test_user_registration_details_permission_error(self):
|
||||
"""Test FAIL when there's a permission error reading user registration details."""
|
||||
entra_client = mock.MagicMock
|
||||
entra_client.audited_tenant = "audited_tenant"
|
||||
entra_client.audited_domain = DOMAIN
|
||||
entra_client.user_registration_details_error = "Insufficient privileges to read user registration details. Required permission: AuditLog.Read.All"
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_m365_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.m365.services.entra.entra_users_mfa_capable.entra_users_mfa_capable.entra_client",
|
||||
new=entra_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.m365.services.entra.entra_users_mfa_capable.entra_users_mfa_capable import (
|
||||
entra_users_mfa_capable,
|
||||
)
|
||||
|
||||
user_id = str(uuid4())
|
||||
entra_client.users = {
|
||||
user_id: User(
|
||||
id=user_id,
|
||||
name="Test User",
|
||||
on_premises_sync_enabled=False,
|
||||
directory_roles_ids=[],
|
||||
is_mfa_capable=False,
|
||||
account_enabled=True,
|
||||
)
|
||||
}
|
||||
|
||||
check = entra_users_mfa_capable()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert (
|
||||
"Cannot verify MFA capability for user Test User"
|
||||
in result[0].status_extended
|
||||
)
|
||||
assert "AuditLog.Read.All" in result[0].status_extended
|
||||
assert result[0].resource == entra_client.users[user_id]
|
||||
assert result[0].resource_name == "Test User"
|
||||
assert result[0].resource_id == user_id
|
||||
|
||||
def test_user_registration_details_permission_error_skips_guest_and_disabled(self):
|
||||
"""CIS-scope skip (Guest, disabled) still applies on the permission-error path.
|
||||
|
||||
With ``user_registration_details_error`` set, only enabled member users
|
||||
should receive a per-user "Cannot verify MFA capability" FAIL — guests
|
||||
and disabled members are filtered out before the error branch runs.
|
||||
"""
|
||||
entra_client = mock.MagicMock
|
||||
entra_client.audited_tenant = "audited_tenant"
|
||||
entra_client.audited_domain = DOMAIN
|
||||
entra_client.user_registration_details_error = "Insufficient privileges to read user registration details. Required permission: AuditLog.Read.All"
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_m365_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.m365.services.entra.entra_users_mfa_capable.entra_users_mfa_capable.entra_client",
|
||||
new=entra_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.m365.services.entra.entra_users_mfa_capable.entra_users_mfa_capable import (
|
||||
entra_users_mfa_capable,
|
||||
)
|
||||
|
||||
member_id = str(uuid4())
|
||||
guest_id = str(uuid4())
|
||||
disabled_member_id = str(uuid4())
|
||||
entra_client.users = {
|
||||
member_id: User(
|
||||
id=member_id,
|
||||
name="Enabled Member",
|
||||
on_premises_sync_enabled=False,
|
||||
directory_roles_ids=[],
|
||||
is_mfa_capable=False,
|
||||
account_enabled=True,
|
||||
user_type="Member",
|
||||
),
|
||||
guest_id: User(
|
||||
id=guest_id,
|
||||
name="Guest User",
|
||||
on_premises_sync_enabled=False,
|
||||
directory_roles_ids=[],
|
||||
is_mfa_capable=False,
|
||||
account_enabled=True,
|
||||
user_type="Guest",
|
||||
),
|
||||
disabled_member_id: User(
|
||||
id=disabled_member_id,
|
||||
name="Disabled Member",
|
||||
on_premises_sync_enabled=False,
|
||||
directory_roles_ids=[],
|
||||
is_mfa_capable=False,
|
||||
account_enabled=False,
|
||||
user_type="Member",
|
||||
),
|
||||
}
|
||||
|
||||
check = entra_users_mfa_capable()
|
||||
result = check.execute()
|
||||
|
||||
# Only the enabled member should be reported — Guest and
|
||||
# disabled member are skipped before the error branch.
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert (
|
||||
"Cannot verify MFA capability for user Enabled Member"
|
||||
in result[0].status_extended
|
||||
)
|
||||
assert "AuditLog.Read.All" in result[0].status_extended
|
||||
assert result[0].resource == entra_client.users[member_id]
|
||||
assert result[0].resource_name == "Enabled Member"
|
||||
assert result[0].resource_id == member_id
|
||||
|
||||
@@ -521,10 +521,26 @@ class Test_Entra_Service:
|
||||
|
||||
assert len(users) == 6
|
||||
assert users_builder.get.await_count == 1
|
||||
assert users_builder.get.await_args.kwargs == {}
|
||||
# The Graph users.get() call must request accountEnabled, userType and
|
||||
# onPremisesSyncEnabled via $select. They are not part of the default
|
||||
# property set, and omitting them causes disabled guest users to leak
|
||||
# into checks like entra_users_mfa_capable (issue #10921).
|
||||
request_configuration = users_builder.get.await_args.kwargs[
|
||||
"request_configuration"
|
||||
]
|
||||
assert set(request_configuration.query_parameters.select) == {
|
||||
"id",
|
||||
"displayName",
|
||||
"userType",
|
||||
"accountEnabled",
|
||||
"onPremisesSyncEnabled",
|
||||
}
|
||||
with_url_mock.assert_called_once_with("next-link")
|
||||
assert users["user-1"].directory_roles_ids == ["role-template-1"]
|
||||
assert users["user-6"].directory_roles_ids == ["role-template-1"]
|
||||
# When Graph does not return accountEnabled (legacy SimpleNamespace
|
||||
# fixtures) we still honour the EXO PowerShell fallback for backwards
|
||||
# compatibility.
|
||||
assert users["user-6"].account_enabled is False
|
||||
assert users["user-1"].is_mfa_capable is True
|
||||
assert users["user-2"].is_mfa_capable is False
|
||||
@@ -532,6 +548,81 @@ class Test_Entra_Service:
|
||||
assert users["user-6"].authentication_methods == ["mobilePhone"]
|
||||
assert users["user-2"].authentication_methods == []
|
||||
|
||||
def test__get_users_uses_graph_account_enabled_for_disabled_guests(self):
|
||||
"""Regression test for https://github.com/prowler-cloud/prowler/issues/10921.
|
||||
|
||||
Disabled guest users do not appear in EXO's ``Get-User`` output, so the
|
||||
previous code resolved their ``account_enabled`` from the EXO map,
|
||||
defaulted it to ``True`` and surfaced them as failing findings in
|
||||
``entra_users_mfa_capable``. The Graph ``accountEnabled`` value must be
|
||||
used as the source of truth so disabled guests are excluded.
|
||||
"""
|
||||
entra_service = Entra.__new__(Entra)
|
||||
# Empty EXO map mirrors the production scenario where the disabled guest
|
||||
# is absent from Get-User results.
|
||||
entra_service.user_accounts_status = {}
|
||||
|
||||
graph_users = [
|
||||
SimpleNamespace(
|
||||
id="member-1",
|
||||
display_name="Member User",
|
||||
on_premises_sync_enabled=False,
|
||||
account_enabled=True,
|
||||
user_type="Member",
|
||||
),
|
||||
SimpleNamespace(
|
||||
id="guest-1",
|
||||
display_name="Disabled Guest",
|
||||
on_premises_sync_enabled=False,
|
||||
account_enabled=False,
|
||||
user_type="Guest",
|
||||
),
|
||||
SimpleNamespace(
|
||||
id="guest-2",
|
||||
display_name="Enabled Guest",
|
||||
on_premises_sync_enabled=False,
|
||||
account_enabled=True,
|
||||
user_type="Guest",
|
||||
),
|
||||
]
|
||||
users_response = SimpleNamespace(
|
||||
value=graph_users,
|
||||
odata_next_link=None,
|
||||
)
|
||||
users_builder = SimpleNamespace(
|
||||
get=AsyncMock(return_value=users_response),
|
||||
with_url=MagicMock(),
|
||||
)
|
||||
directory_roles_builder = SimpleNamespace(
|
||||
get=AsyncMock(return_value=SimpleNamespace(value=[])),
|
||||
by_directory_role_id=MagicMock(),
|
||||
)
|
||||
registration_details_builder = SimpleNamespace(
|
||||
get=AsyncMock(return_value=SimpleNamespace(value=[], odata_next_link=None)),
|
||||
with_url=MagicMock(),
|
||||
)
|
||||
reports_builder = SimpleNamespace(
|
||||
authentication_methods=SimpleNamespace(
|
||||
user_registration_details=registration_details_builder
|
||||
)
|
||||
)
|
||||
|
||||
entra_service.client = SimpleNamespace(
|
||||
users=users_builder,
|
||||
directory_roles=directory_roles_builder,
|
||||
reports=reports_builder,
|
||||
)
|
||||
|
||||
users = asyncio.run(entra_service._get_users())
|
||||
|
||||
assert len(users) == 3
|
||||
assert users["member-1"].account_enabled is True
|
||||
assert users["member-1"].user_type == "Member"
|
||||
assert users["guest-1"].account_enabled is False
|
||||
assert users["guest-1"].user_type == "Guest"
|
||||
assert users["guest-2"].account_enabled is True
|
||||
assert users["guest-2"].user_type == "Guest"
|
||||
|
||||
def test__get_user_registration_details_handles_pagination(self):
|
||||
entra_service = Entra.__new__(Entra)
|
||||
|
||||
@@ -573,10 +664,11 @@ class Test_Entra_Service:
|
||||
)
|
||||
)
|
||||
|
||||
registration_details = asyncio.run(
|
||||
registration_details, error_message = asyncio.run(
|
||||
entra_service._get_user_registration_details()
|
||||
)
|
||||
|
||||
assert error_message is None
|
||||
assert registration_details == {
|
||||
"user-1": {
|
||||
"is_mfa_capable": True,
|
||||
@@ -593,3 +685,34 @@ class Test_Entra_Service:
|
||||
registration_builder.get.assert_awaited()
|
||||
registration_builder.with_url.assert_called_once_with("next-link")
|
||||
registration_builder_next.get.assert_awaited()
|
||||
|
||||
def test__get_user_registration_details_returns_error_on_permission_denied(self):
|
||||
"""Test that 403 Authorization_RequestDenied returns an empty dict and
|
||||
a descriptive error message naming the missing AuditLog.Read.All permission.
|
||||
"""
|
||||
from msgraph.generated.models.o_data_errors.main_error import MainError
|
||||
from msgraph.generated.models.o_data_errors.o_data_error import ODataError
|
||||
|
||||
odata_error = ODataError()
|
||||
odata_error.error = MainError()
|
||||
odata_error.error.code = "Authorization_RequestDenied"
|
||||
|
||||
registration_builder = SimpleNamespace(get=AsyncMock(side_effect=odata_error))
|
||||
|
||||
entra_service = Entra.__new__(Entra)
|
||||
entra_service.client = SimpleNamespace(
|
||||
reports=SimpleNamespace(
|
||||
authentication_methods=SimpleNamespace(
|
||||
user_registration_details=registration_builder
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
registration_details, error_message = asyncio.run(
|
||||
entra_service._get_user_registration_details()
|
||||
)
|
||||
|
||||
assert registration_details == {}
|
||||
assert error_message is not None
|
||||
assert "AuditLog.Read.All" in error_message
|
||||
assert "user registration details" in error_message
|
||||
|
||||
+27
-3
@@ -2,15 +2,39 @@
|
||||
|
||||
All notable changes to the **Prowler UI** are documented in this file.
|
||||
|
||||
## [1.26.0] (Prowler UNRELEASED)
|
||||
## [1.26.2] (Prowler 5.26.2)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- Finding drawer no longer renders literal backticks around inline code in Risk, Description and Remediation sections [(#11142)](https://github.com/prowler-cloud/prowler/pull/11142)
|
||||
|
||||
---
|
||||
|
||||
## [1.26.1] (Prowler 5.26.1)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- Role form Cancel buttons now return to Roles [(#11125)](https://github.com/prowler-cloud/prowler/pull/11125)
|
||||
- Shared select dropdowns stay constrained and scrollable inside modals [(#11125)](https://github.com/prowler-cloud/prowler/pull/11125)
|
||||
|
||||
---
|
||||
|
||||
## [1.26.0] (Prowler v5.26.0)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
- ASD Essential Eight compliance framework support [(#11071)](https://github.com/prowler-cloud/prowler/pull/11071)
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
- Standardized "Providers" wording across UI and documentation, replacing legacy "Cloud Providers" / "Accounts" / "Account Groups" copy [(#10971)](https://github.com/prowler-cloud/prowler/pull/10971)
|
||||
- Finding detail drawer now labels remediation actions from finding-level recommendation URLs by destination: "View CVE", "View in Prowler Hub", "View Advisory", or "View Reference", while keeping URL-only remediation cards labeled [(#10853)](https://github.com/prowler-cloud/prowler/pull/10853)
|
||||
- Finding detail drawer reorganized: status-colored banner below the resource info, dedicated Remediation tab, renamed "Findings for this resource" tab, and inline View Resource link next to the resource UID [(#11091)](https://github.com/prowler-cloud/prowler/pull/11091)
|
||||
- ThreatScore compliance views: canonical pillar order across all charts and the accordion, clickable pillars on `/compliance` that anchor the detail page, Top Failed Sections always shows the full pillar set, and donut tooltip now triggers on every segment [(#10975)](https://github.com/prowler-cloud/prowler/pull/10975)
|
||||
|
||||
---
|
||||
|
||||
## [1.25.3] (Prowler UNRELEASED)
|
||||
## [1.25.3] (Prowler v5.25.3)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
@@ -46,7 +70,7 @@ All notable changes to the **Prowler UI** are documented in this file.
|
||||
### 🔄 Changed
|
||||
|
||||
- Redesign compliance page, client-side search for compliance frameworks, compact scan selector trigger, enhanced compliance cards [(#10767)](https://github.com/prowler-cloud/prowler/pull/10767)
|
||||
- Allows tenant owners to expel users from their organizations [(#10787)](https://github.com/prowler-cloud/prowler/pull/10787)
|
||||
- Allows tenant owners to expel users from their organizations [(#10787)](https://github.com/prowler-cloud/prowler/pull/10787)
|
||||
- Shared filter dropdowns now support local option search and auto-scroll to the first visible match across table and provider filters [(#10859)](https://github.com/prowler-cloud/prowler/pull/10859)
|
||||
- Backward-compatibility middleware redirect from `/sign-up?invitation_token=…` to `/invitation/accept?invitation_token=…`; new invitation emails use `/invitation/accept` directly [(#10797)](https://github.com/prowler-cloud/prowler/pull/10797)
|
||||
- Mutelist improvements: table now supports name/reason search and visual count badges for finding targets [(#10846)](https://github.com/prowler-cloud/prowler/pull/10846)
|
||||
|
||||
@@ -172,6 +172,7 @@ export const getUserByMe = async (accessToken: string) => {
|
||||
manage_scans: userRole.attributes.manage_scans || false,
|
||||
manage_integrations: userRole.attributes.manage_integrations || false,
|
||||
manage_billing: userRole.attributes.manage_billing || false,
|
||||
manage_alerts: userRole.attributes.manage_alerts || false,
|
||||
unlimited_visibility: userRole.attributes.unlimited_visibility || false,
|
||||
};
|
||||
|
||||
|
||||
@@ -139,6 +139,7 @@ interface FindingGroupResourceAttributes {
|
||||
resource: ResourceInfo;
|
||||
provider: ProviderInfo;
|
||||
status: string;
|
||||
status_extended?: string;
|
||||
muted?: boolean;
|
||||
delta?: string | null;
|
||||
severity: string;
|
||||
@@ -187,6 +188,7 @@ export function adaptFindingGroupResourcesResponse(
|
||||
region: item.attributes.resource?.region || "-",
|
||||
severity: (item.attributes.severity || "informational") as Severity,
|
||||
status: item.attributes.status,
|
||||
statusExtended: item.attributes.status_extended,
|
||||
delta: item.attributes.delta || null,
|
||||
isMuted: item.attributes.muted ?? item.attributes.status === "MUTED",
|
||||
mutedReason: item.attributes.muted_reason || undefined,
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
fetchMock,
|
||||
getAuthHeadersMock,
|
||||
handleApiErrorMock,
|
||||
handleApiResponseMock,
|
||||
} = vi.hoisted(() => ({
|
||||
fetchMock: vi.fn(),
|
||||
getAuthHeadersMock: vi.fn(),
|
||||
handleApiErrorMock: vi.fn(),
|
||||
handleApiResponseMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/cache", () => ({
|
||||
revalidatePath: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib", () => ({
|
||||
apiBaseUrl: "https://api.example.com/api/v1",
|
||||
getAuthHeaders: getAuthHeadersMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/server-actions-helper", () => ({
|
||||
handleApiError: handleApiErrorMock,
|
||||
handleApiResponse: handleApiResponseMock,
|
||||
}));
|
||||
|
||||
import { addRole, updateRole } from "./roles";
|
||||
|
||||
const lastRequestBody = () => {
|
||||
const call = fetchMock.mock.calls.at(-1);
|
||||
if (!call) throw new Error("fetch was not called");
|
||||
const [, init] = call;
|
||||
return JSON.parse(String((init as RequestInit).body));
|
||||
};
|
||||
|
||||
const makeRoleFormData = () => {
|
||||
const formData = new FormData();
|
||||
formData.set("name", "Alert manager");
|
||||
formData.set("manage_users", "false");
|
||||
formData.set("manage_account", "false");
|
||||
formData.set("manage_billing", "false");
|
||||
formData.set("manage_providers", "false");
|
||||
formData.set("manage_integrations", "false");
|
||||
formData.set("manage_scans", "false");
|
||||
formData.set("manage_alerts", "true");
|
||||
formData.set("unlimited_visibility", "false");
|
||||
return formData;
|
||||
};
|
||||
|
||||
describe("role actions", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
|
||||
handleApiResponseMock.mockResolvedValue({ data: { id: "role-1" } });
|
||||
handleApiErrorMock.mockReturnValue({ error: "Unexpected error" });
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response(JSON.stringify({ data: { id: "role-1" } }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("includes manage_alerts when creating a role in Prowler Cloud", async () => {
|
||||
// Given
|
||||
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
|
||||
|
||||
// When
|
||||
await addRole(makeRoleFormData());
|
||||
|
||||
// Then
|
||||
expect(lastRequestBody().data.attributes.manage_alerts).toBe(true);
|
||||
});
|
||||
|
||||
it("omits manage_alerts when creating a role outside Prowler Cloud", async () => {
|
||||
// Given
|
||||
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
|
||||
|
||||
// When
|
||||
await addRole(makeRoleFormData());
|
||||
|
||||
// Then
|
||||
expect(lastRequestBody().data.attributes).not.toHaveProperty(
|
||||
"manage_alerts",
|
||||
);
|
||||
});
|
||||
|
||||
it("includes manage_alerts when updating a role in Prowler Cloud", async () => {
|
||||
// Given
|
||||
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
|
||||
|
||||
// When
|
||||
await updateRole(makeRoleFormData(), "role-1");
|
||||
|
||||
// Then
|
||||
expect(lastRequestBody().data.attributes.manage_alerts).toBe(true);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user