Compare commits

..

30 Commits

Author SHA1 Message Date
Prowler Bot 4ec493ec5c fix(api): correct service principal for Bedrock AgentCore attack paths (#11152)
Co-authored-by: Rubén De la Torre Vico <ruben@prowler.com>
2026-05-13 12:41:10 +02:00
Prowler Bot 85c1b85852 fix(ui): render inline code without literal backticks in finding drawer (#11155)
Co-authored-by: Hugo Pereira Brito <101209179+HugoPBrito@users.noreply.github.com>
2026-05-13 10:47:13 +01:00
Prowler Bot 8f50c6d684 fix(m365): surface AuditLog.Read.All permission errors instead of false positives (#11146)
Co-authored-by: abdou <b-abderrahmane@outlook.com>
Co-authored-by: Hugo Pereira Brito <101209179+HugoPBrito@users.noreply.github.com>
Co-authored-by: Hugo P.Brito <hugopbrit@gmail.com>
2026-05-12 18:42:21 +01:00
Prowler Bot 1fb6c6a0f0 chore(api): Bump version to v1.27.2 (#11135)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-05-12 15:08:06 +02:00
Prowler Bot fc3a25d7a8 chore(sdk): Bump version to v5.26.2 (#11133)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-05-12 15:07:49 +02:00
Prowler Bot 1a56087ea0 chore(ui): Bump version to v5.26.2 (#11134)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-05-12 15:07:33 +02:00
Prowler Bot 2fdc480beb fix(ui): fix role cancel and select dropdown scroll (#11128)
Co-authored-by: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com>
Co-authored-by: Pepe Fagoaga <pepe@prowler.com>
2026-05-12 13:21:18 +02:00
Prowler Bot 8bfc1d85f5 chore(changelog): prepare changelog for v5.26.1 (#11130)
Co-authored-by: Daniel Barranquero <74871504+danibarranqueroo@users.noreply.github.com>
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-05-12 13:18:44 +02:00
Prowler Bot 57501e1864 fix(api): defer scan broker publish until transaction commits (#11123)
Co-authored-by: Adrián Peña <adrianjpr@gmail.com>
2026-05-12 12:12:40 +02:00
Prowler Bot 02a83adfd4 fix(m365): exclude disabled guest users from entra_users_mfa_capable (#11119)
Co-authored-by: Hugo Pereira Brito <101209179+HugoPBrito@users.noreply.github.com>
2026-05-12 08:50:44 +01:00
Prowler Bot 98a1bca403 chore(api): Bump version to v1.27.1 (#11114)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-05-11 15:36:28 +02:00
Prowler Bot 8aade7f024 chore(sdk): Bump version to v5.26.1 (#11111)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-05-11 15:36:17 +02:00
Prowler Bot 43b50c4d6f chore(ui): Bump version to v5.26.1 (#11113)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-05-11 15:35:40 +02:00
Prowler Bot 578c354a69 chore(api): Update prowler dependency to v5.26 for release 5.26.0 (#11106)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-05-11 13:23:19 +02:00
Pepe Fagoaga 02cdcb29db chore: changelog for v5.26.0 (#11105) 2026-05-11 13:04:24 +02:00
Pepe Fagoaga 6e0d7866cd docs: version badge clickable (#11104) 2026-05-11 12:34:47 +02:00
Alejandro Bailo 4b71f37c91 docs(docs): add alerts guide (#11101)
Co-authored-by: Pepe Fagoaga <pepe@prowler.com>
2026-05-11 12:23:54 +02:00
Hugo Pereira Brito cdfbe5b2e3 fix(ui): label finding drawer column as Provider, not Account (#11102) 2026-05-11 12:03:03 +02:00
Hugo Pereira Brito 1b6a459df4 refactor(ui): reorganize finding detail drawer (#11091) 2026-05-11 09:47:43 +01:00
Daniel Barranquero 73c0305dc4 feat(aws): add bedrock_prompt_encrypted_with_cmk security check (#10905) 2026-05-11 10:32:44 +02:00
Pedro Martín 0e01e67257 feat(ui): ASD Essential Eight compliance framework support (#11071)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
2026-05-11 09:55:04 +02:00
Pedro Martín 1ad329f9cf feat(ui): ThreatScore compliance views pillars, nav + charts (#10975)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
Co-authored-by: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com>
2026-05-11 09:53:55 +02:00
Pedro Martín d03d1d2393 chore(changelog): update for universal compliance (#11100) 2026-05-11 09:50:32 +02:00
Davidm4r 832516be2a fix(mcp_server): bump transitive requests to 2.33.1 (advisory 90553) (#11084) 2026-05-08 12:52:46 +02:00
Prowler Bot 34727a7237 chore(docs): Bump version to v5.25.3 (#11080)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-05-08 10:40:41 +02:00
Alejandro Bailo 4216a3e23a feat(ui): add cloud-gated custom alerts (#11003) 2026-05-08 10:36:43 +02:00
Pepe Fagoaga a59192e6f5 chore: changelog v5.25.3 (#11077) 2026-05-08 08:37:06 +02:00
Pepe Fagoaga 592bc6f6a8 chore: enable sponsor for prowler-cloud (#11076) 2026-05-08 08:25:28 +02:00
lydiavilchez 962ebac8e4 feat(googleworkspace): add Gmail consequence-based checks for attachment safety and spoofing (#10980) 2026-05-07 16:50:36 +02:00
Hugo Pereira Brito 2c5d47a8cd chore: route vulnerability references to canonical URLs (#10853)
Co-authored-by: Hugo P.Brito <hugopbrito@Mac.home>
2026-05-07 15:28:50 +01:00
219 changed files with 13062 additions and 1568 deletions
+1 -1
View File
@@ -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"
+15
View File
@@ -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
View File
@@ -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
+3 -3
View File
@@ -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
View File
@@ -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
-141
View File
@@ -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 -1
View File
@@ -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
+60 -231
View File
@@ -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
+38 -33
View File
@@ -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)
+1
View File
@@ -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

+13 -8
View File
@@ -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>&nbsp;
<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>&nbsp;
<span className="version-badge-version">{version}</span>
</span>
</span>
</a>
);
};
+17
View File
@@ -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**.
![Create Alert From Findings](/images/prowler-app/alerts/create-alert-from-findings.png)
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.
![Create Alert Modal](/images/prowler-app/alerts/create-alert-modal.png)
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.
![Alerts List](/images/prowler-app/alerts/alerts-list.png)
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.
![Edit Alert Test Result](/images/prowler-app/alerts/edit-alert-test.png)
<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.
![Alert Email Example](/images/prowler-app/alerts/alert-email-example.png)
## 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.
-8
View File
@@ -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()
+1 -1
View File
@@ -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"
+3 -3
View File
@@ -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
View File
@@ -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)
---
+2
View File
@@ -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",
+1 -1
View File
@@ -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]
@@ -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": ""
}
@@ -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
@@ -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
@@ -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": ""
}
@@ -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
@@ -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": ""
}
@@ -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
@@ -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": ""
}
@@ -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
@@ -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": ""
}
@@ -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
@@ -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": ""
}
@@ -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
@@ -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": ""
}
@@ -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
@@ -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": ""
}
@@ -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
@@ -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": ""
}
@@ -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
@@ -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"
+21 -3
View File
@@ -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": "",
+20 -4
View File
@@ -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": "",
@@ -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"]]:
"""
@@ -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
View File
@@ -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"]
@@ -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):
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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()
+40 -1
View File
@@ -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": [
+103 -3
View File
@@ -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"""
+50
View File
@@ -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()
@@ -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
@@ -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
View File
@@ -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)
+1
View File
@@ -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,
+109
View File
@@ -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