mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-11-03 01:44:19 +00:00
Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6613d6007 | ||
|
|
78a14f66ad | ||
|
|
e18ebe7ad9 | ||
|
|
dbb141e951 | ||
|
|
b5f535bcad | ||
|
|
17ad662105 | ||
|
|
fc31ff260d | ||
|
|
b5d086235f | ||
|
|
ba2d73a6d9 | ||
|
|
b50a7c5567 | ||
|
|
8ca66a62ff | ||
|
|
af0476124a | ||
|
|
f626aa7a87 | ||
|
|
7a78ca970a | ||
|
|
008864797b | ||
|
|
6999bbac9b | ||
|
|
2cc5c4b2f9 | ||
|
|
1a62d7d99a | ||
|
|
162996fa0a |
No files matched your search
@@ -174,7 +174,7 @@ SENTRY_RELEASE=local
|
||||
# REO_DEV_CLIENT_ID=
|
||||
|
||||
#### Prowler release version ####
|
||||
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.47.0
|
||||
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.48.0
|
||||
|
||||
# Social login credentials
|
||||
SOCIAL_GOOGLE_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/google"
|
||||
|
||||
@@ -4,6 +4,14 @@ All notable changes to the **Prowler API** are documented in this file.
|
||||
|
||||
<!-- changelog: release notes start -->
|
||||
|
||||
## [1.48.0] (Prowler v5.47.0)
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
- pymongo to 4.18.3, dnspython to 2.9.0 and oauthlib to 4.0.0 in the API container image, with django-allauth from 65.15.0 to 65.19.7 because earlier releases cap oauthlib below 4, patching the BSON decoding and DNS compression pointer denial of service advisories and the OAuth device flow token disclosure [(#13185)](https://github.com/prowler-cloud/prowler/pull/13185)
|
||||
|
||||
---
|
||||
|
||||
## [1.47.0] (Prowler v5.46.0)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
pymongo to 4.18.3, dnspython to 2.9.0 and oauthlib to 4.0.0 in the API container image, with django-allauth from 65.15.0 to 65.19.7 because earlier releases cap oauthlib below 4, patching the BSON decoding and DNS compression pointer denial of service advisories and the OAuth device flow token disclosure
|
||||
+1
-1
@@ -71,7 +71,7 @@ name = "prowler-api"
|
||||
package-mode = false
|
||||
# Needed for the SDK compatibility
|
||||
requires-python = ">=3.11,<3.13"
|
||||
version = "1.48.0"
|
||||
version = "1.49.0"
|
||||
|
||||
# Shared ruff baseline (kept in sync with mcp_server/pyproject.toml).
|
||||
# target-version tracks this project's lowest supported Python.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Prowler API
|
||||
version: 1.48.0
|
||||
version: 1.49.0
|
||||
description: |-
|
||||
Prowler API specification.
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,5 +1,5 @@
|
||||
version = 1
|
||||
revision = 2
|
||||
revision = 3
|
||||
requires-python = ">=3.11, <3.13"
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.12' and sys_platform == 'win32'",
|
||||
@@ -4945,7 +4945,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "prowler-api"
|
||||
version = "1.48.0"
|
||||
version = "1.49.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "cartography" },
|
||||
|
||||
+57
-1
@@ -4,7 +4,63 @@ description: "New features and improvements in each Prowler release"
|
||||
rss: true
|
||||
---
|
||||
|
||||
<Update label="v5.46.0" description="October 29, 2026">
|
||||
<Update label="v5.47.0" description="October 29, 2026">
|
||||
### 🧭 Add Provider — One Step to Connect Any Provider
|
||||
|
||||
The add-provider wizard follows the same model for every provider: one connect step, then the launch step. Adding an Azure subscription used to take six screens; now the account, its alias and its credentials are one form, and a single submit registers the account, stores the secret and tests the connection.
|
||||
|
||||
- A failed connection test shows inline, keeps the values and turns the action into **Retry connection** without registering the account twice.
|
||||
- Access methods show as cards on the same step for AWS, GCP, Microsoft 365, GitHub, Alibaba Cloud and Cloudflare, and AWS, Azure and GCP switch to their organization flow through tabs.
|
||||
- Users with the Manage Account permission can invite a teammate to finish the connection for every provider, not only AWS.
|
||||
- **Ask Lighthouse AI** opens beside the wizard without closing it. It guides the connection from the Prowler docs by default and, when asked, connects the provider itself after confirming the provider, UID and credential field names, never echoing a secret back.
|
||||
- Product tours and the guided onboarding sequence are removed; new tenants still land on the add-provider wizard.
|
||||
|
||||
### 🎯 Issues — Verdicts Explained in the List
|
||||
|
||||
<Note>
|
||||
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
|
||||
</Note>
|
||||
|
||||
- The issue list leads with the verdict, shows the attack path under each title and adds a **Why** column with the five evidence legs, so the reason for a verdict reads without opening the issue. The summary tiles become one line with the folded findings and the findings already covered by a control.
|
||||
- A row opens in the side panel, next to Lighthouse AI, with the verdict, legs, remediation, triage and an **Attack path** tab with the issue graph, instead of expanding the issue inline.
|
||||
- The menu entry is renamed to **Exploitability issues**.
|
||||
|
||||
### ☁️ Azure and AWS Checks
|
||||
|
||||
- `network_application_gateway_waf_enabled` and `network_application_gateway_http2_enabled` cover Azure Application Gateways and their WAF policies, mapped to CIS 5.0 and 6.0. Contributed by @BabafemiB. Thank you!
|
||||
- `iam_subscription_owners_between_2_and_3` verifies that each Azure subscription has between two and three Owners, counting owners inherited from management groups, mapped to CIS 5.0 and 6.0. Contributed by @BabafemiB. Thank you!
|
||||
- The AWS security group internet exposure checks treat CIDRs in one ingress rule that together cover `0.0.0.0/0` or `::/0`, such as `0.0.0.0/1` plus `128.0.0.0/1`, as open to the internet; such a rule used to pass `ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22` and its siblings. Contributed by @harshrajdebug. Thank you!
|
||||
|
||||
Explore all checks at [Prowler Hub](https://hub.prowler.com/check).
|
||||
|
||||
### 🛠️ Improvements and Fixes
|
||||
|
||||
- Every MCP Server tool declares `readOnlyHint` and `destructiveHint`, so clients can tell a listing from a write when deciding whether to ask the user. Mutelist and mute rule writes are marked destructive because they hide findings for good.
|
||||
- The cross-provider compliance tab keeps the frameworks that loaded, with a warning, when one catalog page or framework fails, instead of going blank.
|
||||
- The NATO D32 compliance framework has its own icon on the compliance cards.
|
||||
- Lighthouse AI replies no longer blink when the response finishes streaming, and the user avatar no longer requests the current page as an image, which rendered every page twice.
|
||||
- The Invitations page uses **Status** instead of **State**, and the Invitations and Roles filters show capitalized options.
|
||||
- **Prowler Cloud:** actions refused because the tenant is over its plan's usage limit, such as launching a scan, saving a schedule, adding a provider or triaging a finding, now link to the plans page.
|
||||
- **Prowler Cloud:** creating API keys is a Prowler Cloud feature; on Local Server the button opens the upgrade dialog, and existing keys can still be edited and revoked.
|
||||
- **Prowler Cloud:** issue evaluation after a scan reads the scan's findings through their indexes instead of a server-side cursor that took hours on large scans, so the evaluations no longer queue up behind each other.
|
||||
- **Prowler Private Cloud:** Lighthouse AI can use Amazon Bedrock with only a region, relying on the installation's own AWS role, when the API allows it.
|
||||
|
||||
### 🔐 Security Updates
|
||||
|
||||
- `pymongo` upgraded to 4.18.3, `dnspython` to 2.9.0 and `oauthlib` to 4.0.0 in the SDK and the API container image, patching the BSON decoding and DNS compression pointer denial of service advisories and the OAuth device flow token disclosure. The API also moves `django-allauth` to 65.19.7, since earlier releases cap `oauthlib` below 4.
|
||||
- `Authlib` upgraded to 1.9.1 and `dnspython` to 2.9.0 in the MCP Server container image, patching CVE-2026-96760 (critical), CVE-2026-114615, CVE-2026-114616 and CVE-2026-114605.
|
||||
|
||||
### 🙌 External Contributors
|
||||
|
||||
Thank you to our community contributors for this release!
|
||||
|
||||
- @BabafemiB: Azure `network_application_gateway_waf_enabled`, `network_application_gateway_http2_enabled` and `iam_subscription_owners_between_2_and_3` checks ([#13160](https://github.com/prowler-cloud/prowler/pull/13160), [#13145](https://github.com/prowler-cloud/prowler/pull/13145))
|
||||
- @harshrajdebug: AWS security group internet exposure fix for split CIDRs ([#13191](https://github.com/prowler-cloud/prowler/pull/13191))
|
||||
|
||||
See the [full release notes on GitHub](https://github.com/prowler-cloud/prowler/releases/tag/5.47.0) for the complete list of changes.
|
||||
</Update>
|
||||
|
||||
<Update label="v5.46.0" description="October 26, 2026">
|
||||
### 🎯 Issues — Attack Paths with an Exploitability Verdict
|
||||
|
||||
<Note>
|
||||
|
||||
@@ -132,8 +132,8 @@ To update the environment file:
|
||||
Edit the `.env` file and change version values:
|
||||
|
||||
```env
|
||||
PROWLER_UI_VERSION="5.46.0"
|
||||
PROWLER_API_VERSION="5.46.0"
|
||||
PROWLER_UI_VERSION="5.47.0"
|
||||
PROWLER_API_VERSION="5.47.0"
|
||||
```
|
||||
|
||||
<Note>
|
||||
|
||||
@@ -4,6 +4,18 @@ All notable changes to the **Prowler MCP Server** are documented in this file.
|
||||
|
||||
<!-- changelog: release notes start -->
|
||||
|
||||
## [0.13.0] (Prowler v5.47.0)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
- `readOnlyHint` and `destructiveHint` annotations on every MCP tool, with mute rule and mutelist writes marked destructive [(#13196)](https://github.com/prowler-cloud/prowler/pull/13196)
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
- Authlib from 1.7.2 to 1.9.1 and dnspython from 2.8.0 to 2.9.0 in the MCP Server container image, patching CVE-2026-96760 (critical), CVE-2026-114615, CVE-2026-114616 and CVE-2026-114605 [(#13186)](https://github.com/prowler-cloud/prowler/pull/13186)
|
||||
|
||||
---
|
||||
|
||||
## [0.12.4] (Prowler v5.46.0)
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
Authlib from 1.7.2 to 1.9.1 and dnspython from 2.8.0 to 2.9.0 in the MCP Server container image, patching CVE-2026-96760 (critical), CVE-2026-114615, CVE-2026-114616 and CVE-2026-114605
|
||||
@@ -1 +0,0 @@
|
||||
`readOnlyHint` and `destructiveHint` annotations on every MCP tool, with mute rule and mutelist writes marked destructive
|
||||
@@ -1,96 +0,0 @@
|
||||
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
|
||||
@@ -1,91 +0,0 @@
|
||||
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
|
||||
@@ -1,85 +0,0 @@
|
||||
# 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
|
||||
}]
|
||||
})
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
output "queue_url" {
|
||||
description = "Paste this URL in Prowler Cloud, Realtime, Real-time feed."
|
||||
value = aws_sqs_queue.events.url
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
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"
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
# 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
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
output "role_arn" {
|
||||
description = "Paste this ARN in Prowler Cloud, Realtime, Credentials for remediation only."
|
||||
value = aws_iam_role.prowler_remediation.arn
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
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*"
|
||||
}
|
||||
@@ -4,6 +4,23 @@ All notable changes to the **Prowler SDK** are documented in this file.
|
||||
|
||||
<!-- changelog: release notes start -->
|
||||
|
||||
## [5.47.0] (Prowler v5.47.0)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
- `iam_subscription_owners_between_2_and_3` check for Azure provider, verifying each subscription has between 2 and 3 principals with the Owner role, including owners inherited from management groups, mapped to CIS 5.0 and 6.0 [(#13145)](https://github.com/prowler-cloud/prowler/pull/13145)
|
||||
- `network_application_gateway_waf_enabled` and `network_application_gateway_http2_enabled` checks for Azure provider, with Application Gateway and WAF policy collection in the network service, mapped to CIS 5.0 and 6.0 [(#13160)](https://github.com/prowler-cloud/prowler/pull/13160)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- Security group internet exposure checks now treat CIDRs in one ingress rule that together cover 0.0.0.0/0 or ::/0 as open to the internet [(#13191)](https://github.com/prowler-cloud/prowler/pull/13191)
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
- pymongo to 4.18.3, dnspython to 2.9.0 and oauthlib to 4.0.0 in the SDK, patching the BSON decoding and DNS compression pointer denial of service advisories and the OAuth device flow token disclosure; dnspython stays on 2.8.0 for Python 3.10, which 2.9.0 no longer supports [(#13185)](https://github.com/prowler-cloud/prowler/pull/13185)
|
||||
|
||||
---
|
||||
|
||||
## [5.46.0] (Prowler v5.46.0)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
`network_application_gateway_waf_enabled` and `network_application_gateway_http2_enabled` checks for Azure provider, with Application Gateway and WAF policy collection in the network service, mapped to CIS 5.0 and 6.0
|
||||
@@ -1 +0,0 @@
|
||||
`iam_subscription_owners_between_2_and_3` check for Azure provider, verifying each subscription has between 2 and 3 principals with the Owner role, including owners inherited from management groups, mapped to CIS 5.0 and 6.0
|
||||
@@ -1 +0,0 @@
|
||||
Security group internet exposure checks now treat CIDRs in one ingress rule that together cover 0.0.0.0/0 or ::/0 as open to the internet
|
||||
@@ -1 +0,0 @@
|
||||
pymongo to 4.18.3, dnspython to 2.9.0 and oauthlib to 4.0.0 in the SDK, patching the BSON decoding and DNS compression pointer denial of service advisories and the OAuth device flow token disclosure; dnspython stays on 2.8.0 for Python 3.10, which 2.9.0 no longer supports
|
||||
@@ -52,7 +52,7 @@ class _MutableTimestamp:
|
||||
|
||||
timestamp = _MutableTimestamp(datetime.today())
|
||||
timestamp_utc = _MutableTimestamp(datetime.now(timezone.utc))
|
||||
prowler_version = "5.47.0"
|
||||
prowler_version = "5.48.0"
|
||||
html_logo_url = "https://github.com/prowler-cloud/prowler/"
|
||||
square_logo_img = "https://raw.githubusercontent.com/prowler-cloud/prowler/dc7d2d5aeb92fdf12e8604f42ef6472cd3e8e889/docs/img/prowler-logo-black.png"
|
||||
aws_logo = "https://user-images.githubusercontent.com/38561120/235953920-3e3fba08-0795-41dc-b480-9bea57db9f2e.png"
|
||||
|
||||
+1
-1
@@ -145,7 +145,7 @@ maintainers = [{name = "Prowler Engineering", email = "engineering@prowler.com"}
|
||||
name = "prowler"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10,<3.14"
|
||||
version = "5.47.0"
|
||||
version = "5.48.0"
|
||||
|
||||
[project.scripts]
|
||||
prowler = "prowler.__main__:prowler"
|
||||
|
||||
@@ -4,6 +4,26 @@ All notable changes to the **Prowler UI** are documented in this file.
|
||||
|
||||
<!-- changelog: release notes start -->
|
||||
|
||||
## [1.47.0] (Prowler v5.47.0)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
- Icon for the NATO D32 compliance framework on the compliance cards [(#13174)](https://github.com/prowler-cloud/prowler/pull/13174)
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
- Invitations page uses `Status` instead of `State` in the filter, table column and invitation details, and the Invitations and Roles filters show capitalized options [(#13175)](https://github.com/prowler-cloud/prowler/pull/13175)
|
||||
- Product tours and the guided onboarding sequence are no longer shown; new tenants still land on the add-provider wizard [(#13176)](https://github.com/prowler-cloud/prowler/pull/13176)
|
||||
- Add provider wizard connects every provider in one step, saving the account and credentials and testing the connection together, with teammate invitations for every provider and Lighthouse AI help beside the steps [(#13181)](https://github.com/prowler-cloud/prowler/pull/13181)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- User avatar requesting the current page as an image on every load, which rendered each page twice [(#13177)](https://github.com/prowler-cloud/prowler/pull/13177)
|
||||
- Lighthouse AI reply blanking out for a frame when the response finishes streaming, which made the whole text blink and reappear [(#13180)](https://github.com/prowler-cloud/prowler/pull/13180)
|
||||
- Compliance cross-provider tab no longer blanks out when a single catalog page or framework fails to load; it shows the frameworks that loaded with a warning instead [(#13183)](https://github.com/prowler-cloud/prowler/pull/13183)
|
||||
|
||||
---
|
||||
|
||||
## [1.46.0] (Prowler v5.46.0)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
@@ -16,6 +16,10 @@ import {
|
||||
} from "@/lib";
|
||||
import { getFindingGroupFilterOptions } from "@/lib/finding-group-filter-options";
|
||||
import { appendSanitizedProviderFilters } from "@/lib/provider-filters";
|
||||
import {
|
||||
isSampleScanFilter,
|
||||
sampleFindingGroupResources,
|
||||
} from "@/lib/sample-preview/sample-records";
|
||||
import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||
|
||||
/**
|
||||
@@ -187,6 +191,16 @@ async function fetchFindingGroupResourcesEndpoint(
|
||||
filters = {},
|
||||
}: FetchFindingGroupResourcesParams,
|
||||
) {
|
||||
if (isSampleScanFilter(filters)) {
|
||||
const status = filters["filter[status]"] ?? filters["filter[status__in]"];
|
||||
return sampleFindingGroupResources({
|
||||
checkId,
|
||||
page,
|
||||
pageSize,
|
||||
status: Array.isArray(status) ? status.join(",") : status,
|
||||
});
|
||||
}
|
||||
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
const normalizedFilters = normalizeFindingGroupResourceFilters(filters);
|
||||
|
||||
|
||||
@@ -4,6 +4,10 @@ import { z } from "zod";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { IN_WATCHLIST_FILTER_KEY } from "@/lib/compliance/watchlist";
|
||||
import {
|
||||
isSampleId,
|
||||
sampleComplianceFrameworks,
|
||||
} from "@/lib/sample-preview/sample-records";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import type { FindingComplianceFrameworksResult } from "@/types/compliance-watchlist";
|
||||
|
||||
@@ -32,6 +36,9 @@ export const getFindingComplianceFrameworks = async (
|
||||
}
|
||||
|
||||
const { inWatchlist = false } = parsedOptions.data;
|
||||
if (isSampleId(parsedFindingId.data)) {
|
||||
return sampleComplianceFrameworks(parsedFindingId.data);
|
||||
}
|
||||
if (!isCloud()) return { frameworks: [], unavailable: true };
|
||||
|
||||
try {
|
||||
|
||||
@@ -11,6 +11,11 @@ import {
|
||||
} from "@/lib";
|
||||
import { runWithConcurrencyLimit } from "@/lib/concurrency";
|
||||
import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters";
|
||||
import {
|
||||
isSampleResourceUid,
|
||||
sampleFindingsDocument,
|
||||
sampleFindingsQuery,
|
||||
} from "@/lib/sample-preview/sample-records";
|
||||
import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||
|
||||
const FINDING_IDS_RESOLUTION_PAGE_SIZE = 500;
|
||||
@@ -261,6 +266,15 @@ export const getLatestFindingsByResourceUid = async ({
|
||||
pageSize?: number;
|
||||
includeMuted?: boolean;
|
||||
}) => {
|
||||
if (isSampleResourceUid(resourceUid)) {
|
||||
return sampleFindingsDocument(
|
||||
sampleFindingsQuery({
|
||||
"filter[resource_uid]": resourceUid,
|
||||
"filter[status]": "FAIL",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
|
||||
const url = new URL(
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { isSampleId } from "@/lib/sample-preview/sample-records";
|
||||
import { FINDING_STATUS, type FindingStatus } from "@/types/components";
|
||||
import {
|
||||
FINDING_TRIAGE_BILLING_HREF,
|
||||
FINDING_TRIAGE_DISABLED_REASON,
|
||||
FINDING_TRIAGE_NOTE_MAX_LENGTH,
|
||||
FINDING_TRIAGE_STATUS,
|
||||
FINDING_TRIAGE_STATUS_LABELS,
|
||||
@@ -143,6 +145,12 @@ const createSummary = (
|
||||
summary.disabledReason = options.disabledReason;
|
||||
}
|
||||
|
||||
// Sample findings have no triage record to write to.
|
||||
if (isSampleId(summary.findingId)) {
|
||||
summary.canEdit = false;
|
||||
summary.disabledReason = FINDING_TRIAGE_DISABLED_REASON.SAMPLE;
|
||||
}
|
||||
|
||||
return summary;
|
||||
};
|
||||
|
||||
|
||||
@@ -6,6 +6,14 @@ import { attachFindingTriageSummariesToResponse } from "@/actions/findings/findi
|
||||
import { getFindingTriageAdapterOptions } from "@/actions/findings/findings-triage.options";
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters";
|
||||
import {
|
||||
getSampleFinding,
|
||||
isSampleId,
|
||||
isSampleScanFilter,
|
||||
sampleFindingDocument,
|
||||
sampleFindingsDocument,
|
||||
sampleFindingsQuery,
|
||||
} from "@/lib/sample-preview/sample-records";
|
||||
import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||
|
||||
const withFindingTriageSummaries = <T extends { data?: unknown }>(
|
||||
@@ -24,6 +32,14 @@ export const getFindings = async ({
|
||||
sort = "",
|
||||
filters = {},
|
||||
}) => {
|
||||
if (isSampleScanFilter(filters)) {
|
||||
return withFindingTriageSummaries(
|
||||
sampleFindingsDocument(
|
||||
sampleFindingsQuery(filters).slice(0, Number(pageSize) || 10),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
|
||||
if (isNaN(Number(page)) || page < 1)
|
||||
@@ -164,6 +180,11 @@ export const getFindingById = async (
|
||||
include = "",
|
||||
_options?: GetFindingByIdOptions,
|
||||
) => {
|
||||
if (isSampleId(findingId)) {
|
||||
const finding = getSampleFinding(findingId);
|
||||
return finding ? sampleFindingDocument(finding) : undefined;
|
||||
}
|
||||
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
|
||||
const url = new URL(`${apiBaseUrl}/findings/${findingId}`);
|
||||
|
||||
+41
-16
@@ -1,6 +1,13 @@
|
||||
"use server";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { getSampleIssueDetail } from "@/lib/sample-preview/sample-issues";
|
||||
import {
|
||||
isSampleId,
|
||||
SAMPLE_WRITE_MESSAGE,
|
||||
sampleFindingsQuery,
|
||||
sampleIssueFindingsDocument,
|
||||
} from "@/lib/sample-preview/sample-records";
|
||||
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
|
||||
import {
|
||||
ISSUE_FINDINGS_LIMIT,
|
||||
@@ -86,6 +93,11 @@ export const getIssues = async ({
|
||||
export const getIssue = async (
|
||||
id: string,
|
||||
): Promise<IssueDetailResponse | undefined> => {
|
||||
if (isSampleId(id)) {
|
||||
const issue = getSampleIssueDetail(id);
|
||||
return issue ? { data: issue } : undefined;
|
||||
}
|
||||
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
const url = new URL(`${apiBaseUrl}/issues/${encodeURIComponent(id)}`);
|
||||
|
||||
@@ -106,6 +118,13 @@ export const getIssueFindings = async (
|
||||
if (!scanId) return undefined;
|
||||
const uids = Array.from(new Set(findingUids)).slice(0, ISSUE_FINDINGS_LIMIT);
|
||||
if (uids.length === 0) return [];
|
||||
if (isSampleId(scanId)) {
|
||||
return toIssueFindings(
|
||||
sampleIssueFindingsDocument(
|
||||
sampleFindingsQuery({ "filter[uid__in]": uids.join(",") }),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
const url = new URL(`${apiBaseUrl}/findings`);
|
||||
@@ -156,32 +175,38 @@ export const updateIssueTriage = async (
|
||||
id: string,
|
||||
{ status, note }: UpdateIssueTriageInput,
|
||||
): Promise<IssueWriteResult> =>
|
||||
writeIssueTriage(
|
||||
`/issues/${encodeURIComponent(id)}/triage`,
|
||||
ISSUE_TRIAGE_WRITE_METHOD.UPDATE,
|
||||
{
|
||||
type: "issue-triages",
|
||||
attributes: { status, ...(note ? { note } : {}) },
|
||||
},
|
||||
);
|
||||
isSampleId(id)
|
||||
? { error: SAMPLE_WRITE_MESSAGE }
|
||||
: writeIssueTriage(
|
||||
`/issues/${encodeURIComponent(id)}/triage`,
|
||||
ISSUE_TRIAGE_WRITE_METHOD.UPDATE,
|
||||
{
|
||||
type: "issue-triages",
|
||||
attributes: { status, ...(note ? { note } : {}) },
|
||||
},
|
||||
);
|
||||
|
||||
/** Add a note to an issue's triage without changing its status. */
|
||||
export const addIssueNote = async (
|
||||
id: string,
|
||||
body: string,
|
||||
): Promise<IssueWriteResult> =>
|
||||
writeIssueTriage(
|
||||
`/issues/${encodeURIComponent(id)}/triage/notes`,
|
||||
ISSUE_TRIAGE_WRITE_METHOD.ADD_NOTE,
|
||||
{
|
||||
type: "issue-triage-notes",
|
||||
attributes: { body },
|
||||
},
|
||||
);
|
||||
isSampleId(id)
|
||||
? { error: SAMPLE_WRITE_MESSAGE }
|
||||
: writeIssueTriage(
|
||||
`/issues/${encodeURIComponent(id)}/triage/notes`,
|
||||
ISSUE_TRIAGE_WRITE_METHOD.ADD_NOTE,
|
||||
{
|
||||
type: "issue-triage-notes",
|
||||
attributes: { body },
|
||||
},
|
||||
);
|
||||
|
||||
export const getIssueNotes = async (
|
||||
id: string,
|
||||
): Promise<IssueTriageNote[] | undefined> => {
|
||||
if (isSampleId(id)) return [];
|
||||
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
const url = new URL(
|
||||
`${apiBaseUrl}/issues/${encodeURIComponent(id)}/triage/notes`,
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib/helper";
|
||||
import {
|
||||
isSampleId,
|
||||
SAMPLE_WRITE_MESSAGE,
|
||||
} from "@/lib/sample-preview/sample-records";
|
||||
|
||||
import {
|
||||
DeleteMuteRuleActionState,
|
||||
@@ -131,6 +135,10 @@ export const createMuteRule = async (
|
||||
};
|
||||
}
|
||||
|
||||
if (findingIds.some(isSampleId)) {
|
||||
return { errors: { general: SAMPLE_WRITE_MESSAGE } };
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(`${apiBaseUrl}/mute-rules`);
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./realtime";
|
||||
@@ -1,375 +0,0 @@
|
||||
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,468 +0,0 @@
|
||||
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];
|
||||
};
|
||||
@@ -1,806 +0,0 @@
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,666 +0,0 @@
|
||||
"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.");
|
||||
};
|
||||
@@ -12,6 +12,10 @@ import {
|
||||
sanitizeErrorMessage,
|
||||
} from "@/lib";
|
||||
import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters";
|
||||
import {
|
||||
isSampleId,
|
||||
sampleResourceEvents,
|
||||
} from "@/lib/sample-preview/sample-records";
|
||||
import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import { OrganizationResource } from "@/types/organizations";
|
||||
@@ -184,6 +188,9 @@ export const getResourceEvents = async (
|
||||
pageSize?: number;
|
||||
} = {},
|
||||
) => {
|
||||
if (isSampleId(resourceId)) {
|
||||
return { data: sampleResourceEvents(resourceId) };
|
||||
}
|
||||
if (!SAFE_RESOURCE_ID_PATTERN.test(resourceId)) {
|
||||
return { error: "Invalid resource ID format." };
|
||||
}
|
||||
|
||||
@@ -118,8 +118,6 @@ 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
|
||||
@@ -181,8 +179,6 @@ export const updateRole = async (formData: FormData, roleId: string) => {
|
||||
formData.get("manage_lighthouse_ai_configuration") === "true";
|
||||
payload.data.attributes.manage_registry =
|
||||
formData.get("manage_registry") === "true";
|
||||
payload.data.attributes.manage_prowler_realtime =
|
||||
formData.get("manage_prowler_realtime") === "true";
|
||||
}
|
||||
|
||||
// Add provider groups relationships only if there are items
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { type ReactNode } from "react";
|
||||
|
||||
interface OverviewGridProps {
|
||||
threatScore: ReactNode;
|
||||
// Cloud-only widgets: the page leaves them out where they do not apply.
|
||||
exploitability?: ReactNode;
|
||||
topIssues?: ReactNode;
|
||||
checkFindings: ReactNode;
|
||||
riskSeverity: ReactNode;
|
||||
resourcesInventory: ReactNode;
|
||||
complianceWatchlist: ReactNode;
|
||||
serviceWatchlist: ReactNode;
|
||||
attackSurface: ReactNode;
|
||||
severityOverTime: ReactNode;
|
||||
graphsTabs: ReactNode;
|
||||
}
|
||||
|
||||
/** The Overview widget layout, shared by the real page and the sample preview. */
|
||||
export function OverviewGrid({
|
||||
threatScore,
|
||||
exploitability,
|
||||
topIssues,
|
||||
checkFindings,
|
||||
riskSeverity,
|
||||
resourcesInventory,
|
||||
complianceWatchlist,
|
||||
serviceWatchlist,
|
||||
attackSurface,
|
||||
severityOverTime,
|
||||
graphsTabs,
|
||||
}: OverviewGridProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-6 lg:flex-row lg:flex-wrap lg:items-stretch">
|
||||
{threatScore}
|
||||
{exploitability}
|
||||
{checkFindings}
|
||||
{riskSeverity}
|
||||
</div>
|
||||
|
||||
<div className="mt-6">{resourcesInventory}</div>
|
||||
|
||||
<div className="mt-6 flex flex-col gap-6 xl:flex-row">
|
||||
{/* Watchlists: stacked on mobile, row on tablet, stacked on desktop */}
|
||||
{/* No `flex-wrap` here: a multi-line flex container sizes its items to
|
||||
the line's max-content rather than to its own 312px, and a card wider
|
||||
than the column loses its right border and padding to
|
||||
`overflow-hidden`. It never wrapped anyway. */}
|
||||
<div className="flex min-w-0 flex-col gap-6 overflow-hidden sm:flex-row sm:items-stretch xl:w-[312px] xl:shrink-0 xl:flex-col">
|
||||
{/* Sized to its own list, unlike the service card below: this one is
|
||||
as long as the organization made its watchlist, so growing it to
|
||||
fill the column puts a hole under two pinned frameworks. */}
|
||||
{/* `h-full` only where the cards sit side by side and share a row
|
||||
height. In the column it would hand the card the stretched
|
||||
wrapper's height and undo the fit-to-content sizing. */}
|
||||
<div className="min-w-0 sm:flex-1 xl:flex-none sm:[&>*]:h-full xl:[&>*]:h-auto">
|
||||
{complianceWatchlist}
|
||||
</div>
|
||||
<div className="min-w-0 sm:flex-1 xl:flex-auto [&>*]:h-full">
|
||||
{serviceWatchlist}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Charts column: Attack Surface, Top issues, Findings Over Time */}
|
||||
<div className="flex flex-1 flex-col gap-6">
|
||||
{attackSurface}
|
||||
{topIssues}
|
||||
{severityOverTime}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">{graphsTabs}</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { SAMPLE_SERVICES_RESPONSE } from "@/lib/sample-preview/sample-overview";
|
||||
|
||||
import { OverviewSamplePreview } from "./overview-sample-preview";
|
||||
|
||||
// Card links import server actions; their auth chain has no place in jsdom.
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), refresh: vi.fn() }),
|
||||
usePathname: () => "/",
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}));
|
||||
vi.mock("@/auth.config", () => ({ auth: vi.fn() }));
|
||||
|
||||
// The graph tabs have their own test; here they would only add chart noise.
|
||||
vi.mock("./sample-graphs-tabs", () => ({
|
||||
SampleGraphsTabs: () => <div data-testid="sample-graphs-tabs" />,
|
||||
}));
|
||||
|
||||
describe("OverviewSamplePreview", () => {
|
||||
it("fills the Overview with the demo account's numbers", () => {
|
||||
render(<OverviewSamplePreview showIssues={false} />);
|
||||
|
||||
expect(screen.getByText("Findings Severity Over Time")).toBeVisible();
|
||||
expect(screen.getByText("Internet Exposed Resources")).toBeVisible();
|
||||
expect(screen.getByTestId("sample-graphs-tabs")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("only leaves links live where the target page shows the sample", () => {
|
||||
const { container } = render(<OverviewSamplePreview showIssues={false} />);
|
||||
|
||||
const links = Array.from(container.querySelectorAll("a"));
|
||||
const live = links.filter((link) => !link.closest("[inert]"));
|
||||
expect(live.length).toBeGreaterThan(0);
|
||||
expect(live.length).toBeLessThan(links.length);
|
||||
for (const link of live) {
|
||||
expect(link.getAttribute("href")).toMatch(/^\/(findings|issues)(\?|$)/);
|
||||
}
|
||||
});
|
||||
|
||||
it("lists every sample service in the watchlist", () => {
|
||||
render(<OverviewSamplePreview showIssues={false} />);
|
||||
|
||||
// The riskiest service of the demo account leads the watchlist.
|
||||
const riskiest = [...SAMPLE_SERVICES_RESPONSE.data].sort(
|
||||
(left, right) => right.attributes.fail - left.attributes.fail,
|
||||
)[0];
|
||||
expect(
|
||||
screen.getAllByText(new RegExp(riskiest.id, "i")).length,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
import { type ReactNode } from "react";
|
||||
|
||||
import { adaptAttackSurfaceOverview } from "@/actions/overview/attack-surface/attack-surface.adapter";
|
||||
import { adaptComplianceWatchlistResponse } from "@/actions/overview/compliance-watchlist/compliance-watchlist.adapter";
|
||||
import { adaptResourceGroupOverview } from "@/actions/overview/resources-inventory/resources-inventory.adapter";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn";
|
||||
import { FeatureFlagGate } from "@/components/shared/feature-flag-gate";
|
||||
import { FEATURE_FLAG } from "@/lib/feature-flags";
|
||||
import { verdictCounts } from "@/lib/issues/verdicts";
|
||||
import {
|
||||
SAMPLE_ISSUES,
|
||||
SAMPLE_ISSUES_OVERVIEW,
|
||||
} from "@/lib/sample-preview/sample-issues";
|
||||
import {
|
||||
SAMPLE_ATTACK_SURFACE_RESPONSE,
|
||||
SAMPLE_COMPLIANCE_WATCHLIST_RESPONSE,
|
||||
SAMPLE_FAILED_BY_SEVERITY,
|
||||
SAMPLE_RESOURCE_GROUPS_RESPONSE,
|
||||
SAMPLE_SERVICES_RESPONSE,
|
||||
SAMPLE_SEVERITY_TRENDS,
|
||||
SAMPLE_STATUS,
|
||||
SAMPLE_THREAT_SCORE,
|
||||
} from "@/lib/sample-preview/sample-overview";
|
||||
|
||||
import { OverviewGrid } from "../_components/overview-grid";
|
||||
import { AttackSurface } from "../attack-surface/_components/attack-surface";
|
||||
import { ExploitabilityChart } from "../exploitability";
|
||||
import { TopIssues } from "../exploitability/_components/top-issues";
|
||||
import { ResourcesInventory } from "../resources-inventory/_components/resources-inventory";
|
||||
import { RiskSeverityChart } from "../risk-severity";
|
||||
import { FindingSeverityOverTime } from "../severity-over-time";
|
||||
import { StatusChart } from "../status-chart";
|
||||
import { ThreatScore } from "../threat-score";
|
||||
import { ComplianceWatchlist, ServiceWatchlist } from "../watchlist";
|
||||
|
||||
import { SampleGraphsTabs } from "./sample-graphs-tabs";
|
||||
|
||||
// `contents` keeps each card a direct item of the grid's flex rows, while
|
||||
// `inert` stops links into pages with no sample data (compliance details,
|
||||
// resources). Cards that link to Findings or Issues stay live: those pages
|
||||
// show the sample and apply the link's filters.
|
||||
const Inert = ({ children }: { children: ReactNode }) => (
|
||||
<div inert className="contents">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
const NO_SCOPE: Record<string, string> = {};
|
||||
|
||||
interface OverviewSamplePreviewProps {
|
||||
// Exploitability and Top issues only exist in Prowler Cloud, behind their flag.
|
||||
showIssues: boolean;
|
||||
}
|
||||
|
||||
/** The Overview filled with the demo account, for tenants without a scan. */
|
||||
export function OverviewSamplePreview({
|
||||
showIssues,
|
||||
}: OverviewSamplePreviewProps) {
|
||||
const complianceItems = adaptComplianceWatchlistResponse(
|
||||
SAMPLE_COMPLIANCE_WATCHLIST_RESPONSE,
|
||||
).map((item) => ({
|
||||
id: item.id,
|
||||
framework: item.complianceId,
|
||||
label: item.label,
|
||||
icon: item.icon,
|
||||
score: item.score,
|
||||
}));
|
||||
|
||||
return (
|
||||
<OverviewGrid
|
||||
threatScore={
|
||||
<Inert>
|
||||
<ThreatScore
|
||||
score={SAMPLE_THREAT_SCORE.score}
|
||||
scoreDelta={SAMPLE_THREAT_SCORE.scoreDelta}
|
||||
sectionScores={SAMPLE_THREAT_SCORE.sectionScores}
|
||||
criticalRequirements={SAMPLE_THREAT_SCORE.criticalRequirements}
|
||||
/>
|
||||
</Inert>
|
||||
}
|
||||
exploitability={
|
||||
showIssues ? (
|
||||
<FeatureFlagGate flag={FEATURE_FLAG.ISSUES}>
|
||||
<ExploitabilityChart
|
||||
counts={verdictCounts(SAMPLE_ISSUES_OVERVIEW)}
|
||||
issues={SAMPLE_ISSUES_OVERVIEW.issues}
|
||||
findings={SAMPLE_ISSUES_OVERVIEW.findings}
|
||||
actionable={SAMPLE_ISSUES_OVERVIEW.actionable}
|
||||
coveredFindings={SAMPLE_ISSUES_OVERVIEW.not_exploitable_findings}
|
||||
scope={NO_SCOPE}
|
||||
/>
|
||||
</FeatureFlagGate>
|
||||
) : undefined
|
||||
}
|
||||
checkFindings={
|
||||
<StatusChart
|
||||
failFindingsData={{
|
||||
total: SAMPLE_STATUS.fail,
|
||||
new: SAMPLE_STATUS.failNew,
|
||||
}}
|
||||
passFindingsData={{
|
||||
total: SAMPLE_STATUS.pass,
|
||||
new: SAMPLE_STATUS.passNew,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
riskSeverity={<RiskSeverityChart {...SAMPLE_FAILED_BY_SEVERITY} />}
|
||||
resourcesInventory={
|
||||
<Inert>
|
||||
<ResourcesInventory
|
||||
items={adaptResourceGroupOverview(SAMPLE_RESOURCE_GROUPS_RESPONSE)}
|
||||
/>
|
||||
</Inert>
|
||||
}
|
||||
complianceWatchlist={
|
||||
<Inert>
|
||||
<ComplianceWatchlist items={complianceItems} hasWatchlist />
|
||||
</Inert>
|
||||
}
|
||||
serviceWatchlist={
|
||||
<ServiceWatchlist items={SAMPLE_SERVICES_RESPONSE.data} />
|
||||
}
|
||||
attackSurface={
|
||||
<AttackSurface
|
||||
items={adaptAttackSurfaceOverview(SAMPLE_ATTACK_SURFACE_RESPONSE)}
|
||||
/>
|
||||
}
|
||||
topIssues={
|
||||
showIssues ? (
|
||||
<FeatureFlagGate flag={FEATURE_FLAG.ISSUES}>
|
||||
<TopIssues issues={SAMPLE_ISSUES} scope={NO_SCOPE} />
|
||||
</FeatureFlagGate>
|
||||
) : undefined
|
||||
}
|
||||
severityOverTime={
|
||||
<Inert>
|
||||
<Card variant="base" className="flex h-full flex-1 flex-col">
|
||||
<CardHeader className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>Findings Severity Over Time</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-1 flex-col px-6">
|
||||
<FindingSeverityOverTime data={SAMPLE_SEVERITY_TRENDS} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Inert>
|
||||
}
|
||||
graphsTabs={<SampleGraphsTabs />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { SAMPLE_CHECKS } from "@/lib/sample-preview/sample-dataset";
|
||||
|
||||
import { SampleGraphsTabs } from "./sample-graphs-tabs";
|
||||
|
||||
const { fetchMock } = vi.hoisted(() => ({ fetchMock: vi.fn() }));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), refresh: vi.fn() }),
|
||||
usePathname: () => "/",
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}));
|
||||
vi.mock("@/auth.config", () => ({ auth: vi.fn() }));
|
||||
|
||||
describe("SampleGraphsTabs", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
it("shows the sample new failed findings in the default tab without fetching", () => {
|
||||
// Given
|
||||
const firstNewCheck = SAMPLE_CHECKS.find((check) => check.newFailed > 0)!;
|
||||
|
||||
// When
|
||||
render(<SampleGraphsTabs />);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.getByRole("tab", { name: "New Findings", selected: true }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getAllByText(firstNewCheck.title).length).toBeGreaterThan(0);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { type ReactNode } from "react";
|
||||
|
||||
import { SankeyChart } from "@/components/graphs/sankey-chart";
|
||||
import { ThreatMap } from "@/components/graphs/threat-map";
|
||||
import { LinkToFindings } from "@/components/overview";
|
||||
import { LatestFindingsTable } from "@/components/overview/new-findings-table/table";
|
||||
import { CardTitle } from "@/components/shadcn";
|
||||
import {
|
||||
SAMPLE_NEW_FINDINGS,
|
||||
SAMPLE_RADAR_DATA,
|
||||
SAMPLE_RISK_PLOT_POINTS,
|
||||
SAMPLE_SANKEY_DATA,
|
||||
SAMPLE_THREAT_MAP_DATA,
|
||||
} from "@/lib/sample-preview/sample-graphs";
|
||||
|
||||
import { GraphsTabsClient } from "../graphs-tabs/_components/graphs-tabs-client";
|
||||
import type { TabId } from "../graphs-tabs/_config/graphs-tabs-config";
|
||||
import { RiskPlotClient } from "../graphs-tabs/risk-plot/risk-plot-client";
|
||||
import { RiskRadarViewClient } from "../graphs-tabs/risk-radar-view/risk-radar-view-client";
|
||||
|
||||
// Inert so sample rows and charts never open drill-downs or detail panels.
|
||||
const Inert = ({ children }: { children: ReactNode }) => (
|
||||
<div inert className="flex w-full flex-1">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
const SAMPLE_TABS_CONTENT: Record<TabId, ReactNode> = {
|
||||
findings: (
|
||||
<Inert>
|
||||
<div className="flex w-full flex-col">
|
||||
<LatestFindingsTable
|
||||
data={SAMPLE_NEW_FINDINGS}
|
||||
header={
|
||||
<div className="flex w-full items-center justify-between gap-4">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<CardTitle>Latest New Failed Findings</CardTitle>
|
||||
<p className="text-text-neutral-tertiary text-xs">
|
||||
Showing the latest 10 sorted by severity
|
||||
</p>
|
||||
</div>
|
||||
<LinkToFindings />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Inert>
|
||||
),
|
||||
"threat-map": (
|
||||
<Inert>
|
||||
<div className="w-full flex-1 overflow-hidden">
|
||||
<ThreatMap data={SAMPLE_THREAT_MAP_DATA} height={460} />
|
||||
</div>
|
||||
</Inert>
|
||||
),
|
||||
"risk-radar": (
|
||||
<Inert>
|
||||
<RiskRadarViewClient data={SAMPLE_RADAR_DATA} />
|
||||
</Inert>
|
||||
),
|
||||
"risk-pipeline": (
|
||||
<Inert>
|
||||
<div className="w-full flex-1 overflow-visible">
|
||||
<SankeyChart
|
||||
data={SAMPLE_SANKEY_DATA}
|
||||
zeroDataProviders={SAMPLE_SANKEY_DATA.zeroDataProviders}
|
||||
height={460}
|
||||
/>
|
||||
</div>
|
||||
</Inert>
|
||||
),
|
||||
"risk-plot": (
|
||||
<Inert>
|
||||
<div className="w-full flex-1 overflow-visible">
|
||||
<RiskPlotClient data={SAMPLE_RISK_PLOT_POINTS} />
|
||||
</div>
|
||||
</Inert>
|
||||
),
|
||||
};
|
||||
|
||||
export const SampleGraphsTabs = () => (
|
||||
<GraphsTabsClient tabsContent={SAMPLE_TABS_CONTENT} />
|
||||
);
|
||||
+7
-2
@@ -27,6 +27,8 @@ import { ScanStatusBadge } from "./scan-status-badge";
|
||||
|
||||
interface ScanListTableProps {
|
||||
scans: AttackPathScan[];
|
||||
/** Overrides the `scanId` search param, e.g. for the sample scan. */
|
||||
selectedScanId?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 10;
|
||||
@@ -227,12 +229,15 @@ const getColumns = ({
|
||||
* Table displaying AWS account Attack Paths scans
|
||||
* Shows scan metadata and allows selection of completed scans
|
||||
*/
|
||||
export const ScanListTable = ({ scans }: ScanListTableProps) => {
|
||||
export const ScanListTable = ({
|
||||
scans,
|
||||
selectedScanId: selectedScanIdOverride,
|
||||
}: ScanListTableProps) => {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
|
||||
const selectedScanId = searchParams.get("scanId");
|
||||
const selectedScanId = selectedScanIdOverride ?? searchParams.get("scanId");
|
||||
const pageSize = parsePageParam(
|
||||
searchParams.get("scanPageSize"),
|
||||
DEFAULT_PAGE_SIZE,
|
||||
|
||||
+8
-3
@@ -20,15 +20,19 @@ export interface UseAttackPathScansResult {
|
||||
* project forbids `useEffect` in components, but a reusable data hook is the
|
||||
* sanctioned place for a mount-time fetch when no fetching library is wired up.
|
||||
*/
|
||||
export function useAttackPathScans(): UseAttackPathScansResult {
|
||||
const [scans, setScans] = useState<AttackPathScan[]>([]);
|
||||
const [scansLoading, setScansLoading] = useState(true);
|
||||
export function useAttackPathScans(
|
||||
/** Sample scans: shown as loaded, and the API is never called. */
|
||||
sampleScans?: AttackPathScan[],
|
||||
): UseAttackPathScansResult {
|
||||
const [scans, setScans] = useState<AttackPathScan[]>(sampleScans ?? []);
|
||||
const [scansLoading, setScansLoading] = useState(!sampleScans);
|
||||
const [loadError, setLoadError] = useState(false);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
// Silent background refresh for auto-refresh: never flips loading/error, so it
|
||||
// can't disrupt the visible view if it fails.
|
||||
const refreshScans = async () => {
|
||||
if (sampleScans) return;
|
||||
try {
|
||||
const scansData = await getAttackPathScans();
|
||||
if (scansData?.data) {
|
||||
@@ -43,6 +47,7 @@ export function useAttackPathScans(): UseAttackPathScansResult {
|
||||
// the error view's Retry action. A successful empty result (`{ data: [] }`) is
|
||||
// not an error; only a missing payload or a thrown request is.
|
||||
const loadScans = async () => {
|
||||
if (sampleScans) return;
|
||||
setScansLoading(true);
|
||||
setLoadError(false);
|
||||
try {
|
||||
|
||||
@@ -56,8 +56,13 @@ const getDefaultValues = (query?: AttackPathQuery) => {
|
||||
* Custom hook for managing query builder form state
|
||||
* Handles query selection, parameter validation, and form submission
|
||||
*/
|
||||
export const useQueryBuilder = (availableQueries: AttackPathQuery[]) => {
|
||||
const [selectedQuery, setSelectedQuery] = useState<string | null>(null);
|
||||
export const useQueryBuilder = (
|
||||
availableQueries: AttackPathQuery[],
|
||||
initialQueryId: string | null = null,
|
||||
) => {
|
||||
const [selectedQuery, setSelectedQuery] = useState<string | null>(
|
||||
initialQueryId,
|
||||
);
|
||||
|
||||
const getQueryById = (queryId: string | null) =>
|
||||
availableQueries.find((query) => query.id === queryId);
|
||||
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { render } from "@/__tests__/render-browser";
|
||||
import { SAMPLE_ATTACK_PATHS } from "@/lib/sample-preview/sample-attack-paths";
|
||||
import { useLighthouseContextStore } from "@/store/lighthouse-context/store";
|
||||
import { resetLighthouseContextStore } from "@/store/lighthouse-context/store.test-utils";
|
||||
|
||||
const actions = vi.hoisted(() => ({
|
||||
getAttackPathScans: vi.fn(),
|
||||
getAvailableQueries: vi.fn(),
|
||||
executeQuery: vi.fn(),
|
||||
executeCustomQuery: vi.fn(),
|
||||
getFindingById: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/actions/attack-paths", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/actions/attack-paths")>()),
|
||||
getAttackPathScans: actions.getAttackPathScans,
|
||||
getAvailableQueries: actions.getAvailableQueries,
|
||||
executeQuery: actions.executeQuery,
|
||||
executeCustomQuery: actions.executeCustomQuery,
|
||||
}));
|
||||
|
||||
vi.mock("@/actions/findings", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/actions/findings")>()),
|
||||
getFindingById: actions.getFindingById,
|
||||
}));
|
||||
|
||||
import { useGraphStore } from "./_hooks/use-graph-state";
|
||||
import AttackPathsPage from "./attack-paths-page";
|
||||
import { AttackPathPageHarness } from "./attack-paths-page.harness";
|
||||
|
||||
const SAMPLE_FIXTURE = {
|
||||
scans: [SAMPLE_ATTACK_PATHS.scan],
|
||||
scanId: SAMPLE_ATTACK_PATHS.scan.id,
|
||||
queries: [SAMPLE_ATTACK_PATHS.query],
|
||||
queryId: SAMPLE_ATTACK_PATHS.query.id,
|
||||
queryResult: SAMPLE_ATTACK_PATHS.result,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
useGraphStore.getState().reset();
|
||||
resetLighthouseContextStore();
|
||||
window.history.replaceState({}, "", "/attack-paths");
|
||||
});
|
||||
|
||||
describe("Attack Paths sample preview", () => {
|
||||
it("shows the sample graph and keeps it off the API and Lighthouse", async () => {
|
||||
// Given
|
||||
await render(<AttackPathsPage sample={SAMPLE_ATTACK_PATHS} />);
|
||||
const graph = new AttackPathPageHarness(SAMPLE_FIXTURE);
|
||||
|
||||
// When
|
||||
await vi.waitFor(
|
||||
() => expect(graph.getNodeById("sample-s3-exports")).not.toBeNull(),
|
||||
{
|
||||
timeout: 10000,
|
||||
},
|
||||
);
|
||||
await graph.clickNode("sample-s3-exports");
|
||||
await vi.waitFor(() =>
|
||||
expect(graph.getNodeById("sample-finding-public-bucket")).not.toBeNull(),
|
||||
);
|
||||
await graph.clickNode("sample-finding-public-bucket");
|
||||
await graph.executeQuery({ selectFirst: false });
|
||||
|
||||
// Then
|
||||
expect(graph.showsNodeLabel("bastion-01")).toBe(true);
|
||||
expect(
|
||||
graph.showsNodeLabel(SAMPLE_ATTACK_PATHS.query.attributes.name),
|
||||
).toBe(true);
|
||||
expect(actions.getAttackPathScans).not.toHaveBeenCalled();
|
||||
expect(actions.getAvailableQueries).not.toHaveBeenCalled();
|
||||
expect(actions.executeQuery).not.toHaveBeenCalled();
|
||||
expect(actions.getFindingById).not.toHaveBeenCalled();
|
||||
expect(
|
||||
useLighthouseContextStore.getState().contributions["attack-path-current"],
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
OUTCOME_NODE_LABEL,
|
||||
} from "@/lib/attack-paths/group-graph";
|
||||
import { buildAttackPathContext } from "@/lib/lighthouse/context/contributions";
|
||||
import type { SampleAttackPaths } from "@/lib/sample-preview/sample-attack-paths";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import type {
|
||||
AttackPathQuery,
|
||||
@@ -74,18 +75,24 @@ import {
|
||||
const SCROLL_CONTAINER_CLASS =
|
||||
"minimal-scrollbar relative z-0 w-full gap-4 overflow-auto shadow-sm";
|
||||
|
||||
export default function AttackPathsPage() {
|
||||
interface AttackPathsPageProps {
|
||||
/** Sample mode: renders this data, never calls the attack-paths API. */
|
||||
sample?: SampleAttackPaths;
|
||||
}
|
||||
|
||||
export default function AttackPathsPage({ sample }: AttackPathsPageProps) {
|
||||
const searchParams = useSearchParams();
|
||||
const pathname = usePathname();
|
||||
const scanId = searchParams.get("scanId");
|
||||
// The sample scan id stays out of the URL, so the real page never sees it.
|
||||
const scanId = sample ? sample.scan.id : searchParams.get("scanId");
|
||||
const graphState = useGraphState();
|
||||
const finding = useFindingDetails();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { scans, scansLoading, loadError, refreshScans, retryLoadScans } =
|
||||
useAttackPathScans();
|
||||
useAttackPathScans(sample ? [sample.scan] : undefined);
|
||||
|
||||
const [queriesLoading, setQueriesLoading] = useState(true);
|
||||
const [queriesLoading, setQueriesLoading] = useState(!sample);
|
||||
const [queriesError, setQueriesError] = useState<string | null>(null);
|
||||
const [isFullscreenOpen, setIsFullscreenOpen] = useState(false);
|
||||
const graphRef = useRef<GraphHandle>(null);
|
||||
@@ -94,13 +101,26 @@ export default function AttackPathsPage() {
|
||||
const hasResetRef = useRef(false);
|
||||
const graphContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [queries, setQueries] = useState<AttackPathQuery[]>([]);
|
||||
const [queries, setQueries] = useState<AttackPathQuery[]>(
|
||||
sample ? [sample.query] : [],
|
||||
);
|
||||
// Snapshot of the executed query's outcome, so the graph's terminal node
|
||||
// reflects the query that produced the current graph (not a later selection).
|
||||
const [executedOutcome, setExecutedOutcome] =
|
||||
useState<AttackPathOutcome | null>(null);
|
||||
|
||||
const queryBuilder = useQueryBuilder(queries);
|
||||
const queryBuilder = useQueryBuilder(queries, sample?.query.id ?? null);
|
||||
|
||||
// The sample has one precomputed result: "running" its query shows it.
|
||||
const showSampleGraph = (sampleData: SampleAttackPaths) => {
|
||||
graphState.updateGraphData(adaptQueryResultToGraphData(sampleData.result), {
|
||||
queryId: sampleData.query.id,
|
||||
queryLabel: sampleData.query.attributes.name,
|
||||
queryKind: ATTACK_PATH_QUERY_KIND.PREDEFINED,
|
||||
parameters: {},
|
||||
});
|
||||
setExecutedOutcome(sampleData.query.attributes.outcome ?? null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasResetRef.current) {
|
||||
@@ -110,7 +130,8 @@ export default function AttackPathsPage() {
|
||||
}, [graphState]);
|
||||
|
||||
useEffect(() => {
|
||||
graphState.resetGraph();
|
||||
if (sample) showSampleGraph(sample);
|
||||
else graphState.resetGraph();
|
||||
}, [scanId]); // eslint-disable-line react-hooks/exhaustive-deps -- reset on scanId change only
|
||||
|
||||
// Poll while a scan is in flight so the page auto-advances when the graph is ready.
|
||||
@@ -126,6 +147,8 @@ export default function AttackPathsPage() {
|
||||
selectedScan.attributes.state !== SCAN_STATES.COMPLETED;
|
||||
|
||||
useEffect(() => {
|
||||
if (sample) return;
|
||||
|
||||
const loadQueries = async () => {
|
||||
if (!scanId) {
|
||||
setQueriesError("No scan selected");
|
||||
@@ -168,7 +191,7 @@ export default function AttackPathsPage() {
|
||||
};
|
||||
|
||||
loadQueries();
|
||||
}, [scanId, toast]);
|
||||
}, [sample, scanId, toast]);
|
||||
|
||||
const showErrorToast = (title: string, description: string) => {
|
||||
toast({
|
||||
@@ -179,6 +202,11 @@ export default function AttackPathsPage() {
|
||||
};
|
||||
|
||||
const handleExecuteQuery = async () => {
|
||||
if (sample) {
|
||||
showSampleGraph(sample);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!scanId || !queryBuilder.selectedQuery) {
|
||||
showErrorToast("Error", "Please select both a scan and a query");
|
||||
return;
|
||||
@@ -320,10 +348,13 @@ export default function AttackPathsPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
findingNavigationInFlightRef.current = true;
|
||||
// Open finding drawer directly, bypassing the node-details modal.
|
||||
graphState.enterFilteredView(node.id);
|
||||
graphState.selectNode(null); // clear so node-details modal doesn't open first
|
||||
// Sample findings have no API record: focus the path, open no drawer.
|
||||
if (sample) return;
|
||||
|
||||
findingNavigationInFlightRef.current = true;
|
||||
void handleViewFinding(String(node.properties?.id || node.id));
|
||||
return;
|
||||
}
|
||||
@@ -406,24 +437,26 @@ export default function AttackPathsPage() {
|
||||
graphState.selectedNode ?? graphState.filteredNode;
|
||||
const lighthouseGraphData = graphState.fullData ?? graphState.data;
|
||||
const lighthouseExecution = graphState.loading ? null : graphState.execution;
|
||||
const lighthouseContext = scanId
|
||||
? buildAttackPathContext({
|
||||
pathname,
|
||||
scanId,
|
||||
queryId: lighthouseExecution?.queryId,
|
||||
queryLabel: lighthouseExecution?.queryLabel,
|
||||
queryKind: lighthouseExecution?.queryKind,
|
||||
parameters: lighthouseExecution?.parameters,
|
||||
graphData: lighthouseExecution ? lighthouseGraphData : null,
|
||||
selectedNode:
|
||||
lighthouseExecution && lighthouseSelectedNode
|
||||
? {
|
||||
id: lighthouseSelectedNode.id,
|
||||
type: lighthouseSelectedNode.labels[0],
|
||||
}
|
||||
: null,
|
||||
})
|
||||
: null;
|
||||
// Sample data never reaches Lighthouse.
|
||||
const lighthouseContext =
|
||||
scanId && !sample
|
||||
? buildAttackPathContext({
|
||||
pathname,
|
||||
scanId,
|
||||
queryId: lighthouseExecution?.queryId,
|
||||
queryLabel: lighthouseExecution?.queryLabel,
|
||||
queryKind: lighthouseExecution?.queryKind,
|
||||
parameters: lighthouseExecution?.parameters,
|
||||
graphData: lighthouseExecution ? lighthouseGraphData : null,
|
||||
selectedNode:
|
||||
lighthouseExecution && lighthouseSelectedNode
|
||||
? {
|
||||
id: lighthouseSelectedNode.id,
|
||||
type: lighthouseSelectedNode.labels[0],
|
||||
}
|
||||
: null,
|
||||
})
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
@@ -465,9 +498,12 @@ export default function AttackPathsPage() {
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Suspense fallback={<div>Loading scans...</div>}>
|
||||
<ScanListTable scans={scans} />
|
||||
</Suspense>
|
||||
{/* The sample scan is the only one: nothing to pick or page. */}
|
||||
<div inert={Boolean(sample)}>
|
||||
<Suspense fallback={<div>Loading scans...</div>}>
|
||||
<ScanListTable scans={scans} selectedScanId={sample?.scan.id} />
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
{isViewingPreviousCycleData && (
|
||||
<StatusAlert
|
||||
|
||||
@@ -1 +1,22 @@
|
||||
export { default } from "./(workflow)/query-builder/attack-paths-page";
|
||||
import { SamplePreviewBanner } from "@/components/shared/sample-preview/sample-preview-banner";
|
||||
import { getSamplePreviewState } from "@/lib/sample-preview/get-sample-preview-state";
|
||||
import { SAMPLE_ATTACK_PATHS } from "@/lib/sample-preview/sample-attack-paths";
|
||||
import { SAMPLE_PREVIEW_PAGE } from "@/lib/sample-preview/sample-preview-events";
|
||||
|
||||
import AttackPathsPage from "./(workflow)/query-builder/attack-paths-page";
|
||||
|
||||
export default async function AttackPathsRoute() {
|
||||
// Decided on the server, so the sample never flashes in after the real page.
|
||||
const samplePreview = await getSamplePreviewState();
|
||||
if (!samplePreview.active) return <AttackPathsPage />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SamplePreviewBanner
|
||||
state={samplePreview}
|
||||
page={SAMPLE_PREVIEW_PAGE.ATTACK_PATHS}
|
||||
/>
|
||||
<AttackPathsPage sample={SAMPLE_ATTACK_PATHS} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { Card, CardContent } from "@/components/shadcn/card/card";
|
||||
|
||||
export const ComplianceOverviewPanel = ({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) => {
|
||||
return (
|
||||
<Card
|
||||
variant="base"
|
||||
padding="none"
|
||||
className="minimal-scrollbar relative z-0 w-full gap-4 overflow-auto shadow-sm"
|
||||
>
|
||||
<CardContent className="flex flex-col gap-4 p-4">{children}</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { SAMPLE_COMPLIANCE_WATCHLIST_RESPONSE } from "@/lib/sample-preview/sample-overview";
|
||||
import { SAMPLE_PREVIEW_STAGE } from "@/lib/sample-preview/sample-preview-state";
|
||||
|
||||
import { ComplianceSamplePreview } from "./compliance-sample-preview";
|
||||
|
||||
const { fetchMock, getScanMock } = vi.hoisted(() => ({
|
||||
fetchMock: vi.fn(),
|
||||
getScanMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), refresh: vi.fn() }),
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
usePathname: () => "/compliance",
|
||||
}));
|
||||
vi.mock("@/auth.config", () => ({ auth: vi.fn() }));
|
||||
vi.mock("@/actions/scans", () => ({ getScan: getScanMock }));
|
||||
|
||||
const STATE = {
|
||||
active: true as const,
|
||||
stage: SAMPLE_PREVIEW_STAGE.NO_PROVIDER,
|
||||
runningScan: null,
|
||||
};
|
||||
|
||||
describe("ComplianceSamplePreview", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
it("renders the Overview watchlist frameworks as inert cards without fetching", () => {
|
||||
render(<ComplianceSamplePreview state={STATE} />);
|
||||
|
||||
expect(
|
||||
screen.getByText("You are exploring sample data"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
const cards = screen.getAllByRole("button", {
|
||||
name: /compliance details$/,
|
||||
});
|
||||
expect(cards).toHaveLength(
|
||||
SAMPLE_COMPLIANCE_WATCHLIST_RESPONSE.data.length,
|
||||
);
|
||||
|
||||
const cis = SAMPLE_COMPLIANCE_WATCHLIST_RESPONSE.data.find(
|
||||
(item) => item.attributes.compliance_id === "cis_5.0_aws",
|
||||
)!.attributes;
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Open CIS - 5.0 compliance details" }),
|
||||
).toHaveTextContent(
|
||||
`${cis.requirements_passed} / ${cis.total_requirements}`,
|
||||
);
|
||||
|
||||
expect(cards[0].closest("[inert]")).not.toBeNull();
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(getScanMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { ThreatScoreBadge } from "@/components/compliance";
|
||||
import { ComplianceCard } from "@/components/compliance/compliance-card";
|
||||
import { ComplianceFrameworkGrid } from "@/components/compliance/compliance-framework-grid";
|
||||
import { SamplePreviewBanner } from "@/components/shared/sample-preview/sample-preview-banner";
|
||||
import {
|
||||
SAMPLE_COMPLIANCE_OVERVIEWS,
|
||||
SAMPLE_SCAN,
|
||||
} from "@/lib/sample-preview/sample-compliance";
|
||||
import { SAMPLE_THREAT_SCORE } from "@/lib/sample-preview/sample-overview";
|
||||
import { SAMPLE_PREVIEW_PAGE } from "@/lib/sample-preview/sample-preview-events";
|
||||
import type { SamplePreviewActive } from "@/lib/sample-preview/sample-preview-state";
|
||||
|
||||
import { ComplianceOverviewPanel } from "./compliance-overview-panel";
|
||||
|
||||
interface ComplianceSamplePreviewProps {
|
||||
state: SamplePreviewActive;
|
||||
}
|
||||
|
||||
/** Sample per-scan compliance: the Overview watchlist frameworks, inert. */
|
||||
export function ComplianceSamplePreview({
|
||||
state,
|
||||
}: ComplianceSamplePreviewProps) {
|
||||
return (
|
||||
<>
|
||||
<SamplePreviewBanner
|
||||
state={state}
|
||||
page={SAMPLE_PREVIEW_PAGE.COMPLIANCE}
|
||||
/>
|
||||
<div inert>
|
||||
<div className="mb-6">
|
||||
<ThreatScoreBadge
|
||||
score={SAMPLE_THREAT_SCORE.score}
|
||||
scanId={SAMPLE_SCAN.id}
|
||||
provider={SAMPLE_SCAN.providerInfo.provider}
|
||||
selectedScan={SAMPLE_SCAN}
|
||||
sectionScores={SAMPLE_THREAT_SCORE.sectionScores}
|
||||
/>
|
||||
</div>
|
||||
<ComplianceOverviewPanel>
|
||||
<ComplianceFrameworkGrid>
|
||||
{SAMPLE_COMPLIANCE_OVERVIEWS.map(({ id, attributes }) => (
|
||||
<div key={id} className="h-full [&>*]:h-full">
|
||||
<ComplianceCard
|
||||
title={attributes.framework}
|
||||
version={attributes.version}
|
||||
passingRequirements={attributes.requirements_passed}
|
||||
totalRequirements={attributes.total_requirements}
|
||||
prevPassingRequirements={attributes.requirements_passed}
|
||||
prevTotalRequirements={attributes.total_requirements}
|
||||
scanId={SAMPLE_SCAN.id}
|
||||
complianceId={id}
|
||||
id={id}
|
||||
selectedScan={SAMPLE_SCAN}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</ComplianceFrameworkGrid>
|
||||
</ComplianceOverviewPanel>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,11 @@ import {
|
||||
} from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
SAMPLE_PREVIEW_STAGE,
|
||||
type SamplePreviewState,
|
||||
} from "@/lib/sample-preview/sample-preview-state";
|
||||
|
||||
import Compliance from "./page";
|
||||
|
||||
const {
|
||||
@@ -19,6 +24,7 @@ const {
|
||||
complianceOverviewGridSpy,
|
||||
getComplianceOverviewMetadataInfoMock,
|
||||
getCompliancesOverviewMock,
|
||||
getSamplePreviewStateMock,
|
||||
getScanMock,
|
||||
getScansMock,
|
||||
getThreatScoreMock,
|
||||
@@ -29,6 +35,9 @@ const {
|
||||
complianceOverviewGridSpy: vi.fn(),
|
||||
getComplianceOverviewMetadataInfoMock: vi.fn(),
|
||||
getCompliancesOverviewMock: vi.fn(),
|
||||
getSamplePreviewStateMock: vi.fn(
|
||||
async (): Promise<SamplePreviewState> => ({ active: false }),
|
||||
),
|
||||
getScanMock: vi.fn(),
|
||||
getScansMock: vi.fn(),
|
||||
getThreatScoreMock: vi.fn(),
|
||||
@@ -52,6 +61,14 @@ vi.mock("@/actions/scans", () => ({
|
||||
getScansByState: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/sample-preview/get-sample-preview-state", () => ({
|
||||
getSamplePreviewState: getSamplePreviewStateMock,
|
||||
}));
|
||||
|
||||
vi.mock("./_components/compliance-sample-preview", () => ({
|
||||
ComplianceSamplePreview: () => <div>Sample compliance preview</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/shared/env", () => ({
|
||||
isCloud: isCloudMock,
|
||||
}));
|
||||
@@ -155,6 +172,30 @@ describe("Compliance overview page", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Compliance sample preview", () => {
|
||||
it("renders the sample and skips the real fetches until the first scan completes", async () => {
|
||||
// Given - a Cloud tenant whose first scan has not completed yet
|
||||
getSamplePreviewStateMock.mockResolvedValue({
|
||||
active: true,
|
||||
stage: SAMPLE_PREVIEW_STAGE.NO_SCAN,
|
||||
runningScan: null,
|
||||
});
|
||||
|
||||
// When
|
||||
render(
|
||||
(await Compliance({
|
||||
searchParams: Promise.resolve({}),
|
||||
})) as ReactElement,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(screen.getByText("Sample compliance preview")).toBeInTheDocument();
|
||||
expect(getScansMock).not.toHaveBeenCalled();
|
||||
expect(getCompliancesOverviewMock).not.toHaveBeenCalled();
|
||||
expect(loadComplianceWatchlistContextMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Compliance overview task response", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
@@ -17,10 +17,10 @@ import { ComplianceFilters } from "@/components/compliance/compliance-header/com
|
||||
import { ComplianceOverviewGrid } from "@/components/compliance/compliance-overview-grid";
|
||||
import { WatchlistControls } from "@/components/compliance/watchlist/watchlist-controls";
|
||||
import { Alert, AlertDescription } from "@/components/shadcn/alert";
|
||||
import { Card, CardContent } from "@/components/shadcn/card/card";
|
||||
import { ContentLayout } from "@/components/shadcn/content-layout";
|
||||
import { pickLatestCisPerProvider } from "@/lib/compliance/compliance-report-types";
|
||||
import { isReportDownloadLocked } from "@/lib/report-download-access";
|
||||
import { getSamplePreviewState } from "@/lib/sample-preview/get-sample-preview-state";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import {
|
||||
ExpandedScanData,
|
||||
@@ -30,8 +30,10 @@ import {
|
||||
} from "@/types";
|
||||
import { COMPLIANCE_TAB, ComplianceOverviewData } from "@/types/compliance";
|
||||
|
||||
import { ComplianceOverviewPanel } from "./_components/compliance-overview-panel";
|
||||
import { CompliancePageTabs } from "./_components/compliance-page-tabs";
|
||||
import { getComplianceTab } from "./_components/compliance-page-tabs.shared";
|
||||
import { ComplianceSamplePreview } from "./_components/compliance-sample-preview";
|
||||
import { CrossAccountOverviewSection } from "./_components/cross-account-overview-section";
|
||||
import { CrossProviderOverview } from "./_components/cross-provider-overview";
|
||||
import {
|
||||
@@ -71,6 +73,16 @@ export default async function Compliance({
|
||||
}: {
|
||||
searchParams: Promise<SearchParamsProps>;
|
||||
}) {
|
||||
// Sample data replaces both tabs until the first scan completes.
|
||||
const preview = await getSamplePreviewState();
|
||||
if (preview.active) {
|
||||
return (
|
||||
<ContentLayout title="Compliance" icon={<ShieldCheck />}>
|
||||
<ComplianceSamplePreview state={preview} />
|
||||
</ContentLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const searchParamsKey = JSON.stringify(resolvedSearchParams || {});
|
||||
|
||||
@@ -409,19 +421,3 @@ const ComplianceWatchlistControls = async ({
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const ComplianceOverviewPanel = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
return (
|
||||
<Card
|
||||
variant="base"
|
||||
padding="none"
|
||||
className="minimal-scrollbar relative z-0 w-full gap-4 overflow-auto shadow-sm"
|
||||
>
|
||||
<CardContent className="flex flex-col gap-4 p-4">{children}</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,121 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { SAMPLE_FAILED_CHECKS } from "@/lib/sample-preview/sample-dataset";
|
||||
import { SAMPLE_PREVIEW_STAGE } from "@/lib/sample-preview/sample-preview-state";
|
||||
|
||||
import { FindingsSamplePreview } from "./findings-sample-preview";
|
||||
|
||||
const { fetchMock, getScanMock, resolveFindingIdsMock } = vi.hoisted(() => ({
|
||||
fetchMock: vi.fn(),
|
||||
getScanMock: vi.fn(),
|
||||
resolveFindingIdsMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), refresh: vi.fn(), replace: vi.fn() }),
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
usePathname: () => "/findings",
|
||||
}));
|
||||
vi.mock("@/auth.config", () => ({ auth: vi.fn() }));
|
||||
vi.mock("next-auth/react", () => ({
|
||||
useSession: () => ({ data: null, status: "unauthenticated" }),
|
||||
}));
|
||||
vi.mock("@/actions/scans", () => ({ getScan: getScanMock }));
|
||||
vi.mock("@/actions/findings/findings-by-resource", () => ({
|
||||
resolveFindingIdsByVisibleGroupResources: resolveFindingIdsMock,
|
||||
}));
|
||||
|
||||
const STATE = {
|
||||
active: true as const,
|
||||
stage: SAMPLE_PREVIEW_STAGE.NO_SCAN,
|
||||
runningScan: null,
|
||||
};
|
||||
|
||||
describe("FindingsSamplePreview", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
// jsdom has no IntersectionObserver; the drill-down's infinite scroll needs one.
|
||||
vi.stubGlobal(
|
||||
"IntersectionObserver",
|
||||
class {
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("renders every failed sample check without fetching", () => {
|
||||
const { container } = render(<FindingsSamplePreview state={STATE} />);
|
||||
|
||||
expect(
|
||||
screen.getByText("You are exploring sample data"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
const publicBucket = SAMPLE_FAILED_CHECKS.find(
|
||||
(check) => check.checkId === "s3_bucket_public_access",
|
||||
)!;
|
||||
const row = screen
|
||||
.getByText(publicBucket.title)
|
||||
.closest("tr") as HTMLElement;
|
||||
// Impacted resources: failed of failed + passed.
|
||||
expect(
|
||||
within(row).getByText(String(publicBucket.failed)),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(row).getByText(String(publicBucket.failed + publicBucket.passed)),
|
||||
).toBeInTheDocument();
|
||||
|
||||
const table = container.querySelector("table") as HTMLElement;
|
||||
expect(within(table).getAllByRole("row", { hidden: true })).toHaveLength(
|
||||
SAMPLE_FAILED_CHECKS.length + 1,
|
||||
);
|
||||
expect(table.closest("[inert]")).toBeNull();
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(getScanMock).not.toHaveBeenCalled();
|
||||
expect(resolveFindingIdsMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("expands a sample group into its resources from the sample records", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FindingsSamplePreview state={STATE} />);
|
||||
|
||||
await user.click(
|
||||
screen.getByText("Ensure S3 buckets are not publicly accessible"),
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByText("acme-customer-exports"),
|
||||
).toBeInTheDocument();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("applies the severity an Overview link carried, with a way back to all", () => {
|
||||
render(
|
||||
<FindingsSamplePreview
|
||||
state={STATE}
|
||||
searchParams={{
|
||||
"filter[severity__in]": "critical",
|
||||
"filter[status__in]": "FAIL",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const critical = SAMPLE_FAILED_CHECKS.filter(
|
||||
(check) => check.severity === "critical",
|
||||
);
|
||||
const high = SAMPLE_FAILED_CHECKS.find(
|
||||
(check) => check.severity === "high",
|
||||
)!;
|
||||
for (const check of critical) {
|
||||
expect(screen.getByText(check.title)).toBeInTheDocument();
|
||||
}
|
||||
expect(screen.queryByText(high.title)).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "Show all" })).toHaveAttribute(
|
||||
"href",
|
||||
"/findings",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { FindingsGroupTable } from "@/components/findings/table";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { SamplePreviewBanner } from "@/components/shared/sample-preview/sample-preview-banner";
|
||||
import { SAMPLE_ACCOUNT } from "@/lib/sample-preview/sample-dataset";
|
||||
import { filterSampleFindingGroups } from "@/lib/sample-preview/sample-findings";
|
||||
import { SAMPLE_PREVIEW_PAGE } from "@/lib/sample-preview/sample-preview-events";
|
||||
import type { SamplePreviewActive } from "@/lib/sample-preview/sample-preview-state";
|
||||
import type { SearchParamsProps } from "@/types/components";
|
||||
|
||||
// Scoping to the sample scan is what makes every drill-down action answer
|
||||
// from the sample records instead of the API.
|
||||
const SAMPLE_SCAN_FILTERS: Record<string, string> = {
|
||||
"filter[scan]": SAMPLE_ACCOUNT.scanId,
|
||||
};
|
||||
|
||||
interface FindingsSamplePreviewProps {
|
||||
state: SamplePreviewActive;
|
||||
/** The filters an Overview link carried; there are no filter controls. */
|
||||
searchParams?: SearchParamsProps;
|
||||
}
|
||||
|
||||
/** Sample finding groups: no filter controls, no metadata (so no Lighthouse context). */
|
||||
export function FindingsSamplePreview({
|
||||
state,
|
||||
searchParams = {},
|
||||
}: FindingsSamplePreviewProps) {
|
||||
const { groups, filtered } = filterSampleFindingGroups(searchParams);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SamplePreviewBanner state={state} page={SAMPLE_PREVIEW_PAGE.FINDINGS} />
|
||||
{filtered && (
|
||||
<p className="text-text-neutral-tertiary mb-4 text-sm">
|
||||
Showing {groups.length} of the sample checks, filtered from the
|
||||
Overview.{" "}
|
||||
<Button variant="link" size="link-sm" asChild>
|
||||
<Link href="/findings">Show all</Link>
|
||||
</Button>
|
||||
</p>
|
||||
)}
|
||||
<FindingsGroupTable
|
||||
data={groups}
|
||||
resolvedFilters={SAMPLE_SCAN_FILTERS}
|
||||
hasHistoricalData
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -24,17 +24,32 @@ import {
|
||||
isFindingsPreviewLocked,
|
||||
} from "@/lib/findings-access";
|
||||
import { resolveFindingScanDateFilters } from "@/lib/findings-scan-filters";
|
||||
import { getSamplePreviewState } from "@/lib/sample-preview/get-sample-preview-state";
|
||||
import { ScanProps } from "@/types";
|
||||
import { SearchParamsProps } from "@/types/components";
|
||||
|
||||
import { FindingsFiltersSection } from "./_components/findings-filters-section";
|
||||
import { FindingsFiltersSkeleton } from "./_components/findings-filters-skeleton";
|
||||
import { FindingsSamplePreview } from "./_components/findings-sample-preview";
|
||||
|
||||
export default async function Findings({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParamsProps>;
|
||||
}) {
|
||||
// Sample data replaces the page (and its paywall) until the first scan completes.
|
||||
const preview = await getSamplePreviewState();
|
||||
if (preview.active) {
|
||||
return (
|
||||
<ContentLayout title="Findings" icon={<Tag />}>
|
||||
<FindingsSamplePreview
|
||||
state={preview}
|
||||
searchParams={await searchParams}
|
||||
/>
|
||||
</ContentLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const { encodedSort } = extractSortAndKey(resolvedSearchParams);
|
||||
const { filters, query } = extractFiltersAndQuery(resolvedSearchParams);
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { getIssue, notFound } = vi.hoisted(() => ({
|
||||
getIssue: vi.fn(),
|
||||
notFound: vi.fn(() => {
|
||||
throw new Error("NOT_FOUND");
|
||||
}),
|
||||
}));
|
||||
vi.mock("next/navigation", () => ({ notFound }));
|
||||
vi.mock("@/actions/issues", () => ({ getIssue }));
|
||||
vi.mock("@/components/shadcn/content-layout", () => ({
|
||||
ContentLayout: ({ children }: { children: ReactNode }) => children,
|
||||
}));
|
||||
vi.mock("@/components/shared/feature-flag-route", () => ({
|
||||
FeatureFlagRoute: ({ children }: { children: ReactNode }) => children,
|
||||
}));
|
||||
vi.mock("@/components/issues/issue-detail", () => ({
|
||||
IssueDetail: ({ scanId }: { scanId?: string }) => (
|
||||
<div data-testid="issue-detail">{scanId}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
import IssuePage from "./page";
|
||||
|
||||
const params = Promise.resolve({ id: "issue-1" });
|
||||
|
||||
describe("IssuePage", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubEnv("UI_CLOUD_ENABLED", "true");
|
||||
getIssue.mockReset();
|
||||
});
|
||||
|
||||
it("returns not found in Local Server without asking the API", async () => {
|
||||
vi.stubEnv("UI_CLOUD_ENABLED", "false");
|
||||
await expect(IssuePage({ params })).rejects.toThrow("NOT_FOUND");
|
||||
expect(getIssue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns not found when the API has no such issue", async () => {
|
||||
getIssue.mockResolvedValue({ error: "Not found", status: 404 });
|
||||
await expect(IssuePage({ params })).rejects.toThrow("NOT_FOUND");
|
||||
});
|
||||
|
||||
it("shows the issue with the detail the list opens in place", async () => {
|
||||
getIssue.mockResolvedValue({
|
||||
data: {
|
||||
type: "issues",
|
||||
id: "issue-1",
|
||||
attributes: {
|
||||
pattern: "aws_public_bucket",
|
||||
cloud: "aws",
|
||||
account: "prod",
|
||||
title: "Public bucket",
|
||||
verdict: "likely",
|
||||
},
|
||||
relationships: { scan: { data: { type: "scans", id: "scan-1" } } },
|
||||
},
|
||||
});
|
||||
|
||||
render(await IssuePage({ params }));
|
||||
|
||||
expect(getIssue).toHaveBeenCalledWith("issue-1");
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Public bucket" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Likely")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("issue-detail")).toHaveTextContent("scan-1");
|
||||
expect(screen.getByRole("link", { name: "← All issues" })).toHaveAttribute(
|
||||
"href",
|
||||
"/issues",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,69 +0,0 @@
|
||||
import { ShieldAlert } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { getIssue } from "@/actions/issues";
|
||||
import { IssueDetail } from "@/components/issues/issue-detail";
|
||||
import { Badge } from "@/components/shadcn/badge/badge";
|
||||
import { Button } from "@/components/shadcn/button/button";
|
||||
import { Card } from "@/components/shadcn/card/card";
|
||||
import { ContentLayout } from "@/components/shadcn/content-layout";
|
||||
import { EntityInfo } from "@/components/shadcn/entities";
|
||||
import { FeatureFlagRoute } from "@/components/shared/feature-flag-route";
|
||||
import { FEATURE_FLAG } from "@/lib/feature-flags";
|
||||
import { VERDICTS } from "@/lib/issues/verdicts";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
|
||||
/**
|
||||
* One issue on its own URL, so a path can be shared or linked from a ticket.
|
||||
* The body is the same detail the list opens in place.
|
||||
*/
|
||||
export default async function IssuePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
if (!isCloud()) notFound();
|
||||
const { id } = await params;
|
||||
const issue = (await getIssue(id))?.data;
|
||||
// handleApiResponse resolves {error, status} on a 4xx: no data is no issue.
|
||||
if (!issue?.attributes) notFound();
|
||||
|
||||
const { attributes, relationships } = issue;
|
||||
const verdict = VERDICTS.find((item) => item.id === attributes.verdict);
|
||||
|
||||
return (
|
||||
<ContentLayout title="Issue" icon={<ShieldAlert />}>
|
||||
<FeatureFlagRoute flag={FEATURE_FLAG.ISSUES}>
|
||||
<div className="flex min-w-0 flex-col gap-4">
|
||||
<Button asChild variant="link" size="link-sm" className="self-start">
|
||||
<Link href="/issues">← All issues</Link>
|
||||
</Button>
|
||||
<Card variant="base" className="min-w-0 gap-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Badge variant={verdict?.variant ?? "tag"}>
|
||||
{verdict?.label ?? attributes.verdict}
|
||||
</Badge>
|
||||
<span className="text-text-neutral-tertiary text-xs">
|
||||
{attributes.pattern}
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="text-text-neutral-primary text-lg font-semibold break-words">
|
||||
{attributes.title}
|
||||
</h2>
|
||||
<EntityInfo
|
||||
cloudProvider={attributes.cloud}
|
||||
entityAlias={attributes.account}
|
||||
/>
|
||||
<IssueDetail
|
||||
id={issue.id}
|
||||
issue={attributes}
|
||||
scanId={relationships?.scan?.data?.id}
|
||||
providerId={relationships?.provider?.data?.id}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</FeatureFlagRoute>
|
||||
</ContentLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { getIssues, getIssuesOverview } from "@/actions/issues";
|
||||
import { SAMPLE_ISSUES } from "@/lib/sample-preview/sample-issues";
|
||||
import { VERDICT } from "@/types/issues";
|
||||
|
||||
import { IssuesList } from "./issues-list";
|
||||
|
||||
vi.mock("@/actions/issues", () => ({
|
||||
getIssues: vi.fn(),
|
||||
getIssuesOverview: vi.fn(),
|
||||
}));
|
||||
// Modules that reach next-auth under jsdom; the panel has its own tests.
|
||||
vi.mock("@/components/issues/issue-panel", () => ({ IssuePanel: () => null }));
|
||||
vi.mock("@/lib", () => ({ getPaginationInfo: vi.fn() }));
|
||||
vi.mock("@/contexts", () => ({ useFilterTransitionOptional: () => null }));
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: () => "/issues",
|
||||
useRouter: () => ({ push: vi.fn(), refresh: vi.fn() }),
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}));
|
||||
|
||||
const [exploitable] = SAMPLE_ISSUES.filter(
|
||||
(issue) => issue.attributes.verdict === VERDICT.EXPLOITABLE,
|
||||
);
|
||||
const [notExploitable] = SAMPLE_ISSUES.filter(
|
||||
(issue) => issue.attributes.verdict === VERDICT.NOT_EXPLOITABLE,
|
||||
);
|
||||
|
||||
describe("IssuesList sample preview", () => {
|
||||
it("renders the sample issues clickable, filtered by verdict, without the API", async () => {
|
||||
// When
|
||||
render(
|
||||
await IssuesList({
|
||||
searchParams: { "filter[verdict]": VERDICT.EXPLOITABLE },
|
||||
sample: true,
|
||||
}),
|
||||
);
|
||||
|
||||
// Then
|
||||
const title = screen.getByRole("button", {
|
||||
name: exploitable.attributes.title,
|
||||
});
|
||||
expect(title.closest("[inert]")).toBeNull();
|
||||
expect(
|
||||
screen.queryByText(notExploitable.attributes.title),
|
||||
).not.toBeInTheDocument();
|
||||
expect(getIssues).not.toHaveBeenCalled();
|
||||
expect(getIssuesOverview).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("matches nothing for a scope the demo account is not in", async () => {
|
||||
render(
|
||||
await IssuesList({
|
||||
searchParams: { "filter[cloud]": "gcp" },
|
||||
sample: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(screen.getByText("No issues match this filter.")).toBeVisible();
|
||||
expect(
|
||||
screen.queryByText(exploitable.attributes.title),
|
||||
).not.toBeInTheDocument();
|
||||
// The summary is scoped too, not just the rows.
|
||||
expect(screen.getByLabelText("All, 0 issues")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("matches every verdict of a verdict__in list", async () => {
|
||||
render(
|
||||
await IssuesList({
|
||||
searchParams: {
|
||||
"filter[verdict__in]": `${VERDICT.EXPLOITABLE},${VERDICT.NOT_EXPLOITABLE}`,
|
||||
},
|
||||
sample: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", { name: exploitable.attributes.title }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: notExploitable.attributes.title }),
|
||||
).toBeInTheDocument();
|
||||
const outsideList = SAMPLE_ISSUES.filter(
|
||||
(issue) =>
|
||||
issue.attributes.verdict !== VERDICT.EXPLOITABLE &&
|
||||
issue.attributes.verdict !== VERDICT.NOT_EXPLOITABLE,
|
||||
);
|
||||
expect(outsideList.length).toBeGreaterThan(0);
|
||||
for (const issue of outsideList) {
|
||||
expect(
|
||||
screen.queryByText(issue.attributes.title),
|
||||
).not.toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it("reads a scalar verdict filter as one literal value", async () => {
|
||||
render(
|
||||
await IssuesList({
|
||||
searchParams: {
|
||||
"filter[verdict]": `${VERDICT.EXPLOITABLE},${VERDICT.NOT_EXPLOITABLE}`,
|
||||
},
|
||||
sample: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(screen.getByText("No issues match this filter.")).toBeVisible();
|
||||
});
|
||||
|
||||
it("scopes the rows by severity like the API", async () => {
|
||||
const critical = SAMPLE_ISSUES.filter(
|
||||
(issue) => issue.attributes.severity === "critical",
|
||||
);
|
||||
const other = SAMPLE_ISSUES.find(
|
||||
(issue) => issue.attributes.severity !== "critical",
|
||||
);
|
||||
|
||||
render(
|
||||
await IssuesList({
|
||||
searchParams: { "filter[severity]": "critical" },
|
||||
sample: true,
|
||||
}),
|
||||
);
|
||||
|
||||
for (const issue of critical) {
|
||||
expect(
|
||||
screen.getByRole("button", { name: issue.attributes.title }),
|
||||
).toBeInTheDocument();
|
||||
}
|
||||
expect(screen.queryByText(other!.attributes.title)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,13 @@ import { IssuesTable } from "@/components/issues/issues-table";
|
||||
import { VerdictFilter } from "@/components/issues/verdict-filter";
|
||||
import { ISSUE_SCOPE_KEYS } from "@/lib/issues/scope";
|
||||
import { verdictCounts } from "@/lib/issues/verdicts";
|
||||
import { SAMPLE_ACCOUNT } from "@/lib/sample-preview/sample-dataset";
|
||||
import {
|
||||
SAMPLE_ISSUES,
|
||||
summarizeSampleIssues,
|
||||
} from "@/lib/sample-preview/sample-issues";
|
||||
import { SearchParamsProps } from "@/types";
|
||||
import type { IssueResponse, IssuesOverviewResponse } from "@/types/issues";
|
||||
|
||||
/** Only the filters the issues endpoint understands are forwarded. */
|
||||
const ISSUE_FILTER_KEYS = [
|
||||
@@ -21,15 +27,21 @@ const ISSUE_FILTER_KEYS = [
|
||||
"filter[triage_status]",
|
||||
"filter[triage_status__in]",
|
||||
...ISSUE_SCOPE_KEYS,
|
||||
];
|
||||
] as const;
|
||||
|
||||
export const IssuesList = async ({
|
||||
searchParams,
|
||||
filters: filterControls,
|
||||
providerUids,
|
||||
sample = false,
|
||||
}: {
|
||||
searchParams: SearchParamsProps;
|
||||
/** Renders the sample issues instead of calling the issues API. */
|
||||
sample?: boolean;
|
||||
/** The scope selectors, rendered above the verdict tabs: they scope every tab. */
|
||||
filters?: ReactNode;
|
||||
/** Provider uid by provider id, for the issue panel. */
|
||||
providerUids?: Record<string, string>;
|
||||
}) => {
|
||||
const filters: Record<string, string> = {};
|
||||
ISSUE_FILTER_KEYS.forEach((key) => {
|
||||
@@ -45,16 +57,20 @@ export const IssuesList = async ({
|
||||
([key]) => key !== "filter[verdict]" && key !== "filter[verdict__in]",
|
||||
),
|
||||
);
|
||||
const [summary, issues] = await Promise.all([
|
||||
getIssuesOverview({ filters: scope }),
|
||||
getIssues({
|
||||
filters,
|
||||
page: Number(searchParams.page) || 1,
|
||||
pageSize: Number(searchParams.pageSize) || 10,
|
||||
sort:
|
||||
typeof searchParams.sort === "string" ? searchParams.sort : undefined,
|
||||
}),
|
||||
]);
|
||||
const [summary, issues] = sample
|
||||
? sampleIssues(filters, scope)
|
||||
: await Promise.all([
|
||||
getIssuesOverview({ filters: scope }),
|
||||
getIssues({
|
||||
filters,
|
||||
page: Number(searchParams.page) || 1,
|
||||
pageSize: Number(searchParams.pageSize) || 10,
|
||||
sort:
|
||||
typeof searchParams.sort === "string"
|
||||
? searchParams.sort
|
||||
: undefined,
|
||||
}),
|
||||
]);
|
||||
|
||||
const attributes = summary?.data?.attributes;
|
||||
// handleApiResponse resolves {error, status} on a 4xx and undefined on a
|
||||
@@ -87,7 +103,11 @@ export const IssuesList = async ({
|
||||
{rows === null ? (
|
||||
<IssuesLoadError />
|
||||
) : rows.length ? (
|
||||
<IssuesTable issues={rows} metadata={issues?.meta} />
|
||||
<IssuesTable
|
||||
issues={rows}
|
||||
metadata={issues?.meta}
|
||||
providerUids={providerUids}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-text-neutral-tertiary text-sm">
|
||||
No issues match this filter.
|
||||
@@ -96,3 +116,61 @@ export const IssuesList = async ({
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type SampleIssue = (typeof SAMPLE_ISSUES)[number];
|
||||
|
||||
/** The value each forwarded filter matches on a sample issue. */
|
||||
const SAMPLE_FILTER_FIELDS: Record<
|
||||
(typeof ISSUE_FILTER_KEYS)[number],
|
||||
(issue: SampleIssue) => string | undefined
|
||||
> = {
|
||||
"filter[verdict]": (issue) => issue.attributes.verdict,
|
||||
"filter[verdict__in]": (issue) => issue.attributes.verdict,
|
||||
"filter[cloud]": (issue) => issue.attributes.cloud,
|
||||
"filter[pattern]": (issue) => issue.attributes.pattern,
|
||||
"filter[severity]": (issue) => issue.attributes.severity,
|
||||
"filter[provider_type]": (issue) => issue.attributes.cloud,
|
||||
"filter[provider_type__in]": (issue) => issue.attributes.cloud,
|
||||
"filter[provider_alias]": () => SAMPLE_ACCOUNT.alias,
|
||||
"filter[provider_id__in]": (issue) => issue.relationships?.provider?.data?.id,
|
||||
// The sample account belongs to no provider group.
|
||||
"filter[provider_groups__in]": () => undefined,
|
||||
"filter[triage_status]": (issue) => issue.attributes.triage_status,
|
||||
"filter[triage_status__in]": (issue) => issue.attributes.triage_status,
|
||||
};
|
||||
|
||||
const matchesSampleFilters = (
|
||||
issue: SampleIssue,
|
||||
filters: Record<string, string>,
|
||||
) =>
|
||||
Object.entries(filters).every(([key, value]) => {
|
||||
const field =
|
||||
SAMPLE_FILTER_FIELDS[key as keyof typeof SAMPLE_FILTER_FIELDS];
|
||||
const actual = field?.(issue);
|
||||
// Only `__in` filters take a list; the rest match one literal value.
|
||||
const accepted = key.endsWith("__in]") ? value.split(",") : [value];
|
||||
return actual !== undefined && accepted.includes(actual);
|
||||
});
|
||||
|
||||
/** The sample summary and rows, filtered like the API would. */
|
||||
function sampleIssues(
|
||||
filters: Record<string, string>,
|
||||
scope: Record<string, string>,
|
||||
): [IssuesOverviewResponse, IssueResponse] {
|
||||
const scoped = SAMPLE_ISSUES.filter((issue) =>
|
||||
matchesSampleFilters(issue, scope),
|
||||
);
|
||||
return [
|
||||
{
|
||||
data: {
|
||||
type: "issues-overview",
|
||||
attributes: summarizeSampleIssues(scoped),
|
||||
},
|
||||
},
|
||||
{
|
||||
data: SAMPLE_ISSUES.filter((issue) =>
|
||||
matchesSampleFilters(issue, filters),
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1,8 +1,14 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { getAllProviders, getAllProviderGroups, notFound } = vi.hoisted(() => ({
|
||||
const {
|
||||
getAllProviders,
|
||||
getAllProviderGroups,
|
||||
getSamplePreviewState,
|
||||
notFound,
|
||||
} = vi.hoisted(() => ({
|
||||
getAllProviders: vi.fn(),
|
||||
getAllProviderGroups: vi.fn(),
|
||||
getSamplePreviewState: vi.fn(),
|
||||
notFound: vi.fn(() => {
|
||||
throw new Error("NOT_FOUND");
|
||||
}),
|
||||
@@ -22,6 +28,12 @@ vi.mock("@/components/shadcn/content-layout", () => ({
|
||||
ContentLayout: () => null,
|
||||
}));
|
||||
vi.mock("./_components/issues-list", () => ({ IssuesList: () => null }));
|
||||
vi.mock("@/components/shared/sample-preview/sample-preview-banner", () => ({
|
||||
SamplePreviewBanner: () => null,
|
||||
}));
|
||||
vi.mock("@/lib/sample-preview/get-sample-preview-state", () => ({
|
||||
getSamplePreviewState,
|
||||
}));
|
||||
|
||||
import Issues from "./page";
|
||||
|
||||
@@ -29,6 +41,7 @@ describe("Issues deployment availability", () => {
|
||||
beforeEach(() => {
|
||||
getAllProviders.mockResolvedValue({ data: [] });
|
||||
getAllProviderGroups.mockResolvedValue({ data: [] });
|
||||
getSamplePreviewState.mockResolvedValue({ active: false });
|
||||
});
|
||||
it("returns not found before loading data in Local Server", async () => {
|
||||
vi.stubEnv("UI_CLOUD_ENABLED", "false");
|
||||
@@ -45,4 +58,15 @@ describe("Issues deployment availability", () => {
|
||||
expect(getAllProviders).toHaveBeenCalledOnce();
|
||||
expect(getAllProviderGroups).toHaveBeenCalledOnce();
|
||||
});
|
||||
it("skips the scope controls' data while the sample is shown", async () => {
|
||||
vi.stubEnv("UI_CLOUD_ENABLED", "true");
|
||||
getSamplePreviewState.mockResolvedValue({
|
||||
active: true,
|
||||
stage: "no_provider",
|
||||
runningScan: null,
|
||||
});
|
||||
await Issues({ searchParams: Promise.resolve({}) });
|
||||
expect(getAllProviders).not.toHaveBeenCalled();
|
||||
expect(getAllProviderGroups).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -8,12 +8,20 @@ import { ProviderAccountSelectors } from "@/components/filters/provider-account-
|
||||
import { ProviderGroupSelector } from "@/components/filters/provider-group-selector";
|
||||
import { ContentLayout } from "@/components/shadcn/content-layout";
|
||||
import { FeatureFlagRoute } from "@/components/shared/feature-flag-route";
|
||||
import { SamplePreviewBanner } from "@/components/shared/sample-preview/sample-preview-banner";
|
||||
import { FEATURE_FLAG } from "@/lib/feature-flags";
|
||||
import { getSamplePreviewState } from "@/lib/sample-preview/get-sample-preview-state";
|
||||
import { SAMPLE_ACCOUNT } from "@/lib/sample-preview/sample-dataset";
|
||||
import { SAMPLE_PREVIEW_PAGE } from "@/lib/sample-preview/sample-preview-events";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import { SearchParamsProps } from "@/types";
|
||||
|
||||
import { IssuesList } from "./_components/issues-list";
|
||||
|
||||
const SAMPLE_PROVIDER_UIDS = {
|
||||
[SAMPLE_ACCOUNT.providerId]: SAMPLE_ACCOUNT.uid,
|
||||
};
|
||||
|
||||
export default async function Issues({
|
||||
searchParams,
|
||||
}: {
|
||||
@@ -22,11 +30,37 @@ export default async function Issues({
|
||||
if (!isCloud()) notFound();
|
||||
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const samplePreview = await getSamplePreviewState();
|
||||
if (samplePreview.active) {
|
||||
return (
|
||||
<ContentLayout title="Issues" icon={<ShieldAlert />}>
|
||||
<FeatureFlagRoute flag={FEATURE_FLAG.ISSUES}>
|
||||
<SamplePreviewBanner
|
||||
state={samplePreview}
|
||||
page={SAMPLE_PREVIEW_PAGE.ISSUES}
|
||||
/>
|
||||
{/* No scope selectors: the sample account is not one of theirs. */}
|
||||
<IssuesList
|
||||
searchParams={resolvedSearchParams}
|
||||
sample
|
||||
providerUids={SAMPLE_PROVIDER_UIDS}
|
||||
/>
|
||||
</FeatureFlagRoute>
|
||||
</ContentLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const [providersData, providerGroupsData] = await Promise.all([
|
||||
getAllProviders(),
|
||||
getAllProviderGroups(),
|
||||
]);
|
||||
|
||||
const providers = providersData?.data ?? [];
|
||||
// The issue only references its provider; the uid shows in the panel.
|
||||
const providerUids = Object.fromEntries(
|
||||
providers.map((provider) => [provider.id, provider.attributes.uid]),
|
||||
);
|
||||
|
||||
return (
|
||||
<ContentLayout title="Issues" icon={<ShieldAlert />}>
|
||||
{/* Deployment gate above (notFound); per-account rollout here, also a
|
||||
@@ -41,13 +75,12 @@ export default async function Issues({
|
||||
>
|
||||
<IssuesList
|
||||
searchParams={resolvedSearchParams}
|
||||
providerUids={providerUids}
|
||||
filters={
|
||||
// The three scope selectors share one row on desktop, like the
|
||||
// other list pages.
|
||||
<div className="mb-6 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<ProviderAccountSelectors
|
||||
providers={providersData?.data ?? []}
|
||||
/>
|
||||
<ProviderAccountSelectors providers={providers} />
|
||||
<ProviderGroupSelector
|
||||
groups={providerGroupsData?.data ?? []}
|
||||
/>
|
||||
|
||||
@@ -38,8 +38,12 @@ describe("Overview page", () => {
|
||||
|
||||
it("uses the responsive container breakpoint for the primary metric row", () => {
|
||||
// Given
|
||||
const metricRowClassName = source.match(
|
||||
/<div className="([^"]+)">\s*<Suspense fallback={<ThreatScoreSkeleton \/>}>/,
|
||||
const gridSource = readFileSync(
|
||||
path.join(currentDir, "_overview/_components/overview-grid.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const metricRowClassName = gridSource.match(
|
||||
/<div className="([^"]+)">\s*\{threatScore\}/,
|
||||
)?.[1];
|
||||
|
||||
// When
|
||||
|
||||
+85
-71
@@ -12,12 +12,16 @@ import {
|
||||
} from "@/components/layout/app-sidebar";
|
||||
import { ContentLayout } from "@/components/shadcn/content-layout";
|
||||
import { FeatureFlagGate } from "@/components/shared/feature-flag-gate";
|
||||
import { SamplePreviewBanner } from "@/components/shared/sample-preview/sample-preview-banner";
|
||||
import { DOCS_URLS } from "@/lib/external-urls";
|
||||
import { FEATURE_FLAG } from "@/lib/feature-flags";
|
||||
import { getSamplePreviewState } from "@/lib/sample-preview/get-sample-preview-state";
|
||||
import { SAMPLE_PREVIEW_PAGE } from "@/lib/sample-preview/sample-preview-events";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import { SearchParamsProps } from "@/types";
|
||||
|
||||
import { OverviewBanner } from "./_overview/_components/overview-banner";
|
||||
import { OverviewGrid } from "./_overview/_components/overview-grid";
|
||||
import { OverviewProviderContext } from "./_overview/_components/overview-provider-context";
|
||||
import { getLighthouseOverviewBannerHref } from "./_overview/_lib/lighthouse-banner";
|
||||
import { OVERVIEW_BANNER_VARIANT } from "./_overview/_lib/overview-banner";
|
||||
@@ -42,6 +46,7 @@ import {
|
||||
RiskSeverityChartSkeleton,
|
||||
RiskSeverityChartSSR,
|
||||
} from "./_overview/risk-severity";
|
||||
import { OverviewSamplePreview } from "./_overview/sample-preview/overview-sample-preview";
|
||||
import {
|
||||
FindingSeverityOverTimeSkeleton,
|
||||
FindingSeverityOverTimeSSR,
|
||||
@@ -60,12 +65,32 @@ export default async function Home({
|
||||
searchParams: Promise<SearchParamsProps>;
|
||||
}) {
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const [providersData, providerGroupsData, lighthouseBannerHref] =
|
||||
await Promise.all([
|
||||
getAllProviders(),
|
||||
getAllProviderGroups(),
|
||||
getLighthouseOverviewBannerHref(isCloud(), getLighthouseV2Configurations),
|
||||
]);
|
||||
// Resolved alongside the page data, so tenants with scans wait no longer.
|
||||
const [
|
||||
samplePreview,
|
||||
providersData,
|
||||
providerGroupsData,
|
||||
lighthouseBannerHref,
|
||||
] = await Promise.all([
|
||||
getSamplePreviewState(),
|
||||
getAllProviders(),
|
||||
getAllProviderGroups(),
|
||||
getLighthouseOverviewBannerHref(isCloud(), getLighthouseV2Configurations),
|
||||
]);
|
||||
|
||||
// No completed scan yet: the demo account stands in for the tenant's data.
|
||||
if (samplePreview.active) {
|
||||
return (
|
||||
<ContentLayout title="Overview" icon={<SquareChartGantt />}>
|
||||
<AppSidebarModeSync mode={APP_SIDEBAR_MODE.BROWSE} />
|
||||
<SamplePreviewBanner
|
||||
state={samplePreview}
|
||||
page={SAMPLE_PREVIEW_PAGE.OVERVIEW}
|
||||
/>
|
||||
<OverviewSamplePreview showIssues={isCloud()} />
|
||||
</ContentLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ContentLayout title="Overview" icon={<SquareChartGantt />}>
|
||||
@@ -99,82 +124,71 @@ export default async function Home({
|
||||
<ProviderGroupSelector groups={providerGroupsData?.data ?? []} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-6 lg:flex-row lg:flex-wrap lg:items-stretch">
|
||||
<Suspense fallback={<ThreatScoreSkeleton />}>
|
||||
<ThreatScoreSSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
|
||||
{isCloud() && (
|
||||
<FeatureFlagGate flag={FEATURE_FLAG.ISSUES}>
|
||||
<Suspense fallback={<ExploitabilityChartSkeleton />}>
|
||||
<ExploitabilityChartSSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</FeatureFlagGate>
|
||||
)}
|
||||
|
||||
<Suspense fallback={<StatusChartSkeleton />}>
|
||||
<CheckFindingsSSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
|
||||
<Suspense fallback={<RiskSeverityChartSkeleton />}>
|
||||
<RiskSeverityChartSSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<Suspense fallback={<ResourcesInventorySkeleton />}>
|
||||
<ResourcesInventorySSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-col gap-6 xl:flex-row">
|
||||
{/* Watchlists: stacked on mobile, row on tablet, stacked on desktop */}
|
||||
{/* No `flex-wrap` here: a multi-line flex container sizes its items to
|
||||
the line's max-content rather than to its own 312px, and a card wider
|
||||
than the column loses its right border and padding to
|
||||
`overflow-hidden`. It never wrapped anyway. */}
|
||||
<div className="flex min-w-0 flex-col gap-6 overflow-hidden sm:flex-row sm:items-stretch xl:w-[312px] xl:shrink-0 xl:flex-col">
|
||||
{/* Sized to its own list, unlike the service card below: this one is
|
||||
as long as the organization made its watchlist, so growing it to
|
||||
fill the column puts a hole under two pinned frameworks. */}
|
||||
{/* `h-full` only where the cards sit side by side and share a row
|
||||
height. In the column it would hand the card the stretched
|
||||
wrapper's height and undo the fit-to-content sizing. */}
|
||||
<div className="min-w-0 sm:flex-1 xl:flex-none sm:[&>*]:h-full xl:[&>*]:h-auto">
|
||||
<Suspense fallback={<WatchlistCardSkeleton />}>
|
||||
<ComplianceWatchlistSSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</div>
|
||||
<div className="min-w-0 sm:flex-1 xl:flex-auto [&>*]:h-full">
|
||||
<Suspense fallback={<WatchlistCardSkeleton />}>
|
||||
<ServiceWatchlistSSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Charts column: Attack Surface, Top issues, Findings Over Time */}
|
||||
<div className="flex flex-1 flex-col gap-6">
|
||||
<OverviewGrid
|
||||
threatScore={
|
||||
<Suspense fallback={<ThreatScoreSkeleton />}>
|
||||
<ThreatScoreSSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
}
|
||||
exploitability={
|
||||
isCloud() ? (
|
||||
<FeatureFlagGate flag={FEATURE_FLAG.ISSUES}>
|
||||
<Suspense fallback={<ExploitabilityChartSkeleton />}>
|
||||
<ExploitabilityChartSSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</FeatureFlagGate>
|
||||
) : undefined
|
||||
}
|
||||
checkFindings={
|
||||
<Suspense fallback={<StatusChartSkeleton />}>
|
||||
<CheckFindingsSSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
}
|
||||
riskSeverity={
|
||||
<Suspense fallback={<RiskSeverityChartSkeleton />}>
|
||||
<RiskSeverityChartSSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
}
|
||||
resourcesInventory={
|
||||
<Suspense fallback={<ResourcesInventorySkeleton />}>
|
||||
<ResourcesInventorySSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
}
|
||||
complianceWatchlist={
|
||||
<Suspense fallback={<WatchlistCardSkeleton />}>
|
||||
<ComplianceWatchlistSSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
}
|
||||
serviceWatchlist={
|
||||
<Suspense fallback={<WatchlistCardSkeleton />}>
|
||||
<ServiceWatchlistSSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
}
|
||||
attackSurface={
|
||||
<Suspense fallback={<AttackSurfaceSkeleton />}>
|
||||
<AttackSurfaceSSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
{isCloud() && (
|
||||
}
|
||||
topIssues={
|
||||
isCloud() ? (
|
||||
<FeatureFlagGate flag={FEATURE_FLAG.ISSUES}>
|
||||
<Suspense fallback={<TopIssuesSkeleton />}>
|
||||
<TopIssuesSSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</FeatureFlagGate>
|
||||
)}
|
||||
) : undefined
|
||||
}
|
||||
severityOverTime={
|
||||
<Suspense fallback={<FindingSeverityOverTimeSkeleton />}>
|
||||
<FindingSeverityOverTimeSSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<Suspense fallback={<RiskPipelineViewSkeleton />}>
|
||||
<GraphsTabsWrapper searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</div>
|
||||
}
|
||||
graphsTabs={
|
||||
<Suspense fallback={<RiskPipelineViewSkeleton />}>
|
||||
<GraphsTabsWrapper searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
</ContentLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -40,7 +40,6 @@ 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,
|
||||
};
|
||||
|
||||
@@ -62,7 +62,6 @@ const DEFAULT_PERMISSIONS: RolePermissionAttributes = {
|
||||
manage_alerts: false,
|
||||
manage_lighthouse_ai_configuration: false,
|
||||
manage_registry: false,
|
||||
manage_prowler_realtime: false,
|
||||
unlimited_visibility: false,
|
||||
};
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
User avatar requesting the current page as an image on every load, which rendered each page twice
|
||||
@@ -1 +0,0 @@
|
||||
Compliance cross-provider tab no longer blanks out when a single catalog page or framework fails to load; it shows the frameworks that loaded with a warning instead
|
||||
@@ -1 +0,0 @@
|
||||
Invitations page uses `Status` instead of `State` in the filter, table column and invitation details, and the Invitations and Roles filters show capitalized options
|
||||
@@ -1 +0,0 @@
|
||||
Lighthouse AI reply blanking out for a frame when the response finishes streaming, which made the whole text blink and reappear
|
||||
@@ -1 +0,0 @@
|
||||
Icon for the NATO D32 compliance framework on the compliance cards
|
||||
@@ -1 +0,0 @@
|
||||
Add provider wizard connects every provider in one step, saving the account and credentials and testing the connection together, with teammate invitations for every provider and Lighthouse AI help beside the steps
|
||||
@@ -1 +0,0 @@
|
||||
Product tours and the guided onboarding sequence are no longer shown; new tenants still land on the add-provider wizard
|
||||
@@ -42,6 +42,8 @@ import { FindingTriageStatusControl } from "./finding-triage-status-control";
|
||||
|
||||
export const CLOUD_ONLY_TOOLTIP_COPY = "Available in Prowler Cloud";
|
||||
export const EDITING_UNAVAILABLE_COPY = "Editing is currently unavailable.";
|
||||
export const SAMPLE_TRIAGE_COPY =
|
||||
"Sample data. Connect your cloud account to triage your own findings.";
|
||||
|
||||
const getDisabledCopy = ({
|
||||
triage,
|
||||
@@ -56,6 +58,10 @@ const getDisabledCopy = ({
|
||||
return CLOUD_ONLY_TOOLTIP_COPY;
|
||||
}
|
||||
|
||||
if (triage.disabledReason === FINDING_TRIAGE_DISABLED_REASON.SAMPLE) {
|
||||
return SAMPLE_TRIAGE_COPY;
|
||||
}
|
||||
|
||||
// Status-picker only: notes stay available on resolved findings.
|
||||
if (lockResolved && isTriageStatusLocked(triage.status)) {
|
||||
return FINDING_TRIAGE_RESOLVED_LOCKED_COPY;
|
||||
|
||||
+37
@@ -707,6 +707,43 @@ describe("ResourceDetailDrawerContent — resource navigation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ResourceDetailDrawerContent — sample data", () => {
|
||||
it("should not link sample resources to pages or consoles that lack them", () => {
|
||||
// Given
|
||||
const { container } = render(
|
||||
<ResourceDetailDrawerContent
|
||||
isLoading={false}
|
||||
isNavigating={false}
|
||||
checkMeta={mockCheckMeta}
|
||||
currentIndex={0}
|
||||
totalResources={1}
|
||||
currentFinding={{
|
||||
...mockFinding,
|
||||
id: "sample-finding-s3_bucket_public_access-1",
|
||||
resourceId: "sample-resource-s3_bucket-1",
|
||||
resourceUid: "arn:aws:s3:::acme-customer-exports",
|
||||
providerType: "aws",
|
||||
}}
|
||||
otherFindings={[]}
|
||||
onNavigatePrev={vi.fn()}
|
||||
onNavigateNext={vi.fn()}
|
||||
onMuteComplete={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.queryByRole("link", { name: "View Resource" }),
|
||||
).not.toBeInTheDocument();
|
||||
const hrefs = Array.from(container.querySelectorAll("a")).map((link) =>
|
||||
link.getAttribute("href"),
|
||||
);
|
||||
expect(hrefs.filter((href) => href?.includes("aws.amazon.com"))).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ResourceDetailDrawerContent — triage drawer actions", () => {
|
||||
it("should render Triage and Add Triage Note for other findings rows", () => {
|
||||
// Given
|
||||
|
||||
+32
-23
@@ -84,6 +84,7 @@ import { updateFindingTriageOrThrow } from "@/lib/finding-triage-update";
|
||||
import { buildJiraActionLabel } from "@/lib/jira-dispatch-action";
|
||||
import { createJiraDispatchPayload } from "@/lib/jira-dispatch-selection";
|
||||
import { getRegionFlag } from "@/lib/region-flags";
|
||||
import { isSampleId } from "@/lib/sample-preview/sample-records";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import { getRecommendationLinkLabel } from "@/lib/vulnerability-references";
|
||||
import { SIDE_PANEL_TAB, useSidePanelStore } from "@/store/side-panel";
|
||||
@@ -336,17 +337,22 @@ export function ResourceDetailDrawerContent({
|
||||
const regionFilter = searchParams.get("filter[region__in]");
|
||||
const nativeIacConfig = resolveNativeIacConfig(providerType);
|
||||
const showOverviewCheckMetaContent = showCheckMetaContent;
|
||||
const resourceDetailHref = f?.resourceId
|
||||
? buildResourceDetailHref(f.resourceId)
|
||||
: null;
|
||||
const externalResourceTarget = resolveExternalTarget({
|
||||
providerType,
|
||||
resourceUid,
|
||||
providerUid,
|
||||
resourceName,
|
||||
findingUid: f?.uid,
|
||||
region: resourceRegion,
|
||||
});
|
||||
// Resources and Scans have no sample data, so sample records link nowhere.
|
||||
const resourceDetailHref =
|
||||
f?.resourceId && !isSampleId(f.resourceId)
|
||||
? buildResourceDetailHref(f.resourceId)
|
||||
: null;
|
||||
// The sample account is not theirs: its console links would open nothing.
|
||||
const externalResourceTarget = isSampleId(f?.resourceId)
|
||||
? null
|
||||
: resolveExternalTarget({
|
||||
providerType,
|
||||
resourceUid,
|
||||
providerUid,
|
||||
resourceName,
|
||||
findingUid: f?.uid,
|
||||
region: resourceRegion,
|
||||
});
|
||||
const findingRecommendationUrl = f?.remediation.recommendation.url;
|
||||
const checkRecommendationUrl = checkMeta.remediation.recommendation.url;
|
||||
const recommendationUrl = isNonEmptyString(findingRecommendationUrl)
|
||||
@@ -492,8 +498,9 @@ export function ResourceDetailDrawerContent({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Lighthouse skill launcher: chip rail under the title. */}
|
||||
{isCloud() && !isNavigating && (
|
||||
{/* Lighthouse skill launcher: chip rail under the title. Sample
|
||||
findings exist only in the UI, so Lighthouse has nothing to read. */}
|
||||
{isCloud() && !isNavigating && !isSampleId(currentFinding?.id) && (
|
||||
<LighthouseSkillsRail
|
||||
onLaunchSkill={handleLaunchSkill}
|
||||
onSubmitPrompt={handleSubmitPrompt}
|
||||
@@ -1130,16 +1137,18 @@ export function ResourceDetailDrawerContent({
|
||||
<p className="text-text-neutral-secondary text-xs">
|
||||
Showing the latest scan that evaluated this finding
|
||||
</p>
|
||||
<Button variant="link" size="link-sm" asChild>
|
||||
<Link
|
||||
href={`/scans?filter[id__in]=${f?.scan?.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
View scan
|
||||
<ExternalLink className="size-3" />
|
||||
</Link>
|
||||
</Button>
|
||||
{!isSampleId(f?.scan?.id) && (
|
||||
<Button variant="link" size="link-sm" asChild>
|
||||
<Link
|
||||
href={`/scans?filter[id__in]=${f?.scan?.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
View scan
|
||||
<ExternalLink className="size-3" />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<InfoField label="Scan Name" variant="compact">
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { IssueAttributes } from "@/types/issues";
|
||||
|
||||
const { getIssue, getIssueFindings } = vi.hoisted(() => ({
|
||||
getIssue: vi.fn(),
|
||||
getIssueFindings: vi.fn(),
|
||||
}));
|
||||
vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh: vi.fn() }) }));
|
||||
vi.mock("@/actions/issues", () => ({ getIssue, getIssueFindings }));
|
||||
vi.mock("@/components/issues/issue-graph", () => ({
|
||||
IssueGraph: () => <p>graph</p>,
|
||||
}));
|
||||
@@ -18,35 +14,53 @@ import { IssueAttackPath } from "./issue-attack-path";
|
||||
const issue = { legs: {} } as IssueAttributes;
|
||||
|
||||
describe("IssueAttackPath", () => {
|
||||
it("says there is nothing to draw, not a load error, without resources", async () => {
|
||||
// Given: no resources, and findings that cannot load without a scan
|
||||
getIssue.mockResolvedValue({
|
||||
data: { attributes: { resource_uids: [], finding_uids: ["f1"] } },
|
||||
});
|
||||
|
||||
// When
|
||||
render(<IssueAttackPath id="issue-1" issue={issue} />);
|
||||
it("says there is nothing to draw, not a load error, without resources", () => {
|
||||
// Given: no resources, and findings that could not load without a scan
|
||||
render(
|
||||
<IssueAttackPath
|
||||
issue={issue}
|
||||
evidence={{
|
||||
status: "loaded",
|
||||
resources: [],
|
||||
findings: undefined,
|
||||
findingsTruncated: false,
|
||||
}}
|
||||
onRetry={vi.fn()}
|
||||
onOpenFinding={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
await screen.findByText("This issue has no affected resources to draw."),
|
||||
screen.getByText("This issue has no affected resources to draw."),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Retry" })).toBeNull();
|
||||
});
|
||||
|
||||
it("offers a retry when the findings for the drawn resources fail", async () => {
|
||||
// Given
|
||||
getIssue.mockResolvedValue({
|
||||
data: { attributes: { resource_uids: ["bucket"], finding_uids: ["f1"] } },
|
||||
});
|
||||
const onRetry = vi.fn();
|
||||
render(
|
||||
<IssueAttackPath
|
||||
issue={issue}
|
||||
evidence={{
|
||||
status: "loaded",
|
||||
resources: ["bucket"],
|
||||
findings: undefined,
|
||||
findingsTruncated: false,
|
||||
}}
|
||||
onRetry={onRetry}
|
||||
onOpenFinding={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
render(<IssueAttackPath id="issue-1" issue={issue} />);
|
||||
expect(
|
||||
screen.getByText("Could not load the attack path for this issue."),
|
||||
).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||
|
||||
// Then
|
||||
expect(
|
||||
await screen.findByText("Could not load the attack path for this issue."),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument();
|
||||
expect(onRetry).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -2,26 +2,25 @@
|
||||
|
||||
import { IssueGraph } from "@/components/issues/issue-graph";
|
||||
import { IssuesLoadError } from "@/components/issues/issues-load-error";
|
||||
import { useIssueEvidence } from "@/hooks/use-issue-evidence";
|
||||
import { IssueAttributes } from "@/types/issues";
|
||||
|
||||
import type { IssueEvidenceState } from "./use-issue-evidence";
|
||||
|
||||
interface IssueAttackPathProps {
|
||||
id: string;
|
||||
issue: IssueAttributes;
|
||||
scanId?: string;
|
||||
providerId?: string;
|
||||
evidence: IssueEvidenceState;
|
||||
onRetry: () => void;
|
||||
onOpenFinding: (findingId: string) => void;
|
||||
}
|
||||
|
||||
/** The issue's graph for the side panel; findings open on the full page. */
|
||||
/** The issue's graph, or why there is none to draw yet. */
|
||||
export const IssueAttackPath = ({
|
||||
id,
|
||||
issue,
|
||||
scanId,
|
||||
providerId,
|
||||
evidence,
|
||||
onRetry,
|
||||
onOpenFinding,
|
||||
}: IssueAttackPathProps) => {
|
||||
const { state, retry } = useIssueEvidence(id, scanId, providerId);
|
||||
|
||||
if (state.status === "loading") {
|
||||
if (evidence.status === "loading") {
|
||||
return (
|
||||
<p role="status" className="text-text-neutral-tertiary text-sm">
|
||||
Loading the attack path…
|
||||
@@ -30,29 +29,29 @@ export const IssueAttackPath = ({
|
||||
}
|
||||
// Nothing to draw beats a findings error: without resources there is no
|
||||
// graph for the findings to land on.
|
||||
if (state.status === "loaded" && state.resources.length === 0) {
|
||||
if (evidence.status === "loaded" && evidence.resources.length === 0) {
|
||||
return (
|
||||
<p className="text-text-neutral-tertiary text-sm">
|
||||
This issue has no affected resources to draw.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (state.status === "error" || state.findings === undefined) {
|
||||
if (evidence.status === "error" || evidence.findings === undefined) {
|
||||
return (
|
||||
<IssuesLoadError
|
||||
message="Could not load the attack path for this issue."
|
||||
onRetry={retry}
|
||||
onRetry={onRetry}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<IssueGraph
|
||||
issue={issue}
|
||||
resourceUids={state.resources}
|
||||
findings={state.findings}
|
||||
readOnly
|
||||
{...(state.providerId && state.scanId
|
||||
? { providerId: state.providerId, scanId: state.scanId }
|
||||
resourceUids={evidence.resources}
|
||||
findings={evidence.findings}
|
||||
onOpenFinding={onOpenFinding}
|
||||
{...(evidence.providerId && evidence.scanId
|
||||
? { providerId: evidence.providerId, scanId: evidence.scanId }
|
||||
: { providerId: undefined, scanId: undefined })}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
complianceFrameworkLabel,
|
||||
} from "@/components/compliance/compliance-framework-chip";
|
||||
import { IssuesLoadError } from "@/components/issues/issues-load-error";
|
||||
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
|
||||
import type { FindingComplianceFramework } from "@/types/compliance-watchlist";
|
||||
import { IssueFinding } from "@/types/issues";
|
||||
|
||||
@@ -115,13 +116,21 @@ export const IssueCompliance = ({
|
||||
);
|
||||
};
|
||||
|
||||
// Laid out like the finding panel's "Compliance Frameworks" header row.
|
||||
return (
|
||||
<section aria-label="Compliance affected">
|
||||
<h4 className="mb-3 text-sm font-semibold">Compliance affected</h4>
|
||||
<section aria-label="Compliance affected" className="flex flex-col gap-1.5">
|
||||
<span className="text-text-neutral-tertiary text-xs font-medium">
|
||||
Compliance Frameworks:
|
||||
</span>
|
||||
{state.status === "loading" ? (
|
||||
<p role="status" className="text-text-neutral-tertiary text-sm">
|
||||
Loading frameworks…
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span role="status" className="sr-only">
|
||||
Loading frameworks…
|
||||
</span>
|
||||
<Skeleton className="size-7 rounded-md" />
|
||||
<Skeleton className="size-7 rounded-md" />
|
||||
<Skeleton className="size-7 rounded-md" />
|
||||
</div>
|
||||
) : state.status === "unavailable" ? (
|
||||
<IssuesLoadError
|
||||
message="Could not load the frameworks for this issue."
|
||||
@@ -130,7 +139,7 @@ export const IssueCompliance = ({
|
||||
) : (
|
||||
<>
|
||||
{state.frameworks.length === 0 ? (
|
||||
<p className="text-text-neutral-tertiary text-sm">
|
||||
<p className="text-text-neutral-tertiary text-xs">
|
||||
{incomplete
|
||||
? "None of the findings checked map to a framework requirement."
|
||||
: "None of the findings behind this issue map to a framework requirement."}
|
||||
@@ -148,7 +157,7 @@ export const IssueCompliance = ({
|
||||
</div>
|
||||
)}
|
||||
{incomplete && (
|
||||
<p className="text-text-neutral-tertiary mt-2 text-xs">
|
||||
<p className="text-text-neutral-tertiary text-xs">
|
||||
Not every finding could be checked, so this list may be
|
||||
incomplete.
|
||||
</p>
|
||||
|
||||
@@ -1,326 +0,0 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { IssueDetail } from "@/components/issues/issue-detail";
|
||||
import { useJiraDispatchStore } from "@/store/jira-dispatch/store";
|
||||
import { ISSUE_FINDINGS_LIMIT, IssueAttributes } from "@/types/issues";
|
||||
|
||||
const { getIssueMock, getIssueFindingsMock } = vi.hoisted(() => ({
|
||||
getIssueMock: vi.fn(),
|
||||
getIssueFindingsMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh: vi.fn() }) }));
|
||||
|
||||
vi.mock("@/actions/issues", () => ({
|
||||
getIssue: getIssueMock,
|
||||
getIssueFindings: getIssueFindingsMock,
|
||||
getIssueNotes: async () => [],
|
||||
}));
|
||||
|
||||
vi.mock("@/components/issues/issue-compliance", () => ({
|
||||
IssueCompliance: ({ truncated }: { truncated: boolean }) =>
|
||||
truncated ? <p>Compliance from a capped preview</p> : null,
|
||||
}));
|
||||
vi.mock("@/components/findings/send-to-jira-modal", () => ({
|
||||
SendToJiraModal: () => null,
|
||||
}));
|
||||
vi.mock("@/components/issues/issue-graph", () => ({
|
||||
IssueGraph: ({ resourceUids }: { resourceUids: string[] }) => (
|
||||
<div data-testid="issue-graph">{resourceUids.length} resources</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const resourceUids = Array.from({ length: 15 }, (_, i) => `resource-${i + 1}`);
|
||||
|
||||
const issue = {
|
||||
pattern: "unmodelled",
|
||||
cloud: "aws",
|
||||
account: "prod",
|
||||
title: "Findings no attack path models yet (300)",
|
||||
verdict: "unknown",
|
||||
severity: "high",
|
||||
outcome: "",
|
||||
attacker_start: "",
|
||||
legs: {},
|
||||
fix: "Review these findings individually.",
|
||||
findings_count: 300,
|
||||
triage_status: "open",
|
||||
inserted_at: "",
|
||||
updated_at: "",
|
||||
} as unknown as IssueAttributes;
|
||||
|
||||
describe("IssueDetail", () => {
|
||||
beforeEach(() => {
|
||||
getIssueMock.mockReset();
|
||||
getIssueFindingsMock.mockResolvedValue([]);
|
||||
useJiraDispatchStore.getState().closeJiraDispatch();
|
||||
});
|
||||
|
||||
it("links to the issue's scan findings and reveals every folded resource", async () => {
|
||||
getIssueMock.mockResolvedValue({
|
||||
data: { attributes: { resource_uids: resourceUids } },
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(<IssueDetail id="issue-1" issue={issue} scanId="scan-1" />);
|
||||
|
||||
expect(
|
||||
screen.getByRole("link", { name: /failed findings/i }),
|
||||
).toHaveAttribute(
|
||||
"href",
|
||||
"/findings?filter%5Bscan__in%5D=scan-1&filter%5Bstatus__in%5D=FAIL",
|
||||
);
|
||||
|
||||
// The list leaves the uids out; the detail fetches them when it opens.
|
||||
expect(getIssueMock).toHaveBeenCalledWith("issue-1");
|
||||
expect(await screen.findByText("resource-1")).toBeInTheDocument();
|
||||
expect(screen.queryByText("resource-15")).not.toBeInTheDocument();
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Show more resources" }),
|
||||
);
|
||||
expect(screen.getByText("resource-15")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("issue-graph")).toHaveTextContent("15 resources");
|
||||
});
|
||||
|
||||
it("says what a path buys the attacker and where it starts", async () => {
|
||||
getIssueMock.mockResolvedValue({
|
||||
data: { attributes: { resource_uids: [] } },
|
||||
});
|
||||
render(
|
||||
<IssueDetail
|
||||
id="issue-1"
|
||||
issue={{
|
||||
...issue,
|
||||
pattern: "aws_public_bucket",
|
||||
outcome: "data_exposure",
|
||||
attacker_start: "internet",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Data exposure")).toBeInTheDocument();
|
||||
expect(screen.getByText("The internet")).toBeInTheDocument();
|
||||
// Shown inline so keyboard users can read it too.
|
||||
expect(
|
||||
screen.getByText(/Where the attacker has to be standing/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("tells the compliance section when the findings preview was capped", async () => {
|
||||
// Given - one more finding than the preview loads
|
||||
getIssueMock.mockResolvedValue({
|
||||
data: {
|
||||
attributes: {
|
||||
resource_uids: [],
|
||||
finding_uids: Array.from(
|
||||
{ length: ISSUE_FINDINGS_LIMIT + 1 },
|
||||
(_, i) => `uid-${i}`,
|
||||
),
|
||||
},
|
||||
relationships: { scan: { data: { type: "scans", id: "scan-1" } } },
|
||||
},
|
||||
});
|
||||
getIssueFindingsMock.mockResolvedValue([
|
||||
{
|
||||
id: "f1",
|
||||
checkId: "check-a",
|
||||
checkTitle: "Check A",
|
||||
severity: "high",
|
||||
resourceUid: "r1",
|
||||
},
|
||||
]);
|
||||
|
||||
// When
|
||||
render(<IssueDetail id="issue-1" issue={issue} />);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
await screen.findByText("Compliance from a capped preview"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("claims no outcome for an issue that is not one path", async () => {
|
||||
getIssueMock.mockResolvedValue({
|
||||
data: { attributes: { resource_uids: [] } },
|
||||
});
|
||||
render(<IssueDetail id="issue-1" issue={issue} />);
|
||||
|
||||
expect(screen.queryByText("Outcome")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("links each affected resource to it on the Resources page", async () => {
|
||||
getIssueMock.mockResolvedValue({
|
||||
data: { attributes: { resource_uids: ["arn:aws:s3:::bucket"] } },
|
||||
});
|
||||
render(<IssueDetail id="issue-1" issue={issue} providerId="provider-1" />);
|
||||
|
||||
expect(
|
||||
await screen.findByRole("link", {
|
||||
name: "Open in Resources: arn:aws:s3:::bucket",
|
||||
}),
|
||||
).toHaveAttribute(
|
||||
"href",
|
||||
"/resources?filter%5Buid%5D=arn%3Aaws%3As3%3A%3A%3Abucket&filter%5Bprovider_id__in%5D=provider-1",
|
||||
);
|
||||
});
|
||||
|
||||
it("says so when the resources cannot be loaded", async () => {
|
||||
getIssueMock.mockResolvedValue(undefined);
|
||||
render(<IssueDetail id="issue-1" issue={issue} />);
|
||||
|
||||
expect(
|
||||
await screen.findByText(/could not load the resources/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("issue-graph")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reports a failed findings request and retries using the issue scan", async () => {
|
||||
// Given
|
||||
getIssueMock.mockResolvedValue({
|
||||
data: {
|
||||
attributes: {
|
||||
resource_uids: resourceUids,
|
||||
finding_uids: ["finding-uid"],
|
||||
},
|
||||
},
|
||||
});
|
||||
getIssueFindingsMock
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce([]);
|
||||
render(<IssueDetail id="issue-1" issue={issue} scanId="scan-1" />);
|
||||
|
||||
// When
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"Could not load the findings for this issue",
|
||||
);
|
||||
expect(screen.queryByTestId("issue-graph")).not.toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||
|
||||
// Then
|
||||
expect(await screen.findByTestId("issue-graph")).toBeInTheDocument();
|
||||
expect(getIssueFindingsMock).toHaveBeenLastCalledWith("scan-1", [
|
||||
"finding-uid",
|
||||
]);
|
||||
});
|
||||
|
||||
it("names the conditions that make the path, explained by each leg", async () => {
|
||||
getIssueMock.mockResolvedValue({
|
||||
data: { attributes: { resource_uids: [] } },
|
||||
});
|
||||
render(
|
||||
<IssueDetail
|
||||
id="issue-1"
|
||||
issue={{
|
||||
...issue,
|
||||
outcome: "data_exposure",
|
||||
attacker_start: "internet",
|
||||
legs: {
|
||||
...issue.legs,
|
||||
controls: {
|
||||
state: "no",
|
||||
text: "The public access block is off.",
|
||||
evidence: [],
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const factors = screen.getByRole("list", { name: "Risk factors" });
|
||||
expect(factors).toHaveTextContent("Internet-reachable");
|
||||
expect(screen.getByText("No control blocking")).toHaveAttribute(
|
||||
"title",
|
||||
"The public access block is off.",
|
||||
);
|
||||
});
|
||||
|
||||
it("lists each folded finding once, with its own triage and a link to it", async () => {
|
||||
getIssueMock.mockResolvedValue({
|
||||
data: {
|
||||
attributes: { resource_uids: ["bucket"], finding_uids: ["f-1", "f-2"] },
|
||||
},
|
||||
});
|
||||
const finding = {
|
||||
id: "finding-1",
|
||||
uid: "f-1",
|
||||
checkId: "s3_bucket_public_access",
|
||||
checkTitle: "Bucket is public",
|
||||
severity: "critical",
|
||||
triageStatus: "risk_accepted",
|
||||
resourceUid: "bucket",
|
||||
};
|
||||
getIssueFindingsMock.mockResolvedValue([
|
||||
finding,
|
||||
// The same finding on a second resource is still one finding.
|
||||
{ ...finding, resourceUid: "other-bucket" },
|
||||
{
|
||||
...finding,
|
||||
id: "finding-2",
|
||||
uid: "f-2",
|
||||
checkTitle: "Bucket has no logging",
|
||||
triageStatus: "open",
|
||||
},
|
||||
]);
|
||||
render(<IssueDetail id="issue-1" issue={issue} scanId="scan-1" />);
|
||||
|
||||
const section = await screen.findByRole("region", { name: "Findings" });
|
||||
const link = within(section).getByRole("link", {
|
||||
name: "Bucket is public",
|
||||
});
|
||||
const destination = new URL(link.getAttribute("href")!, "https://app.test");
|
||||
expect(Object.fromEntries(destination.searchParams)).toEqual({
|
||||
"filter[scan__in]": "scan-1",
|
||||
"filter[check_id__in]": "s3_bucket_public_access",
|
||||
"filter[resource_uid]": "other-bucket",
|
||||
"filter[muted]": "include",
|
||||
expandedCheckId: "s3_bucket_public_access",
|
||||
});
|
||||
expect(within(section).getAllByRole("listitem")).toHaveLength(2);
|
||||
expect(within(section).getByText("Risk Accepted")).toBeInTheDocument();
|
||||
expect(within(section).getByText("Showing 2 of 300.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the global Jira flow with unique finding IDs", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
getIssueMock.mockResolvedValue({
|
||||
data: {
|
||||
attributes: { resource_uids: ["bucket"], finding_uids: ["f-1"] },
|
||||
},
|
||||
});
|
||||
const finding = {
|
||||
id: "finding-1",
|
||||
uid: "f-1",
|
||||
checkId: "s3_public",
|
||||
checkTitle: "Bucket is public",
|
||||
severity: "critical",
|
||||
triageStatus: "open",
|
||||
resourceUid: "bucket",
|
||||
};
|
||||
getIssueFindingsMock.mockResolvedValue([
|
||||
finding,
|
||||
{ ...finding, resourceUid: "other-bucket" },
|
||||
{ ...finding, id: "finding-2", uid: "f-2" },
|
||||
]);
|
||||
const view = render(
|
||||
<IssueDetail id="issue-1" issue={issue} scanId="scan-1" />,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: "Send to Jira" }),
|
||||
);
|
||||
view.unmount();
|
||||
|
||||
// Then: the globally hosted modal survives the detail closing.
|
||||
expect(useJiraDispatchStore.getState().activePayload).toMatchObject({
|
||||
findingTitle: issue.title,
|
||||
selection: {
|
||||
kind: "target-list",
|
||||
targetType: "finding_id",
|
||||
targetIds: ["finding-1", "finding-2"],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,273 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Send } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
|
||||
import { IssueCompliance } from "@/components/issues/issue-compliance";
|
||||
import { IssueFindings } from "@/components/issues/issue-findings";
|
||||
import { IssueGraph } from "@/components/issues/issue-graph";
|
||||
import { IssueTriage } from "@/components/issues/issue-triage";
|
||||
import { IssuesLoadError } from "@/components/issues/issues-load-error";
|
||||
import { Badge } from "@/components/shadcn/badge/badge";
|
||||
import { Button } from "@/components/shadcn/button/button";
|
||||
import { Card } from "@/components/shadcn/card/card";
|
||||
import { CodeSnippet } from "@/components/shadcn/code-snippet";
|
||||
import { InfoField } from "@/components/shadcn/info-field/info-field";
|
||||
import { useIssueEvidence } from "@/hooks/use-issue-evidence";
|
||||
import { riskFactors } from "@/lib/issues/risk-factors";
|
||||
import {
|
||||
LEG_LABELS,
|
||||
LEG_QUESTIONS,
|
||||
LEG_STATE_LABELS,
|
||||
legBadgeVariant,
|
||||
} from "@/lib/issues/verdicts";
|
||||
import { createJiraDispatchPayload } from "@/lib/jira-dispatch-selection";
|
||||
import { useJiraDispatchStore } from "@/store/jira-dispatch/store";
|
||||
import { JIRA_DISPATCH_TARGET } from "@/types/integrations";
|
||||
import {
|
||||
ATTACKER_START_LABEL,
|
||||
ISSUE_OUTCOME_LABEL,
|
||||
IssueAttributes,
|
||||
LegName,
|
||||
} from "@/types/issues";
|
||||
|
||||
const RESOURCE_PAGE_SIZE = 12;
|
||||
|
||||
// The issue carries resource uids, not ids, so this opens the Resources list
|
||||
// on that one resource rather than its drawer.
|
||||
const resourceHref = (uid: string, providerId?: string) =>
|
||||
`/resources?${new URLSearchParams({
|
||||
"filter[uid]": uid,
|
||||
// A uid is unique within an account, not across them.
|
||||
...(providerId && { "filter[provider_id__in]": providerId }),
|
||||
})}`;
|
||||
|
||||
interface IssueDetailProps {
|
||||
id: string;
|
||||
issue: IssueAttributes;
|
||||
scanId?: string;
|
||||
providerId?: string;
|
||||
}
|
||||
|
||||
export const IssueDetail = ({
|
||||
id,
|
||||
issue,
|
||||
scanId,
|
||||
providerId,
|
||||
}: IssueDetailProps) => {
|
||||
const openJiraDispatch = useJiraDispatchStore(
|
||||
(state) => state.openJiraDispatch,
|
||||
);
|
||||
const [visibleResources, setVisibleResources] = useState(RESOURCE_PAGE_SIZE);
|
||||
const { state, retry } = useIssueEvidence(id, scanId, providerId);
|
||||
const activeScanId = state.status === "loaded" ? state.scanId : scanId;
|
||||
const factors = riskFactors(issue);
|
||||
const uniqueFindings =
|
||||
state.status === "loaded"
|
||||
? Array.from(
|
||||
new Map(
|
||||
state.findings?.map((finding) => [finding.id, finding]),
|
||||
).values(),
|
||||
)
|
||||
: [];
|
||||
const jiraPayload = createJiraDispatchPayload({
|
||||
findingTitle: issue.title,
|
||||
targetIds: uniqueFindings.map((finding) => finding.id),
|
||||
targetType: JIRA_DISPATCH_TARGET.FINDING_ID,
|
||||
description: `Send ${uniqueFindings.length} of ${issue.findings_count} findings on this attack path.`,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-4 py-4">
|
||||
{/* The conditions that hold at once: the verdict says how bad, these
|
||||
say why, and which part to argue with. */}
|
||||
{factors.length > 0 && (
|
||||
<ul aria-label="Risk factors" className="flex flex-wrap gap-1.5">
|
||||
{factors.map((factor) => (
|
||||
<li key={factor.label}>
|
||||
<Badge variant={factor.variant} title={factor.title}>
|
||||
{factor.label}
|
||||
</Badge>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{state.status === "loaded" && state.findings === undefined && (
|
||||
<IssuesLoadError
|
||||
message="Could not load the findings for this issue."
|
||||
onRetry={retry}
|
||||
/>
|
||||
)}
|
||||
{state.status === "loaded" &&
|
||||
state.findings !== undefined &&
|
||||
state.resources.length > 0 && (
|
||||
<IssueGraph
|
||||
issue={issue}
|
||||
resourceUids={state.resources}
|
||||
findings={state.findings}
|
||||
{...(state.providerId && state.scanId
|
||||
? { providerId: state.providerId, scanId: state.scanId }
|
||||
: { providerId: undefined, scanId: undefined })}
|
||||
/>
|
||||
)}
|
||||
{issue.outcome && issue.attacker_start && (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<InfoField label="Outcome">
|
||||
{ISSUE_OUTCOME_LABEL[issue.outcome]}
|
||||
</InfoField>
|
||||
{/* Inline, not an InfoTooltip: its trigger has no tab stop. */}
|
||||
<InfoField label="Starts from">
|
||||
{ATTACKER_START_LABEL[issue.attacker_start]}
|
||||
<p className="text-text-neutral-tertiary text-xs">
|
||||
Where the attacker has to be standing. Whether they also need a
|
||||
credential is the Access leg.
|
||||
</p>
|
||||
</InfoField>
|
||||
</div>
|
||||
)}
|
||||
<section aria-label="Exploitability assessment">
|
||||
<h4 className="mb-3 text-sm font-semibold">
|
||||
Exploitability assessment
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{(Object.keys(LEG_QUESTIONS) as LegName[]).map((name) => {
|
||||
const leg = issue.legs[name];
|
||||
if (!leg) return null;
|
||||
return (
|
||||
<Card key={name} variant="inner" className="min-w-0 gap-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h5 className="text-sm font-medium">{LEG_LABELS[name]}</h5>
|
||||
<Badge variant={legBadgeVariant(name, leg.state)}>
|
||||
{LEG_STATE_LABELS[leg.state]}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-text-neutral-tertiary text-xs">
|
||||
{LEG_QUESTIONS[name]}
|
||||
</p>
|
||||
<p className="text-text-neutral-secondary text-sm">
|
||||
{leg.text}
|
||||
</p>
|
||||
{leg.evidence.length > 0 && (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{leg.evidence.map((line) => (
|
||||
<li key={line}>
|
||||
<CodeSnippet
|
||||
value={line}
|
||||
multiline
|
||||
hideCopyButton
|
||||
transparent
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
{state.status === "loading" ? (
|
||||
<p role="status" className="text-text-neutral-tertiary text-sm">
|
||||
Loading issue details…
|
||||
</p>
|
||||
) : state.status === "error" ? (
|
||||
<IssuesLoadError
|
||||
message="Could not load the resources for this issue."
|
||||
onRetry={retry}
|
||||
/>
|
||||
) : state.resources.length > 0 ? (
|
||||
<section aria-label="Affected resources">
|
||||
<h4 className="mb-3 text-sm font-semibold">
|
||||
Affected resources ({state.resources.length})
|
||||
</h4>
|
||||
<ul className="flex flex-col gap-2">
|
||||
{state.resources.slice(0, visibleResources).map((uid) => (
|
||||
<li
|
||||
key={uid}
|
||||
className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1"
|
||||
>
|
||||
<CodeSnippet value={uid} multiline ariaLabel={`Copy ${uid}`} />
|
||||
<Button asChild variant="link" size="link-sm">
|
||||
<Link
|
||||
href={resourceHref(uid, state.providerId)}
|
||||
aria-label={`Open in Resources: ${uid}`}
|
||||
>
|
||||
Open in Resources
|
||||
</Link>
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{visibleResources < state.resources.length && (
|
||||
<Button
|
||||
variant="link"
|
||||
size="link-sm"
|
||||
onClick={() =>
|
||||
setVisibleResources((value) => value + RESOURCE_PAGE_SIZE)
|
||||
}
|
||||
>
|
||||
Show more resources
|
||||
</Button>
|
||||
)}
|
||||
{visibleResources > RESOURCE_PAGE_SIZE && (
|
||||
<Button
|
||||
variant="link"
|
||||
size="link-sm"
|
||||
onClick={() => setVisibleResources(RESOURCE_PAGE_SIZE)}
|
||||
>
|
||||
Show fewer resources
|
||||
</Button>
|
||||
)}
|
||||
</section>
|
||||
) : null}
|
||||
<section aria-label="Remediation">
|
||||
<h4 className="mb-3 text-sm font-semibold">Remediation</h4>
|
||||
<p className="text-text-neutral-secondary text-sm">{issue.fix}</p>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{activeScanId && (
|
||||
<Button asChild variant="link" size="link-sm">
|
||||
<Link
|
||||
href={`/findings?${new URLSearchParams({ "filter[scan__in]": activeScanId, "filter[status__in]": "FAIL" })}`}
|
||||
>
|
||||
Open this scan's failed findings
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{jiraPayload && (
|
||||
// shortcut: the ticket groups the loaded preview (ISSUE_FINDINGS_LIMIT
|
||||
// findings), not every finding of a larger issue; page through
|
||||
// finding_uids if tickets for big issues need all of them.
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openJiraDispatch(jiraPayload)}
|
||||
>
|
||||
<Send aria-hidden="true" />
|
||||
Send to Jira
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
{state.status === "loaded" &&
|
||||
state.findings &&
|
||||
state.findings.length > 0 && (
|
||||
<IssueFindings
|
||||
findings={uniqueFindings}
|
||||
scanId={activeScanId}
|
||||
total={issue.findings_count}
|
||||
/>
|
||||
)}
|
||||
{state.status === "loaded" &&
|
||||
state.findings &&
|
||||
state.findings.length > 0 && (
|
||||
<IssueCompliance
|
||||
findings={state.findings}
|
||||
scanId={activeScanId}
|
||||
truncated={state.findingsTruncated}
|
||||
/>
|
||||
)}
|
||||
<IssueTriage id={id} status={issue.triage_status} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,83 +1,91 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { Button } from "@/components/shadcn/button/button";
|
||||
import { Card } from "@/components/shadcn/card/card";
|
||||
import { SeverityBadge } from "@/components/shadcn/table/severity-badge";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/shadcn/table/table";
|
||||
import { TriageStatusBadge } from "@/components/shared/triage/triage-status-badge";
|
||||
import { MUTED_FILTER } from "@/lib/findings-filters";
|
||||
|
||||
import type { IssueFindingsProps } from "./types";
|
||||
|
||||
const HEADERS = ["Finding", "Severity", "Triage"] as const;
|
||||
|
||||
/**
|
||||
* The findings the issue folds, each with its own triage. That triage stays on
|
||||
* the finding: the issue's status answers for the combination, the finding's
|
||||
* for whether that one check is right.
|
||||
* The findings the issue folds, laid out like the finding panel's "Other
|
||||
* findings" table. Each keeps its own triage: the issue's status answers for
|
||||
* the combination, the finding's for whether that one check is right.
|
||||
*/
|
||||
export const IssueFindings = ({
|
||||
findings,
|
||||
scanId,
|
||||
total,
|
||||
onOpenFinding,
|
||||
}: IssueFindingsProps) => {
|
||||
return (
|
||||
<section aria-label="Findings">
|
||||
<h4 className="mb-3 text-sm font-semibold">
|
||||
Findings ({total.toLocaleString()})
|
||||
</h4>
|
||||
<ul className="grid grid-cols-1 gap-2 lg:grid-cols-2">
|
||||
{findings.map((finding) => (
|
||||
<li key={finding.id} className="min-w-0">
|
||||
<Card variant="inner" padding="md" className="h-full">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
{scanId ? (
|
||||
<div className="min-w-0">
|
||||
<Button
|
||||
asChild
|
||||
variant="link"
|
||||
size="link-sm"
|
||||
className="max-w-full"
|
||||
>
|
||||
<Link
|
||||
href={`/findings?${new URLSearchParams({
|
||||
"filter[scan__in]": scanId,
|
||||
"filter[check_id__in]": finding.checkId,
|
||||
"filter[resource_uid]": finding.resourceUid,
|
||||
"filter[muted]": MUTED_FILTER.INCLUDE,
|
||||
expandedCheckId: finding.checkId,
|
||||
})}`}
|
||||
title={finding.checkTitle}
|
||||
>
|
||||
<span className="truncate">{finding.checkTitle}</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-text-neutral-primary min-w-0 text-sm break-words">
|
||||
{finding.checkTitle}
|
||||
</span>
|
||||
)}
|
||||
<SeverityBadge severity={finding.severity} />
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className="text-text-neutral-tertiary min-w-0 truncate font-mono text-xs">
|
||||
{finding.checkId}
|
||||
</span>
|
||||
<TriageStatusBadge
|
||||
status={finding.triageStatus}
|
||||
showLabel={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{findings.length < total && (
|
||||
<p className="text-text-neutral-tertiary mt-2 text-xs">
|
||||
Showing {findings.length.toLocaleString()} of {total.toLocaleString()}
|
||||
.
|
||||
</p>
|
||||
)}
|
||||
<section aria-label="Findings" className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-end">
|
||||
<span className="text-text-neutral-tertiary text-sm">
|
||||
{findings.length < total
|
||||
? `Showing ${findings.length.toLocaleString()} of ${total.toLocaleString()}`
|
||||
: `${total.toLocaleString()} Total Entries`}
|
||||
</span>
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{HEADERS.map((header) => (
|
||||
<TableHead key={header}>
|
||||
<span className="text-text-neutral-secondary text-sm font-medium">
|
||||
{header}
|
||||
</span>
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{findings.map((finding) => (
|
||||
<TableRow
|
||||
key={finding.id}
|
||||
className="group cursor-pointer"
|
||||
onClick={() => onOpenFinding(finding.id)}
|
||||
>
|
||||
{/* max-w-0 lets the title take the spare width and truncate. */}
|
||||
<TableCell className="w-full max-w-0">
|
||||
{/* The row opens the finding; the button is its keyboard stop. */}
|
||||
<button
|
||||
type="button"
|
||||
title={finding.checkTitle}
|
||||
className="text-text-neutral-primary block max-w-full truncate text-left text-sm"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onOpenFinding(finding.id);
|
||||
}}
|
||||
>
|
||||
{finding.checkTitle}
|
||||
</button>
|
||||
{/* One check fails on several resources: this tells them apart. */}
|
||||
<p
|
||||
className="text-text-neutral-tertiary truncate font-mono text-xs"
|
||||
title={finding.resourceUid}
|
||||
>
|
||||
{finding.resourceUid}
|
||||
</p>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<SeverityBadge severity={finding.severity} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TriageStatusBadge
|
||||
status={finding.triageStatus}
|
||||
showLabel={false}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -6,22 +6,8 @@ import { beforeEach, describe, expect, expectTypeOf, it, vi } from "vitest";
|
||||
import { GraphNode } from "@/types/attack-paths";
|
||||
import { IssueAttributes } from "@/types/issues";
|
||||
|
||||
const { getFindings, navigateToFinding } = vi.hoisted(() => ({
|
||||
getFindings: vi.fn(),
|
||||
navigateToFinding: vi.fn(),
|
||||
}));
|
||||
const { getFindings } = vi.hoisted(() => ({ getFindings: vi.fn() }));
|
||||
vi.mock("@/actions/findings", () => ({ getFindings }));
|
||||
vi.mock("@/hooks/use-finding-details", () => ({
|
||||
useFindingDetails: () => ({
|
||||
findingDetails: null,
|
||||
findingDetailLoading: false,
|
||||
resetFindingDetails: vi.fn(),
|
||||
navigateToFinding,
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/components/findings/table", () => ({
|
||||
FindingDetailDrawer: () => null,
|
||||
}));
|
||||
vi.mock("@/components/attack-paths/graph/attack-path-graph", () => ({
|
||||
AttackPathGraph: ({
|
||||
data,
|
||||
@@ -52,7 +38,7 @@ const issue = { legs: {} } as IssueAttributes;
|
||||
|
||||
it("requires provider and scan identifiers together in the graph props", () => {
|
||||
type Props = ComponentProps<typeof IssueGraph>;
|
||||
type BaseProps = Pick<Props, "issue" | "resourceUids">;
|
||||
type BaseProps = Pick<Props, "issue" | "resourceUids" | "onOpenFinding">;
|
||||
|
||||
expectTypeOf<BaseProps>().toExtend<Props>();
|
||||
expectTypeOf<
|
||||
@@ -63,31 +49,10 @@ it("requires provider and scan identifiers together in the graph props", () => {
|
||||
});
|
||||
|
||||
describe("IssueGraph finding lookup", () => {
|
||||
const openFinding = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
navigateToFinding.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("leaves findings closed in a read-only graph", async () => {
|
||||
// Given: a host that cannot hold a second detail view, such as the panel
|
||||
render(
|
||||
<IssueGraph
|
||||
issue={issue}
|
||||
resourceUids={["bucket"]}
|
||||
providerId="provider-1"
|
||||
scanId="scan-1"
|
||||
readOnly
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await userEvent.click(screen.getByRole("button", { name: "Open finding" }));
|
||||
|
||||
// Then
|
||||
expect(navigateToFinding).not.toHaveBeenCalled();
|
||||
expect(getFindings).not.toHaveBeenCalled();
|
||||
expect(
|
||||
screen.getByText(/Open the full path to inspect a finding/),
|
||||
).toBeInTheDocument();
|
||||
openFinding.mockReset();
|
||||
});
|
||||
|
||||
it("opens a folded finding by id without another lookup", async () => {
|
||||
@@ -109,6 +74,7 @@ describe("IssueGraph finding lookup", () => {
|
||||
]}
|
||||
providerId="provider-1"
|
||||
scanId="scan-1"
|
||||
onOpenFinding={openFinding}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -116,7 +82,7 @@ describe("IssueGraph finding lookup", () => {
|
||||
await userEvent.click(screen.getByRole("button", { name: "Open finding" }));
|
||||
|
||||
// Then
|
||||
expect(navigateToFinding).toHaveBeenCalledWith("folded-finding");
|
||||
expect(openFinding).toHaveBeenCalledWith("folded-finding");
|
||||
expect(getFindings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -136,6 +102,7 @@ describe("IssueGraph finding lookup", () => {
|
||||
resourceUids={["bucket"]}
|
||||
providerId="provider-1"
|
||||
scanId="scan-1"
|
||||
onOpenFinding={openFinding}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Open finding" }));
|
||||
@@ -149,7 +116,7 @@ describe("IssueGraph finding lookup", () => {
|
||||
"filter[status]": "FAIL",
|
||||
},
|
||||
});
|
||||
expect(navigateToFinding).toHaveBeenCalledWith("finding-1");
|
||||
expect(openFinding).toHaveBeenCalledWith("finding-1");
|
||||
});
|
||||
|
||||
it("rejects a finding from a newer scan", async () => {
|
||||
@@ -167,10 +134,11 @@ describe("IssueGraph finding lookup", () => {
|
||||
resourceUids={["bucket"]}
|
||||
providerId="provider-1"
|
||||
scanId="scan-1"
|
||||
onOpenFinding={openFinding}
|
||||
/>,
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Open finding" }));
|
||||
expect(navigateToFinding).not.toHaveBeenCalled();
|
||||
expect(openFinding).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole("alert")).toHaveTextContent(
|
||||
"no longer available for the issue's scan",
|
||||
);
|
||||
@@ -186,6 +154,7 @@ describe("IssueGraph finding lookup", () => {
|
||||
resourceUids={["bucket"]}
|
||||
providerId="provider-1"
|
||||
scanId="scan-1"
|
||||
onOpenFinding={openFinding}
|
||||
/>,
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Open finding" }));
|
||||
@@ -209,6 +178,7 @@ describe("IssueGraph finding lookup", () => {
|
||||
resourceUids={["bucket"]}
|
||||
providerId="provider-1"
|
||||
scanId="scan-1"
|
||||
onOpenFinding={openFinding}
|
||||
/>,
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Open finding" }));
|
||||
@@ -224,7 +194,13 @@ describe("IssueGraph finding lookup", () => {
|
||||
});
|
||||
|
||||
it("does not look up findings without a provider and scan", async () => {
|
||||
render(<IssueGraph issue={issue} resourceUids={["bucket"]} />);
|
||||
render(
|
||||
<IssueGraph
|
||||
issue={issue}
|
||||
resourceUids={["bucket"]}
|
||||
onOpenFinding={openFinding}
|
||||
/>,
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Open finding" }));
|
||||
expect(getFindings).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole("alert")).toHaveTextContent(
|
||||
|
||||
@@ -4,14 +4,12 @@ import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { getFindings } from "@/actions/findings";
|
||||
import { AttackPathGraph } from "@/components/attack-paths/graph/attack-path-graph";
|
||||
import { FindingDetailDrawer } from "@/components/findings/table";
|
||||
import {
|
||||
buildIssueGraph,
|
||||
GRAPH_RESOURCE_LIMIT,
|
||||
} from "@/components/issues/issue-graph.adapter";
|
||||
import { Alert, AlertDescription } from "@/components/shadcn/alert";
|
||||
import { Card } from "@/components/shadcn/card/card";
|
||||
import { useFindingDetails } from "@/hooks/use-finding-details";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { isProwlerFindingNode } from "@/lib/attack-paths/node-types";
|
||||
import { GraphNode } from "@/types/attack-paths";
|
||||
@@ -21,11 +19,8 @@ interface IssueGraphBaseProps {
|
||||
issue: IssueAttributes;
|
||||
resourceUids: string[];
|
||||
findings?: IssueFinding[];
|
||||
/**
|
||||
* No finding drawer on click: for hosts inside the side panel, where a
|
||||
* second detail view would replace the one holding the graph.
|
||||
*/
|
||||
readOnly?: boolean;
|
||||
/** Called with the clicked finding's id; the host shows its detail. */
|
||||
onOpenFinding: (findingId: string) => void;
|
||||
}
|
||||
|
||||
interface ScopedIssueGraphProps extends IssueGraphBaseProps {
|
||||
@@ -51,9 +46,8 @@ export const IssueGraph = ({
|
||||
findings = [],
|
||||
providerId,
|
||||
scanId,
|
||||
readOnly = false,
|
||||
onOpenFinding,
|
||||
}: IssueGraphProps) => {
|
||||
const finding = useFindingDetails();
|
||||
const canKeepReadableZoom = useMediaQuery(READABLE_ZOOM_MEDIA_QUERY);
|
||||
const view = buildIssueGraph(issue, resourceUids, findings);
|
||||
const shownFindings = new Set(
|
||||
@@ -64,7 +58,6 @@ export const IssueGraph = ({
|
||||
const pending = useRef(false);
|
||||
const mounted = useRef(true);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [lookupComplete, setLookupComplete] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const expandedResources = new Set(
|
||||
view.nodes
|
||||
@@ -82,8 +75,6 @@ export const IssueGraph = ({
|
||||
const handleNodeClick = async (node: GraphNode) => {
|
||||
if (!isProwlerFindingNode(node.labels) || pending.current) return;
|
||||
setError(null);
|
||||
setLookupComplete(false);
|
||||
finding.resetFindingDetails();
|
||||
let findingId = node.properties.finding_id;
|
||||
if (!findingId && (!providerId || !scanId)) {
|
||||
setError(
|
||||
@@ -120,8 +111,7 @@ export const IssueGraph = ({
|
||||
}
|
||||
findingId = match.id;
|
||||
}
|
||||
await finding.navigateToFinding(String(findingId));
|
||||
if (mounted.current) setLookupComplete(true);
|
||||
onOpenFinding(String(findingId));
|
||||
} catch {
|
||||
if (mounted.current)
|
||||
setError("Could not load this finding. Select the finding to retry.");
|
||||
@@ -130,9 +120,6 @@ export const IssueGraph = ({
|
||||
if (mounted.current) setLoading(false);
|
||||
}
|
||||
};
|
||||
const detailFailed =
|
||||
lookupComplete && !finding.findingDetailLoading && !finding.findingDetails;
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label="Finding evidence"
|
||||
@@ -144,20 +131,16 @@ export const IssueGraph = ({
|
||||
{Math.min(resourceUids.length, GRAPH_RESOURCE_LIMIT)} of{" "}
|
||||
{resourceUids.length} affected resources, plus supporting checks cited
|
||||
by the assessment. These links show evidence associations, not confirmed
|
||||
attack paths.
|
||||
{readOnly && " Open the full path to inspect a finding."}
|
||||
attack paths. Select a finding to open it.
|
||||
</p>
|
||||
{loading && (
|
||||
<p role="status" className="text-sm">
|
||||
Loading finding details…
|
||||
</p>
|
||||
)}
|
||||
{(error || detailFailed) && (
|
||||
{error && (
|
||||
<Alert variant="error">
|
||||
<AlertDescription>
|
||||
{error ??
|
||||
"Could not load this finding. Select the finding to retry."}
|
||||
</AlertDescription>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<Card
|
||||
@@ -169,23 +152,10 @@ export const IssueGraph = ({
|
||||
data={{ nodes: view.nodes, edges: view.edges }}
|
||||
view={view}
|
||||
expandedResources={expandedResources}
|
||||
onNodeClick={readOnly ? undefined : handleNodeClick}
|
||||
onNodeClick={handleNodeClick}
|
||||
minFitZoom={canKeepReadableZoom ? ISSUE_GRAPH_MIN_ZOOM : undefined}
|
||||
/>
|
||||
</Card>
|
||||
{finding.findingDetails && (
|
||||
<FindingDetailDrawer
|
||||
key={finding.findingDetails.id}
|
||||
finding={finding.findingDetails}
|
||||
defaultOpen
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setLookupComplete(false);
|
||||
finding.resetFindingDetails();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,533 @@
|
||||
import { act, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { ReactNode } from "react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useJiraDispatchStore } from "@/store/jira-dispatch/store";
|
||||
import {
|
||||
ISSUE_FINDINGS_LIMIT,
|
||||
IssueAttributes,
|
||||
IssueFinding,
|
||||
IssueResource,
|
||||
} from "@/types/issues";
|
||||
|
||||
import { IssuePanel } from "./issue-panel";
|
||||
|
||||
const { getIssue, getIssueFindings, getFindingById } = vi.hoisted(() => ({
|
||||
getIssue: vi.fn(),
|
||||
getIssueFindings: vi.fn(),
|
||||
getFindingById: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: () => "/issues",
|
||||
useRouter: () => ({ refresh: vi.fn() }),
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}));
|
||||
vi.mock("@/actions/issues", () => ({
|
||||
getIssue,
|
||||
getIssueFindings,
|
||||
getIssueNotes: async () => [],
|
||||
}));
|
||||
vi.mock("@/actions/findings", () => ({ getFindingById }));
|
||||
// The real helper sits behind the server-only `@/lib` barrel.
|
||||
vi.mock("@/lib/finding-detail", () => ({
|
||||
expandFindingWithRelationships: (response?: { data?: unknown }) =>
|
||||
response?.data ?? null,
|
||||
}));
|
||||
vi.mock("@/components/issues/issue-compliance", () => ({
|
||||
IssueCompliance: ({ truncated }: { truncated: boolean }) =>
|
||||
truncated ? <p>Compliance from a capped preview</p> : null,
|
||||
}));
|
||||
vi.mock("@/components/findings/send-to-jira-modal", () => ({
|
||||
SendToJiraModal: () => null,
|
||||
}));
|
||||
vi.mock("@/components/findings/table", () => ({
|
||||
FindingDetailDrawer: ({ finding }: { finding: { id: string } }) => (
|
||||
<p>Detail of {finding.id}</p>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/components/issues/issue-graph", () => ({
|
||||
IssueGraph: ({
|
||||
resourceUids,
|
||||
onOpenFinding,
|
||||
}: {
|
||||
resourceUids: string[];
|
||||
onOpenFinding: (id: string) => void;
|
||||
}) => (
|
||||
<button type="button" onClick={() => onOpenFinding("graph-finding")}>
|
||||
Graph of {resourceUids.length} resources
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
// The real panel portals into the app shell's side panel, absent here.
|
||||
vi.mock("@/components/side-panel/detail-side-panel", () => ({
|
||||
DetailSidePanel: ({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
}) => <section aria-label={title}>{children}</section>,
|
||||
}));
|
||||
|
||||
const resourceUids = Array.from({ length: 15 }, (_, i) => `resource-${i + 1}`);
|
||||
|
||||
const attributes = {
|
||||
pattern: "unmodelled",
|
||||
cloud: "aws",
|
||||
account: "prod",
|
||||
title: "Findings no attack path models yet (300)",
|
||||
verdict: "unknown",
|
||||
severity: "high",
|
||||
outcome: "",
|
||||
attacker_start: "",
|
||||
legs: {},
|
||||
fix: "Review these findings individually.",
|
||||
findings_count: 300,
|
||||
triage_status: "open",
|
||||
inserted_at: "",
|
||||
updated_at: "",
|
||||
} as unknown as IssueAttributes;
|
||||
|
||||
const issueWith = (
|
||||
overrides: Partial<IssueAttributes> = {},
|
||||
scanId: string | undefined = "scan-1",
|
||||
): IssueResource => ({
|
||||
type: "issues",
|
||||
id: "issue-1",
|
||||
attributes: { ...attributes, ...overrides },
|
||||
relationships: {
|
||||
provider: { data: { type: "providers", id: "provider-1" } },
|
||||
...(scanId && { scan: { data: { type: "scans", id: scanId } } }),
|
||||
},
|
||||
});
|
||||
|
||||
const finding: IssueFinding = {
|
||||
id: "finding-1",
|
||||
uid: "f-1",
|
||||
checkId: "s3_bucket_public_access",
|
||||
checkTitle: "Bucket is public",
|
||||
severity: "critical",
|
||||
triageStatus: "risk_accepted",
|
||||
resourceUid: "bucket",
|
||||
};
|
||||
|
||||
const detailWith = (resource_uids: string[], finding_uids: string[] = []) => ({
|
||||
data: { attributes: { resource_uids, finding_uids } },
|
||||
});
|
||||
|
||||
const renderPanel = (
|
||||
issue: IssueResource = issueWith(),
|
||||
providerUid?: string,
|
||||
) =>
|
||||
render(
|
||||
<IssuePanel
|
||||
issue={issue}
|
||||
providerUid={providerUid}
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const openTab = (name: string) =>
|
||||
userEvent.click(screen.getByRole("tab", { name }));
|
||||
|
||||
describe("IssuePanel", () => {
|
||||
beforeEach(() => {
|
||||
getIssue.mockReset();
|
||||
getIssueFindings.mockReset().mockResolvedValue([]);
|
||||
getFindingById.mockReset();
|
||||
useJiraDispatchStore.getState().closeJiraDispatch();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("says where the path starts and shows each leg's evidence", async () => {
|
||||
// Given
|
||||
getIssue.mockResolvedValue(detailWith([]));
|
||||
|
||||
// When
|
||||
renderPanel(
|
||||
issueWith({
|
||||
outcome: "data_exposure",
|
||||
attacker_start: "internet",
|
||||
legs: {
|
||||
reachability: {
|
||||
state: "yes",
|
||||
text: "Public access is enabled.",
|
||||
evidence: ["s3_public · arn:aws:s3:::bucket"],
|
||||
},
|
||||
} as IssueAttributes["legs"],
|
||||
}),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(screen.getByText("Data exposure")).toBeInTheDocument();
|
||||
expect(screen.getByText("The internet")).toBeInTheDocument();
|
||||
// Shown inline so keyboard users can read it too.
|
||||
expect(
|
||||
screen.getByText(/Where the attacker has to be standing/),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(
|
||||
screen.getByRole("list", { name: "Reachability evidence" }),
|
||||
).getByText("s3_public · arn:aws:s3:::bucket"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("link", { name: "Open the full path" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("names the provider with its account uid, like the finding panel", () => {
|
||||
// Given
|
||||
getIssue.mockResolvedValue(detailWith([]));
|
||||
|
||||
// When
|
||||
renderPanel(issueWith(), "123456789012");
|
||||
|
||||
// Then
|
||||
expect(screen.getByText("Provider")).toBeInTheDocument();
|
||||
expect(screen.getByText("prod")).toBeInTheDocument();
|
||||
expect(screen.getByText("123456789012")).toBeInTheDocument();
|
||||
expect(screen.getByText("unmodelled")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("claims no outcome for an issue that is not one path", () => {
|
||||
getIssue.mockResolvedValue(detailWith([]));
|
||||
renderPanel();
|
||||
|
||||
expect(screen.queryByText("Leads to")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("pages through every affected resource and links each to Resources", async () => {
|
||||
// Given
|
||||
getIssue.mockResolvedValue(detailWith(resourceUids));
|
||||
renderPanel();
|
||||
|
||||
// When
|
||||
await openTab("Resources");
|
||||
|
||||
// Then: the list leaves the uids out, so the panel fetches them
|
||||
expect(getIssue).toHaveBeenCalledWith("issue-1");
|
||||
expect(await screen.findByText("resource-1")).toBeInTheDocument();
|
||||
expect(screen.queryByText("resource-15")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("link", { name: "Open in Resources: resource-1" }),
|
||||
).toHaveAttribute(
|
||||
"href",
|
||||
"/resources?filter%5Buid%5D=resource-1&filter%5Bprovider_id__in%5D=provider-1",
|
||||
);
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "Show more resources" }),
|
||||
);
|
||||
expect(screen.getByText("resource-15")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("says so when the resources cannot be loaded", async () => {
|
||||
getIssue.mockResolvedValue(undefined);
|
||||
renderPanel();
|
||||
|
||||
await openTab("Resources");
|
||||
|
||||
expect(
|
||||
await screen.findByText(/could not load the resources/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reports a failed findings request and retries using the issue scan", async () => {
|
||||
// Given
|
||||
getIssue.mockResolvedValue(detailWith(resourceUids, ["finding-uid"]));
|
||||
getIssueFindings
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce([finding]);
|
||||
renderPanel();
|
||||
|
||||
// When
|
||||
await openTab("Findings");
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"Could not load the findings for this issue",
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||
|
||||
// Then
|
||||
expect(
|
||||
await screen.findByRole("button", { name: "Bucket is public" }),
|
||||
).toBeInTheDocument();
|
||||
expect(getIssueFindings).toHaveBeenLastCalledWith("scan-1", [
|
||||
"finding-uid",
|
||||
]);
|
||||
});
|
||||
|
||||
it("lists each folded finding once, with its own triage", async () => {
|
||||
// Given
|
||||
getIssue.mockResolvedValue(detailWith(["bucket"], ["f-1", "f-2"]));
|
||||
getIssueFindings.mockResolvedValue([
|
||||
finding,
|
||||
// The same finding on a second resource is still one finding.
|
||||
{ ...finding, resourceUid: "other-bucket" },
|
||||
{
|
||||
...finding,
|
||||
id: "finding-2",
|
||||
uid: "f-2",
|
||||
checkTitle: "Bucket has no logging",
|
||||
triageStatus: "open",
|
||||
},
|
||||
]);
|
||||
renderPanel();
|
||||
|
||||
// When
|
||||
await openTab("Findings");
|
||||
|
||||
// Then
|
||||
const section = await screen.findByRole("region", { name: "Findings" });
|
||||
// One header row plus one row per finding.
|
||||
expect(within(section).getAllByRole("row")).toHaveLength(3);
|
||||
expect(within(section).getByText("Risk Accepted")).toBeInTheDocument();
|
||||
// Cards of one check tell their resources apart.
|
||||
expect(within(section).getByText("other-bucket")).toBeInTheDocument();
|
||||
expect(within(section).getByText("Showing 2 of 300")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("tells the compliance section when the findings preview was capped", async () => {
|
||||
// Given: one more finding than the preview loads
|
||||
getIssue.mockResolvedValue(
|
||||
detailWith(
|
||||
[],
|
||||
Array.from({ length: ISSUE_FINDINGS_LIMIT + 1 }, (_, i) => `uid-${i}`),
|
||||
),
|
||||
);
|
||||
getIssueFindings.mockResolvedValue([finding]);
|
||||
renderPanel();
|
||||
|
||||
// When
|
||||
await openTab("Findings");
|
||||
|
||||
// Then
|
||||
expect(
|
||||
await screen.findByText("Compliance from a capped preview"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens a finding in place and goes back to the tab it came from", async () => {
|
||||
// Given
|
||||
getIssue.mockResolvedValue(detailWith(["bucket"], ["f-1"]));
|
||||
getIssueFindings.mockResolvedValue([finding]);
|
||||
getFindingById.mockResolvedValue({
|
||||
data: {
|
||||
id: "finding-1",
|
||||
attributes: { check_metadata: { checktitle: "Bucket is public" } },
|
||||
relationships: {},
|
||||
},
|
||||
});
|
||||
renderPanel();
|
||||
await openTab("Findings");
|
||||
|
||||
// When
|
||||
await userEvent.click(
|
||||
await screen.findByRole("button", { name: "Bucket is public" }),
|
||||
);
|
||||
|
||||
// Then: the finding replaces the tabs, with a way back
|
||||
expect(await screen.findByText("Detail of finding-1")).toBeInTheDocument();
|
||||
expect(getFindingById).toHaveBeenCalledWith(
|
||||
"finding-1",
|
||||
"resources,scan.provider",
|
||||
);
|
||||
expect(screen.queryByRole("tablist")).not.toBeInTheDocument();
|
||||
|
||||
// When
|
||||
await userEvent.click(screen.getByText("Issue details"));
|
||||
|
||||
// Then
|
||||
expect(screen.getByRole("tab", { name: "Findings" })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
});
|
||||
|
||||
it("refreshes the findings behind the tabs when a finding closes", async () => {
|
||||
// Given: the finding is triaged while open in the panel
|
||||
getIssue.mockResolvedValue(detailWith(["bucket"], ["f-1"]));
|
||||
getIssueFindings
|
||||
.mockResolvedValueOnce([{ ...finding, triageStatus: "open" }])
|
||||
.mockResolvedValueOnce([{ ...finding, triageStatus: "risk_accepted" }]);
|
||||
getFindingById.mockResolvedValue({
|
||||
data: { id: "finding-1", attributes: {}, relationships: {} },
|
||||
});
|
||||
renderPanel();
|
||||
await openTab("Findings");
|
||||
await userEvent.click(
|
||||
await screen.findByRole("button", { name: "Bucket is public" }),
|
||||
);
|
||||
await screen.findByText("Detail of finding-1");
|
||||
|
||||
// When
|
||||
await userEvent.click(screen.getByText("Issue details"));
|
||||
|
||||
// Then: the row shows the new triage without dropping to a loading state
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Bucket is public" }),
|
||||
).toBeInTheDocument();
|
||||
expect(await screen.findByText("Risk Accepted")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Loading issue details…")).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
failure: "the findings request",
|
||||
fail: () => getIssueFindings.mockResolvedValueOnce(undefined),
|
||||
failing: getIssueFindings,
|
||||
},
|
||||
{
|
||||
failure: "the issue request",
|
||||
fail: () => getIssue.mockRejectedValueOnce(new Error("Offline")),
|
||||
failing: getIssue,
|
||||
},
|
||||
])(
|
||||
"keeps the loaded rows when $failure fails on a background refresh",
|
||||
async ({ fail, failing }) => {
|
||||
// Given: the findings loaded, and a finding is open
|
||||
getIssue.mockResolvedValue(detailWith(["bucket"], ["f-1"]));
|
||||
getIssueFindings.mockResolvedValueOnce([finding]);
|
||||
getFindingById.mockResolvedValue({
|
||||
data: { id: "finding-1", attributes: {}, relationships: {} },
|
||||
});
|
||||
renderPanel();
|
||||
await openTab("Findings");
|
||||
await userEvent.click(
|
||||
await screen.findByRole("button", { name: "Bucket is public" }),
|
||||
);
|
||||
await screen.findByText("Detail of finding-1");
|
||||
fail();
|
||||
|
||||
// When: closing it refreshes, and the refresh fails
|
||||
await userEvent.click(screen.getByText("Issue details"));
|
||||
|
||||
// Then: once the failing refresh has settled, the rows stay
|
||||
await waitFor(() => expect(failing).toHaveBeenCalledTimes(2));
|
||||
await act(async () => {
|
||||
await failing.mock.results[1]?.value?.catch(() => undefined);
|
||||
});
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Bucket is public" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps the rows on screen while the background refresh is pending", async () => {
|
||||
// Given: the refresh after closing the finding never answers
|
||||
getIssue
|
||||
.mockResolvedValueOnce(detailWith(["bucket"], ["f-1"]))
|
||||
.mockReturnValueOnce(new Promise(() => {}));
|
||||
getIssueFindings.mockResolvedValueOnce([finding]);
|
||||
getFindingById.mockResolvedValue({
|
||||
data: { id: "finding-1", attributes: {}, relationships: {} },
|
||||
});
|
||||
renderPanel();
|
||||
await openTab("Findings");
|
||||
await userEvent.click(
|
||||
await screen.findByRole("button", { name: "Bucket is public" }),
|
||||
);
|
||||
await screen.findByText("Detail of finding-1");
|
||||
|
||||
// When
|
||||
await userEvent.click(screen.getByText("Issue details"));
|
||||
await waitFor(() => expect(getIssue).toHaveBeenCalledTimes(2));
|
||||
|
||||
// Then: the loaded rows stay, with no loading state in their place
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Bucket is public" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText("Loading issue details…")).toBeNull();
|
||||
});
|
||||
|
||||
it("applies refreshed resources even when the refreshed findings fail", async () => {
|
||||
// Given: the refresh brings a new resource but loses the findings
|
||||
getIssue
|
||||
.mockResolvedValueOnce(detailWith(["bucket"], ["f-1"]))
|
||||
.mockResolvedValueOnce(detailWith(["bucket", "new-bucket"], ["f-1"]));
|
||||
getIssueFindings
|
||||
.mockResolvedValueOnce([finding])
|
||||
.mockResolvedValueOnce(undefined);
|
||||
getFindingById.mockResolvedValue({
|
||||
data: { id: "finding-1", attributes: {}, relationships: {} },
|
||||
});
|
||||
renderPanel();
|
||||
await openTab("Findings");
|
||||
await userEvent.click(
|
||||
await screen.findByRole("button", { name: "Bucket is public" }),
|
||||
);
|
||||
await screen.findByText("Detail of finding-1");
|
||||
|
||||
// When
|
||||
await userEvent.click(screen.getByText("Issue details"));
|
||||
await waitFor(() => expect(getIssueFindings).toHaveBeenCalledTimes(2));
|
||||
|
||||
// Then: the findings stay, the resources follow the refresh
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Bucket is public" }),
|
||||
).toBeInTheDocument();
|
||||
await openTab("Resources");
|
||||
expect(await screen.findByText("new-bucket")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens a finding clicked on the attack path in place", async () => {
|
||||
// Given
|
||||
getIssue.mockResolvedValue(detailWith(["bucket"], ["f-1"]));
|
||||
getIssueFindings.mockResolvedValue([finding]);
|
||||
getFindingById.mockResolvedValue(undefined);
|
||||
renderPanel();
|
||||
await openTab("Attack path");
|
||||
|
||||
// When
|
||||
await userEvent.click(
|
||||
await screen.findByRole("button", { name: "Graph of 1 resources" }),
|
||||
);
|
||||
|
||||
// Then: a failed detail says so and can be retried
|
||||
expect(getFindingById).toHaveBeenCalledWith(
|
||||
"graph-finding",
|
||||
"resources,scan.provider",
|
||||
);
|
||||
expect(
|
||||
await screen.findByText("Could not load this finding."),
|
||||
).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||
expect(getFindingById).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("sends the unique findings to Jira from the actions menu", async () => {
|
||||
// Given: Prowler Cloud, where several findings can go in one dispatch
|
||||
vi.stubEnv("UI_CLOUD_ENABLED", "true");
|
||||
getIssue.mockResolvedValue(detailWith(["bucket"], ["f-1", "f-2"]));
|
||||
getIssueFindings.mockResolvedValue([
|
||||
finding,
|
||||
{ ...finding, resourceUid: "other-bucket" },
|
||||
{ ...finding, id: "finding-2", uid: "f-2" },
|
||||
]);
|
||||
const view = renderPanel();
|
||||
|
||||
// When
|
||||
await userEvent.click(
|
||||
await screen.findByRole("button", { name: "Issue actions" }),
|
||||
);
|
||||
await userEvent.click(
|
||||
await screen.findByRole("menuitem", { name: "Send 2 Findings to Jira" }),
|
||||
);
|
||||
view.unmount();
|
||||
|
||||
// Then: the globally hosted modal survives the panel closing.
|
||||
expect(useJiraDispatchStore.getState().activePayload).toMatchObject({
|
||||
findingTitle: attributes.title,
|
||||
selection: {
|
||||
kind: "target-list",
|
||||
targetType: "finding_id",
|
||||
targetIds: ["finding-1", "finding-2"],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft, Box, Tag } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import { JiraDispatchActionItem } from "@/components/findings/jira-dispatch-action-item";
|
||||
import { MarkdownContainer } from "@/components/findings/markdown-container";
|
||||
import { FindingDetailDrawer } from "@/components/findings/table";
|
||||
import { IssueAttackPath } from "@/components/issues/issue-attack-path";
|
||||
import { IssueCompliance } from "@/components/issues/issue-compliance";
|
||||
import { IssueFindings } from "@/components/issues/issue-findings";
|
||||
import { IssueResources } from "@/components/issues/issue-resources";
|
||||
import { IssueTriage } from "@/components/issues/issue-triage";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/shadcn";
|
||||
import { IssuesLoadError } from "@/components/issues/issues-load-error";
|
||||
import {
|
||||
BreadcrumbNavigation,
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@/components/shadcn";
|
||||
import { Badge } from "@/components/shadcn/badge/badge";
|
||||
import { Button } from "@/components/shadcn/button/button";
|
||||
import { CodeSnippet } from "@/components/shadcn/code-snippet";
|
||||
import {
|
||||
ActionDropdown,
|
||||
ActionDropdownItem,
|
||||
} from "@/components/shadcn/dropdown";
|
||||
import { EntityInfo } from "@/components/shadcn/entities";
|
||||
import { InfoField } from "@/components/shadcn/info-field/info-field";
|
||||
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
|
||||
import { LoadingState } from "@/components/shadcn/spinner/loading-state";
|
||||
import { SeverityBadge } from "@/components/shadcn/table/severity-badge";
|
||||
import { DetailSidePanel } from "@/components/side-panel/detail-side-panel";
|
||||
import { useFindingDetails } from "@/hooks/use-finding-details";
|
||||
import {
|
||||
LEG_LABELS,
|
||||
LEG_QUESTIONS,
|
||||
@@ -18,6 +40,9 @@ import {
|
||||
legBadgeVariant,
|
||||
VERDICTS,
|
||||
} from "@/lib/issues/verdicts";
|
||||
import { buildJiraActionLabel } from "@/lib/jira-dispatch-action";
|
||||
import { createJiraDispatchPayload } from "@/lib/jira-dispatch-selection";
|
||||
import { JIRA_DISPATCH_TARGET } from "@/types/integrations";
|
||||
import {
|
||||
ATTACKER_START_LABEL,
|
||||
ISSUE_OUTCOME_LABEL,
|
||||
@@ -25,149 +50,399 @@ import {
|
||||
LegName,
|
||||
} from "@/types/issues";
|
||||
|
||||
import { useIssueEvidence } from "./use-issue-evidence";
|
||||
|
||||
const ISSUE_PANEL_TAB = {
|
||||
EXPLOITABILITY: "exploitability",
|
||||
ATTACK_PATH: "attack-path",
|
||||
FINDINGS: "findings",
|
||||
RESOURCES: "resources",
|
||||
REMEDIATION: "remediation",
|
||||
TRIAGE: "triage",
|
||||
} as const;
|
||||
|
||||
type IssuePanelTab = (typeof ISSUE_PANEL_TAB)[keyof typeof ISSUE_PANEL_TAB];
|
||||
|
||||
const ISSUE_PANEL_TABS: { id: IssuePanelTab; label: string }[] = [
|
||||
{ id: ISSUE_PANEL_TAB.EXPLOITABILITY, label: "Exploitability" },
|
||||
{ id: ISSUE_PANEL_TAB.ATTACK_PATH, label: "Attack path" },
|
||||
{ id: ISSUE_PANEL_TAB.FINDINGS, label: "Findings" },
|
||||
{ id: ISSUE_PANEL_TAB.RESOURCES, label: "Resources" },
|
||||
{ id: ISSUE_PANEL_TAB.REMEDIATION, label: "Remediation" },
|
||||
{ id: ISSUE_PANEL_TAB.TRIAGE, label: "Triage" },
|
||||
];
|
||||
|
||||
interface IssuePanelProps {
|
||||
issue: IssueResource;
|
||||
/** The provider's account uid, which the issue itself does not carry. */
|
||||
providerUid?: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The verdict and the reasoning behind it, from the list row alone (no
|
||||
* fetch). Graph, resources, findings and compliance stay on the issue page.
|
||||
*/
|
||||
export const IssuePanel = ({ issue, open, onOpenChange }: IssuePanelProps) => {
|
||||
/** Everything about one issue, including drilling into its findings. */
|
||||
export const IssuePanel = ({
|
||||
issue,
|
||||
providerUid,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: IssuePanelProps) => (
|
||||
<DetailSidePanel
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={issue.attributes.title}
|
||||
description="The issue's verdict, attack path, evidence, fix and triage"
|
||||
>
|
||||
{/* Mounted only while open, so the evidence loads when someone looks. */}
|
||||
<IssuePanelContent issue={issue} providerUid={providerUid} />
|
||||
</DetailSidePanel>
|
||||
);
|
||||
|
||||
const IssuePanelContent = ({
|
||||
issue,
|
||||
providerUid,
|
||||
}: Pick<IssuePanelProps, "issue" | "providerUid">) => {
|
||||
const router = useRouter();
|
||||
const { attributes } = issue;
|
||||
const verdict = VERDICTS.find((item) => item.id === attributes.verdict);
|
||||
const scanId = issue.relationships?.scan?.data?.id;
|
||||
const providerId = issue.relationships?.provider?.data?.id;
|
||||
const {
|
||||
state: evidence,
|
||||
retry,
|
||||
refresh,
|
||||
} = useIssueEvidence(issue.id, scanId, providerId);
|
||||
// Controlled so closing a finding returns to the tab it was opened from.
|
||||
const [tab, setTab] = useState<IssuePanelTab>(ISSUE_PANEL_TAB.EXPLOITABILITY);
|
||||
const [openFindingId, setOpenFindingId] = useState<string | null>(null);
|
||||
const finding = useFindingDetails();
|
||||
|
||||
const activeScanId = evidence.status === "loaded" ? evidence.scanId : scanId;
|
||||
const scanFindingsHref =
|
||||
activeScanId &&
|
||||
`/findings?${new URLSearchParams({ "filter[scan__in]": activeScanId, "filter[status__in]": "FAIL" })}`;
|
||||
const loadedFindings =
|
||||
evidence.status === "loaded" ? evidence.findings : undefined;
|
||||
// The findings request returns one row per finding and resource.
|
||||
const uniqueFindings = Array.from(
|
||||
new Map(loadedFindings?.map((item) => [item.id, item])).values(),
|
||||
);
|
||||
// shortcut: the ticket groups the loaded preview (ISSUE_FINDINGS_LIMIT
|
||||
// findings), not every finding of a larger issue; page through finding_uids
|
||||
// if tickets for big issues need all of them.
|
||||
const jiraPayload = createJiraDispatchPayload({
|
||||
findingTitle: attributes.title,
|
||||
targetIds: uniqueFindings.map((item) => item.id),
|
||||
targetType: JIRA_DISPATCH_TARGET.FINDING_ID,
|
||||
description: `Send ${uniqueFindings.length} of ${attributes.findings_count} findings on this attack path.`,
|
||||
});
|
||||
|
||||
const openFinding = (findingId: string) => {
|
||||
setOpenFindingId(findingId);
|
||||
void finding.navigateToFinding(findingId);
|
||||
};
|
||||
const closeFinding = () => {
|
||||
setOpenFindingId(null);
|
||||
finding.resetFindingDetails();
|
||||
// The finding may have been triaged or muted while open.
|
||||
refresh();
|
||||
};
|
||||
|
||||
// A finding opens in place, like a resource's findings do: a second detail
|
||||
// view would take the side panel over and close this issue.
|
||||
if (openFindingId) {
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col gap-4">
|
||||
<BreadcrumbNavigation
|
||||
mode="custom"
|
||||
customItems={[
|
||||
{
|
||||
name: "Issue details",
|
||||
icon: <ArrowLeft />,
|
||||
isClickable: true,
|
||||
onClick: closeFinding,
|
||||
},
|
||||
{
|
||||
name: finding.findingDetailLoading
|
||||
? "Loading..."
|
||||
: (finding.findingDetails?.attributes?.check_metadata
|
||||
?.checktitle ?? "Finding Detail"),
|
||||
isLast: true,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{finding.findingDetailLoading ? (
|
||||
<LoadingState label="Loading finding details..." />
|
||||
) : finding.findingDetails ? (
|
||||
// The detail scrolls inside itself, so it needs a bounded height.
|
||||
<div className="min-h-0 flex-1">
|
||||
<FindingDetailDrawer
|
||||
key={finding.findingDetails.id}
|
||||
finding={finding.findingDetails}
|
||||
inline
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<IssuesLoadError
|
||||
message="Could not load this finding."
|
||||
onRetry={() => openFinding(openFindingId)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const evidencePending =
|
||||
evidence.status === "loading" ? (
|
||||
<p role="status" className="text-text-neutral-tertiary text-sm">
|
||||
Loading issue details…
|
||||
</p>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<DetailSidePanel
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={attributes.title}
|
||||
description="Why this issue got its verdict and how to fix it"
|
||||
>
|
||||
<div className="flex h-full min-w-0 flex-col gap-4 overflow-hidden">
|
||||
<header className="flex flex-col gap-2">
|
||||
<div className="flex h-full min-w-0 flex-col gap-4 overflow-hidden">
|
||||
<header className="flex flex-col gap-2">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Badge variant={verdict?.variant ?? "tag"}>
|
||||
{verdict?.label ?? attributes.verdict}
|
||||
</Badge>
|
||||
<SeverityBadge severity={attributes.severity} />
|
||||
</div>
|
||||
<h3 className="text-text-neutral-primary line-clamp-2 text-lg leading-tight font-medium">
|
||||
{attributes.title}
|
||||
</h3>
|
||||
<EntityInfo
|
||||
cloudProvider={attributes.cloud}
|
||||
entityAlias={attributes.account}
|
||||
/>
|
||||
<p className="text-text-neutral-tertiary text-xs break-all">
|
||||
{attributes.findings_count.toLocaleString()}{" "}
|
||||
{attributes.findings_count === 1 ? "finding" : "findings"} ·{" "}
|
||||
{attributes.pattern}
|
||||
</p>
|
||||
<Button asChild variant="link" size="link-sm" className="self-start">
|
||||
<Link href={`/issues/${issue.id}`}>Open the full path</Link>
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<Tabs
|
||||
defaultValue="exploitability"
|
||||
className="flex min-h-0 w-full flex-1 flex-col"
|
||||
{/* Same actions menu as the resource and finding panels. Jira joins
|
||||
once the findings it sends have loaded. */}
|
||||
<div className="shrink-0">
|
||||
{scanFindingsHref || jiraPayload ? (
|
||||
<ActionDropdown variant="bordered" ariaLabel="Issue actions">
|
||||
{scanFindingsHref && (
|
||||
<ActionDropdownItem
|
||||
icon={<Tag className="size-5" />}
|
||||
label="Open this scan's failed findings"
|
||||
onSelect={() => router.push(scanFindingsHref)}
|
||||
/>
|
||||
)}
|
||||
<JiraDispatchActionItem
|
||||
label={buildJiraActionLabel({
|
||||
findingCount: uniqueFindings.length,
|
||||
})}
|
||||
payload={jiraPayload}
|
||||
/>
|
||||
</ActionDropdown>
|
||||
) : (
|
||||
evidence.status === "loading" && (
|
||||
<Skeleton className="size-8 rounded-md" />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-text-neutral-primary line-clamp-2 text-lg leading-tight font-medium">
|
||||
{attributes.title}
|
||||
</h3>
|
||||
{/* Under the title, like the finding panel's frameworks. */}
|
||||
{evidence.status === "loading" ? (
|
||||
<div aria-hidden="true" className="flex flex-col gap-1.5">
|
||||
<Skeleton className="h-4 w-28 rounded" />
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Skeleton className="size-7 rounded-md" />
|
||||
<Skeleton className="size-7 rounded-md" />
|
||||
<Skeleton className="size-7 rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
loadedFindings &&
|
||||
loadedFindings.length > 0 && (
|
||||
<IssueCompliance
|
||||
findings={loadedFindings}
|
||||
scanId={activeScanId}
|
||||
truncated={
|
||||
evidence.status === "loaded" && evidence.findingsTruncated
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{/* The finding panel's Provider / Service / Region row. */}
|
||||
<div
|
||||
data-responsive-container
|
||||
className="@container mt-2 flex min-w-0 flex-col"
|
||||
>
|
||||
<div className="mb-4 shrink-0">
|
||||
<TabsList>
|
||||
<TabsTrigger value="exploitability">Exploitability</TabsTrigger>
|
||||
<TabsTrigger value="attack-path">Attack path</TabsTrigger>
|
||||
<TabsTrigger value="remediation">Remediation</TabsTrigger>
|
||||
<TabsTrigger value="triage">Triage</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<div className="minimal-scrollbar min-h-0 flex-1 overflow-y-auto">
|
||||
<TabsContent value="exploitability" className="flex flex-col gap-4">
|
||||
{attributes.attacker_start && attributes.outcome && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<InfoField label="Starts from">
|
||||
{ATTACKER_START_LABEL[attributes.attacker_start]}
|
||||
</InfoField>
|
||||
<InfoField label="Leads to">
|
||||
{ISSUE_OUTCOME_LABEL[attributes.outcome]}
|
||||
</InfoField>
|
||||
</div>
|
||||
)}
|
||||
<ul
|
||||
aria-label="Exploitability assessment"
|
||||
className="divide-border-neutral-secondary flex flex-col divide-y"
|
||||
<div className="grid min-w-0 grid-cols-2 gap-4 @md:grid-cols-[minmax(0,1fr)_minmax(0,0.55fr)_minmax(0,1fr)] @md:gap-x-8">
|
||||
<div className="col-span-2 flex min-w-0 flex-col gap-1 @md:col-span-1">
|
||||
<span className="text-text-neutral-secondary text-[10px] whitespace-nowrap">
|
||||
Provider
|
||||
</span>
|
||||
<EntityInfo
|
||||
cloudProvider={attributes.cloud}
|
||||
nameIcon={<Box className="size-4" />}
|
||||
entityAlias={attributes.account}
|
||||
entityId={providerUid}
|
||||
/>
|
||||
</div>
|
||||
<InfoField label="Findings" variant="compact" className="min-w-0">
|
||||
<span className="block truncate whitespace-nowrap">
|
||||
{attributes.findings_count.toLocaleString()}
|
||||
</span>
|
||||
</InfoField>
|
||||
<InfoField label="Pattern" variant="compact" className="min-w-0">
|
||||
<span
|
||||
className="block truncate whitespace-nowrap"
|
||||
title={attributes.pattern}
|
||||
>
|
||||
{(Object.keys(LEG_LABELS) as LegName[]).map((name) => {
|
||||
const leg = attributes.legs[name];
|
||||
if (!leg) return null;
|
||||
return (
|
||||
<li key={name} className="flex items-start gap-3 py-3">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span className="text-sm font-medium">
|
||||
{LEG_LABELS[name]}
|
||||
</span>
|
||||
<span className="text-text-neutral-tertiary text-xs">
|
||||
{LEG_QUESTIONS[name]}
|
||||
</span>
|
||||
<span className="text-text-neutral-secondary text-sm">
|
||||
{leg.text}
|
||||
</span>
|
||||
</div>
|
||||
<Badge variant={legBadgeVariant(name, leg.state)}>
|
||||
{LEG_STATE_LABELS[leg.state]}
|
||||
</Badge>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</TabsContent>
|
||||
|
||||
{/* Mounted only while selected, so the graph's requests run when
|
||||
someone asks for it. */}
|
||||
<TabsContent value="attack-path">
|
||||
<IssueAttackPath
|
||||
id={issue.id}
|
||||
issue={attributes}
|
||||
scanId={scanId}
|
||||
providerId={providerId}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="remediation" className="flex flex-col gap-2">
|
||||
<p className="text-text-neutral-secondary text-sm">
|
||||
{attributes.fix}
|
||||
</p>
|
||||
{scanId && (
|
||||
<Button
|
||||
asChild
|
||||
variant="link"
|
||||
size="link-sm"
|
||||
className="self-start"
|
||||
>
|
||||
<Link
|
||||
href={`/findings?${new URLSearchParams({ "filter[scan__in]": scanId, "filter[status__in]": "FAIL" })}`}
|
||||
>
|
||||
Open this scan's failed findings
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="triage">
|
||||
<IssueTriage
|
||||
key={`${issue.id}-${attributes.updated_at}`}
|
||||
id={issue.id}
|
||||
status={attributes.triage_status}
|
||||
/>
|
||||
</TabsContent>
|
||||
{attributes.pattern}
|
||||
</span>
|
||||
</InfoField>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
</DetailSidePanel>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={(value) => setTab(value as IssuePanelTab)}
|
||||
className="flex min-h-0 w-full flex-1 flex-col"
|
||||
>
|
||||
<div className="mb-4 shrink-0">
|
||||
<TabsList>
|
||||
{ISSUE_PANEL_TABS.map(({ id, label }) => (
|
||||
// Six tabs truncate on a phone; the tooltip names them.
|
||||
<TabsTrigger key={id} value={id} tooltip={label}>
|
||||
{label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<div className="minimal-scrollbar min-h-0 flex-1 overflow-y-auto">
|
||||
<TabsContent
|
||||
value={ISSUE_PANEL_TAB.EXPLOITABILITY}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
{attributes.attacker_start && attributes.outcome && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{/* Inline, not an InfoTooltip: its trigger has no tab stop. */}
|
||||
<InfoField label="Starts from">
|
||||
{ATTACKER_START_LABEL[attributes.attacker_start]}
|
||||
<p className="text-text-neutral-tertiary text-xs">
|
||||
Where the attacker has to be standing. Whether they also
|
||||
need a credential is the Access leg.
|
||||
</p>
|
||||
</InfoField>
|
||||
<InfoField label="Leads to">
|
||||
{ISSUE_OUTCOME_LABEL[attributes.outcome]}
|
||||
</InfoField>
|
||||
</div>
|
||||
)}
|
||||
<ul
|
||||
aria-label="Exploitability assessment"
|
||||
className="divide-border-neutral-secondary flex flex-col divide-y"
|
||||
>
|
||||
{(Object.keys(LEG_LABELS) as LegName[]).map((name) => {
|
||||
const leg = attributes.legs[name];
|
||||
if (!leg) return null;
|
||||
return (
|
||||
<li key={name} className="flex items-start gap-3 py-3">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span className="text-sm font-medium">
|
||||
{LEG_LABELS[name]}
|
||||
</span>
|
||||
<span className="text-text-neutral-tertiary text-xs">
|
||||
{LEG_QUESTIONS[name]}
|
||||
</span>
|
||||
<span className="text-text-neutral-secondary text-sm">
|
||||
{leg.text}
|
||||
</span>
|
||||
{leg.evidence.length > 0 && (
|
||||
<ul
|
||||
aria-label={`${LEG_LABELS[name]} evidence`}
|
||||
className="mt-1 flex flex-col gap-1"
|
||||
>
|
||||
{leg.evidence.map((line) => (
|
||||
<li key={line}>
|
||||
<CodeSnippet
|
||||
value={line}
|
||||
multiline
|
||||
hideCopyButton
|
||||
transparent
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<Badge variant={legBadgeVariant(name, leg.state)}>
|
||||
{LEG_STATE_LABELS[leg.state]}
|
||||
</Badge>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value={ISSUE_PANEL_TAB.ATTACK_PATH}>
|
||||
<IssueAttackPath
|
||||
issue={attributes}
|
||||
evidence={evidence}
|
||||
onRetry={retry}
|
||||
onOpenFinding={openFinding}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value={ISSUE_PANEL_TAB.FINDINGS}>
|
||||
{evidencePending ??
|
||||
(evidence.status === "error" || loadedFindings === undefined ? (
|
||||
<IssuesLoadError
|
||||
message="Could not load the findings for this issue."
|
||||
onRetry={retry}
|
||||
/>
|
||||
) : uniqueFindings.length === 0 ? (
|
||||
<p className="text-text-neutral-tertiary text-sm">
|
||||
No findings to show for this issue.
|
||||
</p>
|
||||
) : (
|
||||
<IssueFindings
|
||||
findings={uniqueFindings}
|
||||
total={attributes.findings_count}
|
||||
onOpenFinding={openFinding}
|
||||
/>
|
||||
))}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value={ISSUE_PANEL_TAB.RESOURCES}>
|
||||
{evidencePending ??
|
||||
(evidence.status === "error" ? (
|
||||
<IssuesLoadError
|
||||
message="Could not load the resources for this issue."
|
||||
onRetry={retry}
|
||||
/>
|
||||
) : (
|
||||
evidence.status === "loaded" && (
|
||||
<IssueResources
|
||||
resources={evidence.resources}
|
||||
providerId={evidence.providerId}
|
||||
/>
|
||||
)
|
||||
))}
|
||||
</TabsContent>
|
||||
|
||||
{/* Laid out like the finding panel's Remediation tab. */}
|
||||
<TabsContent value={ISSUE_PANEL_TAB.REMEDIATION}>
|
||||
<div className="flex flex-col gap-1 px-1">
|
||||
<span className="text-text-neutral-primary text-sm font-semibold">
|
||||
Remediation:
|
||||
</span>
|
||||
<div className="text-text-neutral-primary text-sm">
|
||||
<MarkdownContainer>{attributes.fix}</MarkdownContainer>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value={ISSUE_PANEL_TAB.TRIAGE}>
|
||||
<IssueTriage
|
||||
key={`${issue.id}-${attributes.updated_at}`}
|
||||
id={issue.id}
|
||||
status={attributes.triage_status}
|
||||
/>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { IssueResources } from "./issue-resources";
|
||||
|
||||
const UID = "arn:aws:s3:::acme-customer-exports";
|
||||
|
||||
describe("IssueResources", () => {
|
||||
it("links each resource to the Resources page of its provider", () => {
|
||||
render(<IssueResources resources={[UID]} providerId="provider-1" />);
|
||||
|
||||
expect(
|
||||
screen.getByRole("link", { name: `Open in Resources: ${UID}` }),
|
||||
).toHaveAttribute(
|
||||
"href",
|
||||
`/resources?filter%5Buid%5D=${encodeURIComponent(UID)}&filter%5Bprovider_id__in%5D=provider-1`,
|
||||
);
|
||||
});
|
||||
|
||||
it("lists sample resources without a link, since Resources has no sample data", () => {
|
||||
render(<IssueResources resources={[UID]} providerId="sample-provider" />);
|
||||
|
||||
expect(screen.getByText(UID)).toBeInTheDocument();
|
||||
expect(screen.queryByRole("link")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Button } from "@/components/shadcn/button/button";
|
||||
import { CodeSnippet } from "@/components/shadcn/code-snippet";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/shadcn/table/table";
|
||||
import { isSampleId } from "@/lib/sample-preview/sample-records";
|
||||
|
||||
const RESOURCE_PAGE_SIZE = 12;
|
||||
|
||||
// The issue carries resource uids, not ids, so this opens the Resources list
|
||||
// on that one resource rather than its drawer.
|
||||
const resourceHref = (uid: string, providerId?: string) =>
|
||||
`/resources?${new URLSearchParams({
|
||||
"filter[uid]": uid,
|
||||
// A uid is unique within an account, not across them.
|
||||
...(providerId && { "filter[provider_id__in]": providerId }),
|
||||
})}`;
|
||||
|
||||
interface IssueResourcesProps {
|
||||
resources: string[];
|
||||
providerId?: string;
|
||||
}
|
||||
|
||||
/** The resources the issue folds, a page at a time, as a table. */
|
||||
export const IssueResources = ({
|
||||
resources,
|
||||
providerId,
|
||||
}: IssueResourcesProps) => {
|
||||
const [visible, setVisible] = useState(RESOURCE_PAGE_SIZE);
|
||||
|
||||
if (resources.length === 0) {
|
||||
return (
|
||||
<p className="text-text-neutral-tertiary text-sm">
|
||||
This issue has no affected resources.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section aria-label="Affected resources" className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-end">
|
||||
<span className="text-text-neutral-tertiary text-sm">
|
||||
{resources.length.toLocaleString()} Total Entries
|
||||
</span>
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
<span className="text-text-neutral-secondary text-sm font-medium">
|
||||
Resource
|
||||
</span>
|
||||
</TableHead>
|
||||
<TableHead className="w-10" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{resources.slice(0, visible).map((uid) => (
|
||||
<TableRow key={uid}>
|
||||
<TableCell className="max-w-0">
|
||||
<CodeSnippet value={uid} ariaLabel={`Copy ${uid}`} />
|
||||
</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap">
|
||||
{/* The Resources page has no sample data to open. */}
|
||||
{!isSampleId(providerId) && (
|
||||
<Button asChild variant="link" size="link-sm">
|
||||
<Link
|
||||
href={resourceHref(uid, providerId)}
|
||||
aria-label={`Open in Resources: ${uid}`}
|
||||
>
|
||||
Open in Resources
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{visible < resources.length && (
|
||||
<Button
|
||||
variant="link"
|
||||
size="link-sm"
|
||||
onClick={() => setVisible((value) => value + RESOURCE_PAGE_SIZE)}
|
||||
>
|
||||
Show more resources
|
||||
</Button>
|
||||
)}
|
||||
{visible > RESOURCE_PAGE_SIZE && (
|
||||
<Button
|
||||
variant="link"
|
||||
size="link-sm"
|
||||
onClick={() => setVisible(RESOURCE_PAGE_SIZE)}
|
||||
>
|
||||
Show fewer resources
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -23,18 +23,13 @@ vi.mock("@/actions/issues", () => ({
|
||||
getIssueFindings: async () => [],
|
||||
getIssueNotes: async () => [],
|
||||
}));
|
||||
vi.mock("@/actions/findings", () => ({ getFindingById: vi.fn() }));
|
||||
vi.mock("@/components/findings/table", () => ({
|
||||
FindingDetailDrawer: () => null,
|
||||
}));
|
||||
vi.mock("@/components/issues/issue-graph", () => ({
|
||||
IssueGraph: ({
|
||||
resourceUids,
|
||||
readOnly,
|
||||
}: {
|
||||
resourceUids: string[];
|
||||
readOnly?: boolean;
|
||||
}) => (
|
||||
<p>
|
||||
Graph of {resourceUids.join(", ")}
|
||||
{readOnly ? " (read-only)" : ""}
|
||||
</p>
|
||||
IssueGraph: ({ resourceUids }: { resourceUids: string[] }) => (
|
||||
<p>Graph of {resourceUids.join(", ")}</p>
|
||||
),
|
||||
}));
|
||||
// The real panel portals into the app shell's side panel, absent here.
|
||||
@@ -135,47 +130,54 @@ describe("IssuesTable", () => {
|
||||
it("opens the issue in the side panel and closes it", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
render(<IssuesTable issues={[issue]} />);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "Public bucket" }));
|
||||
|
||||
// Then: the assessment tab leads, the full page stays one click away
|
||||
const panel = screen.getByRole("region", { name: "Public bucket" });
|
||||
expect(
|
||||
within(panel).getByRole("tab", { name: "Exploitability" }),
|
||||
).toHaveAttribute("aria-selected", "true");
|
||||
expect(within(panel).getByText("Block Public Access off")).toBeVisible();
|
||||
expect(
|
||||
within(panel).getByRole("link", { name: "Open the full path" }),
|
||||
).toHaveAttribute("href", "/issues/issue-1");
|
||||
|
||||
// When
|
||||
await user.click(within(panel).getByRole("tab", { name: "Remediation" }));
|
||||
|
||||
// Then
|
||||
expect(within(panel).getByText("Restrict access")).toBeVisible();
|
||||
expect(
|
||||
within(panel).getByRole("link", {
|
||||
name: "Open this scan's failed findings",
|
||||
}),
|
||||
).toHaveAttribute(
|
||||
"href",
|
||||
"/findings?filter%5Bscan__in%5D=scan-1&filter%5Bstatus__in%5D=FAIL",
|
||||
);
|
||||
|
||||
// When
|
||||
getIssue.mockResolvedValue({
|
||||
data: {
|
||||
...issue,
|
||||
attributes: { ...issue.attributes, resource_uids: ["bucket"] },
|
||||
},
|
||||
});
|
||||
render(<IssuesTable issues={[issue]} />);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "Public bucket" }));
|
||||
|
||||
// Then: the assessment tab leads, and nothing sends the user elsewhere
|
||||
const panel = screen.getByRole("region", { name: "Public bucket" });
|
||||
expect(
|
||||
within(panel).getByRole("tab", { name: "Exploitability" }),
|
||||
).toHaveAttribute("aria-selected", "true");
|
||||
expect(within(panel).getByText("Block Public Access off")).toBeVisible();
|
||||
expect(
|
||||
within(panel).queryByRole("link", { name: "Open the full path" }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// When
|
||||
await user.click(within(panel).getByRole("tab", { name: "Remediation" }));
|
||||
|
||||
// Then
|
||||
expect(within(panel).getByText("Restrict access")).toBeVisible();
|
||||
|
||||
// When: the scan's failed findings sit in the actions menu
|
||||
await user.click(
|
||||
within(panel).getByRole("button", { name: "Issue actions" }),
|
||||
);
|
||||
await user.click(
|
||||
await screen.findByRole("menuitem", {
|
||||
name: "Open this scan's failed findings",
|
||||
}),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(push).toHaveBeenLastCalledWith(
|
||||
"/findings?filter%5Bscan__in%5D=scan-1&filter%5Bstatus__in%5D=FAIL",
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(within(panel).getByRole("tab", { name: "Attack path" }));
|
||||
|
||||
// Then: the graph loads on demand and leaves finding drill-down to the page
|
||||
// Then
|
||||
expect(
|
||||
await within(panel).findByText("Graph of bucket (read-only)"),
|
||||
await within(panel).findByText("Graph of bucket"),
|
||||
).toBeInTheDocument();
|
||||
expect(getIssue).toHaveBeenCalledWith("issue-1");
|
||||
|
||||
|
||||
@@ -169,9 +169,12 @@ const columns: ColumnDef<IssueRow>[] = [
|
||||
export const IssuesTable = ({
|
||||
issues,
|
||||
metadata,
|
||||
providerUids,
|
||||
}: {
|
||||
issues: IssueRow[];
|
||||
metadata?: MetaDataProps;
|
||||
/** Provider uid by provider id, shown in the panel's Provider field. */
|
||||
providerUids?: Record<string, string>;
|
||||
}) => {
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
const openIssue = issues.find((issue) => issue.id === openId);
|
||||
@@ -190,6 +193,9 @@ export const IssuesTable = ({
|
||||
<IssuePanel
|
||||
key={openIssue.id}
|
||||
issue={openIssue}
|
||||
providerUid={
|
||||
providerUids?.[openIssue.relationships?.provider?.data?.id ?? ""]
|
||||
}
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setOpenId(null);
|
||||
|
||||
@@ -4,18 +4,10 @@ import { page } from "vitest/browser";
|
||||
|
||||
import { worker } from "@/__tests__/msw/worker";
|
||||
import { render } from "@/__tests__/render-browser";
|
||||
import {
|
||||
getFindingGroups,
|
||||
getFindingGroupResources,
|
||||
} from "@/actions/finding-groups";
|
||||
import { getScan } from "@/actions/scans";
|
||||
import { ExploitabilityChart } from "@/app/(prowler)/_overview/exploitability/_components/exploitability-chart";
|
||||
import { TopIssues } from "@/app/(prowler)/_overview/exploitability/_components/top-issues";
|
||||
import { IssuesList } from "@/app/(prowler)/issues/_components/issues-list";
|
||||
import { IssueDetail } from "@/components/issues/issue-detail";
|
||||
import { GlobalSidePanel } from "@/components/side-panel/global-side-panel";
|
||||
import { applyDefaultMutedFilter, extractFiltersAndQuery } from "@/lib";
|
||||
import { resolveFindingScanDateFilters } from "@/lib/findings-scan-filters";
|
||||
import { IssueResource, IssuesOverviewAttributes } from "@/types/issues";
|
||||
|
||||
const resource = `arn:aws:s3:::production-customer-data-${"long-resource-name-".repeat(8)}`;
|
||||
@@ -153,84 +145,57 @@ describe("Issues responsive layout", () => {
|
||||
await page.viewport(1280, 800);
|
||||
});
|
||||
|
||||
it("opens the selected finding's group and muted resource through the destination actions", async () => {
|
||||
// Given: the destination returns the finding only for its supported filters.
|
||||
it("opens a folded finding inside the panel and comes back to the issue", async () => {
|
||||
// Given: the list next to the real side panel host the app shell mounts.
|
||||
setup();
|
||||
worker.use(
|
||||
http.get("*/scans/scan-1", () =>
|
||||
http.get("*/findings/f1", () =>
|
||||
HttpResponse.json({
|
||||
data: {
|
||||
id: "scan-1",
|
||||
type: "scans",
|
||||
type: "findings",
|
||||
id: "f1",
|
||||
attributes: {
|
||||
inserted_at: "2026-10-20T10:00:00Z",
|
||||
completed_at: "2026-10-20T11:00:00Z",
|
||||
uid: "finding-uid-1",
|
||||
check_id: "s3_public",
|
||||
status: "FAIL",
|
||||
severity: "critical",
|
||||
check_metadata: { checktitle: "S3 bucket is public" },
|
||||
},
|
||||
relationships: { resources: { data: [] } },
|
||||
},
|
||||
}),
|
||||
),
|
||||
http.get("*/finding-groups", ({ request }) => {
|
||||
const params = new URL(request.url).searchParams;
|
||||
const matches =
|
||||
params.get("filter[check_id__in]") === "s3_public" &&
|
||||
params.get("filter[resource_uid]") === resource &&
|
||||
params.get("filter[scan__in]") === "scan-1" &&
|
||||
params.get("filter[inserted_at]") === "2026-10-20" &&
|
||||
params.get("filter[muted]") === "include";
|
||||
return HttpResponse.json({
|
||||
data: matches ? [{ id: "s3_public", type: "finding-groups" }] : [],
|
||||
});
|
||||
}),
|
||||
http.get("*/finding-groups/s3_public/resources", ({ request }) => {
|
||||
const params = new URL(request.url).searchParams;
|
||||
const matches =
|
||||
params.get("filter[resource_uid]") === resource &&
|
||||
params.get("filter[muted]") === "include";
|
||||
return HttpResponse.json({
|
||||
data: matches
|
||||
? [{ id: "r1", attributes: { finding_id: "f1", muted: true } }]
|
||||
: [],
|
||||
});
|
||||
}),
|
||||
);
|
||||
const content = await IssuesList({ searchParams: {} });
|
||||
const screen = await render(
|
||||
<IssueDetail
|
||||
id={issue.id}
|
||||
issue={issue.attributes}
|
||||
scanId="scan-1"
|
||||
providerId="provider-1"
|
||||
/>,
|
||||
<>
|
||||
{content}
|
||||
<GlobalSidePanel />
|
||||
</>,
|
||||
);
|
||||
const link = screen.getByRole("link", { name: "S3 bucket is public" });
|
||||
await expect.element(link).toBeVisible();
|
||||
await screen
|
||||
.getByRole("row", { name: /Public bucket exposes customer data/ })
|
||||
.getByText("Production")
|
||||
.click();
|
||||
await screen.getByRole("tab", { name: "Findings" }).click();
|
||||
|
||||
// When: resolve the same filters used by the Findings page and drill-down.
|
||||
const destination = new URL(
|
||||
link.element().getAttribute("href")!,
|
||||
window.location.origin,
|
||||
);
|
||||
const { filters } = extractFiltersAndQuery(
|
||||
Object.fromEntries(destination.searchParams),
|
||||
);
|
||||
const resolvedFilters = applyDefaultMutedFilter(
|
||||
await resolveFindingScanDateFilters({
|
||||
filters,
|
||||
scans: [],
|
||||
loadScan: async (id) => (await getScan(id))?.data,
|
||||
}),
|
||||
);
|
||||
const groups = await getFindingGroups({ filters: resolvedFilters });
|
||||
// When
|
||||
await screen.getByRole("button", { name: "S3 bucket is public" }).click();
|
||||
|
||||
// Then: the finding takes the panel without closing the issue
|
||||
const back = screen.getByRole("button", { name: "Issue details" });
|
||||
await expect.element(back).toBeVisible();
|
||||
await expect
|
||||
.element(screen.getByRole("tab", { name: "Exploitability" }))
|
||||
.not.toBeInTheDocument();
|
||||
|
||||
// When
|
||||
await back.click();
|
||||
|
||||
// Then
|
||||
expect(groups.data).toEqual([{ id: "s3_public", type: "finding-groups" }]);
|
||||
expect(destination.searchParams.get("expandedCheckId")).toBe("s3_public");
|
||||
const resources = await getFindingGroupResources({
|
||||
checkId: "s3_public",
|
||||
filters: resolvedFilters,
|
||||
});
|
||||
expect(resources.data).toEqual([
|
||||
{ id: "r1", attributes: { finding_id: "f1", muted: true } },
|
||||
]);
|
||||
await expect
|
||||
.element(screen.getByRole("tab", { name: "Findings" }))
|
||||
.toHaveAttribute("aria-selected", "true");
|
||||
});
|
||||
|
||||
it("opens a clicked row in the app's Details side panel", async () => {
|
||||
@@ -251,9 +216,6 @@ describe("Issues responsive layout", () => {
|
||||
.click();
|
||||
|
||||
// Then
|
||||
await expect
|
||||
.element(screen.getByRole("link", { name: "Open the full path" }))
|
||||
.toHaveAttribute("href", "/issues/issue-1");
|
||||
await expect
|
||||
.element(screen.getByText("Block Public Access is disabled."))
|
||||
.toBeVisible();
|
||||
@@ -289,6 +251,15 @@ describe("Issues responsive layout", () => {
|
||||
http.get("*/issues", () =>
|
||||
HttpResponse.json({ data: [issue, secondIssue] }),
|
||||
),
|
||||
// The panel loads the evidence of whichever issue it opens.
|
||||
http.get("*/issues/issue-2", () =>
|
||||
HttpResponse.json({
|
||||
data: {
|
||||
...secondIssue,
|
||||
attributes: { ...secondIssue.attributes, resource_uids: [] },
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
const content = await IssuesList({ searchParams: {} });
|
||||
const screen = await render(
|
||||
@@ -314,8 +285,13 @@ describe("Issues responsive layout", () => {
|
||||
|
||||
// Then: the panel remounted on the new issue, back on its first tab
|
||||
await expect
|
||||
.element(screen.getByRole("link", { name: "Open the full path" }))
|
||||
.toHaveAttribute("href", "/issues/issue-2");
|
||||
.element(
|
||||
screen.getByRole("heading", {
|
||||
level: 3,
|
||||
name: "Admin role assumable from any account",
|
||||
}),
|
||||
)
|
||||
.toBeVisible();
|
||||
await expect
|
||||
.element(screen.getByRole("tab", { name: "Exploitability" }))
|
||||
.toHaveAttribute("aria-selected", "true");
|
||||
@@ -328,7 +304,7 @@ describe("Issues responsive layout", () => {
|
||||
{ width: 375, theme: "dark" },
|
||||
{ width: 1440, theme: "dark" },
|
||||
])(
|
||||
"keeps the list and the issue detail usable at $width px in $theme mode",
|
||||
"keeps the list and the issue panel usable at $width px in $theme mode",
|
||||
async ({ width, theme }) => {
|
||||
await page.viewport(width, 1000);
|
||||
localStorage.setItem("theme", theme);
|
||||
@@ -337,16 +313,13 @@ describe("Issues responsive layout", () => {
|
||||
searchParams: { "filter[pattern]": "aws_public_bucket" },
|
||||
});
|
||||
const screen = await render(
|
||||
<main className="flex min-w-0 flex-col gap-6 p-4">
|
||||
<h1 className="text-2xl font-semibold">Issues</h1>
|
||||
{content}
|
||||
<IssueDetail
|
||||
id={issue.id}
|
||||
issue={issue.attributes}
|
||||
scanId="scan-1"
|
||||
providerId="provider-1"
|
||||
/>
|
||||
</main>,
|
||||
<>
|
||||
<main className="flex min-w-0 flex-col gap-6 p-4">
|
||||
<h1 className="text-2xl font-semibold">Issues</h1>
|
||||
{content}
|
||||
</main>
|
||||
<GlobalSidePanel />
|
||||
</>,
|
||||
);
|
||||
await expect
|
||||
.element(screen.getByRole("button", { name: "Remove Pattern filter" }))
|
||||
@@ -356,25 +329,46 @@ describe("Issues responsive layout", () => {
|
||||
screen.getByText("The internet → Data exposure · aws_public_bucket"),
|
||||
)
|
||||
.toBeVisible();
|
||||
await expect
|
||||
.element(screen.getByRole("region", { name: "Affected resources" }))
|
||||
.toBeVisible();
|
||||
|
||||
// When: the row opens in the panel
|
||||
await screen
|
||||
.getByRole("row", { name: /Public bucket exposes customer data/ })
|
||||
.getByRole("button", { name: "Public bucket exposes customer data" })
|
||||
.click();
|
||||
|
||||
// Then: every tab fits the viewport, long identifiers wrap
|
||||
await expect
|
||||
.element(screen.getByText("Partly", { exact: true }))
|
||||
.toBeVisible();
|
||||
await expect.element(screen.getByText("Public by design.")).toBeVisible();
|
||||
const fits = (element: Element) =>
|
||||
element.getBoundingClientRect().right <= width;
|
||||
for (const snippet of screen
|
||||
.getByText(resource, { exact: true })
|
||||
.elements()) {
|
||||
expect(snippet.scrollWidth).toBeLessThanOrEqual(
|
||||
snippet.clientWidth + 1,
|
||||
);
|
||||
}
|
||||
|
||||
await screen.getByRole("tab", { name: "Findings" }).click();
|
||||
await expect
|
||||
.element(
|
||||
screen.getByRole("img", { name: "Critical finding risk icon" }),
|
||||
)
|
||||
.element(screen.getByRole("button", { name: "S3 bucket is public" }))
|
||||
.toBeVisible();
|
||||
|
||||
await screen.getByRole("tab", { name: "Resources" }).click();
|
||||
const resources = screen.getByRole("region", {
|
||||
name: "Affected resources",
|
||||
});
|
||||
await expect.element(resources).toBeVisible();
|
||||
expect(fits(resources.element())).toBe(true);
|
||||
expect(document.documentElement.scrollWidth).toBeLessThanOrEqual(width);
|
||||
const detailBounds = screen
|
||||
.getByRole("region", { name: "Affected resources" })
|
||||
.element()
|
||||
.getBoundingClientRect();
|
||||
expect(detailBounds.right).toBeLessThanOrEqual(width);
|
||||
await page.screenshot();
|
||||
|
||||
await screen.getByRole("tab", { name: "Triage" }).click();
|
||||
await expect.element(screen.getByText("Public by design.")).toBeVisible();
|
||||
|
||||
if (width < 640) {
|
||||
await screen.getByRole("tab", { name: "Attack path" }).click();
|
||||
await expect
|
||||
.poll(() => {
|
||||
const node = screen
|
||||
@@ -386,20 +380,6 @@ describe("Issues responsive layout", () => {
|
||||
})
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
// Long resource identifiers and evidence must wrap within the detail.
|
||||
for (const snippet of screen
|
||||
.getByText(resource, { exact: true })
|
||||
.elements()) {
|
||||
expect(snippet.scrollWidth).toBeLessThanOrEqual(
|
||||
snippet.clientWidth + 1,
|
||||
);
|
||||
}
|
||||
await page.viewport(
|
||||
width,
|
||||
Math.max(1000, document.documentElement.scrollHeight),
|
||||
);
|
||||
await screen.getByRole("main").screenshot();
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
Loaded 100 of 220 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user