Compare commits

..
Author SHA1 Message Date
Rubén De la Torre Vico e4c0cfd755 fix(ui): drop stale Realtime answers without touching the ref in cleanup 2026-10-30 09:58:03 +01:00
Rubén De la Torre Vico bb0164c606 fix(ui): address review findings on Prowler Realtime
- Keep realtime data tied to the provider it was loaded for and drop stale polls
- Show failures instead of empty or stuck states on rejected requests
- Correct the undo, region and Terraform output wording in setup and templates
- Keep the anomaly threshold visible and sort the posture timeline
- Tighten the template and form tests
2026-10-29 16:59:22 +01:00
Rubén De la Torre Vico f2ad8b9465 feat(ui): add Prowler Realtime Detection and Remediation for AWS
- Add the Realtime overview, setup wizard, settings and cost anomaly pages
- Add the Manage Realtime permission to the role form
- Add the Realtime feed and remediation role templates
- Gate everything to Prowler Cloud and the realtime-feature flag
2026-10-29 13:40:31 +01:00
170 changed files with 16914 additions and 1713 deletions

No files matched your search

+1 -1
View File
@@ -174,7 +174,7 @@ SENTRY_RELEASE=local
# REO_DEV_CLIENT_ID=
#### Prowler release version ####
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.48.0
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.47.0
# Social login credentials
SOCIAL_GOOGLE_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/google"
+1 -1
View File
@@ -1,4 +1,4 @@
FROM python:3.12.16-slim-trixie@sha256:8523d6abeb48ceae0b7240d30c5808fc26cb79c1a635bacdf19cf3baecfbec07 AS build
FROM python:3.12.15-slim-trixie@sha256:05cda9777409a9c3ffddd94a4c476b79f0769a0b4857f0c7ed9226b6800b0d6f AS build
LABEL maintainer="https://github.com/prowler-cloud/prowler"
LABEL org.opencontainers.image.source="https://github.com/prowler-cloud/prowler"
-8
View File
@@ -4,14 +4,6 @@ All notable changes to the **Prowler API** are documented in this file.
<!-- changelog: release notes start -->
## [1.48.0] (Prowler v5.47.0)
### 🔐 Security
- pymongo to 4.18.3, dnspython to 2.9.0 and oauthlib to 4.0.0 in the API container image, with django-allauth from 65.15.0 to 65.19.7 because earlier releases cap oauthlib below 4, patching the BSON decoding and DNS compression pointer denial of service advisories and the OAuth device flow token disclosure [(#13185)](https://github.com/prowler-cloud/prowler/pull/13185)
---
## [1.47.0] (Prowler v5.46.0)
### 🐞 Fixed
+1 -1
View File
@@ -1,4 +1,4 @@
FROM python:3.12.16-slim-trixie@sha256:8523d6abeb48ceae0b7240d30c5808fc26cb79c1a635bacdf19cf3baecfbec07 AS build
FROM python:3.12.15-slim-trixie@sha256:05cda9777409a9c3ffddd94a4c476b79f0769a0b4857f0c7ed9226b6800b0d6f AS build
LABEL maintainer="https://github.com/prowler-cloud/api"
@@ -0,0 +1 @@
pymongo to 4.18.3, dnspython to 2.9.0 and oauthlib to 4.0.0 in the API container image, with django-allauth from 65.15.0 to 65.19.7 because earlier releases cap oauthlib below 4, patching the BSON decoding and DNS compression pointer denial of service advisories and the OAuth device flow token disclosure
@@ -1 +0,0 @@
Python base image from 3.12.15 to 3.12.16 in the API container image, patching CVE-2026-115557
+1 -1
View File
@@ -71,7 +71,7 @@ name = "prowler-api"
package-mode = false
# Needed for the SDK compatibility
requires-python = ">=3.11,<3.13"
version = "1.49.0"
version = "1.48.0"
# Shared ruff baseline (kept in sync with mcp_server/pyproject.toml).
# target-version tracks this project's lowest supported Python.
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.0.3
info:
title: Prowler API
version: 1.49.0
version: 1.48.0
description: |-
Prowler API specification.
Generated
+2 -2
View File
@@ -1,5 +1,5 @@
version = 1
revision = 3
revision = 2
requires-python = ">=3.11, <3.13"
resolution-markers = [
"python_full_version >= '3.12' and sys_platform == 'win32'",
@@ -4945,7 +4945,7 @@ dependencies = [
[[package]]
name = "prowler-api"
version = "1.49.0"
version = "1.48.0"
source = { virtual = "." }
dependencies = [
{ name = "cartography" },
+1 -57
View File
@@ -4,63 +4,7 @@ description: "New features and improvements in each Prowler release"
rss: true
---
<Update label="v5.47.0" description="October 29, 2026">
### 🧭 Add Provider — One Step to Connect Any Provider
The add-provider wizard follows the same model for every provider: one connect step, then the launch step. Adding an Azure subscription used to take six screens; now the account, its alias and its credentials are one form, and a single submit registers the account, stores the secret and tests the connection.
- A failed connection test shows inline, keeps the values and turns the action into **Retry connection** without registering the account twice.
- Access methods show as cards on the same step for AWS, GCP, Microsoft 365, GitHub, Alibaba Cloud and Cloudflare, and AWS, Azure and GCP switch to their organization flow through tabs.
- Users with the Manage Account permission can invite a teammate to finish the connection for every provider, not only AWS.
- **Ask Lighthouse AI** opens beside the wizard without closing it. It guides the connection from the Prowler docs by default and, when asked, connects the provider itself after confirming the provider, UID and credential field names, never echoing a secret back.
- Product tours and the guided onboarding sequence are removed; new tenants still land on the add-provider wizard.
### 🎯 Issues — Verdicts Explained in the List
<Note>
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
</Note>
- The issue list leads with the verdict, shows the attack path under each title and adds a **Why** column with the five evidence legs, so the reason for a verdict reads without opening the issue. The summary tiles become one line with the folded findings and the findings already covered by a control.
- A row opens in the side panel, next to Lighthouse AI, with the verdict, legs, remediation, triage and an **Attack path** tab with the issue graph, instead of expanding the issue inline.
- The menu entry is renamed to **Exploitability issues**.
### ☁️ Azure and AWS Checks
- `network_application_gateway_waf_enabled` and `network_application_gateway_http2_enabled` cover Azure Application Gateways and their WAF policies, mapped to CIS 5.0 and 6.0. Contributed by @BabafemiB. Thank you!
- `iam_subscription_owners_between_2_and_3` verifies that each Azure subscription has between two and three Owners, counting owners inherited from management groups, mapped to CIS 5.0 and 6.0. Contributed by @BabafemiB. Thank you!
- The AWS security group internet exposure checks treat CIDRs in one ingress rule that together cover `0.0.0.0/0` or `::/0`, such as `0.0.0.0/1` plus `128.0.0.0/1`, as open to the internet; such a rule used to pass `ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22` and its siblings. Contributed by @harshrajdebug. Thank you!
Explore all checks at [Prowler Hub](https://hub.prowler.com/check).
### 🛠️ Improvements and Fixes
- Every MCP Server tool declares `readOnlyHint` and `destructiveHint`, so clients can tell a listing from a write when deciding whether to ask the user. Mutelist and mute rule writes are marked destructive because they hide findings for good.
- The cross-provider compliance tab keeps the frameworks that loaded, with a warning, when one catalog page or framework fails, instead of going blank.
- The NATO D32 compliance framework has its own icon on the compliance cards.
- Lighthouse AI replies no longer blink when the response finishes streaming, and the user avatar no longer requests the current page as an image, which rendered every page twice.
- The Invitations page uses **Status** instead of **State**, and the Invitations and Roles filters show capitalized options.
- **Prowler Cloud:** actions refused because the tenant is over its plan's usage limit, such as launching a scan, saving a schedule, adding a provider or triaging a finding, now link to the plans page.
- **Prowler Cloud:** creating API keys is a Prowler Cloud feature; on Local Server the button opens the upgrade dialog, and existing keys can still be edited and revoked.
- **Prowler Cloud:** issue evaluation after a scan reads the scan's findings through their indexes instead of a server-side cursor that took hours on large scans, so the evaluations no longer queue up behind each other.
- **Prowler Private Cloud:** Lighthouse AI can use Amazon Bedrock with only a region, relying on the installation's own AWS role, when the API allows it.
### 🔐 Security Updates
- `pymongo` upgraded to 4.18.3, `dnspython` to 2.9.0 and `oauthlib` to 4.0.0 in the SDK and the API container image, patching the BSON decoding and DNS compression pointer denial of service advisories and the OAuth device flow token disclosure. The API also moves `django-allauth` to 65.19.7, since earlier releases cap `oauthlib` below 4.
- `Authlib` upgraded to 1.9.1 and `dnspython` to 2.9.0 in the MCP Server container image, patching CVE-2026-96760 (critical), CVE-2026-114615, CVE-2026-114616 and CVE-2026-114605.
### 🙌 External Contributors
Thank you to our community contributors for this release!
- @BabafemiB: Azure `network_application_gateway_waf_enabled`, `network_application_gateway_http2_enabled` and `iam_subscription_owners_between_2_and_3` checks ([#13160](https://github.com/prowler-cloud/prowler/pull/13160), [#13145](https://github.com/prowler-cloud/prowler/pull/13145))
- @harshrajdebug: AWS security group internet exposure fix for split CIDRs ([#13191](https://github.com/prowler-cloud/prowler/pull/13191))
See the [full release notes on GitHub](https://github.com/prowler-cloud/prowler/releases/tag/5.47.0) for the complete list of changes.
</Update>
<Update label="v5.46.0" description="October 26, 2026">
<Update label="v5.46.0" description="October 29, 2026">
### 🎯 Issues — Attack Paths with an Exploitability Verdict
<Note>
@@ -132,8 +132,8 @@ To update the environment file:
Edit the `.env` file and change version values:
```env
PROWLER_UI_VERSION="5.47.0"
PROWLER_API_VERSION="5.47.0"
PROWLER_UI_VERSION="5.46.0"
PROWLER_API_VERSION="5.46.0"
```
<Note>
-12
View File
@@ -4,18 +4,6 @@ All notable changes to the **Prowler MCP Server** are documented in this file.
<!-- changelog: release notes start -->
## [0.13.0] (Prowler v5.47.0)
### 🚀 Added
- `readOnlyHint` and `destructiveHint` annotations on every MCP tool, with mute rule and mutelist writes marked destructive [(#13196)](https://github.com/prowler-cloud/prowler/pull/13196)
### 🔐 Security
- Authlib from 1.7.2 to 1.9.1 and dnspython from 2.8.0 to 2.9.0 in the MCP Server container image, patching CVE-2026-96760 (critical), CVE-2026-114615, CVE-2026-114616 and CVE-2026-114605 [(#13186)](https://github.com/prowler-cloud/prowler/pull/13186)
---
## [0.12.4] (Prowler v5.46.0)
### 🔐 Security
@@ -0,0 +1 @@
Authlib from 1.7.2 to 1.9.1 and dnspython from 2.8.0 to 2.9.0 in the MCP Server container image, patching CVE-2026-96760 (critical), CVE-2026-114615, CVE-2026-114616 and CVE-2026-114605
@@ -0,0 +1 @@
`readOnlyHint` and `destructiveHint` annotations on every MCP tool, with mute rule and mutelist writes marked destructive
@@ -0,0 +1,96 @@
AWSTemplateFormatVersion: "2010-09-09"
Description: |
Prowler Realtime Detection and Remediation real-time feed. EventBridge forwards risky AWS API calls recorded by CloudTrail to an SQS queue,
and Prowler Cloud reads the queue with the ProwlerScan role. Without this stack Prowler Realtime still works, using CloudTrail
LookupEvents with a delay of a few minutes. Delete the stack to remove everything it creates.
Deploy it in one region: Prowler Realtime reads one queue per account. Global-service events (IAM, S3 account settings)
arrive in us-east-1.
Parameters:
ProwlerScanRoleName:
Description: Name of the ProwlerScan role Prowler Cloud already uses for this account. It receives read access to this queue only.
Type: String
Default: ProwlerScan
MinLength: 1
Resources:
Queue:
Type: AWS::SQS::Queue
Properties:
QueueName: prowler-realtime-events
MessageRetentionPeriod: 3600
ReceiveMessageWaitTimeSeconds: 20
SqsManagedSseEnabled: true
Tags:
- { Key: prowler, Value: realtime-feed }
Rule:
Type: AWS::Events::Rule
Properties:
Name: prowler-realtime-risky-api-calls
Description: Risky changes that can break compliance checks, forwarded to Prowler Realtime Detection and Remediation
State: ENABLED
EventPattern:
detail-type: ["AWS API Call via CloudTrail", "AWS Console Sign In via CloudTrail"]
detail:
eventName:
- AuthorizeSecurityGroupIngress
- PutBucketPublicAccessBlock
- DeleteBucketPublicAccessBlock
- PutAccountPublicAccessBlock
- DeletePublicAccessBlock
- PutBucketPolicy
- StopLogging
- DeleteTrail
- UpdateTrail
- StopConfigurationRecorder
- DeleteConfigurationRecorder
- DeleteFlowLogs
- ScheduleKeyDeletion
- DisableKey
- DisableEbsEncryptionByDefault
- CreateVolume
- RunInstances
- CreateDBInstance
- ModifyDBInstance
- AttachUserPolicy
- AttachRolePolicy
- AttachGroupPolicy
- CreateAccessKey
- ConsoleLogin
Targets:
- { Id: prowler-realtime-queue, Arn: !GetAtt Queue.Arn }
QueuePolicy:
Type: AWS::SQS::QueuePolicy
Properties:
Queues: [!Ref Queue]
PolicyDocument:
Version: "2012-10-17"
Statement:
- Sid: AllowEventBridge
Effect: Allow
Principal: { Service: events.amazonaws.com }
Action: sqs:SendMessage
Resource: !GetAtt Queue.Arn
Condition:
ArnEquals: { "aws:SourceArn": !GetAtt Rule.Arn }
ReadFeedPolicy:
Type: AWS::IAM::RolePolicy
Properties:
RoleName: !Ref ProwlerScanRoleName
PolicyName: ProwlerRealtimeFeed
PolicyDocument:
Version: "2012-10-17"
Statement:
- Sid: ReadRealtimeQueue
Effect: Allow
Action: [sqs:ReceiveMessage, sqs:DeleteMessage, sqs:GetQueueAttributes]
Resource: !GetAtt Queue.Arn
Outputs:
QueueUrl:
Description: Paste this URL in Prowler Cloud, Prowler Realtime, Real-time feed.
Value: !Ref Queue
@@ -0,0 +1,91 @@
AWSTemplateFormatVersion: "2010-09-09"
Description: |
Optional. Creates the ProwlerRemediation IAM role that lets Prowler Realtime Detection and Remediation fix a small, fixed set of misconfigurations
automatically (and undo them). It is separate from the read-only ProwlerScan role: without it Prowler Realtime can only alert
and propose pull requests. Every action below is one a listed fix or its undo needs; the resource comes from the event,
so most statements cannot name it in advance. Delete the stack to revoke.
If you are deploying this template to be used in Prowler Cloud please do not edit the AccountId and IAMPrincipal parameters.
What Prowler can do at runtime through this role, and nothing else:
- Close internet-open ports (22, 3389, all) on the security group named in an event, revoking only 0.0.0.0/0 and ::/0 rules (undo recorded)
- Turn on the four public access block settings of the S3 bucket named in an event (undo recorded)
- Turn on the account-wide S3 public access block (undo recorded; can affect buckets that are public on purpose)
- Start CloudTrail logging again on trails that were stopped (not revertible)
- Enable EBS encryption by default in the region; existing volumes are not changed (undo recorded)
- Set PubliclyAccessible to false on the RDS database named in an event (undo recorded)
Parameters:
ExternalId:
Description: The External ID Prowler Realtime Detection and Remediation uses to assume this role. Use the same value you enter in Prowler Cloud.
Type: String
MinLength: 8
AllowedPattern: ".+"
ConstraintDescription: "ExternalId must be at least 8 characters."
AccountId:
Description: AWS account ID that assumes the role. If you are deploying this template to be used in Prowler Cloud please do not edit this.
Type: String
Default: "232136659152"
MinLength: 12
MaxLength: 12
AllowedPattern: "[0-9]{12}"
IAMPrincipal:
Description: IAM principal allowed to assume the role. If you are deploying this template to be used in Prowler Cloud please do not edit this.
Type: String
Default: role/prowler*
Resources:
ProwlerRemediation:
Type: AWS::IAM::Role
Properties:
RoleName: ProwlerRemediation
MaxSessionDuration: 3600
AssumeRolePolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Principal: { AWS: !Sub "arn:${AWS::Partition}:iam::${AccountId}:root" }
Action: sts:AssumeRole
Condition:
StringEquals: { "sts:ExternalId": !Ref ExternalId }
StringLike: { "aws:PrincipalArn": !Sub "arn:${AWS::Partition}:iam::${AccountId}:${IAMPrincipal}" }
Policies:
- PolicyName: ProwlerRealtimeFixes
PolicyDocument:
Version: "2012-10-17"
Statement:
- Sid: SecurityGroupIngress
Effect: Allow
Action: [ec2:DescribeSecurityGroups, ec2:RevokeSecurityGroupIngress, ec2:AuthorizeSecurityGroupIngress]
Resource: "*"
- Sid: EbsDefaultEncryption
Effect: Allow
Action: [ec2:GetEbsEncryptionByDefault, ec2:EnableEbsEncryptionByDefault, ec2:DisableEbsEncryptionByDefault]
Resource: "*"
- Sid: BucketPublicAccessBlock
Effect: Allow
Action: [s3:GetBucketPublicAccessBlock, s3:PutBucketPublicAccessBlock]
Resource: !Sub "arn:${AWS::Partition}:s3:::*"
- Sid: AccountPublicAccessBlock
Effect: Allow
Action: [s3:GetAccountPublicAccessBlock, s3:PutAccountPublicAccessBlock]
Resource: "*"
- Sid: CloudTrailLogging
Effect: Allow
Action: [cloudtrail:DescribeTrails, cloudtrail:GetTrailStatus, cloudtrail:StartLogging]
Resource: "*"
- Sid: RdsPrivate
Effect: Allow
Action: [rds:DescribeDBInstances, rds:ModifyDBInstance]
Resource: !Sub "arn:${AWS::Partition}:rds:*:${AWS::AccountId}:db:*"
- Sid: WhoAmI
Effect: Allow
Action: sts:GetCallerIdentity
Resource: "*"
Outputs:
RoleArn:
Description: Paste this ARN in Prowler Cloud, Prowler Realtime, Auto-fix.
Value: !GetAtt ProwlerRemediation.Arn
@@ -0,0 +1,85 @@
# Prowler Realtime Detection and Remediation: real-time feed.
#
# EventBridge forwards risky AWS API calls recorded by CloudTrail to an SQS queue, and Prowler Cloud reads the queue with
# the ProwlerScan role. Without it Prowler still works, using CloudTrail LookupEvents with a delay of a few minutes.
# Apply it in one region: Prowler Realtime reads one queue per account. Global-service events (IAM, S3 account settings)
# arrive in us-east-1.
terraform {
required_version = ">= 1.5"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.83"
}
}
}
locals {
event_names = [
"AuthorizeSecurityGroupIngress", "PutBucketPublicAccessBlock", "DeleteBucketPublicAccessBlock",
"PutAccountPublicAccessBlock", "DeletePublicAccessBlock", "PutBucketPolicy", "StopLogging", "DeleteTrail",
"UpdateTrail", "StopConfigurationRecorder", "DeleteConfigurationRecorder", "DeleteFlowLogs", "ScheduleKeyDeletion",
"DisableKey", "DisableEbsEncryptionByDefault", "CreateVolume", "RunInstances", "CreateDBInstance",
"ModifyDBInstance", "AttachUserPolicy", "AttachRolePolicy", "AttachGroupPolicy", "CreateAccessKey", "ConsoleLogin",
]
}
resource "aws_sqs_queue" "events" {
name = "prowler-realtime-events"
message_retention_seconds = 3600
receive_wait_time_seconds = 20
sqs_managed_sse_enabled = true
tags = { prowler = "realtime-feed" }
}
resource "aws_cloudwatch_event_rule" "risky_api_calls" {
name = "prowler-realtime-risky-api-calls"
description = "Risky changes that can break compliance checks, forwarded to Prowler Realtime"
event_pattern = jsonencode({
"detail-type" = ["AWS API Call via CloudTrail", "AWS Console Sign In via CloudTrail"]
detail = { eventName = local.event_names }
})
}
resource "aws_cloudwatch_event_target" "queue" {
rule = aws_cloudwatch_event_rule.risky_api_calls.name
target_id = "prowler-realtime-queue"
arn = aws_sqs_queue.events.arn
}
data "aws_iam_policy_document" "queue" {
statement {
sid = "AllowEventBridge"
actions = ["sqs:SendMessage"]
resources = [aws_sqs_queue.events.arn]
principals {
type = "Service"
identifiers = ["events.amazonaws.com"]
}
condition {
test = "ArnEquals"
variable = "aws:SourceArn"
values = [aws_cloudwatch_event_rule.risky_api_calls.arn]
}
}
}
resource "aws_sqs_queue_policy" "events" {
queue_url = aws_sqs_queue.events.id
policy = data.aws_iam_policy_document.queue.json
}
resource "aws_iam_role_policy" "read_feed" {
name = "ProwlerRealtimeFeed"
role = var.prowler_scan_role_name
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Sid = "ReadRealtimeQueue"
Effect = "Allow"
Action = ["sqs:ReceiveMessage", "sqs:DeleteMessage", "sqs:GetQueueAttributes"]
Resource = aws_sqs_queue.events.arn
}]
})
}
@@ -0,0 +1,4 @@
output "queue_url" {
description = "Paste this URL in Prowler Cloud, Realtime, Real-time feed."
value = aws_sqs_queue.events.url
}
@@ -0,0 +1,5 @@
variable "prowler_scan_role_name" {
type = string
description = "Name of the ProwlerScan role Prowler Cloud already uses for this account. It receives read access to this queue only."
default = "ProwlerScan"
}
@@ -0,0 +1,99 @@
# Prowler Realtime Detection and Remediation: remediation-only IAM role (optional).
#
# Lets Prowler fix a small, fixed set of misconfigurations automatically (and undo them). It is separate from the
# read-only ProwlerScan role: without it Prowler can only alert and propose pull requests. Every action is one a listed
# fix or its undo needs; the resource comes from the event, so most statements cannot name it in advance. Destroy the
# stack to revoke.
#
# If you deploy this to be used in Prowler Cloud, do not edit account_id and iam_principal.
#
# What Prowler can do at runtime through this role, and nothing else:
# - Close internet-open ports (22, 3389, all) on the security group named in an event, revoking only 0.0.0.0/0 and ::/0 rules (undo recorded)
# - Turn on the four public access block settings of the S3 bucket named in an event (undo recorded)
# - Turn on the account-wide S3 public access block (undo recorded; can affect buckets that are public on purpose)
# - Start CloudTrail logging again on trails that were stopped (not revertible)
# - Enable EBS encryption by default in the region; existing volumes are not changed (undo recorded)
# - Set PubliclyAccessible to false on the RDS database named in an event (undo recorded)
terraform {
required_version = ">= 1.5"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.83"
}
}
}
data "aws_partition" "current" {}
data "aws_caller_identity" "current" {}
data "aws_iam_policy_document" "assume_role" {
statement {
actions = ["sts:AssumeRole"]
principals {
type = "AWS"
identifiers = ["arn:${data.aws_partition.current.partition}:iam::${var.account_id}:root"]
}
condition {
test = "StringEquals"
variable = "sts:ExternalId"
values = [var.external_id]
}
condition {
test = "StringLike"
variable = "aws:PrincipalArn"
values = ["arn:${data.aws_partition.current.partition}:iam::${var.account_id}:${var.iam_principal}"]
}
}
}
data "aws_iam_policy_document" "fixes" {
statement {
sid = "SecurityGroupIngress"
actions = ["ec2:DescribeSecurityGroups", "ec2:RevokeSecurityGroupIngress", "ec2:AuthorizeSecurityGroupIngress"]
resources = ["*"]
}
statement {
sid = "EbsDefaultEncryption"
actions = ["ec2:GetEbsEncryptionByDefault", "ec2:EnableEbsEncryptionByDefault", "ec2:DisableEbsEncryptionByDefault"]
resources = ["*"]
}
statement {
sid = "BucketPublicAccessBlock"
actions = ["s3:GetBucketPublicAccessBlock", "s3:PutBucketPublicAccessBlock"]
resources = ["arn:${data.aws_partition.current.partition}:s3:::*"]
}
statement {
sid = "AccountPublicAccessBlock"
actions = ["s3:GetAccountPublicAccessBlock", "s3:PutAccountPublicAccessBlock"]
resources = ["*"]
}
statement {
sid = "CloudTrailLogging"
actions = ["cloudtrail:DescribeTrails", "cloudtrail:GetTrailStatus", "cloudtrail:StartLogging"]
resources = ["*"]
}
statement {
sid = "RdsPrivate"
actions = ["rds:DescribeDBInstances", "rds:ModifyDBInstance"]
resources = ["arn:${data.aws_partition.current.partition}:rds:*:${data.aws_caller_identity.current.account_id}:db:*"]
}
statement {
sid = "WhoAmI"
actions = ["sts:GetCallerIdentity"]
resources = ["*"]
}
}
resource "aws_iam_role" "prowler_remediation" {
name = "ProwlerRemediation"
max_session_duration = 3600
assume_role_policy = data.aws_iam_policy_document.assume_role.json
}
resource "aws_iam_role_policy" "fixes" {
name = "ProwlerRealtimeFixes"
role = aws_iam_role.prowler_remediation.id
policy = data.aws_iam_policy_document.fixes.json
}
@@ -0,0 +1,4 @@
output "role_arn" {
description = "Paste this ARN in Prowler Cloud, Realtime, Credentials for remediation only."
value = aws_iam_role.prowler_remediation.arn
}
@@ -0,0 +1,26 @@
variable "external_id" {
type = string
description = "The External ID Prowler uses to assume this role. Use the same value you enter in Prowler Cloud."
validation {
condition = length(var.external_id) >= 8
error_message = "external_id must be at least 8 characters."
}
}
variable "account_id" {
type = string
description = "AWS account ID that assumes the role. If you deploy this to be used in Prowler Cloud, do not edit this."
default = "232136659152"
validation {
condition = can(regex("^[0-9]{12}$", var.account_id))
error_message = "account_id must be a valid AWS account ID."
}
}
variable "iam_principal" {
type = string
description = "IAM principal allowed to assume the role. If you deploy this to be used in Prowler Cloud, do not edit this."
default = "role/prowler*"
}
-17
View File
@@ -4,23 +4,6 @@ All notable changes to the **Prowler SDK** are documented in this file.
<!-- changelog: release notes start -->
## [5.47.0] (Prowler v5.47.0)
### 🚀 Added
- `iam_subscription_owners_between_2_and_3` check for Azure provider, verifying each subscription has between 2 and 3 principals with the Owner role, including owners inherited from management groups, mapped to CIS 5.0 and 6.0 [(#13145)](https://github.com/prowler-cloud/prowler/pull/13145)
- `network_application_gateway_waf_enabled` and `network_application_gateway_http2_enabled` checks for Azure provider, with Application Gateway and WAF policy collection in the network service, mapped to CIS 5.0 and 6.0 [(#13160)](https://github.com/prowler-cloud/prowler/pull/13160)
### 🐞 Fixed
- Security group internet exposure checks now treat CIDRs in one ingress rule that together cover 0.0.0.0/0 or ::/0 as open to the internet [(#13191)](https://github.com/prowler-cloud/prowler/pull/13191)
### 🔐 Security
- pymongo to 4.18.3, dnspython to 2.9.0 and oauthlib to 4.0.0 in the SDK, patching the BSON decoding and DNS compression pointer denial of service advisories and the OAuth device flow token disclosure; dnspython stays on 2.8.0 for Python 3.10, which 2.9.0 no longer supports [(#13185)](https://github.com/prowler-cloud/prowler/pull/13185)
---
## [5.46.0] (Prowler v5.46.0)
### 🚀 Added
@@ -0,0 +1 @@
`network_application_gateway_waf_enabled` and `network_application_gateway_http2_enabled` checks for Azure provider, with Application Gateway and WAF policy collection in the network service, mapped to CIS 5.0 and 6.0
@@ -0,0 +1 @@
`iam_subscription_owners_between_2_and_3` check for Azure provider, verifying each subscription has between 2 and 3 principals with the Owner role, including owners inherited from management groups, mapped to CIS 5.0 and 6.0
@@ -0,0 +1 @@
Security group internet exposure checks now treat CIDRs in one ingress rule that together cover 0.0.0.0/0 or ::/0 as open to the internet
@@ -1 +0,0 @@
MongoDB Atlas connection errors now include the HTTP status, error code and detail returned by Atlas instead of a generic HTTP error, and a custom exception message no longer persists into later errors of the same type
@@ -0,0 +1 @@
pymongo to 4.18.3, dnspython to 2.9.0 and oauthlib to 4.0.0 in the SDK, patching the BSON decoding and DNS compression pointer denial of service advisories and the OAuth device flow token disclosure; dnspython stays on 2.8.0 for Python 3.10, which 2.9.0 no longer supports
@@ -1 +0,0 @@
Python base image from 3.12.15 to 3.12.16 in the SDK container image, patching CVE-2026-115557
+1 -1
View File
@@ -52,7 +52,7 @@ class _MutableTimestamp:
timestamp = _MutableTimestamp(datetime.today())
timestamp_utc = _MutableTimestamp(datetime.now(timezone.utc))
prowler_version = "5.48.0"
prowler_version = "5.47.0"
html_logo_url = "https://github.com/prowler-cloud/prowler/"
square_logo_img = "https://raw.githubusercontent.com/prowler-cloud/prowler/dc7d2d5aeb92fdf12e8604f42ef6472cd3e8e889/docs/img/prowler-logo-black.png"
aws_logo = "https://user-images.githubusercontent.com/38561120/235953920-3e3fba08-0795-41dc-b480-9bea57db9f2e.png"
@@ -38,9 +38,7 @@ class MongoDBAtlasBaseException(ProwlerException):
def __init__(self, code, file=None, original_exception=None, message=None):
provider = "MongoDB Atlas"
error_info = dict(
self.MONGODBATLAS_ERROR_CODES.get((code, self.__class__.__name__))
)
error_info = self.MONGODBATLAS_ERROR_CODES.get((code, self.__class__.__name__))
if message:
error_info["message"] = message
super().__init__(
@@ -236,12 +236,7 @@ class MongodbatlasProvider(Provider):
message="MongoDB Atlas authentication failed. Please check your API credentials.",
)
if not response.ok:
raise MongoDBAtlasIdentityError(
file=os.path.basename(__file__),
message=MongodbatlasProvider._describe_api_error(response),
)
response.raise_for_status()
organizations_data = response.json()
# Extract organization information from the response
@@ -271,7 +266,7 @@ class MongodbatlasProvider(Provider):
return identity
except (MongoDBAtlasAuthenticationError, MongoDBAtlasIdentityError):
except MongoDBAtlasAuthenticationError:
raise
except Exception as error:
logger.critical(
@@ -281,22 +276,6 @@ class MongodbatlasProvider(Provider):
original_exception=error,
)
@staticmethod
def _describe_api_error(response) -> str:
"""Build an identity error message that keeps the reason Atlas returns."""
message = f"MongoDB Atlas identity setup failed - HTTP {response.status_code}"
try:
body = response.json()
except ValueError:
return message
if not isinstance(body, dict):
return message
if body.get("errorCode"):
message += f" {body['errorCode']}"
if body.get("detail"):
message += f": {body['detail']}"
return message
def print_credentials(self):
"""Print the MongoDB Atlas credentials"""
report_lines = [
+1 -1
View File
@@ -145,7 +145,7 @@ maintainers = [{name = "Prowler Engineering", email = "engineering@prowler.com"}
name = "prowler"
readme = "README.md"
requires-python = ">=3.10,<3.14"
version = "5.48.0"
version = "5.47.0"
[project.scripts]
prowler = "prowler.__main__:prowler"
@@ -136,60 +136,6 @@ class TestMongodbatlasProvider:
with pytest.raises(MongoDBAtlasIdentityError):
MongodbatlasProvider.setup_identity(session)
@patch("requests.get")
def test_setup_identity_forbidden_keeps_atlas_error_detail(self, mock_get):
mock_response = MagicMock()
mock_response.status_code = 403
mock_response.ok = False
mock_response.json.return_value = {
"detail": "IP address 203.0.113.10 is not allowed to access this resource.",
"error": 403,
"errorCode": "IP_ADDRESS_NOT_ON_ACCESS_LIST",
"reason": "Forbidden",
}
mock_get.return_value = mock_response
session = MongoDBAtlasSession(
public_key=ATLAS_PUBLIC_KEY,
private_key=ATLAS_PRIVATE_KEY,
)
with pytest.raises(MongoDBAtlasIdentityError) as exc_info:
MongodbatlasProvider.setup_identity(session)
assert str(exc_info.value) == (
"MongoDBAtlasIdentityError[8003]: MongoDB Atlas identity setup failed"
" - HTTP 403 IP_ADDRESS_NOT_ON_ACCESS_LIST: IP address 203.0.113.10"
" is not allowed to access this resource."
)
@patch("requests.get")
def test_setup_identity_error_without_json_body_reports_status(self, mock_get):
mock_response = MagicMock()
mock_response.status_code = 502
mock_response.ok = False
mock_response.json.side_effect = ValueError("No JSON")
mock_get.return_value = mock_response
session = MongoDBAtlasSession(
public_key=ATLAS_PUBLIC_KEY,
private_key=ATLAS_PRIVATE_KEY,
)
with pytest.raises(MongoDBAtlasIdentityError) as exc_info:
MongodbatlasProvider.setup_identity(session)
assert exc_info.value.message == (
"MongoDB Atlas identity setup failed - HTTP 502"
)
def test_custom_message_does_not_leak_into_later_exceptions(self):
MongoDBAtlasIdentityError(message="custom detail")
assert (
MongoDBAtlasIdentityError().message == "MongoDB Atlas identity setup failed"
)
def test_test_connection_success(self):
"""Test successful connection test"""
with (
-20
View File
@@ -4,26 +4,6 @@ All notable changes to the **Prowler UI** are documented in this file.
<!-- changelog: release notes start -->
## [1.47.0] (Prowler v5.47.0)
### 🚀 Added
- Icon for the NATO D32 compliance framework on the compliance cards [(#13174)](https://github.com/prowler-cloud/prowler/pull/13174)
### 🔄 Changed
- Invitations page uses `Status` instead of `State` in the filter, table column and invitation details, and the Invitations and Roles filters show capitalized options [(#13175)](https://github.com/prowler-cloud/prowler/pull/13175)
- Product tours and the guided onboarding sequence are no longer shown; new tenants still land on the add-provider wizard [(#13176)](https://github.com/prowler-cloud/prowler/pull/13176)
- Add provider wizard connects every provider in one step, saving the account and credentials and testing the connection together, with teammate invitations for every provider and Lighthouse AI help beside the steps [(#13181)](https://github.com/prowler-cloud/prowler/pull/13181)
### 🐞 Fixed
- User avatar requesting the current page as an image on every load, which rendered each page twice [(#13177)](https://github.com/prowler-cloud/prowler/pull/13177)
- Lighthouse AI reply blanking out for a frame when the response finishes streaming, which made the whole text blink and reappear [(#13180)](https://github.com/prowler-cloud/prowler/pull/13180)
- Compliance cross-provider tab no longer blanks out when a single catalog page or framework fails to load; it shows the frameworks that loaded with a warning instead [(#13183)](https://github.com/prowler-cloud/prowler/pull/13183)
---
## [1.46.0] (Prowler v5.46.0)
### 🚀 Added
+1
View File
@@ -0,0 +1 @@
export * from "./realtime";
@@ -0,0 +1,375 @@
import { describe, expect, it } from "vitest";
import {
toAction,
toDemoPayload,
toEvent,
toNotifications,
toNotificationTargets,
toRemediation,
toSetup,
toFramework,
toFieldErrors,
toInsightPayload,
toPage,
toPolicy,
toRequestBody,
} from "./realtime.adapter";
const resource = (attributes: Record<string, unknown>, extra = {}) => ({
id: "id-1",
type: "x",
attributes,
...extra,
});
describe("realtime adapter", () => {
it("maps a policy and falls back to the API's safe defaults", () => {
const policy = toPolicy(
resource(
{ enabled: true, mode: "bogus", scope_tags: { env: "prod" } },
{
relationships: {
provider: { data: { id: "prov-1", type: "providers" } },
},
},
),
);
expect(policy).toMatchObject({
id: "id-1",
provider_id: "prov-1",
enabled: true,
mode: "pr",
dry_run: true,
min_severity: "low",
scope_tags: { env: "prod" },
deny_checks: [],
});
});
it("maps the new policy fields and defaults them like the API", () => {
expect(
toPolicy(
resource({
use_lighthouse: false,
frameworks: ["soc2_aws", 3],
cost_anomaly_enabled: true,
cost_anomaly_min_ratio: 3.5,
cost_anomaly_min_delta: 12,
}),
),
).toMatchObject({
use_lighthouse: false,
frameworks: ["soc2_aws"],
cost_anomaly_enabled: true,
cost_anomaly_min_ratio: 3.5,
cost_anomaly_min_delta: 12,
});
expect(toPolicy(resource({}))).toMatchObject({
use_lighthouse: true,
frameworks: [],
cost_anomaly_enabled: false,
cost_anomaly_min_ratio: 2,
cost_anomaly_min_delta: 5,
});
});
it("maps a framework of the catalog", () => {
expect(
toFramework({
id: "soc2_aws",
type: "realtime-frameworks",
attributes: { name: "SOC 2", version: "2017" },
}),
).toEqual({ id: "soc2_aws", name: "SOC 2", version: "2017" });
expect(toFramework({ id: "x_aws", type: "realtime-frameworks" })).toEqual({
id: "x_aws",
name: "x_aws",
version: "",
});
});
it("maps an action with its detection relationship", () => {
const action = toAction(
resource(
{ kind: "self_fix", status: "applied", revertible: true },
{ relationships: { detection: { data: { id: "d1", type: "x" } } } },
),
);
expect(action.detection_id).toBe("d1");
expect(action.revertible).toBe(true);
});
it("keeps the event id as the resource id and a null detection", () => {
const event = toEvent(resource({ event_name: "RunInstances" }));
expect(event.id).toBe("id-1");
expect(event.detection_status).toBeNull();
expect(event.rule_id).toBeNull();
});
it("builds a page from JSON:API pagination meta", () => {
const page = toPage(
{
data: [resource({ kind: "code", status: "queued" })],
meta: { pagination: { page: 2, pages: 3, count: 61 } },
},
toAction,
);
expect(page).toMatchObject({ page: 2, pages: 3, count: 61 });
expect(page.items).toHaveLength(1);
});
it("returns an insight payload only for the requested kind", () => {
const document = {
data: resource({ kind: "simulation", payload: { open: 3 } }),
};
expect(toInsightPayload(document, "simulation")).toEqual({ open: 3 });
expect(toInsightPayload(document, "overview")).toBeNull();
expect(toInsightPayload(undefined, "overview")).toBeNull();
});
it("keys field errors by attribute name from the pointer", () => {
expect(
toFieldErrors([
{
detail: "Unknown severity.",
source: { pointer: "/data/attributes/min_severity" },
},
{ detail: "General" },
{
detail: "Second",
source: { pointer: "/data/attributes/min_severity" },
},
]),
).toEqual({ min_severity: "Unknown severity." });
});
it("builds a JSON:API body with the provider relationship only when given", () => {
expect(
toRequestBody({
type: "realtime-policies",
attributes: { mode: "pr" },
providerId: "p1",
}),
).toEqual({
data: {
type: "realtime-policies",
attributes: { mode: "pr" },
relationships: { provider: { data: { type: "providers", id: "p1" } } },
},
});
expect(
toRequestBody({ type: "t", id: "i", attributes: {} }).data,
).not.toHaveProperty("relationships");
});
it("reads the alert settings of a policy and defaults to sending nothing", () => {
expect(toPolicy(resource({})).notifications).toEqual({
min_severity: "high",
slack: { enabled: false, channel_ids: [] },
email: { enabled: false, recipient_ids: [] },
});
expect(
toPolicy(
resource({
notifications: {
min_severity: "critical",
slack: { enabled: true, channel_ids: ["C1", 2] },
email: { enabled: true, recipient_ids: ["r1"] },
},
}),
).notifications,
).toEqual({
min_severity: "critical",
slack: { enabled: true, channel_ids: ["C1"] },
email: { enabled: true, recipient_ids: ["r1"] },
});
expect(toNotifications("nonsense").slack.enabled).toBe(false);
});
it("reads alert destinations from a single resource or a one-element list", () => {
const attributes = {
slack: [
{ integration_id: "i1", channel_id: "C1", name: "security" },
{ integration_id: "i1", name: "no id" },
],
email: [
{ id: "r1", email: "a@example.com", status: "confirmed" },
{ id: "r2", email: "b@example.com", status: "pending" },
],
};
const expected = {
slack: [{ integration_id: "i1", channel_id: "C1", name: "security" }],
email: [
{ id: "r1", email: "a@example.com", status: "confirmed" },
{ id: "r2", email: "b@example.com", status: "pending" },
],
};
expect(toNotificationTargets({ data: resource(attributes) })).toEqual(
expected,
);
expect(toNotificationTargets({ data: [resource(attributes)] })).toEqual(
expected,
);
expect(toNotificationTargets(undefined)).toEqual({ slack: [], email: [] });
});
it("maps a remediation", () => {
expect(
toRemediation({
id: "security-group-ingress",
type: "realtime-remediations",
attributes: {
title: "Close open security group rules",
effect: "Removes the rule",
checks: ["a", "b"],
active_checks: ["a"],
iam_actions: ["ec2:RevokeSecurityGroupIngress"],
revertible: true,
undo: "Adds the rule back",
in_enforced_scope: true,
},
}),
).toEqual({
id: "security-group-ingress",
title: "Close open security group rules",
effect: "Removes the rule",
checks: ["a", "b"],
active_checks: ["a"],
iam_actions: ["ec2:RevokeSecurityGroupIngress"],
revertible: true,
undo: "Adds the rule back",
in_enforced_scope: true,
});
});
it("maps the setup progress and drops unknown steps", () => {
expect(
toSetup({
data: resource({
payload: {
activated: false,
next_step: "enforcement",
steps: [
{ id: "feed", required: true, done: true, detail: "SQS" },
{ id: "future", required: true, done: false, detail: null },
{ id: "alerts", required: false, done: false, detail: "" },
],
},
}),
}),
).toEqual({
activated: false,
next_step: "enforcement",
steps: [
{ id: "feed", required: true, done: true, detail: "SQS" },
{ id: "alerts", required: false, done: false, detail: null },
],
});
expect(toSetup({ data: resource({}) })).toBeNull();
expect(
toSetup({
data: resource({ payload: { activated: true, next_step: null } }),
}),
).toEqual({ activated: true, next_step: null, steps: [] });
});
it("reads demo insight payloads as they are", () => {
const payload = { open: 3 };
expect(
toDemoPayload(
{ data: resource({ kind: "simulation", payload }) },
"simulation",
),
).toEqual(payload);
expect(
toDemoPayload({ data: resource({ payload }) }, "simulation"),
).toEqual(payload);
expect(
toDemoPayload(
{ data: resource({ kind: "overview", payload }) },
"simulation",
),
).toBeNull();
expect(toDemoPayload({ data: resource({}) }, "status")).toBeNull();
});
it("maps demo list rows with the same mappers as the real lists", () => {
const events = toDemoPayload(
{
data: resource({
payload: {
items: [
{
id: "e1",
event_name: "RunInstances",
read_only: false,
provider_id: "demo",
},
],
},
}),
},
"events",
);
const detections = toDemoPayload(
{
data: resource({
payload: {
items: [
{
id: "d1",
title: "Open SG",
status: "violation",
provider_id: "demo",
},
],
},
}),
},
"detections",
);
const actions = toDemoPayload(
{
data: resource({
payload: {
items: [
{
id: "a1",
kind: "self_fix",
status: "fixed_verified",
revertible: true,
inserted_at: "2026-10-23T10:00:00Z",
},
],
},
}),
},
"actions",
);
expect(events).toMatchObject({
page: 1,
pages: 1,
count: 1,
items: [{ id: "e1", event_name: "RunInstances", provider_id: "demo" }],
});
expect(detections?.items[0]).toMatchObject({
id: "d1",
status: "violation",
provider_id: "demo",
});
expect(actions?.items[0]).toMatchObject({
id: "a1",
revertible: true,
inserted_at: "2026-10-23T10:00:00Z",
});
});
});
+468
View File
@@ -0,0 +1,468 @@
import {
REALTIME_DATA_STORE,
REALTIME_DETECTION_STATUS,
REALTIME_FEED_STATUS,
REALTIME_INSIGHT_KIND,
REALTIME_MODE,
REALTIME_MIN_SEVERITY,
REALTIME_SETUP_STEP,
type RealtimeAction,
type RealtimeApiDocument,
type RealtimeApiError,
type RealtimeApiResource,
type RealtimeCredential,
type RealtimeDetection,
type RealtimeEvent,
type RealtimeFeed,
type RealtimeFramework,
type RealtimeHealth,
type RealtimeDemoKind,
type RealtimeDemoPayloads,
type RealtimeInsightKind,
type RealtimeInsightPayloads,
type RealtimeNotifications,
type RealtimeNotificationTargets,
type RealtimePage,
type RealtimePolicy,
type RealtimeRemediation,
type RealtimeSetupPayload,
type RealtimeSetupStepId,
} from "@/types/realtime";
const asString = (value: unknown, fallback = ""): string =>
typeof value === "string" ? value : fallback;
const asNullableString = (value: unknown): string | null =>
typeof value === "string" && value ? value : null;
const asBoolean = (value: unknown, fallback = false): boolean =>
typeof value === "boolean" ? value : fallback;
const asNumber = (value: unknown, fallback = 0): number =>
typeof value === "number" && Number.isFinite(value) ? value : fallback;
const asStringList = (value: unknown): string[] =>
Array.isArray(value)
? value.filter((item): item is string => typeof item === "string")
: [];
const asStringMap = (value: unknown): Record<string, string> =>
value && typeof value === "object" && !Array.isArray(value)
? Object.fromEntries(
Object.entries(value).map(([key, item]) => [key, String(item)]),
)
: {};
const oneOf = <T extends string>(
value: unknown,
allowed: readonly T[],
fallback: T,
): T => (allowed.includes(value as T) ? (value as T) : fallback);
const relatedId = (resource: RealtimeApiResource, name: string): string =>
resource.relationships?.[name]?.data?.id ?? "";
/** The resources of a JSON:API document, whether it holds one or many. */
export const resourcesOf = (
document: RealtimeApiDocument | undefined,
): RealtimeApiResource[] => {
const data = document?.data;
if (!data) return [];
return Array.isArray(data) ? data : [data];
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value) && typeof value === "object" && !Array.isArray(value);
/** The policy's alert settings; anything missing falls back to "nothing is sent". */
export const toNotifications = (value: unknown): RealtimeNotifications => {
const n = isRecord(value) ? value : {};
const slack = isRecord(n.slack) ? n.slack : {};
const email = isRecord(n.email) ? n.email : {};
return {
min_severity: oneOf(
n.min_severity,
Object.values(REALTIME_MIN_SEVERITY),
REALTIME_MIN_SEVERITY.HIGH,
),
slack: {
enabled: asBoolean(slack.enabled),
channel_ids: asStringList(slack.channel_ids),
},
email: {
enabled: asBoolean(email.enabled),
recipient_ids: asStringList(email.recipient_ids),
},
};
};
export const toPolicy = (resource: RealtimeApiResource): RealtimePolicy => {
const a = resource.attributes ?? {};
return {
id: resource.id,
provider_id: relatedId(resource, "provider"),
enabled: asBoolean(a.enabled),
// Same safe defaults as the API: react in code review, change nothing.
mode: oneOf(a.mode, Object.values(REALTIME_MODE), REALTIME_MODE.PR),
dry_run: asBoolean(a.dry_run, true),
min_severity: oneOf(
a.min_severity,
Object.values(REALTIME_MIN_SEVERITY),
REALTIME_MIN_SEVERITY.LOW,
),
deny_checks: asStringList(a.deny_checks),
allow_regions: asStringList(a.allow_regions),
scope_tags: asStringMap(a.scope_tags),
iac_repo: asString(a.iac_repo),
iac_path: asString(a.iac_path),
open_pr: asBoolean(a.open_pr),
use_lighthouse: asBoolean(a.use_lighthouse, true),
frameworks: asStringList(a.frameworks),
cost_anomaly_enabled: asBoolean(a.cost_anomaly_enabled),
cost_anomaly_min_ratio: asNumber(a.cost_anomaly_min_ratio, 2),
cost_anomaly_min_delta: asNumber(a.cost_anomaly_min_delta, 5),
notifications: toNotifications(a.notifications),
};
};
const asRecords = (value: unknown): Array<Record<string, unknown>> =>
Array.isArray(value) ? value.filter(isRecord) : [];
/**
* Where alerts can go. The API sends one `realtime-notification-targets`
* resource, or a one-element list; both read the same.
*/
export const toNotificationTargets = (
document: RealtimeApiDocument | undefined,
): RealtimeNotificationTargets => {
const [resource] = resourcesOf(document);
const a = resource?.attributes ?? {};
return {
slack: asRecords(a.slack)
.map((item) => ({
integration_id: asString(item.integration_id),
channel_id: asString(item.channel_id),
name: asString(item.name),
}))
.filter((item) => item.channel_id),
email: asRecords(a.email)
.map((item) => ({
id: asString(item.id),
email: asString(item.email),
status: asString(item.status),
}))
.filter((item) => item.id),
};
};
export const toRemediation = (
resource: RealtimeApiResource,
): RealtimeRemediation => {
const a = resource.attributes ?? {};
return {
id: resource.id,
title: asString(a.title, resource.id),
effect: asString(a.effect),
checks: asStringList(a.checks),
active_checks: asStringList(a.active_checks),
iam_actions: asStringList(a.iam_actions),
revertible: asBoolean(a.revertible),
undo: asString(a.undo),
in_enforced_scope: asBoolean(a.in_enforced_scope),
};
};
const SETUP_STEP_IDS: readonly string[] = Object.values(REALTIME_SETUP_STEP);
/** The wizard's progress; unknown steps are dropped so a newer API cannot break it. */
export const toSetup = (
document: RealtimeApiDocument | undefined,
): RealtimeSetupPayload | null => {
const [resource] = resourcesOf(document);
const payload = resource?.attributes?.payload;
if (!isRecord(payload)) return null;
const next = payload.next_step;
return {
activated: asBoolean(payload.activated),
next_step: SETUP_STEP_IDS.includes(next as string)
? (next as RealtimeSetupStepId)
: null,
steps: asRecords(payload.steps)
.filter((step) => SETUP_STEP_IDS.includes(step.id as string))
.map((step) => ({
id: step.id as RealtimeSetupStepId,
required: asBoolean(step.required),
done: asBoolean(step.done),
detail: asNullableString(step.detail),
})),
};
};
export const toFramework = (
resource: RealtimeApiResource,
): RealtimeFramework => {
const a = resource.attributes ?? {};
return {
id: resource.id,
name: asString(a.name, resource.id),
version: asString(a.version),
};
};
export const toFeed = (resource: RealtimeApiResource): RealtimeFeed => {
const a = resource.attributes ?? {};
return {
id: resource.id,
provider_id: relatedId(resource, "provider"),
queue_url: asString(a.queue_url),
region: asString(a.region, "us-east-1"),
status: oneOf(
a.status,
Object.values(REALTIME_FEED_STATUS),
REALTIME_FEED_STATUS.NOT_CONFIGURED,
),
last_polled_at: asNullableString(a.last_polled_at),
last_event_at: asNullableString(a.last_event_at),
last_error: asString(a.last_error),
};
};
export const toCredential = (
resource: RealtimeApiResource,
): RealtimeCredential => {
const a = resource.attributes ?? {};
return {
id: resource.id,
provider_id: relatedId(resource, "provider"),
role_arn: asString(a.role_arn),
has_github_token: asBoolean(a.has_github_token),
};
};
export const toDetection = (
resource: RealtimeApiResource,
): RealtimeDetection => {
const a = resource.attributes ?? {};
return {
id: resource.id,
provider_id: relatedId(resource, "provider"),
event_id: asString(a.event_id),
event_time: asString(a.event_time),
region: asString(a.region),
rule_id: asString(a.rule_id),
title: asString(a.title),
severity: asString(a.severity),
actor: asString(a.actor),
resource: asString(a.resource),
check_ids: asStringList(a.check_ids),
requirements: asStringList(a.requirements),
status: oneOf(
a.status,
Object.values(REALTIME_DETECTION_STATUS),
REALTIME_DETECTION_STATUS.NEW,
),
note: asString(a.note),
verified_at: asNullableString(a.verified_at),
};
};
export const toAction = (resource: RealtimeApiResource): RealtimeAction => {
const a = resource.attributes ?? {};
return {
id: resource.id,
provider_id: relatedId(resource, "provider"),
detection_id: relatedId(resource, "detection") || null,
kind: a.kind as RealtimeAction["kind"],
status: a.status as RealtimeAction["status"],
check_id: asString(a.check_id),
resource_uid: asString(a.resource_uid),
region: asString(a.region),
severity: asString(a.severity),
summary: asString(a.summary),
pr_url: asString(a.pr_url),
branch: asString(a.branch),
revertible: asBoolean(a.revertible),
inserted_at: asString(a.inserted_at),
};
};
export const toEvent = (resource: RealtimeApiResource): RealtimeEvent => {
const a = resource.attributes ?? {};
return {
// The API serialises the CloudTrail event id as the resource id.
id: resource.id,
event_time: asString(a.event_time),
region: asString(a.region),
event_source: asString(a.event_source),
event_name: asString(a.event_name),
user_type: asString(a.user_type),
user_arn: asString(a.user_arn),
source_ip: asString(a.source_ip),
error_code: asString(a.error_code),
read_only: asBoolean(a.read_only),
provider_id: asString(a.provider_id),
detection_status:
(a.detection_status as RealtimeEvent["detection_status"]) ?? null,
rule_id: asNullableString(a.rule_id),
};
};
export const toHealth = (resource: RealtimeApiResource): RealtimeHealth => {
const a = resource.attributes ?? {};
return {
enabled: asBoolean(a.enabled),
clickhouse: oneOf(
a.clickhouse,
Object.values(REALTIME_DATA_STORE),
REALTIME_DATA_STORE.UNAVAILABLE,
),
events_last_hour: asNumber(a.events_last_hour),
changes_last_hour: asNumber(a.changes_last_hour),
};
};
/** One page of a JSON:API list, mapped through `map`. */
export const toPage = <T>(
document: RealtimeApiDocument | undefined,
map: (resource: RealtimeApiResource) => T,
): RealtimePage<T> => {
const items = resourcesOf(document).map(map);
const pagination = document?.meta?.pagination;
return {
items,
page: pagination?.page ?? 1,
pages: pagination?.pages ?? 1,
count: pagination?.count ?? items.length,
};
};
/**
* The payload of a `realtime-insights` resource. Null when the document does not
* carry one of the requested kind, so a panel shows its error state instead of
* rendering a payload of the wrong shape.
*/
export const toInsightPayload = <K extends RealtimeInsightKind>(
document: RealtimeApiDocument | undefined,
kind: K,
): RealtimeInsightPayloads[K] | null => {
const [resource] = resourcesOf(document);
const attributes = resource?.attributes;
if (!attributes || attributes.kind !== kind) return null;
const payload = attributes.payload;
if (!isRecord(payload)) return null;
// The cost panel reads these during render; without them it would throw.
if (
kind === REALTIME_INSIGHT_KIND.COST &&
payload.enabled === true &&
!(
isRecord(payload.total) &&
Array.isArray(payload.series) &&
Array.isArray(payload.anomalies)
)
) {
return null;
}
return payload as unknown as RealtimeInsightPayloads[K];
};
/**
* Per-attribute messages of a 400, keyed by attribute name: the API points at
* `/data/attributes/<name>` (or `/data/relationships/<name>`). Errors without
* a pointer carry no field and are left to the generic message.
*/
export const toFieldErrors = (
errors: RealtimeApiError[] | undefined,
): Record<string, string> => {
const fieldErrors: Record<string, string> = {};
(errors ?? []).forEach((error) => {
const field = error.source?.pointer?.split("/").filter(Boolean).pop();
if (field && field !== "data" && error.detail && !fieldErrors[field]) {
fieldErrors[field] = error.detail;
}
});
return fieldErrors;
};
/** JSON:API request body for a create or update. */
export const toRequestBody = ({
type,
attributes,
id,
providerId,
}: {
type: string;
attributes: Record<string, unknown>;
id?: string;
providerId?: string;
}) => ({
data: {
type,
...(id ? { id } : {}),
attributes,
...(providerId
? {
relationships: {
provider: { data: { type: "providers", id: providerId } },
},
}
: {}),
},
});
/** A plain demo row dressed as the resource the real row mappers expect. */
const demoRowToResource = (
row: Record<string, unknown>,
index: number,
): RealtimeApiResource => ({
id: asString(row.id, `demo-${index}`),
type: "realtime-demo",
attributes: row,
relationships: {
provider: asString(row.provider_id)
? { data: { id: asString(row.provider_id), type: "providers" } }
: undefined,
detection: asString(row.detection_id)
? { data: { id: asString(row.detection_id), type: "detections" } }
: undefined,
},
});
const DEMO_LIST_MAPPERS = {
events: toEvent,
detections: toDetection,
actions: toAction,
} as const;
/**
* The payload of a demo resource. The insight views have the real shape; the
* lists carry their rows in `payload.items`. Null when the answer is not in
* the expected shape, so a panel shows its error state, never a half view.
*/
export const toDemoPayload = <K extends RealtimeDemoKind>(
document: RealtimeApiDocument | undefined,
kind: K,
): RealtimeDemoPayloads[K] | null => {
const [resource] = resourcesOf(document);
const attributes = resource?.attributes;
if (!attributes) return null;
if (typeof attributes.kind === "string" && attributes.kind !== kind) {
return null;
}
const payload = attributes.payload;
if (!isRecord(payload)) return null;
if (kind in DEMO_LIST_MAPPERS) {
if (!Array.isArray(payload.items)) return null;
const map = DEMO_LIST_MAPPERS[kind as keyof typeof DEMO_LIST_MAPPERS];
const items = asRecords(payload.items).map((row, index) =>
map(demoRowToResource(row, index)),
);
return {
items,
page: 1,
pages: 1,
count: items.length,
} as unknown as RealtimeDemoPayloads[K];
}
return payload as unknown as RealtimeDemoPayloads[K];
};
+806
View File
@@ -0,0 +1,806 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { fetchMock, getAuthHeadersMock, handleApiResponseMock } = vi.hoisted(
() => ({
fetchMock: vi.fn(),
getAuthHeadersMock: vi.fn(),
handleApiResponseMock: vi.fn(),
}),
);
vi.mock("@/lib", () => ({
apiBaseUrl: "https://api.example.com/api/v1",
getAuthHeaders: getAuthHeadersMock,
}));
vi.mock("@/lib/server-actions-helper", () => ({
handleApiResponse: handleApiResponseMock,
}));
import {
getRealtimeCostAnomaly,
getRealtimeDemo,
getRealtimeEvents,
getRealtimeFrameworks,
getRealtimeInsight,
getRealtimeNotificationTargets,
getRealtimeRemediations,
getRealtimeSetup,
patchRealtimePolicy,
pollRealtimeFeed,
revertRealtimeAction,
saveRealtimeCostSettings,
saveRealtimeCredential,
saveRealtimeNotifications,
saveRealtimePolicy,
} from "./realtime";
const PROVIDER = "0b8c2b46-7f3a-4d1e-9a52-3f5f1d6a7c10";
const ACTION = "6f1d2c3b-1a2b-4c3d-8e9f-0a1b2c3d4e5f";
const NEW_FIELDS = {
use_lighthouse: true,
frameworks: [],
cost_anomaly_enabled: false,
cost_anomaly_min_ratio: 2,
cost_anomaly_min_delta: 5,
notifications: {
min_severity: "high" as const,
slack: { enabled: false, channel_ids: [] },
email: { enabled: false, recipient_ids: [] },
},
};
const json = (body: unknown, status: number) =>
new Response(JSON.stringify(body), { status });
describe("realtime actions", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal("fetch", fetchMock);
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
vi.spyOn(console, "error").mockImplementation(() => {});
});
it("rejects a malformed provider id without calling the API", async () => {
const result = await getRealtimeEvents({ providerId: "nope" });
expect(result).toMatchObject({ ok: false, status: 400 });
expect(fetchMock).not.toHaveBeenCalled();
});
it("answers 503 from the event stream as an expected failure, not an error report", async () => {
fetchMock.mockResolvedValue(
json({ errors: [{ detail: "ClickHouse is not configured." }] }, 503),
);
const result = await getRealtimeEvents({ providerId: PROVIDER });
expect(result).toEqual({
ok: false,
status: 503,
error: "ClickHouse is not configured.",
});
expect(handleApiResponseMock).not.toHaveBeenCalled();
});
it("sends the provider, a bounded limit and the writes-only flag", async () => {
fetchMock.mockResolvedValue(json({ data: [] }, 200));
handleApiResponseMock.mockResolvedValue({ data: [] });
await getRealtimeEvents({
providerId: PROVIDER,
limit: 9999,
writesOnly: true,
});
const url = new URL(fetchMock.mock.calls[0][0]);
expect(url.pathname).toBe("/api/v1/realtime/events");
expect(url.searchParams.get("provider")).toBe(PROVIDER);
expect(url.searchParams.get("limit")).toBe("500");
expect(url.searchParams.get("writes_only")).toBe("true");
});
it("returns a 403 from the API as a failure the panel can explain", async () => {
fetchMock.mockResolvedValue(json({}, 403));
handleApiResponseMock.mockResolvedValue({
error: "Forbidden",
status: 403,
});
await expect(
getRealtimeInsight("overview", PROVIDER),
).resolves.toMatchObject({ ok: false, status: 403 });
});
it("maps a cost insight payload", async () => {
fetchMock.mockResolvedValue(json({}, 200));
handleApiResponseMock.mockResolvedValue({
data: {
id: "cost:x",
type: "realtime-insights",
attributes: {
kind: "cost",
payload: { enabled: false },
},
},
});
const result = await getRealtimeInsight("cost", PROVIDER);
expect(result.ok && result.data).toEqual({ enabled: false });
});
it("does not report a network failure as an exception", async () => {
fetchMock.mockRejectedValue(new Error("boom"));
await expect(getRealtimeInsight("status", PROVIDER)).resolves.toMatchObject(
{ ok: false, status: 0 },
);
});
it("validates a policy before sending it", async () => {
const result = await saveRealtimePolicy({
providerId: PROVIDER,
policy: {
enabled: true,
mode: "pr",
dry_run: true,
min_severity: "low",
deny_checks: [],
allow_regions: [],
scope_tags: { "a=b": "c" },
iac_repo: "",
iac_path: "",
open_pr: false,
...NEW_FIELDS,
},
});
expect(result).toMatchObject({ ok: false, status: 400 });
expect(fetchMock).not.toHaveBeenCalled();
});
it("creates a policy with the provider relationship and patches an existing one", async () => {
fetchMock.mockResolvedValue(json({}, 200));
handleApiResponseMock.mockResolvedValue({
data: { id: "pol", type: "realtime-policies", attributes: {} },
});
const policy = {
enabled: false,
mode: "pr" as const,
dry_run: true,
min_severity: "low" as const,
deny_checks: [],
allow_regions: [],
scope_tags: {},
iac_repo: "",
iac_path: "",
open_pr: false,
...NEW_FIELDS,
};
await saveRealtimePolicy({ providerId: PROVIDER, policy });
await saveRealtimePolicy({
providerId: PROVIDER,
policyId: ACTION,
policy,
});
const [create, update] = fetchMock.mock.calls;
expect(create[1].method).toBe("POST");
expect(JSON.parse(create[1].body).data.relationships.provider.data.id).toBe(
PROVIDER,
);
expect(update[1].method).toBe("PATCH");
expect(new URL(update[0]).pathname).toBe(
`/api/v1/realtime/policies/${ACTION}`,
);
});
it("leaves empty credential fields out of the request", async () => {
fetchMock.mockResolvedValue(json({}, 201));
handleApiResponseMock.mockResolvedValue({
data: { id: "c", type: "realtime-credentials", attributes: {} },
});
await saveRealtimeCredential({
providerId: PROVIDER,
credential: {
github_token: "ghp_example",
role_arn: "",
external_id: "",
},
});
expect(JSON.parse(fetchMock.mock.calls[0][1].body).data.attributes).toEqual(
{ github_token: "ghp_example" },
);
});
it("requires an external id with a role", async () => {
const result = await saveRealtimeCredential({
providerId: PROVIDER,
credential: { role_arn: "arn:aws:iam::123456789012:role/ProwlerX" },
});
expect(result).toMatchObject({
ok: false,
fieldErrors: { external_id: expect.any(String) },
});
});
it("surfaces the reason AWS refused a revert", async () => {
fetchMock.mockResolvedValue(
json(
{ errors: [{ detail: "The change could not be reverted: x" }] },
409,
),
);
await expect(revertRealtimeAction(ACTION)).resolves.toEqual({
ok: false,
status: 409,
error: "The change could not be reverted: x",
});
});
it("treats the empty 202 of a poll as success", async () => {
fetchMock.mockResolvedValue(new Response(null, { status: 202 }));
handleApiResponseMock.mockResolvedValue({ success: true, status: 202 });
await expect(pollRealtimeFeed(ACTION)).resolves.toEqual({
ok: true,
data: { queued: true },
});
});
it("creates a policy from the cost page with only the cost fields, patches without touching the rest", async () => {
fetchMock.mockResolvedValue(json({}, 200));
handleApiResponseMock.mockResolvedValue({
data: { id: "pol", type: "realtime-policies", attributes: {} },
});
const settings = {
cost_anomaly_enabled: true,
cost_anomaly_min_ratio: 3,
cost_anomaly_min_delta: 10,
};
await saveRealtimeCostSettings({ providerId: PROVIDER, settings });
await saveRealtimeCostSettings({
providerId: PROVIDER,
policyId: ACTION,
settings,
});
const [create, update] = fetchMock.mock.calls;
expect(create[1].method).toBe("POST");
const created = JSON.parse(create[1].body).data;
expect(created.attributes).toEqual(settings);
expect(created.relationships.provider.data.id).toBe(PROVIDER);
expect(update[1].method).toBe("PATCH");
expect(JSON.parse(update[1].body).data.attributes).toEqual(settings);
});
it("rejects cost thresholds outside the API ranges without calling it", async () => {
const tooLow = await saveRealtimeCostSettings({
providerId: PROVIDER,
settings: {
cost_anomaly_enabled: true,
cost_anomaly_min_ratio: 1.05,
cost_anomaly_min_delta: 5,
},
});
const tooHigh = await saveRealtimeCostSettings({
providerId: PROVIDER,
settings: {
cost_anomaly_enabled: true,
cost_anomaly_min_ratio: 2,
cost_anomaly_min_delta: 100001,
},
});
expect(tooLow).toMatchObject({
ok: false,
status: 400,
fieldErrors: { cost_anomaly_min_ratio: expect.any(String) },
});
expect(tooHigh).toMatchObject({
ok: false,
fieldErrors: { cost_anomaly_min_delta: expect.any(String) },
});
expect(fetchMock).not.toHaveBeenCalled();
});
it("lists the frameworks of the catalog", async () => {
fetchMock.mockResolvedValue(json({}, 200));
handleApiResponseMock.mockResolvedValue({
data: [
{
id: "soc2_aws",
type: "realtime-frameworks",
attributes: { name: "SOC 2", version: "2017" },
},
],
});
const result = await getRealtimeFrameworks();
expect(new URL(fetchMock.mock.calls[0][0]).pathname).toBe(
"/api/v1/realtime/frameworks",
);
expect(result).toEqual({
ok: true,
data: [{ id: "soc2_aws", name: "SOC 2", version: "2017" }],
});
});
it("asks for one anomaly by service and region, and passes 404 and 503 on", async () => {
fetchMock.mockResolvedValue(json({}, 200));
handleApiResponseMock.mockResolvedValue({
data: {
id: "anomaly:x",
type: "realtime-insights",
attributes: {
kind: "anomaly",
payload: { anomaly: null, series: [], events: [], findings: [] },
},
},
});
const result = await getRealtimeCostAnomaly({
providerId: PROVIDER,
service: "Amazon Elastic Compute Cloud - Compute",
region: "eu-west-1",
});
const url = new URL(fetchMock.mock.calls[0][0]);
expect(url.pathname).toBe("/api/v1/realtime/insights/anomaly");
expect(url.searchParams.get("provider")).toBe(PROVIDER);
expect(url.searchParams.get("service")).toBe(
"Amazon Elastic Compute Cloud - Compute",
);
expect(url.searchParams.get("region")).toBe("eu-west-1");
expect(result.ok).toBe(true);
fetchMock.mockResolvedValue(
json({ errors: [{ detail: "Not found." }] }, 404),
);
handleApiResponseMock.mockResolvedValue({
error: "Not found.",
status: 404,
});
await expect(
getRealtimeCostAnomaly({
providerId: PROVIDER,
service: "s",
region: "r",
}),
).resolves.toMatchObject({ ok: false, status: 404 });
fetchMock.mockResolvedValue(
json({ errors: [{ detail: "No store." }] }, 503),
);
await expect(
getRealtimeCostAnomaly({
providerId: PROVIDER,
service: "s",
region: "r",
}),
).resolves.toMatchObject({ ok: false, status: 503 });
});
it("does not call the API for a malformed anomaly request", async () => {
await expect(
getRealtimeCostAnomaly({ providerId: "nope", service: "s", region: "r" }),
).resolves.toMatchObject({ ok: false, status: 400 });
await expect(
getRealtimeCostAnomaly({
providerId: PROVIDER,
service: "",
region: "r",
}),
).resolves.toMatchObject({ ok: false, status: 404 });
expect(fetchMock).not.toHaveBeenCalled();
});
describe("alerts", () => {
const NOTIFICATIONS = {
min_severity: "high" as const,
slack: { enabled: true, channel_ids: ["C1"] },
email: { enabled: false, recipient_ids: [] },
};
const policyAnswer = {
data: { id: "pol", type: "realtime-policies", attributes: {} },
};
it("sends the alert settings alone, so the rest of the policy is untouched", async () => {
fetchMock.mockResolvedValue(json({}, 200));
handleApiResponseMock.mockResolvedValue(policyAnswer);
await saveRealtimeNotifications({
providerId: PROVIDER,
policyId: ACTION,
notifications: NOTIFICATIONS,
});
const [url, init] = fetchMock.mock.calls[0];
expect(init.method).toBe("PATCH");
expect(new URL(url).pathname).toBe(`/api/v1/realtime/policies/${ACTION}`);
expect(JSON.parse(init.body).data.attributes).toEqual({
notifications: NOTIFICATIONS,
});
});
it("creates the policy when there is none yet", async () => {
fetchMock.mockResolvedValue(json({}, 201));
handleApiResponseMock.mockResolvedValue(policyAnswer);
await saveRealtimeNotifications({
providerId: PROVIDER,
notifications: NOTIFICATIONS,
});
expect(fetchMock.mock.calls[0][1].method).toBe("POST");
});
it("refuses a turned-on channel with nothing selected, without calling the API", async () => {
const result = await saveRealtimeNotifications({
providerId: PROVIDER,
policyId: ACTION,
notifications: {
...NOTIFICATIONS,
slack: { enabled: true, channel_ids: [] },
},
});
expect(result).toMatchObject({
ok: false,
status: 400,
error: expect.stringMatching(/Slack channel/),
fieldErrors: { notifications: expect.any(String) },
});
expect(fetchMock).not.toHaveBeenCalled();
});
it("surfaces the API's message for an unknown or unconfirmed destination", async () => {
fetchMock.mockResolvedValue(json({}, 400));
handleApiResponseMock.mockResolvedValue({
error: "Unknown recipient",
status: 400,
errors: [
{
detail: "Recipient r9 is not confirmed.",
source: { pointer: "/data/attributes/notifications" },
},
],
});
const result = await saveRealtimeNotifications({
providerId: PROVIDER,
policyId: ACTION,
notifications: NOTIFICATIONS,
});
expect(result).toMatchObject({
ok: false,
status: 400,
fieldErrors: { notifications: "Recipient r9 is not confirmed." },
});
});
it("carries the alert settings in a whole-policy save", async () => {
fetchMock.mockResolvedValue(json({}, 200));
handleApiResponseMock.mockResolvedValue(policyAnswer);
await saveRealtimePolicy({
providerId: PROVIDER,
policyId: ACTION,
policy: {
enabled: false,
mode: "pr",
dry_run: true,
min_severity: "low",
deny_checks: [],
allow_regions: [],
scope_tags: {},
iac_repo: "",
iac_path: "",
open_pr: false,
...NEW_FIELDS,
notifications: NOTIFICATIONS,
},
});
expect(
JSON.parse(fetchMock.mock.calls[0][1].body).data.attributes
.notifications,
).toEqual(NOTIFICATIONS);
});
it("rejects a whole-policy save with invalid alert settings", async () => {
const result = await saveRealtimePolicy({
providerId: PROVIDER,
policy: {
enabled: false,
mode: "pr",
dry_run: true,
min_severity: "low",
deny_checks: [],
allow_regions: [],
scope_tags: {},
iac_repo: "",
iac_path: "",
open_pr: false,
...NEW_FIELDS,
notifications: {
...NOTIFICATIONS,
email: { enabled: true, recipient_ids: [] },
},
},
});
expect(result).toMatchObject({ ok: false, status: 400 });
expect(fetchMock).not.toHaveBeenCalled();
});
it("reads the destinations", async () => {
fetchMock.mockResolvedValue(json({}, 200));
handleApiResponseMock.mockResolvedValue({
data: [
{
id: "t",
type: "realtime-notification-targets",
attributes: {
slack: [{ integration_id: "i", channel_id: "C1", name: "sec" }],
email: [{ id: "r1", email: "a@example.com", status: "pending" }],
},
},
],
});
const result = await getRealtimeNotificationTargets();
expect(new URL(fetchMock.mock.calls[0][0]).pathname).toBe(
"/api/v1/realtime/notification-targets",
);
expect(result.ok && result.data.email[0].status).toBe("pending");
expect(result.ok && result.data.slack[0].channel_id).toBe("C1");
});
});
describe("patchRealtimePolicy", () => {
it("sends only the fields it was given", async () => {
fetchMock.mockResolvedValue(json({}, 200));
handleApiResponseMock.mockResolvedValue({
data: { id: "pol", type: "realtime-policies", attributes: {} },
});
await patchRealtimePolicy({
providerId: PROVIDER,
policyId: ACTION,
patch: { enabled: true },
});
const [url, init] = fetchMock.mock.calls[0];
expect(init.method).toBe("PATCH");
expect(new URL(url).pathname).toBe(`/api/v1/realtime/policies/${ACTION}`);
expect(JSON.parse(init.body).data.attributes).toEqual({ enabled: true });
});
it("validates the fields it was given", async () => {
const result = await patchRealtimePolicy({
providerId: PROVIDER,
policyId: ACTION,
patch: { scope_tags: { "a=b": "c" } },
});
expect(result).toMatchObject({ ok: false, status: 400 });
expect(fetchMock).not.toHaveBeenCalled();
});
it("refuses a malformed policy id", async () => {
await expect(
patchRealtimePolicy({
providerId: PROVIDER,
policyId: "nope",
patch: { enabled: true },
}),
).resolves.toMatchObject({ ok: false, status: 400 });
});
});
describe("remediations", () => {
const answer = {
data: [
{
id: "security-group-ingress",
type: "realtime-remediations",
attributes: {
title: "Close open rules",
effect: "e",
checks: ["a"],
active_checks: [],
iam_actions: ["ec2:Revoke"],
revertible: true,
undo: "u",
in_enforced_scope: false,
},
},
],
};
it("sends the provider when there is one", async () => {
fetchMock.mockResolvedValue(json({}, 200));
handleApiResponseMock.mockResolvedValue(answer);
const result = await getRealtimeRemediations(PROVIDER);
const url = new URL(fetchMock.mock.calls[0][0]);
expect(url.pathname).toBe("/api/v1/realtime/remediations");
expect(url.searchParams.get("provider")).toBe(PROVIDER);
expect(result.ok && result.data[0].id).toBe("security-group-ingress");
});
it("asks without a provider in a demo or no-account context", async () => {
fetchMock.mockResolvedValue(json({}, 200));
handleApiResponseMock.mockResolvedValue(answer);
await getRealtimeRemediations();
expect(new URL(fetchMock.mock.calls[0][0]).search).toBe("");
});
it("rejects a malformed provider id", async () => {
await expect(getRealtimeRemediations("nope")).resolves.toMatchObject({
ok: false,
status: 400,
});
expect(fetchMock).not.toHaveBeenCalled();
});
it("explains 403 and 503 as expected answers", async () => {
fetchMock.mockResolvedValue(json({ errors: [{ detail: "x" }] }, 503));
await expect(getRealtimeRemediations()).resolves.toMatchObject({
ok: false,
status: 503,
});
fetchMock.mockResolvedValue(json({}, 403));
handleApiResponseMock.mockResolvedValue({ error: "no", status: 403 });
await expect(getRealtimeRemediations()).resolves.toMatchObject({
ok: false,
status: 403,
});
});
});
describe("setup", () => {
it("reads the wizard progress for a provider", async () => {
fetchMock.mockResolvedValue(json({}, 200));
handleApiResponseMock.mockResolvedValue({
data: {
id: "s",
type: "realtime-insights",
attributes: {
kind: "setup",
payload: {
activated: false,
next_step: "feed",
steps: [
{ id: "feed", required: true, done: false, detail: null },
],
},
},
},
});
const result = await getRealtimeSetup(PROVIDER);
const url = new URL(fetchMock.mock.calls[0][0]);
expect(url.pathname).toBe("/api/v1/realtime/insights/setup");
expect(url.searchParams.get("provider")).toBe(PROVIDER);
expect(result.ok && result.data.next_step).toBe("feed");
});
it("answers 404, 403 and 503 as failures, and rejects a bad provider", async () => {
await expect(getRealtimeSetup("nope")).resolves.toMatchObject({
status: 400,
});
fetchMock.mockResolvedValue(json({}, 503));
await expect(getRealtimeSetup(PROVIDER)).resolves.toMatchObject({
ok: false,
status: 503,
});
fetchMock.mockResolvedValue(json({}, 404));
handleApiResponseMock.mockResolvedValue({ error: "nf", status: 404 });
await expect(getRealtimeSetup(PROVIDER)).resolves.toMatchObject({
ok: false,
status: 404,
});
});
it("reports an unreadable answer", async () => {
fetchMock.mockResolvedValue(json({}, 200));
handleApiResponseMock.mockResolvedValue({ data: [] });
await expect(getRealtimeSetup(PROVIDER)).resolves.toMatchObject({
ok: false,
status: 0,
});
});
});
describe("demo", () => {
it("asks for sample data without a provider", async () => {
fetchMock.mockResolvedValue(json({}, 200));
handleApiResponseMock.mockResolvedValue({
data: {
id: "d",
type: "realtime-insights",
attributes: {
kind: "status",
payload: { demo: true, demo_notice: "made up", sha256: "x" },
},
},
});
const result = await getRealtimeDemo("status");
const url = new URL(fetchMock.mock.calls[0][0]);
expect(url.pathname).toBe("/api/v1/realtime/demo/status");
expect(url.search).toBe("");
expect(result.ok && result.data).toMatchObject({ demo: true });
});
it("returns the list kinds as one page of rows", async () => {
fetchMock.mockResolvedValue(json({}, 200));
handleApiResponseMock.mockResolvedValue({
data: {
id: "d",
type: "realtime-insights",
attributes: {
kind: "actions",
payload: {
demo: true,
items: [
{
id: "a1",
kind: "self_fix",
status: "fixed_verified",
revertible: true,
inserted_at: "2026-10-23T10:00:00Z",
},
],
},
},
},
});
const result = await getRealtimeDemo("actions");
expect(result.ok && result.data.items[0]).toMatchObject({
id: "a1",
revertible: true,
});
expect(result.ok && result.data.count).toBe(1);
});
it("rejects an unknown kind without calling the API", async () => {
await expect(getRealtimeDemo("nope" as "status")).resolves.toMatchObject({
ok: false,
status: 404,
});
expect(fetchMock).not.toHaveBeenCalled();
});
it("answers a missing deployment as a notice, not an error", async () => {
fetchMock.mockResolvedValue(json({}, 404));
handleApiResponseMock.mockResolvedValue({ error: "nf", status: 404 });
await expect(getRealtimeDemo("overview")).resolves.toMatchObject({
ok: false,
status: 404,
});
});
});
});
+666
View File
@@ -0,0 +1,666 @@
"use server";
import { z } from "zod";
import { apiBaseUrl, getAuthHeaders } from "@/lib";
import {
realtimeCostAnomalySchema,
realtimeCredentialSchema,
realtimeFeedSchema,
realtimeNotificationsSchema,
realtimePolicyPatchSchema,
realtimePolicySchema,
} from "@/lib/realtime/policy-schema";
import { handleApiResponse } from "@/lib/server-actions-helper";
import type {
RealtimeAction,
RealtimeApiDocument,
RealtimeApiError,
RealtimeAnomalyPayload,
RealtimeCostAnomalySettings,
RealtimeCredential,
RealtimeCredentialInput,
RealtimeDemoKind,
RealtimeDemoPayloads,
RealtimeDetection,
RealtimeEvent,
RealtimeFailure,
RealtimeFeed,
RealtimeFeedInput,
RealtimeFramework,
RealtimeHealth,
RealtimeInsightKind,
RealtimeInsightPayloads,
RealtimeNotifications,
RealtimeNotificationTargets,
RealtimePage,
RealtimePolicy,
RealtimePolicyInput,
RealtimeRemediation,
RealtimeResult,
RealtimeSetupPayload,
} from "@/types/realtime";
import {
REALTIME_DEMO_LIST_KIND,
REALTIME_INSIGHT_KIND,
} from "@/types/realtime";
import {
resourcesOf,
toAction,
toCredential,
toDetection,
toEvent,
toFeed,
toFieldErrors,
toFramework,
toHealth,
toDemoPayload,
toInsightPayload,
toNotificationTargets,
toPage,
toPolicy,
toRemediation,
toRequestBody,
toSetup,
} from "./realtime.adapter";
const REALTIME_URL = `${apiBaseUrl}/realtime`;
const UNREACHABLE =
"Prowler could not reach the Prowler Realtime service. Try again.";
const INVALID_PROVIDER = "Select an AWS provider first.";
const MAX_EVENTS = 500;
const DEFAULT_PAGE_SIZE = 25;
const uuid = z.uuid();
const isUuid = (value: string) => uuid.safeParse(value).success;
const fail = (
status: number,
error: string,
fieldErrors?: Record<string, string>,
): RealtimeFailure => ({
ok: false,
status,
error,
...(fieldErrors && Object.keys(fieldErrors).length ? { fieldErrors } : {}),
});
/** A schema failure as a 400: the first message, plus one per field. */
const invalid = (error: z.ZodError, fallback: string): RealtimeFailure => {
const fieldErrors: Record<string, string> = {};
error.issues.forEach((issue) => {
const field = String(issue.path[0] ?? "form");
if (!fieldErrors[field]) fieldErrors[field] = issue.message;
});
return fail(400, error.issues[0]?.message ?? fallback, fieldErrors);
};
const detailOf = async (response: Response, fallback: string) => {
const body = (await response.json().catch(() => undefined)) as
| { errors?: RealtimeApiError[] }
| undefined;
return body?.errors?.[0]?.detail || fallback;
};
/**
* One call to the Prowler Realtime API.
*
* 409 (AWS refused a revert) and 503 (data store not configured) are normal
* answers here: they come back as a failure the panel can explain, and they
* are not reported as server errors. Any other 5xx still goes through
* `handleApiResponse`, which reports it.
*/
const call = async (
path: string,
init: { method?: string; body?: unknown; query?: URLSearchParams } = {},
): Promise<
| { ok: true; document: RealtimeApiDocument | undefined; status: number }
| RealtimeFailure
> => {
try {
const url = new URL(`${REALTIME_URL}/${path}`);
init.query?.forEach((value, key) => url.searchParams.append(key, value));
const response = await fetch(url.toString(), {
method: init.method ?? "GET",
headers: await getAuthHeaders({ contentType: init.body !== undefined }),
body: init.body === undefined ? undefined : JSON.stringify(init.body),
cache: "no-store",
});
if (response.status === 409 || response.status === 503) {
return fail(
response.status,
await detailOf(
response,
response.status === 503
? "Prowler Realtime's data store is not available."
: "AWS did not accept the change.",
),
);
}
const body = await handleApiResponse(response);
if (body && typeof body.error === "string" && !body.data) {
return fail(
Number(body.status) || response.status,
body.error,
toFieldErrors(body.errors),
);
}
return { ok: true, document: body, status: response.status };
} catch (error) {
console.error("Prowler Realtime request failed:", error);
return fail(0, UNREACHABLE);
}
};
const withProvider = (providerId: string, extra?: Record<string, string>) => {
const query = new URLSearchParams({ "filter[provider]": providerId });
Object.entries(extra ?? {}).forEach(([key, value]) => query.set(key, value));
return query;
};
// ------------------------------------------------------------------ policy
/** Create the provider's policy, or patch it when `policyId` is given. */
const writePolicy = async ({
providerId,
policyId,
attributes,
unreadable,
}: {
providerId: string;
policyId?: string;
attributes: Record<string, unknown>;
unreadable: string;
}): Promise<RealtimeResult<RealtimePolicy>> => {
const result = policyId
? await call(`policies/${policyId}`, {
method: "PATCH",
body: toRequestBody({
type: "realtime-policies",
id: policyId,
attributes,
}),
})
: await call("policies", {
method: "POST",
body: toRequestBody({
type: "realtime-policies",
attributes,
providerId,
}),
});
if (!result.ok) return result;
const [saved] = resourcesOf(result.document);
return saved ? { ok: true, data: toPolicy(saved) } : fail(0, unreadable);
};
export const getRealtimePolicy = async (
providerId: string,
): Promise<RealtimeResult<RealtimePolicy | null>> => {
if (!isUuid(providerId)) return fail(400, INVALID_PROVIDER);
const result = await call("policies", { query: withProvider(providerId) });
if (!result.ok) return result;
const [first] = resourcesOf(result.document);
return { ok: true, data: first ? toPolicy(first) : null };
};
/** Create the provider's policy, or update it when one already exists. */
export const saveRealtimePolicy = async ({
providerId,
policyId,
policy,
}: {
providerId: string;
policyId?: string;
policy: RealtimePolicyInput;
}): Promise<RealtimeResult<RealtimePolicy>> => {
if (!isUuid(providerId)) return fail(400, INVALID_PROVIDER);
if (policyId && !isUuid(policyId)) return fail(400, "Unknown policy.");
const parsed = realtimePolicySchema.safeParse(policy);
if (!parsed.success) {
return invalid(parsed.error, "The policy is not valid.");
}
return writePolicy({
providerId,
policyId,
attributes: parsed.data,
unreadable: "The policy was saved but the answer was unreadable.",
});
};
/**
* Save only the cost anomaly settings. Updates send just those fields, so a
* stale page cannot overwrite the rest of the policy; a provider without a
* policy gets one created with the API's defaults (detection stays off).
*/
export const saveRealtimeCostSettings = async ({
providerId,
policyId,
settings,
}: {
providerId: string;
policyId?: string;
settings: RealtimeCostAnomalySettings;
}): Promise<RealtimeResult<RealtimePolicy>> => {
if (!isUuid(providerId)) return fail(400, INVALID_PROVIDER);
if (policyId && !isUuid(policyId)) return fail(400, "Unknown policy.");
const parsed = realtimeCostAnomalySchema.safeParse(settings);
if (!parsed.success) {
return invalid(parsed.error, "The cost anomaly settings are not valid.");
}
return writePolicy({
providerId,
policyId,
attributes: parsed.data,
unreadable: "The settings were saved but the answer was unreadable.",
});
};
/**
* Save only the alert settings. Same shape as the cost settings: updates send
* just `notifications`, so a stale page cannot overwrite the rest of the
* policy; a provider without a policy gets one with the API's defaults.
*/
export const saveRealtimeNotifications = async ({
providerId,
policyId,
notifications,
}: {
providerId: string;
policyId?: string;
notifications: RealtimeNotifications;
}): Promise<RealtimeResult<RealtimePolicy>> => {
if (!isUuid(providerId)) return fail(400, INVALID_PROVIDER);
if (policyId && !isUuid(policyId)) return fail(400, "Unknown policy.");
const parsed = realtimeNotificationsSchema.safeParse(notifications);
if (!parsed.success) {
const failure = invalid(parsed.error, "The alert settings are not valid.");
// Same key as the API uses: the whole `notifications` attribute.
return {
...failure,
fieldErrors: { notifications: failure.error, ...failure.fieldErrors },
};
}
return writePolicy({
providerId,
policyId,
attributes: { notifications: parsed.data },
unreadable: "The alert settings were saved but the answer was unreadable.",
});
};
/**
* Change part of an existing policy (a wizard step, for example). Fields left
* out keep their stored value, so a step cannot overwrite another one.
*/
export const patchRealtimePolicy = async ({
providerId,
policyId,
patch,
}: {
providerId: string;
policyId: string;
patch: Partial<RealtimePolicyInput>;
}): Promise<RealtimeResult<RealtimePolicy>> => {
if (!isUuid(providerId)) return fail(400, INVALID_PROVIDER);
if (!isUuid(policyId)) return fail(400, "Unknown policy.");
const parsed = realtimePolicyPatchSchema.safeParse(patch);
if (!parsed.success) {
return invalid(parsed.error, "The policy is not valid.");
}
return writePolicy({
providerId,
policyId,
attributes: parsed.data,
unreadable: "The policy was saved but the answer was unreadable.",
});
};
/** The compliance frameworks a policy can enforce. Not paginated. */
export const getRealtimeFrameworks = async (): Promise<
RealtimeResult<RealtimeFramework[]>
> => {
const result = await call("frameworks");
return result.ok
? { ok: true, data: resourcesOf(result.document).map(toFramework) }
: result;
};
// -------------------------------------------------------------------- feed
export const getRealtimeFeed = async (
providerId: string,
): Promise<RealtimeResult<RealtimeFeed | null>> => {
if (!isUuid(providerId)) return fail(400, INVALID_PROVIDER);
const result = await call("feeds", { query: withProvider(providerId) });
if (!result.ok) return result;
const [first] = resourcesOf(result.document);
return { ok: true, data: first ? toFeed(first) : null };
};
/** An empty queue URL is valid: it selects the CloudTrail LookupEvents fallback. */
export const saveRealtimeFeed = async ({
providerId,
feedId,
feed,
}: {
providerId: string;
feedId?: string;
feed: RealtimeFeedInput;
}): Promise<RealtimeResult<RealtimeFeed>> => {
if (!isUuid(providerId)) return fail(400, INVALID_PROVIDER);
if (feedId && !isUuid(feedId)) return fail(400, "Unknown feed.");
const parsed = realtimeFeedSchema.safeParse(feed);
if (!parsed.success) {
return invalid(parsed.error, "The feed is not valid.");
}
const result = feedId
? await call(`feeds/${feedId}`, {
method: "PATCH",
body: toRequestBody({
type: "realtime-feeds",
id: feedId,
attributes: parsed.data,
}),
})
: await call("feeds", {
method: "POST",
body: toRequestBody({
type: "realtime-feeds",
attributes: parsed.data,
providerId,
}),
});
if (!result.ok) return result;
const [saved] = resourcesOf(result.document);
return saved
? { ok: true, data: toFeed(saved) }
: fail(0, "The feed was saved but the answer was unreadable.");
};
/** Ask for a check now instead of waiting for the next minute (202). */
export const pollRealtimeFeed = async (
feedId: string,
): Promise<RealtimeResult<{ queued: true }>> => {
if (!isUuid(feedId)) return fail(400, "Unknown feed.");
const result = await call(`feeds/${feedId}/poll`, { method: "POST" });
return result.ok ? { ok: true, data: { queued: true } } : result;
};
export const getRealtimeHealth = async (): Promise<
RealtimeResult<RealtimeHealth>
> => {
const result = await call("feeds/health");
if (!result.ok) return result;
const [resource] = resourcesOf(result.document);
return resource
? { ok: true, data: toHealth(resource) }
: fail(0, "The data store status was unreadable.");
};
// ------------------------------------------------------------------ events
export const getRealtimeEvents = async ({
providerId,
limit = 100,
writesOnly = false,
}: {
providerId: string;
limit?: number;
writesOnly?: boolean;
}): Promise<RealtimeResult<RealtimeEvent[]>> => {
if (!isUuid(providerId)) return fail(400, INVALID_PROVIDER);
const query = new URLSearchParams({
provider: providerId,
limit: String(Math.min(Math.max(Math.trunc(limit) || 100, 1), MAX_EVENTS)),
});
if (writesOnly) query.set("writes_only", "true");
const result = await call("events", { query });
return result.ok
? { ok: true, data: resourcesOf(result.document).map(toEvent) }
: result;
};
// ------------------------------------------------- detections and actions
interface PageOptions {
providerId: string;
page?: number;
pageSize?: number;
}
const pageQuery = (
providerId: string,
{ page = 1, pageSize = DEFAULT_PAGE_SIZE }: Omit<PageOptions, "providerId">,
extra: Record<string, string> = {},
) =>
withProvider(providerId, {
"page[number]": String(Math.max(Math.trunc(page) || 1, 1)),
"page[size]": String(
Math.min(Math.max(Math.trunc(pageSize) || 25, 1), 100),
),
...extra,
});
export const getRealtimeDetections = async ({
providerId,
statusIn,
...paging
}: PageOptions & { statusIn?: string[] }): Promise<
RealtimeResult<RealtimePage<RealtimeDetection>>
> => {
if (!isUuid(providerId)) return fail(400, INVALID_PROVIDER);
const result = await call("detections", {
query: pageQuery(providerId, paging, {
sort: "-event_time",
...(statusIn?.length ? { "filter[status__in]": statusIn.join(",") } : {}),
}),
});
return result.ok
? { ok: true, data: toPage(result.document, toDetection) }
: result;
};
export const getRealtimeActions = async ({
providerId,
...paging
}: PageOptions): Promise<RealtimeResult<RealtimePage<RealtimeAction>>> => {
if (!isUuid(providerId)) return fail(400, INVALID_PROVIDER);
const result = await call("actions", {
query: pageQuery(providerId, paging, { sort: "-inserted_at" }),
});
return result.ok
? { ok: true, data: toPage(result.document, toAction) }
: result;
};
/** Undo an automatic fix with the exact inverse call that was recorded. */
export const revertRealtimeAction = async (
actionId: string,
): Promise<RealtimeResult<RealtimeAction>> => {
if (!isUuid(actionId)) return fail(400, "Unknown action.");
const result = await call(`actions/${actionId}/revert`, { method: "POST" });
if (!result.ok) return result;
const [reverted] = resourcesOf(result.document);
return reverted
? { ok: true, data: toAction(reverted) }
: fail(0, "The change was reverted but the answer was unreadable.");
};
// ------------------------------------------------------------- credentials
export const getRealtimeCredential = async (
providerId: string,
): Promise<RealtimeResult<RealtimeCredential | null>> => {
if (!isUuid(providerId)) return fail(400, INVALID_PROVIDER);
const result = await call("credentials", { query: withProvider(providerId) });
if (!result.ok) return result;
const [first] = resourcesOf(result.document);
return { ok: true, data: first ? toCredential(first) : null };
};
/** Secrets are write-only: the answer says whether they exist, never what they are. */
export const saveRealtimeCredential = async ({
providerId,
credential,
}: {
providerId: string;
credential: RealtimeCredentialInput;
}): Promise<RealtimeResult<RealtimeCredential>> => {
if (!isUuid(providerId)) return fail(400, INVALID_PROVIDER);
const parsed = realtimeCredentialSchema.safeParse(credential);
if (!parsed.success) {
return invalid(parsed.error, "The credentials are not valid.");
}
// Empty fields are left out so they keep their stored value.
const attributes = Object.fromEntries(
Object.entries(parsed.data).filter(([, value]) => Boolean(value)),
);
const result = await call("credentials", {
method: "POST",
body: toRequestBody({
type: "realtime-credentials",
attributes,
providerId,
}),
});
if (!result.ok) return result;
const [saved] = resourcesOf(result.document);
return saved
? { ok: true, data: toCredential(saved) }
: fail(0, "The credentials were saved but the answer was unreadable.");
};
export const deleteRealtimeCredential = async (
credentialId: string,
): Promise<RealtimeResult<{ deleted: true }>> => {
if (!isUuid(credentialId)) return fail(400, "Unknown credential.");
const result = await call(`credentials/${credentialId}`, {
method: "DELETE",
});
return result.ok ? { ok: true, data: { deleted: true } } : result;
};
// ---------------------------------------------------------------- insights
const INSIGHT_KINDS: readonly string[] = Object.values(REALTIME_INSIGHT_KIND);
/** One computed view of an AWS provider. `cost` answers 503 without a data store. */
export const getRealtimeInsight = async <K extends RealtimeInsightKind>(
kind: K,
providerId: string,
): Promise<RealtimeResult<RealtimeInsightPayloads[K]>> => {
if (!INSIGHT_KINDS.includes(kind)) return fail(404, "Unknown view.");
if (!isUuid(providerId)) return fail(400, INVALID_PROVIDER);
const result = await call(`insights/${kind}`, {
query: new URLSearchParams({ provider: providerId }),
});
if (!result.ok) return result;
const payload = toInsightPayload(result.document, kind);
return payload
? { ok: true, data: payload }
: fail(0, "The answer was not in the expected shape.");
};
/**
* Everything behind one cost anomaly. 404 means the spike is over (or the
* signal was turned off); 503 means the data store is not available.
*/
export const getRealtimeCostAnomaly = async ({
providerId,
service,
region,
}: {
providerId: string;
service: string;
region: string;
}): Promise<RealtimeResult<RealtimeAnomalyPayload>> => {
if (!isUuid(providerId)) return fail(400, INVALID_PROVIDER);
if (!service || !region) return fail(404, "Unknown anomaly.");
const result = await call("insights/anomaly", {
query: new URLSearchParams({ provider: providerId, service, region }),
});
if (!result.ok) return result;
const payload = toInsightPayload(
result.document,
REALTIME_INSIGHT_KIND.ANOMALY,
);
return payload
? { ok: true, data: payload }
: fail(0, "The answer was not in the expected shape.");
};
// ------------------------------------------------- setup, alerts, remediations
/** Where a provider stands in the setup wizard. */
export const getRealtimeSetup = async (
providerId: string,
): Promise<RealtimeResult<RealtimeSetupPayload>> => {
if (!isUuid(providerId)) return fail(400, INVALID_PROVIDER);
const result = await call("insights/setup", {
query: new URLSearchParams({ provider: providerId }),
});
if (!result.ok) return result;
const setup = toSetup(result.document);
return setup
? { ok: true, data: setup }
: fail(0, "The answer was not in the expected shape.");
};
/** The Slack channels and email recipients alerts can go to. */
export const getRealtimeNotificationTargets = async (): Promise<
RealtimeResult<RealtimeNotificationTargets>
> => {
const result = await call("notification-targets");
return result.ok
? { ok: true, data: toNotificationTargets(result.document) }
: result;
};
/**
* The fixes the remediation role makes possible. With a provider, each says
* whether it is inside the frameworks that account enforces; without one (demo,
* no account yet) the list is the same, with no scope.
*/
export const getRealtimeRemediations = async (
providerId?: string,
): Promise<RealtimeResult<RealtimeRemediation[]>> => {
if (providerId && !isUuid(providerId)) return fail(400, INVALID_PROVIDER);
const result = await call("remediations", {
query: providerId
? new URLSearchParams({ provider: providerId })
: undefined,
});
return result.ok
? { ok: true, data: resourcesOf(result.document).map(toRemediation) }
: result;
};
// -------------------------------------------------------------------- demo
const DEMO_KINDS: readonly string[] = [
...Object.values(REALTIME_INSIGHT_KIND),
...Object.values(REALTIME_DEMO_LIST_KIND),
];
/**
* Made-up data for an account that is not set up yet: same shapes as the real
* views, no provider needed. The lists come back as one page.
*/
export const getRealtimeDemo = async <K extends RealtimeDemoKind>(
kind: K,
): Promise<RealtimeResult<RealtimeDemoPayloads[K]>> => {
if (!DEMO_KINDS.includes(kind)) return fail(404, "Unknown view.");
const result = await call(`demo/${kind}`);
if (!result.ok) return result;
const payload = toDemoPayload(result.document, kind);
return payload
? { ok: true, data: payload }
: fail(0, "The answer was not in the expected shape.");
};
+4
View File
@@ -118,6 +118,8 @@ export const addRole = async (formData: FormData) => {
formData.get("manage_lighthouse_ai_configuration") === "true";
payload.data.attributes.manage_registry =
formData.get("manage_registry") === "true";
payload.data.attributes.manage_prowler_realtime =
formData.get("manage_prowler_realtime") === "true";
}
// Add provider groups relationships only if there are items
@@ -179,6 +181,8 @@ export const updateRole = async (formData: FormData, roleId: string) => {
formData.get("manage_lighthouse_ai_configuration") === "true";
payload.data.attributes.manage_registry =
formData.get("manage_registry") === "true";
payload.data.attributes.manage_prowler_realtime =
formData.get("manage_prowler_realtime") === "true";
}
// Add provider groups relationships only if there are items
@@ -0,0 +1,75 @@
import { render, screen } from "@testing-library/react";
import { ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { getIssue, notFound } = vi.hoisted(() => ({
getIssue: vi.fn(),
notFound: vi.fn(() => {
throw new Error("NOT_FOUND");
}),
}));
vi.mock("next/navigation", () => ({ notFound }));
vi.mock("@/actions/issues", () => ({ getIssue }));
vi.mock("@/components/shadcn/content-layout", () => ({
ContentLayout: ({ children }: { children: ReactNode }) => children,
}));
vi.mock("@/components/shared/feature-flag-route", () => ({
FeatureFlagRoute: ({ children }: { children: ReactNode }) => children,
}));
vi.mock("@/components/issues/issue-detail", () => ({
IssueDetail: ({ scanId }: { scanId?: string }) => (
<div data-testid="issue-detail">{scanId}</div>
),
}));
import IssuePage from "./page";
const params = Promise.resolve({ id: "issue-1" });
describe("IssuePage", () => {
beforeEach(() => {
vi.stubEnv("UI_CLOUD_ENABLED", "true");
getIssue.mockReset();
});
it("returns not found in Local Server without asking the API", async () => {
vi.stubEnv("UI_CLOUD_ENABLED", "false");
await expect(IssuePage({ params })).rejects.toThrow("NOT_FOUND");
expect(getIssue).not.toHaveBeenCalled();
});
it("returns not found when the API has no such issue", async () => {
getIssue.mockResolvedValue({ error: "Not found", status: 404 });
await expect(IssuePage({ params })).rejects.toThrow("NOT_FOUND");
});
it("shows the issue with the detail the list opens in place", async () => {
getIssue.mockResolvedValue({
data: {
type: "issues",
id: "issue-1",
attributes: {
pattern: "aws_public_bucket",
cloud: "aws",
account: "prod",
title: "Public bucket",
verdict: "likely",
},
relationships: { scan: { data: { type: "scans", id: "scan-1" } } },
},
});
render(await IssuePage({ params }));
expect(getIssue).toHaveBeenCalledWith("issue-1");
expect(
screen.getByRole("heading", { name: "Public bucket" }),
).toBeInTheDocument();
expect(screen.getByText("Likely")).toBeInTheDocument();
expect(screen.getByTestId("issue-detail")).toHaveTextContent("scan-1");
expect(screen.getByRole("link", { name: "← All issues" })).toHaveAttribute(
"href",
"/issues",
);
});
});
+69
View File
@@ -0,0 +1,69 @@
import { ShieldAlert } from "lucide-react";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getIssue } from "@/actions/issues";
import { IssueDetail } from "@/components/issues/issue-detail";
import { Badge } from "@/components/shadcn/badge/badge";
import { Button } from "@/components/shadcn/button/button";
import { Card } from "@/components/shadcn/card/card";
import { ContentLayout } from "@/components/shadcn/content-layout";
import { EntityInfo } from "@/components/shadcn/entities";
import { FeatureFlagRoute } from "@/components/shared/feature-flag-route";
import { FEATURE_FLAG } from "@/lib/feature-flags";
import { VERDICTS } from "@/lib/issues/verdicts";
import { isCloud } from "@/lib/shared/env";
/**
* One issue on its own URL, so a path can be shared or linked from a ticket.
* The body is the same detail the list opens in place.
*/
export default async function IssuePage({
params,
}: {
params: Promise<{ id: string }>;
}) {
if (!isCloud()) notFound();
const { id } = await params;
const issue = (await getIssue(id))?.data;
// handleApiResponse resolves {error, status} on a 4xx: no data is no issue.
if (!issue?.attributes) notFound();
const { attributes, relationships } = issue;
const verdict = VERDICTS.find((item) => item.id === attributes.verdict);
return (
<ContentLayout title="Issue" icon={<ShieldAlert />}>
<FeatureFlagRoute flag={FEATURE_FLAG.ISSUES}>
<div className="flex min-w-0 flex-col gap-4">
<Button asChild variant="link" size="link-sm" className="self-start">
<Link href="/issues">← All issues</Link>
</Button>
<Card variant="base" className="min-w-0 gap-3">
<div className="flex flex-wrap items-center gap-3">
<Badge variant={verdict?.variant ?? "tag"}>
{verdict?.label ?? attributes.verdict}
</Badge>
<span className="text-text-neutral-tertiary text-xs">
{attributes.pattern}
</span>
</div>
<h2 className="text-text-neutral-primary text-lg font-semibold break-words">
{attributes.title}
</h2>
<EntityInfo
cloudProvider={attributes.cloud}
entityAlias={attributes.account}
/>
<IssueDetail
id={issue.id}
issue={attributes}
scanId={relationships?.scan?.data?.id}
providerId={relationships?.provider?.data?.id}
/>
</Card>
</div>
</FeatureFlagRoute>
</ContentLayout>
);
}
@@ -26,13 +26,10 @@ const ISSUE_FILTER_KEYS = [
export const IssuesList = async ({
searchParams,
filters: filterControls,
providerUids,
}: {
searchParams: SearchParamsProps;
/** The scope selectors, rendered above the verdict tabs: they scope every tab. */
filters?: ReactNode;
/** Provider uid by provider id, for the issue panel. */
providerUids?: Record<string, string>;
}) => {
const filters: Record<string, string> = {};
ISSUE_FILTER_KEYS.forEach((key) => {
@@ -90,11 +87,7 @@ export const IssuesList = async ({
{rows === null ? (
<IssuesLoadError />
) : rows.length ? (
<IssuesTable
issues={rows}
metadata={issues?.meta}
providerUids={providerUids}
/>
<IssuesTable issues={rows} metadata={issues?.meta} />
) : (
<p className="text-text-neutral-tertiary text-sm">
No issues match this filter.
+3 -8
View File
@@ -27,12 +27,6 @@ export default async function Issues({
getAllProviderGroups(),
]);
const providers = providersData?.data ?? [];
// The issue only references its provider; the uid shows in the panel.
const providerUids = Object.fromEntries(
providers.map((provider) => [provider.id, provider.attributes.uid]),
);
return (
<ContentLayout title="Issues" icon={<ShieldAlert />}>
{/* Deployment gate above (notFound); per-account rollout here, also a
@@ -47,12 +41,13 @@ export default async function Issues({
>
<IssuesList
searchParams={resolvedSearchParams}
providerUids={providerUids}
filters={
// The three scope selectors share one row on desktop, like the
// other list pages.
<div className="mb-6 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
<ProviderAccountSelectors providers={providers} />
<ProviderAccountSelectors
providers={providersData?.data ?? []}
/>
<ProviderGroupSelector
groups={providerGroupsData?.data ?? []}
/>
@@ -0,0 +1,42 @@
import { AnomalyDetail } from "@/components/realtime/anomaly-detail";
import { RealtimePageShell } from "@/components/realtime/realtime-page-shell";
import { DEMO_ANOMALY_ID, DEMO_ANOMALY_SLUG } from "@/lib/realtime/demo";
import { resolveProviderId } from "@/lib/realtime/providers";
import { SearchParamsProps } from "@/types";
const first = (value: string | string[] | undefined) =>
typeof value === "string" ? value : undefined;
/** `service|region`, URL-encoded; Next may hand it over still encoded. */
const decode = (value: string) => {
try {
return decodeURIComponent(value);
} catch {
return value;
}
};
export default async function RealtimeCostAnomalyPage({
params,
searchParams,
}: {
params: Promise<{ id: string }>;
searchParams: Promise<SearchParamsProps>;
}) {
const [{ id }, resolved] = await Promise.all([params, searchParams]);
return (
<RealtimePageShell>
{(providers) => (
<AnomalyDetail
// The sample anomaly has one fixed page; its data is made up.
anomalyId={id === DEMO_ANOMALY_SLUG ? DEMO_ANOMALY_ID : decode(id)}
providerId={
resolveProviderId(providers, first(resolved.provider)) ?? ""
}
demo={id === DEMO_ANOMALY_SLUG}
/>
)}
</RealtimePageShell>
);
}
+28
View File
@@ -0,0 +1,28 @@
import { RealtimePageShell } from "@/components/realtime/realtime-page-shell";
import { RealtimeWorkspace } from "@/components/realtime/realtime-workspace";
import { SearchParamsProps } from "@/types";
const first = (value: string | string[] | undefined) =>
typeof value === "string" ? value : undefined;
export default async function Realtime({
searchParams,
}: {
searchParams: Promise<SearchParamsProps>;
}) {
const resolved = await searchParams;
return (
<RealtimePageShell>
{(providers) => (
<RealtimeWorkspace
// A soft navigation (e.g. the sidebar link) must reset the selection.
key={`${first(resolved.provider)}:${first(resolved.tab)}`}
providers={providers}
initialProviderId={first(resolved.provider)}
initialTab={first(resolved.tab)}
/>
)}
</RealtimePageShell>
);
}
@@ -0,0 +1,25 @@
import { CostAnomalySettings } from "@/components/realtime/cost-anomaly-settings";
import { RealtimePageShell } from "@/components/realtime/realtime-page-shell";
import { SearchParamsProps } from "@/types";
const first = (value: string | string[] | undefined) =>
typeof value === "string" ? value : undefined;
export default async function RealtimeCostAnomalySettingsPage({
searchParams,
}: {
searchParams: Promise<SearchParamsProps>;
}) {
const resolved = await searchParams;
return (
<RealtimePageShell>
{(providers) => (
<CostAnomalySettings
providers={providers}
initialProviderId={first(resolved.provider)}
/>
)}
</RealtimePageShell>
);
}
@@ -0,0 +1,28 @@
import { isLighthouseConfigured } from "@/actions/lighthouse-v1";
import { RealtimePageShell } from "@/components/realtime/realtime-page-shell";
import { RealtimeSettings } from "@/components/realtime/realtime-settings";
import { SearchParamsProps } from "@/types";
const first = (value: string | string[] | undefined) =>
typeof value === "string" ? value : undefined;
export default async function RealtimeSettingsPage({
searchParams,
}: {
searchParams: Promise<SearchParamsProps>;
}) {
const resolved = await searchParams;
return (
<RealtimePageShell>
{async (providers) => (
<RealtimeSettings
providers={providers}
initialProviderId={first(resolved.provider)}
// Only used to warn that the code-fix switch has no LLM behind it.
lighthouseConfigured={Boolean(await isLighthouseConfigured())}
/>
)}
</RealtimePageShell>
);
}
+28
View File
@@ -0,0 +1,28 @@
import { isLighthouseConfigured } from "@/actions/lighthouse-v1";
import { RealtimePageShell } from "@/components/realtime/realtime-page-shell";
import { RealtimeSetupWizard } from "@/components/realtime/setup/realtime-setup-wizard";
import { SearchParamsProps } from "@/types";
const first = (value: string | string[] | undefined) =>
typeof value === "string" ? value : undefined;
export default async function RealtimeSetupPage({
searchParams,
}: {
searchParams: Promise<SearchParamsProps>;
}) {
const resolved = await searchParams;
return (
<RealtimePageShell>
{async (providers) => (
<RealtimeSetupWizard
providers={providers}
initialProviderId={first(resolved.provider)}
// Only used to warn that the code-fix switch has no LLM behind it.
lighthouseConfigured={Boolean(await isLighthouseConfigured())}
/>
)}
</RealtimePageShell>
);
}
+1
View File
@@ -40,6 +40,7 @@ const RESTRICTED_PERMISSIONS: RolePermissionAttributes = {
manage_billing: false,
manage_alerts: false,
manage_registry: false,
manage_prowler_realtime: false,
manage_lighthouse_ai_configuration: false,
unlimited_visibility: false,
};
+1
View File
@@ -62,6 +62,7 @@ const DEFAULT_PERMISSIONS: RolePermissionAttributes = {
manage_alerts: false,
manage_lighthouse_ai_configuration: false,
manage_registry: false,
manage_prowler_realtime: false,
unlimited_visibility: false,
};
@@ -0,0 +1 @@
User avatar requesting the current page as an image on every load, which rendered each page twice
@@ -0,0 +1 @@
Compliance cross-provider tab no longer blanks out when a single catalog page or framework fails to load; it shows the frameworks that loaded with a warning instead
@@ -0,0 +1 @@
Invitations page uses `Status` instead of `State` in the filter, table column and invitation details, and the Invitations and Roles filters show capitalized options
@@ -0,0 +1 @@
Lighthouse AI reply blanking out for a frame when the response finishes streaming, which made the whole text blink and reappear
@@ -0,0 +1 @@
Icon for the NATO D32 compliance framework on the compliance cards
@@ -0,0 +1 @@
Add provider wizard connects every provider in one step, saving the account and credentials and testing the connection together, with teammate invitations for every provider and Lighthouse AI help beside the steps
@@ -0,0 +1 @@
Product tours and the guided onboarding sequence are no longer shown; new tenants still land on the add-provider wizard
+22 -36
View File
@@ -1,10 +1,14 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import type { IssueAttributes } from "@/types/issues";
const { getIssue, getIssueFindings } = vi.hoisted(() => ({
getIssue: vi.fn(),
getIssueFindings: vi.fn(),
}));
vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh: vi.fn() }) }));
vi.mock("@/actions/issues", () => ({ getIssue, getIssueFindings }));
vi.mock("@/components/issues/issue-graph", () => ({
IssueGraph: () => <p>graph</p>,
}));
@@ -14,53 +18,35 @@ import { IssueAttackPath } from "./issue-attack-path";
const issue = { legs: {} } as IssueAttributes;
describe("IssueAttackPath", () => {
it("says there is nothing to draw, not a load error, without resources", () => {
// Given: no resources, and findings that could not load without a scan
render(
<IssueAttackPath
issue={issue}
evidence={{
status: "loaded",
resources: [],
findings: undefined,
findingsTruncated: false,
}}
onRetry={vi.fn()}
onOpenFinding={vi.fn()}
/>,
);
it("says there is nothing to draw, not a load error, without resources", async () => {
// Given: no resources, and findings that cannot load without a scan
getIssue.mockResolvedValue({
data: { attributes: { resource_uids: [], finding_uids: ["f1"] } },
});
// When
render(<IssueAttackPath id="issue-1" issue={issue} />);
// Then
expect(
screen.getByText("This issue has no affected resources to draw."),
await screen.findByText("This issue has no affected resources to draw."),
).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Retry" })).toBeNull();
});
it("offers a retry when the findings for the drawn resources fail", async () => {
// Given
const onRetry = vi.fn();
render(
<IssueAttackPath
issue={issue}
evidence={{
status: "loaded",
resources: ["bucket"],
findings: undefined,
findingsTruncated: false,
}}
onRetry={onRetry}
onOpenFinding={vi.fn()}
/>,
);
getIssue.mockResolvedValue({
data: { attributes: { resource_uids: ["bucket"], finding_uids: ["f1"] } },
});
// When
expect(
screen.getByText("Could not load the attack path for this issue."),
).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Retry" }));
render(<IssueAttackPath id="issue-1" issue={issue} />);
// Then
expect(onRetry).toHaveBeenCalled();
expect(
await screen.findByText("Could not load the attack path for this issue."),
).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument();
});
});
+19 -18
View File
@@ -2,25 +2,26 @@
import { IssueGraph } from "@/components/issues/issue-graph";
import { IssuesLoadError } from "@/components/issues/issues-load-error";
import { useIssueEvidence } from "@/hooks/use-issue-evidence";
import { IssueAttributes } from "@/types/issues";
import type { IssueEvidenceState } from "./use-issue-evidence";
interface IssueAttackPathProps {
id: string;
issue: IssueAttributes;
evidence: IssueEvidenceState;
onRetry: () => void;
onOpenFinding: (findingId: string) => void;
scanId?: string;
providerId?: string;
}
/** The issue's graph, or why there is none to draw yet. */
/** The issue's graph for the side panel; findings open on the full page. */
export const IssueAttackPath = ({
id,
issue,
evidence,
onRetry,
onOpenFinding,
scanId,
providerId,
}: IssueAttackPathProps) => {
if (evidence.status === "loading") {
const { state, retry } = useIssueEvidence(id, scanId, providerId);
if (state.status === "loading") {
return (
<p role="status" className="text-text-neutral-tertiary text-sm">
Loading the attack path…
@@ -29,29 +30,29 @@ export const IssueAttackPath = ({
}
// Nothing to draw beats a findings error: without resources there is no
// graph for the findings to land on.
if (evidence.status === "loaded" && evidence.resources.length === 0) {
if (state.status === "loaded" && state.resources.length === 0) {
return (
<p className="text-text-neutral-tertiary text-sm">
This issue has no affected resources to draw.
</p>
);
}
if (evidence.status === "error" || evidence.findings === undefined) {
if (state.status === "error" || state.findings === undefined) {
return (
<IssuesLoadError
message="Could not load the attack path for this issue."
onRetry={onRetry}
onRetry={retry}
/>
);
}
return (
<IssueGraph
issue={issue}
resourceUids={evidence.resources}
findings={evidence.findings}
onOpenFinding={onOpenFinding}
{...(evidence.providerId && evidence.scanId
? { providerId: evidence.providerId, scanId: evidence.scanId }
resourceUids={state.resources}
findings={state.findings}
readOnly
{...(state.providerId && state.scanId
? { providerId: state.providerId, scanId: state.scanId }
: { providerId: undefined, scanId: undefined })}
/>
);
+7 -16
View File
@@ -9,7 +9,6 @@ import {
complianceFrameworkLabel,
} from "@/components/compliance/compliance-framework-chip";
import { IssuesLoadError } from "@/components/issues/issues-load-error";
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
import type { FindingComplianceFramework } from "@/types/compliance-watchlist";
import { IssueFinding } from "@/types/issues";
@@ -116,21 +115,13 @@ export const IssueCompliance = ({
);
};
// Laid out like the finding panel's "Compliance Frameworks" header row.
return (
<section aria-label="Compliance affected" className="flex flex-col gap-1.5">
<span className="text-text-neutral-tertiary text-xs font-medium">
Compliance Frameworks:
</span>
<section aria-label="Compliance affected">
<h4 className="mb-3 text-sm font-semibold">Compliance affected</h4>
{state.status === "loading" ? (
<div className="flex flex-wrap items-center gap-2">
<span role="status" className="sr-only">
Loading frameworks…
</span>
<Skeleton className="size-7 rounded-md" />
<Skeleton className="size-7 rounded-md" />
<Skeleton className="size-7 rounded-md" />
</div>
<p role="status" className="text-text-neutral-tertiary text-sm">
Loading frameworks…
</p>
) : state.status === "unavailable" ? (
<IssuesLoadError
message="Could not load the frameworks for this issue."
@@ -139,7 +130,7 @@ export const IssueCompliance = ({
) : (
<>
{state.frameworks.length === 0 ? (
<p className="text-text-neutral-tertiary text-xs">
<p className="text-text-neutral-tertiary text-sm">
{incomplete
? "None of the findings checked map to a framework requirement."
: "None of the findings behind this issue map to a framework requirement."}
@@ -157,7 +148,7 @@ export const IssueCompliance = ({
</div>
)}
{incomplete && (
<p className="text-text-neutral-tertiary text-xs">
<p className="text-text-neutral-tertiary mt-2 text-xs">
Not every finding could be checked, so this list may be
incomplete.
</p>
+326
View File
@@ -0,0 +1,326 @@
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { IssueDetail } from "@/components/issues/issue-detail";
import { useJiraDispatchStore } from "@/store/jira-dispatch/store";
import { ISSUE_FINDINGS_LIMIT, IssueAttributes } from "@/types/issues";
const { getIssueMock, getIssueFindingsMock } = vi.hoisted(() => ({
getIssueMock: vi.fn(),
getIssueFindingsMock: vi.fn(),
}));
vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh: vi.fn() }) }));
vi.mock("@/actions/issues", () => ({
getIssue: getIssueMock,
getIssueFindings: getIssueFindingsMock,
getIssueNotes: async () => [],
}));
vi.mock("@/components/issues/issue-compliance", () => ({
IssueCompliance: ({ truncated }: { truncated: boolean }) =>
truncated ? <p>Compliance from a capped preview</p> : null,
}));
vi.mock("@/components/findings/send-to-jira-modal", () => ({
SendToJiraModal: () => null,
}));
vi.mock("@/components/issues/issue-graph", () => ({
IssueGraph: ({ resourceUids }: { resourceUids: string[] }) => (
<div data-testid="issue-graph">{resourceUids.length} resources</div>
),
}));
const resourceUids = Array.from({ length: 15 }, (_, i) => `resource-${i + 1}`);
const issue = {
pattern: "unmodelled",
cloud: "aws",
account: "prod",
title: "Findings no attack path models yet (300)",
verdict: "unknown",
severity: "high",
outcome: "",
attacker_start: "",
legs: {},
fix: "Review these findings individually.",
findings_count: 300,
triage_status: "open",
inserted_at: "",
updated_at: "",
} as unknown as IssueAttributes;
describe("IssueDetail", () => {
beforeEach(() => {
getIssueMock.mockReset();
getIssueFindingsMock.mockResolvedValue([]);
useJiraDispatchStore.getState().closeJiraDispatch();
});
it("links to the issue's scan findings and reveals every folded resource", async () => {
getIssueMock.mockResolvedValue({
data: { attributes: { resource_uids: resourceUids } },
});
const user = userEvent.setup();
render(<IssueDetail id="issue-1" issue={issue} scanId="scan-1" />);
expect(
screen.getByRole("link", { name: /failed findings/i }),
).toHaveAttribute(
"href",
"/findings?filter%5Bscan__in%5D=scan-1&filter%5Bstatus__in%5D=FAIL",
);
// The list leaves the uids out; the detail fetches them when it opens.
expect(getIssueMock).toHaveBeenCalledWith("issue-1");
expect(await screen.findByText("resource-1")).toBeInTheDocument();
expect(screen.queryByText("resource-15")).not.toBeInTheDocument();
await user.click(
screen.getByRole("button", { name: "Show more resources" }),
);
expect(screen.getByText("resource-15")).toBeInTheDocument();
expect(screen.getByTestId("issue-graph")).toHaveTextContent("15 resources");
});
it("says what a path buys the attacker and where it starts", async () => {
getIssueMock.mockResolvedValue({
data: { attributes: { resource_uids: [] } },
});
render(
<IssueDetail
id="issue-1"
issue={{
...issue,
pattern: "aws_public_bucket",
outcome: "data_exposure",
attacker_start: "internet",
}}
/>,
);
expect(screen.getByText("Data exposure")).toBeInTheDocument();
expect(screen.getByText("The internet")).toBeInTheDocument();
// Shown inline so keyboard users can read it too.
expect(
screen.getByText(/Where the attacker has to be standing/),
).toBeInTheDocument();
});
it("tells the compliance section when the findings preview was capped", async () => {
// Given - one more finding than the preview loads
getIssueMock.mockResolvedValue({
data: {
attributes: {
resource_uids: [],
finding_uids: Array.from(
{ length: ISSUE_FINDINGS_LIMIT + 1 },
(_, i) => `uid-${i}`,
),
},
relationships: { scan: { data: { type: "scans", id: "scan-1" } } },
},
});
getIssueFindingsMock.mockResolvedValue([
{
id: "f1",
checkId: "check-a",
checkTitle: "Check A",
severity: "high",
resourceUid: "r1",
},
]);
// When
render(<IssueDetail id="issue-1" issue={issue} />);
// Then
expect(
await screen.findByText("Compliance from a capped preview"),
).toBeInTheDocument();
});
it("claims no outcome for an issue that is not one path", async () => {
getIssueMock.mockResolvedValue({
data: { attributes: { resource_uids: [] } },
});
render(<IssueDetail id="issue-1" issue={issue} />);
expect(screen.queryByText("Outcome")).not.toBeInTheDocument();
});
it("links each affected resource to it on the Resources page", async () => {
getIssueMock.mockResolvedValue({
data: { attributes: { resource_uids: ["arn:aws:s3:::bucket"] } },
});
render(<IssueDetail id="issue-1" issue={issue} providerId="provider-1" />);
expect(
await screen.findByRole("link", {
name: "Open in Resources: arn:aws:s3:::bucket",
}),
).toHaveAttribute(
"href",
"/resources?filter%5Buid%5D=arn%3Aaws%3As3%3A%3A%3Abucket&filter%5Bprovider_id__in%5D=provider-1",
);
});
it("says so when the resources cannot be loaded", async () => {
getIssueMock.mockResolvedValue(undefined);
render(<IssueDetail id="issue-1" issue={issue} />);
expect(
await screen.findByText(/could not load the resources/i),
).toBeInTheDocument();
expect(screen.queryByTestId("issue-graph")).not.toBeInTheDocument();
});
it("reports a failed findings request and retries using the issue scan", async () => {
// Given
getIssueMock.mockResolvedValue({
data: {
attributes: {
resource_uids: resourceUids,
finding_uids: ["finding-uid"],
},
},
});
getIssueFindingsMock
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce([]);
render(<IssueDetail id="issue-1" issue={issue} scanId="scan-1" />);
// When
expect(await screen.findByRole("alert")).toHaveTextContent(
"Could not load the findings for this issue",
);
expect(screen.queryByTestId("issue-graph")).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Retry" }));
// Then
expect(await screen.findByTestId("issue-graph")).toBeInTheDocument();
expect(getIssueFindingsMock).toHaveBeenLastCalledWith("scan-1", [
"finding-uid",
]);
});
it("names the conditions that make the path, explained by each leg", async () => {
getIssueMock.mockResolvedValue({
data: { attributes: { resource_uids: [] } },
});
render(
<IssueDetail
id="issue-1"
issue={{
...issue,
outcome: "data_exposure",
attacker_start: "internet",
legs: {
...issue.legs,
controls: {
state: "no",
text: "The public access block is off.",
evidence: [],
},
},
}}
/>,
);
const factors = screen.getByRole("list", { name: "Risk factors" });
expect(factors).toHaveTextContent("Internet-reachable");
expect(screen.getByText("No control blocking")).toHaveAttribute(
"title",
"The public access block is off.",
);
});
it("lists each folded finding once, with its own triage and a link to it", async () => {
getIssueMock.mockResolvedValue({
data: {
attributes: { resource_uids: ["bucket"], finding_uids: ["f-1", "f-2"] },
},
});
const finding = {
id: "finding-1",
uid: "f-1",
checkId: "s3_bucket_public_access",
checkTitle: "Bucket is public",
severity: "critical",
triageStatus: "risk_accepted",
resourceUid: "bucket",
};
getIssueFindingsMock.mockResolvedValue([
finding,
// The same finding on a second resource is still one finding.
{ ...finding, resourceUid: "other-bucket" },
{
...finding,
id: "finding-2",
uid: "f-2",
checkTitle: "Bucket has no logging",
triageStatus: "open",
},
]);
render(<IssueDetail id="issue-1" issue={issue} scanId="scan-1" />);
const section = await screen.findByRole("region", { name: "Findings" });
const link = within(section).getByRole("link", {
name: "Bucket is public",
});
const destination = new URL(link.getAttribute("href")!, "https://app.test");
expect(Object.fromEntries(destination.searchParams)).toEqual({
"filter[scan__in]": "scan-1",
"filter[check_id__in]": "s3_bucket_public_access",
"filter[resource_uid]": "other-bucket",
"filter[muted]": "include",
expandedCheckId: "s3_bucket_public_access",
});
expect(within(section).getAllByRole("listitem")).toHaveLength(2);
expect(within(section).getByText("Risk Accepted")).toBeInTheDocument();
expect(within(section).getByText("Showing 2 of 300.")).toBeInTheDocument();
});
it("opens the global Jira flow with unique finding IDs", async () => {
// Given
const user = userEvent.setup();
getIssueMock.mockResolvedValue({
data: {
attributes: { resource_uids: ["bucket"], finding_uids: ["f-1"] },
},
});
const finding = {
id: "finding-1",
uid: "f-1",
checkId: "s3_public",
checkTitle: "Bucket is public",
severity: "critical",
triageStatus: "open",
resourceUid: "bucket",
};
getIssueFindingsMock.mockResolvedValue([
finding,
{ ...finding, resourceUid: "other-bucket" },
{ ...finding, id: "finding-2", uid: "f-2" },
]);
const view = render(
<IssueDetail id="issue-1" issue={issue} scanId="scan-1" />,
);
// When
await user.click(
await screen.findByRole("button", { name: "Send to Jira" }),
);
view.unmount();
// Then: the globally hosted modal survives the detail closing.
expect(useJiraDispatchStore.getState().activePayload).toMatchObject({
findingTitle: issue.title,
selection: {
kind: "target-list",
targetType: "finding_id",
targetIds: ["finding-1", "finding-2"],
},
});
});
});
+273
View File
@@ -0,0 +1,273 @@
"use client";
import { Send } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { IssueCompliance } from "@/components/issues/issue-compliance";
import { IssueFindings } from "@/components/issues/issue-findings";
import { IssueGraph } from "@/components/issues/issue-graph";
import { IssueTriage } from "@/components/issues/issue-triage";
import { IssuesLoadError } from "@/components/issues/issues-load-error";
import { Badge } from "@/components/shadcn/badge/badge";
import { Button } from "@/components/shadcn/button/button";
import { Card } from "@/components/shadcn/card/card";
import { CodeSnippet } from "@/components/shadcn/code-snippet";
import { InfoField } from "@/components/shadcn/info-field/info-field";
import { useIssueEvidence } from "@/hooks/use-issue-evidence";
import { riskFactors } from "@/lib/issues/risk-factors";
import {
LEG_LABELS,
LEG_QUESTIONS,
LEG_STATE_LABELS,
legBadgeVariant,
} from "@/lib/issues/verdicts";
import { createJiraDispatchPayload } from "@/lib/jira-dispatch-selection";
import { useJiraDispatchStore } from "@/store/jira-dispatch/store";
import { JIRA_DISPATCH_TARGET } from "@/types/integrations";
import {
ATTACKER_START_LABEL,
ISSUE_OUTCOME_LABEL,
IssueAttributes,
LegName,
} from "@/types/issues";
const RESOURCE_PAGE_SIZE = 12;
// The issue carries resource uids, not ids, so this opens the Resources list
// on that one resource rather than its drawer.
const resourceHref = (uid: string, providerId?: string) =>
`/resources?${new URLSearchParams({
"filter[uid]": uid,
// A uid is unique within an account, not across them.
...(providerId && { "filter[provider_id__in]": providerId }),
})}`;
interface IssueDetailProps {
id: string;
issue: IssueAttributes;
scanId?: string;
providerId?: string;
}
export const IssueDetail = ({
id,
issue,
scanId,
providerId,
}: IssueDetailProps) => {
const openJiraDispatch = useJiraDispatchStore(
(state) => state.openJiraDispatch,
);
const [visibleResources, setVisibleResources] = useState(RESOURCE_PAGE_SIZE);
const { state, retry } = useIssueEvidence(id, scanId, providerId);
const activeScanId = state.status === "loaded" ? state.scanId : scanId;
const factors = riskFactors(issue);
const uniqueFindings =
state.status === "loaded"
? Array.from(
new Map(
state.findings?.map((finding) => [finding.id, finding]),
).values(),
)
: [];
const jiraPayload = createJiraDispatchPayload({
findingTitle: issue.title,
targetIds: uniqueFindings.map((finding) => finding.id),
targetType: JIRA_DISPATCH_TARGET.FINDING_ID,
description: `Send ${uniqueFindings.length} of ${issue.findings_count} findings on this attack path.`,
});
return (
<div className="flex min-w-0 flex-col gap-4 py-4">
{/* The conditions that hold at once: the verdict says how bad, these
say why, and which part to argue with. */}
{factors.length > 0 && (
<ul aria-label="Risk factors" className="flex flex-wrap gap-1.5">
{factors.map((factor) => (
<li key={factor.label}>
<Badge variant={factor.variant} title={factor.title}>
{factor.label}
</Badge>
</li>
))}
</ul>
)}
{state.status === "loaded" && state.findings === undefined && (
<IssuesLoadError
message="Could not load the findings for this issue."
onRetry={retry}
/>
)}
{state.status === "loaded" &&
state.findings !== undefined &&
state.resources.length > 0 && (
<IssueGraph
issue={issue}
resourceUids={state.resources}
findings={state.findings}
{...(state.providerId && state.scanId
? { providerId: state.providerId, scanId: state.scanId }
: { providerId: undefined, scanId: undefined })}
/>
)}
{issue.outcome && issue.attacker_start && (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<InfoField label="Outcome">
{ISSUE_OUTCOME_LABEL[issue.outcome]}
</InfoField>
{/* Inline, not an InfoTooltip: its trigger has no tab stop. */}
<InfoField label="Starts from">
{ATTACKER_START_LABEL[issue.attacker_start]}
<p className="text-text-neutral-tertiary text-xs">
Where the attacker has to be standing. Whether they also need a
credential is the Access leg.
</p>
</InfoField>
</div>
)}
<section aria-label="Exploitability assessment">
<h4 className="mb-3 text-sm font-semibold">
Exploitability assessment
</h4>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
{(Object.keys(LEG_QUESTIONS) as LegName[]).map((name) => {
const leg = issue.legs[name];
if (!leg) return null;
return (
<Card key={name} variant="inner" className="min-w-0 gap-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<h5 className="text-sm font-medium">{LEG_LABELS[name]}</h5>
<Badge variant={legBadgeVariant(name, leg.state)}>
{LEG_STATE_LABELS[leg.state]}
</Badge>
</div>
<p className="text-text-neutral-tertiary text-xs">
{LEG_QUESTIONS[name]}
</p>
<p className="text-text-neutral-secondary text-sm">
{leg.text}
</p>
{leg.evidence.length > 0 && (
<ul className="flex flex-col gap-1">
{leg.evidence.map((line) => (
<li key={line}>
<CodeSnippet
value={line}
multiline
hideCopyButton
transparent
/>
</li>
))}
</ul>
)}
</Card>
);
})}
</div>
</section>
{state.status === "loading" ? (
<p role="status" className="text-text-neutral-tertiary text-sm">
Loading issue details…
</p>
) : state.status === "error" ? (
<IssuesLoadError
message="Could not load the resources for this issue."
onRetry={retry}
/>
) : state.resources.length > 0 ? (
<section aria-label="Affected resources">
<h4 className="mb-3 text-sm font-semibold">
Affected resources ({state.resources.length})
</h4>
<ul className="flex flex-col gap-2">
{state.resources.slice(0, visibleResources).map((uid) => (
<li
key={uid}
className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1"
>
<CodeSnippet value={uid} multiline ariaLabel={`Copy ${uid}`} />
<Button asChild variant="link" size="link-sm">
<Link
href={resourceHref(uid, state.providerId)}
aria-label={`Open in Resources: ${uid}`}
>
Open in Resources
</Link>
</Button>
</li>
))}
</ul>
{visibleResources < state.resources.length && (
<Button
variant="link"
size="link-sm"
onClick={() =>
setVisibleResources((value) => value + RESOURCE_PAGE_SIZE)
}
>
Show more resources
</Button>
)}
{visibleResources > RESOURCE_PAGE_SIZE && (
<Button
variant="link"
size="link-sm"
onClick={() => setVisibleResources(RESOURCE_PAGE_SIZE)}
>
Show fewer resources
</Button>
)}
</section>
) : null}
<section aria-label="Remediation">
<h4 className="mb-3 text-sm font-semibold">Remediation</h4>
<p className="text-text-neutral-secondary text-sm">{issue.fix}</p>
<div className="flex flex-wrap items-center gap-3">
{activeScanId && (
<Button asChild variant="link" size="link-sm">
<Link
href={`/findings?${new URLSearchParams({ "filter[scan__in]": activeScanId, "filter[status__in]": "FAIL" })}`}
>
Open this scan&apos;s failed findings
</Link>
</Button>
)}
{jiraPayload && (
// shortcut: the ticket groups the loaded preview (ISSUE_FINDINGS_LIMIT
// findings), not every finding of a larger issue; page through
// finding_uids if tickets for big issues need all of them.
<Button
variant="outline"
size="sm"
onClick={() => openJiraDispatch(jiraPayload)}
>
<Send aria-hidden="true" />
Send to Jira
</Button>
)}
</div>
</section>
{state.status === "loaded" &&
state.findings &&
state.findings.length > 0 && (
<IssueFindings
findings={uniqueFindings}
scanId={activeScanId}
total={issue.findings_count}
/>
)}
{state.status === "loaded" &&
state.findings &&
state.findings.length > 0 && (
<IssueCompliance
findings={state.findings}
scanId={activeScanId}
truncated={state.findingsTruncated}
/>
)}
<IssueTriage id={id} status={issue.triage_status} />
</div>
);
};
+68 -76
View File
@@ -1,91 +1,83 @@
import Link from "next/link";
import { Button } from "@/components/shadcn/button/button";
import { Card } from "@/components/shadcn/card/card";
import { SeverityBadge } from "@/components/shadcn/table/severity-badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/shadcn/table/table";
import { TriageStatusBadge } from "@/components/shared/triage/triage-status-badge";
import { MUTED_FILTER } from "@/lib/findings-filters";
import type { IssueFindingsProps } from "./types";
const HEADERS = ["Finding", "Severity", "Triage"] as const;
/**
* The findings the issue folds, laid out like the finding panel's "Other
* findings" table. Each keeps its own triage: the issue's status answers for
* the combination, the finding's for whether that one check is right.
* The findings the issue folds, each with its own triage. That triage stays on
* the finding: the issue's status answers for the combination, the finding's
* for whether that one check is right.
*/
export const IssueFindings = ({
findings,
scanId,
total,
onOpenFinding,
}: IssueFindingsProps) => {
return (
<section aria-label="Findings" className="flex flex-col gap-2">
<div className="flex items-center justify-end">
<span className="text-text-neutral-tertiary text-sm">
{findings.length < total
? `Showing ${findings.length.toLocaleString()} of ${total.toLocaleString()}`
: `${total.toLocaleString()} Total Entries`}
</span>
</div>
<Table>
<TableHeader>
<TableRow>
{HEADERS.map((header) => (
<TableHead key={header}>
<span className="text-text-neutral-secondary text-sm font-medium">
{header}
</span>
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{findings.map((finding) => (
<TableRow
key={finding.id}
className="group cursor-pointer"
onClick={() => onOpenFinding(finding.id)}
>
{/* max-w-0 lets the title take the spare width and truncate. */}
<TableCell className="w-full max-w-0">
{/* The row opens the finding; the button is its keyboard stop. */}
<button
type="button"
title={finding.checkTitle}
className="text-text-neutral-primary block max-w-full truncate text-left text-sm"
onClick={(event) => {
event.stopPropagation();
onOpenFinding(finding.id);
}}
>
{finding.checkTitle}
</button>
{/* One check fails on several resources: this tells them apart. */}
<p
className="text-text-neutral-tertiary truncate font-mono text-xs"
title={finding.resourceUid}
>
{finding.resourceUid}
</p>
</TableCell>
<TableCell>
<SeverityBadge severity={finding.severity} />
</TableCell>
<TableCell>
<TriageStatusBadge
status={finding.triageStatus}
showLabel={false}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<section aria-label="Findings">
<h4 className="mb-3 text-sm font-semibold">
Findings ({total.toLocaleString()})
</h4>
<ul className="grid grid-cols-1 gap-2 lg:grid-cols-2">
{findings.map((finding) => (
<li key={finding.id} className="min-w-0">
<Card variant="inner" padding="md" className="h-full">
<div className="flex flex-col gap-1">
<div className="flex items-start justify-between gap-3">
{scanId ? (
<div className="min-w-0">
<Button
asChild
variant="link"
size="link-sm"
className="max-w-full"
>
<Link
href={`/findings?${new URLSearchParams({
"filter[scan__in]": scanId,
"filter[check_id__in]": finding.checkId,
"filter[resource_uid]": finding.resourceUid,
"filter[muted]": MUTED_FILTER.INCLUDE,
expandedCheckId: finding.checkId,
})}`}
title={finding.checkTitle}
>
<span className="truncate">{finding.checkTitle}</span>
</Link>
</Button>
</div>
) : (
<span className="text-text-neutral-primary min-w-0 text-sm break-words">
{finding.checkTitle}
</span>
)}
<SeverityBadge severity={finding.severity} />
</div>
<div className="flex items-baseline justify-between gap-3">
<span className="text-text-neutral-tertiary min-w-0 truncate font-mono text-xs">
{finding.checkId}
</span>
<TriageStatusBadge
status={finding.triageStatus}
showLabel={false}
/>
</div>
</div>
</Card>
</li>
))}
</ul>
{findings.length < total && (
<p className="text-text-neutral-tertiary mt-2 text-xs">
Showing {findings.length.toLocaleString()} of {total.toLocaleString()}
.
</p>
)}
</section>
);
};
+44 -20
View File
@@ -6,8 +6,22 @@ import { beforeEach, describe, expect, expectTypeOf, it, vi } from "vitest";
import { GraphNode } from "@/types/attack-paths";
import { IssueAttributes } from "@/types/issues";
const { getFindings } = vi.hoisted(() => ({ getFindings: vi.fn() }));
const { getFindings, navigateToFinding } = vi.hoisted(() => ({
getFindings: vi.fn(),
navigateToFinding: vi.fn(),
}));
vi.mock("@/actions/findings", () => ({ getFindings }));
vi.mock("@/hooks/use-finding-details", () => ({
useFindingDetails: () => ({
findingDetails: null,
findingDetailLoading: false,
resetFindingDetails: vi.fn(),
navigateToFinding,
}),
}));
vi.mock("@/components/findings/table", () => ({
FindingDetailDrawer: () => null,
}));
vi.mock("@/components/attack-paths/graph/attack-path-graph", () => ({
AttackPathGraph: ({
data,
@@ -38,7 +52,7 @@ const issue = { legs: {} } as IssueAttributes;
it("requires provider and scan identifiers together in the graph props", () => {
type Props = ComponentProps<typeof IssueGraph>;
type BaseProps = Pick<Props, "issue" | "resourceUids" | "onOpenFinding">;
type BaseProps = Pick<Props, "issue" | "resourceUids">;
expectTypeOf<BaseProps>().toExtend<Props>();
expectTypeOf<
@@ -49,10 +63,31 @@ it("requires provider and scan identifiers together in the graph props", () => {
});
describe("IssueGraph finding lookup", () => {
const openFinding = vi.fn();
beforeEach(() => {
openFinding.mockReset();
navigateToFinding.mockResolvedValue(undefined);
});
it("leaves findings closed in a read-only graph", async () => {
// Given: a host that cannot hold a second detail view, such as the panel
render(
<IssueGraph
issue={issue}
resourceUids={["bucket"]}
providerId="provider-1"
scanId="scan-1"
readOnly
/>,
);
// When
await userEvent.click(screen.getByRole("button", { name: "Open finding" }));
// Then
expect(navigateToFinding).not.toHaveBeenCalled();
expect(getFindings).not.toHaveBeenCalled();
expect(
screen.getByText(/Open the full path to inspect a finding/),
).toBeInTheDocument();
});
it("opens a folded finding by id without another lookup", async () => {
@@ -74,7 +109,6 @@ describe("IssueGraph finding lookup", () => {
]}
providerId="provider-1"
scanId="scan-1"
onOpenFinding={openFinding}
/>,
);
@@ -82,7 +116,7 @@ describe("IssueGraph finding lookup", () => {
await userEvent.click(screen.getByRole("button", { name: "Open finding" }));
// Then
expect(openFinding).toHaveBeenCalledWith("folded-finding");
expect(navigateToFinding).toHaveBeenCalledWith("folded-finding");
expect(getFindings).not.toHaveBeenCalled();
});
@@ -102,7 +136,6 @@ describe("IssueGraph finding lookup", () => {
resourceUids={["bucket"]}
providerId="provider-1"
scanId="scan-1"
onOpenFinding={openFinding}
/>,
);
await user.click(screen.getByRole("button", { name: "Open finding" }));
@@ -116,7 +149,7 @@ describe("IssueGraph finding lookup", () => {
"filter[status]": "FAIL",
},
});
expect(openFinding).toHaveBeenCalledWith("finding-1");
expect(navigateToFinding).toHaveBeenCalledWith("finding-1");
});
it("rejects a finding from a newer scan", async () => {
@@ -134,11 +167,10 @@ describe("IssueGraph finding lookup", () => {
resourceUids={["bucket"]}
providerId="provider-1"
scanId="scan-1"
onOpenFinding={openFinding}
/>,
);
await userEvent.click(screen.getByRole("button", { name: "Open finding" }));
expect(openFinding).not.toHaveBeenCalled();
expect(navigateToFinding).not.toHaveBeenCalled();
expect(screen.getByRole("alert")).toHaveTextContent(
"no longer available for the issue's scan",
);
@@ -154,7 +186,6 @@ describe("IssueGraph finding lookup", () => {
resourceUids={["bucket"]}
providerId="provider-1"
scanId="scan-1"
onOpenFinding={openFinding}
/>,
);
await userEvent.click(screen.getByRole("button", { name: "Open finding" }));
@@ -178,7 +209,6 @@ describe("IssueGraph finding lookup", () => {
resourceUids={["bucket"]}
providerId="provider-1"
scanId="scan-1"
onOpenFinding={openFinding}
/>,
);
await userEvent.click(screen.getByRole("button", { name: "Open finding" }));
@@ -194,13 +224,7 @@ describe("IssueGraph finding lookup", () => {
});
it("does not look up findings without a provider and scan", async () => {
render(
<IssueGraph
issue={issue}
resourceUids={["bucket"]}
onOpenFinding={openFinding}
/>,
);
render(<IssueGraph issue={issue} resourceUids={["bucket"]} />);
await userEvent.click(screen.getByRole("button", { name: "Open finding" }));
expect(getFindings).not.toHaveBeenCalled();
expect(screen.getByRole("alert")).toHaveTextContent(
+38 -8
View File
@@ -4,12 +4,14 @@ import { useEffect, useRef, useState } from "react";
import { getFindings } from "@/actions/findings";
import { AttackPathGraph } from "@/components/attack-paths/graph/attack-path-graph";
import { FindingDetailDrawer } from "@/components/findings/table";
import {
buildIssueGraph,
GRAPH_RESOURCE_LIMIT,
} from "@/components/issues/issue-graph.adapter";
import { Alert, AlertDescription } from "@/components/shadcn/alert";
import { Card } from "@/components/shadcn/card/card";
import { useFindingDetails } from "@/hooks/use-finding-details";
import { useMediaQuery } from "@/hooks/use-media-query";
import { isProwlerFindingNode } from "@/lib/attack-paths/node-types";
import { GraphNode } from "@/types/attack-paths";
@@ -19,8 +21,11 @@ interface IssueGraphBaseProps {
issue: IssueAttributes;
resourceUids: string[];
findings?: IssueFinding[];
/** Called with the clicked finding's id; the host shows its detail. */
onOpenFinding: (findingId: string) => void;
/**
* No finding drawer on click: for hosts inside the side panel, where a
* second detail view would replace the one holding the graph.
*/
readOnly?: boolean;
}
interface ScopedIssueGraphProps extends IssueGraphBaseProps {
@@ -46,8 +51,9 @@ export const IssueGraph = ({
findings = [],
providerId,
scanId,
onOpenFinding,
readOnly = false,
}: IssueGraphProps) => {
const finding = useFindingDetails();
const canKeepReadableZoom = useMediaQuery(READABLE_ZOOM_MEDIA_QUERY);
const view = buildIssueGraph(issue, resourceUids, findings);
const shownFindings = new Set(
@@ -58,6 +64,7 @@ export const IssueGraph = ({
const pending = useRef(false);
const mounted = useRef(true);
const [loading, setLoading] = useState(false);
const [lookupComplete, setLookupComplete] = useState(false);
const [error, setError] = useState<string | null>(null);
const expandedResources = new Set(
view.nodes
@@ -75,6 +82,8 @@ export const IssueGraph = ({
const handleNodeClick = async (node: GraphNode) => {
if (!isProwlerFindingNode(node.labels) || pending.current) return;
setError(null);
setLookupComplete(false);
finding.resetFindingDetails();
let findingId = node.properties.finding_id;
if (!findingId && (!providerId || !scanId)) {
setError(
@@ -111,7 +120,8 @@ export const IssueGraph = ({
}
findingId = match.id;
}
onOpenFinding(String(findingId));
await finding.navigateToFinding(String(findingId));
if (mounted.current) setLookupComplete(true);
} catch {
if (mounted.current)
setError("Could not load this finding. Select the finding to retry.");
@@ -120,6 +130,9 @@ export const IssueGraph = ({
if (mounted.current) setLoading(false);
}
};
const detailFailed =
lookupComplete && !finding.findingDetailLoading && !finding.findingDetails;
return (
<section
aria-label="Finding evidence"
@@ -131,16 +144,20 @@ export const IssueGraph = ({
{Math.min(resourceUids.length, GRAPH_RESOURCE_LIMIT)} of{" "}
{resourceUids.length} affected resources, plus supporting checks cited
by the assessment. These links show evidence associations, not confirmed
attack paths. Select a finding to open it.
attack paths.
{readOnly && " Open the full path to inspect a finding."}
</p>
{loading && (
<p role="status" className="text-sm">
Loading finding details…
</p>
)}
{error && (
{(error || detailFailed) && (
<Alert variant="error">
<AlertDescription>{error}</AlertDescription>
<AlertDescription>
{error ??
"Could not load this finding. Select the finding to retry."}
</AlertDescription>
</Alert>
)}
<Card
@@ -152,10 +169,23 @@ export const IssueGraph = ({
data={{ nodes: view.nodes, edges: view.edges }}
view={view}
expandedResources={expandedResources}
onNodeClick={handleNodeClick}
onNodeClick={readOnly ? undefined : handleNodeClick}
minFitZoom={canKeepReadableZoom ? ISSUE_GRAPH_MIN_ZOOM : undefined}
/>
</Card>
{finding.findingDetails && (
<FindingDetailDrawer
key={finding.findingDetails.id}
finding={finding.findingDetails}
defaultOpen
onOpenChange={(open) => {
if (!open) {
setLookupComplete(false);
finding.resetFindingDetails();
}
}}
/>
)}
</section>
);
};
-533
View File
@@ -1,533 +0,0 @@
import { act, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ReactNode } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useJiraDispatchStore } from "@/store/jira-dispatch/store";
import {
ISSUE_FINDINGS_LIMIT,
IssueAttributes,
IssueFinding,
IssueResource,
} from "@/types/issues";
import { IssuePanel } from "./issue-panel";
const { getIssue, getIssueFindings, getFindingById } = vi.hoisted(() => ({
getIssue: vi.fn(),
getIssueFindings: vi.fn(),
getFindingById: vi.fn(),
}));
vi.mock("next/navigation", () => ({
usePathname: () => "/issues",
useRouter: () => ({ refresh: vi.fn() }),
useSearchParams: () => new URLSearchParams(),
}));
vi.mock("@/actions/issues", () => ({
getIssue,
getIssueFindings,
getIssueNotes: async () => [],
}));
vi.mock("@/actions/findings", () => ({ getFindingById }));
// The real helper sits behind the server-only `@/lib` barrel.
vi.mock("@/lib/finding-detail", () => ({
expandFindingWithRelationships: (response?: { data?: unknown }) =>
response?.data ?? null,
}));
vi.mock("@/components/issues/issue-compliance", () => ({
IssueCompliance: ({ truncated }: { truncated: boolean }) =>
truncated ? <p>Compliance from a capped preview</p> : null,
}));
vi.mock("@/components/findings/send-to-jira-modal", () => ({
SendToJiraModal: () => null,
}));
vi.mock("@/components/findings/table", () => ({
FindingDetailDrawer: ({ finding }: { finding: { id: string } }) => (
<p>Detail of {finding.id}</p>
),
}));
vi.mock("@/components/issues/issue-graph", () => ({
IssueGraph: ({
resourceUids,
onOpenFinding,
}: {
resourceUids: string[];
onOpenFinding: (id: string) => void;
}) => (
<button type="button" onClick={() => onOpenFinding("graph-finding")}>
Graph of {resourceUids.length} resources
</button>
),
}));
// The real panel portals into the app shell's side panel, absent here.
vi.mock("@/components/side-panel/detail-side-panel", () => ({
DetailSidePanel: ({
title,
children,
}: {
title: string;
children: ReactNode;
}) => <section aria-label={title}>{children}</section>,
}));
const resourceUids = Array.from({ length: 15 }, (_, i) => `resource-${i + 1}`);
const attributes = {
pattern: "unmodelled",
cloud: "aws",
account: "prod",
title: "Findings no attack path models yet (300)",
verdict: "unknown",
severity: "high",
outcome: "",
attacker_start: "",
legs: {},
fix: "Review these findings individually.",
findings_count: 300,
triage_status: "open",
inserted_at: "",
updated_at: "",
} as unknown as IssueAttributes;
const issueWith = (
overrides: Partial<IssueAttributes> = {},
scanId: string | undefined = "scan-1",
): IssueResource => ({
type: "issues",
id: "issue-1",
attributes: { ...attributes, ...overrides },
relationships: {
provider: { data: { type: "providers", id: "provider-1" } },
...(scanId && { scan: { data: { type: "scans", id: scanId } } }),
},
});
const finding: IssueFinding = {
id: "finding-1",
uid: "f-1",
checkId: "s3_bucket_public_access",
checkTitle: "Bucket is public",
severity: "critical",
triageStatus: "risk_accepted",
resourceUid: "bucket",
};
const detailWith = (resource_uids: string[], finding_uids: string[] = []) => ({
data: { attributes: { resource_uids, finding_uids } },
});
const renderPanel = (
issue: IssueResource = issueWith(),
providerUid?: string,
) =>
render(
<IssuePanel
issue={issue}
providerUid={providerUid}
open
onOpenChange={vi.fn()}
/>,
);
const openTab = (name: string) =>
userEvent.click(screen.getByRole("tab", { name }));
describe("IssuePanel", () => {
beforeEach(() => {
getIssue.mockReset();
getIssueFindings.mockReset().mockResolvedValue([]);
getFindingById.mockReset();
useJiraDispatchStore.getState().closeJiraDispatch();
});
afterEach(() => {
vi.unstubAllEnvs();
});
it("says where the path starts and shows each leg's evidence", async () => {
// Given
getIssue.mockResolvedValue(detailWith([]));
// When
renderPanel(
issueWith({
outcome: "data_exposure",
attacker_start: "internet",
legs: {
reachability: {
state: "yes",
text: "Public access is enabled.",
evidence: ["s3_public · arn:aws:s3:::bucket"],
},
} as IssueAttributes["legs"],
}),
);
// Then
expect(screen.getByText("Data exposure")).toBeInTheDocument();
expect(screen.getByText("The internet")).toBeInTheDocument();
// Shown inline so keyboard users can read it too.
expect(
screen.getByText(/Where the attacker has to be standing/),
).toBeInTheDocument();
expect(
within(
screen.getByRole("list", { name: "Reachability evidence" }),
).getByText("s3_public · arn:aws:s3:::bucket"),
).toBeInTheDocument();
expect(
screen.queryByRole("link", { name: "Open the full path" }),
).not.toBeInTheDocument();
});
it("names the provider with its account uid, like the finding panel", () => {
// Given
getIssue.mockResolvedValue(detailWith([]));
// When
renderPanel(issueWith(), "123456789012");
// Then
expect(screen.getByText("Provider")).toBeInTheDocument();
expect(screen.getByText("prod")).toBeInTheDocument();
expect(screen.getByText("123456789012")).toBeInTheDocument();
expect(screen.getByText("unmodelled")).toBeInTheDocument();
});
it("claims no outcome for an issue that is not one path", () => {
getIssue.mockResolvedValue(detailWith([]));
renderPanel();
expect(screen.queryByText("Leads to")).not.toBeInTheDocument();
});
it("pages through every affected resource and links each to Resources", async () => {
// Given
getIssue.mockResolvedValue(detailWith(resourceUids));
renderPanel();
// When
await openTab("Resources");
// Then: the list leaves the uids out, so the panel fetches them
expect(getIssue).toHaveBeenCalledWith("issue-1");
expect(await screen.findByText("resource-1")).toBeInTheDocument();
expect(screen.queryByText("resource-15")).not.toBeInTheDocument();
expect(
screen.getByRole("link", { name: "Open in Resources: resource-1" }),
).toHaveAttribute(
"href",
"/resources?filter%5Buid%5D=resource-1&filter%5Bprovider_id__in%5D=provider-1",
);
await userEvent.click(
screen.getByRole("button", { name: "Show more resources" }),
);
expect(screen.getByText("resource-15")).toBeInTheDocument();
});
it("says so when the resources cannot be loaded", async () => {
getIssue.mockResolvedValue(undefined);
renderPanel();
await openTab("Resources");
expect(
await screen.findByText(/could not load the resources/i),
).toBeInTheDocument();
});
it("reports a failed findings request and retries using the issue scan", async () => {
// Given
getIssue.mockResolvedValue(detailWith(resourceUids, ["finding-uid"]));
getIssueFindings
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce([finding]);
renderPanel();
// When
await openTab("Findings");
expect(await screen.findByRole("alert")).toHaveTextContent(
"Could not load the findings for this issue",
);
await userEvent.click(screen.getByRole("button", { name: "Retry" }));
// Then
expect(
await screen.findByRole("button", { name: "Bucket is public" }),
).toBeInTheDocument();
expect(getIssueFindings).toHaveBeenLastCalledWith("scan-1", [
"finding-uid",
]);
});
it("lists each folded finding once, with its own triage", async () => {
// Given
getIssue.mockResolvedValue(detailWith(["bucket"], ["f-1", "f-2"]));
getIssueFindings.mockResolvedValue([
finding,
// The same finding on a second resource is still one finding.
{ ...finding, resourceUid: "other-bucket" },
{
...finding,
id: "finding-2",
uid: "f-2",
checkTitle: "Bucket has no logging",
triageStatus: "open",
},
]);
renderPanel();
// When
await openTab("Findings");
// Then
const section = await screen.findByRole("region", { name: "Findings" });
// One header row plus one row per finding.
expect(within(section).getAllByRole("row")).toHaveLength(3);
expect(within(section).getByText("Risk Accepted")).toBeInTheDocument();
// Cards of one check tell their resources apart.
expect(within(section).getByText("other-bucket")).toBeInTheDocument();
expect(within(section).getByText("Showing 2 of 300")).toBeInTheDocument();
});
it("tells the compliance section when the findings preview was capped", async () => {
// Given: one more finding than the preview loads
getIssue.mockResolvedValue(
detailWith(
[],
Array.from({ length: ISSUE_FINDINGS_LIMIT + 1 }, (_, i) => `uid-${i}`),
),
);
getIssueFindings.mockResolvedValue([finding]);
renderPanel();
// When
await openTab("Findings");
// Then
expect(
await screen.findByText("Compliance from a capped preview"),
).toBeInTheDocument();
});
it("opens a finding in place and goes back to the tab it came from", async () => {
// Given
getIssue.mockResolvedValue(detailWith(["bucket"], ["f-1"]));
getIssueFindings.mockResolvedValue([finding]);
getFindingById.mockResolvedValue({
data: {
id: "finding-1",
attributes: { check_metadata: { checktitle: "Bucket is public" } },
relationships: {},
},
});
renderPanel();
await openTab("Findings");
// When
await userEvent.click(
await screen.findByRole("button", { name: "Bucket is public" }),
);
// Then: the finding replaces the tabs, with a way back
expect(await screen.findByText("Detail of finding-1")).toBeInTheDocument();
expect(getFindingById).toHaveBeenCalledWith(
"finding-1",
"resources,scan.provider",
);
expect(screen.queryByRole("tablist")).not.toBeInTheDocument();
// When
await userEvent.click(screen.getByText("Issue details"));
// Then
expect(screen.getByRole("tab", { name: "Findings" })).toHaveAttribute(
"aria-selected",
"true",
);
});
it("refreshes the findings behind the tabs when a finding closes", async () => {
// Given: the finding is triaged while open in the panel
getIssue.mockResolvedValue(detailWith(["bucket"], ["f-1"]));
getIssueFindings
.mockResolvedValueOnce([{ ...finding, triageStatus: "open" }])
.mockResolvedValueOnce([{ ...finding, triageStatus: "risk_accepted" }]);
getFindingById.mockResolvedValue({
data: { id: "finding-1", attributes: {}, relationships: {} },
});
renderPanel();
await openTab("Findings");
await userEvent.click(
await screen.findByRole("button", { name: "Bucket is public" }),
);
await screen.findByText("Detail of finding-1");
// When
await userEvent.click(screen.getByText("Issue details"));
// Then: the row shows the new triage without dropping to a loading state
expect(
screen.getByRole("button", { name: "Bucket is public" }),
).toBeInTheDocument();
expect(await screen.findByText("Risk Accepted")).toBeInTheDocument();
expect(screen.queryByText("Loading issue details…")).toBeNull();
});
it.each([
{
failure: "the findings request",
fail: () => getIssueFindings.mockResolvedValueOnce(undefined),
failing: getIssueFindings,
},
{
failure: "the issue request",
fail: () => getIssue.mockRejectedValueOnce(new Error("Offline")),
failing: getIssue,
},
])(
"keeps the loaded rows when $failure fails on a background refresh",
async ({ fail, failing }) => {
// Given: the findings loaded, and a finding is open
getIssue.mockResolvedValue(detailWith(["bucket"], ["f-1"]));
getIssueFindings.mockResolvedValueOnce([finding]);
getFindingById.mockResolvedValue({
data: { id: "finding-1", attributes: {}, relationships: {} },
});
renderPanel();
await openTab("Findings");
await userEvent.click(
await screen.findByRole("button", { name: "Bucket is public" }),
);
await screen.findByText("Detail of finding-1");
fail();
// When: closing it refreshes, and the refresh fails
await userEvent.click(screen.getByText("Issue details"));
// Then: once the failing refresh has settled, the rows stay
await waitFor(() => expect(failing).toHaveBeenCalledTimes(2));
await act(async () => {
await failing.mock.results[1]?.value?.catch(() => undefined);
});
expect(
screen.getByRole("button", { name: "Bucket is public" }),
).toBeInTheDocument();
expect(screen.queryByRole("alert")).toBeNull();
},
);
it("keeps the rows on screen while the background refresh is pending", async () => {
// Given: the refresh after closing the finding never answers
getIssue
.mockResolvedValueOnce(detailWith(["bucket"], ["f-1"]))
.mockReturnValueOnce(new Promise(() => {}));
getIssueFindings.mockResolvedValueOnce([finding]);
getFindingById.mockResolvedValue({
data: { id: "finding-1", attributes: {}, relationships: {} },
});
renderPanel();
await openTab("Findings");
await userEvent.click(
await screen.findByRole("button", { name: "Bucket is public" }),
);
await screen.findByText("Detail of finding-1");
// When
await userEvent.click(screen.getByText("Issue details"));
await waitFor(() => expect(getIssue).toHaveBeenCalledTimes(2));
// Then: the loaded rows stay, with no loading state in their place
expect(
screen.getByRole("button", { name: "Bucket is public" }),
).toBeInTheDocument();
expect(screen.queryByText("Loading issue details…")).toBeNull();
});
it("applies refreshed resources even when the refreshed findings fail", async () => {
// Given: the refresh brings a new resource but loses the findings
getIssue
.mockResolvedValueOnce(detailWith(["bucket"], ["f-1"]))
.mockResolvedValueOnce(detailWith(["bucket", "new-bucket"], ["f-1"]));
getIssueFindings
.mockResolvedValueOnce([finding])
.mockResolvedValueOnce(undefined);
getFindingById.mockResolvedValue({
data: { id: "finding-1", attributes: {}, relationships: {} },
});
renderPanel();
await openTab("Findings");
await userEvent.click(
await screen.findByRole("button", { name: "Bucket is public" }),
);
await screen.findByText("Detail of finding-1");
// When
await userEvent.click(screen.getByText("Issue details"));
await waitFor(() => expect(getIssueFindings).toHaveBeenCalledTimes(2));
// Then: the findings stay, the resources follow the refresh
expect(
screen.getByRole("button", { name: "Bucket is public" }),
).toBeInTheDocument();
await openTab("Resources");
expect(await screen.findByText("new-bucket")).toBeInTheDocument();
});
it("opens a finding clicked on the attack path in place", async () => {
// Given
getIssue.mockResolvedValue(detailWith(["bucket"], ["f-1"]));
getIssueFindings.mockResolvedValue([finding]);
getFindingById.mockResolvedValue(undefined);
renderPanel();
await openTab("Attack path");
// When
await userEvent.click(
await screen.findByRole("button", { name: "Graph of 1 resources" }),
);
// Then: a failed detail says so and can be retried
expect(getFindingById).toHaveBeenCalledWith(
"graph-finding",
"resources,scan.provider",
);
expect(
await screen.findByText("Could not load this finding."),
).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Retry" }));
expect(getFindingById).toHaveBeenCalledTimes(2);
});
it("sends the unique findings to Jira from the actions menu", async () => {
// Given: Prowler Cloud, where several findings can go in one dispatch
vi.stubEnv("UI_CLOUD_ENABLED", "true");
getIssue.mockResolvedValue(detailWith(["bucket"], ["f-1", "f-2"]));
getIssueFindings.mockResolvedValue([
finding,
{ ...finding, resourceUid: "other-bucket" },
{ ...finding, id: "finding-2", uid: "f-2" },
]);
const view = renderPanel();
// When
await userEvent.click(
await screen.findByRole("button", { name: "Issue actions" }),
);
await userEvent.click(
await screen.findByRole("menuitem", { name: "Send 2 Findings to Jira" }),
);
view.unmount();
// Then: the globally hosted modal survives the panel closing.
expect(useJiraDispatchStore.getState().activePayload).toMatchObject({
findingTitle: attributes.title,
selection: {
kind: "target-list",
targetType: "finding_id",
targetIds: ["finding-1", "finding-2"],
},
});
});
});
+123 -398
View File
@@ -1,38 +1,16 @@
"use client";
import { ArrowLeft, Box, Tag } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import Link from "next/link";
import { JiraDispatchActionItem } from "@/components/findings/jira-dispatch-action-item";
import { MarkdownContainer } from "@/components/findings/markdown-container";
import { FindingDetailDrawer } from "@/components/findings/table";
import { IssueAttackPath } from "@/components/issues/issue-attack-path";
import { IssueCompliance } from "@/components/issues/issue-compliance";
import { IssueFindings } from "@/components/issues/issue-findings";
import { IssueResources } from "@/components/issues/issue-resources";
import { IssueTriage } from "@/components/issues/issue-triage";
import { IssuesLoadError } from "@/components/issues/issues-load-error";
import {
BreadcrumbNavigation,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/shadcn";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/shadcn";
import { Badge } from "@/components/shadcn/badge/badge";
import { CodeSnippet } from "@/components/shadcn/code-snippet";
import {
ActionDropdown,
ActionDropdownItem,
} from "@/components/shadcn/dropdown";
import { Button } from "@/components/shadcn/button/button";
import { EntityInfo } from "@/components/shadcn/entities";
import { InfoField } from "@/components/shadcn/info-field/info-field";
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
import { LoadingState } from "@/components/shadcn/spinner/loading-state";
import { SeverityBadge } from "@/components/shadcn/table/severity-badge";
import { DetailSidePanel } from "@/components/side-panel/detail-side-panel";
import { useFindingDetails } from "@/hooks/use-finding-details";
import {
LEG_LABELS,
LEG_QUESTIONS,
@@ -40,9 +18,6 @@ import {
legBadgeVariant,
VERDICTS,
} from "@/lib/issues/verdicts";
import { buildJiraActionLabel } from "@/lib/jira-dispatch-action";
import { createJiraDispatchPayload } from "@/lib/jira-dispatch-selection";
import { JIRA_DISPATCH_TARGET } from "@/types/integrations";
import {
ATTACKER_START_LABEL,
ISSUE_OUTCOME_LABEL,
@@ -50,399 +25,149 @@ import {
LegName,
} from "@/types/issues";
import { useIssueEvidence } from "./use-issue-evidence";
const ISSUE_PANEL_TAB = {
EXPLOITABILITY: "exploitability",
ATTACK_PATH: "attack-path",
FINDINGS: "findings",
RESOURCES: "resources",
REMEDIATION: "remediation",
TRIAGE: "triage",
} as const;
type IssuePanelTab = (typeof ISSUE_PANEL_TAB)[keyof typeof ISSUE_PANEL_TAB];
const ISSUE_PANEL_TABS: { id: IssuePanelTab; label: string }[] = [
{ id: ISSUE_PANEL_TAB.EXPLOITABILITY, label: "Exploitability" },
{ id: ISSUE_PANEL_TAB.ATTACK_PATH, label: "Attack path" },
{ id: ISSUE_PANEL_TAB.FINDINGS, label: "Findings" },
{ id: ISSUE_PANEL_TAB.RESOURCES, label: "Resources" },
{ id: ISSUE_PANEL_TAB.REMEDIATION, label: "Remediation" },
{ id: ISSUE_PANEL_TAB.TRIAGE, label: "Triage" },
];
interface IssuePanelProps {
issue: IssueResource;
/** The provider's account uid, which the issue itself does not carry. */
providerUid?: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}
/** Everything about one issue, including drilling into its findings. */
export const IssuePanel = ({
issue,
providerUid,
open,
onOpenChange,
}: IssuePanelProps) => (
<DetailSidePanel
open={open}
onOpenChange={onOpenChange}
title={issue.attributes.title}
description="The issue's verdict, attack path, evidence, fix and triage"
>
{/* Mounted only while open, so the evidence loads when someone looks. */}
<IssuePanelContent issue={issue} providerUid={providerUid} />
</DetailSidePanel>
);
const IssuePanelContent = ({
issue,
providerUid,
}: Pick<IssuePanelProps, "issue" | "providerUid">) => {
const router = useRouter();
/**
* The verdict and the reasoning behind it, from the list row alone (no
* fetch). Graph, resources, findings and compliance stay on the issue page.
*/
export const IssuePanel = ({ issue, open, onOpenChange }: IssuePanelProps) => {
const { attributes } = issue;
const verdict = VERDICTS.find((item) => item.id === attributes.verdict);
const scanId = issue.relationships?.scan?.data?.id;
const providerId = issue.relationships?.provider?.data?.id;
const {
state: evidence,
retry,
refresh,
} = useIssueEvidence(issue.id, scanId, providerId);
// Controlled so closing a finding returns to the tab it was opened from.
const [tab, setTab] = useState<IssuePanelTab>(ISSUE_PANEL_TAB.EXPLOITABILITY);
const [openFindingId, setOpenFindingId] = useState<string | null>(null);
const finding = useFindingDetails();
const activeScanId = evidence.status === "loaded" ? evidence.scanId : scanId;
const scanFindingsHref =
activeScanId &&
`/findings?${new URLSearchParams({ "filter[scan__in]": activeScanId, "filter[status__in]": "FAIL" })}`;
const loadedFindings =
evidence.status === "loaded" ? evidence.findings : undefined;
// The findings request returns one row per finding and resource.
const uniqueFindings = Array.from(
new Map(loadedFindings?.map((item) => [item.id, item])).values(),
);
// shortcut: the ticket groups the loaded preview (ISSUE_FINDINGS_LIMIT
// findings), not every finding of a larger issue; page through finding_uids
// if tickets for big issues need all of them.
const jiraPayload = createJiraDispatchPayload({
findingTitle: attributes.title,
targetIds: uniqueFindings.map((item) => item.id),
targetType: JIRA_DISPATCH_TARGET.FINDING_ID,
description: `Send ${uniqueFindings.length} of ${attributes.findings_count} findings on this attack path.`,
});
const openFinding = (findingId: string) => {
setOpenFindingId(findingId);
void finding.navigateToFinding(findingId);
};
const closeFinding = () => {
setOpenFindingId(null);
finding.resetFindingDetails();
// The finding may have been triaged or muted while open.
refresh();
};
// A finding opens in place, like a resource's findings do: a second detail
// view would take the side panel over and close this issue.
if (openFindingId) {
return (
<div className="flex h-full min-h-0 flex-col gap-4">
<BreadcrumbNavigation
mode="custom"
customItems={[
{
name: "Issue details",
icon: <ArrowLeft />,
isClickable: true,
onClick: closeFinding,
},
{
name: finding.findingDetailLoading
? "Loading..."
: (finding.findingDetails?.attributes?.check_metadata
?.checktitle ?? "Finding Detail"),
isLast: true,
},
]}
/>
{finding.findingDetailLoading ? (
<LoadingState label="Loading finding details..." />
) : finding.findingDetails ? (
// The detail scrolls inside itself, so it needs a bounded height.
<div className="min-h-0 flex-1">
<FindingDetailDrawer
key={finding.findingDetails.id}
finding={finding.findingDetails}
inline
/>
</div>
) : (
<IssuesLoadError
message="Could not load this finding."
onRetry={() => openFinding(openFindingId)}
/>
)}
</div>
);
}
const evidencePending =
evidence.status === "loading" ? (
<p role="status" className="text-text-neutral-tertiary text-sm">
Loading issue details…
</p>
) : null;
return (
<div className="flex h-full min-w-0 flex-col gap-4 overflow-hidden">
<header className="flex flex-col gap-2">
<div className="flex items-start justify-between gap-3">
<DetailSidePanel
open={open}
onOpenChange={onOpenChange}
title={attributes.title}
description="Why this issue got its verdict and how to fix it"
>
<div className="flex h-full min-w-0 flex-col gap-4 overflow-hidden">
<header className="flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-3">
<Badge variant={verdict?.variant ?? "tag"}>
{verdict?.label ?? attributes.verdict}
</Badge>
<SeverityBadge severity={attributes.severity} />
</div>
{/* Same actions menu as the resource and finding panels. Jira joins
once the findings it sends have loaded. */}
<div className="shrink-0">
{scanFindingsHref || jiraPayload ? (
<ActionDropdown variant="bordered" ariaLabel="Issue actions">
{scanFindingsHref && (
<ActionDropdownItem
icon={<Tag className="size-5" />}
label="Open this scan's failed findings"
onSelect={() => router.push(scanFindingsHref)}
/>
)}
<JiraDispatchActionItem
label={buildJiraActionLabel({
findingCount: uniqueFindings.length,
})}
payload={jiraPayload}
/>
</ActionDropdown>
) : (
evidence.status === "loading" && (
<Skeleton className="size-8 rounded-md" />
)
)}
</div>
</div>
<h3 className="text-text-neutral-primary line-clamp-2 text-lg leading-tight font-medium">
{attributes.title}
</h3>
{/* Under the title, like the finding panel's frameworks. */}
{evidence.status === "loading" ? (
<div aria-hidden="true" className="flex flex-col gap-1.5">
<Skeleton className="h-4 w-28 rounded" />
<div className="flex flex-wrap items-center gap-2">
<Skeleton className="size-7 rounded-md" />
<Skeleton className="size-7 rounded-md" />
<Skeleton className="size-7 rounded-md" />
</div>
</div>
) : (
loadedFindings &&
loadedFindings.length > 0 && (
<IssueCompliance
findings={loadedFindings}
scanId={activeScanId}
truncated={
evidence.status === "loaded" && evidence.findingsTruncated
}
/>
)
)}
{/* The finding panel's Provider / Service / Region row. */}
<div
data-responsive-container
className="@container mt-2 flex min-w-0 flex-col"
<h3 className="text-text-neutral-primary line-clamp-2 text-lg leading-tight font-medium">
{attributes.title}
</h3>
<EntityInfo
cloudProvider={attributes.cloud}
entityAlias={attributes.account}
/>
<p className="text-text-neutral-tertiary text-xs break-all">
{attributes.findings_count.toLocaleString()}{" "}
{attributes.findings_count === 1 ? "finding" : "findings"} ·{" "}
{attributes.pattern}
</p>
<Button asChild variant="link" size="link-sm" className="self-start">
<Link href={`/issues/${issue.id}`}>Open the full path</Link>
</Button>
</header>
<Tabs
defaultValue="exploitability"
className="flex min-h-0 w-full flex-1 flex-col"
>
<div className="grid min-w-0 grid-cols-2 gap-4 @md:grid-cols-[minmax(0,1fr)_minmax(0,0.55fr)_minmax(0,1fr)] @md:gap-x-8">
<div className="col-span-2 flex min-w-0 flex-col gap-1 @md:col-span-1">
<span className="text-text-neutral-secondary text-[10px] whitespace-nowrap">
Provider
</span>
<EntityInfo
cloudProvider={attributes.cloud}
nameIcon={<Box className="size-4" />}
entityAlias={attributes.account}
entityId={providerUid}
/>
</div>
<InfoField label="Findings" variant="compact" className="min-w-0">
<span className="block truncate whitespace-nowrap">
{attributes.findings_count.toLocaleString()}
</span>
</InfoField>
<InfoField label="Pattern" variant="compact" className="min-w-0">
<span
className="block truncate whitespace-nowrap"
title={attributes.pattern}
>
{attributes.pattern}
</span>
</InfoField>
<div className="mb-4 shrink-0">
<TabsList>
<TabsTrigger value="exploitability">Exploitability</TabsTrigger>
<TabsTrigger value="attack-path">Attack path</TabsTrigger>
<TabsTrigger value="remediation">Remediation</TabsTrigger>
<TabsTrigger value="triage">Triage</TabsTrigger>
</TabsList>
</div>
</div>
</header>
<Tabs
value={tab}
onValueChange={(value) => setTab(value as IssuePanelTab)}
className="flex min-h-0 w-full flex-1 flex-col"
>
<div className="mb-4 shrink-0">
<TabsList>
{ISSUE_PANEL_TABS.map(({ id, label }) => (
// Six tabs truncate on a phone; the tooltip names them.
<TabsTrigger key={id} value={id} tooltip={label}>
{label}
</TabsTrigger>
))}
</TabsList>
</div>
<div className="minimal-scrollbar min-h-0 flex-1 overflow-y-auto">
<TabsContent value="exploitability" className="flex flex-col gap-4">
{attributes.attacker_start && attributes.outcome && (
<div className="grid grid-cols-2 gap-3">
<InfoField label="Starts from">
{ATTACKER_START_LABEL[attributes.attacker_start]}
</InfoField>
<InfoField label="Leads to">
{ISSUE_OUTCOME_LABEL[attributes.outcome]}
</InfoField>
</div>
)}
<ul
aria-label="Exploitability assessment"
className="divide-border-neutral-secondary flex flex-col divide-y"
>
{(Object.keys(LEG_LABELS) as LegName[]).map((name) => {
const leg = attributes.legs[name];
if (!leg) return null;
return (
<li key={name} className="flex items-start gap-3 py-3">
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="text-sm font-medium">
{LEG_LABELS[name]}
</span>
<span className="text-text-neutral-tertiary text-xs">
{LEG_QUESTIONS[name]}
</span>
<span className="text-text-neutral-secondary text-sm">
{leg.text}
</span>
</div>
<Badge variant={legBadgeVariant(name, leg.state)}>
{LEG_STATE_LABELS[leg.state]}
</Badge>
</li>
);
})}
</ul>
</TabsContent>
<div className="minimal-scrollbar min-h-0 flex-1 overflow-y-auto">
<TabsContent
value={ISSUE_PANEL_TAB.EXPLOITABILITY}
className="flex flex-col gap-4"
>
{attributes.attacker_start && attributes.outcome && (
<div className="grid grid-cols-2 gap-3">
{/* Inline, not an InfoTooltip: its trigger has no tab stop. */}
<InfoField label="Starts from">
{ATTACKER_START_LABEL[attributes.attacker_start]}
<p className="text-text-neutral-tertiary text-xs">
Where the attacker has to be standing. Whether they also
need a credential is the Access leg.
</p>
</InfoField>
<InfoField label="Leads to">
{ISSUE_OUTCOME_LABEL[attributes.outcome]}
</InfoField>
</div>
)}
<ul
aria-label="Exploitability assessment"
className="divide-border-neutral-secondary flex flex-col divide-y"
>
{(Object.keys(LEG_LABELS) as LegName[]).map((name) => {
const leg = attributes.legs[name];
if (!leg) return null;
return (
<li key={name} className="flex items-start gap-3 py-3">
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="text-sm font-medium">
{LEG_LABELS[name]}
</span>
<span className="text-text-neutral-tertiary text-xs">
{LEG_QUESTIONS[name]}
</span>
<span className="text-text-neutral-secondary text-sm">
{leg.text}
</span>
{leg.evidence.length > 0 && (
<ul
aria-label={`${LEG_LABELS[name]} evidence`}
className="mt-1 flex flex-col gap-1"
>
{leg.evidence.map((line) => (
<li key={line}>
<CodeSnippet
value={line}
multiline
hideCopyButton
transparent
/>
</li>
))}
</ul>
)}
</div>
<Badge variant={legBadgeVariant(name, leg.state)}>
{LEG_STATE_LABELS[leg.state]}
</Badge>
</li>
);
})}
</ul>
</TabsContent>
{/* Mounted only while selected, so the graph's requests run when
someone asks for it. */}
<TabsContent value="attack-path">
<IssueAttackPath
id={issue.id}
issue={attributes}
scanId={scanId}
providerId={providerId}
/>
</TabsContent>
<TabsContent value={ISSUE_PANEL_TAB.ATTACK_PATH}>
<IssueAttackPath
issue={attributes}
evidence={evidence}
onRetry={retry}
onOpenFinding={openFinding}
/>
</TabsContent>
<TabsContent value="remediation" className="flex flex-col gap-2">
<p className="text-text-neutral-secondary text-sm">
{attributes.fix}
</p>
{scanId && (
<Button
asChild
variant="link"
size="link-sm"
className="self-start"
>
<Link
href={`/findings?${new URLSearchParams({ "filter[scan__in]": scanId, "filter[status__in]": "FAIL" })}`}
>
Open this scan&apos;s failed findings
</Link>
</Button>
)}
</TabsContent>
<TabsContent value={ISSUE_PANEL_TAB.FINDINGS}>
{evidencePending ??
(evidence.status === "error" || loadedFindings === undefined ? (
<IssuesLoadError
message="Could not load the findings for this issue."
onRetry={retry}
/>
) : uniqueFindings.length === 0 ? (
<p className="text-text-neutral-tertiary text-sm">
No findings to show for this issue.
</p>
) : (
<IssueFindings
findings={uniqueFindings}
total={attributes.findings_count}
onOpenFinding={openFinding}
/>
))}
</TabsContent>
<TabsContent value={ISSUE_PANEL_TAB.RESOURCES}>
{evidencePending ??
(evidence.status === "error" ? (
<IssuesLoadError
message="Could not load the resources for this issue."
onRetry={retry}
/>
) : (
evidence.status === "loaded" && (
<IssueResources
resources={evidence.resources}
providerId={evidence.providerId}
/>
)
))}
</TabsContent>
{/* Laid out like the finding panel's Remediation tab. */}
<TabsContent value={ISSUE_PANEL_TAB.REMEDIATION}>
<div className="flex flex-col gap-1 px-1">
<span className="text-text-neutral-primary text-sm font-semibold">
Remediation:
</span>
<div className="text-text-neutral-primary text-sm">
<MarkdownContainer>{attributes.fix}</MarkdownContainer>
</div>
</div>
</TabsContent>
<TabsContent value={ISSUE_PANEL_TAB.TRIAGE}>
<IssueTriage
key={`${issue.id}-${attributes.updated_at}`}
id={issue.id}
status={attributes.triage_status}
/>
</TabsContent>
</div>
</Tabs>
</div>
<TabsContent value="triage">
<IssueTriage
key={`${issue.id}-${attributes.updated_at}`}
id={issue.id}
status={attributes.triage_status}
/>
</TabsContent>
</div>
</Tabs>
</div>
</DetailSidePanel>
);
};
-108
View File
@@ -1,108 +0,0 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { Button } from "@/components/shadcn/button/button";
import { CodeSnippet } from "@/components/shadcn/code-snippet";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/shadcn/table/table";
const RESOURCE_PAGE_SIZE = 12;
// The issue carries resource uids, not ids, so this opens the Resources list
// on that one resource rather than its drawer.
const resourceHref = (uid: string, providerId?: string) =>
`/resources?${new URLSearchParams({
"filter[uid]": uid,
// A uid is unique within an account, not across them.
...(providerId && { "filter[provider_id__in]": providerId }),
})}`;
interface IssueResourcesProps {
resources: string[];
providerId?: string;
}
/** The resources the issue folds, a page at a time, as a table. */
export const IssueResources = ({
resources,
providerId,
}: IssueResourcesProps) => {
const [visible, setVisible] = useState(RESOURCE_PAGE_SIZE);
if (resources.length === 0) {
return (
<p className="text-text-neutral-tertiary text-sm">
This issue has no affected resources.
</p>
);
}
return (
<section aria-label="Affected resources" className="flex flex-col gap-2">
<div className="flex items-center justify-end">
<span className="text-text-neutral-tertiary text-sm">
{resources.length.toLocaleString()} Total Entries
</span>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>
<span className="text-text-neutral-secondary text-sm font-medium">
Resource
</span>
</TableHead>
<TableHead className="w-10" />
</TableRow>
</TableHeader>
<TableBody>
{resources.slice(0, visible).map((uid) => (
<TableRow key={uid}>
<TableCell className="max-w-0">
<CodeSnippet value={uid} ariaLabel={`Copy ${uid}`} />
</TableCell>
<TableCell className="text-right whitespace-nowrap">
<Button asChild variant="link" size="link-sm">
<Link
href={resourceHref(uid, providerId)}
aria-label={`Open in Resources: ${uid}`}
>
Open in Resources
</Link>
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="flex flex-wrap items-center gap-3">
{visible < resources.length && (
<Button
variant="link"
size="link-sm"
onClick={() => setVisible((value) => value + RESOURCE_PAGE_SIZE)}
>
Show more resources
</Button>
)}
{visible > RESOURCE_PAGE_SIZE && (
<Button
variant="link"
size="link-sm"
onClick={() => setVisible(RESOURCE_PAGE_SIZE)}
>
Show fewer resources
</Button>
)}
</div>
</section>
);
};
+26 -28
View File
@@ -23,13 +23,18 @@ vi.mock("@/actions/issues", () => ({
getIssueFindings: async () => [],
getIssueNotes: async () => [],
}));
vi.mock("@/actions/findings", () => ({ getFindingById: vi.fn() }));
vi.mock("@/components/findings/table", () => ({
FindingDetailDrawer: () => null,
}));
vi.mock("@/components/issues/issue-graph", () => ({
IssueGraph: ({ resourceUids }: { resourceUids: string[] }) => (
<p>Graph of {resourceUids.join(", ")}</p>
IssueGraph: ({
resourceUids,
readOnly,
}: {
resourceUids: string[];
readOnly?: boolean;
}) => (
<p>
Graph of {resourceUids.join(", ")}
{readOnly ? " (read-only)" : ""}
</p>
),
}));
// The real panel portals into the app shell's side panel, absent here.
@@ -130,54 +135,47 @@ describe("IssuesTable", () => {
it("opens the issue in the side panel and closes it", async () => {
// Given
const user = userEvent.setup();
getIssue.mockResolvedValue({
data: {
...issue,
attributes: { ...issue.attributes, resource_uids: ["bucket"] },
},
});
render(<IssuesTable issues={[issue]} />);
// When
await user.click(screen.getByRole("button", { name: "Public bucket" }));
// Then: the assessment tab leads, and nothing sends the user elsewhere
// Then: the assessment tab leads, the full page stays one click away
const panel = screen.getByRole("region", { name: "Public bucket" });
expect(
within(panel).getByRole("tab", { name: "Exploitability" }),
).toHaveAttribute("aria-selected", "true");
expect(within(panel).getByText("Block Public Access off")).toBeVisible();
expect(
within(panel).queryByRole("link", { name: "Open the full path" }),
).not.toBeInTheDocument();
within(panel).getByRole("link", { name: "Open the full path" }),
).toHaveAttribute("href", "/issues/issue-1");
// When
await user.click(within(panel).getByRole("tab", { name: "Remediation" }));
// Then
expect(within(panel).getByText("Restrict access")).toBeVisible();
// When: the scan's failed findings sit in the actions menu
await user.click(
within(panel).getByRole("button", { name: "Issue actions" }),
);
await user.click(
await screen.findByRole("menuitem", {
expect(
within(panel).getByRole("link", {
name: "Open this scan's failed findings",
}),
);
// Then
expect(push).toHaveBeenLastCalledWith(
).toHaveAttribute(
"href",
"/findings?filter%5Bscan__in%5D=scan-1&filter%5Bstatus__in%5D=FAIL",
);
// When
getIssue.mockResolvedValue({
data: {
...issue,
attributes: { ...issue.attributes, resource_uids: ["bucket"] },
},
});
await user.click(within(panel).getByRole("tab", { name: "Attack path" }));
// Then
// Then: the graph loads on demand and leaves finding drill-down to the page
expect(
await within(panel).findByText("Graph of bucket"),
await within(panel).findByText("Graph of bucket (read-only)"),
).toBeInTheDocument();
expect(getIssue).toHaveBeenCalledWith("issue-1");
-6
View File
@@ -169,12 +169,9 @@ const columns: ColumnDef<IssueRow>[] = [
export const IssuesTable = ({
issues,
metadata,
providerUids,
}: {
issues: IssueRow[];
metadata?: MetaDataProps;
/** Provider uid by provider id, shown in the panel's Provider field. */
providerUids?: Record<string, string>;
}) => {
const [openId, setOpenId] = useState<string | null>(null);
const openIssue = issues.find((issue) => issue.id === openId);
@@ -193,9 +190,6 @@ export const IssuesTable = ({
<IssuePanel
key={openIssue.id}
issue={openIssue}
providerUid={
providerUids?.[openIssue.relationships?.provider?.data?.id ?? ""]
}
open
onOpenChange={(open) => {
if (!open) setOpenId(null);
+115 -95
View File
@@ -4,10 +4,18 @@ import { page } from "vitest/browser";
import { worker } from "@/__tests__/msw/worker";
import { render } from "@/__tests__/render-browser";
import {
getFindingGroups,
getFindingGroupResources,
} from "@/actions/finding-groups";
import { getScan } from "@/actions/scans";
import { ExploitabilityChart } from "@/app/(prowler)/_overview/exploitability/_components/exploitability-chart";
import { TopIssues } from "@/app/(prowler)/_overview/exploitability/_components/top-issues";
import { IssuesList } from "@/app/(prowler)/issues/_components/issues-list";
import { IssueDetail } from "@/components/issues/issue-detail";
import { GlobalSidePanel } from "@/components/side-panel/global-side-panel";
import { applyDefaultMutedFilter, extractFiltersAndQuery } from "@/lib";
import { resolveFindingScanDateFilters } from "@/lib/findings-scan-filters";
import { IssueResource, IssuesOverviewAttributes } from "@/types/issues";
const resource = `arn:aws:s3:::production-customer-data-${"long-resource-name-".repeat(8)}`;
@@ -145,57 +153,84 @@ describe("Issues responsive layout", () => {
await page.viewport(1280, 800);
});
it("opens a folded finding inside the panel and comes back to the issue", async () => {
// Given: the list next to the real side panel host the app shell mounts.
it("opens the selected finding's group and muted resource through the destination actions", async () => {
// Given: the destination returns the finding only for its supported filters.
setup();
worker.use(
http.get("*/findings/f1", () =>
http.get("*/scans/scan-1", () =>
HttpResponse.json({
data: {
type: "findings",
id: "f1",
id: "scan-1",
type: "scans",
attributes: {
uid: "finding-uid-1",
check_id: "s3_public",
status: "FAIL",
severity: "critical",
check_metadata: { checktitle: "S3 bucket is public" },
inserted_at: "2026-10-20T10:00:00Z",
completed_at: "2026-10-20T11:00:00Z",
},
relationships: { resources: { data: [] } },
},
}),
),
http.get("*/finding-groups", ({ request }) => {
const params = new URL(request.url).searchParams;
const matches =
params.get("filter[check_id__in]") === "s3_public" &&
params.get("filter[resource_uid]") === resource &&
params.get("filter[scan__in]") === "scan-1" &&
params.get("filter[inserted_at]") === "2026-10-20" &&
params.get("filter[muted]") === "include";
return HttpResponse.json({
data: matches ? [{ id: "s3_public", type: "finding-groups" }] : [],
});
}),
http.get("*/finding-groups/s3_public/resources", ({ request }) => {
const params = new URL(request.url).searchParams;
const matches =
params.get("filter[resource_uid]") === resource &&
params.get("filter[muted]") === "include";
return HttpResponse.json({
data: matches
? [{ id: "r1", attributes: { finding_id: "f1", muted: true } }]
: [],
});
}),
);
const content = await IssuesList({ searchParams: {} });
const screen = await render(
<>
{content}
<GlobalSidePanel />
</>,
<IssueDetail
id={issue.id}
issue={issue.attributes}
scanId="scan-1"
providerId="provider-1"
/>,
);
await screen
.getByRole("row", { name: /Public bucket exposes customer data/ })
.getByText("Production")
.click();
await screen.getByRole("tab", { name: "Findings" }).click();
const link = screen.getByRole("link", { name: "S3 bucket is public" });
await expect.element(link).toBeVisible();
// When
await screen.getByRole("button", { name: "S3 bucket is public" }).click();
// Then: the finding takes the panel without closing the issue
const back = screen.getByRole("button", { name: "Issue details" });
await expect.element(back).toBeVisible();
await expect
.element(screen.getByRole("tab", { name: "Exploitability" }))
.not.toBeInTheDocument();
// When
await back.click();
// When: resolve the same filters used by the Findings page and drill-down.
const destination = new URL(
link.element().getAttribute("href")!,
window.location.origin,
);
const { filters } = extractFiltersAndQuery(
Object.fromEntries(destination.searchParams),
);
const resolvedFilters = applyDefaultMutedFilter(
await resolveFindingScanDateFilters({
filters,
scans: [],
loadScan: async (id) => (await getScan(id))?.data,
}),
);
const groups = await getFindingGroups({ filters: resolvedFilters });
// Then
await expect
.element(screen.getByRole("tab", { name: "Findings" }))
.toHaveAttribute("aria-selected", "true");
expect(groups.data).toEqual([{ id: "s3_public", type: "finding-groups" }]);
expect(destination.searchParams.get("expandedCheckId")).toBe("s3_public");
const resources = await getFindingGroupResources({
checkId: "s3_public",
filters: resolvedFilters,
});
expect(resources.data).toEqual([
{ id: "r1", attributes: { finding_id: "f1", muted: true } },
]);
});
it("opens a clicked row in the app's Details side panel", async () => {
@@ -216,6 +251,9 @@ describe("Issues responsive layout", () => {
.click();
// Then
await expect
.element(screen.getByRole("link", { name: "Open the full path" }))
.toHaveAttribute("href", "/issues/issue-1");
await expect
.element(screen.getByText("Block Public Access is disabled."))
.toBeVisible();
@@ -251,15 +289,6 @@ describe("Issues responsive layout", () => {
http.get("*/issues", () =>
HttpResponse.json({ data: [issue, secondIssue] }),
),
// The panel loads the evidence of whichever issue it opens.
http.get("*/issues/issue-2", () =>
HttpResponse.json({
data: {
...secondIssue,
attributes: { ...secondIssue.attributes, resource_uids: [] },
},
}),
),
);
const content = await IssuesList({ searchParams: {} });
const screen = await render(
@@ -285,13 +314,8 @@ describe("Issues responsive layout", () => {
// Then: the panel remounted on the new issue, back on its first tab
await expect
.element(
screen.getByRole("heading", {
level: 3,
name: "Admin role assumable from any account",
}),
)
.toBeVisible();
.element(screen.getByRole("link", { name: "Open the full path" }))
.toHaveAttribute("href", "/issues/issue-2");
await expect
.element(screen.getByRole("tab", { name: "Exploitability" }))
.toHaveAttribute("aria-selected", "true");
@@ -304,7 +328,7 @@ describe("Issues responsive layout", () => {
{ width: 375, theme: "dark" },
{ width: 1440, theme: "dark" },
])(
"keeps the list and the issue panel usable at $width px in $theme mode",
"keeps the list and the issue detail usable at $width px in $theme mode",
async ({ width, theme }) => {
await page.viewport(width, 1000);
localStorage.setItem("theme", theme);
@@ -313,13 +337,16 @@ describe("Issues responsive layout", () => {
searchParams: { "filter[pattern]": "aws_public_bucket" },
});
const screen = await render(
<>
<main className="flex min-w-0 flex-col gap-6 p-4">
<h1 className="text-2xl font-semibold">Issues</h1>
{content}
</main>
<GlobalSidePanel />
</>,
<main className="flex min-w-0 flex-col gap-6 p-4">
<h1 className="text-2xl font-semibold">Issues</h1>
{content}
<IssueDetail
id={issue.id}
issue={issue.attributes}
scanId="scan-1"
providerId="provider-1"
/>
</main>,
);
await expect
.element(screen.getByRole("button", { name: "Remove Pattern filter" }))
@@ -329,46 +356,25 @@ describe("Issues responsive layout", () => {
screen.getByText("The internet → Data exposure · aws_public_bucket"),
)
.toBeVisible();
// When: the row opens in the panel
await screen
.getByRole("row", { name: /Public bucket exposes customer data/ })
.getByRole("button", { name: "Public bucket exposes customer data" })
.click();
// Then: every tab fits the viewport, long identifiers wrap
await expect
.element(screen.getByRole("region", { name: "Affected resources" }))
.toBeVisible();
await expect
.element(screen.getByText("Partly", { exact: true }))
.toBeVisible();
const fits = (element: Element) =>
element.getBoundingClientRect().right <= width;
for (const snippet of screen
.getByText(resource, { exact: true })
.elements()) {
expect(snippet.scrollWidth).toBeLessThanOrEqual(
snippet.clientWidth + 1,
);
}
await screen.getByRole("tab", { name: "Findings" }).click();
await expect
.element(screen.getByRole("button", { name: "S3 bucket is public" }))
.toBeVisible();
await screen.getByRole("tab", { name: "Resources" }).click();
const resources = screen.getByRole("region", {
name: "Affected resources",
});
await expect.element(resources).toBeVisible();
expect(fits(resources.element())).toBe(true);
expect(document.documentElement.scrollWidth).toBeLessThanOrEqual(width);
await page.screenshot();
await screen.getByRole("tab", { name: "Triage" }).click();
await expect.element(screen.getByText("Public by design.")).toBeVisible();
await expect
.element(
screen.getByRole("img", { name: "Critical finding risk icon" }),
)
.toBeVisible();
expect(document.documentElement.scrollWidth).toBeLessThanOrEqual(width);
const detailBounds = screen
.getByRole("region", { name: "Affected resources" })
.element()
.getBoundingClientRect();
expect(detailBounds.right).toBeLessThanOrEqual(width);
if (width < 640) {
await screen.getByRole("tab", { name: "Attack path" }).click();
await expect
.poll(() => {
const node = screen
@@ -380,6 +386,20 @@ describe("Issues responsive layout", () => {
})
.toBe(true);
}
// Long resource identifiers and evidence must wrap within the detail.
for (const snippet of screen
.getByText(resource, { exact: true })
.elements()) {
expect(snippet.scrollWidth).toBeLessThanOrEqual(
snippet.clientWidth + 1,
);
}
await page.viewport(
width,
Math.max(1000, document.documentElement.scrollHeight),
);
await screen.getByRole("main").screenshot();
},
);
+1 -1
View File
@@ -4,9 +4,9 @@ import type { IssueFinding } from "@/types/issues";
export interface IssueFindingsProps {
/** One entry per finding, with a resource to open in the group drill-down. */
findings: IssueFinding[];
scanId?: string;
/** All the findings the issue folds; the list shows a bounded preview. */
total: number;
onOpenFinding: (findingId: string) => void;
}
export interface IssueTriageProps {
@@ -16,6 +16,7 @@ const {
pathnameValue,
permissionsValue,
pushMock,
realtimeFlagValue,
} = vi.hoisted(() => ({
issuesFlagValue: { current: false },
openCloudUpgradeMock: vi.fn(),
@@ -23,6 +24,7 @@ const {
pathnameValue: { current: "/findings" },
permissionsValue: { current: {} as Record<string, boolean> },
pushMock: vi.fn(),
realtimeFlagValue: { current: false },
}));
vi.mock("next/navigation", () => ({
@@ -40,7 +42,10 @@ vi.mock("@/hooks/use-runtime-config", () => ({
vi.mock("@/lib/feature-flags", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/feature-flags")>()),
useFeatureFlag: () => issuesFlagValue.current,
useFeatureFlag: (flag: string) =>
flag === "realtime-feature"
? realtimeFlagValue.current
: issuesFlagValue.current,
}));
vi.mock("@/store", () => ({
@@ -65,6 +70,7 @@ describe("AppSidebarContent", () => {
pathnameValue.current = "/findings";
permissionsValue.current = { manage_providers: true };
issuesFlagValue.current = false;
realtimeFlagValue.current = false;
pushMock.mockClear();
openCloudUpgradeMock.mockClear();
openLaunchScanModalMock.mockClear();
@@ -135,6 +141,37 @@ describe("AppSidebarContent", () => {
).toHaveAttribute("href", "/issues");
});
it("shows Realtime navigation in Cloud once its feature flag is on", () => {
// Given
vi.stubEnv("UI_CLOUD_ENABLED", "true");
realtimeFlagValue.current = true;
// When
render(<AppSidebarContent />);
// Then
expect(
screen
.getAllByRole("link", { name: /Realtime/ })
.map((link) => link.getAttribute("href")),
).toEqual(["/realtime", "/realtime/settings"]);
expect(
screen.getByRole("link", { name: "Cost anomalies" }),
).toHaveAttribute("href", "/realtime/settings/cost-anomalies");
});
it("hides Realtime navigation while its feature flag is off", () => {
// Given / When
vi.stubEnv("UI_CLOUD_ENABLED", "true");
issuesFlagValue.current = true;
render(<AppSidebarContent />);
// Then
expect(
screen.queryByRole("link", { name: /Realtime/ }),
).not.toBeInTheDocument();
});
it("hides Issues navigation in Cloud while the feature flag is off", () => {
// Given / When
vi.stubEnv("UI_CLOUD_ENABLED", "true");
@@ -32,12 +32,14 @@ export function AppSidebarContent({ onSelect }: AppSidebarContentProps) {
const mode = useHydratedAppSidebarMode();
const isCloudEnvironment = isCloud();
const issuesEnabled = useFeatureFlag(FEATURE_FLAG.ISSUES);
const realtimeEnabled = useFeatureFlag(FEATURE_FLAG.REALTIME);
const sections = getNavigationConfig({
pathname,
apiDocsUrl,
cloudBillingEnabled,
registryEligible,
issuesEnabled,
realtimeEnabled,
permissions,
});
const showChat = isCloudEnvironment && mode === APP_SIDEBAR_MODE.CHAT;
@@ -139,6 +139,85 @@ describe("getNavigationConfig", () => {
expect(labels).not.toContain("Exploitability issues");
});
it("shows Realtime only in Cloud and only once its feature flag is on", () => {
// Given
const labelsFor = (realtimeEnabled?: boolean) =>
getNavigationConfig({ pathname: "/realtime", realtimeEnabled })
.flatMap((section) => section.items)
.map((item) => item.label);
// When / Then
vi.stubEnv("UI_CLOUD_ENABLED", "true");
expect(labelsFor()).not.toContain("Realtime");
expect(labelsFor(true)).toContain("Realtime");
vi.stubEnv("UI_CLOUD_ENABLED", "false");
expect(labelsFor(true)).not.toContain("Realtime");
});
it("adds Realtime and Cost anomalies to Configuration under the same gate", () => {
// Given
const childrenFor = (pathname: string, realtimeEnabled?: boolean) => {
const configuration = getNavigationConfig({ pathname, realtimeEnabled })
.flatMap((section) => section.items)
.find((item) => item.label === "Configuration");
if (configuration?.kind !== NAVIGATION_ITEM_KIND.COLLAPSIBLE) {
throw new Error("Configuration must be a collapsible navigation item");
}
return configuration.children;
};
// When / Then
vi.stubEnv("UI_CLOUD_ENABLED", "true");
expect(childrenFor("/providers").map((item) => item.label)).not.toEqual(
expect.arrayContaining(["Realtime"]),
);
expect(childrenFor("/realtime/settings", true)).toEqual(
expect.arrayContaining([
expect.objectContaining({
href: "/realtime/settings",
label: "Realtime",
active: true,
}),
expect.objectContaining({
href: "/realtime/settings/cost-anomalies",
label: "Cost anomalies",
active: false,
}),
]),
);
expect(
childrenFor("/realtime/settings/cost-anomalies", true).filter(
(item) => "active" in item && item.active,
),
).toEqual([
expect.objectContaining({
href: "/realtime/settings/cost-anomalies",
}),
]);
vi.stubEnv("UI_CLOUD_ENABLED", "false");
expect(
childrenFor("/realtime/settings", true).map((i) => i.label),
).not.toEqual(expect.arrayContaining(["Cost anomalies"]));
});
it("keeps the top-level Realtime entry off while on its settings pages", () => {
// Given
vi.stubEnv("UI_CLOUD_ENABLED", "true");
const realtime = (pathname: string) =>
getNavigationConfig({ pathname, realtimeEnabled: true })
.flatMap((section) => section.items)
.find((item) => item.label === "Realtime");
// When / Then
expect(realtime("/realtime")).toMatchObject({ active: true });
expect(realtime("/realtime/cost-anomalies/x")).toMatchObject({
active: true,
});
expect(realtime("/realtime/settings")).toMatchObject({ active: false });
});
it("uses Cloud destinations and keeps Configuration children unbadged", () => {
// Given
vi.stubEnv("UI_CLOUD_ENABLED", "true");
@@ -158,8 +237,9 @@ describe("getNavigationConfig", () => {
// Then
expect(sections[0]?.items.map((item) => item.label)).toEqual(["Overview"]);
expect(sections[1]?.items[0]).toMatchObject({
label: "Exploitability issues",
expect(
items.find((item) => item.label === "Exploitability issues"),
).toMatchObject({
href: "/issues",
});
expect(configuration.children).not.toEqual(
@@ -7,6 +7,7 @@ import {
LayoutGrid,
MessageCircleQuestion,
Package,
Radar,
Settings,
ShieldCheck,
SquareChartGantt,
@@ -37,6 +38,8 @@ interface NavigationConfigOptions {
registryEligible?: boolean;
// PostHog FEATURE_FLAG.ISSUES, resolved by the caller; Cloud only.
issuesEnabled?: boolean;
// PostHog FEATURE_FLAG.REALTIME, resolved by the caller; Cloud only.
realtimeEnabled?: boolean;
permissions?: RolePermissionAttributes;
}
@@ -114,6 +117,7 @@ export function getNavigationConfig({
cloudBillingEnabled = false,
registryEligible = false,
issuesEnabled = false,
realtimeEnabled = false,
permissions,
}: NavigationConfigOptions): NavigationSection[] {
const isCloudEnvironment = isCloud();
@@ -149,19 +153,6 @@ export function getNavigationConfig({
{
label: "SECURITY",
items: [
// First: the exploitable issues are what to fix before anything else.
...(isCloudEnvironment && issuesEnabled
? [
{
kind: NAVIGATION_ITEM_KIND.LINK,
href: "/issues",
label: "Exploitability issues",
icon: Crosshair,
active: isRouteActive(pathname, "/issues"),
highlight: true,
} as const,
]
: []),
{
kind: NAVIGATION_ITEM_KIND.LINK,
href: "/compliance",
@@ -176,6 +167,33 @@ export function getNavigationConfig({
icon: Tag,
active: isRouteActive(pathname, "/findings"),
},
...(isCloudEnvironment && issuesEnabled
? [
{
kind: NAVIGATION_ITEM_KIND.LINK,
href: "/issues",
label: "Exploitability issues",
icon: Crosshair,
active: isRouteActive(pathname, "/issues"),
highlight: true,
} as const,
]
: []),
...(isCloudEnvironment && realtimeEnabled
? [
{
kind: NAVIGATION_ITEM_KIND.LINK,
href: "/realtime",
label: "Realtime",
icon: Radar,
// Its settings pages live under Configuration.
active:
isRouteActive(pathname, "/realtime") &&
!isRouteActive(pathname, "/realtime/settings"),
highlight: true,
} as const,
]
: []),
{
kind: NAVIGATION_ITEM_KIND.LINK,
href: "/attack-paths",
@@ -270,6 +288,30 @@ export function getNavigationConfig({
label: "Lighthouse AI",
active: isRouteActive(pathname, "/lighthouse/settings"),
},
...(isCloudEnvironment && realtimeEnabled
? [
{
kind: NAVIGATION_ITEM_KIND.LINK,
href: "/realtime/settings",
label: "Realtime",
active:
isRouteActive(pathname, "/realtime/settings") &&
!isRouteActive(
pathname,
"/realtime/settings/cost-anomalies",
),
} as const,
{
kind: NAVIGATION_ITEM_KIND.LINK,
href: "/realtime/settings/cost-anomalies",
label: "Cost anomalies",
active: isRouteActive(
pathname,
"/realtime/settings/cost-anomalies",
),
} as const,
]
: []),
],
},
{
+313
View File
@@ -0,0 +1,313 @@
"use client";
import { useState } from "react";
import {
getRealtimeActions,
getRealtimeDemo,
revertRealtimeAction,
} from "@/actions/realtime";
import { DemoDisabled, useIsDemo } from "@/components/realtime/demo-context";
import { ActionStatusBadge } from "@/components/realtime/realtime-badges";
import { RealtimePager } from "@/components/realtime/realtime-pager";
import { RealtimeSection } from "@/components/realtime/realtime-stat-card";
import {
PanelEmpty,
PanelFailure,
PanelLoading,
} from "@/components/realtime/realtime-states";
import { useRealtimeResource } from "@/components/realtime/use-realtime-resource";
import { Badge } from "@/components/shadcn/badge/badge";
import { Button } from "@/components/shadcn/button/button";
import { Modal } from "@/components/shadcn/modal";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/shadcn/table/table";
import { useToast } from "@/components/shadcn/toast";
import {
formatDateTime,
humanize,
isLighthouseAction,
safeExternalUrl,
} from "@/lib/realtime/display";
import {
REALTIME_ACTION_KIND,
type RealtimeAction,
type RealtimeActionKind,
type RealtimePage,
} from "@/types/realtime";
const REFRESH_MS = 20_000;
const KIND_LABELS: Record<RealtimeActionKind, string> = {
[REALTIME_ACTION_KIND.SELF_FIX]: "Fix in the account",
[REALTIME_ACTION_KIND.CODE]: "Change in code",
[REALTIME_ACTION_KIND.PROPOSAL]: "Proposal",
};
export const actionKindLabel = (kind: string): string =>
KIND_LABELS[kind as RealtimeActionKind] ?? humanize(kind);
interface RevertDialogProps {
action: RealtimeAction | null;
reverting: boolean;
onConfirm: () => void;
onCancel: () => void;
}
export function RevertDialog({
action,
reverting,
onConfirm,
onCancel,
}: RevertDialogProps) {
return (
<Modal
open={action !== null}
onOpenChange={(open) => {
if (!open && !reverting) onCancel();
}}
title="Revert this change?"
description="Prowler Realtime applies the exact inverse of the call it recorded for this fix. The compliance check will fail again for this resource."
size="lg"
>
{action && (
<div className="flex flex-col gap-4">
<p className="text-text-neutral-secondary text-sm">
{action.summary || action.check_id}
{action.resource_uid ? ` (${action.resource_uid})` : ""}
</p>
<div className="flex justify-end gap-2">
<Button variant="outline" disabled={reverting} onClick={onCancel}>
Keep the fix
</Button>
<Button
variant="destructive"
disabled={reverting}
onClick={onConfirm}
>
{reverting ? "Reverting…" : "Revert the change"}
</Button>
</div>
</div>
)}
</Modal>
);
}
interface ActionsTableProps {
actions: RealtimeAction[];
canManage: boolean;
onRevert: (action: RealtimeAction) => void;
/** Sample rows: Revert is disabled. Defaults to the surrounding view. */
demo?: boolean;
}
export function ActionsTable({
actions,
canManage,
onRevert,
demo: demoProp,
}: ActionsTableProps) {
const inDemo = useIsDemo();
const demo = demoProp ?? inDemo;
if (actions.length === 0) {
return (
<PanelEmpty
title="Prowler Realtime has not acted yet"
description="Fixes and proposals appear here, each with the evidence and, when possible, a way to undo it."
/>
);
}
return (
<Table aria-label="Prowler Realtime actions">
<TableHeader>
<TableRow>
<TableHead>When</TableHead>
<TableHead>What</TableHead>
<TableHead>Status</TableHead>
<TableHead>Resource</TableHead>
<TableHead>Details</TableHead>
<TableHead>
<span className="sr-only">Actions</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{actions.map((action) => {
const prUrl = action.pr_url ? safeExternalUrl(action.pr_url) : null;
return (
<TableRow key={action.id}>
<TableCell className="text-text-neutral-secondary text-xs whitespace-nowrap tabular-nums">
{formatDateTime(action.inserted_at)}
</TableCell>
<TableCell>
<div className="flex flex-col">
<span className="text-text-neutral-primary">
{actionKindLabel(action.kind)}
</span>
<code className="text-text-neutral-tertiary text-xs break-all">
{action.check_id}
</code>
</div>
</TableCell>
<TableCell>
<div className="flex flex-col items-start gap-1">
<ActionStatusBadge status={action.status} />
{isLighthouseAction(action) && (
<Badge variant="lighthouse" size="sm">
Lighthouse AI
</Badge>
)}
</div>
</TableCell>
<TableCell className="max-w-56">
<span
className="text-text-neutral-secondary block truncate"
title={action.resource_uid}
>
{action.resource_uid || "-"}
</span>
{action.region && (
<span className="text-text-neutral-tertiary text-xs">
{action.region}
</span>
)}
</TableCell>
<TableCell className="max-w-72">
<p className="text-text-neutral-secondary text-xs">
{action.summary}
</p>
{prUrl ? (
<a
href={prUrl}
target="_blank"
rel="noopener noreferrer"
className="text-button-tertiary text-xs underline underline-offset-4"
>
View pull request
</a>
) : action.branch ? (
<span className="text-text-neutral-tertiary text-xs">
Branch {action.branch}
</span>
) : null}
</TableCell>
<TableCell>
{action.revertible &&
(demo ? (
<DemoDisabled>
<Button variant="outline" size="sm" disabled>
Revert
</Button>
</DemoDisabled>
) : (
<Button
variant="outline"
size="sm"
disabled={!canManage}
title={
canManage
? undefined
: "Reverting needs the Manage Realtime permission"
}
onClick={() => onRevert(action)}
>
Revert
</Button>
))}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
);
}
export function ActionsList({
providerId,
canManage,
demo = false,
}: {
providerId: string;
canManage: boolean;
demo?: boolean;
}) {
const { toast } = useToast();
const [page, setPage] = useState(1);
const [pending, setPending] = useState<RealtimeAction | null>(null);
const [reverting, setReverting] = useState(false);
const actions = useRealtimeResource<RealtimePage<RealtimeAction>>(
() =>
demo
? getRealtimeDemo("actions")
: getRealtimeActions({ providerId, page }),
demo ? "demo:actions" : `actions:${providerId}:${page}`,
{ intervalMs: demo ? undefined : REFRESH_MS },
);
const confirmRevert = async () => {
if (!pending || demo) return;
setReverting(true);
const result = await revertRealtimeAction(pending.id).finally(() =>
setReverting(false),
);
if (result.ok) {
setPending(null);
toast({ title: "Change reverted", description: result.data.summary });
actions.reload();
} else {
setPending(null);
toast({
variant: "destructive",
title: "Could not revert the change",
description: result.error,
});
}
};
return (
<RealtimeSection
title="Actions"
description="Everything Prowler Realtime changed or proposed. Automatic fixes keep the exact inverse call, so they can be undone."
>
{actions.loading ? (
<PanelLoading label="Loading actions" />
) : actions.failure ? (
<PanelFailure
failure={actions.failure}
onRetry={actions.reload}
subject="the actions"
/>
) : actions.data ? (
<div className="flex flex-col gap-3">
<ActionsTable
actions={actions.data.items}
canManage={canManage}
demo={demo}
onRevert={setPending}
/>
<RealtimePager
page={actions.data.page}
pages={actions.data.pages}
count={actions.data.count}
onPageChange={setPage}
/>
</div>
) : null}
<RevertDialog
action={pending}
reverting={reverting}
onConfirm={() => void confirmRevert()}
onCancel={() => setPending(null)}
/>
</RealtimeSection>
);
}
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { anomalyThreshold, baselineOf } from "./anomaly-chart";
describe("anomaly chart helpers", () => {
it("needs both the ratio and the dollar increase to be crossed", () => {
const thresholds = { min_ratio: 2, min_delta: 5 };
expect(anomalyThreshold(10, thresholds)).toBe(20);
expect(anomalyThreshold(1, thresholds)).toBe(6);
});
it("takes the baseline as the mean of the days before the last", () => {
expect(
baselineOf([
{ day: "a", amount: 10 },
{ day: "b", amount: 20 },
{ day: "c", amount: 100 },
]),
).toBe(15);
expect(baselineOf([{ day: "a", amount: 10 }])).toBe(0);
expect(baselineOf([])).toBe(0);
});
});
+131
View File
@@ -0,0 +1,131 @@
"use client";
import {
CartesianGrid,
Line,
LineChart,
ReferenceLine,
XAxis,
YAxis,
} from "recharts";
import { PanelEmpty } from "@/components/realtime/realtime-states";
import {
type ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@/components/shadcn/chart/Chart";
import { formatMoney } from "@/lib/realtime/display";
import type { RealtimeCostDay, RealtimeCostThresholds } from "@/types/realtime";
// Recharts props do not take className, so the design tokens go in by name.
const CHART_COLORS = {
spend: "var(--color-bg-data-info)",
baseline: "var(--color-bg-pass)",
threshold: "var(--color-bg-fail)",
} as const;
const CHART_CONFIG: ChartConfig = {
amount: { label: "Daily spend", color: CHART_COLORS.spend },
};
/** Spend at which a day counts as an anomaly: both thresholds must be crossed. */
export const anomalyThreshold = (
baseline: number,
thresholds: RealtimeCostThresholds,
): number =>
Math.max(baseline * thresholds.min_ratio, baseline + thresholds.min_delta);
/** The mean of the days before the last one, when the API gave no baseline. */
export const baselineOf = (series: RealtimeCostDay[]): number => {
const earlier = series.slice(0, -1);
if (earlier.length === 0) return 0;
return earlier.reduce((sum, day) => sum + day.amount, 0) / earlier.length;
};
/** Daily spend of one service with its baseline and the line that makes it an anomaly. */
export function AnomalyChart({
series,
baseline,
thresholds,
label,
}: {
series: RealtimeCostDay[];
baseline: number;
thresholds: RealtimeCostThresholds;
label: string;
}) {
if (series.length === 0) {
return (
<PanelEmpty
title="No daily cost yet"
description="The chart fills in once Cost Explorer has returned figures for this service."
/>
);
}
const threshold = anomalyThreshold(baseline, thresholds);
const latest = series[series.length - 1];
return (
<div className="flex flex-col gap-3">
<p className="sr-only">
{label}. Latest day {latest.day}: {formatMoney(latest.amount)}. Baseline{" "}
{formatMoney(baseline)}; anomaly threshold {formatMoney(threshold)}.
</p>
<ChartContainer
config={CHART_CONFIG}
className="aspect-auto h-64 w-full"
aria-hidden="true"
>
<LineChart data={series} margin={{ left: 4, right: 12, top: 8 }}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="day"
tickLine={false}
axisLine={false}
minTickGap={24}
/>
<YAxis
tickLine={false}
axisLine={false}
width={56}
tickFormatter={(value: number) => formatMoney(value)}
/>
<ChartTooltip
content={
<ChartTooltipContent
formatter={(value) => formatMoney(Number(value))}
/>
}
/>
<Line
dataKey="amount"
type="monotone"
stroke={CHART_COLORS.spend}
strokeWidth={2}
dot={false}
/>
<ReferenceLine
y={baseline}
stroke={CHART_COLORS.baseline}
strokeDasharray="4 4"
ifOverflow="extendDomain"
/>
<ReferenceLine
y={threshold}
stroke={CHART_COLORS.threshold}
strokeDasharray="2 4"
// Keep the threshold on the chart when every day is below it.
ifOverflow="extendDomain"
/>
</LineChart>
</ChartContainer>
<ul className="text-text-neutral-secondary flex flex-wrap gap-x-4 gap-y-1 text-xs">
<li>Solid: daily spend</li>
<li>Green dashed: baseline ({formatMoney(baseline)})</li>
<li>Red dotted: anomaly threshold ({formatMoney(threshold)})</li>
</ul>
</div>
);
}
@@ -0,0 +1,165 @@
import { render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { getAnomaly } = vi.hoisted(() => ({ getAnomaly: vi.fn() }));
vi.mock("@/actions/realtime", () => ({ getRealtimeCostAnomaly: getAnomaly }));
// Recharts needs layout that jsdom does not have; the chart is covered by its helpers.
vi.mock("@/components/realtime/anomaly-chart", async (importOriginal) => ({
...(await importOriginal<
typeof import("@/components/realtime/anomaly-chart")
>()),
AnomalyChart: ({ label }: { label: string }) => <div>chart: {label}</div>,
}));
import type { RealtimeAnomalyPayload } from "@/types/realtime";
import { AnomalyBody, AnomalyDetail } from "./anomaly-detail";
const PROVIDER = "0b8c2b46-7f3a-4d1e-9a52-3f5f1d6a7c10";
const ID = "Amazon Elastic Compute Cloud - Compute|eu-west-1";
const detail: RealtimeAnomalyPayload = {
anomaly: {
id: ID,
service: "Amazon Elastic Compute Cloud - Compute",
region: "eu-west-1",
short_service: "ec2",
today: 120,
base: 20,
delta: 100,
ratio: 6,
},
short_service: "ec2",
series: [
{ day: "2026-10-21", amount: 20 },
{ day: "2026-10-22", amount: 22 },
{ day: "2026-10-23", amount: 120 },
],
thresholds: { min_ratio: 2, min_delta: 5 },
events: [
{
event_id: "e1",
event_time: "2026-10-23T09:00:00Z",
region: "eu-west-1",
event_source: "ec2.amazonaws.com",
event_name: "RunInstances",
user_type: "IAMUser",
user_arn: "arn:aws:iam::123456789012:user/mallory",
source_ip: "203.0.113.9",
error_code: "",
},
],
findings: [
{
finding_id: "f1",
check_id: "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22",
severity: "high",
title: "SSH open to the internet",
service: "ec2",
resource_uid: "sg-1",
region: "eu-west-1",
status_extended: "",
score: 90,
reasons: [],
route: "pr",
},
],
};
describe("AnomalyBody", () => {
it("shows today, baseline, increase and ratio against the thresholds", () => {
render(
<AnomalyBody
detail={detail}
service="Amazon Elastic Compute Cloud - Compute"
region="eu-west-1"
/>,
);
expect(screen.getByText("$120.00")).toBeVisible();
expect(screen.getByText("$20.00")).toBeVisible();
expect(screen.getByText("+$100.00")).toBeVisible();
expect(screen.getByText("6x")).toBeVisible();
expect(screen.getByText(/Needs at least 2x/)).toBeVisible();
expect(screen.getByText(/Needs at least \+\$5\.00/)).toBeVisible();
expect(screen.getByText(/chart: Daily cost of/)).toBeVisible();
});
it("lists the recent changes and the related open findings", () => {
render(
<AnomalyBody
detail={detail}
service="Amazon Elastic Compute Cloud - Compute"
region="eu-west-1"
/>,
);
expect(screen.getByText("RunInstances")).toBeVisible();
expect(screen.getByText("user/mallory")).toBeVisible();
expect(screen.getByText("SSH open to the internet")).toBeVisible();
});
it("says when the spend is back to normal", () => {
render(
<AnomalyBody
detail={{ ...detail, anomaly: null }}
service="Amazon Elastic Compute Cloud - Compute"
region="eu-west-1"
/>,
);
expect(screen.getByText("Back to normal")).toBeVisible();
});
});
describe("AnomalyDetail", () => {
beforeEach(() => vi.clearAllMocks());
it("fetches by service and region from the id and links back", async () => {
getAnomaly.mockResolvedValue({ ok: true, data: detail });
render(<AnomalyDetail anomalyId={ID} providerId={PROVIDER} />);
expect(await screen.findByText("RunInstances")).toBeVisible();
expect(getAnomaly).toHaveBeenCalledWith({
providerId: PROVIDER,
service: "Amazon Elastic Compute Cloud - Compute",
region: "eu-west-1",
});
expect(
screen.getByRole("link", { name: /back to realtime/i }),
).toHaveAttribute("href", `/realtime?provider=${PROVIDER}&tab=findings`);
});
it("says the anomaly is no longer active on a 404", async () => {
getAnomaly.mockResolvedValue({
ok: false,
status: 404,
error: "Not found",
});
render(<AnomalyDetail anomalyId={ID} providerId={PROVIDER} />);
expect(
await screen.findByText("This anomaly is no longer active"),
).toBeVisible();
expect(
screen.getAllByRole("link", { name: /back to realtime/i }),
).not.toHaveLength(0);
});
it("shows the data store notice on a 503", async () => {
getAnomaly.mockResolvedValue({ ok: false, status: 503, error: "x" });
render(<AnomalyDetail anomalyId={ID} providerId={PROVIDER} />);
expect(
await screen.findByText("Real-time data store not configured"),
).toBeVisible();
});
it("does not call the API for an id or provider that cannot be valid", () => {
render(<AnomalyDetail anomalyId="nopipe" providerId={PROVIDER} />);
expect(screen.getByText("This anomaly is no longer active")).toBeVisible();
expect(getAnomaly).not.toHaveBeenCalled();
});
});
+231
View File
@@ -0,0 +1,231 @@
"use client";
import { ArrowLeft } from "lucide-react";
import Link from "next/link";
import { z } from "zod";
import { getRealtimeCostAnomaly, getRealtimeDemo } from "@/actions/realtime";
import {
AnomalyChart,
anomalyThreshold,
baselineOf,
} from "@/components/realtime/anomaly-chart";
import { DemoBanner, DemoProvider } from "@/components/realtime/demo-context";
import { EventsTable } from "@/components/realtime/events-table";
import { RankedFindingsTable } from "@/components/realtime/findings-tab";
import {
RealtimeSection,
RealtimeStatCard,
StatFigure,
} from "@/components/realtime/realtime-stat-card";
import {
PanelEmpty,
PanelFailure,
PanelLoading,
} from "@/components/realtime/realtime-states";
import { useRealtimeResource } from "@/components/realtime/use-realtime-resource";
import { Badge } from "@/components/shadcn/badge/badge";
import { DEMO_ANOMALY_ID } from "@/lib/realtime/demo";
import { formatMoney, parseAnomalyId } from "@/lib/realtime/display";
import type {
RealtimeAnomalyEvent,
RealtimeAnomalyPayload,
RealtimeEvent,
} from "@/types/realtime";
const REFRESH_MS = 60_000;
const isUuid = (value: string) => z.uuid().safeParse(value).success;
const toEvent = (event: RealtimeAnomalyEvent): RealtimeEvent => ({
id: event.event_id,
event_time: event.event_time,
region: event.region,
event_source: event.event_source,
event_name: event.event_name,
user_type: event.user_type,
user_arn: event.user_arn,
source_ip: event.source_ip,
error_code: event.error_code,
read_only: false,
provider_id: "",
detection_status: null,
rule_id: null,
});
function BackLink({ providerId }: { providerId: string }) {
return (
<Link
href={
isUuid(providerId)
? `/realtime?provider=${encodeURIComponent(providerId)}&tab=findings`
: "/realtime"
}
className="text-button-tertiary inline-flex items-center gap-1 text-sm underline-offset-4 hover:underline"
>
<ArrowLeft aria-hidden="true" className="size-4" />
Back to Realtime
</Link>
);
}
export function AnomalyGone({ providerId }: { providerId: string }) {
return (
<PanelEmpty
title="This anomaly is no longer active"
description="Spend in this service is back under the thresholds, or cost anomalies were turned off for the account."
>
<BackLink providerId={providerId} />
</PanelEmpty>
);
}
/** The numbers, the chart, the changes and the findings behind one anomaly. */
export function AnomalyBody({
detail,
service,
region,
}: {
detail: RealtimeAnomalyPayload;
service: string;
region: string;
}) {
const { anomaly, series, thresholds } = detail;
const latest = series[series.length - 1];
const today = anomaly?.today ?? latest?.amount ?? 0;
const baseline = anomaly?.base ?? baselineOf(series);
const delta = anomaly?.delta ?? today - baseline;
const ratio = anomaly?.ratio ?? (baseline > 0 ? today / baseline : 0);
const threshold = anomalyThreshold(baseline, thresholds);
return (
<div className="flex flex-col gap-8">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
<RealtimeStatCard
title="Today"
adornment={
anomaly ? (
<Badge variant="warning">Anomaly</Badge>
) : (
<Badge variant="success">Back to normal</Badge>
)
}
>
<StatFigure value={formatMoney(today)} />
</RealtimeStatCard>
<RealtimeStatCard title="Baseline">
<StatFigure
value={formatMoney(baseline)}
caption="trailing daily average"
/>
</RealtimeStatCard>
<RealtimeStatCard title="Increase">
<StatFigure
value={`+${formatMoney(delta)}`}
caption={`Needs at least +${formatMoney(thresholds.min_delta)}`}
/>
</RealtimeStatCard>
<RealtimeStatCard title="Ratio to baseline">
<StatFigure
value={`${Number(ratio.toFixed(1))}x`}
caption={`Needs at least ${thresholds.min_ratio}x (${formatMoney(threshold)} a day)`}
/>
</RealtimeStatCard>
</div>
<RealtimeSection
title="Daily spend"
description={`${service} in ${region}, last 30 days, against its baseline.`}
>
<AnomalyChart
series={series}
baseline={baseline}
thresholds={thresholds}
label={`Daily cost of ${service} in ${region}`}
/>
</RealtimeSection>
<RealtimeSection
title={`Recent changes in ${detail.short_service}`}
description="Write calls to this service in the last 48 hours. A change from an unexpected actor or IP may explain the spend."
>
<EventsTable events={detail.events.map(toEvent)} writesOnly />
</RealtimeSection>
<RealtimeSection
title={`Open findings in ${detail.short_service}`}
description="Findings in this service rank higher while its cost is spiking."
>
<RankedFindingsTable findings={detail.findings} />
</RealtimeSection>
</div>
);
}
export function AnomalyDetail({
anomalyId,
providerId,
demo = false,
}: {
anomalyId: string;
providerId: string;
/** The sample anomaly: the sample data stands in for the account's. */
demo?: boolean;
}) {
const parts = parseAnomalyId(demo ? DEMO_ANOMALY_ID : anomalyId);
const valid = Boolean(parts) && (demo || isUuid(providerId));
const resource = useRealtimeResource(
() =>
demo
? getRealtimeDemo("anomaly")
: getRealtimeCostAnomaly({
providerId,
service: parts?.service ?? "",
region: parts?.region ?? "",
}),
demo ? "demo:anomaly" : `anomaly:${providerId}:${anomalyId}`,
{ intervalMs: demo ? undefined : REFRESH_MS, enabled: valid },
);
if (!valid || !parts) return <AnomalyGone providerId={providerId} />;
return (
<DemoProvider demo={demo}>
<div className="flex flex-col gap-6">
{demo && (
<DemoBanner
providerId={isUuid(providerId) ? providerId : undefined}
/>
)}
<BackLink providerId={providerId} />
<div className="flex flex-col gap-1">
<h2 className="text-text-neutral-primary text-xl font-semibold">
Cost anomaly: {parts.service}
</h2>
<p className="text-text-neutral-tertiary text-sm">
Region {parts.region}
</p>
</div>
{resource.loading ? (
<PanelLoading label="Loading the anomaly" />
) : resource.failure?.status === 404 ? (
<AnomalyGone providerId={providerId} />
) : resource.failure ? (
<PanelFailure
failure={resource.failure}
onRetry={resource.reload}
subject="the anomaly"
/>
) : resource.data ? (
<AnomalyBody
detail={resource.data}
service={parts.service}
region={parts.region}
/>
) : null}
</div>
</DemoProvider>
);
}
@@ -0,0 +1,203 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { saveCost, toast } = vi.hoisted(() => ({
saveCost: vi.fn(),
toast: vi.fn(),
}));
vi.mock("@/actions/realtime", () => ({
saveRealtimeCostSettings: saveCost,
getRealtimePolicy: vi.fn(),
}));
vi.mock("@/components/shadcn/toast", () => ({
useToast: () => ({ toast }),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ permissions: { manage_prowler_realtime: true } }),
}));
import { CostAnomalyForm } from "./cost-anomaly-settings";
const PROVIDER = "0b8c2b46-7f3a-4d1e-9a52-3f5f1d6a7c10";
const policy = {
id: "pol-1",
provider_id: PROVIDER,
enabled: false,
mode: "pr" as const,
dry_run: true,
min_severity: "low" as const,
deny_checks: [],
allow_regions: [],
scope_tags: {},
iac_repo: "",
iac_path: "",
open_pr: false,
use_lighthouse: true,
frameworks: [],
cost_anomaly_enabled: false,
cost_anomaly_min_ratio: 2,
cost_anomaly_min_delta: 5,
notifications: {
min_severity: "high" as const,
slack: { enabled: false, channel_ids: [] },
email: { enabled: false, recipient_ids: [] },
},
};
describe("CostAnomalyForm", () => {
beforeEach(() => {
vi.clearAllMocks();
saveCost.mockResolvedValue({ ok: true, data: policy });
});
it("is off by default and explains the Cost Explorer billing", () => {
render(
<CostAnomalyForm
providerId={PROVIDER}
policy={null}
canManage
onSaved={vi.fn()}
/>,
);
expect(
screen.getByRole("switch", { name: /detect cost anomalies/i }),
).not.toBeChecked();
expect(screen.getByLabelText("Minimum ratio")).toHaveValue(2);
expect(screen.getByLabelText(/Minimum increase/)).toHaveValue(5);
expect(screen.getByText(/billed per request/i)).toBeVisible();
expect(screen.getByText(/about every 6 hours/)).toBeVisible();
expect(screen.getByText(/off until you enable it/i)).toBeVisible();
});
it("creates the policy without a policy id when none exists, keeping detection off", async () => {
const user = userEvent.setup();
const onSaved = vi.fn();
render(
<CostAnomalyForm
providerId={PROVIDER}
policy={null}
canManage
onSaved={onSaved}
/>,
);
expect(screen.getByText(/has no policy yet/)).toBeVisible();
await user.click(
screen.getByRole("switch", { name: /detect cost anomalies/i }),
);
await user.click(
screen.getByRole("button", { name: /save cost anomaly settings/i }),
);
await waitFor(() => expect(saveCost).toHaveBeenCalled());
expect(saveCost.mock.calls[0][0]).toEqual({
providerId: PROVIDER,
policyId: undefined,
settings: {
cost_anomaly_enabled: true,
cost_anomaly_min_ratio: 2,
cost_anomaly_min_delta: 5,
},
});
await waitFor(() => expect(onSaved).toHaveBeenCalledWith(policy));
});
it("updates the existing policy with the thresholds typed", async () => {
const user = userEvent.setup();
render(
<CostAnomalyForm
providerId={PROVIDER}
policy={policy}
canManage
onSaved={vi.fn()}
/>,
);
const ratio = screen.getByLabelText("Minimum ratio");
await user.clear(ratio);
await user.type(ratio, "3.5");
const delta = screen.getByLabelText(/Minimum increase/);
await user.clear(delta);
await user.type(delta, "20");
await user.click(
screen.getByRole("button", { name: /save cost anomaly settings/i }),
);
await waitFor(() => expect(saveCost).toHaveBeenCalled());
expect(saveCost.mock.calls[0][0]).toMatchObject({
policyId: "pol-1",
settings: { cost_anomaly_min_ratio: 3.5, cost_anomaly_min_delta: 20 },
});
});
it("shows range errors and does not call the API", async () => {
const user = userEvent.setup();
render(
<CostAnomalyForm
providerId={PROVIDER}
policy={policy}
canManage
onSaved={vi.fn()}
/>,
);
const ratio = screen.getByLabelText("Minimum ratio");
await user.clear(ratio);
await user.type(ratio, "50");
await user.click(
screen.getByRole("button", { name: /save cost anomaly settings/i }),
);
expect(
await screen.findByText("Must be between 1.1 and 20."),
).toBeVisible();
expect(saveCost).not.toHaveBeenCalled();
});
it("is read only without the permission", () => {
render(
<CostAnomalyForm
providerId={PROVIDER}
policy={policy}
canManage={false}
onSaved={vi.fn()}
/>,
);
expect(screen.getByText("Read only")).toBeVisible();
expect(
screen.getByRole("switch", { name: /detect cost anomalies/i }),
).toBeDisabled();
expect(screen.getByLabelText("Minimum ratio")).toBeDisabled();
expect(
screen.queryByRole("button", { name: /save cost anomaly settings/i }),
).not.toBeInTheDocument();
});
it("explains a 403 from the API", async () => {
saveCost.mockResolvedValue({ ok: false, status: 403, error: "no" });
const user = userEvent.setup();
render(
<CostAnomalyForm
providerId={PROVIDER}
policy={policy}
canManage
onSaved={vi.fn()}
/>,
);
await user.click(
screen.getByRole("button", { name: /save cost anomaly settings/i }),
);
await waitFor(() =>
expect(toast).toHaveBeenCalledWith(
expect.objectContaining({ title: "You cannot change these settings" }),
),
);
});
});
@@ -0,0 +1,355 @@
"use client";
import Link from "next/link";
import { useId, useState } from "react";
import {
getRealtimePolicy,
saveRealtimeCostSettings,
} from "@/actions/realtime";
import {
ProviderSelect,
syncProviderToUrl,
} from "@/components/realtime/provider-select";
import { NoAwsProvider } from "@/components/realtime/realtime-settings";
import { RealtimeSection } from "@/components/realtime/realtime-stat-card";
import {
PanelFailure,
PanelLoading,
} from "@/components/realtime/realtime-states";
import { useRealtimeResource } from "@/components/realtime/use-realtime-resource";
import { Alert, AlertDescription, AlertTitle } from "@/components/shadcn/alert";
import { Badge } from "@/components/shadcn/badge/badge";
import { Button } from "@/components/shadcn/button/button";
import { Field, FieldError, FieldLabel } from "@/components/shadcn/field/field";
import { Input } from "@/components/shadcn/input/input";
import { Switch } from "@/components/shadcn/switch/switch";
import { useToast } from "@/components/shadcn/toast";
import { useAuth } from "@/hooks/use-auth";
import { settingsHref } from "@/lib/realtime/display";
import {
DEFAULT_REALTIME_POLICY,
realtimeCostAnomalySchema,
} from "@/lib/realtime/policy-schema";
import {
type RealtimeProviderOption,
resolveProviderId,
} from "@/lib/realtime/providers";
import {
REALTIME_COST_DELTA_RANGE,
REALTIME_COST_RATIO_RANGE,
type RealtimePolicy,
} from "@/types/realtime";
interface CostAnomalyFormProps {
providerId: string;
policy: RealtimePolicy | null;
canManage: boolean;
onSaved: (policy: RealtimePolicy) => void;
}
/** The text of a number input to a number; NaN when it is empty or not a number. */
const toNumber = (text: string) => (text.trim() === "" ? NaN : Number(text));
export function CostAnomalyForm({
providerId,
policy,
canManage,
onSaved,
}: CostAnomalyFormProps) {
const { toast } = useToast();
const id = useId();
const initial = policy ?? DEFAULT_REALTIME_POLICY;
const [enabled, setEnabled] = useState(initial.cost_anomaly_enabled);
const [ratio, setRatio] = useState(String(initial.cost_anomaly_min_ratio));
const [delta, setDelta] = useState(String(initial.cost_anomaly_min_delta));
const [errors, setErrors] = useState<Record<string, string>>({});
const [saving, setSaving] = useState(false);
const disabled = !canManage || saving;
const submit = async () => {
const parsed = realtimeCostAnomalySchema.safeParse({
cost_anomaly_enabled: enabled,
cost_anomaly_min_ratio: toNumber(ratio),
cost_anomaly_min_delta: toNumber(delta),
});
if (!parsed.success) {
const found: Record<string, string> = {};
parsed.error.issues.forEach((issue) => {
const field = String(issue.path[0] ?? "form");
if (!found[field]) found[field] = issue.message;
});
setErrors(found);
return;
}
setErrors({});
setSaving(true);
const result = await saveRealtimeCostSettings({
providerId,
policyId: policy?.id,
settings: parsed.data,
});
setSaving(false);
if (result.ok) {
onSaved(result.data);
toast({
title: enabled
? "Cost anomalies are on"
: "Cost anomaly settings saved",
});
} else {
setErrors(result.fieldErrors ?? {});
toast({
variant: "destructive",
title:
result.status === 403
? "You cannot change these settings"
: "Could not save the settings",
description:
result.status === 403
? "Changing them needs the Manage Realtime permission."
: result.error,
});
}
};
return (
<form
noValidate
className="flex flex-col gap-6"
onSubmit={(event) => {
event.preventDefault();
void submit();
}}
>
{!canManage && (
<Alert variant="info">
<AlertTitle>Read only</AlertTitle>
<AlertDescription>
<p>
Your role can see these settings but not change them. Changing
them needs the Manage Realtime permission.
</p>
</AlertDescription>
</Alert>
)}
<div className="flex items-start gap-3">
<Switch
id={`${id}-enabled`}
checked={enabled}
disabled={disabled}
aria-describedby={`${id}-enabled-description`}
onCheckedChange={setEnabled}
/>
<div className="flex flex-col gap-0.5">
<label
htmlFor={`${id}-enabled`}
className="text-text-neutral-primary text-sm font-medium"
>
Detect cost anomalies for this account
</label>
<p
id={`${id}-enabled-description`}
className="text-text-neutral-tertiary max-w-prose text-xs"
>
A sudden jump in spend in one service often means a resource is
being used by someone else. When it is on, findings in that service
rank higher and the dashboard shows the cost.
</p>
</div>
</div>
<Alert variant="warning">
<AlertTitle>AWS Cost Explorer is billed per request</AlertTitle>
<AlertDescription>
<p>
Once on, Prowler Realtime asks AWS Cost Explorer for this
account&apos;s daily cost about every 6 hours, and AWS charges for
each request. It is off until you enable it here, and nothing is
requested while it is off.
</p>
</AlertDescription>
</Alert>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Field>
<FieldLabel htmlFor={`${id}-ratio`}>Minimum ratio</FieldLabel>
<Input
id={`${id}-ratio`}
type="number"
inputMode="decimal"
step="0.1"
min={REALTIME_COST_RATIO_RANGE.min}
max={REALTIME_COST_RATIO_RANGE.max}
value={ratio}
disabled={disabled}
aria-invalid={Boolean(errors.cost_anomaly_min_ratio)}
aria-describedby={`${id}-ratio-hint`}
onChange={(event) => setRatio(event.target.value)}
/>
<p
id={`${id}-ratio-hint`}
className="text-text-neutral-tertiary text-xs"
>
An anomaly is today&apos;s spend being this many times the trailing
baseline. From {REALTIME_COST_RATIO_RANGE.min} to{" "}
{REALTIME_COST_RATIO_RANGE.max}; 2 means double.
</p>
{errors.cost_anomaly_min_ratio && (
<FieldError>{errors.cost_anomaly_min_ratio}</FieldError>
)}
</Field>
<Field>
<FieldLabel htmlFor={`${id}-delta`}>
Minimum increase (dollars)
</FieldLabel>
<Input
id={`${id}-delta`}
type="number"
inputMode="decimal"
step="0.5"
min={REALTIME_COST_DELTA_RANGE.min}
max={REALTIME_COST_DELTA_RANGE.max}
value={delta}
disabled={disabled}
aria-invalid={Boolean(errors.cost_anomaly_min_delta)}
aria-describedby={`${id}-delta-hint`}
onChange={(event) => setDelta(event.target.value)}
/>
<p
id={`${id}-delta-hint`}
className="text-text-neutral-tertiary text-xs"
>
The spend must also grow by at least this much, so a cheap service
doubling from a few cents is not reported. From{" "}
{REALTIME_COST_DELTA_RANGE.min} to {REALTIME_COST_DELTA_RANGE.max}.
</p>
{errors.cost_anomaly_min_delta && (
<FieldError>{errors.cost_anomaly_min_delta}</FieldError>
)}
</Field>
</div>
{!policy && (
<p className="text-text-neutral-tertiary text-xs">
This account has no policy yet. Saving creates one with the safe
defaults: detection stays off and nothing is changed in the account.
You can set it up in the{" "}
<Link
href={settingsHref(providerId)}
className="text-button-tertiary underline underline-offset-4"
>
Realtime settings
</Link>
.
</p>
)}
{canManage && (
<div className="flex items-center gap-3">
<Button type="submit" disabled={saving}>
{saving ? "Saving…" : "Save cost anomaly settings"}
</Button>
{Object.keys(errors).length > 0 && (
<span role="alert" className="text-text-error-primary text-sm">
Fix the highlighted fields and try again.
</span>
)}
</div>
)}
</form>
);
}
function CostSettingsBody({
providerId,
canManage,
}: {
providerId: string;
canManage: boolean;
}) {
const policy = useRealtimeResource(
() => getRealtimePolicy(providerId),
`policy:${providerId}`,
);
return (
<div className="flex max-w-3xl flex-col gap-4">
<RealtimeSection
title="Cost anomalies"
description="Turn the cost signal on per AWS account and choose how big a jump counts as an anomaly."
action={
policy.data?.cost_anomaly_enabled ? (
<Badge variant="success">On</Badge>
) : (
<Badge variant="tag">Off</Badge>
)
}
>
{policy.loading ? (
<PanelLoading label="Loading the settings" />
) : policy.failure ? (
<PanelFailure
failure={policy.failure}
onRetry={policy.reload}
subject="the settings"
/>
) : (
<CostAnomalyForm
key={policy.data?.id ?? "new"}
providerId={providerId}
policy={policy.data ?? null}
canManage={canManage}
onSaved={policy.setData}
/>
)}
</RealtimeSection>
</div>
);
}
export function CostAnomalySettings({
providers,
initialProviderId,
}: {
providers: RealtimeProviderOption[];
initialProviderId?: string;
}) {
const { permissions } = useAuth();
const canManage = permissions.manage_prowler_realtime === true;
const [providerId, setProviderId] = useState(
resolveProviderId(providers, initialProviderId),
);
if (!providerId) return <NoAwsProvider />;
return (
<div className="flex flex-col gap-6">
<div className="flex flex-wrap items-end justify-between gap-4">
<ProviderSelect
providers={providers}
value={providerId}
onChange={(value) => {
setProviderId(value);
syncProviderToUrl({ provider: value });
}}
/>
<div className="flex items-center gap-3">
{!canManage && <Badge variant="tag">Read only</Badge>}
<Button asChild variant="outline" size="sm">
<Link href={`/realtime?provider=${encodeURIComponent(providerId)}`}>
Back to the dashboard
</Link>
</Button>
</div>
</div>
<CostSettingsBody
key={providerId}
providerId={providerId}
canManage={canManage}
/>
</div>
);
}
@@ -0,0 +1,66 @@
import { render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { getInsight } = vi.hoisted(() => ({ getInsight: vi.fn() }));
vi.mock("@/actions/realtime", () => ({ getRealtimeInsight: getInsight }));
import { CostPanel } from "./cost-panel";
const PROVIDER = "0b8c2b46-7f3a-4d1e-9a52-3f5f1d6a7c10";
describe("CostPanel", () => {
beforeEach(() => vi.clearAllMocks());
it("renders nothing while cost anomalies are off", async () => {
getInsight.mockResolvedValue({ ok: true, data: { enabled: false } });
const { container } = render(<CostPanel providerId={PROVIDER} />);
await vi.waitFor(() => expect(getInsight).toHaveBeenCalled());
await vi.waitFor(() =>
expect(screen.queryByRole("status")).not.toBeInTheDocument(),
);
expect(container).toBeEmptyDOMElement();
});
it("links each anomaly to its detail page when enabled", async () => {
getInsight.mockResolvedValue({
ok: true,
data: {
enabled: true,
total: { total: 100, today: 10 },
series: [],
thresholds: { min_ratio: 2, min_delta: 5 },
anomalies: [
{
id: "Amazon S3|us-east-1",
service: "Amazon S3",
region: "us-east-1",
short_service: "s3",
today: 30,
base: 10,
delta: 20,
ratio: 3,
},
],
},
});
render(<CostPanel providerId={PROVIDER} />);
expect(
await screen.findByRole("link", { name: "Amazon S3" }),
).toHaveAttribute(
"href",
`/realtime/cost-anomalies/Amazon%20S3%7Cus-east-1?provider=${PROVIDER}`,
);
});
it("explains a missing data store with a notice", async () => {
getInsight.mockResolvedValue({ ok: false, status: 503, error: "x" });
render(<CostPanel providerId={PROVIDER} />);
expect(
await screen.findByText("Real-time data store not configured"),
).toBeVisible();
});
});
+211
View File
@@ -0,0 +1,211 @@
"use client";
import Link from "next/link";
import {
StatFigure,
RealtimeSection,
RealtimeStatCard,
} from "@/components/realtime/realtime-stat-card";
import {
PanelEmpty,
PanelFailure,
PanelLoading,
} from "@/components/realtime/realtime-states";
import { useRealtimeInsight } from "@/components/realtime/use-realtime-insight";
import { Badge } from "@/components/shadcn/badge/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/shadcn/table/table";
import { anomalyHref, formatMoney } from "@/lib/realtime/display";
import {
REALTIME_INSIGHT_KIND,
type RealtimeCostDay,
type RealtimeCostPayloadOn,
} from "@/types/realtime";
const REFRESH_MS = 60_000;
/** Daily spend as bars; the tallest day fills the height. */
function Sparkline({
days,
label,
}: {
days: RealtimeCostDay[];
label: string;
}) {
const max = Math.max(...days.map((day) => day.amount), 0.01);
return (
<div
role="img"
aria-label={label}
className="flex h-8 w-32 items-end gap-px"
>
{days.map((day) => (
<div
key={day.day}
title={`${day.day}: ${formatMoney(day.amount)}`}
className="bg-bg-data-info min-w-px flex-1 rounded-sm"
style={{ height: `${Math.max((day.amount / max) * 100, 4)}%` }}
/>
))}
</div>
);
}
export function CostBody({
cost,
providerId,
demo = false,
}: {
cost: RealtimeCostPayloadOn;
providerId: string;
demo?: boolean;
}) {
if (cost.series.length === 0 && cost.anomalies.length === 0) {
return (
<PanelEmpty
title="No cost data yet"
description="Daily cost is fetched from AWS Cost Explorer once the data store is receiving it."
/>
);
}
return (
<div className="flex flex-col gap-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<RealtimeStatCard title="Latest day">
<StatFigure value={formatMoney(cost.total.today)} />
</RealtimeStatCard>
<RealtimeStatCard title="Last 30 days">
<StatFigure value={formatMoney(cost.total.total)} />
</RealtimeStatCard>
<RealtimeStatCard title="Anomalies">
<StatFigure
value={cost.anomalies.length}
caption="services spiking"
/>
</RealtimeStatCard>
</div>
{cost.anomalies.length > 0 && (
<Table aria-label="Cost anomalies">
<TableHeader>
<TableRow>
<TableHead>Service</TableHead>
<TableHead>Region</TableHead>
<TableHead>Latest day</TableHead>
<TableHead>Baseline</TableHead>
<TableHead>Change</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{cost.anomalies.map((anomaly) => (
<TableRow key={anomaly.id}>
<TableCell className="text-text-neutral-primary">
<Link
href={anomalyHref(anomaly.id, providerId, demo)}
className="text-button-tertiary underline underline-offset-4"
>
{anomaly.service}
</Link>
</TableCell>
<TableCell>{anomaly.region}</TableCell>
<TableCell className="tabular-nums">
{formatMoney(anomaly.today)}
</TableCell>
<TableCell className="tabular-nums">
{formatMoney(anomaly.base)}
</TableCell>
<TableCell>
<Badge variant="warning">
{anomaly.ratio}x (+{formatMoney(anomaly.delta)})
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
{cost.series.length > 0 && (
<Table aria-label="Daily cost by service">
<TableHeader>
<TableRow>
<TableHead>Service</TableHead>
<TableHead>Region</TableHead>
<TableHead>Trend</TableHead>
<TableHead>Latest day</TableHead>
<TableHead>Baseline</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{cost.series.map((series) => (
<TableRow key={`${series.service}:${series.region}`}>
<TableCell className="text-text-neutral-primary">
{series.service}
</TableCell>
<TableCell>{series.region}</TableCell>
<TableCell>
<Sparkline
days={series.days}
label={`Daily cost of ${series.service} in ${series.region}`}
/>
</TableCell>
<TableCell className="tabular-nums">
{formatMoney(series.latest)}
</TableCell>
<TableCell className="tabular-nums">
{formatMoney(series.base)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
);
}
/**
* Cost series and anomalies. Renders nothing while the signal is off for the
* account (the API answers `enabled: false`); answers 503 without the data
* store, shown as a notice.
*/
export function CostPanel({
providerId,
demo = false,
}: {
providerId: string;
demo?: boolean;
}) {
const cost = useRealtimeInsight(
REALTIME_INSIGHT_KIND.COST,
providerId,
demo,
REFRESH_MS,
);
if (cost.data && !cost.data.enabled) return null;
return (
<RealtimeSection
title="Cost"
description="A sudden jump in spend in a service raises the rank of findings in that service. Select a service to see what changed."
>
{cost.loading ? (
<PanelLoading label="Loading cost" />
) : cost.failure ? (
<PanelFailure
failure={cost.failure}
onRetry={cost.reload}
subject="cost"
/>
) : cost.data?.enabled ? (
<CostBody cost={cost.data} providerId={providerId} demo={demo} />
) : null}
</RealtimeSection>
);
}
@@ -0,0 +1,37 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
vi.mock("@/actions/realtime", () => ({
deleteRealtimeCredential: vi.fn(),
saveRealtimeCredential: vi.fn(),
}));
vi.mock("@/components/shadcn/toast", () => ({
useToast: () => ({ toast: vi.fn() }),
}));
vi.mock("@/components/realtime/remediation-list", () => ({
RemediationList: ({ providerId }: { providerId?: string }) => (
<div>remediation list for {providerId}</div>
),
}));
vi.mock("@/components/realtime/template-guide", () => ({
TemplateGuide: () => <div>template guide</div>,
}));
import { CredentialsForm } from "./credentials-form";
describe("CredentialsForm", () => {
it("lists what the permission allows, with the template that creates it", () => {
render(
<CredentialsForm
providerId="p1"
credential={null}
canManage
onChanged={vi.fn()}
/>,
);
expect(screen.getByText("Credentials for remediation only")).toBeVisible();
expect(screen.getByText("remediation list for p1")).toBeVisible();
expect(screen.getByText("template guide")).toBeVisible();
});
});
+297
View File
@@ -0,0 +1,297 @@
"use client";
import { useId, useState } from "react";
import {
deleteRealtimeCredential,
saveRealtimeCredential,
} from "@/actions/realtime";
import { RealtimeSection } from "@/components/realtime/realtime-stat-card";
import { RemediationList } from "@/components/realtime/remediation-list";
import { TemplateGuide } from "@/components/realtime/template-guide";
import { Alert, AlertDescription, AlertTitle } from "@/components/shadcn/alert";
import { Badge } from "@/components/shadcn/badge/badge";
import { Button } from "@/components/shadcn/button/button";
import { Field, FieldError, FieldLabel } from "@/components/shadcn/field/field";
import { Input } from "@/components/shadcn/input/input";
import { Modal } from "@/components/shadcn/modal";
import { useToast } from "@/components/shadcn/toast";
import { realtimeCredentialSchema } from "@/lib/realtime/policy-schema";
import {
REMEDIATION_CLOUDFORMATION,
REMEDIATION_TERRAFORM,
} from "@/lib/realtime/templates";
import type { RealtimeCredential } from "@/types/realtime";
interface CredentialsFormProps {
providerId: string;
credential: RealtimeCredential | null;
canManage: boolean;
onChanged: (credential: RealtimeCredential | null) => void;
}
/**
* Remediation role and GitHub token. Secrets are write-only: the inputs start
* empty, are cleared after a save, and the API only ever says whether they
* exist.
*/
export function CredentialsForm({
providerId,
credential,
canManage,
onChanged,
}: CredentialsFormProps) {
const { toast } = useToast();
const id = useId();
const [roleArn, setRoleArn] = useState("");
const [externalId, setExternalId] = useState("");
const [githubToken, setGithubToken] = useState("");
const [errors, setErrors] = useState<Record<string, string>>({});
const [saving, setSaving] = useState(false);
const [confirmRemove, setConfirmRemove] = useState(false);
const hasRole = Boolean(credential?.role_arn);
const save = async () => {
const parsed = realtimeCredentialSchema.safeParse({
role_arn: roleArn,
external_id: externalId,
github_token: githubToken,
});
if (!parsed.success) {
setErrors(
Object.fromEntries(
parsed.error.issues.map((issue) => [
String(issue.path[0]),
issue.message,
]),
),
);
return;
}
setErrors({});
setSaving(true);
const result = await saveRealtimeCredential({
providerId,
credential: parsed.data,
});
setSaving(false);
if (result.ok) {
setRoleArn("");
setExternalId("");
setGithubToken("");
onChanged(result.data);
toast({ title: "Credentials saved" });
} else {
setErrors(result.fieldErrors ?? {});
toast({
variant: "destructive",
title: "Could not save the credentials",
description: result.error,
});
}
};
const remove = async () => {
if (!credential) return;
setSaving(true);
const result = await deleteRealtimeCredential(credential.id);
setSaving(false);
setConfirmRemove(false);
if (result.ok) {
onChanged(null);
toast({ title: "Credentials removed" });
} else {
toast({
variant: "destructive",
title: "Could not remove the credentials",
description: result.error,
});
}
};
return (
<RealtimeSection
title="Credentials for remediation only"
description="Used only to fix the account and open pull requests; detection never needs them. Secrets are never shown again after you save them."
>
<ul
className="flex flex-wrap items-center gap-2"
aria-label="Stored credentials"
>
<li>
<Badge variant={hasRole ? "success" : "tag"}>
{hasRole ? "Remediation role set" : "No remediation role"}
</Badge>
</li>
<li>
<Badge variant={credential?.has_github_token ? "success" : "tag"}>
{credential?.has_github_token
? "GitHub token stored"
: "No GitHub token"}
</Badge>
</li>
</ul>
{hasRole && (
<p className="text-text-neutral-secondary text-sm break-all">
Role: <code className="text-xs">{credential?.role_arn}</code>
</p>
)}
<Alert variant="info">
<AlertTitle>Allow Prowler to remediate, in your AWS account</AlertTitle>
<AlertDescription>
<p>
Prowler can only alert and open pull requests until you give it a
separate role that is allowed to make a small, fixed set of fixes.
Your scan role stays read-only, and you can delete the role at any
time to revoke it.
</p>
</AlertDescription>
</Alert>
<RemediationList providerId={providerId} />
<TemplateGuide
cloudformation={REMEDIATION_CLOUDFORMATION}
terraform={REMEDIATION_TERRAFORM}
steps={[
<>
Pick an <strong>external id</strong> (at least 8 characters) and
keep it: you enter the same value below.
</>,
<>
Apply the template below in this AWS account, as CloudFormation
(stack) or Terraform (<code className="text-xs">apply</code>). It
creates one role named{" "}
<code className="text-xs">ProwlerRemediation</code> that only
Prowler Cloud can assume, with your external id.
</>,
<>
Copy the <code className="text-xs">RoleArn</code> output (
<code className="text-xs">role_arn</code> in Terraform), paste it
below with the external id, and save.
</>,
<>
Optional: add a GitHub token with permission to create branches and
pull requests, so fixes in code can be proposed.
</>,
]}
/>
{canManage ? (
<form
className="flex flex-col gap-4"
autoComplete="off"
onSubmit={(event) => {
event.preventDefault();
void save();
}}
>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Field>
<FieldLabel htmlFor={`${id}-role`}>
Remediation role ARN
</FieldLabel>
<Input
id={`${id}-role`}
value={roleArn}
disabled={saving}
autoComplete="off"
placeholder={
hasRole
? "Leave empty to keep the stored role"
: "arn:aws:iam::123456789012:role/ProwlerRealtimeRemediation"
}
aria-invalid={Boolean(errors.role_arn)}
onChange={(event) => setRoleArn(event.target.value)}
/>
{errors.role_arn && <FieldError>{errors.role_arn}</FieldError>}
</Field>
<Field>
<FieldLabel htmlFor={`${id}-external`}>External id</FieldLabel>
<Input
id={`${id}-external`}
type="password"
value={externalId}
disabled={saving}
autoComplete="new-password"
placeholder="Write only"
aria-invalid={Boolean(errors.external_id)}
onChange={(event) => setExternalId(event.target.value)}
/>
{errors.external_id && (
<FieldError>{errors.external_id}</FieldError>
)}
</Field>
</div>
<Field className="max-w-xl">
<FieldLabel htmlFor={`${id}-token`}>GitHub token</FieldLabel>
<Input
id={`${id}-token`}
type="password"
value={githubToken}
disabled={saving}
autoComplete="new-password"
placeholder={
credential?.has_github_token
? "Leave empty to keep the stored token"
: "Write only"
}
aria-invalid={Boolean(errors.github_token)}
onChange={(event) => setGithubToken(event.target.value)}
/>
{errors.github_token && (
<FieldError>{errors.github_token}</FieldError>
)}
</Field>
{errors.form && <FieldError>{errors.form}</FieldError>}
<div className="flex flex-wrap items-center gap-3">
<Button type="submit" disabled={saving}>
{saving ? "Saving…" : "Save credentials"}
</Button>
{credential && (
<Button
type="button"
variant="outline"
disabled={saving}
onClick={() => setConfirmRemove(true)}
>
Remove credentials
</Button>
)}
</div>
</form>
) : (
<p className="text-text-neutral-tertiary text-sm">
Read only: changing credentials needs the Manage Realtime permission.
</p>
)}
<Modal
open={confirmRemove}
onOpenChange={(open) => {
if (!open && !saving) setConfirmRemove(false);
}}
title="Remove the stored credentials?"
description="Prowler Realtime can no longer fix this account or open pull requests until you add them again."
size="lg"
>
<div className="flex justify-end gap-2">
<Button
variant="outline"
disabled={saving}
onClick={() => setConfirmRemove(false)}
>
Cancel
</Button>
<Button
variant="destructive"
disabled={saving}
onClick={() => void remove()}
>
{saving ? "Removing…" : "Remove credentials"}
</Button>
</div>
</Modal>
</RealtimeSection>
);
}
+104
View File
@@ -0,0 +1,104 @@
"use client";
import Link from "next/link";
import { createContext, type ReactNode, useContext } from "react";
import { Alert, AlertDescription, AlertTitle } from "@/components/shadcn/alert";
import { Badge } from "@/components/shadcn/badge/badge";
import { Button } from "@/components/shadcn/button/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import {
DEMO_BANNER_TITLE,
DEMO_TOOLTIP,
LIGHTHOUSE_CHAT_PATH,
setupHref,
} from "@/lib/realtime/demo";
const DemoContext = createContext(false);
/** Everything below shows made-up sample data, and says so. */
export function DemoProvider({
demo,
children,
}: {
demo: boolean;
children: ReactNode;
}) {
return <DemoContext.Provider value={demo}>{children}</DemoContext.Provider>;
}
export const useIsDemo = () => useContext(DemoContext);
/** Marks a card or table header as sample data, for sight and for screen readers. */
export function DemoBadge() {
return (
<Badge variant="warning" size="sm" data-testid="demo-badge">
DEMO
<span className="sr-only"> data: made up, not your account</span>
</Badge>
);
}
/** The badge when the surrounding view is a demo; nothing otherwise. */
export function DemoBadgeIfDemo() {
return useIsDemo() ? <DemoBadge /> : null;
}
/** Small, secondary: the same setup, done by chat. */
export function LighthouseSetupLink({ className }: { className?: string }) {
return (
<span className={className}>
Prefer to do this by chat?{" "}
<Link
href={LIGHTHOUSE_CHAT_PATH}
className="text-button-tertiary underline underline-offset-4"
>
Ask Lighthouse AI to set it up
</Link>
</span>
);
}
/** Impossible to miss, on every dashboard tab and on the sample anomaly page. */
export function DemoBanner({ providerId }: { providerId?: string }) {
return (
<Alert variant="warning" role="status" aria-label="Demo data">
<AlertTitle className="line-clamp-none text-base">
{DEMO_BANNER_TITLE}
</AlertTitle>
<AlertDescription>
<p>
Everything on this page is sample data. Nothing here comes from your
AWS account, and nothing is switched on yet.
</p>
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-2">
<Button asChild size="sm">
<Link href={setupHref(providerId)}>Set up Prowler Realtime</Link>
</Button>
<LighthouseSetupLink className="text-xs" />
</div>
</AlertDescription>
</Alert>
);
}
/**
* Wraps a disabled write control in demo mode, so a tooltip can say why.
* A disabled button takes no hover, hence the focusable wrapper.
*/
export function DemoDisabled({ children }: { children: ReactNode }) {
return (
<Tooltip>
<TooltipTrigger asChild>
<span tabIndex={0} className="inline-flex">
{children}
</span>
</TooltipTrigger>
<TooltipContent>{DEMO_TOOLTIP}</TooltipContent>
</Tooltip>
);
}
+398
View File
@@ -0,0 +1,398 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
getDemo: vi.fn(),
getInsight: vi.fn(),
getActions: vi.fn(),
getEvents: vi.fn(),
getDetections: vi.fn(),
getAnomaly: vi.fn(),
getFrameworks: vi.fn(),
revert: vi.fn(),
}));
vi.mock("@/actions/realtime", () => ({
getRealtimeDemo: mocks.getDemo,
getRealtimeInsight: mocks.getInsight,
getRealtimeActions: mocks.getActions,
getRealtimeEvents: mocks.getEvents,
getRealtimeDetections: mocks.getDetections,
getRealtimeCostAnomaly: mocks.getAnomaly,
getRealtimeFrameworks: mocks.getFrameworks,
revertRealtimeAction: mocks.revert,
}));
vi.mock("@/components/shadcn/toast", () => ({
useToast: () => ({ toast: vi.fn() }),
}));
vi.mock("@/components/realtime/posture-timeline", () => ({
PostureTimeline: () => null,
}));
vi.mock("@/components/realtime/anomaly-chart", async (importOriginal) => ({
...(await importOriginal<
typeof import("@/components/realtime/anomaly-chart")
>()),
AnomalyChart: ({ label }: { label: string }) => <div>chart: {label}</div>,
}));
import { DEMO_ANOMALY_ID } from "@/lib/realtime/demo";
import type {
RealtimeAction,
RealtimeDetection,
RealtimeEvent,
} from "@/types/realtime";
import { ActionsList } from "./actions-list";
import { AnomalyDetail } from "./anomaly-detail";
import { DemoBanner, DemoProvider } from "./demo-context";
import { DetectionsList } from "./detections-list";
import { FindingsTab } from "./findings-tab";
import { OverviewTab } from "./overview-tab";
import { RealtimeSection } from "./realtime-stat-card";
import { StatusStatementCard } from "./status-statement-card";
import { StreamTab } from "./stream-tab";
const ok = <T,>(data: T) => ({ ok: true as const, data });
const page = <T,>(items: T[]) =>
ok({ items, page: 1, pages: 1, count: items.length });
const action: RealtimeAction = {
id: "a1",
provider_id: "",
detection_id: null,
kind: "self_fix",
status: "fixed_verified",
check_id: "s3_bucket_level_public_access_block",
resource_uid: "demo-bucket",
region: "eu-west-1",
severity: "high",
summary: "Enabled public access block",
pr_url: "",
branch: "",
revertible: true,
inserted_at: "2026-10-23T10:00:00Z",
};
const event = (id: string, readOnly: boolean): RealtimeEvent => ({
id,
event_time: "2026-10-23T10:00:00Z",
region: "eu-west-1",
event_source: "ec2.amazonaws.com",
event_name: readOnly ? "DescribeInstances" : "RunInstances",
user_type: "IAMUser",
user_arn: "arn:aws:iam::123456789012:user/demo",
source_ip: "203.0.113.9",
error_code: "",
read_only: readOnly,
provider_id: "",
detection_status: null,
rule_id: null,
});
const detection = (id: string, status: RealtimeDetection["status"]) =>
({
id,
provider_id: "p",
event_id: "e",
event_time: "2026-10-23T10:00:00Z",
region: "eu-west-1",
rule_id: `rule_${id}`,
title: `Detection ${id}`,
severity: "high",
actor: "demo",
resource: "r",
check_ids: [],
requirements: [],
status,
note: "",
verified_at: null,
}) as RealtimeDetection;
beforeAll(() => {
// Radix Select needs these in jsdom.
Object.defineProperty(HTMLElement.prototype, "hasPointerCapture", {
configurable: true,
value: () => false,
});
Object.defineProperty(HTMLElement.prototype, "releasePointerCapture", {
configurable: true,
value: () => {},
});
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
configurable: true,
value: () => {},
});
});
const REAL = "0b8c2b46-7f3a-4d1e-9a52-3f5f1d6a7c10";
describe("demo labelling", () => {
it("banner has the title, the setup button and the Lighthouse AI alternative", () => {
render(<DemoBanner providerId={REAL} />);
expect(screen.getByRole("status", { name: "Demo data" })).toHaveTextContent(
"DEMO DATA — a made-up account shown until you set up Prowler Realtime",
);
expect(
screen.getByRole("link", { name: "Set up Prowler Realtime" }),
).toHaveAttribute("href", `/realtime/setup?provider=${REAL}`);
expect(
screen.getByRole("link", { name: "Ask Lighthouse AI to set it up" }),
).toHaveAttribute("href", "/lighthouse");
});
it("marks section headers as demo for sight and for screen readers, only in a demo", () => {
const { rerender } = render(
<DemoProvider demo>
<RealtimeSection title="Actions">body</RealtimeSection>
</DemoProvider>,
);
expect(screen.getByTestId("demo-badge")).toHaveTextContent(
"DEMO data: made up, not your account",
);
rerender(
<DemoProvider demo={false}>
<RealtimeSection title="Actions">body</RealtimeSection>
</DemoProvider>,
);
expect(screen.queryByTestId("demo-badge")).not.toBeInTheDocument();
});
});
describe("dashboard tabs in demo mode", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.getFrameworks.mockResolvedValue(ok([]));
});
it("loads the overview, simulation and timeline from the demo, never the account", async () => {
mocks.getDemo.mockImplementation(async (kind: string) => {
if (kind === "overview") {
return ok({
provider: { id: "demo", uid: "123456789012", alias: "demo" },
policy: null,
feed: null,
data_store: "ok",
events_last_hour: { events: 3, changes: 1 },
cost: {
enabled: true,
available: true,
total: 100,
today: 10,
anomaly_count: 1,
anomalies: [
{
id: DEMO_ANOMALY_ID,
service: "Amazon Elastic Compute Cloud - Compute",
region: "us-east-1",
short_service: "ec2",
today: 120,
base: 20,
delta: 100,
ratio: 6,
},
],
},
posture: {
passed: 8,
failed: 2,
muted: 0,
score: 80,
failed_by_severity: {},
has_scan: true,
},
detections: {},
actions: {},
});
}
if (kind === "simulation") {
return ok({
open: 1,
would_fix_in_account: 0,
would_propose_in_code: 0,
needs_a_person: 0,
outside_frameworks: 0,
frameworks: [],
dry_run: true,
mode: "monitor",
effects: [],
projected_open_after_auto: 1,
automation_share: 0,
});
}
return ok({ points: [], actions: [] });
});
render(
<DemoProvider demo>
<OverviewTab providerId="" demo />
</DemoProvider>,
);
const link = await screen.findByRole("link", { name: /ec2/ });
expect(link).toHaveAttribute("href", "/realtime/cost-anomalies/demo");
expect(mocks.getDemo.mock.calls.map(([kind]) => kind).sort()).toEqual([
"overview",
"simulation",
"timeline",
]);
expect(mocks.getInsight).not.toHaveBeenCalled();
expect(screen.getAllByTestId("demo-badge").length).toBeGreaterThan(0);
});
it("loads findings and cost from the demo", async () => {
mocks.getDemo.mockImplementation(async (kind: string) =>
kind === "findings"
? ok({ signals: [], findings: [] })
: ok({ enabled: false }),
);
render(
<DemoProvider demo>
<FindingsTab providerId="" demo />
</DemoProvider>,
);
await screen.findByText("No open findings");
expect(mocks.getDemo).toHaveBeenCalledWith("findings");
expect(mocks.getDemo).toHaveBeenCalledWith("cost");
expect(mocks.getInsight).not.toHaveBeenCalled();
});
it("loads the status statement from the demo", async () => {
mocks.getDemo.mockResolvedValue(
ok({
generated_on: "2026-10-23",
mode: "monitor",
scope: "Whole account",
checks_passing: 9,
checks_failing: 1,
failing_by_severity: {},
fixed_and_verified_last_30_days: 2,
sha256: "abc",
}),
);
render(
<DemoProvider demo>
<StatusStatementCard providerId="" demo />
</DemoProvider>,
);
expect(await screen.findByText("Whole account")).toBeVisible();
expect(mocks.getDemo).toHaveBeenCalledWith("status");
expect(mocks.getInsight).not.toHaveBeenCalled();
});
it("streams sample events, filters changes locally and never refreshes from the account", async () => {
const user = userEvent.setup();
mocks.getDemo.mockResolvedValue(
page([event("e1", false), event("e2", true)]),
);
render(
<DemoProvider demo>
<StreamTab providerId="" demo />
</DemoProvider>,
);
expect(await screen.findByText("RunInstances")).toBeVisible();
expect(screen.queryByText("DescribeInstances")).not.toBeInTheDocument();
await user.click(screen.getByRole("switch", { name: "Changes only" }));
expect(await screen.findByText("DescribeInstances")).toBeVisible();
expect(mocks.getDemo).toHaveBeenCalledWith("events");
expect(mocks.getEvents).not.toHaveBeenCalled();
});
it("lists sample detections and filters them by verdict locally", async () => {
const user = userEvent.setup();
mocks.getDemo.mockResolvedValue(
page([detection("a", "violation"), detection("b", "compliant")]),
);
render(
<DemoProvider demo>
<DetectionsList providerId="" demo />
</DemoProvider>,
);
expect(await screen.findByText("Detection a")).toBeVisible();
expect(screen.getByText("Detection b")).toBeVisible();
await user.click(
screen.getByRole("combobox", { name: "Filter detections by verdict" }),
);
await user.click(await screen.findByRole("option", { name: "Compliant" }));
await waitFor(() =>
expect(screen.queryByText("Detection a")).not.toBeInTheDocument(),
);
expect(screen.getByText("Detection b")).toBeVisible();
expect(mocks.getDetections).not.toHaveBeenCalled();
});
it("disables Revert on sample actions, with the Demo data tooltip, and never calls the API", async () => {
const user = userEvent.setup();
mocks.getDemo.mockResolvedValue(page([action]));
render(
<DemoProvider demo>
<ActionsList providerId="" canManage demo />
</DemoProvider>,
);
const revert = await screen.findByRole("button", { name: "Revert" });
expect(revert).toBeDisabled();
await user.hover(revert.parentElement as HTMLElement);
expect((await screen.findAllByText("Demo data")).length).toBeGreaterThan(0);
expect(mocks.getActions).not.toHaveBeenCalled();
expect(mocks.revert).not.toHaveBeenCalled();
});
});
describe("the demo anomaly page", () => {
beforeEach(() => vi.clearAllMocks());
it("shows the banner and loads the demo anomaly without a provider", async () => {
mocks.getDemo.mockResolvedValue(
ok({
anomaly: {
id: DEMO_ANOMALY_ID,
service: "Amazon Elastic Compute Cloud - Compute",
region: "us-east-1",
short_service: "ec2",
today: 120,
base: 20,
delta: 100,
ratio: 6,
},
short_service: "ec2",
series: [{ day: "2026-10-23", amount: 120 }],
thresholds: { min_ratio: 2, min_delta: 5 },
events: [],
findings: [],
}),
);
render(<AnomalyDetail anomalyId={DEMO_ANOMALY_ID} providerId="" demo />);
expect(await screen.findByText(/chart: Daily cost of/)).toBeVisible();
expect(screen.getByText(/DEMO DATA/)).toBeVisible();
expect(
screen.getByRole("heading", {
name: "Cost anomaly: Amazon Elastic Compute Cloud - Compute",
}),
).toBeVisible();
expect(mocks.getDemo).toHaveBeenCalledWith("anomaly");
expect(mocks.getAnomaly).not.toHaveBeenCalled();
expect(screen.getAllByTestId("demo-badge").length).toBeGreaterThan(0);
});
it("has no banner for a real anomaly", () => {
mocks.getAnomaly.mockReturnValue(new Promise(() => {}));
render(<AnomalyDetail anomalyId={DEMO_ANOMALY_ID} providerId={REAL} />);
expect(screen.queryByText(/DEMO DATA/)).not.toBeInTheDocument();
});
});
@@ -0,0 +1,49 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
vi.mock("@/actions/realtime", () => ({}));
import type { RealtimeDetection } from "@/types/realtime";
import { DetectionsTable } from "./detections-list";
const detection = (
overrides: Partial<RealtimeDetection>,
): RealtimeDetection => ({
id: "d1",
provider_id: "p",
event_id: "e",
event_time: "2026-10-23T10:00:00Z",
region: "eu-west-1",
rule_id: "s3_public_acl",
title: "Bucket made public",
severity: "high",
actor: "",
resource: "bucket-1",
check_ids: [],
requirements: [],
status: "violation",
note: "",
verified_at: null,
...overrides,
});
describe("DetectionsTable", () => {
it("marks drift found by the reconciler as compliance drift", () => {
render(
<DetectionsTable
detections={[
detection({
id: "d2",
rule_id: "framework_drift",
title: "Public bucket (compliance drift)",
}),
detection({}),
]}
/>,
);
expect(screen.getAllByText("Compliance drift")).toHaveLength(1);
expect(screen.getByText("Public bucket (compliance drift)")).toBeVisible();
});
});
+221
View File
@@ -0,0 +1,221 @@
"use client";
import { useState } from "react";
import { getRealtimeDemo, getRealtimeDetections } from "@/actions/realtime";
import { DetectionStatusBadge } from "@/components/realtime/realtime-badges";
import { RealtimePager } from "@/components/realtime/realtime-pager";
import { RealtimeSeverity } from "@/components/realtime/realtime-severity";
import { RealtimeSection } from "@/components/realtime/realtime-stat-card";
import {
PanelEmpty,
PanelFailure,
PanelLoading,
} from "@/components/realtime/realtime-states";
import { useRealtimeResource } from "@/components/realtime/use-realtime-resource";
import { Badge } from "@/components/shadcn/badge/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/shadcn/select/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/shadcn/table/table";
import {
detectionStatusBadge,
formatDateTime,
isDriftDetection,
} from "@/lib/realtime/display";
import {
REALTIME_DETECTION_STATUS,
type RealtimeDetection,
type RealtimePage,
} from "@/types/realtime";
const REFRESH_MS = 20_000;
const ALL = "all";
export function DetectionsTable({
detections,
}: {
detections: RealtimeDetection[];
}) {
if (detections.length === 0) {
return (
<PanelEmpty
title="No detections"
description="When a risky API call is seen, Prowler Realtime checks the resource it touched and records the verdict here."
/>
);
}
return (
<Table aria-label="Detections">
<TableHeader>
<TableRow>
<TableHead>When</TableHead>
<TableHead>Detection</TableHead>
<TableHead>Severity</TableHead>
<TableHead>Actor and resource</TableHead>
<TableHead>Verdict</TableHead>
<TableHead>Requirements</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{detections.map((detection) => (
<TableRow key={detection.id}>
<TableCell className="text-text-neutral-secondary text-xs whitespace-nowrap tabular-nums">
{formatDateTime(detection.event_time)}
</TableCell>
<TableCell className="max-w-64">
<div className="flex flex-col">
<span className="text-text-neutral-primary">
{detection.title}
</span>
{isDriftDetection(detection) && (
<Badge variant="warning" size="sm" className="my-0.5">
Compliance drift
</Badge>
)}
<code className="text-text-neutral-tertiary text-xs">
{detection.rule_id}
</code>
</div>
</TableCell>
<TableCell>
<RealtimeSeverity severity={detection.severity} />
</TableCell>
<TableCell className="max-w-64">
<span
className="text-text-neutral-secondary block truncate text-xs"
title={detection.actor}
>
{detection.actor || "Unknown actor"}
</span>
<span
className="text-text-neutral-tertiary block truncate text-xs"
title={detection.resource}
>
{detection.resource || "No single target"}
{detection.region ? ` (${detection.region})` : ""}
</span>
</TableCell>
<TableCell className="max-w-64">
<div className="flex flex-col items-start gap-1">
<DetectionStatusBadge status={detection.status} />
{detection.note && (
<span className="text-text-neutral-tertiary text-xs">
{detection.note}
</span>
)}
</div>
</TableCell>
<TableCell className="max-w-56">
<div className="flex flex-wrap gap-1">
{detection.requirements.length > 0 ? (
detection.requirements.map((requirement) => (
<Badge key={requirement} variant="tag" size="sm">
{requirement}
</Badge>
))
) : (
<span className="text-text-neutral-tertiary text-xs">-</span>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}
export function DetectionsList({
providerId,
demo = false,
}: {
providerId: string;
demo?: boolean;
}) {
const [page, setPage] = useState(1);
const [status, setStatus] = useState(ALL);
// The sample is one fixed page: the verdict filter is applied here.
const detections = useRealtimeResource<RealtimePage<RealtimeDetection>>(
async () => {
if (!demo) {
return getRealtimeDetections({
providerId,
page,
statusIn: status === ALL ? undefined : [status],
});
}
const result = await getRealtimeDemo("detections");
if (!result.ok || status === ALL) return result;
const items = result.data.items.filter((item) => item.status === status);
return {
ok: true as const,
data: { items, page: 1, pages: 1, count: items.length },
};
},
demo
? `demo:detections:${status}`
: `detections:${providerId}:${page}:${status}`,
{ intervalMs: demo ? undefined : REFRESH_MS },
);
return (
<RealtimeSection
title="Detections"
description="Risky API calls Prowler Realtime saw, and what Prowler found when it checked the resource."
action={
<Select
value={status}
onValueChange={(value) => {
setStatus(value || ALL);
setPage(1);
}}
>
<SelectTrigger size="xs" aria-label="Filter detections by verdict">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>All verdicts</SelectItem>
{Object.values(REALTIME_DETECTION_STATUS).map((value) => (
<SelectItem key={value} value={value}>
{detectionStatusBadge(value).label}
</SelectItem>
))}
</SelectContent>
</Select>
}
>
{detections.loading ? (
<PanelLoading label="Loading detections" />
) : detections.failure ? (
<PanelFailure
failure={detections.failure}
onRetry={detections.reload}
subject="the detections"
/>
) : detections.data ? (
<div className="flex flex-col gap-3">
<DetectionsTable detections={detections.data.items} />
<RealtimePager
page={detections.data.page}
pages={detections.data.pages}
count={detections.data.count}
onPageChange={setPage}
/>
</div>
) : null}
</RealtimeSection>
);
}
+111
View File
@@ -0,0 +1,111 @@
import { DetectionStatusBadge } from "@/components/realtime/realtime-badges";
import { PanelEmpty } from "@/components/realtime/realtime-states";
import { Badge } from "@/components/shadcn/badge/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/shadcn/table/table";
import { formatDateTime } from "@/lib/realtime/display";
import type { RealtimeEvent } from "@/types/realtime";
/** The short name of an ARN: `arn:aws:iam::123:user/ana` to `user/ana`. */
export const actorLabel = (event: RealtimeEvent): string => {
if (event.user_arn) {
const resource = event.user_arn.split(":").slice(5).join(":");
return resource || event.user_arn;
}
return event.user_type || "Unknown";
};
export function EventsTable({
events,
writesOnly,
}: {
events: RealtimeEvent[];
writesOnly: boolean;
}) {
if (events.length === 0) {
return (
<PanelEmpty
title="No events in the last 24 hours"
description={
writesOnly
? "Nothing that changes your account arrived yet. Show read calls too, or check the feed now."
: "Nothing arrived yet. Check the feed now, or confirm the feed is set up."
}
/>
);
}
return (
<Table aria-label="Recent CloudTrail events">
<TableHeader>
<TableRow>
<TableHead>Time</TableHead>
<TableHead>Event</TableHead>
<TableHead>Region</TableHead>
<TableHead>Actor</TableHead>
<TableHead>Source IP</TableHead>
<TableHead>Detection</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{events.map((event) => (
<TableRow key={event.id}>
<TableCell className="text-text-neutral-secondary text-xs whitespace-nowrap tabular-nums">
{formatDateTime(event.event_time)}
</TableCell>
<TableCell>
<div className="flex flex-col">
<span className="text-text-neutral-primary font-medium">
{event.event_name}
</span>
<span className="text-text-neutral-tertiary text-xs">
{event.event_source}
{event.read_only ? " (read)" : ""}
</span>
{event.error_code && (
<span className="text-text-error-primary text-xs">
{event.error_code}
</span>
)}
</div>
</TableCell>
<TableCell className="text-text-neutral-secondary">
{event.region || "global"}
</TableCell>
<TableCell className="max-w-56">
<span
className="text-text-neutral-secondary block truncate"
title={event.user_arn || undefined}
>
{actorLabel(event)}
</span>
</TableCell>
<TableCell className="text-text-neutral-secondary font-mono text-xs">
{event.source_ip || "-"}
</TableCell>
<TableCell>
{event.detection_status ? (
<div className="flex flex-col items-start gap-1">
<DetectionStatusBadge status={event.detection_status} />
{event.rule_id && (
<span className="text-text-neutral-tertiary text-xs">
{event.rule_id}
</span>
)}
</div>
) : (
<Badge variant="outline">None</Badge>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}
@@ -0,0 +1,93 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { getFeed, saveFeed } = vi.hoisted(() => ({
getFeed: vi.fn(),
saveFeed: vi.fn(),
}));
vi.mock("@/actions/realtime", () => ({
getRealtimeFeed: getFeed,
saveRealtimeFeed: saveFeed,
pollRealtimeFeed: vi.fn(),
}));
vi.mock("@/components/shadcn/toast", () => ({
useToast: () => ({ toast: vi.fn() }),
}));
vi.mock("@/components/realtime/template-guide", () => ({
TemplateGuide: () => <div>template guide</div>,
}));
import { FeedSetupPanel } from "./feed-setup-panel";
const feed = {
id: "f1",
provider_id: "p1",
queue_url: "",
region: "us-east-1",
status: "connected",
last_polled_at: null,
last_event_at: null,
last_error: "",
};
describe("FeedSetupPanel", () => {
beforeEach(() => {
vi.clearAllMocks();
getFeed.mockResolvedValue({ ok: true, data: null });
saveFeed.mockResolvedValue({ ok: true, data: feed });
});
it("tells its parent when CloudTrail lookup was started", async () => {
const user = userEvent.setup();
const onFeedChanged = vi.fn();
render(
<FeedSetupPanel
providerId="p1"
canManage
onFeedChanged={onFeedChanged}
/>,
);
await user.click(
await screen.findByRole("button", {
name: "Start with CloudTrail lookup",
}),
);
await waitFor(() => expect(onFeedChanged).toHaveBeenCalledWith(feed));
expect(saveFeed).toHaveBeenCalledWith({
providerId: "p1",
feed: { queue_url: "", region: "us-east-1" },
});
});
it("tells its parent when a queue URL was saved", async () => {
const user = userEvent.setup();
const onFeedChanged = vi.fn();
getFeed.mockResolvedValue({ ok: true, data: feed });
render(
<FeedSetupPanel
providerId="p1"
canManage
onFeedChanged={onFeedChanged}
/>,
);
await user.type(
await screen.findByLabelText("SQS queue URL"),
"https://sqs.us-east-1.amazonaws.com/123456789012/q",
);
await user.click(screen.getByRole("button", { name: "Save feed" }));
await waitFor(() => expect(onFeedChanged).toHaveBeenCalled());
expect(saveFeed).toHaveBeenCalledWith(
expect.objectContaining({
feed: expect.objectContaining({
queue_url: "https://sqs.us-east-1.amazonaws.com/123456789012/q",
}),
}),
);
});
});
+323
View File
@@ -0,0 +1,323 @@
"use client";
import { useId, useState } from "react";
import {
getRealtimeFeed,
pollRealtimeFeed,
saveRealtimeFeed,
} from "@/actions/realtime";
import { FeedStatusBadge } from "@/components/realtime/realtime-badges";
import { RealtimeSection } from "@/components/realtime/realtime-stat-card";
import {
PanelFailure,
PanelLoading,
} from "@/components/realtime/realtime-states";
import { TemplateGuide } from "@/components/realtime/template-guide";
import { useRealtimeResource } from "@/components/realtime/use-realtime-resource";
import { Alert, AlertDescription, AlertTitle } from "@/components/shadcn/alert";
import { Button } from "@/components/shadcn/button/button";
import { Field, FieldError, FieldLabel } from "@/components/shadcn/field/field";
import { Input } from "@/components/shadcn/input/input";
import { useToast } from "@/components/shadcn/toast";
import { formatRelativeTime } from "@/lib/realtime/display";
import { realtimeFeedSchema } from "@/lib/realtime/policy-schema";
import { FEED_CLOUDFORMATION, FEED_TERRAFORM } from "@/lib/realtime/templates";
import type { RealtimeFeed } from "@/types/realtime";
const DEFAULT_REGION = "us-east-1";
interface FeedFormProps {
providerId: string;
feed: RealtimeFeed | null;
canManage: boolean;
onSaved: (feed: RealtimeFeed) => void;
}
function FeedForm({ providerId, feed, canManage, onSaved }: FeedFormProps) {
const { toast } = useToast();
const queueId = useId();
const regionId = useId();
const [queueUrl, setQueueUrl] = useState(feed?.queue_url ?? "");
const [region, setRegion] = useState(feed?.region ?? DEFAULT_REGION);
const [errors, setErrors] = useState<Record<string, string>>({});
const [saving, setSaving] = useState(false);
const submit = async () => {
const parsed = realtimeFeedSchema.safeParse({
queue_url: queueUrl,
region,
});
if (!parsed.success) {
setErrors(
Object.fromEntries(
parsed.error.issues.map((issue) => [
String(issue.path[0]),
issue.message,
]),
),
);
return;
}
setErrors({});
setSaving(true);
const result = await saveRealtimeFeed({
providerId,
feedId: feed?.id,
feed: parsed.data,
});
setSaving(false);
if (result.ok) {
onSaved(result.data);
toast({
title: parsed.data.queue_url
? "Real-time queue saved"
: "Using CloudTrail lookup",
});
} else {
setErrors(result.fieldErrors ?? {});
toast({
variant: "destructive",
title: "Could not save the feed",
description: result.error,
});
}
};
return (
<form
className="flex flex-col gap-4"
onSubmit={(event) => {
event.preventDefault();
void submit();
}}
>
<div className="grid grid-cols-1 gap-4 md:grid-cols-[2fr_1fr]">
<Field>
<FieldLabel htmlFor={queueId}>SQS queue URL</FieldLabel>
<Input
id={queueId}
value={queueUrl}
disabled={!canManage || saving}
placeholder="https://sqs.us-east-1.amazonaws.com/123456789012/prowler-realtime-events"
aria-invalid={Boolean(errors.queue_url)}
aria-describedby={errors.queue_url ? `${queueId}-error` : undefined}
onChange={(event) => setQueueUrl(event.target.value)}
/>
{errors.queue_url && (
<FieldError id={`${queueId}-error`}>{errors.queue_url}</FieldError>
)}
</Field>
<Field>
<FieldLabel htmlFor={regionId}>Queue region</FieldLabel>
<Input
id={regionId}
value={region}
disabled={!canManage || saving}
aria-invalid={Boolean(errors.region)}
aria-describedby={errors.region ? `${regionId}-error` : undefined}
onChange={(event) => setRegion(event.target.value)}
/>
{errors.region && (
<FieldError id={`${regionId}-error`}>{errors.region}</FieldError>
)}
</Field>
</div>
<p className="text-text-neutral-tertiary text-xs">
Leave the queue URL empty to use CloudTrail lookup.
</p>
{canManage ? (
<div>
<Button type="submit" disabled={saving}>
{saving ? "Saving…" : feed ? "Save feed" : "Create feed"}
</Button>
</div>
) : (
<p className="text-text-neutral-tertiary text-sm">
Read only: changing the feed needs the Manage Realtime permission.
</p>
)}
</form>
);
}
interface FeedSetupPanelProps {
providerId: string;
canManage: boolean;
/** Called after a poll was queued, so the stream can refresh. */
onPolled?: () => void;
/** Called after the feed was created or changed. */
onFeedChanged?: (feed: RealtimeFeed) => void;
}
/** The provider's event feed: status, "check now", and the queue form. */
export function FeedSetupPanel({
providerId,
canManage,
onPolled,
onFeedChanged,
}: FeedSetupPanelProps) {
const { toast } = useToast();
const [polling, setPolling] = useState(false);
const [starting, setStarting] = useState(false);
const feedResource = useRealtimeResource(
() => getRealtimeFeed(providerId),
`feed:${providerId}`,
{ intervalMs: 15_000 },
);
const feed = feedResource.data ?? null;
const checkNow = async () => {
if (!feed) return;
setPolling(true);
const result = await pollRealtimeFeed(feed.id);
setPolling(false);
if (result.ok) {
toast({
title: "Check requested",
description: "New events appear in the stream within moments.",
});
onPolled?.();
feedResource.reload();
} else {
toast({
variant: "destructive",
title: "Could not start the check",
description: result.error,
});
}
};
const startLookup = async () => {
setStarting(true);
const result = await saveRealtimeFeed({
providerId,
feed: { queue_url: "", region: DEFAULT_REGION },
}).finally(() => setStarting(false));
if (result.ok) {
feedResource.setData(result.data);
onFeedChanged?.(result.data);
} else
toast({
variant: "destructive",
title: "Could not create the feed",
description: result.error,
});
};
return (
<RealtimeSection
title="Event feed"
description="How Prowler Realtime learns about changes in this AWS account."
>
{feedResource.loading ? (
<PanelLoading label="Loading the feed" />
) : feedResource.failure ? (
<PanelFailure
failure={feedResource.failure}
onRetry={feedResource.reload}
subject="the feed"
/>
) : (
<div className="flex flex-col gap-4">
<Alert variant="info">
<AlertTitle>Real time or a few minutes behind</AlertTitle>
<AlertDescription>
<p>
With an SQS queue, risky API calls reach Prowler Realtime in
near real time. Without one, Prowler Realtime polls CloudTrail
LookupEvents, which trails by a few minutes.
</p>
</AlertDescription>
</Alert>
<TemplateGuide
cloudformation={FEED_CLOUDFORMATION}
terraform={FEED_TERRAFORM}
steps={[
<>
Apply the template below in this AWS account, as CloudFormation
(stack) or Terraform, in one region: Prowler Realtime reads one
queue per account. Global-service events (IAM, S3 account
settings) arrive in us-east-1.
</>,
<>
It creates an SQS queue, an EventBridge rule for risky API
calls, and read-only access to that queue for your{" "}
<code className="text-xs">ProwlerScan</code> role. Nothing is
changed in your account beyond that.
</>,
<>
Copy the <code className="text-xs">QueueUrl</code> output (
<code className="text-xs">queue_url</code> in Terraform) and
paste it below.
</>,
]}
/>
{feed ? (
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
<FeedStatusBadge status={feed.status} />
<span className="text-text-neutral-secondary text-sm">
{feed.queue_url
? "Real time (SQS)"
: "CloudTrail lookup (delayed)"}
</span>
<span className="text-text-neutral-tertiary text-xs">
Last polled {formatRelativeTime(feed.last_polled_at)}
</span>
<span className="text-text-neutral-tertiary text-xs">
Last event {formatRelativeTime(feed.last_event_at)}
</span>
{canManage && (
<Button
variant="outline"
size="sm"
disabled={polling}
onClick={() => void checkNow()}
>
{polling ? "Checking…" : "Check now"}
</Button>
)}
</div>
) : (
<div className="flex flex-wrap items-center gap-3">
<span className="text-text-neutral-secondary text-sm">
No feed yet for this account.
</span>
{canManage && (
<Button
variant="outline"
size="sm"
disabled={starting}
onClick={() => void startLookup()}
>
{starting ? "Starting…" : "Start with CloudTrail lookup"}
</Button>
)}
</div>
)}
{feed?.last_error && (
<Alert variant="error">
<AlertTitle>The last poll failed</AlertTitle>
<AlertDescription>
<p>{feed.last_error}</p>
</AlertDescription>
</Alert>
)}
<FeedForm
key={`${feed?.id ?? "new"}:${feed?.queue_url ?? ""}:${feed?.region ?? ""}`}
providerId={providerId}
feed={feed}
canManage={canManage}
onSaved={(saved) => {
feedResource.setData(saved);
onFeedChanged?.(saved);
}}
/>
</div>
)}
</RealtimeSection>
);
}
+215
View File
@@ -0,0 +1,215 @@
"use client";
import { CostPanel } from "@/components/realtime/cost-panel";
import { RouteBadge } from "@/components/realtime/realtime-badges";
import { RealtimeSeverity } from "@/components/realtime/realtime-severity";
import { RealtimeSection } from "@/components/realtime/realtime-stat-card";
import {
PanelEmpty,
PanelFailure,
PanelLoading,
} from "@/components/realtime/realtime-states";
import { useRealtimeInsight } from "@/components/realtime/use-realtime-insight";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/shadcn/table/table";
import { ROUTE_LEGEND, signalLabel } from "@/lib/realtime/display";
import {
REALTIME_INSIGHT_KIND,
REALTIME_ROUTE,
type RealtimeRankedFinding,
type RealtimeSignal,
} from "@/types/realtime";
const REFRESH_MS = 60_000;
/** What each "Handled by" badge means. */
export function RouteLegend() {
return (
<dl
aria-label="Handled by legend"
className="grid grid-cols-1 gap-x-6 gap-y-1 text-xs sm:grid-cols-2"
>
{Object.values(REALTIME_ROUTE).map((route) => (
<div key={route} className="flex items-center gap-2">
<dt>
<RouteBadge route={route} />
</dt>
<dd className="text-text-neutral-tertiary">{ROUTE_LEGEND[route]}</dd>
</div>
))}
</dl>
);
}
export function RankedFindingsTable({
findings,
}: {
findings: RealtimeRankedFinding[];
}) {
if (findings.length === 0) {
return (
<PanelEmpty
title="No open findings"
description="Nothing is failing in the latest scan of this account."
/>
);
}
return (
<div className="flex flex-col gap-3">
<Table aria-label="Open findings ranked by likelihood of exploitation">
<TableHeader>
<TableRow>
<TableHead>Score</TableHead>
<TableHead>Finding</TableHead>
<TableHead>Severity</TableHead>
<TableHead>Resource</TableHead>
<TableHead>Why it ranks here</TableHead>
<TableHead>Handled by</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{findings.map((finding) => (
<TableRow key={finding.finding_id}>
<TableCell className="text-text-neutral-primary font-semibold tabular-nums">
{finding.score}
</TableCell>
<TableCell className="max-w-72">
<div className="flex flex-col">
<span className="text-text-neutral-primary">
{finding.title}
</span>
<code className="text-text-neutral-tertiary text-xs break-all">
{finding.check_id}
</code>
</div>
</TableCell>
<TableCell>
<RealtimeSeverity severity={finding.severity} />
</TableCell>
<TableCell className="max-w-56">
<span
className="text-text-neutral-secondary block truncate"
title={finding.resource_uid}
>
{finding.resource_uid}
</span>
{finding.region && (
<span className="text-text-neutral-tertiary text-xs">
{finding.region}
</span>
)}
</TableCell>
<TableCell className="max-w-64">
{finding.reasons.length > 0 ? (
<ul className="text-text-neutral-secondary flex flex-col gap-0.5 text-xs">
{finding.reasons.map((reason) => (
<li key={reason}>{reason}</li>
))}
</ul>
) : (
<span className="text-text-neutral-tertiary text-xs">
Severity only
</span>
)}
</TableCell>
<TableCell>
<RouteBadge route={finding.route} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<RouteLegend />
</div>
);
}
export function SignalsList({ signals }: { signals: RealtimeSignal[] }) {
if (signals.length === 0) {
return (
<PanelEmpty
title="No suspicious activity patterns"
description="Nothing in the last 24 hours points at a resource being used rather than just exposed."
/>
);
}
return (
<ul className="flex flex-col gap-2">
{signals.map((signal) => (
<li
key={`${signal.signal}:${signal.service}:${signal.region}`}
className="border-border-neutral-secondary flex flex-wrap items-center justify-between gap-2 rounded-lg border px-3 py-2 text-sm"
>
<span className="text-text-neutral-primary">
{signalLabel(signal.signal)}
</span>
<span className="text-text-neutral-tertiary">
{signal.service} in {signal.region || "global"}, {signal.count}{" "}
event{signal.count === 1 ? "" : "s"}
</span>
</li>
))}
</ul>
);
}
export function FindingsTab({
providerId,
demo = false,
}: {
providerId: string;
demo?: boolean;
}) {
const findings = useRealtimeInsight(
REALTIME_INSIGHT_KIND.FINDINGS,
providerId,
demo,
REFRESH_MS,
);
return (
<div className="flex flex-col gap-8">
<RealtimeSection
title="Open findings, ranked"
description="Compliance says what is misconfigured; activity and cost say what is being used. A finding in a service showing risky activity or a cost spike moves up."
>
{findings.loading ? (
<PanelLoading label="Loading findings" />
) : findings.failure ? (
<PanelFailure
failure={findings.failure}
onRetry={findings.reload}
subject="the ranked findings"
/>
) : findings.data ? (
<RankedFindingsTable findings={findings.data.findings} />
) : null}
</RealtimeSection>
<RealtimeSection
title="Activity signals"
description="Patterns from the last 24 hours of CloudTrail events."
>
{findings.loading ? (
<PanelLoading label="Loading signals" />
) : findings.failure ? (
<PanelFailure
failure={findings.failure}
onRetry={findings.reload}
subject="the activity signals"
/>
) : findings.data ? (
<SignalsList signals={findings.data.signals} />
) : null}
</RealtimeSection>
<CostPanel providerId={providerId} demo={demo} />
</div>
);
}
@@ -0,0 +1,27 @@
import { Badge } from "@/components/shadcn/badge/badge";
import { frameworkLabel } from "@/lib/realtime/display";
/** The compliance frameworks a policy enforces, or a note that all checks are in scope. */
export function FrameworkBadges({
frameworks,
catalog = [],
}: {
frameworks: string[];
catalog?: Array<{ id: string; name: string }>;
}) {
if (frameworks.length === 0) {
return <Badge variant="outline">Enforcing: all checks</Badge>;
}
return (
<ul
className="flex flex-wrap items-center gap-2"
aria-label="Enforced frameworks"
>
{frameworks.map((id) => (
<li key={id}>
<Badge variant="tag">{frameworkLabel(id, catalog)}</Badge>
</li>
))}
</ul>
);
}
+151
View File
@@ -0,0 +1,151 @@
"use client";
import { X } from "lucide-react";
import { useId, useState } from "react";
import { Badge } from "@/components/shadcn/badge/badge";
import { Field, FieldError, FieldLabel } from "@/components/shadcn/field/field";
import { Input } from "@/components/shadcn/input/input";
import { frameworkLabel } from "@/lib/realtime/display";
import {
REALTIME_MAX_FRAMEWORKS,
type RealtimeFramework,
} from "@/types/realtime";
interface FrameworkPickerProps {
/** The catalog; empty while it loads or when it could not be loaded. */
catalog: RealtimeFramework[];
selected: string[];
disabled?: boolean;
error?: string;
onChange: (frameworks: string[]) => void;
}
const MAX_LISTED = 50;
const optionLabel = (framework: RealtimeFramework) =>
framework.version
? `${framework.name} (${framework.version})`
: framework.name;
/**
* Search and chips for the compliance frameworks a policy enforces. At most
* five; every selected id stays visible as a chip even if the catalog does not
* know it (an id saved earlier), so it can always be removed.
*/
export function FrameworkPicker({
catalog,
selected,
disabled = false,
error,
onChange,
}: FrameworkPickerProps) {
const id = useId();
const [query, setQuery] = useState("");
const full = selected.length >= REALTIME_MAX_FRAMEWORKS;
const needle = query.trim().toLowerCase();
const matches = catalog
.filter(
(framework) =>
!selected.includes(framework.id) &&
(!needle ||
framework.name.toLowerCase().includes(needle) ||
framework.id.toLowerCase().includes(needle)),
)
.slice(0, MAX_LISTED);
const remove = (frameworkId: string) =>
onChange(selected.filter((item) => item !== frameworkId));
const add = (frameworkId: string) => {
if (full || selected.includes(frameworkId)) return;
onChange([...selected, frameworkId]);
setQuery("");
};
return (
<Field>
<FieldLabel htmlFor={`${id}-search`}>
Enforce compliance frameworks
</FieldLabel>
{selected.length > 0 ? (
<ul className="flex flex-wrap gap-2" aria-label="Enforced frameworks">
{selected.map((frameworkId) => (
<li key={frameworkId}>
<Badge variant="tag" className="gap-1.5 py-1 pr-1">
{frameworkLabel(frameworkId, catalog)}
<button
type="button"
disabled={disabled}
aria-label={`Remove ${frameworkLabel(frameworkId, catalog)}`}
className="hover:bg-bg-neutral-tertiary rounded-full p-0.5 disabled:cursor-not-allowed disabled:opacity-50"
onClick={() => remove(frameworkId)}
>
<X aria-hidden="true" className="size-3" />
</button>
</Badge>
</li>
))}
</ul>
) : (
<p className="text-text-neutral-secondary text-sm">
No framework selected: every check is in scope.
</p>
)}
<Input
id={`${id}-search`}
type="search"
value={query}
disabled={disabled || full}
placeholder={
full
? `Limit reached (${REALTIME_MAX_FRAMEWORKS})`
: "Search frameworks, for example SOC 2"
}
aria-describedby={`${id}-hint`}
onChange={(event) => setQuery(event.target.value)}
/>
{!full && !disabled && (needle || catalog.length > 0) && (
<ul
aria-label="Available frameworks"
className="border-border-neutral-secondary max-h-56 overflow-y-auto rounded-lg border"
>
{matches.length === 0 ? (
<li className="text-text-neutral-tertiary px-3 py-2 text-sm">
{catalog.length === 0
? "The framework list is not available right now."
: "No framework matches your search."}
</li>
) : (
matches.map((framework) => (
<li key={framework.id}>
<button
type="button"
className="hover:bg-bg-neutral-secondary text-text-neutral-primary w-full px-3 py-2 text-left text-sm"
onClick={() => add(framework.id)}
>
{optionLabel(framework)}
</button>
</li>
))
)}
</ul>
)}
<p
id={`${id}-hint`}
className="text-text-neutral-tertiary max-w-prose text-xs"
>
Choose up to {REALTIME_MAX_FRAMEWORKS}. When frameworks are selected,
remediation (account fixes and pull requests) acts only on checks that
belong to them, for new events and for drift already present in the
account (checked about every 10 minutes). Everything else is only
detected and alerted. Leave empty to cover every check.
</p>
{error && <FieldError>{error}</FieldError>}
</Field>
);
}
@@ -0,0 +1,234 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const { getTargets, saveNotifications, toast } = vi.hoisted(() => ({
getTargets: vi.fn(),
saveNotifications: vi.fn(),
toast: vi.fn(),
}));
vi.mock("@/actions/realtime", () => ({
getRealtimeNotificationTargets: getTargets,
saveRealtimeNotifications: saveNotifications,
}));
vi.mock("@/components/shadcn/toast", () => ({
useToast: () => ({ toast }),
}));
import type { RealtimePolicy } from "@/types/realtime";
import { AlertsSection } from "./notifications-section";
const PROVIDER = "0b8c2b46-7f3a-4d1e-9a52-3f5f1d6a7c10";
const policy = {
id: "pol-1",
provider_id: PROVIDER,
notifications: {
min_severity: "high",
slack: { enabled: false, channel_ids: [] },
email: { enabled: false, recipient_ids: [] },
},
} as unknown as RealtimePolicy;
const TARGETS = {
slack: [
{ integration_id: "i1", channel_id: "C1", name: "security" },
{ integration_id: "i1", channel_id: "C2", name: "ops" },
],
email: [
{ id: "r1", email: "ana@example.com", status: "confirmed" },
{ id: "r2", email: "bob@example.com", status: "pending" },
{ id: "r3", email: "cy@example.com", status: "bounced" },
],
};
beforeAll(() => {
Object.defineProperty(HTMLElement.prototype, "hasPointerCapture", {
configurable: true,
value: () => false,
});
Object.defineProperty(HTMLElement.prototype, "releasePointerCapture", {
configurable: true,
value: () => {},
});
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
configurable: true,
value: () => {},
});
});
const renderSection = (
overrides: Partial<React.ComponentProps<typeof AlertsSection>> = {},
) =>
render(
<AlertsSection
providerId={PROVIDER}
policy={policy}
canManage
onSaved={vi.fn()}
{...overrides}
/>,
);
describe("AlertsSection", () => {
beforeEach(() => {
vi.clearAllMocks();
getTargets.mockResolvedValue({ ok: true, data: TARGETS });
saveNotifications.mockResolvedValue({ ok: true, data: policy });
});
it("explains when alerts go out and that nothing is sent until a channel is on", async () => {
renderSection();
expect(await screen.findByText("When an alert goes out")).toBeVisible();
expect(screen.getByText(/confirmed violation/)).toBeVisible();
expect(screen.getByText(/result of a verified fix/)).toBeVisible();
expect(
screen.getByText(/Nothing is sent until you turn a channel on/),
).toBeVisible();
expect(screen.getByRole("switch", { name: /slack/i })).not.toBeChecked();
expect(screen.getByRole("switch", { name: /email/i })).not.toBeChecked();
});
it("links to where destinations are created", async () => {
renderSection();
await screen.findByText("When an alert goes out");
expect(
screen.getAllByRole("link", { name: "Integrations" })[0],
).toHaveAttribute("href", "/integrations");
expect(screen.getAllByRole("link", { name: "Alerts" })[0]).toHaveAttribute(
"href",
"/alerts",
);
});
it("offers the channels once Slack is on and saves the selection with the severity", async () => {
const user = userEvent.setup();
const onSaved = vi.fn();
renderSection({ onSaved });
await user.click(await screen.findByRole("switch", { name: /slack/i }));
await user.click(screen.getByRole("checkbox", { name: "#security" }));
await user.click(screen.getByRole("button", { name: "Save alerts" }));
await waitFor(() => expect(saveNotifications).toHaveBeenCalled());
expect(saveNotifications).toHaveBeenCalledWith({
providerId: PROVIDER,
policyId: "pol-1",
notifications: {
min_severity: "high",
slack: { enabled: true, channel_ids: ["C1"] },
email: { enabled: false, recipient_ids: [] },
},
});
await waitFor(() => expect(onSaved).toHaveBeenCalledWith(policy));
});
it("lets only confirmed recipients be selected and shows the others disabled with their status", async () => {
const user = userEvent.setup();
renderSection();
await user.click(await screen.findByRole("switch", { name: /email/i }));
expect(
screen.getByRole("checkbox", { name: "ana@example.com" }),
).toBeEnabled();
expect(
screen.getByRole("checkbox", { name: "bob@example.com" }),
).toBeDisabled();
expect(
screen.getByRole("checkbox", { name: "cy@example.com" }),
).toBeDisabled();
expect(screen.getByText("Pending")).toBeVisible();
expect(screen.getByText("Bounced")).toBeVisible();
});
it("refuses a turned-on channel with nothing selected, without calling the API", async () => {
const user = userEvent.setup();
renderSection();
await user.click(await screen.findByRole("switch", { name: /slack/i }));
await user.click(screen.getByRole("button", { name: "Save alerts" }));
expect(
await screen.findByText(/Select at least one Slack channel/),
).toBeVisible();
expect(saveNotifications).not.toHaveBeenCalled();
});
it("shows the API's message when a destination is rejected", async () => {
const user = userEvent.setup();
saveNotifications.mockResolvedValue({
ok: false,
status: 400,
error: "Recipient r1 is not confirmed.",
fieldErrors: { notifications: "Recipient r1 is not confirmed." },
});
renderSection();
await user.click(await screen.findByRole("switch", { name: /email/i }));
await user.click(screen.getByRole("checkbox", { name: "ana@example.com" }));
await user.click(screen.getByRole("button", { name: "Save alerts" }));
expect(
await screen.findByText("Recipient r1 is not confirmed."),
).toBeVisible();
expect(toast).toHaveBeenCalledWith(
expect.objectContaining({ title: "Could not save the alerts" }),
);
});
it("points at Integrations and Alerts when there is nowhere to send to", async () => {
const user = userEvent.setup();
getTargets.mockResolvedValue({ ok: true, data: { slack: [], email: [] } });
renderSection();
await user.click(await screen.findByRole("switch", { name: /slack/i }));
expect(screen.getByText(/No Slack channel is available/)).toBeVisible();
await user.click(screen.getByRole("switch", { name: /email/i }));
expect(screen.getByText(/No recipient exists yet/)).toBeVisible();
});
it("keeps a selection whose destination vanished removable", async () => {
renderSection({
policy: {
...policy,
notifications: {
...policy.notifications,
slack: { enabled: true, channel_ids: ["C-gone"] },
},
},
});
expect(
await screen.findByRole("checkbox", { name: "C-gone" }),
).toBeChecked();
expect(screen.getByText("No longer available")).toBeVisible();
});
it("is read only without the permission", async () => {
renderSection({ canManage: false });
expect(
await screen.findByRole("switch", { name: /slack/i }),
).toBeDisabled();
expect(
screen.queryByRole("button", { name: "Save alerts" }),
).not.toBeInTheDocument();
expect(
screen.getByText(/needs the Manage Realtime permission/),
).toBeVisible();
});
it("explains a 503 or 403 loading the destinations", async () => {
getTargets.mockResolvedValue({ ok: false, status: 403, error: "no" });
renderSection();
expect(
await screen.findByText("You do not have access to this"),
).toBeVisible();
});
});
Loaded 100 of 170 files, more files were not shown because too many files have changed in this diff. Show more