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

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

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

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

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

|
||||
|
||||
## Best Practices
|
||||
|
||||
* **Start with focused filters:** Create Alerts for specific high-priority scopes, such as critical findings, production providers, or important services.
|
||||
* **Use clear names:** Choose names that explain the intent of the Alert.
|
||||
* **Review recipients regularly:** Keep recipient lists aligned with current ownership.
|
||||
* **Test before saving edits:** Use **Test** after changing filters to confirm that the Alert matches the expected findings.
|
||||
* **Disable instead of deleting during tuning:** Disable Alerts temporarily when adjusting filters or recipients.
|
||||
Generated
+3
-3
@@ -1009,7 +1009,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.33.1"
|
||||
version = "2.32.5"
|
||||
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/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -2,17 +2,16 @@
|
||||
|
||||
All notable changes to the **Prowler SDK** are documented in this file.
|
||||
|
||||
## [5.26.0] (Prowler v5.26.0)
|
||||
## [5.26.0] (Prowler UNRELEASED)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
- `bedrock_guardrails_configured` check for AWS provider [(#10844)](https://github.com/prowler-cloud/prowler/pull/10844)
|
||||
- Universal compliance with OCSF support [(#10301)](https://github.com/prowler-cloud/prowler/pull/10301)
|
||||
- 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)
|
||||
- ASD Essential Eight Maturity Model compliance framework for AWS (Maturity Level One, Nov 2023) [(#10808)](https://github.com/prowler-cloud/prowler/pull/10808)
|
||||
- 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)
|
||||
- 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,7 +6473,6 @@
|
||||
"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",
|
||||
@@ -6731,7 +6730,6 @@
|
||||
"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,7 +1311,6 @@
|
||||
"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,7 +1767,6 @@
|
||||
"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,7 +2115,6 @@
|
||||
"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,7 +2117,6 @@
|
||||
"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,7 +903,6 @@
|
||||
"Checks": [
|
||||
"backup_vaults_encrypted",
|
||||
"backup_recovery_point_encrypted",
|
||||
"bedrock_prompt_encrypted_with_cmk",
|
||||
"cloudtrail_kms_encryption_enabled",
|
||||
"cloudwatch_log_group_kms_encryption_enabled",
|
||||
"s3_bucket_kms_encryption",
|
||||
|
||||
-43
@@ -1,43 +0,0 @@
|
||||
{
|
||||
"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
@@ -1,32 +0,0 @@
|
||||
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
|
||||
+1
-3
@@ -34,8 +34,6 @@
|
||||
"gen-ai"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [
|
||||
"bedrock_prompt_encrypted_with_cmk"
|
||||
],
|
||||
"RelatedTo": [],
|
||||
"Notes": "Results are generated per scanned region. Regions where `ListPrompts` cannot be queried are omitted from the findings."
|
||||
}
|
||||
|
||||
@@ -136,10 +136,7 @@ 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 = {}
|
||||
@@ -147,7 +144,6 @@ 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):
|
||||
@@ -175,43 +171,29 @@ class BedrockAgent(AWSService):
|
||||
)
|
||||
|
||||
def _list_prompts(self, regional_client):
|
||||
"""List all prompts in a region."""
|
||||
"""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.
|
||||
"""
|
||||
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", "")
|
||||
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.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...")
|
||||
@@ -230,8 +212,6 @@ class BedrockAgent(AWSService):
|
||||
|
||||
|
||||
class Agent(BaseModel):
|
||||
"""Model for a Bedrock Agent resource."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
arn: str
|
||||
@@ -247,4 +227,3 @@ class Prompt(BaseModel):
|
||||
name: str
|
||||
arn: str
|
||||
region: str
|
||||
customer_encryption_key_arn: Optional[str] = None
|
||||
|
||||
+49
-491
@@ -1,8 +1,6 @@
|
||||
---
|
||||
name: django-drf
|
||||
description: >
|
||||
Django REST Framework patterns.
|
||||
Trigger: When implementing generic DRF APIs (ViewSets, serializers, routers, permissions, filtersets). For Prowler API specifics (RLS/RBAC/Providers), also use prowler-api.
|
||||
description: "Trigger: When implementing generic DRF APIs such as viewsets, serializers, routers, permissions, pagination, or filtersets, including JSON:API-capable endpoints. Applies the shared DRF execution patterns used in Prowler."
|
||||
license: Apache-2.0
|
||||
metadata:
|
||||
author: prowler-cloud
|
||||
@@ -15,491 +13,51 @@ metadata:
|
||||
allowed-tools: Read, Edit, Write, Glob, Grep, Bash, WebFetch, WebSearch, Task
|
||||
---
|
||||
|
||||
## Critical Patterns
|
||||
|
||||
- ALWAYS separate serializers by operation: Read / Create / Update / Include
|
||||
- ALWAYS use `filterset_class` for complex filtering (not `filterset_fields`)
|
||||
- ALWAYS validate unknown fields in write serializers (inherit `BaseWriteSerializer`)
|
||||
- ALWAYS use `select_related`/`prefetch_related` in `get_queryset()` to avoid N+1
|
||||
- ALWAYS handle `swagger_fake_view` in `get_queryset()` for schema generation
|
||||
- ALWAYS use `@extend_schema_field` for OpenAPI docs on `SerializerMethodField`
|
||||
- NEVER put business logic in serializers - use services/utils
|
||||
- NEVER use auto-increment PKs - use UUIDv4 or UUIDv7
|
||||
- NEVER use trailing slashes in URLs (`trailing_slash=False`)
|
||||
|
||||
> **Note:** `swagger_fake_view` is specific to **drf-spectacular** for OpenAPI schema generation.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
When implementing a new endpoint, review these patterns in order:
|
||||
|
||||
| # | Pattern | Reference | Key Points |
|
||||
|---|---------|-----------|------------|
|
||||
| 1 | **Models** | `api/models.py` | UUID PK, `inserted_at`/`updated_at`, `JSONAPIMeta.resource_name` |
|
||||
| 2 | **ViewSets** | `api/base_views.py`, `api/v1/views.py` | Inherit `BaseRLSViewSet`, `get_queryset()` with N+1 prevention |
|
||||
| 3 | **Serializers** | `api/v1/serializers.py` | Separate Read/Create/Update/Include, inherit `BaseWriteSerializer` |
|
||||
| 4 | **Filters** | `api/filters.py` | Use `filterset_class`, inherit base filter classes |
|
||||
| 5 | **Permissions** | `api/base_views.py` | `required_permissions`, `set_required_permissions()` |
|
||||
| 6 | **Pagination** | `api/pagination.py` | Custom pagination class if needed |
|
||||
| 7 | **URL Routing** | `api/v1/urls.py` | `trailing_slash=False`, kebab-case paths |
|
||||
| 8 | **OpenAPI Schema** | `api/v1/views.py` | `@extend_schema_view` with drf-spectacular |
|
||||
| 9 | **Tests** | `api/tests/test_views.py` | JSON:API content type, fixture patterns |
|
||||
|
||||
> **Full file paths**: See [references/file-locations.md](references/file-locations.md)
|
||||
|
||||
---
|
||||
|
||||
## Decision Trees
|
||||
|
||||
### Which Serializer?
|
||||
```
|
||||
GET list/retrieve → <Model>Serializer
|
||||
POST create → <Model>CreateSerializer
|
||||
PATCH update → <Model>UpdateSerializer
|
||||
?include=... → <Model>IncludeSerializer
|
||||
```
|
||||
|
||||
### Which Base Serializer?
|
||||
```
|
||||
Read-only serializer → BaseModelSerializerV1
|
||||
Create with tenant_id → RLSSerializer + BaseWriteSerializer (auto-injects tenant_id on create)
|
||||
Update with validation → BaseWriteSerializer (tenant_id already exists on object)
|
||||
Non-model data → BaseSerializerV1
|
||||
```
|
||||
|
||||
### Which Filter Base?
|
||||
```
|
||||
Direct FK to Provider → BaseProviderFilter
|
||||
FK via Scan → BaseScanProviderFilter
|
||||
No provider relation → FilterSet
|
||||
```
|
||||
|
||||
### Which Base ViewSet?
|
||||
```
|
||||
RLS-protected model → BaseRLSViewSet (most common)
|
||||
Tenant operations → BaseTenantViewset
|
||||
User operations → BaseUserViewset
|
||||
No RLS required → BaseViewSet (rare)
|
||||
```
|
||||
|
||||
### Resource Name Format?
|
||||
```
|
||||
Single word model → plural lowercase (Provider → providers)
|
||||
Multi-word model → plural lowercase kebab (ProviderGroup → provider-groups)
|
||||
Through/join model → parent-child pattern (UserRoleRelationship → user-roles)
|
||||
Aggregation/overview → descriptive kebab plural (ComplianceOverview → compliance-overviews)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Serializer Patterns
|
||||
|
||||
### Base Class Hierarchy
|
||||
|
||||
```python
|
||||
# Read serializer (most common)
|
||||
class ProviderSerializer(RLSSerializer):
|
||||
class Meta:
|
||||
model = Provider
|
||||
fields = ["id", "provider", "uid", "alias", "connected", "inserted_at"]
|
||||
|
||||
# Write serializer (validates unknown fields)
|
||||
class ProviderCreateSerializer(RLSSerializer, BaseWriteSerializer):
|
||||
class Meta:
|
||||
model = Provider
|
||||
fields = ["provider", "uid", "alias"]
|
||||
|
||||
# Include serializer (sparse fields for ?include=)
|
||||
class ProviderIncludeSerializer(RLSSerializer):
|
||||
class Meta:
|
||||
model = Provider
|
||||
fields = ["id", "alias"] # Minimal fields
|
||||
```
|
||||
|
||||
### SerializerMethodField with OpenAPI
|
||||
|
||||
```python
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
|
||||
class ProviderSerializer(RLSSerializer):
|
||||
connection = serializers.SerializerMethodField(read_only=True)
|
||||
|
||||
@extend_schema_field({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"connected": {"type": "boolean"},
|
||||
"last_checked_at": {"type": "string", "format": "date-time"},
|
||||
},
|
||||
})
|
||||
def get_connection(self, obj):
|
||||
return {
|
||||
"connected": obj.connected,
|
||||
"last_checked_at": obj.connection_last_checked_at,
|
||||
}
|
||||
```
|
||||
|
||||
### Included Serializers (JSON:API)
|
||||
|
||||
```python
|
||||
class ScanSerializer(RLSSerializer):
|
||||
included_serializers = {
|
||||
"provider": "api.v1.serializers.ProviderIncludeSerializer",
|
||||
}
|
||||
```
|
||||
|
||||
### Sensitive Data Masking
|
||||
|
||||
```python
|
||||
def to_representation(self, instance):
|
||||
data = super().to_representation(instance)
|
||||
# Mask by default, expose only on explicit request
|
||||
fields_param = self.context.get("request").query_params.get("fields[my-model]", "")
|
||||
if "api_key" in fields_param:
|
||||
data["api_key"] = instance.api_key_decoded
|
||||
else:
|
||||
data["api_key"] = "****" if instance.api_key else None
|
||||
return data
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ViewSet Patterns
|
||||
|
||||
### get_queryset() with N+1 Prevention
|
||||
|
||||
**Always combine** `swagger_fake_view` check with `select_related`/`prefetch_related`:
|
||||
|
||||
```python
|
||||
def get_queryset(self):
|
||||
# REQUIRED: Return empty queryset for OpenAPI schema generation
|
||||
if getattr(self, "swagger_fake_view", False):
|
||||
return Provider.objects.none()
|
||||
|
||||
# N+1 prevention: eager load relationships
|
||||
return Provider.objects.select_related(
|
||||
"tenant",
|
||||
).prefetch_related(
|
||||
"provider_groups",
|
||||
Prefetch("tags", queryset=ProviderTag.objects.filter(tenant_id=self.request.tenant_id)),
|
||||
)
|
||||
```
|
||||
|
||||
> **Why swagger_fake_view?** drf-spectacular introspects ViewSets to generate OpenAPI schemas. Without this check, it executes real queries and can fail without request context.
|
||||
|
||||
### Action-Specific Serializers
|
||||
|
||||
```python
|
||||
def get_serializer_class(self):
|
||||
if self.action == "create":
|
||||
return ProviderCreateSerializer
|
||||
elif self.action == "partial_update":
|
||||
return ProviderUpdateSerializer
|
||||
elif self.action in ["connection", "destroy"]:
|
||||
return TaskSerializer
|
||||
return ProviderSerializer
|
||||
```
|
||||
|
||||
### Dynamic Permissions per Action
|
||||
|
||||
```python
|
||||
class ProviderViewSet(BaseRLSViewSet):
|
||||
required_permissions = [Permissions.MANAGE_PROVIDERS]
|
||||
|
||||
def set_required_permissions(self):
|
||||
if self.action in ["list", "retrieve"]:
|
||||
self.required_permissions = [] # Read-only = no permission
|
||||
else:
|
||||
self.required_permissions = [Permissions.MANAGE_PROVIDERS]
|
||||
```
|
||||
|
||||
### Cache Decorator
|
||||
|
||||
```python
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.cache import cache_control
|
||||
|
||||
CACHE_DECORATOR = cache_control(
|
||||
max_age=django_settings.CACHE_MAX_AGE,
|
||||
stale_while_revalidate=django_settings.CACHE_STALE_WHILE_REVALIDATE,
|
||||
)
|
||||
|
||||
@method_decorator(CACHE_DECORATOR, name="list")
|
||||
@method_decorator(CACHE_DECORATOR, name="retrieve")
|
||||
class ProviderViewSet(BaseRLSViewSet):
|
||||
pass
|
||||
```
|
||||
|
||||
### Custom Actions
|
||||
|
||||
```python
|
||||
# Detail action (operates on single object)
|
||||
@action(detail=True, methods=["post"], url_name="connection")
|
||||
def connection(self, request, pk=None):
|
||||
instance = self.get_object()
|
||||
# Process instance...
|
||||
|
||||
# List action (operates on collection)
|
||||
@action(detail=False, methods=["get"], url_name="metadata")
|
||||
def metadata(self, request):
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
# Aggregate over queryset...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Filter Patterns
|
||||
|
||||
### Base Filter Classes
|
||||
|
||||
```python
|
||||
class BaseProviderFilter(FilterSet):
|
||||
"""For models with direct FK to Provider"""
|
||||
provider_id = UUIDFilter(field_name="provider__id", lookup_expr="exact")
|
||||
provider_id__in = UUIDInFilter(field_name="provider__id", lookup_expr="in")
|
||||
provider_type = ChoiceFilter(field_name="provider__provider", choices=Provider.ProviderChoices.choices)
|
||||
|
||||
class BaseScanProviderFilter(FilterSet):
|
||||
"""For models with FK to Scan (Scan has FK to Provider)"""
|
||||
provider_id = UUIDFilter(field_name="scan__provider__id", lookup_expr="exact")
|
||||
```
|
||||
|
||||
### Custom Multi-Value Filters
|
||||
|
||||
```python
|
||||
class UUIDInFilter(BaseInFilter, UUIDFilter):
|
||||
pass
|
||||
|
||||
class CharInFilter(BaseInFilter, CharFilter):
|
||||
pass
|
||||
|
||||
class ChoiceInFilter(BaseInFilter, ChoiceFilter):
|
||||
pass
|
||||
```
|
||||
|
||||
### ArrayField Filtering
|
||||
|
||||
```python
|
||||
# Single value contains
|
||||
region = CharFilter(method="filter_region")
|
||||
|
||||
def filter_region(self, queryset, name, value):
|
||||
return queryset.filter(resource_regions__contains=[value])
|
||||
|
||||
# Multi-value overlap
|
||||
region__in = CharInFilter(field_name="resource_regions", lookup_expr="overlap")
|
||||
```
|
||||
|
||||
### Date Range Validation
|
||||
|
||||
```python
|
||||
def filter_queryset(self, queryset):
|
||||
# Require date filter for performance
|
||||
if not (date_filters_provided):
|
||||
raise ValidationError([{
|
||||
"detail": "At least one date filter is required",
|
||||
"status": 400,
|
||||
"source": {"pointer": "/data/attributes/inserted_at"},
|
||||
"code": "required",
|
||||
}])
|
||||
|
||||
# Validate max range
|
||||
if date_range > settings.FINDINGS_MAX_DAYS_IN_RANGE:
|
||||
raise ValidationError(...)
|
||||
|
||||
return super().filter_queryset(queryset)
|
||||
```
|
||||
|
||||
### Dynamic FilterSet Selection
|
||||
|
||||
```python
|
||||
def get_filterset_class(self):
|
||||
if self.action in ["latest", "metadata_latest"]:
|
||||
return LatestFindingFilter
|
||||
return FindingFilter
|
||||
```
|
||||
|
||||
### Enum Field Override
|
||||
|
||||
```python
|
||||
class Meta:
|
||||
model = Finding
|
||||
filter_overrides = {
|
||||
FindingDeltaEnumField: {"filter_class": CharFilter},
|
||||
StatusEnumField: {"filter_class": CharFilter},
|
||||
SeverityEnumField: {"filter_class": CharFilter},
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Performance Patterns
|
||||
|
||||
### PaginateByPkMixin
|
||||
|
||||
For large querysets with expensive joins:
|
||||
|
||||
```python
|
||||
class PaginateByPkMixin:
|
||||
def paginate_by_pk(self, request, base_queryset, manager,
|
||||
select_related=None, prefetch_related=None):
|
||||
# 1. Get PKs only (cheap)
|
||||
pk_list = base_queryset.values_list("id", flat=True)
|
||||
page = self.paginate_queryset(pk_list)
|
||||
|
||||
# 2. Fetch full objects for just the page
|
||||
queryset = manager.filter(id__in=page)
|
||||
if select_related:
|
||||
queryset = queryset.select_related(*select_related)
|
||||
if prefetch_related:
|
||||
queryset = queryset.prefetch_related(*prefetch_related)
|
||||
|
||||
# 3. Re-sort to preserve DB ordering
|
||||
queryset = sorted(queryset, key=lambda obj: page.index(obj.id))
|
||||
return self.get_paginated_response(self.get_serializer(queryset, many=True).data)
|
||||
```
|
||||
|
||||
### Prefetch in Serializers
|
||||
|
||||
```python
|
||||
def get_tags(self, obj):
|
||||
# Use prefetched tags if available
|
||||
if hasattr(obj, "prefetched_tags"):
|
||||
return {tag.key: tag.value for tag in obj.prefetched_tags}
|
||||
# Fallback (causes N+1 if not prefetched)
|
||||
return obj.get_tags(self.context.get("tenant_id"))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
| Entity | Pattern | Example |
|
||||
|--------|---------|---------|
|
||||
| Serializer (read) | `<Model>Serializer` | `ProviderSerializer` |
|
||||
| Serializer (create) | `<Model>CreateSerializer` | `ProviderCreateSerializer` |
|
||||
| Serializer (update) | `<Model>UpdateSerializer` | `ProviderUpdateSerializer` |
|
||||
| Serializer (include) | `<Model>IncludeSerializer` | `ProviderIncludeSerializer` |
|
||||
| Filter | `<Model>Filter` | `ProviderFilter` |
|
||||
| ViewSet | `<Model>ViewSet` | `ProviderViewSet` |
|
||||
|
||||
---
|
||||
|
||||
## OpenAPI Documentation
|
||||
|
||||
```python
|
||||
from drf_spectacular.utils import extend_schema, extend_schema_view
|
||||
|
||||
@extend_schema_view(
|
||||
list=extend_schema(tags=["Provider"], summary="List all providers"),
|
||||
retrieve=extend_schema(tags=["Provider"], summary="Retrieve provider"),
|
||||
create=extend_schema(tags=["Provider"], summary="Create provider"),
|
||||
)
|
||||
@extend_schema(tags=["Provider"])
|
||||
class ProviderViewSet(BaseRLSViewSet):
|
||||
pass
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Security Patterns
|
||||
|
||||
> **Full examples**: See [assets/security_patterns.py](assets/security_patterns.py)
|
||||
|
||||
| Pattern | Key Points |
|
||||
|---------|------------|
|
||||
| **Input Validation** | Use `validate_<field>()` for sanitization, `validate()` for cross-field |
|
||||
| **Prevent Mass Assignment** | ALWAYS use explicit `fields` list, NEVER `__all__` or `exclude` |
|
||||
| **Object-Level Permissions** | Implement `has_object_permission()` for ownership checks |
|
||||
| **Rate Limiting** | Configure `DEFAULT_THROTTLE_RATES`, use per-view throttles for sensitive endpoints |
|
||||
| **Prevent Info Disclosure** | Generic error messages, return 404 not 403 for unauthorized (prevents enumeration) |
|
||||
| **SQL Injection** | ALWAYS use ORM parameterization, NEVER string interpolation in raw SQL |
|
||||
|
||||
### Quick Reference
|
||||
|
||||
```python
|
||||
# Input validation in serializer
|
||||
def validate_uid(self, value):
|
||||
value = value.strip().lower()
|
||||
if not re.match(r'^[a-z0-9-]+$', value):
|
||||
raise serializers.ValidationError("Invalid format")
|
||||
return value
|
||||
|
||||
# Explicit fields (prevent mass assignment)
|
||||
class Meta:
|
||||
fields = ["name", "email"] # GOOD: whitelist
|
||||
read_only_fields = ["id", "inserted_at"] # System fields
|
||||
|
||||
# Object permission
|
||||
class IsOwnerOrReadOnly(BasePermission):
|
||||
def has_object_permission(self, request, view, obj):
|
||||
if request.method in SAFE_METHODS:
|
||||
return True
|
||||
return obj.owner == request.user
|
||||
|
||||
# Throttling for sensitive endpoints
|
||||
class BurstRateThrottle(UserRateThrottle):
|
||||
rate = "10/minute"
|
||||
|
||||
# Safe error messages (prevent enumeration)
|
||||
def get_object(self):
|
||||
try:
|
||||
return super().get_object()
|
||||
except Http404:
|
||||
raise NotFound("Resource not found") # Generic, no internal IDs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Development
|
||||
cd api && poetry run python src/backend/manage.py runserver
|
||||
cd api && poetry run python src/backend/manage.py shell
|
||||
|
||||
# Database
|
||||
cd api && poetry run python src/backend/manage.py makemigrations
|
||||
cd api && poetry run python src/backend/manage.py migrate
|
||||
|
||||
# Testing
|
||||
cd api && poetry run pytest -x --tb=short
|
||||
cd api && poetry run make lint
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Resources
|
||||
|
||||
### Local References
|
||||
- **File Locations**: See [references/file-locations.md](references/file-locations.md)
|
||||
- **JSON:API Conventions**: See [references/json-api-conventions.md](references/json-api-conventions.md)
|
||||
- **Security Patterns**: See [assets/security_patterns.py](assets/security_patterns.py)
|
||||
|
||||
### Context7 MCP (Recommended)
|
||||
|
||||
**Prerequisite:** Install Context7 MCP server for up-to-date documentation lookup.
|
||||
|
||||
When implementing or debugging, query these libraries via `mcp_context7_query-docs`:
|
||||
|
||||
| Library | Context7 ID | Use For |
|
||||
|---------|-------------|---------|
|
||||
| **Django** | `/websites/djangoproject_en_5_2` | Models, ORM, migrations |
|
||||
| **DRF** | `/websites/django-rest-framework` | ViewSets, serializers, permissions |
|
||||
| **drf-spectacular** | `/tfranzel/drf-spectacular` | OpenAPI schema, `@extend_schema` |
|
||||
|
||||
**Example queries:**
|
||||
```
|
||||
mcp_context7_query-docs(libraryId="/websites/django-rest-framework", query="ViewSet get_queryset best practices")
|
||||
mcp_context7_query-docs(libraryId="/tfranzel/drf-spectacular", query="extend_schema examples for custom actions")
|
||||
mcp_context7_query-docs(libraryId="/websites/djangoproject_en_5_2", query="model constraints and indexes")
|
||||
```
|
||||
|
||||
> **Note:** Use `mcp_context7_resolve-library-id` first if you need to find the correct library ID.
|
||||
|
||||
### External Docs
|
||||
- **DRF Docs**: https://www.django-rest-framework.org/
|
||||
- **DRF JSON:API**: https://django-rest-framework-json-api.readthedocs.io/
|
||||
- **drf-spectacular**: https://drf-spectacular.readthedocs.io/
|
||||
- **django-filter**: https://django-filter.readthedocs.io/
|
||||
## Activation Contract
|
||||
|
||||
Use this skill for generic DRF implementation structure: serializer layering, viewset composition, filtersets, routing, pagination, schema annotations, and query efficiency. Pair it with `jsonapi` for spec compliance and `prowler-api` when tenant isolation, RBAC, providers, or Celery-specific behavior enters the picture.
|
||||
|
||||
## Hard Rules
|
||||
|
||||
- Always separate serializer responsibilities by operation instead of one serializer doing everything.
|
||||
- Always use `filterset_class` for meaningful filtering logic.
|
||||
- Always validate unknown write fields through the repo’s write-serializer pattern.
|
||||
- Always protect `get_queryset()` with `swagger_fake_view` handling and N+1 prevention.
|
||||
- Always prefer UUID-based identifiers and kebab-case API paths.
|
||||
- Never hide business logic in serializers when it belongs in services, utilities, or domain code.
|
||||
|
||||
## Decision Gates
|
||||
|
||||
| Question | Action |
|
||||
|---|---|
|
||||
| Is this a generic DRF endpoint concern? | Use this skill as the primary implementation guide. |
|
||||
| Is the task about payload compliance rather than mechanics? | Load `jsonapi` too. |
|
||||
| Is the endpoint Prowler-specific because of RLS, RBAC, or providers? | Load `prowler-api` too. |
|
||||
| Do reads and writes have different responsibilities? | Split read, create, update, and include serializers. |
|
||||
| Could the queryset explode into N+1 queries or schema-generation failures? | Fix `get_queryset()` with eager loading and `swagger_fake_view` handling. |
|
||||
|
||||
## Execution Steps
|
||||
|
||||
1. Identify the endpoint surface: model, serializer set, filterset, router path, permission rule, or schema annotation.
|
||||
2. Choose the correct base classes for read, write, include, and viewset behavior.
|
||||
3. Design `get_queryset()` for correctness first, then add eager loading and schema-safety.
|
||||
4. Add filtersets, pagination, and action-specific serializers instead of overloading one class.
|
||||
5. Cross-check response shape with `jsonapi` and any tenant/provider behavior with `prowler-api`.
|
||||
6. Return the concrete DRF patterns that should be applied in code.
|
||||
|
||||
## Output Contract
|
||||
|
||||
- State which DRF layer is being guided: serializer, viewset, filterset, router, schema, or permission.
|
||||
- Mention the main pattern chosen, such as split serializers, `filterset_class`, or safe `get_queryset()`.
|
||||
- Name any companion skills required.
|
||||
- Flag the main correctness risk: N+1, schema-generation failure, weak validation, or over-coupled serializer logic.
|
||||
|
||||
## References
|
||||
|
||||
- [Repository agent rules](../../AGENTS.md)
|
||||
- [API component guidance](../../api/AGENTS.md)
|
||||
- [DRF file locations](references/file-locations.md)
|
||||
- [JSON:API conventions](references/json-api-conventions.md)
|
||||
- [Security patterns asset](assets/security_patterns.py)
|
||||
- [JSON:API skill](../jsonapi/SKILL.md)
|
||||
- [Prowler API skill](../prowler-api/SKILL.md)
|
||||
|
||||
+35
-247
@@ -1,8 +1,6 @@
|
||||
---
|
||||
name: jsonapi
|
||||
description: >
|
||||
Strict JSON:API v1.1 specification compliance.
|
||||
Trigger: When creating or modifying API endpoints, reviewing API responses, or validating JSON:API compliance.
|
||||
description: "Trigger: When creating or modifying API endpoints, reviewing API responses, or validating JSON:API behavior in Prowler. Enforces JSON:API v1.1 response, relationship, and media-type compliance."
|
||||
license: Apache-2.0
|
||||
metadata:
|
||||
author: prowler-cloud
|
||||
@@ -14,258 +12,48 @@ metadata:
|
||||
- "Reviewing JSON:API compliance"
|
||||
---
|
||||
|
||||
## Use With django-drf
|
||||
## Activation Contract
|
||||
|
||||
This skill focuses on **spec compliance**. For **implementation patterns** (ViewSets, Serializers, Filters), use `django-drf` skill together with this one.
|
||||
Use this skill when the task is about what the JSON:API contract MUST look like: document shape, media types, relationship linkage, sparse fields, includes, errors, and status-code semantics. Pair it with `django-drf` for implementation mechanics and `prowler-api` for Prowler tenant or provider rules.
|
||||
|
||||
| Skill | Focus |
|
||||
|-------|-------|
|
||||
| `jsonapi` | What the spec requires (MUST/MUST NOT rules) |
|
||||
| `django-drf` | How to implement it in DRF (code patterns) |
|
||||
## Hard Rules
|
||||
|
||||
**When creating/modifying endpoints, invoke BOTH skills.**
|
||||
- Never return `data` and `errors` in the same document.
|
||||
- Always return JSON:API media types and document members consistent with the spec.
|
||||
- Always model resource identifiers with string `id` values and kebab-case `type` values.
|
||||
- Always represent relationships with JSON:API linkage objects, not raw foreign keys.
|
||||
- Always emit error objects as an array and keep `status` as a string.
|
||||
- Never hide spec violations behind framework defaults; verify the final payload shape.
|
||||
|
||||
---
|
||||
## Decision Gates
|
||||
|
||||
## Before Implementing/Reviewing
|
||||
| Question | Action |
|
||||
|---|---|
|
||||
| Are you designing endpoint structure or reviewing payload correctness? | Use this skill as the compliance authority. |
|
||||
| Are you implementing DRF serializers/viewsets/filters too? | Load `django-drf` as a companion skill. |
|
||||
| Does tenant visibility affect whether a resource should appear? | Load `prowler-api` too. |
|
||||
| Is the change about relationship payloads or compound documents? | Validate linkage, `include`, and deduplication rules explicitly. |
|
||||
| Is the response async or task-based? | Confirm status codes and response shape still satisfy JSON:API rules. |
|
||||
|
||||
**ALWAYS validate against the latest spec** before creating or modifying endpoints:
|
||||
## Execution Steps
|
||||
|
||||
### Option 1: Context7 MCP (Preferred)
|
||||
1. Identify the document type involved: success, error, relationship update, compound document, or sparse fieldset response.
|
||||
2. Check media type, top-level members, and status code semantics first.
|
||||
3. Validate resource object shape: `type`, string `id`, `attributes`, and `relationships`.
|
||||
4. Verify query parameter behavior for `include`, `fields`, `filter`, `sort`, and pagination.
|
||||
5. Review error payloads for array shape, string status, and pointers when field-specific.
|
||||
6. Hand implementation details back to `django-drf` once compliance constraints are clear.
|
||||
|
||||
If Context7 MCP is available, query the JSON:API spec directly:
|
||||
## Output Contract
|
||||
|
||||
```
|
||||
mcp_context7_resolve-library-id(query="jsonapi specification")
|
||||
mcp_context7_query-docs(libraryId="<resolved-id>", query="[specific topic: relationships, errors, etc.]")
|
||||
```
|
||||
- State the JSON:API rule or family of rules that governs the task.
|
||||
- Mention the endpoint or payload surface being validated.
|
||||
- Name companion skills needed for implementation or tenant-aware behavior.
|
||||
- Call out the concrete violation risk if the current shape is wrong.
|
||||
|
||||
### Option 2: WebFetch (Fallback)
|
||||
## References
|
||||
|
||||
If Context7 is not available, fetch from the official spec:
|
||||
|
||||
```
|
||||
WebFetch(url="https://jsonapi.org/format/", prompt="Extract rules for [specific topic]")
|
||||
```
|
||||
|
||||
This ensures compliance with the latest JSON:API version, even after spec updates.
|
||||
|
||||
---
|
||||
|
||||
## Critical Rules (NEVER Break)
|
||||
|
||||
### Document Structure
|
||||
- NEVER include both `data` and `errors` in the same response
|
||||
- ALWAYS include at least one of: `data`, `errors`, `meta`
|
||||
- ALWAYS use `type` and `id` (string) in resource objects
|
||||
- NEVER include `id` when creating resources (server generates it)
|
||||
|
||||
### Content-Type
|
||||
- ALWAYS use `Content-Type: application/vnd.api+json`
|
||||
- ALWAYS use `Accept: application/vnd.api+json`
|
||||
- NEVER add parameters to media type without `ext`/`profile`
|
||||
|
||||
### Resource Objects
|
||||
- ALWAYS use **string** for `id` (even if UUID)
|
||||
- ALWAYS use **lowercase kebab-case** for `type`
|
||||
- NEVER put `id` or `type` inside `attributes`
|
||||
- NEVER include foreign keys in `attributes` - use `relationships`
|
||||
|
||||
### Relationships
|
||||
- ALWAYS include at least one of: `links`, `data`, or `meta`
|
||||
- ALWAYS use resource linkage format: `{"type": "...", "id": "..."}`
|
||||
- NEVER use raw IDs in relationships - always use linkage objects
|
||||
|
||||
### Error Objects
|
||||
- ALWAYS return errors as array: `{"errors": [...]}`
|
||||
- ALWAYS include `status` as **string** (e.g., `"400"`, not `400`)
|
||||
- ALWAYS include `source.pointer` for field-specific errors
|
||||
|
||||
---
|
||||
|
||||
## HTTP Status Codes (Mandatory)
|
||||
|
||||
| Operation | Success | Async | Conflict | Not Found | Forbidden | Bad Request |
|
||||
|-----------|---------|-------|----------|-----------|-----------|-------------|
|
||||
| **GET** | `200` | - | - | `404` | `403` | `400` |
|
||||
| **POST** | `201` | `202` | `409` | `404` | `403` | `400` |
|
||||
| **PATCH** | `200` | `202` | `409` | `404` | `403` | `400` |
|
||||
| **DELETE** | `200`/`204` | `202` | - | `404` | `403` | - |
|
||||
|
||||
### When to Use Each
|
||||
|
||||
| Code | Use When |
|
||||
|------|----------|
|
||||
| `200 OK` | Successful GET, PATCH with response body, DELETE with response |
|
||||
| `201 Created` | POST created resource (MUST include `Location` header) |
|
||||
| `202 Accepted` | Async operation started (return task reference) |
|
||||
| `204 No Content` | Successful DELETE, PATCH with no response body |
|
||||
| `400 Bad Request` | Invalid query params, malformed request, unknown fields |
|
||||
| `403 Forbidden` | Authentication ok but no permission, client-generated ID rejected |
|
||||
| `404 Not Found` | Resource doesn't exist OR RLS hides it (never reveal which) |
|
||||
| `409 Conflict` | Duplicate ID, type mismatch, relationship conflict |
|
||||
| `415 Unsupported` | Wrong Content-Type header |
|
||||
|
||||
---
|
||||
|
||||
## Document Structure
|
||||
|
||||
### Success Response (Single)
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"type": "providers",
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"attributes": {
|
||||
"alias": "Production",
|
||||
"connected": true
|
||||
},
|
||||
"relationships": {
|
||||
"tenant": {
|
||||
"data": {"type": "tenants", "id": "..."}
|
||||
}
|
||||
},
|
||||
"links": {
|
||||
"self": "/api/v1/providers/550e8400-..."
|
||||
}
|
||||
},
|
||||
"links": {
|
||||
"self": "/api/v1/providers/550e8400-..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Success Response (List)
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{"type": "providers", "id": "...", "attributes": {...}},
|
||||
{"type": "providers", "id": "...", "attributes": {...}}
|
||||
],
|
||||
"links": {
|
||||
"self": "/api/v1/providers?page[number]=1",
|
||||
"first": "/api/v1/providers?page[number]=1",
|
||||
"last": "/api/v1/providers?page[number]=5",
|
||||
"prev": null,
|
||||
"next": "/api/v1/providers?page[number]=2"
|
||||
},
|
||||
"meta": {
|
||||
"pagination": {"count": 100, "pages": 5}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Error Response
|
||||
|
||||
```json
|
||||
{
|
||||
"errors": [
|
||||
{
|
||||
"status": "400",
|
||||
"code": "invalid",
|
||||
"title": "Invalid attribute",
|
||||
"detail": "UID must be 12 digits for AWS accounts",
|
||||
"source": {"pointer": "/data/attributes/uid"}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Query Parameters
|
||||
|
||||
| Family | Format | Example |
|
||||
|--------|--------|---------|
|
||||
| `page` | `page[number]`, `page[size]` | `?page[number]=2&page[size]=25` |
|
||||
| `filter` | `filter[field]`, `filter[field__op]` | `?filter[status]=FAIL` |
|
||||
| `sort` | Comma-separated, `-` for desc | `?sort=-inserted_at,name` |
|
||||
| `fields` | `fields[type]` | `?fields[providers]=id,alias` |
|
||||
| `include` | Comma-separated paths | `?include=provider,scan.task` |
|
||||
|
||||
### Rules
|
||||
|
||||
- MUST return `400` for unsupported query parameters
|
||||
- MUST return `400` for unsupported `include` paths
|
||||
- MUST return `400` for unsupported `sort` fields
|
||||
- MUST NOT include extra fields when `fields[type]` is specified
|
||||
|
||||
---
|
||||
|
||||
## Common Violations (AVOID)
|
||||
|
||||
| Violation | Wrong | Correct |
|
||||
|-----------|-------|---------|
|
||||
| ID as integer | `"id": 123` | `"id": "123"` |
|
||||
| Type as camelCase | `"type": "providerGroup"` | `"type": "provider-groups"` |
|
||||
| FK in attributes | `"tenant_id": "..."` | `"relationships": {"tenant": {...}}` |
|
||||
| Errors not array | `{"error": "..."}` | `{"errors": [{"detail": "..."}]}` |
|
||||
| Status as number | `"status": 400` | `"status": "400"` |
|
||||
| Data + errors | `{"data": ..., "errors": ...}` | Only one or the other |
|
||||
| Missing pointer | `{"detail": "Invalid"}` | `{"detail": "...", "source": {"pointer": "..."}}` |
|
||||
|
||||
---
|
||||
|
||||
## Relationship Updates
|
||||
|
||||
### To-One Relationship
|
||||
|
||||
```http
|
||||
PATCH /api/v1/providers/123/relationships/tenant
|
||||
Content-Type: application/vnd.api+json
|
||||
|
||||
{"data": {"type": "tenants", "id": "456"}}
|
||||
```
|
||||
|
||||
To clear: `{"data": null}`
|
||||
|
||||
### To-Many Relationship
|
||||
|
||||
| Operation | Method | Body |
|
||||
|-----------|--------|------|
|
||||
| Replace all | PATCH | `{"data": [{...}, {...}]}` |
|
||||
| Add members | POST | `{"data": [{...}]}` |
|
||||
| Remove members | DELETE | `{"data": [{...}]}` |
|
||||
|
||||
---
|
||||
|
||||
## Compound Documents (`include`)
|
||||
|
||||
When using `?include=provider`:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"type": "scans",
|
||||
"id": "...",
|
||||
"relationships": {
|
||||
"provider": {
|
||||
"data": {"type": "providers", "id": "prov-123"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"included": [
|
||||
{
|
||||
"type": "providers",
|
||||
"id": "prov-123",
|
||||
"attributes": {"alias": "Production"}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Rules
|
||||
|
||||
- Every included resource MUST be reachable via relationship chain from primary data
|
||||
- MUST NOT include orphan resources
|
||||
- MUST NOT duplicate resources (same type+id)
|
||||
|
||||
---
|
||||
|
||||
## Spec Reference
|
||||
|
||||
- **Full Specification**: https://jsonapi.org/format/
|
||||
- **Implementation**: Use `django-drf` skill for DRF-specific patterns
|
||||
- **Testing**: Use `prowler-test-api` skill for test patterns
|
||||
- [Repository agent rules](../../AGENTS.md)
|
||||
- [API component guidance](../../api/AGENTS.md)
|
||||
- [DRF implementation skill](../django-drf/SKILL.md)
|
||||
- [Prowler API skill](../prowler-api/SKILL.md)
|
||||
|
||||
+50
-494
@@ -1,8 +1,6 @@
|
||||
---
|
||||
name: prowler-api
|
||||
description: >
|
||||
Prowler API patterns: RLS, RBAC, providers, Celery tasks.
|
||||
Trigger: When working in api/ on models/serializers/viewsets/filters/tasks involving tenant isolation (RLS), RBAC, or provider lifecycle.
|
||||
description: "Trigger: When working in `api/` on Prowler-specific models, serializers, viewsets, filters, Celery tasks, provider lifecycle, RBAC, or tenant isolation. Applies the repository’s RLS-first API contract."
|
||||
license: Apache-2.0
|
||||
metadata:
|
||||
author: prowler-cloud
|
||||
@@ -12,494 +10,52 @@ metadata:
|
||||
allowed-tools: Read, Edit, Write, Glob, Grep, Bash, WebFetch, WebSearch, Task
|
||||
---
|
||||
|
||||
## When to Use
|
||||
|
||||
Use this skill for **Prowler-specific** patterns:
|
||||
- Row-Level Security (RLS) / tenant isolation
|
||||
- RBAC permissions and role checks
|
||||
- Provider lifecycle and validation
|
||||
- Celery tasks with tenant context
|
||||
- Multi-database architecture (4-database setup)
|
||||
|
||||
For **generic DRF patterns** (ViewSets, Serializers, Filters, JSON:API), use `django-drf` skill.
|
||||
|
||||
---
|
||||
|
||||
## Critical Rules
|
||||
|
||||
- ALWAYS use `rls_transaction(tenant_id)` when querying outside ViewSet context
|
||||
- ALWAYS use `get_role()` before checking permissions (returns FIRST role only)
|
||||
- ALWAYS use `@set_tenant` then `@handle_provider_deletion` decorator order
|
||||
- ALWAYS use explicit through models for M2M relationships (required for RLS)
|
||||
- NEVER access `Provider.objects` without RLS context in Celery tasks
|
||||
- NEVER bypass RLS by using raw SQL or `connection.cursor()`
|
||||
- NEVER use Django's default M2M - RLS requires through models with `tenant_id`
|
||||
|
||||
> **Note**: `rls_transaction()` accepts both UUID objects and strings - it converts internally via `str(value)`.
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### 4-Database Architecture
|
||||
|
||||
| Database | Alias | Purpose | RLS |
|
||||
|----------|-------|---------|-----|
|
||||
| `default` | `prowler_user` | Standard API queries | **Yes** |
|
||||
| `admin` | `admin` | Migrations, auth bypass | No |
|
||||
| `replica` | `prowler_user` | Read-only queries | **Yes** |
|
||||
| `admin_replica` | `admin` | Admin read replica | No |
|
||||
|
||||
```python
|
||||
# When to use admin (bypasses RLS)
|
||||
from api.db_router import MainRouter
|
||||
User.objects.using(MainRouter.admin_db).get(id=user_id) # Auth lookups
|
||||
|
||||
# Standard queries use default (RLS enforced)
|
||||
Provider.objects.filter(connected=True) # Requires rls_transaction context
|
||||
```
|
||||
|
||||
### RLS Transaction Flow
|
||||
|
||||
```
|
||||
Request → Authentication → BaseRLSViewSet.initial()
|
||||
│
|
||||
├─ Extract tenant_id from JWT
|
||||
├─ SET api.tenant_id = 'uuid' (PostgreSQL)
|
||||
└─ All queries now tenant-scoped
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
When implementing Prowler-specific API features:
|
||||
|
||||
| # | Pattern | Reference | Key Points |
|
||||
|---|---------|-----------|------------|
|
||||
| 1 | **RLS Models** | `api/rls.py` | Inherit `RowLevelSecurityProtectedModel`, add constraint |
|
||||
| 2 | **RLS Transactions** | `api/db_utils.py` | Use `rls_transaction(tenant_id)` context manager |
|
||||
| 3 | **RBAC Permissions** | `api/rbac/permissions.py` | `get_role()`, `get_providers()`, `Permissions` enum |
|
||||
| 4 | **Provider Validation** | `api/models.py` | `validate_<provider>_uid()` methods on `Provider` model |
|
||||
| 5 | **Celery Tasks** | `tasks/tasks.py`, `api/decorators.py`, `config/celery.py` | Task definitions, decorators (`@set_tenant`, `@handle_provider_deletion`), `RLSTask` base |
|
||||
| 6 | **RLS Serializers** | `api/v1/serializers.py` | Inherit `RLSSerializer` to auto-inject `tenant_id` |
|
||||
| 7 | **Through Models** | `api/models.py` | ALL M2M must use explicit through with `tenant_id` |
|
||||
|
||||
> **Full file paths**: See [references/file-locations.md](references/file-locations.md)
|
||||
|
||||
---
|
||||
|
||||
## Decision Trees
|
||||
|
||||
### Which Base Model?
|
||||
```
|
||||
Tenant-scoped data → RowLevelSecurityProtectedModel
|
||||
Global/shared data → models.Model + BaseSecurityConstraint (rare)
|
||||
Partitioned time-series → PostgresPartitionedModel + RowLevelSecurityProtectedModel
|
||||
Soft-deletable → Add is_deleted + ActiveProviderManager
|
||||
```
|
||||
|
||||
### Which Manager?
|
||||
```
|
||||
Normal queries → Model.objects (excludes deleted)
|
||||
Include deleted records → Model.all_objects
|
||||
Celery task context → Must use rls_transaction() first
|
||||
```
|
||||
|
||||
### Which Database?
|
||||
```
|
||||
Standard API queries → default (automatic via ViewSet)
|
||||
Read-only operations → replica (automatic for GET in BaseRLSViewSet)
|
||||
Auth/admin operations → MainRouter.admin_db
|
||||
Cross-tenant lookups → MainRouter.admin_db (use sparingly!)
|
||||
```
|
||||
|
||||
### Celery Task Decorator Order?
|
||||
```
|
||||
@shared_task(base=RLSTask, name="...", queue="...")
|
||||
@set_tenant # First: sets tenant context
|
||||
@handle_provider_deletion # Second: handles deleted providers
|
||||
def my_task(tenant_id, provider_id):
|
||||
pass
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## RLS Model Pattern
|
||||
|
||||
```python
|
||||
from api.rls import RowLevelSecurityProtectedModel, RowLevelSecurityConstraint
|
||||
|
||||
class MyModel(RowLevelSecurityProtectedModel):
|
||||
# tenant FK inherited from parent
|
||||
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
|
||||
name = models.CharField(max_length=255)
|
||||
inserted_at = models.DateTimeField(auto_now_add=True, editable=False)
|
||||
updated_at = models.DateTimeField(auto_now=True, editable=False)
|
||||
|
||||
class Meta(RowLevelSecurityProtectedModel.Meta):
|
||||
db_table = "my_models"
|
||||
constraints = [
|
||||
RowLevelSecurityConstraint(
|
||||
field="tenant_id",
|
||||
name="rls_on_%(class)s",
|
||||
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
|
||||
),
|
||||
]
|
||||
|
||||
class JSONAPIMeta:
|
||||
resource_name = "my-models"
|
||||
```
|
||||
|
||||
### M2M Relationships (MUST use through models)
|
||||
|
||||
```python
|
||||
class Resource(RowLevelSecurityProtectedModel):
|
||||
tags = models.ManyToManyField(
|
||||
ResourceTag,
|
||||
through="ResourceTagMapping", # REQUIRED for RLS
|
||||
)
|
||||
|
||||
class ResourceTagMapping(RowLevelSecurityProtectedModel):
|
||||
# Through model MUST have tenant_id for RLS
|
||||
resource = models.ForeignKey(Resource, on_delete=models.CASCADE)
|
||||
tag = models.ForeignKey(ResourceTag, on_delete=models.CASCADE)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
RowLevelSecurityConstraint(
|
||||
field="tenant_id",
|
||||
name="rls_on_%(class)s",
|
||||
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
|
||||
),
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Async Task Response Pattern (202 Accepted)
|
||||
|
||||
For long-running operations, return 202 with task reference:
|
||||
|
||||
```python
|
||||
@action(detail=True, methods=["post"], url_name="connection")
|
||||
def connection(self, request, pk=None):
|
||||
with transaction.atomic():
|
||||
task = check_provider_connection_task.delay(
|
||||
provider_id=pk, tenant_id=self.request.tenant_id
|
||||
)
|
||||
prowler_task = Task.objects.get(id=task.id)
|
||||
serializer = TaskSerializer(prowler_task)
|
||||
return Response(
|
||||
data=serializer.data,
|
||||
status=status.HTTP_202_ACCEPTED,
|
||||
headers={"Content-Location": reverse("task-detail", kwargs={"pk": prowler_task.id})}
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Providers (11 Supported)
|
||||
|
||||
| Provider | UID Format | Example |
|
||||
|----------|-----------|---------|
|
||||
| AWS | 12 digits | `123456789012` |
|
||||
| Azure | UUID v4 | `a1b2c3d4-e5f6-...` |
|
||||
| GCP | 6-30 chars, lowercase, letter start | `my-gcp-project` |
|
||||
| M365 | Valid domain | `contoso.onmicrosoft.com` |
|
||||
| Kubernetes | 2-251 chars | `arn:aws:eks:...` |
|
||||
| GitHub | 1-39 chars | `my-org` |
|
||||
| IaC | Git URL | `https://github.com/user/repo.git` |
|
||||
| Oracle Cloud | OCID format | `ocid1.tenancy.oc1..` |
|
||||
| MongoDB Atlas | 24-char hex | `507f1f77bcf86cd799439011` |
|
||||
| Alibaba Cloud | 16 digits | `1234567890123456` |
|
||||
|
||||
**Adding new provider**: Add to `ProviderChoices` enum + create `validate_<provider>_uid()` staticmethod.
|
||||
|
||||
---
|
||||
|
||||
## RBAC Permissions
|
||||
|
||||
| Permission | Controls |
|
||||
|------------|----------|
|
||||
| `MANAGE_USERS` | User CRUD, role assignments |
|
||||
| `MANAGE_ACCOUNT` | Tenant settings |
|
||||
| `MANAGE_BILLING` | Billing/subscription |
|
||||
| `MANAGE_PROVIDERS` | Provider CRUD |
|
||||
| `MANAGE_INTEGRATIONS` | Integration config |
|
||||
| `MANAGE_SCANS` | Scan execution |
|
||||
| `UNLIMITED_VISIBILITY` | See all providers (bypasses provider_groups) |
|
||||
|
||||
### RBAC Visibility Pattern
|
||||
|
||||
```python
|
||||
def get_queryset(self):
|
||||
user_role = get_role(self.request.user)
|
||||
if user_role.unlimited_visibility:
|
||||
return Model.objects.filter(tenant_id=self.request.tenant_id)
|
||||
else:
|
||||
# Filter by provider_groups assigned to role
|
||||
return Model.objects.filter(provider__in=get_providers(user_role))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Celery Queues
|
||||
|
||||
| Queue | Purpose |
|
||||
|-------|---------|
|
||||
| `scans` | Prowler scan execution |
|
||||
| `overview` | Dashboard aggregations (severity, attack surface) |
|
||||
| `compliance` | Compliance report generation |
|
||||
| `integrations` | External integrations (Jira, S3, Security Hub) |
|
||||
| `deletion` | Provider/tenant deletion (async) |
|
||||
| `backfill` | Historical data backfill operations |
|
||||
| `scan-reports` | Output generation (CSV, JSON, HTML, PDF) |
|
||||
|
||||
---
|
||||
|
||||
## Task Composition (Canvas)
|
||||
|
||||
Use Celery's Canvas primitives for complex workflows:
|
||||
|
||||
| Primitive | Use For |
|
||||
|-----------|---------|
|
||||
| `chain()` | Sequential execution: A → B → C |
|
||||
| `group()` | Parallel execution: A, B, C simultaneously |
|
||||
| Combined | Chain with nested groups for complex workflows |
|
||||
|
||||
> **Note:** Use `.si()` (signature immutable) to prevent result passing. Use `.s()` if you need to pass results.
|
||||
|
||||
> **Examples:** See [assets/celery_patterns.py](assets/celery_patterns.py) for chain, group, and combined patterns.
|
||||
|
||||
---
|
||||
|
||||
## Beat Scheduling (Periodic Tasks)
|
||||
|
||||
| Operation | Key Points |
|
||||
|-----------|------------|
|
||||
| **Create schedule** | `IntervalSchedule.objects.get_or_create(every=24, period=HOURS)` |
|
||||
| **Create periodic task** | Use task name (not function), `kwargs=json.dumps(...)` |
|
||||
| **Delete scheduled task** | `PeriodicTask.objects.filter(name=...).delete()` |
|
||||
| **Avoid race conditions** | Use `countdown=5` to wait for DB commit |
|
||||
|
||||
> **Examples:** See [assets/celery_patterns.py](assets/celery_patterns.py) for schedule_provider_scan pattern.
|
||||
|
||||
---
|
||||
|
||||
## Advanced Task Patterns
|
||||
|
||||
### `@set_tenant` Behavior
|
||||
|
||||
| Mode | `tenant_id` in kwargs | `tenant_id` passed to function |
|
||||
|------|----------------------|-------------------------------|
|
||||
| `@set_tenant` (default) | Popped (removed) | NO - function doesn't receive it |
|
||||
| `@set_tenant(keep_tenant=True)` | Read but kept | YES - function receives it |
|
||||
|
||||
### Key Patterns
|
||||
|
||||
| Pattern | Description |
|
||||
|---------|-------------|
|
||||
| `bind=True` | Access `self.request.id`, `self.request.retries` |
|
||||
| `get_task_logger(__name__)` | Proper logging in Celery tasks |
|
||||
| `SoftTimeLimitExceeded` | Catch to save progress before hard kill |
|
||||
| `countdown=30` | Defer execution by N seconds |
|
||||
| `eta=datetime(...)` | Execute at specific time |
|
||||
|
||||
> **Examples:** See [assets/celery_patterns.py](assets/celery_patterns.py) for all advanced patterns.
|
||||
|
||||
---
|
||||
|
||||
## Celery Configuration
|
||||
|
||||
| Setting | Value | Purpose |
|
||||
|---------|-------|---------|
|
||||
| `BROKER_VISIBILITY_TIMEOUT` | `86400` (24h) | Prevent re-queue for long tasks |
|
||||
| `CELERY_RESULT_BACKEND` | `django-db` | Store results in PostgreSQL |
|
||||
| `CELERY_TASK_TRACK_STARTED` | `True` | Track when tasks start |
|
||||
| `soft_time_limit` | Task-specific | Raises `SoftTimeLimitExceeded` |
|
||||
| `time_limit` | Task-specific | Hard kill (SIGKILL) |
|
||||
|
||||
> **Full config:** See [assets/celery_patterns.py](assets/celery_patterns.py) and actual files at `config/celery.py`, `config/settings/celery.py`.
|
||||
|
||||
---
|
||||
|
||||
## UUIDv7 for Partitioned Tables
|
||||
|
||||
`Finding` and `ResourceFindingMapping` use UUIDv7 for time-based partitioning:
|
||||
|
||||
```python
|
||||
from uuid6 import uuid7
|
||||
from api.uuid_utils import uuid7_start, uuid7_end, datetime_to_uuid7
|
||||
|
||||
# Partition-aware filtering
|
||||
start = uuid7_start(datetime_to_uuid7(date_from))
|
||||
end = uuid7_end(datetime_to_uuid7(date_to), settings.FINDINGS_TABLE_PARTITION_MONTHS)
|
||||
queryset.filter(id__gte=start, id__lt=end)
|
||||
```
|
||||
|
||||
**Why UUIDv7?** Time-ordered UUIDs enable PostgreSQL to prune partitions during range queries.
|
||||
|
||||
---
|
||||
|
||||
## Batch Operations with RLS
|
||||
|
||||
```python
|
||||
from api.db_utils import batch_delete, create_objects_in_batches, update_objects_in_batches
|
||||
|
||||
# Delete in batches (RLS-aware)
|
||||
batch_delete(tenant_id, queryset, batch_size=1000)
|
||||
|
||||
# Bulk create with RLS
|
||||
create_objects_in_batches(tenant_id, Finding, objects, batch_size=500)
|
||||
|
||||
# Bulk update with RLS
|
||||
update_objects_in_batches(tenant_id, Finding, objects, fields=["status"], batch_size=500)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security Patterns
|
||||
|
||||
> **Full examples**: See [assets/security_patterns.py](assets/security_patterns.py)
|
||||
|
||||
### Tenant Isolation Summary
|
||||
|
||||
| Pattern | Rule |
|
||||
|---------|------|
|
||||
| **RLS in ViewSets** | Automatic via `BaseRLSViewSet` - tenant_id from JWT |
|
||||
| **RLS in Celery** | MUST use `@set_tenant` + `rls_transaction(tenant_id)` |
|
||||
| **Cross-tenant validation** | Defense-in-depth: verify `obj.tenant_id == request.tenant_id` |
|
||||
| **Never trust user input** | Use `request.tenant_id` from JWT, never `request.data.get("tenant_id")` |
|
||||
| **Admin DB bypass** | Only for cross-tenant admin ops - exposes ALL tenants' data |
|
||||
|
||||
### Celery Task Security Summary
|
||||
|
||||
| Pattern | Rule |
|
||||
|---------|------|
|
||||
| **Named tasks only** | NEVER use dynamic task names from user input |
|
||||
| **Validate arguments** | Check UUID format before database queries |
|
||||
| **Safe queuing** | Use `transaction.on_commit()` to enqueue AFTER commit |
|
||||
| **Modern retries** | Use `autoretry_for`, `retry_backoff`, `retry_jitter` |
|
||||
| **Time limits** | Set `soft_time_limit` and `time_limit` to prevent hung tasks |
|
||||
| **Idempotency** | Use `update_or_create` or idempotency keys |
|
||||
|
||||
### Quick Reference
|
||||
|
||||
```python
|
||||
# Safe task queuing - task only enqueued after transaction commits
|
||||
with transaction.atomic():
|
||||
provider = Provider.objects.create(**data)
|
||||
transaction.on_commit(
|
||||
lambda: verify_provider_connection.delay(
|
||||
tenant_id=str(request.tenant_id),
|
||||
provider_id=str(provider.id)
|
||||
)
|
||||
)
|
||||
|
||||
# Modern retry pattern
|
||||
@shared_task(
|
||||
base=RLSTask,
|
||||
bind=True,
|
||||
autoretry_for=(ConnectionError, TimeoutError, OperationalError),
|
||||
retry_backoff=True,
|
||||
retry_backoff_max=600,
|
||||
retry_jitter=True,
|
||||
max_retries=5,
|
||||
soft_time_limit=300,
|
||||
time_limit=360,
|
||||
)
|
||||
@set_tenant
|
||||
def sync_provider_data(self, tenant_id, provider_id):
|
||||
with rls_transaction(tenant_id):
|
||||
# ... task logic
|
||||
pass
|
||||
|
||||
# Idempotent task - safe to retry
|
||||
@shared_task(base=RLSTask, acks_late=True)
|
||||
@set_tenant
|
||||
def process_finding(tenant_id, finding_uid, data):
|
||||
with rls_transaction(tenant_id):
|
||||
Finding.objects.update_or_create(uid=finding_uid, defaults=data)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Production Deployment Checklist
|
||||
|
||||
> **Full settings**: See [references/production-settings.md](references/production-settings.md)
|
||||
|
||||
Run before every production deployment:
|
||||
|
||||
```bash
|
||||
cd api && poetry run python src/backend/manage.py check --deploy
|
||||
```
|
||||
|
||||
### Critical Settings
|
||||
|
||||
| Setting | Production Value | Risk if Wrong |
|
||||
|---------|-----------------|---------------|
|
||||
| `DEBUG` | `False` | Exposes stack traces, settings, SQL queries |
|
||||
| `SECRET_KEY` | Env var, rotated | Session hijacking, CSRF bypass |
|
||||
| `ALLOWED_HOSTS` | Explicit list | Host header attacks |
|
||||
| `SECURE_SSL_REDIRECT` | `True` | Credentials sent over HTTP |
|
||||
| `SESSION_COOKIE_SECURE` | `True` | Session cookies over HTTP |
|
||||
| `CSRF_COOKIE_SECURE` | `True` | CSRF tokens over HTTP |
|
||||
| `SECURE_HSTS_SECONDS` | `31536000` (1 year) | Downgrade attacks |
|
||||
| `CONN_MAX_AGE` | `60` or higher | Connection pool exhaustion |
|
||||
|
||||
---
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Development
|
||||
cd api && poetry run python src/backend/manage.py runserver
|
||||
cd api && poetry run python src/backend/manage.py shell
|
||||
|
||||
# Celery
|
||||
cd api && poetry run celery -A config.celery worker -l info -Q scans,overview
|
||||
cd api && poetry run celery -A config.celery beat -l info
|
||||
|
||||
# Testing
|
||||
cd api && poetry run pytest -x --tb=short
|
||||
|
||||
# Production checks
|
||||
cd api && poetry run python src/backend/manage.py check --deploy
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Resources
|
||||
|
||||
### Local References
|
||||
- **File Locations**: See [references/file-locations.md](references/file-locations.md)
|
||||
- **Modeling Decisions**: See [references/modeling-decisions.md](references/modeling-decisions.md)
|
||||
- **Configuration**: See [references/configuration.md](references/configuration.md)
|
||||
- **Production Settings**: See [references/production-settings.md](references/production-settings.md)
|
||||
- **Security Patterns**: See [assets/security_patterns.py](assets/security_patterns.py)
|
||||
|
||||
### Related Skills
|
||||
- **Generic DRF Patterns**: Use `django-drf` skill
|
||||
- **API Testing**: Use `prowler-test-api` skill
|
||||
|
||||
### Context7 MCP (Recommended)
|
||||
|
||||
**Prerequisite:** Install Context7 MCP server for up-to-date documentation lookup.
|
||||
|
||||
When implementing or debugging Prowler-specific patterns, query these libraries via `mcp_context7_query-docs`:
|
||||
|
||||
| Library | Context7 ID | Use For |
|
||||
|---------|-------------|---------|
|
||||
| **Celery** | `/websites/celeryq_dev_en_stable` | Task patterns, queues, error handling |
|
||||
| **django-celery-beat** | `/celery/django-celery-beat` | Periodic task scheduling |
|
||||
| **Django** | `/websites/djangoproject_en_5_2` | Models, ORM, constraints, indexes |
|
||||
|
||||
**Example queries:**
|
||||
```
|
||||
mcp_context7_query-docs(libraryId="/websites/celeryq_dev_en_stable", query="shared_task decorator retry patterns")
|
||||
mcp_context7_query-docs(libraryId="/celery/django-celery-beat", query="periodic task database scheduler")
|
||||
mcp_context7_query-docs(libraryId="/websites/djangoproject_en_5_2", query="model constraints CheckConstraint UniqueConstraint")
|
||||
```
|
||||
|
||||
> **Note:** Use `mcp_context7_resolve-library-id` first if you need to find the correct library ID.
|
||||
## Activation Contract
|
||||
|
||||
Use this skill for Prowler API behavior that depends on tenant isolation, RBAC visibility, provider orchestration, or Celery execution semantics. Pair it with `django-drf` for generic DRF patterns and `jsonapi` for response-shape compliance.
|
||||
|
||||
## Hard Rules
|
||||
|
||||
- Always preserve RLS boundaries; queries outside request-scoped viewsets must run inside `rls_transaction(tenant_id)`.
|
||||
- Always check permissions through the repo’s RBAC helpers before assuming provider visibility.
|
||||
- Always model tenant-scoped M2M relations with explicit through models carrying `tenant_id`.
|
||||
- Always keep Celery tenant setup and provider-deletion handling in the established decorator/base-task flow.
|
||||
- Never bypass RLS with raw SQL, unmanaged cursors, or admin connections unless the design explicitly requires cross-tenant access.
|
||||
- Never invent generic DRF patterns here when `django-drf` already owns them.
|
||||
|
||||
## Decision Gates
|
||||
|
||||
| Question | Action |
|
||||
|---|---|
|
||||
| Is the behavior tenant-scoped data access? | Use RLS-safe models, serializers, and `rls_transaction()` where request context is absent. |
|
||||
| Is the endpoint mostly generic DRF plumbing? | Load `django-drf` alongside this skill. |
|
||||
| Is the concern response/media-type compliance? | Load `jsonapi` alongside this skill. |
|
||||
| Is this async provider or scan orchestration? | Use Celery patterns with tenant-aware task setup. |
|
||||
| Does the query need admin or cross-tenant access? | Escalate the reason explicitly and use the admin path sparingly. |
|
||||
|
||||
## Execution Steps
|
||||
|
||||
1. Classify the change: RLS model, RBAC/viewset flow, provider lifecycle, serializer boundary, or Celery workflow.
|
||||
2. Identify where tenant context comes from and where it could be lost.
|
||||
3. Choose the correct base abstractions for models, serializers, viewsets, and tasks.
|
||||
4. Validate relationship modeling, provider visibility, and async handoff against existing Prowler patterns.
|
||||
5. Cross-check the implementation with `django-drf` and `jsonapi` when endpoint behavior is involved.
|
||||
6. Return only the repo-specific constraints that materially affect the change.
|
||||
|
||||
## Output Contract
|
||||
|
||||
- State the Prowler-specific API constraint that governs the task: RLS, RBAC, provider lifecycle, or Celery tenant handling.
|
||||
- Name any companion skills required, especially `django-drf` and `jsonapi`.
|
||||
- Call out the exact files or modules to inspect next.
|
||||
- Mention any high-risk boundary where tenant isolation or provider visibility could break.
|
||||
|
||||
## References
|
||||
|
||||
- [Repository agent rules](../../AGENTS.md)
|
||||
- [API component guidance](../../api/AGENTS.md)
|
||||
- [API file locations](references/file-locations.md)
|
||||
- [API modeling decisions](references/modeling-decisions.md)
|
||||
- [API configuration](references/configuration.md)
|
||||
- [Production settings notes](references/production-settings.md)
|
||||
- [Celery patterns asset](assets/celery_patterns.py)
|
||||
- [Security patterns asset](assets/security_patterns.py)
|
||||
|
||||
+49
-313
@@ -1,8 +1,6 @@
|
||||
---
|
||||
name: prowler-ui
|
||||
description: >
|
||||
Prowler UI-specific patterns. For generic patterns, see: typescript, react-19, nextjs-15, tailwind-4.
|
||||
Trigger: When working inside ui/ on Prowler-specific conventions (shadcn vs HeroUI legacy, folder placement, actions/adapters, shared types/hooks/lib).
|
||||
description: "Trigger: When working inside `ui/` on Prowler-specific app structure, folder placement, shared UI conventions, shadcn adoption, or display-layer patterns beyond generic React/Next.js guidance. Applies the repo’s UI architecture rules."
|
||||
license: Apache-2.0
|
||||
metadata:
|
||||
author: prowler-cloud
|
||||
@@ -14,313 +12,51 @@ metadata:
|
||||
allowed-tools: Read, Edit, Write, Glob, Grep, Bash, WebFetch, WebSearch, Task
|
||||
---
|
||||
|
||||
## Related Generic Skills
|
||||
|
||||
- `typescript` - Const types, flat interfaces
|
||||
- `react-19` - No useMemo/useCallback, compiler
|
||||
- `nextjs-15` - App Router, Server Actions
|
||||
- `tailwind-4` - cn() utility, styling rules
|
||||
- `zod-4` - Schema validation
|
||||
- `zustand-5` - State management
|
||||
- `ai-sdk-5` - Chat/AI features
|
||||
- `playwright` - E2E testing (see also `prowler-test-ui`)
|
||||
|
||||
## Tech Stack (Versions)
|
||||
|
||||
```
|
||||
Next.js 15.5.9 | React 19.2.2 | Tailwind 4.1.13 | shadcn/ui
|
||||
Zod 4.1.11 | React Hook Form 7.62.0 | Zustand 5.0.8
|
||||
NextAuth 5.0.0-beta.30 | Recharts 2.15.4
|
||||
HeroUI 2.8.4 (LEGACY - do not add new components)
|
||||
```
|
||||
|
||||
## CRITICAL: Component Library Rule
|
||||
|
||||
- **ALWAYS**: Use `shadcn/ui` + Tailwind (`components/shadcn/`)
|
||||
- **NEVER**: Add new HeroUI components (`components/ui/` is legacy only)
|
||||
|
||||
## DECISION TREES
|
||||
|
||||
### Component Placement
|
||||
|
||||
```
|
||||
New feature UI? → shadcn/ui + Tailwind
|
||||
Existing HeroUI feature? → Keep HeroUI (don't mix)
|
||||
Used 1 feature? → features/{feature}/components/
|
||||
Used 2+ features? → components/shared/
|
||||
Needs state/hooks? → "use client"
|
||||
Server component? → No directive needed
|
||||
```
|
||||
|
||||
### Code Location
|
||||
|
||||
```
|
||||
Server action → actions/{feature}/{feature}.ts
|
||||
Data transform → actions/{feature}/{feature}.adapter.ts
|
||||
Types (shared 2+) → types/{domain}.ts
|
||||
Types (local 1) → {feature}/types.ts
|
||||
Utils (shared 2+) → lib/
|
||||
Utils (local 1) → {feature}/utils/
|
||||
Hooks (shared 2+) → hooks/
|
||||
Hooks (local 1) → {feature}/hooks.ts
|
||||
shadcn components → components/shadcn/
|
||||
HeroUI components → components/ui/ (LEGACY)
|
||||
```
|
||||
|
||||
### Styling Decision
|
||||
|
||||
```
|
||||
Tailwind class exists? → className
|
||||
Dynamic value? → style prop
|
||||
Conditional styles? → cn()
|
||||
Static only? → className (no cn())
|
||||
Recharts/library? → CHART_COLORS constant + var()
|
||||
```
|
||||
|
||||
### Scope Rule (ABSOLUTE)
|
||||
|
||||
- Used 2+ places → `lib/` or `types/` or `hooks/` (components go in `components/{domain}/`)
|
||||
- Used 1 place → keep local in feature directory
|
||||
- **This determines ALL folder structure decisions**
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
ui/
|
||||
├── app/
|
||||
│ ├── (auth)/ # Auth pages (login, signup)
|
||||
│ └── (prowler)/ # Main app
|
||||
│ ├── compliance/
|
||||
│ ├── findings/
|
||||
│ ├── providers/
|
||||
│ ├── scans/
|
||||
│ ├── services/
|
||||
│ └── integrations/
|
||||
├── components/
|
||||
│ ├── shadcn/ # shadcn/ui (USE THIS)
|
||||
│ ├── ui/ # HeroUI (LEGACY)
|
||||
│ ├── {domain}/ # Domain-specific (compliance, findings, providers, etc.)
|
||||
│ ├── filters/ # Filter components
|
||||
│ ├── graphs/ # Chart components
|
||||
│ └── icons/ # Icon components
|
||||
├── actions/ # Server actions
|
||||
├── types/ # Shared types
|
||||
├── hooks/ # Shared hooks
|
||||
├── lib/ # Utilities
|
||||
├── store/ # Zustand state
|
||||
├── tests/ # Playwright E2E
|
||||
└── styles/ # Global CSS
|
||||
```
|
||||
|
||||
## Recharts (Special Case)
|
||||
|
||||
For Recharts props that don't accept className:
|
||||
|
||||
```typescript
|
||||
const CHART_COLORS = {
|
||||
primary: "var(--color-primary)",
|
||||
secondary: "var(--color-secondary)",
|
||||
text: "var(--color-text)",
|
||||
gridLine: "var(--color-border)",
|
||||
};
|
||||
|
||||
// Only use var() for library props, NEVER in className
|
||||
<XAxis tick={{ fill: CHART_COLORS.text }} />
|
||||
<CartesianGrid stroke={CHART_COLORS.gridLine} />
|
||||
```
|
||||
|
||||
## Form + Validation Pattern
|
||||
|
||||
```typescript
|
||||
"use client";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
const schema = z.object({
|
||||
email: z.email(), // Zod 4 syntax
|
||||
name: z.string().min(1),
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof schema>;
|
||||
|
||||
export function MyForm() {
|
||||
const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
|
||||
resolver: zodResolver(schema),
|
||||
});
|
||||
|
||||
const onSubmit = async (data: FormData) => {
|
||||
await serverAction(data);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<input {...register("email")} />
|
||||
{errors.email && <span>{errors.email.message}</span>}
|
||||
<button type="submit">Submit</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Development
|
||||
cd ui && pnpm install
|
||||
cd ui && pnpm run dev
|
||||
|
||||
# Code Quality
|
||||
cd ui && pnpm run typecheck
|
||||
cd ui && pnpm run lint:fix
|
||||
cd ui && pnpm run format:write
|
||||
cd ui && pnpm run healthcheck # typecheck + lint
|
||||
|
||||
# Testing
|
||||
cd ui && pnpm run test:e2e
|
||||
cd ui && pnpm run test:e2e:ui
|
||||
cd ui && pnpm run test:e2e:debug
|
||||
|
||||
# Build
|
||||
cd ui && pnpm run build
|
||||
cd ui && pnpm start
|
||||
```
|
||||
|
||||
## Batch vs Instant Component API (REQUIRED)
|
||||
|
||||
When a component supports both **batch** (deferred, submit-based) and **instant** (immediate callback) behavior, model the coupling with a discriminated union — never as independent optionals. Coupled props must be all-or-nothing.
|
||||
|
||||
```typescript
|
||||
// ❌ NEVER: Independent optionals — allows invalid half-states
|
||||
interface FilterProps {
|
||||
onBatchApply?: (values: string[]) => void;
|
||||
onInstantChange?: (value: string) => void;
|
||||
isBatchMode?: boolean;
|
||||
}
|
||||
|
||||
// ✅ ALWAYS: Discriminated union — one valid shape per mode
|
||||
type BatchProps = {
|
||||
mode: "batch";
|
||||
onApply: (values: string[]) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
type InstantProps = {
|
||||
mode: "instant";
|
||||
onChange: (value: string) => void;
|
||||
// onApply/onCancel are forbidden here via structural exclusion
|
||||
onApply?: never;
|
||||
onCancel?: never;
|
||||
};
|
||||
|
||||
type FilterProps = BatchProps | InstantProps;
|
||||
```
|
||||
|
||||
This makes invalid prop combinations a compile error, not a runtime surprise.
|
||||
|
||||
## Reuse Shared Display Utilities First (REQUIRED)
|
||||
|
||||
Before adding **local** display maps (labels, provider names, status strings, category formatters), search `ui/types/*` and `ui/lib/*` for existing helpers.
|
||||
|
||||
```typescript
|
||||
// ✅ CHECK THESE FIRST before creating a new map:
|
||||
// ui/lib/utils.ts → general formatters
|
||||
// ui/types/providers.ts → provider display names, icons
|
||||
// ui/types/findings.ts → severity/status display maps
|
||||
// ui/types/compliance.ts → category/group formatters
|
||||
|
||||
// ❌ NEVER add a local map that already exists:
|
||||
const SEVERITY_LABELS: Record<string, string> = {
|
||||
critical: "Critical",
|
||||
high: "High",
|
||||
// ...duplicating an existing shared map
|
||||
};
|
||||
|
||||
// ✅ Import and reuse instead:
|
||||
import { severityLabel } from "@/types/findings";
|
||||
```
|
||||
|
||||
If a helper doesn't exist and will be used in 2+ places, add it to `ui/lib/` or `ui/types/` and reuse it. Keep local only if used in exactly one place.
|
||||
|
||||
## Derived State Rule (REQUIRED)
|
||||
|
||||
Avoid `useState` + `useEffect` patterns that mirror props or searchParams — they create sync bugs and unnecessary re-renders. Derive values directly from the source of truth.
|
||||
|
||||
```typescript
|
||||
// ❌ NEVER: Mirror props into state via effect
|
||||
const [localFilter, setLocalFilter] = useState(filter);
|
||||
useEffect(() => { setLocalFilter(filter); }, [filter]);
|
||||
|
||||
// ✅ ALWAYS: Derive directly
|
||||
const localFilter = filter; // or compute inline
|
||||
```
|
||||
|
||||
If local state is genuinely needed (e.g., optimistic UI, pending edits before submit), add a short comment:
|
||||
|
||||
```typescript
|
||||
// Local state needed: user edits are buffered until "Apply" is clicked
|
||||
const [pending, setPending] = useState(initialValues);
|
||||
```
|
||||
|
||||
## Strict Key Typing for Label Maps (REQUIRED)
|
||||
|
||||
Avoid `Record<string, string>` when the key set is known. Use an explicit union type or a const-key object so typos are caught at compile time.
|
||||
|
||||
```typescript
|
||||
// ❌ Loose — typos compile silently
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
actve: "Active", // typo, no error
|
||||
};
|
||||
|
||||
// ✅ Tight — union key
|
||||
type Status = "active" | "inactive" | "pending";
|
||||
const STATUS_LABELS: Record<Status, string> = {
|
||||
active: "Active",
|
||||
inactive: "Inactive",
|
||||
pending: "Pending",
|
||||
// actve: "Active" ← compile error
|
||||
};
|
||||
|
||||
// ✅ Also fine — const satisfies
|
||||
const STATUS_LABELS = {
|
||||
active: "Active",
|
||||
inactive: "Inactive",
|
||||
pending: "Pending",
|
||||
} as const satisfies Record<Status, string>;
|
||||
```
|
||||
|
||||
## QA Checklist Before Commit
|
||||
|
||||
- [ ] `pnpm run typecheck` passes
|
||||
- [ ] `pnpm run lint:fix` passes
|
||||
- [ ] `pnpm run format:write` passes
|
||||
- [ ] Relevant E2E tests pass
|
||||
- [ ] All UI states handled (loading, error, empty)
|
||||
- [ ] No secrets in code (use `.env.local`)
|
||||
- [ ] Error messages sanitized (no stack traces to users)
|
||||
- [ ] Server-side validation present (don't trust client)
|
||||
- [ ] Accessibility: keyboard navigation, ARIA labels
|
||||
- [ ] Mobile responsive (if applicable)
|
||||
|
||||
## Pre-Re-Review Checklist (Review Thread Hygiene)
|
||||
|
||||
Before requesting re-review from a reviewer:
|
||||
|
||||
- [ ] Every unresolved inline thread has been either fixed or explicitly answered with a rationale
|
||||
- [ ] If you agreed with a comment: the change is committed and the commit hash is mentioned in the reply
|
||||
- [ ] If you disagreed: the reply explains why with clear reasoning — do not leave threads silently open
|
||||
- [ ] Re-request review only after all threads are in a clean state
|
||||
|
||||
## Migrations Reference
|
||||
|
||||
| From | To | Key Changes |
|
||||
|------|-----|-------------|
|
||||
| React 18 | 19.1 | Async components, React Compiler (no useMemo/useCallback) |
|
||||
| Next.js 14 | 15.5 | Improved App Router, better streaming |
|
||||
| NextUI | HeroUI 2.8.4 | Package rename only, same API |
|
||||
| Zod 3 | 4 | `z.email()` not `z.string().email()`, `error` not `message` |
|
||||
| AI SDK 4 | 5 | `@ai-sdk/react`, `sendMessage` not `handleSubmit`, `parts` not `content` |
|
||||
|
||||
## Resources
|
||||
|
||||
- **Documentation**: See [references/](references/) for links to local developer guide
|
||||
## Activation Contract
|
||||
|
||||
Use this skill when the work depends on Prowler UI structure rather than generic framework syntax: component placement, action/adapter boundaries, shared-vs-local scope decisions, legacy HeroUI avoidance, or shared display utilities. Pair it with `react-19`, `nextjs-15`, `tailwind-4`, `typescript`, `zod-4`, or `zustand-5` when those implementation details matter.
|
||||
|
||||
## Hard Rules
|
||||
|
||||
- Always prefer `components/shadcn/` for new UI; do not introduce new HeroUI usage.
|
||||
- Always apply the scope rule first: code reused in 2+ places becomes shared, otherwise keep it local.
|
||||
- Always keep server actions, adapters, types, hooks, and utilities in their intended folders.
|
||||
- Always derive state directly when possible; do not mirror props or search params into effect-driven local state without a real buffering reason.
|
||||
- Always reuse shared label, formatter, and display helpers before adding local maps.
|
||||
- Never encode invalid prop combinations with unrelated optional fields when a discriminated union can model the API correctly.
|
||||
|
||||
## Decision Gates
|
||||
|
||||
| Question | Action |
|
||||
|---|---|
|
||||
| Is this a new component? | Build with shadcn + Tailwind conventions. |
|
||||
| Is logic reused across multiple features? | Promote it to `components/`, `types/`, `hooks/`, or `lib/` as appropriate. |
|
||||
| Is it only used in one feature? | Keep it inside that feature boundary. |
|
||||
| Is styling conditional or compositional? | Use `cn()`; use plain `className` for static classes. |
|
||||
| Does a third-party prop reject Tailwind classes? | Use a constant or `style` value, not `var()` inside `className`. |
|
||||
|
||||
## Execution Steps
|
||||
|
||||
1. Identify whether the change is component structure, state modeling, display formatting, or action/data flow.
|
||||
2. Apply the scope rule to decide local versus shared placement.
|
||||
3. Choose shadcn-first component patterns and keep legacy HeroUI isolated.
|
||||
4. Check shared helpers in `ui/types`, `ui/lib`, and `ui/hooks` before adding duplicates.
|
||||
5. Validate prop APIs, derived state, and styling decisions against the established UI rules.
|
||||
6. Pull in generic framework skills only for the parts they specifically own.
|
||||
|
||||
## Output Contract
|
||||
|
||||
- State where the code should live in `ui/` and why.
|
||||
- Call out the main UI rule applied: shadcn-first, scope rule, derived state, shared helper reuse, or discriminated unions.
|
||||
- Mention any companion generic skills required.
|
||||
- Flag any legacy HeroUI or state-sync risk that must be preserved or removed carefully.
|
||||
|
||||
## References
|
||||
|
||||
- [Repository agent rules](../../AGENTS.md)
|
||||
- [UI component guidance](../../ui/AGENTS.md)
|
||||
- [UI references](references/ui-docs.md)
|
||||
- [TypeScript skill](../typescript/SKILL.md)
|
||||
- [React 19 skill](../react-19/SKILL.md)
|
||||
- [Next.js 15 skill](../nextjs-15/SKILL.md)
|
||||
- [Tailwind 4 skill](../tailwind-4/SKILL.md)
|
||||
|
||||
+34
-43
@@ -1,8 +1,6 @@
|
||||
---
|
||||
name: prowler
|
||||
description: >
|
||||
Main entry point for Prowler development - quick reference for all components.
|
||||
Trigger: General Prowler development questions, project overview, component navigation (NOT PR CI gates or GitHub Actions workflows).
|
||||
description: "Trigger: When the task is general Prowler development, repository navigation, component selection, or project overview work outside PR CI workflow details. Routes the model to the right Prowler surface fast."
|
||||
license: Apache-2.0
|
||||
metadata:
|
||||
author: prowler-cloud
|
||||
@@ -12,54 +10,47 @@ metadata:
|
||||
allowed-tools: Read, Edit, Write, Glob, Grep, Bash, WebFetch, WebSearch, Task
|
||||
---
|
||||
|
||||
## Components
|
||||
## Activation Contract
|
||||
|
||||
| Component | Stack | Location |
|
||||
|-----------|-------|----------|
|
||||
| SDK | Python 3.10+, Poetry | `prowler/` |
|
||||
| API | Django 5.1, DRF, Celery | `api/` |
|
||||
| UI | Next.js 15, React 19, Tailwind 4 | `ui/` |
|
||||
| MCP | FastMCP 2.13.1 | `mcp_server/` |
|
||||
Use this skill first when the model needs to orient itself in the Prowler monorepo, choose the correct component, or point to the follow-up skill that should own the task.
|
||||
|
||||
## Quick Commands
|
||||
## Hard Rules
|
||||
|
||||
```bash
|
||||
# SDK
|
||||
poetry install --with dev
|
||||
poetry run python prowler-cli.py aws --check check_name
|
||||
poetry run pytest tests/
|
||||
- Treat this skill as a router, not the final authority for API, UI, SDK, MCP, CI, or testing implementation details.
|
||||
- Redirect specialized work to the matching Prowler skill before giving deep guidance.
|
||||
- Keep component guidance anchored to real repo paths and current stack names.
|
||||
- Do not use this skill for PR workflow gates or GitHub Actions analysis; those belong to `prowler-pr` or `prowler-ci`.
|
||||
- Prefer concise orientation over long cookbook explanations.
|
||||
|
||||
# API
|
||||
cd api && poetry run python src/backend/manage.py runserver
|
||||
cd api && poetry run pytest
|
||||
## Decision Gates
|
||||
|
||||
# UI
|
||||
cd ui && pnpm run dev
|
||||
cd ui && pnpm run healthcheck
|
||||
| Question | Action |
|
||||
|---|---|
|
||||
| Is the task about monorepo orientation or “where does this live”? | Use this skill and route to the right component. |
|
||||
| Is the task inside `api/` with RLS, RBAC, providers, or Celery? | Load `prowler-api`. |
|
||||
| Is the task inside `ui/` with app structure or component conventions? | Load `prowler-ui`. |
|
||||
| Is the task about checks, providers, compliance, docs, CI, or PR gates? | Hand off to the corresponding specialized Prowler skill. |
|
||||
| Is the task only about testing strategy? | Load `tdd` plus the matching test skill. |
|
||||
|
||||
# MCP
|
||||
cd mcp_server && uv run prowler-mcp
|
||||
## Execution Steps
|
||||
|
||||
# Full Stack
|
||||
docker-compose up -d
|
||||
```
|
||||
1. Identify the affected surface: `prowler/`, `api/`, `ui/`, `mcp_server/`, or cross-cutting docs/CI.
|
||||
2. Confirm the stack and runtime boundary for that surface.
|
||||
3. Route to the correct specialized skill before proposing implementation details.
|
||||
4. If multiple surfaces are involved, call out the primary owner and the supporting skills.
|
||||
5. Return repo paths, component names, and the next best skill to load.
|
||||
|
||||
## Providers
|
||||
## Output Contract
|
||||
|
||||
AWS, Azure, GCP, Kubernetes, GitHub, M365, OCI, AlibabaCloud, Cloudflare, MongoDB Atlas, NHN, LLM, IaC
|
||||
- State the target component or components.
|
||||
- Name the follow-up skill or skills that should own the work.
|
||||
- Mention the canonical repo path(s) to inspect next.
|
||||
- If the task is out of scope for this router skill, say so explicitly.
|
||||
|
||||
## Commit Style
|
||||
## References
|
||||
|
||||
`feat:`, `fix:`, `docs:`, `chore:`, `perf:`, `refactor:`, `test:`
|
||||
|
||||
## Related Skills
|
||||
|
||||
- `prowler-sdk-check` - Create security checks
|
||||
- `prowler-api` - Django/DRF patterns
|
||||
- `prowler-ui` - Next.js/React patterns
|
||||
- `prowler-mcp` - MCP server tools
|
||||
- `prowler-test` - Testing patterns
|
||||
|
||||
## Resources
|
||||
|
||||
- **Documentation**: See [references/](references/) for links to local developer guide
|
||||
- [Repository agent rules](../../AGENTS.md)
|
||||
- [Prowler skill references](references/prowler-docs.md)
|
||||
- [API component guidance](../../api/AGENTS.md)
|
||||
- [UI component guidance](../../ui/AGENTS.md)
|
||||
- [MCP component guidance](../../mcp_server/AGENTS.md)
|
||||
|
||||
+35
-171
@@ -1,8 +1,6 @@
|
||||
---
|
||||
name: pytest
|
||||
description: >
|
||||
Pytest testing patterns for Python.
|
||||
Trigger: When writing or refactoring pytest tests (fixtures, mocking, parametrize, markers). For Prowler-specific API/SDK testing conventions, also use prowler-test-api or prowler-test-sdk.
|
||||
description: "Trigger: When writing or refactoring pytest tests in Python, including fixtures, mocking, parametrization, async tests, and markers. Provides generic pytest structure before component-specific API or SDK rules."
|
||||
license: Apache-2.0
|
||||
metadata:
|
||||
author: prowler-cloud
|
||||
@@ -12,183 +10,49 @@ metadata:
|
||||
allowed-tools: Read, Edit, Write, Glob, Grep, Bash, WebFetch, WebSearch, Task
|
||||
---
|
||||
|
||||
## Basic Test Structure
|
||||
## Activation Contract
|
||||
|
||||
```python
|
||||
import pytest
|
||||
Use this skill for generic pytest structure and patterns; if the test touches Prowler API or SDK specifics, pair it with `prowler-test-api` or `prowler-test-sdk`.
|
||||
|
||||
class TestUserService:
|
||||
def test_create_user_success(self):
|
||||
user = create_user(name="John", email="john@test.com")
|
||||
assert user.name == "John"
|
||||
assert user.email == "john@test.com"
|
||||
## Hard Rules
|
||||
|
||||
def test_create_user_invalid_email_fails(self):
|
||||
with pytest.raises(ValueError, match="Invalid email"):
|
||||
create_user(name="John", email="invalid")
|
||||
```
|
||||
- Keep tests behavior-focused and name them after expected outcomes.
|
||||
- Extract reusable setup into fixtures instead of repeating inline construction.
|
||||
- Use `pytest.raises` for failure expectations and `@pytest.mark.parametrize` for matrix coverage.
|
||||
- Mock external boundaries, not the logic under test.
|
||||
- Register and use markers intentionally; do not invent silent marker names.
|
||||
- Prefer local references only; do not rely on external documentation links inside the skill.
|
||||
|
||||
## Fixtures
|
||||
## Decision Gates
|
||||
|
||||
```python
|
||||
import pytest
|
||||
| Question | Action |
|
||||
|---|---|
|
||||
| Shared setup across tests? | Move it into a fixture or `conftest.py`. |
|
||||
| Same assertion logic over many inputs? | Use `@pytest.mark.parametrize`. |
|
||||
| Need to verify an exception? | Use `pytest.raises(..., match=...)`. |
|
||||
| Testing async behavior? | Use `@pytest.mark.asyncio` or the repo's async test pattern. |
|
||||
| Working in `api/` or `prowler/`? | Load the component-specific testing skill too. |
|
||||
|
||||
@pytest.fixture
|
||||
def user():
|
||||
"""Create a test user."""
|
||||
return User(name="Test User", email="test@example.com")
|
||||
## Execution Steps
|
||||
|
||||
@pytest.fixture
|
||||
def authenticated_client(client, user):
|
||||
"""Client with authenticated user."""
|
||||
client.force_login(user)
|
||||
return client
|
||||
1. Identify whether the test is generic pytest, API-specific, or SDK-specific.
|
||||
2. Read neighboring tests and `conftest.py` before adding new fixtures.
|
||||
3. Write focused test functions or test classes with clear outcome-based names.
|
||||
4. Promote repeated setup into fixtures and shared helpers only when duplication appears twice or more.
|
||||
5. Use parametrization, markers, and mocks deliberately to keep coverage broad but readable.
|
||||
6. Run the narrowest relevant pytest target and inspect failures before widening scope.
|
||||
7. Report the exact command used and any fixture or marker introduced.
|
||||
|
||||
# Fixture with teardown
|
||||
@pytest.fixture
|
||||
def temp_file():
|
||||
path = Path("/tmp/test_file.txt")
|
||||
path.write_text("test content")
|
||||
yield path # Test runs here
|
||||
path.unlink() # Cleanup after test
|
||||
## Output Contract
|
||||
|
||||
# Fixture scopes
|
||||
@pytest.fixture(scope="module") # Once per module
|
||||
@pytest.fixture(scope="class") # Once per class
|
||||
@pytest.fixture(scope="session") # Once per test session
|
||||
```
|
||||
|
||||
## conftest.py
|
||||
|
||||
```python
|
||||
# tests/conftest.py - Shared fixtures
|
||||
import pytest
|
||||
|
||||
@pytest.fixture
|
||||
def db_session():
|
||||
session = create_session()
|
||||
yield session
|
||||
session.rollback()
|
||||
|
||||
@pytest.fixture
|
||||
def api_client():
|
||||
return TestClient(app)
|
||||
```
|
||||
|
||||
## Mocking
|
||||
|
||||
```python
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
class TestPaymentService:
|
||||
def test_process_payment_success(self):
|
||||
with patch("services.payment.stripe_client") as mock_stripe:
|
||||
mock_stripe.charge.return_value = {"id": "ch_123", "status": "succeeded"}
|
||||
|
||||
result = process_payment(amount=100)
|
||||
|
||||
assert result["status"] == "succeeded"
|
||||
mock_stripe.charge.assert_called_once_with(amount=100)
|
||||
|
||||
def test_process_payment_failure(self):
|
||||
with patch("services.payment.stripe_client") as mock_stripe:
|
||||
mock_stripe.charge.side_effect = PaymentError("Card declined")
|
||||
|
||||
with pytest.raises(PaymentError):
|
||||
process_payment(amount=100)
|
||||
|
||||
# MagicMock for complex objects
|
||||
def test_with_mock_object():
|
||||
mock_user = MagicMock()
|
||||
mock_user.id = "user-123"
|
||||
mock_user.name = "Test User"
|
||||
mock_user.is_active = True
|
||||
|
||||
result = get_user_info(mock_user)
|
||||
assert result["name"] == "Test User"
|
||||
```
|
||||
|
||||
## Parametrize
|
||||
|
||||
```python
|
||||
@pytest.mark.parametrize("input,expected", [
|
||||
("hello", "HELLO"),
|
||||
("world", "WORLD"),
|
||||
("pytest", "PYTEST"),
|
||||
])
|
||||
def test_uppercase(input, expected):
|
||||
assert input.upper() == expected
|
||||
|
||||
@pytest.mark.parametrize("email,is_valid", [
|
||||
("user@example.com", True),
|
||||
("invalid-email", False),
|
||||
("", False),
|
||||
("user@.com", False),
|
||||
])
|
||||
def test_email_validation(email, is_valid):
|
||||
assert validate_email(email) == is_valid
|
||||
```
|
||||
|
||||
## Markers
|
||||
|
||||
```python
|
||||
# pytest.ini or pyproject.toml
|
||||
[tool.pytest.ini_options]
|
||||
markers = [
|
||||
"slow: marks tests as slow",
|
||||
"integration: marks integration tests",
|
||||
]
|
||||
|
||||
# Usage
|
||||
@pytest.mark.slow
|
||||
def test_large_data_processing():
|
||||
...
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_database_connection():
|
||||
...
|
||||
|
||||
@pytest.mark.skip(reason="Not implemented yet")
|
||||
def test_future_feature():
|
||||
...
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="Unix only")
|
||||
def test_unix_specific():
|
||||
...
|
||||
|
||||
# Run specific markers
|
||||
# pytest -m "not slow"
|
||||
# pytest -m "integration"
|
||||
```
|
||||
|
||||
## Async Tests
|
||||
|
||||
```python
|
||||
import pytest
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_function():
|
||||
result = await async_fetch_data()
|
||||
assert result is not None
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
pytest # Run all tests
|
||||
pytest -v # Verbose output
|
||||
pytest -x # Stop on first failure
|
||||
pytest -k "test_user" # Filter by name
|
||||
pytest -m "not slow" # Filter by marker
|
||||
pytest --cov=src # With coverage
|
||||
pytest -n auto # Parallel (pytest-xdist)
|
||||
pytest --tb=short # Short traceback
|
||||
```
|
||||
- State whether the change relied on fixtures, parametrization, mocking, markers, or async support.
|
||||
- Mention any component-specific skill paired with pytest.
|
||||
- Report the exact pytest command used for validation.
|
||||
- Call out any test isolation or fixture-scope decision that affects future contributors.
|
||||
|
||||
## References
|
||||
|
||||
For general pytest documentation, see:
|
||||
- **Official Docs**: https://docs.pytest.org/en/stable/
|
||||
|
||||
For Prowler SDK testing with provider-specific patterns (moto, MagicMock), see:
|
||||
- **Documentation**: [references/prowler-testing.md](references/prowler-testing.md)
|
||||
- [TDD skill](../tdd/SKILL.md)
|
||||
- [Prowler API testing skill](../prowler-test-api/SKILL.md)
|
||||
- [Prowler SDK testing skill](../prowler-test-sdk/SKILL.md)
|
||||
- [Repository agent rules](../../AGENTS.md)
|
||||
|
||||
+33
-102
@@ -1,8 +1,6 @@
|
||||
---
|
||||
name: react-19
|
||||
description: >
|
||||
React 19 patterns with React Compiler.
|
||||
Trigger: When writing React 19 components/hooks in .tsx (React Compiler rules, hook patterns, refs as props). If using Next.js App Router/Server Actions, also use nextjs-15.
|
||||
description: "Trigger: When writing React 19 components, hooks, or `.tsx` files, especially with React Compiler, `use()`, actions, or ref-as-prop patterns. Applies React 19 runtime and composition rules."
|
||||
license: Apache-2.0
|
||||
metadata:
|
||||
author: prowler-cloud
|
||||
@@ -12,113 +10,46 @@ metadata:
|
||||
allowed-tools: Read, Edit, Write, Glob, Grep, Bash, WebFetch, WebSearch, Task
|
||||
---
|
||||
|
||||
## No Manual Memoization (REQUIRED)
|
||||
## Activation Contract
|
||||
|
||||
```typescript
|
||||
// ✅ React Compiler handles optimization automatically
|
||||
function Component({ items }) {
|
||||
const filtered = items.filter(x => x.active);
|
||||
const sorted = filtered.sort((a, b) => a.name.localeCompare(b.name));
|
||||
Use this skill when the change is inside React 19 component code and the agent must choose between Server Components, Client Components, compiler-friendly patterns, or modern hook APIs.
|
||||
|
||||
const handleClick = (id) => {
|
||||
console.log(id);
|
||||
};
|
||||
## Hard Rules
|
||||
|
||||
return <List items={sorted} onClick={handleClick} />;
|
||||
}
|
||||
- Do not add `useMemo` or `useCallback` for routine render-path optimization; React Compiler handles the common case.
|
||||
- Prefer Server Components by default; add `"use client"` only for client-only behavior.
|
||||
- Import named React APIs; do not use default `React` imports.
|
||||
- Use `ref` as a prop in React 19 instead of introducing `forwardRef` by habit.
|
||||
- If the task also involves App Router or Server Actions integration details, load `nextjs-15` too.
|
||||
|
||||
// ❌ NEVER: Manual memoization
|
||||
const filtered = useMemo(() => items.filter(x => x.active), [items]);
|
||||
const handleClick = useCallback((id) => console.log(id), []);
|
||||
```
|
||||
## Decision Gates
|
||||
|
||||
## Imports (REQUIRED)
|
||||
| Question | Action |
|
||||
|---|---|
|
||||
| Does the component use state, effects, browser APIs, or event handlers? | Mark it as a Client Component with `"use client"`. |
|
||||
| Does the component only fetch or compose data for rendering? | Keep it as a Server Component. |
|
||||
| Are you reading a promise or conditional context? | Consider `use()` instead of older workarounds. |
|
||||
| Are you wiring form actions or pending state? | Prefer actions and `useActionState`. |
|
||||
| Are you about to add memoization for performance? | Stop and justify it; default to compiler-friendly plain code first. |
|
||||
|
||||
```typescript
|
||||
// ✅ ALWAYS: Named imports
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
## Execution Steps
|
||||
|
||||
// ❌ NEVER
|
||||
import React from "react";
|
||||
import * as React from "react";
|
||||
```
|
||||
1. Identify whether the file should stay server-side or become client-side.
|
||||
2. Remove legacy React imports and manual memoization unless there is a proven exception.
|
||||
3. Keep render logic direct and compiler-friendly.
|
||||
4. Use `use()` for supported promise/context reads when it simplifies the flow.
|
||||
5. Use action-based form patterns for mutation flows when relevant.
|
||||
6. Pass refs as props in new React 19 component APIs.
|
||||
7. Validate that the final component model matches the feature's runtime needs.
|
||||
|
||||
## Server Components First
|
||||
## Output Contract
|
||||
|
||||
```typescript
|
||||
// ✅ Server Component (default) - no directive
|
||||
export default async function Page() {
|
||||
const data = await fetchData();
|
||||
return <ClientComponent data={data} />;
|
||||
}
|
||||
- State whether the component is server or client and why.
|
||||
- Call out any React 19 modernization applied, such as removing manual memoization, using `use()`, or replacing `forwardRef`.
|
||||
- Mention whether `nextjs-15` was also required.
|
||||
|
||||
// ✅ Client Component - only when needed
|
||||
"use client";
|
||||
export function Interactive() {
|
||||
const [state, setState] = useState(false);
|
||||
return <button onClick={() => setState(!state)}>Toggle</button>;
|
||||
}
|
||||
```
|
||||
## References
|
||||
|
||||
## When to use "use client"
|
||||
|
||||
- useState, useEffect, useRef, useContext
|
||||
- Event handlers (onClick, onChange)
|
||||
- Browser APIs (window, localStorage)
|
||||
|
||||
## use() Hook
|
||||
|
||||
```typescript
|
||||
import { use } from "react";
|
||||
|
||||
// Read promises (suspends until resolved)
|
||||
function Comments({ promise }) {
|
||||
const comments = use(promise);
|
||||
return comments.map(c => <div key={c.id}>{c.text}</div>);
|
||||
}
|
||||
|
||||
// Conditional context (not possible with useContext!)
|
||||
function Theme({ showTheme }) {
|
||||
if (showTheme) {
|
||||
const theme = use(ThemeContext);
|
||||
return <div style={{ color: theme.primary }}>Themed</div>;
|
||||
}
|
||||
return <div>Plain</div>;
|
||||
}
|
||||
```
|
||||
|
||||
## Actions & useActionState
|
||||
|
||||
```typescript
|
||||
"use server";
|
||||
async function submitForm(formData: FormData) {
|
||||
await saveToDatabase(formData);
|
||||
revalidatePath("/");
|
||||
}
|
||||
|
||||
// With pending state
|
||||
import { useActionState } from "react";
|
||||
|
||||
function Form() {
|
||||
const [state, action, isPending] = useActionState(submitForm, null);
|
||||
return (
|
||||
<form action={action}>
|
||||
<button disabled={isPending}>
|
||||
{isPending ? "Saving..." : "Save"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## ref as Prop (No forwardRef)
|
||||
|
||||
```typescript
|
||||
// ✅ React 19: ref is just a prop
|
||||
function Input({ ref, ...props }) {
|
||||
return <input ref={ref} {...props} />;
|
||||
}
|
||||
|
||||
// ❌ Old way (unnecessary now)
|
||||
const Input = forwardRef((props, ref) => <input ref={ref} {...props} />);
|
||||
```
|
||||
- [Next.js 15 skill](../nextjs-15/SKILL.md)
|
||||
- [TypeScript skill](../typescript/SKILL.md)
|
||||
- [Repository agent rules](../../AGENTS.md)
|
||||
|
||||
+36
-149
@@ -1,8 +1,6 @@
|
||||
---
|
||||
name: skill-creator
|
||||
description: >
|
||||
Creates new AI agent skills following the Agent Skills spec.
|
||||
Trigger: When user asks to create a new skill, add agent instructions, or document patterns for AI.
|
||||
description: "Trigger: When user asks to create a new skill, add agent instructions, or document patterns for AI. Creates new AI agent skills following the Agent Skills spec."
|
||||
license: Apache-2.0
|
||||
metadata:
|
||||
author: prowler-cloud
|
||||
@@ -12,160 +10,49 @@ metadata:
|
||||
allowed-tools: Read, Edit, Write, Glob, Grep, Bash, WebFetch, WebSearch, Task
|
||||
---
|
||||
|
||||
## When to Create a Skill
|
||||
## Activation Contract
|
||||
|
||||
Create a skill when:
|
||||
- A pattern is used repeatedly and AI needs guidance
|
||||
- Project-specific conventions differ from generic best practices
|
||||
- Complex workflows need step-by-step instructions
|
||||
- Decision trees help AI choose the right approach
|
||||
Use this skill when the task is to create a new skill or reshape rough agent guidance into a reusable skill package.
|
||||
|
||||
**Don't create a skill when:**
|
||||
- Documentation already exists (create a reference instead)
|
||||
- Pattern is trivial or self-explanatory
|
||||
- It's a one-off task
|
||||
## Hard Rules
|
||||
|
||||
---
|
||||
- Create a skill only for reusable, non-trivial patterns.
|
||||
- Keep `description` on one quoted physical line with `Trigger:` first.
|
||||
- Use local references only; never point `references/` at web URLs.
|
||||
- Prefer short rules, decision tables, and minimal examples over tutorials.
|
||||
- Add `metadata.scope` and `metadata.auto_invoke` when the skill should surface in `AGENTS.md` auto-invoke tables.
|
||||
- Do not duplicate long docs inside the skill; point to local references instead.
|
||||
|
||||
## Skill Structure
|
||||
## Decision Gates
|
||||
|
||||
```
|
||||
skills/{skill-name}/
|
||||
├── SKILL.md # Required - main skill file
|
||||
├── assets/ # Optional - templates, schemas, examples
|
||||
│ ├── template.py
|
||||
│ └── schema.json
|
||||
└── references/ # Optional - links to local docs
|
||||
└── docs.md # Points to docs/developer-guide/*.mdx
|
||||
```
|
||||
| Question | Action |
|
||||
|---|---|
|
||||
| Is the pattern already documented well enough? | Reuse or reference the existing doc instead of creating a new skill. |
|
||||
| Is the guidance specific to this repo or workflow? | Create a project-specific skill name such as `prowler-{component}` or `{action}-{target}`. |
|
||||
| Do you need templates, schemas, or example configs? | Put them in `assets/`. |
|
||||
| Do you need supporting documentation? | Link only local files from `references/`. |
|
||||
| Will the skill be auto-invoked from `AGENTS.md`? | Add or update `metadata.scope` and `metadata.auto_invoke`, then decide whether `skill-sync` must run. |
|
||||
|
||||
---
|
||||
## Execution Steps
|
||||
|
||||
## SKILL.md Template
|
||||
1. Confirm the skill does not already exist under `skills/`.
|
||||
2. Choose a reusable name that matches the repo naming conventions.
|
||||
3. Create `skills/{skill-name}/SKILL.md` and required support folders only if needed (`assets/`, `references/`).
|
||||
4. Write frontmatter with `name`, one-line quoted `description`, `license`, and metadata.
|
||||
5. Write the body in this order: Activation Contract, Hard Rules, Decision Gates, Execution Steps, Output Contract, References.
|
||||
6. Keep the body compact: operational instructions first, examples only when they unblock execution.
|
||||
7. If auto-invoke metadata changed, run the `skill-sync` workflow appropriate to the scope.
|
||||
8. Update any non-generated skill index entries the repository expects.
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: {skill-name}
|
||||
description: >
|
||||
{One-line description of what this skill does}.
|
||||
Trigger: {When the AI should load this skill}.
|
||||
license: Apache-2.0
|
||||
metadata:
|
||||
author: prowler-cloud
|
||||
version: "1.0"
|
||||
---
|
||||
## Output Contract
|
||||
|
||||
## When to Use
|
||||
- Return the created or updated skill path(s).
|
||||
- State whether auto-invoke metadata changed and whether `skill-sync` was run, dry-run, or intentionally skipped.
|
||||
- Summarize the reusable pattern the skill captures in 1-3 bullets.
|
||||
- Call out any follow-up files the human should review, such as `AGENTS.md` or assets/templates.
|
||||
|
||||
{Bullet points of when to use this skill}
|
||||
## References
|
||||
|
||||
## Critical Patterns
|
||||
|
||||
{The most important rules - what AI MUST know}
|
||||
|
||||
## Code Examples
|
||||
|
||||
{Minimal, focused examples}
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
{Common commands}
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
- **Templates**: See [assets/](assets/) for {description}
|
||||
- **Documentation**: See [references/](references/) for local docs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
| Type | Pattern | Examples |
|
||||
|------|---------|----------|
|
||||
| Generic skill | `{technology}` | `pytest`, `playwright`, `typescript` |
|
||||
| Prowler-specific | `prowler-{component}` | `prowler-api`, `prowler-ui`, `prowler-sdk-check` |
|
||||
| Testing skill | `prowler-test-{component}` | `prowler-test-sdk`, `prowler-test-api` |
|
||||
| Workflow skill | `{action}-{target}` | `skill-creator`, `jira-task` |
|
||||
|
||||
---
|
||||
|
||||
## Decision: assets/ vs references/
|
||||
|
||||
```
|
||||
Need code templates? → assets/
|
||||
Need JSON schemas? → assets/
|
||||
Need example configs? → assets/
|
||||
Link to existing docs? → references/
|
||||
Link to external guides? → references/ (with local path)
|
||||
```
|
||||
|
||||
**Key Rule**: `references/` should point to LOCAL files (`docs/developer-guide/*.mdx`), not web URLs.
|
||||
|
||||
---
|
||||
|
||||
## Decision: Prowler-Specific vs Generic
|
||||
|
||||
```
|
||||
Patterns apply to ANY project? → Generic skill (e.g., pytest, typescript)
|
||||
Patterns are Prowler-specific? → prowler-{name} skill
|
||||
Generic skill needs Prowler info? → Add references/ pointing to Prowler docs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Frontmatter Fields
|
||||
|
||||
| Field | Required | Description |
|
||||
|-------|----------|-------------|
|
||||
| `name` | Yes | Skill identifier (lowercase, hyphens) |
|
||||
| `description` | Yes | What + Trigger in one block |
|
||||
| `license` | Yes | Always `Apache-2.0` for Prowler |
|
||||
| `metadata.author` | Yes | `prowler-cloud` |
|
||||
| `metadata.version` | Yes | Semantic version as string |
|
||||
|
||||
---
|
||||
|
||||
## Content Guidelines
|
||||
|
||||
### DO
|
||||
- Start with the most critical patterns
|
||||
- Use tables for decision trees
|
||||
- Keep code examples minimal and focused
|
||||
- Include Commands section with copy-paste commands
|
||||
|
||||
### DON'T
|
||||
- Add Keywords section (agent searches frontmatter, not body)
|
||||
- Duplicate content from existing docs (reference instead)
|
||||
- Include lengthy explanations (link to docs)
|
||||
- Add troubleshooting sections (keep focused)
|
||||
- Use web URLs in references (use local paths)
|
||||
|
||||
---
|
||||
|
||||
## Registering the Skill
|
||||
|
||||
After creating the skill, add it to `AGENTS.md`:
|
||||
|
||||
```markdown
|
||||
| `{skill-name}` | {Description} | [SKILL.md](skills/{skill-name}/SKILL.md) |
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Checklist Before Creating
|
||||
|
||||
- [ ] Skill doesn't already exist (check `skills/`)
|
||||
- [ ] Pattern is reusable (not one-off)
|
||||
- [ ] Name follows conventions
|
||||
- [ ] Frontmatter is complete (description includes trigger keywords)
|
||||
- [ ] Critical patterns are clear
|
||||
- [ ] Code examples are minimal
|
||||
- [ ] Commands section exists
|
||||
- [ ] Added to AGENTS.md
|
||||
|
||||
## Resources
|
||||
|
||||
- **Templates**: See [assets/](assets/) for SKILL.md template
|
||||
- [Template](assets/SKILL-TEMPLATE.md)
|
||||
- [Skills overview](../README.md)
|
||||
- [Repository agent rules](../../AGENTS.md)
|
||||
|
||||
+33
-96
@@ -1,8 +1,6 @@
|
||||
---
|
||||
name: skill-sync
|
||||
description: >
|
||||
Syncs skill metadata to AGENTS.md Auto-invoke sections.
|
||||
Trigger: When updating skill metadata (metadata.scope/metadata.auto_invoke), regenerating Auto-invoke tables, or running ./skills/skill-sync/assets/sync.sh (including --dry-run/--scope).
|
||||
description: "Trigger: When updating skill metadata (metadata.scope/metadata.auto_invoke), regenerating Auto-invoke tables, or running ./skills/skill-sync/assets/sync.sh. Syncs skill metadata to AGENTS.md Auto-invoke sections."
|
||||
license: Apache-2.0
|
||||
metadata:
|
||||
author: prowler-cloud
|
||||
@@ -15,107 +13,46 @@ metadata:
|
||||
allowed-tools: Read, Edit, Write, Glob, Grep, Bash
|
||||
---
|
||||
|
||||
## Purpose
|
||||
## Activation Contract
|
||||
|
||||
Keeps AGENTS.md Auto-invoke sections in sync with skill metadata. When you create or modify a skill, run the sync script to automatically update all affected AGENTS.md files.
|
||||
Use this skill when a skill's `metadata.scope` or `metadata.auto_invoke` changes, when auto-invoke tables need regeneration, or when a skill is missing from `AGENTS.md` auto-invoke output.
|
||||
|
||||
## Required Skill Metadata
|
||||
## Hard Rules
|
||||
|
||||
Each skill that should appear in Auto-invoke sections needs these fields in `metadata`.
|
||||
- Treat `./skills/skill-sync/assets/sync.sh` as the source of truth for generated auto-invoke tables.
|
||||
- Do not hand-edit generated auto-invoke sections unless the workflow itself is being fixed.
|
||||
- Run `--dry-run` first when you only need verification or when metadata impact is uncertain.
|
||||
- Only `metadata.scope` and `metadata.auto_invoke` should drive sync decisions.
|
||||
- Keep scope values aligned to real targets: `root`, `ui`, `api`, `sdk`, `mcp_server`.
|
||||
|
||||
`auto_invoke` can be either a single string **or** a list of actions:
|
||||
## Decision Gates
|
||||
|
||||
```yaml
|
||||
metadata:
|
||||
author: prowler-cloud
|
||||
version: "1.0"
|
||||
scope: [ui] # Which AGENTS.md: ui, api, sdk, root
|
||||
| Question | Action |
|
||||
|---|---|
|
||||
| Did `metadata.scope` or `metadata.auto_invoke` change? | Run `sync.sh` for real, or `--scope` if the blast radius is intentionally narrow. |
|
||||
| Did only body text or examples change? | Skip sync and say why; generated tables are unaffected. |
|
||||
| Are you checking expected output without modifying files? | Run `sync.sh --dry-run`. |
|
||||
| Is one surface affected? | Use `sync.sh --scope <scope>`. |
|
||||
| Is a skill missing from auto-invoke output? | Inspect its frontmatter first, then run `--dry-run` to confirm what the script sees. |
|
||||
|
||||
# Option A: single action
|
||||
auto_invoke: "Creating/modifying components"
|
||||
## Execution Steps
|
||||
|
||||
# Option B: multiple actions
|
||||
# auto_invoke:
|
||||
# - "Creating/modifying components"
|
||||
# - "Refactoring component folder placement"
|
||||
```
|
||||
1. Read the changed skill frontmatter and confirm `metadata.scope` and `metadata.auto_invoke` are present and well-formed.
|
||||
2. Decide whether the task needs a real sync, a dry-run, or a documented no-op.
|
||||
3. If validating only, run `./skills/skill-sync/assets/sync.sh --dry-run`.
|
||||
4. If updating one target, run `./skills/skill-sync/assets/sync.sh --scope <scope>`.
|
||||
5. If updating all affected targets, run `./skills/skill-sync/assets/sync.sh`.
|
||||
6. Verify the expected `AGENTS.md` surfaces changed only where metadata demanded it.
|
||||
|
||||
### Scope Values
|
||||
## Output Contract
|
||||
|
||||
| Scope | Updates |
|
||||
|-------|---------|
|
||||
| `root` | `AGENTS.md` (repo root) |
|
||||
| `ui` | `ui/AGENTS.md` |
|
||||
| `api` | `api/AGENTS.md` |
|
||||
| `sdk` | `prowler/AGENTS.md` |
|
||||
| `mcp_server` | `mcp_server/AGENTS.md` |
|
||||
- State whether sync was executed, dry-run only, or skipped as a no-op.
|
||||
- List the scope(s) evaluated and the `AGENTS.md` file(s) affected or intentionally untouched.
|
||||
- If the issue was missing auto-invoke output, explain the root cause in the skill metadata or script behavior.
|
||||
- Return the exact command used for verification or update.
|
||||
|
||||
Skills can have multiple scopes: `scope: [ui, api]`
|
||||
## References
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
### After Creating/Modifying a Skill
|
||||
|
||||
```bash
|
||||
./skills/skill-sync/assets/sync.sh
|
||||
```
|
||||
|
||||
### What It Does
|
||||
|
||||
1. Reads all `skills/*/SKILL.md` files
|
||||
2. Extracts `metadata.scope` and `metadata.auto_invoke`
|
||||
3. Generates Auto-invoke tables for each AGENTS.md
|
||||
4. Updates the `### Auto-invoke Skills` section in each file
|
||||
|
||||
---
|
||||
|
||||
## Example
|
||||
|
||||
Given this skill metadata:
|
||||
|
||||
```yaml
|
||||
# skills/prowler-ui/SKILL.md
|
||||
metadata:
|
||||
author: prowler-cloud
|
||||
version: "1.0"
|
||||
scope: [ui]
|
||||
auto_invoke: "Creating/modifying React components"
|
||||
```
|
||||
|
||||
The sync script generates in `ui/AGENTS.md`:
|
||||
|
||||
```markdown
|
||||
### Auto-invoke Skills
|
||||
|
||||
When performing these actions, ALWAYS invoke the corresponding skill FIRST:
|
||||
|
||||
| Action | Skill |
|
||||
|--------|-------|
|
||||
| Creating/modifying React components | `prowler-ui` |
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Sync all AGENTS.md files
|
||||
./skills/skill-sync/assets/sync.sh
|
||||
|
||||
# Dry run (show what would change)
|
||||
./skills/skill-sync/assets/sync.sh --dry-run
|
||||
|
||||
# Sync specific scope only
|
||||
./skills/skill-sync/assets/sync.sh --scope ui
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Checklist After Modifying Skills
|
||||
|
||||
- [ ] Added `metadata.scope` to new/modified skill
|
||||
- [ ] Added `metadata.auto_invoke` with action description
|
||||
- [ ] Ran `./skills/skill-sync/assets/sync.sh`
|
||||
- [ ] Verified AGENTS.md files updated correctly
|
||||
- [Sync script](assets/sync.sh)
|
||||
- [Sync script test helper](assets/sync_test.sh)
|
||||
- [Repository agent rules](../../AGENTS.md)
|
||||
|
||||
+32
-177
@@ -1,8 +1,6 @@
|
||||
---
|
||||
name: tailwind-4
|
||||
description: >
|
||||
Tailwind CSS 4 patterns and best practices.
|
||||
Trigger: When styling with Tailwind (className, variants, cn()), especially when dynamic styling or CSS variables are involved (no var() in className).
|
||||
description: "Trigger: When styling with Tailwind CSS 4, especially in `className`, variant composition, `cn()`, or dynamic-value decisions. Enforces Tailwind-first styling rules and escape hatches."
|
||||
license: Apache-2.0
|
||||
metadata:
|
||||
author: prowler-cloud
|
||||
@@ -12,188 +10,45 @@ metadata:
|
||||
allowed-tools: Read, Edit, Write, Glob, Grep, Bash, WebFetch, WebSearch, Task
|
||||
---
|
||||
|
||||
## Styling Decision Tree
|
||||
## Activation Contract
|
||||
|
||||
```
|
||||
Tailwind class exists? → className="..."
|
||||
Dynamic value? → style={{ width: `${x}%` }}
|
||||
Conditional styles? → cn("base", condition && "variant")
|
||||
Static only? → className="..." (no cn() needed)
|
||||
Library can't use class?→ style prop with var() constants
|
||||
```
|
||||
Use this skill when UI styling decisions involve Tailwind class composition, semantic theme usage, or choosing between `className`, `cn()`, and inline styles.
|
||||
|
||||
## Critical Rules
|
||||
## Hard Rules
|
||||
|
||||
### Never Use var() in className
|
||||
- Prefer Tailwind utility classes directly in `className` for static styling.
|
||||
- Do not put `var(...)` expressions inside `className`; use semantic Tailwind tokens or inline styles where needed.
|
||||
- Do not use hex colors in class strings; use theme or Tailwind palette classes.
|
||||
- Use `cn()` only when conditional or merge behavior is real.
|
||||
- Use inline `style` only for truly dynamic values or third-party APIs that cannot consume class names.
|
||||
|
||||
```typescript
|
||||
// ❌ NEVER: var() in className
|
||||
<div className="bg-[var(--color-primary)]" />
|
||||
<div className="text-[var(--text-color)]" />
|
||||
## Decision Gates
|
||||
|
||||
// ✅ ALWAYS: Use Tailwind semantic classes
|
||||
<div className="bg-primary" />
|
||||
<div className="text-slate-400" />
|
||||
```
|
||||
| Question | Action |
|
||||
|---|---|
|
||||
| Static styling only? | Use plain `className="..."`. |
|
||||
| Conditional or override-prone classes? | Use `cn(...)`. |
|
||||
| Dynamic numeric or percentage values? | Use the `style` prop. |
|
||||
| Third-party library prop cannot accept classes? | Pass CSS custom property values or inline style constants. |
|
||||
| Need a one-off dimension not in the design system? | Use an arbitrary value sparingly, but never for colors. |
|
||||
|
||||
### Never Use Hex Colors
|
||||
## Execution Steps
|
||||
|
||||
```typescript
|
||||
// ❌ NEVER: Hex colors in className
|
||||
<p className="text-[#ffffff]" />
|
||||
<div className="bg-[#1e293b]" />
|
||||
1. Classify the styling need as static, conditional, dynamic, or third-party-only.
|
||||
2. Prefer semantic Tailwind utilities and theme tokens first.
|
||||
3. Introduce `cn()` only if merge logic or conditions justify it.
|
||||
4. Move dynamic measurements or library-only values into `style` constants.
|
||||
5. Replace color escape hatches with palette or theme classes.
|
||||
6. Review the final markup and remove unnecessary wrappers or styling indirection.
|
||||
|
||||
// ✅ ALWAYS: Use Tailwind color classes
|
||||
<p className="text-white" />
|
||||
<div className="bg-slate-800" />
|
||||
```
|
||||
## Output Contract
|
||||
|
||||
## The cn() Utility
|
||||
- State which styling path was chosen: plain `className`, `cn()`, or inline `style`.
|
||||
- Call out any removed anti-pattern such as `var(...)` in `className` or hex colors.
|
||||
- Mention any remaining escape hatch and why it was necessary.
|
||||
|
||||
```typescript
|
||||
import { clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
## References
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
```
|
||||
|
||||
### When to Use cn()
|
||||
|
||||
```typescript
|
||||
// ✅ Conditional classes
|
||||
<div className={cn("base-class", isActive && "active-class")} />
|
||||
|
||||
// ✅ Merging with potential conflicts
|
||||
<button className={cn("px-4 py-2", className)} /> // className might override
|
||||
|
||||
// ✅ Multiple conditions
|
||||
<div className={cn(
|
||||
"rounded-lg border",
|
||||
variant === "primary" && "bg-blue-500 text-white",
|
||||
variant === "secondary" && "bg-gray-200 text-gray-800",
|
||||
disabled && "opacity-50 cursor-not-allowed"
|
||||
)} />
|
||||
```
|
||||
|
||||
### When NOT to Use cn()
|
||||
|
||||
```typescript
|
||||
// ❌ Static classes - unnecessary wrapper
|
||||
<div className={cn("flex items-center gap-2")} />
|
||||
|
||||
// ✅ Just use className directly
|
||||
<div className="flex items-center gap-2" />
|
||||
```
|
||||
|
||||
## Style Constants for Charts/Libraries
|
||||
|
||||
When libraries don't accept className (like Recharts):
|
||||
|
||||
```typescript
|
||||
// ✅ Constants with var() - ONLY for library props
|
||||
const CHART_COLORS = {
|
||||
primary: "var(--color-primary)",
|
||||
secondary: "var(--color-secondary)",
|
||||
text: "var(--color-text)",
|
||||
gridLine: "var(--color-border)",
|
||||
};
|
||||
|
||||
// Usage with Recharts (can't use className)
|
||||
<XAxis tick={{ fill: CHART_COLORS.text }} />
|
||||
<CartesianGrid stroke={CHART_COLORS.gridLine} />
|
||||
```
|
||||
|
||||
## Dynamic Values
|
||||
|
||||
```typescript
|
||||
// ✅ style prop for truly dynamic values
|
||||
<div style={{ width: `${percentage}%` }} />
|
||||
<div style={{ opacity: isVisible ? 1 : 0 }} />
|
||||
|
||||
// ✅ CSS custom properties for theming
|
||||
<div style={{ "--progress": `${value}%` } as React.CSSProperties} />
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Flexbox
|
||||
|
||||
```typescript
|
||||
<div className="flex items-center justify-between gap-4" />
|
||||
<div className="flex flex-col gap-2" />
|
||||
<div className="inline-flex items-center" />
|
||||
```
|
||||
|
||||
### Grid
|
||||
|
||||
```typescript
|
||||
<div className="grid grid-cols-3 gap-4" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6" />
|
||||
```
|
||||
|
||||
### Spacing
|
||||
|
||||
```typescript
|
||||
// Padding
|
||||
<div className="p-4" /> // All sides
|
||||
<div className="px-4 py-2" /> // Horizontal, vertical
|
||||
<div className="pt-4 pb-2" /> // Top, bottom
|
||||
|
||||
// Margin
|
||||
<div className="m-4" />
|
||||
<div className="mx-auto" /> // Center horizontally
|
||||
<div className="mt-8 mb-4" />
|
||||
```
|
||||
|
||||
### Typography
|
||||
|
||||
```typescript
|
||||
<h1 className="text-2xl font-bold text-white" />
|
||||
<p className="text-sm text-slate-400" />
|
||||
<span className="text-xs font-medium uppercase tracking-wide" />
|
||||
```
|
||||
|
||||
### Borders & Shadows
|
||||
|
||||
```typescript
|
||||
<div className="rounded-lg border border-slate-700" />
|
||||
<div className="rounded-full shadow-lg" />
|
||||
<div className="ring-2 ring-blue-500 ring-offset-2" />
|
||||
```
|
||||
|
||||
### States
|
||||
|
||||
```typescript
|
||||
<button className="hover:bg-blue-600 focus:ring-2 active:scale-95" />
|
||||
<input className="focus:border-blue-500 focus:outline-none" />
|
||||
<div className="group-hover:opacity-100" />
|
||||
```
|
||||
|
||||
### Responsive
|
||||
|
||||
```typescript
|
||||
<div className="w-full md:w-1/2 lg:w-1/3" />
|
||||
<div className="hidden md:block" />
|
||||
<div className="text-sm md:text-base lg:text-lg" />
|
||||
```
|
||||
|
||||
### Dark Mode
|
||||
|
||||
```typescript
|
||||
<div className="bg-white dark:bg-slate-900" />
|
||||
<p className="text-gray-900 dark:text-white" />
|
||||
```
|
||||
|
||||
## Arbitrary Values (Escape Hatch)
|
||||
|
||||
```typescript
|
||||
// ✅ OK for one-off values not in design system
|
||||
<div className="w-[327px]" />
|
||||
<div className="top-[117px]" />
|
||||
<div className="grid-cols-[1fr_2fr_1fr]" />
|
||||
|
||||
// ❌ Don't use for colors - use theme instead
|
||||
<div className="bg-[#1e293b]" /> // NO
|
||||
```
|
||||
- [Prowler UI skill](../prowler-ui/SKILL.md)
|
||||
- [React 19 skill](../react-19/SKILL.md)
|
||||
- [Repository agent rules](../../AGENTS.md)
|
||||
|
||||
+35
-344
@@ -1,9 +1,6 @@
|
||||
---
|
||||
name: tdd
|
||||
description: >
|
||||
Test-Driven Development workflow for ALL Prowler components (UI, SDK, API).
|
||||
Trigger: ALWAYS when implementing features, fixing bugs, or refactoring - regardless of component.
|
||||
This is a MANDATORY workflow, not optional.
|
||||
description: "Trigger: ALWAYS when implementing features, fixing bugs, refactoring, or modifying behavior in Prowler. Enforces the RED -> GREEN -> REFACTOR workflow across UI, API, and SDK work."
|
||||
license: Apache-2.0
|
||||
metadata:
|
||||
author: prowler-cloud
|
||||
@@ -18,354 +15,48 @@ metadata:
|
||||
allowed-tools: Read, Edit, Write, Glob, Grep, Bash, Task
|
||||
---
|
||||
|
||||
## TDD Cycle (MANDATORY)
|
||||
## Activation Contract
|
||||
|
||||
```
|
||||
+-----------------------------------------+
|
||||
| RED -> GREEN -> REFACTOR |
|
||||
| ^ | |
|
||||
| +------------------------+ |
|
||||
+-----------------------------------------+
|
||||
```
|
||||
Use this skill before changing production code whenever the task adds behavior, fixes a bug, or refactors existing logic.
|
||||
|
||||
**The question is NOT "should I write tests?" but "what tests do I need?"**
|
||||
## Hard Rules
|
||||
|
||||
---
|
||||
- Start with a failing test; no production change before RED is proven.
|
||||
- Run the smallest relevant test scope, not the whole suite, unless the refactor safety net requires broader coverage.
|
||||
- Add only enough code to pass the current failing test.
|
||||
- After GREEN, refactor with tests still passing.
|
||||
- Load the stack-specific testing skill when applicable: `vitest`, `prowler-test-ui`, `pytest`, `prowler-test-api`, or `prowler-test-sdk`.
|
||||
|
||||
## The Three Laws of TDD
|
||||
## Decision Gates
|
||||
|
||||
1. **No production code** until you have a failing test
|
||||
2. **No more test** than necessary to fail
|
||||
3. **No more code** than necessary to pass
|
||||
| Question | Action |
|
||||
|---|---|
|
||||
| Working in `ui/`? | Use Vitest conventions and co-located `*.test.{ts,tsx}` files. |
|
||||
| Working in `api/`? | Use pytest + Django patterns and the API testing skill. |
|
||||
| Working in `prowler/`? | Use pytest + provider-specific SDK testing patterns. |
|
||||
| Refactoring without new behavior? | Capture current behavior first by running the closest existing tests before editing. |
|
||||
| No relevant test exists? | Create the narrowest new test that demonstrates the target behavior or bug. |
|
||||
|
||||
---
|
||||
## Execution Steps
|
||||
|
||||
## Detect Your Stack
|
||||
1. Identify the component and matching test runner.
|
||||
2. Read nearby tests first to match naming, fixtures, and assertion style.
|
||||
3. Write or extend one test that fails for the intended behavior.
|
||||
4. Run that focused test and confirm RED.
|
||||
5. Implement the minimum change to reach GREEN.
|
||||
6. Add triangulation cases when one test could be satisfied by a fake or hardcoded implementation.
|
||||
7. Refactor only after the behavior is protected by passing tests.
|
||||
8. Re-run the focused suite and report the exact validation command used.
|
||||
|
||||
Before starting, identify which component you're working on:
|
||||
## Output Contract
|
||||
|
||||
| Working in | Stack | Runner | Test pattern | Details |
|
||||
|------------|-------|--------|-------------|---------|
|
||||
| `ui/` | TypeScript / React | Vitest + RTL | `*.test.{ts,tsx}` (co-located) | See `vitest` skill |
|
||||
| `prowler/` | Python | pytest + moto | `*_test.py` (suffix) in `tests/` | See `prowler-test-sdk` skill |
|
||||
| `api/` | Python / Django | pytest + django | `test_*.py` (prefix) in `api/src/backend/**/tests/` | See `prowler-test-api` skill |
|
||||
- State the RED evidence: which test failed and why.
|
||||
- State the GREEN evidence: which command passed after the change.
|
||||
- Name the stack and test skill used.
|
||||
- Call out any blocker if RED or GREEN could not be executed exactly as intended.
|
||||
|
||||
---
|
||||
## References
|
||||
|
||||
## Phase 0: Assessment (ALWAYS FIRST)
|
||||
|
||||
Before writing ANY code:
|
||||
|
||||
### UI (`ui/`)
|
||||
|
||||
```bash
|
||||
# 1. Find existing tests
|
||||
fd "*.test.tsx" ui/components/feature/
|
||||
|
||||
# 2. Check coverage
|
||||
pnpm test:coverage -- components/feature/
|
||||
|
||||
# 3. Read existing tests
|
||||
```
|
||||
|
||||
### SDK (`prowler/`)
|
||||
|
||||
```bash
|
||||
# 1. Find existing tests
|
||||
fd "*_test.py" tests/providers/aws/services/ec2/
|
||||
|
||||
# 2. Run specific test
|
||||
poetry run pytest tests/providers/aws/services/ec2/ec2_ami_public/ -v
|
||||
|
||||
# 3. Read existing tests
|
||||
```
|
||||
|
||||
### API (`api/`)
|
||||
|
||||
```bash
|
||||
# 1. Find existing tests
|
||||
fd "test_*.py" api/src/backend/api/tests/
|
||||
|
||||
# 2. Run specific test
|
||||
poetry run pytest api/src/backend/api/tests/test_models.py -v
|
||||
|
||||
# 3. Read existing tests
|
||||
```
|
||||
|
||||
### Decision Tree (All Stacks)
|
||||
|
||||
```
|
||||
+------------------------------------------+
|
||||
| Does test file exist for this code? |
|
||||
+----------+-----------------------+-------+
|
||||
| NO | YES
|
||||
v v
|
||||
+------------------+ +------------------+
|
||||
| CREATE test file | | Check coverage |
|
||||
| -> Phase 1: RED | | for your change |
|
||||
+------------------+ +--------+---------+
|
||||
|
|
||||
+--------+--------+
|
||||
| Missing cases? |
|
||||
+---+---------+---+
|
||||
| YES | NO
|
||||
v v
|
||||
+-----------+ +-----------+
|
||||
| ADD tests | | Proceed |
|
||||
| Phase 1 | | Phase 2 |
|
||||
+-----------+ +-----------+
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: RED - Write Failing Tests
|
||||
|
||||
### For NEW Functionality
|
||||
|
||||
**UI (Vitest)**
|
||||
|
||||
```typescript
|
||||
describe("PriceCalculator", () => {
|
||||
it("should return 0 for quantities below threshold", () => {
|
||||
// Given
|
||||
const quantity = 3;
|
||||
|
||||
// When
|
||||
const result = calculateDiscount(quantity);
|
||||
|
||||
// Then
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**SDK (pytest)**
|
||||
|
||||
```python
|
||||
class Test_ec2_ami_public:
|
||||
@mock_aws
|
||||
def test_no_public_amis(self):
|
||||
# Given - No AMIs exist
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
|
||||
|
||||
with mock.patch("prowler...ec2_service", new=EC2(aws_provider)):
|
||||
from prowler...ec2_ami_public import ec2_ami_public
|
||||
|
||||
# When
|
||||
check = ec2_ami_public()
|
||||
result = check.execute()
|
||||
|
||||
# Then
|
||||
assert len(result) == 0
|
||||
```
|
||||
|
||||
**API (pytest-django)**
|
||||
|
||||
```python
|
||||
@pytest.mark.django_db
|
||||
class TestResourceModel:
|
||||
def test_create_resource_with_tags(self, providers_fixture):
|
||||
# Given
|
||||
provider, *_ = providers_fixture
|
||||
tenant_id = provider.tenant_id
|
||||
|
||||
# When
|
||||
resource = Resource.objects.create(
|
||||
tenant_id=tenant_id, provider=provider,
|
||||
uid="arn:aws:ec2:us-east-1:123456789:instance/i-1234",
|
||||
name="test", region="us-east-1", service="ec2", type="instance",
|
||||
)
|
||||
|
||||
# Then
|
||||
assert resource.uid == "arn:aws:ec2:us-east-1:123456789:instance/i-1234"
|
||||
```
|
||||
|
||||
**Run -> MUST fail:** Test references code that doesn't exist yet.
|
||||
|
||||
### For BUG FIXES
|
||||
|
||||
Write a test that **reproduces the bug** first:
|
||||
|
||||
**UI:** `expect(() => render(<DatePicker value={null} />)).not.toThrow();`
|
||||
|
||||
**SDK:** `assert result[0].status == "FAIL" # Currently returns PASS incorrectly`
|
||||
|
||||
**API:** `assert response.status_code == 403 # Currently returns 200`
|
||||
|
||||
**Run -> Should FAIL (reproducing the bug)**
|
||||
|
||||
### For REFACTORING
|
||||
|
||||
Capture ALL current behavior BEFORE refactoring:
|
||||
|
||||
```
|
||||
# Any stack: run ALL existing tests, they should PASS
|
||||
# This is your safety net - if any fail after refactoring, you broke something
|
||||
```
|
||||
|
||||
**Run -> All should PASS (baseline)**
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: GREEN - Minimum Code
|
||||
|
||||
Write the MINIMUM code to make the test pass. Hardcoding is valid for the first test.
|
||||
|
||||
**UI:**
|
||||
|
||||
```typescript
|
||||
// Test expects calculateDiscount(100, 10) === 10
|
||||
function calculateDiscount() {
|
||||
return 10; // FAKE IT - hardcoded is valid for first test
|
||||
}
|
||||
```
|
||||
|
||||
**Python (SDK/API):**
|
||||
|
||||
```python
|
||||
# Test expects check.execute() returns 0 results
|
||||
def execute(self):
|
||||
return [] # FAKE IT - hardcoded is valid for first test
|
||||
```
|
||||
|
||||
**This passes. But we're not done...**
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Triangulation (CRITICAL)
|
||||
|
||||
**One test allows faking. Multiple tests FORCE real logic.**
|
||||
|
||||
Add tests with different inputs that break the hardcoded value:
|
||||
|
||||
| Scenario | Required? |
|
||||
|----------|-----------|
|
||||
| Happy path | YES |
|
||||
| Zero/empty values | YES |
|
||||
| Boundary values | YES |
|
||||
| Different valid inputs | YES (breaks fake) |
|
||||
| Error conditions | YES |
|
||||
|
||||
**UI:**
|
||||
|
||||
```typescript
|
||||
it("should calculate 10% discount", () => {
|
||||
expect(calculateDiscount(100, 10)).toBe(10);
|
||||
});
|
||||
|
||||
// ADD - breaks the fake:
|
||||
it("should calculate 15% on 200", () => {
|
||||
expect(calculateDiscount(200, 15)).toBe(30);
|
||||
});
|
||||
|
||||
it("should return 0 for 0% rate", () => {
|
||||
expect(calculateDiscount(100, 0)).toBe(0);
|
||||
});
|
||||
```
|
||||
|
||||
**Python:**
|
||||
|
||||
```python
|
||||
def test_single_public_ami(self):
|
||||
# Different input -> breaks hardcoded empty list
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
|
||||
def test_private_ami(self):
|
||||
assert result[0].status == "PASS"
|
||||
```
|
||||
|
||||
**Now fake BREAKS -> Real implementation required.**
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: REFACTOR
|
||||
|
||||
Tests GREEN -> Improve code quality WITHOUT changing behavior.
|
||||
|
||||
- Extract functions/methods
|
||||
- Improve naming
|
||||
- Add types/validation
|
||||
- Reduce duplication
|
||||
|
||||
**Run tests after EACH change -> Must stay GREEN**
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
```
|
||||
+------------------------------------------------+
|
||||
| TDD WORKFLOW |
|
||||
+------------------------------------------------+
|
||||
| 0. ASSESS: What tests exist? What's missing? |
|
||||
| |
|
||||
| 1. RED: Write ONE failing test |
|
||||
| +-- Run -> Must fail with clear error |
|
||||
| |
|
||||
| 2. GREEN: Write MINIMUM code to pass |
|
||||
| +-- Fake It is valid for first test |
|
||||
| |
|
||||
| 3. TRIANGULATE: Add tests that break the fake |
|
||||
| +-- Different inputs, edge cases |
|
||||
| |
|
||||
| 4. REFACTOR: Improve with confidence |
|
||||
| +-- Tests stay green throughout |
|
||||
| |
|
||||
| 5. REPEAT: Next behavior/requirement |
|
||||
+------------------------------------------------+
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Anti-Patterns (NEVER DO)
|
||||
|
||||
```
|
||||
# ANY language:
|
||||
|
||||
# 1. Code first, tests after
|
||||
def new_feature(): ... # Then writing tests = USELESS
|
||||
|
||||
# 2. Skip triangulation
|
||||
# Single test allows faking forever
|
||||
|
||||
# 3. Test implementation details
|
||||
assert component.state.is_loading == True # BAD - test behavior, not internals
|
||||
assert mock_service.call_count == 3 # BAD - brittle coupling
|
||||
|
||||
# 4. All tests at once before any code
|
||||
# Write ONE test, make it pass, THEN write the next
|
||||
|
||||
# 5. Giant test methods
|
||||
# Each test should verify ONE behavior
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Commands by Stack
|
||||
|
||||
### UI (`ui/`)
|
||||
|
||||
```bash
|
||||
pnpm test # Watch mode
|
||||
pnpm test:run # Single run (CI)
|
||||
pnpm test:coverage # Coverage report
|
||||
pnpm test ComponentName # Filter by name
|
||||
```
|
||||
|
||||
### SDK (`prowler/`)
|
||||
|
||||
```bash
|
||||
poetry run pytest tests/path/ -v # Run specific tests
|
||||
poetry run pytest tests/path/ -v -k "test_name" # Filter by name
|
||||
poetry run pytest -n auto tests/ # Parallel run
|
||||
poetry run pytest --cov=./prowler tests/ # Coverage
|
||||
```
|
||||
|
||||
### API (`api/`)
|
||||
|
||||
```bash
|
||||
poetry run pytest -x --tb=short # Run all (stop on first fail)
|
||||
poetry run pytest api/src/backend/api/tests/test_file.py # Specific file
|
||||
poetry run pytest -k "test_name" -v # Filter by name
|
||||
```
|
||||
- [Vitest skill](../vitest/SKILL.md)
|
||||
- [Pytest skill](../pytest/SKILL.md)
|
||||
- [Repository agent rules](../../AGENTS.md)
|
||||
|
||||
+33
-120
@@ -1,8 +1,6 @@
|
||||
---
|
||||
name: typescript
|
||||
description: >
|
||||
TypeScript strict patterns and best practices.
|
||||
Trigger: When implementing or refactoring TypeScript in .ts/.tsx (types, interfaces, generics, const maps, type guards, removing any, tightening unknown).
|
||||
description: "Trigger: When implementing or refactoring TypeScript in `.ts` or `.tsx`, including types, interfaces, generics, type guards, const maps, and stricter unknown handling. Enforces strict TypeScript modeling patterns."
|
||||
license: Apache-2.0
|
||||
metadata:
|
||||
author: prowler-cloud
|
||||
@@ -12,131 +10,46 @@ metadata:
|
||||
allowed-tools: Read, Edit, Write, Glob, Grep, Bash, WebFetch, WebSearch, Task
|
||||
---
|
||||
|
||||
## Const Types Pattern (REQUIRED)
|
||||
## Activation Contract
|
||||
|
||||
```typescript
|
||||
// ✅ ALWAYS: Create const object first, then extract type
|
||||
const STATUS = {
|
||||
ACTIVE: "active",
|
||||
INACTIVE: "inactive",
|
||||
PENDING: "pending",
|
||||
} as const;
|
||||
Use this skill when the work changes TypeScript types or when runtime behavior depends on better compile-time modeling.
|
||||
|
||||
type Status = (typeof STATUS)[keyof typeof STATUS];
|
||||
## Hard Rules
|
||||
|
||||
// ❌ NEVER: Direct union types
|
||||
type Status = "active" | "inactive" | "pending";
|
||||
```
|
||||
- Prefer strict, expressive types over `any`; use `unknown`, generics, or narrow unions instead.
|
||||
- Model reusable literals from `as const` objects when values exist at runtime.
|
||||
- Keep interfaces flat; extract nested object shapes into named types.
|
||||
- Use discriminated unions when props or fields are only valid in coordinated sets.
|
||||
- Import types with `import type` when only the type is needed.
|
||||
|
||||
**Why?** Single source of truth, runtime values, autocomplete, easier refactoring.
|
||||
## Decision Gates
|
||||
|
||||
## Flat Interfaces (REQUIRED)
|
||||
| Question | Action |
|
||||
|---|---|
|
||||
| Need both runtime values and a type union? | Create a const object and derive the type from it. |
|
||||
| Is a value shape deeply nested inline? | Extract dedicated named interfaces or types. |
|
||||
| Are multiple optional props semantically coupled? | Replace them with discriminated union branches. |
|
||||
| Is the input truly unknown? | Accept `unknown` and narrow with a type guard. |
|
||||
| Are you duplicating a mapped or transformed shape manually? | Reach for utility types before inventing parallel interfaces. |
|
||||
|
||||
```typescript
|
||||
// ✅ ALWAYS: One level depth, nested objects → dedicated interface
|
||||
interface UserAddress {
|
||||
street: string;
|
||||
city: string;
|
||||
}
|
||||
## Execution Steps
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
address: UserAddress; // Reference, not inline
|
||||
}
|
||||
1. Identify the domain shape that needs stronger typing.
|
||||
2. Replace `any` or weak optionals with precise unions, generics, or guards.
|
||||
3. Convert literal unions to const-derived types when runtime values matter.
|
||||
4. Flatten nested inline objects into named interfaces.
|
||||
5. Use utility types for projections, partials, and derived shapes.
|
||||
6. Re-check imports and convert type-only imports to `import type` where appropriate.
|
||||
7. Validate that invalid states are now rejected by the type system.
|
||||
|
||||
interface Admin extends User {
|
||||
permissions: string[];
|
||||
}
|
||||
## Output Contract
|
||||
|
||||
// ❌ NEVER: Inline nested objects
|
||||
interface User {
|
||||
address: { street: string; city: string }; // NO!
|
||||
}
|
||||
```
|
||||
- Summarize the type-system improvement made.
|
||||
- Call out any invalid state now prevented at compile time.
|
||||
- Mention the main pattern used: const-derived type, discriminated union, utility type, or type guard.
|
||||
|
||||
## Never Use `any`
|
||||
## References
|
||||
|
||||
```typescript
|
||||
// ✅ Use unknown for truly unknown types
|
||||
function parse(input: unknown): User {
|
||||
if (isUser(input)) return input;
|
||||
throw new Error("Invalid input");
|
||||
}
|
||||
|
||||
// ✅ Use generics for flexible types
|
||||
function first<T>(arr: T[]): T | undefined {
|
||||
return arr[0];
|
||||
}
|
||||
|
||||
// ❌ NEVER
|
||||
function parse(input: any): any { }
|
||||
```
|
||||
|
||||
## Utility Types
|
||||
|
||||
```typescript
|
||||
Pick<User, "id" | "name"> // Select fields
|
||||
Omit<User, "id"> // Exclude fields
|
||||
Partial<User> // All optional
|
||||
Required<User> // All required
|
||||
Readonly<User> // All readonly
|
||||
Record<string, User> // Object type
|
||||
Extract<Union, "a" | "b"> // Extract from union
|
||||
Exclude<Union, "a"> // Exclude from union
|
||||
NonNullable<T | null> // Remove null/undefined
|
||||
ReturnType<typeof fn> // Function return type
|
||||
Parameters<typeof fn> // Function params tuple
|
||||
```
|
||||
|
||||
## Type Guards
|
||||
|
||||
```typescript
|
||||
function isUser(value: unknown): value is User {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"id" in value &&
|
||||
"name" in value
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Coupled Optional Props (REQUIRED)
|
||||
|
||||
Do not model semantically coupled props as independent optionals — this allows invalid half-states that compile but break at runtime. Use discriminated unions with `never` to make invalid combinations impossible.
|
||||
|
||||
```typescript
|
||||
// ❌ BEFORE: Independent optionals — half-states allowed
|
||||
interface PaginationProps {
|
||||
onPageChange?: (page: number) => void;
|
||||
pageSize?: number;
|
||||
currentPage?: number;
|
||||
}
|
||||
|
||||
// ✅ AFTER: Discriminated union — shape is all-or-nothing
|
||||
type ControlledPagination = {
|
||||
controlled: true;
|
||||
currentPage: number;
|
||||
pageSize: number;
|
||||
onPageChange: (page: number) => void;
|
||||
};
|
||||
|
||||
type UncontrolledPagination = {
|
||||
controlled: false;
|
||||
currentPage?: never;
|
||||
pageSize?: never;
|
||||
onPageChange?: never;
|
||||
};
|
||||
|
||||
type PaginationProps = ControlledPagination | UncontrolledPagination;
|
||||
```
|
||||
|
||||
**Key rule:** If two or more props are only meaningful together, they belong to the same discriminated union branch. Mixing them as independent optionals shifts correctness responsibility from the type system to runtime guards.
|
||||
|
||||
## Import Types
|
||||
|
||||
```typescript
|
||||
import type { User } from "./types";
|
||||
import { createUser, type Config } from "./utils";
|
||||
```
|
||||
- [React 19 skill](../react-19/SKILL.md)
|
||||
- [Zod 4 skill](../zod-4/SKILL.md)
|
||||
- [Repository agent rules](../../AGENTS.md)
|
||||
|
||||
+35
-175
@@ -1,8 +1,6 @@
|
||||
---
|
||||
name: vitest
|
||||
description: >
|
||||
Vitest unit testing patterns with React Testing Library.
|
||||
Trigger: When writing unit tests for React components, hooks, or utilities.
|
||||
description: "Trigger: When writing or refactoring Vitest tests for React components, hooks, or UI utilities. Defines unit and integration testing patterns with React Testing Library."
|
||||
license: Apache-2.0
|
||||
metadata:
|
||||
author: prowler-cloud
|
||||
@@ -16,186 +14,48 @@ metadata:
|
||||
allowed-tools: Read, Edit, Write, Glob, Grep, Bash, Task
|
||||
---
|
||||
|
||||
> **For E2E tests**: Use `prowler-test-ui` skill (Playwright).
|
||||
> This skill covers **unit/integration tests** with Vitest + React Testing Library.
|
||||
## Activation Contract
|
||||
|
||||
## Test Structure (REQUIRED)
|
||||
Use this skill for UI unit and integration tests built with Vitest and React Testing Library; for browser E2E flows, switch to `prowler-test-ui` instead.
|
||||
|
||||
Use **Given/When/Then** (AAA) pattern with comments:
|
||||
## Hard Rules
|
||||
|
||||
```typescript
|
||||
it("should update user name when form is submitted", async () => {
|
||||
// Given - Arrange
|
||||
const user = userEvent.setup();
|
||||
const onSubmit = vi.fn();
|
||||
render(<UserForm onSubmit={onSubmit} />);
|
||||
- Structure tests with Given/When/Then intent.
|
||||
- Prefer behavior-oriented `describe` blocks grouped by condition, not by implementation method.
|
||||
- Query the screen by accessibility priority first: role, label, placeholder, text, then test id.
|
||||
- Use `userEvent` for interactions unless a lower-level event is explicitly required.
|
||||
- Keep async assertions focused: one expectation per `waitFor` block.
|
||||
- Restore mocks between tests.
|
||||
|
||||
// When - Act
|
||||
await user.type(screen.getByLabelText(/name/i), "John");
|
||||
await user.click(screen.getByRole("button", { name: /submit/i }));
|
||||
## Decision Gates
|
||||
|
||||
// Then - Assert
|
||||
expect(onSubmit).toHaveBeenCalledWith({ name: "John" });
|
||||
});
|
||||
```
|
||||
| Question | Action |
|
||||
|---|---|
|
||||
| Testing a browser flow across pages? | Use `prowler-test-ui`, not Vitest. |
|
||||
| Need to interact like a user? | Use `userEvent.setup()` and await the interaction. |
|
||||
| Element appears later? | Use `findBy*` or `waitFor` appropriately. |
|
||||
| Need a selector? | Prefer accessible queries before `getByTestId`. |
|
||||
| Thinking about testing internals? | Stop and assert user-visible behavior instead. |
|
||||
|
||||
---
|
||||
## Execution Steps
|
||||
|
||||
## Describe Block Organization
|
||||
1. Confirm the test belongs in unit/integration scope, not Playwright.
|
||||
2. Read nearby tests to match file placement and helper patterns.
|
||||
3. Write or update the spec using AAA comments when clarity helps.
|
||||
4. Render through public component APIs and interact through accessible queries.
|
||||
5. Use `userEvent` for user actions and async queries for delayed UI.
|
||||
6. Isolate mocks and restore them after each test.
|
||||
7. Run only the relevant Vitest target and verify the expected behavior.
|
||||
|
||||
```typescript
|
||||
describe("ComponentName", () => {
|
||||
describe("when [condition]", () => {
|
||||
it("should [expected behavior]", () => {});
|
||||
});
|
||||
});
|
||||
```
|
||||
## Output Contract
|
||||
|
||||
**Group by behavior, NOT by method.**
|
||||
- State whether the test covers a component, hook, or utility.
|
||||
- Report the main query and interaction patterns used.
|
||||
- Mention the exact Vitest command or filter used for validation.
|
||||
- Call out if E2E coverage was intentionally out of scope.
|
||||
|
||||
---
|
||||
## References
|
||||
|
||||
## Query Priority (REQUIRED)
|
||||
|
||||
| Priority | Query | Use Case |
|
||||
|----------|-------|----------|
|
||||
| 1 | `getByRole` | Buttons, inputs, headings |
|
||||
| 2 | `getByLabelText` | Form fields |
|
||||
| 3 | `getByPlaceholderText` | Inputs without label |
|
||||
| 4 | `getByText` | Static text |
|
||||
| 5 | `getByTestId` | Last resort only |
|
||||
|
||||
```typescript
|
||||
// ✅ GOOD
|
||||
screen.getByRole("button", { name: /submit/i });
|
||||
screen.getByLabelText(/email/i);
|
||||
|
||||
// ❌ BAD
|
||||
container.querySelector(".btn-primary");
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## userEvent over fireEvent (REQUIRED)
|
||||
|
||||
```typescript
|
||||
// ✅ ALWAYS use userEvent
|
||||
const user = userEvent.setup();
|
||||
await user.click(button);
|
||||
await user.type(input, "hello");
|
||||
|
||||
// ❌ NEVER use fireEvent for interactions
|
||||
fireEvent.click(button);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Async Testing Patterns
|
||||
|
||||
```typescript
|
||||
// ✅ findBy for elements that appear async
|
||||
const element = await screen.findByText(/loaded/i);
|
||||
|
||||
// ✅ waitFor for assertions
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/success/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ✅ ONE assertion per waitFor
|
||||
await waitFor(() => expect(mockFn).toHaveBeenCalled());
|
||||
await waitFor(() => expect(screen.getByText(/done/i)).toBeVisible());
|
||||
|
||||
// ❌ NEVER multiple assertions in waitFor
|
||||
await waitFor(() => {
|
||||
expect(mockFn).toHaveBeenCalled();
|
||||
expect(screen.getByText(/done/i)).toBeVisible(); // Slower failures
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Mocking
|
||||
|
||||
```typescript
|
||||
// Basic mock
|
||||
const handleClick = vi.fn();
|
||||
|
||||
// Mock with return value
|
||||
const fetchUser = vi.fn().mockResolvedValue({ name: "John" });
|
||||
|
||||
// Always clean up
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
```
|
||||
|
||||
### vi.spyOn vs vi.mock
|
||||
|
||||
| Method | When to Use |
|
||||
|--------|-------------|
|
||||
| `vi.spyOn` | Observe without replacing (PREFERRED) |
|
||||
| `vi.mock` | Replace entire module (use sparingly) |
|
||||
|
||||
---
|
||||
|
||||
## Common Matchers
|
||||
|
||||
```typescript
|
||||
// Presence
|
||||
expect(element).toBeInTheDocument();
|
||||
expect(element).toBeVisible();
|
||||
|
||||
// State
|
||||
expect(button).toBeDisabled();
|
||||
expect(input).toHaveValue("text");
|
||||
expect(checkbox).toBeChecked();
|
||||
|
||||
// Content
|
||||
expect(element).toHaveTextContent(/hello/i);
|
||||
expect(element).toHaveAttribute("href", "/home");
|
||||
|
||||
// Functions
|
||||
expect(fn).toHaveBeenCalledWith(arg1, arg2);
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## What NOT to Test
|
||||
|
||||
```typescript
|
||||
// ❌ Internal state
|
||||
expect(component.state.isLoading).toBe(true);
|
||||
|
||||
// ❌ Third-party libraries
|
||||
expect(axios.get).toHaveBeenCalled();
|
||||
|
||||
// ❌ Static content (unless conditional)
|
||||
expect(screen.getByText("Welcome")).toBeInTheDocument();
|
||||
|
||||
// ✅ User-visible behavior
|
||||
expect(screen.getByRole("button")).toBeDisabled();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File Organization
|
||||
|
||||
```
|
||||
components/
|
||||
├── Button/
|
||||
│ ├── Button.tsx
|
||||
│ ├── Button.test.tsx # Co-located
|
||||
│ └── index.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
pnpm test # Watch mode
|
||||
pnpm test:run # Single run
|
||||
pnpm test:coverage # With coverage
|
||||
pnpm test Button # Filter by name
|
||||
```
|
||||
- [TDD skill](../tdd/SKILL.md)
|
||||
- [Prowler UI E2E skill](../prowler-test-ui/SKILL.md)
|
||||
- [Repository agent rules](../../AGENTS.md)
|
||||
|
||||
-174
@@ -1,174 +0,0 @@
|
||||
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,14 +406,12 @@ class TestBedrockPromptPagination:
|
||||
regional_client.get_paginator.assert_called_once_with("list_prompts")
|
||||
paginator.paginate.assert_called_once()
|
||||
|
||||
def test_list_prompts_filters_audit_resources(self):
|
||||
"""Prompt collection must honor audit_resources when resource ARNs are scoped."""
|
||||
def test_list_prompts_ignores_audit_resources_filter(self):
|
||||
"""Prompt collection is region-scoped and must ignore audit_resources."""
|
||||
audit_info = MagicMock()
|
||||
audit_info.audited_partition = "aws"
|
||||
audit_info.audited_account = "123456789012"
|
||||
audit_info.audit_resources = [
|
||||
"arn:aws:bedrock:us-east-1:123456789012:prompt/prompt-1"
|
||||
]
|
||||
audit_info.audit_resources = ["arn:aws:s3:::unrelated-resource"]
|
||||
|
||||
regional_client = MagicMock()
|
||||
regional_client.region = "us-east-1"
|
||||
@@ -426,12 +424,7 @@ 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",
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -445,14 +438,6 @@ 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):
|
||||
|
||||
+2
-8
@@ -2,18 +2,12 @@
|
||||
|
||||
All notable changes to the **Prowler UI** are documented in this file.
|
||||
|
||||
## [1.26.0] (Prowler v5.26.0)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
- ASD Essential Eight compliance framework support [(#11071)](https://github.com/prowler-cloud/prowler/pull/11071)
|
||||
## [1.26.0] (Prowler UNRELEASED)
|
||||
|
||||
### 🔄 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)
|
||||
|
||||
---
|
||||
|
||||
@@ -53,7 +47,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,7 +139,6 @@ interface FindingGroupResourceAttributes {
|
||||
resource: ResourceInfo;
|
||||
provider: ProviderInfo;
|
||||
status: string;
|
||||
status_extended?: string;
|
||||
muted?: boolean;
|
||||
delta?: string | null;
|
||||
severity: string;
|
||||
@@ -188,7 +187,6 @@ export function adaptFindingGroupResourcesResponse(
|
||||
region: item.attributes.resource?.region || "-",
|
||||
severity: (item.attributes.severity || "informational") as Severity,
|
||||
status: item.attributes.status,
|
||||
statusExtended: item.attributes.status_extended,
|
||||
delta: item.attributes.delta || null,
|
||||
isMuted: item.attributes.muted ?? item.attributes.status === "MUTED",
|
||||
mutedReason: item.attributes.muted_reason || undefined,
|
||||
|
||||
@@ -174,7 +174,7 @@ describe("AlertsManager", () => {
|
||||
expect(findingsLink.closest("[data-variant='link']")).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "here." })).toHaveAttribute(
|
||||
"href",
|
||||
"https://docs.prowler.com/user-guide/tutorials/prowler-app-alerts",
|
||||
"https://docs.prowler.com/user-guide/tutorials/prowler-app",
|
||||
);
|
||||
expect(screen.getByText(/get notified when findings match/i)).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -42,7 +42,6 @@ interface ComplianceDetailSearchParams {
|
||||
complianceId: string;
|
||||
version?: string;
|
||||
scanId?: string;
|
||||
section?: string;
|
||||
"filter[region__in]"?: string;
|
||||
"filter[cis_profile_level]"?: string;
|
||||
page?: string;
|
||||
@@ -58,7 +57,7 @@ export default async function ComplianceDetail({
|
||||
}) {
|
||||
const { compliancetitle } = await params;
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const { complianceId, version, scanId, section } = resolvedSearchParams;
|
||||
const { complianceId, version, scanId } = resolvedSearchParams;
|
||||
const regionFilter = resolvedSearchParams["filter[region__in]"];
|
||||
const cisProfileFilter = resolvedSearchParams["filter[cis_profile_level]"];
|
||||
const logoPath = getComplianceIcon(compliancetitle);
|
||||
@@ -226,7 +225,6 @@ export default async function ComplianceDetail({
|
||||
filter={cisProfileFilter}
|
||||
attributesData={attributesData}
|
||||
threatScoreData={threatScoreData}
|
||||
targetSection={section}
|
||||
/>
|
||||
</Suspense>
|
||||
</ContentLayout>
|
||||
@@ -240,7 +238,6 @@ const SSRComplianceContent = async ({
|
||||
filter,
|
||||
attributesData,
|
||||
threatScoreData,
|
||||
targetSection,
|
||||
}: {
|
||||
complianceId: string;
|
||||
scanId: string;
|
||||
@@ -251,7 +248,6 @@ const SSRComplianceContent = async ({
|
||||
overallScore: number;
|
||||
sectionScores: Record<string, number>;
|
||||
} | null;
|
||||
targetSection?: string;
|
||||
}) => {
|
||||
const requirementsData = await getComplianceRequirements({
|
||||
complianceId,
|
||||
@@ -292,21 +288,6 @@ 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 */}
|
||||
@@ -334,7 +315,6 @@ const SSRComplianceContent = async ({
|
||||
<TopFailedSectionsCard
|
||||
sections={topFailedResult.items}
|
||||
dataType={topFailedResult.type}
|
||||
prepopulated={topFailedResult.prepopulated}
|
||||
/>
|
||||
{/* <SectionsFailureRateCard categories={categoryHeatmapData} /> */}
|
||||
</div>
|
||||
@@ -343,8 +323,7 @@ const SSRComplianceContent = async ({
|
||||
<ClientAccordionWrapper
|
||||
hideExpandButton={complianceId.includes("mitre_attack")}
|
||||
items={accordionItems}
|
||||
defaultExpandedKeys={initialExpandedKeys}
|
||||
scrollToKey={initialExpandedKeys[0]}
|
||||
defaultExpandedKeys={[]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { Accordion, AccordionItemProps } from "@/components/ui";
|
||||
@@ -9,12 +9,10 @@ export const ClientAccordionWrapper = ({
|
||||
items,
|
||||
defaultExpandedKeys,
|
||||
hideExpandButton = false,
|
||||
scrollToKey,
|
||||
}: {
|
||||
items: AccordionItemProps[];
|
||||
defaultExpandedKeys: string[];
|
||||
hideExpandButton?: boolean;
|
||||
scrollToKey?: string;
|
||||
}) => {
|
||||
const [selectedKeys, setSelectedKeys] =
|
||||
useState<string[]>(defaultExpandedKeys);
|
||||
@@ -58,33 +56,8 @@ 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 ref={containerRef}>
|
||||
<div>
|
||||
{!hideExpandButton && (
|
||||
<div className="text-text-neutral-tertiary hover:text-text-neutral-primary mt-[-16px] flex justify-end text-xs font-medium transition-colors">
|
||||
<Button
|
||||
@@ -102,6 +75,7 @@ export const ClientAccordionWrapper = ({
|
||||
items={items}
|
||||
variant="light"
|
||||
selectionMode="multiple"
|
||||
defaultExpandedKeys={defaultExpandedKeys}
|
||||
selectedKeys={selectedKeys}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
/>
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
getScoreTextClass,
|
||||
SCORE_COLORS,
|
||||
} from "@/lib/compliance/score-utils";
|
||||
import { getOrderedPillars } from "@/lib/compliance/threatscore-pillars";
|
||||
|
||||
export interface ThreatScoreBreakdownCardProps {
|
||||
overallScore: number;
|
||||
@@ -26,17 +25,17 @@ export function ThreatScoreBreakdownCard({
|
||||
const scoreLevel = getScoreLevel(overallScore);
|
||||
const scoreColor = SCORE_COLORS[scoreLevel];
|
||||
|
||||
const pillars = getOrderedPillars(sectionScores);
|
||||
// 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)],
|
||||
}));
|
||||
|
||||
// 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)],
|
||||
}));
|
||||
// Sort sections by score (lowest first to highlight areas needing attention)
|
||||
const sortedSections = Object.entries(sectionScores).sort(
|
||||
([, a], [, b]) => a - b,
|
||||
);
|
||||
|
||||
return (
|
||||
<Card variant="base" className="flex h-full w-full flex-col">
|
||||
@@ -77,23 +76,19 @@ export function ThreatScoreBreakdownCard({
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{pillars.map(({ name, score, hasData }) => (
|
||||
<div key={name} className="space-y-0.5">
|
||||
{sortedSections.map(([section, score]) => (
|
||||
<div key={section} className="space-y-0.5">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<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 className="text-default-700 truncate pr-2">
|
||||
{section}
|
||||
</span>
|
||||
<span className={`font-semibold ${getScoreTextClass(score)}`}>
|
||||
{score.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
aria-label={`${name} score`}
|
||||
value={hasData ? score : 0}
|
||||
aria-label={`${section} score`}
|
||||
value={score}
|
||||
color={getScoreColor(score)}
|
||||
size="md"
|
||||
className="w-full"
|
||||
|
||||
@@ -12,17 +12,13 @@ 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) => ({
|
||||
@@ -43,10 +39,7 @@ export function TopFailedSectionsCard({
|
||||
<CardTitle>{title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-1 items-center justify-start">
|
||||
<HorizontalBarChart
|
||||
data={barData}
|
||||
useSeverityEmptyState={!prepopulated}
|
||||
/>
|
||||
<HorizontalBarChart data={barData} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
-226
@@ -1,226 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,169 +0,0 @@
|
||||
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,10 +19,6 @@ import {
|
||||
getScoreIndicatorClass,
|
||||
getScoreTextClass,
|
||||
} from "@/lib/compliance/score-utils";
|
||||
import {
|
||||
getOrderedPillars,
|
||||
THREATSCORE_SECTION_PARAM,
|
||||
} from "@/lib/compliance/threatscore-pillars";
|
||||
import {
|
||||
downloadComplianceCsv,
|
||||
downloadComplianceReportPdf,
|
||||
@@ -50,7 +46,7 @@ export const ThreatScoreBadge = ({
|
||||
|
||||
const complianceId = `prowler_threatscore_${provider.toLowerCase()}`;
|
||||
|
||||
const buildDetailHref = (section?: string) => {
|
||||
const handleCardClick = () => {
|
||||
const title = "ProwlerThreatScore";
|
||||
const version = "1.0";
|
||||
const formattedTitleForUrl = encodeURIComponent(title);
|
||||
@@ -66,23 +62,9 @@ export const ThreatScoreBadge = ({
|
||||
params.set("filter[region__in]", regionFilter);
|
||||
}
|
||||
|
||||
if (section) {
|
||||
params.set(THREATSCORE_SECTION_PARAM, section);
|
||||
}
|
||||
|
||||
return `${path}?${params.toString()}`;
|
||||
router.push(`${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);
|
||||
@@ -131,45 +113,31 @@ export const ThreatScoreBadge = ({
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Pillar breakdown — always visible, in canonical order */}
|
||||
{pillars.length > 0 && (
|
||||
{/* Pillar breakdown — always visible */}
|
||||
{sectionScores && Object.keys(sectionScores).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">
|
||||
{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>
|
||||
))}
|
||||
{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>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
+27
-15
@@ -288,8 +288,7 @@ vi.mock("@/components/ui/entities/date-with-time", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/entities/entity-info", () => ({
|
||||
EntityInfo: ({ idAction }: { idAction?: ReactNode }) =>
|
||||
idAction ? <span data-testid="entity-id-action">{idAction}</span> : null,
|
||||
EntityInfo: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/table", () => ({
|
||||
@@ -428,7 +427,7 @@ const mockFinding: ResourceDrawerFinding = {
|
||||
};
|
||||
|
||||
describe("ResourceDetailDrawerContent — resource navigation", () => {
|
||||
it("should render a View Resource link inline next to the resource UID", () => {
|
||||
it("should render a View Resource link below the resource actions menu", () => {
|
||||
// Given
|
||||
render(
|
||||
<ResourceDetailDrawerContent
|
||||
@@ -449,6 +448,9 @@ describe("ResourceDetailDrawerContent — resource navigation", () => {
|
||||
const viewResourceLink = screen.getByRole("link", {
|
||||
name: "View Resource",
|
||||
});
|
||||
const resourceActionsMenu = screen.getByRole("menu", {
|
||||
name: "Resource actions",
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(viewResourceLink).toHaveAttribute(
|
||||
@@ -457,6 +459,10 @@ 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 = {
|
||||
@@ -914,8 +920,8 @@ describe("ResourceDetailDrawerContent — CVE recommendation button", () => {
|
||||
// Fix 5 & 6: Risk section has danger styling, sections have separators and bigger headings
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("ResourceDetailDrawerContent — Risk section styling", () => {
|
||||
it("should render the Risk section with a vertical accent border (no danger card)", () => {
|
||||
describe("ResourceDetailDrawerContent — Fix 5 & 6: Risk section styling", () => {
|
||||
it("should wrap the Risk section in a Card component (data-slot='card')", () => {
|
||||
// Given
|
||||
const { container } = render(
|
||||
<ResourceDetailDrawerContent
|
||||
@@ -932,16 +938,16 @@ describe("ResourceDetailDrawerContent — Risk section styling", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
// 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:",
|
||||
// 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:"),
|
||||
);
|
||||
const riskSection = riskHeading?.parentElement;
|
||||
|
||||
// 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();
|
||||
// Then — Risk section must be wrapped in a Card variant="danger"
|
||||
expect(riskCard).toBeDefined();
|
||||
});
|
||||
|
||||
it("should use larger heading size for section labels (text-sm → text-base or larger)", () => {
|
||||
@@ -1370,10 +1376,14 @@ 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", () => {
|
||||
@@ -1456,11 +1466,12 @@ 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: "Findings for this resource" }),
|
||||
screen.getByRole("button", { name: "Other Findings For This Resource" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText("uid-1")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Status extended")).not.toBeInTheDocument();
|
||||
@@ -1573,7 +1584,7 @@ describe("ResourceDetailDrawerContent — header skeleton while navigating", ()
|
||||
screen.getByRole("button", { name: "Finding Overview" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Findings for this resource" }),
|
||||
screen.getByRole("button", { name: "Other Findings For This Resource" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -1639,6 +1650,7 @@ 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();
|
||||
});
|
||||
|
||||
|
||||
+227
-257
@@ -69,6 +69,7 @@ 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";
|
||||
@@ -409,6 +410,8 @@ 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;
|
||||
@@ -426,6 +429,7 @@ 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;
|
||||
@@ -442,8 +446,7 @@ export function ResourceDetailDrawerContent({
|
||||
label: getRecommendationLinkLabel(recommendationUrl),
|
||||
}
|
||||
: null;
|
||||
const overviewStatusExtended =
|
||||
currentResource?.statusExtended || f?.statusExtended;
|
||||
const overviewStatusExtended = f?.statusExtended;
|
||||
const showOverviewStatusExtended = Boolean(overviewStatusExtended);
|
||||
|
||||
const handleOpenCompliance = async (framework: string) => {
|
||||
@@ -675,72 +678,83 @@ export function ResourceDetailDrawerContent({
|
||||
<>
|
||||
<div className="flex items-start gap-4">
|
||||
{/* Resource info grid — 4 data columns */}
|
||||
<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}
|
||||
/>
|
||||
</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
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<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: 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>
|
||||
{/* 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"
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
|
||||
{/* Row 4: Resource metadata */}
|
||||
<InfoField label="Resource type" variant="compact">
|
||||
{resourceType || "-"}
|
||||
</InfoField>
|
||||
</div>
|
||||
|
||||
{/* Actions button — fixed size, aligned with row 1 */}
|
||||
@@ -774,28 +788,19 @@ export function ResourceDetailDrawerContent({
|
||||
</div>
|
||||
</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>
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@@ -808,9 +813,8 @@ 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">
|
||||
Findings for this resource
|
||||
Other Findings For This Resource
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="scans">Scans</TabsTrigger>
|
||||
<TabsTrigger value="events">Events</TabsTrigger>
|
||||
@@ -824,26 +828,132 @@ export function ResourceDetailDrawerContent({
|
||||
>
|
||||
{showOverviewCheckMetaContent ? (
|
||||
<>
|
||||
{/* 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 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>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{checkMeta.additionalUrls.length > 0 && (
|
||||
@@ -889,154 +999,13 @@ 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>
|
||||
|
||||
{/* 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 */}
|
||||
{/* Other Findings For This Resource */}
|
||||
<TabsContent
|
||||
value="other-findings"
|
||||
className="minimal-scrollbar flex flex-col gap-2 overflow-y-auto"
|
||||
@@ -1169,7 +1138,7 @@ export function ResourceDetailDrawerContent({
|
||||
: "-"}
|
||||
</InfoField>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<InfoField label="Started At" variant="compact">
|
||||
<DateWithTime inline dateTime={f?.scan?.startedAt || "-"} />
|
||||
</InfoField>
|
||||
@@ -1179,6 +1148,8 @@ 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
|
||||
@@ -1231,11 +1202,11 @@ export function ResourceDetailDrawerContent({
|
||||
);
|
||||
}
|
||||
|
||||
function OverviewNavigationSkeleton({ testId }: { testId?: string } = {}) {
|
||||
function OverviewNavigationSkeleton() {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col gap-4"
|
||||
data-testid={testId ?? "overview-navigation-skeleton"}
|
||||
data-testid="overview-navigation-skeleton"
|
||||
>
|
||||
<Card variant="inner">
|
||||
<OverviewCardSkeleton lineWidths={["w-24", "w-full", "w-5/6"]} />
|
||||
@@ -1317,9 +1288,8 @@ function ScansNavigationSkeleton() {
|
||||
labels={["Scan Name", "Resources Scanned", "Progress"]}
|
||||
/>
|
||||
<ScansInfoGridSkeleton labels={["Trigger", "State", "Duration"]} />
|
||||
<ScansInfoGridSkeleton
|
||||
labels={["Started At", "Completed At", "Launched At"]}
|
||||
/>
|
||||
<ScansInfoGridSkeleton labels={["Started At", "Completed At"]} />
|
||||
<ScansInfoGridSkeleton labels={["Launched At", "Scheduled At"]} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+9
-4
@@ -10,12 +10,17 @@ vi.mock("@/components/shadcn/skeleton/skeleton", () => ({
|
||||
import { ResourceDetailSkeleton } from "./resource-detail-skeleton";
|
||||
|
||||
describe("ResourceDetailSkeleton", () => {
|
||||
it("should render placeholders mirroring the resource info grid layout", () => {
|
||||
it("should include placeholders for group and resource type fields", () => {
|
||||
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");
|
||||
expect(blocks.length).toBeGreaterThanOrEqual(7);
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,21 +8,26 @@ import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
|
||||
export function ResourceDetailSkeleton() {
|
||||
return (
|
||||
<div className="flex items-start gap-4">
|
||||
<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>
|
||||
<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" />
|
||||
|
||||
{/* 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>
|
||||
{/* 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" />
|
||||
</div>
|
||||
|
||||
{/* Actions button */}
|
||||
@@ -31,25 +36,16 @@ export function ResourceDetailSkeleton() {
|
||||
);
|
||||
}
|
||||
|
||||
function EntityInfoSkeleton({
|
||||
hasIcon = false,
|
||||
labelWidth,
|
||||
}: {
|
||||
hasIcon?: boolean;
|
||||
labelWidth?: string;
|
||||
}) {
|
||||
function EntityInfoSkeleton({ hasIcon = false }: { hasIcon?: boolean }) {
|
||||
return (
|
||||
<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 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>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Cell,
|
||||
Label,
|
||||
Pie,
|
||||
PieChart,
|
||||
Sector,
|
||||
type SectorProps,
|
||||
Tooltip,
|
||||
} from "recharts";
|
||||
import { Cell, Label, Pie, PieChart, Tooltip } from "recharts";
|
||||
|
||||
import { ChartConfig, ChartContainer } from "@/components/ui/chart/Chart";
|
||||
|
||||
@@ -164,22 +156,6 @@ 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
|
||||
@@ -187,29 +163,15 @@ export function DonutChart({
|
||||
className="mx-auto aspect-square max-h-[350px]"
|
||||
>
|
||||
<PieChart>
|
||||
{!isEmpty && (
|
||||
<Tooltip
|
||||
content={<CustomTooltip />}
|
||||
cursor={false}
|
||||
wrapperStyle={{ zIndex: 1000 }}
|
||||
/>
|
||||
)}
|
||||
{!isEmpty && <Tooltip content={<CustomTooltip />} />}
|
||||
<Pie
|
||||
data={isEmpty ? emptyData : chartData}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
innerRadius={innerRadius}
|
||||
outerRadius={restingOuterRadius}
|
||||
outerRadius={outerRadius}
|
||||
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 =
|
||||
@@ -224,6 +186,8 @@ export function DonutChart({
|
||||
style={{
|
||||
transition: "opacity 0.2s",
|
||||
}}
|
||||
onMouseEnter={() => setHoveredIndex(index)}
|
||||
onMouseLeave={() => setHoveredIndex(null)}
|
||||
onClick={() => {
|
||||
if (isClickable) {
|
||||
onSegmentClick(data[index], index);
|
||||
|
||||
@@ -14,24 +14,17 @@ 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 && (useSeverityEmptyState || data.length === 0);
|
||||
const isEmpty = total <= 0;
|
||||
|
||||
const emptyData: BarDataPoint[] = [
|
||||
{ name: "Critical", value: 1, percentage: 100 },
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
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,5 +1,4 @@
|
||||
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";
|
||||
@@ -22,58 +21,34 @@ import PROWLERTHREATLogo from "./prowlerThreat.svg";
|
||||
import RBILogo from "./rbi.svg";
|
||||
import SOC2Logo from "./soc2.svg";
|
||||
|
||||
// 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;
|
||||
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;
|
||||
|
||||
export const getComplianceIcon = (complianceTitle: string) => {
|
||||
const lowerTitle = complianceTitle.toLowerCase();
|
||||
return COMPLIANCE_LOGOS.find(([keyword]) =>
|
||||
return Object.entries(COMPLIANCE_LOGOS).find(([keyword]) =>
|
||||
lowerTitle.includes(keyword),
|
||||
)?.[1];
|
||||
};
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 604 B |
@@ -20,8 +20,6 @@ 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: "",
|
||||
@@ -42,16 +40,6 @@ 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,7 +134,6 @@ 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,8 +23,6 @@ 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 */
|
||||
@@ -42,7 +40,6 @@ export const EntityInfo = ({
|
||||
badge,
|
||||
idLabel = "UID",
|
||||
showCopyAction = true,
|
||||
idAction,
|
||||
}: EntityInfoProps) => {
|
||||
const canCopy = Boolean(entityId && showCopyAction);
|
||||
const renderedIcon =
|
||||
@@ -76,7 +73,7 @@ export const EntityInfo = ({
|
||||
)}
|
||||
</div>
|
||||
{entityId && (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<span className="text-text-neutral-tertiary shrink-0 text-xs font-medium">
|
||||
{idLabel}:
|
||||
</span>
|
||||
@@ -85,7 +82,6 @@ export const EntityInfo = ({
|
||||
className="max-w-[160px]"
|
||||
hideCopyButton={!canCopy}
|
||||
/>
|
||||
{idAction && <span className="shrink-0">{idAction}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,390 +0,0 @@
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -1,172 +0,0 @@
|
||||
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: [],
|
||||
};
|
||||
}),
|
||||
};
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -1,196 +0,0 @@
|
||||
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,6 +1,5 @@
|
||||
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";
|
||||
@@ -22,10 +21,6 @@ import {
|
||||
TopFailedResult,
|
||||
} from "@/types/compliance";
|
||||
|
||||
import {
|
||||
mapComplianceData as mapASDEssentialEightComplianceData,
|
||||
toAccordionItems as toASDEssentialEightAccordionItems,
|
||||
} from "./asd-essential-eight";
|
||||
import {
|
||||
mapComplianceData as mapAWSWellArchitectedComplianceData,
|
||||
toAccordionItems as toAWSWellArchitectedAccordionItems,
|
||||
@@ -70,7 +65,6 @@ import {
|
||||
toAccordionItems as toMITREAccordionItems,
|
||||
} from "./mitre";
|
||||
import {
|
||||
getTopFailedSections as getThreatScoreTopFailedSections,
|
||||
mapComplianceData as mapThetaComplianceData,
|
||||
toAccordionItems as toThetaAccordionItems,
|
||||
} from "./threat";
|
||||
@@ -101,15 +95,6 @@ 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,
|
||||
@@ -184,7 +169,7 @@ const getComplianceMappers = (): Record<string, ComplianceMapper> => ({
|
||||
ProwlerThreatScore: {
|
||||
mapComplianceData: mapThetaComplianceData,
|
||||
toAccordionItems: toThetaAccordionItems,
|
||||
getTopFailedSections: getThreatScoreTopFailedSections,
|
||||
getTopFailedSections,
|
||||
calculateCategoryHeatmapData: (complianceData: Framework[]) =>
|
||||
calculateCategoryHeatmapData(complianceData),
|
||||
getDetailsComponent: (requirement: Requirement) =>
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
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 };
|
||||
};
|
||||
@@ -1,100 +0,0 @@
|
||||
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,9 +20,6 @@ import {
|
||||
findOrCreateFramework,
|
||||
updateCounters,
|
||||
} from "./commons";
|
||||
import { compareSectionsByCanonicalOrder } from "./threatscore-pillars";
|
||||
|
||||
export { getTopFailedSections } from "./threat-helpers";
|
||||
|
||||
export const mapComplianceData = (
|
||||
attributesData: AttributesData,
|
||||
@@ -94,14 +91,6 @@ 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;
|
||||
@@ -160,7 +149,9 @@ export const mapComplianceData = (
|
||||
? (numerator / denominator) * 100
|
||||
: 0;
|
||||
|
||||
category.percentualScore = Math.round(percentualScore * 100) / 100;
|
||||
// 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
|
||||
|
||||
framework.pass += category.pass;
|
||||
framework.fail += category.fail;
|
||||
@@ -177,7 +168,7 @@ export const toAccordionItems = (
|
||||
): AccordionItemProps[] => {
|
||||
return data.flatMap((framework) =>
|
||||
framework.categories.map((category) => {
|
||||
const percentualScore = category.percentualScore ?? 0;
|
||||
const percentualScore = (category as any).percentualScore || 0;
|
||||
|
||||
return {
|
||||
key: `${framework.name}-${category.name}`,
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,75 +0,0 @@
|
||||
import type { SectionScores } from "@/actions/overview/threat-score";
|
||||
|
||||
export const THREATSCORE_PILLARS = [
|
||||
"1. IAM",
|
||||
"2. Attack Surface",
|
||||
"3. Logging and Monitoring",
|
||||
"4. Encryption",
|
||||
] as const;
|
||||
|
||||
export interface OrderedPillar {
|
||||
name: string;
|
||||
score: number;
|
||||
hasData: boolean;
|
||||
}
|
||||
|
||||
const compareNatural = (a: string, b: string) =>
|
||||
a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" });
|
||||
|
||||
// API contract is `Record<string, number>`, but defensively coerce so a
|
||||
// future null/string value cannot blow up `score.toFixed(...)` callers.
|
||||
// `treatMissingAsFull` makes a missing canonical pillar mean "no findings →
|
||||
// 100%" rather than "no data". Only safe when `sectionScores` is provided
|
||||
// (i.e. the scan ran); when undefined we still surface "no data".
|
||||
const readScore = (
|
||||
scores: SectionScores,
|
||||
name: string,
|
||||
treatMissingAsFull: boolean,
|
||||
): { score: number; hasData: boolean } => {
|
||||
const raw = scores[name];
|
||||
if (typeof raw === "number" && Number.isFinite(raw)) {
|
||||
return { score: raw, hasData: true };
|
||||
}
|
||||
if (treatMissingAsFull && raw === undefined) {
|
||||
return { score: 100, hasData: true };
|
||||
}
|
||||
return { score: 0, hasData: false };
|
||||
};
|
||||
|
||||
export function getOrderedPillars(
|
||||
sectionScores?: SectionScores,
|
||||
): OrderedPillar[] {
|
||||
const scores = sectionScores ?? {};
|
||||
const treatMissingAsFull = sectionScores !== undefined;
|
||||
const remaining = new Set(Object.keys(scores));
|
||||
|
||||
const canonical: OrderedPillar[] = THREATSCORE_PILLARS.map((name) => {
|
||||
remaining.delete(name);
|
||||
const { score, hasData } = readScore(scores, name, treatMissingAsFull);
|
||||
return { name, score, hasData };
|
||||
});
|
||||
|
||||
const extras: OrderedPillar[] = Array.from(remaining)
|
||||
.sort(compareNatural)
|
||||
.map((name) => {
|
||||
const { score, hasData } = readScore(scores, name, false);
|
||||
return { name, score, hasData };
|
||||
});
|
||||
|
||||
return [...canonical, ...extras];
|
||||
}
|
||||
|
||||
export const THREATSCORE_SECTION_PARAM = "section";
|
||||
|
||||
export const compareSectionsByCanonicalOrder = (a: string, b: string) => {
|
||||
const indexA = THREATSCORE_PILLARS.indexOf(
|
||||
a as (typeof THREATSCORE_PILLARS)[number],
|
||||
);
|
||||
const indexB = THREATSCORE_PILLARS.indexOf(
|
||||
b as (typeof THREATSCORE_PILLARS)[number],
|
||||
);
|
||||
if (indexA !== -1 && indexB !== -1) return indexA - indexB;
|
||||
if (indexA !== -1) return -1;
|
||||
if (indexB !== -1) return 1;
|
||||
return compareNatural(a, b);
|
||||
};
|
||||
@@ -6,7 +6,7 @@ export const DOCS_URLS = {
|
||||
"https://docs.prowler.com/user-guide/tutorials/prowler-app#step-8:-analyze-the-findings",
|
||||
AWS_ORGANIZATIONS:
|
||||
"https://docs.prowler.com/user-guide/tutorials/prowler-cloud-aws-organizations",
|
||||
ALERTS: "https://docs.prowler.com/user-guide/tutorials/prowler-app-alerts",
|
||||
ALERTS: "https://docs.prowler.com/user-guide/tutorials/prowler-app", // TODO: Update this URL to the Alerts documentation
|
||||
ATTACK_PATHS_CUSTOM_QUERIES:
|
||||
"https://docs.prowler.com/user-guide/tutorials/prowler-app-attack-paths#writing-custom-opencypher-queries",
|
||||
} as const;
|
||||
|
||||
@@ -53,7 +53,6 @@ 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,7 +60,6 @@ export interface Category {
|
||||
fail: number;
|
||||
manual: number;
|
||||
controls: Control[];
|
||||
percentualScore?: number;
|
||||
}
|
||||
|
||||
export interface Framework {
|
||||
@@ -90,10 +89,6 @@ 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 {
|
||||
@@ -224,109 +219,6 @@ 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;
|
||||
@@ -348,7 +240,6 @@ export interface AttributesItemData {
|
||||
| MITREAttributesMetadata[]
|
||||
| CCCAttributesMetadata[]
|
||||
| CSAAttributesMetadata[]
|
||||
| ASDEssentialEightAttributesMetadata[]
|
||||
| GenericAttributesMetadata[];
|
||||
check_ids: string[];
|
||||
// MITRE structure
|
||||
|
||||
@@ -60,7 +60,6 @@ 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