mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-05-09 00:47:04 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 832516be2a | |||
| 34727a7237 | |||
| 4216a3e23a | |||
| a59192e6f5 | |||
| 592bc6f6a8 | |||
| 962ebac8e4 | |||
| 2c5d47a8cd |
@@ -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>
|
||||
|
||||
Generated
+3
-3
@@ -1009,7 +1009,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.32.5"
|
||||
version = "2.33.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
@@ -1017,9 +1017,9 @@ dependencies = [
|
||||
{ name = "idna" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -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]
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"Provider": "googleworkspace",
|
||||
"CheckID": "gmail_anomalous_attachment_protection_enabled",
|
||||
"CheckTitle": "Protection against anomalous attachment types in emails is enabled",
|
||||
"CheckType": [],
|
||||
"ServiceName": "gmail",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "NotDefined",
|
||||
"ResourceGroup": "collaboration",
|
||||
"Description": "Verifies that Gmail is configured to take a protective action (such as moving to spam, quarantining, or showing a warning) when emails contain anomalous attachment types. Unusual file types that are uncommon for the sender or organization may indicate an attempt to deliver malware through less-scrutinized formats.",
|
||||
"Risk": "Without protection against anomalous attachment types, users may receive **emails with unusual file formats** that are designed to bypass standard security filters. Attackers may use **uncommon file extensions or MIME types** to deliver malware that evades signature-based detection.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://support.google.com/a/answer/7676854",
|
||||
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Sign in to the Google **Admin console** at https://admin.google.com\n2. Navigate to **Apps** > **Google Workspace** > **Gmail**\n3. Click **Safety** > **Attachments**\n4. Check **Protect against anomalous attachment types in emails**\n5. Select the desired action (e.g., Move email to spam)\n6. Click **Save**",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Enable protection against anomalous attachment types in emails and configure an appropriate action such as moving to spam or quarantining.",
|
||||
"Url": "https://hub.prowler.com/check/gmail_anomalous_attachment_protection_enabled"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"email-security"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [
|
||||
"gmail_encrypted_attachment_protection_enabled",
|
||||
"gmail_script_attachment_protection_enabled"
|
||||
],
|
||||
"Notes": ""
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
from typing import List
|
||||
|
||||
from prowler.lib.check.models import Check, CheckReportGoogleWorkspace
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_client import gmail_client
|
||||
|
||||
|
||||
class gmail_anomalous_attachment_protection_enabled(Check):
|
||||
"""Check that protection against anomalous attachment types in emails is enabled.
|
||||
|
||||
This check verifies that Gmail is configured to take action on
|
||||
emails containing unusual attachment types, helping prevent
|
||||
malware delivery via uncommon file formats.
|
||||
"""
|
||||
|
||||
def execute(self) -> List[CheckReportGoogleWorkspace]:
|
||||
findings = []
|
||||
|
||||
if gmail_client.policies_fetched:
|
||||
report = CheckReportGoogleWorkspace(
|
||||
metadata=self.metadata(),
|
||||
resource=gmail_client.provider.domain_resource,
|
||||
)
|
||||
|
||||
enabled = gmail_client.policies.enable_anomalous_attachment_protection
|
||||
consequence = (
|
||||
gmail_client.policies.anomalous_attachment_protection_consequence
|
||||
)
|
||||
|
||||
if enabled is False:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Protection against anomalous attachment types in emails "
|
||||
f"is disabled in domain "
|
||||
f"{gmail_client.provider.identity.domain}. "
|
||||
f"Enable the protection and configure a protective action."
|
||||
)
|
||||
elif enabled is None:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Protection against anomalous attachment types in emails "
|
||||
f"is not configured and uses Google's insecure default "
|
||||
f"(disabled) in domain "
|
||||
f"{gmail_client.provider.identity.domain}. "
|
||||
f"Enable the protection and configure a protective action."
|
||||
)
|
||||
elif consequence == "NO_ACTION":
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Protection against anomalous attachment types in emails "
|
||||
f"is set to take no action in domain "
|
||||
f"{gmail_client.provider.identity.domain}. "
|
||||
f"A protective action should be configured."
|
||||
)
|
||||
elif consequence is None:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Protection against anomalous attachment types in emails "
|
||||
f"is enabled in domain "
|
||||
f"{gmail_client.provider.identity.domain}."
|
||||
)
|
||||
else:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Protection against anomalous attachment types in emails "
|
||||
f"is enabled with consequence '{consequence}' "
|
||||
f"in domain {gmail_client.provider.identity.domain}."
|
||||
)
|
||||
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"Provider": "googleworkspace",
|
||||
"CheckID": "gmail_domain_spoofing_protection_enabled",
|
||||
"CheckTitle": "Protection against domain spoofing based on similar domain names is enabled",
|
||||
"CheckType": [],
|
||||
"ServiceName": "gmail",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "NotDefined",
|
||||
"ResourceGroup": "collaboration",
|
||||
"Description": "Verifies that Gmail is configured to take a protective action (such as moving to spam, quarantining, or showing a warning) when emails appear to come from domain names that look similar to the organization's domain. Lookalike domains are a common phishing technique used to trick users into trusting malicious messages.",
|
||||
"Risk": "Without protection against domain spoofing based on similar domain names, users may receive **phishing emails from lookalike domains** (e.g., examp1e.com instead of example.com) that appear legitimate. This enables **credential theft, malware delivery, and business email compromise** attacks.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://support.google.com/a/answer/9157861",
|
||||
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Sign in to the Google **Admin console** at https://admin.google.com\n2. Navigate to **Apps** > **Google Workspace** > **Gmail**\n3. Click **Safety** > **Spoofing and authentication**\n4. Check **Protect against domain spoofing based on similar domain names**\n5. Select the desired action (e.g., Move email to spam)\n6. Click **Save**",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Enable protection against domain spoofing based on similar domain names and configure an appropriate action such as moving to spam or quarantining.",
|
||||
"Url": "https://hub.prowler.com/check/gmail_domain_spoofing_protection_enabled"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"email-security"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [
|
||||
"gmail_employee_name_spoofing_protection_enabled",
|
||||
"gmail_inbound_domain_spoofing_protection_enabled",
|
||||
"gmail_unauthenticated_email_protection_enabled",
|
||||
"gmail_groups_spoofing_protection_enabled"
|
||||
],
|
||||
"Notes": ""
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
from typing import List
|
||||
|
||||
from prowler.lib.check.models import Check, CheckReportGoogleWorkspace
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_client import gmail_client
|
||||
|
||||
|
||||
class gmail_domain_spoofing_protection_enabled(Check):
|
||||
"""Check that protection against domain spoofing based on similar domain names is enabled.
|
||||
|
||||
This check verifies that Gmail is configured to take action on
|
||||
emails that appear to come from similar-looking domain names,
|
||||
helping prevent phishing via domain impersonation.
|
||||
"""
|
||||
|
||||
def execute(self) -> List[CheckReportGoogleWorkspace]:
|
||||
findings = []
|
||||
|
||||
if gmail_client.policies_fetched:
|
||||
report = CheckReportGoogleWorkspace(
|
||||
metadata=self.metadata(),
|
||||
resource=gmail_client.provider.domain_resource,
|
||||
)
|
||||
|
||||
enabled = gmail_client.policies.detect_domain_name_spoofing
|
||||
consequence = gmail_client.policies.domain_spoofing_consequence
|
||||
|
||||
if enabled is False:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Protection against domain spoofing based on similar "
|
||||
f"domain names is disabled in domain "
|
||||
f"{gmail_client.provider.identity.domain}. "
|
||||
f"Enable the protection and configure a protective action."
|
||||
)
|
||||
elif consequence == "NO_ACTION":
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Protection against domain spoofing based on similar "
|
||||
f"domain names is set to take no action in domain "
|
||||
f"{gmail_client.provider.identity.domain}. "
|
||||
f"A protective action should be configured."
|
||||
)
|
||||
elif consequence is None:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Protection against domain spoofing based on similar "
|
||||
f"domain names uses Google's secure default configuration "
|
||||
f"(enabled) in domain "
|
||||
f"{gmail_client.provider.identity.domain}."
|
||||
)
|
||||
else:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Protection against domain spoofing based on similar "
|
||||
f"domain names is enabled with consequence "
|
||||
f"'{consequence}' in domain "
|
||||
f"{gmail_client.provider.identity.domain}."
|
||||
)
|
||||
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"Provider": "googleworkspace",
|
||||
"CheckID": "gmail_employee_name_spoofing_protection_enabled",
|
||||
"CheckTitle": "Protection against spoofing of employee names is enabled",
|
||||
"CheckType": [],
|
||||
"ServiceName": "gmail",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "NotDefined",
|
||||
"ResourceGroup": "collaboration",
|
||||
"Description": "Verifies that Gmail is configured to take a protective action (such as moving to spam, quarantining, or showing a warning) when the sender's display name matches an employee's name but the email comes from an external address. This is a common social engineering technique where attackers impersonate colleagues or executives.",
|
||||
"Risk": "Without protection against employee name spoofing, users may receive **emails that appear to come from colleagues or executives** but are actually from external attackers. This enables **business email compromise (BEC)**, **wire fraud**, and **social engineering attacks** that exploit trust relationships.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://support.google.com/a/answer/9157861",
|
||||
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Sign in to the Google **Admin console** at https://admin.google.com\n2. Navigate to **Apps** > **Google Workspace** > **Gmail**\n3. Click **Safety** > **Spoofing and authentication**\n4. Check **Protect against spoofing of employee names**\n5. Select the desired action (e.g., Move email to spam)\n6. Click **Save**",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Enable protection against spoofing of employee names and configure an appropriate action such as moving to spam or quarantining.",
|
||||
"Url": "https://hub.prowler.com/check/gmail_employee_name_spoofing_protection_enabled"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"email-security"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [
|
||||
"gmail_domain_spoofing_protection_enabled",
|
||||
"gmail_inbound_domain_spoofing_protection_enabled",
|
||||
"gmail_unauthenticated_email_protection_enabled",
|
||||
"gmail_groups_spoofing_protection_enabled"
|
||||
],
|
||||
"Notes": ""
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
from typing import List
|
||||
|
||||
from prowler.lib.check.models import Check, CheckReportGoogleWorkspace
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_client import gmail_client
|
||||
|
||||
|
||||
class gmail_employee_name_spoofing_protection_enabled(Check):
|
||||
"""Check that protection against spoofing of employee names is enabled.
|
||||
|
||||
This check verifies that Gmail is configured to take action on
|
||||
emails where the sender name matches an employee name but comes
|
||||
from an external address, helping prevent social engineering attacks.
|
||||
"""
|
||||
|
||||
def execute(self) -> List[CheckReportGoogleWorkspace]:
|
||||
findings = []
|
||||
|
||||
if gmail_client.policies_fetched:
|
||||
report = CheckReportGoogleWorkspace(
|
||||
metadata=self.metadata(),
|
||||
resource=gmail_client.provider.domain_resource,
|
||||
)
|
||||
|
||||
enabled = gmail_client.policies.detect_employee_name_spoofing
|
||||
consequence = gmail_client.policies.employee_name_spoofing_consequence
|
||||
|
||||
if enabled is False:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Protection against spoofing of employee names is "
|
||||
f"disabled in domain "
|
||||
f"{gmail_client.provider.identity.domain}. "
|
||||
f"Enable the protection and configure a protective action."
|
||||
)
|
||||
elif consequence == "NO_ACTION":
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Protection against spoofing of employee names is set "
|
||||
f"to take no action in domain "
|
||||
f"{gmail_client.provider.identity.domain}. "
|
||||
f"A protective action should be configured."
|
||||
)
|
||||
elif consequence is None:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Protection against spoofing of employee names uses "
|
||||
f"Google's secure default configuration (enabled) "
|
||||
f"in domain {gmail_client.provider.identity.domain}."
|
||||
)
|
||||
else:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Protection against spoofing of employee names is "
|
||||
f"enabled with consequence '{consequence}' in domain "
|
||||
f"{gmail_client.provider.identity.domain}."
|
||||
)
|
||||
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"Provider": "googleworkspace",
|
||||
"CheckID": "gmail_encrypted_attachment_protection_enabled",
|
||||
"CheckTitle": "Protection against encrypted attachments from untrusted senders is enabled",
|
||||
"CheckType": [],
|
||||
"ServiceName": "gmail",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "NotDefined",
|
||||
"ResourceGroup": "collaboration",
|
||||
"Description": "Verifies that Gmail is configured to take a protective action (such as moving to spam, quarantining, or showing a warning) when an encrypted attachment is received from an untrusted sender. Encrypted attachments cannot be scanned for malware by security filters, making them a common vector for delivering malicious payloads.",
|
||||
"Risk": "Without protection against encrypted attachments from untrusted senders, users may receive **password-protected archives containing malware** that bypass standard content scanning. Attackers commonly use encrypted attachments to evade detection and deliver **ransomware, trojans, or other malicious payloads**.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://support.google.com/a/answer/7676854",
|
||||
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Sign in to the Google **Admin console** at https://admin.google.com\n2. Navigate to **Apps** > **Google Workspace** > **Gmail**\n3. Click **Safety** > **Attachments**\n4. Check **Protect against encrypted attachments from untrusted senders**\n5. Select the desired action (e.g., Move email to spam)\n6. Click **Save**",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Enable protection against encrypted attachments from untrusted senders and configure an appropriate action such as moving to spam or quarantining.",
|
||||
"Url": "https://hub.prowler.com/check/gmail_encrypted_attachment_protection_enabled"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"email-security"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [
|
||||
"gmail_script_attachment_protection_enabled",
|
||||
"gmail_anomalous_attachment_protection_enabled"
|
||||
],
|
||||
"Notes": ""
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
from typing import List
|
||||
|
||||
from prowler.lib.check.models import Check, CheckReportGoogleWorkspace
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_client import gmail_client
|
||||
|
||||
|
||||
class gmail_encrypted_attachment_protection_enabled(Check):
|
||||
"""Check that protection against encrypted attachments from untrusted senders is enabled.
|
||||
|
||||
This check verifies that Gmail is configured to take action on
|
||||
encrypted attachments from untrusted senders, helping prevent
|
||||
malware delivery via password-protected archives.
|
||||
"""
|
||||
|
||||
def execute(self) -> List[CheckReportGoogleWorkspace]:
|
||||
findings = []
|
||||
|
||||
if gmail_client.policies_fetched:
|
||||
report = CheckReportGoogleWorkspace(
|
||||
metadata=self.metadata(),
|
||||
resource=gmail_client.provider.domain_resource,
|
||||
)
|
||||
|
||||
enabled = gmail_client.policies.enable_encrypted_attachment_protection
|
||||
consequence = (
|
||||
gmail_client.policies.encrypted_attachment_protection_consequence
|
||||
)
|
||||
|
||||
if enabled is False:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Protection against encrypted attachments from untrusted "
|
||||
f"senders is disabled in domain "
|
||||
f"{gmail_client.provider.identity.domain}. "
|
||||
f"Enable the protection and configure a protective action."
|
||||
)
|
||||
elif consequence == "NO_ACTION":
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Protection against encrypted attachments from untrusted "
|
||||
f"senders is set to take no action in domain "
|
||||
f"{gmail_client.provider.identity.domain}. "
|
||||
f"A protective action should be configured."
|
||||
)
|
||||
elif consequence is None:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Protection against encrypted attachments from untrusted "
|
||||
f"senders uses Google's secure default configuration "
|
||||
f"(enabled) in domain "
|
||||
f"{gmail_client.provider.identity.domain}."
|
||||
)
|
||||
else:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Protection against encrypted attachments from untrusted "
|
||||
f"senders is enabled with consequence '{consequence}' "
|
||||
f"in domain {gmail_client.provider.identity.domain}."
|
||||
)
|
||||
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"Provider": "googleworkspace",
|
||||
"CheckID": "gmail_groups_spoofing_protection_enabled",
|
||||
"CheckTitle": "Groups are protected from inbound emails spoofing your domain",
|
||||
"CheckType": [],
|
||||
"ServiceName": "gmail",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "NotDefined",
|
||||
"ResourceGroup": "collaboration",
|
||||
"Description": "Verifies that Gmail is configured to take a protective action (such as moving to spam, quarantining, or showing a warning) when groups receive inbound emails that spoof the organization's domain. Google Groups are a high-value target because a single spoofed message can reach many recipients at once.",
|
||||
"Risk": "Without protection of groups from domain-spoofing emails, attackers can send **spoofed messages to group mailboxes** that appear to originate from the organization. Since groups distribute to many recipients, a single spoofed email can enable **mass phishing, social engineering, or misinformation** campaigns across the organization.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://support.google.com/a/answer/9157861",
|
||||
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Sign in to the Google **Admin console** at https://admin.google.com\n2. Navigate to **Apps** > **Google Workspace** > **Gmail**\n3. Click **Safety** > **Spoofing and authentication**\n4. Check **Protect your Groups from inbound emails spoofing your domain**\n5. Select the desired action (e.g., Move email to spam)\n6. Click **Save**",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Enable protection of groups from inbound emails spoofing your domain and configure an appropriate action such as moving to spam or quarantining.",
|
||||
"Url": "https://hub.prowler.com/check/gmail_groups_spoofing_protection_enabled"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"email-security"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [
|
||||
"gmail_domain_spoofing_protection_enabled",
|
||||
"gmail_employee_name_spoofing_protection_enabled",
|
||||
"gmail_inbound_domain_spoofing_protection_enabled",
|
||||
"gmail_unauthenticated_email_protection_enabled"
|
||||
],
|
||||
"Notes": ""
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
from typing import List
|
||||
|
||||
from prowler.lib.check.models import Check, CheckReportGoogleWorkspace
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_client import gmail_client
|
||||
|
||||
|
||||
class gmail_groups_spoofing_protection_enabled(Check):
|
||||
"""Check that groups are protected from inbound emails spoofing your domain.
|
||||
|
||||
This check verifies that Gmail is configured to take action on
|
||||
inbound emails to groups that spoof the organization's domain,
|
||||
helping prevent impersonation attacks targeting group mailboxes.
|
||||
"""
|
||||
|
||||
def execute(self) -> List[CheckReportGoogleWorkspace]:
|
||||
findings = []
|
||||
|
||||
if gmail_client.policies_fetched:
|
||||
report = CheckReportGoogleWorkspace(
|
||||
metadata=self.metadata(),
|
||||
resource=gmail_client.provider.domain_resource,
|
||||
)
|
||||
|
||||
enabled = gmail_client.policies.detect_groups_spoofing
|
||||
consequence = gmail_client.policies.groups_spoofing_consequence
|
||||
visibility_type = gmail_client.policies.groups_spoofing_visibility_type
|
||||
|
||||
if enabled is False:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Protection of groups from inbound emails spoofing your "
|
||||
f"domain is disabled in domain "
|
||||
f"{gmail_client.provider.identity.domain}. "
|
||||
f"Enable the protection and configure a protective action."
|
||||
)
|
||||
elif enabled is None:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Protection of groups from inbound emails spoofing your "
|
||||
f"domain is not configured and uses Google's insecure "
|
||||
f"default (disabled) in domain "
|
||||
f"{gmail_client.provider.identity.domain}. "
|
||||
f"Enable the protection and configure a protective action."
|
||||
)
|
||||
elif consequence == "NO_ACTION":
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Protection of groups from inbound emails spoofing your "
|
||||
f"domain is set to take no action in domain "
|
||||
f"{gmail_client.provider.identity.domain}. "
|
||||
f"A protective action should be configured."
|
||||
)
|
||||
elif consequence is None:
|
||||
report.status = "PASS"
|
||||
scope = (
|
||||
"private groups only"
|
||||
if visibility_type == "PRIVATE_GROUPS_ONLY"
|
||||
else "all groups"
|
||||
)
|
||||
report.status_extended = (
|
||||
f"Protection of groups from inbound emails spoofing your "
|
||||
f"domain is enabled for {scope} in domain "
|
||||
f"{gmail_client.provider.identity.domain}."
|
||||
)
|
||||
else:
|
||||
report.status = "PASS"
|
||||
scope = (
|
||||
"private groups only"
|
||||
if visibility_type == "PRIVATE_GROUPS_ONLY"
|
||||
else "all groups"
|
||||
)
|
||||
report.status_extended = (
|
||||
f"Protection of groups from inbound emails spoofing your "
|
||||
f"domain is enabled for {scope} with consequence "
|
||||
f"'{consequence}' in domain "
|
||||
f"{gmail_client.provider.identity.domain}."
|
||||
)
|
||||
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"Provider": "googleworkspace",
|
||||
"CheckID": "gmail_inbound_domain_spoofing_protection_enabled",
|
||||
"CheckTitle": "Protection against inbound emails spoofing your domain is enabled",
|
||||
"CheckType": [],
|
||||
"ServiceName": "gmail",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "NotDefined",
|
||||
"ResourceGroup": "collaboration",
|
||||
"Description": "Verifies that Gmail is configured to take a protective action (such as moving to spam, quarantining, or showing a warning) when inbound emails spoof the organization's own domain. This protects against attackers sending emails that appear to originate from within the organization but are actually external.",
|
||||
"Risk": "Without protection against inbound domain spoofing, users may receive **emails that appear to come from their own organization** but are sent by external attackers. This enables **internal impersonation**, **phishing**, and **business email compromise** attacks that exploit trust in internal communications.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://support.google.com/a/answer/9157861",
|
||||
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Sign in to the Google **Admin console** at https://admin.google.com\n2. Navigate to **Apps** > **Google Workspace** > **Gmail**\n3. Click **Safety** > **Spoofing and authentication**\n4. Check **Protect against inbound emails spoofing your domain**\n5. Select the desired action (e.g., Move email to spam)\n6. Click **Save**",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Enable protection against inbound emails spoofing your domain and configure an appropriate action such as moving to spam or quarantining.",
|
||||
"Url": "https://hub.prowler.com/check/gmail_inbound_domain_spoofing_protection_enabled"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"email-security"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [
|
||||
"gmail_domain_spoofing_protection_enabled",
|
||||
"gmail_employee_name_spoofing_protection_enabled",
|
||||
"gmail_unauthenticated_email_protection_enabled",
|
||||
"gmail_groups_spoofing_protection_enabled"
|
||||
],
|
||||
"Notes": ""
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
from typing import List
|
||||
|
||||
from prowler.lib.check.models import Check, CheckReportGoogleWorkspace
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_client import gmail_client
|
||||
|
||||
|
||||
class gmail_inbound_domain_spoofing_protection_enabled(Check):
|
||||
"""Check that protection against inbound emails spoofing your domain is enabled.
|
||||
|
||||
This check verifies that Gmail is configured to take action on
|
||||
inbound emails that spoof the organization's own domain, helping
|
||||
prevent impersonation of internal senders.
|
||||
"""
|
||||
|
||||
def execute(self) -> List[CheckReportGoogleWorkspace]:
|
||||
findings = []
|
||||
|
||||
if gmail_client.policies_fetched:
|
||||
report = CheckReportGoogleWorkspace(
|
||||
metadata=self.metadata(),
|
||||
resource=gmail_client.provider.domain_resource,
|
||||
)
|
||||
|
||||
enabled = gmail_client.policies.detect_inbound_domain_spoofing
|
||||
consequence = gmail_client.policies.inbound_domain_spoofing_consequence
|
||||
|
||||
if enabled is False:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Protection against inbound emails spoofing your domain "
|
||||
f"is disabled in domain "
|
||||
f"{gmail_client.provider.identity.domain}. "
|
||||
f"Enable the protection and configure a protective action."
|
||||
)
|
||||
elif consequence == "NO_ACTION":
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Protection against inbound emails spoofing your domain "
|
||||
f"is set to take no action in domain "
|
||||
f"{gmail_client.provider.identity.domain}. "
|
||||
f"A protective action should be configured."
|
||||
)
|
||||
elif consequence is None:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Protection against inbound emails spoofing your domain "
|
||||
f"uses Google's secure default configuration (enabled) "
|
||||
f"in domain {gmail_client.provider.identity.domain}."
|
||||
)
|
||||
else:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Protection against inbound emails spoofing your domain "
|
||||
f"is enabled with consequence '{consequence}' "
|
||||
f"in domain {gmail_client.provider.identity.domain}."
|
||||
)
|
||||
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"Provider": "googleworkspace",
|
||||
"CheckID": "gmail_script_attachment_protection_enabled",
|
||||
"CheckTitle": "Protection against attachments with scripts from untrusted senders is enabled",
|
||||
"CheckType": [],
|
||||
"ServiceName": "gmail",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "NotDefined",
|
||||
"ResourceGroup": "collaboration",
|
||||
"Description": "Verifies that Gmail is configured to take a protective action (such as moving to spam, quarantining, or showing a warning) when an attachment containing scripts is received from an untrusted sender. Script-bearing attachments (e.g., .js, .vbs, .ps1) are a common malware delivery mechanism.",
|
||||
"Risk": "Without protection against script-bearing attachments from untrusted senders, users may receive **files containing malicious scripts** that can execute harmful code when opened. Attackers commonly use script attachments to deliver **malware, backdoors, or credential stealers**.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://support.google.com/a/answer/7676854",
|
||||
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Sign in to the Google **Admin console** at https://admin.google.com\n2. Navigate to **Apps** > **Google Workspace** > **Gmail**\n3. Click **Safety** > **Attachments**\n4. Check **Protect against attachments with scripts from untrusted senders**\n5. Select the desired action (e.g., Move email to spam)\n6. Click **Save**",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Enable protection against attachments with scripts from untrusted senders and configure an appropriate action such as moving to spam or quarantining.",
|
||||
"Url": "https://hub.prowler.com/check/gmail_script_attachment_protection_enabled"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"email-security"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [
|
||||
"gmail_encrypted_attachment_protection_enabled",
|
||||
"gmail_anomalous_attachment_protection_enabled"
|
||||
],
|
||||
"Notes": ""
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
from typing import List
|
||||
|
||||
from prowler.lib.check.models import Check, CheckReportGoogleWorkspace
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_client import gmail_client
|
||||
|
||||
|
||||
class gmail_script_attachment_protection_enabled(Check):
|
||||
"""Check that protection against attachments with scripts from untrusted senders is enabled.
|
||||
|
||||
This check verifies that Gmail is configured to take action on
|
||||
attachments containing scripts from untrusted senders, helping
|
||||
prevent malware delivery via script-bearing files.
|
||||
"""
|
||||
|
||||
def execute(self) -> List[CheckReportGoogleWorkspace]:
|
||||
findings = []
|
||||
|
||||
if gmail_client.policies_fetched:
|
||||
report = CheckReportGoogleWorkspace(
|
||||
metadata=self.metadata(),
|
||||
resource=gmail_client.provider.domain_resource,
|
||||
)
|
||||
|
||||
enabled = gmail_client.policies.enable_script_attachment_protection
|
||||
consequence = gmail_client.policies.script_attachment_protection_consequence
|
||||
|
||||
if enabled is False:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Protection against attachments with scripts from "
|
||||
f"untrusted senders is disabled in domain "
|
||||
f"{gmail_client.provider.identity.domain}. "
|
||||
f"Enable the protection and configure a protective action."
|
||||
)
|
||||
elif consequence == "NO_ACTION":
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Protection against attachments with scripts from "
|
||||
f"untrusted senders is set to take no action in domain "
|
||||
f"{gmail_client.provider.identity.domain}. "
|
||||
f"A protective action should be configured."
|
||||
)
|
||||
elif consequence is None:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Protection against attachments with scripts from "
|
||||
f"untrusted senders uses Google's secure default "
|
||||
f"configuration (enabled) in domain "
|
||||
f"{gmail_client.provider.identity.domain}."
|
||||
)
|
||||
else:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Protection against attachments with scripts from "
|
||||
f"untrusted senders is enabled with consequence "
|
||||
f"'{consequence}' in domain "
|
||||
f"{gmail_client.provider.identity.domain}."
|
||||
)
|
||||
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
@@ -57,12 +57,21 @@ class Gmail(GoogleWorkspaceService):
|
||||
logger.debug("Gmail mail delegation setting fetched.")
|
||||
|
||||
elif setting_type == "gmail.email_attachment_safety":
|
||||
self.policies.enable_encrypted_attachment_protection = (
|
||||
value.get("enableEncryptedAttachmentProtection")
|
||||
)
|
||||
self.policies.encrypted_attachment_protection_consequence = value.get(
|
||||
"encryptedAttachmentProtectionConsequence"
|
||||
)
|
||||
self.policies.enable_script_attachment_protection = (
|
||||
value.get("enableAttachmentWithScriptsProtection")
|
||||
)
|
||||
self.policies.script_attachment_protection_consequence = (
|
||||
value.get("scriptAttachmentProtectionConsequence")
|
||||
)
|
||||
self.policies.enable_anomalous_attachment_protection = (
|
||||
value.get("enableAnomalousAttachmentProtection")
|
||||
)
|
||||
self.policies.anomalous_attachment_protection_consequence = value.get(
|
||||
"anomalousAttachmentProtectionConsequence"
|
||||
)
|
||||
@@ -83,18 +92,36 @@ class Gmail(GoogleWorkspaceService):
|
||||
)
|
||||
|
||||
elif setting_type == "gmail.spoofing_and_authentication":
|
||||
self.policies.detect_domain_name_spoofing = value.get(
|
||||
"detectDomainNameSpoofing"
|
||||
)
|
||||
self.policies.domain_spoofing_consequence = value.get(
|
||||
"domainSpoofingConsequence"
|
||||
)
|
||||
self.policies.detect_employee_name_spoofing = value.get(
|
||||
"detectEmployeeNameSpoofing"
|
||||
)
|
||||
self.policies.employee_name_spoofing_consequence = (
|
||||
value.get("employeeNameSpoofingConsequence")
|
||||
)
|
||||
self.policies.detect_inbound_domain_spoofing = value.get(
|
||||
"detectDomainSpoofingFromUnauthenticatedSenders"
|
||||
)
|
||||
self.policies.inbound_domain_spoofing_consequence = (
|
||||
value.get("inboundDomainSpoofingConsequence")
|
||||
)
|
||||
self.policies.detect_unauthenticated_emails = value.get(
|
||||
"detectUnauthenticatedEmails"
|
||||
)
|
||||
self.policies.unauthenticated_email_consequence = value.get(
|
||||
"unauthenticatedEmailConsequence"
|
||||
)
|
||||
self.policies.detect_groups_spoofing = value.get(
|
||||
"detectGroupsSpoofing"
|
||||
)
|
||||
self.policies.groups_spoofing_visibility_type = value.get(
|
||||
"groupsSpoofingVisibilityType"
|
||||
)
|
||||
self.policies.groups_spoofing_consequence = value.get(
|
||||
"groupsSpoofingConsequence"
|
||||
)
|
||||
@@ -177,8 +204,11 @@ class GmailPolicies(BaseModel):
|
||||
enable_mail_delegation: Optional[bool] = None
|
||||
|
||||
# gmail.email_attachment_safety
|
||||
enable_encrypted_attachment_protection: Optional[bool] = None
|
||||
encrypted_attachment_protection_consequence: Optional[str] = None
|
||||
enable_script_attachment_protection: Optional[bool] = None
|
||||
script_attachment_protection_consequence: Optional[str] = None
|
||||
enable_anomalous_attachment_protection: Optional[bool] = None
|
||||
anomalous_attachment_protection_consequence: Optional[str] = None
|
||||
|
||||
# gmail.links_and_external_images
|
||||
@@ -187,10 +217,16 @@ class GmailPolicies(BaseModel):
|
||||
enable_aggressive_warnings_on_untrusted_links: Optional[bool] = None
|
||||
|
||||
# gmail.spoofing_and_authentication
|
||||
detect_domain_name_spoofing: Optional[bool] = None
|
||||
domain_spoofing_consequence: Optional[str] = None
|
||||
detect_employee_name_spoofing: Optional[bool] = None
|
||||
employee_name_spoofing_consequence: Optional[str] = None
|
||||
detect_inbound_domain_spoofing: Optional[bool] = None
|
||||
inbound_domain_spoofing_consequence: Optional[str] = None
|
||||
detect_unauthenticated_emails: Optional[bool] = None
|
||||
unauthenticated_email_consequence: Optional[str] = None
|
||||
detect_groups_spoofing: Optional[bool] = None
|
||||
groups_spoofing_visibility_type: Optional[str] = None
|
||||
groups_spoofing_consequence: Optional[str] = None
|
||||
|
||||
# gmail.pop_access
|
||||
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"Provider": "googleworkspace",
|
||||
"CheckID": "gmail_unauthenticated_email_protection_enabled",
|
||||
"CheckTitle": "Protection against any unauthenticated emails is enabled",
|
||||
"CheckType": [],
|
||||
"ServiceName": "gmail",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "NotDefined",
|
||||
"ResourceGroup": "collaboration",
|
||||
"Description": "Verifies that Gmail is configured to take a protective action (such as moving to spam, quarantining, or showing a warning) when emails are not authenticated via SPF or DKIM. Unauthenticated emails cannot be verified as originating from the claimed sender, making them more likely to be spoofed or forged.",
|
||||
"Risk": "Without protection against unauthenticated emails, users may receive **spoofed or forged messages** that fail SPF and DKIM checks but are still delivered normally. This enables **phishing**, **spam**, and **impersonation attacks** that exploit the lack of sender verification.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://support.google.com/a/answer/9157861",
|
||||
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Sign in to the Google **Admin console** at https://admin.google.com\n2. Navigate to **Apps** > **Google Workspace** > **Gmail**\n3. Click **Safety** > **Spoofing and authentication**\n4. Check **Protect against any unauthenticated emails**\n5. Select the desired action (e.g., Move email to spam)\n6. Click **Save**",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Enable protection against any unauthenticated emails and configure an appropriate action such as moving to spam or quarantining.",
|
||||
"Url": "https://hub.prowler.com/check/gmail_unauthenticated_email_protection_enabled"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"email-security"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [
|
||||
"gmail_domain_spoofing_protection_enabled",
|
||||
"gmail_employee_name_spoofing_protection_enabled",
|
||||
"gmail_inbound_domain_spoofing_protection_enabled",
|
||||
"gmail_groups_spoofing_protection_enabled"
|
||||
],
|
||||
"Notes": ""
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
from typing import List
|
||||
|
||||
from prowler.lib.check.models import Check, CheckReportGoogleWorkspace
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_client import gmail_client
|
||||
|
||||
|
||||
class gmail_unauthenticated_email_protection_enabled(Check):
|
||||
"""Check that protection against any unauthenticated emails is enabled.
|
||||
|
||||
This check verifies that Gmail is configured to take action on
|
||||
emails that are not authenticated via SPF or DKIM, helping prevent
|
||||
delivery of spoofed or forged messages.
|
||||
"""
|
||||
|
||||
def execute(self) -> List[CheckReportGoogleWorkspace]:
|
||||
findings = []
|
||||
|
||||
if gmail_client.policies_fetched:
|
||||
report = CheckReportGoogleWorkspace(
|
||||
metadata=self.metadata(),
|
||||
resource=gmail_client.provider.domain_resource,
|
||||
)
|
||||
|
||||
enabled = gmail_client.policies.detect_unauthenticated_emails
|
||||
consequence = gmail_client.policies.unauthenticated_email_consequence
|
||||
|
||||
if enabled is False:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Protection against unauthenticated emails is disabled "
|
||||
f"in domain {gmail_client.provider.identity.domain}. "
|
||||
f"Enable the protection and configure a protective action."
|
||||
)
|
||||
elif enabled is None:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Protection against unauthenticated emails is not "
|
||||
f"configured and uses Google's insecure default "
|
||||
f"(disabled) in domain "
|
||||
f"{gmail_client.provider.identity.domain}. "
|
||||
f"Enable the protection and configure a protective action."
|
||||
)
|
||||
elif consequence == "NO_ACTION":
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Protection against unauthenticated emails is set to "
|
||||
f"take no action in domain "
|
||||
f"{gmail_client.provider.identity.domain}. "
|
||||
f"A protective action should be configured."
|
||||
)
|
||||
elif consequence is None:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Protection against unauthenticated emails is enabled "
|
||||
f"in domain {gmail_client.provider.identity.domain}."
|
||||
)
|
||||
else:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Protection against unauthenticated emails is enabled "
|
||||
f"with consequence '{consequence}' in domain "
|
||||
f"{gmail_client.provider.identity.domain}."
|
||||
)
|
||||
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
+6
-4
@@ -32,11 +32,13 @@ class gmail_untrusted_link_warnings_enabled(Check):
|
||||
f"in domain {gmail_client.provider.identity.domain}."
|
||||
)
|
||||
elif warnings_enabled is None:
|
||||
report.status = "PASS"
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Warning prompts for clicks on untrusted domain links uses Google's "
|
||||
f"secure default configuration (enabled) "
|
||||
f"in domain {gmail_client.provider.identity.domain}."
|
||||
f"Warning prompts for clicks on untrusted domain links "
|
||||
f"are not configured and use Google's insecure default "
|
||||
f"(disabled) in domain "
|
||||
f"{gmail_client.provider.identity.domain}. "
|
||||
f"Untrusted link warnings should be enabled to protect users."
|
||||
)
|
||||
else:
|
||||
report.status = "FAIL"
|
||||
|
||||
@@ -18,6 +18,10 @@ from prowler.config.config import (
|
||||
from prowler.lib.check.models import CheckReportIAC
|
||||
from prowler.lib.logger import logger
|
||||
from prowler.lib.utils.utils import print_boxes
|
||||
from prowler.lib.utils.vulnerability_references import (
|
||||
build_finding_reference_url,
|
||||
resolve_vulnerability_reference_urls,
|
||||
)
|
||||
from prowler.providers.common.models import Audit_Metadata, Connection
|
||||
from prowler.providers.common.provider import Provider
|
||||
|
||||
@@ -189,14 +193,28 @@ class IacProvider(Provider):
|
||||
finding_id = finding["VulnerabilityID"]
|
||||
finding_description = finding["Description"]
|
||||
finding_status = finding.get("Status", "FAIL")
|
||||
recommendation_url, additional_urls = (
|
||||
resolve_vulnerability_reference_urls(
|
||||
vulnerability_id=finding_id,
|
||||
references=finding.get("References"),
|
||||
primary_url=finding.get("PrimaryURL", ""),
|
||||
)
|
||||
)
|
||||
if not recommendation_url:
|
||||
recommendation_url = build_finding_reference_url(finding_id)
|
||||
additional_urls = [recommendation_url]
|
||||
elif "RuleID" in finding:
|
||||
finding_id = finding["RuleID"]
|
||||
finding_description = finding["Title"]
|
||||
finding_status = finding.get("Status", "FAIL")
|
||||
recommendation_url = build_finding_reference_url(finding_id)
|
||||
additional_urls = [recommendation_url]
|
||||
else:
|
||||
finding_id = finding["ID"]
|
||||
finding_description = finding["Description"]
|
||||
finding_status = finding["Status"]
|
||||
recommendation_url = build_finding_reference_url(finding_id)
|
||||
additional_urls = [recommendation_url]
|
||||
|
||||
metadata_dict = {
|
||||
"Provider": "iac",
|
||||
@@ -210,7 +228,7 @@ class IacProvider(Provider):
|
||||
"ResourceType": "iac",
|
||||
"Description": finding_description,
|
||||
"Risk": "This provider has not defined a risk for this check.",
|
||||
"RelatedUrl": finding.get("PrimaryURL", ""),
|
||||
"RelatedUrl": "",
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"NativeIaC": "",
|
||||
@@ -220,11 +238,11 @@ class IacProvider(Provider):
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": finding.get("Resolution", ""),
|
||||
"Url": finding.get("PrimaryURL", ""),
|
||||
"Url": recommendation_url,
|
||||
},
|
||||
},
|
||||
"Categories": [],
|
||||
"AdditionalURLs": [],
|
||||
"AdditionalURLs": additional_urls,
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": "",
|
||||
|
||||
@@ -18,6 +18,9 @@ from prowler.config.config import (
|
||||
from prowler.lib.check.models import CheckReportImage
|
||||
from prowler.lib.logger import logger
|
||||
from prowler.lib.utils.utils import print_boxes
|
||||
from prowler.lib.utils.vulnerability_references import (
|
||||
resolve_vulnerability_reference_urls,
|
||||
)
|
||||
from prowler.providers.common.models import Audit_Metadata, Connection
|
||||
from prowler.providers.common.provider import Provider
|
||||
from prowler.providers.image.exceptions.exceptions import (
|
||||
@@ -395,6 +398,8 @@ class ImageProvider(Provider):
|
||||
"""
|
||||
try:
|
||||
# Determine finding ID and category based on type
|
||||
recommendation_url = ""
|
||||
additional_urls: list[str] = []
|
||||
if "VulnerabilityID" in finding:
|
||||
finding_id = finding["VulnerabilityID"]
|
||||
finding_description = finding.get(
|
||||
@@ -402,17 +407,30 @@ class ImageProvider(Provider):
|
||||
)
|
||||
finding_status = "FAIL"
|
||||
finding_categories = ["vulnerabilities"]
|
||||
recommendation_url, additional_urls = (
|
||||
resolve_vulnerability_reference_urls(
|
||||
vulnerability_id=finding_id,
|
||||
references=finding.get("References"),
|
||||
primary_url=finding.get("PrimaryURL", ""),
|
||||
)
|
||||
)
|
||||
elif "RuleID" in finding:
|
||||
# Secret finding
|
||||
finding_id = finding["RuleID"]
|
||||
finding_description = finding.get("Title", "Secret detected")
|
||||
finding_status = "FAIL"
|
||||
finding_categories = ["secrets"]
|
||||
additional_urls = (
|
||||
[url] if (url := finding.get("PrimaryURL", "")) else []
|
||||
)
|
||||
else:
|
||||
finding_id = finding.get("ID", "UNKNOWN")
|
||||
finding_description = finding.get("Description", "")
|
||||
finding_status = finding.get("Status", "FAIL")
|
||||
finding_categories = []
|
||||
additional_urls = (
|
||||
[url] if (url := finding.get("PrimaryURL", "")) else []
|
||||
)
|
||||
|
||||
# Build remediation text for vulnerabilities
|
||||
remediation_text = ""
|
||||
@@ -451,13 +469,11 @@ class ImageProvider(Provider):
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": remediation_text,
|
||||
"Url": "",
|
||||
"Url": recommendation_url,
|
||||
},
|
||||
},
|
||||
"Categories": finding_categories,
|
||||
"AdditionalURLs": (
|
||||
[url] if (url := finding.get("PrimaryURL", "")) else []
|
||||
),
|
||||
"AdditionalURLs": additional_urls,
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": "",
|
||||
|
||||
@@ -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"]
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_service import GmailPolicies
|
||||
from tests.providers.googleworkspace.googleworkspace_fixtures import (
|
||||
CUSTOMER_ID,
|
||||
DOMAIN,
|
||||
set_mocked_googleworkspace_provider,
|
||||
)
|
||||
|
||||
|
||||
class TestGmailAnomalousAttachmentProtectionEnabled:
|
||||
def test_pass(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_anomalous_attachment_protection_enabled.gmail_anomalous_attachment_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_anomalous_attachment_protection_enabled.gmail_anomalous_attachment_protection_enabled import (
|
||||
gmail_anomalous_attachment_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = True
|
||||
mock_client.policies = GmailPolicies(
|
||||
enable_anomalous_attachment_protection=True,
|
||||
anomalous_attachment_protection_consequence="WARNING",
|
||||
)
|
||||
|
||||
check = gmail_anomalous_attachment_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "PASS"
|
||||
assert "WARNING" in findings[0].status_extended
|
||||
assert findings[0].resource_name == DOMAIN
|
||||
assert findings[0].customer_id == CUSTOMER_ID
|
||||
|
||||
def test_fail_no_action(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_anomalous_attachment_protection_enabled.gmail_anomalous_attachment_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_anomalous_attachment_protection_enabled.gmail_anomalous_attachment_protection_enabled import (
|
||||
gmail_anomalous_attachment_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = True
|
||||
mock_client.policies = GmailPolicies(
|
||||
enable_anomalous_attachment_protection=True,
|
||||
anomalous_attachment_protection_consequence="NO_ACTION",
|
||||
)
|
||||
|
||||
check = gmail_anomalous_attachment_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "FAIL"
|
||||
assert "no action" in findings[0].status_extended
|
||||
|
||||
def test_fail_protection_disabled(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_anomalous_attachment_protection_enabled.gmail_anomalous_attachment_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_anomalous_attachment_protection_enabled.gmail_anomalous_attachment_protection_enabled import (
|
||||
gmail_anomalous_attachment_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = True
|
||||
mock_client.policies = GmailPolicies(
|
||||
enable_anomalous_attachment_protection=False,
|
||||
anomalous_attachment_protection_consequence="WARNING",
|
||||
)
|
||||
|
||||
check = gmail_anomalous_attachment_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "FAIL"
|
||||
assert "disabled" in findings[0].status_extended
|
||||
|
||||
def test_fail_no_policy_set(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_anomalous_attachment_protection_enabled.gmail_anomalous_attachment_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_anomalous_attachment_protection_enabled.gmail_anomalous_attachment_protection_enabled import (
|
||||
gmail_anomalous_attachment_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = True
|
||||
mock_client.policies = GmailPolicies()
|
||||
|
||||
check = gmail_anomalous_attachment_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "FAIL"
|
||||
assert "insecure default" in findings[0].status_extended
|
||||
|
||||
def test_no_findings_when_fetch_failed(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_anomalous_attachment_protection_enabled.gmail_anomalous_attachment_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_anomalous_attachment_protection_enabled.gmail_anomalous_attachment_protection_enabled import (
|
||||
gmail_anomalous_attachment_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = False
|
||||
mock_client.policies = GmailPolicies()
|
||||
|
||||
check = gmail_anomalous_attachment_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 0
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_service import GmailPolicies
|
||||
from tests.providers.googleworkspace.googleworkspace_fixtures import (
|
||||
CUSTOMER_ID,
|
||||
DOMAIN,
|
||||
set_mocked_googleworkspace_provider,
|
||||
)
|
||||
|
||||
|
||||
class TestGmailDomainSpoofingProtectionEnabled:
|
||||
def test_pass(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_domain_spoofing_protection_enabled.gmail_domain_spoofing_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_domain_spoofing_protection_enabled.gmail_domain_spoofing_protection_enabled import (
|
||||
gmail_domain_spoofing_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = True
|
||||
mock_client.policies = GmailPolicies(
|
||||
detect_domain_name_spoofing=True,
|
||||
domain_spoofing_consequence="SPAM_FOLDER",
|
||||
)
|
||||
|
||||
check = gmail_domain_spoofing_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "PASS"
|
||||
assert "SPAM_FOLDER" in findings[0].status_extended
|
||||
assert findings[0].resource_name == DOMAIN
|
||||
assert findings[0].customer_id == CUSTOMER_ID
|
||||
|
||||
def test_fail_no_action(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_domain_spoofing_protection_enabled.gmail_domain_spoofing_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_domain_spoofing_protection_enabled.gmail_domain_spoofing_protection_enabled import (
|
||||
gmail_domain_spoofing_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = True
|
||||
mock_client.policies = GmailPolicies(
|
||||
detect_domain_name_spoofing=True,
|
||||
domain_spoofing_consequence="NO_ACTION",
|
||||
)
|
||||
|
||||
check = gmail_domain_spoofing_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "FAIL"
|
||||
assert "no action" in findings[0].status_extended
|
||||
|
||||
def test_fail_protection_disabled(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_domain_spoofing_protection_enabled.gmail_domain_spoofing_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_domain_spoofing_protection_enabled.gmail_domain_spoofing_protection_enabled import (
|
||||
gmail_domain_spoofing_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = True
|
||||
mock_client.policies = GmailPolicies(
|
||||
detect_domain_name_spoofing=False,
|
||||
domain_spoofing_consequence="WARNING",
|
||||
)
|
||||
|
||||
check = gmail_domain_spoofing_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "FAIL"
|
||||
assert "disabled" in findings[0].status_extended
|
||||
|
||||
def test_pass_using_default(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_domain_spoofing_protection_enabled.gmail_domain_spoofing_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_domain_spoofing_protection_enabled.gmail_domain_spoofing_protection_enabled import (
|
||||
gmail_domain_spoofing_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = True
|
||||
mock_client.policies = GmailPolicies()
|
||||
|
||||
check = gmail_domain_spoofing_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "PASS"
|
||||
assert "secure default" in findings[0].status_extended
|
||||
|
||||
def test_no_findings_when_fetch_failed(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_domain_spoofing_protection_enabled.gmail_domain_spoofing_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_domain_spoofing_protection_enabled.gmail_domain_spoofing_protection_enabled import (
|
||||
gmail_domain_spoofing_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = False
|
||||
mock_client.policies = GmailPolicies()
|
||||
|
||||
check = gmail_domain_spoofing_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 0
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_service import GmailPolicies
|
||||
from tests.providers.googleworkspace.googleworkspace_fixtures import (
|
||||
CUSTOMER_ID,
|
||||
DOMAIN,
|
||||
set_mocked_googleworkspace_provider,
|
||||
)
|
||||
|
||||
|
||||
class TestGmailEmployeeNameSpoofingProtectionEnabled:
|
||||
def test_pass(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_employee_name_spoofing_protection_enabled.gmail_employee_name_spoofing_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_employee_name_spoofing_protection_enabled.gmail_employee_name_spoofing_protection_enabled import (
|
||||
gmail_employee_name_spoofing_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = True
|
||||
mock_client.policies = GmailPolicies(
|
||||
detect_employee_name_spoofing=True,
|
||||
employee_name_spoofing_consequence="SPAM_FOLDER",
|
||||
)
|
||||
|
||||
check = gmail_employee_name_spoofing_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "PASS"
|
||||
assert "SPAM_FOLDER" in findings[0].status_extended
|
||||
assert findings[0].resource_name == DOMAIN
|
||||
assert findings[0].customer_id == CUSTOMER_ID
|
||||
|
||||
def test_fail_no_action(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_employee_name_spoofing_protection_enabled.gmail_employee_name_spoofing_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_employee_name_spoofing_protection_enabled.gmail_employee_name_spoofing_protection_enabled import (
|
||||
gmail_employee_name_spoofing_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = True
|
||||
mock_client.policies = GmailPolicies(
|
||||
detect_employee_name_spoofing=True,
|
||||
employee_name_spoofing_consequence="NO_ACTION",
|
||||
)
|
||||
|
||||
check = gmail_employee_name_spoofing_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "FAIL"
|
||||
assert "no action" in findings[0].status_extended
|
||||
|
||||
def test_fail_protection_disabled(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_employee_name_spoofing_protection_enabled.gmail_employee_name_spoofing_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_employee_name_spoofing_protection_enabled.gmail_employee_name_spoofing_protection_enabled import (
|
||||
gmail_employee_name_spoofing_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = True
|
||||
mock_client.policies = GmailPolicies(
|
||||
detect_employee_name_spoofing=False,
|
||||
employee_name_spoofing_consequence="WARNING",
|
||||
)
|
||||
|
||||
check = gmail_employee_name_spoofing_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "FAIL"
|
||||
assert "disabled" in findings[0].status_extended
|
||||
|
||||
def test_pass_using_default(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_employee_name_spoofing_protection_enabled.gmail_employee_name_spoofing_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_employee_name_spoofing_protection_enabled.gmail_employee_name_spoofing_protection_enabled import (
|
||||
gmail_employee_name_spoofing_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = True
|
||||
mock_client.policies = GmailPolicies()
|
||||
|
||||
check = gmail_employee_name_spoofing_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "PASS"
|
||||
assert "secure default" in findings[0].status_extended
|
||||
|
||||
def test_no_findings_when_fetch_failed(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_employee_name_spoofing_protection_enabled.gmail_employee_name_spoofing_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_employee_name_spoofing_protection_enabled.gmail_employee_name_spoofing_protection_enabled import (
|
||||
gmail_employee_name_spoofing_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = False
|
||||
mock_client.policies = GmailPolicies()
|
||||
|
||||
check = gmail_employee_name_spoofing_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 0
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_service import GmailPolicies
|
||||
from tests.providers.googleworkspace.googleworkspace_fixtures import (
|
||||
CUSTOMER_ID,
|
||||
DOMAIN,
|
||||
set_mocked_googleworkspace_provider,
|
||||
)
|
||||
|
||||
|
||||
class TestGmailEncryptedAttachmentProtectionEnabled:
|
||||
def test_pass(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_encrypted_attachment_protection_enabled.gmail_encrypted_attachment_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_encrypted_attachment_protection_enabled.gmail_encrypted_attachment_protection_enabled import (
|
||||
gmail_encrypted_attachment_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = True
|
||||
mock_client.policies = GmailPolicies(
|
||||
enable_encrypted_attachment_protection=True,
|
||||
encrypted_attachment_protection_consequence="QUARANTINE",
|
||||
)
|
||||
|
||||
check = gmail_encrypted_attachment_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "PASS"
|
||||
assert "QUARANTINE" in findings[0].status_extended
|
||||
assert findings[0].resource_name == DOMAIN
|
||||
assert findings[0].customer_id == CUSTOMER_ID
|
||||
|
||||
def test_fail_no_action(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_encrypted_attachment_protection_enabled.gmail_encrypted_attachment_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_encrypted_attachment_protection_enabled.gmail_encrypted_attachment_protection_enabled import (
|
||||
gmail_encrypted_attachment_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = True
|
||||
mock_client.policies = GmailPolicies(
|
||||
enable_encrypted_attachment_protection=True,
|
||||
encrypted_attachment_protection_consequence="NO_ACTION",
|
||||
)
|
||||
|
||||
check = gmail_encrypted_attachment_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "FAIL"
|
||||
assert "no action" in findings[0].status_extended
|
||||
|
||||
def test_fail_protection_disabled(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_encrypted_attachment_protection_enabled.gmail_encrypted_attachment_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_encrypted_attachment_protection_enabled.gmail_encrypted_attachment_protection_enabled import (
|
||||
gmail_encrypted_attachment_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = True
|
||||
mock_client.policies = GmailPolicies(
|
||||
enable_encrypted_attachment_protection=False,
|
||||
encrypted_attachment_protection_consequence="WARNING",
|
||||
)
|
||||
|
||||
check = gmail_encrypted_attachment_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "FAIL"
|
||||
assert "disabled" in findings[0].status_extended
|
||||
|
||||
def test_pass_using_default(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_encrypted_attachment_protection_enabled.gmail_encrypted_attachment_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_encrypted_attachment_protection_enabled.gmail_encrypted_attachment_protection_enabled import (
|
||||
gmail_encrypted_attachment_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = True
|
||||
mock_client.policies = GmailPolicies()
|
||||
|
||||
check = gmail_encrypted_attachment_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "PASS"
|
||||
assert "secure default" in findings[0].status_extended
|
||||
|
||||
def test_no_findings_when_fetch_failed(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_encrypted_attachment_protection_enabled.gmail_encrypted_attachment_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_encrypted_attachment_protection_enabled.gmail_encrypted_attachment_protection_enabled import (
|
||||
gmail_encrypted_attachment_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = False
|
||||
mock_client.policies = GmailPolicies()
|
||||
|
||||
check = gmail_encrypted_attachment_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 0
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_service import GmailPolicies
|
||||
from tests.providers.googleworkspace.googleworkspace_fixtures import (
|
||||
CUSTOMER_ID,
|
||||
DOMAIN,
|
||||
set_mocked_googleworkspace_provider,
|
||||
)
|
||||
|
||||
|
||||
class TestGmailGroupsSpoofingProtectionEnabled:
|
||||
def test_pass(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_groups_spoofing_protection_enabled.gmail_groups_spoofing_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_groups_spoofing_protection_enabled.gmail_groups_spoofing_protection_enabled import (
|
||||
gmail_groups_spoofing_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = True
|
||||
mock_client.policies = GmailPolicies(
|
||||
detect_groups_spoofing=True,
|
||||
groups_spoofing_consequence="SPAM_FOLDER",
|
||||
)
|
||||
|
||||
check = gmail_groups_spoofing_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "PASS"
|
||||
assert "all groups" in findings[0].status_extended
|
||||
assert "SPAM_FOLDER" in findings[0].status_extended
|
||||
assert findings[0].resource_name == DOMAIN
|
||||
assert findings[0].customer_id == CUSTOMER_ID
|
||||
|
||||
def test_pass_private_groups_only(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_groups_spoofing_protection_enabled.gmail_groups_spoofing_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_groups_spoofing_protection_enabled.gmail_groups_spoofing_protection_enabled import (
|
||||
gmail_groups_spoofing_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = True
|
||||
mock_client.policies = GmailPolicies(
|
||||
detect_groups_spoofing=True,
|
||||
groups_spoofing_visibility_type="PRIVATE_GROUPS_ONLY",
|
||||
groups_spoofing_consequence="SPAM_FOLDER",
|
||||
)
|
||||
|
||||
check = gmail_groups_spoofing_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "PASS"
|
||||
assert "private groups only" in findings[0].status_extended
|
||||
assert "SPAM_FOLDER" in findings[0].status_extended
|
||||
|
||||
def test_fail_no_action(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_groups_spoofing_protection_enabled.gmail_groups_spoofing_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_groups_spoofing_protection_enabled.gmail_groups_spoofing_protection_enabled import (
|
||||
gmail_groups_spoofing_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = True
|
||||
mock_client.policies = GmailPolicies(
|
||||
detect_groups_spoofing=True,
|
||||
groups_spoofing_consequence="NO_ACTION",
|
||||
)
|
||||
|
||||
check = gmail_groups_spoofing_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "FAIL"
|
||||
assert "no action" in findings[0].status_extended
|
||||
|
||||
def test_fail_protection_disabled(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_groups_spoofing_protection_enabled.gmail_groups_spoofing_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_groups_spoofing_protection_enabled.gmail_groups_spoofing_protection_enabled import (
|
||||
gmail_groups_spoofing_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = True
|
||||
mock_client.policies = GmailPolicies(
|
||||
detect_groups_spoofing=False,
|
||||
groups_spoofing_consequence="WARNING",
|
||||
)
|
||||
|
||||
check = gmail_groups_spoofing_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "FAIL"
|
||||
assert "disabled" in findings[0].status_extended
|
||||
|
||||
def test_fail_no_policy_set(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_groups_spoofing_protection_enabled.gmail_groups_spoofing_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_groups_spoofing_protection_enabled.gmail_groups_spoofing_protection_enabled import (
|
||||
gmail_groups_spoofing_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = True
|
||||
mock_client.policies = GmailPolicies()
|
||||
|
||||
check = gmail_groups_spoofing_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "FAIL"
|
||||
assert "insecure default" in findings[0].status_extended
|
||||
|
||||
def test_no_findings_when_fetch_failed(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_groups_spoofing_protection_enabled.gmail_groups_spoofing_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_groups_spoofing_protection_enabled.gmail_groups_spoofing_protection_enabled import (
|
||||
gmail_groups_spoofing_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = False
|
||||
mock_client.policies = GmailPolicies()
|
||||
|
||||
check = gmail_groups_spoofing_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 0
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_service import GmailPolicies
|
||||
from tests.providers.googleworkspace.googleworkspace_fixtures import (
|
||||
CUSTOMER_ID,
|
||||
DOMAIN,
|
||||
set_mocked_googleworkspace_provider,
|
||||
)
|
||||
|
||||
|
||||
class TestGmailInboundDomainSpoofingProtectionEnabled:
|
||||
def test_pass(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_inbound_domain_spoofing_protection_enabled.gmail_inbound_domain_spoofing_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_inbound_domain_spoofing_protection_enabled.gmail_inbound_domain_spoofing_protection_enabled import (
|
||||
gmail_inbound_domain_spoofing_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = True
|
||||
mock_client.policies = GmailPolicies(
|
||||
detect_inbound_domain_spoofing=True,
|
||||
inbound_domain_spoofing_consequence="QUARANTINE",
|
||||
)
|
||||
|
||||
check = gmail_inbound_domain_spoofing_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "PASS"
|
||||
assert "QUARANTINE" in findings[0].status_extended
|
||||
assert findings[0].resource_name == DOMAIN
|
||||
assert findings[0].customer_id == CUSTOMER_ID
|
||||
|
||||
def test_fail_no_action(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_inbound_domain_spoofing_protection_enabled.gmail_inbound_domain_spoofing_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_inbound_domain_spoofing_protection_enabled.gmail_inbound_domain_spoofing_protection_enabled import (
|
||||
gmail_inbound_domain_spoofing_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = True
|
||||
mock_client.policies = GmailPolicies(
|
||||
detect_inbound_domain_spoofing=True,
|
||||
inbound_domain_spoofing_consequence="NO_ACTION",
|
||||
)
|
||||
|
||||
check = gmail_inbound_domain_spoofing_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "FAIL"
|
||||
assert "no action" in findings[0].status_extended
|
||||
|
||||
def test_fail_protection_disabled(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_inbound_domain_spoofing_protection_enabled.gmail_inbound_domain_spoofing_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_inbound_domain_spoofing_protection_enabled.gmail_inbound_domain_spoofing_protection_enabled import (
|
||||
gmail_inbound_domain_spoofing_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = True
|
||||
mock_client.policies = GmailPolicies(
|
||||
detect_inbound_domain_spoofing=False,
|
||||
inbound_domain_spoofing_consequence="WARNING",
|
||||
)
|
||||
|
||||
check = gmail_inbound_domain_spoofing_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "FAIL"
|
||||
assert "disabled" in findings[0].status_extended
|
||||
|
||||
def test_pass_using_default(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_inbound_domain_spoofing_protection_enabled.gmail_inbound_domain_spoofing_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_inbound_domain_spoofing_protection_enabled.gmail_inbound_domain_spoofing_protection_enabled import (
|
||||
gmail_inbound_domain_spoofing_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = True
|
||||
mock_client.policies = GmailPolicies()
|
||||
|
||||
check = gmail_inbound_domain_spoofing_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "PASS"
|
||||
assert "secure default" in findings[0].status_extended
|
||||
|
||||
def test_no_findings_when_fetch_failed(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_inbound_domain_spoofing_protection_enabled.gmail_inbound_domain_spoofing_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_inbound_domain_spoofing_protection_enabled.gmail_inbound_domain_spoofing_protection_enabled import (
|
||||
gmail_inbound_domain_spoofing_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = False
|
||||
mock_client.policies = GmailPolicies()
|
||||
|
||||
check = gmail_inbound_domain_spoofing_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 0
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_service import GmailPolicies
|
||||
from tests.providers.googleworkspace.googleworkspace_fixtures import (
|
||||
CUSTOMER_ID,
|
||||
DOMAIN,
|
||||
set_mocked_googleworkspace_provider,
|
||||
)
|
||||
|
||||
|
||||
class TestGmailScriptAttachmentProtectionEnabled:
|
||||
def test_pass(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_script_attachment_protection_enabled.gmail_script_attachment_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_script_attachment_protection_enabled.gmail_script_attachment_protection_enabled import (
|
||||
gmail_script_attachment_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = True
|
||||
mock_client.policies = GmailPolicies(
|
||||
enable_script_attachment_protection=True,
|
||||
script_attachment_protection_consequence="QUARANTINE",
|
||||
)
|
||||
|
||||
check = gmail_script_attachment_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "PASS"
|
||||
assert "QUARANTINE" in findings[0].status_extended
|
||||
assert findings[0].resource_name == DOMAIN
|
||||
assert findings[0].customer_id == CUSTOMER_ID
|
||||
|
||||
def test_fail_no_action(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_script_attachment_protection_enabled.gmail_script_attachment_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_script_attachment_protection_enabled.gmail_script_attachment_protection_enabled import (
|
||||
gmail_script_attachment_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = True
|
||||
mock_client.policies = GmailPolicies(
|
||||
enable_script_attachment_protection=True,
|
||||
script_attachment_protection_consequence="NO_ACTION",
|
||||
)
|
||||
|
||||
check = gmail_script_attachment_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "FAIL"
|
||||
assert "no action" in findings[0].status_extended
|
||||
|
||||
def test_fail_protection_disabled(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_script_attachment_protection_enabled.gmail_script_attachment_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_script_attachment_protection_enabled.gmail_script_attachment_protection_enabled import (
|
||||
gmail_script_attachment_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = True
|
||||
mock_client.policies = GmailPolicies(
|
||||
enable_script_attachment_protection=False,
|
||||
script_attachment_protection_consequence="WARNING",
|
||||
)
|
||||
|
||||
check = gmail_script_attachment_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "FAIL"
|
||||
assert "disabled" in findings[0].status_extended
|
||||
|
||||
def test_pass_using_default(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_script_attachment_protection_enabled.gmail_script_attachment_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_script_attachment_protection_enabled.gmail_script_attachment_protection_enabled import (
|
||||
gmail_script_attachment_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = True
|
||||
mock_client.policies = GmailPolicies()
|
||||
|
||||
check = gmail_script_attachment_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "PASS"
|
||||
assert "secure default" in findings[0].status_extended
|
||||
|
||||
def test_no_findings_when_fetch_failed(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_script_attachment_protection_enabled.gmail_script_attachment_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_script_attachment_protection_enabled.gmail_script_attachment_protection_enabled import (
|
||||
gmail_script_attachment_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = False
|
||||
mock_client.policies = GmailPolicies()
|
||||
|
||||
check = gmail_script_attachment_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 0
|
||||
@@ -34,8 +34,11 @@ class TestGmailService:
|
||||
"setting": {
|
||||
"type": "settings/gmail.email_attachment_safety",
|
||||
"value": {
|
||||
"enableEncryptedAttachmentProtection": True,
|
||||
"encryptedAttachmentProtectionConsequence": "SPAM_FOLDER",
|
||||
"enableAttachmentWithScriptsProtection": True,
|
||||
"scriptAttachmentProtectionConsequence": "QUARANTINE",
|
||||
"enableAnomalousAttachmentProtection": True,
|
||||
"anomalousAttachmentProtectionConsequence": "WARNING",
|
||||
},
|
||||
}
|
||||
@@ -54,10 +57,15 @@ class TestGmailService:
|
||||
"setting": {
|
||||
"type": "settings/gmail.spoofing_and_authentication",
|
||||
"value": {
|
||||
"detectDomainNameSpoofing": True,
|
||||
"domainSpoofingConsequence": "SPAM_FOLDER",
|
||||
"detectEmployeeNameSpoofing": True,
|
||||
"employeeNameSpoofingConsequence": "SPAM_FOLDER",
|
||||
"detectDomainSpoofingFromUnauthenticatedSenders": True,
|
||||
"inboundDomainSpoofingConsequence": "QUARANTINE",
|
||||
"detectUnauthenticatedEmails": True,
|
||||
"unauthenticatedEmailConsequence": "WARNING",
|
||||
"detectGroupsSpoofing": True,
|
||||
"groupsSpoofingConsequence": "SPAM_FOLDER",
|
||||
},
|
||||
}
|
||||
@@ -121,23 +129,31 @@ class TestGmailService:
|
||||
|
||||
assert gmail.policies_fetched is True
|
||||
assert gmail.policies.enable_mail_delegation is False
|
||||
assert gmail.policies.enable_encrypted_attachment_protection is True
|
||||
assert (
|
||||
gmail.policies.encrypted_attachment_protection_consequence
|
||||
== "SPAM_FOLDER"
|
||||
)
|
||||
assert gmail.policies.enable_script_attachment_protection is True
|
||||
assert (
|
||||
gmail.policies.script_attachment_protection_consequence == "QUARANTINE"
|
||||
)
|
||||
assert gmail.policies.enable_anomalous_attachment_protection is True
|
||||
assert (
|
||||
gmail.policies.anomalous_attachment_protection_consequence == "WARNING"
|
||||
)
|
||||
assert gmail.policies.enable_shortener_scanning is True
|
||||
assert gmail.policies.enable_external_image_scanning is True
|
||||
assert gmail.policies.enable_aggressive_warnings_on_untrusted_links is True
|
||||
assert gmail.policies.detect_domain_name_spoofing is True
|
||||
assert gmail.policies.domain_spoofing_consequence == "SPAM_FOLDER"
|
||||
assert gmail.policies.detect_employee_name_spoofing is True
|
||||
assert gmail.policies.employee_name_spoofing_consequence == "SPAM_FOLDER"
|
||||
assert gmail.policies.detect_inbound_domain_spoofing is True
|
||||
assert gmail.policies.inbound_domain_spoofing_consequence == "QUARANTINE"
|
||||
assert gmail.policies.detect_unauthenticated_emails is True
|
||||
assert gmail.policies.unauthenticated_email_consequence == "WARNING"
|
||||
assert gmail.policies.detect_groups_spoofing is True
|
||||
assert gmail.policies.groups_spoofing_consequence == "SPAM_FOLDER"
|
||||
assert gmail.policies.enable_pop_access is False
|
||||
assert gmail.policies.enable_imap_access is False
|
||||
@@ -464,16 +480,24 @@ class TestGmailService:
|
||||
|
||||
policies = GmailPolicies(
|
||||
enable_mail_delegation=False,
|
||||
enable_encrypted_attachment_protection=True,
|
||||
encrypted_attachment_protection_consequence="SPAM_FOLDER",
|
||||
enable_script_attachment_protection=True,
|
||||
script_attachment_protection_consequence="QUARANTINE",
|
||||
enable_anomalous_attachment_protection=True,
|
||||
anomalous_attachment_protection_consequence="WARNING",
|
||||
enable_shortener_scanning=True,
|
||||
enable_external_image_scanning=True,
|
||||
enable_aggressive_warnings_on_untrusted_links=True,
|
||||
detect_domain_name_spoofing=True,
|
||||
domain_spoofing_consequence="SPAM_FOLDER",
|
||||
detect_employee_name_spoofing=True,
|
||||
employee_name_spoofing_consequence="SPAM_FOLDER",
|
||||
detect_inbound_domain_spoofing=True,
|
||||
inbound_domain_spoofing_consequence="QUARANTINE",
|
||||
detect_unauthenticated_emails=True,
|
||||
unauthenticated_email_consequence="WARNING",
|
||||
detect_groups_spoofing=True,
|
||||
groups_spoofing_consequence="SPAM_FOLDER",
|
||||
enable_pop_access=False,
|
||||
enable_imap_access=False,
|
||||
@@ -484,8 +508,10 @@ class TestGmailService:
|
||||
)
|
||||
|
||||
assert policies.enable_mail_delegation is False
|
||||
assert policies.enable_encrypted_attachment_protection is True
|
||||
assert policies.encrypted_attachment_protection_consequence == "SPAM_FOLDER"
|
||||
assert policies.enable_shortener_scanning is True
|
||||
assert policies.detect_domain_name_spoofing is True
|
||||
assert policies.domain_spoofing_consequence == "SPAM_FOLDER"
|
||||
assert policies.enable_pop_access is False
|
||||
assert policies.enable_auto_forwarding is False
|
||||
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_service import GmailPolicies
|
||||
from tests.providers.googleworkspace.googleworkspace_fixtures import (
|
||||
CUSTOMER_ID,
|
||||
DOMAIN,
|
||||
set_mocked_googleworkspace_provider,
|
||||
)
|
||||
|
||||
|
||||
class TestGmailUnauthenticatedEmailProtectionEnabled:
|
||||
def test_pass(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_unauthenticated_email_protection_enabled.gmail_unauthenticated_email_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_unauthenticated_email_protection_enabled.gmail_unauthenticated_email_protection_enabled import (
|
||||
gmail_unauthenticated_email_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = True
|
||||
mock_client.policies = GmailPolicies(
|
||||
detect_unauthenticated_emails=True,
|
||||
unauthenticated_email_consequence="WARNING",
|
||||
)
|
||||
|
||||
check = gmail_unauthenticated_email_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "PASS"
|
||||
assert "WARNING" in findings[0].status_extended
|
||||
assert findings[0].resource_name == DOMAIN
|
||||
assert findings[0].customer_id == CUSTOMER_ID
|
||||
|
||||
def test_fail_no_action(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_unauthenticated_email_protection_enabled.gmail_unauthenticated_email_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_unauthenticated_email_protection_enabled.gmail_unauthenticated_email_protection_enabled import (
|
||||
gmail_unauthenticated_email_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = True
|
||||
mock_client.policies = GmailPolicies(
|
||||
detect_unauthenticated_emails=True,
|
||||
unauthenticated_email_consequence="NO_ACTION",
|
||||
)
|
||||
|
||||
check = gmail_unauthenticated_email_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "FAIL"
|
||||
assert "no action" in findings[0].status_extended
|
||||
|
||||
def test_fail_protection_disabled(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_unauthenticated_email_protection_enabled.gmail_unauthenticated_email_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_unauthenticated_email_protection_enabled.gmail_unauthenticated_email_protection_enabled import (
|
||||
gmail_unauthenticated_email_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = True
|
||||
mock_client.policies = GmailPolicies(
|
||||
detect_unauthenticated_emails=False,
|
||||
unauthenticated_email_consequence="WARNING",
|
||||
)
|
||||
|
||||
check = gmail_unauthenticated_email_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "FAIL"
|
||||
assert "disabled" in findings[0].status_extended
|
||||
|
||||
def test_fail_no_policy_set(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_unauthenticated_email_protection_enabled.gmail_unauthenticated_email_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_unauthenticated_email_protection_enabled.gmail_unauthenticated_email_protection_enabled import (
|
||||
gmail_unauthenticated_email_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = True
|
||||
mock_client.policies = GmailPolicies()
|
||||
|
||||
check = gmail_unauthenticated_email_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "FAIL"
|
||||
assert "insecure default" in findings[0].status_extended
|
||||
|
||||
def test_no_findings_when_fetch_failed(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.gmail.gmail_unauthenticated_email_protection_enabled.gmail_unauthenticated_email_protection_enabled.gmail_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.gmail.gmail_unauthenticated_email_protection_enabled.gmail_unauthenticated_email_protection_enabled import (
|
||||
gmail_unauthenticated_email_protection_enabled,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = False
|
||||
mock_client.policies = GmailPolicies()
|
||||
|
||||
check = gmail_unauthenticated_email_protection_enabled()
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 0
|
||||
+3
-3
@@ -69,7 +69,7 @@ class TestGmailUntrustedLinkWarningsEnabled:
|
||||
assert findings[0].status == "FAIL"
|
||||
assert "disabled" in findings[0].status_extended
|
||||
|
||||
def test_pass_using_default(self):
|
||||
def test_fail_insecure_default(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
with (
|
||||
@@ -95,8 +95,8 @@ class TestGmailUntrustedLinkWarningsEnabled:
|
||||
findings = check.execute()
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "PASS"
|
||||
assert "secure default" in findings[0].status_extended
|
||||
assert findings[0].status == "FAIL"
|
||||
assert "insecure default" in findings[0].status_extended
|
||||
|
||||
def test_no_findings_when_fetch_failed(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
@@ -259,7 +259,13 @@ SAMPLE_TRIVY_VULNERABILITY_OUTPUT = {
|
||||
"Title": "Example vulnerability",
|
||||
"Description": "This is an example vulnerability",
|
||||
"Severity": "high",
|
||||
"PrimaryURL": "https://example.com/cve-2023-1234",
|
||||
"PrimaryURL": "https://avd.aquasec.com/nvd/cve-2023-1234",
|
||||
"References": [
|
||||
"https://avd.aquasec.com/nvd/cve-2023-1234",
|
||||
"https://nvd.nist.gov/vuln/detail/CVE-2023-1234",
|
||||
"https://www.cve.org/CVERecord?id=CVE-2023-1234",
|
||||
"https://security.example.com/advisories/CVE-2023-1234",
|
||||
],
|
||||
}
|
||||
],
|
||||
"Secrets": [],
|
||||
@@ -268,6 +274,39 @@ SAMPLE_TRIVY_VULNERABILITY_OUTPUT = {
|
||||
]
|
||||
}
|
||||
|
||||
SAMPLE_TRIVY_VULNERABILITY_WITHOUT_CVE_ORG_REFERENCE = {
|
||||
"VulnerabilityID": "CVE-2023-5678",
|
||||
"Title": "Vulnerability without cve.org reference",
|
||||
"Description": "This vulnerability includes references but no cve.org reference",
|
||||
"Severity": "high",
|
||||
"PrimaryURL": "https://avd.aquasec.com/nvd/cve-2023-5678",
|
||||
"References": [
|
||||
"https://avd.aquasec.com/nvd/cve-2023-5678",
|
||||
"https://nvd.nist.gov/vuln/detail/CVE-2023-5678",
|
||||
"https://security.example.com/advisories/CVE-2023-5678",
|
||||
],
|
||||
}
|
||||
|
||||
SAMPLE_TRIVY_VULNERABILITY_WITHOUT_REFERENCES = {
|
||||
"VulnerabilityID": "CVE-2023-9012",
|
||||
"Title": "Fallback CVE vulnerability",
|
||||
"Description": "This vulnerability requires building the URL from VulnerabilityID",
|
||||
"Severity": "medium",
|
||||
"PrimaryURL": "https://avd.aquasec.com/nvd/cve-2023-9012",
|
||||
}
|
||||
|
||||
SAMPLE_TRIVY_NON_CVE_VULNERABILITY = {
|
||||
"VulnerabilityID": "GHSA-abcd-1234-efgh",
|
||||
"Title": "Non-CVE vulnerability",
|
||||
"Description": "This advisory has no CVE identifier",
|
||||
"Severity": "high",
|
||||
"PrimaryURL": "https://avd.aquasec.com/nvd/ghsa-abcd-1234-efgh",
|
||||
"References": [
|
||||
"https://avd.aquasec.com/nvd/ghsa-abcd-1234-efgh",
|
||||
"https://github.com/advisories/GHSA-abcd-1234-efgh",
|
||||
],
|
||||
}
|
||||
|
||||
# Sample Trivy output with secrets
|
||||
SAMPLE_TRIVY_SECRET_OUTPUT = {
|
||||
"Results": [
|
||||
|
||||
@@ -20,6 +20,9 @@ from tests.providers.iac.iac_fixtures import (
|
||||
SAMPLE_KUBERNETES_CHECK,
|
||||
SAMPLE_PASSED_CHECK,
|
||||
SAMPLE_SKIPPED_CHECK,
|
||||
SAMPLE_TRIVY_NON_CVE_VULNERABILITY,
|
||||
SAMPLE_TRIVY_VULNERABILITY_WITHOUT_CVE_ORG_REFERENCE,
|
||||
SAMPLE_TRIVY_VULNERABILITY_WITHOUT_REFERENCES,
|
||||
SAMPLE_YAML_CHECK,
|
||||
get_empty_trivy_output,
|
||||
get_invalid_trivy_output,
|
||||
@@ -57,13 +60,15 @@ class TestIacProvider:
|
||||
assert isinstance(report, CheckReportIAC)
|
||||
assert report.status == "FAIL"
|
||||
|
||||
# Trivy emits "AVD-AWS-0001"; Hub indexes it without the AVD- prefix.
|
||||
expected_url = "https://hub.prowler.com/check/AWS-0001"
|
||||
assert report.check_metadata.Provider == "iac"
|
||||
assert report.check_metadata.CheckID == SAMPLE_FAILED_CHECK["ID"]
|
||||
assert report.check_metadata.CheckTitle == SAMPLE_FAILED_CHECK["Title"]
|
||||
assert report.check_metadata.Severity == "low"
|
||||
assert report.check_metadata.RelatedUrl == SAMPLE_FAILED_CHECK.get(
|
||||
"PrimaryURL", ""
|
||||
)
|
||||
assert report.check_metadata.Remediation.Recommendation.Url == expected_url
|
||||
assert report.check_metadata.RelatedUrl == ""
|
||||
assert report.check_metadata.AdditionalURLs == [expected_url]
|
||||
|
||||
def test_iac_provider_process_finding_passed(self):
|
||||
"""Test processing a passed finding"""
|
||||
@@ -79,6 +84,101 @@ class TestIacProvider:
|
||||
assert report.check_metadata.CheckTitle == SAMPLE_PASSED_CHECK["Title"]
|
||||
assert report.check_metadata.Severity == "low"
|
||||
|
||||
def test_iac_provider_process_vulnerability_prefers_cve_reference_and_filters_aqua(
|
||||
self,
|
||||
):
|
||||
"""Test CVE findings use cve.org and exclude Aqua references."""
|
||||
provider = IacProvider()
|
||||
|
||||
report = provider._process_finding(
|
||||
{
|
||||
"VulnerabilityID": "CVE-2023-1234",
|
||||
"Title": "Example vulnerability",
|
||||
"Description": "This is an example vulnerability",
|
||||
"Severity": "high",
|
||||
"PrimaryURL": "https://avd.aquasec.com/nvd/cve-2023-1234",
|
||||
"References": [
|
||||
"https://avd.aquasec.com/nvd/cve-2023-1234",
|
||||
"https://nvd.nist.gov/vuln/detail/CVE-2023-1234",
|
||||
"https://www.cve.org/CVERecord?id=CVE-2023-1234",
|
||||
"https://security.example.com/advisories/CVE-2023-1234",
|
||||
],
|
||||
},
|
||||
"package.json",
|
||||
"nodejs",
|
||||
)
|
||||
|
||||
assert (
|
||||
report.check_metadata.Remediation.Recommendation.Url
|
||||
== "https://www.cve.org/CVERecord?id=CVE-2023-1234"
|
||||
)
|
||||
assert report.check_metadata.RelatedUrl == ""
|
||||
assert report.check_metadata.AdditionalURLs == [
|
||||
"https://www.cve.org/CVERecord?id=CVE-2023-1234"
|
||||
]
|
||||
|
||||
def test_iac_provider_process_vulnerability_builds_cve_org_for_nvd_reference(
|
||||
self,
|
||||
):
|
||||
"""Test official CVE URL is built when only NVD is provided."""
|
||||
provider = IacProvider()
|
||||
|
||||
report = provider._process_finding(
|
||||
SAMPLE_TRIVY_VULNERABILITY_WITHOUT_CVE_ORG_REFERENCE,
|
||||
"package.json",
|
||||
"nodejs",
|
||||
)
|
||||
|
||||
assert (
|
||||
report.check_metadata.Remediation.Recommendation.Url
|
||||
== "https://www.cve.org/CVERecord?id=CVE-2023-5678"
|
||||
)
|
||||
assert report.check_metadata.RelatedUrl == ""
|
||||
assert report.check_metadata.AdditionalURLs == [
|
||||
"https://www.cve.org/CVERecord?id=CVE-2023-5678"
|
||||
]
|
||||
|
||||
def test_iac_provider_process_vulnerability_builds_cve_org_when_references_missing(
|
||||
self,
|
||||
):
|
||||
"""Test CVE URL is built from VulnerabilityID when references are absent."""
|
||||
provider = IacProvider()
|
||||
|
||||
report = provider._process_finding(
|
||||
SAMPLE_TRIVY_VULNERABILITY_WITHOUT_REFERENCES,
|
||||
"package.json",
|
||||
"nodejs",
|
||||
)
|
||||
|
||||
assert (
|
||||
report.check_metadata.Remediation.Recommendation.Url
|
||||
== "https://www.cve.org/CVERecord?id=CVE-2023-9012"
|
||||
)
|
||||
assert report.check_metadata.RelatedUrl == ""
|
||||
assert report.check_metadata.AdditionalURLs == [
|
||||
"https://www.cve.org/CVERecord?id=CVE-2023-9012"
|
||||
]
|
||||
|
||||
def test_iac_provider_process_non_cve_vulnerability_falls_back_to_github_advisory(
|
||||
self,
|
||||
):
|
||||
"""Non-CVE vulnerabilities (GHSA-…) point to GitHub Security Advisories."""
|
||||
provider = IacProvider()
|
||||
|
||||
report = provider._process_finding(
|
||||
SAMPLE_TRIVY_NON_CVE_VULNERABILITY,
|
||||
"package.json",
|
||||
"nodejs",
|
||||
)
|
||||
|
||||
expected_url = (
|
||||
"https://github.com/advisories/"
|
||||
f"{SAMPLE_TRIVY_NON_CVE_VULNERABILITY['VulnerabilityID'].upper()}"
|
||||
)
|
||||
assert report.check_metadata.Remediation.Recommendation.Url == expected_url
|
||||
assert report.check_metadata.RelatedUrl == ""
|
||||
assert report.check_metadata.AdditionalURLs == [expected_url]
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_iac_provider_run_scan_success(self, mock_subprocess):
|
||||
"""Test successful IAC scan with Trivy"""
|
||||
|
||||
@@ -11,6 +11,12 @@ SAMPLE_VULNERABILITY_FINDING = {
|
||||
"Title": "OpenSSL Buffer Overflow",
|
||||
"Description": "A buffer overflow vulnerability in OpenSSL allows remote attackers to execute arbitrary code.",
|
||||
"PrimaryURL": "https://avd.aquasec.com/nvd/cve-2024-1234",
|
||||
"References": [
|
||||
"https://avd.aquasec.com/nvd/cve-2024-1234",
|
||||
"https://nvd.nist.gov/vuln/detail/CVE-2024-1234",
|
||||
"https://www.cve.org/CVERecord?id=CVE-2024-1234",
|
||||
"https://security.alpinelinux.org/vuln/CVE-2024-1234",
|
||||
],
|
||||
}
|
||||
|
||||
# Sample secret finding from Trivy
|
||||
@@ -45,6 +51,50 @@ SAMPLE_UNKNOWN_SEVERITY_FINDING = {
|
||||
"Description": "An issue with unknown severity.",
|
||||
}
|
||||
|
||||
SAMPLE_VULNERABILITY_WITHOUT_CVE_ORG_REFERENCE = {
|
||||
"VulnerabilityID": "CVE-2024-5678",
|
||||
"PkgID": "libcrypto3@3.3.2-r0",
|
||||
"PkgName": "libcrypto3",
|
||||
"InstalledVersion": "3.3.2-r0",
|
||||
"FixedVersion": "3.3.2-r1",
|
||||
"Severity": "HIGH",
|
||||
"Title": "OpenSSL advisory without cve.org reference",
|
||||
"Description": "A vulnerability with references but no cve.org reference.",
|
||||
"PrimaryURL": "https://avd.aquasec.com/nvd/cve-2024-5678",
|
||||
"References": [
|
||||
"https://avd.aquasec.com/nvd/cve-2024-5678",
|
||||
"https://nvd.nist.gov/vuln/detail/CVE-2024-5678",
|
||||
"https://security.alpinelinux.org/vuln/CVE-2024-5678",
|
||||
],
|
||||
}
|
||||
|
||||
SAMPLE_CVE_WITHOUT_REFERENCES_FINDING = {
|
||||
"VulnerabilityID": "CVE-2024-9012",
|
||||
"PkgID": "busybox@1.36.1-r8",
|
||||
"PkgName": "busybox",
|
||||
"InstalledVersion": "1.36.1-r8",
|
||||
"FixedVersion": "1.36.1-r9",
|
||||
"Severity": "MEDIUM",
|
||||
"Title": "Busybox fallback CVE",
|
||||
"Description": "A vulnerability without explicit references.",
|
||||
"PrimaryURL": "https://avd.aquasec.com/nvd/cve-2024-9012",
|
||||
}
|
||||
|
||||
SAMPLE_NON_CVE_VULNERABILITY_FINDING = {
|
||||
"VulnerabilityID": "GHSA-abcd-1234-efgh",
|
||||
"PkgID": "custompkg@0.0.1",
|
||||
"PkgName": "custompkg",
|
||||
"InstalledVersion": "0.0.1",
|
||||
"Severity": "HIGH",
|
||||
"Title": "Non-CVE advisory",
|
||||
"Description": "An advisory without a CVE identifier.",
|
||||
"PrimaryURL": "https://avd.aquasec.com/nvd/ghsa-abcd-1234-efgh",
|
||||
"References": [
|
||||
"https://avd.aquasec.com/nvd/ghsa-abcd-1234-efgh",
|
||||
"https://github.com/advisories/GHSA-abcd-1234-efgh",
|
||||
],
|
||||
}
|
||||
|
||||
# Sample image SHA for testing (first 12 chars of a sha256 digest)
|
||||
SAMPLE_IMAGE_SHA = "c1aabb73d233"
|
||||
SAMPLE_IMAGE_ID = f"sha256:{SAMPLE_IMAGE_SHA}abcdef1234567890"
|
||||
|
||||
@@ -23,11 +23,14 @@ from prowler.providers.image.exceptions.exceptions import (
|
||||
)
|
||||
from prowler.providers.image.image_provider import ImageProvider
|
||||
from tests.providers.image.image_fixtures import (
|
||||
SAMPLE_CVE_WITHOUT_REFERENCES_FINDING,
|
||||
SAMPLE_IMAGE_SHA,
|
||||
SAMPLE_MISCONFIGURATION_FINDING,
|
||||
SAMPLE_NON_CVE_VULNERABILITY_FINDING,
|
||||
SAMPLE_SECRET_FINDING,
|
||||
SAMPLE_UNKNOWN_SEVERITY_FINDING,
|
||||
SAMPLE_VULNERABILITY_FINDING,
|
||||
SAMPLE_VULNERABILITY_WITHOUT_CVE_ORG_REFERENCE,
|
||||
get_empty_trivy_output,
|
||||
get_invalid_trivy_output,
|
||||
get_multi_type_trivy_output,
|
||||
@@ -148,6 +151,77 @@ class TestImageProvider:
|
||||
assert report.check_metadata.Categories == ["vulnerabilities"]
|
||||
assert report.check_metadata.RelatedUrl == ""
|
||||
|
||||
def test_process_finding_vulnerability_prefers_cve_reference_and_filters_aqua(self):
|
||||
"""Test CVE findings use cve.org and exclude Aqua references."""
|
||||
provider = _make_provider()
|
||||
|
||||
report = provider._process_finding(
|
||||
SAMPLE_VULNERABILITY_FINDING,
|
||||
"alpine:3.18",
|
||||
"alpine:3.18 (alpine 3.18.0)",
|
||||
)
|
||||
|
||||
assert (
|
||||
report.check_metadata.Remediation.Recommendation.Url
|
||||
== "https://www.cve.org/CVERecord?id=CVE-2024-1234"
|
||||
)
|
||||
assert report.check_metadata.AdditionalURLs == [
|
||||
"https://www.cve.org/CVERecord?id=CVE-2024-1234"
|
||||
]
|
||||
|
||||
def test_process_finding_vulnerability_builds_cve_org_when_only_nvd_reference(
|
||||
self,
|
||||
):
|
||||
"""Test official CVE URL is built when only NVD is provided."""
|
||||
provider = _make_provider()
|
||||
|
||||
report = provider._process_finding(
|
||||
SAMPLE_VULNERABILITY_WITHOUT_CVE_ORG_REFERENCE,
|
||||
"alpine:3.18",
|
||||
"alpine:3.18 (alpine 3.18.0)",
|
||||
)
|
||||
|
||||
assert (
|
||||
report.check_metadata.Remediation.Recommendation.Url
|
||||
== "https://www.cve.org/CVERecord?id=CVE-2024-5678"
|
||||
)
|
||||
assert report.check_metadata.AdditionalURLs == [
|
||||
"https://www.cve.org/CVERecord?id=CVE-2024-5678"
|
||||
]
|
||||
|
||||
def test_process_finding_vulnerability_builds_cve_org_when_references_missing(self):
|
||||
"""Test CVE URL is built from VulnerabilityID when references are absent."""
|
||||
provider = _make_provider()
|
||||
|
||||
report = provider._process_finding(
|
||||
SAMPLE_CVE_WITHOUT_REFERENCES_FINDING,
|
||||
"alpine:3.18",
|
||||
"alpine:3.18 (alpine 3.18.0)",
|
||||
)
|
||||
|
||||
assert (
|
||||
report.check_metadata.Remediation.Recommendation.Url
|
||||
== "https://www.cve.org/CVERecord?id=CVE-2024-9012"
|
||||
)
|
||||
assert report.check_metadata.AdditionalURLs == [
|
||||
"https://www.cve.org/CVERecord?id=CVE-2024-9012"
|
||||
]
|
||||
|
||||
def test_process_finding_non_cve_vulnerability_does_not_fallback_to_aqua(self):
|
||||
"""Test non-CVE vulnerabilities do not keep Aqua links."""
|
||||
provider = _make_provider()
|
||||
|
||||
report = provider._process_finding(
|
||||
SAMPLE_NON_CVE_VULNERABILITY_FINDING,
|
||||
"alpine:3.18",
|
||||
"alpine:3.18 (alpine 3.18.0)",
|
||||
)
|
||||
|
||||
assert report.check_metadata.Remediation.Recommendation.Url == ""
|
||||
assert report.check_metadata.AdditionalURLs == [
|
||||
"https://github.com/advisories/GHSA-abcd-1234-efgh"
|
||||
]
|
||||
|
||||
def test_process_finding_secret(self):
|
||||
"""Test processing a secret finding (identified by RuleID)."""
|
||||
provider = _make_provider()
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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", "/");
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[]>;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+16
-3
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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 />}>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user