Compare commits

...

8 Commits

Author SHA1 Message Date
prowler-bot e7eed4c244 feat(aws): update regions for AWS services 2026-05-11 10:10:18 +00:00
Hugo Pereira Brito cdfbe5b2e3 fix(ui): label finding drawer column as Provider, not Account (#11102) 2026-05-11 12:03:03 +02:00
Hugo Pereira Brito 1b6a459df4 refactor(ui): reorganize finding detail drawer (#11091) 2026-05-11 09:47:43 +01:00
Daniel Barranquero 73c0305dc4 feat(aws): add bedrock_prompt_encrypted_with_cmk security check (#10905) 2026-05-11 10:32:44 +02:00
Pedro Martín 0e01e67257 feat(ui): ASD Essential Eight compliance framework support (#11071)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
2026-05-11 09:55:04 +02:00
Pedro Martín 1ad329f9cf feat(ui): ThreatScore compliance views pillars, nav + charts (#10975)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
Co-authored-by: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com>
2026-05-11 09:53:55 +02:00
Pedro Martín d03d1d2393 chore(changelog): update for universal compliance (#11100) 2026-05-11 09:50:32 +02:00
Davidm4r 832516be2a fix(mcp_server): bump transitive requests to 2.33.1 (advisory 90553) (#11084) 2026-05-08 12:52:46 +02:00
49 changed files with 2732 additions and 422 deletions
+3 -3
View File
@@ -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 -1
View File
@@ -7,11 +7,12 @@ All notable changes to the **Prowler SDK** are documented in this file.
### 🚀 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)
- `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
+2
View File
@@ -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",
@@ -482,6 +482,7 @@
"ap-southeast-3",
"ap-southeast-4",
"ap-southeast-5",
"ap-southeast-7",
"ca-central-1",
"ca-west-1",
"eu-central-1",
@@ -1231,6 +1232,21 @@
"aws-us-gov": []
}
},
"aws-devops-agent": {
"regions": {
"aws": [
"ap-northeast-1",
"ap-southeast-2",
"eu-central-1",
"eu-west-1",
"us-east-1",
"us-west-2"
],
"aws-cn": [],
"aws-eusc": [],
"aws-us-gov": []
}
},
"awshealthdashboard": {
"regions": {
"aws": [
@@ -1590,6 +1606,7 @@
"ap-southeast-2",
"ca-central-1",
"eu-central-1",
"eu-south-2",
"eu-west-1",
"eu-west-2",
"us-east-1",
@@ -2193,6 +2210,7 @@
"ap-southeast-2",
"ap-southeast-3",
"ap-southeast-5",
"ap-southeast-6",
"ap-southeast-7",
"ca-central-1",
"ca-west-1",
@@ -2255,6 +2273,8 @@
"ap-southeast-2",
"ap-southeast-3",
"ap-southeast-4",
"ap-southeast-5",
"ap-southeast-7",
"ca-central-1",
"ca-west-1",
"eu-central-1",
@@ -3767,6 +3787,7 @@
"ap-southeast-5",
"ap-southeast-7",
"ca-central-1",
"ca-west-1",
"eu-central-1",
"eu-central-2",
"eu-north-1",
@@ -3829,7 +3850,9 @@
"us-west-2"
],
"aws-cn": [],
"aws-eusc": [],
"aws-eusc": [
"eusc-de-east-1"
],
"aws-us-gov": [
"us-gov-east-1",
"us-gov-west-1"
@@ -5260,6 +5283,7 @@
"ap-southeast-3",
"ap-southeast-4",
"ap-southeast-5",
"ap-southeast-6",
"ap-southeast-7",
"ca-central-1",
"ca-west-1",
@@ -5310,6 +5334,7 @@
"ap-southeast-3",
"ap-southeast-4",
"ap-southeast-5",
"ap-southeast-6",
"ap-southeast-7",
"ca-central-1",
"ca-west-1",
@@ -5360,6 +5385,7 @@
"ap-southeast-3",
"ap-southeast-4",
"ap-southeast-5",
"ap-southeast-6",
"ap-southeast-7",
"ca-central-1",
"ca-west-1",
@@ -5410,6 +5436,7 @@
"ap-southeast-3",
"ap-southeast-4",
"ap-southeast-5",
"ap-southeast-6",
"ap-southeast-7",
"ca-central-1",
"ca-west-1",
@@ -5460,6 +5487,7 @@
"ap-southeast-3",
"ap-southeast-4",
"ap-southeast-5",
"ap-southeast-6",
"ap-southeast-7",
"ca-central-1",
"ca-west-1",
@@ -8065,6 +8093,7 @@
"aws": [
"af-south-1",
"ap-east-1",
"ap-east-2",
"ap-northeast-1",
"ap-northeast-2",
"ap-northeast-3",
@@ -8075,8 +8104,10 @@
"ap-southeast-3",
"ap-southeast-4",
"ap-southeast-5",
"ap-southeast-6",
"ap-southeast-7",
"ca-central-1",
"ca-west-1",
"eu-central-1",
"eu-central-2",
"eu-north-1",
@@ -8088,6 +8119,7 @@
"il-central-1",
"me-central-1",
"me-south-1",
"mx-central-1",
"sa-east-1",
"us-east-1",
"us-east-2",
@@ -8279,22 +8311,31 @@
"aws": [
"af-south-1",
"ap-east-1",
"ap-east-2",
"ap-northeast-1",
"ap-northeast-2",
"ap-northeast-3",
"ap-south-1",
"ap-south-2",
"ap-southeast-1",
"ap-southeast-2",
"ap-southeast-3",
"ap-southeast-4",
"ap-southeast-5",
"ap-southeast-6",
"ap-southeast-7",
"ca-central-1",
"ca-west-1",
"eu-central-1",
"eu-central-2",
"eu-north-1",
"eu-south-1",
"eu-south-2",
"eu-west-1",
"eu-west-2",
"eu-west-3",
"il-central-1",
"mx-central-1",
"sa-east-1",
"us-east-1",
"us-east-2",
@@ -8315,6 +8356,7 @@
"ap-northeast-2",
"ap-northeast-3",
"ap-south-1",
"ap-south-2",
"ap-southeast-1",
"ap-southeast-2",
"ap-southeast-3",
@@ -8574,6 +8616,7 @@
"il-central-1",
"me-central-1",
"me-south-1",
"mx-central-1",
"sa-east-1",
"us-east-1",
"us-east-2",
@@ -8706,11 +8749,15 @@
"aws": [
"ap-northeast-1",
"ap-northeast-2",
"ap-northeast-3",
"ap-south-1",
"ap-south-2",
"ap-southeast-2",
"ap-southeast-4",
"ca-central-1",
"eu-central-1",
"eu-central-2",
"eu-south-1",
"eu-west-1",
"eu-west-2",
"us-east-1",
@@ -9034,6 +9081,7 @@
"eu-west-1",
"eu-west-2",
"eu-west-3",
"sa-east-1",
"us-east-1",
"us-east-2",
"us-west-2"
@@ -9141,6 +9189,8 @@
"ap-southeast-2",
"eu-central-1",
"eu-north-1",
"eu-south-1",
"eu-south-2",
"eu-west-1",
"eu-west-2",
"eu-west-3",
@@ -9865,10 +9915,12 @@
"ap-southeast-1",
"ap-southeast-2",
"ap-southeast-3",
"ap-southeast-4",
"ap-southeast-5",
"ap-southeast-6",
"ap-southeast-7",
"ca-central-1",
"ca-west-1",
"eu-central-1",
"eu-central-2",
"eu-north-1",
@@ -10012,7 +10064,10 @@
],
"aws-cn": [],
"aws-eusc": [],
"aws-us-gov": []
"aws-us-gov": [
"us-gov-east-1",
"us-gov-west-1"
]
}
},
"resource-groups": {
@@ -10693,7 +10748,10 @@
"us-west-1",
"us-west-2"
],
"aws-cn": [],
"aws-cn": [
"cn-north-1",
"cn-northwest-1"
],
"aws-eusc": [],
"aws-us-gov": []
}
@@ -11315,7 +11373,9 @@
"ap-southeast-2",
"ap-southeast-3",
"ap-southeast-4",
"ap-southeast-6",
"ca-central-1",
"ca-west-1",
"eu-central-1",
"eu-central-2",
"eu-north-1",
@@ -11647,26 +11707,6 @@
]
}
},
"simspaceweaver": {
"regions": {
"aws": [
"ap-southeast-1",
"ap-southeast-2",
"eu-central-1",
"eu-north-1",
"eu-west-1",
"us-east-1",
"us-east-2",
"us-west-2"
],
"aws-cn": [],
"aws-eusc": [],
"aws-us-gov": [
"us-gov-east-1",
"us-gov-west-1"
]
}
},
"sms": {
"regions": {
"aws": [
@@ -13063,6 +13103,7 @@
"eu-west-3",
"me-central-1",
"me-south-1",
"mx-central-1",
"sa-east-1",
"us-east-1",
"us-east-2",
@@ -13414,6 +13455,7 @@
"ap-south-1",
"ap-southeast-1",
"ap-southeast-2",
"ap-southeast-5",
"ca-central-1",
"eu-central-1",
"eu-west-1",
@@ -13422,6 +13464,7 @@
"il-central-1",
"sa-east-1",
"us-east-1",
"us-east-2",
"us-west-2"
],
"aws-cn": [
@@ -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": ""
}
@@ -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
@@ -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
@@ -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
View File
@@ -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: AWS scans now surface the Essential Eight overview card, accordion view (Sections normalised to `N. <name>`, controls grouped per ML1 clause) and a dedicated requirement detail panel with maturity level, assessment, cloud applicability, mitigated threats and References [(#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,
@@ -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>
);
@@ -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>
);
};
+58 -26
View File
@@ -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>
@@ -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();
});
@@ -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>
);
}
@@ -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>
);
+41 -5
View File
@@ -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

+12
View File
@@ -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",
+1
View File
@@ -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}`
}
+5 -1
View File
@@ -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([]);
});
});
+172
View File
@@ -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: [],
};
}),
};
}),
);
};
+196
View File
@@ -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);
}
});
});
+16 -1
View File
@@ -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) =>
+47
View File
@@ -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 };
};
+100
View File
@@ -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);
});
});
+13 -4
View File
@@ -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",
]);
});
});
+75
View File
@@ -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);
};
+1
View File
@@ -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,
+109
View File
@@ -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
+1
View File
@@ -60,6 +60,7 @@ export interface FindingResourceRow {
region: string;
severity: Severity;
status: string;
statusExtended?: string;
delta?: string | null;
isMuted: boolean;
mutedReason?: string;