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
112 changed files with 15642 additions and 5 deletions

No files matched your search

@@ -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*"
}
+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,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,
};
@@ -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");
@@ -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();
@@ -175,6 +179,21 @@ export function getNavigationConfig({
} 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",
@@ -269,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();
});
});
@@ -0,0 +1,459 @@
"use client";
import Link from "next/link";
import { useId, useState } from "react";
import {
getRealtimeNotificationTargets,
saveRealtimeNotifications,
} from "@/actions/realtime";
import { RealtimeSection } from "@/components/realtime/realtime-stat-card";
import {
PanelFailure,
PanelLoading,
} from "@/components/realtime/realtime-states";
import { ToggleRow } from "@/components/realtime/toggle-row";
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 { Checkbox } from "@/components/shadcn/checkbox";
import { Field, FieldError, FieldLabel } from "@/components/shadcn/field/field";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/shadcn/select/select";
import { useToast } from "@/components/shadcn/toast";
import { humanize } from "@/lib/realtime/display";
import {
ALERTS_PATH,
isSelectableRecipient,
recipientStatusLabel,
SLACK_INTEGRATIONS_PATH,
toggleId,
} from "@/lib/realtime/notifications";
import {
DEFAULT_REALTIME_NOTIFICATIONS,
realtimeNotificationsSchema,
} from "@/lib/realtime/policy-schema";
import {
REALTIME_MIN_SEVERITY,
type RealtimeNotifications,
type RealtimeNotificationTargets,
type RealtimePolicy,
} from "@/types/realtime";
interface AlertsSectionProps {
providerId: string;
policy: RealtimePolicy | null;
canManage: boolean;
onSaved: (policy: RealtimePolicy) => void;
}
const link = "text-button-tertiary underline underline-offset-4";
function ChoiceRow({
id,
checked,
disabled,
onChange,
children,
hint,
}: {
id: string;
checked: boolean;
disabled: boolean;
onChange: (checked: boolean) => void;
children: string;
hint?: string;
}) {
return (
<li className="flex items-center gap-2">
<Checkbox
id={id}
size="sm"
checked={checked}
disabled={disabled}
onCheckedChange={(value) => onChange(value === true)}
/>
<label
htmlFor={id}
className={
disabled && !checked
? "text-text-neutral-tertiary text-sm"
: "text-text-neutral-primary text-sm"
}
>
{children}
</label>
{hint && (
<Badge variant="tag" size="sm">
{hint}
</Badge>
)}
</li>
);
}
function SlackChannels({
id,
targets,
selected,
disabled,
onChange,
}: {
id: string;
targets: RealtimeNotificationTargets;
selected: string[];
disabled: boolean;
onChange: (ids: string[]) => void;
}) {
const known = targets.slack.map((channel) => channel.channel_id);
const gone = selected.filter((channel) => !known.includes(channel));
return (
<fieldset className="flex flex-col gap-2 pl-14">
<legend className="text-text-neutral-secondary mb-1 text-xs font-medium">
Slack channels
</legend>
{targets.slack.length === 0 && gone.length === 0 ? (
<p className="text-text-neutral-tertiary text-sm">
No Slack channel is available. Connect Slack in{" "}
<Link href={SLACK_INTEGRATIONS_PATH} className={link}>
Integrations
</Link>{" "}
first.
</p>
) : (
<ul className="flex flex-col gap-1.5">
{targets.slack.map((channel) => (
<ChoiceRow
key={channel.channel_id}
id={`${id}-${channel.channel_id}`}
checked={selected.includes(channel.channel_id)}
disabled={disabled}
onChange={(on) =>
onChange(toggleId(selected, channel.channel_id, on))
}
>
{`#${channel.name.replace(/^#/, "")}`}
</ChoiceRow>
))}
{gone.map((channel) => (
<ChoiceRow
key={channel}
id={`${id}-${channel}`}
checked
disabled={disabled}
hint="No longer available"
onChange={(on) => onChange(toggleId(selected, channel, on))}
>
{channel}
</ChoiceRow>
))}
</ul>
)}
</fieldset>
);
}
function EmailRecipients({
id,
targets,
selected,
disabled,
onChange,
}: {
id: string;
targets: RealtimeNotificationTargets;
selected: string[];
disabled: boolean;
onChange: (ids: string[]) => void;
}) {
const known = targets.email.map((recipient) => recipient.id);
const gone = selected.filter((recipient) => !known.includes(recipient));
return (
<fieldset className="flex flex-col gap-2 pl-14">
<legend className="text-text-neutral-secondary mb-1 text-xs font-medium">
Email recipients
</legend>
{targets.email.length === 0 && gone.length === 0 ? (
<p className="text-text-neutral-tertiary text-sm">
No recipient exists yet. Add one in{" "}
<Link href={ALERTS_PATH} className={link}>
Alerts
</Link>
; they must confirm by email before they can receive anything.
</p>
) : (
<ul className="flex flex-col gap-1.5">
{targets.email.map((recipient) => {
const selectable = isSelectableRecipient(recipient);
return (
<ChoiceRow
key={recipient.id}
id={`${id}-${recipient.id}`}
checked={selected.includes(recipient.id)}
// Only a confirmed recipient can be added; one that stopped
// being confirmed can still be taken off.
disabled={
disabled || (!selectable && !selected.includes(recipient.id))
}
hint={
selectable
? undefined
: recipientStatusLabel(recipient.status)
}
onChange={(on) =>
onChange(toggleId(selected, recipient.id, on))
}
>
{recipient.email}
</ChoiceRow>
);
})}
{gone.map((recipient) => (
<ChoiceRow
key={recipient}
id={`${id}-${recipient}`}
checked
disabled={disabled}
hint="No longer available"
onChange={(on) => onChange(toggleId(selected, recipient, on))}
>
{recipient}
</ChoiceRow>
))}
</ul>
)}
</fieldset>
);
}
/** Where a validation message belongs: the Slack block, the email block or both. */
const fieldMessages = (notifications: RealtimeNotifications) => {
const result = realtimeNotificationsSchema.safeParse(notifications);
const messages: Record<string, string> = {};
if (!result.success) {
result.error.issues.forEach((issue) => {
const field = String(issue.path[0] ?? "notifications");
if (!messages[field]) messages[field] = issue.message;
});
}
return messages;
};
/**
* Alerts by Slack or email for what Prowler Realtime detects and does. Reused
* by the setup wizard and the settings page; it saves only `notifications`.
*/
export function AlertsSection({
providerId,
policy,
canManage,
onSaved,
}: AlertsSectionProps) {
const { toast } = useToast();
const id = useId();
const targets = useRealtimeResource(
() => getRealtimeNotificationTargets(),
"notification-targets",
);
const [draft, setDraft] = useState<RealtimeNotifications>(
policy?.notifications ?? DEFAULT_REALTIME_NOTIFICATIONS,
);
const [errors, setErrors] = useState<Record<string, string>>({});
const [saving, setSaving] = useState(false);
const disabled = !canManage || saving;
const patch = (changes: Partial<RealtimeNotifications>) => {
setDraft((current) => ({ ...current, ...changes }));
setErrors({});
};
const anyOn = draft.slack.enabled || draft.email.enabled;
// The badge reports what is saved, not the unsaved draft.
const savedOn = Boolean(
policy?.notifications &&
(policy.notifications.slack.enabled ||
policy.notifications.email.enabled),
);
const save = async () => {
const found = fieldMessages(draft);
if (Object.keys(found).length > 0) {
setErrors(found);
return;
}
setErrors({});
setSaving(true);
const result = await saveRealtimeNotifications({
providerId,
policyId: policy?.id,
notifications: draft,
});
setSaving(false);
if (result.ok) {
onSaved(result.data);
toast({ title: anyOn ? "Alerts saved" : "Alerts are off" });
} else {
// The API names unknown or unconfirmed destinations; show its words.
setErrors({
notifications:
result.fieldErrors?.notifications ??
Object.values(result.fieldErrors ?? {})[0] ??
result.error,
});
toast({
variant: "destructive",
title:
result.status === 403
? "You cannot change alerts"
: "Could not save the alerts",
description:
result.status === 403
? "Changing alerts needs the Manage Realtime permission."
: result.error,
});
}
};
return (
<RealtimeSection
title="Alerts"
description="Get told in Slack or by email when Prowler Realtime detects a risky change and acts on it."
action={
<Badge variant={savedOn ? "success" : "tag"}>
{savedOn ? "On" : "Off"}
</Badge>
}
>
<Alert variant="info">
<AlertTitle>When an alert goes out</AlertTitle>
<AlertDescription>
<p>
An alert is sent when a detection has a confirmed violation, with
what Prowler did about it: fixed it, proposed a change, or only
recorded it. Later, the result of a verified fix is sent too.
Compliant, out-of-scope and failed checks never alert, and nothing
below the minimum severity does. Nothing is sent until you turn a
channel on.
</p>
</AlertDescription>
</Alert>
{targets.loading ? (
<PanelLoading label="Loading alert destinations" />
) : targets.failure ? (
<PanelFailure
failure={targets.failure}
onRetry={targets.reload}
subject="the alert destinations"
/>
) : (
<div className="flex flex-col gap-5">
<ToggleRow
id={`${id}-slack`}
label="Send alerts to Slack"
description="Post to the channels you pick, through your Slack integration."
checked={draft.slack.enabled}
disabled={disabled}
onChange={(enabled) =>
patch({ slack: { ...draft.slack, enabled } })
}
/>
{draft.slack.enabled && targets.data && (
<SlackChannels
id={`${id}-slack-channel`}
targets={targets.data}
selected={draft.slack.channel_ids}
disabled={disabled}
onChange={(channel_ids) =>
patch({ slack: { ...draft.slack, channel_ids } })
}
/>
)}
{errors.slack && <FieldError>{errors.slack}</FieldError>}
<ToggleRow
id={`${id}-email`}
label="Send alerts by email"
description="Email the confirmed recipients you pick."
checked={draft.email.enabled}
disabled={disabled}
onChange={(enabled) =>
patch({ email: { ...draft.email, enabled } })
}
/>
{draft.email.enabled && targets.data && (
<EmailRecipients
id={`${id}-email-recipient`}
targets={targets.data}
selected={draft.email.recipient_ids}
disabled={disabled}
onChange={(recipient_ids) =>
patch({ email: { ...draft.email, recipient_ids } })
}
/>
)}
{errors.email && <FieldError>{errors.email}</FieldError>}
<Field className="max-w-xs">
<FieldLabel htmlFor={`${id}-severity`}>
Minimum severity to alert
</FieldLabel>
<Select
value={draft.min_severity}
disabled={disabled}
onValueChange={(value) =>
patch({
min_severity: value as RealtimeNotifications["min_severity"],
})
}
>
<SelectTrigger id={`${id}-severity`} size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.values(REALTIME_MIN_SEVERITY).map((severity) => (
<SelectItem key={severity} value={severity}>
{humanize(severity)} and above
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<p className="text-text-neutral-tertiary text-xs">
New destinations are created in{" "}
<Link href={SLACK_INTEGRATIONS_PATH} className={link}>
Integrations
</Link>{" "}
(Slack) and{" "}
<Link href={ALERTS_PATH} className={link}>
Alerts
</Link>{" "}
(email recipients, who must confirm by email first).
</p>
{errors.notifications && (
<FieldError>{errors.notifications}</FieldError>
)}
{canManage ? (
<div>
<Button disabled={saving} onClick={() => void save()}>
{saving ? "Saving…" : "Save alerts"}
</Button>
</div>
) : (
<p className="text-text-neutral-tertiary text-sm">
Read only: changing alerts needs the Manage Realtime permission.
</p>
)}
</div>
)}
</RealtimeSection>
);
}
@@ -0,0 +1,181 @@
import { render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { getInsight, getFrameworks } = vi.hoisted(() => ({
getInsight: vi.fn(),
getFrameworks: vi.fn(),
}));
vi.mock("@/actions/realtime", () => ({
getRealtimeInsight: getInsight,
getRealtimeFrameworks: getFrameworks,
}));
vi.mock("@/components/realtime/posture-timeline", () => ({
PostureTimeline: () => null,
}));
import type {
RealtimeOverviewPayload,
RealtimeSimulationPayload,
} from "@/types/realtime";
import { OverviewTab } from "./overview-tab";
const PROVIDER = "0b8c2b46-7f3a-4d1e-9a52-3f5f1d6a7c10";
const overview = (
cost: RealtimeOverviewPayload["cost"],
policy: RealtimeOverviewPayload["policy"] = {
enabled: true,
mode: "pr",
dry_run: true,
scope_tags: {},
},
): RealtimeOverviewPayload => ({
provider: { id: PROVIDER, uid: "123456789012", alias: null },
policy,
feed: null,
data_store: "ok",
events_last_hour: { events: 10, changes: 2 },
cost,
posture: {
passed: 8,
failed: 2,
muted: 0,
score: 80,
failed_by_severity: {},
has_scan: true,
},
detections: {},
actions: {},
});
const simulation: RealtimeSimulationPayload = {
open: 4,
would_fix_in_account: 0,
would_propose_in_code: 1,
needs_a_person: 1,
outside_frameworks: 2,
frameworks: ["soc2_aws"],
dry_run: true,
mode: "pr",
effects: [],
projected_open_after_auto: 4,
automation_share: 50,
};
const respond = (cost: RealtimeOverviewPayload["cost"], policy?: never) => {
getInsight.mockImplementation(async (kind: string) => {
if (kind === "overview") return { ok: true, data: overview(cost, policy) };
if (kind === "simulation") return { ok: true, data: simulation };
return { ok: true, data: { points: [], actions: [] } };
});
};
describe("OverviewTab cost", () => {
beforeEach(() => {
vi.clearAllMocks();
getFrameworks.mockResolvedValue({
ok: true,
data: [{ id: "soc2_aws", name: "SOC 2", version: "2017" }],
});
});
it("shows no cost card, only a hint to enable it, while cost anomalies are off", async () => {
respond({ enabled: false });
render(<OverviewTab providerId={PROVIDER} />);
const hint = await screen.findByRole("link", {
name: "enable them in Configuration",
});
expect(hint).toHaveAttribute(
"href",
`/realtime/settings/cost-anomalies?provider=${PROVIDER}`,
);
expect(screen.getByText(/Cost anomalies are off/)).toBeVisible();
expect(screen.queryByText("Cost today")).not.toBeInTheDocument();
});
it("shows the cost card and links every anomaly to its detail", async () => {
respond({
enabled: true,
available: true,
total: 300,
today: 42.5,
anomaly_count: 2,
anomalies: [
{
id: "Amazon Elastic Compute Cloud - Compute|eu-west-1",
service: "Amazon Elastic Compute Cloud - Compute",
region: "eu-west-1",
short_service: "ec2",
today: 120,
base: 20,
delta: 100,
ratio: 6,
},
{
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(<OverviewTab providerId={PROVIDER} />);
expect(await screen.findByText("Cost today")).toBeVisible();
expect(screen.getByText("$42.50")).toBeVisible();
expect(screen.getByText("2 cost anomalies")).toBeVisible();
expect(
screen.getByRole("link", { name: "ec2 (eu-west-1)" }),
).toHaveAttribute(
"href",
`/realtime/cost-anomalies/${encodeURIComponent(
"Amazon Elastic Compute Cloud - Compute|eu-west-1",
)}?provider=${PROVIDER}`,
);
expect(
screen.getByRole("link", { name: "s3 (us-east-1)" }),
).toHaveAttribute(
"href",
`/realtime/cost-anomalies/Amazon%20S3%7Cus-east-1?provider=${PROVIDER}`,
);
expect(
screen.queryByText(/Cost anomalies are off/),
).not.toBeInTheDocument();
});
it("says there is no cost data yet when enabled but not available", async () => {
respond({
enabled: true,
available: false,
anomaly_count: 0,
anomalies: [],
});
render(<OverviewTab providerId={PROVIDER} />);
expect(await screen.findByText(/No cost data yet/)).toBeVisible();
});
it("shows the enforced frameworks as badges, and links to the settings", async () => {
respond({ enabled: false });
render(<OverviewTab providerId={PROVIDER} />);
const list = await screen.findByRole("list", {
name: "Enforced frameworks",
});
expect(list).toHaveTextContent("SOC 2");
expect(screen.getByRole("link", { name: "Configure" })).toHaveAttribute(
"href",
`/realtime/settings?provider=${PROVIDER}`,
);
expect(
await screen.findByText(/outside the enforced frameworks/),
).toBeVisible();
});
});
+370
View File
@@ -0,0 +1,370 @@
"use client";
import Link from "next/link";
import { getRealtimeFrameworks } from "@/actions/realtime";
import { FrameworkBadges } from "@/components/realtime/framework-badges";
import { PostureTimeline } from "@/components/realtime/posture-timeline";
import {
DataStoreBadge,
FeedStatusBadge,
} from "@/components/realtime/realtime-badges";
import { RealtimeSeverity } from "@/components/realtime/realtime-severity";
import {
CountList,
StatFigure,
RealtimeSection,
RealtimeStatCard,
} from "@/components/realtime/realtime-stat-card";
import {
PanelFailure,
PanelLoading,
} from "@/components/realtime/realtime-states";
import { SimulationCard } from "@/components/realtime/simulation-card";
import { useRealtimeInsight } from "@/components/realtime/use-realtime-insight";
import { useRealtimeResource } from "@/components/realtime/use-realtime-resource";
import { Badge } from "@/components/shadcn/badge/badge";
import { Button } from "@/components/shadcn/button/button";
import {
actionStatusBadge,
anomalyHref,
detectionStatusBadge,
formatMoney,
formatRelativeTime,
modeLabel,
orderedSeverities,
REALTIME_COST_SETTINGS_PATH,
settingsHref,
} from "@/lib/realtime/display";
import {
REALTIME_DATA_STORE,
REALTIME_FEED_SOURCE,
REALTIME_INSIGHT_KIND,
REALTIME_MODE,
type RealtimeOverviewPayload,
} from "@/types/realtime";
const REFRESH_MS = 30_000;
interface OverviewTabProps {
providerId: string;
/** Show the sample data instead of the account's. */
demo?: boolean;
}
function PolicyBadges({
policy,
providerId,
}: {
policy: RealtimeOverviewPayload["policy"];
providerId: string;
}) {
if (!policy) {
return (
<div className="flex flex-wrap items-center gap-2">
<Badge variant="tag">No policy yet</Badge>
<span className="text-text-neutral-tertiary text-sm">
Prowler Realtime only monitors until you save one.
</span>
<Button asChild variant="link" size="link-sm">
<Link href={settingsHref(providerId)}>Set up the policy</Link>
</Button>
</div>
);
}
const scoped = Object.keys(policy.scope_tags).length;
return (
<ul
className="flex flex-wrap items-center gap-2"
aria-label="Policy settings"
>
<li>
<Badge variant={policy.enabled ? "success" : "tag"}>
{policy.enabled ? "Enabled" : "Disabled"}
</Badge>
</li>
<li>
<Badge variant="outline">Mode: {modeLabel(policy.mode)}</Badge>
</li>
{policy.mode === REALTIME_MODE.AUTO && (
<li>
<Badge variant={policy.dry_run ? "info" : "warning"}>
{policy.dry_run ? "Dry run" : "Live: changes the account"}
</Badge>
</li>
)}
<li>
<Badge variant="outline">
{scoped
? `Scope: ${scoped} tag${scoped === 1 ? "" : "s"}`
: "Scope: whole account"}
</Badge>
</li>
<li>
<Button asChild variant="link" size="link-sm">
<Link href={settingsHref(providerId)}>Configure</Link>
</Button>
</li>
</ul>
);
}
function OverviewFigures({
overview,
providerId,
demo,
}: {
overview: RealtimeOverviewPayload;
providerId: string;
demo: boolean;
}) {
const { posture, feed, events_last_hour: events, cost } = overview;
const failing = orderedSeverities(posture.failed_by_severity);
const dataStoreReady = overview.data_store === REALTIME_DATA_STORE.OK;
return (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
<RealtimeStatCard title="Posture score">
{posture.has_scan && posture.score !== null ? (
<StatFigure
value={`${posture.score}%`}
caption={`${posture.passed} passing, ${posture.failed} failing`}
/>
) : (
<p className="text-text-neutral-tertiary text-sm">
No scan has finished for this account yet.
</p>
)}
{failing.length > 0 && (
<ul className="flex flex-col gap-1" aria-label="Failing by severity">
{failing.map(([severity, count]) => (
<li
key={severity}
className="flex items-center justify-between gap-2"
>
<RealtimeSeverity severity={severity} />
<span className="text-text-neutral-primary text-sm font-medium tabular-nums">
{count}
</span>
</li>
))}
</ul>
)}
</RealtimeStatCard>
<RealtimeStatCard
title="Event feed"
adornment={feed ? <FeedStatusBadge status={feed.status} /> : undefined}
>
{feed ? (
<>
<p className="text-text-neutral-primary text-sm">
{feed.source === REALTIME_FEED_SOURCE.SQS
? "Real time, through your SQS queue"
: "CloudTrail lookup, a few minutes behind"}
</p>
<p className="text-text-neutral-tertiary text-xs">
Last event {formatRelativeTime(feed.last_event_at)}
</p>
{feed.last_error && (
<p className="text-text-error-primary text-xs">
{feed.last_error}
</p>
)}
</>
) : (
<p className="text-text-neutral-tertiary text-sm">
No feed is configured yet. Set one up in the Live stream tab.
</p>
)}
</RealtimeStatCard>
<RealtimeStatCard
title="Events, last hour"
adornment={<DataStoreBadge state={overview.data_store} />}
>
{dataStoreReady && events ? (
<StatFigure
value={events.events}
caption={`${events.changes} change${events.changes === 1 ? "" : "s"} (writes)`}
/>
) : (
<p className="text-text-neutral-tertiary text-sm">
{overview.data_store === REALTIME_DATA_STORE.NOT_CONFIGURED
? "Real-time data store not configured: the event stream and cost views are off."
: "The event data store could not be reached."}
</p>
)}
</RealtimeStatCard>
{cost?.enabled && (
<RealtimeStatCard title="Cost today">
{cost.available ? (
<>
<StatFigure
value={formatMoney(cost.today)}
caption={`${formatMoney(cost.total)} over 30 days`}
/>
<Badge variant={cost.anomaly_count > 0 ? "warning" : "success"}>
{cost.anomaly_count > 0
? `${cost.anomaly_count} cost anomal${cost.anomaly_count === 1 ? "y" : "ies"}`
: "No cost anomalies"}
</Badge>
</>
) : (
<p className="text-text-neutral-tertiary text-sm">
No cost data yet. It needs the real-time data store and a first
Cost Explorer fetch.
</p>
)}
{cost.anomalies.length > 0 && (
<ul className="flex flex-col gap-1" aria-label="Cost anomalies">
{cost.anomalies.map((anomaly) => (
<li
key={anomaly.id}
className="flex items-center justify-between gap-2 text-sm"
>
<Link
href={anomalyHref(anomaly.id, providerId, demo)}
className="text-button-tertiary truncate underline underline-offset-4"
title={`${anomaly.service} in ${anomaly.region}`}
>
{anomaly.short_service || anomaly.service} ({anomaly.region}
)
</Link>
<span className="text-text-neutral-secondary tabular-nums">
{anomaly.ratio}x
</span>
</li>
))}
</ul>
)}
</RealtimeStatCard>
)}
<RealtimeStatCard title="Detections by status">
<CountList
entries={Object.entries(overview.detections)}
empty="No risky changes detected yet."
format={(status) => detectionStatusBadge(status).label}
/>
</RealtimeStatCard>
<RealtimeStatCard title="Actions by status">
<CountList
entries={Object.entries(overview.actions)}
empty="Prowler Realtime has not acted yet."
format={(status) => actionStatusBadge(status).label}
/>
</RealtimeStatCard>
{!cost?.enabled && (
<p className="text-text-neutral-tertiary text-sm md:col-span-2 xl:col-span-3">
Cost anomalies are off &mdash;{" "}
<Link
href={settingsHref(providerId, REALTIME_COST_SETTINGS_PATH)}
className="text-button-tertiary underline underline-offset-4"
>
enable them in Configuration
</Link>
</p>
)}
</div>
);
}
export function OverviewTab({ providerId, demo = false }: OverviewTabProps) {
const overview = useRealtimeInsight(
REALTIME_INSIGHT_KIND.OVERVIEW,
providerId,
demo,
REFRESH_MS,
);
const simulation = useRealtimeInsight(
REALTIME_INSIGHT_KIND.SIMULATION,
providerId,
demo,
REFRESH_MS,
);
// Names for the enforced frameworks; ids stand in if the list is unavailable.
const frameworks = useRealtimeResource(
() => getRealtimeFrameworks(),
"frameworks",
);
const timeline = useRealtimeInsight(
REALTIME_INSIGHT_KIND.TIMELINE,
providerId,
demo,
REFRESH_MS,
);
return (
<div className="flex flex-col gap-8">
<RealtimeSection title="Overview">
{overview.loading ? (
<PanelLoading label="Loading the overview" />
) : overview.failure ? (
<PanelFailure
failure={overview.failure}
onRetry={overview.reload}
subject="the overview"
/>
) : overview.data ? (
<div className="flex flex-col gap-4">
<PolicyBadges
policy={overview.data.policy}
providerId={providerId}
/>
{simulation.data && (
<div className="flex flex-wrap items-center gap-2">
<span className="text-text-neutral-secondary text-xs font-medium">
Enforced frameworks
</span>
<FrameworkBadges
frameworks={simulation.data.frameworks}
catalog={frameworks.data}
/>
</div>
)}
<OverviewFigures
overview={overview.data}
providerId={providerId}
demo={demo}
/>
</div>
) : null}
</RealtimeSection>
<RealtimeSection title="Simulation">
{simulation.loading ? (
<PanelLoading label="Loading the simulation" />
) : simulation.failure ? (
<PanelFailure
failure={simulation.failure}
onRetry={simulation.reload}
subject="the simulation"
/>
) : simulation.data ? (
<SimulationCard simulation={simulation.data} />
) : null}
</RealtimeSection>
<RealtimeSection
title="Posture timeline"
description="Passing and failing checks after each scan. Dashed lines mark what Prowler Realtime did."
>
{timeline.loading ? (
<PanelLoading label="Loading the timeline" />
) : timeline.failure ? (
<PanelFailure
failure={timeline.failure}
onRetry={timeline.reload}
subject="the timeline"
/>
) : timeline.data ? (
<PostureTimeline timeline={timeline.data} />
) : null}
</RealtimeSection>
</div>
);
}
+448
View File
@@ -0,0 +1,448 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { savePolicy, toast } = vi.hoisted(() => ({
savePolicy: vi.fn(),
toast: vi.fn(),
}));
vi.mock("@/actions/realtime", () => ({ saveRealtimePolicy: savePolicy }));
vi.mock("@/components/shadcn/toast", () => ({
useToast: () => ({ toast }),
}));
import type { RealtimePolicy } from "@/types/realtime";
import { PolicyForm } from "./policy-form";
const PROVIDER = "0b8c2b46-7f3a-4d1e-9a52-3f5f1d6a7c10";
const savedPolicy: RealtimePolicy = {
id: "p1",
provider_id: PROVIDER,
enabled: false,
mode: "pr",
dry_run: true,
min_severity: "low",
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",
slack: { enabled: false, channel_ids: [] },
email: { enabled: false, recipient_ids: [] },
},
};
const CATALOG = [
{ id: "soc2_aws", name: "SOC 2", version: "2017" },
{ id: "pci_4.0_aws", name: "PCI DSS", version: "4.0" },
{ id: "cis_5.0_aws", name: "CIS Foundations", version: "5.0" },
{ id: "hipaa_aws", name: "HIPAA", version: "" },
{ id: "gdpr_aws", name: "GDPR", version: "" },
{ id: "nist_aws", name: "NIST 800-53", version: "r5" },
];
describe("PolicyForm", () => {
beforeEach(() => {
savePolicy.mockResolvedValue({ ok: true, data: savedPolicy });
});
it("starts from the safe defaults", () => {
render(
<PolicyForm
providerId={PROVIDER}
policy={null}
canManage
onSaved={vi.fn()}
/>,
);
expect(screen.getByRole("switch", { name: /dry run/i })).toBeChecked();
expect(
screen.getByRole("switch", { name: /enable prowler realtime/i }),
).not.toBeChecked();
expect(
screen.getByRole("radio", { name: /propose a change/i }),
).toBeChecked();
});
it("is read only without the permission", () => {
render(
<PolicyForm
providerId={PROVIDER}
policy={null}
canManage={false}
onSaved={vi.fn()}
/>,
);
expect(screen.getByText("Read only")).toBeInTheDocument();
expect(screen.getByRole("switch", { name: /dry run/i })).toBeDisabled();
expect(
screen.queryByRole("button", { name: /save policy/i }),
).not.toBeInTheDocument();
});
it("warns when dry run is turned off in auto mode", async () => {
const user = userEvent.setup();
render(
<PolicyForm
providerId={PROVIDER}
policy={null}
canManage
onSaved={vi.fn()}
/>,
);
await user.click(screen.getByRole("radio", { name: /fix automatically/i }));
expect(screen.queryByText("Dry run is off")).not.toBeInTheDocument();
await user.click(screen.getByRole("switch", { name: /dry run/i }));
expect(screen.getByText("Dry run is off")).toBeInTheDocument();
});
it("shows a validation error and does not call the API for a bad repository", async () => {
const user = userEvent.setup();
render(
<PolicyForm
providerId={PROVIDER}
policy={null}
canManage
onSaved={vi.fn()}
/>,
);
await user.type(screen.getByLabelText("GitHub repository"), "not-a-repo");
await user.click(screen.getByRole("button", { name: /save policy/i }));
expect(await screen.findByText('Use the "owner/repo" form.')).toBeVisible();
expect(savePolicy).not.toHaveBeenCalled();
});
it("limits the scope to ten tags", async () => {
const user = userEvent.setup();
render(
<PolicyForm
providerId={PROVIDER}
policy={null}
canManage
onSaved={vi.fn()}
/>,
);
const add = screen.getByRole("button", { name: "Add tag" });
for (let i = 0; i < 10; i += 1) await user.click(add);
expect(screen.getAllByLabelText(/^Tag key/)).toHaveLength(10);
expect(
screen.getByRole("button", { name: /limit of 10 tags reached/i }),
).toBeDisabled();
});
it("sends the scope, lists and settings it was given", async () => {
const user = userEvent.setup();
const onSaved = vi.fn();
render(
<PolicyForm
providerId={PROVIDER}
policy={null}
canManage
onSaved={onSaved}
/>,
);
await user.click(screen.getByRole("button", { name: "Add tag" }));
await user.type(screen.getByLabelText("Tag key 1"), "env");
await user.type(screen.getByLabelText("Tag value 1"), "prod");
await user.type(
screen.getByLabelText("Allowed regions"),
"eu-west-1, us-east-1",
);
await user.click(screen.getByRole("button", { name: /save policy/i }));
await waitFor(() => expect(savePolicy).toHaveBeenCalled());
expect(savePolicy.mock.calls[0][0]).toMatchObject({
providerId: PROVIDER,
policy: {
scope_tags: { env: "prod" },
allow_regions: ["eu-west-1", "us-east-1"],
mode: "pr",
dry_run: true,
},
});
await waitFor(() => expect(onSaved).toHaveBeenCalledWith(savedPolicy));
});
it("asks for confirmation before going live in the account", async () => {
const user = userEvent.setup();
render(
<PolicyForm
providerId={PROVIDER}
policy={null}
canManage
onSaved={vi.fn()}
/>,
);
await user.click(
screen.getByRole("switch", { name: /enable prowler realtime/i }),
);
await user.click(screen.getByRole("radio", { name: /fix automatically/i }));
await user.click(screen.getByRole("switch", { name: /dry run/i }));
await user.click(screen.getByRole("button", { name: /save policy/i }));
expect(
await screen.findByText("Let Prowler Realtime change your AWS account?"),
).toBeVisible();
expect(savePolicy).not.toHaveBeenCalled();
await user.click(
screen.getByRole("button", { name: /enable live fixes/i }),
);
await waitFor(() => expect(savePolicy).toHaveBeenCalledTimes(1));
expect(savePolicy.mock.calls[0][0].policy).toMatchObject({
mode: "auto",
dry_run: false,
enabled: true,
});
});
it("explains a 403 from the API", async () => {
savePolicy.mockResolvedValue({ ok: false, status: 403, error: "no" });
const user = userEvent.setup();
render(
<PolicyForm
providerId={PROVIDER}
policy={null}
canManage
onSaved={vi.fn()}
/>,
);
await user.click(screen.getByRole("button", { name: /save policy/i }));
await waitFor(() =>
expect(toast).toHaveBeenCalledWith(
expect.objectContaining({ title: "You cannot change the policy" }),
),
);
});
it("keeps the cost anomaly settings of a loaded policy when saving it", async () => {
const user = userEvent.setup();
render(
<PolicyForm
providerId={PROVIDER}
policy={{
...savedPolicy,
cost_anomaly_enabled: true,
cost_anomaly_min_ratio: 3.5,
cost_anomaly_min_delta: 12,
}}
canManage
onSaved={vi.fn()}
/>,
);
await user.click(screen.getByRole("button", { name: /save policy/i }));
await waitFor(() => expect(savePolicy).toHaveBeenCalled());
expect(savePolicy.mock.calls[0][0].policy).toMatchObject({
cost_anomaly_enabled: true,
cost_anomaly_min_ratio: 3.5,
cost_anomaly_min_delta: 12,
});
});
it("selects up to five frameworks with search and removes them with the chip", async () => {
const user = userEvent.setup();
render(
<PolicyForm
providerId={PROVIDER}
policy={null}
canManage
onSaved={vi.fn()}
frameworkCatalog={CATALOG}
/>,
);
const search = screen.getByLabelText("Enforce compliance frameworks");
await user.type(search, "soc");
await user.click(screen.getByRole("button", { name: /SOC 2/ }));
expect(
screen.getByRole("list", { name: "Enforced frameworks" }),
).toHaveTextContent("SOC 2");
await user.click(screen.getByRole("button", { name: /PCI DSS/ }));
await user.click(screen.getByRole("button", { name: /CIS Foundations/ }));
await user.click(screen.getByRole("button", { name: /^HIPAA/ }));
await user.click(screen.getByRole("button", { name: /GDPR/ }));
// Five chosen: the search is closed and the limit is stated.
expect(search).toBeDisabled();
expect(
screen.queryByRole("list", { name: "Available frameworks" }),
).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Remove GDPR" }));
expect(search).toBeEnabled();
await user.click(screen.getByRole("button", { name: /save policy/i }));
await waitFor(() => expect(savePolicy).toHaveBeenCalled());
expect(savePolicy.mock.calls[0][0].policy.frameworks).toEqual([
"soc2_aws",
"pci_4.0_aws",
"cis_5.0_aws",
"hipaa_aws",
]);
});
it("explains what enforcing frameworks does, and that empty means every check", () => {
render(
<PolicyForm
providerId={PROVIDER}
policy={null}
canManage
onSaved={vi.fn()}
frameworkCatalog={CATALOG}
/>,
);
expect(
screen.getByText(/acts only on checks that belong to them/),
).toBeVisible();
expect(screen.getByText(/about every 10 minutes/)).toBeVisible();
expect(
screen.getByText(/No framework selected: every check is in scope/),
).toBeVisible();
});
it("shows a saved framework as a chip even when the catalog is missing", () => {
render(
<PolicyForm
providerId={PROVIDER}
policy={{ ...savedPolicy, frameworks: ["soc2_aws"] }}
canManage
onSaved={vi.fn()}
/>,
);
expect(
screen.getByRole("button", { name: "Remove soc2_aws" }),
).toBeVisible();
});
it("switches Lighthouse AI code fixes, links to its settings and warns without a configuration", async () => {
const user = userEvent.setup();
const { rerender } = render(
<PolicyForm
providerId={PROVIDER}
policy={null}
canManage
onSaved={vi.fn()}
lighthouseConfigured={false}
/>,
);
const toggle = screen.getByRole("switch", {
name: /let lighthouse ai write the code change/i,
});
expect(toggle).toBeChecked();
expect(
screen.getByText("No Lighthouse AI configuration connected"),
).toBeVisible();
expect(
screen.getByRole("link", { name: /lighthouse ai settings/i }),
).toHaveAttribute("href", "/lighthouse/settings");
expect(screen.getByText(/sent to that LLM provider/)).toBeVisible();
rerender(
<PolicyForm
providerId={PROVIDER}
policy={null}
canManage
onSaved={vi.fn()}
lighthouseConfigured
/>,
);
expect(
screen.queryByText("No Lighthouse AI configuration connected"),
).not.toBeInTheDocument();
await user.click(toggle);
await user.click(screen.getByRole("button", { name: /save policy/i }));
await waitFor(() => expect(savePolicy).toHaveBeenCalled());
expect(savePolicy.mock.calls[0][0].policy.use_lighthouse).toBe(false);
});
it("carries the alert settings unchanged in the whole-policy save", async () => {
const user = userEvent.setup();
const notifications = {
min_severity: "critical" as const,
slack: { enabled: true, channel_ids: ["C1"] },
email: { enabled: true, recipient_ids: ["r1"] },
};
render(
<PolicyForm
providerId={PROVIDER}
policy={{ ...savedPolicy, notifications }}
canManage
onSaved={vi.fn()}
/>,
);
await user.click(screen.getByRole("button", { name: /save policy/i }));
await waitFor(() => expect(savePolicy).toHaveBeenCalled());
expect(savePolicy.mock.calls[0][0].policy.notifications).toEqual(
notifications,
);
});
it("sends what is stored now for the alerts and cost, not what it loaded", async () => {
const user = userEvent.setup();
const { rerender } = render(
<PolicyForm
providerId={PROVIDER}
policy={savedPolicy}
canManage
onSaved={vi.fn()}
/>,
);
const newer = {
...savedPolicy,
cost_anomaly_enabled: true,
notifications: {
min_severity: "low" as const,
slack: { enabled: true, channel_ids: ["C9"] },
email: { enabled: false, recipient_ids: [] },
},
};
// Another section saved meanwhile and the page handed the form the result.
rerender(
<PolicyForm
providerId={PROVIDER}
policy={newer}
canManage
onSaved={vi.fn()}
/>,
);
await user.click(screen.getByRole("button", { name: /save policy/i }));
await waitFor(() => expect(savePolicy).toHaveBeenCalled());
expect(savePolicy.mock.calls[0][0].policy).toMatchObject({
cost_anomaly_enabled: true,
notifications: newer.notifications,
});
});
});
+481
View File
@@ -0,0 +1,481 @@
"use client";
import Link from "next/link";
import { useId, useState } from "react";
import { saveRealtimePolicy } from "@/actions/realtime";
import { FrameworkPicker } from "@/components/realtime/framework-picker";
import { RealtimeSection } from "@/components/realtime/realtime-stat-card";
import { ScopeTagsEditor } from "@/components/realtime/scope-tags-editor";
import { ToggleRow } from "@/components/realtime/toggle-row";
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 {
RadioGroup,
RadioGroupItem,
} from "@/components/shadcn/radio-group/radio-group";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/shadcn/select/select";
import { Textarea } from "@/components/shadcn/textarea/textarea";
import { useToast } from "@/components/shadcn/toast";
import {
humanize,
MODE_DESCRIPTIONS,
policyChangesAccount,
} from "@/lib/realtime/display";
import {
DEFAULT_REALTIME_POLICY,
newScopeRow,
policyFieldErrors,
type ScopeRow,
scopeRowsToTags,
splitList,
} from "@/lib/realtime/policy-schema";
import {
REALTIME_MIN_SEVERITY,
REALTIME_MODE,
type RealtimeFramework,
type RealtimeMode,
type RealtimePolicy,
type RealtimePolicyInput,
} from "@/types/realtime";
interface PolicyFormProps {
providerId: string;
policy: RealtimePolicy | null;
canManage: boolean;
onSaved: (policy: RealtimePolicy) => void;
/** The frameworks a policy can enforce; empty when the list could not be loaded. */
frameworkCatalog?: RealtimeFramework[];
/** Whether a connected Lighthouse AI configuration exists; undefined when unknown. */
lighthouseConfigured?: boolean;
}
const sectionsEditedElsewhere = (
policy: RealtimePolicy,
): Pick<
RealtimePolicyInput,
| "cost_anomaly_enabled"
| "cost_anomaly_min_ratio"
| "cost_anomaly_min_delta"
| "notifications"
> => ({
cost_anomaly_enabled: policy.cost_anomaly_enabled,
cost_anomaly_min_ratio: policy.cost_anomaly_min_ratio,
cost_anomaly_min_delta: policy.cost_anomaly_min_delta,
notifications: policy.notifications,
});
const toRows = (tags: Record<string, string>): ScopeRow[] =>
Object.entries(tags).map(([key, value]) => newScopeRow(key, value));
export function PolicyForm({
providerId,
policy,
canManage,
onSaved,
frameworkCatalog = [],
lighthouseConfigured,
}: PolicyFormProps) {
const { toast } = useToast();
const id = useId();
const initial: RealtimePolicyInput = policy ?? DEFAULT_REALTIME_POLICY;
const [draft, setDraft] = useState<RealtimePolicyInput>(initial);
const [denyText, setDenyText] = useState(initial.deny_checks.join("\n"));
const [regionsText, setRegionsText] = useState(
initial.allow_regions.join(", "),
);
const [rows, setRows] = useState<ScopeRow[]>(() =>
toRows(initial.scope_tags),
);
const [errors, setErrors] = useState<Record<string, string>>({});
const [saving, setSaving] = useState(false);
const [confirmLive, setConfirmLive] = useState(false);
const disabled = !canManage || saving;
const patch = (changes: Partial<RealtimePolicyInput>) =>
setDraft((current) => ({ ...current, ...changes }));
/** The policy the inputs describe, or the errors that stop it. */
const collect = ():
| { ok: true; policy: RealtimePolicyInput }
| { ok: false; errors: Record<string, string> } => {
const scope = scopeRowsToTags(rows);
const candidate: RealtimePolicyInput = {
...draft,
// The cost fields and the alerts are edited elsewhere (their own page and
// section) and this save sends the whole policy: carry what is stored
// now, not what this form loaded, so it never wipes a newer save.
...(policy ? sectionsEditedElsewhere(policy) : {}),
deny_checks: splitList(denyText),
allow_regions: splitList(regionsText),
scope_tags: scope.tags,
};
const found = policyFieldErrors(candidate);
if (scope.error) found.scope_tags = scope.error;
return Object.keys(found).length
? { ok: false, errors: found }
: { ok: true, policy: candidate };
};
const persist = async (candidate: RealtimePolicyInput) => {
setSaving(true);
const result = await saveRealtimePolicy({
providerId,
policyId: policy?.id,
policy: candidate,
}).finally(() => setSaving(false));
setConfirmLive(false);
if (result.ok) {
setErrors({});
onSaved(result.data);
toast({ title: "Policy saved" });
} else {
setErrors(result.fieldErrors ?? {});
toast({
variant: "destructive",
title:
result.status === 403
? "You cannot change the policy"
: "Could not save the policy",
description:
result.status === 403
? "Changing the policy needs the Manage Realtime permission."
: result.error,
});
}
};
const submit = () => {
const collected = collect();
if (!collected.ok) {
setErrors(collected.errors);
return;
}
setErrors({});
// Going live in the account is the one change that cannot be taken back
// by pressing "undo" in the form, so it asks first.
const wasLive = policy ? policyChangesAccount(policy) : false;
if (policyChangesAccount(collected.policy) && !wasLive) {
setConfirmLive(true);
return;
}
void persist(collected.policy);
};
const liveWarning = draft.mode === REALTIME_MODE.AUTO && !draft.dry_run;
return (
<form
className="flex flex-col gap-8"
onSubmit={(event) => {
event.preventDefault();
submit();
}}
>
{!canManage && (
<Alert variant="info">
<AlertTitle>Read only</AlertTitle>
<AlertDescription>
<p>
Your role can see the policy but not change it. Changing it needs
the Manage Realtime permission.
</p>
</AlertDescription>
</Alert>
)}
<RealtimeSection title="Response">
<ToggleRow
id={`${id}-enabled`}
label="Enable Prowler Realtime for this account"
description="While disabled, Prowler Realtime does not react to events. Nothing in the account or your code is touched."
checked={draft.enabled}
disabled={disabled}
onChange={(enabled) => patch({ enabled })}
/>
<fieldset className="flex flex-col gap-3">
<legend className="text-text-neutral-primary mb-1 text-sm font-medium">
Mode
</legend>
<RadioGroup
value={draft.mode}
disabled={disabled}
onValueChange={(mode) => patch({ mode: mode as RealtimeMode })}
>
{Object.values(REALTIME_MODE).map((mode) => (
<div key={mode} className="flex items-start gap-3">
<RadioGroupItem
id={`${id}-mode-${mode}`}
value={mode}
aria-describedby={`${id}-mode-${mode}-description`}
className="mt-0.5"
/>
<div className="flex flex-col gap-0.5">
<label
htmlFor={`${id}-mode-${mode}`}
className="text-text-neutral-primary text-sm font-medium"
>
{MODE_DESCRIPTIONS[mode].label}
</label>
<p
id={`${id}-mode-${mode}-description`}
className="text-text-neutral-tertiary max-w-prose text-xs"
>
{MODE_DESCRIPTIONS[mode].consequence}
</p>
</div>
</div>
))}
</RadioGroup>
</fieldset>
<ToggleRow
id={`${id}-dry-run`}
label="Dry run"
description="Record what Prowler Realtime would change in the account without changing it. Applies to Fix automatically."
checked={draft.dry_run}
disabled={disabled}
onChange={(dryRun) => patch({ dry_run: dryRun })}
/>
{liveWarning && (
<Alert variant="warning">
<AlertTitle>Dry run is off</AlertTitle>
<AlertDescription>
<p>
{draft.enabled
? "Prowler Realtime will change resources in your AWS account as soon as it detects a matching risky change. Each change records how to undo it."
: "Once the policy is enabled, Prowler Realtime will change resources in your AWS account as soon as it detects a matching risky change."}
</p>
</AlertDescription>
</Alert>
)}
{errors.dry_run && <FieldError>{errors.dry_run}</FieldError>}
{errors.mode && <FieldError>{errors.mode}</FieldError>}
</RealtimeSection>
<RealtimeSection title="What it acts on">
<Field className="max-w-xs">
<FieldLabel htmlFor={`${id}-severity`}>Minimum severity</FieldLabel>
<Select
value={draft.min_severity}
disabled={disabled}
onValueChange={(value) =>
patch({
min_severity: value as RealtimePolicyInput["min_severity"],
})
}
>
<SelectTrigger id={`${id}-severity`} size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.values(REALTIME_MIN_SEVERITY).map((severity) => (
<SelectItem key={severity} value={severity}>
{humanize(severity)} and above
</SelectItem>
))}
</SelectContent>
</Select>
{errors.min_severity && (
<FieldError>{errors.min_severity}</FieldError>
)}
</Field>
<Field>
<FieldLabel htmlFor={`${id}-deny`}>
Never fix automatically (check ids)
</FieldLabel>
<Textarea
id={`${id}-deny`}
value={denyText}
disabled={disabled}
rows={3}
font="mono"
placeholder="rds_instance_no_public_access"
aria-describedby={`${id}-deny-hint`}
onChange={(event) => setDenyText(event.target.value)}
/>
<p
id={`${id}-deny-hint`}
className="text-text-neutral-tertiary text-xs"
>
One check id per line or separated by commas. These fall back to a
pull request.
</p>
{errors.deny_checks && <FieldError>{errors.deny_checks}</FieldError>}
</Field>
<Field>
<FieldLabel htmlFor={`${id}-regions`}>Allowed regions</FieldLabel>
<Input
id={`${id}-regions`}
value={regionsText}
disabled={disabled}
placeholder="eu-west-1, us-east-1"
aria-describedby={`${id}-regions-hint`}
onChange={(event) => setRegionsText(event.target.value)}
/>
<p
id={`${id}-regions-hint`}
className="text-text-neutral-tertiary text-xs"
>
Prowler Realtime only acts in these regions. Empty means all
regions.
</p>
{errors.allow_regions && (
<FieldError>{errors.allow_regions}</FieldError>
)}
</Field>
<FrameworkPicker
catalog={frameworkCatalog}
selected={draft.frameworks}
disabled={disabled}
error={errors.frameworks}
onChange={(frameworks) => patch({ frameworks })}
/>
<ScopeTagsEditor
rows={rows}
disabled={disabled}
error={errors.scope_tags}
onChange={setRows}
/>
</RealtimeSection>
<RealtimeSection
title="Changes in code"
description="Where Prowler Realtime proposes fixes to your infrastructure code."
>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Field>
<FieldLabel htmlFor={`${id}-repo`}>GitHub repository</FieldLabel>
<Input
id={`${id}-repo`}
value={draft.iac_repo}
disabled={disabled}
placeholder="owner/repo"
aria-invalid={Boolean(errors.iac_repo)}
onChange={(event) => patch({ iac_repo: event.target.value })}
/>
{errors.iac_repo && <FieldError>{errors.iac_repo}</FieldError>}
</Field>
<Field>
<FieldLabel htmlFor={`${id}-path`}>Terraform directory</FieldLabel>
<Input
id={`${id}-path`}
value={draft.iac_path}
disabled={disabled}
placeholder="infra/terraform"
aria-invalid={Boolean(errors.iac_path)}
onChange={(event) => patch({ iac_path: event.target.value })}
/>
{errors.iac_path && <FieldError>{errors.iac_path}</FieldError>}
</Field>
</div>
<ToggleRow
id={`${id}-open-pr`}
label="Open pull requests"
description="Open the pull request for you. Off, Prowler Realtime only prepares the branch. Needs a GitHub token below."
checked={draft.open_pr}
disabled={disabled}
onChange={(openPr) => patch({ open_pr: openPr })}
/>
{errors.open_pr && <FieldError>{errors.open_pr}</FieldError>}
<div className="flex flex-col gap-2">
<ToggleRow
id={`${id}-lighthouse`}
label="Let Lighthouse AI write the code change"
description="For checks without a built-in patcher, Lighthouse AI writes the change using your configured LLM. The Terraform file and the finding are sent to that LLM provider. Pull requests are always reviewed by a person, and changes are checked by a security gate."
checked={draft.use_lighthouse}
disabled={disabled}
onChange={(useLighthouse) =>
patch({ use_lighthouse: useLighthouse })
}
/>
<div className="flex flex-wrap items-center gap-2 pl-14">
{draft.use_lighthouse && lighthouseConfigured === false && (
<Badge variant="warning">
No Lighthouse AI configuration connected
</Badge>
)}
<Link
href="/lighthouse/settings"
className="text-button-tertiary text-xs underline underline-offset-4"
>
Open Lighthouse AI settings
</Link>
</div>
{errors.use_lighthouse && (
<FieldError>{errors.use_lighthouse}</FieldError>
)}
</div>
</RealtimeSection>
{canManage && (
<div className="flex items-center gap-3">
<Button type="submit" disabled={saving}>
{saving ? "Saving…" : "Save policy"}
</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>
)}
<Modal
open={confirmLive}
onOpenChange={(open) => {
if (!open && !saving) setConfirmLive(false);
}}
title="Let Prowler Realtime change your AWS account?"
description="Mode is Fix automatically, dry run is off and the policy is enabled."
size="lg"
>
<div className="flex flex-col gap-4">
<p className="text-text-neutral-secondary text-sm">
Prowler Realtime will apply supported fixes in this account when it
detects a matching risky change, using the remediation role. Every
change records the exact call that undoes it, and you can revert
from the Remediation tab of the dashboard.
</p>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
disabled={saving}
onClick={() => setConfirmLive(false)}
>
Cancel
</Button>
<Button
type="button"
variant="destructive"
disabled={saving}
onClick={() => {
const collected = collect();
if (collected.ok) void persist(collected.policy);
}}
>
{saving ? "Saving…" : "Enable live fixes"}
</Button>
</div>
</div>
</Modal>
</form>
);
}
+167
View File
@@ -0,0 +1,167 @@
"use client";
import {
CartesianGrid,
Line,
LineChart,
ReferenceLine,
XAxis,
YAxis,
} from "recharts";
import { ActionStatusBadge } from "@/components/realtime/realtime-badges";
import { PanelEmpty } from "@/components/realtime/realtime-states";
import {
type ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@/components/shadcn/chart/Chart";
import { formatDateTime, humanize } from "@/lib/realtime/display";
import type { RealtimeTimelinePayload } from "@/types/realtime";
// Recharts props do not take className, so the design tokens go in by name.
const CHART_COLORS = {
passed: "var(--color-bg-pass)",
failed: "var(--color-bg-fail)",
marker: "var(--color-bg-data-info)",
} as const;
const CHART_CONFIG: ChartConfig = {
passed: { label: "Passing checks", color: CHART_COLORS.passed },
failed: { label: "Failing checks", color: CHART_COLORS.failed },
};
const MAX_LISTED_ACTIONS = 5;
const toTime = (value: string) => new Date(value).getTime();
const formatTick = (value: number) =>
new Date(value).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
/** Posture per scan, with a marker where Prowler Realtime acted. */
export function PostureTimeline({
timeline,
}: {
timeline: RealtimeTimelinePayload;
}) {
const points = timeline.points
.map((point) => ({ ...point, time: toTime(point.at) }))
.filter((point) => !Number.isNaN(point.time))
.sort((a, b) => a.time - b.time);
const actions = timeline.actions
.map((action) => ({ ...action, time: toTime(action.at) }))
.filter((action) => !Number.isNaN(action.time));
if (points.length === 0) {
return (
<div className="flex flex-col gap-3">
<PanelEmpty
title="No posture history yet"
description="The chart fills in after the first completed scan of this account."
/>
<ActionList actions={actions} title="Prowler Realtime actions" />
</div>
);
}
const times = [...points, ...actions].map((item) => item.time);
const domain: [number, number] = [Math.min(...times), Math.max(...times)];
const latest = points[points.length - 1];
return (
<div className="flex flex-col gap-3">
<p className="sr-only">
Posture over {points.length} scans. Latest scan: {latest.passed} passing
and {latest.failed} failing checks. Prowler Realtime took{" "}
{actions.length} recorded actions.
</p>
<ChartContainer
config={CHART_CONFIG}
className="aspect-auto h-64 w-full"
aria-hidden="true"
>
<LineChart data={points} margin={{ left: 4, right: 12, top: 8 }}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="time"
type="number"
scale="time"
domain={domain}
tickFormatter={formatTick}
tickLine={false}
axisLine={false}
/>
<YAxis tickLine={false} axisLine={false} width={40} />
<ChartTooltip
content={
<ChartTooltipContent
labelFormatter={(_, payload) =>
formatDateTime(payload?.[0]?.payload?.at)
}
/>
}
/>
<Line
dataKey="passed"
type="monotone"
stroke={CHART_COLORS.passed}
strokeWidth={2}
dot={false}
/>
<Line
dataKey="failed"
type="monotone"
stroke={CHART_COLORS.failed}
strokeWidth={2}
dot={false}
/>
{actions.map((action, index) => (
<ReferenceLine
key={`${action.at}-${index}`}
x={action.time}
stroke={CHART_COLORS.marker}
strokeDasharray="3 3"
/>
))}
</LineChart>
</ChartContainer>
<ActionList
actions={actions}
title="Prowler Realtime actions (dashed lines)"
/>
</div>
);
}
function ActionList({
actions,
title,
}: {
actions: RealtimeTimelinePayload["actions"];
title: string;
}) {
if (actions.length === 0) return null;
return (
<div className="flex flex-col gap-1">
<h4 className="text-text-neutral-primary text-sm font-medium">{title}</h4>
<ul className="flex flex-col gap-1">
{actions.slice(0, MAX_LISTED_ACTIONS).map((action, index) => (
<li
key={`${action.at}-${index}`}
className="text-text-neutral-secondary flex flex-wrap items-center gap-2 text-sm"
>
<span className="text-text-neutral-tertiary tabular-nums">
{formatDateTime(action.at)}
</span>
<ActionStatusBadge status={action.status} />
<span>{action.summary || humanize(action.kind)}</span>
</li>
))}
</ul>
</div>
);
}
@@ -0,0 +1,60 @@
"use client";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/shadcn/select/select";
import {
providerLabel,
type RealtimeProviderOption,
} from "@/lib/realtime/providers";
interface ProviderSelectProps {
providers: RealtimeProviderOption[];
value: string;
onChange: (providerId: string) => void;
}
/** The AWS account picker every Realtime page shares; `?provider=` keeps the choice. */
export function ProviderSelect({
providers,
value,
onChange,
}: ProviderSelectProps) {
return (
<div className="flex max-w-md min-w-64 flex-1 flex-col gap-1.5">
<label
htmlFor="realtime-provider"
className="text-text-neutral-tertiary text-xs font-medium"
>
AWS account
</label>
<Select value={value} onValueChange={onChange}>
<SelectTrigger id="realtime-provider" size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{providers.map((provider) => (
<SelectItem key={provider.id} value={provider.id}>
{providerLabel(provider)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
/** Write the selection to the URL without a navigation, so a view can be shared. */
export const syncProviderToUrl = (next: {
provider?: string;
tab?: string;
}) => {
const params = new URLSearchParams(window.location.search);
if (next.provider) params.set("provider", next.provider);
if (next.tab) params.set("tab", next.tab);
window.history.replaceState(null, "", `?${params.toString()}`);
};
@@ -0,0 +1,28 @@
import { Badge } from "@/components/shadcn/badge/badge";
import {
actionStatusBadge,
type BadgeDescriptor,
dataStoreBadge,
detectionStatusBadge,
feedStatusBadge,
routeBadge,
} from "@/lib/realtime/display";
const render = ({ label, tone }: BadgeDescriptor) => (
<Badge variant={tone}>{label}</Badge>
);
export const ActionStatusBadge = ({ status }: { status: string }) =>
render(actionStatusBadge(status));
export const DetectionStatusBadge = ({ status }: { status: string }) =>
render(detectionStatusBadge(status));
export const RouteBadge = ({ route }: { route: string }) =>
render(routeBadge(route));
export const FeedStatusBadge = ({ status }: { status: string }) =>
render(feedStatusBadge(status));
export const DataStoreBadge = ({ state }: { state: string }) =>
render(dataStoreBadge(state));
@@ -0,0 +1,54 @@
import { Radar } from "lucide-react";
import { notFound } from "next/navigation";
import type { ReactNode } from "react";
import { getAllProviders } from "@/actions/providers";
import { ContentLayout } from "@/components/shadcn/content-layout";
import { FeatureFlagGate } from "@/components/shared/feature-flag-gate";
import { FEATURE_FLAG } from "@/lib/feature-flags";
import type { RealtimeProviderOption } from "@/lib/realtime/providers";
import { isCloud } from "@/lib/shared/env";
export const REALTIME_PRODUCT_NAME =
"Prowler Realtime Detection and Remediation";
/**
* The frame every Realtime page shares: Cloud only (404 elsewhere), the
* per-account feature flag, and the account's AWS providers (the feature reads
* CloudTrail and fixes AWS resources, so nothing else is offered).
*/
export async function RealtimePageShell({
children,
}: {
// May be async: page lookups then run only after the Cloud gate below.
children: (
providers: RealtimeProviderOption[],
) => ReactNode | Promise<ReactNode>;
}) {
if (!isCloud()) notFound();
const providersData = await getAllProviders();
const providers = (providersData?.data ?? [])
.filter((provider) => provider.attributes.provider === "aws")
.map((provider) => ({
id: provider.id,
uid: provider.attributes.uid,
alias: provider.attributes.alias || null,
}));
return (
<ContentLayout title={REALTIME_PRODUCT_NAME} icon={<Radar />}>
{/* Deployment gate above (notFound); per-account rollout here. */}
<FeatureFlagGate
flag={FEATURE_FLAG.REALTIME}
fallback={
<p className="text-text-neutral-tertiary text-sm">
{REALTIME_PRODUCT_NAME} is not available for this account yet.
</p>
}
>
{await children(providers)}
</FeatureFlagGate>
</ContentLayout>
);
}
+47
View File
@@ -0,0 +1,47 @@
import { Button } from "@/components/shadcn/button/button";
interface RealtimePagerProps {
page: number;
pages: number;
count: number;
disabled?: boolean;
onPageChange: (page: number) => void;
}
export function RealtimePager({
page,
pages,
count,
disabled,
onPageChange,
}: RealtimePagerProps) {
if (pages <= 1) return null;
return (
<nav
aria-label="Pagination"
className="flex flex-wrap items-center justify-between gap-2"
>
<span className="text-text-neutral-tertiary text-sm">
Page {page} of {pages} ({count} total)
</span>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={disabled || page <= 1}
onClick={() => onPageChange(page - 1)}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
disabled={disabled || page >= pages}
onClick={() => onPageChange(page + 1)}
>
Next
</Button>
</div>
</nav>
);
}
@@ -0,0 +1,98 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
vi.mock("@/actions/realtime", () => ({}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ permissions: { manage_prowler_realtime: false } }),
}));
vi.mock("@/components/realtime/policy-form", () => ({
PolicyForm: ({
lighthouseConfigured,
frameworkCatalog,
}: {
lighthouseConfigured?: boolean;
frameworkCatalog?: unknown[];
}) => (
<div>
policy form, lighthouse {String(lighthouseConfigured)}, catalog{" "}
{frameworkCatalog?.length}
</div>
),
}));
vi.mock("@/components/realtime/notifications-section", () => ({
AlertsSection: ({ policy }: { policy: { id: string } | null }) => (
<div>alerts section for {policy?.id ?? "no policy"}</div>
),
}));
vi.mock("@/components/realtime/credentials-form", () => ({
CredentialsForm: () => <div>Credentials for remediation only</div>,
}));
vi.mock("@/components/realtime/feed-setup-panel", () => ({
FeedSetupPanel: () => <div>Event feed setup</div>,
}));
vi.mock("@/components/realtime/use-realtime-resource", () => ({
useRealtimeResource: (_fetcher: unknown, key: string) => ({
loading: false,
failure: undefined,
reload: vi.fn(),
setData: vi.fn(),
data:
key === "frameworks"
? [{ id: "soc2_aws", name: "SOC 2", version: "" }]
: key.startsWith("policy")
? { id: "pol", frameworks: ["soc2_aws"] }
: null,
}),
}));
import { RealtimeSettings } from "./realtime-settings";
const providers = [
{ id: "p1", uid: "111111111111", alias: "prod" },
{ id: "p2", uid: "222222222222", alias: null },
];
describe("RealtimeSettings", () => {
it("holds the policy, the alerts, the remediation credentials and the event feed", () => {
render(
<RealtimeSettings
providers={providers}
initialProviderId="p2"
lighthouseConfigured
/>,
);
expect(
screen.getByText(/policy form, lighthouse true, catalog 1/),
).toBeVisible();
expect(screen.getByText("alerts section for pol")).toBeVisible();
expect(screen.getByText("Credentials for remediation only")).toBeVisible();
expect(screen.getByText("Event feed setup")).toBeVisible();
expect(screen.getByText("Read only")).toBeVisible();
});
it("shows the enforced frameworks as badges", () => {
render(<RealtimeSettings providers={providers} />);
expect(
screen.getByRole("list", { name: "Enforced frameworks" }),
).toHaveTextContent("SOC 2");
});
it("links to the cost anomalies page and back to the dashboard for the account", () => {
render(<RealtimeSettings providers={providers} initialProviderId="p2" />);
expect(
screen.getByRole("link", { name: "Cost anomalies" }),
).toHaveAttribute("href", "/realtime/settings/cost-anomalies?provider=p2");
expect(
screen.getByRole("link", { name: "Back to the dashboard" }),
).toHaveAttribute("href", "/realtime?provider=p2");
});
it("asks for an AWS account first when there is none", () => {
render(<RealtimeSettings providers={[]} />);
expect(screen.getByText("Connect an AWS account first")).toBeVisible();
});
});
@@ -0,0 +1,208 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import {
getRealtimeCredential,
getRealtimeFrameworks,
getRealtimePolicy,
} from "@/actions/realtime";
import { CredentialsForm } from "@/components/realtime/credentials-form";
import { FeedSetupPanel } from "@/components/realtime/feed-setup-panel";
import { FrameworkBadges } from "@/components/realtime/framework-badges";
import { AlertsSection } from "@/components/realtime/notifications-section";
import { PolicyForm } from "@/components/realtime/policy-form";
import {
ProviderSelect,
syncProviderToUrl,
} from "@/components/realtime/provider-select";
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 { useAuth } from "@/hooks/use-auth";
import {
REALTIME_COST_SETTINGS_PATH,
settingsHref,
} from "@/lib/realtime/display";
import {
type RealtimeProviderOption,
resolveProviderId,
} from "@/lib/realtime/providers";
interface RealtimeSettingsProps {
providers: RealtimeProviderOption[];
initialProviderId?: string;
/** Whether a connected Lighthouse AI configuration exists; undefined when unknown. */
lighthouseConfigured?: boolean;
}
export function NoAwsProvider() {
return (
<PanelEmpty
title="Connect an AWS account first"
description="Prowler Realtime Detection and Remediation works on AWS accounts. Add an AWS provider and it will appear here."
>
<Link
href="/providers"
className="text-button-tertiary text-sm underline underline-offset-4"
>
Go to Providers
</Link>
</PanelEmpty>
);
}
/** Settings for one AWS account: policy, remediation credentials and the event feed. */
export function RealtimeSettings({
providers,
initialProviderId,
lighthouseConfigured,
}: RealtimeSettingsProps) {
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={settingsHref(providerId, REALTIME_COST_SETTINGS_PATH)}>
Cost anomalies
</Link>
</Button>
<Button asChild variant="outline" size="sm">
<Link href={`/realtime?provider=${encodeURIComponent(providerId)}`}>
Back to the dashboard
</Link>
</Button>
</div>
</div>
<SettingsBody
key={providerId}
providerId={providerId}
canManage={canManage}
lighthouseConfigured={lighthouseConfigured}
/>
</div>
);
}
function SettingsBody({
providerId,
canManage,
lighthouseConfigured,
}: {
providerId: string;
canManage: boolean;
lighthouseConfigured?: boolean;
}) {
const policy = useRealtimeResource(
() => getRealtimePolicy(providerId),
`policy:${providerId}`,
);
const credential = useRealtimeResource(
() => getRealtimeCredential(providerId),
`credential:${providerId}`,
);
const frameworks = useRealtimeResource(
() => getRealtimeFrameworks(),
"frameworks",
);
const catalog = frameworks.data ?? [];
return (
<div className="flex max-w-4xl flex-col gap-10">
<RealtimeSection
title="Policy"
description="How Prowler Realtime responds for this AWS account. The defaults are the safe ones: propose changes for review, change nothing in the account."
>
{policy.loading ? (
<PanelLoading label="Loading the policy" />
) : policy.failure ? (
<PanelFailure
failure={policy.failure}
onRetry={policy.reload}
subject="the policy"
/>
) : (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-2">
<span className="text-text-neutral-secondary text-xs font-medium">
Enforced compliance frameworks
</span>
{policy.data ? (
<FrameworkBadges
frameworks={policy.data.frameworks}
catalog={catalog}
/>
) : (
<Badge variant="outline">Not configured</Badge>
)}
</div>
<PolicyForm
// A saved policy gets a new id and remounts the form on first save.
key={policy.data?.id ?? "new"}
providerId={providerId}
policy={policy.data ?? null}
canManage={canManage}
onSaved={policy.setData}
frameworkCatalog={catalog}
lighthouseConfigured={lighthouseConfigured}
/>
</div>
)}
</RealtimeSection>
{!policy.loading && !policy.failure && (
<AlertsSection
key={policy.data?.id ?? "new"}
providerId={providerId}
policy={policy.data ?? null}
canManage={canManage}
onSaved={policy.setData}
/>
)}
{credential.loading ? (
<PanelLoading label="Loading credentials" />
) : credential.failure ? (
<PanelFailure
failure={credential.failure}
onRetry={credential.reload}
subject="the credentials"
/>
) : (
<CredentialsForm
providerId={providerId}
credential={credential.data ?? null}
canManage={canManage}
onChanged={(next) => credential.setData(next)}
/>
)}
<FeedSetupPanel providerId={providerId} canManage={canManage} />
</div>
);
}
@@ -0,0 +1,19 @@
import {
type Severity,
SeverityBadge,
SeverityValues,
} from "@/components/shadcn/table/severity-badge";
const KNOWN = Object.values(SeverityValues) as string[];
/** The shared severity chip for known severities; unknown values show as text. */
export function RealtimeSeverity({ severity }: { severity: string }) {
const normalized = severity.toLowerCase();
return KNOWN.includes(normalized) ? (
<SeverityBadge severity={normalized as Severity} />
) : (
<span className="text-text-neutral-primary text-sm capitalize">
{severity || "Unknown"}
</span>
);
}
@@ -0,0 +1,120 @@
import { ReactNode } from "react";
import { DemoBadgeIfDemo } from "@/components/realtime/demo-context";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/shadcn/card/card";
interface RealtimeStatCardProps {
title: string;
children: ReactNode;
/** Right-aligned content in the header, e.g. a badge. */
adornment?: ReactNode;
}
/** The framed tile every overview figure sits in. */
export function RealtimeStatCard({
title,
children,
adornment,
}: RealtimeStatCardProps) {
return (
<Card variant="base" className="gap-3">
<CardHeader className="flex flex-row items-center justify-between gap-2">
<CardTitle className="text-text-neutral-secondary mt-0 text-sm font-medium">
{title}
</CardTitle>
<div className="flex items-center gap-2">
<DemoBadgeIfDemo />
{adornment}
</div>
</CardHeader>
<CardContent className="flex flex-col gap-2">{children}</CardContent>
</Card>
);
}
export function StatFigure({
value,
caption,
}: {
value: ReactNode;
caption?: ReactNode;
}) {
return (
<div className="flex flex-col">
<span className="text-text-neutral-primary text-3xl font-semibold">
{value}
</span>
{caption && (
<span className="text-text-neutral-tertiary text-xs">{caption}</span>
)}
</div>
);
}
/** A label with a count on the right; `empty` renders when there is nothing. */
export function CountList({
entries,
empty,
format = (key: string) => key,
}: {
entries: Array<[string, number]>;
empty: string;
format?: (key: string) => string;
}) {
if (entries.length === 0) {
return <p className="text-text-neutral-tertiary text-sm">{empty}</p>;
}
return (
<ul className="flex flex-col gap-1">
{entries.map(([key, count]) => (
<li
key={key}
className="flex items-center justify-between gap-2 text-sm"
>
<span className="text-text-neutral-secondary">{format(key)}</span>
<span className="text-text-neutral-primary font-medium tabular-nums">
{count}
</span>
</li>
))}
</ul>
);
}
/** A titled block of a tab: heading, optional explanation, then content. */
export function RealtimeSection({
title,
description,
action,
children,
}: {
title: string;
description?: ReactNode;
action?: ReactNode;
children: ReactNode;
}) {
return (
<section className="flex flex-col gap-3">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="flex flex-col gap-1">
<h3 className="text-text-neutral-primary flex items-center gap-2 text-base font-medium">
{title}
<DemoBadgeIfDemo />
</h3>
{description && (
<p className="text-text-neutral-tertiary max-w-prose text-sm">
{description}
</p>
)}
</div>
{action}
</div>
{children}
</section>
);
}
@@ -0,0 +1,53 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { PanelFailure } from "./realtime-states";
describe("PanelFailure", () => {
it("shows the real-time data store empty state for a 503, not an error", () => {
render(
<PanelFailure
failure={{ ok: false, status: 503, error: "x" }}
onRetry={vi.fn()}
/>,
);
expect(
screen.getByText("Real-time data store not configured"),
).toBeVisible();
expect(screen.getByText(/ClickHouse/)).toBeVisible();
});
it("explains a missing permission on a 403", () => {
render(<PanelFailure failure={{ ok: false, status: 403, error: "x" }} />);
expect(screen.getByText("You do not have access to this")).toBeVisible();
});
it("offers a retry for a generic error and none for a 404", async () => {
const user = userEvent.setup();
const onRetry = vi.fn();
const { rerender } = render(
<PanelFailure
failure={{ ok: false, status: 500, error: "Boom" }}
onRetry={onRetry}
subject="the overview"
/>,
);
expect(screen.getByText("Could not load the overview")).toBeVisible();
await user.click(screen.getByRole("button", { name: "Retry" }));
expect(onRetry).toHaveBeenCalledTimes(1);
rerender(
<PanelFailure
failure={{ ok: false, status: 404, error: "x" }}
onRetry={onRetry}
/>,
);
expect(
screen.queryByRole("button", { name: "Retry" }),
).not.toBeInTheDocument();
});
});
@@ -0,0 +1,77 @@
import { ReactNode } from "react";
import { Alert, AlertDescription, AlertTitle } from "@/components/shadcn/alert";
import { Button } from "@/components/shadcn/button/button";
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
import { failureCopy } from "@/lib/realtime/display";
import type { RealtimeFailure } from "@/types/realtime";
export function PanelLoading({ label = "Loading" }: { label?: string }) {
return (
<div
role="status"
aria-busy="true"
aria-label={label}
className="flex flex-col gap-3"
>
<Skeleton className="h-6 w-1/3" />
<Skeleton className="h-24 w-full" />
<Skeleton className="h-24 w-full" />
<span className="sr-only">{label}…</span>
</div>
);
}
/**
* 403, 404 and 503 are expected answers (no access, feature off, no data
* store) and read as notices; anything else is an error with a retry.
*/
export function PanelFailure({
failure,
onRetry,
subject,
}: {
failure: RealtimeFailure;
onRetry?: () => void;
/** Used in the generic error title, e.g. "the event stream". */
subject?: string;
}) {
const copy = failureCopy(failure.status);
const isNotice = [403, 404, 503].includes(failure.status);
const title = !isNotice && subject ? `Could not load ${subject}` : copy.title;
return (
<Alert variant={isNotice ? "info" : "error"}>
<AlertTitle>{title}</AlertTitle>
<AlertDescription>
<p>{isNotice ? copy.description : failure.error || copy.description}</p>
{onRetry && failure.status !== 404 && (
<Button variant="outline" size="sm" onClick={onRetry}>
Retry
</Button>
)}
</AlertDescription>
</Alert>
);
}
export function PanelEmpty({
title,
description,
children,
}: {
title: string;
description?: string;
children?: ReactNode;
}) {
return (
<div className="border-border-neutral-secondary flex flex-col items-start gap-2 rounded-xl border border-dashed p-6">
<p className="text-text-neutral-primary text-sm font-medium">{title}</p>
{description && (
<p className="text-text-neutral-tertiary max-w-prose text-sm">
{description}
</p>
)}
{children}
</div>
);
}
@@ -0,0 +1,188 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { getPolicy, tab } = vi.hoisted(() => ({
getPolicy: vi.fn(),
tab:
(name: string) =>
({ providerId, demo }: { providerId: string; demo?: boolean }) => (
<div>
{name} for [{providerId}] demo={String(demo)}
</div>
),
}));
vi.mock("@/actions/realtime", () => ({ getRealtimePolicy: getPolicy }));
vi.mock("@/components/realtime/findings-tab", () => ({
FindingsTab: tab("findings"),
}));
vi.mock("@/components/realtime/remediation-tab", () => ({
RemediationTab: tab("remediation"),
}));
vi.mock("@/components/realtime/status-statement-card", () => ({
StatusStatementCard: tab("status"),
}));
vi.mock("@/components/realtime/stream-tab", () => ({
StreamTab: tab("stream"),
}));
vi.mock("@/components/realtime/overview-tab", () => ({
OverviewTab: tab("overview"),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ permissions: { manage_prowler_realtime: false } }),
}));
import {
providerLabel,
RealtimeWorkspace,
resolveProviderId,
} from "./realtime-workspace";
const providers = [
{ id: "p1", uid: "111111111111", alias: "prod" },
{ id: "p2", uid: "222222222222", alias: null },
];
const policy = (enabled: boolean) => ({
ok: true,
data: { id: "pol", provider_id: "p2", enabled },
});
const BANNER = /DEMO DATA/;
describe("RealtimeWorkspace", () => {
beforeEach(() => {
vi.clearAllMocks();
getPolicy.mockResolvedValue(policy(true));
});
it("shows demo data with a banner when there is no AWS provider at all", async () => {
render(<RealtimeWorkspace providers={[]} />);
expect(await screen.findByText(BANNER)).toBeVisible();
expect(screen.getByText("overview for [] demo=true")).toBeVisible();
expect(getPolicy).not.toHaveBeenCalled();
expect(
screen.getByRole("link", { name: "Set up Prowler Realtime" }),
).toHaveAttribute("href", "/realtime/setup");
expect(
screen.getByRole("link", { name: "Go to Providers" }),
).toHaveAttribute("href", "/providers");
expect(screen.queryByText("Configure")).not.toBeInTheDocument();
});
it("is a demo while the selected account has no policy", async () => {
getPolicy.mockResolvedValue({ ok: true, data: null });
render(<RealtimeWorkspace providers={providers} initialProviderId="p2" />);
expect(await screen.findByText(BANNER)).toBeVisible();
expect(screen.getByText("overview for [p2] demo=true")).toBeVisible();
expect(
screen.getByRole("link", { name: "Set up Prowler Realtime" }),
).toHaveAttribute("href", "/realtime/setup?provider=p2");
});
it("is a demo while the policy exists but is not enabled, on every tab", async () => {
const user = userEvent.setup();
getPolicy.mockResolvedValue(policy(false));
render(<RealtimeWorkspace providers={providers} initialProviderId="p2" />);
for (const [name, marker] of [
["Overview", "overview"],
["Live stream", "stream"],
["Findings & signals", "findings"],
["Remediation", "remediation"],
["Status", "status"],
]) {
await user.click(await screen.findByRole("tab", { name }));
expect(screen.getByRole("status", { name: "Demo data" })).toBeVisible();
expect(screen.getByText(BANNER)).toBeVisible();
expect(screen.getByText(`${marker} for [p2] demo=true`)).toBeVisible();
}
});
it("points the banner at Lighthouse AI as a secondary way to set up", async () => {
getPolicy.mockResolvedValue(policy(false));
render(<RealtimeWorkspace providers={providers} initialProviderId="p2" />);
expect(
await screen.findByRole("link", {
name: "Ask Lighthouse AI to set it up",
}),
).toHaveAttribute("href", "/lighthouse");
});
it("shows the account's own data, with no banner, once the policy is enabled", async () => {
render(<RealtimeWorkspace providers={providers} initialProviderId="p2" />);
expect(
await screen.findByText("overview for [p2] demo=false"),
).toBeVisible();
expect(screen.queryByText(BANNER)).not.toBeInTheDocument();
expect(getPolicy).toHaveBeenCalledWith("p2");
});
it("does not flash the demo while it checks the account", () => {
getPolicy.mockReturnValue(new Promise(() => {}));
render(<RealtimeWorkspace providers={providers} initialProviderId="p2" />);
expect(
screen.getByRole("status", { name: "Checking the account" }),
).toBeVisible();
expect(screen.queryByText(BANNER)).not.toBeInTheDocument();
expect(screen.queryByRole("tab")).not.toBeInTheDocument();
});
it("explains a failure to read the policy instead of guessing demo or real", async () => {
getPolicy.mockResolvedValue({ ok: false, status: 403, error: "no" });
render(<RealtimeWorkspace providers={providers} initialProviderId="p2" />);
expect(
await screen.findByText("You do not have access to this"),
).toBeVisible();
expect(screen.queryByText(BANNER)).not.toBeInTheDocument();
expect(screen.queryByRole("tab")).not.toBeInTheDocument();
});
it("opens on the provider named in the URL, read only without permission", async () => {
render(<RealtimeWorkspace providers={providers} initialProviderId="p2" />);
await waitFor(() =>
expect(screen.getByText("overview for [p2] demo=false")).toBeVisible(),
);
expect(screen.getByText("Read only")).toBeVisible();
expect(screen.getByRole("tab", { name: "Live stream" })).toBeVisible();
});
it("has no Policy tab and links to the settings of the selected account", async () => {
render(<RealtimeWorkspace providers={providers} initialProviderId="p2" />);
await screen.findByRole("tab", { name: "Overview" });
expect(
screen.queryByRole("tab", { name: "Policy" }),
).not.toBeInTheDocument();
expect(screen.getAllByRole("tab").map((item) => item.textContent)).toEqual([
"Overview",
"Live stream",
"Findings & signals",
"Remediation",
"Status",
]);
expect(screen.getByRole("link", { name: "Configure" })).toHaveAttribute(
"href",
"/realtime/settings?provider=p2",
);
});
it("falls back to the first provider for an unknown id", () => {
expect(resolveProviderId(providers, "nope")).toBe("p1");
expect(resolveProviderId([], "p1")).toBeUndefined();
});
it("labels providers with alias and account id", () => {
expect(providerLabel(providers[0])).toBe("prod (111111111111)");
expect(providerLabel(providers[1])).toBe("222222222222");
});
});
@@ -0,0 +1,170 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { getRealtimePolicy } from "@/actions/realtime";
import { DemoBanner, DemoProvider } from "@/components/realtime/demo-context";
import { FindingsTab } from "@/components/realtime/findings-tab";
import { OverviewTab } from "@/components/realtime/overview-tab";
import {
ProviderSelect,
syncProviderToUrl,
} from "@/components/realtime/provider-select";
import {
PanelFailure,
PanelLoading,
} from "@/components/realtime/realtime-states";
import { RemediationTab } from "@/components/realtime/remediation-tab";
import { StatusStatementCard } from "@/components/realtime/status-statement-card";
import { StreamTab } from "@/components/realtime/stream-tab";
import { useRealtimeResource } from "@/components/realtime/use-realtime-resource";
import { Badge } from "@/components/shadcn/badge/badge";
import { Button } from "@/components/shadcn/button/button";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/shadcn/tabs/tabs";
import { useAuth } from "@/hooks/use-auth";
import { isRealtimeDemoMode } from "@/lib/realtime/demo";
import { settingsHref } from "@/lib/realtime/display";
import {
providerLabel,
type RealtimeProviderOption,
resolveProviderId,
} from "@/lib/realtime/providers";
import { REALTIME_TAB, type RealtimeTab } from "@/types/realtime";
export { providerLabel, resolveProviderId };
export type { RealtimeProviderOption };
interface RealtimeWorkspaceProps {
providers: RealtimeProviderOption[];
initialProviderId?: string;
initialTab?: string;
}
const TAB_LABELS: Array<[RealtimeTab, string]> = [
[REALTIME_TAB.OVERVIEW, "Overview"],
[REALTIME_TAB.STREAM, "Live stream"],
[REALTIME_TAB.FINDINGS, "Findings & signals"],
[REALTIME_TAB.REMEDIATION, "Remediation"],
[REALTIME_TAB.STATUS, "Status"],
];
export const isRealtimeTab = (value: unknown): value is RealtimeTab =>
TAB_LABELS.some(([tab]) => tab === value);
export function RealtimeWorkspace({
providers,
initialProviderId,
initialTab,
}: RealtimeWorkspaceProps) {
const { permissions } = useAuth();
const canManage = permissions.manage_prowler_realtime === true;
const [providerId, setProviderId] = useState(
resolveProviderId(providers, initialProviderId),
);
const [tab, setTab] = useState<RealtimeTab>(
isRealtimeTab(initialTab) ? initialTab : REALTIME_TAB.OVERVIEW,
);
// Whether the account is set up decides between its data and the sample.
const policy = useRealtimeResource(
() => getRealtimePolicy(providerId ?? ""),
`policy:${providerId ?? ""}`,
{ enabled: Boolean(providerId) },
);
// The URL carries the selection so a view can be shared and survives a reload.
const syncUrl = syncProviderToUrl;
const demo = isRealtimeDemoMode({ providerId, policy: policy.data });
const checking = Boolean(providerId) && (policy.loading || !policy.result);
return (
<div className="flex flex-col gap-6">
<div className="flex flex-wrap items-end justify-between gap-4">
{providerId ? (
<ProviderSelect
providers={providers}
value={providerId}
onChange={(value) => {
setProviderId(value);
syncUrl({ provider: value });
}}
/>
) : (
<p className="text-text-neutral-secondary text-sm">
No AWS account is connected yet.{" "}
<Link
href="/providers"
className="text-button-tertiary underline underline-offset-4"
>
Go to Providers
</Link>
</p>
)}
<div className="flex items-center gap-3">
{!canManage && <Badge variant="tag">Read only</Badge>}
{providerId && (
<Button asChild variant="outline" size="sm">
<Link href={settingsHref(providerId)}>Configure</Link>
</Button>
)}
</div>
</div>
{checking ? (
<PanelLoading label="Checking the account" />
) : policy.failure ? (
<PanelFailure
failure={policy.failure}
onRetry={policy.reload}
subject="the account"
/>
) : (
<DemoProvider key={providerId} demo={demo}>
{demo && <DemoBanner providerId={providerId} />}
<Tabs
value={tab}
onValueChange={(value) => {
setTab(value as RealtimeTab);
syncUrl({ tab: value });
}}
>
<TabsList className="overflow-x-auto">
{TAB_LABELS.map(([value, label]) => (
<TabsTrigger key={value} value={value}>
{label}
</TabsTrigger>
))}
</TabsList>
<TabsContent value={REALTIME_TAB.OVERVIEW} className="pt-4">
<OverviewTab providerId={providerId ?? ""} demo={demo} />
</TabsContent>
<TabsContent value={REALTIME_TAB.STREAM} className="pt-4">
<StreamTab providerId={providerId ?? ""} demo={demo} />
</TabsContent>
<TabsContent value={REALTIME_TAB.FINDINGS} className="pt-4">
<FindingsTab providerId={providerId ?? ""} demo={demo} />
</TabsContent>
<TabsContent value={REALTIME_TAB.REMEDIATION} className="pt-4">
<RemediationTab
providerId={providerId ?? ""}
canManage={canManage}
demo={demo}
/>
</TabsContent>
<TabsContent value={REALTIME_TAB.STATUS} className="pt-4">
<StatusStatementCard providerId={providerId ?? ""} demo={demo} />
</TabsContent>
</Tabs>
</DemoProvider>
)}
</div>
);
}
@@ -0,0 +1,150 @@
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { getRemediations } = vi.hoisted(() => ({ getRemediations: vi.fn() }));
vi.mock("@/actions/realtime", () => ({
getRealtimeRemediations: getRemediations,
}));
import type { RealtimeRemediation } from "@/types/realtime";
import { RemediationList } from "./remediation-list";
const PROVIDER = "0b8c2b46-7f3a-4d1e-9a52-3f5f1d6a7c10";
const sg: RealtimeRemediation = {
id: "security-group-ingress",
title: "Close open security group rules",
effect: "Removes the rule that opens a sensitive port to the internet.",
checks: ["ec2_sg_ssh_open", "ec2_sg_rdp_open"],
active_checks: ["ec2_sg_ssh_open"],
iam_actions: ["ec2:RevokeSecurityGroupIngress", "ec2:DescribeSecurityGroups"],
revertible: true,
undo: "Adds the same rule back.",
in_enforced_scope: true,
};
const s3: RealtimeRemediation = {
id: "s3-public-access",
title: "Block public access on a bucket",
effect: "Turns on the bucket public access block.",
checks: ["s3_public"],
active_checks: [],
iam_actions: ["s3:PutBucketPublicAccessBlock"],
revertible: false,
undo: "Cannot be restored automatically.",
in_enforced_scope: false,
};
describe("RemediationList", () => {
beforeEach(() => {
vi.clearAllMocks();
getRemediations.mockResolvedValue({ ok: true, data: [sg, s3] });
});
it("is titled as what the permission allows, and nothing more", async () => {
render(<RemediationList providerId={PROVIDER} />);
expect(
await screen.findByRole("heading", {
name: "With this permission Prowler will only be able to:",
}),
).toBeVisible();
expect(getRemediations).toHaveBeenCalledWith(PROVIDER);
expect(
screen.getByText(/Nothing outside this list is ever fixed automatically/),
).toBeVisible();
expect(
screen.getByText(/only on the resource named in the event/),
).toBeVisible();
expect(screen.getByText(/dry run on/i)).toBeVisible();
expect(screen.getByText(/Every fix is audited/)).toBeVisible();
});
it("lists each fix with its effect, checks and undo text", async () => {
render(<RemediationList providerId={PROVIDER} />);
const items = await screen.findAllByRole("listitem");
const first = items[0];
expect(
within(first).getByText("Close open security group rules"),
).toBeVisible();
expect(within(first).getByText(/opens a sensitive port/)).toBeVisible();
expect(within(first).getByText("ec2_sg_ssh_open")).toBeVisible();
expect(within(first).getByText("ec2_sg_rdp_open")).toBeVisible();
expect(within(first).getByText(/Adds the same rule back/)).toBeVisible();
});
it("says whether each fix can be undone", async () => {
render(<RemediationList providerId={PROVIDER} />);
expect(await screen.findByText("Can be undone")).toBeVisible();
expect(screen.getByText("Cannot be undone")).toBeVisible();
});
it("says whether each fix is inside the enforced frameworks, with the active checks", async () => {
render(<RemediationList providerId={PROVIDER} />);
expect(
await screen.findByText(
"In the enforced frameworks (1 of 2 checks active)",
),
).toBeVisible();
expect(
screen.getByText("Outside the enforced frameworks: never applied"),
).toBeVisible();
});
it("keeps the exact IAM actions collapsed until asked", async () => {
const user = userEvent.setup();
render(<RemediationList providerId={PROVIDER} />);
expect(
screen.queryByText("ec2:RevokeSecurityGroupIngress"),
).not.toBeInTheDocument();
await user.click(
await screen.findByRole("button", {
name: /Exact AWS permissions \(2\)/,
}),
);
expect(screen.getByText("ec2:RevokeSecurityGroupIngress")).toBeVisible();
expect(screen.getByText("ec2:DescribeSecurityGroups")).toBeVisible();
});
it("asks without a provider and shows no scope when there is no account", async () => {
render(<RemediationList />);
await screen.findByText("Close open security group rules");
expect(getRemediations).toHaveBeenCalledWith(undefined);
expect(screen.queryByText(/enforced frameworks/)).not.toBeInTheDocument();
expect(screen.getAllByText("Can be undone").length).toBeGreaterThan(0);
});
it("explains a 403 and still states the limits", async () => {
getRemediations.mockResolvedValue({ ok: false, status: 403, error: "no" });
render(<RemediationList providerId={PROVIDER} />);
expect(
await screen.findByText("You do not have access to this"),
).toBeVisible();
expect(
screen.getByText(/Nothing outside this list is ever fixed automatically/),
).toBeVisible();
});
it("explains a 503 as a notice, and an empty answer as empty", async () => {
getRemediations.mockResolvedValue({ ok: false, status: 503, error: "x" });
const { unmount } = render(<RemediationList providerId={PROVIDER} />);
expect(
await screen.findByText("Real-time data store not configured"),
).toBeVisible();
unmount();
getRemediations.mockResolvedValue({ ok: true, data: [] });
render(<RemediationList providerId={PROVIDER} />);
expect(await screen.findByText("No fixes are available yet")).toBeVisible();
});
});
+171
View File
@@ -0,0 +1,171 @@
"use client";
import { ChevronDown } from "lucide-react";
import { useId, useState } from "react";
import { getRealtimeRemediations } from "@/actions/realtime";
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 {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/shadcn/collapsible";
import type { RealtimeRemediation } from "@/types/realtime";
interface RemediationItemProps {
remediation: RealtimeRemediation;
/** Whether the scope is known (an account is selected). */
showScope: boolean;
}
export function RemediationItem({
remediation,
showScope,
}: RemediationItemProps) {
const [open, setOpen] = useState(false);
const { checks, active_checks: active, iam_actions: actions } = remediation;
return (
<li className="border-border-neutral-secondary flex flex-col gap-2 rounded-lg border p-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<h5 className="text-text-neutral-primary text-sm font-medium">
{remediation.title}
</h5>
<div className="flex flex-wrap items-center gap-2">
<Badge variant={remediation.revertible ? "success" : "warning"}>
{remediation.revertible ? "Can be undone" : "Cannot be undone"}
</Badge>
{showScope && (
<Badge variant={remediation.in_enforced_scope ? "info" : "outline"}>
{remediation.in_enforced_scope
? `In the enforced frameworks (${active.length} of ${checks.length} checks active)`
: "Outside the enforced frameworks: never applied"}
</Badge>
)}
</div>
</div>
<p className="text-text-neutral-secondary text-sm">
{remediation.effect}
</p>
{remediation.undo && (
<p className="text-text-neutral-tertiary text-xs">
{remediation.revertible ? "To undo: " : "Why: "}
{remediation.undo}
</p>
)}
{checks.length > 0 && (
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-text-neutral-tertiary text-xs">
Answers the checks
</span>
{checks.map((check) => (
<Badge
key={check}
variant="tag"
size="sm"
className={
showScope && !active.includes(check) ? "opacity-60" : undefined
}
>
{check}
</Badge>
))}
</div>
)}
{actions.length > 0 && (
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="text-button-tertiary inline-flex items-center gap-1 text-xs underline-offset-4 hover:underline"
>
<ChevronDown
aria-hidden="true"
className={open ? "size-3.5 rotate-180" : "size-3.5"}
/>
Exact AWS permissions ({actions.length})
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<ul
aria-label={`AWS permissions for ${remediation.title}`}
className="mt-2 flex flex-col gap-0.5"
>
{actions.map((action) => (
<li key={action}>
<code className="text-text-neutral-secondary text-xs">
{action}
</code>
</li>
))}
</ul>
</CollapsibleContent>
</Collapsible>
)}
</li>
);
}
/**
* What the remediation permission can do, and nothing else. Shown next to the
* template that creates it, so the person knows what they are granting.
* Without an account (demo, none yet) the list is the same, with no scope.
*/
export function RemediationList({ providerId }: { providerId?: string }) {
const headingId = useId();
const remediations = useRealtimeResource(
() => getRealtimeRemediations(providerId),
`remediations:${providerId ?? "none"}`,
);
return (
<section aria-labelledby={headingId} className="flex flex-col gap-3">
<h4
id={headingId}
className="text-text-neutral-primary text-sm font-semibold"
>
With this permission Prowler will only be able to:
</h4>
{remediations.loading ? (
<PanelLoading label="Loading the list of fixes" />
) : remediations.failure ? (
<PanelFailure
failure={remediations.failure}
onRetry={remediations.reload}
subject="the list of fixes"
/>
) : remediations.data && remediations.data.length > 0 ? (
<ul className="flex flex-col gap-2">
{remediations.data.map((remediation) => (
<RemediationItem
key={remediation.id}
remediation={remediation}
showScope={Boolean(providerId)}
/>
))}
</ul>
) : (
<PanelEmpty title="No fixes are available yet" />
)}
<p className="text-text-neutral-tertiary max-w-prose text-xs">
Nothing outside this list is ever fixed automatically. A fix acts only
on the resource named in the event that triggered it. With dry run on,
Prowler records what it would change instead of changing it. Every fix
is audited: what changed, when, and how to undo it when it can be
undone.
</p>
</section>
);
}
@@ -0,0 +1,19 @@
import { ActionsList } from "@/components/realtime/actions-list";
import { DetectionsList } from "@/components/realtime/detections-list";
export function RemediationTab({
providerId,
canManage,
demo = false,
}: {
providerId: string;
canManage: boolean;
demo?: boolean;
}) {
return (
<div className="flex flex-col gap-8">
<ActionsList providerId={providerId} canManage={canManage} demo={demo} />
<DetectionsList providerId={providerId} demo={demo} />
</div>
);
}
@@ -0,0 +1,110 @@
"use client";
import { useId } from "react";
import { Button } from "@/components/shadcn/button/button";
import { Field, FieldError, FieldLabel } from "@/components/shadcn/field/field";
import { Input } from "@/components/shadcn/input/input";
import { newScopeRow, type ScopeRow } from "@/lib/realtime/policy-schema";
import { REALTIME_MAX_SCOPE_TAGS } from "@/types/realtime";
interface ScopeTagsEditorProps {
rows: ScopeRow[];
disabled?: boolean;
error?: string;
onChange: (rows: ScopeRow[]) => void;
}
/** key=value tag filters. Every row must match (AND), one value per key. */
export function ScopeTagsEditor({
rows,
disabled,
error,
onChange,
}: ScopeTagsEditorProps) {
const groupId = useId();
const atLimit = rows.length >= REALTIME_MAX_SCOPE_TAGS;
const update = (id: string, patch: Partial<ScopeRow>) =>
onChange(rows.map((row) => (row.id === id ? { ...row, ...patch } : row)));
return (
<fieldset
className="flex flex-col gap-3"
aria-describedby={`${groupId}-note`}
>
<legend className="text-text-neutral-primary text-sm font-medium">
Tag scope
</legend>
<p
id={`${groupId}-note`}
className="text-text-neutral-tertiary max-w-prose text-xs"
>
Watch only resources that carry all of these tags (up to{" "}
{REALTIME_MAX_SCOPE_TAGS}). Resources outside the scope are not
evaluated, so account-wide controls such as root MFA and account-level
settings are skipped while a scope is set. Leave it empty to watch the
whole account.
</p>
{rows.map((row, index) => (
<div
key={row.id}
className="grid grid-cols-1 items-end gap-2 sm:grid-cols-[1fr_1fr_auto]"
>
<Field>
<FieldLabel htmlFor={`${groupId}-key-${row.id}`}>
Tag key {index + 1}
</FieldLabel>
<Input
id={`${groupId}-key-${row.id}`}
value={row.key}
disabled={disabled}
placeholder="environment"
onChange={(event) => update(row.id, { key: event.target.value })}
/>
</Field>
<Field>
<FieldLabel htmlFor={`${groupId}-value-${row.id}`}>
Tag value {index + 1}
</FieldLabel>
<Input
id={`${groupId}-value-${row.id}`}
value={row.value}
disabled={disabled}
placeholder="production"
onChange={(event) =>
update(row.id, { value: event.target.value })
}
/>
</Field>
<Button
type="button"
variant="outline"
disabled={disabled}
onClick={() => onChange(rows.filter((item) => item.id !== row.id))}
aria-label={`Remove tag ${index + 1}`}
>
Remove
</Button>
</div>
))}
{error && <FieldError>{error}</FieldError>}
<div>
<Button
type="button"
variant="outline"
size="sm"
disabled={disabled || atLimit}
onClick={() => onChange([...rows, newScopeRow()])}
>
{atLimit
? `Limit of ${REALTIME_MAX_SCOPE_TAGS} tags reached`
: "Add tag"}
</Button>
</div>
</fieldset>
);
}
@@ -0,0 +1,283 @@
"use client";
import Link from "next/link";
import { useId, useState } from "react";
import { patchRealtimePolicy, saveRealtimePolicy } from "@/actions/realtime";
import { FrameworkPicker } from "@/components/realtime/framework-picker";
import { ScopeTagsEditor } from "@/components/realtime/scope-tags-editor";
import { ToggleRow } from "@/components/realtime/toggle-row";
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 {
RadioGroup,
RadioGroupItem,
} from "@/components/shadcn/radio-group/radio-group";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/shadcn/select/select";
import { useToast } from "@/components/shadcn/toast";
import { humanize, MODE_DESCRIPTIONS } from "@/lib/realtime/display";
import {
newScopeRow,
policyFieldErrors,
type ScopeRow,
scopeRowsToTags,
} from "@/lib/realtime/policy-schema";
import { WIZARD_DEFAULT_POLICY } from "@/lib/realtime/setup";
import {
REALTIME_MIN_SEVERITY,
REALTIME_MODE,
type RealtimeFramework,
type RealtimeMode,
type RealtimePolicy,
type RealtimePolicyInput,
} from "@/types/realtime";
interface EnforcementStepProps {
providerId: string;
policy: RealtimePolicy | null;
frameworkCatalog: RealtimeFramework[];
lighthouseConfigured?: boolean;
onSaved: (policy: RealtimePolicy) => void;
}
type EnforcementPatch = Pick<
RealtimePolicyInput,
| "enabled"
| "mode"
| "dry_run"
| "min_severity"
| "frameworks"
| "scope_tags"
| "use_lighthouse"
>;
/**
* Step 3: what Prowler Realtime enforces and how. Saves with `enabled: false`
* (creating the policy the first time), so nothing runs yet.
*/
export function EnforcementStep({
providerId,
policy,
frameworkCatalog,
lighthouseConfigured,
onSaved,
}: EnforcementStepProps) {
const { toast } = useToast();
const id = useId();
const initial = policy ?? WIZARD_DEFAULT_POLICY;
const [frameworks, setFrameworks] = useState(initial.frameworks);
const [mode, setMode] = useState<RealtimeMode>(initial.mode);
const [dryRun, setDryRun] = useState(initial.dry_run);
const [minSeverity, setMinSeverity] = useState(initial.min_severity);
const [useLighthouse, setUseLighthouse] = useState(initial.use_lighthouse);
const [rows, setRows] = useState<ScopeRow[]>(() =>
Object.entries(initial.scope_tags).map(([key, value]) =>
newScopeRow(key, value),
),
);
const [errors, setErrors] = useState<Record<string, string>>({});
const [saving, setSaving] = useState(false);
const save = async () => {
const scope = scopeRowsToTags(rows);
const patch: EnforcementPatch = {
enabled: false,
mode,
dry_run: dryRun,
min_severity: minSeverity,
frameworks,
scope_tags: scope.tags,
use_lighthouse: useLighthouse,
};
// Only what this step edits can block it.
const found = Object.fromEntries(
Object.entries(
policyFieldErrors({ ...(policy ?? WIZARD_DEFAULT_POLICY), ...patch }),
).filter(([field]) => field in patch),
);
if (scope.error) found.scope_tags = scope.error;
if (Object.keys(found).length > 0) {
setErrors(found);
return;
}
setErrors({});
setSaving(true);
const result = policy
? await patchRealtimePolicy({
providerId,
policyId: policy.id,
patch,
})
: await saveRealtimePolicy({
providerId,
policy: { ...WIZARD_DEFAULT_POLICY, ...patch },
});
setSaving(false);
if (result.ok) {
onSaved(result.data);
toast({ title: "Choices saved. Nothing is switched on yet." });
} else {
setErrors(result.fieldErrors ?? {});
toast({
variant: "destructive",
title: "Could not save your choices",
description: result.error,
});
}
};
return (
<form
noValidate
className="flex flex-col gap-6"
onSubmit={(event) => {
event.preventDefault();
void save();
}}
>
<FrameworkPicker
catalog={frameworkCatalog}
selected={frameworks}
disabled={saving}
error={errors.frameworks}
onChange={setFrameworks}
/>
<fieldset className="flex flex-col gap-3">
<legend className="text-text-neutral-primary mb-1 text-sm font-medium">
What Prowler does when it finds a problem
</legend>
<RadioGroup
value={mode}
disabled={saving}
onValueChange={(value) => setMode(value as RealtimeMode)}
>
{Object.values(REALTIME_MODE).map((value) => (
<div key={value} className="flex items-start gap-3">
<RadioGroupItem
id={`${id}-mode-${value}`}
value={value}
aria-describedby={`${id}-mode-${value}-description`}
className="mt-0.5"
/>
<div className="flex flex-col gap-0.5">
<label
htmlFor={`${id}-mode-${value}`}
className="text-text-neutral-primary flex items-center gap-2 text-sm font-medium"
>
{MODE_DESCRIPTIONS[value].label}
{value === REALTIME_MODE.MONITOR && (
<Badge variant="tag" size="sm">
Default
</Badge>
)}
</label>
<p
id={`${id}-mode-${value}-description`}
className="text-text-neutral-tertiary max-w-prose text-xs"
>
{MODE_DESCRIPTIONS[value].consequence}
</p>
</div>
</div>
))}
</RadioGroup>
{errors.mode && <FieldError>{errors.mode}</FieldError>}
</fieldset>
<ToggleRow
id={`${id}-dry-run`}
label="Dry run"
description="Record what Prowler Realtime would change in the account without changing it. Applies to Fix automatically. On by default."
checked={dryRun}
disabled={saving}
onChange={setDryRun}
/>
{mode === REALTIME_MODE.AUTO && !dryRun && (
<Alert variant="warning">
<AlertTitle>Dry run is off</AlertTitle>
<AlertDescription>
<p>
Once you activate in the last step, Prowler Realtime will change
resources in your AWS account as soon as it detects a matching
risky change. Each change records how to undo it.
</p>
</AlertDescription>
</Alert>
)}
<Field className="max-w-xs">
<FieldLabel htmlFor={`${id}-severity`}>Minimum severity</FieldLabel>
<Select
value={minSeverity}
disabled={saving}
onValueChange={(value) =>
setMinSeverity(value as RealtimePolicyInput["min_severity"])
}
>
<SelectTrigger id={`${id}-severity`} size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.values(REALTIME_MIN_SEVERITY).map((severity) => (
<SelectItem key={severity} value={severity}>
{humanize(severity)} and above
</SelectItem>
))}
</SelectContent>
</Select>
{errors.min_severity && <FieldError>{errors.min_severity}</FieldError>}
</Field>
<ScopeTagsEditor
rows={rows}
disabled={saving}
error={errors.scope_tags}
onChange={setRows}
/>
<div className="flex flex-col gap-2">
<ToggleRow
id={`${id}-lighthouse`}
label="Let Lighthouse AI write the code change"
description="For checks without a built-in patcher, Lighthouse AI writes the change using your configured LLM. The Terraform file and the finding are sent to that LLM provider. Pull requests are always reviewed by a person."
checked={useLighthouse}
disabled={saving}
onChange={setUseLighthouse}
/>
<div className="flex flex-wrap items-center gap-2 pl-14">
{useLighthouse && lighthouseConfigured === false && (
<Badge variant="warning">
No Lighthouse AI configuration connected
</Badge>
)}
<Link
href="/lighthouse/settings"
className="text-button-tertiary text-xs underline underline-offset-4"
>
Open Lighthouse AI settings
</Link>
</div>
</div>
<div className="flex items-center gap-3">
<Button type="submit" disabled={saving}>
{saving ? "Saving…" : "Save and continue"}
</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>
);
}
@@ -0,0 +1,502 @@
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
getSetup: vi.fn(),
getPolicy: vi.fn(),
getCredential: vi.fn(),
getFrameworks: vi.fn(),
getTargets: vi.fn(),
savePolicy: vi.fn(),
patchPolicy: vi.fn(),
push: vi.fn(),
toast: vi.fn(),
permissions: { manage_prowler_realtime: true } as Record<string, boolean>,
}));
vi.mock("@/actions/realtime", () => ({
getRealtimeSetup: mocks.getSetup,
getRealtimePolicy: mocks.getPolicy,
getRealtimeCredential: mocks.getCredential,
getRealtimeFrameworks: mocks.getFrameworks,
getRealtimeNotificationTargets: mocks.getTargets,
saveRealtimePolicy: mocks.savePolicy,
patchRealtimePolicy: mocks.patchPolicy,
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: mocks.push }),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ permissions: mocks.permissions }),
}));
vi.mock("@/components/shadcn/toast", () => ({
useToast: () => ({ toast: mocks.toast }),
}));
vi.mock("@/components/realtime/feed-setup-panel", () => ({
FeedSetupPanel: () => <div>Event feed setup</div>,
}));
vi.mock("@/components/realtime/credentials-form", () => ({
CredentialsForm: () => <div>Credentials for remediation only</div>,
}));
vi.mock("@/components/realtime/notifications-section", () => ({
AlertsSection: () => <div>Alerts section</div>,
}));
vi.mock("@/components/realtime/cost-anomaly-settings", () => ({
CostAnomalyForm: () => <div>Cost anomaly form</div>,
}));
import type {
RealtimePolicy,
RealtimeSetupPayload,
RealtimeSetupStepId,
} from "@/types/realtime";
import { RealtimeSetupWizard } from "./realtime-setup-wizard";
const P1 = "0b8c2b46-7f3a-4d1e-9a52-3f5f1d6a7c10";
const P2 = "1c9d3c57-8a4b-4e2f-8b63-4a6a2e7b8d21";
const providers = [
{ id: P1, uid: "111111111111", alias: "prod" },
{ id: P2, uid: "222222222222", alias: null },
];
const setup = (
next: RealtimeSetupStepId | null,
done: RealtimeSetupStepId[] = [],
activated = false,
): RealtimeSetupPayload => ({
activated,
next_step: next,
steps: (
[
["feed", true],
["enforcement", true],
["remediation", false],
["alerts", false],
["cost", false],
["activate", true],
] as Array<[RealtimeSetupStepId, boolean]>
).map(([id, required]) => ({
id,
required,
done: done.includes(id),
detail: id === "feed" && done.includes(id) ? "CloudTrail lookup" : null,
})),
});
const policy = (overrides: Partial<RealtimePolicy> = {}): RealtimePolicy => ({
id: "1f2e3d4c-5b6a-4798-8a9b-0c1d2e3f4a5b",
provider_id: P1,
enabled: false,
mode: "monitor",
dry_run: true,
min_severity: "low",
deny_checks: [],
allow_regions: [],
scope_tags: {},
iac_repo: "",
iac_path: "",
open_pr: false,
use_lighthouse: true,
frameworks: ["soc2_aws"],
cost_anomaly_enabled: false,
cost_anomaly_min_ratio: 2,
cost_anomaly_min_delta: 5,
notifications: {
min_severity: "high",
slack: { enabled: false, channel_ids: [] },
email: { enabled: false, recipient_ids: [] },
},
...overrides,
});
const ok = <T,>(data: T) => ({ ok: true as const, data });
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: () => {},
});
});
describe("RealtimeSetupWizard", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.permissions = { manage_prowler_realtime: true };
mocks.getSetup.mockResolvedValue(ok(setup("feed")));
mocks.getPolicy.mockResolvedValue(ok(null));
mocks.getCredential.mockResolvedValue(ok(null));
mocks.getFrameworks.mockResolvedValue(
ok([{ id: "soc2_aws", name: "SOC 2", version: "2017" }]),
);
mocks.getTargets.mockResolvedValue(ok({ slack: [], email: [] }));
mocks.savePolicy.mockImplementation(async ({ policy: input }) =>
ok(policy(input)),
);
mocks.patchPolicy.mockImplementation(async ({ patch }) =>
ok(policy(patch)),
);
});
it("explains and links to Providers when the tenant has no AWS provider", () => {
render(<RealtimeSetupWizard providers={[]} />);
expect(screen.getByText("Connect an AWS account first")).toBeVisible();
expect(
screen.getByRole("link", { name: "Go to Providers" }),
).toHaveAttribute("href", "/providers");
});
it("explains and changes nothing without the permission", () => {
mocks.permissions = { manage_prowler_realtime: false };
render(
<RealtimeSetupWizard providers={providers} initialProviderId={P1} />,
);
expect(screen.getByText("You cannot set this up")).toBeVisible();
expect(
screen.getByRole("link", { name: "Back to the dashboard" }),
).toHaveAttribute("href", "/realtime");
expect(mocks.getSetup).not.toHaveBeenCalled();
expect(mocks.savePolicy).not.toHaveBeenCalled();
});
it("offers the Lighthouse AI alternative, secondary, in the header", async () => {
render(
<RealtimeSetupWizard providers={providers} initialProviderId={P1} />,
);
expect(
await screen.findByRole("link", {
name: "Ask Lighthouse AI to set it up",
}),
).toHaveAttribute("href", "/lighthouse");
expect(screen.getByText(/Nothing runs until you activate/)).toBeVisible();
});
it("starts at the account step when no provider is in the URL, then resumes", async () => {
const user = userEvent.setup();
mocks.getSetup.mockResolvedValue(ok(setup("enforcement", ["feed"])));
render(<RealtimeSetupWizard providers={providers} />);
expect(
await screen.findByRole("heading", { name: /Step 1 of 7: Account/ }),
).toBeVisible();
await user.click(await screen.findByRole("button", { name: "Continue" }));
expect(
await screen.findByRole("heading", {
name: /Step 3 of 7: What to enforce/,
}),
).toBeVisible();
});
it("resumes at the first required unfinished step and draws the progress", async () => {
mocks.getSetup.mockResolvedValue(ok(setup("enforcement", ["feed"])));
render(
<RealtimeSetupWizard providers={providers} initialProviderId={P1} />,
);
expect(
await screen.findByRole("heading", {
name: /Step 3 of 7: What to enforce/,
}),
).toBeVisible();
expect(mocks.getSetup).toHaveBeenCalledWith(P1);
const progress = screen.getByRole("list", { name: "Setup steps" });
expect(within(progress).getAllByRole("listitem")).toHaveLength(7);
expect(
within(progress).getByRole("button", { name: /What to enforce/ }),
).toHaveAttribute("aria-current", "step");
expect(
within(progress).getByRole("button", { name: /Event feed/ }),
).toHaveTextContent("done");
// The review is not reachable while the enforcement step is open.
expect(
within(progress).getByRole("button", { name: /Review and activate/ }),
).toBeDisabled();
});
it("resumes at the feed for a provider with nothing done", async () => {
render(
<RealtimeSetupWizard providers={providers} initialProviderId={P1} />,
);
expect(
await screen.findByRole("heading", { name: /Step 2 of 7: Event feed/ }),
).toBeVisible();
expect(screen.getByText("Event feed setup")).toBeVisible();
// Required and not done: it blocks Continue.
expect(screen.getByRole("button", { name: "Continue" })).toBeDisabled();
});
it("creates the policy switched off, in monitor mode with dry run on, on the first save", async () => {
const user = userEvent.setup();
mocks.getSetup.mockResolvedValue(ok(setup("enforcement", ["feed"])));
render(
<RealtimeSetupWizard providers={providers} initialProviderId={P1} />,
);
expect(
await screen.findByRole("radio", { name: /Monitor only/ }),
).toBeChecked();
expect(screen.getByRole("switch", { name: /Dry run/ })).toBeChecked();
await user.click(screen.getByRole("button", { name: "Save and continue" }));
await waitFor(() => expect(mocks.savePolicy).toHaveBeenCalledTimes(1));
expect(mocks.savePolicy.mock.calls[0][0]).toMatchObject({
providerId: P1,
policy: { enabled: false, mode: "monitor", dry_run: true },
});
expect(mocks.patchPolicy).not.toHaveBeenCalled();
expect(
await screen.findByRole("heading", {
name: /Step 4 of 7: Remediation permission/,
}),
).toBeVisible();
expect(screen.getByText("Credentials for remediation only")).toBeVisible();
});
it("patches an existing policy with enabled false and only what the step edits", async () => {
const user = userEvent.setup();
mocks.getSetup.mockResolvedValue(ok(setup("enforcement", ["feed"])));
mocks.getPolicy.mockResolvedValue(ok(policy()));
render(
<RealtimeSetupWizard providers={providers} initialProviderId={P1} />,
);
await user.click(
await screen.findByRole("radio", { name: /Propose a change/ }),
);
await user.click(screen.getByRole("button", { name: "Save and continue" }));
await waitFor(() => expect(mocks.patchPolicy).toHaveBeenCalledTimes(1));
const { policyId, patch } = mocks.patchPolicy.mock.calls[0][0];
expect(policyId).toBe(policy().id);
expect(patch).toMatchObject({ enabled: false, mode: "pr", dry_run: true });
expect(Object.keys(patch).sort()).toEqual([
"dry_run",
"enabled",
"frameworks",
"min_severity",
"mode",
"scope_tags",
"use_lighthouse",
]);
expect(mocks.savePolicy).not.toHaveBeenCalled();
});
it("shows the API's message when a step cannot be saved", async () => {
const user = userEvent.setup();
mocks.getSetup.mockResolvedValue(ok(setup("enforcement", ["feed"])));
mocks.savePolicy.mockResolvedValue({
ok: false,
status: 403,
error: "Forbidden",
});
render(
<RealtimeSetupWizard providers={providers} initialProviderId={P1} />,
);
await user.click(
await screen.findByRole("button", { name: "Save and continue" }),
);
await waitFor(() =>
expect(mocks.toast).toHaveBeenCalledWith(
expect.objectContaining({ title: "Could not save your choices" }),
),
);
expect(screen.getByRole("heading", { name: /Step 3 of 7/ })).toBeVisible();
});
it("lets the optional steps be skipped", async () => {
const user = userEvent.setup();
mocks.getSetup.mockResolvedValue(
ok(setup("activate", ["feed", "enforcement"])),
);
mocks.getPolicy.mockResolvedValue(ok(policy()));
render(
<RealtimeSetupWizard providers={providers} initialProviderId={P1} />,
);
await user.click(
await screen.findByRole("button", { name: /Remediation permission/ }),
);
expect(screen.getByText("Credentials for remediation only")).toBeVisible();
await user.click(screen.getByRole("button", { name: "Skip for now" }));
expect(await screen.findByText("Alerts section")).toBeVisible();
await user.click(screen.getByRole("button", { name: "Skip for now" }));
expect(await screen.findByText("Cost anomaly form")).toBeVisible();
await user.click(screen.getByRole("button", { name: "Skip for now" }));
expect(
await screen.findByRole("heading", {
name: /Step 7 of 7: Review and activate/,
}),
).toBeVisible();
expect(mocks.patchPolicy).not.toHaveBeenCalled();
});
it("sends enabled true only from the review step, and then opens the real dashboard", async () => {
const user = userEvent.setup();
mocks.getSetup.mockResolvedValue(
ok(setup("activate", ["feed", "enforcement"])),
);
mocks.getPolicy.mockResolvedValue(ok(policy()));
render(
<RealtimeSetupWizard providers={providers} initialProviderId={P1} />,
);
expect(
await screen.findByRole("heading", {
name: /Step 7 of 7: Review and activate/,
}),
).toBeVisible();
// Nothing was written just by getting here.
expect(mocks.savePolicy).not.toHaveBeenCalled();
expect(mocks.patchPolicy).not.toHaveBeenCalled();
await user.click(
screen.getByRole("button", { name: "Activate Prowler Realtime" }),
);
await waitFor(() => expect(mocks.patchPolicy).toHaveBeenCalledTimes(1));
expect(mocks.patchPolicy.mock.calls[0][0]).toMatchObject({
providerId: P1,
patch: { enabled: true },
});
await waitFor(() =>
expect(mocks.push).toHaveBeenCalledWith(`/realtime?provider=${P1}`),
);
});
it("summarises every choice before activating", async () => {
mocks.getSetup.mockResolvedValue(
ok(setup("activate", ["feed", "enforcement"])),
);
mocks.getPolicy.mockResolvedValue(
ok(
policy({
cost_anomaly_enabled: true,
scope_tags: { env: "prod" },
notifications: {
min_severity: "critical",
slack: { enabled: true, channel_ids: ["C1"] },
email: { enabled: false, recipient_ids: [] },
},
}),
),
);
mocks.getTargets.mockResolvedValue(
ok({
slack: [{ integration_id: "i", channel_id: "C1", name: "security" }],
email: [],
}),
);
render(
<RealtimeSetupWizard providers={providers} initialProviderId={P1} />,
);
const dl = await screen.findByLabelText("Summary of your choices");
expect(within(dl).getByText("prod (111111111111)")).toBeVisible();
expect(within(dl).getByText("CloudTrail lookup")).toBeVisible();
expect(await within(dl).findByText("SOC 2")).toBeVisible();
expect(within(dl).getByText("Monitor only")).toBeVisible();
expect(within(dl).getByText("env=prod")).toBeVisible();
expect(await within(dl).findByText("Slack: #security")).toBeVisible();
expect(within(dl).getByText(/^On \(from 2x/)).toBeVisible();
expect(
within(dl).getByText("Not set: Prowler cannot change the account"),
).toBeVisible();
});
it("asks once more before activating live fixes", async () => {
const user = userEvent.setup();
mocks.getSetup.mockResolvedValue(
ok(setup("activate", ["feed", "enforcement"])),
);
mocks.getPolicy.mockResolvedValue(
ok(policy({ mode: "auto", dry_run: false })),
);
render(
<RealtimeSetupWizard providers={providers} initialProviderId={P1} />,
);
await user.click(
await screen.findByRole("button", { name: "Activate Prowler Realtime" }),
);
expect(
await screen.findByText("Let Prowler Realtime change your AWS account?"),
).toBeVisible();
expect(mocks.patchPolicy).not.toHaveBeenCalled();
await user.click(
screen.getByRole("button", { name: "Activate with live fixes" }),
);
await waitFor(() => expect(mocks.patchPolicy).toHaveBeenCalledTimes(1));
expect(mocks.patchPolicy.mock.calls[0][0].patch).toEqual({ enabled: true });
});
it("does not leave for the dashboard when activation fails", async () => {
const user = userEvent.setup();
mocks.getSetup.mockResolvedValue(
ok(setup("activate", ["feed", "enforcement"])),
);
mocks.getPolicy.mockResolvedValue(ok(policy()));
mocks.patchPolicy.mockResolvedValue({
ok: false,
status: 400,
error: "Add a feed first.",
});
render(
<RealtimeSetupWizard providers={providers} initialProviderId={P1} />,
);
await user.click(
await screen.findByRole("button", { name: "Activate Prowler Realtime" }),
);
expect(await screen.findByText("Add a feed first.")).toBeVisible();
expect(mocks.push).not.toHaveBeenCalled();
});
it("says so when the account is already on, instead of starting over", async () => {
mocks.getSetup.mockResolvedValue(
ok(setup(null, ["feed", "enforcement"], true)),
);
mocks.getPolicy.mockResolvedValue(ok(policy({ enabled: true })));
render(
<RealtimeSetupWizard providers={providers} initialProviderId={P1} />,
);
expect(
await screen.findByText(
"Prowler Realtime is already on for this account",
),
).toBeVisible();
expect(
screen.getByRole("link", { name: "Open the dashboard" }),
).toHaveAttribute("href", `/realtime?provider=${P1}`);
});
it("explains a 503 or 404 from the setup endpoint as a notice", async () => {
mocks.getSetup.mockResolvedValue({ ok: false, status: 503, error: "x" });
render(
<RealtimeSetupWizard providers={providers} initialProviderId={P1} />,
);
expect(
await screen.findByText("Real-time data store not configured"),
).toBeVisible();
});
});
@@ -0,0 +1,492 @@
"use client";
import { Check } from "lucide-react";
import Link from "next/link";
import { type ReactNode, useState } from "react";
import {
getRealtimeCredential,
getRealtimeFrameworks,
getRealtimePolicy,
getRealtimeSetup,
} from "@/actions/realtime";
import { CostAnomalyForm } from "@/components/realtime/cost-anomaly-settings";
import { CredentialsForm } from "@/components/realtime/credentials-form";
import { LighthouseSetupLink } from "@/components/realtime/demo-context";
import { FeedSetupPanel } from "@/components/realtime/feed-setup-panel";
import { AlertsSection } from "@/components/realtime/notifications-section";
import {
ProviderSelect,
syncProviderToUrl,
} from "@/components/realtime/provider-select";
import { RealtimeSection } from "@/components/realtime/realtime-stat-card";
import {
PanelEmpty,
PanelFailure,
PanelLoading,
} from "@/components/realtime/realtime-states";
import { EnforcementStep } from "@/components/realtime/setup/enforcement-step";
import { ReviewStep } from "@/components/realtime/setup/review-step";
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 { useAuth } from "@/hooks/use-auth";
import { REALTIME_PRODUCT_NAME_TEXT } from "@/lib/realtime/demo";
import { settingsHref } from "@/lib/realtime/display";
import {
providerLabel,
type RealtimeProviderOption,
resolveProviderId,
} from "@/lib/realtime/providers";
import {
canEnterStep,
nextStepAfter,
resumeStep,
SETUP_STEP,
SETUP_STEP_LABELS,
SETUP_STEP_ORDER,
type SetupStep,
stepBefore,
stepProgress,
} from "@/lib/realtime/setup";
import type { RealtimePolicy, RealtimeSetupPayload } from "@/types/realtime";
interface RealtimeSetupWizardProps {
providers: RealtimeProviderOption[];
initialProviderId?: string;
/** Whether a connected Lighthouse AI configuration exists; undefined when unknown. */
lighthouseConfigured?: boolean;
}
function Progress({
current,
setup,
onSelect,
}: {
current: SetupStep;
setup: RealtimeSetupPayload | undefined;
onSelect: (step: SetupStep) => void;
}) {
return (
<ol aria-label="Setup steps" className="flex flex-wrap gap-2">
{SETUP_STEP_ORDER.map((step, index) => {
const progress = stepProgress(step, setup);
const enterable = canEnterStep(step, setup);
const isCurrent = step === current;
return (
<li key={step}>
<button
type="button"
disabled={!enterable}
aria-current={isCurrent ? "step" : undefined}
onClick={() => onSelect(step)}
className={
isCurrent
? "border-button-tertiary bg-bg-neutral-secondary text-text-neutral-primary flex items-center gap-2 rounded-lg border px-3 py-1.5 text-sm font-medium"
: "border-border-neutral-secondary text-text-neutral-secondary hover:bg-bg-neutral-secondary flex items-center gap-2 rounded-lg border px-3 py-1.5 text-sm disabled:cursor-not-allowed disabled:opacity-50"
}
>
<span className="text-text-neutral-tertiary text-xs tabular-nums">
{index + 1}
</span>
{SETUP_STEP_LABELS[step]}
{progress.done && step !== SETUP_STEP.ACCOUNT && (
<>
<Check aria-hidden="true" className="size-3.5" />
<span className="sr-only">done</span>
</>
)}
{!progress.required && (
<span className="text-text-neutral-tertiary text-xs">
optional
</span>
)}
</button>
</li>
);
})}
</ol>
);
}
function StepFrame({
step,
setup,
children,
}: {
step: SetupStep;
setup: RealtimeSetupPayload | undefined;
children: ReactNode;
}) {
const progress = stepProgress(step, setup);
return (
<section
aria-labelledby="setup-step-title"
className="flex max-w-4xl flex-col gap-5"
>
<div className="flex flex-wrap items-center gap-3">
<h2
id="setup-step-title"
className="text-text-neutral-primary text-lg font-semibold"
>
Step {SETUP_STEP_ORDER.indexOf(step) + 1} of {SETUP_STEP_ORDER.length}
: {SETUP_STEP_LABELS[step]}
</h2>
<Badge variant={progress.required ? "tag" : "outline"}>
{progress.required ? "Required" : "Optional"}
</Badge>
{progress.done && step !== SETUP_STEP.ACCOUNT && (
<Badge variant="success">Done</Badge>
)}
</div>
{children}
</section>
);
}
function StepNav({
step,
setup,
onGo,
hint,
}: {
step: SetupStep;
setup: RealtimeSetupPayload | undefined;
onGo: (step: SetupStep) => void;
hint?: string;
}) {
const previous = stepBefore(step);
const next = nextStepAfter(step);
const progress = stepProgress(step, setup);
const blocked = progress.required && !progress.done;
return (
<div className="flex flex-wrap items-center gap-3">
{previous && (
<Button variant="outline" onClick={() => onGo(previous)}>
Back
</Button>
)}
{next && (
<Button disabled={blocked} onClick={() => onGo(next)}>
{progress.required || progress.done ? "Continue" : "Skip for now"}
</Button>
)}
{blocked && hint && (
<span className="text-text-neutral-tertiary text-xs">{hint}</span>
)}
</div>
);
}
function SetupFlow({
providers,
initialProviderId,
startAtAccount,
lighthouseConfigured,
canManage,
}: {
providers: RealtimeProviderOption[];
initialProviderId?: string;
startAtAccount: boolean;
lighthouseConfigured?: boolean;
canManage: boolean;
}) {
const [providerId, setProviderId] = useState(
resolveProviderId(providers, initialProviderId) ?? "",
);
const [chosen, setChosen] = useState<SetupStep | null>(
startAtAccount ? SETUP_STEP.ACCOUNT : null,
);
const setup = useRealtimeResource(
() => getRealtimeSetup(providerId),
`setup:${providerId}`,
);
const policy = useRealtimeResource(
() => getRealtimePolicy(providerId),
`policy:${providerId}`,
);
const credential = useRealtimeResource(
() => getRealtimeCredential(providerId),
`credential:${providerId}`,
);
const frameworks = useRealtimeResource(
() => getRealtimeFrameworks(),
"frameworks",
);
// A save changes what is done; read it again without blanking the page.
const refreshSetup = async () => {
const result = await getRealtimeSetup(providerId);
if (result.ok) setup.setData(result.data);
// A full reload shows the failure with a retry instead of stale steps.
else setup.reload();
};
const provider = providers.find((item) => item.id === providerId);
const loading = setup.loading || policy.loading;
const failure = setup.failure ?? policy.failure;
const resumeAt = setup.data ? resumeStep(setup.data) : null;
const current: SetupStep | null = chosen ?? resumeAt;
const go = (step: SetupStep) => {
if (canEnterStep(step, setup.data)) setChosen(step);
};
const saved = (next: RealtimePolicy, advanceTo?: SetupStep) => {
policy.setData(next);
void refreshSetup();
if (advanceTo) setChosen(advanceTo);
};
const body = () => {
if (loading) return <PanelLoading label="Loading your setup" />;
if (failure) {
return (
<PanelFailure
failure={failure}
onRetry={() => {
setup.reload();
policy.reload();
}}
subject="your setup"
/>
);
}
if (setup.data?.activated && !chosen) {
return (
<PanelEmpty
title="Prowler Realtime is already on for this account"
description="Change anything later in Configuration."
>
<div className="flex gap-2">
<Button asChild>
<Link
href={`/realtime?provider=${encodeURIComponent(providerId)}`}
>
Open the dashboard
</Link>
</Button>
<Button asChild variant="outline">
<Link href={settingsHref(providerId)}>Configure</Link>
</Button>
</div>
</PanelEmpty>
);
}
if (!current) return null;
const flow = setup.data;
switch (current) {
case SETUP_STEP.ACCOUNT:
return (
<StepFrame step={current} setup={flow}>
<p className="text-text-neutral-secondary max-w-prose text-sm">
Choose the AWS account to set up. Prowler Realtime reads its
CloudTrail events and, only if you allow it, fixes it.
</p>
<ProviderSelect
providers={providers}
value={providerId}
onChange={(value) => {
setProviderId(value);
syncProviderToUrl({ provider: value });
}}
/>
<div>
<Button onClick={() => setChosen(resumeAt ?? SETUP_STEP.FEED)}>
Continue
</Button>
</div>
</StepFrame>
);
case SETUP_STEP.FEED:
return (
<StepFrame step={current} setup={flow}>
<FeedSetupPanel
key={providerId}
providerId={providerId}
canManage={canManage}
onFeedChanged={() => void refreshSetup()}
/>
<StepNav
step={current}
setup={flow}
onGo={go}
hint="Pick an event feed above to continue. CloudTrail lookup needs no setup in your account."
/>
</StepFrame>
);
case SETUP_STEP.ENFORCEMENT:
return (
<StepFrame step={current} setup={flow}>
<EnforcementStep
key={`${providerId}:${policy.data?.id ?? "new"}`}
providerId={providerId}
policy={policy.data ?? null}
frameworkCatalog={frameworks.data ?? []}
lighthouseConfigured={lighthouseConfigured}
onSaved={(next) => saved(next, SETUP_STEP.REMEDIATION)}
/>
<StepNav step={current} setup={flow} onGo={go} />
</StepFrame>
);
case SETUP_STEP.REMEDIATION:
return (
<StepFrame step={current} setup={flow}>
{credential.loading ? (
<PanelLoading label="Loading credentials" />
) : credential.failure ? (
<PanelFailure
failure={credential.failure}
onRetry={credential.reload}
subject="the credentials"
/>
) : (
<CredentialsForm
providerId={providerId}
credential={credential.data ?? null}
canManage={canManage}
onChanged={(next) => {
credential.setData(next);
void refreshSetup();
}}
/>
)}
<StepNav step={current} setup={flow} onGo={go} />
</StepFrame>
);
case SETUP_STEP.ALERTS:
return (
<StepFrame step={current} setup={flow}>
<AlertsSection
key={policy.data?.id ?? "new"}
providerId={providerId}
policy={policy.data ?? null}
canManage={canManage}
onSaved={(next) => saved(next)}
/>
<StepNav step={current} setup={flow} onGo={go} />
</StepFrame>
);
case SETUP_STEP.COST:
return (
<StepFrame step={current} setup={flow}>
<RealtimeSection
title="Cost anomalies"
description="Turn the cost signal on for this account and choose how big a jump counts as an anomaly."
>
<CostAnomalyForm
key={policy.data?.id ?? "new"}
providerId={providerId}
policy={policy.data ?? null}
canManage={canManage}
onSaved={(next) => saved(next)}
/>
</RealtimeSection>
<StepNav step={current} setup={flow} onGo={go} />
</StepFrame>
);
case SETUP_STEP.ACTIVATE:
return (
<StepFrame step={current} setup={flow}>
{flow ? (
<ReviewStep
providerId={providerId}
providerName={provider ? providerLabel(provider) : providerId}
policy={policy.data ?? null}
setup={flow}
canManage={canManage}
/>
) : null}
<StepNav step={current} setup={flow} onGo={go} />
</StepFrame>
);
}
};
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-2">
<p className="text-text-neutral-secondary max-w-prose text-sm">
Set up {REALTIME_PRODUCT_NAME_TEXT}. Nothing runs until you activate
it in the last step. Every choice is saved as you go, so you can leave
and come back where you stopped.
</p>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1">
<LighthouseSetupLink className="text-text-neutral-tertiary text-xs" />
<Link
href="/realtime"
className="text-button-tertiary text-xs underline underline-offset-4"
>
Back to the demo dashboard
</Link>
</div>
</div>
{current && (
<Progress current={current} setup={setup.data} onSelect={go} />
)}
{body()}
</div>
);
}
/**
* The resumable setup wizard. Gated like the dashboard: it needs an AWS
* account and the permission to change settings; without either it explains
* and the demo dashboard stays as it is.
*/
export function RealtimeSetupWizard({
providers,
initialProviderId,
lighthouseConfigured,
}: RealtimeSetupWizardProps) {
const { permissions } = useAuth();
const canManage = permissions.manage_prowler_realtime === true;
if (providers.length === 0) {
return (
<PanelEmpty
title="Connect an AWS account first"
description="Prowler Realtime Detection and Remediation works on AWS accounts, and you have none connected yet. Add an AWS provider, then come back here to set it up. Until then the dashboard shows demo data."
>
<Button asChild>
<Link href="/providers">Go to Providers</Link>
</Button>
</PanelEmpty>
);
}
if (!canManage) {
return (
<div className="flex max-w-3xl flex-col gap-4">
<Alert variant="info">
<AlertTitle>You cannot set this up</AlertTitle>
<AlertDescription>
<p>
Setting up {REALTIME_PRODUCT_NAME_TEXT} needs the Manage Realtime
permission. Ask an administrator, or keep exploring the demo
dashboard.
</p>
</AlertDescription>
</Alert>
<div>
<Button asChild variant="outline">
<Link href="/realtime">Back to the dashboard</Link>
</Button>
</div>
</div>
);
}
return (
<SetupFlow
providers={providers}
initialProviderId={initialProviderId}
startAtAccount={!providers.some((item) => item.id === initialProviderId)}
lighthouseConfigured={lighthouseConfigured}
canManage={canManage}
/>
);
}
@@ -0,0 +1,275 @@
"use client";
import { useRouter } from "next/navigation";
import { type ReactNode, useState } from "react";
import {
getRealtimeFrameworks,
getRealtimeNotificationTargets,
patchRealtimePolicy,
} from "@/actions/realtime";
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 { Modal } from "@/components/shadcn/modal";
import { useToast } from "@/components/shadcn/toast";
import {
frameworkLabel,
humanize,
modeLabel,
policyChangesAccount,
} from "@/lib/realtime/display";
import { notificationsSummary } from "@/lib/realtime/notifications";
import { stepProgress } from "@/lib/realtime/setup";
import {
REALTIME_MODE,
REALTIME_SETUP_STEP,
type RealtimePolicy,
type RealtimeSetupPayload,
} from "@/types/realtime";
interface ReviewStepProps {
providerId: string;
providerName: string;
policy: RealtimePolicy | null;
setup: RealtimeSetupPayload;
canManage: boolean;
}
function Row({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="border-border-neutral-secondary flex flex-col gap-1 border-b py-3 last:border-b-0 sm:flex-row sm:gap-6">
<dt className="text-text-neutral-secondary w-56 shrink-0 text-sm font-medium">
{label}
</dt>
<dd className="text-text-neutral-primary flex flex-col gap-0.5 text-sm">
{children}
</dd>
</div>
);
}
/**
* Step 7: every choice in one place. `enabled: true` is sent from here and
* nowhere else; going live in the account asks once more.
*/
export function ReviewStep({
providerId,
providerName,
policy,
setup,
canManage,
}: ReviewStepProps) {
const router = useRouter();
const { toast } = useToast();
const [confirmLive, setConfirmLive] = useState(false);
const [activating, setActivating] = useState(false);
const [error, setError] = useState<string | undefined>();
const frameworks = useRealtimeResource(
() => getRealtimeFrameworks(),
"frameworks",
);
const targets = useRealtimeResource(
() => getRealtimeNotificationTargets(),
"notification-targets",
);
const feed = stepProgress(REALTIME_SETUP_STEP.FEED, setup);
const role = stepProgress(REALTIME_SETUP_STEP.REMEDIATION, setup);
const activate = async () => {
if (!policy) return;
setActivating(true);
setError(undefined);
const result = await patchRealtimePolicy({
providerId,
policyId: policy.id,
patch: { enabled: true },
});
setActivating(false);
setConfirmLive(false);
if (result.ok) {
toast({
title: "Prowler Realtime is on",
description: "The dashboard now shows your account's own data.",
});
router.push(`/realtime?provider=${encodeURIComponent(providerId)}`);
} else {
setError(result.error);
toast({
variant: "destructive",
title:
result.status === 403
? "You cannot activate Prowler Realtime"
: "Could not activate Prowler Realtime",
description:
result.status === 403
? "Activating needs the Manage Realtime permission."
: result.error,
});
}
};
const onActivate = () => {
if (!policy) return;
if (policyChangesAccount({ ...policy, enabled: true })) {
setConfirmLive(true);
return;
}
void activate();
};
const alerts = policy
? notificationsSummary(policy.notifications, targets.data)
: [];
const scoped = policy ? Object.entries(policy.scope_tags) : [];
return (
<div className="flex flex-col gap-6">
<p className="text-text-neutral-secondary max-w-prose text-sm">
Check your choices. Nothing is switched on until you press Activate
below; until then the dashboard keeps showing demo data.
</p>
<dl aria-label="Summary of your choices" className="flex flex-col">
<Row label="AWS account">{providerName}</Row>
<Row label="Event feed">
<span>{feed.detail ?? (feed.done ? "Set up" : "Not set up")}</span>
</Row>
{policy ? (
<>
<Row label="Frameworks enforced">
{policy.frameworks.length > 0 ? (
<span className="flex flex-wrap gap-1.5">
{policy.frameworks.map((id) => (
<Badge key={id} variant="tag">
{frameworkLabel(id, frameworks.data)}
</Badge>
))}
</span>
) : (
<span>Every check</span>
)}
</Row>
<Row label="What Prowler does">
<span>{modeLabel(policy.mode)}</span>
{policy.mode === REALTIME_MODE.AUTO && (
<span className="text-text-neutral-tertiary text-xs">
{policy.dry_run
? "Dry run: records what it would change"
: "Live: changes the account"}
</span>
)}
{policy.mode !== REALTIME_MODE.AUTO && (
<span className="text-text-neutral-tertiary text-xs">
Dry run {policy.dry_run ? "on" : "off"}
</span>
)}
</Row>
<Row label="Minimum severity">
{humanize(policy.min_severity)} and above
</Row>
<Row label="Scope">
{scoped.length > 0
? scoped.map(([key, value]) => `${key}=${value}`).join(", ")
: "Whole account"}
</Row>
<Row label="Lighthouse AI code fixes">
{policy.use_lighthouse ? "On" : "Off"}
</Row>
</>
) : (
<Row label="Enforcement">Not saved yet</Row>
)}
<Row label="Remediation permission">
<span>
{role.done
? (role.detail ?? "Role set")
: "Not set: Prowler cannot change the account"}
</span>
</Row>
<Row label="Alerts">
{alerts.length > 0 ? (
alerts.map((line) => <span key={line}>{line}</span>)
) : (
<span>Off: nothing is sent</span>
)}
</Row>
<Row label="Cost anomalies">
{policy?.cost_anomaly_enabled
? `On (from ${policy.cost_anomaly_min_ratio}x and $${policy.cost_anomaly_min_delta})`
: "Off"}
</Row>
</dl>
{policy && policyChangesAccount({ ...policy, enabled: true }) && (
<Alert variant="warning">
<AlertTitle>This will change your AWS account</AlertTitle>
<AlertDescription>
<p>
Mode is Fix automatically and dry run is off. Prowler Realtime
will apply supported fixes as soon as it detects a matching risky
change.
</p>
</AlertDescription>
</Alert>
)}
{error && (
<p role="alert" className="text-text-error-primary text-sm">
{error}
</p>
)}
{canManage ? (
<div>
<Button disabled={!policy || activating} onClick={onActivate}>
{activating ? "Activating…" : "Activate Prowler Realtime"}
</Button>
</div>
) : (
<p className="text-text-neutral-tertiary text-sm">
Read only: activating needs the Manage Realtime permission.
</p>
)}
<Modal
open={confirmLive}
onOpenChange={(open) => {
if (!open && !activating) setConfirmLive(false);
}}
title="Let Prowler Realtime change your AWS account?"
description="Mode is Fix automatically, dry run is off and you are about to activate."
size="lg"
>
<div className="flex flex-col gap-4">
<p className="text-text-neutral-secondary text-sm">
Prowler Realtime will apply supported fixes in this account when it
detects a matching risky change, using the remediation role. Most
fixes record the exact call that undoes them, and you can revert
those from the Remediation tab of the dashboard. Fixes marked
&ldquo;Cannot be undone&rdquo;, such as restarting CloudTrail
logging, cannot be reverted.
</p>
<div className="flex justify-end gap-2">
<Button
variant="outline"
disabled={activating}
onClick={() => setConfirmLive(false)}
>
Cancel
</Button>
<Button
variant="destructive"
disabled={activating}
onClick={() => void activate()}
>
{activating ? "Activating…" : "Activate with live fixes"}
</Button>
</div>
</div>
</Modal>
</div>
);
}
+130
View File
@@ -0,0 +1,130 @@
import { Alert, AlertDescription, AlertTitle } from "@/components/shadcn/alert";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/shadcn/card/card";
import { modeLabel } from "@/lib/realtime/display";
import {
REALTIME_MODE,
type RealtimeSimulationPayload,
} from "@/types/realtime";
function Outcome({ value, label }: { value: number; label: string }) {
return (
<div className="flex flex-col">
<span className="text-text-neutral-primary text-3xl font-semibold tabular-nums">
{value}
</span>
<span className="text-text-neutral-secondary text-sm">{label}</span>
</div>
);
}
/** What the current policy would do to today's open findings. Changes nothing. */
export function SimulationCard({
simulation,
}: {
simulation: RealtimeSimulationPayload;
}) {
const {
would_fix_in_account: fixes,
would_propose_in_code: proposals,
needs_a_person: manual,
} = simulation;
const isAuto = simulation.mode === REALTIME_MODE.AUTO;
return (
<Card variant="base" className="gap-4">
<CardHeader>
<CardTitle className="mt-0 text-base">
If the policy ran today
</CardTitle>
<p className="text-text-neutral-tertiary text-sm">
Based on {simulation.open} open finding
{simulation.open === 1 ? "" : "s"} in mode{" "}
<strong>{modeLabel(simulation.mode)}</strong>. This is a preview: it
changes nothing.
</p>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<Outcome value={fixes} label="fixed in the account" />
<Outcome value={proposals} label="pull requests" />
<Outcome value={manual} label="need a person" />
</div>
{simulation.outside_frameworks > 0 && (
<p className="text-text-neutral-secondary text-sm">
<strong>{simulation.outside_frameworks}</strong> outside the
enforced frameworks: detected and alerted, never changed.
</p>
)}
{simulation.open > 0 && (
<p className="text-text-neutral-secondary text-sm">
Automation would handle {simulation.automation_share}% of open
findings, leaving {simulation.projected_open_after_auto} open after
automatic fixes.
</p>
)}
{simulation.effects.length > 0 && (
<div className="flex flex-col gap-2">
<h4 className="text-text-neutral-primary text-sm font-medium">
What automatic fixes would do
</h4>
<ul className="flex flex-col gap-1">
{simulation.effects.map((effect) => (
<li
key={effect.check_id}
className="text-text-neutral-secondary text-sm"
>
<code className="text-text-neutral-primary text-xs">
{effect.check_id}
</code>
: {effect.effect}{" "}
<span className="text-text-neutral-tertiary">
({effect.resources} resource
{effect.resources === 1 ? "" : "s"})
</span>
</li>
))}
</ul>
</div>
)}
{isAuto && simulation.dry_run && (
<Alert variant="info">
<AlertTitle>Dry run is on</AlertTitle>
<AlertDescription>
<p>
Prowler Realtime records what it would change and changes
nothing in your AWS account. Turn dry run off in the policy to
apply fixes.
</p>
</AlertDescription>
</Alert>
)}
{isAuto && !simulation.dry_run && (
<Alert variant="warning">
<AlertTitle>Dry run is off</AlertTitle>
<AlertDescription>
<p>
With the policy enabled, these fixes are applied to your AWS
account as soon as a matching change is detected.
</p>
</AlertDescription>
</Alert>
)}
{!isAuto && (
<p className="text-text-neutral-tertiary text-xs">
Automatic fixes in the account are off in this mode, so none are
counted. Switch the policy to Fix automatically to preview them.
</p>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,137 @@
"use client";
import { RealtimeSection } from "@/components/realtime/realtime-stat-card";
import {
PanelFailure,
PanelLoading,
} from "@/components/realtime/realtime-states";
import { useRealtimeInsight } from "@/components/realtime/use-realtime-insight";
import { Button } from "@/components/shadcn/button/button";
import { useToast } from "@/components/shadcn/toast";
import { modeLabel } from "@/lib/realtime/display";
import { statementToJson, statementFilename } from "@/lib/realtime/statement";
import {
REALTIME_INSIGHT_KIND,
type RealtimeStatusPayload,
} from "@/types/realtime";
export function StatementBody({
statement,
}: {
statement: RealtimeStatusPayload;
}) {
const { toast } = useToast();
const json = statementToJson(statement);
const copy = async () => {
try {
await navigator.clipboard.writeText(json);
toast({ title: "Statement copied" });
} catch {
toast({
variant: "destructive",
title: "Could not copy",
description: "Select the text and copy it, or download the file.",
});
}
};
const download = () => {
const url = URL.createObjectURL(
new Blob([json], { type: "application/json" }),
);
const link = document.createElement("a");
link.href = url;
link.download = statementFilename(statement);
link.click();
URL.revokeObjectURL(url);
};
return (
<div className="flex flex-col gap-4">
<dl className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{[
["Date", statement.generated_on],
[
"Mode",
statement.mode === "off" ? "No policy" : modeLabel(statement.mode),
],
["Scope", statement.scope],
["Checks passing", String(statement.checks_passing)],
["Checks failing", String(statement.checks_failing)],
[
"Fixed and verified, last 30 days",
String(statement.fixed_and_verified_last_30_days),
],
].map(([label, value]) => (
<div key={label} className="flex flex-col">
<dt className="text-text-neutral-tertiary text-xs">{label}</dt>
<dd className="text-text-neutral-primary text-sm font-medium">
{value}
</dd>
</div>
))}
</dl>
<div className="flex flex-col gap-1">
<span className="text-text-neutral-tertiary text-xs">
SHA-256 stamp
</span>
<code className="text-text-neutral-secondary text-xs break-all">
{statement.sha256}
</code>
</div>
<pre
tabIndex={0}
aria-label="Status statement as JSON"
className="bg-bg-neutral-tertiary text-text-neutral-secondary max-h-80 overflow-auto rounded-lg p-4 text-xs"
>
{json}
</pre>
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => void copy()}>
Copy JSON
</Button>
<Button variant="outline" onClick={download}>
Download JSON
</Button>
</div>
</div>
);
}
const REFRESH_MS = 60_000;
/** The redacted, hash-stamped statement a third party can hold. */
export function StatusStatementCard({
providerId,
demo = false,
}: {
providerId: string;
demo?: boolean;
}) {
const status = useRealtimeInsight(
REALTIME_INSIGHT_KIND.STATUS,
providerId,
demo,
// Applied and reverted fixes change the counts and the stamp.
REFRESH_MS,
);
return (
<RealtimeSection
title="Status statement"
description="Counts only: no account ids, resources or people. The SHA-256 stamp covers every other field, so a change to the statement is detectable."
>
{status.loading ? (
<PanelLoading label="Loading the statement" />
) : status.failure ? (
<PanelFailure
failure={status.failure}
onRetry={status.reload}
subject="the statement"
/>
) : status.data ? (
<StatementBody statement={status.data} />
) : null}
</RealtimeSection>
);
}
+120
View File
@@ -0,0 +1,120 @@
"use client";
import Link from "next/link";
import { useId, useState } from "react";
import { getRealtimeDemo, getRealtimeEvents } from "@/actions/realtime";
import { EventsTable } from "@/components/realtime/events-table";
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 { Switch } from "@/components/shadcn/switch/switch";
import { settingsHref } from "@/lib/realtime/display";
const STREAM_REFRESH_MS = 5_000;
const STREAM_LIMIT = 100;
interface StreamTabProps {
providerId: string;
/** Show the sample events instead of the account's. */
demo?: boolean;
}
export function StreamTab({ providerId, demo = false }: StreamTabProps) {
const writesId = useId();
const liveId = useId();
const [writesOnly, setWritesOnly] = useState(true);
const [live, setLive] = useState(true);
// The sample is one fixed page: the filter is applied here, nothing refreshes.
const events = useRealtimeResource(
async () => {
if (!demo) {
return getRealtimeEvents({
providerId,
limit: STREAM_LIMIT,
writesOnly,
});
}
const result = await getRealtimeDemo("events");
return result.ok
? {
ok: true as const,
data: result.data.items.filter(
(event) => !writesOnly || !event.read_only,
),
}
: result;
},
demo ? `demo:events:${writesOnly}` : `events:${providerId}:${writesOnly}`,
{ intervalMs: live && !demo ? STREAM_REFRESH_MS : undefined },
);
return (
<div className="flex flex-col gap-8">
<RealtimeSection
title="Live CloudTrail stream"
description="The last 24 hours of API calls, newest first, with the detection each one raised."
action={
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2">
<Switch
id={writesId}
checked={writesOnly}
onCheckedChange={setWritesOnly}
/>
<label
htmlFor={writesId}
className="text-text-neutral-secondary text-sm"
>
Changes only
</label>
</div>
<div className="flex items-center gap-2">
<Switch
id={liveId}
checked={live && !demo}
disabled={demo}
onCheckedChange={setLive}
/>
<label
htmlFor={liveId}
className="text-text-neutral-secondary text-sm"
>
Auto-refresh ({STREAM_REFRESH_MS / 1000}s)
</label>
</div>
</div>
}
>
<div>
{events.loading ? (
<PanelLoading label="Loading events" />
) : events.failure ? (
<PanelFailure
failure={events.failure}
onRetry={events.reload}
subject="the event stream"
/>
) : events.data ? (
<EventsTable events={events.data} writesOnly={writesOnly} />
) : null}
</div>
</RealtimeSection>
<p className="text-text-neutral-tertiary text-sm">
The event feed (real-time queue or CloudTrail lookup) is set up in{" "}
<Link
href={settingsHref(providerId)}
className="text-button-tertiary underline underline-offset-4"
>
Configuration
</Link>
.
</p>
</div>
);
}
+313
View File
@@ -0,0 +1,313 @@
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
vi.mock("@/actions/realtime", () => ({}));
vi.mock("@/components/shadcn/toast", () => ({
useToast: () => ({ toast: vi.fn() }),
}));
import type {
RealtimeAction,
RealtimeEvent,
RealtimeRankedFinding,
RealtimeSimulationPayload,
} from "@/types/realtime";
import { ActionsTable } from "./actions-list";
import { actorLabel, EventsTable } from "./events-table";
import { RankedFindingsTable } from "./findings-tab";
import { SimulationCard } from "./simulation-card";
const action = (overrides: Partial<RealtimeAction>): RealtimeAction => ({
id: "a1",
provider_id: "p",
detection_id: null,
kind: "self_fix",
status: "fixed_verified",
check_id: "s3_bucket_level_public_access_block",
resource_uid: "bucket-1",
region: "eu-west-1",
severity: "high",
summary: "Enabled public access block",
pr_url: "",
branch: "",
revertible: false,
inserted_at: "2026-10-23T10:00:00Z",
...overrides,
});
describe("ActionsTable", () => {
it("shows Revert only for revertible actions and confirms through the callback", async () => {
const user = userEvent.setup();
const onRevert = vi.fn();
const revertible = action({ id: "a1", revertible: true });
render(
<ActionsTable
actions={[revertible, action({ id: "a2", resource_uid: "bucket-2" })]}
canManage
onRevert={onRevert}
/>,
);
const buttons = screen.getAllByRole("button", { name: "Revert" });
expect(buttons).toHaveLength(1);
await user.click(buttons[0]);
expect(onRevert).toHaveBeenCalledWith(revertible);
});
it("disables Revert without the permission", () => {
render(
<ActionsTable
actions={[action({ revertible: true })]}
canManage={false}
onRevert={vi.fn()}
/>,
);
expect(screen.getByRole("button", { name: "Revert" })).toBeDisabled();
});
it("links only safe pull request URLs", () => {
render(
<ActionsTable
actions={[
action({
id: "a1",
status: "pr_opened",
pr_url: "https://github.com/a/b/pull/1",
}),
action({ id: "a2", pr_url: "javascript:alert(1)" }),
]}
canManage
onRevert={vi.fn()}
/>,
);
const links = screen.getAllByRole("link", { name: "View pull request" });
expect(links).toHaveLength(1);
expect(links[0]).toHaveAttribute("rel", "noopener noreferrer");
});
it("marks Lighthouse AI code fixes and rejected proposals", () => {
render(
<ActionsTable
actions={[
action({
id: "l1",
kind: "code",
status: "pr_opened",
summary: "Lighthouse AI fix for s3_bucket_versioning",
}),
action({
id: "l2",
kind: "proposal",
status: "pr_blocked",
summary: "Lighthouse AI proposal rejected by the security gate",
}),
action({ id: "l3", summary: "Enabled public access block" }),
]}
canManage
onRevert={vi.fn()}
/>,
);
expect(screen.getAllByText("Lighthouse AI")).toHaveLength(2);
});
it("renders the empty state", () => {
render(<ActionsTable actions={[]} canManage onRevert={vi.fn()} />);
expect(
screen.getByText("Prowler Realtime has not acted yet"),
).toBeVisible();
});
});
const event = (overrides: Partial<RealtimeEvent>): RealtimeEvent => ({
id: "e1",
event_time: "2026-10-23T10:00:00Z",
region: "eu-west-1",
event_source: "ec2.amazonaws.com",
event_name: "AuthorizeSecurityGroupIngress",
user_type: "IAMUser",
user_arn: "arn:aws:iam::123456789012:user/ana",
source_ip: "203.0.113.9",
error_code: "",
read_only: false,
provider_id: "p",
detection_status: null,
rule_id: null,
...overrides,
});
describe("EventsTable", () => {
it("shows the detection badge, region, actor and IP", () => {
render(
<EventsTable
events={[
event({ detection_status: "violation", rule_id: "sg_open" }),
event({ id: "e2", event_name: "DescribeInstances", read_only: true }),
]}
writesOnly={false}
/>,
);
const rows = screen.getAllByRole("row");
expect(within(rows[1]).getByText("Violation")).toBeVisible();
expect(within(rows[1]).getByText("sg_open")).toBeVisible();
expect(within(rows[1]).getByText("user/ana")).toBeVisible();
expect(within(rows[1]).getByText("203.0.113.9")).toBeVisible();
expect(within(rows[2]).getByText("None")).toBeVisible();
});
it("derives the actor from the ARN or the user type", () => {
expect(actorLabel(event({}))).toBe("user/ana");
expect(actorLabel(event({ user_arn: "", user_type: "AWSService" }))).toBe(
"AWSService",
);
});
it("explains an empty stream", () => {
render(<EventsTable events={[]} writesOnly />);
expect(screen.getByText("No events in the last 24 hours")).toBeVisible();
});
});
describe("RankedFindingsTable", () => {
const finding = (overrides: Partial<RealtimeRankedFinding>) =>
({
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: 110,
reasons: ["cost spike in ec2"],
route: "auto",
...overrides,
}) as RealtimeRankedFinding;
it("shows score, reasons and the route badge", () => {
render(
<RankedFindingsTable
findings={[
finding({}),
finding({
finding_id: "f2",
route: "manual",
reasons: [],
score: 70,
}),
finding({ finding_id: "f3", route: "pr" }),
]}
/>,
);
expect(screen.getAllByText("110").length).toBeGreaterThan(0);
expect(screen.getAllByText("cost spike in ec2")).toHaveLength(2);
const table = within(screen.getByRole("table"));
expect(table.getByText("Auto-fix")).toBeVisible();
expect(table.getByText("Needs a person")).toBeVisible();
expect(table.getByText("Pull request")).toBeVisible();
expect(screen.getByText("Severity only")).toBeVisible();
});
it("labels findings outside the enforced frameworks and explains every route", () => {
render(
<RankedFindingsTable
findings={[finding({ finding_id: "f9", route: "not_enforced" })]}
/>,
);
const legend = screen.getByLabelText("Handled by legend");
expect(within(legend).getAllByText("Not enforced")).toHaveLength(1);
expect(
within(legend).getByText(/Outside the enforced compliance frameworks/),
).toBeVisible();
expect(within(legend).getByText("Auto-fix")).toBeVisible();
expect(
within(screen.getByRole("table")).getByText("Not enforced"),
).toBeVisible();
});
});
describe("SimulationCard", () => {
const simulation = (
overrides: Partial<RealtimeSimulationPayload>,
): RealtimeSimulationPayload => ({
open: 10,
would_fix_in_account: 4,
would_propose_in_code: 3,
needs_a_person: 3,
outside_frameworks: 0,
frameworks: [],
dry_run: true,
mode: "auto",
effects: [
{
check_id: "rds_instance_no_public_access",
effect: "Set PubliclyAccessible to false (undo recorded)",
resources: 2,
},
],
projected_open_after_auto: 6,
automation_share: 40,
...overrides,
});
it("states the three outcomes, the effects and the dry-run caveat", () => {
render(<SimulationCard simulation={simulation({})} />);
expect(screen.getByText("fixed in the account")).toBeVisible();
expect(screen.getByText("pull requests")).toBeVisible();
expect(screen.getByText("need a person")).toBeVisible();
expect(screen.getByText(/Set PubliclyAccessible/)).toBeVisible();
expect(screen.getByText("Dry run is on")).toBeVisible();
});
it("states how many findings are outside the enforced frameworks", () => {
render(
<SimulationCard
simulation={simulation({
outside_frameworks: 7,
frameworks: ["soc2_aws"],
})}
/>,
);
expect(screen.getByText(/outside the enforced frameworks/)).toBeVisible();
expect(screen.getByText("7")).toBeVisible();
});
it("says nothing about frameworks when none are outside", () => {
render(<SimulationCard simulation={simulation({})} />);
expect(
screen.queryByText(/outside the enforced frameworks/),
).not.toBeInTheDocument();
});
it("warns that fixes will be applied when dry run is off", () => {
render(<SimulationCard simulation={simulation({ dry_run: false })} />);
expect(screen.getByText("Dry run is off")).toBeVisible();
});
it("says automatic fixes are not counted outside auto mode", () => {
render(
<SimulationCard
simulation={simulation({ mode: "pr", would_fix_in_account: 0 })}
/>,
);
expect(
screen.getByText(/Automatic fixes in the account are off/),
).toBeVisible();
expect(screen.queryByText("Dry run is on")).not.toBeInTheDocument();
});
});
+94
View File
@@ -0,0 +1,94 @@
"use client";
import { Download } from "lucide-react";
import type { ReactNode } from "react";
import { Button } from "@/components/shadcn/button/button";
import { CodeSnippet } from "@/components/shadcn/code-snippet/code-snippet";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/shadcn/tabs/tabs";
import type { RealtimeTemplate } from "@/lib/realtime/templates";
interface TemplateGuideProps {
/** What the person does in their AWS account, in order. */
steps: ReactNode[];
cloudformation: RealtimeTemplate;
terraform: RealtimeTemplate;
}
const download = (template: RealtimeTemplate) => {
const url = URL.createObjectURL(
new Blob([template.content], { type: "text/plain" }),
);
const link = document.createElement("a");
link.href = url;
link.download = template.filename;
link.click();
URL.revokeObjectURL(url);
};
/**
* Tells the person what to do in their own AWS account: numbered steps and the
* template to apply, as CloudFormation or Terraform, ready to copy or download.
*/
export function TemplateGuide({
steps,
cloudformation,
terraform,
}: TemplateGuideProps) {
return (
<div className="flex flex-col gap-4">
<ol className="text-text-neutral-secondary flex list-decimal flex-col gap-1.5 pl-5 text-sm">
{steps.map((step, index) => (
<li key={index}>{step}</li>
))}
</ol>
<Tabs defaultValue="cloudformation">
<TabsList>
<TabsTrigger value="cloudformation">CloudFormation</TabsTrigger>
<TabsTrigger value="terraform">Terraform</TabsTrigger>
</TabsList>
{[
["cloudformation", cloudformation],
["terraform", terraform],
].map(([value, template]) => {
const item = template as RealtimeTemplate;
return (
<TabsContent key={value as string} value={value as string}>
<div className="flex flex-col gap-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<code className="text-xs">{item.filename}</code>
<div className="flex items-center gap-2">
<CodeSnippet
value={item.content}
hideCode
ariaLabel={`Copy ${item.filename}`}
/>
<Button
variant="outline"
size="sm"
onClick={() => download(item)}
>
<Download className="size-4" /> Download
</Button>
</div>
</div>
<pre
className="bg-bg-neutral-tertiary border-border-neutral-tertiary max-h-96 overflow-auto rounded-lg border p-3 text-xs"
tabIndex={0}
aria-label={`${item.filename} contents`}
>
{item.content}
</pre>
</div>
</TabsContent>
);
})}
</Tabs>
</div>
);
}
+46
View File
@@ -0,0 +1,46 @@
"use client";
import { Switch } from "@/components/shadcn/switch/switch";
/** A switch with its label and a one-line consequence under it. */
export function ToggleRow({
id,
label,
description,
checked,
disabled,
onChange,
}: {
id: string;
label: string;
description: string;
checked: boolean;
disabled: boolean;
onChange: (checked: boolean) => void;
}) {
return (
<div className="flex items-start gap-3">
<Switch
id={id}
checked={checked}
disabled={disabled}
aria-describedby={`${id}-description`}
onCheckedChange={onChange}
/>
<div className="flex flex-col gap-0.5">
<label
htmlFor={id}
className="text-text-neutral-primary text-sm font-medium"
>
{label}
</label>
<p
id={`${id}-description`}
className="text-text-neutral-tertiary max-w-prose text-xs"
>
{description}
</p>
</div>
</div>
);
}
@@ -0,0 +1,32 @@
"use client";
import { getRealtimeDemo, getRealtimeInsight } from "@/actions/realtime";
import { useRealtimeResource } from "@/components/realtime/use-realtime-resource";
import type {
RealtimeInsightKind,
RealtimeInsightPayloads,
RealtimeResult,
} from "@/types/realtime";
/**
* One computed view of an account. In demo mode it comes from the sample data
* instead (no provider needed, never refreshed): a view is either the account's
* or the sample's, never both.
*/
export function useRealtimeInsight<K extends RealtimeInsightKind>(
kind: K,
providerId: string,
demo: boolean,
intervalMs?: number,
) {
return useRealtimeResource<RealtimeInsightPayloads[K]>(
() =>
(demo
? getRealtimeDemo(kind)
: getRealtimeInsight(kind, providerId)) as Promise<
RealtimeResult<RealtimeInsightPayloads[K]>
>,
demo ? `demo:${kind}` : `${kind}:${providerId}`,
{ intervalMs: demo ? undefined : intervalMs },
);
}
@@ -0,0 +1,68 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { RealtimeResult } from "@/types/realtime";
import { useRealtimeResource } from "./use-realtime-resource";
const ok = (data: string): RealtimeResult<string> => ({ ok: true, data });
const deferred = () => {
let resolve!: (value: RealtimeResult<string>) => void;
const promise = new Promise<RealtimeResult<string>>((r) => (resolve = r));
return { promise, resolve };
};
describe("useRealtimeResource", () => {
afterEach(() => vi.useRealTimers());
it("hides the previous key's data until the new key loads", async () => {
const next = deferred();
const fetcher = vi
.fn<() => Promise<RealtimeResult<string>>>()
.mockResolvedValueOnce(ok("p1"))
.mockReturnValueOnce(next.promise);
const { result, rerender } = renderHook(
({ key }) => useRealtimeResource(fetcher, key),
{ initialProps: { key: "p1" } },
);
await waitFor(() => expect(result.current.data).toBe("p1"));
rerender({ key: "p2" });
expect(result.current.data).toBeUndefined();
expect(result.current.loading).toBe(true);
await act(async () => next.resolve(ok("p2")));
expect(result.current.data).toBe("p2");
});
it("turns a rejected fetch into a failure", async () => {
const fetcher = vi.fn().mockRejectedValue(new Error("transport"));
const { result } = renderHook(() => useRealtimeResource(fetcher, "k"));
await waitFor(() => expect(result.current.failure?.status).toBe(0));
expect(result.current.loading).toBe(false);
});
it("drops a poll answer that a newer poll overtook", async () => {
vi.useFakeTimers();
const slow = deferred();
const fetcher = vi
.fn<() => Promise<RealtimeResult<string>>>()
.mockResolvedValueOnce(ok("first"))
.mockReturnValueOnce(slow.promise)
.mockResolvedValueOnce(ok("newest"));
const { result } = renderHook(() =>
useRealtimeResource(fetcher, "k", { intervalMs: 1000 }),
);
await act(async () => {});
expect(result.current.data).toBe("first");
await act(async () => vi.advanceTimersByTime(1000)); // starts the slow poll
await act(async () => vi.advanceTimersByTime(1000)); // newer poll wins
expect(result.current.data).toBe("newest");
await act(async () => slow.resolve(ok("stale")));
expect(result.current.data).toBe("newest");
});
});
@@ -0,0 +1,84 @@
"use client";
import { useEffect, useRef, useState } from "react";
import type { RealtimeResult } from "@/types/realtime";
interface UseRealtimeResourceOptions {
/** Silent re-fetch period. Paused while the tab is hidden. */
intervalMs?: number;
enabled?: boolean;
}
const UNREACHABLE =
"Prowler could not reach the Prowler Realtime service. Try again.";
/**
* Loads one Prowler Realtime server action result and keeps it fresh.
*
* `key` identifies what is loaded (provider, filters, page): a new key hides
* the old result and shows the loading state, while interval refreshes swap
* the data in place so a live table does not flicker.
*/
export function useRealtimeResource<T>(
fetcher: () => Promise<RealtimeResult<T>>,
key: string,
{ intervalMs, enabled = true }: UseRealtimeResourceOptions = {},
) {
// The result remembers the key it was loaded for, so a new key never
// renders the previous key's data while its first request is in flight.
const [state, setState] = useState<
{ key: string; result: RealtimeResult<T> } | undefined
>();
const [reloadCount, setReloadCount] = useState(0);
const fetcherRef = useRef(fetcher);
const requestRef = useRef(0);
const loadKey = `${key}#${reloadCount}`;
useEffect(() => {
fetcherRef.current = fetcher;
});
useEffect(() => {
if (!enabled) return;
let active = true;
const load = async () => {
const request = ++requestRef.current;
let next: RealtimeResult<T>;
try {
next = await fetcherRef.current();
} catch {
next = { ok: false, status: 0, error: UNREACHABLE };
}
// A newer request or a key change overtook this one: drop its answer.
if (active && request === requestRef.current)
setState({ key: loadKey, result: next });
};
void load();
const timer = intervalMs
? setInterval(() => {
if (document.visibilityState === "visible") void load();
}, intervalMs)
: undefined;
return () => {
active = false;
if (timer) clearInterval(timer);
};
}, [loadKey, enabled, intervalMs]);
const result = state?.key === loadKey ? state.result : undefined;
return {
result,
loading: enabled && !result,
data: result?.ok ? result.data : undefined,
failure: result && !result.ok ? result : undefined,
reload: () => setReloadCount((count) => count + 1),
/** Replace the data without a round trip, e.g. with a save's answer. */
setData: (data: T) =>
setState({ key: loadKey, result: { ok: true, data } }),
};
}
@@ -73,6 +73,12 @@ vi.mock("@/lib", () => ({
label: "Manage Registry",
description: "Allows managing tenant Registry credentials and artifacts",
},
{
field: "manage_prowler_realtime",
label: "Manage Realtime",
description:
"Allows setting up Prowler Realtime Detection and Remediation, including its policies, alerts and fixes",
},
{
field: "manage_billing",
label: "Manage Billing",
@@ -81,6 +87,13 @@ vi.mock("@/lib", () => ({
],
}));
const featureFlagMocks = vi.hoisted(() => ({ realtime: false }));
vi.mock("@/lib/feature-flags", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useFeatureFlag: () => featureFlagMocks.realtime,
}));
vi.mock("@/components/shadcn/select/enhanced-multi-select", () => ({
EnhancedMultiSelect: () => <div data-testid="group-select" />,
}));
@@ -127,6 +140,39 @@ describe("AddRoleForm", () => {
routerMocks.push.mockClear();
vi.mocked(addRole).mockClear();
vi.unstubAllEnvs();
featureFlagMocks.realtime = false;
});
it("shows Manage Realtime only when the Realtime flag is on", () => {
// Given
vi.stubEnv("UI_CLOUD_ENABLED", "true");
// When
const { unmount } = render(<AddRoleForm groups={[]} />);
// Then
expect(screen.queryByText("Manage Realtime")).not.toBeInTheDocument();
unmount();
featureFlagMocks.realtime = true;
render(<AddRoleForm groups={[]} />);
expect(screen.getByText("Manage Realtime")).toBeInTheDocument();
});
it("submits manage_prowler_realtime when granted in Prowler Cloud", async () => {
// Given
vi.stubEnv("UI_CLOUD_ENABLED", "true");
featureFlagMocks.realtime = true;
const user = userEvent.setup();
render(<AddRoleForm groups={[]} />);
// When
await user.type(screen.getByPlaceholderText("Enter role name"), "New role");
await user.click(screen.getByRole("checkbox", { name: "Manage Realtime" }));
await user.click(screen.getByRole("button", { name: "Add Role" }));
// Then
expect(submittedFormData().get("manage_prowler_realtime")).toBe("true");
});
it("shows Manage Alerts in Prowler Cloud", () => {
@@ -29,6 +29,7 @@ export const AddRoleForm = ({ groups }: { groups: RoleGroupOption[] }) => {
manage_alerts: false,
manage_lighthouse_ai_configuration: false,
manage_registry: false,
manage_prowler_realtime: false,
}),
};
@@ -58,6 +59,10 @@ export const AddRoleForm = ({ groups }: { groups: RoleGroupOption[] }) => {
String(values.manage_lighthouse_ai_configuration),
);
formData.append("manage_registry", String(values.manage_registry));
formData.append(
"manage_prowler_realtime",
String(values.manage_prowler_realtime),
);
}
if (values.groups && values.groups.length > 0) {
@@ -73,6 +73,12 @@ vi.mock("@/lib", () => ({
label: "Manage Registry",
description: "Allows managing tenant Registry credentials and artifacts",
},
{
field: "manage_prowler_realtime",
label: "Manage Realtime",
description:
"Allows setting up Prowler Realtime Detection and Remediation, including its policies, alerts and fixes",
},
{
field: "manage_billing",
label: "Manage Billing",
@@ -81,6 +87,13 @@ vi.mock("@/lib", () => ({
],
}));
const featureFlagMocks = vi.hoisted(() => ({ realtime: false }));
vi.mock("@/lib/feature-flags", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useFeatureFlag: () => featureFlagMocks.realtime,
}));
vi.mock("@/components/shadcn/select/enhanced-multi-select", () => ({
EnhancedMultiSelect: () => <div data-testid="group-select" />,
}));
@@ -103,10 +116,12 @@ beforeAll(() => {
const roleData = ({
manageProviders = false,
manageRegistry = false,
manageRealtime = false,
unlimitedVisibility = false,
}: {
manageProviders?: boolean;
manageRegistry?: boolean;
manageRealtime?: boolean;
unlimitedVisibility?: boolean;
} = {}) => ({
data: {
@@ -117,6 +132,7 @@ const roleData = ({
manage_providers: manageProviders,
manage_integrations: false,
manage_registry: manageRegistry,
manage_prowler_realtime: manageRealtime,
manage_scans: false,
unlimited_visibility: unlimitedVisibility,
groups: [],
@@ -145,6 +161,35 @@ describe("EditRoleForm", () => {
routerMocks.push.mockClear();
vi.mocked(updateRole).mockClear();
vi.unstubAllEnvs();
featureFlagMocks.realtime = false;
});
it("retains manage_prowler_realtime when updating a role in Prowler Cloud", async () => {
// Given
vi.stubEnv("UI_CLOUD_ENABLED", "true");
const user = userEvent.setup();
renderEditRoleForm({ manageRealtime: true });
// When
await user.click(screen.getByRole("button", { name: "Update Role" }));
// Then
expect(submittedFormData().get("manage_prowler_realtime")).toBe("true");
});
it("revokes manage_prowler_realtime when unchecked with the Realtime flag on", async () => {
// Given
vi.stubEnv("UI_CLOUD_ENABLED", "true");
featureFlagMocks.realtime = true;
const user = userEvent.setup();
renderEditRoleForm({ manageRealtime: true });
// When
await user.click(screen.getByRole("checkbox", { name: "Manage Realtime" }));
await user.click(screen.getByRole("button", { name: "Update Role" }));
// Then
expect(submittedFormData().get("manage_prowler_realtime")).toBe("false");
});
it("retains manage_registry when updating a role in Prowler Cloud", async () => {
@@ -37,6 +37,8 @@ export const EditRoleForm = ({
...roleData.data.attributes,
...(isCloudEnvironment && {
manage_registry: roleData.data.attributes.manage_registry ?? false,
manage_prowler_realtime:
roleData.data.attributes.manage_prowler_realtime ?? false,
}),
groups:
roleData.data.relationships?.provider_groups?.data.map((g) => g.id) || [],
@@ -66,6 +68,7 @@ export const EditRoleForm = ({
updatedFields.manage_lighthouse_ai_configuration =
values.manage_lighthouse_ai_configuration;
updatedFields.manage_registry = values.manage_registry;
updatedFields.manage_prowler_realtime = values.manage_prowler_realtime;
}
if (
@@ -31,6 +31,7 @@ import {
} from "@/components/shadcn/tooltip";
import { useFormServerErrors } from "@/hooks/use-form-server-errors";
import { useManageProvidersUnlimitedVisibility } from "@/hooks/use-manage-providers-unlimited-visibility";
import { FEATURE_FLAG, useFeatureFlag } from "@/lib/feature-flags";
import {
getUnlimitedVisibilityField,
getVisiblePermissionFormFields,
@@ -76,8 +77,11 @@ export const RoleForm = ({
});
const isCloudEnvironment = isCloud();
const visiblePermissionFormFields =
getVisiblePermissionFormFields(isCloudEnvironment);
const isRealtimeEnabled = useFeatureFlag(FEATURE_FLAG.REALTIME);
const visiblePermissionFormFields = getVisiblePermissionFormFields(
isCloudEnvironment,
isRealtimeEnabled,
);
const showUnlimitedVisibilityField = !!getUnlimitedVisibilityField();
const { setPermissionValue, setUnlimitedVisibility } =
+1
View File
@@ -17,6 +17,7 @@ export function useAuth() {
manage_alerts: false,
manage_lighthouse_ai_configuration: false,
manage_registry: false,
manage_prowler_realtime: false,
unlimited_visibility: false,
};
+1
View File
@@ -6,6 +6,7 @@ describe("FEATURE_FLAG", () => {
it("should map each flag to the key PostHog knows it by", () => {
expect(FEATURE_FLAG).toEqual({
ISSUES: "issues-feature",
REALTIME: "realtime-feature",
BILLING_SYSTEM_METRONOME: "BILLING_SYSTEM_METRONOME",
FINDINGS_TRIAL_PREVIEW: "findings-trial-preview",
});
+3
View File
@@ -6,6 +6,9 @@ export const FEATURE_FLAG = {
// Boolean flag gating the experimental Issues feature (contextual findings).
// Read by cloud only.
ISSUES: "issues-feature",
// Boolean flag gating Realtime (real-time compliance for AWS). Read by cloud
// only; the API side is gated separately by CLOUD_REALTIME_ENABLED.
REALTIME: "realtime-feature",
// Boolean flag evaluated server-side per tenant in cloud. When on, it pins
// the tenant to legacy billing; off or absent means the new Metronome system.
BILLING_SYSTEM_METRONOME: "BILLING_SYSTEM_METRONOME",
+6
View File
@@ -485,6 +485,12 @@ export const permissionFormFields: PermissionInfo[] = [
description: "Allows managing tenant Registry credentials and artifacts",
},
{
field: "manage_prowler_realtime",
label: "Manage Realtime",
description:
"Allows setting up Prowler Realtime Detection and Remediation, including its policies, alerts and fixes",
},
{
field: "manage_billing",
label: "Manage Billing",
+69
View File
@@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import {
DEMO_ANOMALY_ID,
demoAnomalyHref,
isRealtimeDemoMode,
setupHref,
} from "./demo";
import { anomalyHref, parseAnomalyId } from "./display";
describe("isRealtimeDemoMode", () => {
it("is a demo with no AWS provider at all", () => {
expect(isRealtimeDemoMode({ providerId: undefined, policy: null })).toBe(
true,
);
expect(
isRealtimeDemoMode({ providerId: "", policy: { enabled: true } }),
).toBe(true);
});
it("is a demo while the provider has no policy or a disabled one", () => {
expect(isRealtimeDemoMode({ providerId: "p", policy: null })).toBe(true);
expect(isRealtimeDemoMode({ providerId: "p", policy: undefined })).toBe(
true,
);
expect(
isRealtimeDemoMode({ providerId: "p", policy: { enabled: false } }),
).toBe(true);
});
it("shows the account's own data once the policy is enabled", () => {
expect(
isRealtimeDemoMode({ providerId: "p", policy: { enabled: true } }),
).toBe(false);
});
});
describe("demo links", () => {
it("links the setup wizard with the provider when there is one", () => {
expect(setupHref()).toBe("/realtime/setup");
expect(setupHref("a b")).toBe("/realtime/setup?provider=a%20b");
});
it("gives the sample anomaly one fixed page", () => {
expect(demoAnomalyHref()).toBe("/realtime/cost-anomalies/demo");
expect(demoAnomalyHref("p1")).toBe(
"/realtime/cost-anomalies/demo?provider=p1",
);
expect(anomalyHref(DEMO_ANOMALY_ID, "p1", true)).toBe(
"/realtime/cost-anomalies/demo?provider=p1",
);
expect(anomalyHref(DEMO_ANOMALY_ID, "", true)).toBe(
"/realtime/cost-anomalies/demo",
);
});
it("keeps real anomaly links as they were", () => {
expect(anomalyHref("EC2|us-east-1", "p1")).toBe(
"/realtime/cost-anomalies/EC2%7Cus-east-1?provider=p1",
);
});
it("parses the sample anomaly id", () => {
expect(parseAnomalyId(DEMO_ANOMALY_ID)).toEqual({
service: "Amazon Elastic Compute Cloud - Compute",
region: "us-east-1",
});
});
});
+45
View File
@@ -0,0 +1,45 @@
import type { RealtimePolicy } from "@/types/realtime";
export const REALTIME_PRODUCT_NAME_TEXT =
"Prowler Realtime Detection and Remediation";
export const REALTIME_SETUP_PATH = "/realtime/setup";
/** The Lighthouse AI chat route (the sidebar's "Lighthouse AI" entry). */
export const LIGHTHOUSE_CHAT_PATH = "/lighthouse";
/** Shown in place of every write control while sample data is on screen. */
export const DEMO_TOOLTIP = "Demo data";
/** The path segment of the sample cost anomaly's detail page. */
export const DEMO_ANOMALY_SLUG = "demo";
/** The id of the anomaly in the sample data: `<service>|<region>`. */
export const DEMO_ANOMALY_ID =
"Amazon Elastic Compute Cloud - Compute|us-east-1";
export const DEMO_BANNER_TITLE =
"DEMO DATA — a made-up account shown until you set up Prowler Realtime";
/**
* The dashboard shows sample data until the selected AWS account has a policy
* that is switched on, or when there is no AWS account at all. `policy` is the
* selected provider's policy, null when it has none.
*/
export const isRealtimeDemoMode = ({
providerId,
policy,
}: {
providerId: string | undefined;
policy: Pick<RealtimePolicy, "enabled"> | null | undefined;
}): boolean => !providerId || !policy?.enabled;
export const setupHref = (providerId?: string): string =>
providerId
? `${REALTIME_SETUP_PATH}?provider=${encodeURIComponent(providerId)}`
: REALTIME_SETUP_PATH;
export const demoAnomalyHref = (providerId?: string): string =>
`/realtime/cost-anomalies/${DEMO_ANOMALY_SLUG}${
providerId ? `?provider=${encodeURIComponent(providerId)}` : ""
}`;
+138
View File
@@ -0,0 +1,138 @@
import { describe, expect, it } from "vitest";
import {
actionStatusBadge,
anomalyHref,
frameworkLabel,
isDriftDetection,
isLighthouseAction,
parseAnomalyId,
ROUTE_LEGEND,
settingsHref,
detectionStatusBadge,
failureCopy,
formatRelativeTime,
humanize,
orderedSeverities,
policyChangesAccount,
routeBadge,
safeExternalUrl,
} from "./display";
describe("realtime display helpers", () => {
it("maps route values to the three labelled badges", () => {
expect(routeBadge("auto")).toEqual({ label: "Auto-fix", tone: "success" });
expect(routeBadge("pr").label).toBe("Pull request");
expect(routeBadge("manual").tone).toBe("warning");
});
it("labels findings outside the enforced frameworks", () => {
expect(routeBadge("not_enforced")).toEqual({
label: "Not enforced",
tone: "outline",
});
expect(Object.keys(ROUTE_LEGEND).sort()).toEqual(
["auto", "manual", "not_enforced", "pr"].sort(),
);
});
it("recognises compliance drift and Lighthouse AI actions", () => {
expect(isDriftDetection({ rule_id: "framework_drift" })).toBe(true);
expect(isDriftDetection({ rule_id: "s3_public_acl" })).toBe(false);
expect(isLighthouseAction({ summary: "Lighthouse AI fix for rds_x" })).toBe(
true,
);
expect(
isLighthouseAction({ summary: "Lighthouse AI proposal rejected: gate" }),
).toBe(true);
expect(isLighthouseAction({ summary: "Enabled public access block" })).toBe(
false,
);
});
it("names a framework from the catalog and falls back to its id", () => {
const catalog = [{ id: "soc2_aws", name: "SOC 2" }];
expect(frameworkLabel("soc2_aws", catalog)).toBe("SOC 2");
expect(frameworkLabel("pci_aws", catalog)).toBe("pci_aws");
expect(frameworkLabel("soc2_aws")).toBe("soc2_aws");
});
it("builds and parses anomaly routes", () => {
const id = "Amazon Elastic Compute Cloud - Compute|eu-west-1";
expect(parseAnomalyId(id)).toEqual({
service: "Amazon Elastic Compute Cloud - Compute",
region: "eu-west-1",
});
expect(parseAnomalyId("nopipe")).toBeNull();
expect(parseAnomalyId("|region")).toBeNull();
expect(parseAnomalyId("service|")).toBeNull();
expect(anomalyHref("EC2|eu-west-1", "p1")).toBe(
"/realtime/cost-anomalies/EC2%7Ceu-west-1?provider=p1",
);
expect(settingsHref("p1")).toBe("/realtime/settings?provider=p1");
expect(settingsHref(undefined)).toBe("/realtime/settings");
expect(settingsHref("p1", "/realtime/settings/cost-anomalies")).toBe(
"/realtime/settings/cost-anomalies?provider=p1",
);
});
it("tones action statuses and falls back for unknown ones", () => {
expect(actionStatusBadge("fixed_verified").tone).toBe("success");
expect(actionStatusBadge("fix_failed").tone).toBe("error");
expect(actionStatusBadge("pr_blocked").tone).toBe("error");
expect(actionStatusBadge("something_new")).toEqual({
label: "Something new",
tone: "tag",
});
});
it("names detection verdicts", () => {
expect(detectionStatusBadge("violation").tone).toBe("error");
expect(detectionStatusBadge("compliant").tone).toBe("success");
expect(detectionStatusBadge("out_of_scope").label).toBe("Out of scope");
});
it("only a live auto policy changes the account", () => {
const base = { enabled: true, mode: "auto", dry_run: false };
expect(policyChangesAccount(base)).toBe(true);
expect(policyChangesAccount({ ...base, dry_run: true })).toBe(false);
expect(policyChangesAccount({ ...base, mode: "pr" })).toBe(false);
expect(policyChangesAccount({ ...base, enabled: false })).toBe(false);
});
it("orders severities worst first and skips zeroes", () => {
expect(
orderedSeverities({ low: 1, critical: 2, medium: 0, high: 3 }),
).toEqual([
["critical", 2],
["high", 3],
["low", 1],
]);
});
it("formats relative times", () => {
const now = Date.parse("2026-10-23T12:00:00Z");
expect(formatRelativeTime("2026-10-23T11:59:50Z", now)).toBe("just now");
expect(formatRelativeTime("2026-10-23T11:50:00Z", now)).toBe("10 min ago");
expect(formatRelativeTime("2026-10-23T09:00:00Z", now)).toBe("3 h ago");
expect(formatRelativeTime(null, now)).toBe("never");
});
it("only links http(s) URLs", () => {
expect(safeExternalUrl("https://github.com/a/b/pull/1")).toContain(
"github.com",
);
expect(safeExternalUrl("javascript:alert(1)")).toBeNull();
expect(safeExternalUrl("not a url")).toBeNull();
});
it("explains a 503 as a missing data store and a 403 as no access", () => {
expect(failureCopy(503).title).toMatch(/data store not configured/i);
expect(failureCopy(403).title).toMatch(/do not have access/i);
expect(failureCopy(404).title).toMatch(/not available/i);
});
it("humanizes snake case", () => {
expect(humanize("access_denied_burst")).toBe("Access denied burst");
});
});
+375
View File
@@ -0,0 +1,375 @@
import { demoAnomalyHref } from "@/lib/realtime/demo";
import {
REALTIME_ACTION_STATUS,
REALTIME_DRIFT_RULE_ID,
REALTIME_DATA_STORE,
REALTIME_DETECTION_STATUS,
REALTIME_FEED_STATUS,
REALTIME_MODE,
REALTIME_ROUTE,
type RealtimeActionStatus,
type RealtimeDataStore,
type RealtimeDetectionStatus,
type RealtimeFeedStatus,
type RealtimeMode,
type RealtimeRoute,
} from "@/types/realtime";
/** Badge variants the shared `Badge` component defines. */
export const BADGE_TONE = {
SUCCESS: "success",
WARNING: "warning",
ERROR: "error",
INFO: "info",
TAG: "tag",
OUTLINE: "outline",
} as const;
export type BadgeTone = (typeof BADGE_TONE)[keyof typeof BADGE_TONE];
export interface BadgeDescriptor {
label: string;
tone: BadgeTone;
}
const ACTION_STATUS_BADGES: Record<RealtimeActionStatus, BadgeDescriptor> = {
[REALTIME_ACTION_STATUS.DRY_RUN]: { label: "Dry run", tone: BADGE_TONE.INFO },
[REALTIME_ACTION_STATUS.APPLIED]: {
label: "Applied, verifying",
tone: BADGE_TONE.INFO,
},
[REALTIME_ACTION_STATUS.FIXED_VERIFIED]: {
label: "Fixed and verified",
tone: BADGE_TONE.SUCCESS,
},
[REALTIME_ACTION_STATUS.FIX_FAILED]: {
label: "Fix failed",
tone: BADGE_TONE.ERROR,
},
[REALTIME_ACTION_STATUS.STILL_FAILING]: {
label: "Applied, still failing",
tone: BADGE_TONE.WARNING,
},
[REALTIME_ACTION_STATUS.PR_OPENED]: {
label: "Pull request opened",
tone: BADGE_TONE.SUCCESS,
},
[REALTIME_ACTION_STATUS.PR_READY]: {
label: "Branch prepared",
tone: BADGE_TONE.INFO,
},
[REALTIME_ACTION_STATUS.PR_BLOCKED]: {
label: "Blocked by security gate",
tone: BADGE_TONE.ERROR,
},
[REALTIME_ACTION_STATUS.QUEUED]: {
label: "Queued for review",
tone: BADGE_TONE.TAG,
},
[REALTIME_ACTION_STATUS.NOT_IN_CODE]: {
label: "Not managed in code",
tone: BADGE_TONE.WARNING,
},
[REALTIME_ACTION_STATUS.REVERTED]: {
label: "Reverted",
tone: BADGE_TONE.OUTLINE,
},
};
export const actionStatusBadge = (status: string): BadgeDescriptor =>
ACTION_STATUS_BADGES[status as RealtimeActionStatus] ?? {
label: humanize(status),
tone: BADGE_TONE.TAG,
};
const DETECTION_STATUS_BADGES: Record<
RealtimeDetectionStatus,
BadgeDescriptor
> = {
[REALTIME_DETECTION_STATUS.NEW]: {
label: "Verifying",
tone: BADGE_TONE.INFO,
},
[REALTIME_DETECTION_STATUS.VIOLATION]: {
label: "Violation",
tone: BADGE_TONE.ERROR,
},
[REALTIME_DETECTION_STATUS.COMPLIANT]: {
label: "Compliant",
tone: BADGE_TONE.SUCCESS,
},
[REALTIME_DETECTION_STATUS.OUT_OF_SCOPE]: {
label: "Out of scope",
tone: BADGE_TONE.OUTLINE,
},
[REALTIME_DETECTION_STATUS.ERROR]: {
label: "Check failed",
tone: BADGE_TONE.WARNING,
},
};
export const detectionStatusBadge = (status: string): BadgeDescriptor =>
DETECTION_STATUS_BADGES[status as RealtimeDetectionStatus] ?? {
label: humanize(status),
tone: BADGE_TONE.TAG,
};
const ROUTE_BADGES: Record<RealtimeRoute, BadgeDescriptor> = {
[REALTIME_ROUTE.AUTO]: { label: "Auto-fix", tone: BADGE_TONE.SUCCESS },
[REALTIME_ROUTE.PR]: { label: "Pull request", tone: BADGE_TONE.INFO },
[REALTIME_ROUTE.MANUAL]: {
label: "Needs a person",
tone: BADGE_TONE.WARNING,
},
[REALTIME_ROUTE.NOT_ENFORCED]: {
label: "Not enforced",
tone: BADGE_TONE.OUTLINE,
},
};
/** What each route means, for the legend under the ranked findings. */
export const ROUTE_LEGEND: Record<RealtimeRoute, string> = {
[REALTIME_ROUTE.AUTO]: "Fixed in the AWS account by Prowler Realtime.",
[REALTIME_ROUTE.PR]: "A pull request proposes the change in your code.",
[REALTIME_ROUTE.MANUAL]: "Nothing can be done safely without a person.",
[REALTIME_ROUTE.NOT_ENFORCED]:
"Outside the enforced compliance frameworks: detected and alerted, never changed.",
};
export const routeBadge = (route: string): BadgeDescriptor =>
ROUTE_BADGES[route as RealtimeRoute] ?? {
label: humanize(route),
tone: BADGE_TONE.TAG,
};
const FEED_STATUS_BADGES: Record<RealtimeFeedStatus, BadgeDescriptor> = {
[REALTIME_FEED_STATUS.CONNECTED]: {
label: "Connected",
tone: BADGE_TONE.SUCCESS,
},
[REALTIME_FEED_STATUS.ERROR]: { label: "Error", tone: BADGE_TONE.ERROR },
[REALTIME_FEED_STATUS.NOT_CONFIGURED]: {
label: "Not configured",
tone: BADGE_TONE.TAG,
},
};
export const feedStatusBadge = (status: string): BadgeDescriptor =>
FEED_STATUS_BADGES[status as RealtimeFeedStatus] ?? {
label: humanize(status),
tone: BADGE_TONE.TAG,
};
const DATA_STORE_BADGES: Record<RealtimeDataStore, BadgeDescriptor> = {
[REALTIME_DATA_STORE.OK]: {
label: "Data store ready",
tone: BADGE_TONE.SUCCESS,
},
[REALTIME_DATA_STORE.NOT_CONFIGURED]: {
label: "Data store not configured",
tone: BADGE_TONE.TAG,
},
[REALTIME_DATA_STORE.UNAVAILABLE]: {
label: "Data store unavailable",
tone: BADGE_TONE.ERROR,
},
};
export const dataStoreBadge = (state: string): BadgeDescriptor =>
DATA_STORE_BADGES[state as RealtimeDataStore] ?? {
label: humanize(state),
tone: BADGE_TONE.TAG,
};
export interface ModeDescription {
label: string;
/** What choosing this mode means for the account, in plain language. */
consequence: string;
}
export const MODE_DESCRIPTIONS: Record<RealtimeMode, ModeDescription> = {
[REALTIME_MODE.MONITOR]: {
label: "Monitor only",
consequence:
"Prowler Realtime watches and records risky changes and checks them against your compliance requirements. It never changes your account or your code.",
},
[REALTIME_MODE.PR]: {
label: "Propose a change",
consequence:
"Prowler Realtime prepares a fix in your infrastructure code for review. Nothing in the AWS account is changed until you merge and deploy it.",
},
[REALTIME_MODE.AUTO]: {
label: "Fix automatically",
consequence:
"Prowler Realtime fixes supported issues in the AWS account itself, using the remediation role, and records how to undo each change. Findings it cannot fix safely fall back to a pull request.",
},
};
export const modeLabel = (mode: string): string =>
MODE_DESCRIPTIONS[mode as RealtimeMode]?.label ?? humanize(mode);
/** A policy only changes the account when it is live, on and not in dry run. */
export const policyChangesAccount = (policy: {
enabled: boolean;
mode: string;
dry_run: boolean;
}): boolean =>
policy.enabled && policy.mode === REALTIME_MODE.AUTO && !policy.dry_run;
const SIGNAL_LABELS: Record<string, string> = {
root_usage: "Root account activity",
access_denied_burst: "Burst of denied API calls",
external_ip_write: "Sensitive changes from outside AWS",
recent_open_exposure: "Recent change that opens exposure",
};
export const signalLabel = (signal: string): string =>
SIGNAL_LABELS[signal] ?? humanize(signal);
/** `access_denied_burst` to `Access denied burst`. */
export function humanize(value: string): string {
const text = value.replace(/[_-]+/g, " ").trim();
return text ? text.charAt(0).toUpperCase() + text.slice(1) : "Unknown";
}
const SEVERITY_ORDER = ["critical", "high", "medium", "low", "informational"];
/** Severity counts as ordered entries, worst first, skipping zeroes. */
export const orderedSeverities = (
counts: Record<string, number> | undefined,
): Array<[string, number]> =>
Object.entries(counts ?? {})
.filter(([, count]) => count > 0)
.sort(
([a], [b]) =>
(SEVERITY_ORDER.indexOf(a) === -1 ? 99 : SEVERITY_ORDER.indexOf(a)) -
(SEVERITY_ORDER.indexOf(b) === -1 ? 99 : SEVERITY_ORDER.indexOf(b)),
);
export const formatDateTime = (value: string | null | undefined): string => {
if (!value) return "Never";
const date = new Date(value);
return Number.isNaN(date.getTime())
? "Unknown"
: date.toLocaleString("en-US", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
});
};
/** `3 min ago`, `just now`, `2 h ago`, `4 d ago`. */
export const formatRelativeTime = (
value: string | null | undefined,
now: number = Date.now(),
): string => {
if (!value) return "never";
const time = new Date(value).getTime();
if (Number.isNaN(time)) return "unknown";
const seconds = Math.max(0, Math.round((now - time) / 1000));
if (seconds < 45) return "just now";
const minutes = Math.round(seconds / 60);
if (minutes < 60) return `${minutes} min ago`;
const hours = Math.round(minutes / 60);
if (hours < 48) return `${hours} h ago`;
return `${Math.round(hours / 24)} d ago`;
};
export const formatMoney = (value: number | null | undefined): string =>
`$${(value ?? 0).toLocaleString("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}`;
/** Only http(s) links are rendered as links; anything else is shown as text. */
export const safeExternalUrl = (value: string): string | null => {
try {
const url = new URL(value);
return url.protocol === "https:" || url.protocol === "http:"
? url.toString()
: null;
} catch {
return null;
}
};
/** Why a panel is empty, from the status of the failing call. */
export const failureCopy = (
status: number,
): { title: string; description: string } => {
switch (status) {
case 403:
return {
title: "You do not have access to this",
description:
"Your role cannot read this Prowler Realtime data. Ask an administrator for access.",
};
case 404:
return {
title: "Prowler Realtime is not available",
description:
"Prowler Realtime is not enabled for this deployment, or the AWS provider no longer exists.",
};
case 503:
return {
title: "Real-time data store not configured",
description:
"This view needs Prowler Realtime's event data store (ClickHouse), which is not configured or not reachable. Compliance checks, policies and remediation keep working without it.",
};
default:
return {
title: "Could not load this view",
description: "Something went wrong while loading. Try again.",
};
}
};
/** Detections found by the reconciler rather than by a live event. */
export const isDriftDetection = (detection: { rule_id: string }): boolean =>
detection.rule_id === REALTIME_DRIFT_RULE_ID;
const LIGHTHOUSE_SUMMARY_PREFIXES = [
"Lighthouse AI fix",
"Lighthouse AI proposal rejected",
];
/** Actions whose code change Lighthouse AI wrote (or whose proposal the gate refused). */
export const isLighthouseAction = (action: { summary: string }): boolean =>
LIGHTHOUSE_SUMMARY_PREFIXES.some((prefix) =>
action.summary.startsWith(prefix),
);
/** The framework's name from the catalog, or its id while the catalog is missing. */
export const frameworkLabel = (
id: string,
catalog: Array<{ id: string; name: string }> = [],
): string => catalog.find((framework) => framework.id === id)?.name ?? id;
/** `<service>|<region>` to its parts; null when it is not an anomaly id. */
export const parseAnomalyId = (
id: string,
): { service: string; region: string } | null => {
const at = id.indexOf("|");
if (at <= 0 || at === id.length - 1) return null;
return { service: id.slice(0, at), region: id.slice(at + 1) };
};
/** The detail page of an anomaly; the id is URL-encoded. Sample anomalies have one fixed page. */
export const anomalyHref = (
id: string,
providerId: string,
demo = false,
): string =>
demo
? demoAnomalyHref(providerId || undefined)
: `/realtime/cost-anomalies/${encodeURIComponent(id)}?provider=${encodeURIComponent(providerId)}`;
export const REALTIME_SETTINGS_PATH = "/realtime/settings";
export const REALTIME_COST_SETTINGS_PATH = "/realtime/settings/cost-anomalies";
export const settingsHref = (
providerId?: string,
path = REALTIME_SETTINGS_PATH,
) => (providerId ? `${path}?provider=${encodeURIComponent(providerId)}` : path);
+70
View File
@@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import {
isSelectableRecipient,
notificationsSummary,
recipientStatusLabel,
sameNotifications,
toggleId,
} from "./notifications";
import { DEFAULT_REALTIME_NOTIFICATIONS } from "./policy-schema";
describe("alert helpers", () => {
it("only lets a confirmed recipient be selected", () => {
expect(
isSelectableRecipient({ id: "1", email: "a@x.io", status: "confirmed" }),
).toBe(true);
for (const status of ["pending", "unsubscribed", "bounced"]) {
expect(isSelectableRecipient({ id: "1", email: "a@x.io", status })).toBe(
false,
);
}
expect(recipientStatusLabel("unsubscribed")).toBe("Unsubscribed");
});
it("adds and removes an id once", () => {
expect(toggleId(["a"], "b", true)).toEqual(["a", "b"]);
expect(toggleId(["a", "b"], "b", true)).toEqual(["a", "b"]);
expect(toggleId(["a", "b"], "a", false)).toEqual(["b"]);
expect(toggleId(["a"], "z", false)).toEqual(["a"]);
});
it("compares alert settings by content", () => {
expect(
sameNotifications(
DEFAULT_REALTIME_NOTIFICATIONS,
structuredClone(DEFAULT_REALTIME_NOTIFICATIONS),
),
).toBe(true);
expect(
sameNotifications(DEFAULT_REALTIME_NOTIFICATIONS, {
...DEFAULT_REALTIME_NOTIFICATIONS,
min_severity: "low",
}),
).toBe(false);
});
it("summarises nothing when no channel is on", () => {
expect(notificationsSummary(DEFAULT_REALTIME_NOTIFICATIONS)).toEqual([]);
});
it("names the channels and recipients in the summary", () => {
expect(
notificationsSummary(
{
min_severity: "critical",
slack: { enabled: true, channel_ids: ["C1", "C9"] },
email: { enabled: true, recipient_ids: ["r1"] },
},
{
slack: [{ integration_id: "i", channel_id: "C1", name: "security" }],
email: [{ id: "r1", email: "a@x.io", status: "confirmed" }],
},
),
).toEqual([
"Slack: #security, C9",
"Email: a@x.io",
"From severity: Critical",
]);
});
});
+60
View File
@@ -0,0 +1,60 @@
import { humanize } from "@/lib/realtime/display";
import {
REALTIME_RECIPIENT_STATUS,
type RealtimeEmailTarget,
type RealtimeNotifications,
type RealtimeNotificationTargets,
} from "@/types/realtime";
export const SLACK_INTEGRATIONS_PATH = "/integrations";
export const ALERTS_PATH = "/alerts";
/** Only a recipient who confirmed by email can be sent to. */
export const isSelectableRecipient = (target: RealtimeEmailTarget): boolean =>
target.status === REALTIME_RECIPIENT_STATUS.CONFIRMED;
export const recipientStatusLabel = (status: string): string =>
humanize(status);
/** Add or remove one id, keeping order and uniqueness. */
export const toggleId = (
ids: string[],
id: string,
selected: boolean,
): string[] =>
selected
? ids.includes(id)
? ids
: [...ids, id]
: ids.filter((item) => item !== id);
/** True when two alert settings would send the same alerts to the same places. */
export const sameNotifications = (
a: RealtimeNotifications,
b: RealtimeNotifications,
): boolean => JSON.stringify(a) === JSON.stringify(b);
/** One line per channel for the review step; nothing on means nothing is sent. */
export const notificationsSummary = (
notifications: RealtimeNotifications,
targets?: RealtimeNotificationTargets,
): string[] => {
const lines: string[] = [];
if (notifications.slack.enabled) {
const names = notifications.slack.channel_ids.map((id) => {
const found = targets?.slack.find((item) => item.channel_id === id);
return found ? `#${found.name.replace(/^#/, "")}` : id;
});
lines.push(`Slack: ${names.join(", ") || "no channel selected"}`);
}
if (notifications.email.enabled) {
const emails = notifications.email.recipient_ids.map(
(id) => targets?.email.find((item) => item.id === id)?.email ?? id,
);
lines.push(`Email: ${emails.join(", ") || "no recipient selected"}`);
}
if (lines.length > 0) {
lines.push(`From severity: ${humanize(notifications.min_severity)}`);
}
return lines;
};
+240
View File
@@ -0,0 +1,240 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_REALTIME_NOTIFICATIONS,
DEFAULT_REALTIME_POLICY,
newScopeRow,
policyFieldErrors,
realtimeCostAnomalySchema,
realtimeCredentialSchema,
realtimeNotificationsSchema,
realtimePolicyPatchSchema,
scopeRowsToTags,
splitList,
} from "./policy-schema";
describe("policy schema", () => {
it("accepts the safe defaults", () => {
expect(policyFieldErrors(DEFAULT_REALTIME_POLICY)).toEqual({});
expect(DEFAULT_REALTIME_POLICY).toMatchObject({
mode: "pr",
dry_run: true,
enabled: false,
use_lighthouse: true,
frameworks: [],
cost_anomaly_enabled: false,
cost_anomaly_min_ratio: 2,
cost_anomaly_min_delta: 5,
});
});
it("allows at most five frameworks", () => {
const five = ["a", "b", "c", "d", "e"];
expect(
policyFieldErrors({ ...DEFAULT_REALTIME_POLICY, frameworks: five }),
).toEqual({});
expect(
policyFieldErrors({
...DEFAULT_REALTIME_POLICY,
frameworks: [...five, "f"],
}).frameworks,
).toMatch(/at most 5/);
});
it("keeps cost thresholds inside the API ranges", () => {
const check = (ratio: number, delta: number) =>
realtimeCostAnomalySchema.safeParse({
cost_anomaly_enabled: true,
cost_anomaly_min_ratio: ratio,
cost_anomaly_min_delta: delta,
}).success;
expect(check(1.1, 0)).toBe(true);
expect(check(20, 100000)).toBe(true);
expect(check(1.09, 5)).toBe(false);
expect(check(20.1, 5)).toBe(false);
expect(check(2, -1)).toBe(false);
expect(check(2, 100000.01)).toBe(false);
expect(check(Number.NaN, 5)).toBe(false);
expect(
policyFieldErrors({
...DEFAULT_REALTIME_POLICY,
cost_anomaly_min_ratio: 25,
}).cost_anomaly_min_ratio,
).toMatch(/between 1.1 and 20/);
});
it("rejects more than ten scope tags", () => {
const scope_tags = Object.fromEntries(
Array.from({ length: 11 }, (_, i) => [`k${i}`, "v"]),
);
expect(
policyFieldErrors({ ...DEFAULT_REALTIME_POLICY, scope_tags }).scope_tags,
).toMatch(/at most 10/);
});
it("rejects a tag key with an equals sign and empty values", () => {
expect(
policyFieldErrors({
...DEFAULT_REALTIME_POLICY,
scope_tags: { "a=b": "c" },
}).scope_tags,
).toMatch(/cannot contain/);
expect(
policyFieldErrors({
...DEFAULT_REALTIME_POLICY,
scope_tags: { a: " " },
}).scope_tags,
).toMatch(/both a key and a value/);
});
it("requires owner/repo, and a repo when pull requests are on", () => {
expect(
policyFieldErrors({ ...DEFAULT_REALTIME_POLICY, iac_repo: "nope" })
.iac_repo,
).toBeDefined();
expect(
policyFieldErrors({ ...DEFAULT_REALTIME_POLICY, open_pr: true }).iac_repo,
).toMatch(/repository/);
expect(
policyFieldErrors({
...DEFAULT_REALTIME_POLICY,
open_pr: true,
iac_repo: "acme/infra",
iac_path: "infra/terraform",
}),
).toEqual({});
});
it("rejects a path that climbs out of the repository", () => {
expect(
policyFieldErrors({ ...DEFAULT_REALTIME_POLICY, iac_path: "../x" })
.iac_path,
).toBeDefined();
});
it("turns editor rows into tags, dropping blanks and flagging problems", () => {
expect(
scopeRowsToTags([
newScopeRow("env", "prod"),
newScopeRow(),
newScopeRow("team", "a"),
]).tags,
).toEqual({ env: "prod", team: "a" });
expect(scopeRowsToTags([newScopeRow("env", "")]).error).toBeDefined();
expect(
scopeRowsToTags([newScopeRow("env", "a"), newScopeRow("env", "b")]).error,
).toMatch(/more than once/);
});
it("splits lists on commas, spaces and newlines without duplicates", () => {
expect(splitList("a, b\nc a")).toEqual(["a", "b", "c"]);
expect(splitList("")).toEqual([]);
});
it("requires a role to come with an external id and a valid ARN", () => {
expect(realtimeCredentialSchema.safeParse({}).success).toBe(false);
expect(
realtimeCredentialSchema.safeParse({ role_arn: "arn:aws:iam::1:role/x" })
.success,
).toBe(false);
expect(
realtimeCredentialSchema.safeParse({
role_arn: "arn:aws:iam::123456789012:role/ProwlerRemediation",
}).success,
).toBe(false);
expect(
realtimeCredentialSchema.safeParse({
role_arn: "arn:aws:iam::123456789012:role/ProwlerRemediation",
external_id: "abc",
}).success,
).toBe(true);
expect(
realtimeCredentialSchema.safeParse({ github_token: "token" }).success,
).toBe(true);
});
describe("alerts", () => {
const notifications = (
overrides: Partial<typeof DEFAULT_REALTIME_NOTIFICATIONS>,
) => ({ ...DEFAULT_REALTIME_NOTIFICATIONS, ...overrides });
it("sends nothing by default", () => {
expect(DEFAULT_REALTIME_POLICY.notifications).toEqual(
DEFAULT_REALTIME_NOTIFICATIONS,
);
expect(DEFAULT_REALTIME_NOTIFICATIONS.slack.enabled).toBe(false);
expect(DEFAULT_REALTIME_NOTIFICATIONS.email.enabled).toBe(false);
expect(
realtimeNotificationsSchema.safeParse(DEFAULT_REALTIME_NOTIFICATIONS)
.success,
).toBe(true);
});
it("needs a channel when Slack is on", () => {
const result = realtimeNotificationsSchema.safeParse(
notifications({ slack: { enabled: true, channel_ids: [] } }),
);
expect(result.success).toBe(false);
expect(!result.success && result.error.issues[0]).toMatchObject({
path: ["slack"],
message: expect.stringMatching(/Slack channel/),
});
});
it("needs a recipient when email is on", () => {
expect(
policyFieldErrors({
...DEFAULT_REALTIME_POLICY,
notifications: notifications({
email: { enabled: true, recipient_ids: [] },
}),
}).notifications,
).toMatch(/recipient/);
expect(
realtimeNotificationsSchema.safeParse(
notifications({ email: { enabled: true, recipient_ids: [] } }),
).success,
).toBe(false);
});
it("lets a channel that is off keep its selection", () => {
expect(
realtimeNotificationsSchema.safeParse(
notifications({ slack: { enabled: false, channel_ids: ["C1"] } }),
).success,
).toBe(true);
});
it("rejects an unknown severity", () => {
expect(
realtimeNotificationsSchema.safeParse({
...DEFAULT_REALTIME_NOTIFICATIONS,
min_severity: "urgent",
}).success,
).toBe(false);
});
it("is part of the policy and of its patch", () => {
const bad = {
...DEFAULT_REALTIME_POLICY,
notifications: notifications({
slack: { enabled: true, channel_ids: [] },
}),
};
expect(policyFieldErrors(bad).notifications).toMatch(/Slack channel/);
expect(
realtimePolicyPatchSchema.safeParse({ enabled: true }).data,
).toEqual({ enabled: true });
expect(
realtimePolicyPatchSchema.safeParse({
notifications: bad.notifications,
}).success,
).toBe(false);
});
});
});
+292
View File
@@ -0,0 +1,292 @@
import { z } from "zod";
import {
REALTIME_COST_DELTA_RANGE,
REALTIME_COST_RATIO_RANGE,
REALTIME_MAX_FRAMEWORKS,
REALTIME_MAX_SCOPE_TAGS,
REALTIME_MIN_SEVERITY,
REALTIME_MODE,
type RealtimeNotifications,
type RealtimePolicyInput,
} from "@/types/realtime";
const GITHUB_REPO = /^[\w.-]+\/[\w.-]+$/;
const IAC_PATH = /^[\w./@-]*$/;
const nonEmptyList = z.array(z.string().trim().min(1));
/** The cost anomaly signal's own settings; a policy and its own page share them. */
const costAnomalyShape = {
cost_anomaly_enabled: z.boolean(),
cost_anomaly_min_ratio: z
.number({ error: "Enter a number." })
.min(
REALTIME_COST_RATIO_RANGE.min,
`Must be between ${REALTIME_COST_RATIO_RANGE.min} and ${REALTIME_COST_RATIO_RANGE.max}.`,
)
.max(
REALTIME_COST_RATIO_RANGE.max,
`Must be between ${REALTIME_COST_RATIO_RANGE.min} and ${REALTIME_COST_RATIO_RANGE.max}.`,
),
cost_anomaly_min_delta: z
.number({ error: "Enter a number." })
.min(
REALTIME_COST_DELTA_RANGE.min,
`Must be between ${REALTIME_COST_DELTA_RANGE.min} and ${REALTIME_COST_DELTA_RANGE.max} dollars.`,
)
.max(
REALTIME_COST_DELTA_RANGE.max,
`Must be between ${REALTIME_COST_DELTA_RANGE.min} and ${REALTIME_COST_DELTA_RANGE.max} dollars.`,
),
};
export const realtimeCostAnomalySchema = z.object(costAnomalyShape);
const idList = z.array(z.string().trim().min(1));
/**
* Where alerts go. A channel that is turned on needs somewhere to send to; the
* API repeats this and also checks the destinations exist and are confirmed.
*/
export const realtimeNotificationsSchema = z
.object({
min_severity: z.enum(Object.values(REALTIME_MIN_SEVERITY)),
slack: z.object({ enabled: z.boolean(), channel_ids: idList }),
email: z.object({ enabled: z.boolean(), recipient_ids: idList }),
})
.superRefine((notifications, ctx) => {
if (
notifications.slack.enabled &&
!notifications.slack.channel_ids.length
) {
ctx.addIssue({
code: "custom",
path: ["slack"],
message: "Select at least one Slack channel, or turn Slack alerts off.",
});
}
if (
notifications.email.enabled &&
!notifications.email.recipient_ids.length
) {
ctx.addIssue({
code: "custom",
path: ["email"],
message: "Select at least one recipient, or turn email alerts off.",
});
}
});
/** Nothing is sent until a channel is turned on. */
export const DEFAULT_REALTIME_NOTIFICATIONS: RealtimeNotifications = {
min_severity: REALTIME_MIN_SEVERITY.HIGH,
slack: { enabled: false, channel_ids: [] },
email: { enabled: false, recipient_ids: [] },
};
/**
* What a policy may contain. Shared by the form (instant feedback) and the
* server action (never trust the client); the API repeats the rules.
*/
const policyShape = {
enabled: z.boolean(),
mode: z.enum(Object.values(REALTIME_MODE)),
dry_run: z.boolean(),
min_severity: z.enum(Object.values(REALTIME_MIN_SEVERITY)),
deny_checks: nonEmptyList,
allow_regions: nonEmptyList,
scope_tags: z.record(z.string(), z.string()).superRefine((tags, ctx) => {
const entries = Object.entries(tags);
if (entries.length > REALTIME_MAX_SCOPE_TAGS) {
ctx.addIssue({
code: "custom",
message: `A scope can have at most ${REALTIME_MAX_SCOPE_TAGS} tags.`,
});
}
entries.forEach(([key, value]) => {
if (!key.trim() || !value.trim()) {
ctx.addIssue({
code: "custom",
message: "Each scope tag needs both a key and a value.",
});
} else if (key.includes("=")) {
ctx.addIssue({
code: "custom",
message: `The tag key "${key}" cannot contain "=".`,
});
}
});
}),
iac_repo: z
.string()
.trim()
.max(200)
.refine((value) => value === "" || GITHUB_REPO.test(value), {
message: 'Use the "owner/repo" form.',
}),
iac_path: z
.string()
.trim()
.max(200)
.refine((value) => IAC_PATH.test(value) && !value.includes(".."), {
message: "Use a relative directory such as infra/terraform.",
}),
open_pr: z.boolean(),
use_lighthouse: z.boolean(),
frameworks: z
.array(z.string().trim().min(1))
.max(
REALTIME_MAX_FRAMEWORKS,
`Select at most ${REALTIME_MAX_FRAMEWORKS} frameworks.`,
),
...costAnomalyShape,
};
export const realtimePolicySchema = z
.object({ ...policyShape, notifications: realtimeNotificationsSchema })
.superRefine((policy, ctx) => {
if (policy.open_pr && !policy.iac_repo) {
ctx.addIssue({
code: "custom",
path: ["iac_repo"],
message: "Pull requests need a repository to open them in.",
});
}
});
/**
* Any subset of a policy, for a section that saves on its own (the setup
* wizard, the alerts section): fields left out keep their stored value.
*/
export const realtimePolicyPatchSchema = z
.object({ ...policyShape, notifications: realtimeNotificationsSchema })
.partial();
/** The first message per field, for display next to the inputs. */
export const policyFieldErrors = (
input: RealtimePolicyInput,
): Record<string, string> => {
const result = realtimePolicySchema.safeParse(input);
if (result.success) return {};
const errors: Record<string, string> = {};
result.error.issues.forEach((issue) => {
const field = String(issue.path[0] ?? "form");
if (!errors[field]) errors[field] = issue.message;
});
return errors;
};
export interface ScopeRow {
id: string;
key: string;
value: string;
}
export const newScopeRow = (key = "", value = ""): ScopeRow => ({
id: crypto.randomUUID(),
key,
value,
});
/**
* Editor rows to a scope. Fully empty rows are dropped; a half-filled row or
* a repeated key is an error (the API allows one value per key).
*/
export const scopeRowsToTags = (
rows: ScopeRow[],
): { tags: Record<string, string>; error?: string } => {
const tags: Record<string, string> = {};
for (const row of rows) {
const key = row.key.trim();
const value = row.value.trim();
if (!key && !value) continue;
if (!key || !value) {
return { tags, error: "Each scope tag needs both a key and a value." };
}
if (key in tags) {
return { tags, error: `The tag key "${key}" is used more than once.` };
}
tags[key] = value;
}
return { tags };
};
/** Comma, space or newline separated values, trimmed, unique, order kept. */
export const splitList = (raw: string): string[] =>
Array.from(
new Set(
raw
.split(/[\s,]+/)
.map((item) => item.trim())
.filter(Boolean),
),
);
/** The policy a provider gets before anyone saves one (the API's safe defaults). */
export const DEFAULT_REALTIME_POLICY: RealtimePolicyInput = {
enabled: false,
mode: REALTIME_MODE.PR,
dry_run: true,
min_severity: REALTIME_MIN_SEVERITY.LOW,
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: DEFAULT_REALTIME_NOTIFICATIONS,
};
const AWS_ROLE_ARN = /^arn:aws:iam::\d{12}:role\/[\w+=,.@/-]+$/;
/** Role and secrets for the credentials form; the API enforces the rest. */
export const realtimeCredentialSchema = z
.object({
role_arn: z.string().trim().optional(),
external_id: z.string().trim().optional(),
github_token: z.string().trim().optional(),
})
.superRefine((credential, ctx) => {
const { role_arn, external_id, github_token } = credential;
if (!role_arn && !github_token) {
ctx.addIssue({
code: "custom",
path: ["role_arn"],
message: "Enter a role ARN, a GitHub token, or both.",
});
}
if (role_arn && !AWS_ROLE_ARN.test(role_arn)) {
ctx.addIssue({
code: "custom",
path: ["role_arn"],
message: "Must be an IAM role ARN (arn:aws:iam::<account>:role/...).",
});
}
if (role_arn && !external_id) {
ctx.addIssue({
code: "custom",
path: ["external_id"],
message: "An external id is required with a role.",
});
}
});
export const realtimeFeedSchema = z.object({
queue_url: z
.string()
.trim()
.refine((value) => value === "" || value.startsWith("https://sqs."), {
message:
"Must be an SQS queue URL (https://sqs.<region>.amazonaws.com/...).",
}),
region: z
.string()
.trim()
.regex(/^[a-z]{2}(-[a-z]+)+-\d$/, "Use an AWS region such as us-east-1."),
});
+16
View File
@@ -0,0 +1,16 @@
export interface RealtimeProviderOption {
id: string;
uid: string;
alias: string | null;
}
/** The provider to show: the one in the URL when it is an AWS provider, else the first. */
export const resolveProviderId = (
providers: RealtimeProviderOption[],
requested?: string,
): string | undefined =>
providers.find((provider) => provider.id === requested)?.id ??
providers[0]?.id;
export const providerLabel = (provider: RealtimeProviderOption): string =>
provider.alias ? `${provider.alias} (${provider.uid})` : provider.uid;
Loaded 100 of 112 files, more files were not shown because too many files have changed in this diff. Show more