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