Compare commits

...

7 Commits

Author SHA1 Message Date
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
142 changed files with 9344 additions and 335 deletions
+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']
@@ -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>
+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]]
+6 -2
View File
@@ -11,6 +11,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
- 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)
- `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)
### 🔄 Changed
@@ -19,6 +20,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 +34,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)
---
@@ -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",
@@ -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,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": "",
@@ -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,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()
+2 -1
View File
@@ -7,10 +7,11 @@ All notable changes to the **Prowler UI** are documented in this file.
### 🔄 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)
---
## [1.25.3] (Prowler UNRELEASED)
## [1.25.3] (Prowler v5.25.3)
### 🐞 Fixed
+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,
};
+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);
});
});
+6 -2
View File
@@ -107,10 +107,12 @@ export const addRole = async (formData: FormData) => {
},
};
// Conditionally include manage_billing for cloud environment
// Conditionally include Prowler Cloud permissions.
if (process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true") {
payload.data.attributes.manage_billing =
formData.get("manage_billing") === "true";
payload.data.attributes.manage_alerts =
formData.get("manage_alerts") === "true";
}
// Add provider groups relationships only if there are items
@@ -162,10 +164,12 @@ export const updateRole = async (formData: FormData, roleId: string) => {
},
};
// Conditionally include manage_billing for cloud environments
// Conditionally include Prowler Cloud permissions.
if (process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true") {
payload.data.attributes.manage_billing =
formData.get("manage_billing") === "true";
payload.data.attributes.manage_alerts =
formData.get("manage_alerts") === "true";
}
// Add provider groups relationships only if there are items
@@ -0,0 +1,138 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { confirmAlertRecipient } from "./confirm-alert-recipient";
const fetchMock = vi.fn();
const lastFetchCall = (): { url: string; init: RequestInit } => {
const call = fetchMock.mock.calls.at(-1);
if (!call) throw new Error("fetch was not called");
const [url, init] = call;
return { url: String(url), init: (init ?? {}) as RequestInit };
};
describe("confirmAlertRecipient", () => {
beforeEach(() => {
vi.stubGlobal("fetch", fetchMock);
vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "https://api.example.com/api/v1");
fetchMock.mockResolvedValue(
new Response(
JSON.stringify({
state: "confirmed",
message:
"Your subscription has been confirmed. You will receive alert digests at this address.",
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
),
);
});
afterEach(() => {
vi.unstubAllEnvs();
vi.unstubAllGlobals();
vi.clearAllMocks();
});
it("calls the public confirmation endpoint without auth headers", async () => {
// When
const result = await confirmAlertRecipient("token-1");
// Then
expect(result).toEqual({
ok: true,
state: "confirmed",
message:
"Your subscription has been confirmed. You will receive alert digests at this address.",
});
const { url, init } = lastFetchCall();
expect(url).toBe(
"https://api.example.com/api/v1/alerts/recipients/confirm?token=token-1",
);
expect(init).toEqual({
headers: { Accept: "application/json" },
cache: "no-store",
});
});
it("returns the API message for invalid tokens", async () => {
// Given
fetchMock.mockResolvedValueOnce(
new Response(
JSON.stringify({
state: "invalid_token",
message: "This link is invalid or has expired.",
}),
{ status: 400, headers: { "Content-Type": "application/json" } },
),
);
// When
const result = await confirmAlertRecipient("expired-token");
// Then
expect(result).toEqual({
ok: false,
state: "invalid_token",
message: "This link is invalid or has expired.",
});
});
it("returns the API message for missing tokens", async () => {
// Given
fetchMock.mockResolvedValueOnce(
new Response(
JSON.stringify({
state: "missing_token",
message: "This link is missing a token.",
}),
{ status: 400, headers: { "Content-Type": "application/json" } },
),
);
// When
const result = await confirmAlertRecipient();
// Then
expect(result).toEqual({
ok: false,
state: "missing_token",
message: "This link is missing a token.",
});
expect(lastFetchCall().url).toBe(
"https://api.example.com/api/v1/alerts/recipients/confirm",
);
});
it("returns the fallback message when the API base URL is missing", async () => {
// Given
vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "");
// When
const result = await confirmAlertRecipient("token-1");
// Then
expect(result).toEqual({
ok: false,
state: "missing_api_base_url",
message:
"We could not process this confirmation link. Please try again later.",
});
expect(fetchMock).not.toHaveBeenCalled();
});
it("returns the fallback message when the request fails", async () => {
// Given
fetchMock.mockRejectedValueOnce(new Error("network down"));
// When
const result = await confirmAlertRecipient("token-1");
// Then
expect(result).toEqual({
ok: false,
state: "network_error",
message:
"We could not process this confirmation link. Please try again later.",
});
});
});
@@ -0,0 +1,79 @@
interface AlertConfirmApiResponse {
state?: string;
message?: string;
}
interface AlertConfirmResult {
ok: boolean;
state: string;
message: string;
}
const FALLBACK_CONFIRM_ERROR =
"We could not process this confirmation link. Please try again later.";
const toMessage = (payload: unknown): string | null => {
if (
typeof payload === "object" &&
payload !== null &&
"message" in payload &&
typeof payload.message === "string"
) {
return payload.message;
}
return null;
};
const toState = (payload: unknown): string => {
if (
typeof payload === "object" &&
payload !== null &&
"state" in payload &&
typeof payload.state === "string"
) {
return payload.state;
}
return "unknown";
};
export const confirmAlertRecipient = async (
token?: string,
): Promise<AlertConfirmResult> => {
const apiBaseUrl = process.env.NEXT_PUBLIC_API_BASE_URL;
if (!apiBaseUrl) {
return {
ok: false,
state: "missing_api_base_url",
message: FALLBACK_CONFIRM_ERROR,
};
}
const url = new URL(`${apiBaseUrl}/alerts/recipients/confirm`);
if (token) {
url.searchParams.set("token", token);
}
try {
const response = await fetch(url.toString(), {
headers: {
Accept: "application/json",
},
cache: "no-store",
});
const payload = (await response.json()) as AlertConfirmApiResponse;
return {
ok: response.ok,
state: toState(payload),
message: toMessage(payload) ?? FALLBACK_CONFIRM_ERROR,
};
} catch {
return {
ok: false,
state: "network_error",
message: FALLBACK_CONFIRM_ERROR,
};
}
};
@@ -0,0 +1,81 @@
import { render, screen } from "@testing-library/react";
import { ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
import AlertsConfirmPage from "./page";
const confirmAlertRecipientMock = vi.hoisted(() => vi.fn());
vi.mock("./confirm-alert-recipient", () => ({
confirmAlertRecipient: confirmAlertRecipientMock,
}));
vi.mock("@/components/auth/oss/auth-layout", () => ({
AuthLayout: ({ title, children }: { title: string; children: ReactNode }) => (
<section aria-label={title}>{children}</section>
),
}));
vi.mock("@/components/shadcn", () => ({
Button: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}));
vi.mock("next/link", () => ({
default: ({ children, href }: { children: ReactNode; href: string }) => (
<a href={href}>{children}</a>
),
}));
describe("AlertsConfirmPage", () => {
it("shows the API message after confirming the alert recipient", async () => {
// Given
confirmAlertRecipientMock.mockResolvedValueOnce({
ok: true,
state: "confirmed",
message:
"Your subscription has been confirmed. You will receive alert digests at this address.",
});
// When
render(
await AlertsConfirmPage({
searchParams: Promise.resolve({ token: "token-1" }),
}),
);
// Then
expect(confirmAlertRecipientMock).toHaveBeenCalledWith("token-1");
expect(screen.getByLabelText("Subscription confirmed")).toBeInTheDocument();
expect(
screen.getByText(
"Your subscription has been confirmed. You will receive alert digests at this address.",
),
).toBeVisible();
expect(
screen.getByRole("link", { name: "Continue to Prowler" }),
).toHaveAttribute("href", "/");
});
it("shows the subscription link title when confirmation fails", async () => {
// Given
confirmAlertRecipientMock.mockResolvedValueOnce({
ok: false,
state: "invalid_token",
message: "This link is invalid or has expired.",
});
// When
render(
await AlertsConfirmPage({
searchParams: Promise.resolve({ token: ["expired-token"] }),
}),
);
// Then
expect(confirmAlertRecipientMock).toHaveBeenCalledWith("expired-token");
expect(screen.getByLabelText("Subscription link")).toBeInTheDocument();
expect(
screen.getByText("This link is invalid or has expired."),
).toBeVisible();
});
});
+40
View File
@@ -0,0 +1,40 @@
import Link from "next/link";
import { AuthLayout } from "@/components/auth/oss/auth-layout";
import { Button } from "@/components/shadcn";
import { confirmAlertRecipient } from "./confirm-alert-recipient";
interface AlertsConfirmPageProps {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}
const getParamValue = (
params: Awaited<AlertsConfirmPageProps["searchParams"]>,
key: string,
): string | undefined => {
const value = params[key];
return Array.isArray(value) ? value[0] : value;
};
export default async function AlertsConfirmPage({
searchParams,
}: AlertsConfirmPageProps) {
const resolvedSearchParams = await searchParams;
const token = getParamValue(resolvedSearchParams, "token");
const result = await confirmAlertRecipient(token);
const title = result.ok ? "Subscription confirmed" : "Subscription link";
return (
<AuthLayout title={title}>
<div className="flex flex-col gap-4">
<p className="text-text-neutral-secondary text-sm leading-6">
{result.message}
</p>
<Button variant="outline" className="w-full" asChild>
<Link href="/">Continue to Prowler</Link>
</Button>
</div>
</AuthLayout>
);
}
@@ -0,0 +1,58 @@
import { render, screen } from "@testing-library/react";
import { ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
import AlertsUnsubscribePage from "./page";
const unsubscribeAlertRecipientMock = vi.hoisted(() => vi.fn());
vi.mock("./unsubscribe-alert-recipient", () => ({
unsubscribeAlertRecipient: unsubscribeAlertRecipientMock,
}));
vi.mock("@/components/auth/oss/auth-layout", () => ({
AuthLayout: ({ title, children }: { title: string; children: ReactNode }) => (
<section aria-label={title}>{children}</section>
),
}));
vi.mock("@/components/shadcn", () => ({
Button: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}));
vi.mock("next/link", () => ({
default: ({ children, href }: { children: ReactNode; href: string }) => (
<a href={href}>{children}</a>
),
}));
describe("AlertsUnsubscribePage", () => {
it("shows a neutral link back to the app after unsubscribing", async () => {
// Given
unsubscribeAlertRecipientMock.mockResolvedValueOnce({
ok: true,
state: "unsubscribed",
message:
"You have been unsubscribed. You will not receive further alerts at this address.",
});
// When
render(
await AlertsUnsubscribePage({
searchParams: Promise.resolve({ token: "token-1" }),
}),
);
// Then
expect(unsubscribeAlertRecipientMock).toHaveBeenCalledWith("token-1");
expect(screen.getByLabelText("Unsubscribed")).toBeInTheDocument();
expect(
screen.getByText(
"You have been unsubscribed. You will not receive further alerts at this address.",
),
).toBeVisible();
expect(
screen.getByRole("link", { name: "Continue to Prowler" }),
).toHaveAttribute("href", "/");
});
});
+40
View File
@@ -0,0 +1,40 @@
import Link from "next/link";
import { AuthLayout } from "@/components/auth/oss/auth-layout";
import { Button } from "@/components/shadcn";
import { unsubscribeAlertRecipient } from "./unsubscribe-alert-recipient";
interface AlertsUnsubscribePageProps {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}
const getParamValue = (
params: Awaited<AlertsUnsubscribePageProps["searchParams"]>,
key: string,
): string | undefined => {
const value = params[key];
return Array.isArray(value) ? value[0] : value;
};
export default async function AlertsUnsubscribePage({
searchParams,
}: AlertsUnsubscribePageProps) {
const resolvedSearchParams = await searchParams;
const token = getParamValue(resolvedSearchParams, "token");
const result = await unsubscribeAlertRecipient(token);
const title = result.ok ? "Unsubscribed" : "Subscription link";
return (
<AuthLayout title={title}>
<div className="flex flex-col gap-4">
<p className="text-text-neutral-secondary text-sm leading-6">
{result.message}
</p>
<Button variant="outline" className="w-full" asChild>
<Link href="/">Continue to Prowler</Link>
</Button>
</div>
</AuthLayout>
);
}
@@ -0,0 +1,105 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { unsubscribeAlertRecipient } from "./unsubscribe-alert-recipient";
const fetchMock = vi.fn();
const lastFetchCall = (): { url: string; init: RequestInit } => {
const call = fetchMock.mock.calls.at(-1);
if (!call) throw new Error("fetch was not called");
const [url, init] = call;
return { url: String(url), init: (init ?? {}) as RequestInit };
};
describe("unsubscribeAlertRecipient", () => {
beforeEach(() => {
vi.stubGlobal("fetch", fetchMock);
vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "https://api.example.com/api/v1");
fetchMock.mockResolvedValue(
new Response(
JSON.stringify({
state: "unsubscribed",
message:
"You have been unsubscribed. You will not receive further alerts at this address.",
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
),
);
});
afterEach(() => {
vi.unstubAllEnvs();
vi.unstubAllGlobals();
vi.clearAllMocks();
});
it("calls the public unsubscribe endpoint without auth headers", async () => {
// When
const result = await unsubscribeAlertRecipient("token-1");
// Then
expect(result).toEqual({
ok: true,
state: "unsubscribed",
message:
"You have been unsubscribed. You will not receive further alerts at this address.",
});
const { url, init } = lastFetchCall();
expect(url).toBe(
"https://api.example.com/api/v1/alerts/recipients/unsubscribe?token=token-1",
);
expect(init).toEqual({
headers: { Accept: "application/json" },
cache: "no-store",
});
});
it("returns the API message for invalid tokens", async () => {
// Given
fetchMock.mockResolvedValueOnce(
new Response(
JSON.stringify({
state: "invalid_token",
message: "This link is invalid or has expired.",
}),
{ status: 400, headers: { "Content-Type": "application/json" } },
),
);
// When
const result = await unsubscribeAlertRecipient("expired-token");
// Then
expect(result).toEqual({
ok: false,
state: "invalid_token",
message: "This link is invalid or has expired.",
});
});
it("returns the API message for missing tokens", async () => {
// Given
fetchMock.mockResolvedValueOnce(
new Response(
JSON.stringify({
state: "missing_token",
message: "This link is missing a token.",
}),
{ status: 400, headers: { "Content-Type": "application/json" } },
),
);
// When
const result = await unsubscribeAlertRecipient();
// Then
expect(result).toEqual({
ok: false,
state: "missing_token",
message: "This link is missing a token.",
});
expect(lastFetchCall().url).toBe(
"https://api.example.com/api/v1/alerts/recipients/unsubscribe",
);
});
});
@@ -0,0 +1,79 @@
interface AlertUnsubscribeApiResponse {
state?: string;
message?: string;
}
interface AlertUnsubscribeResult {
ok: boolean;
state: string;
message: string;
}
const FALLBACK_UNSUBSCRIBE_ERROR =
"We could not process this unsubscribe link. Please try again later.";
const toMessage = (payload: unknown): string | null => {
if (
typeof payload === "object" &&
payload !== null &&
"message" in payload &&
typeof payload.message === "string"
) {
return payload.message;
}
return null;
};
const toState = (payload: unknown): string => {
if (
typeof payload === "object" &&
payload !== null &&
"state" in payload &&
typeof payload.state === "string"
) {
return payload.state;
}
return "unknown";
};
export const unsubscribeAlertRecipient = async (
token?: string,
): Promise<AlertUnsubscribeResult> => {
const apiBaseUrl = process.env.NEXT_PUBLIC_API_BASE_URL;
if (!apiBaseUrl) {
return {
ok: false,
state: "missing_api_base_url",
message: FALLBACK_UNSUBSCRIBE_ERROR,
};
}
const url = new URL(`${apiBaseUrl}/alerts/recipients/unsubscribe`);
if (token) {
url.searchParams.set("token", token);
}
try {
const response = await fetch(url.toString(), {
headers: {
Accept: "application/json",
},
cache: "no-store",
});
const payload = (await response.json()) as AlertUnsubscribeApiResponse;
return {
ok: response.ok,
state: toState(payload),
message: toMessage(payload) ?? FALLBACK_UNSUBSCRIBE_ERROR,
};
} catch {
return {
ok: false,
state: "network_error",
message: FALLBACK_UNSUBSCRIBE_ERROR,
};
}
};
@@ -138,4 +138,13 @@ describe("AccountsSelector", () => {
screen.getByText("Production AWS").closest("[data-value]"),
).toHaveAttribute("data-keywords", expect.stringContaining("123456789012"));
});
it("disables select all when every account is already shown", () => {
render(<AccountsSelector providers={providers} />);
expect(
screen.getByRole("option", { name: /select all accounts/i }),
).toHaveAttribute("aria-disabled", "true");
expect(screen.getByText("All selected")).toBeInTheDocument();
});
});
@@ -171,18 +171,23 @@ export function AccountsSelector({
<div
role="option"
aria-selected={selectedIds.length === 0}
aria-disabled={selectedIds.length === 0}
aria-label="Select all accounts (clears current selection to show all)"
tabIndex={0}
className="text-text-neutral-secondary flex w-full cursor-pointer items-center gap-3 rounded-lg px-4 py-3 text-sm font-semibold hover:bg-slate-200 dark:hover:bg-slate-700/50"
onClick={() => handleMultiValueChange([])}
className="text-text-neutral-secondary flex w-full cursor-pointer items-center gap-3 rounded-lg px-4 py-3 text-sm font-semibold hover:bg-slate-200 aria-disabled:cursor-not-allowed aria-disabled:opacity-50 dark:hover:bg-slate-700/50"
onClick={() => {
if (selectedIds.length === 0) return;
handleMultiValueChange([]);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
if (selectedIds.length === 0) return;
handleMultiValueChange([]);
}
}}
>
Select All
{selectedIds.length === 0 ? "All selected" : "Select All"}
</div>
{visibleProviders.map((p) => {
const id = p.id;
@@ -135,4 +135,13 @@ describe("ProviderTypeSelector", () => {
expect.stringContaining("Amazon Web Services"),
);
});
it("disables select all when every provider is already shown", () => {
render(<ProviderTypeSelector providers={providers} />);
expect(
screen.getByRole("option", { name: /select all providers/i }),
).toHaveAttribute("aria-disabled", "true");
expect(screen.getByText("All selected")).toBeInTheDocument();
});
});
@@ -295,18 +295,23 @@ export const ProviderTypeSelector = ({
<div
role="option"
aria-selected={selectedTypes.length === 0}
aria-disabled={selectedTypes.length === 0}
aria-label="Select all providers (clears current selection to show all)"
tabIndex={0}
className="text-text-neutral-secondary flex w-full cursor-pointer items-center gap-3 rounded-lg px-4 py-3 text-sm font-semibold hover:bg-slate-200 dark:hover:bg-slate-700/50"
onClick={() => handleMultiValueChange([])}
className="text-text-neutral-secondary flex w-full cursor-pointer items-center gap-3 rounded-lg px-4 py-3 text-sm font-semibold hover:bg-slate-200 aria-disabled:cursor-not-allowed aria-disabled:opacity-50 dark:hover:bg-slate-700/50"
onClick={() => {
if (selectedTypes.length === 0) return;
handleMultiValueChange([]);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
if (selectedTypes.length === 0) return;
handleMultiValueChange([]);
}
}}
>
Select All
{selectedTypes.length === 0 ? "All selected" : "Select All"}
</div>
{availableTypes.map((providerType) => (
<MultiSelectItem
@@ -0,0 +1,224 @@
import { 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("@/lib", () => ({
apiBaseUrl: "https://api.test/api/v1",
getAuthHeaders: getAuthHeadersMock,
getErrorMessage: (error: unknown) =>
error instanceof Error ? error.message : String(error),
}));
vi.mock("@/lib/server-actions-helper", () => ({
handleApiError: handleApiErrorMock,
handleApiResponse: handleApiResponseMock,
}));
import { ALERT_AGGREGATE_OPS, ALERT_TRIGGER_KINDS } from "../_types";
import {
createAlert,
deleteAlert,
disableAlert,
enableAlert,
listAlerts,
previewAlertCondition,
seedAlertRule,
updateAlert,
} from "./alerts";
const lastFetchCall = (): { url: string; init: RequestInit } => {
const call = fetchMock.mock.calls.at(-1);
if (!call) throw new Error("fetch was not called");
const [url, init] = call;
return { url: String(url), init: (init ?? {}) as RequestInit };
};
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal("fetch", fetchMock);
fetchMock.mockResolvedValue(
new Response(JSON.stringify({ data: [] }), {
status: 200,
headers: { "Content-Type": "application/vnd.api+json" },
}),
);
getAuthHeadersMock.mockResolvedValue({
Accept: "application/vnd.api+json",
Authorization: "Bearer test-token",
"Content-Type": "application/vnd.api+json",
});
handleApiResponseMock.mockResolvedValue({ data: [] });
handleApiErrorMock.mockReturnValue({ error: "Unexpected error." });
});
describe("listAlerts", () => {
it("returns whatever handleApiResponse returns", async () => {
handleApiResponseMock.mockResolvedValue({
data: [],
meta: { pagination: { count: 0 } },
});
const result = await listAlerts({ "filter[enabled]": "true" });
expect(result).toEqual({ data: [], meta: { pagination: { count: 0 } } });
});
it("forwards searchParams as query string", async () => {
await listAlerts({ "filter[trigger]": "daily" });
expect(lastFetchCall().url).toContain("filter%5Btrigger%5D=daily");
});
it("delegates network errors to handleApiError", async () => {
fetchMock.mockRejectedValueOnce(new Error("boom"));
handleApiErrorMock.mockReturnValueOnce({ error: "boom" });
const result = await listAlerts();
expect(handleApiErrorMock).toHaveBeenCalled();
expect(result).toEqual({ error: "boom" });
});
});
describe("createAlert", () => {
it("posts a JSON:API envelope with schema_version", async () => {
handleApiResponseMock.mockResolvedValue({
data: {
id: "alert-1",
type: "alert-rules",
attributes: { name: "n", trigger: "after_scan" },
},
});
await createAlert({
name: "Daily critical",
trigger: ALERT_TRIGGER_KINDS.AFTER_SCAN,
condition: {
op: ALERT_AGGREGATE_OPS.ANY,
filter: { severity: ["critical"] },
},
});
const { init } = lastFetchCall();
expect(init.method).toBe("POST");
const body = JSON.parse(init.body as string);
expect(body.data.type).toBe("alert-rules");
expect(body.data.attributes.schema_version).toBe(1);
});
it("sends an empty recipient list when provided", async () => {
await createAlert({
name: "No recipients yet",
trigger: ALERT_TRIGGER_KINDS.AFTER_SCAN,
condition: {
op: ALERT_AGGREGATE_OPS.ANY,
filter: { severity: ["critical"] },
},
recipientEmails: [],
});
const body = JSON.parse(lastFetchCall().init.body as string);
expect(body.data.attributes.recipient_emails).toEqual([]);
});
});
describe("seedAlertRule", () => {
it("posts a JSON:API seeding envelope to /seed", async () => {
const filterBag = {
"filter[severity__in]": "critical",
"filter[sort]": "-severity",
};
await seedAlertRule(filterBag);
const { url, init } = lastFetchCall();
expect(url).toMatch(/\/alerts\/rules\/seed$/);
expect(init.method).toBe("POST");
expect(JSON.parse(init.body as string)).toEqual({
data: {
type: "alert-rule-seedings",
attributes: { filter_bag: filterBag },
},
});
});
});
describe("updateAlert", () => {
it("PATCHes the alert with the id in the URL", async () => {
await updateAlert("alert-1", {
name: "Updated",
trigger: ALERT_TRIGGER_KINDS.DAILY,
condition: {
op: ALERT_AGGREGATE_OPS.ANY,
filter: { severity: ["critical"] },
},
});
const { url, init } = lastFetchCall();
expect(url).toContain("/alerts/rules/alert-1");
expect(init.method).toBe("PATCH");
});
});
describe("deleteAlert", () => {
it("issues a DELETE against the alert id", async () => {
handleApiResponseMock.mockResolvedValue({ success: true, status: 204 });
await deleteAlert("alert-1");
const { init } = lastFetchCall();
expect(init.method).toBe("DELETE");
});
});
describe("enable / disable", () => {
it("PATCHes enabled true to the alert rule endpoint", async () => {
await enableAlert("alert-1");
const { url, init } = lastFetchCall();
expect(url).toMatch(/\/alerts\/rules\/alert-1$/);
expect(init.method).toBe("PATCH");
expect(JSON.parse(init.body as string)).toEqual({
data: {
type: "alert-rules",
id: "alert-1",
attributes: { enabled: true },
},
});
});
it("PATCHes enabled false to the alert rule endpoint", async () => {
await disableAlert("alert-1");
const { url, init } = lastFetchCall();
expect(url).toMatch(/\/alerts\/rules\/alert-1$/);
expect(init.method).toBe("PATCH");
expect(JSON.parse(init.body as string)).toEqual({
data: {
type: "alert-rules",
id: "alert-1",
attributes: { enabled: false },
},
});
});
});
describe("previewAlertCondition", () => {
it("posts a JSON:API preview envelope to /preview", async () => {
const condition = {
op: ALERT_AGGREGATE_OPS.ANY,
filter: { severity: ["critical"] },
};
await previewAlertCondition({ condition });
const { url, init } = lastFetchCall();
expect(url).toMatch(/\/alerts\/rules\/preview$/);
expect(init.method).toBe("POST");
expect(init.headers).toEqual(
expect.objectContaining({
Accept: "application/vnd.api+json",
"Content-Type": "application/vnd.api+json",
}),
);
expect(JSON.parse(init.body as string)).toEqual({
data: {
type: "alert-rule-previews",
attributes: { condition },
},
});
});
});
+214
View File
@@ -0,0 +1,214 @@
"use server";
import { apiBaseUrl, getAuthHeaders } from "@/lib";
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
import {
ALERT_SCHEMA_VERSION,
type AlertCondition,
type AlertTriggerKind,
} from "../_types";
const ALERT_RULES_API_PATH = "/alerts/rules";
const ALERTS_REVALIDATE_PATH = "/alerts";
export interface AlertPayload {
name: string;
description?: string;
enabled?: boolean;
trigger: AlertTriggerKind;
condition: AlertCondition;
/**
* List of recipient email addresses. The API resolves them to existing
* `AlertRecipient` rows or creates new pending ones with confirmation
* emails. Recipient IDs are NOT used by the rule write path.
*/
recipientEmails?: string[];
}
const buildRuleEnvelope = (payload: AlertPayload, alertId?: string) => ({
data: {
type: "alert-rules",
...(alertId ? { id: alertId } : {}),
attributes: {
name: payload.name,
description: payload.description ?? "",
enabled: payload.enabled ?? true,
trigger: payload.trigger,
condition: payload.condition,
schema_version: ALERT_SCHEMA_VERSION,
...(payload.recipientEmails !== undefined
? { recipient_emails: payload.recipientEmails }
: {}),
},
},
});
const buildEnabledEnvelope = (alertId: string, enabled: boolean) => ({
data: {
type: "alert-rules",
id: alertId,
attributes: { enabled },
},
});
const buildSeedEnvelope = (filterBag: Record<string, string | string[]>) => ({
data: {
type: "alert-rule-seedings",
attributes: { filter_bag: filterBag },
},
});
export const listAlerts = async (
searchParams?: Record<string, string | undefined>,
) => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}${ALERT_RULES_API_PATH}`);
if (searchParams) {
for (const [key, value] of Object.entries(searchParams)) {
if (value !== undefined && value !== "") {
url.searchParams.append(key, value);
}
}
}
try {
const response = await fetch(url.toString(), { headers });
return handleApiResponse(response);
} catch (error) {
return handleApiError(error);
}
};
export const getAlert = async (alertId: string) => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}${ALERT_RULES_API_PATH}/${alertId}`);
try {
const response = await fetch(url.toString(), { headers });
return handleApiResponse(response);
} catch (error) {
return handleApiError(error);
}
};
export const seedAlertRule = async (
filterBag: Record<string, string | string[]>,
) => {
const headers = await getAuthHeaders({ contentType: true });
const url = new URL(`${apiBaseUrl}${ALERT_RULES_API_PATH}/seed`);
try {
const response = await fetch(url.toString(), {
method: "POST",
headers,
body: JSON.stringify(buildSeedEnvelope(filterBag)),
});
return handleApiResponse(response);
} catch (error) {
return handleApiError(error);
}
};
export const createAlert = async (payload: AlertPayload) => {
const headers = await getAuthHeaders({ contentType: true });
const url = new URL(`${apiBaseUrl}${ALERT_RULES_API_PATH}`);
try {
const response = await fetch(url.toString(), {
method: "POST",
headers,
body: JSON.stringify(buildRuleEnvelope(payload)),
});
return handleApiResponse(response, ALERTS_REVALIDATE_PATH);
} catch (error) {
return handleApiError(error);
}
};
export const updateAlert = async (alertId: string, payload: AlertPayload) => {
const headers = await getAuthHeaders({ contentType: true });
const url = new URL(`${apiBaseUrl}${ALERT_RULES_API_PATH}/${alertId}`);
try {
const response = await fetch(url.toString(), {
method: "PATCH",
headers,
body: JSON.stringify(buildRuleEnvelope(payload, alertId)),
});
return handleApiResponse(response, ALERTS_REVALIDATE_PATH);
} catch (error) {
return handleApiError(error);
}
};
export const deleteAlert = async (alertId: string) => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}${ALERT_RULES_API_PATH}/${alertId}`);
try {
const response = await fetch(url.toString(), {
method: "DELETE",
headers,
});
return handleApiResponse(response, ALERTS_REVALIDATE_PATH);
} catch (error) {
return handleApiError(error);
}
};
export const enableAlert = async (alertId: string) => {
const headers = await getAuthHeaders({ contentType: true });
const url = new URL(`${apiBaseUrl}${ALERT_RULES_API_PATH}/${alertId}`);
try {
const response = await fetch(url.toString(), {
method: "PATCH",
headers,
body: JSON.stringify(buildEnabledEnvelope(alertId, true)),
});
return handleApiResponse(response, ALERTS_REVALIDATE_PATH);
} catch (error) {
return handleApiError(error);
}
};
export const disableAlert = async (alertId: string) => {
const headers = await getAuthHeaders({ contentType: true });
const url = new URL(`${apiBaseUrl}${ALERT_RULES_API_PATH}/${alertId}`);
try {
const response = await fetch(url.toString(), {
method: "PATCH",
headers,
body: JSON.stringify(buildEnabledEnvelope(alertId, false)),
});
return handleApiResponse(response, ALERTS_REVALIDATE_PATH);
} catch (error) {
return handleApiError(error);
}
};
export const previewAlertCondition = async (payload: {
condition: AlertCondition;
}) => {
const headers = await getAuthHeaders({ contentType: true });
const url = new URL(`${apiBaseUrl}${ALERT_RULES_API_PATH}/preview`);
try {
const response = await fetch(url.toString(), {
method: "POST",
headers,
body: JSON.stringify({
data: {
type: "alert-rule-previews",
attributes: { condition: payload.condition },
},
}),
});
return handleApiResponse(response);
} catch (error) {
return handleApiError(error);
}
};
@@ -0,0 +1,2 @@
export * from "./alerts";
export * from "./recipients";
@@ -0,0 +1,70 @@
import { 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("@/lib", () => ({
apiBaseUrl: "https://api.test/api/v1",
getAuthHeaders: getAuthHeadersMock,
getErrorMessage: (error: unknown) =>
error instanceof Error ? error.message : String(error),
}));
vi.mock("@/lib/server-actions-helper", () => ({
handleApiError: handleApiErrorMock,
handleApiResponse: handleApiResponseMock,
}));
import { listAlertRecipients } from "./recipients";
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal("fetch", fetchMock);
fetchMock.mockResolvedValue(
new Response(JSON.stringify({ data: [] }), {
status: 200,
headers: { "Content-Type": "application/vnd.api+json" },
}),
);
getAuthHeadersMock.mockResolvedValue({
Accept: "application/vnd.api+json",
Authorization: "Bearer test-token",
});
handleApiResponseMock.mockResolvedValue({ data: [] });
handleApiErrorMock.mockReturnValue({ error: "Unexpected error." });
});
describe("listAlertRecipients", () => {
it("returns whatever handleApiResponse returns", async () => {
handleApiResponseMock.mockResolvedValue({
data: [
{
id: "1",
type: "alert-recipients",
attributes: { email: "a@b.test", status: "pending" },
},
],
meta: { pagination: { count: 1, page: 1, pages: 1 } },
});
const result = await listAlertRecipients({
"filter[status]": "pending",
});
expect(result.data).toHaveLength(1);
expect(result.data[0].attributes.email).toBe("a@b.test");
});
it("forwards searchParams as query string", async () => {
await listAlertRecipients({ "filter[status]": "pending" });
const [url] = fetchMock.mock.calls.at(-1) ?? [""];
expect(String(url)).toContain("filter%5Bstatus%5D=pending");
});
});
@@ -0,0 +1,28 @@
"use server";
import { apiBaseUrl, getAuthHeaders } from "@/lib";
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
const RECIPIENTS_PATH = "/alerts/recipients";
export const listAlertRecipients = async (
searchParams?: Record<string, string | undefined>,
) => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}${RECIPIENTS_PATH}`);
if (searchParams) {
for (const [key, value] of Object.entries(searchParams)) {
if (value !== undefined && value !== "") {
url.searchParams.append(key, value);
}
}
}
try {
const response = await fetch(url.toString(), { headers });
return handleApiResponse(response);
} catch (error) {
return handleApiError(error);
}
};
@@ -0,0 +1,755 @@
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
ALERT_AGGREGATE_OPS,
ALERT_BOOLEAN_OPS,
ALERT_RECIPIENT_STATUS,
ALERT_TRIGGER_KINDS,
type AlertCondition,
type AlertRecipient,
type AlertRule,
} from "@/app/(prowler)/alerts/_types";
import type { ProviderProps } from "@/types/providers";
import { AlertFormModal } from "../alert-form-modal";
const recipientsActionMocks = vi.hoisted(() => ({
listAlertRecipients: vi.fn(),
}));
const alertsActionMocks = vi.hoisted(() => ({
previewAlertCondition: vi.fn(),
seedAlertRule: vi.fn(),
}));
vi.mock(
"@/app/(prowler)/alerts/_actions/recipients",
() => recipientsActionMocks,
);
vi.mock("@/app/(prowler)/alerts/_actions", () => alertsActionMocks);
vi.mock(
"@/components/compliance/compliance-header/compliance-scan-info",
() => ({
ComplianceScanInfo: () => <span>Scan</span>,
}),
);
vi.mock("@/components/ui/entities/entity-info", () => ({
EntityInfo: ({
entityAlias,
entityId,
}: {
entityAlias?: string;
entityId?: string;
}) => <span>{entityAlias ?? entityId}</span>,
}));
vi.mock("next-auth/react", () => ({
useSession: () => ({ data: null, status: "unauthenticated" }),
}));
vi.mock("next/navigation", () => ({
usePathname: () => "/alerts",
useRouter: () => ({ replace: vi.fn(), push: vi.fn(), refresh: vi.fn() }),
useSearchParams: () => new URLSearchParams(),
}));
vi.mock("@/components/shadcn/modal", () => ({
Modal: ({
open,
title,
description,
className,
onOpenAutoFocus,
children,
}: {
open: boolean;
title?: string;
description?: string;
className?: string;
onOpenAutoFocus?: (event: Event) => void;
children: ReactNode;
}) =>
open ? (
<div
role="dialog"
aria-label={title}
aria-description={description}
className={className}
data-allows-open-auto-focus={String(Boolean(onOpenAutoFocus))}
>
{children}
</div>
) : null,
}));
class ResizeObserverMock {
observe = vi.fn();
unobserve = vi.fn();
disconnect = vi.fn();
}
global.ResizeObserver = ResizeObserverMock;
Element.prototype.scrollIntoView = vi.fn();
const mockProviders: ProviderProps[] = [
{
id: "provider-aws-1",
type: "providers",
attributes: {
provider: "aws",
uid: "123456789012",
alias: "Production AWS",
status: "completed",
resources: 42,
connection: {
connected: true,
last_checked_at: "2026-04-30T00:00:00Z",
},
scanner_args: {
only_logs: false,
excluded_checks: [],
aws_retries_max_attempts: 3,
},
inserted_at: "2026-04-30T00:00:00Z",
updated_at: "2026-04-30T00:00:00Z",
created_by: { object: "users", id: "user-1" },
},
relationships: {
secret: { data: null },
provider_groups: { meta: { count: 0 }, data: [] },
},
},
{
id: "provider-gcp-1",
type: "providers",
attributes: {
provider: "gcp",
uid: "prowler-prod-project",
alias: "Production GCP",
status: "completed",
resources: 21,
connection: {
connected: true,
last_checked_at: "2026-04-30T00:00:00Z",
},
scanner_args: {
only_logs: false,
excluded_checks: [],
aws_retries_max_attempts: 3,
},
inserted_at: "2026-04-30T00:00:00Z",
updated_at: "2026-04-30T00:00:00Z",
created_by: { object: "users", id: "user-1" },
},
relationships: {
secret: { data: null },
provider_groups: { meta: { count: 0 }, data: [] },
},
},
];
const createRecipient = (
id: string,
email: string,
status: AlertRecipient["attributes"]["status"],
): AlertRecipient => ({
id,
type: "alert-recipients",
attributes: {
email,
status,
inserted_at: "2026-04-30T00:00:00Z",
updated_at: "2026-04-30T00:00:00Z",
},
relationships: { rules: { data: [] } },
});
const confirmedRecipient = createRecipient(
"recipient-confirmed",
"security@example.com",
ALERT_RECIPIENT_STATUS.CONFIRMED,
);
const pendingRecipient = createRecipient(
"recipient-pending",
"pending@example.com",
ALERT_RECIPIENT_STATUS.PENDING,
);
const createEditingAlert = (
overrides: Partial<AlertRule["attributes"]> = {},
): AlertRule => ({
id: "alert-1",
type: "alert-rules",
attributes: {
name: "Existing alert",
description: "Existing description",
enabled: true,
trigger: ALERT_TRIGGER_KINDS.AFTER_SCAN,
condition: {
op: ALERT_AGGREGATE_OPS.COUNT_GTE,
filter: { severity: ["critical"] },
value: 1,
},
schema_version: 1,
recipient_emails: ["security@example.com"],
inserted_at: "2026-04-30T00:00:00Z",
updated_at: "2026-04-30T00:00:00Z",
...overrides,
},
});
const mockRecipientsList = () => {
recipientsActionMocks.listAlertRecipients.mockResolvedValue({
data: [confirmedRecipient, pendingRecipient],
meta: { pagination: { page: 1, pages: 1, count: 2 } },
});
};
const renderCreateModal = (
props: Partial<React.ComponentProps<typeof AlertFormModal>> = {},
) =>
render(
<AlertFormModal
open
defaultFrequency={ALERT_TRIGGER_KINDS.AFTER_SCAN}
onOpenChange={vi.fn()}
onSubmit={vi.fn()}
{...props}
/>,
);
const getVisibleFilterTrigger = (label: string): HTMLButtonElement => {
const trigger = screen
.getAllByRole("combobox")
.find(
(element) =>
element.textContent?.includes(label) &&
!element.closest('[aria-hidden="true"]'),
);
expect(trigger).toBeDefined();
return trigger as HTMLButtonElement;
};
describe("AlertFormModal", () => {
beforeEach(() => {
recipientsActionMocks.listAlertRecipients.mockReset();
recipientsActionMocks.listAlertRecipients.mockReturnValue(
new Promise(() => {}),
);
alertsActionMocks.previewAlertCondition.mockReset();
alertsActionMocks.seedAlertRule.mockReset();
alertsActionMocks.seedAlertRule.mockResolvedValue({
data: {
attributes: {
condition: {
op: ALERT_AGGREGATE_OPS.ANY,
filter: { provider_type: ["gcp"] },
},
schema_version: 1,
warnings: [],
},
},
});
});
it("should render the simplified alert form without preview, delivery settings, or nested recipient management", () => {
// Given / When
renderCreateModal({
providers: mockProviders,
uniqueRegions: ["us-east-1", "europe-west1"],
uniqueServices: ["iam", "cloudsql"],
uniqueCategories: ["identity-security"],
uniqueGroups: ["prod"],
});
// Then
expect(screen.getByRole("dialog", { name: "Create Alert" })).toBeVisible();
expect(screen.getByLabelText(/^name$/i)).toBeVisible();
expect(screen.getByLabelText(/^description$/i)).toBeVisible();
expect(screen.getByLabelText(/^frequency$/i)).toBeVisible();
expect(screen.getByLabelText(/^recipients$/i)).toBeVisible();
expect(screen.getAllByRole("combobox")).toHaveLength(2);
expect(screen.queryByText("Alert criteria")).not.toBeInTheDocument();
expect(screen.queryByText(/delivery settings/i)).not.toBeInTheDocument();
expect(
screen.queryByLabelText(/notification method/i),
).not.toBeInTheDocument();
expect(screen.queryByText(/run preview/i)).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: /manage recipients/i }),
).not.toBeInTheDocument();
expect(screen.queryByText("Production AWS")).not.toBeInTheDocument();
expect(screen.queryByText(/resource type/i)).not.toBeInTheDocument();
expect(screen.queryByText(/^date$/i)).not.toBeInTheDocument();
});
it("should provide accessible dialog description and allow initial focus when editing", () => {
// Given / When
renderCreateModal({
editingAlert: createEditingAlert(),
});
// Then
const dialog = screen.getByRole("dialog", { name: "Edit Alert" });
expect(dialog).toHaveAccessibleDescription(
"Update recipients, frequency, and finding filters for this alert.",
);
expect(dialog).toHaveAttribute("data-allows-open-auto-focus", "true");
});
it("should show selected Findings filters as chips while keeping criteria controls hidden", () => {
// Given / When
renderCreateModal({
seededCondition: {
op: ALERT_AGGREGATE_OPS.ANY,
filter: { severity: ["critical"] },
},
selectedFindingsFilterChips: [
{ key: "filter[status__in]", label: "Status", value: "FAIL" },
{ key: "filter[muted]", label: "Muted", value: "false" },
],
});
// Then
expect(
screen.getByRole("region", { name: /active filters/i }),
).toHaveTextContent("Status: FAIL");
expect(
screen.getByRole("region", { name: /active filters/i }),
).toHaveTextContent("Muted: false");
expect(screen.queryByText("All Provider")).not.toBeInTheDocument();
expect(screen.queryByText(/run preview/i)).not.toBeInTheDocument();
});
it("should list tenant recipients with status and submit selected emails", async () => {
// Given
const user = userEvent.setup();
const onSubmit = vi
.fn()
.mockResolvedValue({ ok: true, alertId: "alert-1" });
mockRecipientsList();
renderCreateModal({ onSubmit });
// When
await user.type(screen.getByLabelText(/^name$/i), "Critical alerts");
await user.click(getVisibleFilterTrigger("Select emails"));
expect((await screen.findAllByText("Confirmed")).at(-1)).toBeVisible();
expect(screen.getAllByText("Pending").at(-1)).toBeVisible();
const recipientOptions = await screen.findAllByText("pending@example.com");
const visibleRecipientOption = recipientOptions.at(-1);
expect(visibleRecipientOption).toBeDefined();
await user.click(visibleRecipientOption as HTMLElement);
await user.click(screen.getByRole("button", { name: /^create$/i }));
// Then
expect(screen.getAllByText("pending@example.com").at(-1)).toBeVisible();
await waitFor(() =>
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
frequency: ALERT_TRIGGER_KINDS.AFTER_SCAN,
recipientEmails: ["pending@example.com"],
}),
),
);
const recipientsParams = recipientsActionMocks.listAlertRecipients.mock
.calls[0][0] as Record<string, string>;
expect(recipientsParams["filter[status]"]).toBeUndefined();
expect(recipientsParams["page[size]"]).toBe("100");
});
it("should submit the configured alert frequency", async () => {
// Given
const user = userEvent.setup();
const onSubmit = vi
.fn()
.mockResolvedValue({ ok: true, alertId: "alert-1" });
mockRecipientsList();
renderCreateModal({
defaultFrequency: ALERT_TRIGGER_KINDS.DAILY,
onSubmit,
});
// When
await user.type(screen.getByLabelText(/^name$/i), "Daily alerts");
expect(
screen.getByRole("combobox", { name: /frequency/i }),
).toHaveTextContent("Daily digest");
await user.click(getVisibleFilterTrigger("Select emails"));
const recipientOptions = await screen.findAllByText("security@example.com");
const visibleRecipientOption = recipientOptions.at(-1);
expect(visibleRecipientOption).toBeDefined();
await user.click(visibleRecipientOption as HTMLElement);
await user.click(screen.getByRole("button", { name: /^create$/i }));
// Then
await waitFor(() =>
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
frequency: ALERT_TRIGGER_KINDS.DAILY,
}),
),
);
});
it("should allow submitting without selected recipients", async () => {
// Given
const user = userEvent.setup();
const onSubmit = vi
.fn()
.mockResolvedValue({ ok: true, alertId: "alert-1" });
mockRecipientsList();
renderCreateModal({ onSubmit });
// When
await user.type(screen.getByLabelText(/^name$/i), "Critical alerts");
await user.click(screen.getByRole("button", { name: /^create$/i }));
// Then
await waitFor(() =>
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
recipientEmails: [],
}),
),
);
expect(
screen.queryByText(/select at least one recipient/i),
).not.toBeInTheDocument();
});
it("should render backend submit errors with the design error color", async () => {
// Given
const user = userEvent.setup();
const onSubmit = vi.fn().mockResolvedValue({
ok: false,
error: "Backend validation failed",
});
mockRecipientsList();
renderCreateModal({ onSubmit });
// When
await user.type(screen.getByLabelText(/^name$/i), "Critical alerts");
await user.click(screen.getByRole("button", { name: /^create$/i }));
// Then
const errorMessage = await screen.findByText("Backend validation failed");
expect(errorMessage).toHaveClass("text-text-error-primary");
});
it("should reset form defaults when opening a different alert", () => {
// Given
const { rerender } = render(
<AlertFormModal
open
defaultFrequency={ALERT_TRIGGER_KINDS.AFTER_SCAN}
editingAlert={createEditingAlert({ name: "First alert" })}
onOpenChange={vi.fn()}
onSubmit={vi.fn()}
/>,
);
// When
rerender(
<AlertFormModal
open
defaultFrequency={ALERT_TRIGGER_KINDS.AFTER_SCAN}
editingAlert={createEditingAlert({
name: "Second alert",
updated_at: "2026-05-01T00:00:00Z",
})}
onOpenChange={vi.fn()}
onSubmit={vi.fn()}
/>,
);
// Then
expect(screen.getByLabelText(/^name$/i)).toHaveValue("Second alert");
});
it("should render the shared Findings batch filter controls for an existing alert", async () => {
// Given
mockRecipientsList();
renderCreateModal({
editingAlert: createEditingAlert({
condition: {
op: ALERT_BOOLEAN_OPS.AND,
children: [
{
op: ALERT_AGGREGATE_OPS.COUNT_GTE,
filter: { severity: ["critical"] },
value: 1,
},
{
op: ALERT_AGGREGATE_OPS.COUNT_GTE,
filter: { provider_type: ["aws"] },
value: 1,
},
],
},
}),
providers: mockProviders,
uniqueRegions: ["us-east-1", "europe-west1"],
uniqueServices: ["iam", "cloudsql"],
uniqueResourceTypes: ["AWS::IAM::User"],
uniqueCategories: ["identity-security"],
uniqueGroups: ["prod"],
});
// Then
const recipientsTrigger = screen.getByLabelText(/^recipients$/i);
const filtersHeading = screen.getByRole("heading", { name: /^filters$/i });
expect(filtersHeading).toBeVisible();
expect(
recipientsTrigger.compareDocumentPosition(filtersHeading) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
expect(filtersHeading.closest('[data-slot="card"]')).toBeVisible();
const filterControls = screen.getByTestId("findings-filter-controls");
const alertEditGrid = filterControls.querySelector(".grid");
expect(alertEditGrid).toHaveClass("xl:grid-cols-3", "2xl:grid-cols-3");
expect(alertEditGrid).not.toHaveClass("xl:grid-cols-4", "2xl:grid-cols-5");
expect(screen.getAllByText("Amazon Web Services")[0]).toBeVisible();
expect(screen.getByText("All accounts")).toBeVisible();
expect(within(filterControls).getByText("All Delta")).toBeVisible();
expect(within(filterControls).getByText("All Resource Type")).toBeVisible();
expect(
screen.queryByTestId("findings-expanded-filters"),
).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: /more filters/i }),
).not.toBeInTheDocument();
expect(screen.queryByText("All Status")).not.toBeInTheDocument();
expect(screen.queryByText("Scan ID")).not.toBeInTheDocument();
expect(screen.queryByText(/^date$/i)).not.toBeInTheDocument();
expect(screen.queryByLabelText(/^severity$/i)).not.toBeInTheDocument();
});
it("should save edited filters as a normalized simple condition", async () => {
// Given
const user = userEvent.setup();
const onSubmit = vi
.fn()
.mockResolvedValue({ ok: true, alertId: "alert-1" });
mockRecipientsList();
renderCreateModal({
editingAlert: createEditingAlert(),
providers: mockProviders,
onSubmit,
});
// When
await user.click(screen.getByLabelText(/provider type/i));
const providerOptions = await screen.findAllByText("Google Cloud Platform");
const visibleProviderOption = providerOptions.at(-1);
expect(visibleProviderOption).toBeDefined();
await user.click(visibleProviderOption as HTMLElement);
await user.click(screen.getByRole("button", { name: /^save$/i }));
// Then
await waitFor(() =>
expect(alertsActionMocks.seedAlertRule).toHaveBeenCalled(),
);
expect(alertsActionMocks.seedAlertRule).toHaveBeenCalledWith(
expect.objectContaining({
"filter[provider_type__in]": ["gcp"],
}),
);
await waitFor(() =>
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
condition: expect.objectContaining({
filter: { provider_type: ["gcp"] },
}),
}),
),
);
});
it("should preview the edited alert using current unsaved filters", async () => {
// Given
const user = userEvent.setup();
alertsActionMocks.previewAlertCondition.mockResolvedValue({
data: {
attributes: {
summary: {
finding_count_total: 7,
top_severity: "critical",
},
sample_finding_ids: [],
evaluation_failed: false,
duration_ms: 42,
},
},
});
mockRecipientsList();
renderCreateModal({
editingAlert: createEditingAlert(),
providers: mockProviders,
});
// When
await user.click(screen.getByLabelText(/provider type/i));
const providerOptions = await screen.findAllByText("Google Cloud Platform");
const visibleProviderOption = providerOptions.at(-1);
expect(visibleProviderOption).toBeDefined();
await user.click(visibleProviderOption as HTMLElement);
await user.click(screen.getByRole("button", { name: /^test$/i }));
// Then
await waitFor(() =>
expect(alertsActionMocks.seedAlertRule).toHaveBeenCalledWith(
expect.objectContaining({
"filter[provider_type__in]": ["gcp"],
}),
),
);
await waitFor(() =>
expect(alertsActionMocks.previewAlertCondition).toHaveBeenCalledWith(
expect.objectContaining({
condition: expect.objectContaining({
filter: { provider_type: ["gcp"] },
}),
}),
),
);
const previewHeading = await screen.findByText("Test result");
expect(previewHeading).toBeVisible();
const previewCard = previewHeading.closest('[data-slot="card"]');
expect(previewCard).toBeInTheDocument();
const previewCardQueries = within(previewCard as HTMLElement);
expect(
previewCardQueries.getByText(
"It found 7 findings, including Critical severity.",
),
).toBeVisible();
expect(
previewCardQueries.queryByText(/^findings$/i),
).not.toBeInTheDocument();
expect(
previewCardQueries.queryByText(/^top severity$/i),
).not.toBeInTheDocument();
expect(
previewCardQueries.queryByText(/^duration$/i),
).not.toBeInTheDocument();
expect(previewCardQueries.queryByText(/42 ms/i)).not.toBeInTheDocument();
expect(
previewCardQueries.queryByText("Would fire"),
).not.toBeInTheDocument();
expect(
previewCardQueries.queryByText("Would not fire"),
).not.toBeInTheDocument();
});
it("should explain when the edited alert has no matching findings", async () => {
// Given
const user = userEvent.setup();
alertsActionMocks.previewAlertCondition.mockResolvedValue({
data: {
attributes: {
summary: {
finding_count_total: 0,
},
sample_finding_ids: [],
evaluation_failed: false,
},
},
});
mockRecipientsList();
renderCreateModal({ editingAlert: createEditingAlert() });
// When
await user.click(screen.getByRole("button", { name: /^test$/i }));
// Then
expect(
await screen.findByText(
"These filters did not match any findings for the latest scan.",
),
).toBeVisible();
expect(screen.queryByText("Would fire")).not.toBeInTheDocument();
expect(screen.queryByText("Would not fire")).not.toBeInTheDocument();
});
it("should render preview errors inline in edit mode", async () => {
// Given
const user = userEvent.setup();
alertsActionMocks.previewAlertCondition.mockResolvedValue({
error: "Invalid condition",
});
mockRecipientsList();
renderCreateModal({ editingAlert: createEditingAlert() });
// When
await user.click(screen.getByRole("button", { name: /^test$/i }));
// Then
const errorMessage = await screen.findByText(/invalid condition/i);
expect(errorMessage).toBeVisible();
expect(errorMessage).toHaveClass("text-text-error-primary");
});
it("should hydrate advanced edit mode filters and normalize them on save", async () => {
// Given
const user = userEvent.setup();
const advancedCondition: AlertCondition = {
op: ALERT_BOOLEAN_OPS.NOT,
child: {
op: ALERT_AGGREGATE_OPS.COUNT_GTE,
filter: { severity: ["critical"] },
value: 1,
},
};
const onSubmit = vi
.fn()
.mockResolvedValue({ ok: true, alertId: "alert-1" });
alertsActionMocks.seedAlertRule.mockResolvedValue({
data: {
attributes: {
condition: {
op: ALERT_AGGREGATE_OPS.ANY,
filter: { severity: ["critical"] },
},
schema_version: 1,
warnings: [],
},
},
});
mockRecipientsList();
renderCreateModal({
editingAlert: createEditingAlert({
condition: advancedCondition,
recipient_emails: ["security@example.com"],
}),
onSubmit,
});
// When
await user.click(screen.getByRole("button", { name: /^save$/i }));
// Then
await waitFor(() =>
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
name: "Existing alert",
recipientEmails: ["security@example.com"],
condition: {
op: ALERT_AGGREGATE_OPS.ANY,
filter: { severity: ["critical"] },
},
}),
),
);
expect(
screen.queryByText(/advanced condition preserved/i),
).not.toBeInTheDocument();
});
});
@@ -0,0 +1,305 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { isValidElement, type ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
ALERT_AGGREGATE_OPS,
ALERT_TRIGGER_KINDS,
type AlertRule,
} from "@/app/(prowler)/alerts/_types";
import { AlertsManager } from "../alerts-manager";
const actionMocks = vi.hoisted(() => ({
deleteAlert: vi.fn(),
disableAlert: vi.fn(),
enableAlert: vi.fn(),
updateAlert: vi.fn(),
}));
const routerMocks = vi.hoisted(() => ({
refresh: vi.fn(),
replace: vi.fn(),
push: vi.fn(),
currentSearch: "",
}));
const toastMock = vi.hoisted(() => vi.fn());
vi.mock("@/app/(prowler)/alerts/_actions", () => actionMocks);
vi.mock("next/link", () => ({
default: ({
children,
href,
className,
}: {
children: ReactNode;
href: string;
className?: string;
}) => (
<a href={href} className={className}>
{children}
</a>
),
}));
vi.mock("@/lib", () => ({
cn: (...classes: Array<string | false | null | undefined>) =>
classes.filter(Boolean).join(" "),
}));
vi.mock("next/navigation", () => ({
usePathname: () => "/alerts",
useRouter: () => routerMocks,
useSearchParams: () => new URLSearchParams(routerMocks.currentSearch),
}));
vi.mock("@/components/ui", () => ({
useToast: () => ({ toast: toastMock }),
}));
vi.mock("@/components/shadcn", () => ({
Button: ({
asChild,
children,
disabled,
onClick,
variant,
}: {
asChild?: boolean;
children: ReactNode;
disabled?: boolean;
onClick?: () => void;
variant?: string;
}) => {
if (asChild && isValidElement(children)) {
return <span data-variant={variant}>{children}</span>;
}
return (
<button
type="button"
disabled={disabled}
onClick={onClick}
data-variant={variant}
>
{children}
</button>
);
},
}));
vi.mock("../alert-form-modal", () => ({
AlertFormModal: ({
open,
editingAlert,
onOpenChange,
}: {
open: boolean;
editingAlert?: AlertRule | null;
onOpenChange: (open: boolean) => void;
}) =>
open ? (
<div
role="dialog"
aria-label={editingAlert ? "Edit Alert" : "Create Alert"}
>
<button type="button" onClick={() => onOpenChange(false)}>
Close modal
</button>
{editingAlert?.attributes.name}
</div>
) : null,
}));
vi.mock("../alerts-empty-state", () => ({
AlertsEmptyState: () => <div>No alerts</div>,
}));
const makeAlert = (enabled: boolean): AlertRule => ({
id: enabled ? "enabled-alert" : "disabled-alert",
type: "alert-rules",
attributes: {
name: enabled ? "Enabled alert" : "Disabled alert",
description: "",
enabled,
trigger: ALERT_TRIGGER_KINDS.AFTER_SCAN,
condition: {
op: ALERT_AGGREGATE_OPS.ANY,
filter: { severity: ["critical"] },
},
schema_version: 1,
recipient_emails: [],
inserted_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
},
});
const renderManager = (alerts: AlertRule[]) =>
render(
<AlertsManager
alerts={alerts}
loadError={null}
providers={[]}
completedScanIds={[]}
scanDetails={[]}
uniqueRegions={[]}
uniqueServices={[]}
uniqueResourceTypes={[]}
uniqueCategories={[]}
uniqueGroups={[]}
/>,
);
describe("AlertsManager", () => {
beforeEach(() => {
vi.clearAllMocks();
routerMocks.currentSearch = "";
});
it("links to Findings from the alerts description", () => {
// Given
renderManager([]);
// When
const findingsLink = screen.getByRole("link", { name: "Findings" });
// Then
expect(findingsLink).toHaveAttribute(
"href",
"/findings?filter[muted]=false&filter[status__in]=FAIL",
);
expect(findingsLink.closest("[data-variant='link']")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "here." })).toHaveAttribute(
"href",
"https://docs.prowler.com/user-guide/tutorials/prowler-app",
);
expect(screen.getByText(/get notified when findings match/i)).toBeVisible();
});
it("opens the edit modal for an initial editing alert", () => {
// Given
const alert = makeAlert(true);
// When
render(
<AlertsManager
alerts={[alert]}
loadError={null}
providers={[]}
completedScanIds={[]}
scanDetails={[]}
uniqueRegions={[]}
uniqueServices={[]}
uniqueResourceTypes={[]}
uniqueCategories={[]}
uniqueGroups={[]}
initialEditingAlert={alert}
/>,
);
// Then
expect(
screen.getByRole("dialog", { name: /edit alert/i }),
).toHaveTextContent("Enabled alert");
});
it("adds the edit alert id to the URL when opening the edit modal", async () => {
// Given
const user = userEvent.setup();
const alert = makeAlert(true);
routerMocks.currentSearch = "page=2&filter[enabled]=true";
renderManager([alert]);
// When
await user.click(
screen.getByRole("button", { name: /actions for enabled alert/i }),
);
await user.click(screen.getByRole("menuitem", { name: /edit/i }));
// Then
expect(routerMocks.replace).toHaveBeenCalledWith(
"/alerts?page=2&filter%5Benabled%5D=true&edit=enabled-alert",
{ scroll: false },
);
expect(
screen.getByRole("dialog", { name: /edit alert/i }),
).toHaveTextContent("Enabled alert");
});
it("removes only the edit alert id from the URL when closing the edit modal", async () => {
// Given
const user = userEvent.setup();
const alert = makeAlert(true);
routerMocks.currentSearch = "page=2&edit=enabled-alert";
render(
<AlertsManager
alerts={[alert]}
loadError={null}
providers={[]}
completedScanIds={[]}
scanDetails={[]}
uniqueRegions={[]}
uniqueServices={[]}
uniqueResourceTypes={[]}
uniqueCategories={[]}
uniqueGroups={[]}
initialEditingAlert={alert}
/>,
);
// When
await user.click(screen.getByRole("button", { name: /close modal/i }));
// Then
expect(routerMocks.replace).toHaveBeenCalledWith("/alerts?page=2", {
scroll: false,
});
});
it("shows a success toast after disabling an alert", async () => {
// Given
const user = userEvent.setup();
const alert = makeAlert(true);
actionMocks.disableAlert.mockResolvedValue({ data: alert });
renderManager([alert]);
// When
await user.click(
screen.getByRole("button", { name: /actions for enabled alert/i }),
);
await user.click(screen.getByRole("menuitem", { name: /disable/i }));
// Then
await waitFor(() =>
expect(toastMock).toHaveBeenCalledWith({
title: "Alert disabled",
description: "Enabled alert",
}),
);
});
it("shows a success toast after enabling an alert", async () => {
// Given
const user = userEvent.setup();
const alert = makeAlert(false);
actionMocks.enableAlert.mockResolvedValue({ data: alert });
renderManager([alert]);
// When
await user.click(
screen.getByRole("button", { name: /actions for disabled alert/i }),
);
await user.click(screen.getByRole("menuitem", { name: /enable/i }));
// Then
await waitFor(() =>
expect(toastMock).toHaveBeenCalledWith({
title: "Alert enabled",
description: "Disabled alert",
}),
);
});
});
@@ -0,0 +1,253 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
ALERT_AGGREGATE_OPS,
ALERT_TRIGGER_KINDS,
type AlertRule,
} from "@/app/(prowler)/alerts/_types";
import { AlertsTable } from "../alerts-table";
const navigationMocks = vi.hoisted(() => ({
routerPush: vi.fn(),
currentSearch: "",
}));
vi.mock("next/navigation", () => ({
usePathname: () => "/alerts",
useRouter: () => ({ push: navigationMocks.routerPush }),
useSearchParams: () => new URLSearchParams(navigationMocks.currentSearch),
}));
vi.mock("@/components/ui/table/data-table", () => ({
DataTable: ({
columns,
data,
metadata,
}: {
columns: {
id?: string;
size?: number;
minSize?: number;
cell?: (context: { row: { original: AlertRule } }) => ReactNode;
}[];
data: AlertRule[];
metadata?: { pagination?: { count?: number } };
}) => (
<div>
{metadata?.pagination?.count !== undefined && (
<span>{metadata.pagination.count} Total Entries</span>
)}
<table>
<thead>
<tr>
{columns.map((column) => (
<th
key={column.id}
data-testid={`column-${column.id}`}
data-size={column.size}
data-min-size={column.minSize}
>
<button
type="button"
onClick={() =>
navigationMocks.routerPush(`/alerts?sort=${column.id}`, {
scroll: false,
})
}
>
{column.id === "enabled" ? "Status" : column.id}
</button>
</th>
))}
</tr>
</thead>
<tbody>
{data.map((alert) => (
<tr key={alert.id}>
{columns.map((column) => (
<td key={`${alert.id}-${column.id}`}>
{column.cell?.({ row: { original: alert } })}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
),
}));
vi.mock("@/components/ui/table/data-table-column-header", () => ({
DataTableColumnHeader: ({ title }: { title: string }) => <span>{title}</span>,
}));
interface AlertRuleOverrides extends Partial<Omit<AlertRule, "attributes">> {
attributes?: Partial<AlertRule["attributes"]>;
}
const makeRule = (overrides: AlertRuleOverrides = {}): AlertRule => ({
id: overrides.id ?? "alert-1",
type: "alert-rules",
attributes: {
name: "Critical findings",
description: "Notify security",
enabled: true,
trigger: ALERT_TRIGGER_KINDS.AFTER_SCAN,
condition: {
op: ALERT_AGGREGATE_OPS.ANY,
filter: { severity: ["critical"] },
},
schema_version: 1,
recipient_emails: ["security@example.com"],
inserted_at: "2026-01-01T10:00:00Z",
updated_at: "2026-01-02T11:30:00Z",
...overrides.attributes,
},
});
describe("AlertsTable", () => {
beforeEach(() => {
navigationMocks.currentSearch = "";
navigationMocks.routerPush.mockClear();
});
it("should render alert rows with dropdown actions and shared pagination", () => {
// Given / When
render(
<AlertsTable
alerts={[makeRule()]}
meta={{ pagination: { page: 1, pages: 2, count: 12 }, version: "1" }}
mutatingId={null}
onEdit={vi.fn()}
onToggleEnabled={vi.fn()}
onDelete={vi.fn()}
/>,
);
// Then
expect(
screen.getByRole("cell", { name: /critical findings/i }),
).toBeVisible();
expect(
screen.getByRole("button", { name: /actions for critical findings/i }),
).toBeVisible();
expect(
screen.queryByRole("button", { name: /edit critical findings/i }),
).not.toBeInTheDocument();
expect(screen.getByText(/12 total entries/i)).toBeVisible();
expect(screen.getByTestId("column-actions")).toHaveAttribute(
"data-size",
"72",
);
expect(screen.getByTestId("column-name")).toHaveAttribute(
"data-size",
"320",
);
expect(screen.getByTestId("column-inserted_at")).toHaveAttribute(
"data-size",
"170",
);
expect(screen.getByTestId("column-updated_at")).toHaveAttribute(
"data-size",
"170",
);
expect(screen.getByText("Jan 01, 2026")).toBeVisible();
expect(screen.getByText("Jan 02, 2026")).toBeVisible();
expect(
screen.queryByRole("button", { name: /run preview|test/i }),
).not.toBeInTheDocument();
expect(
screen.queryByRole("link", { name: /critical findings/i }),
).not.toBeInTheDocument();
});
it("should truncate long descriptions in the name column", () => {
// Given
const description =
"This alert description is intentionally long enough to overflow the alerts table if it is not constrained by the cell renderer.";
// When
render(
<AlertsTable
alerts={[makeRule({ attributes: { description } })]}
mutatingId={null}
onEdit={vi.fn()}
onToggleEnabled={vi.fn()}
onDelete={vi.fn()}
/>,
);
// Then
expect(screen.getByText(description)).toHaveClass("truncate");
expect(screen.getByText(description).parentElement).toHaveClass(
"max-w-[320px]",
);
expect(screen.getByText(description)).toHaveAttribute("title", description);
});
it("should call row action callbacks for edit, toggle, and delete", async () => {
// Given
const user = userEvent.setup();
const alert = makeRule({ id: "alert-enabled" });
const onEdit = vi.fn();
const onToggleEnabled = vi.fn();
const onDelete = vi.fn();
render(
<AlertsTable
alerts={[alert]}
mutatingId={null}
onEdit={onEdit}
onToggleEnabled={onToggleEnabled}
onDelete={onDelete}
/>,
);
// When
await user.click(
screen.getByRole("button", { name: /actions for critical findings/i }),
);
await user.click(screen.getByRole("menuitem", { name: /edit/i }));
await user.click(
screen.getByRole("button", { name: /actions for critical findings/i }),
);
await user.click(screen.getByRole("menuitem", { name: /disable/i }));
await user.click(
screen.getByRole("button", { name: /actions for critical findings/i }),
);
await user.click(screen.getByRole("menuitem", { name: /delete/i }));
// Then
expect(onEdit).toHaveBeenCalledWith(alert);
expect(onToggleEnabled).toHaveBeenCalledWith(alert);
expect(onDelete).toHaveBeenCalledWith(alert);
});
it("should edit the alert directly when clicking the alert name", async () => {
// Given
const user = userEvent.setup();
const alert = makeRule();
const onEdit = vi.fn();
render(
<AlertsTable
alerts={[alert]}
mutatingId={null}
onEdit={onEdit}
onToggleEnabled={vi.fn()}
onDelete={vi.fn()}
/>,
);
// When
await user.click(screen.getByRole("button", { name: "Critical findings" }));
// Then
expect(onEdit).toHaveBeenCalledWith(alert);
expect(screen.queryByRole("menuitem", { name: /edit/i })).toBeNull();
expect(screen.queryByRole("menuitem", { name: /disable/i })).toBeNull();
expect(screen.queryByRole("menuitem", { name: /delete/i })).toBeNull();
});
});
@@ -0,0 +1,384 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ComponentProps, ReactNode } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { AlertCondition } from "@/app/(prowler)/alerts/_types";
import type {
AlertFormSubmitResult,
AlertFormValues,
} from "@/app/(prowler)/alerts/_types/alert-form";
const routerMocks = vi.hoisted(() => ({
push: vi.fn(),
refresh: vi.fn(),
}));
const actionMocks = vi.hoisted(() => ({
createAlert: vi.fn(),
seedAlertRule: vi.fn(),
}));
const toastMock = vi.hoisted(() => vi.fn());
vi.mock("next/navigation", () => ({
useRouter: () => routerMocks,
}));
vi.mock("@/components/ui", () => ({
ToastAction: ({
asChild,
children,
...props
}: ComponentProps<"button"> & {
asChild?: boolean;
children?: ReactNode;
}) => (asChild ? children : <button {...props}>{children}</button>),
useToast: () => ({ toast: toastMock }),
}));
vi.mock("@/app/(prowler)/alerts/_actions", () => ({
createAlert: actionMocks.createAlert,
seedAlertRule: actionMocks.seedAlertRule,
}));
vi.mock("@/app/(prowler)/alerts/_components/alert-form-modal", () => ({
AlertFormModal: ({
open,
seededCondition,
selectedFindingsFilterChips,
defaultName,
onSubmit,
}: {
open: boolean;
seededCondition?: AlertCondition | null;
selectedFindingsFilterChips?: Array<{
label: string;
displayValue?: string;
value: string;
}>;
defaultName?: string;
onSubmit: (values: AlertFormValues) => Promise<AlertFormSubmitResult>;
}) =>
open ? (
<div role="dialog" aria-label="Create alert">
<output data-testid="seeded-condition">
{JSON.stringify(seededCondition)}
</output>
<output data-testid="selected-filter-chips">
{(selectedFindingsFilterChips ?? [])
.map((chip) => `${chip.label}:${chip.displayValue ?? chip.value}`)
.join("|")}
</output>
<button
type="button"
onClick={() =>
onSubmit({
name: defaultName ?? "Findings filter alert",
description: "",
method: "email",
frequency: "after_scan",
condition: seededCondition ?? {
op: "any",
filter: { severity: ["critical"] },
},
recipientEmails: ["security@example.com"],
enabled: true,
})
}
>
Submit mock alert
</button>
</div>
) : null,
}));
import { SeedFromFindingsButton } from "../seed-from-findings-button";
describe("SeedFromFindingsButton", () => {
afterEach(() => {
vi.clearAllMocks();
});
it("should explain why creating an alert is disabled when no real filters are applied", async () => {
// Given
const user = userEvent.setup();
render(<SeedFromFindingsButton filterBag={{ sort: "-inserted_at" }} />);
// When
const button = screen.getByRole("button", {
name: /Create Alert/i,
});
const tooltipTrigger = button.parentElement;
expect(tooltipTrigger).not.toBeNull();
await user.hover(tooltipTrigger as HTMLElement);
// Then
expect(button).toBeDisabled();
expect(
await screen.findAllByText(/at least one findings filter/i),
).not.toHaveLength(0);
});
it("should enable creation from the first real filter, including unsupported backend filters", () => {
// Given / When
render(
<SeedFromFindingsButton
filterBag={{
"filter[status__in]": "FAIL",
"filter[muted]": "false",
"filter[scan__in]": "11111111-1111-1111-1111-111111111111",
}}
/>,
);
// Then
expect(
screen.getByRole("button", { name: /Create Alert/i }),
).not.toBeDisabled();
expect(screen.getByRole("button", { name: /Create Alert/i })).toHaveClass(
"h-10",
);
});
it("should add all severities when Findings only has non-portable default filters", async () => {
// Given
const user = userEvent.setup();
const seededCondition: AlertCondition = {
op: "any",
filter: {
severity: ["critical", "high", "medium", "low", "informational"],
},
};
actionMocks.seedAlertRule.mockResolvedValue({
data: {
attributes: {
condition: seededCondition,
schema_version: 1,
warnings: [],
},
},
});
const filterBag = {
"filter[status__in]": "FAIL",
"filter[muted]": "false",
"filter[scan__in]": "11111111-1111-1111-1111-111111111111",
};
render(<SeedFromFindingsButton filterBag={filterBag} />);
// When
await user.click(screen.getByRole("button", { name: /Create Alert/i }));
// Then
await waitFor(() =>
expect(actionMocks.seedAlertRule).toHaveBeenCalledWith({
...filterBag,
"filter[severity__in]": [
"critical",
"high",
"medium",
"low",
"informational",
],
}),
);
expect(screen.getByRole("dialog", { name: /create alert/i })).toBeVisible();
expect(screen.getByTestId("seeded-condition")).toHaveTextContent(
"severity",
);
});
it("should seed from the full Findings filter bag before opening the modal", async () => {
// Given
const user = userEvent.setup();
const seededCondition: AlertCondition = {
op: "any",
filter: { severity: ["critical", "high"] },
};
actionMocks.seedAlertRule.mockResolvedValue({
data: {
attributes: {
condition: seededCondition,
schema_version: 1,
warnings: [],
},
},
});
const filterBag = {
"filter[status__in]": "FAIL",
"filter[muted]": "false",
"filter[scan__in]": "11111111-1111-1111-1111-111111111111",
"filter[severity__in]": "critical,high",
};
render(<SeedFromFindingsButton filterBag={filterBag} />);
// When
await user.click(screen.getByRole("button", { name: /Create Alert/i }));
// Then
await waitFor(() =>
expect(actionMocks.seedAlertRule).toHaveBeenCalledWith(filterBag),
);
expect(screen.getByRole("dialog", { name: /create alert/i })).toBeVisible();
expect(routerMocks.push).not.toHaveBeenCalled();
expect(screen.getByTestId("selected-filter-chips")).toHaveTextContent(
/severity:\+2/i,
);
expect(screen.getByTestId("seeded-condition")).toHaveTextContent(
"severity",
);
expect(screen.getByTestId("selected-filter-chips")).not.toHaveTextContent(
/status/i,
);
});
it("should create the alert through the existing alert action from the modal", async () => {
// Given
const user = userEvent.setup();
const seededCondition: AlertCondition = {
op: "any",
filter: { severity: ["critical"] },
};
actionMocks.seedAlertRule.mockResolvedValue({
data: {
attributes: {
condition: seededCondition,
schema_version: 1,
warnings: [],
},
},
});
actionMocks.createAlert.mockResolvedValue({
data: {
id: "alert-1",
attributes: { name: "Findings filter alert" },
},
});
render(
<SeedFromFindingsButton
filterBag={{ "filter[severity__in]": "critical" }}
/>,
);
// When
await user.click(screen.getByRole("button", { name: /Create Alert/i }));
await user.click(
screen.getByRole("button", { name: /submit mock alert/i }),
);
// Then
await waitFor(() =>
expect(actionMocks.createAlert).toHaveBeenCalledWith(
expect.objectContaining({
name: "Findings filter alert",
trigger: "after_scan",
condition: seededCondition,
recipientEmails: ["security@example.com"],
}),
),
);
expect(routerMocks.refresh).toHaveBeenCalled();
expect(toastMock).toHaveBeenCalledWith(
expect.objectContaining({
title: "Alert created",
action: expect.anything(),
}),
);
});
it("should add a toast action to navigate to alerts after creating an alert", async () => {
// Given
const user = userEvent.setup();
actionMocks.seedAlertRule.mockResolvedValue({
data: {
attributes: {
condition: { op: "any", filter: { severity: ["critical"] } },
schema_version: 1,
warnings: [],
},
},
});
actionMocks.createAlert.mockResolvedValue({
data: {
id: "alert-1",
attributes: { name: "Findings filter alert" },
},
});
render(
<SeedFromFindingsButton
filterBag={{ "filter[severity__in]": "critical" }}
/>,
);
// When
await user.click(screen.getByRole("button", { name: /Create Alert/i }));
await user.click(
screen.getByRole("button", { name: /submit mock alert/i }),
);
// Then
await waitFor(() => expect(toastMock).toHaveBeenCalled());
const toastAction = toastMock.mock.calls[0][0].action;
render(toastAction);
expect(screen.getByRole("link", { name: /view alerts/i })).toHaveAttribute(
"href",
"/alerts",
);
});
it("should show a toast and keep the modal closed when seed fails", async () => {
// Given
const user = userEvent.setup();
actionMocks.seedAlertRule.mockResolvedValue({
error: "invalid_shape",
});
render(
<SeedFromFindingsButton
filterBag={{ "filter[severity__in]": "critical" }}
/>,
);
// When
await user.click(screen.getByRole("button", { name: /Create Alert/i }));
// Then
await waitFor(() =>
expect(toastMock).toHaveBeenCalledWith(
expect.objectContaining({
variant: "destructive",
title: "Alert seed failed",
}),
),
);
expect(
screen.queryByRole("dialog", { name: /create alert/i }),
).not.toBeInTheDocument();
});
it("should render disabled as a Cloud-only feature in OSS", () => {
// Given
render(
<SeedFromFindingsButton
filterBag={{ "filter[severity__in]": "critical" }}
isCloudEnabled={false}
/>,
);
// When
const button = screen.getByRole("button", { name: /Create Alert/i });
// Then
expect(button).toBeDisabled();
expect(button.className).not.toContain("min-w");
expect(button).not.toHaveClass("justify-start");
const pricingLink = screen.getByRole("link", {
name: /available in prowler cloud/i,
});
expect(pricingLink).toHaveAttribute("href", "https://prowler.com/pricing");
expect(pricingLink).toHaveClass("whitespace-nowrap");
expect(pricingLink).toHaveTextContent("Available in Prowler Cloud");
expect(pricingLink.closest("button")).toBeNull();
expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();
expect(actionMocks.seedAlertRule).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,613 @@
"use client";
import { useState } from "react";
import {
previewAlertCondition,
seedAlertRule,
} from "@/app/(prowler)/alerts/_actions";
import { listAlertRecipients } from "@/app/(prowler)/alerts/_actions/recipients";
import {
ALERT_TRIGGER_KINDS,
type AlertCondition,
type AlertPreviewResponse,
type AlertRecipient,
type AlertRule,
type AlertTriggerKind,
} from "@/app/(prowler)/alerts/_types";
import type { FilterChip } from "@/components/filters/filter-summary-strip";
import { FilterSummaryStrip } from "@/components/filters/filter-summary-strip";
import { FindingsFilterBatchControls } from "@/components/findings/findings-filters";
import {
Badge,
Button,
Card,
CardContent,
Field,
FieldError,
FieldLabel,
Input,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Skeleton,
Textarea,
} from "@/components/shadcn";
import { Modal } from "@/components/shadcn/modal";
import {
MultiSelect,
MultiSelectContent,
MultiSelectItem,
MultiSelectSelectAll,
MultiSelectSeparator,
MultiSelectTrigger,
MultiSelectValue,
} from "@/components/shadcn/select/multiselect";
import { useMountEffect } from "@/hooks/use-mount-effect";
import type { ScanEntity } from "@/types";
import type { ProviderProps } from "@/types/providers";
import {
getAlertFormDefaults,
getEmptyAlertFormDefaults,
getFindingsFiltersFromAlertCondition,
} from "../_lib/alert-adapter";
import { alertFormSchema } from "../_lib/alert-form-schema";
import type {
AlertFormSubmitResult,
AlertFormValues,
} from "../_types/alert-form";
import { ALERT_NOTIFICATION_METHODS } from "../_types/alert-form";
interface AlertFormModalProps {
open: boolean;
defaultFrequency: AlertTriggerKind;
providers?: ProviderProps[];
completedScanIds?: string[];
scanDetails?: { [key: string]: ScanEntity }[];
uniqueRegions?: string[];
uniqueServices?: string[];
uniqueResourceTypes?: string[];
uniqueCategories?: string[];
uniqueGroups?: string[];
editingAlert?: AlertRule | null;
seededCondition?: AlertCondition | null;
selectedFindingsFilterChips?: FilterChip[];
defaultName?: string;
onOpenChange: (open: boolean) => void;
onSubmit: (values: AlertFormValues) => Promise<AlertFormSubmitResult>;
}
interface FormErrors {
name?: string;
recipientEmails?: string;
root?: string;
}
const normalizeEmail = (email: string): string => email.trim().toLowerCase();
const getRecipientEmails = (selectedEmails: Set<string>): string[] =>
Array.from(selectedEmails);
const ALERT_FREQUENCY_OPTIONS = [
{
value: ALERT_TRIGGER_KINDS.AFTER_SCAN,
label: "After each scan",
},
{
value: ALERT_TRIGGER_KINDS.DAILY,
label: "Daily digest",
},
{
value: ALERT_TRIGGER_KINDS.BOTH,
label: "After each scan and daily",
},
] as const;
const ALERT_SEED_ERROR = "Apply at least one alert-compatible Findings filter.";
const serializeCondition = (condition: AlertCondition | null): string =>
condition ? JSON.stringify(condition) : "none";
const getAlertFormModalResetKey = ({
open,
defaultFrequency,
editingAlert,
seededCondition,
}: Pick<
AlertFormModalProps,
"open" | "defaultFrequency" | "editingAlert" | "seededCondition"
>): string =>
[
open ? "open" : "closed",
editingAlert?.id ?? "create",
editingAlert?.attributes.updated_at ?? "",
defaultFrequency,
serializeCondition(seededCondition ?? null),
].join("|");
const allowInitialDialogFocus = () => undefined;
const uniqueValues = (values: string[]): string[] =>
Array.from(new Set(values));
interface PreviewState {
status: "success" | "error";
data?: AlertPreviewResponse;
error?: string;
}
const formatPreviewNumber = (value: number): string =>
new Intl.NumberFormat("en-US").format(value);
const getPreviewSeverityLabel = (severity: string): string =>
severity.charAt(0).toUpperCase() + severity.slice(1);
const getPreviewMessage = (data: AlertPreviewResponse): string => {
const totalFindings = data.summary.finding_count_total ?? 0;
if (totalFindings === 0) {
return "These filters did not match any findings for the latest scan.";
}
const findingLabel = totalFindings === 1 ? "finding" : "findings";
const topSeverity = data.summary.top_severity;
const severityClause = topSeverity
? `, including ${getPreviewSeverityLabel(topSeverity)} severity`
: "";
return `It found ${formatPreviewNumber(totalFindings)} ${findingLabel}${severityClause}.`;
};
const PreviewSummarySkeleton = () => (
<Card variant="inner" padding="sm">
<CardContent className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-2">
<Skeleton className="h-5 w-28" />
<Skeleton className="h-5 w-20 rounded-full" />
</div>
<Skeleton className="h-4 w-full" />
</CardContent>
</Card>
);
const PreviewSummary = ({ preview }: { preview: PreviewState }) => {
if (preview.status === "error") {
return (
<Card variant="danger" padding="sm">
<CardContent className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-2">
<span className="text-text-neutral-primary text-sm font-medium">
Test result
</span>
<Badge variant="tag">Error</Badge>
</div>
<p className="text-text-error-primary text-sm">{preview.error}</p>
</CardContent>
</Card>
);
}
const data = preview.data;
if (!data) return null;
return (
<Card variant="inner" padding="sm">
<CardContent className="flex flex-col gap-2">
<span className="text-text-neutral-primary text-sm font-medium">
Test result
</span>
<p className="text-text-neutral-secondary text-sm">
{getPreviewMessage(data)}
</p>
</CardContent>
</Card>
);
};
const normalizeFindingsFilterKey = (filterKey: string): string =>
filterKey.startsWith("filter[") ? filterKey : `filter[${filterKey}]`;
interface AlertRecipientsSelectProps {
selectedEmails: Set<string>;
onValuesChange: (emails: string[]) => void;
}
interface RecipientOption {
email: string;
status?: AlertRecipient["attributes"]["status"];
}
const getRecipientStatusLabel = (
status: AlertRecipient["attributes"]["status"],
): string => status.charAt(0).toUpperCase() + status.slice(1);
const getRecipientOptions = (
recipients: AlertRecipient[],
selectedEmails: string[],
): RecipientOption[] => {
const options = new Map<string, RecipientOption>();
recipients.forEach((recipient) => {
const email = normalizeEmail(recipient.attributes.email);
if (!email) return;
options.set(email, { email, status: recipient.attributes.status });
});
selectedEmails.forEach((email) => {
const normalizedEmail = normalizeEmail(email);
if (!normalizedEmail || options.has(normalizedEmail)) return;
options.set(normalizedEmail, { email: normalizedEmail });
});
return Array.from(options.values()).sort((left, right) =>
left.email.localeCompare(right.email),
);
};
const AlertRecipientsSelect = ({
selectedEmails,
onValuesChange,
}: AlertRecipientsSelectProps) => {
const [recipients, setRecipients] = useState<AlertRecipient[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useMountEffect(() => {
listAlertRecipients({
"page[size]": "100",
sort: "email",
}).then((result) => {
setLoading(false);
if (result?.error) {
setRecipients([]);
setError(result.error);
return;
}
setRecipients(result.data);
setError(null);
});
});
const selectedValues = Array.from(selectedEmails);
const options = getRecipientOptions(recipients, selectedValues);
return (
<div className="flex flex-col gap-2">
<MultiSelect values={selectedValues} onValuesChange={onValuesChange}>
<MultiSelectTrigger
id="alert-recipients"
aria-label="Recipients"
size="default"
>
<MultiSelectValue
placeholder={loading ? "Loading recipients" : "Select emails"}
/>
</MultiSelectTrigger>
<MultiSelectContent
search={{
placeholder: "Search recipients...",
emptyMessage: "No confirmed recipients found.",
}}
width="wide"
>
<MultiSelectSelectAll
mode="select"
values={options.map((option) => option.email)}
>
Select All
</MultiSelectSelectAll>
<MultiSelectSeparator />
{options.map((option) => (
<MultiSelectItem
key={option.email}
value={option.email}
badgeLabel={option.email}
keywords={[option.email, option.status ?? ""]}
>
<span className="truncate">{option.email}</span>
{option.status && (
<Badge variant="tag">
{getRecipientStatusLabel(option.status)}
</Badge>
)}
</MultiSelectItem>
))}
</MultiSelectContent>
</MultiSelect>
{error && <p className="text-text-error-primary text-xs">{error}</p>}
</div>
);
};
export const AlertFormModal = (props: AlertFormModalProps) => {
const resetKey = getAlertFormModalResetKey(props);
return <AlertFormModalContent key={resetKey} {...props} />;
};
const AlertFormModalContent = ({
open,
defaultFrequency,
providers = [],
completedScanIds = [],
scanDetails = [],
uniqueRegions = [],
uniqueServices = [],
uniqueResourceTypes = [],
uniqueCategories = [],
uniqueGroups = [],
editingAlert = null,
seededCondition = null,
selectedFindingsFilterChips = [],
defaultName = "Findings filter alert",
onOpenChange,
onSubmit,
}: AlertFormModalProps) => {
const defaults = editingAlert
? getAlertFormDefaults(editingAlert)
: getEmptyAlertFormDefaults(defaultFrequency, seededCondition ?? undefined);
const initialName = editingAlert
? defaults.name
: defaults.name || defaultName;
// Local state needed: user edits are buffered until the modal form is submitted.
const [name, setName] = useState(initialName);
const [description, setDescription] = useState(defaults.description);
const [frequency, setFrequency] = useState<AlertTriggerKind>(
defaults.frequency,
);
const [pendingFilters, setPendingFilters] = useState<
Record<string, string[]>
>(
editingAlert
? getFindingsFiltersFromAlertCondition(editingAlert.attributes.condition)
: {},
);
const [selectedRecipientEmails, setSelectedRecipientEmails] = useState(
() => new Set(defaults.recipientEmails.map(normalizeEmail)),
);
const [errors, setErrors] = useState<FormErrors>({});
const [saving, setSaving] = useState(false);
const [previewLoading, setPreviewLoading] = useState(false);
const [preview, setPreview] = useState<PreviewState | null>(null);
const submitLabel = editingAlert ? "Save" : "Create";
const setRecipientEmails = (emails: string[]) =>
setSelectedRecipientEmails(
new Set(emails.map(normalizeEmail).filter(Boolean)),
);
const setPendingFilter = (filterKey: string, values: string[]) => {
setPendingFilters((current) => ({
...current,
[normalizeFindingsFilterKey(filterKey)]: uniqueValues(values),
}));
setPreview(null);
};
const getPendingFilterValue = (filterKey: string): string[] =>
pendingFilters[normalizeFindingsFilterKey(filterKey)] ?? [];
const buildCurrentValues = (condition: AlertCondition): AlertFormValues => ({
name,
description,
method: ALERT_NOTIFICATION_METHODS.EMAIL,
frequency,
condition,
recipientEmails: getRecipientEmails(selectedRecipientEmails),
enabled: defaults.enabled,
});
const handlePreview = async () => {
if (!editingAlert) return;
const seedResult = await seedAlertRule(pendingFilters);
if (seedResult?.error) {
setPreview({
status: "error",
error: ALERT_SEED_ERROR,
});
return;
}
const values = buildCurrentValues(seedResult.data.attributes.condition);
const parsed = alertFormSchema.safeParse(values);
if (!parsed.success) {
setPreview({
status: "error",
error: "Fix alert fields before running test.",
});
return;
}
setPreviewLoading(true);
const result = await previewAlertCondition({
condition: parsed.data.condition,
});
setPreviewLoading(false);
if (result?.error) {
setPreview({ status: "error", error: result.error });
return;
}
const previewData = result.data.attributes as AlertPreviewResponse;
if (previewData.evaluation_failed) {
setPreview({
status: "error",
error: previewData.last_error ?? "Preview evaluation failed.",
});
return;
}
setPreview({ status: "success", data: previewData });
};
const handleSubmit = async () => {
const seedResult = editingAlert
? await seedAlertRule(pendingFilters)
: null;
if (seedResult?.error) {
setErrors({ root: ALERT_SEED_ERROR });
return;
}
const values = buildCurrentValues(
seedResult?.data.attributes.condition ?? defaults.condition,
);
const parsed = alertFormSchema.safeParse(values);
if (!parsed.success) {
const fieldErrors = parsed.error.flatten().fieldErrors;
setErrors({
name: fieldErrors.name?.[0],
recipientEmails: fieldErrors.recipientEmails?.[0],
});
return;
}
setSaving(true);
const result = await onSubmit(parsed.data);
setSaving(false);
if (result.ok) {
setErrors({});
onOpenChange(false);
return;
}
setErrors({ root: result.error ?? "Could not save alert." });
};
return (
<Modal
open={open}
onOpenChange={onOpenChange}
title={editingAlert ? "Edit Alert" : "Create Alert"}
description={
editingAlert
? "Update recipients, frequency, and finding filters for this alert."
: "Create an alert from the current Findings filters."
}
onOpenAutoFocus={allowInitialDialogFocus}
size={editingAlert ? "5xl" : "xl"}
className={
editingAlert
? "minimal-scrollbar max-h-[calc(100vh-2rem)] overflow-y-auto"
: undefined
}
>
<div className="flex flex-col gap-4">
<FilterSummaryStrip chips={selectedFindingsFilterChips} />
<Field>
<FieldLabel htmlFor="alert-name">Name</FieldLabel>
<Input
id="alert-name"
aria-label="Name"
value={name}
onChange={(event) => setName(event.target.value)}
/>
{errors.name && <FieldError>{errors.name}</FieldError>}
</Field>
<Field>
<FieldLabel htmlFor="alert-description">Description</FieldLabel>
<Textarea
id="alert-description"
aria-label="Description"
textareaSize="lg"
value={description}
onChange={(event) => setDescription(event.target.value)}
/>
</Field>
<Field>
<FieldLabel htmlFor="alert-frequency">Frequency</FieldLabel>
<Select
value={frequency}
onValueChange={(value) => {
setFrequency(value as AlertTriggerKind);
setPreview(null);
}}
>
<SelectTrigger id="alert-frequency" aria-label="Frequency">
<SelectValue placeholder="Select frequency" />
</SelectTrigger>
<SelectContent width="wide" className="z-[60]">
{ALERT_FREQUENCY_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="alert-recipients">Recipients</FieldLabel>
<AlertRecipientsSelect
selectedEmails={selectedRecipientEmails}
onValuesChange={setRecipientEmails}
/>
{errors.recipientEmails && (
<FieldError>{errors.recipientEmails}</FieldError>
)}
</Field>
{editingAlert && (
<div className="flex flex-col gap-3">
<Card variant="inner" padding="sm">
<CardContent className="flex flex-col gap-3">
<h3 className="text-text-neutral-primary text-sm font-medium">
Filters
</h3>
<FindingsFilterBatchControls
providers={providers}
completedScanIds={completedScanIds}
scanDetails={scanDetails}
uniqueRegions={uniqueRegions}
uniqueServices={uniqueServices}
uniqueResourceTypes={uniqueResourceTypes}
uniqueCategories={uniqueCategories}
uniqueGroups={uniqueGroups}
appliedFilters={{}}
pendingFilters={pendingFilters}
changedFilters={pendingFilters}
setPending={setPendingFilter}
getFilterValue={getPendingFilterValue}
showSummaries={false}
variant="alerts-edit"
/>
</CardContent>
</Card>
{(previewLoading || preview) && (
<div className="pt-1">
{previewLoading ? (
<PreviewSummarySkeleton />
) : (
preview && <PreviewSummary preview={preview} />
)}
</div>
)}
</div>
)}
{errors.root && (
<div className="text-text-error-primary text-sm">{errors.root}</div>
)}
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
{editingAlert && (
<Button
variant="outline"
onClick={handlePreview}
disabled={previewLoading || saving}
>
{previewLoading ? "Running..." : "Test"}
</Button>
)}
<Button onClick={handleSubmit} disabled={saving}>
{submitLabel}
</Button>
</div>
</div>
</Modal>
);
};
@@ -0,0 +1,29 @@
import { BellRing, TagIcon } from "lucide-react";
import Link from "next/link";
import { Button, Card, CardContent } from "@/components/shadcn";
export const AlertsEmptyState = () => (
<Card variant="base" padding="lg">
<CardContent className="flex flex-col items-center gap-4 text-center">
<div className="bg-button-primary/10 flex h-14 w-14 items-center justify-center rounded-full">
<BellRing className="text-button-primary h-7 w-7" aria-hidden="true" />
</div>
<div className="flex flex-col gap-1">
<h3 className="text-text-neutral-primary text-lg font-semibold">
No alerts yet
</h3>
<p className="text-text-neutral-secondary max-w-md text-sm">
Create alerts from Findings page to notify selected recipients when
matching findings appear.
</p>
</div>
<Button asChild size="sm">
<Link href="/findings?filter[muted]=false&filter[status__in]=FAIL">
<TagIcon size={14} aria-hidden="true" />
Go to Findings
</Link>
</Button>
</CardContent>
</Card>
);
@@ -0,0 +1,262 @@
"use client";
import { Info } from "lucide-react";
import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useState, useTransition } from "react";
import {
deleteAlert,
disableAlert,
enableAlert,
updateAlert,
} from "@/app/(prowler)/alerts/_actions";
import {
ALERT_TRIGGER_KINDS,
type AlertRule,
} from "@/app/(prowler)/alerts/_types";
import { Button } from "@/components/shadcn";
import { Modal } from "@/components/shadcn/modal";
import { useToast } from "@/components/ui";
import { DOCS_URLS } from "@/lib/external-urls";
import type { MetaDataProps } from "@/types";
import type { ScanEntity } from "@/types";
import type { ProviderProps } from "@/types/providers";
import { toAlertPayload } from "../_lib/alert-adapter";
import type {
AlertFormSubmitResult,
AlertFormValues,
} from "../_types/alert-form";
import { AlertFormModal } from "./alert-form-modal";
import { AlertsEmptyState } from "./alerts-empty-state";
import { AlertsTable } from "./alerts-table";
interface AlertsManagerProps {
alerts: AlertRule[];
meta?: MetaDataProps;
loadError: string | null;
providers: ProviderProps[];
completedScanIds: string[];
scanDetails: { [key: string]: ScanEntity }[];
uniqueRegions: string[];
uniqueServices: string[];
uniqueResourceTypes: string[];
uniqueCategories: string[];
uniqueGroups: string[];
initialEditingAlert?: AlertRule | null;
}
const ALERTS_FINDINGS_HREF =
"/findings?filter[muted]=false&filter[status__in]=FAIL";
export const AlertsManager = ({
alerts,
meta,
loadError,
providers,
completedScanIds,
scanDetails,
uniqueRegions,
uniqueServices,
uniqueResourceTypes,
uniqueCategories,
uniqueGroups,
initialEditingAlert = null,
}: AlertsManagerProps) => {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const { toast } = useToast();
const [, startTransition] = useTransition();
const [modalOpen, setModalOpen] = useState(Boolean(initialEditingAlert));
const [editingAlert, setEditingRule] = useState<AlertRule | null>(
initialEditingAlert,
);
const [mutatingId, setMutatingId] = useState<string | null>(null);
const [pendingDelete, setPendingDelete] = useState<AlertRule | null>(null);
const refresh = () => startTransition(() => router.refresh());
const replaceEditParam = (alertId: string | null) => {
const params = new URLSearchParams(searchParams.toString());
if (alertId) {
params.set("edit", alertId);
} else {
params.delete("edit");
}
const queryString = params.toString();
router.replace(queryString ? `${pathname}?${queryString}` : pathname, {
scroll: false,
});
};
const closeModal = (open: boolean) => {
setModalOpen(open);
if (!open) {
setEditingRule(null);
replaceEditParam(null);
}
};
const submitAlert = async (
values: AlertFormValues,
): Promise<AlertFormSubmitResult> => {
if (!editingAlert) {
return { ok: false, error: "Create alerts from Findings." };
}
const payload = toAlertPayload(values);
const result = await updateAlert(editingAlert.id, payload);
if (result?.error) return { ok: false, error: result.error };
toast({
title: "Alert updated",
description: result.data.attributes.name,
});
refresh();
return { ok: true, alertId: result.data.id };
};
const toggleAlert = async (alert: AlertRule) => {
setMutatingId(alert.id);
const result = alert.attributes.enabled
? await disableAlert(alert.id)
: await enableAlert(alert.id);
setMutatingId(null);
if (result?.error) {
toast({
variant: "destructive",
title: "Alert update failed",
description: result.error,
});
return;
}
toast({
title: alert.attributes.enabled ? "Alert disabled" : "Alert enabled",
description: result.data.attributes.name,
});
refresh();
};
const confirmDelete = async () => {
if (!pendingDelete) return;
setMutatingId(pendingDelete.id);
const result = await deleteAlert(pendingDelete.id);
setMutatingId(null);
if (result?.error) {
toast({
variant: "destructive",
title: "Alert delete failed",
description: result.error,
});
return;
}
setPendingDelete(null);
refresh();
};
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="flex max-w-3xl flex-col gap-2">
<div className="text-text-neutral-secondary flex flex-wrap items-center gap-1 text-sm">
<Info className="size-4 shrink-0" />
<span>
Get notified when findings match the conditions you define.
</span>
<span>To create an alert, go to</span>
<Button
variant="link"
size="link-sm"
className="h-auto p-0"
asChild
>
<Link href={ALERTS_FINDINGS_HREF}>Findings</Link>
</Button>
<span>.</span>
<span>Learn more about configuring the Alerts</span>
<Button
variant="link"
size="link-sm"
className="h-auto p-0"
asChild
>
<a
href={DOCS_URLS.ALERTS}
target="_blank"
rel="noopener noreferrer"
>
<span>here.</span>
</a>
</Button>
</div>
</div>
</div>
{loadError && (
<div className="border-destructive/40 bg-destructive/10 text-destructive rounded-md border p-4 text-sm">
Failed to load alerts: {loadError}
</div>
)}
{alerts.length === 0 && !loadError ? (
<AlertsEmptyState />
) : (
<AlertsTable
alerts={alerts}
meta={meta}
mutatingId={mutatingId}
onEdit={(alert) => {
setEditingRule(alert);
setModalOpen(true);
replaceEditParam(alert.id);
}}
onToggleEnabled={toggleAlert}
onDelete={setPendingDelete}
/>
)}
<AlertFormModal
key={editingAlert?.id ?? "edit"}
open={modalOpen}
defaultFrequency={ALERT_TRIGGER_KINDS.AFTER_SCAN}
providers={providers}
completedScanIds={completedScanIds}
scanDetails={scanDetails}
uniqueRegions={uniqueRegions}
uniqueServices={uniqueServices}
uniqueResourceTypes={uniqueResourceTypes}
uniqueCategories={uniqueCategories}
uniqueGroups={uniqueGroups}
editingAlert={editingAlert}
onOpenChange={closeModal}
onSubmit={submitAlert}
/>
<Modal
open={Boolean(pendingDelete)}
onOpenChange={(open) => !open && setPendingDelete(null)}
title="Delete alert"
description={
pendingDelete
? `Delete "${pendingDelete.attributes.name}"? This alert will stop evaluating.`
: ""
}
size="md"
>
<div className="flex justify-end gap-2 pt-4">
<Button variant="outline" onClick={() => setPendingDelete(null)}>
Cancel
</Button>
<Button
variant="destructive"
disabled={mutatingId === pendingDelete?.id}
onClick={confirmDelete}
>
Delete alert
</Button>
</div>
</Modal>
</div>
);
};
@@ -0,0 +1,241 @@
"use client";
import type { ColumnDef } from "@tanstack/react-table";
import { PencilIcon, PowerIcon, TrashIcon } from "lucide-react";
import type { AlertRule } from "@/app/(prowler)/alerts/_types";
import {
ActionDropdown,
ActionDropdownDangerZone,
ActionDropdownItem,
} from "@/components/shadcn/dropdown";
import { DateWithTime } from "@/components/ui/entities";
import { DataTable } from "@/components/ui/table/data-table";
import { DataTableColumnHeader } from "@/components/ui/table/data-table-column-header";
import type { MetaDataProps } from "@/types";
interface AlertsTableProps {
alerts: AlertRule[];
meta?: MetaDataProps;
mutatingId: string | null;
onEdit: (alert: AlertRule) => void;
onToggleEnabled: (alert: AlertRule) => void;
onDelete: (alert: AlertRule) => void;
}
const TRIGGER_LABELS = {
after_scan: "After each scan",
daily: "Daily digest",
both: "After scan and daily",
} as const satisfies Record<AlertRule["attributes"]["trigger"], string>;
const formatRecipients = (alert: AlertRule): string => {
const recipients = alert.attributes.recipient_emails ?? [];
if (recipients.length === 0) return "No recipients";
if (recipients.length === 1) return recipients[0];
return `${recipients[0]} +${recipients.length - 1} more`;
};
interface GetAlertsTableColumnsOptions {
mutatingId: string | null;
onEdit: (alert: AlertRule) => void;
onToggleEnabled: (alert: AlertRule) => void;
onDelete: (alert: AlertRule) => void;
}
const AlertActionsItems = ({
alert,
isMutating,
onEdit,
onToggleEnabled,
onDelete,
}: {
alert: AlertRule;
isMutating: boolean;
onEdit: (alert: AlertRule) => void;
onToggleEnabled: (alert: AlertRule) => void;
onDelete: (alert: AlertRule) => void;
}) => {
const enabled = alert.attributes.enabled;
const toggleLabel = enabled ? "Disable" : "Enable";
return (
<>
<ActionDropdownItem
icon={<PencilIcon />}
label="Edit"
onSelect={() => onEdit(alert)}
/>
<ActionDropdownItem
icon={<PowerIcon />}
label={toggleLabel}
disabled={isMutating}
onSelect={() => onToggleEnabled(alert)}
/>
<ActionDropdownDangerZone>
<ActionDropdownItem
icon={<TrashIcon />}
label="Delete"
destructive
disabled={isMutating}
onSelect={() => onDelete(alert)}
/>
</ActionDropdownDangerZone>
</>
);
};
const getAlertsTableColumns = ({
mutatingId,
onEdit,
onToggleEnabled,
onDelete,
}: GetAlertsTableColumnsOptions): ColumnDef<AlertRule>[] => [
{
id: "name",
size: 320,
minSize: 280,
accessorFn: (alert) => alert.attributes.name,
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Name" param="name" />
),
cell: ({ row }) => {
const alert = row.original;
return (
<div className="flex w-[320px] max-w-[320px] min-w-0 flex-col gap-1">
<button
type="button"
className="hover:text-button-tertiary block w-full min-w-0 truncate text-left font-medium transition-colors"
onClick={() => onEdit(alert)}
>
{alert.attributes.name}
</button>
{alert.attributes.description && (
<span
className="text-text-neutral-secondary block w-full truncate text-xs"
title={alert.attributes.description}
>
{alert.attributes.description}
</span>
)}
</div>
);
},
},
{
id: "enabled",
size: 140,
minSize: 120,
accessorFn: (alert) => (alert.attributes.enabled ? "Enabled" : "Disabled"),
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Status" param="enabled" />
),
cell: ({ row }) =>
row.original.attributes.enabled ? "Enabled" : "Disabled",
},
{
id: "trigger",
size: 190,
minSize: 170,
accessorFn: (alert) => TRIGGER_LABELS[alert.attributes.trigger],
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title="Frequency"
param="trigger"
/>
),
cell: ({ row }) => TRIGGER_LABELS[row.original.attributes.trigger],
},
{
id: "recipients",
size: 220,
minSize: 180,
accessorFn: (alert) => formatRecipients(alert),
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Recipients" />
),
cell: ({ row }) => formatRecipients(row.original),
},
{
id: "inserted_at",
size: 170,
minSize: 150,
accessorFn: (alert) => alert.attributes.inserted_at,
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title="Created at"
param="inserted_at"
/>
),
cell: ({ row }) => (
<div className="w-[150px]">
<DateWithTime dateTime={row.original.attributes.inserted_at} />
</div>
),
},
{
id: "updated_at",
size: 170,
minSize: 150,
accessorFn: (alert) => alert.attributes.updated_at,
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title="Updated at"
param="updated_at"
/>
),
cell: ({ row }) => (
<div className="w-[150px]">
<DateWithTime dateTime={row.original.attributes.updated_at} />
</div>
),
},
{
id: "actions",
size: 72,
minSize: 64,
enableSorting: false,
cell: ({ row }) => {
const alert = row.original;
const isMutating = mutatingId === alert.id;
return (
<div className="flex items-center justify-end">
<ActionDropdown ariaLabel={`Actions for ${alert.attributes.name}`}>
<AlertActionsItems
alert={alert}
isMutating={isMutating}
onEdit={onEdit}
onToggleEnabled={onToggleEnabled}
onDelete={onDelete}
/>
</ActionDropdown>
</div>
);
},
},
];
export const AlertsTable = ({
alerts,
meta,
mutatingId,
onEdit,
onToggleEnabled,
onDelete,
}: AlertsTableProps) => (
<DataTable
columns={getAlertsTableColumns({
mutatingId,
onEdit,
onToggleEnabled,
onDelete,
})}
data={alerts}
metadata={meta}
showSearch
searchPlaceholder="Search alerts"
/>
);
@@ -0,0 +1 @@
export * from "./seed-from-findings-button";
@@ -0,0 +1,260 @@
"use client";
import { BellPlusIcon } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { createAlert, seedAlertRule } from "@/app/(prowler)/alerts/_actions";
import { AlertFormModal } from "@/app/(prowler)/alerts/_components/alert-form-modal";
import {
getFindingsFiltersFromAlertCondition,
toAlertPayload,
} from "@/app/(prowler)/alerts/_lib/alert-adapter";
import {
ALERT_SEVERITY_VALUES,
ALERT_TRIGGER_KINDS,
type AlertCondition,
type AlertsFilterBag,
} from "@/app/(prowler)/alerts/_types";
import type {
AlertFormSubmitResult,
AlertFormValues,
} from "@/app/(prowler)/alerts/_types/alert-form";
import { buildFindingsFilterChips } from "@/components/findings/findings-filters.utils";
import {
Button,
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn";
import { CloudFeatureBadgeLink } from "@/components/shared/cloud-feature-badge";
import { ToastAction, useToast } from "@/components/ui";
import type { ScanEntity } from "@/types";
import type { ProviderProps } from "@/types/providers";
const DISABLED_FILTER_TOOLTIP =
"Apply at least one Findings filter to create an alert from filters.";
const ALERT_SEED_ERROR = "Apply at least one alert-compatible Findings filter.";
const NON_FILTER_QUERY_KEYS = new Set(["sort", "page", "pageSize"]);
const ALERT_COMPATIBLE_FILTER_KEYS = new Set([
"filter[provider_type__in]",
"filter[provider_id__in]",
"filter[severity__in]",
"filter[delta]",
"filter[region__in]",
"filter[service__in]",
"filter[resource_type__in]",
"filter[category__in]",
"filter[resource_groups__in]",
"filter[check_id__in]",
"filter[finding_group_id]",
"filter[resource_uid__in]",
]);
interface SeedFromFindingsButtonProps {
filterBag: AlertsFilterBag;
providers?: ProviderProps[];
scans?: Array<{ [scanId: string]: ScanEntity }>;
uniqueRegions?: string[];
uniqueServices?: string[];
uniqueResourceTypes?: string[];
uniqueCategories?: string[];
uniqueGroups?: string[];
className?: string;
size?: "sm" | "default" | "lg";
defaultName?: string;
isCloudEnabled?: boolean;
}
const toChipFilterMap = (
filterBag: AlertsFilterBag,
): Record<string, string[]> =>
Object.fromEntries(
Object.entries(filterBag)
.filter(([key]) => key.startsWith("filter["))
.map(([key, value]) => [
key,
(Array.isArray(value) ? value : value.split(","))
.map((entry) => entry.trim())
.filter(Boolean),
])
.filter(([, values]) => values.length > 0),
);
const hasFindingFilterValue = (filterBag: AlertsFilterBag): boolean =>
Object.entries(filterBag).some(([rawKey, value]) => {
if (!rawKey.startsWith("filter[") || NON_FILTER_QUERY_KEYS.has(rawKey)) {
return false;
}
const values = Array.isArray(value) ? value : [value];
return values.some((entry) =>
entry
.split(",")
.map((part) => part.trim())
.some(Boolean),
);
});
const hasAlertCompatibleFilterValue = (filterBag: AlertsFilterBag): boolean =>
Object.entries(filterBag).some(([rawKey, value]) => {
if (!ALERT_COMPATIBLE_FILTER_KEYS.has(rawKey)) return false;
const values = Array.isArray(value) ? value : [value];
return values.some((entry) =>
entry
.split(",")
.map((part) => part.trim())
.some(Boolean),
);
});
const withDefaultAlertSeedFilters = (
filterBag: AlertsFilterBag,
): AlertsFilterBag => {
if (hasAlertCompatibleFilterValue(filterBag)) return filterBag;
return {
...filterBag,
"filter[severity__in]": [...ALERT_SEVERITY_VALUES],
};
};
export const SeedFromFindingsButton = ({
filterBag,
providers = [],
scans = [],
uniqueRegions = [],
uniqueServices = [],
uniqueResourceTypes = [],
uniqueCategories = [],
uniqueGroups = [],
className,
size = "lg",
defaultName = "Findings filter alert",
isCloudEnabled = true,
}: SeedFromFindingsButtonProps) => {
const router = useRouter();
const { toast } = useToast();
const [modalOpen, setModalOpen] = useState(false);
const [seeding, setSeeding] = useState(false);
const [seededCondition, setSeededCondition] = useState<AlertCondition | null>(
null,
);
const [selectedFindingsFilterChips, setSelectedFindingsFilterChips] =
useState(() =>
buildFindingsFilterChips(toChipFilterMap(filterBag), {
providers,
scans,
}),
);
const canSeedFromFilters = hasFindingFilterValue(filterBag);
const handleClick = async () => {
if (!isCloudEnabled || !canSeedFromFilters) return;
setSeeding(true);
const result = await seedAlertRule(withDefaultAlertSeedFilters(filterBag));
setSeeding(false);
if (result?.error) {
toast({
variant: "destructive",
title: "Alert seed failed",
description: ALERT_SEED_ERROR,
});
return;
}
const condition = result.data.attributes.condition as AlertCondition;
setSeededCondition(condition);
setSelectedFindingsFilterChips(
buildFindingsFilterChips(
getFindingsFiltersFromAlertCondition(condition),
{ providers, scans },
),
);
setModalOpen(true);
};
const submitAlert = async (
values: AlertFormValues,
): Promise<AlertFormSubmitResult> => {
const result = await createAlert(toAlertPayload(values));
if (result?.error) return { ok: false, error: result.error };
toast({
title: "Alert created",
description: result.data.attributes.name,
action: (
<ToastAction altText="View alerts" asChild>
<Link href="/alerts">View Alerts</Link>
</ToastAction>
),
});
router.refresh();
return { ok: true, alertId: result.data.id };
};
const button = (
<Button
size={size}
variant="default"
onClick={handleClick}
disabled={!isCloudEnabled || !canSeedFromFilters || seeding}
className={className}
>
<BellPlusIcon size={14} />
{seeding ? "Preparing Alert" : "Create Alert"}
</Button>
);
if (isCloudEnabled && canSeedFromFilters) {
return (
<>
{button}
{seededCondition && (
<AlertFormModal
open={modalOpen}
defaultFrequency={ALERT_TRIGGER_KINDS.AFTER_SCAN}
providers={providers}
uniqueRegions={uniqueRegions}
uniqueServices={uniqueServices}
uniqueResourceTypes={uniqueResourceTypes}
uniqueCategories={uniqueCategories}
uniqueGroups={uniqueGroups}
seededCondition={seededCondition}
selectedFindingsFilterChips={selectedFindingsFilterChips}
defaultName={defaultName}
onOpenChange={setModalOpen}
onSubmit={submitAlert}
/>
)}
</>
);
}
if (!isCloudEnabled) {
return (
<span className="relative inline-flex" tabIndex={0}>
{button}
<span className="absolute top-0 right-0 z-10 translate-x-1/3 -translate-y-1/2">
<CloudFeatureBadgeLink />
</span>
</span>
);
}
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="relative inline-flex" tabIndex={0}>
{button}
</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
{DISABLED_FILTER_TOOLTIP}
</TooltipContent>
</Tooltip>
);
};
@@ -0,0 +1,111 @@
import { describe, expect, it } from "vitest";
import {
ALERT_AGGREGATE_OPS,
ALERT_BOOLEAN_OPS,
ALERT_TRIGGER_KINDS,
type AlertCondition,
type AlertRule,
} from "@/app/(prowler)/alerts/_types";
import type { AlertFormValues } from "../../_types/alert-form";
import {
getAlertFormDefaults,
getFindingsFiltersFromAlertCondition,
toAlertPayload,
} from "../alert-adapter";
const condition: AlertCondition = {
op: ALERT_AGGREGATE_OPS.ANY,
filter: { severity: ["critical", "high"] },
};
const baseValues = {
name: " Critical findings ",
description: " Notify security ",
method: "email",
frequency: ALERT_TRIGGER_KINDS.DAILY,
condition,
recipientEmails: [" Security@Example.COM ", "ops@example.com"],
enabled: true,
} satisfies AlertFormValues;
const existingRule = {
id: "alert-1",
type: "alert-rules",
attributes: {
name: "Existing alert",
description: "Existing description",
enabled: false,
trigger: ALERT_TRIGGER_KINDS.BOTH,
condition,
schema_version: 1,
recipient_emails: ["alerts@example.com"],
inserted_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
},
} satisfies AlertRule;
const countFilter = (filter: Record<string, string[]>) => ({
op: ALERT_AGGREGATE_OPS.COUNT_GTE,
filter,
value: 1,
});
describe("simple alert adapter", () => {
it("should map form values to the existing create payload contract without translating filters", () => {
// Given / When
const payload = toAlertPayload(baseValues);
// Then
expect(payload).toEqual({
name: "Critical findings",
description: "Notify security",
enabled: true,
trigger: ALERT_TRIGGER_KINDS.DAILY,
condition,
recipientEmails: ["security@example.com", "ops@example.com"],
});
expect(payload.condition).toBe(condition);
expect(payload).not.toHaveProperty("method");
});
it("should hydrate defaults from an existing alert without reshaping the condition", () => {
// Given / When
const defaults = getAlertFormDefaults(existingRule);
// Then
expect(defaults).toEqual({
name: "Existing alert",
description: "Existing description",
method: "email",
frequency: ALERT_TRIGGER_KINDS.BOTH,
condition,
recipientEmails: ["alerts@example.com"],
enabled: false,
});
});
it("should expose editable alert condition fields as pending Findings filters", () => {
// Given
const editableCondition = {
op: ALERT_BOOLEAN_OPS.AND,
children: [
countFilter({ check_id: ["iam_user_no_mfa"] }),
countFilter({ resource_uid: ["arn:aws:iam::123:user/alice"] }),
countFilter({ finding_group_id: ["finding-group-1"] }),
countFilter({ status: ["FAIL"] }),
],
} satisfies AlertCondition;
// When
const filters = getFindingsFiltersFromAlertCondition(editableCondition);
// Then
expect(filters).toEqual({
"filter[check_id__in]": ["iam_user_no_mfa"],
"filter[resource_uid__in]": ["arn:aws:iam::123:user/alice"],
"filter[finding_group_id]": ["finding-group-1"],
});
});
});
@@ -0,0 +1,123 @@
import type { AlertPayload } from "@/app/(prowler)/alerts/_actions/alerts";
import {
ALERT_AGGREGATE_OPS,
ALERT_SEVERITY_VALUES,
ALERT_TRIGGER_KINDS,
type AlertCondition,
type AlertLeafFilter,
type AlertRule,
} from "@/app/(prowler)/alerts/_types";
import {
ALERT_NOTIFICATION_METHODS,
type AlertFormValues,
} from "../_types/alert-form";
const DEFAULT_CONDITION: AlertCondition = {
op: ALERT_AGGREGATE_OPS.COUNT_GTE,
filter: { severity: [...ALERT_SEVERITY_VALUES] },
value: 1,
};
const normalizeRecipientEmails = (emails: string[]): string[] =>
emails
.map((email) => email.trim().toLowerCase())
.filter((email) => email.length > 0);
export const toAlertPayload = (values: AlertFormValues): AlertPayload => ({
name: values.name.trim(),
description: values.description.trim(),
enabled: values.enabled,
trigger: values.frequency,
condition: values.condition,
recipientEmails: normalizeRecipientEmails(values.recipientEmails),
});
export const getEmptyAlertFormDefaults = (
frequency: AlertFormValues["frequency"] = ALERT_TRIGGER_KINDS.AFTER_SCAN,
condition: AlertCondition = DEFAULT_CONDITION,
): AlertFormValues => ({
name: "",
description: "",
method: ALERT_NOTIFICATION_METHODS.EMAIL,
frequency,
condition,
recipientEmails: [],
enabled: true,
});
export const getAlertFormDefaults = (alert: AlertRule): AlertFormValues => ({
name: alert.attributes.name,
description: alert.attributes.description,
method: ALERT_NOTIFICATION_METHODS.EMAIL,
frequency: alert.attributes.trigger,
condition: alert.attributes.condition,
recipientEmails: alert.attributes.recipient_emails ?? [],
enabled: alert.attributes.enabled,
});
const SIMPLE_FIELD_TO_FINDINGS_FILTER: Partial<
Record<keyof AlertLeafFilter, string>
> = {
provider_type: "filter[provider_type__in]",
provider_id: "filter[provider_id__in]",
severity: "filter[severity__in]",
delta: "filter[delta]",
resource_regions: "filter[region__in]",
resource_services: "filter[service__in]",
resource_types: "filter[resource_type__in]",
categories: "filter[category__in]",
resource_groups: "filter[resource_groups__in]",
check_id: "filter[check_id__in]",
finding_group_id: "filter[finding_group_id]",
resource_uid: "filter[resource_uid__in]",
};
const uniqueValues = (values: string[]): string[] =>
Array.from(new Set(values));
const addFilterValues = (
filters: Record<string, string[]>,
field: keyof AlertLeafFilter,
value: AlertLeafFilter[keyof AlertLeafFilter],
): Record<string, string[]> => {
const filterKey = SIMPLE_FIELD_TO_FINDINGS_FILTER[field];
if (!filterKey || !Array.isArray(value)) return filters;
filters[filterKey] = uniqueValues([...(filters[filterKey] ?? []), ...value]);
return filters;
};
export const getFindingsFiltersFromAlertCondition = (
condition: AlertCondition,
): Record<string, string[]> => {
if ("filter" in condition) {
return Object.entries(condition.filter).reduce<Record<string, string[]>>(
(filters, [field, value]) =>
addFilterValues(
filters,
field as keyof AlertLeafFilter,
value as AlertLeafFilter[keyof AlertLeafFilter],
),
{},
);
}
if ("child" in condition) {
return getFindingsFiltersFromAlertCondition(condition.child);
}
return condition.children.reduce<Record<string, string[]>>(
(filters, child) => {
const childFilters = getFindingsFiltersFromAlertCondition(child);
Object.entries(childFilters).forEach(([filterKey, values]) => {
filters[filterKey] = uniqueValues([
...(filters[filterKey] ?? []),
...values,
]);
});
return filters;
},
{},
);
};
@@ -0,0 +1,25 @@
import { z } from "zod";
import {
ALERT_TRIGGER_KIND_VALUES,
type AlertCondition,
} from "@/app/(prowler)/alerts/_types";
import { ALERT_NOTIFICATION_METHODS } from "../_types/alert-form";
const alertConditionSchema = z.custom<AlertCondition>(
(value) => typeof value === "object" && value !== null,
"Alert condition is required.",
);
export const alertFormSchema = z.object({
name: z.string().trim().min(1, { error: "Name is required." }).max(120),
description: z.string().trim().max(2000).default(""),
method: z.literal(ALERT_NOTIFICATION_METHODS.EMAIL),
frequency: z.enum(ALERT_TRIGGER_KIND_VALUES),
condition: alertConditionSchema,
recipientEmails: z
.array(z.email({ error: "Enter a valid email address." }))
.default([]),
enabled: z.boolean(),
});
@@ -0,0 +1,27 @@
import type {
AlertCondition,
AlertTriggerKind,
} from "@/app/(prowler)/alerts/_types";
export const ALERT_NOTIFICATION_METHODS = {
EMAIL: "email",
} as const;
export type AlertNotificationMethod =
(typeof ALERT_NOTIFICATION_METHODS)[keyof typeof ALERT_NOTIFICATION_METHODS];
export interface AlertFormValues {
name: string;
description: string;
method: AlertNotificationMethod;
frequency: AlertTriggerKind;
condition: AlertCondition;
recipientEmails: string[];
enabled: boolean;
}
export interface AlertFormSubmitResult {
ok: boolean;
alertId?: string;
error?: string;
}
+216
View File
@@ -0,0 +1,216 @@
import { SEVERITY_LEVELS } from "@/types/severities";
// Canonical DSL vocabulary and resource types for the Alerts UI.
// Mirrors api/src/backend/alerts/dsl.py — every constant declared here MUST
// match the API. Drift is a bug; reviewers compare the two files when either
// changes.
// ---- operator vocabulary -------------------------------------------------
export const ALERT_BOOLEAN_OPS = {
AND: "and",
OR: "or",
NOT: "not",
} as const;
export const ALERT_AGGREGATE_OPS = {
COUNT_GTE: "count_gte",
COUNT_LTE: "count_lte",
ANY: "any",
NONE: "none",
} as const;
// ---- filter field vocabulary --------------------------------------------
export const ALERT_FILTER_FIELDS = {
SEVERITY: "severity",
DELTA: "delta",
CHECK_ID: "check_id",
FINDING_GROUP_ID: "finding_group_id",
CATEGORIES: "categories",
RESOURCE_REGIONS: "resource_regions",
RESOURCE_SERVICES: "resource_services",
RESOURCE_TYPES: "resource_types",
RESOURCE_UID: "resource_uid",
RESOURCE_GROUPS: "resource_groups",
PROVIDER_ID: "provider_id",
PROVIDER_TYPE: "provider_type",
} as const;
export type AlertFilterField =
(typeof ALERT_FILTER_FIELDS)[keyof typeof ALERT_FILTER_FIELDS];
// Closed enum for severity, the only filter field whose values are bounded
// and consumed by the seed flow.
export const ALERT_SEVERITY_VALUES = SEVERITY_LEVELS;
// ---- limits --------------------------------------------------------------
export const ALERT_SCHEMA_VERSION = 1 as const;
// ---- triggers ------------------------------------------------------------
export const ALERT_TRIGGER_KINDS = {
AFTER_SCAN: "after_scan",
DAILY: "daily",
BOTH: "both",
} as const;
export type AlertTriggerKind =
(typeof ALERT_TRIGGER_KINDS)[keyof typeof ALERT_TRIGGER_KINDS];
export const ALERT_TRIGGER_KIND_VALUES = Object.values(
ALERT_TRIGGER_KINDS,
) as readonly AlertTriggerKind[];
// ---- recipient lifecycle -------------------------------------------------
export const ALERT_RECIPIENT_STATUS = {
PENDING: "pending",
CONFIRMED: "confirmed",
UNSUBSCRIBED: "unsubscribed",
BOUNCED: "bounced",
} as const;
export type AlertRecipientStatus =
(typeof ALERT_RECIPIENT_STATUS)[keyof typeof ALERT_RECIPIENT_STATUS];
// ---- discriminated condition union --------------------------------------
// Leaf filter is a partial mapping from a whitelisted field name to its
// validated value. Kept loose at the type level (the Zod schema in
// ./lib/schemas.ts does the strict per-kind validation).
export type AlertLeafFilterValue = string[] | boolean;
export type AlertLeafFilter = Partial<
Record<AlertFilterField, AlertLeafFilterValue>
>;
export interface AlertConditionAnd {
op: typeof ALERT_BOOLEAN_OPS.AND;
children: AlertCondition[];
}
export interface AlertConditionOr {
op: typeof ALERT_BOOLEAN_OPS.OR;
children: AlertCondition[];
}
export interface AlertConditionNot {
op: typeof ALERT_BOOLEAN_OPS.NOT;
child: AlertCondition;
}
export interface AlertConditionCountGte {
op: typeof ALERT_AGGREGATE_OPS.COUNT_GTE;
filter: AlertLeafFilter;
value: number;
}
export interface AlertConditionCountLte {
op: typeof ALERT_AGGREGATE_OPS.COUNT_LTE;
filter: AlertLeafFilter;
value: number;
}
export interface AlertConditionAny {
op: typeof ALERT_AGGREGATE_OPS.ANY;
filter: AlertLeafFilter;
}
export interface AlertConditionNone {
op: typeof ALERT_AGGREGATE_OPS.NONE;
filter: AlertLeafFilter;
}
export type AlertConditionGroup =
| AlertConditionAnd
| AlertConditionOr
| AlertConditionNot;
export type AlertConditionLeaf =
| AlertConditionCountGte
| AlertConditionCountLte
| AlertConditionAny
| AlertConditionNone;
export type AlertCondition = AlertConditionGroup | AlertConditionLeaf;
// ---- resource attribute shapes ------------------------------------------
export interface AlertRuleAttributes {
name: string;
description: string;
enabled: boolean;
trigger: AlertTriggerKind;
condition: AlertCondition;
schema_version: number;
/**
* Emails of the recipients attached to the rule. The API exposes the
* relationship as a list of email strings (write- and read-side via the
* `recipient_emails` attribute), not as a JSON:API relationships block.
*/
recipient_emails?: string[];
created_by?: string | null;
inserted_at: string;
updated_at: string;
}
export interface AlertRecipientAttributes {
email: string;
status: AlertRecipientStatus;
confirmation_sent_at?: string | null;
confirmation_expires_at?: string | null;
confirmed_at?: string | null;
unsubscribed_at?: string | null;
last_bounce_at?: string | null;
inserted_at: string;
updated_at: string;
}
export interface AlertPreviewSummary {
finding_count_total?: number;
counts_by_severity?: Record<string, number>;
top_severity?: string;
top_findings?: string[];
deep_link_filter_hint?: Record<string, unknown>;
}
export interface AlertPreviewResponse {
summary: AlertPreviewSummary;
sample_finding_ids?: string[];
evaluation_failed: boolean;
last_error?: string | null;
summary_fallback?: boolean;
duration_ms?: number;
}
// ---- JSON:API envelopes --------------------------------------------------
export interface JsonApiRelationshipRef {
id: string;
type: string;
}
export interface JsonApiRelationship {
data: JsonApiRelationshipRef | JsonApiRelationshipRef[] | null;
}
export interface AlertRule {
id: string;
type: "alert-rules";
attributes: AlertRuleAttributes;
relationships?: {
recipients?: JsonApiRelationship;
last_event?: JsonApiRelationship;
};
}
export interface AlertRecipient {
id: string;
type: "alert-recipients";
attributes: AlertRecipientAttributes;
relationships?: {
rules?: JsonApiRelationship;
};
}
// ---- seeding payloads ----------------------------------------------------
export type AlertsFilterBag = Record<string, string | string[]>;
+119
View File
@@ -0,0 +1,119 @@
import { redirect } from "next/navigation";
import { getLatestMetadataInfo } from "@/actions/findings";
import { getProviders } from "@/actions/providers";
import { getScans } from "@/actions/scans";
import { getAlert, listAlerts } from "@/app/(prowler)/alerts/_actions";
import { AlertsManager } from "@/app/(prowler)/alerts/_components/alerts-manager";
import { ContentLayout } from "@/components/ui";
import { createScanDetailsMapping } from "@/lib";
import type { MetaDataProps, ScanEntity, ScanProps } from "@/types";
interface AlertsPageProps {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}
const getParamValue = (
params: Awaited<AlertsPageProps["searchParams"]>,
key: string,
): string | undefined => {
const value = params[key];
return Array.isArray(value) ? value[0] : value;
};
const toAlertsSearchParams = (
resolvedSearchParams: Awaited<AlertsPageProps["searchParams"]>,
): Record<string, string> => {
const page = Number.parseInt(
getParamValue(resolvedSearchParams, "page") ?? "1",
10,
);
const pageSize = Number.parseInt(
getParamValue(resolvedSearchParams, "pageSize") ?? "20",
10,
);
const sort = getParamValue(resolvedSearchParams, "sort") ?? "-inserted_at";
const search = getParamValue(resolvedSearchParams, "filter[search]") ?? "";
const enabledFilter = getParamValue(resolvedSearchParams, "filter[enabled]");
const triggerFilter = getParamValue(resolvedSearchParams, "filter[trigger]");
const params: Record<string, string> = {
"page[number]": String(page),
"page[size]": String(pageSize),
sort,
};
if (search) params["filter[search]"] = search;
if (enabledFilter) params["filter[enabled]"] = enabledFilter;
if (triggerFilter) params["filter[trigger]"] = triggerFilter;
return params;
};
export default async function AlertsPage({ searchParams }: AlertsPageProps) {
if (process.env.NEXT_PUBLIC_IS_CLOUD_ENV !== "true") {
redirect("/");
}
const resolvedSearchParams = await searchParams;
const editAlertId = getParamValue(resolvedSearchParams, "edit");
const [result, providersData, scansData, metadataInfoData, editResult] =
await Promise.all([
listAlerts(toAlertsSearchParams(resolvedSearchParams)),
getProviders({ pageSize: 50 }),
getScans({ pageSize: 50 }),
getLatestMetadataInfo({}),
editAlertId ? getAlert(editAlertId) : Promise.resolve(null),
]);
const hasError = result && "error" in result;
const alerts = !hasError ? result.data : [];
const apiMeta = !hasError ? result.meta : undefined;
const loadError = hasError ? result.error : null;
const uniqueRegions = metadataInfoData?.data?.attributes?.regions || [];
const uniqueServices = metadataInfoData?.data?.attributes?.services || [];
const uniqueResourceTypes =
metadataInfoData?.data?.attributes?.resource_types || [];
const uniqueCategories = metadataInfoData?.data?.attributes?.categories || [];
const uniqueGroups = metadataInfoData?.data?.attributes?.groups || [];
const scans = scansData && "data" in scansData ? scansData.data : [];
const completedScans = scans?.filter(
(scan: ScanProps) =>
scan.attributes.state === "completed" &&
scan.attributes.unique_resource_count > 1,
);
const completedScanIds =
completedScans?.map((scan: ScanProps) => scan.id) || [];
const scanDetails = createScanDetailsMapping(
completedScans || [],
providersData,
) as { [uid: string]: ScanEntity }[];
const editingAlert =
editResult && !("error" in editResult) ? editResult.data : null;
const meta: MetaDataProps | undefined = apiMeta?.pagination
? {
pagination: {
page: apiMeta.pagination.page,
pages: apiMeta.pagination.pages,
count: apiMeta.pagination.count,
},
version: "1",
}
: undefined;
return (
<ContentLayout title="Alerts" icon="lucide:bell-ring">
<AlertsManager
alerts={alerts}
meta={meta}
loadError={loadError}
providers={providersData?.data || []}
completedScanIds={completedScanIds}
scanDetails={scanDetails}
uniqueRegions={uniqueRegions}
uniqueServices={uniqueServices}
uniqueResourceTypes={uniqueResourceTypes}
uniqueCategories={uniqueCategories}
uniqueGroups={uniqueGroups}
initialEditingAlert={editingAlert}
/>
</ContentLayout>
);
}
@@ -3,6 +3,7 @@
import { ColumnDef } from "@tanstack/react-table";
import { Check, Minus } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useRef } from "react";
import {
RadioGroup,
@@ -243,6 +244,8 @@ export const ScanListTable = ({ scans }: ScanListTableProps) => {
const endIndex = startIndex + pageSize;
const paginatedScans = scans.slice(startIndex, endIndex);
const suppressNextPageResetRef = useRef(false);
const pushWithParams = (nextParams: Record<string, string>) => {
const params = new URLSearchParams(searchParams.toString());
@@ -257,9 +260,18 @@ export const ScanListTable = ({ scans }: ScanListTableProps) => {
pushWithParams({ scanId });
};
const handlePaginationChange = (nextPage: number, nextPageSize: number) => {
const handlePageChange = (page: number) => {
if (suppressNextPageResetRef.current && page === 1) {
suppressNextPageResetRef.current = false;
return;
}
pushWithParams({ scanPage: page.toString() });
};
const handlePageSizeChange = (nextPageSize: number) => {
suppressNextPageResetRef.current = true;
pushWithParams({
scanPage: nextPage.toString(),
scanPage: "1",
scanPageSize: nextPageSize.toString(),
});
};
@@ -276,7 +288,8 @@ export const ScanListTable = ({ scans }: ScanListTableProps) => {
metadata={buildMetadata(scans.length, currentPage, totalPages)}
controlledPage={currentPage}
controlledPageSize={pageSize}
onPaginationChange={handlePaginationChange}
onPageChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
onRowClick={(row) => {
if (row.original.attributes.graph_data_ready) {
handleSelectScan(row.original.id);
+2 -4
View File
@@ -166,7 +166,6 @@ export default async function Compliance({
>
<SSRComplianceGrid
searchParams={resolvedSearchParams}
scanId={selectedScanId}
selectedScan={selectedScanData}
/>
</Suspense>
@@ -180,13 +179,12 @@ export default async function Compliance({
const SSRComplianceGrid = async ({
searchParams,
scanId,
selectedScan,
}: {
searchParams: SearchParamsProps;
scanId: string | null;
selectedScan?: ScanEntity;
}) => {
const scanId = searchParams.scanId?.toString() || "";
const regionFilter = searchParams["filter[region__in]"]?.toString() || "";
// Only fetch compliance data if we have a valid scanId
@@ -249,7 +247,7 @@ const SSRComplianceGrid = async ({
<ComplianceOverviewPanel>
<ComplianceOverviewGrid
frameworks={frameworks}
scanId={scanId ?? ""}
scanId={scanId}
selectedScan={selectedScan}
latestCisIds={latestCisIds}
/>
+15
View File
@@ -8,6 +8,7 @@ import {
import { getLatestMetadataInfo, getMetadataInfo } from "@/actions/findings";
import { getProviders } from "@/actions/providers";
import { getScan, getScans } from "@/actions/scans";
import { SeedFromFindingsButton } from "@/app/(prowler)/alerts/_components";
import { FindingsFilters } from "@/components/findings/findings-filters";
import {
FindingsGroupTable,
@@ -80,6 +81,7 @@ export default async function Findings({
completedScans || [],
providersData,
) as { [uid: string]: ScanEntity }[];
const alertsEnabled = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
return (
<ContentLayout title="Findings" icon="lucide:tag">
@@ -94,6 +96,19 @@ export default async function Findings({
uniqueResourceTypes={uniqueResourceTypes}
uniqueCategories={uniqueCategories}
uniqueGroups={uniqueGroups}
trailingControls={
<SeedFromFindingsButton
filterBag={filters}
providers={providersData?.data || []}
scans={scanDetails}
uniqueRegions={uniqueRegions}
uniqueServices={uniqueServices}
uniqueResourceTypes={uniqueResourceTypes}
uniqueCategories={uniqueCategories}
uniqueGroups={uniqueGroups}
isCloudEnabled={alertsEnabled}
/>
}
/>
</div>
<Suspense fallback={<SkeletonTableFindings />}>
+1
View File
@@ -54,6 +54,7 @@ const DEFAULT_PERMISSIONS: RolePermissionAttributes = {
manage_scans: false,
manage_integrations: false,
manage_billing: false,
manage_alerts: false,
unlimited_visibility: false,
};
@@ -55,7 +55,7 @@ describe("ApplyFiltersButton", () => {
// ── No changes ───────────────────────────────────────────────────────────
describe("when hasChanges is false", () => {
it("should render the Apply Filters button as disabled", () => {
it("should render the Apply Changes button as disabled", () => {
// Given / When
render(
<ApplyFiltersButton
@@ -68,7 +68,7 @@ describe("ApplyFiltersButton", () => {
// Then
const applyButton = screen.getByRole("button", {
name: "Apply Filters",
name: "Apply Changes",
});
expect(applyButton).toBeDisabled();
});
@@ -92,7 +92,7 @@ describe("ApplyFiltersButton", () => {
).not.toBeInTheDocument();
});
it("should show 'Apply Filters' label without count", () => {
it("should show 'Apply Changes' label without count", () => {
// Given / When
render(
<ApplyFiltersButton
@@ -105,7 +105,7 @@ describe("ApplyFiltersButton", () => {
// Then
expect(
screen.getByRole("button", { name: "Apply Filters" }),
screen.getByRole("button", { name: "Apply Changes" }),
).toBeInTheDocument();
});
});
@@ -113,7 +113,7 @@ describe("ApplyFiltersButton", () => {
// ── Has changes ──────────────────────────────────────────────────────────
describe("when hasChanges is true", () => {
it("should render the Apply Filters button as enabled", () => {
it("should render the Apply Changes button as enabled", () => {
// Given / When
render(
<ApplyFiltersButton
@@ -126,7 +126,7 @@ describe("ApplyFiltersButton", () => {
// Then
const applyButton = screen.getByRole("button", {
name: "Apply Filters (2)",
name: "Apply Changes (2)",
});
expect(applyButton).not.toBeDisabled();
});
@@ -144,11 +144,11 @@ describe("ApplyFiltersButton", () => {
// Then
expect(
screen.getByRole("button", { name: "Apply Filters (3)" }),
screen.getByRole("button", { name: "Apply Changes (3)" }),
).toBeInTheDocument();
});
it("should show 'Apply Filters' (without count) when changeCount is 0 but hasChanges is true", () => {
it("should show 'Apply Changes' (without count) when changeCount is 0 but hasChanges is true", () => {
// Given — hasChanges=true but changeCount=0 (edge case)
render(
<ApplyFiltersButton
@@ -161,7 +161,7 @@ describe("ApplyFiltersButton", () => {
// Then
expect(
screen.getByRole("button", { name: "Apply Filters" }),
screen.getByRole("button", { name: "Apply Changes" }),
).toBeInTheDocument();
});
@@ -186,7 +186,7 @@ describe("ApplyFiltersButton", () => {
// ── onApply interaction ──────────────────────────────────────────────────
describe("onApply", () => {
it("should call onApply when the Apply Filters button is clicked", async () => {
it("should call onApply when the Apply Changes button is clicked", async () => {
// Given
const user = userEvent.setup();
const onApply = vi.fn();
@@ -203,7 +203,7 @@ describe("ApplyFiltersButton", () => {
// When
await user.click(
screen.getByRole("button", { name: "Apply Filters (1)" }),
screen.getByRole("button", { name: "Apply Changes (1)" }),
);
// Then
@@ -226,7 +226,7 @@ describe("ApplyFiltersButton", () => {
);
// When
await user.click(screen.getByRole("button", { name: "Apply Filters" }));
await user.click(screen.getByRole("button", { name: "Apply Changes" }));
// Then — disabled button should not fire
expect(onApply).not.toHaveBeenCalled();
@@ -8,7 +8,7 @@ export interface ApplyFiltersButtonProps {
hasChanges: boolean;
/** Number of filter keys that have pending changes */
changeCount: number;
/** Called when the user clicks "Apply Filters" */
/** Called when the user clicks "Apply Changes" */
onApply: () => void;
/** Called when the user clicks the discard (Undo) action */
onDiscard: () => void;
@@ -17,7 +17,7 @@ export interface ApplyFiltersButtonProps {
}
/**
* Displays an "Apply Filters" button with an optional discard action.
* Displays an "Apply Changes" button with an optional discard action.
*
* - Shows the count of pending changes when `hasChanges` is true.
* - The apply button is disabled (and visually muted) when there are no changes.
@@ -32,12 +32,12 @@ export const ApplyFiltersButton = ({
className,
}: ApplyFiltersButtonProps) => {
const label =
changeCount > 0 ? `Apply Filters (${changeCount})` : "Apply Filters";
changeCount > 0 ? `Apply Changes (${changeCount})` : "Apply Changes";
return (
<div className={cn("flex items-center gap-2", className)}>
<Button
variant="link"
variant="default"
size="sm"
disabled={!hasChanges}
onClick={onApply}
@@ -1,7 +1,10 @@
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
interface BatchFiltersLayoutProps {
controls: ReactNode;
controlsClassName?: string;
expandedFilters?: ReactNode;
expandedFiltersVisible?: boolean;
appliedSummary?: ReactNode;
@@ -14,6 +17,7 @@ interface BatchFiltersLayoutProps {
export const BatchFiltersLayout = ({
controls,
controlsClassName,
expandedFilters,
expandedFiltersVisible = true,
appliedSummary,
@@ -26,7 +30,7 @@ export const BatchFiltersLayout = ({
<div className="flex flex-col gap-3">
<div
data-testid={`${testIdPrefix}-filter-controls`}
className="flex flex-wrap items-center gap-4"
className={cn("flex flex-wrap items-center gap-4", controlsClassName)}
>
{controls}
</div>
@@ -14,8 +14,8 @@ export interface ClearFiltersButtonProps {
ariaLabel?: string;
/** Show the count of active filters */
showCount?: boolean;
/** Use link style (text only, no button background) */
variant?: "link" | "default";
/** Button visual variant */
variant?: "link" | "default" | "outline";
/**
* Optional callback for batch mode. When provided, this is called INSTEAD
* of pushing URL params directly. Useful for clearing pending filter state
@@ -32,10 +32,10 @@ export interface ClearFiltersButtonProps {
}
export const ClearFiltersButton = ({
text = "Clear all filters",
text = "Clear All",
ariaLabel = "Reset",
showCount = false,
variant = "link",
variant = "outline",
onClear,
pendingCount,
}: ClearFiltersButtonProps) => {
@@ -80,7 +80,7 @@ export const ClearFiltersButton = ({
return null;
}
const displayText = showCount ? `Clear Filters (${displayCount})` : text;
const displayText = showCount ? `Clear All (${displayCount})` : text;
return (
<Button
@@ -70,7 +70,10 @@ export const FilterSummaryStrip = ({
<Tooltip key={`${chip.key}-${chip.values?.join("|") ?? chip.value}`}>
<Badge
variant="tag"
className="flex max-w-[280px] min-w-0 items-center gap-1 overflow-hidden pr-1"
className={cn(
"flex max-w-[280px] min-w-0 items-center gap-1 overflow-hidden",
onRemove && "pr-1",
)}
>
<TooltipTrigger asChild>
<span className="text-text-neutral-primary min-w-0 flex-1 truncate text-xs">
+194 -96
View File
@@ -1,6 +1,7 @@
"use client";
import { ChevronDown } from "lucide-react";
import type { ReactNode } from "react";
import { useState } from "react";
import { AccountsSelector } from "@/app/(prowler)/_overview/_components/accounts-selector";
@@ -16,7 +17,7 @@ import {
} from "@/components/filters/filter-summary-strip";
import { Button } from "@/components/shadcn";
import { ExpandableSection } from "@/components/ui/expandable-section";
import { DataTableFilterCustom } from "@/components/ui/table";
import { DataTableFilterCustom } from "@/components/ui/table/data-table-filter-custom";
import { useFilterBatch } from "@/hooks/use-filter-batch";
import { getCategoryLabel, getGroupLabel } from "@/lib/categories";
import { FilterType, ScanEntity } from "@/types";
@@ -38,6 +39,23 @@ interface FindingsFiltersProps {
uniqueResourceTypes: string[];
uniqueCategories: string[];
uniqueGroups: string[];
trailingControls?: ReactNode;
variant?: "default" | "alerts-edit";
}
interface FindingsFilterBatchControlsProps extends FindingsFiltersProps {
appliedFilters: Record<string, string[]>;
pendingFilters: Record<string, string[]>;
changedFilters: Record<string, string[]>;
setPending: (filterKey: string, values: string[]) => void;
applyAll?: () => void;
discardAll?: () => void;
clearAndApply?: () => void;
removeAppliedAndApply?: (filterKey: string, value?: string) => void;
hasChanges?: boolean;
changeCount?: number;
getFilterValue: (filterKey: string) => string[];
showSummaries?: boolean;
}
const countVisibleFilterKeys = (filters: Record<string, string[]>): number =>
@@ -47,7 +65,11 @@ const countVisibleFilterKeys = (filters: Record<string, string[]>): number =>
return true;
}).length;
export const FindingsFilters = ({
const FILTER_CONTROL_COLUMN_CLASS =
"min-w-0 flex-none basis-full sm:basis-[calc((100%_-_0.75rem)/2)] lg:basis-[calc((100%_-_1.5rem)/3)] xl:basis-[calc((100%_-_2.25rem)/4)] 2xl:basis-[calc((100%_-_3rem)/5)]";
const FILTER_GRID_ITEM_CLASS = "min-w-0";
export const FindingsFilterBatchControls = ({
providers,
completedScanIds,
scanDetails,
@@ -56,35 +78,36 @@ export const FindingsFilters = ({
uniqueResourceTypes,
uniqueCategories,
uniqueGroups,
}: FindingsFiltersProps) => {
trailingControls,
appliedFilters,
pendingFilters,
changedFilters,
setPending,
applyAll,
discardAll,
clearAndApply,
removeAppliedAndApply,
hasChanges = false,
changeCount = 0,
getFilterValue,
showSummaries = true,
variant = "default",
}: FindingsFilterBatchControlsProps) => {
const [isExpanded, setIsExpanded] = useState(false);
const {
appliedFilters,
pendingFilters,
changedFilters,
setPending,
applyAll,
discardAll,
clearAndApply,
removeAppliedAndApply,
hasChanges,
changeCount,
getFilterValue,
} = useFilterBatch({
defaultParams: { "filter[muted]": "false" },
});
const isAlertsEdit = variant === "alerts-edit";
// Custom filters for the expandable section (removed Provider - now using AccountsSelector)
const customFilters = [
...filterFindings.map((filter) => ({
...filter,
labelFormatter: (value: string) =>
getFindingsFilterDisplayValue(`filter[${filter.key}]`, value, {
providers,
scans: scanDetails,
}),
})),
...filterFindings
.filter((filter) => !isAlertsEdit || filter.key !== FilterType.STATUS)
.map((filter) => ({
...filter,
labelFormatter: (value: string) =>
getFindingsFilterDisplayValue(`filter[${filter.key}]`, value, {
providers,
scans: scanDetails,
}),
})),
{
key: FilterType.REGION,
labelCheckboxGroup: "Regions",
@@ -117,19 +140,27 @@ export const FindingsFilters = ({
labelFormatter: getGroupLabel,
index: 6,
},
{
key: FilterType.SCAN,
labelCheckboxGroup: "Scan ID",
values: completedScanIds,
width: "wide" as const,
valueLabelMapping: scanDetails,
labelFormatter: (value: string) =>
getFindingsFilterDisplayValue(`filter[${FilterType.SCAN}]`, value, {
providers,
scans: scanDetails,
}),
index: 7,
},
...(isAlertsEdit
? []
: [
{
key: FilterType.SCAN,
labelCheckboxGroup: "Scan ID",
values: completedScanIds,
width: "wide" as const,
valueLabelMapping: scanDetails,
labelFormatter: (value: string) =>
getFindingsFilterDisplayValue(
`filter[${FilterType.SCAN}]`,
value,
{
providers,
scans: scanDetails,
},
),
index: 7,
},
]),
];
const hasCustomFilters = customFilters.length > 0;
@@ -172,35 +203,76 @@ export const FindingsFilters = ({
? pendingDateValues[0]
: undefined;
const expandedFilters = hasCustomFilters ? (
<ExpandableSection isExpanded={isExpanded} contentClassName="pt-0">
<DataTableFilterCustom
gridClassName="gap-3"
filters={customFilters}
prependElement={
<CustomDatePicker
onBatchChange={(filterKey, value) =>
setPending(filterKey, value ? [value] : [])
}
value={pendingDateValue}
/>
}
hideClearButton
mode={DATA_TABLE_FILTER_MODE.BATCH}
const providerTypeControl = (className: string) => (
<div className={className}>
<ProviderTypeSelector
providers={providers}
onBatchChange={setPending}
getFilterValue={getFilterValue}
selectedValues={getFilterValue("filter[provider_type__in]")}
/>
</ExpandableSection>
</div>
);
const accountsControl = (className: string) => (
<div className={className}>
<AccountsSelector
providers={providers}
onBatchChange={setPending}
selectedValues={getFilterValue("filter[provider_id__in]")}
selectedProviderTypes={getFilterValue("filter[provider_type__in]")}
/>
</div>
);
const alertEditFilterGrid = hasCustomFilters ? (
<DataTableFilterCustom
gridClassName="w-full gap-3 xl:grid-cols-3 2xl:grid-cols-3"
filters={customFilters}
prependElement={
<>
{providerTypeControl(FILTER_GRID_ITEM_CLASS)}
{accountsControl(FILTER_GRID_ITEM_CLASS)}
</>
}
hideClearButton
mode={DATA_TABLE_FILTER_MODE.BATCH}
onBatchChange={setPending}
getFilterValue={getFilterValue}
/>
) : null;
const expandedFilters =
hasCustomFilters && !isAlertsEdit ? (
<ExpandableSection isExpanded={isExpanded} contentClassName="pt-0">
<DataTableFilterCustom
gridClassName="gap-3"
filters={customFilters}
prependElement={
isAlertsEdit ? undefined : (
<CustomDatePicker
onBatchChange={(filterKey, value) =>
setPending(filterKey, value ? [value] : [])
}
value={pendingDateValue}
/>
)
}
hideClearButton
mode={DATA_TABLE_FILTER_MODE.BATCH}
onBatchChange={setPending}
getFilterValue={getFilterValue}
/>
</ExpandableSection>
) : null;
const appliedSummary = (
<FilterSummaryStrip
chips={appliedFilterChips}
onRemove={removeAppliedAndApply}
onRemove={removeAppliedAndApply ?? (() => undefined)}
trailingContent={
<ClearFiltersButton
showCount
onClear={clearAndApply}
onClear={clearAndApply ?? (() => undefined)}
pendingCount={appliedCount}
/>
}
@@ -215,8 +287,8 @@ export const FindingsFilters = ({
<ApplyFiltersButton
hasChanges={hasChanges}
changeCount={changeCount}
onApply={applyAll}
onDiscard={discardAll}
onApply={applyAll ?? (() => undefined)}
onDiscard={discardAll ?? (() => undefined)}
/>
}
/>
@@ -225,45 +297,71 @@ export const FindingsFilters = ({
return (
<BatchFiltersLayout
testIdPrefix="findings"
controlsClassName="gap-3"
controls={
<>
<div className="min-w-[200px] flex-1 md:max-w-[280px]">
<ProviderTypeSelector
providers={providers}
onBatchChange={setPending}
selectedValues={getFilterValue("filter[provider_type__in]")}
/>
</div>
<div className="min-w-[200px] flex-1 md:max-w-[280px]">
<AccountsSelector
providers={providers}
onBatchChange={setPending}
selectedValues={getFilterValue("filter[provider_id__in]")}
selectedProviderTypes={getFilterValue(
"filter[provider_type__in]",
)}
/>
</div>
{hasCustomFilters && (
<Button
variant="outline"
size="lg"
onClick={() => setIsExpanded(!isExpanded)}
>
{isExpanded ? "Less Filters" : "More Filters"}
<ChevronDown
className={`size-4 transition-transform duration-300 ${isExpanded ? "rotate-180" : "rotate-0"}`}
/>
</Button>
)}
</>
isAlertsEdit ? (
alertEditFilterGrid
) : (
<>
{providerTypeControl(FILTER_CONTROL_COLUMN_CLASS)}
{accountsControl(FILTER_CONTROL_COLUMN_CLASS)}
{hasCustomFilters && (
<Button
variant="outline"
size="lg"
onClick={() => setIsExpanded(!isExpanded)}
>
{isExpanded ? "Less Filters" : "More Filters"}
<ChevronDown
className={`size-4 transition-transform duration-300 ${isExpanded ? "rotate-180" : "rotate-0"}`}
/>
</Button>
)}
{trailingControls}
</>
)
}
expandedFilters={expandedFilters}
expandedFiltersVisible={isExpanded}
appliedSummary={appliedSummary}
pendingSummary={pendingSummary}
showAppliedRow={showAppliedRow}
showPendingRow={showPendingRow}
appliedSummary={showSummaries ? appliedSummary : null}
pendingSummary={showSummaries ? pendingSummary : null}
showAppliedRow={showSummaries && showAppliedRow}
showPendingRow={showSummaries && showPendingRow}
/>
);
};
export const FindingsFilters = (props: FindingsFiltersProps) => {
const {
appliedFilters,
pendingFilters,
changedFilters,
setPending,
applyAll,
discardAll,
clearAndApply,
removeAppliedAndApply,
hasChanges,
changeCount,
getFilterValue,
} = useFilterBatch({
defaultParams: { "filter[muted]": "false" },
});
return (
<FindingsFilterBatchControls
{...props}
appliedFilters={appliedFilters}
pendingFilters={pendingFilters}
changedFilters={changedFilters}
setPending={setPending}
applyAll={applyAll}
discardAll={discardAll}
clearAndApply={clearAndApply}
removeAppliedAndApply={removeAppliedAndApply}
hasChanges={hasChanges}
changeCount={changeCount}
getFilterValue={getFilterValue}
/>
);
};
@@ -121,6 +121,7 @@ export const FILTER_KEY_LABELS: Record<FilterParam, string> = {
interface BuildFindingsFilterChipsOptions {
providers?: ProviderProps[];
scans?: Array<{ [scanId: string]: ScanEntity }>;
includeMuted?: boolean;
}
/**
@@ -140,7 +141,7 @@ export function buildFindingsFilterChips(
Object.entries(pendingFilters).forEach(([key, values]) => {
if (!values || values.length === 0) return;
if (key === "filter[muted]") return;
if (key === "filter[muted]" && !options.includeMuted) return;
const label = FILTER_KEY_LABELS[key as FilterParam] ?? key;
const visibleValues = values;

Some files were not shown because too many files have changed in this diff Show More