mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-05-13 15:50:55 +00:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 578c354a69 | |||
| 02cdcb29db | |||
| 6e0d7866cd | |||
| 4b71f37c91 | |||
| cdfbe5b2e3 | |||
| 1b6a459df4 | |||
| 73c0305dc4 | |||
| 0e01e67257 | |||
| 1ad329f9cf | |||
| d03d1d2393 | |||
| 832516be2a |
+2
-3
@@ -2,13 +2,12 @@
|
||||
|
||||
All notable changes to the **Prowler API** are documented in this file.
|
||||
|
||||
## [1.27.0] (Prowler UNRELEASED)
|
||||
## [1.27.0] (Prowler v5.26.0)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
- New `scan-reset-ephemeral-resources` post-scan task zeroes `failed_findings_count` for resources missing from the latest full-scope scan, keeping ephemeral resources from polluting the Resources page sort [(#10929)](https://github.com/prowler-cloud/prowler/pull/10929)
|
||||
- ASD Essential Eight (AWS) compliance framework support [(#10982)](https://github.com/prowler-cloud/prowler/pull/10982)
|
||||
- `scan-reset-ephemeral-resources` post-scan task zeroes `failed_findings_count` for resources missing from the latest full-scope scan, keeping ephemeral resources from polluting the Resources page sort [(#10929)](https://github.com/prowler-cloud/prowler/pull/10929)
|
||||
- ASD Essential Eight (AWS) compliance framework support [(#10982)](https://github.com/prowler-cloud/prowler/pull/10982)
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
|
||||
Generated
+3
-3
@@ -6754,8 +6754,8 @@ uuid6 = "2024.7.10"
|
||||
[package.source]
|
||||
type = "git"
|
||||
url = "https://github.com/prowler-cloud/prowler.git"
|
||||
reference = "master"
|
||||
resolved_reference = "16798e293da365965120961e6539e3a9756564f9"
|
||||
reference = "v5.26"
|
||||
resolved_reference = "02cdcb29dbcd8eb5ed442c1cd03830000324fb0f"
|
||||
|
||||
[[package]]
|
||||
name = "psutil"
|
||||
@@ -9424,4 +9424,4 @@ files = [
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = ">=3.11,<3.13"
|
||||
content-hash = "a3ab982d11a87d951ff15694d2ca7fd51f1f51a451abb0baa067ccf6966367a8"
|
||||
content-hash = "24f7a92f6c72a8207ab15f75c813a5a244c018afb0a582a5abf8c96e2c7faf12"
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ dependencies = [
|
||||
"defusedxml==0.7.1",
|
||||
"gunicorn==23.0.0",
|
||||
"lxml==5.3.2",
|
||||
"prowler @ git+https://github.com/prowler-cloud/prowler.git@master",
|
||||
"prowler @ git+https://github.com/prowler-cloud/prowler.git@v5.26",
|
||||
"psycopg2-binary==2.9.9",
|
||||
"pytest-celery[redis] (==1.3.0)",
|
||||
"sentry-sdk[django] (==2.56.0)",
|
||||
|
||||
@@ -119,6 +119,7 @@
|
||||
"user-guide/tutorials/prowler-app-multi-tenant",
|
||||
"user-guide/tutorials/prowler-app-api-keys",
|
||||
"user-guide/tutorials/prowler-app-import-findings",
|
||||
"user-guide/tutorials/prowler-app-alerts",
|
||||
{
|
||||
"group": "Mutelist",
|
||||
"expanded": true,
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 257 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 399 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 425 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 88 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 222 KiB |
@@ -1,12 +1,17 @@
|
||||
export const VersionBadge = ({ version }) => {
|
||||
return (
|
||||
<code className="version-badge-container">
|
||||
<p className="version-badge">
|
||||
<span className="version-badge-label">Added in:</span>
|
||||
<code className="version-badge-version">{version}</code>
|
||||
</p>
|
||||
</code>
|
||||
|
||||
|
||||
<a
|
||||
href={`https://github.com/prowler-cloud/prowler/releases/tag/${version}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="version-badge-link"
|
||||
>
|
||||
<span className="version-badge-container">
|
||||
<span className="version-badge">
|
||||
<span className="version-badge-label">Added in:</span>
|
||||
<span className="version-badge-version">{version}</span>
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,21 @@
|
||||
/* Version Badge Styling */
|
||||
.version-badge-link,
|
||||
.version-badge-link:hover,
|
||||
.version-badge-link:focus,
|
||||
.version-badge-link:active,
|
||||
.version-badge-link:visited {
|
||||
display: inline-block;
|
||||
text-decoration: none !important;
|
||||
background-image: none !important;
|
||||
border-bottom: none !important;
|
||||
color: inherit;
|
||||
transition: opacity 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
.version-badge-link:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.version-badge-container {
|
||||
display: inline-block;
|
||||
margin: 0 0 1rem 0;
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
---
|
||||
title: 'Alerts'
|
||||
description: 'Create email alerts from Prowler Cloud findings to monitor relevant security changes after scans or in daily digests.'
|
||||
---
|
||||
|
||||
import { VersionBadge } from "/snippets/version-badge.mdx"
|
||||
|
||||
<VersionBadge version="5.26.0" />
|
||||
|
||||
Alerts notify recipients by email when security findings match saved filter conditions. Use Alerts to track high-priority findings, monitor specific providers or services, and keep teams informed about scan results that match defined criteria.
|
||||
|
||||
<Note>
|
||||
This feature is available exclusively in **Prowler Cloud** with a paid subscription.
|
||||
</Note>
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before creating Alerts, ensure that:
|
||||
|
||||
* At least one scan has completed and produced findings.
|
||||
* The user role includes the `manage_alerts` permission.
|
||||
|
||||
The `manage_alerts` permission is required to create, edit, test, enable, disable, and delete Alerts. See [RBAC Administrative Permissions](/user-guide/tutorials/prowler-app-rbac#rbac-administrative-permissions) for details.
|
||||
|
||||
## How Alerts Work
|
||||
|
||||
Alerts are created from Findings filters. When an Alert runs, Prowler Cloud evaluates the saved conditions against findings and sends an email digest when matching findings exist.
|
||||
|
||||
<Note>
|
||||
Alerts evaluate findings with status `FAIL` only. Findings with status `PASS` or `MANUAL`, and muted findings, never trigger an Alert regardless of the saved filters.
|
||||
</Note>
|
||||
|
||||
Alerts run on one of three schedules:
|
||||
|
||||
| Frequency | Description |
|
||||
|-----------|-------------|
|
||||
| After each scan | Evaluates the Alert after each completed scan. |
|
||||
| Daily digest | Evaluates the Alert once per day and sends a digest when findings match. |
|
||||
| After each scan and daily | Evaluates the Alert after every scan and in the daily digest. |
|
||||
|
||||
## Creating an Alert From Findings
|
||||
|
||||
To create an Alert:
|
||||
|
||||
1. Navigate to **Findings** in Prowler Cloud.
|
||||
2. Apply at least one [Alert-compatible filter](#alert-compatible-filters) to define the findings that should trigger the Alert.
|
||||
3. Click **Create Alert**.
|
||||
|
||||

|
||||
|
||||
4. Configure the Alert settings:
|
||||
* **Name:** Add a short, descriptive name.
|
||||
* **Description:** Add optional context for the Alert.
|
||||
* **Frequency:** Select when Prowler Cloud should evaluate the Alert.
|
||||
* **Recipients:** Select the recipients who should receive the email digest.
|
||||
|
||||

|
||||
|
||||
5. Click **Create**.
|
||||
|
||||
After the Alert is created, Prowler Cloud evaluates it based on the selected frequency.
|
||||
|
||||
## Alert-Compatible Filters
|
||||
|
||||
An **Alert-compatible filter** is a Findings-page filter that the Alert condition language can evaluate when the Alert runs. The Findings page exposes many filters, but only a specific subset can be saved into an Alert. Filters outside this subset, such as **Status**, free-text search, sort, or pagination, are ignored when seeding an Alert from the current Findings view.
|
||||
|
||||
When **Create Alert** is clicked on the Findings page, Prowler Cloud takes the active filters, keeps only the Alert-compatible ones, and uses them to build the Alert condition.
|
||||
|
||||
The following filters are Alert-compatible:
|
||||
|
||||
* Provider type
|
||||
* Provider
|
||||
* Severity
|
||||
* Delta (new findings since the previous scan)
|
||||
* Region
|
||||
* Service
|
||||
* Resource type
|
||||
* Category
|
||||
* Resource group
|
||||
|
||||
If only the **Status** filter is applied on the Findings page, Prowler Cloud substitutes all severities as the condition base so the Alert can still be created. Status itself never becomes part of the Alert condition.
|
||||
|
||||
## Managing Alerts
|
||||
|
||||
Navigate to **Alerts** to review and manage existing Alerts.
|
||||
|
||||

|
||||
|
||||
Each Alert provides these actions:
|
||||
|
||||
| Action | Description |
|
||||
|--------|-------------|
|
||||
| Edit | Update name, description, recipients, frequency, or filters. |
|
||||
| Enable/Disable | Start or stop Alert evaluation without deleting the Alert. |
|
||||
| Delete | Permanently remove the Alert. |
|
||||
|
||||
## Testing Alert Filters
|
||||
|
||||
When editing an Alert, click **Test** to preview whether the current filters match existing findings.
|
||||
|
||||
The test result indicates whether the filters match findings and includes a summary of the matching results.
|
||||
|
||||

|
||||
|
||||
<Warning>
|
||||
**The Test result is a snapshot, not a guarantee of future Alert triggers.**
|
||||
|
||||
The Test evaluates the current filters against existing findings at the moment **Test** is clicked. It does not predict whether the Alert will trigger on its next evaluation. The Alert trigger depends on the state at evaluation time:
|
||||
|
||||
* **After each scan:** The Alert is evaluated against the findings produced by that scan only. If the next scan produces no findings that match the filters, the Alert will not trigger, even if a Test run earlier in the day showed matches.
|
||||
* **Daily digest:** The Alert is evaluated against the findings present on the digest day. If no matching findings exist for that day, the Alert will not trigger, even if previous days had matches.
|
||||
|
||||
The reverse is also true: a Test showing no matches does not guarantee the Alert will stay silent. Future scans may produce matching findings.
|
||||
|
||||
Use **Test** to validate that the filters are well-formed and target the intended findings, not to forecast future Alert behavior.
|
||||
</Warning>
|
||||
|
||||
## Recipients
|
||||
|
||||
Alert recipients are selected from the email addresses available in the tenant. Recipients receive an email digest each time an Alert evaluates and matches findings.
|
||||
|
||||
<Note>
|
||||
By default, the **organization owner** receives a **daily digest** for **critical findings**. Adjust the recipient, frequency, or filters in the Alert configuration to change this behavior.
|
||||
</Note>
|
||||
|
||||
If a recipient unsubscribes from Alerts, that address stops receiving digests until it is reconfirmed.
|
||||
|
||||
## Email Notifications
|
||||
|
||||
When an Alert matches findings, Prowler Cloud sends a security alert email that summarizes the matching findings. The email includes:
|
||||
|
||||
* The scan name and evaluation time.
|
||||
* The total number of matching findings.
|
||||
* The number of Alert rules that triggered.
|
||||
* A preview of the affected findings, grouped by severity, with resource details and the originating rule.
|
||||
* A direct link to view all matching findings in Prowler Cloud.
|
||||
|
||||

|
||||
|
||||
## Best Practices
|
||||
|
||||
* **Start with focused filters:** Create Alerts for specific high-priority scopes, such as critical findings, production providers, or important services.
|
||||
* **Use clear names:** Choose names that explain the intent of the Alert.
|
||||
* **Review recipients regularly:** Keep recipient lists aligned with current ownership.
|
||||
* **Test before saving edits:** Use **Test** after changing filters to confirm that the Alert matches the expected findings.
|
||||
* **Disable instead of deleting during tuning:** Disable Alerts temporarily when adjusting filters or recipients.
|
||||
Generated
+3
-3
@@ -1009,7 +1009,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.32.5"
|
||||
version = "2.33.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
@@ -1017,9 +1017,9 @@ dependencies = [
|
||||
{ name = "idna" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -2,16 +2,17 @@
|
||||
|
||||
All notable changes to the **Prowler SDK** are documented in this file.
|
||||
|
||||
## [5.26.0] (Prowler UNRELEASED)
|
||||
## [5.26.0] (Prowler v5.26.0)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
- `bedrock_guardrails_configured` check for AWS provider [(#10844)](https://github.com/prowler-cloud/prowler/pull/10844)
|
||||
- Universal compliance pipeline integrated into the CLI: `--list-compliance` and `--list-compliance-requirements` show universal frameworks, and CSV plus OCSF outputs are generated for any framework declaring a `TableConfig` [(#10301)](https://github.com/prowler-cloud/prowler/pull/10301)
|
||||
- Universal compliance with OCSF support [(#10301)](https://github.com/prowler-cloud/prowler/pull/10301)
|
||||
- ASD Essential Eight Maturity Model compliance framework for AWS (Maturity Level One, Nov 2023) [(#10808)](https://github.com/prowler-cloud/prowler/pull/10808)
|
||||
- Update Vercel checks to return personalized finding status extended depending on billing plan and classify them with billing-plan categories [(#10663)](https://github.com/prowler-cloud/prowler/pull/10663)
|
||||
- Vercel checks to return personalized finding status extended depending on billing plan and classify them with billing-plan categories [(#10663)](https://github.com/prowler-cloud/prowler/pull/10663)
|
||||
- `bedrock_prompt_management_exists` check for AWS provider [(#10878)](https://github.com/prowler-cloud/prowler/pull/10878)
|
||||
- 8 Gmail attachment safety and spoofing protection checks for Google Workspace provider using the Cloud Identity Policy API [(#10980)](https://github.com/prowler-cloud/prowler/pull/10980)
|
||||
- `bedrock_prompt_encrypted_with_cmk` check for AWS provider [(#10905)](https://github.com/prowler-cloud/prowler/pull/10905)
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
|
||||
@@ -6473,6 +6473,7 @@
|
||||
"backup_recovery_point_encrypted",
|
||||
"backup_vaults_encrypted",
|
||||
"bedrock_model_invocation_logs_encryption_enabled",
|
||||
"bedrock_prompt_encrypted_with_cmk",
|
||||
"cloudfront_distributions_field_level_encryption_enabled",
|
||||
"cloudfront_distributions_origin_traffic_encrypted",
|
||||
"cloudtrail_kms_encryption_enabled",
|
||||
@@ -6730,6 +6731,7 @@
|
||||
"backup_recovery_point_encrypted",
|
||||
"backup_vaults_encrypted",
|
||||
"bedrock_model_invocation_logs_encryption_enabled",
|
||||
"bedrock_prompt_encrypted_with_cmk",
|
||||
"cloudfront_distributions_field_level_encryption_enabled",
|
||||
"cloudfront_distributions_origin_traffic_encrypted",
|
||||
"cloudtrail_kms_encryption_enabled",
|
||||
|
||||
@@ -1311,6 +1311,7 @@
|
||||
"glue_development_endpoints_job_bookmark_encryption_enabled",
|
||||
"glue_ml_transform_encrypted_at_rest",
|
||||
"bedrock_model_invocation_logs_encryption_enabled",
|
||||
"bedrock_prompt_encrypted_with_cmk",
|
||||
"codebuild_project_s3_logs_encrypted",
|
||||
"codebuild_report_group_export_encrypted"
|
||||
]
|
||||
|
||||
@@ -1767,6 +1767,7 @@
|
||||
"backup_recovery_point_encrypted",
|
||||
"backup_vaults_encrypted",
|
||||
"bedrock_model_invocation_logs_encryption_enabled",
|
||||
"bedrock_prompt_encrypted_with_cmk",
|
||||
"cloudfront_distributions_field_level_encryption_enabled",
|
||||
"cloudfront_distributions_origin_traffic_encrypted",
|
||||
"cloudtrail_kms_encryption_enabled",
|
||||
|
||||
@@ -2115,6 +2115,7 @@
|
||||
"Checks": [
|
||||
"backup_vaults_encrypted",
|
||||
"bedrock_model_invocation_logs_encryption_enabled",
|
||||
"bedrock_prompt_encrypted_with_cmk",
|
||||
"cloudtrail_kms_encryption_enabled",
|
||||
"cloudwatch_log_group_kms_encryption_enabled",
|
||||
"dynamodb_tables_kms_cmk_encryption_enabled",
|
||||
|
||||
@@ -2117,6 +2117,7 @@
|
||||
"Checks": [
|
||||
"backup_vaults_encrypted",
|
||||
"bedrock_model_invocation_logs_encryption_enabled",
|
||||
"bedrock_prompt_encrypted_with_cmk",
|
||||
"cloudtrail_kms_encryption_enabled",
|
||||
"cloudwatch_log_group_kms_encryption_enabled",
|
||||
"dynamodb_tables_kms_cmk_encryption_enabled",
|
||||
|
||||
@@ -903,6 +903,7 @@
|
||||
"Checks": [
|
||||
"backup_vaults_encrypted",
|
||||
"backup_recovery_point_encrypted",
|
||||
"bedrock_prompt_encrypted_with_cmk",
|
||||
"cloudtrail_kms_encryption_enabled",
|
||||
"cloudwatch_log_group_kms_encryption_enabled",
|
||||
"s3_bucket_kms_encryption",
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"Provider": "aws",
|
||||
"CheckID": "bedrock_prompt_encrypted_with_cmk",
|
||||
"CheckTitle": "Amazon Bedrock prompt is encrypted at rest with a customer-managed KMS key",
|
||||
"CheckType": [
|
||||
"Software and Configuration Checks/AWS Security Best Practices"
|
||||
],
|
||||
"ServiceName": "bedrock",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "Other",
|
||||
"ResourceGroup": "ai_ml",
|
||||
"Description": "Bedrock prompts should be encrypted at rest with a **customer-managed KMS key (CMK)** rather than the AWS-owned default key. Prompts can contain sensitive instructions, business logic, and references to downstream tooling that warrant tenant-controlled key material and auditable access via AWS KMS.",
|
||||
"Risk": "A prompt encrypted only with the AWS-owned default key offers limited tenant control over key access and lifecycle: no customer KMS key policy to govern decrypt permissions, no control over rotation cadence or scheduled deletion, and gaps against frameworks (ISO 27001 A.8.24, NIST CSF PR.DS, KISA-ISMS-P 2.7.2) that require customer-managed keys for sensitive data at rest.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://docs.aws.amazon.com/bedrock/latest/userguide/prompt-management.html",
|
||||
"https://docs.aws.amazon.com/bedrock/latest/APIReference/API_agent_CreatePrompt.html",
|
||||
"https://docs.aws.amazon.com/bedrock/latest/APIReference/API_agent_UpdatePrompt.html"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "# Retrieve the current DRAFT prompt first and note the existing fields you want to preserve, such as description, defaultVariant, and variants:\naws bedrock-agent get-prompt --prompt-identifier <prompt_id> --prompt-version DRAFT --output json\n# Then update the prompt and include the existing fields you want to keep alongside the CMK change:\naws bedrock-agent update-prompt --prompt-identifier <prompt_id> --name <prompt_name> --description <current_or_new_description> --default-variant <current_default_variant> --variants <current_or_updated_variants_json> --customer-encryption-key-arn <kms_key_arn>",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Open the Amazon Bedrock console\n2. Navigate to Prompt management\n3. Select the prompt\n4. Edit the prompt and choose a customer-managed KMS key for encryption\n5. Save the prompt",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Encrypt every Bedrock prompt with a **customer-managed KMS key** to retain control over key access, rotation, and lifecycle. When using `update-prompt`, first retrieve the current draft and carry forward the fields you want to preserve, such as the existing description, `defaultVariant`, and `variants`, so the encryption change does not unintentionally overwrite prompt configuration.",
|
||||
"Url": "https://hub.prowler.com/check/bedrock_prompt_encrypted_with_cmk"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"gen-ai",
|
||||
"encryption"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [
|
||||
"bedrock_prompt_management_exists"
|
||||
],
|
||||
"Notes": ""
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
from prowler.lib.check.models import Check, Check_Report_AWS
|
||||
from prowler.providers.aws.services.bedrock.bedrock_agent_client import (
|
||||
bedrock_agent_client,
|
||||
)
|
||||
|
||||
|
||||
class bedrock_prompt_encrypted_with_cmk(Check):
|
||||
"""Ensure that Bedrock prompts are encrypted with a customer-managed KMS key.
|
||||
|
||||
This check evaluates whether each Bedrock prompt is encrypted at rest using
|
||||
a customer-managed KMS key (CMK) rather than the AWS-owned default key.
|
||||
- PASS: The Bedrock prompt is encrypted with a customer-managed KMS key.
|
||||
- FAIL: The Bedrock prompt is not encrypted with a customer-managed KMS key.
|
||||
"""
|
||||
|
||||
def execute(self) -> list[Check_Report_AWS]:
|
||||
"""Execute the Bedrock prompt CMK encryption check.
|
||||
|
||||
Returns:
|
||||
A list of reports containing the result of the check.
|
||||
"""
|
||||
findings = []
|
||||
for prompt in bedrock_agent_client.prompts.values():
|
||||
report = Check_Report_AWS(metadata=self.metadata(), resource=prompt)
|
||||
if prompt.customer_encryption_key_arn:
|
||||
report.status = "PASS"
|
||||
report.status_extended = f"Bedrock Prompt {prompt.name} is encrypted with a customer-managed KMS key."
|
||||
else:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = f"Bedrock Prompt {prompt.name} is not encrypted with a customer-managed KMS key."
|
||||
findings.append(report)
|
||||
return findings
|
||||
+3
-1
@@ -34,6 +34,8 @@
|
||||
"gen-ai"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"RelatedTo": [
|
||||
"bedrock_prompt_encrypted_with_cmk"
|
||||
],
|
||||
"Notes": "Results are generated per scanned region. Regions where `ListPrompts` cannot be queried are omitted from the findings."
|
||||
}
|
||||
|
||||
@@ -136,7 +136,10 @@ class Guardrail(BaseModel):
|
||||
|
||||
|
||||
class BedrockAgent(AWSService):
|
||||
"""Bedrock Agent service class for managing agents and prompts."""
|
||||
|
||||
def __init__(self, provider):
|
||||
"""Initialize the BedrockAgent service."""
|
||||
# Call AWSService's __init__
|
||||
super().__init__("bedrock-agent", provider)
|
||||
self.agents = {}
|
||||
@@ -144,6 +147,7 @@ class BedrockAgent(AWSService):
|
||||
self.prompt_scanned_regions: set = set()
|
||||
self.__threading_call__(self._list_agents)
|
||||
self.__threading_call__(self._list_prompts)
|
||||
self.__threading_call__(self._get_prompt, self.prompts.values())
|
||||
self.__threading_call__(self._list_tags_for_resource, self.agents.values())
|
||||
|
||||
def _list_agents(self, regional_client):
|
||||
@@ -171,29 +175,43 @@ class BedrockAgent(AWSService):
|
||||
)
|
||||
|
||||
def _list_prompts(self, regional_client):
|
||||
"""List all prompts in a region.
|
||||
|
||||
Prompt Management is evaluated as a region-level adoption signal, so
|
||||
prompt collection is intentionally not filtered by audit_resources.
|
||||
"""
|
||||
"""List all prompts in a region."""
|
||||
logger.info("Bedrock Agent - Listing Prompts...")
|
||||
try:
|
||||
paginator = regional_client.get_paginator("list_prompts")
|
||||
for page in paginator.paginate():
|
||||
for prompt in page.get("promptSummaries", []):
|
||||
prompt_arn = prompt.get("arn", "")
|
||||
self.prompts[prompt_arn] = Prompt(
|
||||
id=prompt.get("id", ""),
|
||||
name=prompt.get("name", ""),
|
||||
arn=prompt_arn,
|
||||
region=regional_client.region,
|
||||
)
|
||||
if not self.audit_resources or (
|
||||
is_resource_filtered(prompt_arn, self.audit_resources)
|
||||
):
|
||||
self.prompts[prompt_arn] = Prompt(
|
||||
id=prompt.get("id", ""),
|
||||
name=prompt.get("name", ""),
|
||||
arn=prompt_arn,
|
||||
region=regional_client.region,
|
||||
)
|
||||
self.prompt_scanned_regions.add(regional_client.region)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
|
||||
def _get_prompt(self, prompt):
|
||||
"""Get detailed prompt information including encryption configuration."""
|
||||
logger.info("Bedrock Agent - Getting Prompt...")
|
||||
try:
|
||||
prompt_info = self.regional_clients[prompt.region].get_prompt(
|
||||
promptIdentifier=prompt.id
|
||||
)
|
||||
prompt.customer_encryption_key_arn = prompt_info.get(
|
||||
"customerEncryptionKeyArn"
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{prompt.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
|
||||
def _list_tags_for_resource(self, resource):
|
||||
"""List tags for a Bedrock Agent resource."""
|
||||
logger.info("Bedrock Agent - Listing Tags for Resource...")
|
||||
@@ -212,6 +230,8 @@ class BedrockAgent(AWSService):
|
||||
|
||||
|
||||
class Agent(BaseModel):
|
||||
"""Model for a Bedrock Agent resource."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
arn: str
|
||||
@@ -227,3 +247,4 @@ class Prompt(BaseModel):
|
||||
name: str
|
||||
arn: str
|
||||
region: str
|
||||
customer_encryption_key_arn: Optional[str] = None
|
||||
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
from unittest import mock
|
||||
|
||||
import botocore
|
||||
|
||||
from tests.providers.aws.utils import (
|
||||
AWS_ACCOUNT_NUMBER,
|
||||
AWS_REGION_EU_WEST_1,
|
||||
AWS_REGION_US_EAST_1,
|
||||
set_mocked_aws_provider,
|
||||
)
|
||||
|
||||
make_api_call = botocore.client.BaseClient._make_api_call
|
||||
|
||||
PROMPT_ARN = (
|
||||
f"arn:aws:bedrock:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:prompt/test-prompt-id"
|
||||
)
|
||||
PROMPT_ID = "test-prompt-id"
|
||||
PROMPT_NAME = "test-prompt"
|
||||
KMS_KEY_ARN = (
|
||||
f"arn:aws:kms:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:key/"
|
||||
"12345678-1234-1234-1234-123456789012"
|
||||
)
|
||||
|
||||
|
||||
def mock_make_api_call_with_cmk(self, operation_name, kwarg):
|
||||
"""Mock API call returning a prompt encrypted with a customer-managed KMS key."""
|
||||
if operation_name == "ListPrompts":
|
||||
return {
|
||||
"promptSummaries": [
|
||||
{
|
||||
"id": PROMPT_ID,
|
||||
"name": PROMPT_NAME,
|
||||
"arn": PROMPT_ARN,
|
||||
}
|
||||
]
|
||||
}
|
||||
elif operation_name == "GetPrompt":
|
||||
return {
|
||||
"id": PROMPT_ID,
|
||||
"name": PROMPT_NAME,
|
||||
"arn": PROMPT_ARN,
|
||||
"customerEncryptionKeyArn": KMS_KEY_ARN,
|
||||
}
|
||||
return make_api_call(self, operation_name, kwarg)
|
||||
|
||||
|
||||
def mock_make_api_call_without_cmk(self, operation_name, kwarg):
|
||||
"""Mock API call returning a prompt without a customer-managed KMS key."""
|
||||
if operation_name == "ListPrompts":
|
||||
return {
|
||||
"promptSummaries": [
|
||||
{
|
||||
"id": PROMPT_ID,
|
||||
"name": PROMPT_NAME,
|
||||
"arn": PROMPT_ARN,
|
||||
}
|
||||
]
|
||||
}
|
||||
elif operation_name == "GetPrompt":
|
||||
return {
|
||||
"id": PROMPT_ID,
|
||||
"name": PROMPT_NAME,
|
||||
"arn": PROMPT_ARN,
|
||||
}
|
||||
return make_api_call(self, operation_name, kwarg)
|
||||
|
||||
|
||||
class Test_bedrock_prompt_encrypted_with_cmk:
|
||||
"""Test suite for the bedrock_prompt_encrypted_with_cmk check."""
|
||||
|
||||
@mock.patch(
|
||||
"botocore.client.BaseClient._make_api_call",
|
||||
new=lambda self, op, kwarg: make_api_call(self, op, kwarg),
|
||||
)
|
||||
def test_no_prompts(self):
|
||||
"""Test when no prompts exist."""
|
||||
from prowler.providers.aws.services.bedrock.bedrock_service import BedrockAgent
|
||||
|
||||
aws_provider = set_mocked_aws_provider(
|
||||
[AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1]
|
||||
)
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=aws_provider,
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.bedrock.bedrock_prompt_encrypted_with_cmk.bedrock_prompt_encrypted_with_cmk.bedrock_agent_client",
|
||||
new=BedrockAgent(aws_provider),
|
||||
),
|
||||
):
|
||||
from prowler.providers.aws.services.bedrock.bedrock_prompt_encrypted_with_cmk.bedrock_prompt_encrypted_with_cmk import (
|
||||
bedrock_prompt_encrypted_with_cmk,
|
||||
)
|
||||
|
||||
check = bedrock_prompt_encrypted_with_cmk()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 0
|
||||
|
||||
@mock.patch(
|
||||
"botocore.client.BaseClient._make_api_call",
|
||||
new=mock_make_api_call_with_cmk,
|
||||
)
|
||||
def test_prompt_encrypted_with_cmk(self):
|
||||
"""Test when a prompt is encrypted with a customer-managed KMS key."""
|
||||
from prowler.providers.aws.services.bedrock.bedrock_service import BedrockAgent
|
||||
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=aws_provider,
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.bedrock.bedrock_prompt_encrypted_with_cmk.bedrock_prompt_encrypted_with_cmk.bedrock_agent_client",
|
||||
new=BedrockAgent(aws_provider),
|
||||
),
|
||||
):
|
||||
from prowler.providers.aws.services.bedrock.bedrock_prompt_encrypted_with_cmk.bedrock_prompt_encrypted_with_cmk import (
|
||||
bedrock_prompt_encrypted_with_cmk,
|
||||
)
|
||||
|
||||
check = bedrock_prompt_encrypted_with_cmk()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
assert (
|
||||
result[0].status_extended
|
||||
== f"Bedrock Prompt {PROMPT_NAME} is encrypted with a customer-managed KMS key."
|
||||
)
|
||||
assert result[0].resource_id == PROMPT_ID
|
||||
assert result[0].resource_arn == PROMPT_ARN
|
||||
assert result[0].region == AWS_REGION_US_EAST_1
|
||||
|
||||
@mock.patch(
|
||||
"botocore.client.BaseClient._make_api_call",
|
||||
new=mock_make_api_call_without_cmk,
|
||||
)
|
||||
def test_prompt_not_encrypted_with_cmk(self):
|
||||
"""Test when a prompt is not encrypted with a customer-managed KMS key."""
|
||||
from prowler.providers.aws.services.bedrock.bedrock_service import BedrockAgent
|
||||
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=aws_provider,
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.bedrock.bedrock_prompt_encrypted_with_cmk.bedrock_prompt_encrypted_with_cmk.bedrock_agent_client",
|
||||
new=BedrockAgent(aws_provider),
|
||||
),
|
||||
):
|
||||
from prowler.providers.aws.services.bedrock.bedrock_prompt_encrypted_with_cmk.bedrock_prompt_encrypted_with_cmk import (
|
||||
bedrock_prompt_encrypted_with_cmk,
|
||||
)
|
||||
|
||||
check = bedrock_prompt_encrypted_with_cmk()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert (
|
||||
result[0].status_extended
|
||||
== f"Bedrock Prompt {PROMPT_NAME} is not encrypted with a customer-managed KMS key."
|
||||
)
|
||||
assert result[0].resource_id == PROMPT_ID
|
||||
assert result[0].resource_arn == PROMPT_ARN
|
||||
assert result[0].region == AWS_REGION_US_EAST_1
|
||||
@@ -406,12 +406,14 @@ class TestBedrockPromptPagination:
|
||||
regional_client.get_paginator.assert_called_once_with("list_prompts")
|
||||
paginator.paginate.assert_called_once()
|
||||
|
||||
def test_list_prompts_ignores_audit_resources_filter(self):
|
||||
"""Prompt collection is region-scoped and must ignore audit_resources."""
|
||||
def test_list_prompts_filters_audit_resources(self):
|
||||
"""Prompt collection must honor audit_resources when resource ARNs are scoped."""
|
||||
audit_info = MagicMock()
|
||||
audit_info.audited_partition = "aws"
|
||||
audit_info.audited_account = "123456789012"
|
||||
audit_info.audit_resources = ["arn:aws:s3:::unrelated-resource"]
|
||||
audit_info.audit_resources = [
|
||||
"arn:aws:bedrock:us-east-1:123456789012:prompt/prompt-1"
|
||||
]
|
||||
|
||||
regional_client = MagicMock()
|
||||
regional_client.region = "us-east-1"
|
||||
@@ -424,7 +426,12 @@ class TestBedrockPromptPagination:
|
||||
"id": "prompt-1",
|
||||
"name": "prompt-name-1",
|
||||
"arn": "arn:aws:bedrock:us-east-1:123456789012:prompt/prompt-1",
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "prompt-2",
|
||||
"name": "prompt-name-2",
|
||||
"arn": "arn:aws:bedrock:us-east-1:123456789012:prompt/prompt-2",
|
||||
},
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -438,6 +445,14 @@ class TestBedrockPromptPagination:
|
||||
bedrock_agent_service._list_prompts(regional_client)
|
||||
|
||||
assert len(bedrock_agent_service.prompts) == 1
|
||||
assert (
|
||||
"arn:aws:bedrock:us-east-1:123456789012:prompt/prompt-1"
|
||||
in bedrock_agent_service.prompts
|
||||
)
|
||||
assert (
|
||||
"arn:aws:bedrock:us-east-1:123456789012:prompt/prompt-2"
|
||||
not in bedrock_agent_service.prompts
|
||||
)
|
||||
assert "us-east-1" in bedrock_agent_service.prompt_scanned_regions
|
||||
|
||||
def test_list_prompts_error_does_not_mark_region_scanned(self):
|
||||
|
||||
+8
-2
@@ -2,12 +2,18 @@
|
||||
|
||||
All notable changes to the **Prowler UI** are documented in this file.
|
||||
|
||||
## [1.26.0] (Prowler UNRELEASED)
|
||||
## [1.26.0] (Prowler v5.26.0)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
- ASD Essential Eight compliance framework support [(#11071)](https://github.com/prowler-cloud/prowler/pull/11071)
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
- Standardized "Providers" wording across UI and documentation, replacing legacy "Cloud Providers" / "Accounts" / "Account Groups" copy [(#10971)](https://github.com/prowler-cloud/prowler/pull/10971)
|
||||
- Finding detail drawer now labels remediation actions from finding-level recommendation URLs by destination: "View CVE", "View in Prowler Hub", "View Advisory", or "View Reference", while keeping URL-only remediation cards labeled [(#10853)](https://github.com/prowler-cloud/prowler/pull/10853)
|
||||
- Finding detail drawer reorganized: status-colored banner below the resource info, dedicated Remediation tab, renamed "Findings for this resource" tab, and inline View Resource link next to the resource UID [(#11091)](https://github.com/prowler-cloud/prowler/pull/11091)
|
||||
- ThreatScore compliance views: canonical pillar order across all charts and the accordion, clickable pillars on `/compliance` that anchor the detail page, Top Failed Sections always shows the full pillar set, and donut tooltip now triggers on every segment [(#10975)](https://github.com/prowler-cloud/prowler/pull/10975)
|
||||
|
||||
---
|
||||
|
||||
@@ -47,7 +53,7 @@ All notable changes to the **Prowler UI** are documented in this file.
|
||||
### 🔄 Changed
|
||||
|
||||
- Redesign compliance page, client-side search for compliance frameworks, compact scan selector trigger, enhanced compliance cards [(#10767)](https://github.com/prowler-cloud/prowler/pull/10767)
|
||||
- Allows tenant owners to expel users from their organizations [(#10787)](https://github.com/prowler-cloud/prowler/pull/10787)
|
||||
- Allows tenant owners to expel users from their organizations [(#10787)](https://github.com/prowler-cloud/prowler/pull/10787)
|
||||
- Shared filter dropdowns now support local option search and auto-scroll to the first visible match across table and provider filters [(#10859)](https://github.com/prowler-cloud/prowler/pull/10859)
|
||||
- Backward-compatibility middleware redirect from `/sign-up?invitation_token=…` to `/invitation/accept?invitation_token=…`; new invitation emails use `/invitation/accept` directly [(#10797)](https://github.com/prowler-cloud/prowler/pull/10797)
|
||||
- Mutelist improvements: table now supports name/reason search and visual count badges for finding targets [(#10846)](https://github.com/prowler-cloud/prowler/pull/10846)
|
||||
|
||||
@@ -139,6 +139,7 @@ interface FindingGroupResourceAttributes {
|
||||
resource: ResourceInfo;
|
||||
provider: ProviderInfo;
|
||||
status: string;
|
||||
status_extended?: string;
|
||||
muted?: boolean;
|
||||
delta?: string | null;
|
||||
severity: string;
|
||||
@@ -187,6 +188,7 @@ export function adaptFindingGroupResourcesResponse(
|
||||
region: item.attributes.resource?.region || "-",
|
||||
severity: (item.attributes.severity || "informational") as Severity,
|
||||
status: item.attributes.status,
|
||||
statusExtended: item.attributes.status_extended,
|
||||
delta: item.attributes.delta || null,
|
||||
isMuted: item.attributes.muted ?? item.attributes.status === "MUTED",
|
||||
mutedReason: item.attributes.muted_reason || undefined,
|
||||
|
||||
@@ -174,7 +174,7 @@ describe("AlertsManager", () => {
|
||||
expect(findingsLink.closest("[data-variant='link']")).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "here." })).toHaveAttribute(
|
||||
"href",
|
||||
"https://docs.prowler.com/user-guide/tutorials/prowler-app",
|
||||
"https://docs.prowler.com/user-guide/tutorials/prowler-app-alerts",
|
||||
);
|
||||
expect(screen.getByText(/get notified when findings match/i)).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -42,6 +42,7 @@ interface ComplianceDetailSearchParams {
|
||||
complianceId: string;
|
||||
version?: string;
|
||||
scanId?: string;
|
||||
section?: string;
|
||||
"filter[region__in]"?: string;
|
||||
"filter[cis_profile_level]"?: string;
|
||||
page?: string;
|
||||
@@ -57,7 +58,7 @@ export default async function ComplianceDetail({
|
||||
}) {
|
||||
const { compliancetitle } = await params;
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const { complianceId, version, scanId } = resolvedSearchParams;
|
||||
const { complianceId, version, scanId, section } = resolvedSearchParams;
|
||||
const regionFilter = resolvedSearchParams["filter[region__in]"];
|
||||
const cisProfileFilter = resolvedSearchParams["filter[cis_profile_level]"];
|
||||
const logoPath = getComplianceIcon(compliancetitle);
|
||||
@@ -225,6 +226,7 @@ export default async function ComplianceDetail({
|
||||
filter={cisProfileFilter}
|
||||
attributesData={attributesData}
|
||||
threatScoreData={threatScoreData}
|
||||
targetSection={section}
|
||||
/>
|
||||
</Suspense>
|
||||
</ContentLayout>
|
||||
@@ -238,6 +240,7 @@ const SSRComplianceContent = async ({
|
||||
filter,
|
||||
attributesData,
|
||||
threatScoreData,
|
||||
targetSection,
|
||||
}: {
|
||||
complianceId: string;
|
||||
scanId: string;
|
||||
@@ -248,6 +251,7 @@ const SSRComplianceContent = async ({
|
||||
overallScore: number;
|
||||
sectionScores: Record<string, number>;
|
||||
} | null;
|
||||
targetSection?: string;
|
||||
}) => {
|
||||
const requirementsData = await getComplianceRequirements({
|
||||
complianceId,
|
||||
@@ -288,6 +292,21 @@ const SSRComplianceContent = async ({
|
||||
const accordionItems = mapper.toAccordionItems(data, scanId);
|
||||
const topFailedResult = mapper.getTopFailedSections(data);
|
||||
|
||||
// Resolve which accordion key matches the requested ?section= so we can
|
||||
// auto-expand it on first render. Each mapper builds keys as
|
||||
// `${framework.name}-${category.name}`; rebuild the exact candidates here
|
||||
// to avoid suffix collisions across frameworks or category names.
|
||||
const initialExpandedKeys: string[] = [];
|
||||
if (targetSection) {
|
||||
const candidates = new Set(
|
||||
data.map((f: Framework) => `${f.name}-${targetSection}`),
|
||||
);
|
||||
const match = accordionItems.find((item) => candidates.has(item.key));
|
||||
if (match) {
|
||||
initialExpandedKeys.push(match.key);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
{/* Charts section */}
|
||||
@@ -315,6 +334,7 @@ const SSRComplianceContent = async ({
|
||||
<TopFailedSectionsCard
|
||||
sections={topFailedResult.items}
|
||||
dataType={topFailedResult.type}
|
||||
prepopulated={topFailedResult.prepopulated}
|
||||
/>
|
||||
{/* <SectionsFailureRateCard categories={categoryHeatmapData} /> */}
|
||||
</div>
|
||||
@@ -323,7 +343,8 @@ const SSRComplianceContent = async ({
|
||||
<ClientAccordionWrapper
|
||||
hideExpandButton={complianceId.includes("mitre_attack")}
|
||||
items={accordionItems}
|
||||
defaultExpandedKeys={[]}
|
||||
defaultExpandedKeys={initialExpandedKeys}
|
||||
scrollToKey={initialExpandedKeys[0]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { Accordion, AccordionItemProps } from "@/components/ui";
|
||||
@@ -9,10 +9,12 @@ export const ClientAccordionWrapper = ({
|
||||
items,
|
||||
defaultExpandedKeys,
|
||||
hideExpandButton = false,
|
||||
scrollToKey,
|
||||
}: {
|
||||
items: AccordionItemProps[];
|
||||
defaultExpandedKeys: string[];
|
||||
hideExpandButton?: boolean;
|
||||
scrollToKey?: string;
|
||||
}) => {
|
||||
const [selectedKeys, setSelectedKeys] =
|
||||
useState<string[]>(defaultExpandedKeys);
|
||||
@@ -56,8 +58,33 @@ export const ClientAccordionWrapper = ({
|
||||
setSelectedKeys(keys);
|
||||
};
|
||||
|
||||
// Tracks the last `scrollToKey` we already scrolled to so the inline
|
||||
// callback ref below stays idempotent. Without this flag React would
|
||||
// re-fire the scroll on every state change (Expand all, row toggle,
|
||||
// parent re-render) because the callback ref's identity changes per
|
||||
// render and React re-attaches it.
|
||||
const lastScrolledKeyRef = useRef<string | null>(null);
|
||||
|
||||
const containerRef = (node: HTMLDivElement | null) => {
|
||||
if (!node || !scrollToKey) return;
|
||||
if (lastScrolledKeyRef.current === scrollToKey) return;
|
||||
lastScrolledKeyRef.current = scrollToKey;
|
||||
// Two nested rAFs: the first lets the accordion children commit to
|
||||
// the DOM, the second lands after the browser has run a layout pass
|
||||
// so HeroUI's framer-motion expand has settled enough for
|
||||
// scrollIntoView to read a stable offset.
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
const target = node.querySelector(
|
||||
`[data-accordion-key="${CSS.escape(scrollToKey)}"]`,
|
||||
);
|
||||
target?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div ref={containerRef}>
|
||||
{!hideExpandButton && (
|
||||
<div className="text-text-neutral-tertiary hover:text-text-neutral-primary mt-[-16px] flex justify-end text-xs font-medium transition-colors">
|
||||
<Button
|
||||
@@ -75,7 +102,6 @@ export const ClientAccordionWrapper = ({
|
||||
items={items}
|
||||
variant="light"
|
||||
selectionMode="multiple"
|
||||
defaultExpandedKeys={defaultExpandedKeys}
|
||||
selectedKeys={selectedKeys}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
/>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
getScoreTextClass,
|
||||
SCORE_COLORS,
|
||||
} from "@/lib/compliance/score-utils";
|
||||
import { getOrderedPillars } from "@/lib/compliance/threatscore-pillars";
|
||||
|
||||
export interface ThreatScoreBreakdownCardProps {
|
||||
overallScore: number;
|
||||
@@ -25,17 +26,17 @@ export function ThreatScoreBreakdownCard({
|
||||
const scoreLevel = getScoreLevel(overallScore);
|
||||
const scoreColor = SCORE_COLORS[scoreLevel];
|
||||
|
||||
// Convert section scores to tooltip data for the radial chart
|
||||
const tooltipData = Object.entries(sectionScores).map(([name, value]) => ({
|
||||
name,
|
||||
value,
|
||||
color: SCORE_COLORS[getScoreLevel(value)],
|
||||
}));
|
||||
const pillars = getOrderedPillars(sectionScores);
|
||||
|
||||
// Sort sections by score (lowest first to highlight areas needing attention)
|
||||
const sortedSections = Object.entries(sectionScores).sort(
|
||||
([, a], [, b]) => a - b,
|
||||
);
|
||||
// Tooltip preserves canonical order so the radial chart hover panel
|
||||
// mirrors the breakdown list below it.
|
||||
const tooltipData = pillars
|
||||
.filter((p) => p.hasData)
|
||||
.map(({ name, score }) => ({
|
||||
name,
|
||||
value: score,
|
||||
color: SCORE_COLORS[getScoreLevel(score)],
|
||||
}));
|
||||
|
||||
return (
|
||||
<Card variant="base" className="flex h-full w-full flex-col">
|
||||
@@ -76,19 +77,23 @@ export function ThreatScoreBreakdownCard({
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{sortedSections.map(([section, score]) => (
|
||||
<div key={section} className="space-y-0.5">
|
||||
{pillars.map(({ name, score, hasData }) => (
|
||||
<div key={name} className="space-y-0.5">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-default-700 truncate pr-2">
|
||||
{section}
|
||||
</span>
|
||||
<span className={`font-semibold ${getScoreTextClass(score)}`}>
|
||||
{score.toFixed(1)}%
|
||||
<span className="text-default-700 truncate pr-2">{name}</span>
|
||||
<span
|
||||
className={`font-semibold ${
|
||||
hasData
|
||||
? getScoreTextClass(score)
|
||||
: "text-text-neutral-tertiary"
|
||||
}`}
|
||||
>
|
||||
{hasData ? `${score.toFixed(1)}%` : "—"}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
aria-label={`${section} score`}
|
||||
value={score}
|
||||
aria-label={`${name} score`}
|
||||
value={hasData ? score : 0}
|
||||
color={getScoreColor(score)}
|
||||
size="md"
|
||||
className="w-full"
|
||||
|
||||
@@ -12,13 +12,17 @@ import {
|
||||
interface TopFailedSectionsCardProps {
|
||||
sections: FailedSection[];
|
||||
dataType?: TopFailedDataType;
|
||||
// True when `sections` already covers every relevant category (e.g.
|
||||
// ThreatScore's canonical pillars zero-filled). Renders the supplied list
|
||||
// as-is instead of falling back to severity placeholders on zero totals.
|
||||
prepopulated?: boolean;
|
||||
}
|
||||
|
||||
export function TopFailedSectionsCard({
|
||||
sections,
|
||||
dataType = TOP_FAILED_DATA_TYPE.SECTIONS,
|
||||
prepopulated = false,
|
||||
}: TopFailedSectionsCardProps) {
|
||||
// Transform FailedSection[] to BarDataPoint[]
|
||||
const total = sections.reduce((sum, section) => sum + section.total, 0);
|
||||
|
||||
const barData: BarDataPoint[] = sections.map((section) => ({
|
||||
@@ -39,7 +43,10 @@ export function TopFailedSectionsCard({
|
||||
<CardTitle>{title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-1 items-center justify-start">
|
||||
<HorizontalBarChart data={barData} />
|
||||
<HorizontalBarChart
|
||||
data={barData}
|
||||
useSeverityEmptyState={!prepopulated}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
+226
@@ -0,0 +1,226 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
// `CustomLink` re-imports the `@/lib` barrel which transitively pulls in
|
||||
// `next-auth` (server-only). Stub it with a plain anchor — we only need
|
||||
// the `<a>` semantics here so the regex/extraction tests can assert on
|
||||
// `href` and accessible name.
|
||||
vi.mock("@/components/ui/custom/custom-link", () => ({
|
||||
CustomLink: ({ href, children }: { href: string; children: ReactNode }) => (
|
||||
<a href={href}>{children}</a>
|
||||
),
|
||||
}));
|
||||
|
||||
import {
|
||||
type ASDEssentialEightRequirement,
|
||||
type Requirement,
|
||||
REQUIREMENT_STATUS,
|
||||
} from "@/types/compliance";
|
||||
|
||||
import { ASDEssentialEightCustomDetails } from "./asd-essential-eight-details";
|
||||
|
||||
const fullRequirement: ASDEssentialEightRequirement = {
|
||||
name: "E8-PA-1",
|
||||
description: "Apply patches to internet-facing applications.",
|
||||
status: REQUIREMENT_STATUS.PASS,
|
||||
pass: 1,
|
||||
fail: 0,
|
||||
manual: 0,
|
||||
check_ids: ["check_one"],
|
||||
maturity_level: "ML1",
|
||||
assessment_status: "Automated",
|
||||
cloud_applicability: "full",
|
||||
mitigated_threats: ["T1190", "T1059"],
|
||||
implementation_notes: "Use SSM Patch Manager for AWS workloads.",
|
||||
rationale_statement: "Unpatched apps are commonly exploited.",
|
||||
impact_statement: "Increases blast radius of public-facing CVEs.",
|
||||
remediation_procedure: "Run **patch baseline** weekly.",
|
||||
audit_procedure: "Verify *baseline compliance*.",
|
||||
additional_information: "Refer to internal SOPs.",
|
||||
references: "https://example.com/a, https://example.com/b",
|
||||
};
|
||||
|
||||
const emptyRequirement: Requirement = {
|
||||
name: "E8-EMPTY",
|
||||
description: "",
|
||||
status: REQUIREMENT_STATUS.MANUAL,
|
||||
pass: 0,
|
||||
fail: 0,
|
||||
manual: 1,
|
||||
check_ids: [],
|
||||
};
|
||||
|
||||
describe("ASDEssentialEightCustomDetails", () => {
|
||||
describe("with a fully populated requirement", () => {
|
||||
it("renders every textual section", () => {
|
||||
render(<ASDEssentialEightCustomDetails requirement={fullRequirement} />);
|
||||
|
||||
expect(screen.getByText("Description")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Apply patches to internet-facing applications."),
|
||||
).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText("Implementation Notes")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Use SSM Patch Manager for AWS workloads."),
|
||||
).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText("Rationale Statement")).toBeInTheDocument();
|
||||
expect(screen.getByText("Impact Statement")).toBeInTheDocument();
|
||||
expect(screen.getByText("Additional Information")).toBeInTheDocument();
|
||||
expect(screen.getByText("Refer to internal SOPs.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the three classification badges with their values", () => {
|
||||
render(<ASDEssentialEightCustomDetails requirement={fullRequirement} />);
|
||||
|
||||
expect(screen.getByText("Maturity Level:")).toBeInTheDocument();
|
||||
expect(screen.getByText("ML1")).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText("Assessment:")).toBeInTheDocument();
|
||||
expect(screen.getByText("Automated")).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText("Cloud Applicability:")).toBeInTheDocument();
|
||||
expect(screen.getByText("full")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render invalid ASD classification values", () => {
|
||||
render(
|
||||
<ASDEssentialEightCustomDetails
|
||||
requirement={{
|
||||
...fullRequirement,
|
||||
maturity_level: "ML4",
|
||||
assessment_status: "Partially automated",
|
||||
cloud_applicability: "hybrid",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText("Maturity Level:")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Assessment:")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("Cloud Applicability:"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders mitigated threats as individual chips", () => {
|
||||
render(<ASDEssentialEightCustomDetails requirement={fullRequirement} />);
|
||||
|
||||
expect(screen.getByText("Mitigated Threats")).toBeInTheDocument();
|
||||
expect(screen.getByText("T1190")).toBeInTheDocument();
|
||||
expect(screen.getByText("T1059")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders Remediation and Audit procedures as markdown", () => {
|
||||
render(<ASDEssentialEightCustomDetails requirement={fullRequirement} />);
|
||||
|
||||
// The markdown renderer transforms `**patch baseline**` into a <strong>
|
||||
// and `*baseline compliance*` into an <em>. Asserting on the rendered
|
||||
// tags is what makes this a behavioral test rather than a string grep.
|
||||
expect(screen.getByText("Remediation Procedure")).toBeInTheDocument();
|
||||
expect(screen.getByText("patch baseline").tagName).toBe("STRONG");
|
||||
|
||||
expect(screen.getByText("Audit Procedure")).toBeInTheDocument();
|
||||
expect(screen.getByText("baseline compliance").tagName).toBe("EM");
|
||||
});
|
||||
|
||||
it("extracts every URL from the comma-separated References field", () => {
|
||||
render(<ASDEssentialEightCustomDetails requirement={fullRequirement} />);
|
||||
|
||||
expect(screen.getByText("References")).toBeInTheDocument();
|
||||
const linkA = screen.getByRole("link", {
|
||||
name: "https://example.com/a",
|
||||
});
|
||||
const linkB = screen.getByRole("link", {
|
||||
name: "https://example.com/b",
|
||||
});
|
||||
expect(linkA).toHaveAttribute("href", "https://example.com/a");
|
||||
expect(linkB).toHaveAttribute("href", "https://example.com/b");
|
||||
});
|
||||
|
||||
it("preserves http:// references (regex must not silently drop plain HTTP)", () => {
|
||||
render(
|
||||
<ASDEssentialEightCustomDetails
|
||||
requirement={{
|
||||
...fullRequirement,
|
||||
references:
|
||||
"http://insecure.example.com/x https://secure.example.com/y",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("link", { name: "http://insecure.example.com/x" }),
|
||||
).toHaveAttribute("href", "http://insecure.example.com/x");
|
||||
expect(
|
||||
screen.getByRole("link", { name: "https://secure.example.com/y" }),
|
||||
).toHaveAttribute("href", "https://secure.example.com/y");
|
||||
});
|
||||
});
|
||||
|
||||
describe("with an empty requirement", () => {
|
||||
it("renders nothing inside the container when every optional field is missing", () => {
|
||||
const { container } = render(
|
||||
<ASDEssentialEightCustomDetails requirement={emptyRequirement} />,
|
||||
);
|
||||
|
||||
// No section headings should be rendered for an empty requirement.
|
||||
for (const heading of [
|
||||
"Description",
|
||||
"Implementation Notes",
|
||||
"Rationale Statement",
|
||||
"Impact Statement",
|
||||
"Remediation Procedure",
|
||||
"Audit Procedure",
|
||||
"Additional Information",
|
||||
"Mitigated Threats",
|
||||
"References",
|
||||
]) {
|
||||
expect(screen.queryByText(heading)).not.toBeInTheDocument();
|
||||
}
|
||||
|
||||
// No badges either.
|
||||
for (const label of [
|
||||
"Maturity Level:",
|
||||
"Assessment:",
|
||||
"Cloud Applicability:",
|
||||
]) {
|
||||
expect(screen.queryByText(label)).not.toBeInTheDocument();
|
||||
}
|
||||
|
||||
// The outer container still exists (an empty flex column) but it
|
||||
// shouldn't carry any rendered children.
|
||||
const outer = container.firstElementChild as HTMLElement | null;
|
||||
expect(outer).not.toBeNull();
|
||||
expect(outer?.children.length).toBe(1); // only the empty badge container
|
||||
});
|
||||
|
||||
it("ignores a non-string References field (no broken link rendered)", () => {
|
||||
render(
|
||||
<ASDEssentialEightCustomDetails
|
||||
requirement={{
|
||||
...emptyRequirement,
|
||||
references: undefined,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText("References")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("link")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("ignores a non-string-array `mitigated_threats` field", () => {
|
||||
render(
|
||||
<ASDEssentialEightCustomDetails
|
||||
requirement={{
|
||||
...emptyRequirement,
|
||||
mitigated_threats: [{ not: "a string" }],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText("Mitigated Threats")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
import ReactMarkdown from "react-markdown";
|
||||
|
||||
import { CustomLink } from "@/components/ui/custom/custom-link";
|
||||
import {
|
||||
isASDAssessmentStatus,
|
||||
isASDCloudApplicability,
|
||||
isASDMaturityLevel,
|
||||
type Requirement,
|
||||
} from "@/types/compliance";
|
||||
|
||||
import {
|
||||
ComplianceBadge,
|
||||
ComplianceBadgeContainer,
|
||||
ComplianceChipContainer,
|
||||
ComplianceDetailContainer,
|
||||
ComplianceDetailSection,
|
||||
ComplianceDetailText,
|
||||
} from "./shared-components";
|
||||
|
||||
interface ASDEssentialEightDetailsProps {
|
||||
requirement: Requirement;
|
||||
}
|
||||
|
||||
// Each requirement's References field is a single URL or a comma/space
|
||||
// separated list of URLs. The regex matches both http:// and https:// so
|
||||
// plain-http references aren't silently dropped.
|
||||
const URL_REGEX = /https?:\/\/[^\s,]+/g;
|
||||
|
||||
const extractUrls = (references: unknown): string[] => {
|
||||
if (typeof references !== "string") return [];
|
||||
return references.match(URL_REGEX) ?? [];
|
||||
};
|
||||
|
||||
const isNonEmptyString = (value: unknown): value is string =>
|
||||
typeof value === "string" && value.length > 0;
|
||||
|
||||
const isStringArray = (value: unknown): value is string[] =>
|
||||
Array.isArray(value) && value.every((item) => typeof item === "string");
|
||||
|
||||
export const ASDEssentialEightCustomDetails = ({
|
||||
requirement,
|
||||
}: ASDEssentialEightDetailsProps) => {
|
||||
const {
|
||||
description,
|
||||
implementation_notes,
|
||||
maturity_level,
|
||||
assessment_status,
|
||||
cloud_applicability,
|
||||
mitigated_threats,
|
||||
rationale_statement,
|
||||
impact_statement,
|
||||
remediation_procedure,
|
||||
audit_procedure,
|
||||
additional_information,
|
||||
references,
|
||||
} = requirement;
|
||||
|
||||
const referenceUrls = extractUrls(references);
|
||||
const maturityLevel = isASDMaturityLevel(maturity_level)
|
||||
? maturity_level
|
||||
: undefined;
|
||||
const assessmentStatus = isASDAssessmentStatus(assessment_status)
|
||||
? assessment_status
|
||||
: undefined;
|
||||
const cloudApplicability = isASDCloudApplicability(cloud_applicability)
|
||||
? cloud_applicability
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<ComplianceDetailContainer>
|
||||
{description && (
|
||||
<ComplianceDetailSection title="Description">
|
||||
<ComplianceDetailText>{description}</ComplianceDetailText>
|
||||
</ComplianceDetailSection>
|
||||
)}
|
||||
|
||||
{isNonEmptyString(implementation_notes) && (
|
||||
<ComplianceDetailSection title="Implementation Notes">
|
||||
<ComplianceDetailText>{implementation_notes}</ComplianceDetailText>
|
||||
</ComplianceDetailSection>
|
||||
)}
|
||||
|
||||
<ComplianceBadgeContainer>
|
||||
{maturityLevel && (
|
||||
<ComplianceBadge
|
||||
label="Maturity Level"
|
||||
value={maturityLevel}
|
||||
color="purple"
|
||||
/>
|
||||
)}
|
||||
|
||||
{assessmentStatus && (
|
||||
<ComplianceBadge
|
||||
label="Assessment"
|
||||
value={assessmentStatus}
|
||||
color="blue"
|
||||
/>
|
||||
)}
|
||||
|
||||
{cloudApplicability && (
|
||||
<ComplianceBadge
|
||||
label="Cloud Applicability"
|
||||
value={cloudApplicability}
|
||||
color="orange"
|
||||
/>
|
||||
)}
|
||||
</ComplianceBadgeContainer>
|
||||
|
||||
{/* `isStringArray` narrows the index-signature union to string[], so no cast is needed. `ComplianceChipContainer` returns null on empty arrays, so no length check is needed here either. */}
|
||||
{isStringArray(mitigated_threats) && (
|
||||
<ComplianceChipContainer
|
||||
title="Mitigated Threats"
|
||||
items={mitigated_threats}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isNonEmptyString(rationale_statement) && (
|
||||
<ComplianceDetailSection title="Rationale Statement">
|
||||
<ComplianceDetailText>{rationale_statement}</ComplianceDetailText>
|
||||
</ComplianceDetailSection>
|
||||
)}
|
||||
|
||||
{isNonEmptyString(impact_statement) && (
|
||||
<ComplianceDetailSection title="Impact Statement">
|
||||
<ComplianceDetailText>{impact_statement}</ComplianceDetailText>
|
||||
</ComplianceDetailSection>
|
||||
)}
|
||||
|
||||
{isNonEmptyString(remediation_procedure) && (
|
||||
<ComplianceDetailSection title="Remediation Procedure">
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none">
|
||||
<ReactMarkdown>{remediation_procedure}</ReactMarkdown>
|
||||
</div>
|
||||
</ComplianceDetailSection>
|
||||
)}
|
||||
|
||||
{isNonEmptyString(audit_procedure) && (
|
||||
<ComplianceDetailSection title="Audit Procedure">
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none">
|
||||
<ReactMarkdown>{audit_procedure}</ReactMarkdown>
|
||||
</div>
|
||||
</ComplianceDetailSection>
|
||||
)}
|
||||
|
||||
{isNonEmptyString(additional_information) && (
|
||||
<ComplianceDetailSection title="Additional Information">
|
||||
<ComplianceDetailText className="whitespace-pre-wrap">
|
||||
{additional_information}
|
||||
</ComplianceDetailText>
|
||||
</ComplianceDetailSection>
|
||||
)}
|
||||
|
||||
{referenceUrls.length > 0 && (
|
||||
<ComplianceDetailSection title="References">
|
||||
<div className="flex flex-col gap-1">
|
||||
{referenceUrls.map((url) => (
|
||||
// URLs are unique within this list, so they outperform the
|
||||
// positional index as a React key (avoids reconciliation
|
||||
// glitches if the order ever shifts).
|
||||
<div key={url}>
|
||||
<CustomLink href={url}>{url}</CustomLink>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ComplianceDetailSection>
|
||||
)}
|
||||
</ComplianceDetailContainer>
|
||||
);
|
||||
};
|
||||
@@ -19,6 +19,10 @@ import {
|
||||
getScoreIndicatorClass,
|
||||
getScoreTextClass,
|
||||
} from "@/lib/compliance/score-utils";
|
||||
import {
|
||||
getOrderedPillars,
|
||||
THREATSCORE_SECTION_PARAM,
|
||||
} from "@/lib/compliance/threatscore-pillars";
|
||||
import {
|
||||
downloadComplianceCsv,
|
||||
downloadComplianceReportPdf,
|
||||
@@ -46,7 +50,7 @@ export const ThreatScoreBadge = ({
|
||||
|
||||
const complianceId = `prowler_threatscore_${provider.toLowerCase()}`;
|
||||
|
||||
const handleCardClick = () => {
|
||||
const buildDetailHref = (section?: string) => {
|
||||
const title = "ProwlerThreatScore";
|
||||
const version = "1.0";
|
||||
const formattedTitleForUrl = encodeURIComponent(title);
|
||||
@@ -62,9 +66,23 @@ export const ThreatScoreBadge = ({
|
||||
params.set("filter[region__in]", regionFilter);
|
||||
}
|
||||
|
||||
router.push(`${path}?${params.toString()}`);
|
||||
if (section) {
|
||||
params.set(THREATSCORE_SECTION_PARAM, section);
|
||||
}
|
||||
|
||||
return `${path}?${params.toString()}`;
|
||||
};
|
||||
|
||||
const handleCardClick = () => {
|
||||
router.push(buildDetailHref());
|
||||
};
|
||||
|
||||
const handlePillarClick = (section: string) => {
|
||||
router.push(buildDetailHref(section));
|
||||
};
|
||||
|
||||
const pillars = getOrderedPillars(sectionScores);
|
||||
|
||||
const handleDownloadCsv = async () => {
|
||||
if (isDownloadingCsv) return;
|
||||
setIsDownloadingCsv(true);
|
||||
@@ -113,31 +131,45 @@ export const ThreatScoreBadge = ({
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Pillar breakdown — always visible */}
|
||||
{sectionScores && Object.keys(sectionScores).length > 0 && (
|
||||
{/* Pillar breakdown — always visible, in canonical order */}
|
||||
{pillars.length > 0 && (
|
||||
<div className="border-border-neutral-secondary flex-1 space-y-2 border-t pt-3 lg:border-t-0 lg:border-l lg:pt-0 lg:pl-6">
|
||||
{Object.entries(sectionScores)
|
||||
.sort(([, a], [, b]) => a - b)
|
||||
.map(([section, sectionScore]) => (
|
||||
<div key={section} className="flex items-center gap-2 text-xs">
|
||||
<span className="text-text-neutral-secondary w-1/3 min-w-0 shrink-0 truncate lg:w-1/4">
|
||||
{section}
|
||||
</span>
|
||||
<Progress
|
||||
aria-label={`${section} score`}
|
||||
value={sectionScore}
|
||||
className="border-border-neutral-secondary h-2 min-w-16 flex-1 border"
|
||||
indicatorClassName={getScoreIndicatorClass(
|
||||
getScoreColor(sectionScore),
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={`w-12 shrink-0 text-right font-medium ${getScoreTextClass(sectionScore)}`}
|
||||
>
|
||||
{sectionScore.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{pillars.map(({ name, score: sectionScore, hasData }) => (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
onClick={() => hasData && handlePillarClick(name)}
|
||||
disabled={!hasData}
|
||||
aria-disabled={!hasData}
|
||||
aria-label={
|
||||
hasData
|
||||
? `Open ${name} details`
|
||||
: `${name} (no data for this scan)`
|
||||
}
|
||||
className="hover:bg-bg-neutral-secondary focus-visible:ring-border-neutral-primary -mx-1 flex w-full items-center gap-2 rounded-md px-1 py-0.5 text-left text-xs transition-colors focus:outline-none focus-visible:ring-2 enabled:cursor-pointer disabled:cursor-default disabled:opacity-60"
|
||||
>
|
||||
<span className="text-text-neutral-secondary w-1/3 min-w-0 shrink-0 truncate lg:w-1/4">
|
||||
{name}
|
||||
</span>
|
||||
<Progress
|
||||
aria-label={`${name} score`}
|
||||
value={hasData ? sectionScore : 0}
|
||||
className="border-border-neutral-secondary h-2 min-w-16 flex-1 border"
|
||||
indicatorClassName={getScoreIndicatorClass(
|
||||
getScoreColor(sectionScore),
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={`w-12 shrink-0 text-right font-medium ${
|
||||
hasData
|
||||
? getScoreTextClass(sectionScore)
|
||||
: "text-text-neutral-tertiary"
|
||||
}`}
|
||||
>
|
||||
{hasData ? `${sectionScore.toFixed(1)}%` : "—"}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
+15
-27
@@ -288,7 +288,8 @@ vi.mock("@/components/ui/entities/date-with-time", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/entities/entity-info", () => ({
|
||||
EntityInfo: () => null,
|
||||
EntityInfo: ({ idAction }: { idAction?: ReactNode }) =>
|
||||
idAction ? <span data-testid="entity-id-action">{idAction}</span> : null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/table", () => ({
|
||||
@@ -427,7 +428,7 @@ const mockFinding: ResourceDrawerFinding = {
|
||||
};
|
||||
|
||||
describe("ResourceDetailDrawerContent — resource navigation", () => {
|
||||
it("should render a View Resource link below the resource actions menu", () => {
|
||||
it("should render a View Resource link inline next to the resource UID", () => {
|
||||
// Given
|
||||
render(
|
||||
<ResourceDetailDrawerContent
|
||||
@@ -448,9 +449,6 @@ describe("ResourceDetailDrawerContent — resource navigation", () => {
|
||||
const viewResourceLink = screen.getByRole("link", {
|
||||
name: "View Resource",
|
||||
});
|
||||
const resourceActionsMenu = screen.getByRole("menu", {
|
||||
name: "Resource actions",
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(viewResourceLink).toHaveAttribute(
|
||||
@@ -459,10 +457,6 @@ describe("ResourceDetailDrawerContent — resource navigation", () => {
|
||||
);
|
||||
expect(viewResourceLink).toHaveAttribute("target", "_blank");
|
||||
expect(viewResourceLink).toHaveAttribute("rel", "noopener noreferrer");
|
||||
expect(
|
||||
resourceActionsMenu.compareDocumentPosition(viewResourceLink) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).not.toBe(0);
|
||||
});
|
||||
});
|
||||
const mockResourceRow: FindingResourceRow = {
|
||||
@@ -920,8 +914,8 @@ describe("ResourceDetailDrawerContent — CVE recommendation button", () => {
|
||||
// Fix 5 & 6: Risk section has danger styling, sections have separators and bigger headings
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("ResourceDetailDrawerContent — Fix 5 & 6: Risk section styling", () => {
|
||||
it("should wrap the Risk section in a Card component (data-slot='card')", () => {
|
||||
describe("ResourceDetailDrawerContent — Risk section styling", () => {
|
||||
it("should render the Risk section with a vertical accent border (no danger card)", () => {
|
||||
// Given
|
||||
const { container } = render(
|
||||
<ResourceDetailDrawerContent
|
||||
@@ -938,16 +932,16 @@ describe("ResourceDetailDrawerContent — Fix 5 & 6: Risk section styling", () =
|
||||
/>,
|
||||
);
|
||||
|
||||
// When — find a Card with variant="danger" that contains the Risk label
|
||||
const dangerCards = Array.from(
|
||||
container.querySelectorAll('[data-variant="danger"]'),
|
||||
);
|
||||
const riskCard = dangerCards.find((el) =>
|
||||
el.textContent?.includes("Risk:"),
|
||||
// When — find the Risk heading and walk up to the section wrapper
|
||||
const riskHeading = Array.from(container.querySelectorAll("span")).find(
|
||||
(el) => el.textContent?.trim() === "Risk:",
|
||||
);
|
||||
const riskSection = riskHeading?.parentElement;
|
||||
|
||||
// Then — Risk section must be wrapped in a Card variant="danger"
|
||||
expect(riskCard).toBeDefined();
|
||||
// Then — Risk wrapper has a left accent border, not a danger Card
|
||||
expect(riskSection).toBeDefined();
|
||||
expect(riskSection?.className).toMatch(/border-l/);
|
||||
expect(riskSection?.getAttribute("data-variant")).toBeNull();
|
||||
});
|
||||
|
||||
it("should use larger heading size for section labels (text-sm → text-base or larger)", () => {
|
||||
@@ -1376,14 +1370,10 @@ describe("ResourceDetailDrawerContent — current resource row display", () => {
|
||||
// Then
|
||||
expect(screen.getByText("row-service")).toBeInTheDocument();
|
||||
expect(screen.getByText("eu-west-1")).toBeInTheDocument();
|
||||
expect(screen.getByText("row-group")).toBeInTheDocument();
|
||||
expect(screen.getByText("row-type")).toBeInTheDocument();
|
||||
expect(screen.getByText("FAIL")).toBeInTheDocument();
|
||||
expect(screen.getByText("critical")).toBeInTheDocument();
|
||||
expect(screen.queryByText("finding-service")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("ap-south-1")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("finding-group")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("finding-type")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should prefer the fetched finding status and severity in the header when the current row is stale", () => {
|
||||
@@ -1466,12 +1456,11 @@ describe("ResourceDetailDrawerContent — header skeleton while navigating", ()
|
||||
expect(screen.getByText("low")).toBeInTheDocument();
|
||||
expect(screen.getByText("ec2")).toBeInTheDocument();
|
||||
expect(screen.getByText("eu-west-1")).toBeInTheDocument();
|
||||
expect(screen.getByText("row-group")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Finding Overview" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Other Findings For This Resource" }),
|
||||
screen.getByRole("button", { name: "Findings for this resource" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText("uid-1")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Status extended")).not.toBeInTheDocument();
|
||||
@@ -1584,7 +1573,7 @@ describe("ResourceDetailDrawerContent — header skeleton while navigating", ()
|
||||
screen.getByRole("button", { name: "Finding Overview" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Other Findings For This Resource" }),
|
||||
screen.getByRole("button", { name: "Findings for this resource" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -1650,7 +1639,6 @@ describe("ResourceDetailDrawerContent — header skeleton while navigating", ()
|
||||
expect(screen.getByText("Started At")).toBeInTheDocument();
|
||||
expect(screen.getByText("Completed At")).toBeInTheDocument();
|
||||
expect(screen.getByText("Launched At")).toBeInTheDocument();
|
||||
expect(screen.getByText("Scheduled At")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("scans-navigation-skeleton")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
+255
-225
@@ -69,7 +69,6 @@ import {
|
||||
import { getFailingForLabel } from "@/lib/date-utils";
|
||||
import { formatDuration } from "@/lib/date-utils";
|
||||
import { getRegionFlag } from "@/lib/region-flags";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getRecommendationLinkLabel } from "@/lib/vulnerability-references";
|
||||
import type { ComplianceOverviewData } from "@/types/compliance";
|
||||
import type { FindingResourceRow } from "@/types/findings-table";
|
||||
@@ -410,8 +409,6 @@ export function ResourceDetailDrawerContent({
|
||||
const resourceUid = currentResource?.resourceUid ?? f?.resourceUid;
|
||||
const resourceService = currentResource?.service ?? f?.resourceService;
|
||||
const resourceRegion = currentResource?.region ?? f?.resourceRegion;
|
||||
const resourceGroup = currentResource?.resourceGroup ?? f?.resourceGroup;
|
||||
const resourceType = currentResource?.resourceType ?? f?.resourceType;
|
||||
const resourceRegionLabel = resourceRegion || "-";
|
||||
const firstSeenAt = currentResource?.firstSeenAt ?? f?.firstSeenAt ?? null;
|
||||
const lastSeenAt = currentResource?.lastSeenAt ?? f?.updatedAt ?? null;
|
||||
@@ -429,7 +426,6 @@ export function ResourceDetailDrawerContent({
|
||||
const regionFilter = searchParams.get("filter[region__in]");
|
||||
const nativeIacConfig = resolveNativeIacConfig(providerType);
|
||||
const showOverviewCheckMetaContent = showCheckMetaContent;
|
||||
const showOverviewFindingContent = Boolean(f);
|
||||
const resourceDetailHref = f?.resourceId
|
||||
? buildResourceDetailHref(f.resourceId)
|
||||
: null;
|
||||
@@ -446,7 +442,8 @@ export function ResourceDetailDrawerContent({
|
||||
label: getRecommendationLinkLabel(recommendationUrl),
|
||||
}
|
||||
: null;
|
||||
const overviewStatusExtended = f?.statusExtended;
|
||||
const overviewStatusExtended =
|
||||
currentResource?.statusExtended || f?.statusExtended;
|
||||
const showOverviewStatusExtended = Boolean(overviewStatusExtended);
|
||||
|
||||
const handleOpenCompliance = async (framework: string) => {
|
||||
@@ -678,83 +675,72 @@ export function ResourceDetailDrawerContent({
|
||||
<>
|
||||
<div className="flex items-start gap-4">
|
||||
{/* Resource info grid — 4 data columns */}
|
||||
<div className="grid min-w-0 flex-1 grid-cols-1 gap-4 md:grid-cols-4 md:gap-x-8 md:gap-y-4">
|
||||
{/* Row 1: Account, Resource, Service, Region */}
|
||||
<EntityInfo
|
||||
cloudProvider={providerType}
|
||||
nameIcon={<Box className="size-4" />}
|
||||
entityAlias={providerAlias}
|
||||
entityId={providerUid}
|
||||
/>
|
||||
<EntityInfo
|
||||
nameIcon={<Container className="size-4" />}
|
||||
entityAlias={resourceName}
|
||||
entityId={resourceUid}
|
||||
idLabel="UID"
|
||||
/>
|
||||
<InfoField label="Service" variant="compact">
|
||||
{resourceService}
|
||||
</InfoField>
|
||||
<InfoField label="Region" variant="compact">
|
||||
<span className="flex items-center gap-1.5">
|
||||
{getRegionFlag(resourceRegionLabel) && (
|
||||
<span className="translate-y-px text-base leading-none">
|
||||
{getRegionFlag(resourceRegionLabel)}
|
||||
</span>
|
||||
)}
|
||||
{resourceRegionLabel}
|
||||
</span>
|
||||
</InfoField>
|
||||
|
||||
{/* Row 2: Dates */}
|
||||
<InfoField label="Last detected" variant="compact">
|
||||
<DateWithTime inline dateTime={lastSeenAt || "-"} />
|
||||
</InfoField>
|
||||
<InfoField label="First seen" variant="compact">
|
||||
<DateWithTime inline dateTime={firstSeenAt || "-"} />
|
||||
</InfoField>
|
||||
<InfoField label="Failing for" variant="compact">
|
||||
{getFailingForLabel(firstSeenAt) || "-"}
|
||||
</InfoField>
|
||||
<InfoField label="Group" variant="compact">
|
||||
{resourceGroup || "-"}
|
||||
</InfoField>
|
||||
|
||||
{/* Row 3: IDs */}
|
||||
<InfoField label="Check ID" variant="compact">
|
||||
<CodeSnippet
|
||||
value={currentResource?.checkId ?? checkMeta.checkId}
|
||||
transparent
|
||||
className="max-w-full text-sm"
|
||||
/>
|
||||
</InfoField>
|
||||
<InfoField label="Finding ID" variant="compact">
|
||||
{currentResource?.findingId || f?.id ? (
|
||||
<CodeSnippet
|
||||
value={currentResource?.findingId ?? f?.id ?? "-"}
|
||||
transparent
|
||||
className="max-w-full text-sm"
|
||||
<div className="@container flex min-w-0 flex-1 flex-col gap-4">
|
||||
{/* Row 1: Provider (cols 1-2), Resource (cols 3-5) */}
|
||||
<div className="grid min-w-0 grid-cols-1 gap-4 @md:grid-cols-5 @md:gap-x-8">
|
||||
<div className="flex min-w-0 flex-col gap-1 @md:col-span-2">
|
||||
<span className="text-text-neutral-secondary text-[10px] whitespace-nowrap">
|
||||
Provider
|
||||
</span>
|
||||
<EntityInfo
|
||||
cloudProvider={providerType}
|
||||
nameIcon={<Box className="size-4" />}
|
||||
entityAlias={providerAlias}
|
||||
entityId={providerUid}
|
||||
/>
|
||||
) : (
|
||||
<Skeleton className="h-5 w-28 rounded" />
|
||||
)}
|
||||
</InfoField>
|
||||
<InfoField label="Finding UID" variant="compact">
|
||||
{f?.uid ? (
|
||||
<CodeSnippet
|
||||
value={f.uid}
|
||||
transparent
|
||||
className="max-w-full text-sm"
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col gap-1 @md:col-span-3">
|
||||
<span className="text-text-neutral-secondary text-[10px] whitespace-nowrap">
|
||||
Resource
|
||||
</span>
|
||||
<EntityInfo
|
||||
nameIcon={<Container className="size-4" />}
|
||||
entityAlias={resourceName}
|
||||
entityId={resourceUid}
|
||||
idLabel="UID"
|
||||
idAction={
|
||||
resourceDetailHref ? (
|
||||
<Button variant="link" size="link-sm" asChild>
|
||||
<Link
|
||||
href={resourceDetailHref}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
View Resource
|
||||
<ExternalLink className="size-3" />
|
||||
</Link>
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Skeleton className="h-5 w-36 rounded" />
|
||||
)}
|
||||
</InfoField>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 4: Resource metadata */}
|
||||
<InfoField label="Resource type" variant="compact">
|
||||
{resourceType || "-"}
|
||||
</InfoField>
|
||||
{/* Row 2: Last detected, First seen, Failing for, Service, Region */}
|
||||
<div className="grid min-w-0 grid-cols-1 gap-4 @md:grid-cols-5 @md:gap-x-8">
|
||||
<InfoField label="Last detected" variant="compact">
|
||||
<DateWithTime inline dateTime={lastSeenAt || "-"} />
|
||||
</InfoField>
|
||||
<InfoField label="First seen" variant="compact">
|
||||
<DateWithTime inline dateTime={firstSeenAt || "-"} />
|
||||
</InfoField>
|
||||
<InfoField label="Failing for" variant="compact">
|
||||
{getFailingForLabel(firstSeenAt) || "-"}
|
||||
</InfoField>
|
||||
<InfoField label="Service" variant="compact">
|
||||
{resourceService}
|
||||
</InfoField>
|
||||
<InfoField label="Region" variant="compact">
|
||||
<span className="flex items-center gap-1.5">
|
||||
{getRegionFlag(resourceRegionLabel) && (
|
||||
<span className="translate-y-px text-base leading-none">
|
||||
{getRegionFlag(resourceRegionLabel)}
|
||||
</span>
|
||||
)}
|
||||
{resourceRegionLabel}
|
||||
</span>
|
||||
</InfoField>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions button — fixed size, aligned with row 1 */}
|
||||
@@ -788,19 +774,28 @@ export function ResourceDetailDrawerContent({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{resourceDetailHref && (
|
||||
<div className="border-border-neutral-secondary flex justify-end border-t pt-3">
|
||||
<Button variant="link" size="link-sm" asChild>
|
||||
<Link
|
||||
href={resourceDetailHref}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
View Resource
|
||||
<ExternalLink className="size-3" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
{/* Status Extended — context below the resource */}
|
||||
{showOverviewStatusExtended && (
|
||||
<Card
|
||||
variant={
|
||||
findingStatus === "PASS"
|
||||
? "success"
|
||||
: findingStatus === "MANUAL"
|
||||
? "warning"
|
||||
: "danger"
|
||||
}
|
||||
className={
|
||||
findingStatus === "MUTED"
|
||||
? "border-border-neutral-tertiary bg-bg-neutral-tertiary"
|
||||
: findingStatus === "MANUAL"
|
||||
? "bg-orange-100 dark:bg-[color-mix(in_oklch,var(--bg-warning-secondary)_90%,white)]"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<p className="text-text-neutral-primary text-sm leading-relaxed break-words">
|
||||
{overviewStatusExtended}
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@@ -813,8 +808,9 @@ export function ResourceDetailDrawerContent({
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">Finding Overview</TabsTrigger>
|
||||
<TabsTrigger value="remediation">Remediation</TabsTrigger>
|
||||
<TabsTrigger value="other-findings">
|
||||
Other Findings For This Resource
|
||||
Findings for this resource
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="scans">Scans</TabsTrigger>
|
||||
<TabsTrigger value="events">Events</TabsTrigger>
|
||||
@@ -828,132 +824,26 @@ export function ResourceDetailDrawerContent({
|
||||
>
|
||||
{showOverviewCheckMetaContent ? (
|
||||
<>
|
||||
{/* Card 1: Risk + Description + Status Extended */}
|
||||
{(checkMeta.risk ||
|
||||
checkMeta.description ||
|
||||
showOverviewFindingContent) && (
|
||||
<Card variant="inner">
|
||||
{checkMeta.risk && (
|
||||
<Card variant="danger">
|
||||
<span className="text-text-neutral-secondary text-sm font-semibold">
|
||||
Risk:
|
||||
</span>
|
||||
<MarkdownContainer>{checkMeta.risk}</MarkdownContainer>
|
||||
</Card>
|
||||
)}
|
||||
{checkMeta.description && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-1",
|
||||
showOverviewStatusExtended &&
|
||||
"border-default-200 border-b pb-4",
|
||||
)}
|
||||
>
|
||||
<span className="text-text-neutral-secondary text-sm font-semibold">
|
||||
Description:
|
||||
</span>
|
||||
<MarkdownContainer>
|
||||
{checkMeta.description}
|
||||
</MarkdownContainer>
|
||||
</div>
|
||||
)}
|
||||
{showOverviewFindingContent &&
|
||||
showOverviewStatusExtended && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-text-neutral-secondary text-sm font-semibold">
|
||||
Status Extended:
|
||||
</span>
|
||||
<p className="text-text-neutral-primary text-sm">
|
||||
{overviewStatusExtended}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
{/* Risk */}
|
||||
{checkMeta.risk && (
|
||||
<div className="border-border-neutral-primary flex flex-col gap-1 border-l-4 pl-3">
|
||||
<span className="text-text-neutral-primary text-sm font-semibold">
|
||||
Risk:
|
||||
</span>
|
||||
<MarkdownContainer>{checkMeta.risk}</MarkdownContainer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Card 2: Remediation + Commands */}
|
||||
{(checkMeta.remediation.recommendation.text ||
|
||||
recommendationLink ||
|
||||
checkMeta.remediation.code.cli ||
|
||||
checkMeta.remediation.code.terraform ||
|
||||
checkMeta.remediation.code.nativeiac) && (
|
||||
<Card variant="inner">
|
||||
{(checkMeta.remediation.recommendation.text ||
|
||||
recommendationLink) && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-text-neutral-secondary text-xs">
|
||||
Remediation:
|
||||
</span>
|
||||
<div className="flex items-start gap-3">
|
||||
{checkMeta.remediation.recommendation.text && (
|
||||
<div className="text-text-neutral-primary flex-1 text-sm">
|
||||
<MarkdownContainer>
|
||||
{checkMeta.remediation.recommendation.text}
|
||||
</MarkdownContainer>
|
||||
</div>
|
||||
)}
|
||||
{recommendationLink && (
|
||||
<CustomLink
|
||||
href={recommendationLink.href}
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
>
|
||||
{recommendationLink.label}
|
||||
</CustomLink>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{checkMeta.remediation.code.cli && (
|
||||
<div className="flex flex-col gap-1">
|
||||
{renderRemediationCodeBlock({
|
||||
label: "CLI Command",
|
||||
language: QUERY_EDITOR_LANGUAGE.SHELL,
|
||||
value: `$ ${stripCodeFences(checkMeta.remediation.code.cli)}`,
|
||||
copyValue: stripCodeFences(
|
||||
checkMeta.remediation.code.cli,
|
||||
),
|
||||
showLineNumbers: false,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{checkMeta.remediation.code.terraform && (
|
||||
<div className="flex flex-col gap-1">
|
||||
{renderRemediationCodeBlock({
|
||||
label: "Terraform",
|
||||
language: QUERY_EDITOR_LANGUAGE.HCL,
|
||||
value: stripCodeFences(
|
||||
checkMeta.remediation.code.terraform,
|
||||
),
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{checkMeta.remediation.code.nativeiac && providerType && (
|
||||
<div className="flex flex-col gap-1">
|
||||
{renderRemediationCodeBlock({
|
||||
label: nativeIacConfig.label,
|
||||
language: nativeIacConfig.language,
|
||||
value: stripCodeFences(
|
||||
checkMeta.remediation.code.nativeiac,
|
||||
),
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{checkMeta.remediation.code.other && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-text-neutral-secondary text-xs">
|
||||
Remediation Steps:
|
||||
</span>
|
||||
<MarkdownContainer>
|
||||
{checkMeta.remediation.code.other}
|
||||
</MarkdownContainer>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
{/* Description */}
|
||||
{checkMeta.description && (
|
||||
<div className="flex flex-col gap-1 px-1">
|
||||
<span className="text-text-neutral-primary text-sm font-semibold">
|
||||
Description:
|
||||
</span>
|
||||
<MarkdownContainer>
|
||||
{checkMeta.description}
|
||||
</MarkdownContainer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{checkMeta.additionalUrls.length > 0 && (
|
||||
@@ -999,13 +889,154 @@ export function ResourceDetailDrawerContent({
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* IDs */}
|
||||
<Card variant="inner">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3 md:gap-x-6">
|
||||
<InfoField label="Check ID" variant="compact">
|
||||
<CodeSnippet
|
||||
value={currentResource?.checkId ?? checkMeta.checkId}
|
||||
transparent
|
||||
className="max-w-full text-sm"
|
||||
/>
|
||||
</InfoField>
|
||||
<InfoField label="Finding ID" variant="compact">
|
||||
{currentResource?.findingId || f?.id ? (
|
||||
<CodeSnippet
|
||||
value={currentResource?.findingId ?? f?.id ?? "-"}
|
||||
transparent
|
||||
className="max-w-full text-sm"
|
||||
/>
|
||||
) : (
|
||||
<Skeleton className="h-5 w-28 rounded" />
|
||||
)}
|
||||
</InfoField>
|
||||
<InfoField label="Finding UID" variant="compact">
|
||||
{f?.uid ? (
|
||||
<CodeSnippet
|
||||
value={f.uid}
|
||||
transparent
|
||||
className="max-w-full text-sm"
|
||||
/>
|
||||
) : (
|
||||
<Skeleton className="h-5 w-36 rounded" />
|
||||
)}
|
||||
</InfoField>
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
) : (
|
||||
<OverviewNavigationSkeleton />
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Other Findings For This Resource */}
|
||||
{/* Remediation */}
|
||||
<TabsContent
|
||||
value="remediation"
|
||||
className="minimal-scrollbar flex flex-col gap-4 overflow-y-auto"
|
||||
>
|
||||
{showOverviewCheckMetaContent ? (
|
||||
checkMeta.remediation.recommendation.text ||
|
||||
recommendationLink ||
|
||||
checkMeta.remediation.code.cli ||
|
||||
checkMeta.remediation.code.terraform ||
|
||||
checkMeta.remediation.code.nativeiac ||
|
||||
checkMeta.remediation.code.other ? (
|
||||
<>
|
||||
{(checkMeta.remediation.recommendation.text ||
|
||||
recommendationLink) && (
|
||||
<div className="flex flex-col gap-1 px-1">
|
||||
<span className="text-text-neutral-primary text-sm font-semibold">
|
||||
Remediation:
|
||||
</span>
|
||||
<div className="flex items-start gap-3">
|
||||
{checkMeta.remediation.recommendation.text && (
|
||||
<div className="text-text-neutral-primary flex-1 text-sm">
|
||||
<MarkdownContainer>
|
||||
{checkMeta.remediation.recommendation.text}
|
||||
</MarkdownContainer>
|
||||
</div>
|
||||
)}
|
||||
{recommendationLink && (
|
||||
<CustomLink
|
||||
href={recommendationLink.href}
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
>
|
||||
{recommendationLink.label}
|
||||
</CustomLink>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(checkMeta.remediation.code.cli ||
|
||||
checkMeta.remediation.code.terraform ||
|
||||
checkMeta.remediation.code.nativeiac ||
|
||||
checkMeta.remediation.code.other) && (
|
||||
<Card variant="inner">
|
||||
{checkMeta.remediation.code.cli && (
|
||||
<div className="flex flex-col gap-1">
|
||||
{renderRemediationCodeBlock({
|
||||
label: "CLI Command",
|
||||
language: QUERY_EDITOR_LANGUAGE.SHELL,
|
||||
value: `$ ${stripCodeFences(checkMeta.remediation.code.cli)}`,
|
||||
copyValue: stripCodeFences(
|
||||
checkMeta.remediation.code.cli,
|
||||
),
|
||||
showLineNumbers: false,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{checkMeta.remediation.code.terraform && (
|
||||
<div className="flex flex-col gap-1">
|
||||
{renderRemediationCodeBlock({
|
||||
label: "Terraform",
|
||||
language: QUERY_EDITOR_LANGUAGE.HCL,
|
||||
value: stripCodeFences(
|
||||
checkMeta.remediation.code.terraform,
|
||||
),
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{checkMeta.remediation.code.nativeiac && providerType && (
|
||||
<div className="flex flex-col gap-1">
|
||||
{renderRemediationCodeBlock({
|
||||
label: nativeIacConfig.label,
|
||||
language: nativeIacConfig.language,
|
||||
value: stripCodeFences(
|
||||
checkMeta.remediation.code.nativeiac,
|
||||
),
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{checkMeta.remediation.code.other && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-text-neutral-secondary text-xs">
|
||||
Remediation Steps:
|
||||
</span>
|
||||
<MarkdownContainer>
|
||||
{checkMeta.remediation.code.other}
|
||||
</MarkdownContainer>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-text-neutral-tertiary text-sm">
|
||||
No remediation guidance available for this check.
|
||||
</p>
|
||||
)
|
||||
) : (
|
||||
<OverviewNavigationSkeleton testId="remediation-navigation-skeleton" />
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Findings for this resource */}
|
||||
<TabsContent
|
||||
value="other-findings"
|
||||
className="minimal-scrollbar flex flex-col gap-2 overflow-y-auto"
|
||||
@@ -1138,7 +1169,7 @@ export function ResourceDetailDrawerContent({
|
||||
: "-"}
|
||||
</InfoField>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<InfoField label="Started At" variant="compact">
|
||||
<DateWithTime inline dateTime={f?.scan?.startedAt || "-"} />
|
||||
</InfoField>
|
||||
@@ -1148,8 +1179,6 @@ export function ResourceDetailDrawerContent({
|
||||
dateTime={f?.scan?.completedAt || "-"}
|
||||
/>
|
||||
</InfoField>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<InfoField label="Launched At" variant="compact">
|
||||
<DateWithTime
|
||||
inline
|
||||
@@ -1202,11 +1231,11 @@ export function ResourceDetailDrawerContent({
|
||||
);
|
||||
}
|
||||
|
||||
function OverviewNavigationSkeleton() {
|
||||
function OverviewNavigationSkeleton({ testId }: { testId?: string } = {}) {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col gap-4"
|
||||
data-testid="overview-navigation-skeleton"
|
||||
data-testid={testId ?? "overview-navigation-skeleton"}
|
||||
>
|
||||
<Card variant="inner">
|
||||
<OverviewCardSkeleton lineWidths={["w-24", "w-full", "w-5/6"]} />
|
||||
@@ -1288,8 +1317,9 @@ function ScansNavigationSkeleton() {
|
||||
labels={["Scan Name", "Resources Scanned", "Progress"]}
|
||||
/>
|
||||
<ScansInfoGridSkeleton labels={["Trigger", "State", "Duration"]} />
|
||||
<ScansInfoGridSkeleton labels={["Started At", "Completed At"]} />
|
||||
<ScansInfoGridSkeleton labels={["Launched At", "Scheduled At"]} />
|
||||
<ScansInfoGridSkeleton
|
||||
labels={["Started At", "Completed At", "Launched At"]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+4
-9
@@ -10,17 +10,12 @@ vi.mock("@/components/shadcn/skeleton/skeleton", () => ({
|
||||
import { ResourceDetailSkeleton } from "./resource-detail-skeleton";
|
||||
|
||||
describe("ResourceDetailSkeleton", () => {
|
||||
it("should include placeholders for group and resource type fields", () => {
|
||||
it("should render placeholders mirroring the resource info grid layout", () => {
|
||||
render(<ResourceDetailSkeleton />);
|
||||
|
||||
// Provider/Resource entity placeholders + 5 info fields (dates + service +
|
||||
// region) + actions button = at least 7 blocks rendered.
|
||||
const blocks = screen.getAllByTestId("skeleton-block");
|
||||
const classes = blocks.map(
|
||||
(block) => block.getAttribute("data-class") ?? "",
|
||||
);
|
||||
|
||||
expect(classes).toContain("h-3.5 w-10 rounded");
|
||||
expect(classes).toContain("h-5 w-18 rounded");
|
||||
expect(classes).toContain("h-3.5 w-20 rounded");
|
||||
expect(classes).toContain("h-5 w-28 rounded");
|
||||
expect(blocks.length).toBeGreaterThanOrEqual(7);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,26 +8,21 @@ import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
|
||||
export function ResourceDetailSkeleton() {
|
||||
return (
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="grid min-w-0 flex-1 grid-cols-1 gap-4 md:grid-cols-4 md:gap-x-8 md:gap-y-4">
|
||||
{/* Row 1: Account, Resource, Service, Region */}
|
||||
<EntityInfoSkeleton hasIcon />
|
||||
<EntityInfoSkeleton />
|
||||
<InfoFieldSkeleton labelWidth="w-12" valueWidth="w-20" />
|
||||
<InfoFieldSkeleton labelWidth="w-12" valueWidth="w-24" />
|
||||
<div className="@container flex min-w-0 flex-1 flex-col gap-4">
|
||||
{/* Row 1: Provider, Resource */}
|
||||
<div className="grid min-w-0 grid-cols-1 gap-4 @md:grid-cols-[minmax(0,1fr)_minmax(0,2fr)] @md:gap-x-8">
|
||||
<EntityInfoSkeleton hasIcon labelWidth="w-12" />
|
||||
<EntityInfoSkeleton labelWidth="w-14" />
|
||||
</div>
|
||||
|
||||
{/* Row 2: Last detected, First seen, Failing for, Group */}
|
||||
<InfoFieldSkeleton labelWidth="w-20" valueWidth="w-32" />
|
||||
<InfoFieldSkeleton labelWidth="w-16" valueWidth="w-32" />
|
||||
<InfoFieldSkeleton labelWidth="w-16" valueWidth="w-16" />
|
||||
<InfoFieldSkeleton labelWidth="w-10" valueWidth="w-18" />
|
||||
|
||||
{/* Row 3: Check ID, Finding ID, Finding UID */}
|
||||
<InfoFieldSkeleton labelWidth="w-14" valueWidth="w-36" />
|
||||
<InfoFieldSkeleton labelWidth="w-16" valueWidth="w-36" />
|
||||
<InfoFieldSkeleton labelWidth="w-20" valueWidth="w-36" />
|
||||
|
||||
{/* Row 4: Resource type */}
|
||||
<InfoFieldSkeleton labelWidth="w-20" valueWidth="w-28" />
|
||||
{/* Row 2: Last detected, First seen, Failing for, Service, Region */}
|
||||
<div className="grid min-w-0 grid-cols-1 gap-4 @md:grid-cols-5 @md:gap-x-8">
|
||||
<InfoFieldSkeleton labelWidth="w-20" valueWidth="w-32" />
|
||||
<InfoFieldSkeleton labelWidth="w-16" valueWidth="w-32" />
|
||||
<InfoFieldSkeleton labelWidth="w-16" valueWidth="w-16" />
|
||||
<InfoFieldSkeleton labelWidth="w-12" valueWidth="w-20" />
|
||||
<InfoFieldSkeleton labelWidth="w-12" valueWidth="w-24" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions button */}
|
||||
@@ -36,16 +31,25 @@ export function ResourceDetailSkeleton() {
|
||||
);
|
||||
}
|
||||
|
||||
function EntityInfoSkeleton({ hasIcon = false }: { hasIcon?: boolean }) {
|
||||
function EntityInfoSkeleton({
|
||||
hasIcon = false,
|
||||
labelWidth,
|
||||
}: {
|
||||
hasIcon?: boolean;
|
||||
labelWidth?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
{hasIcon && <Skeleton className="size-9 shrink-0 rounded-md" />}
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Skeleton className="size-4 rounded" />
|
||||
<Skeleton className="h-5 w-28 rounded" />
|
||||
<div className="flex flex-col gap-1">
|
||||
{labelWidth && <Skeleton className={`h-3 ${labelWidth} rounded`} />}
|
||||
<div className="flex items-center gap-4">
|
||||
{hasIcon && <Skeleton className="size-9 shrink-0 rounded-md" />}
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Skeleton className="size-4 rounded" />
|
||||
<Skeleton className="h-5 w-28 rounded" />
|
||||
</div>
|
||||
<Skeleton className="h-6 w-24 rounded-full" />
|
||||
</div>
|
||||
<Skeleton className="h-6 w-24 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Cell, Label, Pie, PieChart, Tooltip } from "recharts";
|
||||
import {
|
||||
Cell,
|
||||
Label,
|
||||
Pie,
|
||||
PieChart,
|
||||
Sector,
|
||||
type SectorProps,
|
||||
Tooltip,
|
||||
} from "recharts";
|
||||
|
||||
import { ChartConfig, ChartContainer } from "@/components/ui/chart/Chart";
|
||||
|
||||
@@ -156,6 +164,22 @@ export function DonutChart({
|
||||
},
|
||||
}));
|
||||
|
||||
// Reserve a small ring at the outer edge so the active sector can grow into
|
||||
// it without being clipped by the SVG viewport (consumers like
|
||||
// RequirementsStatusCard wrap the chart in a fixed-size box where
|
||||
// outerRadius == container/2 leaves no room to expand).
|
||||
const ACTIVE_GROW = 4;
|
||||
const restingOuterRadius = Math.max(
|
||||
innerRadius + 1,
|
||||
outerRadius - ACTIVE_GROW,
|
||||
);
|
||||
|
||||
// Grows the hovered slice up to the original outerRadius so tiny segments
|
||||
// (e.g. 1% fail) are easy to see and target with the cursor.
|
||||
const renderActiveShape = (props: SectorProps) => (
|
||||
<Sector {...props} outerRadius={(props.outerRadius ?? 0) + ACTIVE_GROW} />
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ChartContainer
|
||||
@@ -163,15 +187,29 @@ export function DonutChart({
|
||||
className="mx-auto aspect-square max-h-[350px]"
|
||||
>
|
||||
<PieChart>
|
||||
{!isEmpty && <Tooltip content={<CustomTooltip />} />}
|
||||
{!isEmpty && (
|
||||
<Tooltip
|
||||
content={<CustomTooltip />}
|
||||
cursor={false}
|
||||
wrapperStyle={{ zIndex: 1000 }}
|
||||
/>
|
||||
)}
|
||||
<Pie
|
||||
data={isEmpty ? emptyData : chartData}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
innerRadius={innerRadius}
|
||||
outerRadius={outerRadius}
|
||||
outerRadius={restingOuterRadius}
|
||||
strokeWidth={0}
|
||||
paddingAngle={0}
|
||||
// `?? undefined` — Recharts treats `null` as truthy in some paths
|
||||
// and `||` would clobber index 0 (e.g. the "Pass" pillar).
|
||||
activeIndex={hoveredIndex ?? undefined}
|
||||
activeShape={renderActiveShape}
|
||||
onMouseEnter={(_, index) => {
|
||||
if (!isEmpty) setHoveredIndex(index);
|
||||
}}
|
||||
onMouseLeave={() => setHoveredIndex(null)}
|
||||
>
|
||||
{(isEmpty ? emptyData : chartData).map((entry, index) => {
|
||||
const opacity =
|
||||
@@ -186,8 +224,6 @@ export function DonutChart({
|
||||
style={{
|
||||
transition: "opacity 0.2s",
|
||||
}}
|
||||
onMouseEnter={() => setHoveredIndex(index)}
|
||||
onMouseLeave={() => setHoveredIndex(null)}
|
||||
onClick={() => {
|
||||
if (isClickable) {
|
||||
onSegmentClick(data[index], index);
|
||||
|
||||
@@ -14,17 +14,24 @@ interface HorizontalBarChartProps {
|
||||
height?: number;
|
||||
title?: string;
|
||||
onBarClick?: (dataPoint: BarDataPoint, index: number) => void;
|
||||
/**
|
||||
* When false, totals of 0 still render the supplied `data` as zero-width
|
||||
* bars instead of falling back to severity placeholders. Useful for callers
|
||||
* that pre-populate a canonical category list (e.g. ThreatScore pillars).
|
||||
*/
|
||||
useSeverityEmptyState?: boolean;
|
||||
}
|
||||
|
||||
export function HorizontalBarChart({
|
||||
data,
|
||||
title,
|
||||
onBarClick,
|
||||
useSeverityEmptyState = true,
|
||||
}: HorizontalBarChartProps) {
|
||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
||||
|
||||
const total = data.reduce((sum, d) => sum + (Number(d.value) || 0), 0);
|
||||
const isEmpty = total <= 0;
|
||||
const isEmpty = total <= 0 && (useSeverityEmptyState || data.length === 0);
|
||||
|
||||
const emptyData: BarDataPoint[] = [
|
||||
{ name: "Critical", value: 1, percentage: 100 },
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { getComplianceIcon } from "./IconCompliance";
|
||||
|
||||
describe("getComplianceIcon", () => {
|
||||
describe("framework name matching", () => {
|
||||
it("resolves ASD Essential Eight via the `essential` keyword", () => {
|
||||
expect(getComplianceIcon("ASD-Essential-Eight")).toBeDefined();
|
||||
expect(getComplianceIcon("asd-essential-eight")).toBeDefined();
|
||||
expect(getComplianceIcon("ASD Essential Eight Maturity Model")).toBe(
|
||||
getComplianceIcon("ASD-Essential-Eight"),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns undefined for an unknown framework name", () => {
|
||||
expect(getComplianceIcon("Made-Up-Framework")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for an empty string", () => {
|
||||
expect(getComplianceIcon("")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("compliance_id matching (with provider suffix)", () => {
|
||||
// Regression coverage for the icon-shadowing bug: every AWS-hosted
|
||||
// compliance_id ends with `_aws`, so `getComplianceIcon` MUST resolve
|
||||
// by the framework keyword (cis, iso, ...) before falling through to
|
||||
// the provider-level `aws` keyword. If `aws` ever moves up in
|
||||
// COMPLIANCE_LOGOS, every assertion below will flip and surface the
|
||||
// regression.
|
||||
|
||||
it("resolves CIS variants by the framework keyword, not by `aws`", () => {
|
||||
const cisLogo = getComplianceIcon("CIS");
|
||||
expect(cisLogo).toBeDefined();
|
||||
expect(getComplianceIcon("cis_4.0_aws")).toBe(cisLogo);
|
||||
expect(getComplianceIcon("cis_5.0_aws")).toBe(cisLogo);
|
||||
expect(getComplianceIcon("cis_6.0_aws")).toBe(cisLogo);
|
||||
});
|
||||
|
||||
it("resolves CISA before falling back to CIS or AWS", () => {
|
||||
const cisLogo = getComplianceIcon("CIS");
|
||||
const cisaLogo = getComplianceIcon("cisa");
|
||||
expect(cisaLogo).toBeDefined();
|
||||
expect(cisaLogo).not.toBe(cisLogo);
|
||||
expect(getComplianceIcon("cisa_aws")).toBe(cisaLogo);
|
||||
});
|
||||
|
||||
it("resolves ISO 27001 by the framework keyword, not by `aws`", () => {
|
||||
const isoLogo = getComplianceIcon("ISO27001");
|
||||
expect(isoLogo).toBeDefined();
|
||||
expect(getComplianceIcon("iso27001_2022_aws")).toBe(isoLogo);
|
||||
expect(getComplianceIcon("iso27001_2013_aws")).toBe(isoLogo);
|
||||
});
|
||||
|
||||
it("resolves Prowler ThreatScore by the framework keyword, not by `aws`", () => {
|
||||
const threatLogo = getComplianceIcon("ProwlerThreatScore");
|
||||
expect(threatLogo).toBeDefined();
|
||||
expect(getComplianceIcon("prowler_threatscore_aws")).toBe(threatLogo);
|
||||
});
|
||||
|
||||
it("resolves ASD Essential Eight by the framework keyword, not by `aws`", () => {
|
||||
const essentialLogo = getComplianceIcon("ASD-Essential-Eight");
|
||||
expect(essentialLogo).toBeDefined();
|
||||
expect(getComplianceIcon("asd_essential_eight_aws")).toBe(essentialLogo);
|
||||
});
|
||||
|
||||
it("resolves NIS2 distinctly from NIST", () => {
|
||||
const nis2Logo = getComplianceIcon("NIS2");
|
||||
const nistLogo = getComplianceIcon("NIST-800-53");
|
||||
expect(nis2Logo).toBeDefined();
|
||||
expect(nistLogo).toBeDefined();
|
||||
expect(nis2Logo).not.toBe(nistLogo);
|
||||
expect(getComplianceIcon("nis2_aws")).toBe(nis2Logo);
|
||||
expect(getComplianceIcon("nist_800_53_revision_5_aws")).toBe(nistLogo);
|
||||
});
|
||||
|
||||
it("resolves PCI/HIPAA/GDPR/SOC2/ENS/FedRAMP/MITRE/RBI/KISA/SecNumCloud by their framework keyword", () => {
|
||||
// Spot-check the rest of the framework keywords against AWS-suffixed ids.
|
||||
// Each must resolve to a distinct logo from `aws` so the watchlist
|
||||
// surface (which keys icons by compliance_id) renders correctly.
|
||||
const awsLogo = getComplianceIcon(
|
||||
"AWS-Well-Architected-Framework-Security-Pillar",
|
||||
);
|
||||
const cases = [
|
||||
"pci_4.0_aws",
|
||||
"hipaa_aws",
|
||||
"gdpr_aws",
|
||||
"soc2_aws",
|
||||
"ens_rd2022_aws",
|
||||
"fedramp_low_revision_4_aws",
|
||||
"mitre_attack_aws",
|
||||
"rbi_cyber_security_framework_aws",
|
||||
"kisa_isms_p_2023_aws",
|
||||
"secnumcloud_3.2_aws",
|
||||
];
|
||||
for (const id of cases) {
|
||||
const resolved = getComplianceIcon(id);
|
||||
expect(
|
||||
resolved,
|
||||
`${id} should resolve to a framework-specific logo, not the AWS fallback`,
|
||||
).toBeDefined();
|
||||
expect(
|
||||
resolved,
|
||||
`${id} should not collapse to the generic AWS logo`,
|
||||
).not.toBe(awsLogo);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("AWS-only frameworks fall through to the AWS logo", () => {
|
||||
// These frameworks are genuinely AWS-specific and have no other matching
|
||||
// keyword in the registry. They must resolve to the AWS logo via the
|
||||
// tail-end fallback.
|
||||
|
||||
it("resolves AWS Well-Architected pillars to the AWS logo", () => {
|
||||
const awsLogo = getComplianceIcon(
|
||||
"AWS-Well-Architected-Framework-Security-Pillar",
|
||||
);
|
||||
expect(awsLogo).toBeDefined();
|
||||
expect(
|
||||
getComplianceIcon("AWS-Well-Architected-Framework-Reliability-Pillar"),
|
||||
).toBe(awsLogo);
|
||||
expect(
|
||||
getComplianceIcon("aws_well_architected_framework_security_pillar_aws"),
|
||||
).toBe(awsLogo);
|
||||
});
|
||||
|
||||
it("resolves AWS Foundational frameworks to the AWS logo", () => {
|
||||
const awsLogo = getComplianceIcon(
|
||||
"AWS-Well-Architected-Framework-Security-Pillar",
|
||||
);
|
||||
expect(
|
||||
getComplianceIcon("aws_foundational_security_best_practices_aws"),
|
||||
).toBe(awsLogo);
|
||||
expect(getComplianceIcon("aws_foundational_technical_review_aws")).toBe(
|
||||
awsLogo,
|
||||
);
|
||||
expect(
|
||||
getComplianceIcon("aws_audit_manager_control_tower_guardrails_aws"),
|
||||
).toBe(awsLogo);
|
||||
expect(getComplianceIcon("aws_account_security_onboarding_aws")).toBe(
|
||||
awsLogo,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import ANSSILogo from "./anssi.png";
|
||||
import ASDEssentialEightLogo from "./asd-essential-eight.svg";
|
||||
import AWSLogo from "./aws.svg";
|
||||
import C5Logo from "./c5.svg";
|
||||
import CCCLogo from "./ccc.svg";
|
||||
@@ -21,34 +22,58 @@ import PROWLERTHREATLogo from "./prowlerThreat.svg";
|
||||
import RBILogo from "./rbi.svg";
|
||||
import SOC2Logo from "./soc2.svg";
|
||||
|
||||
const COMPLIANCE_LOGOS = {
|
||||
aws: AWSLogo,
|
||||
cisa: CISALogo,
|
||||
cis: CISLogo,
|
||||
ens: ENSLogo,
|
||||
ffiec: FFIECLogo,
|
||||
fedramp: FedRAMPLogo,
|
||||
gdpr: GDPRLogo,
|
||||
gxp: GxPLogo,
|
||||
hipaa: HIPAALogo,
|
||||
iso: ISOLogo,
|
||||
mitre: MITRELogo,
|
||||
nist: NISTLogo,
|
||||
pci: PCILogo,
|
||||
rbi: RBILogo,
|
||||
soc2: SOC2Logo,
|
||||
kisa: KISALogo,
|
||||
prowlerthreatscore: PROWLERTHREATLogo,
|
||||
nis2: NIS2Logo,
|
||||
c5: C5Logo,
|
||||
ccc: CCCLogo,
|
||||
csa: CSALogo,
|
||||
secnumcloud: ANSSILogo,
|
||||
} as const;
|
||||
// Framework-specific keywords MUST come before the generic provider-level
|
||||
// `aws` keyword. `getComplianceIcon` resolves by substring `includes`, and
|
||||
// AWS compliance ids carry a `_aws` provider suffix (e.g. `cis_4.0_aws`,
|
||||
// `iso27001_2022_aws`, `prowler_threatscore_aws`, `asd_essential_eight_aws`).
|
||||
// Without this ordering the generic `aws` entry would shadow every
|
||||
// framework-specific logo on watchlist surfaces that resolve by id. The
|
||||
// list is a tuple array (rather than an object literal) because lookup
|
||||
// order is semantically meaningful here — JavaScript engines preserve
|
||||
// insertion order for string keys, but a tuple makes that contract
|
||||
// explicit and prevents an accidental object-literal sort or
|
||||
// `Object.fromEntries` round-trip from silently breaking resolution.
|
||||
// `aws` is intentionally last so the framework keywords win, while genuinely
|
||||
// AWS-only frameworks (Well-Architected, Audit Manager, Foundational Security
|
||||
// Best Practices, Account Security Onboarding, Foundational Technical Review)
|
||||
// fall through to it because they expose no other matching keyword.
|
||||
const COMPLIANCE_LOGOS = [
|
||||
["essential", ASDEssentialEightLogo],
|
||||
["cisa", CISALogo],
|
||||
["cis", CISLogo],
|
||||
["ens", ENSLogo],
|
||||
["ffiec", FFIECLogo],
|
||||
["fedramp", FedRAMPLogo],
|
||||
["gdpr", GDPRLogo],
|
||||
["gxp", GxPLogo],
|
||||
["hipaa", HIPAALogo],
|
||||
["iso", ISOLogo],
|
||||
["mitre", MITRELogo],
|
||||
// `nist` comes before `nis2` because NIST 800-53 etc. would otherwise be
|
||||
// checked after `nis2`; both are unambiguous, but pinning the order avoids
|
||||
// surprises if a future id contains "nis2" inside a NIST acronym.
|
||||
["nist", NISTLogo],
|
||||
["nis2", NIS2Logo],
|
||||
["pci", PCILogo],
|
||||
["rbi", RBILogo],
|
||||
["soc2", SOC2Logo],
|
||||
["kisa", KISALogo],
|
||||
// `threatscore` (not `prowlerthreatscore`) matches both the framework name
|
||||
// `ProwlerThreatScore` (lowercased "prowlerthreatscore") AND the
|
||||
// compliance_id `prowler_threatscore_aws` (which separates the words with
|
||||
// an underscore). The previous one-word keyword silently failed for the
|
||||
// watchlist surface — only fixed in concert with moving `aws` to the end.
|
||||
["threatscore", PROWLERTHREATLogo],
|
||||
["c5", C5Logo],
|
||||
["ccc", CCCLogo],
|
||||
["csa", CSALogo],
|
||||
["secnumcloud", ANSSILogo],
|
||||
["aws", AWSLogo],
|
||||
] as const;
|
||||
|
||||
export const getComplianceIcon = (complianceTitle: string) => {
|
||||
const lowerTitle = complianceTitle.toLowerCase();
|
||||
return Object.entries(COMPLIANCE_LOGOS).find(([keyword]) =>
|
||||
return COMPLIANCE_LOGOS.find(([keyword]) =>
|
||||
lowerTitle.includes(keyword),
|
||||
)?.[1];
|
||||
};
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
|
||||
<path fill="#1f2937" fill-rule="evenodd" d="M22.343 4h19.314L56 18.343v27.314L41.657 60H22.343L8 45.657V18.343L22.343 4Zm.828 4L12 19.171v25.658L23.171 56h17.658L52 44.829V19.171L40.829 8H23.171ZM32 18.5c-3.59 0-6.5 2.91-6.5 6.5 0 1.792.726 3.415 1.9 4.59A7.498 7.498 0 0 0 24.5 35.5c0 4.142 3.358 7.5 7.5 7.5s7.5-3.358 7.5-7.5a7.498 7.498 0 0 0-2.9-5.91A6.476 6.476 0 0 0 38.5 25c0-3.59-2.91-6.5-6.5-6.5Zm0 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5Zm0 9a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7Z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 604 B |
@@ -20,6 +20,8 @@ const cardVariants = cva("flex flex-col gap-6 rounded-xl border", {
|
||||
inner:
|
||||
"rounded-[12px] backdrop-blur-[46px] border-border-neutral-tertiary bg-bg-neutral-tertiary",
|
||||
danger: "border-border-error bg-bg-fail-secondary gap-1 rounded-[12px]",
|
||||
success: "border-bg-pass bg-bg-pass-secondary gap-1 rounded-[12px]",
|
||||
warning: "border-bg-warning bg-bg-warning-secondary gap-1 rounded-[12px]",
|
||||
},
|
||||
padding: {
|
||||
default: "",
|
||||
@@ -40,6 +42,16 @@ const cardVariants = cva("flex flex-col gap-6 rounded-xl border", {
|
||||
padding: "default",
|
||||
className: "px-4 py-3", // md padding by default for danger
|
||||
},
|
||||
{
|
||||
variant: "success",
|
||||
padding: "default",
|
||||
className: "px-4 py-3", // md padding by default for success
|
||||
},
|
||||
{
|
||||
variant: "warning",
|
||||
padding: "default",
|
||||
className: "px-4 py-3", // md padding by default for warning
|
||||
},
|
||||
],
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
|
||||
@@ -134,6 +134,7 @@ export const Accordion = ({
|
||||
{items.map((item, index) => (
|
||||
<AccordionItem
|
||||
key={item.key}
|
||||
data-accordion-key={item.key}
|
||||
aria-label={
|
||||
typeof item.title === "string" ? item.title : `Item ${item.key}`
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ interface EntityInfoProps {
|
||||
/** Label before the ID value. Defaults to "UID" */
|
||||
idLabel?: string;
|
||||
showCopyAction?: boolean;
|
||||
/** Inline element rendered after the entity ID (e.g. action link). */
|
||||
idAction?: ReactNode;
|
||||
/** @deprecated No longer used — layout handles overflow naturally */
|
||||
maxWidth?: string;
|
||||
/** @deprecated No longer used */
|
||||
@@ -40,6 +42,7 @@ export const EntityInfo = ({
|
||||
badge,
|
||||
idLabel = "UID",
|
||||
showCopyAction = true,
|
||||
idAction,
|
||||
}: EntityInfoProps) => {
|
||||
const canCopy = Boolean(entityId && showCopyAction);
|
||||
const renderedIcon =
|
||||
@@ -73,7 +76,7 @@ export const EntityInfo = ({
|
||||
)}
|
||||
</div>
|
||||
{entityId && (
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="text-text-neutral-tertiary shrink-0 text-xs font-medium">
|
||||
{idLabel}:
|
||||
</span>
|
||||
@@ -82,6 +85,7 @@ export const EntityInfo = ({
|
||||
className="max-w-[160px]"
|
||||
hideCopyButton={!canCopy}
|
||||
/>
|
||||
{idAction && <span className="shrink-0">{idAction}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
import { isValidElement } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
// `asd-essential-eight.tsx` re-exports `toAccordionItems` which builds JSX
|
||||
// referencing client-side accordion components. Those components transitively
|
||||
// import server-only code (next-auth → next/server) and would crash vitest
|
||||
// at load time. Mocking the JSX deps lets us load the module and exercise
|
||||
// the real `mapComplianceData` and `toAccordionItems` functions, which are
|
||||
// what we actually want to test.
|
||||
vi.mock(
|
||||
"@/components/compliance/compliance-accordion/client-accordion-content",
|
||||
() => ({
|
||||
ClientAccordionContent: () => null,
|
||||
}),
|
||||
);
|
||||
vi.mock(
|
||||
"@/components/compliance/compliance-accordion/compliance-accordion-requeriment-title",
|
||||
() => ({
|
||||
ComplianceAccordionRequirementTitle: () => null,
|
||||
}),
|
||||
);
|
||||
vi.mock(
|
||||
"@/components/compliance/compliance-accordion/compliance-accordion-title",
|
||||
() => ({
|
||||
ComplianceAccordionTitle: () => null,
|
||||
}),
|
||||
);
|
||||
|
||||
import {
|
||||
ASDEssentialEightAttributesMetadata,
|
||||
AttributesData,
|
||||
AttributesItemData,
|
||||
REQUIREMENT_STATUS,
|
||||
RequirementItemData,
|
||||
RequirementsData,
|
||||
RequirementStatus,
|
||||
} from "@/types/compliance";
|
||||
|
||||
import { mapComplianceData, toAccordionItems } from "./asd-essential-eight";
|
||||
|
||||
const FRAMEWORK = "ASD-Essential-Eight";
|
||||
|
||||
const baseMetadata = (
|
||||
overrides: Partial<ASDEssentialEightAttributesMetadata> = {},
|
||||
): ASDEssentialEightAttributesMetadata => ({
|
||||
Section: "1 Patch applications",
|
||||
MaturityLevel: "ML1",
|
||||
AssessmentStatus: "Automated",
|
||||
CloudApplicability: "full",
|
||||
MitigatedThreats: ["T1190"],
|
||||
Description: "Provider-specific implementation note.",
|
||||
RationaleStatement: "Why this matters.",
|
||||
ImpactStatement: "Impact when not in place.",
|
||||
RemediationProcedure: "Steps to remediate.",
|
||||
AuditProcedure: "Steps to audit.",
|
||||
AdditionalInformation: "Extra context.",
|
||||
References: "https://example.com/a, https://example.com/b",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const buildAttribute = (
|
||||
id: string,
|
||||
description: string,
|
||||
metadata: ASDEssentialEightAttributesMetadata,
|
||||
checks: string[] = ["check_one"],
|
||||
): AttributesItemData => ({
|
||||
type: "compliance-requirements-attributes",
|
||||
id,
|
||||
attributes: {
|
||||
framework_description: "ASD Essential Eight",
|
||||
framework: FRAMEWORK,
|
||||
version: "1.0",
|
||||
description,
|
||||
attributes: {
|
||||
metadata: [metadata],
|
||||
check_ids: checks,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const buildRequirement = (
|
||||
id: string,
|
||||
status: RequirementStatus = REQUIREMENT_STATUS.PASS,
|
||||
): RequirementItemData => ({
|
||||
type: "compliance-requirements-details",
|
||||
id,
|
||||
attributes: {
|
||||
framework: FRAMEWORK,
|
||||
version: "1.0",
|
||||
description: "Canonical ASD clause text.",
|
||||
status,
|
||||
},
|
||||
});
|
||||
|
||||
const buildInputs = (
|
||||
pairs: Array<{
|
||||
attribute: AttributesItemData;
|
||||
requirement: RequirementItemData;
|
||||
}>,
|
||||
): { attributesData: AttributesData; requirementsData: RequirementsData } => ({
|
||||
attributesData: { data: pairs.map((p) => p.attribute) },
|
||||
requirementsData: { data: pairs.map((p) => p.requirement) },
|
||||
});
|
||||
|
||||
describe("mapComplianceData (ASD Essential Eight)", () => {
|
||||
it("returns an empty list when there are no attributes", () => {
|
||||
const { attributesData, requirementsData } = buildInputs([]);
|
||||
expect(mapComplianceData(attributesData, requirementsData)).toEqual([]);
|
||||
});
|
||||
|
||||
it("creates one framework with one category containing one control per requirement", () => {
|
||||
const attribute = buildAttribute(
|
||||
"E8-PA-1",
|
||||
"Apply patches to applications.",
|
||||
baseMetadata(),
|
||||
);
|
||||
const requirement = buildRequirement("E8-PA-1");
|
||||
const { attributesData, requirementsData } = buildInputs([
|
||||
{ attribute, requirement },
|
||||
]);
|
||||
|
||||
const [framework] = mapComplianceData(attributesData, requirementsData);
|
||||
|
||||
expect(framework.name).toBe(FRAMEWORK);
|
||||
expect(framework.categories).toHaveLength(1);
|
||||
expect(framework.categories[0].controls).toHaveLength(1);
|
||||
expect(framework.categories[0].controls[0].requirements).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("normalizes 'N Foo' Section names to 'N. Foo' for the accordion header", () => {
|
||||
const attribute = buildAttribute(
|
||||
"E8-PA-1",
|
||||
"Apply patches.",
|
||||
baseMetadata({ Section: "1 Patch applications" }),
|
||||
);
|
||||
const requirement = buildRequirement("E8-PA-1");
|
||||
const { attributesData, requirementsData } = buildInputs([
|
||||
{ attribute, requirement },
|
||||
]);
|
||||
|
||||
const [framework] = mapComplianceData(attributesData, requirementsData);
|
||||
expect(framework.categories[0].name).toBe("1. Patch applications");
|
||||
});
|
||||
|
||||
it("uses the literal API description (not the metadata Description) for the requirement description", () => {
|
||||
// Regression: an earlier draft surfaced `attrs.Description` (provider
|
||||
// commentary) in place of the canonical clause. The literal API
|
||||
// description must win.
|
||||
const attribute = buildAttribute(
|
||||
"E8-PA-1",
|
||||
"Canonical clause text.",
|
||||
baseMetadata({ Description: "Provider-specific commentary." }),
|
||||
);
|
||||
const requirement = buildRequirement("E8-PA-1");
|
||||
const { attributesData, requirementsData } = buildInputs([
|
||||
{ attribute, requirement },
|
||||
]);
|
||||
|
||||
const [framework] = mapComplianceData(attributesData, requirementsData);
|
||||
const requirementOut = framework.categories[0].controls[0].requirements[0];
|
||||
|
||||
expect(requirementOut.description).toBe("Canonical clause text.");
|
||||
expect(framework.categories[0].controls[0].label).toBe(
|
||||
"E8-PA-1 - Canonical clause text.",
|
||||
);
|
||||
});
|
||||
|
||||
it("exposes provider commentary as `implementation_notes` (not `aws_description`)", () => {
|
||||
const attribute = buildAttribute(
|
||||
"E8-PA-1",
|
||||
"Canonical clause text.",
|
||||
baseMetadata({ Description: "Provider commentary." }),
|
||||
);
|
||||
const requirement = buildRequirement("E8-PA-1");
|
||||
const { attributesData, requirementsData } = buildInputs([
|
||||
{ attribute, requirement },
|
||||
]);
|
||||
|
||||
const [framework] = mapComplianceData(attributesData, requirementsData);
|
||||
const requirementOut = framework.categories[0].controls[0].requirements[0];
|
||||
|
||||
expect(requirementOut.implementation_notes).toBe("Provider commentary.");
|
||||
// The legacy field name must NOT be set, so a stale UI reading
|
||||
// `aws_description` surfaces the regression instead of silently
|
||||
// falling back to undefined.
|
||||
expect(requirementOut.aws_description).toBeUndefined();
|
||||
});
|
||||
|
||||
it("propagates every metadata field onto the requirement", () => {
|
||||
const metadata = baseMetadata({
|
||||
Section: "2 Patch operating systems",
|
||||
MaturityLevel: "ML1",
|
||||
AssessmentStatus: "Manual",
|
||||
CloudApplicability: "partial",
|
||||
MitigatedThreats: ["T1059", "T1190"],
|
||||
RationaleStatement: "Rationale.",
|
||||
ImpactStatement: "Impact.",
|
||||
RemediationProcedure: "Remediate.",
|
||||
AuditProcedure: "Audit.",
|
||||
AdditionalInformation: "More info.",
|
||||
References: "https://example.com/x",
|
||||
});
|
||||
const attribute = buildAttribute("E8-OS-1", "OS patching.", metadata);
|
||||
const requirement = buildRequirement("E8-OS-1");
|
||||
const { attributesData, requirementsData } = buildInputs([
|
||||
{ attribute, requirement },
|
||||
]);
|
||||
|
||||
const [framework] = mapComplianceData(attributesData, requirementsData);
|
||||
const requirementOut = framework.categories[0].controls[0].requirements[0];
|
||||
|
||||
expect(requirementOut.maturity_level).toBe("ML1");
|
||||
expect(requirementOut.assessment_status).toBe("Manual");
|
||||
expect(requirementOut.cloud_applicability).toBe("partial");
|
||||
expect(requirementOut.mitigated_threats).toEqual(["T1059", "T1190"]);
|
||||
expect(requirementOut.rationale_statement).toBe("Rationale.");
|
||||
expect(requirementOut.impact_statement).toBe("Impact.");
|
||||
expect(requirementOut.remediation_procedure).toBe("Remediate.");
|
||||
expect(requirementOut.audit_procedure).toBe("Audit.");
|
||||
expect(requirementOut.additional_information).toBe("More info.");
|
||||
expect(requirementOut.references).toBe("https://example.com/x");
|
||||
});
|
||||
|
||||
it("skips attributes whose ASD metadata does not match the typed model", () => {
|
||||
const attribute = buildAttribute("E8-BAD-1", "Invalid metadata.", {
|
||||
...baseMetadata(),
|
||||
MaturityLevel: "ML4",
|
||||
} as unknown as ASDEssentialEightAttributesMetadata);
|
||||
const requirement = buildRequirement("E8-BAD-1");
|
||||
const { attributesData, requirementsData } = buildInputs([
|
||||
{ attribute, requirement },
|
||||
]);
|
||||
|
||||
expect(mapComplianceData(attributesData, requirementsData)).toEqual([]);
|
||||
});
|
||||
|
||||
it("derives counters from RequirementStatus, not from metadata flags", () => {
|
||||
const cases: Array<{
|
||||
status: RequirementStatus;
|
||||
expected: "pass" | "fail" | "manual";
|
||||
}> = [
|
||||
{ status: REQUIREMENT_STATUS.PASS, expected: "pass" },
|
||||
{ status: REQUIREMENT_STATUS.FAIL, expected: "fail" },
|
||||
{ status: REQUIREMENT_STATUS.MANUAL, expected: "manual" },
|
||||
];
|
||||
|
||||
for (const { status, expected } of cases) {
|
||||
const attribute = buildAttribute(
|
||||
`E8-${status}`,
|
||||
"clause",
|
||||
baseMetadata(),
|
||||
);
|
||||
const requirement = buildRequirement(`E8-${status}`, status);
|
||||
const { attributesData, requirementsData } = buildInputs([
|
||||
{ attribute, requirement },
|
||||
]);
|
||||
|
||||
const [framework] = mapComplianceData(attributesData, requirementsData);
|
||||
const requirementOut =
|
||||
framework.categories[0].controls[0].requirements[0];
|
||||
|
||||
expect(requirementOut.pass).toBe(expected === "pass" ? 1 : 0);
|
||||
expect(requirementOut.fail).toBe(expected === "fail" ? 1 : 0);
|
||||
expect(requirementOut.manual).toBe(expected === "manual" ? 1 : 0);
|
||||
}
|
||||
});
|
||||
|
||||
it("groups requirements with the same Section under one category", () => {
|
||||
const attrA = buildAttribute(
|
||||
"E8-PA-1",
|
||||
"App patching A.",
|
||||
baseMetadata({ Section: "1 Patch applications" }),
|
||||
);
|
||||
const attrB = buildAttribute(
|
||||
"E8-PA-2",
|
||||
"App patching B.",
|
||||
baseMetadata({ Section: "1 Patch applications" }),
|
||||
);
|
||||
const attrC = buildAttribute(
|
||||
"E8-OS-1",
|
||||
"OS patching.",
|
||||
baseMetadata({ Section: "2 Patch operating systems" }),
|
||||
);
|
||||
|
||||
const { attributesData, requirementsData } = buildInputs([
|
||||
{ attribute: attrA, requirement: buildRequirement("E8-PA-1") },
|
||||
{ attribute: attrB, requirement: buildRequirement("E8-PA-2") },
|
||||
{ attribute: attrC, requirement: buildRequirement("E8-OS-1") },
|
||||
]);
|
||||
|
||||
const [framework] = mapComplianceData(attributesData, requirementsData);
|
||||
|
||||
expect(framework.categories.map((c) => c.name)).toEqual([
|
||||
"1. Patch applications",
|
||||
"2. Patch operating systems",
|
||||
]);
|
||||
expect(framework.categories[0].controls).toHaveLength(2);
|
||||
expect(framework.categories[1].controls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("skips attribute items whose metadata is missing", () => {
|
||||
const valid = buildAttribute("E8-PA-1", "valid", baseMetadata());
|
||||
const broken: AttributesItemData = {
|
||||
...buildAttribute("E8-PA-2", "broken", baseMetadata()),
|
||||
attributes: {
|
||||
...buildAttribute("E8-PA-2", "broken", baseMetadata()).attributes,
|
||||
attributes: {
|
||||
metadata: [],
|
||||
check_ids: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const { attributesData, requirementsData } = buildInputs([
|
||||
{ attribute: valid, requirement: buildRequirement("E8-PA-1") },
|
||||
{ attribute: broken, requirement: buildRequirement("E8-PA-2") },
|
||||
]);
|
||||
|
||||
const [framework] = mapComplianceData(attributesData, requirementsData);
|
||||
expect(framework.categories[0].controls).toHaveLength(1);
|
||||
expect(framework.categories[0].controls[0].requirements[0].name).toBe(
|
||||
"E8-PA-1",
|
||||
);
|
||||
});
|
||||
|
||||
it("skips attribute items without a matching requirement entry", () => {
|
||||
const attribute = buildAttribute("E8-PA-1", "clause", baseMetadata());
|
||||
const orphan = buildAttribute("E8-PA-2", "orphan", baseMetadata());
|
||||
|
||||
const result = mapComplianceData(
|
||||
{ data: [attribute, orphan] },
|
||||
{ data: [buildRequirement("E8-PA-1")] },
|
||||
);
|
||||
|
||||
expect(result[0].categories[0].controls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("accepts a `_filter` parameter without altering output (placeholder for ML2/ML3)", () => {
|
||||
const attribute = buildAttribute("E8-PA-1", "clause", baseMetadata());
|
||||
const { attributesData, requirementsData } = buildInputs([
|
||||
{ attribute, requirement: buildRequirement("E8-PA-1") },
|
||||
]);
|
||||
|
||||
const withoutFilter = mapComplianceData(attributesData, requirementsData);
|
||||
const withFilter = mapComplianceData(
|
||||
attributesData,
|
||||
requirementsData,
|
||||
"ML2",
|
||||
);
|
||||
|
||||
expect(withFilter).toEqual(withoutFilter);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toAccordionItems (ASD Essential Eight)", () => {
|
||||
it("produces one accordion item per category", () => {
|
||||
const attrA = buildAttribute(
|
||||
"E8-PA-1",
|
||||
"App patching.",
|
||||
baseMetadata({ Section: "1 Patch applications" }),
|
||||
);
|
||||
const attrB = buildAttribute(
|
||||
"E8-OS-1",
|
||||
"OS patching.",
|
||||
baseMetadata({ Section: "2 Patch operating systems" }),
|
||||
);
|
||||
|
||||
const frameworks = mapComplianceData(
|
||||
{ data: [attrA, attrB] },
|
||||
{
|
||||
data: [buildRequirement("E8-PA-1"), buildRequirement("E8-OS-1")],
|
||||
},
|
||||
);
|
||||
|
||||
const items = toAccordionItems(frameworks, "scan-1");
|
||||
|
||||
expect(items).toHaveLength(2);
|
||||
expect(items[0].key).toBe(`${FRAMEWORK}-1. Patch applications`);
|
||||
expect(items[1].key).toBe(`${FRAMEWORK}-2. Patch operating systems`);
|
||||
// Every accordion item exposes a renderable React element title and
|
||||
// children — both of which we assert structurally (we mocked the
|
||||
// underlying components, but the elements themselves must exist).
|
||||
expect(isValidElement(items[0].title)).toBe(true);
|
||||
expect(items[0].items).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("returns an empty list when given no frameworks", () => {
|
||||
expect(toAccordionItems([], "scan-1")).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
import { ClientAccordionContent } from "@/components/compliance/compliance-accordion/client-accordion-content";
|
||||
import { ComplianceAccordionRequirementTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-requeriment-title";
|
||||
import { ComplianceAccordionTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-title";
|
||||
import type { AccordionItemProps } from "@/components/ui/accordion/Accordion";
|
||||
import type { FindingStatus } from "@/components/ui/table/status-finding-badge";
|
||||
import {
|
||||
type ASDEssentialEightRequirement,
|
||||
type AttributesData,
|
||||
type Framework,
|
||||
isASDEssentialEightAttributesMetadata,
|
||||
type Requirement,
|
||||
REQUIREMENT_STATUS,
|
||||
type RequirementsData,
|
||||
} from "@/types/compliance";
|
||||
|
||||
import {
|
||||
calculateFrameworkCounters,
|
||||
createRequirementsMap,
|
||||
findOrCreateCategory,
|
||||
findOrCreateFramework,
|
||||
updateCounters,
|
||||
} from "./commons";
|
||||
|
||||
// TODO(PROWLER-1470): `_filter` is reserved for future Maturity Level
|
||||
// filtering (analogous to CIS's Profile filter). Today the JSON only
|
||||
// contains ML1 requirements, so the parameter is a no-op; once ML2/ML3
|
||||
// ship, mirror the CIS pattern of skipping requirements whose
|
||||
// `attrs.MaturityLevel` !== filter. The leading underscore tells eslint
|
||||
// and TypeScript-ESLint that the parameter is intentionally unused.
|
||||
export const mapComplianceData = (
|
||||
attributesData: AttributesData,
|
||||
requirementsData: RequirementsData,
|
||||
_filter?: string,
|
||||
): Framework[] => {
|
||||
const attributes = attributesData?.data || [];
|
||||
const requirementsMap = createRequirementsMap(requirementsData);
|
||||
const frameworks: Framework[] = [];
|
||||
|
||||
// Process attributes and merge with requirements data
|
||||
for (const attributeItem of attributes) {
|
||||
const id = attributeItem.id;
|
||||
const metadataArray = attributeItem.attributes?.attributes?.metadata;
|
||||
const attrs = metadataArray?.[0];
|
||||
if (!isASDEssentialEightAttributesMetadata(attrs)) continue;
|
||||
|
||||
// Get corresponding requirement data
|
||||
const requirementData = requirementsMap.get(id);
|
||||
if (!requirementData) continue;
|
||||
|
||||
const frameworkName = attributeItem.attributes.framework;
|
||||
const sectionName = attrs.Section;
|
||||
const description = attributeItem.attributes.description;
|
||||
const status = requirementData.attributes.status;
|
||||
const checks = attributeItem.attributes.attributes.check_ids;
|
||||
const requirementName = id;
|
||||
|
||||
// Find or create framework using common helper
|
||||
const framework = findOrCreateFramework(frameworks, frameworkName);
|
||||
|
||||
// Sections in the source JSON are formatted "1 Patch applications";
|
||||
// normalize to "1. Patch applications" so the leading clause number reads
|
||||
// as a sentence in the accordion header. Order is preserved by JSON
|
||||
// document order (categories materialize in insertion order via
|
||||
// `findOrCreateCategory`); this rewrite is purely cosmetic.
|
||||
const normalizedSectionName = sectionName.replace(/^(\d+)\s/, "$1. ");
|
||||
const category = findOrCreateCategory(
|
||||
framework.categories,
|
||||
normalizedSectionName,
|
||||
);
|
||||
|
||||
// Each requirement is its own control (matches CIS rendering): keeps
|
||||
// the framework's clause-level granularity visible in the accordion.
|
||||
// The accordion title and the requirement.description must surface the
|
||||
// *literal ASD clause* (`description`, the canonical standard text).
|
||||
// The Attributes[].Description field carries Prowler's
|
||||
// provider-specific implementation note; we expose it separately as
|
||||
// `implementation_notes` so the details panel can render it under
|
||||
// "Implementation Notes" without coupling the field to a single
|
||||
// provider.
|
||||
const controlLabel = `${id} - ${description}`;
|
||||
const control = {
|
||||
label: controlLabel,
|
||||
pass: 0,
|
||||
fail: 0,
|
||||
manual: 0,
|
||||
requirements: [] as Requirement[],
|
||||
};
|
||||
|
||||
const requirement = {
|
||||
name: requirementName,
|
||||
description: description,
|
||||
status: status,
|
||||
check_ids: checks,
|
||||
pass: status === REQUIREMENT_STATUS.PASS ? 1 : 0,
|
||||
fail: status === REQUIREMENT_STATUS.FAIL ? 1 : 0,
|
||||
manual: status === REQUIREMENT_STATUS.MANUAL ? 1 : 0,
|
||||
maturity_level: attrs.MaturityLevel,
|
||||
assessment_status: attrs.AssessmentStatus,
|
||||
cloud_applicability: attrs.CloudApplicability,
|
||||
mitigated_threats: attrs.MitigatedThreats,
|
||||
implementation_notes: attrs.Description,
|
||||
rationale_statement: attrs.RationaleStatement,
|
||||
impact_statement: attrs.ImpactStatement,
|
||||
remediation_procedure: attrs.RemediationProcedure,
|
||||
audit_procedure: attrs.AuditProcedure,
|
||||
additional_information: attrs.AdditionalInformation,
|
||||
references: attrs.References,
|
||||
} satisfies ASDEssentialEightRequirement;
|
||||
|
||||
control.requirements.push(requirement);
|
||||
|
||||
// Update control counters using common helper
|
||||
updateCounters(control, requirement.status);
|
||||
|
||||
category.controls.push(control);
|
||||
}
|
||||
|
||||
// Calculate counters using common helper
|
||||
calculateFrameworkCounters(frameworks);
|
||||
|
||||
return frameworks;
|
||||
};
|
||||
|
||||
export const toAccordionItems = (
|
||||
data: Framework[],
|
||||
scanId: string | undefined,
|
||||
): AccordionItemProps[] => {
|
||||
return data.flatMap((framework) =>
|
||||
framework.categories.map((category) => {
|
||||
return {
|
||||
key: `${framework.name}-${category.name}`,
|
||||
title: (
|
||||
<ComplianceAccordionTitle
|
||||
label={category.name}
|
||||
pass={category.pass}
|
||||
fail={category.fail}
|
||||
manual={category.manual}
|
||||
isParentLevel={true}
|
||||
/>
|
||||
),
|
||||
content: "",
|
||||
items: category.controls.map((control, i: number) => {
|
||||
const requirement = control.requirements[0]; // Each control has one requirement
|
||||
const itemKey = `${framework.name}-${category.name}-control-${i}`;
|
||||
|
||||
return {
|
||||
key: itemKey,
|
||||
title: (
|
||||
<ComplianceAccordionRequirementTitle
|
||||
type=""
|
||||
name={control.label}
|
||||
status={requirement.status as FindingStatus}
|
||||
/>
|
||||
),
|
||||
content: (
|
||||
<ClientAccordionContent
|
||||
key={`content-${itemKey}`}
|
||||
requirement={requirement}
|
||||
scanId={scanId || ""}
|
||||
framework={framework.name}
|
||||
disableFindings={
|
||||
requirement.check_ids.length === 0 && requirement.manual === 0
|
||||
}
|
||||
/>
|
||||
),
|
||||
items: [],
|
||||
};
|
||||
}),
|
||||
};
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,196 @@
|
||||
import { isValidElement, ReactElement } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Custom-details components and the `ClientAccordionContent` chain
|
||||
// transitively import server-only code (next-auth → next/server). Mocking
|
||||
// them with identifiable stubs lets us load the registry under vitest and
|
||||
// assert that `getDetailsComponent` returns the *correct* stub for each
|
||||
// framework — i.e. that the wiring is actually behavioral.
|
||||
type DetailsStubProps = { requirement: { name?: string } };
|
||||
|
||||
// `vi.hoisted` runs *before* the hoisted `vi.mock` factories, so we can
|
||||
// safely close over `stubFactory` from inside each mock without tripping
|
||||
// the temporal-dead-zone error vitest raises for top-level helpers.
|
||||
const { stubFactory } = vi.hoisted(() => ({
|
||||
stubFactory: (label: string) => {
|
||||
const Stub = (_props: DetailsStubProps) => null;
|
||||
Stub.displayName = label;
|
||||
return Stub;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock(
|
||||
"@/components/compliance/compliance-custom-details/asd-essential-eight-details",
|
||||
() => ({ ASDEssentialEightCustomDetails: stubFactory("ASDStub") }),
|
||||
);
|
||||
vi.mock(
|
||||
"@/components/compliance/compliance-custom-details/aws-well-architected-details",
|
||||
() => ({ AWSWellArchitectedCustomDetails: stubFactory("AWSWAStub") }),
|
||||
);
|
||||
vi.mock("@/components/compliance/compliance-custom-details/c5-details", () => ({
|
||||
C5CustomDetails: stubFactory("C5Stub"),
|
||||
}));
|
||||
vi.mock(
|
||||
"@/components/compliance/compliance-custom-details/ccc-details",
|
||||
() => ({ CCCCustomDetails: stubFactory("CCCStub") }),
|
||||
);
|
||||
vi.mock(
|
||||
"@/components/compliance/compliance-custom-details/cis-details",
|
||||
() => ({ CISCustomDetails: stubFactory("CISStub") }),
|
||||
);
|
||||
vi.mock(
|
||||
"@/components/compliance/compliance-custom-details/csa-details",
|
||||
() => ({ CSACustomDetails: stubFactory("CSAStub") }),
|
||||
);
|
||||
vi.mock(
|
||||
"@/components/compliance/compliance-custom-details/ens-details",
|
||||
() => ({ ENSCustomDetails: stubFactory("ENSStub") }),
|
||||
);
|
||||
vi.mock(
|
||||
"@/components/compliance/compliance-custom-details/generic-details",
|
||||
() => ({ GenericCustomDetails: stubFactory("GenericStub") }),
|
||||
);
|
||||
vi.mock(
|
||||
"@/components/compliance/compliance-custom-details/iso-details",
|
||||
() => ({ ISOCustomDetails: stubFactory("ISOStub") }),
|
||||
);
|
||||
vi.mock(
|
||||
"@/components/compliance/compliance-custom-details/kisa-details",
|
||||
() => ({ KISACustomDetails: stubFactory("KISAStub") }),
|
||||
);
|
||||
vi.mock(
|
||||
"@/components/compliance/compliance-custom-details/mitre-details",
|
||||
() => ({ MITRECustomDetails: stubFactory("MITREStub") }),
|
||||
);
|
||||
vi.mock(
|
||||
"@/components/compliance/compliance-custom-details/threat-details",
|
||||
() => ({ ThreatCustomDetails: stubFactory("ThreatStub") }),
|
||||
);
|
||||
|
||||
// Each per-framework mapper file (cis.tsx, ens.tsx, etc.) re-exports JSX
|
||||
// builders that pull in the same client-side accordion chain. Stub them
|
||||
// out so the registry module can load without booting Next's server-only
|
||||
// runtime — the registry is what we actually test here.
|
||||
vi.mock(
|
||||
"@/components/compliance/compliance-accordion/client-accordion-content",
|
||||
() => ({ ClientAccordionContent: () => null }),
|
||||
);
|
||||
vi.mock(
|
||||
"@/components/compliance/compliance-accordion/compliance-accordion-requeriment-title",
|
||||
() => ({ ComplianceAccordionRequirementTitle: () => null }),
|
||||
);
|
||||
vi.mock(
|
||||
"@/components/compliance/compliance-accordion/compliance-accordion-title",
|
||||
() => ({ ComplianceAccordionTitle: () => null }),
|
||||
);
|
||||
|
||||
import { Requirement } from "@/types/compliance";
|
||||
|
||||
import { getComplianceMapper } from "./compliance-mapper";
|
||||
|
||||
const fakeRequirement: Requirement = {
|
||||
name: "test",
|
||||
description: "test",
|
||||
status: "PASS",
|
||||
pass: 1,
|
||||
fail: 0,
|
||||
manual: 0,
|
||||
check_ids: [],
|
||||
};
|
||||
|
||||
const detailsStubName = (component: unknown): string | undefined => {
|
||||
if (!isValidElement(component)) return undefined;
|
||||
// `type` of a React element holds the component function (the stub we
|
||||
// registered above); `displayName` is what we keyed each stub on.
|
||||
const element = component as ReactElement<DetailsStubProps>;
|
||||
const type = element.type as { displayName?: string };
|
||||
return type.displayName;
|
||||
};
|
||||
|
||||
describe("getComplianceMapper", () => {
|
||||
it("falls back to the generic mapper when no framework is supplied", () => {
|
||||
const mapper = getComplianceMapper(undefined);
|
||||
expect(detailsStubName(mapper.getDetailsComponent(fakeRequirement))).toBe(
|
||||
"GenericStub",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the generic mapper for an unknown framework", () => {
|
||||
const mapper = getComplianceMapper("Made-Up-Framework");
|
||||
expect(detailsStubName(mapper.getDetailsComponent(fakeRequirement))).toBe(
|
||||
"GenericStub",
|
||||
);
|
||||
});
|
||||
|
||||
it("wires each registered framework to its dedicated details component", () => {
|
||||
// The keys MUST match the `framework` field the API returns
|
||||
// (case- and hyphen-sensitive).
|
||||
const wiring: Array<{ framework: string; expected: string }> = [
|
||||
{ framework: "ASD-Essential-Eight", expected: "ASDStub" },
|
||||
{ framework: "C5", expected: "C5Stub" },
|
||||
{ framework: "ENS", expected: "ENSStub" },
|
||||
{ framework: "ISO27001", expected: "ISOStub" },
|
||||
{ framework: "CIS", expected: "CISStub" },
|
||||
{
|
||||
framework: "AWS-Well-Architected-Framework-Security-Pillar",
|
||||
expected: "AWSWAStub",
|
||||
},
|
||||
{
|
||||
framework: "AWS-Well-Architected-Framework-Reliability-Pillar",
|
||||
expected: "AWSWAStub",
|
||||
},
|
||||
{ framework: "KISA-ISMS-P", expected: "KISAStub" },
|
||||
{ framework: "MITRE-ATTACK", expected: "MITREStub" },
|
||||
{ framework: "ProwlerThreatScore", expected: "ThreatStub" },
|
||||
{ framework: "CCC", expected: "CCCStub" },
|
||||
{ framework: "CSA-CCM", expected: "CSAStub" },
|
||||
];
|
||||
|
||||
for (const { framework, expected } of wiring) {
|
||||
const mapper = getComplianceMapper(framework);
|
||||
expect(
|
||||
detailsStubName(mapper.getDetailsComponent(fakeRequirement)),
|
||||
`framework "${framework}" should resolve to ${expected}`,
|
||||
).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it("exposes the four functions every consumer relies on", () => {
|
||||
const mapper = getComplianceMapper("ASD-Essential-Eight");
|
||||
expect(typeof mapper.mapComplianceData).toBe("function");
|
||||
expect(typeof mapper.toAccordionItems).toBe("function");
|
||||
expect(typeof mapper.getTopFailedSections).toBe("function");
|
||||
expect(typeof mapper.calculateCategoryHeatmapData).toBe("function");
|
||||
expect(typeof mapper.getDetailsComponent).toBe("function");
|
||||
});
|
||||
|
||||
it("returns the same reference shape for every supported framework", () => {
|
||||
// A regression sentinel: if a future entry forgets one of the five
|
||||
// functions the registry contract requires, this assertion catches
|
||||
// it before the runtime errors leak into the UI.
|
||||
const expectedKeys = [
|
||||
"mapComplianceData",
|
||||
"toAccordionItems",
|
||||
"getTopFailedSections",
|
||||
"calculateCategoryHeatmapData",
|
||||
"getDetailsComponent",
|
||||
].sort();
|
||||
|
||||
for (const framework of [
|
||||
"ASD-Essential-Eight",
|
||||
"C5",
|
||||
"ENS",
|
||||
"ISO27001",
|
||||
"CIS",
|
||||
"AWS-Well-Architected-Framework-Security-Pillar",
|
||||
"KISA-ISMS-P",
|
||||
"MITRE-ATTACK",
|
||||
"ProwlerThreatScore",
|
||||
"CCC",
|
||||
"CSA-CCM",
|
||||
]) {
|
||||
const mapper = getComplianceMapper(framework);
|
||||
expect(Object.keys(mapper).sort(), framework).toEqual(expectedKeys);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createElement, ReactNode } from "react";
|
||||
|
||||
import { ASDEssentialEightCustomDetails } from "@/components/compliance/compliance-custom-details/asd-essential-eight-details";
|
||||
import { AWSWellArchitectedCustomDetails } from "@/components/compliance/compliance-custom-details/aws-well-architected-details";
|
||||
import { C5CustomDetails } from "@/components/compliance/compliance-custom-details/c5-details";
|
||||
import { CCCCustomDetails } from "@/components/compliance/compliance-custom-details/ccc-details";
|
||||
@@ -21,6 +22,10 @@ import {
|
||||
TopFailedResult,
|
||||
} from "@/types/compliance";
|
||||
|
||||
import {
|
||||
mapComplianceData as mapASDEssentialEightComplianceData,
|
||||
toAccordionItems as toASDEssentialEightAccordionItems,
|
||||
} from "./asd-essential-eight";
|
||||
import {
|
||||
mapComplianceData as mapAWSWellArchitectedComplianceData,
|
||||
toAccordionItems as toAWSWellArchitectedAccordionItems,
|
||||
@@ -65,6 +70,7 @@ import {
|
||||
toAccordionItems as toMITREAccordionItems,
|
||||
} from "./mitre";
|
||||
import {
|
||||
getTopFailedSections as getThreatScoreTopFailedSections,
|
||||
mapComplianceData as mapThetaComplianceData,
|
||||
toAccordionItems as toThetaAccordionItems,
|
||||
} from "./threat";
|
||||
@@ -95,6 +101,15 @@ const getDefaultMapper = (): ComplianceMapper => ({
|
||||
});
|
||||
|
||||
const getComplianceMappers = (): Record<string, ComplianceMapper> => ({
|
||||
"ASD-Essential-Eight": {
|
||||
mapComplianceData: mapASDEssentialEightComplianceData,
|
||||
toAccordionItems: toASDEssentialEightAccordionItems,
|
||||
getTopFailedSections,
|
||||
calculateCategoryHeatmapData: (data: Framework[]) =>
|
||||
calculateCategoryHeatmapData(data),
|
||||
getDetailsComponent: (requirement: Requirement) =>
|
||||
createElement(ASDEssentialEightCustomDetails, { requirement }),
|
||||
},
|
||||
C5: {
|
||||
mapComplianceData: mapC5ComplianceData,
|
||||
toAccordionItems: toC5AccordionItems,
|
||||
@@ -169,7 +184,7 @@ const getComplianceMappers = (): Record<string, ComplianceMapper> => ({
|
||||
ProwlerThreatScore: {
|
||||
mapComplianceData: mapThetaComplianceData,
|
||||
toAccordionItems: toThetaAccordionItems,
|
||||
getTopFailedSections,
|
||||
getTopFailedSections: getThreatScoreTopFailedSections,
|
||||
calculateCategoryHeatmapData: (complianceData: Framework[]) =>
|
||||
calculateCategoryHeatmapData(complianceData),
|
||||
getDetailsComponent: (requirement: Requirement) =>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import {
|
||||
FailedSection,
|
||||
Framework,
|
||||
REQUIREMENT_STATUS,
|
||||
TOP_FAILED_DATA_TYPE,
|
||||
TopFailedResult,
|
||||
} from "@/types/compliance";
|
||||
|
||||
import {
|
||||
compareSectionsByCanonicalOrder,
|
||||
THREATSCORE_PILLARS,
|
||||
} from "./threatscore-pillars";
|
||||
|
||||
// Builds the Top Failed Sections data for ThreatScore: every canonical pillar
|
||||
// is always present (zero-fill) so the chart remains meaningful even when
|
||||
// only one or two pillars have failures. Sections returned by the data that
|
||||
// are not in the canonical list are appended afterwards in canonical order.
|
||||
export const getTopFailedSections = (
|
||||
mappedData: Framework[],
|
||||
): TopFailedResult => {
|
||||
const totals = new Map<string, number>();
|
||||
const seen = new Set<string>();
|
||||
|
||||
THREATSCORE_PILLARS.forEach((name) => {
|
||||
totals.set(name, 0);
|
||||
seen.add(name);
|
||||
});
|
||||
|
||||
mappedData.forEach((framework) => {
|
||||
framework.categories.forEach((category) => {
|
||||
seen.add(category.name);
|
||||
category.controls.forEach((control) => {
|
||||
control.requirements.forEach((requirement) => {
|
||||
if (requirement.status === REQUIREMENT_STATUS.FAIL) {
|
||||
totals.set(category.name, (totals.get(category.name) ?? 0) + 1);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const items: FailedSection[] = Array.from(seen)
|
||||
.sort(compareSectionsByCanonicalOrder)
|
||||
.map((name) => ({ name, total: totals.get(name) ?? 0 }));
|
||||
|
||||
return { items, type: TOP_FAILED_DATA_TYPE.SECTIONS, prepopulated: true };
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { Framework, REQUIREMENT_STATUS } from "@/types/compliance";
|
||||
|
||||
import { getTopFailedSections } from "./threat-helpers";
|
||||
import { THREATSCORE_PILLARS } from "./threatscore-pillars";
|
||||
|
||||
const buildFramework = (
|
||||
categoriesSpec: Array<{
|
||||
name: string;
|
||||
statuses: Array<"PASS" | "FAIL" | "MANUAL">;
|
||||
}>,
|
||||
): Framework => ({
|
||||
name: "ProwlerThreatScore",
|
||||
pass: 0,
|
||||
fail: 0,
|
||||
manual: 0,
|
||||
categories: categoriesSpec.map((spec) => ({
|
||||
name: spec.name,
|
||||
pass: 0,
|
||||
fail: 0,
|
||||
manual: 0,
|
||||
controls: [
|
||||
{
|
||||
label: "control-0",
|
||||
pass: 0,
|
||||
fail: 0,
|
||||
manual: 0,
|
||||
requirements: spec.statuses.map((status, i) => ({
|
||||
name: `${spec.name}-req-${i}`,
|
||||
description: "",
|
||||
status: REQUIREMENT_STATUS[status],
|
||||
check_ids: [],
|
||||
pass: 0,
|
||||
fail: 0,
|
||||
manual: 0,
|
||||
})),
|
||||
},
|
||||
],
|
||||
})),
|
||||
});
|
||||
|
||||
describe("threat.getTopFailedSections", () => {
|
||||
it("returns every canonical pillar with zero-fill when no failures", () => {
|
||||
const data = [buildFramework([{ name: "1. IAM", statuses: ["PASS"] }])];
|
||||
const result = getTopFailedSections(data);
|
||||
|
||||
expect(result.items.map((i) => i.name)).toEqual([...THREATSCORE_PILLARS]);
|
||||
expect(result.items.every((i) => i.total === 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("counts FAIL requirements per category and keeps canonical order", () => {
|
||||
const data = [
|
||||
buildFramework([
|
||||
{ name: "1. IAM", statuses: ["FAIL", "FAIL"] },
|
||||
{ name: "4. Encryption", statuses: ["FAIL"] },
|
||||
]),
|
||||
];
|
||||
const result = getTopFailedSections(data);
|
||||
|
||||
expect(result.items).toEqual([
|
||||
{ name: "1. IAM", total: 2 },
|
||||
{ name: "2. Attack Surface", total: 0 },
|
||||
{ name: "3. Logging and Monitoring", total: 0 },
|
||||
{ name: "4. Encryption", total: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("appends non-canonical sections after the canonical ones", () => {
|
||||
const data = [
|
||||
buildFramework([
|
||||
{ name: "1. IAM", statuses: ["FAIL"] },
|
||||
{ name: "5. Data Protection", statuses: ["FAIL", "FAIL"] },
|
||||
]),
|
||||
];
|
||||
const result = getTopFailedSections(data);
|
||||
|
||||
expect(result.items.map((i) => i.name)).toEqual([
|
||||
"1. IAM",
|
||||
"2. Attack Surface",
|
||||
"3. Logging and Monitoring",
|
||||
"4. Encryption",
|
||||
"5. Data Protection",
|
||||
]);
|
||||
expect(
|
||||
result.items.find((i) => i.name === "5. Data Protection")?.total,
|
||||
).toBe(2);
|
||||
});
|
||||
|
||||
it("ignores PASS and MANUAL when counting failures", () => {
|
||||
const data = [
|
||||
buildFramework([
|
||||
{ name: "1. IAM", statuses: ["PASS", "MANUAL", "FAIL", "PASS"] },
|
||||
]),
|
||||
];
|
||||
const result = getTopFailedSections(data);
|
||||
|
||||
expect(result.items.find((i) => i.name === "1. IAM")?.total).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,9 @@ import {
|
||||
findOrCreateFramework,
|
||||
updateCounters,
|
||||
} from "./commons";
|
||||
import { compareSectionsByCanonicalOrder } from "./threatscore-pillars";
|
||||
|
||||
export { getTopFailedSections } from "./threat-helpers";
|
||||
|
||||
export const mapComplianceData = (
|
||||
attributesData: AttributesData,
|
||||
@@ -91,6 +94,14 @@ export const mapComplianceData = (
|
||||
control.requirements.push(requirement);
|
||||
}
|
||||
|
||||
// Sort categories within each framework by canonical pillar order so
|
||||
// the accordion, charts and breakdown all agree on the same ordering.
|
||||
frameworks.forEach((framework) => {
|
||||
framework.categories.sort((a, b) =>
|
||||
compareSectionsByCanonicalOrder(a.name, b.name),
|
||||
);
|
||||
});
|
||||
|
||||
// Calculate counters and percentualScore (Threat-specific logic)
|
||||
frameworks.forEach((framework) => {
|
||||
framework.pass = 0;
|
||||
@@ -149,9 +160,7 @@ export const mapComplianceData = (
|
||||
? (numerator / denominator) * 100
|
||||
: 0;
|
||||
|
||||
// Add percentualScore to category (we can extend the type or use a custom property)
|
||||
(category as any).percentualScore =
|
||||
Math.round(percentualScore * 100) / 100; // Round to 2 decimal places
|
||||
category.percentualScore = Math.round(percentualScore * 100) / 100;
|
||||
|
||||
framework.pass += category.pass;
|
||||
framework.fail += category.fail;
|
||||
@@ -168,7 +177,7 @@ export const toAccordionItems = (
|
||||
): AccordionItemProps[] => {
|
||||
return data.flatMap((framework) =>
|
||||
framework.categories.map((category) => {
|
||||
const percentualScore = (category as any).percentualScore || 0;
|
||||
const percentualScore = category.percentualScore ?? 0;
|
||||
|
||||
return {
|
||||
key: `${framework.name}-${category.name}`,
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
compareSectionsByCanonicalOrder,
|
||||
getOrderedPillars,
|
||||
THREATSCORE_PILLARS,
|
||||
} from "./threatscore-pillars";
|
||||
|
||||
describe("getOrderedPillars", () => {
|
||||
it("returns every canonical pillar in canonical order, treating missing canonical pillars as 100% (no findings = secure)", () => {
|
||||
const result = getOrderedPillars({ "1. IAM": 90, "4. Encryption": 60 });
|
||||
|
||||
expect(result.map((p) => p.name)).toEqual([...THREATSCORE_PILLARS]);
|
||||
expect(result[0]).toEqual({ name: "1. IAM", score: 90, hasData: true });
|
||||
expect(result[1]).toEqual({
|
||||
name: "2. Attack Surface",
|
||||
score: 100,
|
||||
hasData: true,
|
||||
});
|
||||
expect(result[2]).toEqual({
|
||||
name: "3. Logging and Monitoring",
|
||||
score: 100,
|
||||
hasData: true,
|
||||
});
|
||||
expect(result[3]).toEqual({
|
||||
name: "4. Encryption",
|
||||
score: 60,
|
||||
hasData: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("appends non-canonical sections after the canonical ones, sorted naturally", () => {
|
||||
const result = getOrderedPillars({
|
||||
"1. IAM": 50,
|
||||
"10. Future Pillar": 70,
|
||||
"5. Data Protection": 80,
|
||||
});
|
||||
|
||||
expect(result.map((p) => p.name)).toEqual([
|
||||
"1. IAM",
|
||||
"2. Attack Surface",
|
||||
"3. Logging and Monitoring",
|
||||
"4. Encryption",
|
||||
"5. Data Protection",
|
||||
"10. Future Pillar",
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles undefined sectionScores gracefully", () => {
|
||||
const result = getOrderedPillars(undefined);
|
||||
|
||||
expect(result).toHaveLength(THREATSCORE_PILLARS.length);
|
||||
expect(result.every((p) => !p.hasData)).toBe(true);
|
||||
});
|
||||
|
||||
it("treats non-numeric or non-finite scores as missing data", () => {
|
||||
// Defensive: API contract is Record<string, number>, but null/string/NaN
|
||||
// should never crash a `score.toFixed(...)` consumer.
|
||||
const result = getOrderedPillars({
|
||||
"1. IAM": Number.NaN as unknown as number,
|
||||
"2. Attack Surface": null as unknown as number,
|
||||
"3. Logging and Monitoring": "80" as unknown as number,
|
||||
"4. Encryption": 60,
|
||||
});
|
||||
|
||||
expect(result[0]).toEqual({ name: "1. IAM", score: 0, hasData: false });
|
||||
expect(result[1]).toEqual({
|
||||
name: "2. Attack Surface",
|
||||
score: 0,
|
||||
hasData: false,
|
||||
});
|
||||
expect(result[2]).toEqual({
|
||||
name: "3. Logging and Monitoring",
|
||||
score: 0,
|
||||
hasData: false,
|
||||
});
|
||||
expect(result[3]).toEqual({
|
||||
name: "4. Encryption",
|
||||
score: 60,
|
||||
hasData: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("compareSectionsByCanonicalOrder", () => {
|
||||
it("orders canonical pillars by their declared position", () => {
|
||||
const sections = [
|
||||
"4. Encryption",
|
||||
"2. Attack Surface",
|
||||
"1. IAM",
|
||||
"3. Logging and Monitoring",
|
||||
];
|
||||
sections.sort(compareSectionsByCanonicalOrder);
|
||||
expect(sections).toEqual([...THREATSCORE_PILLARS]);
|
||||
});
|
||||
|
||||
it("places unknown sections after canonical ones, in natural order", () => {
|
||||
const sections = [
|
||||
"Custom Section",
|
||||
"10. Tenth",
|
||||
"1. IAM",
|
||||
"5. Data Protection",
|
||||
];
|
||||
sections.sort(compareSectionsByCanonicalOrder);
|
||||
expect(sections).toEqual([
|
||||
"1. IAM",
|
||||
"5. Data Protection",
|
||||
"10. Tenth",
|
||||
"Custom Section",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { SectionScores } from "@/actions/overview/threat-score";
|
||||
|
||||
export const THREATSCORE_PILLARS = [
|
||||
"1. IAM",
|
||||
"2. Attack Surface",
|
||||
"3. Logging and Monitoring",
|
||||
"4. Encryption",
|
||||
] as const;
|
||||
|
||||
export interface OrderedPillar {
|
||||
name: string;
|
||||
score: number;
|
||||
hasData: boolean;
|
||||
}
|
||||
|
||||
const compareNatural = (a: string, b: string) =>
|
||||
a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" });
|
||||
|
||||
// API contract is `Record<string, number>`, but defensively coerce so a
|
||||
// future null/string value cannot blow up `score.toFixed(...)` callers.
|
||||
// `treatMissingAsFull` makes a missing canonical pillar mean "no findings →
|
||||
// 100%" rather than "no data". Only safe when `sectionScores` is provided
|
||||
// (i.e. the scan ran); when undefined we still surface "no data".
|
||||
const readScore = (
|
||||
scores: SectionScores,
|
||||
name: string,
|
||||
treatMissingAsFull: boolean,
|
||||
): { score: number; hasData: boolean } => {
|
||||
const raw = scores[name];
|
||||
if (typeof raw === "number" && Number.isFinite(raw)) {
|
||||
return { score: raw, hasData: true };
|
||||
}
|
||||
if (treatMissingAsFull && raw === undefined) {
|
||||
return { score: 100, hasData: true };
|
||||
}
|
||||
return { score: 0, hasData: false };
|
||||
};
|
||||
|
||||
export function getOrderedPillars(
|
||||
sectionScores?: SectionScores,
|
||||
): OrderedPillar[] {
|
||||
const scores = sectionScores ?? {};
|
||||
const treatMissingAsFull = sectionScores !== undefined;
|
||||
const remaining = new Set(Object.keys(scores));
|
||||
|
||||
const canonical: OrderedPillar[] = THREATSCORE_PILLARS.map((name) => {
|
||||
remaining.delete(name);
|
||||
const { score, hasData } = readScore(scores, name, treatMissingAsFull);
|
||||
return { name, score, hasData };
|
||||
});
|
||||
|
||||
const extras: OrderedPillar[] = Array.from(remaining)
|
||||
.sort(compareNatural)
|
||||
.map((name) => {
|
||||
const { score, hasData } = readScore(scores, name, false);
|
||||
return { name, score, hasData };
|
||||
});
|
||||
|
||||
return [...canonical, ...extras];
|
||||
}
|
||||
|
||||
export const THREATSCORE_SECTION_PARAM = "section";
|
||||
|
||||
export const compareSectionsByCanonicalOrder = (a: string, b: string) => {
|
||||
const indexA = THREATSCORE_PILLARS.indexOf(
|
||||
a as (typeof THREATSCORE_PILLARS)[number],
|
||||
);
|
||||
const indexB = THREATSCORE_PILLARS.indexOf(
|
||||
b as (typeof THREATSCORE_PILLARS)[number],
|
||||
);
|
||||
if (indexA !== -1 && indexB !== -1) return indexA - indexB;
|
||||
if (indexA !== -1) return -1;
|
||||
if (indexB !== -1) return 1;
|
||||
return compareNatural(a, b);
|
||||
};
|
||||
@@ -6,7 +6,7 @@ export const DOCS_URLS = {
|
||||
"https://docs.prowler.com/user-guide/tutorials/prowler-app#step-8:-analyze-the-findings",
|
||||
AWS_ORGANIZATIONS:
|
||||
"https://docs.prowler.com/user-guide/tutorials/prowler-cloud-aws-organizations",
|
||||
ALERTS: "https://docs.prowler.com/user-guide/tutorials/prowler-app", // TODO: Update this URL to the Alerts documentation
|
||||
ALERTS: "https://docs.prowler.com/user-guide/tutorials/prowler-app-alerts",
|
||||
ATTACK_PATHS_CUSTOM_QUERIES:
|
||||
"https://docs.prowler.com/user-guide/tutorials/prowler-app-attack-paths#writing-custom-opencypher-queries",
|
||||
} as const;
|
||||
|
||||
@@ -53,6 +53,7 @@ export function findingToFindingResourceRow(
|
||||
region: resource?.region || "-",
|
||||
severity: finding.attributes.severity,
|
||||
status: finding.attributes.status,
|
||||
statusExtended: finding.attributes.status_extended,
|
||||
delta: finding.attributes.delta,
|
||||
isMuted: finding.attributes.muted,
|
||||
mutedReason: finding.attributes.muted_reason,
|
||||
|
||||
@@ -60,6 +60,7 @@ export interface Category {
|
||||
fail: number;
|
||||
manual: number;
|
||||
controls: Control[];
|
||||
percentualScore?: number;
|
||||
}
|
||||
|
||||
export interface Framework {
|
||||
@@ -89,6 +90,10 @@ export type TopFailedDataType =
|
||||
export interface TopFailedResult {
|
||||
items: FailedSection[];
|
||||
type: TopFailedDataType;
|
||||
// True when items already cover every relevant category (zero-fill). The
|
||||
// chart should render the supplied list as-is instead of falling back to
|
||||
// severity placeholders when totals are zero.
|
||||
prepopulated?: boolean;
|
||||
}
|
||||
|
||||
export interface RequirementsTotals {
|
||||
@@ -219,6 +224,109 @@ export interface CCCAttributesMetadata {
|
||||
}>;
|
||||
}
|
||||
|
||||
// ASD Essential Eight enums — modelled on the canonical Maturity Model
|
||||
// (Nov 2023). Only ML1 ships today; ML2/ML3 are scoped out of the framework
|
||||
// but kept here so the type covers any future expansion without a schema
|
||||
// edit. AssessmentStatus and CloudApplicability are exhaustive per the JSON
|
||||
// fixture; new variants must be added explicitly.
|
||||
export const ASD_MATURITY_LEVEL = {
|
||||
ML1: "ML1",
|
||||
ML2: "ML2",
|
||||
ML3: "ML3",
|
||||
} as const;
|
||||
export type ASDMaturityLevel =
|
||||
(typeof ASD_MATURITY_LEVEL)[keyof typeof ASD_MATURITY_LEVEL];
|
||||
|
||||
export const ASD_ASSESSMENT_STATUS = {
|
||||
AUTOMATED: "Automated",
|
||||
MANUAL: "Manual",
|
||||
} as const;
|
||||
export type ASDAssessmentStatus =
|
||||
(typeof ASD_ASSESSMENT_STATUS)[keyof typeof ASD_ASSESSMENT_STATUS];
|
||||
|
||||
export const ASD_CLOUD_APPLICABILITY = {
|
||||
FULL: "full",
|
||||
PARTIAL: "partial",
|
||||
LIMITED: "limited",
|
||||
NON_APPLICABLE: "non-applicable",
|
||||
} as const;
|
||||
export type ASDCloudApplicability =
|
||||
(typeof ASD_CLOUD_APPLICABILITY)[keyof typeof ASD_CLOUD_APPLICABILITY];
|
||||
|
||||
export interface ASDEssentialEightAttributesMetadata {
|
||||
Section: string;
|
||||
MaturityLevel: ASDMaturityLevel;
|
||||
AssessmentStatus: ASDAssessmentStatus;
|
||||
CloudApplicability: ASDCloudApplicability;
|
||||
MitigatedThreats: string[];
|
||||
Description: string;
|
||||
RationaleStatement: string;
|
||||
ImpactStatement: string;
|
||||
RemediationProcedure: string;
|
||||
AuditProcedure: string;
|
||||
AdditionalInformation: string;
|
||||
References: string;
|
||||
}
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
const isOneOf = <T extends string>(
|
||||
values: Record<string, T>,
|
||||
value: unknown,
|
||||
): value is T => (Object.values(values) as T[]).includes(value as T);
|
||||
|
||||
const isStringArray = (value: unknown): value is string[] =>
|
||||
Array.isArray(value) && value.every((item) => typeof item === "string");
|
||||
|
||||
const ASD_METADATA_STRING_FIELDS = [
|
||||
"Section",
|
||||
"Description",
|
||||
"RationaleStatement",
|
||||
"ImpactStatement",
|
||||
"RemediationProcedure",
|
||||
"AuditProcedure",
|
||||
"AdditionalInformation",
|
||||
"References",
|
||||
] as const satisfies readonly (keyof ASDEssentialEightAttributesMetadata)[];
|
||||
|
||||
export const isASDMaturityLevel = (value: unknown): value is ASDMaturityLevel =>
|
||||
isOneOf(ASD_MATURITY_LEVEL, value);
|
||||
|
||||
export const isASDAssessmentStatus = (
|
||||
value: unknown,
|
||||
): value is ASDAssessmentStatus => isOneOf(ASD_ASSESSMENT_STATUS, value);
|
||||
|
||||
export const isASDCloudApplicability = (
|
||||
value: unknown,
|
||||
): value is ASDCloudApplicability => isOneOf(ASD_CLOUD_APPLICABILITY, value);
|
||||
|
||||
export const isASDEssentialEightAttributesMetadata = (
|
||||
value: unknown,
|
||||
): value is ASDEssentialEightAttributesMetadata =>
|
||||
isRecord(value) &&
|
||||
ASD_METADATA_STRING_FIELDS.every(
|
||||
(field) => typeof value[field] === "string",
|
||||
) &&
|
||||
isASDMaturityLevel(value.MaturityLevel) &&
|
||||
isASDAssessmentStatus(value.AssessmentStatus) &&
|
||||
isASDCloudApplicability(value.CloudApplicability) &&
|
||||
isStringArray(value.MitigatedThreats);
|
||||
|
||||
export interface ASDEssentialEightRequirement extends Requirement {
|
||||
maturity_level: ASDEssentialEightAttributesMetadata["MaturityLevel"];
|
||||
assessment_status: ASDEssentialEightAttributesMetadata["AssessmentStatus"];
|
||||
cloud_applicability: ASDEssentialEightAttributesMetadata["CloudApplicability"];
|
||||
mitigated_threats: ASDEssentialEightAttributesMetadata["MitigatedThreats"];
|
||||
implementation_notes: ASDEssentialEightAttributesMetadata["Description"];
|
||||
rationale_statement: ASDEssentialEightAttributesMetadata["RationaleStatement"];
|
||||
impact_statement: ASDEssentialEightAttributesMetadata["ImpactStatement"];
|
||||
remediation_procedure: ASDEssentialEightAttributesMetadata["RemediationProcedure"];
|
||||
audit_procedure: ASDEssentialEightAttributesMetadata["AuditProcedure"];
|
||||
additional_information: ASDEssentialEightAttributesMetadata["AdditionalInformation"];
|
||||
references: ASDEssentialEightAttributesMetadata["References"];
|
||||
}
|
||||
|
||||
export interface AttributesItemData {
|
||||
type: "compliance-requirements-attributes";
|
||||
id: string;
|
||||
@@ -240,6 +348,7 @@ export interface AttributesItemData {
|
||||
| MITREAttributesMetadata[]
|
||||
| CCCAttributesMetadata[]
|
||||
| CSAAttributesMetadata[]
|
||||
| ASDEssentialEightAttributesMetadata[]
|
||||
| GenericAttributesMetadata[];
|
||||
check_ids: string[];
|
||||
// MITRE structure
|
||||
|
||||
@@ -60,6 +60,7 @@ export interface FindingResourceRow {
|
||||
region: string;
|
||||
severity: Severity;
|
||||
status: string;
|
||||
statusExtended?: string;
|
||||
delta?: string | null;
|
||||
isMuted: boolean;
|
||||
mutedReason?: string;
|
||||
|
||||
Reference in New Issue
Block a user