Compare commits

..

13 Commits

Author SHA1 Message Date
Andoni A.
60350794da feat(api): show only production URL in API reference playground 2025-11-12 09:47:28 +01:00
Andoni Alonso
6a876a3205 Merge branch 'master' into DEVREL-98-include-open-api-specification-in-mintlify 2025-11-12 09:22:08 +01:00
Andoni A.
e9f6bc8604 chore: update CHANGELOG 2025-11-11 07:36:18 +01:00
Andoni A.
dc71d86c35 chore(api): regenerate openapi schema 2025-11-10 08:57:34 +01:00
Andoni Alonso
7a54900a62 Merge branch 'master' into DEVREL-98-include-open-api-specification-in-mintlify 2025-11-10 08:39:59 +01:00
Andoni A.
1d62b8d64e fix(docs): json version is not needed 2025-11-05 14:52:23 +01:00
Andoni A.
0f5dc165bb fix(docs): use hardlink, mintlify can't follow the symlink 2025-11-05 12:51:27 +01:00
Andoni A.
676a11bb13 feat(docs): use symlink to point to openapi spec 2025-11-05 12:43:16 +01:00
Andoni A.
4b80544f0a chore: update CHANGELOG 2025-11-05 12:35:44 +01:00
Andoni A.
6815a9dd86 feat(api): add dynamic server URL configuration to OpenAPI schema
Add a new postprocessing hook 'add_api_servers' that dynamically configures
the servers array in the OpenAPI specification based on the request environment.

This hook:
- Detects the current environment (local, staging, or production) from the
  request host
- Adds the current server URL as the primary option
- Includes production (https://api.prowler.com) as a fallback option when
  generating from non-production environments
- Enables users to toggle between local and production servers in Mintlify's
  API playground via a dropdown

Server detection logic:
- localhost/127.0.0.1 → "Local Development Server"
- hosts with 'dev' or 'staging' → "Development/Staging API"
- api.prowler.com → "Prowler Cloud API"

This enables the "Try it out" feature in Mintlify documentation and allows
testing against different environments without modifying the spec.
2025-11-05 12:04:18 +01:00
Andoni A.
00441e776d feat(api): add OpenAPI schema postprocessing hooks for Mintlify compatibility
Add three postprocessing hooks to fix OpenAPI 3.0.x compatibility issues
generated by drf-spectacular-jsonapi:

1. fix_empty_id_fields: Fixes empty id field definitions ({}) in JSON:API
   request schemas (particularly PATCH/update requests) by replacing them
   with proper schema: {"type": "string", "format": "uuid", "description": "..."}

2. fix_pattern_properties: Converts patternProperties to additionalProperties
   for OpenAPI 3.0 compatibility. patternProperties is only available in
   OpenAPI 3.1+, but drf-spectacular generates 3.0.3 specs. This is needed
   for the Prowler mutelist configuration which uses dynamic keys.

3. fix_type_formats: Fixes invalid type values like "email" that
   drf-spectacular generates from Django's EmailField. Converts them to
   proper OpenAPI format: "type": "string" with "format": "email".
   Also handles "url" → "uri" and "uuid" formats.

These hooks ensure the generated OpenAPI schema validates correctly with
Mintlify and other OpenAPI 3.0.x tools.
2025-11-05 11:36:24 +01:00
Andoni A.
4037927c61 Merge branch 'master' into try-openapi 2025-11-05 10:36:07 +01:00
Andoni A.
20337b7e0c poc: test yq to create json schema 2025-10-31 08:24:21 +01:00
91 changed files with 21596 additions and 4475 deletions

11
.env
View File

@@ -14,6 +14,14 @@ UI_PORT=3000
AUTH_SECRET="N/c6mnaS5+SWq81+819OrzQZlmx1Vxtp/orjttJSmw8="
# Google Tag Manager ID
NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID=""
# Sentry
SENTRY_DSN=
NEXT_PUBLIC_SENTRY_DSN=
SENTRY_ORG=
SENTRY_PROJECT=
SENTRY_AUTH_TOKEN=
SENTRY_ENVIRONMENT=production
NEXT_PUBLIC_SENTRY_ENVIRONMENT=production
#### Code Review Configuration ####
# Enable Claude Code standards validation on pre-push hook
@@ -108,8 +116,6 @@ DJANGO_THROTTLE_TOKEN_OBTAIN=50/minute
# Sentry settings
SENTRY_ENVIRONMENT=local
SENTRY_RELEASE=local
NEXT_PUBLIC_SENTRY_ENVIRONMENT=${SENTRY_ENVIRONMENT}
#### Prowler release version ####
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.12.2
@@ -140,3 +146,4 @@ LANGCHAIN_PROJECT=""
RSS_FEED_SOURCES='[{"id":"prowler-releases","name":"Prowler Releases","type":"github_releases","url":"https://github.com/prowler-cloud/prowler/releases.atom","enabled":true}]'
# Example with multiple sources (no trailing comma after last item):
# RSS_FEED_SOURCES='[{"id":"prowler-releases","name":"Prowler Releases","type":"github_releases","url":"https://github.com/prowler-cloud/prowler/releases.atom","enabled":true},{"id":"prowler-blog","name":"Prowler Blog","type":"blog","url":"https://prowler.com/blog/rss","enabled":false}]'

73
.gitignore vendored
View File

@@ -45,86 +45,21 @@ pytest_*.xml
.coverage
htmlcov/
# VSCode files and settings
# VSCode files
.vscode/
*.code-workspace
.vscode-test/
# VSCode extension settings and workspaces
.history/
.ionide/
# MCP Server Settings (various locations)
**/cline_mcp_settings.json
**/mcp_settings.json
**/mcp-config.json
**/mcpServers.json
.mcp/
# AI Coding Assistants - Cursor
# Cursor files
.cursorignore
.cursor/
.cursorrules
# AI Coding Assistants - RooCode
# RooCode files
.roo/
.rooignore
.roomodes
# AI Coding Assistants - Cline (formerly Claude Dev)
# Cline files
.cline/
.clineignore
.clinerules
# AI Coding Assistants - Continue
.continue/
continue.json
.continuerc
.continuerc.json
# AI Coding Assistants - GitHub Copilot
.copilot/
.github/copilot/
# AI Coding Assistants - Amazon Q Developer (formerly CodeWhisperer)
.aws/
.codewhisperer/
.amazonq/
.aws-toolkit/
# AI Coding Assistants - Tabnine
.tabnine/
tabnine_config.json
# AI Coding Assistants - Kiro
.kiro/
.kiroignore
kiro.config.json
# AI Coding Assistants - Aider
.aider/
.aider.chat.history.md
.aider.input.history
.aider.tags.cache.v3/
# AI Coding Assistants - Windsurf
.windsurf/
.windsurfignore
# AI Coding Assistants - Replit Agent
.replit
.replitignore
# AI Coding Assistants - Supermaven
.supermaven/
# AI Coding Assistants - Sourcegraph Cody
.cody/
# AI Coding Assistants - General
.ai/
.aiconfig
ai-config.json
# Terraform
.terraform*

View File

@@ -14,6 +14,7 @@ All notable changes to the **Prowler API** are documented in this file.
- Support muting findings based on simple rules with custom reason [(#9051)](https://github.com/prowler-cloud/prowler/pull/9051)
- Support C5 compliance framework for the GCP provider [(#9097)](https://github.com/prowler-cloud/prowler/pull/9097)
- Support for Amazon Bedrock and OpenAI compatible providers in Lighthouse AI [(#8957)](https://github.com/prowler-cloud/prowler/pull/8957)
- OpenAPI schema integration with Mintlify documentation including compatibility fixes and dynamic server URL configuration [(#9168)](https://github.com/prowler-cloud/prowler/pull/9168)
- Tenant-wide ThreatScore overview aggregation and snapshot persistence with backfill support [(#9148)](https://github.com/prowler-cloud/prowler/pull/9148)
- Support for MongoDB Atlas provider [(#9167)](https://github.com/prowler-cloud/prowler/pull/9167)

View File

@@ -36,6 +36,143 @@ def _extract_task_example_from_components(components):
}
def fix_empty_id_fields(result, generator, request, public): # noqa: F841
"""
Fix empty id fields in JSON:API request schemas.
drf-spectacular-jsonapi sometimes generates empty id field definitions ({})
which cause validation errors in Mintlify and other OpenAPI validators.
"""
if not isinstance(result, dict):
return result
components = result.get("components", {}) or {}
schemas = components.get("schemas", {}) or {}
for schema_name, schema in schemas.items():
if not isinstance(schema, dict):
continue
# Check if this is a JSON:API request schema with a data object
properties = schema.get("properties", {})
if not isinstance(properties, dict):
continue
data_prop = properties.get("data")
if not isinstance(data_prop, dict):
continue
data_properties = data_prop.get("properties", {})
if not isinstance(data_properties, dict):
continue
# Fix empty id field
id_field = data_properties.get("id")
if id_field == {} or (isinstance(id_field, dict) and not id_field):
data_properties["id"] = {
"type": "string",
"format": "uuid",
"description": "Unique identifier for this resource object.",
}
return result
def convert_pattern_properties_to_additional(obj):
"""
Recursively convert patternProperties to additionalProperties.
OpenAPI 3.0.x doesn't support patternProperties (only available in 3.1+).
"""
if isinstance(obj, dict):
if "patternProperties" in obj:
# Get the pattern and its schema
pattern_props = obj.pop("patternProperties")
# Use the first pattern's schema as additionalProperties
if pattern_props:
first_pattern_schema = next(iter(pattern_props.values()))
obj["additionalProperties"] = first_pattern_schema
# Recursively process all nested objects
for key, value in obj.items():
obj[key] = convert_pattern_properties_to_additional(value)
elif isinstance(obj, list):
return [convert_pattern_properties_to_additional(item) for item in obj]
return obj
def fix_pattern_properties(result, generator, request, public): # noqa: F841
"""
Convert patternProperties to additionalProperties for OpenAPI 3.0 compatibility.
patternProperties is only supported in OpenAPI 3.1+, but drf-spectacular
generates OpenAPI 3.0.x specs.
"""
if not isinstance(result, dict):
return result
return convert_pattern_properties_to_additional(result)
def fix_invalid_types(obj):
"""
Recursively fix invalid type values in OpenAPI schemas.
Converts invalid types like "email" to proper OpenAPI format.
"""
if isinstance(obj, dict):
# Fix invalid "type" values
if "type" in obj:
type_value = obj["type"]
if type_value == "email":
obj["type"] = "string"
obj["format"] = "email"
elif type_value == "url":
obj["type"] = "string"
obj["format"] = "uri"
elif type_value == "uuid":
obj["type"] = "string"
obj["format"] = "uuid"
# Recursively process all nested objects
for key, value in list(obj.items()):
obj[key] = fix_invalid_types(value)
elif isinstance(obj, list):
return [fix_invalid_types(item) for item in obj]
return obj
def fix_type_formats(result, generator, request, public): # noqa: F841
"""
Fix invalid type values in OpenAPI schemas.
drf-spectacular sometimes generates invalid type values like "email"
instead of "type": "string" with "format": "email".
"""
if not isinstance(result, dict):
return result
return fix_invalid_types(result)
def add_api_servers(result, generator, request, public): # noqa: F841
"""
Add servers configuration to OpenAPI spec for Mintlify API playground.
This enables the "Try it out" feature in the documentation.
Only adds the production URL to ensure consistent documentation.
"""
if not isinstance(result, dict):
return result
# Add servers array if not already present
if "servers" not in result:
result["servers"] = [
{
"url": "https://api.prowler.com",
"description": "Prowler Cloud API",
}
]
return result
def attach_task_202_examples(result, generator, request, public): # noqa: F841
if not isinstance(result, dict):
return result

File diff suppressed because it is too large Load Diff

View File

@@ -125,6 +125,10 @@ SPECTACULAR_SETTINGS = {
"drf_spectacular_jsonapi.hooks.fix_nested_path_parameters",
],
"POSTPROCESSING_HOOKS": [
"api.schema_hooks.fix_empty_id_fields",
"api.schema_hooks.fix_pattern_properties",
"api.schema_hooks.fix_type_formats",
"api.schema_hooks.add_api_servers",
"api.schema_hooks.attach_task_202_examples",
],
"TITLE": "API Reference - Prowler",

18987
docs/api-reference/openapi.yml Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -329,6 +329,10 @@
{
"tab": "Public Roadmap",
"href": "https://roadmap.prowler.com/"
},
{
"tab": "API Reference",
"openapi": "api-reference/openapi.yml"
}
],
"global": {

View File

@@ -217,8 +217,7 @@ Prowler enables security scanning of your **GitHub account**, including **Reposi
prowler github --oauth-app-token oauth_token
# GitHub App Credentials:
prowler github --github-app-id app_id --github-app-key-path path/to/app_key.pem
prowler github --github-app-id app_id --github-app-key $APP_KEY_CONTENT
prowler github --github-app-id app_id --github-app-key app_key
```
<Note>
@@ -226,8 +225,7 @@ Prowler enables security scanning of your **GitHub account**, including **Reposi
1. `GITHUB_PERSONAL_ACCESS_TOKEN`
2. `OAUTH_APP_TOKEN`
3. `GITHUB_APP_ID` and `GITHUB_APP_KEY_PATH`
4. `GITHUB_APP_ID` and `GITHUB_APP_KEY`
3. `GITHUB_APP_ID` and `GITHUB_APP_KEY`
</Note>
## Infrastructure as Code (IaC)

View File

@@ -84,17 +84,6 @@ For detailed instructions on how to create the role, see [Authentication > Assum
![Next button in Prowler Cloud](/images/providers/next-button-prowler-cloud.png)
![Launch Scan](/images/providers/launch-scan-button-prowler-cloud.png)
<Note>
Check if your AWS Security Token Service (STS) has the EU (Ireland) endpoint active. If not, we will not be able to connect to your AWS account.
If that is the case your STS configuration may look like this:
<img src="/images/sts-configuration.png" alt="AWS Role" width="800" />
To solve this issue, please activate the EU (Ireland) STS endpoint.
</Note>
---
#### Credentials (Static Access Keys)

View File

@@ -60,8 +60,7 @@ If no login method is explicitly provided, Prowler will automatically attempt to
1. `GITHUB_PERSONAL_ACCESS_TOKEN`
2. `GITHUB_OAUTH_APP_TOKEN`
3. `GITHUB_APP_ID` and `GITHUB_APP_KEY_PATH` (where the key path is the path to the private key file)
4. `GITHUB_APP_ID` and `GITHUB_APP_KEY` (where the key is the content of the private key file)
3. `GITHUB_APP_ID` and `GITHUB_APP_KEY` (where the key is the content of the private key file)
<Note>
Ensure the corresponding environment variables are set up before running Prowler for automatic detection when not specifying the login method.
@@ -89,6 +88,5 @@ prowler github --oauth-app-token oauth_token
Use GitHub App credentials by specifying the App ID and the private key path.
```console
prowler github --github-app-id app_id --github-app-key-path path/to/app_key.pem
prowler github --github-app-id app_id --github-app-key $APP_KEY_CONTENT
prowler github --github-app-id app_id --github-app-key-path app_key_path
```

View File

@@ -21,7 +21,6 @@ All notable changes to the **Prowler SDK** are documented in this file.
- Added validation for invalid checks, services, and categories in `load_checks_to_execute` function [(#8971)](https://github.com/prowler-cloud/prowler/pull/8971)
- NIST CSF 2.0 compliance framework for the AWS provider [(#9185)](https://github.com/prowler-cloud/prowler/pull/9185)
- Add FedRAMP 20x KSI Low for AWS, Azure and GCP [(#9198)](https://github.com/prowler-cloud/prowler/pull/9198)
- Add verification for provider ID in MongoDB Atlas provider [(#9211)](https://github.com/prowler-cloud/prowler/pull/9211)
### Changed
- Update AWS Direct Connect service metadata to new format [(#8855)](https://github.com/prowler-cloud/prowler/pull/8855)
@@ -43,14 +42,9 @@ All notable changes to the **Prowler SDK** are documented in this file.
- Update AWS FSx service metadata to new format [(#9006)](https://github.com/prowler-cloud/prowler/pull/9006)
- Update AWS Glacier service metadata to new format [(#9007)](https://github.com/prowler-cloud/prowler/pull/9007)
- Update oraclecloud analytics service metadata to new format [(#9114)](https://github.com/prowler-cloud/prowler/pull/9114)
- Update AWS ELB service metadata to new format [(#8935)](https://github.com/prowler-cloud/prowler/pull/8935)
- Update AWS CodeArtifact service metadata to new format [(#8850)](https://github.com/prowler-cloud/prowler/pull/8850)
- Rename OCI provider to oraclecloud with oci alias [(#9126)](https://github.com/prowler-cloud/prowler/pull/9126)
- Remove unnecessary tests for M365_PowerShell module [(#9204)](https://github.com/prowler-cloud/prowler/pull/9204)
- Update oraclecloud cloudguard service metadata to new format [(#9223)](https://github.com/prowler-cloud/prowler/pull/9223)
- Update oraclecloud blockstorage service metadata to new format [(#9222)](https://github.com/prowler-cloud/prowler/pull/9222)
- Update oraclecloud audit service metadata to new format [(#9221)](https://github.com/prowler-cloud/prowler/pull/9221)
- GitHub App authentication inconsistency where `--github-app-key` and `--github-app-key-path` were incorrectly aliased to the same parameter, causing confusion between file paths and key content [(#8422)](https://github.com/prowler-cloud/prowler/pull/8422)
---

View File

@@ -1,40 +1,31 @@
{
"Provider": "aws",
"CheckID": "elb_connection_draining_enabled",
"CheckTitle": "Classic Load Balancer has connection draining enabled",
"CheckTitle": "Classic Load Balancer Connection Draining Enabled",
"CheckType": [
"Software and Configuration Checks/AWS Security Best Practices/Network Reachability",
"Effects/Denial of Service"
"Software and Configuration Checks/AWS Security Best Practices/Network Reachability"
],
"ServiceName": "elb",
"SubServiceName": "",
"ResourceIdTemplate": "",
"ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id",
"Severity": "medium",
"ResourceType": "AwsElbLoadBalancer",
"Description": "**Classic Load Balancer** has **connection draining** enabled, so deregistering or unhealthy instances stop receiving new requests while existing connections are allowed to complete within the configured drain window.",
"Risk": "Without **connection draining**, instance removals or health failures can terminate in-flight requests, leading to partial transactions, broken sessions, and inconsistent application state. This reduces **availability** and can impact **data integrity** during deployments, scaling, or failover events.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://aws.amazon.com/blogs/aws/elb-connection-draining-remove-instances-from-service-with-care/",
"https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/ELB/elb-connection-draining-enabled.html",
"https://docs.aws.amazon.com/securityhub/latest/userguide/elb-controls.html#elb-7",
"https://docs.aws.amazon.com/elasticloadbalancing/latest/classic/config-conn-drain.html"
],
"Description": "Checks if connection draining is enabled for Classic Load Balancers. Connection draining ensures that the load balancer stops sending requests to instances that are de-registering or unhealthy, while keeping existing connections open. This is particularly useful for instances in Auto Scaling groups, to ensure that connections aren't severed abruptly.",
"Risk": "Disabling connection draining can lead to abrupt connection termination for users, impacting the user experience and potentially causing application errors.",
"RelatedUrl": "https://docs.aws.amazon.com/elasticloadbalancing/latest/classic/config-conn-drain.html",
"Remediation": {
"Code": {
"CLI": "aws elb modify-load-balancer-attributes --load-balancer-name <example_resource_name> --load-balancer-attributes '{\"ConnectionDraining\":{\"Enabled\":true}}'",
"NativeIaC": "```yaml\n# CloudFormation: Enable connection draining on a Classic Load Balancer\nResources:\n <example_resource_name>:\n Type: AWS::ElasticLoadBalancing::LoadBalancer\n Properties:\n Listeners:\n - InstancePort: 80\n LoadBalancerPort: 80\n Protocol: HTTP\n AvailabilityZones:\n - us-east-1a\n ConnectionDrainingPolicy:\n Enabled: true # CRITICAL: turns on connection draining so in-flight requests complete\n # Timeout is optional; default 300s is used if omitted\n```",
"Other": "1. Open the EC2 console and go to Load Balancers (Classic)\n2. Select the Classic Load Balancer\n3. Choose the Attributes tab, then click Edit\n4. Check Enable connection draining (leave default timeout or set as needed)\n5. Click Save changes",
"Terraform": "```hcl\n# Terraform: Enable connection draining on a Classic Load Balancer\nresource \"aws_elb\" \"<example_resource_name>\" {\n name = \"<example_resource_name>\"\n availability_zones = [\"us-east-1a\"]\n\n listener {\n lb_port = 80\n lb_protocol = \"http\"\n instance_port = 80\n instance_protocol = \"http\"\n }\n\n connection_draining = true # CRITICAL: enables connection draining so existing connections complete\n # connection_draining_timeout can be omitted (defaults to 300s)\n}\n```"
"CLI": "aws elb modify-load-balancer-attributes --load-balancer-name <my_load_balancer_name> --load-balancer-attributes '{'ConnectionDraining':{'Enabled':true,'Timeout':300}}'",
"NativeIaC": "",
"Other": "https://docs.aws.amazon.com/securityhub/latest/userguide/elb-controls.html#elb-7",
"Terraform": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/ELB/elb-connection-draining-enabled.html#"
},
"Recommendation": {
"Text": "Enable **connection draining** on all Classic Load Balancers and set a drain interval aligned to typical request latency. Coordinate autoscaling and deployments to allow graceful instance shutdowns. Monitor errors and retries to validate behavior and adjust the `timeout` conservatively to protect **availability** and **integrity**.",
"Url": "https://hub.prowler.com/check/elb_connection_draining_enabled"
"Text": "Enable connection draining for all Classic Load Balancers. This ensures that existing connections are not abruptly terminated when instances are removed from the load balancer.",
"Url": ""
}
},
"Categories": [
"resilience"
],
"Categories": [],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""

View File

@@ -1,40 +1,29 @@
{
"Provider": "aws",
"CheckID": "elb_cross_zone_load_balancing_enabled",
"CheckTitle": "Classic Load Balancer has cross-zone load balancing enabled",
"CheckType": [
"Software and Configuration Checks/AWS Security Best Practices",
"Effects/Denial of Service",
"Effects/Resource Consumption"
],
"CheckTitle": "Ensure Cross-Zone Load Balancing is Enabled for Classic Load Balancers (CLBs)",
"CheckType": [],
"ServiceName": "elb",
"SubServiceName": "",
"ResourceIdTemplate": "",
"ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id",
"Severity": "medium",
"ResourceType": "AwsElbLoadBalancer",
"Description": "Classic Load Balancer with **cross-zone load balancing** distributes requests across registered targets in all enabled Availability Zones.\n\nThis evaluates whether that setting is `enabled`, instead of restricting distribution to targets within only the same zone.",
"Risk": "Without **cross-zone load balancing**, traffic can concentrate in one AZ due to DNS skew or uneven capacity, creating **hot spots**, timeouts, and latency. This degrades service **availability** and increases the chance of cascading failures during AZ impairment or instance loss.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.aws.amazon.com/securityhub/latest/userguide/elb-controls.html#elb-9",
"https://docs.aws.amazon.com/elasticloadbalancing/latest/classic/enable-disable-crosszone-lb.html",
"https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/ELB/elb-cross-zone-load-balancing-enabled.html"
],
"Description": "Checks whether cross-zone load balancing is enabled for Classic Load Balancers (CLBs). Cross-zone load balancing ensures even distribution of traffic across all registered targets in all Availability Zones, improving fault tolerance and load distribution.",
"Risk": "If cross-zone load balancing is not enabled, traffic may not be evenly distributed across Availability Zones, leading to over-utilization of resources in certain zones and potential application performance degradation or outages.",
"RelatedUrl": "https://docs.aws.amazon.com/elasticloadbalancing/latest/classic/enable-disable-crosszone-lb.html",
"Remediation": {
"Code": {
"CLI": "aws elb modify-load-balancer-attributes --load-balancer-name <load-balancer-name> --load-balancer-attributes \"{\\\"CrossZoneLoadBalancing\\\":{\\\"Enabled\\\":true}}\"",
"NativeIaC": "```yaml\n# CloudFormation: Enable cross-zone load balancing on a Classic Load Balancer\nResources:\n <example_resource_name>:\n Type: AWS::ElasticLoadBalancing::LoadBalancer\n Properties:\n CrossZone: true # Critical: enables cross-zone load balancing to pass the check\n Listeners:\n - LoadBalancerPort: 80\n InstancePort: 80\n Protocol: HTTP\n AvailabilityZones:\n - <example_az>\n```",
"Other": "1. Open the AWS EC2 console\n2. Go to Load Balancing > Load Balancers and select your Classic Load Balancer\n3. Open the Attributes tab and click Edit\n4. Enable Cross-zone load balancing\n5. Click Save changes",
"Terraform": "```hcl\n# Terraform: Enable cross-zone load balancing on a Classic Load Balancer\nresource \"aws_elb\" \"<example_resource_name>\" {\n name = \"<example_resource_name>\"\n\n listener {\n lb_port = 80\n lb_protocol = \"http\"\n instance_port = 80\n instance_protocol = \"http\"\n }\n\n availability_zones = [\"<example_az>\"]\n\n cross_zone_load_balancing = true # Critical: enables cross-zone load balancing to pass the check\n}\n```"
"CLI": "aws elb modify-load-balancer-attributes --load-balancer-name <load-balancer-name> --load-balancer-attributes \"CrossZoneLoadBalancing={Enabled=true}\"",
"NativeIaC": "",
"Other": "https://docs.aws.amazon.com/securityhub/latest/userguide/elb-controls.html#elb-9",
"Terraform": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/ELB/elb-cross-zone-load-balancing-enabled.html"
},
"Recommendation": {
"Text": "Set `cross-zone load balancing` to `enabled` on Classic Load Balancers and use at least two AZs.\n\nBalance capacity per AZ, enforce robust health checks with autoscaling, and design for **high availability** so load remains evenly distributed during demand spikes or partial AZ outages.",
"Url": "https://hub.prowler.com/check/elb_cross_zone_load_balancing_enabled"
"Text": "Enable cross-zone load balancing for Classic Load Balancers to ensure even traffic distribution and enhance fault tolerance across Availability Zones.",
"Url": "https://docs.aws.amazon.com/elasticloadbalancing/latest/classic/enable-disable-crosszone-lb.html"
}
},
"Categories": [
"resilience"
],
"Categories": [],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""

View File

@@ -1,37 +1,28 @@
{
"Provider": "aws",
"CheckID": "elb_desync_mitigation_mode",
"CheckTitle": "Classic Load Balancer desync mitigation mode is defensive or strictest",
"CheckTitle": "Classic Load Balancer should be configured with defensive or strictest desync mitigation mode",
"CheckType": [
"Software and Configuration Checks/AWS Security Best Practices",
"Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices",
"TTPs/Initial Access",
"TTPs/Defense Evasion"
"Software and Configuration Checks/AWS Security Best Practices"
],
"ServiceName": "elb",
"SubServiceName": "",
"ResourceIdTemplate": "",
"ResourceIdTemplate": "arn:aws:elasticloadbalancing:{region}:{account-id}:loadbalancer/{load-balancer-name}",
"Severity": "medium",
"ResourceType": "AwsElbLoadBalancer",
"Description": "**Classic Load Balancer** `desync_mitigation_mode` is evaluated to determine whether it is configured as **`defensive`** or **`strictest`**. Any other mode (such as `monitor`) is identified for attention.",
"Risk": "Without strict desync mitigation, **HTTP request smuggling** can occur, enabling:\n- Cache/queue poisoning (**integrity**)\n- Session hijacking and data exposure (**confidentiality**)\n- Unintended backend actions and abuse (**availability**)",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.aws.amazon.com/elasticloadbalancing/latest/classic/config-desync-mitigation-mode.html",
"https://docs.aws.amazon.com/securityhub/latest/userguide/elb-controls.html#elb-14",
"https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/ELB/enable-configure-desync-mitigation-mode.html",
"https://support.icompaas.com/support/solutions/articles/62000233337-ensure-classic-load-balancer-is-configured-with-defensive-or-strictest-desync-mitigation-mode"
],
"Description": "This control checks whether a Classic Load Balancer is configured with defensive or strictest desync mitigation mode. The control fails if the Classic Load Balancer isn't configured with defensive or strictest desync mitigation mode.",
"Risk": "HTTP Desync issues can lead to request smuggling, making applications vulnerable to attacks such as request queue or cache poisoning, which could result in credential hijacking or unauthorized command execution.",
"RelatedUrl": "https://docs.aws.amazon.com/config/latest/developerguide/clb-desync-mode-check.html",
"Remediation": {
"Code": {
"CLI": "aws elb modify-load-balancer-attributes --load-balancer-name <load-balancer-name> --load-balancer-attributes '{\"AdditionalAttributes\":[{\"Key\":\"elb.http.desyncmitigationmode\",\"Value\":\"defensive\"}]}'",
"CLI": "aws elb modify-load-balancer-attributes --load-balancer-name <load-balancer-name> --load-balancer-attributes '{\"DesyncMitigationMode\":\"defensive\"}'",
"NativeIaC": "",
"Other": "1. Open the AWS Management Console and go to EC2\n2. Under Load Balancing, select Load Balancers\n3. Select your Classic Load Balancer\n4. On the Attributes tab, click Edit\n5. Set Desync mitigation mode to Defensive or Strictest\n6. Click Save changes",
"Terraform": "```hcl\nresource \"aws_elb\" \"<example_resource_name>\" {\n name = \"<example_resource_name>\"\n availability_zones = [\"<example_az>\"]\n\n listener {\n instance_port = 80\n instance_protocol = \"http\"\n lb_port = 80\n lb_protocol = \"http\"\n }\n\n desync_mitigation_mode = \"defensive\" # Critical: sets CLB desync mitigation to defensive to pass the check\n}\n```"
"Other": "https://docs.aws.amazon.com/securityhub/latest/userguide/elb-controls.html#elb-14",
"Terraform": ""
},
"Recommendation": {
"Text": "Set CLB desync mitigation to **`defensive`** or, where compatible, **`strictest`**. Validate in staging to avoid client breakage. Apply **defense in depth**: enforce strict header handling, pair with WAF controls, and monitor non-compliant request indicators.",
"Url": "https://hub.prowler.com/check/elb_desync_mitigation_mode"
"Text": "Configure the Classic Load Balancer with defensive or strictest desync mitigation mode to prevent security issues caused by HTTP desync.",
"Url": "https://docs.aws.amazon.com/elasticloadbalancing/latest/classic/config-desync-mitigation-mode.html#update-desync-mitigation-mode"
}
},
"Categories": [],

View File

@@ -1,38 +1,28 @@
{
"Provider": "aws",
"CheckID": "elb_insecure_ssl_ciphers",
"CheckTitle": "Elastic Load Balancer HTTPS listeners, if present, use the ELBSecurityPolicy-TLS-1-2-2017-01 policy",
"CheckTitle": "Check if Elastic Load Balancers have insecure SSL ciphers.",
"CheckType": [
"Software and Configuration Checks/AWS Security Best Practices",
"Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices",
"Software and Configuration Checks/Industry and Regulatory Standards/CIS AWS Foundations Benchmark",
"Software and Configuration Checks/Industry and Regulatory Standards/PCI-DSS",
"Software and Configuration Checks/Industry and Regulatory Standards/NIST 800-53 Controls (USA)"
"Data Protection"
],
"ServiceName": "elb",
"SubServiceName": "",
"ResourceIdTemplate": "",
"ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id",
"Severity": "medium",
"ResourceType": "AwsElbLoadBalancer",
"Description": "Elastic Load Balancer HTTPS listeners are assessed for use of a **strong TLS policy**. Listeners associated with `ELBSecurityPolicy-TLS-1-2-2017-01` are considered to negotiate only modern protocols and ciphers, avoiding legacy SSL/TLS and weak suites.",
"Risk": "Legacy TLS or weak ciphers allow downgrades and man-in-the-middle decryption or tampering. Attackers can capture credentials, inject responses, and pivot, undermining data-in-transit **confidentiality** and **integrity**, and risking **availability** through failed handshakes.",
"Description": "Check if Elastic Load Balancers have insecure SSL ciphers.",
"Risk": "Using insecure ciphers could affect privacy of in transit information.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/ELB/elb-security-policy.html",
"https://docs.aws.amazon.com/elasticloadbalancing/latest/application/create-https-listener.html#describe-ssl-policies",
"https://docs.aws.amazon.com/elasticloadbalancing/latest/classic/ssl-config-update.html",
"https://docs.aws.amazon.com/elasticloadbalancing/latest/classic/elb-security-policy-table.html"
],
"Remediation": {
"Code": {
"CLI": "aws elb set-load-balancer-policies-of-listener --load-balancer-name <lb_name> --load-balancer-port 443 --policy-names ELBSecurityPolicy-TLS-1-2-2017-01",
"NativeIaC": "```yaml\n# CloudFormation: Classic ELB with TLS 1.2-only security policy on HTTPS listener\nResources:\n <example_resource_name>:\n Type: AWS::ElasticLoadBalancing::LoadBalancer\n Properties:\n AvailabilityZones:\n - <example_az>\n Listeners:\n - LoadBalancerPort: 443\n InstancePort: 443\n Protocol: HTTPS\n InstanceProtocol: HTTPS\n SSLCertificateId: <example_certificate_arn>\n PolicyNames:\n - ELBSecurityPolicy-TLS-1-2-2017-01 # Critical: attach TLS 1.2-only policy to the HTTPS listener\n Policies:\n - PolicyName: ELBSecurityPolicy-TLS-1-2-2017-01 # Critical: create policy referencing the predefined TLS 1.2 policy\n PolicyType: SSLNegotiationPolicyType\n Attributes:\n - Name: Reference-Security-Policy\n Value: ELBSecurityPolicy-TLS-1-2-2017-01 # Critical: enforce TLS 1.2-only\n```",
"Other": "1. Open the AWS Management Console and go to EC2\n2. In the left menu, under Load Balancing, click Load Balancers\n3. Select your Classic Load Balancer\n4. On the Listeners tab, click Manage listeners (or Edit)\n5. Select the HTTPS (port 443) listener and under Security policy choose ELBSecurityPolicy-TLS-1-2-2017-01\n6. Click Save changes",
"Terraform": "```hcl\n# Create and attach TLS 1.2-only policy to a Classic ELB HTTPS listener\nresource \"aws_load_balancer_policy\" \"<example_resource_name>\" {\n load_balancer_name = \"<example_resource_name>\"\n policy_name = \"ELBSecurityPolicy-TLS-1-2-2017-01\" # Critical: policy named as required by the check\n policy_type_name = \"SSLNegotiationPolicyType\"\n\n policy_attributes {\n name = \"Reference-Security-Policy\"\n value = \"ELBSecurityPolicy-TLS-1-2-2017-01\" # Critical: reference the predefined TLS 1.2 policy\n }\n}\n\nresource \"aws_load_balancer_listener_policy\" \"<example_resource_name>\" {\n load_balancer_name = \"<example_resource_name>\"\n load_balancer_port = 443\n policy_names = [aws_load_balancer_policy.<example_resource_name>.policy_name] # Critical: attach policy to HTTPS listener\n}\n```"
"NativeIaC": "",
"Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/ELB/elb-security-policy.html",
"Terraform": "https://docs.prowler.com/checks/aws/general-policies/bc_aws_general_43#terraform"
},
"Recommendation": {
"Text": "Standardize on ELB policies enforcing **TLS 1.2+** with modern AEAD ciphers; disable legacy protocols and weak suites. Enable server cipher order, retire outdated policies, and review regularly for crypto agility. Validate client compatibility, use strong certificates, and monitor negotiation results.",
"Url": "https://hub.prowler.com/check/elb_insecure_ssl_ciphers"
"Text": "Use a Security policy with ciphers that are as strong as possible. Drop legacy and insecure ciphers.",
"Url": "https://docs.aws.amazon.com/elasticloadbalancing/latest/application/create-https-listener.html#describe-ssl-policies"
}
},
"Categories": [

View File

@@ -1,40 +1,31 @@
{
"Provider": "aws",
"CheckID": "elb_internet_facing",
"CheckTitle": "Elastic Load Balancer is not internet-facing",
"CheckTitle": "Check for internet facing Elastic Load Balancers.",
"CheckType": [
"Software and Configuration Checks/AWS Security Best Practices/Network Reachability",
"Effects/Data Exposure",
"TTPs/Initial Access"
"Data Protection"
],
"ServiceName": "elb",
"SubServiceName": "",
"ResourceIdTemplate": "",
"ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id",
"Severity": "medium",
"ResourceType": "AwsElbLoadBalancer",
"Description": "Elastic Load Balancers are evaluated for the `scheme` to determine whether they are **internet-facing** or internal, indicating if the endpoint is publicly reachable via a public DNS name.",
"Risk": "An unintended **internet-facing** load balancer exposes backends to the Internet, enabling reconnaissance, credential stuffing, and exploitation of app flaws. This can lead to data exposure (confidentiality), unauthorized changes (integrity), and **DDoS** or resource exhaustion (availability).",
"Description": "Check for internet facing Elastic Load Balancers.",
"Risk": "Publicly accessible load balancers could expose sensitive data to bad actors.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.aws.amazon.com/waf/latest/developerguide/web-acl-associating-aws-resource.html",
"https://docs.aws.amazon.com/AWSCloudFormation/latest/TemplateReference/aws-resource-elasticloadbalancingv2-loadbalancer.html",
"https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/ELB/internet-facing-load-balancers.html"
],
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "```yaml\n# CloudFormation: create an internal load balancer\nResources:\n <example_resource_name>:\n Type: AWS::ElasticLoadBalancingV2::LoadBalancer\n Properties:\n Scheme: internal # CRITICAL: makes the load balancer internal (not internet-facing)\n Subnets:\n - <example_resource_id>\n - <example_resource_id>\n SecurityGroups:\n - <example_resource_id>\n```",
"Other": "1. In AWS Console, go to EC2 > Load Balancers\n2. Click Create load balancer (Application or Network)\n3. Set Scheme to Internal\n4. Select at least two subnets and a security group; recreate listeners/target groups as needed\n5. Create the new load balancer and update DNS to its DNS name\n6. Delete the old internet-facing load balancer",
"Terraform": "```hcl\nresource \"aws_lb\" \"<example_resource_name>\" {\n internal = true # CRITICAL: sets scheme to internal so it's not internet-facing\n subnets = [\"<example_resource_id>\", \"<example_resource_id>\"]\n security_groups = [\"<example_resource_id>\"]\n}\n```"
"NativeIaC": "",
"Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/ELB/internet-facing-load-balancers.html",
"Terraform": ""
},
"Recommendation": {
"Text": "Use `internal` load balancers for private services and restrict exposure with **security groups**, subnets, and allowlists. For public endpoints, apply **defense in depth**: associate an **AWS WAF** web ACL (*when supported*), enforce **TLS**, least-privilege network rules, and consider **Shield** or rate limiting. Regularly review necessity of public access.",
"Url": "https://hub.prowler.com/check/elb_internet_facing"
"Text": "Ensure the load balancer should be publicly accessible. If publicly exposed ensure a WAF ACL is implemented.",
"Url": "https://docs.aws.amazon.com/waf/latest/developerguide/web-acl-associating-aws-resource.html"
}
},
"Categories": [
"internet-exposed"
],
"Categories": [],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""

View File

@@ -1,38 +1,30 @@
{
"Provider": "aws",
"CheckID": "elb_is_in_multiple_az",
"CheckTitle": "Classic Load Balancer is in multiple Availability Zones",
"CheckType": [
"Software and Configuration Checks/AWS Security Best Practices",
"Effects/Denial of Service"
],
"CheckTitle": "Ensure Classic Load Balancer is Configured Across Multiple Availability Zones",
"CheckType": [],
"ServiceName": "elb",
"SubServiceName": "",
"ResourceIdTemplate": "",
"ResourceIdTemplate": "arn:<partition>:elasticloadbalancing:<region>:<account-id>:loadbalancer/<load-balancer-name>",
"Severity": "medium",
"ResourceType": "AwsElbLoadBalancer",
"Description": "**Classic Load Balancer** spans at least the configured number of **Availability Zones**.\n\nThe evaluation identifies load balancers enabled in fewer AZs than the specified minimum.",
"Risk": "Operating in too few AZs makes the load balancer a **single point of failure**. An AZ outage or zonal degradation can cause **service unavailability**, dropped connections, and uneven capacity, undermining application **availability** and resilience and increasing recovery time.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/ELB/ec2-instances-distribution-across-availability-zones.html",
"https://docs.aws.amazon.com/elasticloadbalancing/latest/classic/enable-disable-crosszone-lb.html",
"https://docs.aws.amazon.com/elasticloadbalancing/latest/classic/introduction.html#classic-load-balancer-overview"
],
"Description": "This check ensures that a Classic Load Balancer is configured to span at least the specified number of Availability Zones (AZs). The control fails if the Load Balancer does not span multiple AZs, which can lead to decreased availability and reliability in case of an AZ failure.",
"Risk": "A Classic Load Balancer configured in a single Availability Zone risks becoming a single point of failure. If the AZ fails, the load balancer will not be able to redirect traffic to other healthy targets, leading to potential service outages.",
"RelatedUrl": "https://docs.aws.amazon.com/elasticloadbalancing/latest/classic/introduction.html#classic-load-balancer-overview",
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "```yaml\n# CloudFormation: Ensure CLB spans at least two Availability Zones by adding two subnets\nResources:\n <example_resource_name>:\n Type: AWS::ElasticLoadBalancing::LoadBalancer\n Properties:\n Subnets:\n - <example_subnet_id_a> # Critical: add a subnet in AZ A to ensure multiple AZs\n - <example_subnet_id_b> # Critical: add a subnet in a different AZ (>=2 AZs total)\n Listeners:\n - LoadBalancerPort: 80\n InstancePort: 80\n Protocol: HTTP\n```",
"Other": "1. Open the Amazon EC2 console and go to Load Balancers\n2. Select your Classic Load Balancer (type: classic)\n3. Choose Edit subnets (or the Subnets tab > Edit)\n4. Add a subnet from a different Availability Zone than the existing one (ensure at least two AZs)\n5. Click Save\n6. If your CLB is in EC2-Classic, use Edit Availability Zones instead and select an additional AZ, then Save",
"Terraform": "```hcl\n# Terraform: Ensure CLB spans at least two Availability Zones by adding two subnets\nresource \"aws_elb\" \"<example_resource_name>\" {\n name = \"<example_resource_name>\"\n subnets = [\n \"<example_subnet_id_a>\", # Critical: subnet in AZ A to ensure multiple AZs\n \"<example_subnet_id_b>\" # Critical: subnet in different AZ (>=2 AZs total)\n ]\n\n listener {\n lb_port = 80\n lb_protocol = \"http\"\n instance_port = 80\n }\n}\n```"
"NativeIaC": "",
"Other": "https://docs.aws.amazon.com/securityhub/latest/userguide/elb-controls.html#elb-10",
"Terraform": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/ELB/ec2-instances-distribution-across-availability-zones.html"
},
"Recommendation": {
"Text": "Design for **multi-AZ high availability**:\n- Enable at least `2` AZs per load balancer\n- Distribute targets evenly and use Auto Scaling across AZs\n- Enable **cross-zone load balancing** to smooth imbalances\n- Regularly test failover and health thresholds\n\nApply **fault isolation** and **defense in depth** principles.",
"Url": "https://hub.prowler.com/check/elb_is_in_multiple_az"
"Text": "Distribute your Classic Load Balancer across multiple Availability Zones to improve redundancy and fault tolerance.",
"Url": "https://docs.aws.amazon.com/elasticloadbalancing/latest/classic/enable-disable-crosszone-lb.html"
}
},
"Categories": [
"resilience"
"redundancy"
],
"DependsOn": [],
"RelatedTo": [],

View File

@@ -1,39 +1,32 @@
{
"Provider": "aws",
"CheckID": "elb_logging_enabled",
"CheckTitle": "Elastic Load Balancer has access logs to S3 configured",
"CheckTitle": "Check if Elastic Load Balancers have logging enabled.",
"CheckType": [
"Software and Configuration Checks/AWS Security Best Practices",
"Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices",
"Software and Configuration Checks/Industry and Regulatory Standards/CIS AWS Foundations Benchmark"
"Logging and Monitoring"
],
"ServiceName": "elb",
"SubServiceName": "",
"ResourceIdTemplate": "",
"ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id",
"Severity": "medium",
"ResourceType": "AwsElbLoadBalancer",
"Description": "**Elastic Load Balancers** have **access logs** configured to deliver request metadata (client IPs, paths, status, TLS details) to **Amazon S3**",
"Risk": "Without **ELB access logs**, you lose **visibility** into edge traffic, reducing detection of reconnaissance, brute-force, and exploitation attempts. This hampers forensics and incident timelines, risking undetected data exfiltration (confidentiality), untraceable changes (integrity), and delayed response to outages or DDoS (availability).",
"Description": "Check if Elastic Load Balancers have logging enabled.",
"Risk": "If logs are not enabled monitoring of service use and threat analysis is not possible.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.aws.amazon.com/elasticloadbalancing/latest/network/enable-access-logs.html",
"https://docs.aws.amazon.com/elasticloadbalancing/latest/classic/access-log-collection.html",
"https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/ElasticBeanstalk/enable-access-logs.html",
"https://docs.aws.amazon.com/elasticloadbalancing/latest/application/enable-access-logging.html"
],
"Remediation": {
"Code": {
"CLI": "aws elb modify-load-balancer-attributes --load-balancer-name <lb_name> --load-balancer-attributes AccessLog={Enabled=true,S3BucketName=<bucket_name>}",
"NativeIaC": "```yaml\n# CloudFormation: Enable access logs for a Classic Load Balancer (CLB)\nResources:\n <example_resource_name>:\n Type: AWS::ElasticLoadBalancing::LoadBalancer\n Properties:\n Listeners:\n - LoadBalancerPort: 80\n InstancePort: 80\n Protocol: HTTP\n AvailabilityZones:\n - <example_resource_id>\n AccessLoggingPolicy: # CRITICAL: Enables S3 access logs\n Enabled: true # CRITICAL: Turn on access logging\n S3BucketName: <example_resource_name> # CRITICAL: S3 bucket to store logs\n```",
"Other": "1. In the AWS Console, go to EC2 > Load Balancers\n2. Select the load balancer and choose Edit attributes (or the Attributes tab)\n3. Turn on Access logs\n4. Enter the S3 URI (e.g., s3://<bucket_name>)\n5. Click Save",
"Terraform": "```hcl\n# Enable access logs for an ELBv2 load balancer (minimal)\nresource \"aws_lb\" \"<example_resource_name>\" {\n load_balancer_type = \"network\"\n subnets = [\"<example_resource_id>\", \"<example_resource_id>\"]\n\n access_logs { # CRITICAL: Enables S3 access logs\n bucket = \"<example_resource_name>\" # CRITICAL: S3 bucket for logs\n enabled = true # CRITICAL: Turn on access logging\n }\n}\n```"
"CLI": "aws elb modify-load-balancer-attributes --load-balancer-name <lb_name> --load-balancer-attributes '{AccessLog:{Enabled:true,EmitInterval:60,S3BucketName:<bucket_name>}}'",
"NativeIaC": "https://docs.prowler.com/checks/aws/logging-policies/bc_aws_logging_23#cloudformation",
"Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/ELB/elb-access-log.html",
"Terraform": "https://docs.prowler.com/checks/aws/logging-policies/bc_aws_logging_23#terraform"
},
"Recommendation": {
"Text": "Enable **access logs** to Amazon S3 (`access_logs.s3.enabled=true`). Apply **least privilege** bucket policies, encrypt objects, and restrict read access. Define lifecycle retention and centralize analysis. Monitor for delivery failures and alert on anomalies. Standardize across all load balancers via IaC as part of **defense in depth**.",
"Url": "https://hub.prowler.com/check/elb_logging_enabled"
"Text": "Enable ELB logging, create a log lifecycle and define use cases.",
"Url": "https://docs.aws.amazon.com/elasticloadbalancing/latest/classic/access-log-collection.html"
}
},
"Categories": [
"forensics-ready",
"logging"
],
"DependsOn": [],

View File

@@ -1,36 +1,28 @@
{
"Provider": "aws",
"CheckID": "elb_ssl_listeners",
"CheckTitle": "Elastic Load Balancer has only HTTPS or SSL listeners",
"CheckTitle": "Check if Elastic Load Balancers have SSL listeners.",
"CheckType": [
"Software and Configuration Checks/AWS Security Best Practices/Network Reachability",
"Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices",
"Software and Configuration Checks/Industry and Regulatory Standards/PCI-DSS",
"Effects/Data Exposure"
"Data Protection"
],
"ServiceName": "elb",
"SubServiceName": "",
"ResourceIdTemplate": "",
"ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id",
"Severity": "medium",
"ResourceType": "AwsElbLoadBalancer",
"Description": "**Elastic Load Balancers** are assessed for client-facing listener protocols. Only `HTTPS` or `SSL` are considered encrypted; any `HTTP` or `TCP` listener indicates plaintext between clients and the load balancer.",
"Risk": "Plaintext listeners enable network eavesdropping and content injection, compromising **confidentiality** and **integrity**. Attackers on public or untrusted paths can harvest credentials and session tokens or alter traffic via MITM, leading to data exposure and unauthorized access.",
"Description": "Check if Elastic Load Balancers have SSL listeners.",
"Risk": "Clear text communication could affect privacy of information in transit.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.aws.amazon.com/elasticloadbalancing/latest/application/create-https-listener.html",
"https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/ELB/elb-listener-security.html",
"https://docs.aws.amazon.com/elasticloadbalancing/latest/classic/elb-security-policy-table.html"
],
"Remediation": {
"Code": {
"CLI": "aws elb delete-load-balancer-listeners --load-balancer-name <lb_name> --load-balancer-ports 80",
"NativeIaC": "```yaml\n# CloudFormation: Classic ELB with only encrypted (HTTPS) listener\nResources:\n <example_resource_name>:\n Type: AWS::ElasticLoadBalancing::LoadBalancer\n Properties:\n AvailabilityZones:\n - <example_az>\n Listeners:\n - Protocol: HTTPS # CRITICAL: enforce encrypted listener\n LoadBalancerPort: 443\n InstanceProtocol: HTTP\n InstancePort: 80\n SSLCertificateId: <certificate_arn> # CRITICAL: required for HTTPS termination\n```",
"Other": "1. In the AWS console, go to EC2 > Load Balancers (Classic)\n2. Select the load balancer and open the Listeners tab\n3. Click Edit and remove any listener with Protocol HTTP or TCP\n4. Add a listener with Protocol HTTPS (port 443) and select an SSL certificate\n5. Save changes",
"Terraform": "```hcl\n# Classic ELB with only encrypted (HTTPS) listener\nresource \"aws_elb\" \"<example_resource_name>\" {\n availability_zones = [\"<example_az>\"]\n\n listener {\n lb_port = 443\n lb_protocol = \"https\" # CRITICAL: enforce encrypted listener\n instance_port = 80\n instance_protocol = \"http\"\n ssl_certificate_id = \"<certificate_arn>\" # CRITICAL: required for HTTPS/SSL\n }\n}\n```"
"CLI": "aws elb create-load-balancer-listeners --load-balancer-name <lb_name> --listeners Protocol=HTTPS, LoadBalancerPort=443, InstanceProtocol=HTTP, InstancePort=80, SSLCertificateId=<certificate_arn>",
"NativeIaC": "",
"Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/ELB/elb-listener-security.html",
"Terraform": ""
},
"Recommendation": {
"Text": "Enforce **encryption in transit** by using only `HTTPS`/`TLS` listeners. Redirect `HTTP` to `HTTPS` and retire plaintext listeners. Use trusted certificates (e.g., ACM) and modern TLS policies; align with **zero trust** and **defense in depth**. *If needed*, use end-to-end TLS to targets and monitor certificate health.",
"Url": "https://hub.prowler.com/check/elb_ssl_listeners"
"Text": "Scan for Load Balancers with HTTP or TCP listeners and understand the reason for each of them. Check if the listener can be implemented as TLS instead..",
"Url": "https://docs.aws.amazon.com/elasticloadbalancing/latest/application/create-https-listener.html"
}
},
"Categories": [

View File

@@ -1,33 +1,28 @@
{
"Provider": "aws",
"CheckID": "elb_ssl_listeners_use_acm_certificate",
"CheckTitle": "Classic Load Balancer HTTPS/SSL listeners use ACM-issued certificates",
"CheckTitle": "Check if Classic Load Balancers with SSL/HTTPS listeners use a certificate provided by AWS Certificate Manager (ACM).",
"CheckType": [
"Software and Configuration Checks/AWS Security Best Practices",
"Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices"
"Software and Configuration Checks/Vulnerabilities/NIST 800-53 Controls (USA)"
],
"ServiceName": "elb",
"SubServiceName": "",
"ResourceIdTemplate": "",
"ResourceIdTemplate": "arn:aws:elasticloadbalancing:{region}:{account-id}:loadbalancer/{loadbalancer-name}",
"Severity": "medium",
"ResourceType": "AwsElbLoadBalancer",
"Description": "Classic Load Balancer HTTPS/SSL listeners use **AWS Certificate Manager** certificates that are **Amazon-issued** (certificate type `AMAZON_ISSUED`).",
"Risk": "Using imported or non Amazon-issued certificates reduces control over issuance and rotation, increasing chances of **expired or weak TLS**. This can trigger **service outages** and enable **man-in-the-middle** interception, compromising data **confidentiality** and **integrity**.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.aws.amazon.com/securityhub/latest/userguide/elb-controls.html#elb-2",
"https://docs.aws.amazon.com/config/latest/developerguide/elb-acm-certificate-required.html"
],
"Description": "This control checks whether the Classic Load Balancer uses HTTPS/SSL certificates provided by AWS Certificate Manager (ACM). The control fails if the Classic Load Balancer does not use a certificate provided by ACM.",
"Risk": "If Classic Load Balancers are not using ACM certificates, it increases the risk of using self-signed or expired certificates, which can impact secure communication and lead to compliance issues.",
"RelatedUrl": "https://docs.aws.amazon.com/config/latest/developerguide/elb-acm-certificate-required.html",
"Remediation": {
"Code": {
"CLI": "aws elb set-load-balancer-listener-ssl-certificate --load-balancer-name <load-balancer-name> --load-balancer-port <port> --ssl-certificate-id <acm_certificate_arn>",
"NativeIaC": "```yaml\n# CloudFormation: Attach an Amazon-issued ACM cert to a CLB HTTPS/SSL listener\nResources:\n <example_resource_name>:\n Type: AWS::ElasticLoadBalancing::LoadBalancer\n Properties:\n AvailabilityZones:\n - <example_az>\n Listeners:\n - LoadBalancerPort: 443\n InstancePort: 443\n Protocol: HTTPS\n SSLCertificateId: <acm_certificate_arn> # critical: use Amazon-issued ACM certificate to pass ELB.2\n```",
"Other": "1. In the AWS Console, go to EC2 > Load Balancing > Load Balancers (Classic)\n2. Select the Classic Load Balancer\n3. Open the Listeners tab and choose the HTTPS/SSL listener\n4. Click Edit (or Change SSL certificate)\n5. Select an ACM certificate that is Amazon-issued (not imported)\n6. Save changes",
"Terraform": "```hcl\n# Terraform: Attach an Amazon-issued ACM cert to a CLB HTTPS/SSL listener\nresource \"aws_elb\" \"<example_resource_name>\" {\n availability_zones = [\"<example_az>\"]\n\n listener {\n lb_port = 443\n lb_protocol = \"https\"\n instance_port = 443\n instance_protocol = \"https\"\n ssl_certificate_id = \"<acm_certificate_arn>\" # critical: Amazon-issued ACM cert to satisfy ELB.2\n }\n}\n```"
"CLI": "aws elb set-load-balancer-listener-ssl-certificate --load-balancer-name <load-balancer-name> --load-balancer-port <port> --ssl-certificate-id <certificate-id>",
"NativeIaC": "",
"Other": "https://docs.aws.amazon.com/securityhub/latest/userguide/elb-controls.html#elb-2",
"Terraform": ""
},
"Recommendation": {
"Text": "Standardize on **Amazon-issued ACM certificates** for CLB HTTPS/SSL listeners to ensure managed validation and **automatic renewal**.\n\nApply **least privilege** to certificate operations, automate rotation, and monitor certificate health as part of **defense in depth**.",
"Url": "https://hub.prowler.com/check/elb_ssl_listeners_use_acm_certificate"
"Text": "Use AWS Certificate Manager (ACM) to manage SSL/TLS certificates for your Classic Load Balancer to ensure secure encryption of data in transit.",
"Url": "https://repost.aws/es/knowledge-center/associate-acm-certificate-alb-nlb"
}
},
"Categories": [

View File

@@ -242,7 +242,6 @@ class Provider(ABC):
personal_access_token=arguments.personal_access_token,
oauth_app_token=arguments.oauth_app_token,
github_app_key=arguments.github_app_key,
github_app_key_path=arguments.github_app_key_path,
github_app_id=arguments.github_app_id,
mutelist_path=arguments.mutelist_file,
config_path=arguments.config_file,

View File

@@ -103,7 +103,6 @@ class GithubProvider(Provider):
personal_access_token: str = "",
oauth_app_token: str = "",
github_app_key: str = "",
github_app_key_path: str = "",
github_app_key_content: str = "",
github_app_id: int = 0,
# Provider configuration
@@ -121,9 +120,8 @@ class GithubProvider(Provider):
Args:
personal_access_token (str): GitHub personal access token.
oauth_app_token (str): GitHub OAuth App token.
github_app_key (str): GitHub App key content.
github_app_key_path (str): Path to GitHub App private key file.
github_app_key_content (str): GitHub App private key content (legacy parameter).
github_app_key (str): GitHub App key.
github_app_key_content (str): GitHub App key content.
github_app_id (int): GitHub App ID.
config_path (str): Path to the audit configuration file.
config_content (dict): Audit configuration content.
@@ -150,7 +148,6 @@ class GithubProvider(Provider):
oauth_app_token,
github_app_id,
github_app_key,
github_app_key_path,
github_app_key_content,
)
@@ -159,17 +156,13 @@ class GithubProvider(Provider):
self._auth_method = "Personal Access Token"
elif oauth_app_token:
self._auth_method = "OAuth App Token"
elif github_app_id and (
github_app_key or github_app_key_path or github_app_key_content
):
self._auth_method = "GitHub App Key and ID"
elif github_app_id and (github_app_key or github_app_key_content):
self._auth_method = "GitHub App Token"
elif environ.get("GITHUB_PERSONAL_ACCESS_TOKEN", ""):
self._auth_method = "Environment Variable for Personal Access Token"
elif environ.get("GITHUB_OAUTH_APP_TOKEN", ""):
self._auth_method = "Environment Variable for OAuth App Token"
elif environ.get("GITHUB_APP_ID", "") and (
environ.get("GITHUB_APP_KEY", "") or environ.get("GITHUB_APP_KEY_PATH", "")
):
elif environ.get("GITHUB_APP_ID", "") and environ.get("GITHUB_APP_KEY", ""):
self._auth_method = "Environment Variables for GitHub App Key and ID"
self._identity = GithubProvider.setup_identity(self._session)
@@ -258,7 +251,6 @@ class GithubProvider(Provider):
oauth_app_token: str = None,
github_app_id: int = 0,
github_app_key: str = None,
github_app_key_path: str = None,
github_app_key_content: str = None,
) -> GithubSession:
"""
@@ -268,9 +260,8 @@ class GithubProvider(Provider):
personal_access_token (str): GitHub personal access token.
oauth_app_token (str): GitHub OAuth App token.
github_app_id (int): GitHub App ID.
github_app_key (str): GitHub App key content.
github_app_key_path (str): Path to GitHub App private key file.
github_app_key_content (str): GitHub App private key content (legacy parameter).
github_app_key (str): GitHub App key.
github_app_key_content (str): GitHub App key content.
Returns:
GithubSession: Authenticated session token for API requests.
"""
@@ -287,35 +278,11 @@ class GithubProvider(Provider):
elif oauth_app_token:
session_token = oauth_app_token
elif github_app_id and (
github_app_key or github_app_key_path or github_app_key_content
):
elif github_app_id and (github_app_key or github_app_key_content):
app_id = github_app_id
if github_app_key_path:
try:
with open(github_app_key_path, "r") as rsa_key:
app_key = rsa_key.read()
except OSError as e:
if e.errno == 63:
raise GithubEnvironmentVariableError(
file=os.path.basename(__file__),
message="--github-app-key-path expects a file path, not key content. Use --github-app-key for key content instead.",
)
else:
raise GithubEnvironmentVariableError(
file=os.path.basename(__file__),
message=f"Could not read GitHub App key file '{github_app_key_path}': {e.message}",
)
elif github_app_key:
if github_app_key.startswith("-----BEGIN"):
app_key = format_rsa_key(github_app_key)
elif os.path.isfile(github_app_key):
raise GithubEnvironmentVariableError(
file=os.path.basename(__file__),
message="--github-app-key expects key content, not a file path. Use --github-app-key-path for file paths instead.",
)
else:
app_key = format_rsa_key(github_app_key)
if github_app_key:
with open(github_app_key, "r") as rsa_key:
app_key = rsa_key.read()
else:
app_key = format_rsa_key(github_app_key_content)
@@ -336,24 +303,13 @@ class GithubProvider(Provider):
if not session_token:
# APP
logger.info(
"Looking for GitHub App environment variables as user has not provided any token...."
"Looking for GITHUB_APP_ID and GITHUB_APP_KEY environment variables as user has not provided any token...."
)
app_id = environ.get("GITHUB_APP_ID", "")
app_key = format_rsa_key(environ.get("GITHUB_APP_KEY", ""))
app_key_path = environ.get("GITHUB_APP_KEY_PATH", "")
if app_key_path:
with open(app_key_path, "r") as rsa_key:
app_key = rsa_key.read()
else:
env_key = environ.get("GITHUB_APP_KEY", "")
if env_key:
if env_key.startswith("-----BEGIN"):
app_key = format_rsa_key(env_key)
else:
raise GithubEnvironmentVariableError(
file=os.path.basename(__file__),
message="GITHUB_APP_KEY must contain RSA key content (starting with -----BEGIN). Use GITHUB_APP_KEY_PATH for file paths.",
)
if app_id and app_key:
pass
if not session_token and not (app_id and app_key):
raise GithubEnvironmentVariableError(
@@ -564,7 +520,6 @@ class GithubProvider(Provider):
personal_access_token: str = "",
oauth_app_token: str = "",
github_app_key: str = "",
github_app_key_path: str = "",
github_app_key_content: str = "",
github_app_id: int = 0,
raise_on_exception: bool = True,
@@ -577,9 +532,8 @@ class GithubProvider(Provider):
Args:
personal_access_token (str): GitHub personal access token.
oauth_app_token (str): GitHub OAuth App token.
github_app_key (str): GitHub App key content.
github_app_key_path (str): Path to GitHub App private key file.
github_app_key_content (str): GitHub App private key content (legacy parameter).
github_app_key (str): GitHub App key.
github_app_key_content (str): GitHub App key content.
github_app_id (int): GitHub App ID.
raise_on_exception (bool): Flag indicating whether to raise an exception if the connection fails.
provider_id (str): The provider ID, in this case it's the GitHub organization/username.
@@ -599,7 +553,7 @@ class GithubProvider(Provider):
Examples:
>>> GithubProvider.test_connection(personal_access_token="ghp_xxxxxxxxxxxxxxxx")
Connection(is_connected=True)
>>> GithubProvider.test_connection(github_app_id=12345, github_app_key_content="/path/to/key.pem")
>>> GithubProvider.test_connection(github_app_id=12345, github_app_key="/path/to/key.pem")
Connection(is_connected=True)
>>> GithubProvider.test_connection(provider_id="my-org")
Connection(is_connected=True)
@@ -611,7 +565,6 @@ class GithubProvider(Provider):
oauth_app_token=oauth_app_token,
github_app_id=github_app_id,
github_app_key=github_app_key,
github_app_key_path=github_app_key_path,
github_app_key_content=github_app_key_content,
)

View File

@@ -31,17 +31,11 @@ def init_parser(self):
)
github_auth_subparser.add_argument(
"--github-app-key",
nargs="?",
help="GitHub App Key content (PEM format) to log in against GitHub",
default=None,
metavar="GITHUB_APP_KEY",
)
github_auth_subparser.add_argument(
"--github-app-key-path",
nargs="?",
help="Path to GitHub App private key file",
help="GitHub App Key Path to log in against GitHub",
default=None,
metavar="GITHUB_APP_KEY_PATH",
metavar="GITHUB_APP_KEY",
)
github_scoping_subparser = github_parser.add_argument_group("Scan Scoping")
@@ -61,31 +55,3 @@ def init_parser(self):
default=None,
metavar="ORGANIZATION",
)
def validate_arguments(arguments) -> tuple[bool, str]:
"""
Validate GitHub provider arguments
Args:
arguments: Parsed command line arguments
Returns:
tuple[bool, str]: (is_valid, error_message)
"""
if arguments.github_app_key and arguments.github_app_key_path:
return (
False,
"Cannot specify both --github-app-key and --github-app-key-path simultaneously",
)
if arguments.github_app_id and not (
arguments.github_app_key or arguments.github_app_key_path
):
return (
False,
"GitHub App ID requires either --github-app-key or --github-app-key-path",
)
return True, ""

View File

@@ -30,10 +30,6 @@ class MongoDBAtlasBaseException(ProwlerException):
"message": "MongoDB Atlas API rate limit exceeded",
"remediation": "Reduce the number of API requests or wait before making more requests.",
},
(8006, "MongoDBAtlasInvalidOrganizationIdError"): {
"message": "The provided credentials do not have access to the organization with the provided ID",
"remediation": "Check the organization ID and ensure it is a valid organization ID and that the credentials have access to it.",
},
}
def __init__(self, code, file=None, original_exception=None, message=None):
@@ -120,15 +116,3 @@ class MongoDBAtlasRateLimitError(MongoDBAtlasBaseException):
original_exception=original_exception,
message=message,
)
class MongoDBAtlasInvalidOrganizationIdError(MongoDBAtlasBaseException):
"""Exception for MongoDB Atlas invalid organization ID errors"""
def __init__(self, file=None, original_exception=None, message=None):
super().__init__(
code=8006,
file=file,
original_exception=original_exception,
message=message,
)

View File

@@ -17,7 +17,6 @@ from prowler.providers.mongodbatlas.exceptions.exceptions import (
MongoDBAtlasAuthenticationError,
MongoDBAtlasCredentialsError,
MongoDBAtlasIdentityError,
MongoDBAtlasInvalidOrganizationIdError,
MongoDBAtlasSessionError,
)
from prowler.providers.mongodbatlas.lib.mutelist.mutelist import MongoDBAtlasMutelist
@@ -315,17 +314,10 @@ class MongodbatlasProvider(Provider):
atlas_private_key=atlas_private_key,
)
identity = MongodbatlasProvider.setup_identity(session)
if provider_id and identity.organization_id != provider_id:
raise MongoDBAtlasInvalidOrganizationIdError(
file=os.path.basename(__file__),
message=f"The provided credentials do not have access to the organization with the provided ID: {provider_id}",
)
MongodbatlasProvider.setup_identity(session)
return Connection(is_connected=True)
except MongoDBAtlasInvalidOrganizationIdError:
raise
except Exception as error:
logger.critical(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"

View File

@@ -1,35 +1,35 @@
{
"Provider": "oraclecloud",
"CheckID": "audit_log_retention_period_365_days",
"CheckTitle": "Tenancy audit log retention period is 365 days or greater",
"CheckType": [],
"CheckTitle": "Ensure audit log retention period is set to 365 days or greater",
"CheckType": [
"Software and Configuration Checks",
"Industry and Regulatory Standards",
"CIS OCI Foundations Benchmark"
],
"ServiceName": "audit",
"SubServiceName": "",
"ResourceIdTemplate": "",
"ResourceIdTemplate": "oci:audit:configuration",
"Severity": "medium",
"ResourceType": "Compartment",
"Description": "**OCI Audit configuration** defines tenancy-wide log retention for audit events. The finding evaluates whether the retention period (days) is `>= 365` and that an audit configuration exists, *applying across all regions and compartments*.",
"Risk": "**Insufficient audit retention** or missing configuration shrinks the **detection window** and breaks **accountability**.\n\nEvidence for older actions may be unavailable, enabling attackers to evade detection, mask **data exfiltration**, and impede **forensic reconstruction** and compliance reporting.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.oracle.com/en-us/iaas/Content/Audit/Tasks/settingretentionperiod.htm",
"https://docs.oracle.com/en-us/iaas/tools/terraform-provider-oci/4.88.1/docs/r/audit_configuration.html"
],
"ResourceType": "OciAudit",
"Description": "Ensure audit log retention period is set to 365 days or greater",
"Risk": "Inadequate audit logging increases risk of undetected security incidents.",
"RelatedUrl": "https://docs.oracle.com/en-us/iaas/Content/Audit/Tasks/settingretentionperiod.htm",
"Remediation": {
"Code": {
"CLI": "oci audit configuration update --compartment-id <tenancy-ocid> --retention-period-days 365",
"NativeIaC": "",
"Other": "1. Open the OCI Console and go to Governance & Administration > Audit\n2. Click Configuration\n3. Set Retention period (days) to 365\n4. Click Save",
"Terraform": "```hcl\nresource \"oci_audit_configuration\" \"<example_resource_name>\" {\n compartment_id = var.tenancy_ocid\n retention_period_days = 365 # Critical: sets audit log retention to 365 days to pass the check\n}\n```"
"Other": "1. Navigate to Governance > Audit\n2. Click Configuration\n3. Set retention period to 365 days or greater\n4. Save changes",
"Terraform": "resource \"oci_audit_configuration\" \"example\" {\n compartment_id = var.tenancy_ocid\n retention_period_days = 365\n}"
},
"Recommendation": {
"Text": "Set audit retention to `>= 365` days at the tenancy level and protect the setting with **least privilege** and **separation of duties**.\n\nAdopt **defense in depth**: export audit logs to centralized, immutable storage or a SIEM for extended retention, integrity, and continuous monitoring.",
"Url": "https://hub.prowler.com/check/audit_log_retention_period_365_days"
"Text": "Ensure audit log retention period is set to 365 days or greater",
"Url": "https://hub.prowler.com/check/oci/audit_log_retention_period_365_days"
}
},
"Categories": [
"logging",
"forensics-ready"
"security-configuration"
],
"DependsOn": [],
"RelatedTo": [],

View File

@@ -1,33 +1,35 @@
{
"Provider": "oraclecloud",
"CheckID": "blockstorage_block_volume_encrypted_with_cmk",
"CheckTitle": "Block volume is encrypted with a Customer Managed Key (CMK)",
"CheckType": [],
"CheckTitle": "Ensure Block Volumes are encrypted with Customer Managed Keys",
"CheckType": [
"Software and Configuration Checks",
"Industry and Regulatory Standards",
"CIS OCI Foundations Benchmark"
],
"ServiceName": "blockstorage",
"SubServiceName": "",
"ResourceIdTemplate": "",
"ResourceIdTemplate": "oci:blockstorage:volume",
"Severity": "medium",
"ResourceType": "Volume",
"Description": "**OCI block volumes** use **Customer-Managed Keys** (`CMK`) from Vault for at-rest encryption instead of Oracle-managed keys.\n\nIdentifies whether a block volume has a customer-managed key associated for its encryption.",
"Risk": "Without **CMK**, encryption key control is limited, impacting confidentiality and auditability:\n- No rapid key disable/rotation to contain breaches\n- Weaker restrictions and visibility on decrypt operations\nThis can prolong unauthorized data access and hinder incident response and compliance.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.oracle.com/en-us/iaas/Content/Block/Concepts/overview.htm"
],
"ResourceType": "OciBlockVolume",
"Description": "Block volumes should be encrypted with Customer Managed Keys (CMK) for enhanced security and control over encryption keys.",
"Risk": "Using Oracle-managed encryption keys instead of Customer Managed Keys reduces control over encryption key lifecycle and access policies.",
"RelatedUrl": "https://docs.oracle.com/en-us/iaas/Content/Block/Concepts/overview.htm",
"Remediation": {
"Code": {
"CLI": "oci bv volume update --volume-id <VOLUME_OCID> --kms-key-id <KMS_KEY_OCID>",
"CLI": "oci bv volume create --compartment-id <compartment-ocid> --availability-domain <ad> --kms-key-id <kms-key-ocid>",
"NativeIaC": "",
"Other": "1. In the OCI Console, go to Block Storage > Block Volumes\n2. Open the failing volume\n3. Click Edit\n4. Under Encryption, select \"Encrypt using customer-managed keys\" and choose the vault key\n5. Click Save changes",
"Terraform": "```hcl\nresource \"oci_core_volume\" \"<example_resource_name>\" {\n compartment_id = \"<example_resource_id>\"\n availability_domain = \"<example_resource_name>\"\n size_in_gbs = 50\n\n kms_key_id = \"<example_resource_id>\" # Critical: uses a Customer Managed Key to encrypt the volume\n}\n```"
"Other": "1. Navigate to Block Storage > Block Volumes\n2. Create a new volume or update existing\n3. Under 'Encryption', select 'Encrypt using customer-managed keys'\n4. Select the KMS vault and key\n5. Click 'Create' or 'Save Changes'",
"Terraform": "resource \"oci_core_volume\" \"example\" {\n compartment_id = var.compartment_id\n availability_domain = var.availability_domain\n kms_key_id = var.kms_key_id\n}"
},
"Recommendation": {
"Text": "Use **Customer-Managed Keys** in Vault for all block volumes.\n- Enforce least privilege and separation of duties on key usage\n- Rotate keys regularly and monitor KMS events\n- Validate that key disable/deny revokes data access\nApply the same controls to snapshots and backups.",
"Url": "https://hub.prowler.com/check/blockstorage_block_volume_encrypted_with_cmk"
"Text": "Encrypt all block volumes with Customer Managed Keys for better security control.",
"Url": "https://hub.prowler.com/check/oci/blockstorage_block_volume_encrypted_with_cmk"
}
},
"Categories": [
"encryption"
"encryption",
"storage"
],
"DependsOn": [],
"RelatedTo": [],

View File

@@ -1,34 +1,34 @@
{
"Provider": "oraclecloud",
"CheckID": "blockstorage_boot_volume_encrypted_with_cmk",
"CheckTitle": "Boot volume is encrypted with Customer Managed Key",
"CheckType": [],
"CheckTitle": "Ensure Boot Volumes are encrypted with Customer Managed Key",
"CheckType": [
"Software and Configuration Checks",
"Industry and Regulatory Standards",
"CIS OCI Foundations Benchmark"
],
"ServiceName": "blockstorage",
"SubServiceName": "",
"ResourceIdTemplate": "",
"ResourceIdTemplate": "oci:blockstorage:resource",
"Severity": "medium",
"ResourceType": "BootVolume",
"Description": "Boot volumes use **customer-managed keys (CMEK)** when a Vault key is assigned (`kms_key_id` present), rather than default Oracle-managed encryption.",
"Risk": "Without **CMEK**, control over encryption is limited: you cannot rapidly disable or rotate keys to contain compromise, weakening **confidentiality** of boot data and backups. Provider-managed keys reduce **separation of duties** and **auditability**, hindering incident response and compliance for sensitive systems.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://www.trendmicro.com/cloudoneconformity/knowledge-base/oci/OCI-BlockVolume/block-volumes-encrypted-with-cmks.html",
"https://docs.public.content.oci.oraclecloud.com/en-us/iaas/Content/Block/Concepts/managingblockencryptionkeys.htm"
],
"ResourceType": "OciBlockstorageResource",
"Description": "Boot volumes should be encrypted with Customer Managed Keys (CMK) for enhanced security and control over encryption keys.",
"Risk": "Not meeting this requirement increases security risk.",
"RelatedUrl": "https://docs.oracle.com/en-us/iaas/",
"Remediation": {
"Code": {
"CLI": "oci bv boot-volume update --boot-volume-id <example_resource_id> --kms-key-id <example_resource_id>",
"CLI": "",
"NativeIaC": "",
"Other": "1. In the OCI Console, go to Storage > Block Storage > Boot Volumes\n2. Click the boot volume name\n3. Click Edit (or Assign master encryption key)\n4. Select a Customer-managed key from Vault\n5. Click Save",
"Terraform": "```hcl\nresource \"oci_core_boot_volume_kms_key\" \"<example_resource_name>\" {\n boot_volume_id = \"<example_resource_id>\" # Critical: target boot volume to update\n kms_key_id = \"<example_resource_id>\" # Critical: assigns a Customer Managed Key (CMK) to the boot volume\n}\n```"
"Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/oci/OCI-BlockVolume/block-volumes-encrypted-with-cmks.html",
"Terraform": ""
},
"Recommendation": {
"Text": "Encrypt boot volumes with **customer-managed keys** and enforce **least privilege** on key usage. Define a key lifecycle (new keys for rotation), monitor and audit key access, and restrict key scope to required compartments and services to achieve **defense in depth** and rapid revocation when needed.",
"Url": "https://hub.prowler.com/check/blockstorage_boot_volume_encrypted_with_cmk"
"Text": "Ensure Boot Volumes are encrypted with Customer Managed Key",
"Url": "https://hub.prowler.com/check/oci/blockstorage_boot_volume_encrypted_with_cmk"
}
},
"Categories": [
"encryption"
"security-configuration"
],
"DependsOn": [],
"RelatedTo": [],

View File

@@ -1,33 +1,34 @@
{
"Provider": "oraclecloud",
"CheckID": "cloudguard_enabled",
"CheckTitle": "Cloud Guard is enabled in the root compartment of the tenancy",
"CheckType": [],
"CheckTitle": "Ensure Cloud Guard is enabled in the root compartment of the tenancy",
"CheckType": [
"Software and Configuration Checks",
"Industry and Regulatory Standards",
"CIS OCI Foundations Benchmark"
],
"ServiceName": "cloudguard",
"SubServiceName": "",
"ResourceIdTemplate": "",
"ResourceIdTemplate": "oci:cloudguard:configuration",
"Severity": "high",
"ResourceType": "Compartment",
"Description": "**OCI Cloud Guard** status in the tenancy's root compartment is evaluated, expecting `ENABLED` to indicate the service is active for organization-wide detection and response.",
"Risk": "Without **Cloud Guard** at the root, signals across compartments can be missed, allowing misconfigurations and malicious activity to persist. This undermines confidentiality (undetected data access), integrity (unauthorized changes), and availability (ongoing abuse without automated response).",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.oracle.com/en-us/iaas/cloud-guard/home.htm"
],
"ResourceType": "OciCloudGuard",
"Description": "Ensure Cloud Guard is enabled in the root compartment of the tenancy",
"Risk": "Without Cloud Guard, security threats may not be detected and remediated.",
"RelatedUrl": "https://docs.oracle.com/en-us/iaas/cloud-guard/home.htm",
"Remediation": {
"Code": {
"CLI": "oci cloud-guard cloud-guard-configuration update --compartment-id <tenancy-ocid> --status ENABLED --reporting-region <region>",
"CLI": "oci cloud-guard configuration update --compartment-id <tenancy-ocid> --status ENABLED --reporting-region <region>",
"NativeIaC": "",
"Other": "1. In the OCI Console, go to Security > Cloud Guard\n2. Ensure the root compartment is selected\n3. Click Enable Cloud Guard\n4. Choose a Reporting region\n5. Click Enable",
"Terraform": "```hcl\nresource \"oci_cloud_guard_cloud_guard_configuration\" \"<example_resource_name>\" {\n compartment_id = var.tenancy_ocid\n reporting_region = var.region\n status = \"ENABLED\" # Critical: Turns on Cloud Guard in the root compartment\n}\n```"
"Other": "1. Navigate to Security > Cloud Guard\n2. Enable Cloud Guard\n3. Select reporting region\n4. Configure detectors and responders",
"Terraform": "resource \"oci_cloud_guard_cloud_guard_configuration\" \"example\" {\n compartment_id = var.tenancy_ocid\n reporting_region = var.region\n status = \"ENABLED\"\n}"
},
"Recommendation": {
"Text": "Enable **Cloud Guard** at the tenancy root to centralize monitoring and automated response. Apply **defense in depth** by using detectors/responders, integrate alerts with monitoring, and enforce **least privilege** for its roles. Regularly tune policies and review findings to prevent blind spots.",
"Url": "https://hub.prowler.com/check/cloudguard_enabled"
"Text": "Ensure Cloud Guard is enabled in the root compartment of the tenancy",
"Url": "https://hub.prowler.com/check/oci/cloudguard_enabled"
}
},
"Categories": [
"forensics-ready"
"monitoring"
],
"DependsOn": [],
"RelatedTo": [],

View File

@@ -39,7 +39,6 @@ class TestGitHubProvider:
oauth_app_token = None
github_app_id = None
github_app_key = None
github_app_key_path = None
fixer_config = load_and_validate_config_file(
"github", default_fixer_config_file_path
)
@@ -63,7 +62,6 @@ class TestGitHubProvider:
oauth_app_token,
github_app_id,
github_app_key,
github_app_key_path,
)
assert provider._type == "github"
@@ -82,7 +80,7 @@ class TestGitHubProvider:
personal_access_token = None
oauth_app_token = OAUTH_TOKEN
github_app_id = None
github_app_key_path = None
github_app_key = None
fixer_config = load_and_validate_config_file(
"github", default_fixer_config_file_path
)
@@ -105,7 +103,7 @@ class TestGitHubProvider:
personal_access_token,
oauth_app_token,
github_app_id,
github_app_key_path,
github_app_key,
)
assert provider._type == "github"
@@ -120,13 +118,11 @@ class TestGitHubProvider:
}
assert provider._fixer_config == fixer_config
def test_github_provider_App_with_key_path(self):
def test_github_provider_App(self):
personal_access_token = None
oauth_app_token = None
github_app_id = APP_ID
github_app_key = None
github_app_key_path = APP_KEY
github_app_key_content = None
github_app_key = APP_KEY
fixer_config = load_and_validate_config_file(
"github", default_fixer_config_file_path
)
@@ -148,10 +144,8 @@ class TestGitHubProvider:
provider = GithubProvider(
personal_access_token,
oauth_app_token,
github_app_key,
github_app_key_path,
github_app_key_content,
github_app_id,
github_app_key,
)
assert provider._type == "github"
@@ -164,104 +158,6 @@ class TestGitHubProvider:
}
assert provider._fixer_config == fixer_config
def test_github_provider_App_with_key_content(self):
personal_access_token = None
oauth_app_token = None
github_app_id = APP_ID
github_app_key = (
"-----BEGIN RSA PRIVATE KEY-----\ntest-key\n-----END RSA PRIVATE KEY-----"
)
github_app_key_path = None
github_app_key_content = None
fixer_config = load_and_validate_config_file(
"github", default_fixer_config_file_path
)
with (
patch(
"prowler.providers.github.github_provider.GithubProvider.setup_session",
return_value=GithubSession(token="", id=APP_ID, key=github_app_key),
),
patch(
"prowler.providers.github.github_provider.GithubProvider.setup_identity",
return_value=GithubAppIdentityInfo(
app_id=APP_ID,
app_name=APP_NAME,
installations=["test-org"],
),
),
):
provider = GithubProvider(
personal_access_token,
oauth_app_token,
github_app_key,
github_app_key_path,
github_app_key_content,
github_app_id,
)
assert provider._type == "github"
assert provider.session == GithubSession(
token="", id=APP_ID, key=github_app_key
)
assert provider.identity == GithubAppIdentityInfo(
app_id=APP_ID, app_name=APP_NAME, installations=["test-org"]
)
assert provider._audit_config == {
"inactive_not_archived_days_threshold": 180,
}
assert provider._fixer_config == fixer_config
def test_github_provider_App_with_legacy_key_content(self):
personal_access_token = None
oauth_app_token = None
github_app_id = APP_ID
github_app_key = None
github_app_key_path = None
github_app_key_content = (
"-----BEGIN RSA PRIVATE KEY-----\ntest-key\n-----END RSA PRIVATE KEY-----"
)
fixer_config = load_and_validate_config_file(
"github", default_fixer_config_file_path
)
with (
patch(
"prowler.providers.github.github_provider.GithubProvider.setup_session",
return_value=GithubSession(
token="", id=APP_ID, key=github_app_key_content
),
),
patch(
"prowler.providers.github.github_provider.GithubProvider.setup_identity",
return_value=GithubAppIdentityInfo(
app_id=APP_ID,
app_name=APP_NAME,
installations=["test-org"],
),
),
):
provider = GithubProvider(
personal_access_token,
oauth_app_token,
github_app_key,
github_app_key_path,
github_app_key_content,
github_app_id,
)
assert provider._type == "github"
assert provider.session == GithubSession(
token="", id=APP_ID, key=github_app_key_content
)
assert provider.identity == GithubAppIdentityInfo(
app_id=APP_ID, app_name=APP_NAME, installations=["test-org"]
)
assert provider._audit_config == {
"inactive_not_archived_days_threshold": 180,
}
assert provider._fixer_config == fixer_config
def test_test_connection_with_personal_access_token_success(self):
"""Test successful connection with personal access token."""
with (
@@ -306,8 +202,8 @@ class TestGitHubProvider:
assert connection.is_connected is True
assert connection.error is None
def test_test_connection_with_github_app_key_path_success(self):
"""Test successful connection with GitHub App key path."""
def test_test_connection_with_github_app_success(self):
"""Test successful connection with GitHub App credentials."""
with (
patch(
"prowler.providers.github.github_provider.GithubProvider.setup_session",
@@ -321,57 +217,7 @@ class TestGitHubProvider:
),
):
connection = GithubProvider.test_connection(
github_app_id=APP_ID, github_app_key_path=APP_KEY
)
assert isinstance(connection, Connection)
assert connection.is_connected is True
assert connection.error is None
def test_test_connection_with_github_app_key_content_success(self):
"""Test successful connection with GitHub App key content."""
key_content = (
"-----BEGIN RSA PRIVATE KEY-----\ntest-key\n-----END RSA PRIVATE KEY-----"
)
with (
patch(
"prowler.providers.github.github_provider.GithubProvider.setup_session",
return_value=GithubSession(token="", id=APP_ID, key=key_content),
),
patch(
"prowler.providers.github.github_provider.GithubProvider.setup_identity",
return_value=GithubAppIdentityInfo(
app_id=APP_ID, app_name=APP_NAME, installations=["test-org"]
),
),
):
connection = GithubProvider.test_connection(
github_app_id=APP_ID, github_app_key=key_content
)
assert isinstance(connection, Connection)
assert connection.is_connected is True
assert connection.error is None
def test_test_connection_with_github_app_legacy_key_content_success(self):
"""Test successful connection with GitHub App legacy key content."""
key_content = (
"-----BEGIN RSA PRIVATE KEY-----\ntest-key\n-----END RSA PRIVATE KEY-----"
)
with (
patch(
"prowler.providers.github.github_provider.GithubProvider.setup_session",
return_value=GithubSession(token="", id=APP_ID, key=key_content),
),
patch(
"prowler.providers.github.github_provider.GithubProvider.setup_identity",
return_value=GithubAppIdentityInfo(
app_id=APP_ID, app_name=APP_NAME, installations=["test-org"]
),
),
):
connection = GithubProvider.test_connection(
github_app_id=APP_ID, github_app_key_content=key_content
github_app_id=APP_ID, github_app_key=APP_KEY
)
assert isinstance(connection, Connection)
@@ -433,7 +279,7 @@ class TestGitHubProvider:
):
with pytest.raises(GithubInvalidCredentialsError):
GithubProvider.test_connection(
github_app_id=APP_ID, github_app_key_path="invalid-key"
github_app_id=APP_ID, github_app_key="invalid-key"
)
def test_test_connection_with_invalid_app_credentials_no_raise(self):
@@ -452,7 +298,7 @@ class TestGitHubProvider:
):
connection = GithubProvider.test_connection(
github_app_id=APP_ID,
github_app_key_path="invalid-key",
github_app_key="invalid-key",
raise_on_exception=False,
)
@@ -460,104 +306,6 @@ class TestGitHubProvider:
assert connection.is_connected is False
assert isinstance(connection.error, GithubInvalidCredentialsError)
def test_test_connection_github_app_key_path_with_content_raises_exception(self):
"""Test connection when github_app_key_path receives key content instead of file path."""
key_content = (
"-----BEGIN RSA PRIVATE KEY-----\ntest-key\n-----END RSA PRIVATE KEY-----"
)
with (
patch(
"prowler.providers.github.github_provider.GithubProvider.setup_session",
side_effect=GithubEnvironmentVariableError(
file="github_provider.py",
message="--github-app-key-path expects a file path, not key content. Use --github-app-key for key content instead.",
),
),
patch("prowler.providers.github.github_provider.logger") as mock_logger,
):
with pytest.raises(GithubEnvironmentVariableError) as exc_info:
GithubProvider.test_connection(
github_app_id=APP_ID, github_app_key_path=key_content
)
assert "--github-app-key-path expects a file path" in str(exc_info.value)
mock_logger.critical.assert_called_once()
def test_test_connection_github_app_key_with_file_path_raises_exception(self):
"""Test connection when github_app_key receives file path instead of key content."""
file_path = "/path/to/key.pem"
with (
patch(
"prowler.providers.github.github_provider.GithubProvider.setup_session",
side_effect=GithubEnvironmentVariableError(
file="github_provider.py",
message="--github-app-key expects key content, not a file path. Use --github-app-key-path for file paths instead.",
),
),
patch("prowler.providers.github.github_provider.logger") as mock_logger,
):
with pytest.raises(GithubEnvironmentVariableError) as exc_info:
GithubProvider.test_connection(
github_app_id=APP_ID, github_app_key=file_path
)
assert "--github-app-key expects key content" in str(exc_info.value)
mock_logger.critical.assert_called_once()
def test_test_connection_github_app_key_path_with_content_no_raise(self):
"""Test connection when github_app_key_path receives key content without raising exception."""
key_content = (
"-----BEGIN RSA PRIVATE KEY-----\ntest-key\n-----END RSA PRIVATE KEY-----"
)
with (
patch(
"prowler.providers.github.github_provider.GithubProvider.setup_session",
side_effect=GithubEnvironmentVariableError(
file="github_provider.py",
message="--github-app-key-path expects a file path, not key content. Use --github-app-key for key content instead.",
),
),
patch("prowler.providers.github.github_provider.logger") as mock_logger,
):
connection = GithubProvider.test_connection(
github_app_id=APP_ID,
github_app_key_path=key_content,
raise_on_exception=False,
)
assert isinstance(connection, Connection)
assert connection.is_connected is False
assert isinstance(connection.error, GithubEnvironmentVariableError)
assert "--github-app-key-path expects a file path" in str(connection.error)
mock_logger.critical.assert_called_once()
def test_test_connection_github_app_key_with_file_path_no_raise(self):
"""Test connection when github_app_key receives file path without raising exception."""
file_path = "/path/to/key.pem"
with (
patch(
"prowler.providers.github.github_provider.GithubProvider.setup_session",
side_effect=GithubEnvironmentVariableError(
file="github_provider.py",
message="--github-app-key expects key content, not a file path. Use --github-app-key-path for file paths instead.",
),
),
patch("prowler.providers.github.github_provider.logger") as mock_logger,
):
connection = GithubProvider.test_connection(
github_app_id=APP_ID, github_app_key=file_path, raise_on_exception=False
)
assert isinstance(connection, Connection)
assert connection.is_connected is False
assert isinstance(connection.error, GithubEnvironmentVariableError)
assert "--github-app-key expects key content" in str(connection.error)
mock_logger.critical.assert_called_once()
def test_test_connection_setup_session_error_raises_exception(self):
"""Test connection when setup_session raises an exception."""
with (
@@ -790,103 +538,6 @@ class TestGitHubProvider:
assert connection.is_connected is False
assert isinstance(connection.error, GithubInvalidProviderIdError)
def test_setup_session_with_github_app_key_path_env_var(self):
"""Test setup_session with GITHUB_APP_KEY_PATH environment variable."""
import os
import tempfile
key_content = (
"-----BEGIN RSA PRIVATE KEY-----\ntest-key\n-----END RSA PRIVATE KEY-----"
)
with tempfile.NamedTemporaryFile(mode="w", suffix=".pem", delete=False) as f:
f.write(key_content)
temp_path = f.name
try:
with patch.dict(
os.environ,
{"GITHUB_APP_ID": str(APP_ID), "GITHUB_APP_KEY_PATH": temp_path},
):
# Clear other env vars that might interfere
for key in [
"GITHUB_PERSONAL_ACCESS_TOKEN",
"GITHUB_OAUTH_APP_TOKEN",
"GITHUB_APP_KEY",
]:
if key in os.environ:
del os.environ[key]
session = GithubProvider.setup_session()
assert session.id == str(APP_ID)
assert session.key == key_content
assert session.token == ""
finally:
os.unlink(temp_path)
def test_setup_session_with_github_app_key_env_var_content(self):
"""Test setup_session with GITHUB_APP_KEY environment variable containing key content."""
import os
key_content = (
"-----BEGIN RSA PRIVATE KEY-----\ntest-key\n-----END RSA PRIVATE KEY-----"
)
with patch.dict(
os.environ, {"GITHUB_APP_ID": str(APP_ID), "GITHUB_APP_KEY": key_content}
):
# Clear other env vars that might interfere
for key in [
"GITHUB_PERSONAL_ACCESS_TOKEN",
"GITHUB_OAUTH_APP_TOKEN",
"GITHUB_APP_KEY_PATH",
]:
if key in os.environ:
del os.environ[key]
session = GithubProvider.setup_session()
assert session.id == str(APP_ID)
assert session.key == key_content
assert session.token == ""
def test_setup_session_with_github_app_key_env_var_file_path(self):
"""Test setup_session with GITHUB_APP_KEY environment variable containing file path should raise error."""
import os
import tempfile
key_content = (
"-----BEGIN RSA PRIVATE KEY-----\ntest-key\n-----END RSA PRIVATE KEY-----"
)
with tempfile.NamedTemporaryFile(mode="w", suffix=".pem", delete=False) as f:
f.write(key_content)
temp_path = f.name
try:
with patch.dict(
os.environ, {"GITHUB_APP_ID": str(APP_ID), "GITHUB_APP_KEY": temp_path}
):
# Clear other env vars that might interfere
for key in [
"GITHUB_PERSONAL_ACCESS_TOKEN",
"GITHUB_OAUTH_APP_TOKEN",
"GITHUB_APP_KEY_PATH",
]:
if key in os.environ:
del os.environ[key]
with pytest.raises(GithubSetUpSessionError) as exc_info:
GithubProvider.setup_session()
assert "GITHUB_APP_KEY must contain RSA key content" in str(
exc_info.value
)
assert "Use GITHUB_APP_KEY_PATH for file paths" in str(exc_info.value)
finally:
os.unlink(temp_path)
def test_validate_provider_id_with_valid_user(self):
"""Test validate_provider_id with valid user (matches authenticated user)."""
mock_session = GithubSession(token=PAT_TOKEN, id="", key="")

View File

@@ -61,23 +61,17 @@ class Test_GitHubArguments:
arguments.init_parser(mock_github_args)
# Verify authentication arguments were added
assert self.mock_auth_group.add_argument.call_count == 5
assert self.mock_auth_group.add_argument.call_count == 4
# Check that all authentication arguments are present
calls = self.mock_auth_group.add_argument.call_args_list
auth_args = []
for call in calls:
# Handle both single arguments and aliases
if len(call[0]) > 1:
auth_args.extend(call[0])
else:
auth_args.append(call[0][0])
auth_args = [call[0][0] for call in calls]
assert "--personal-access-token" in auth_args
assert "--oauth-app-token" in auth_args
assert "--github-app-id" in auth_args
assert "--github-app-key" in auth_args
assert "--github-app-key-path" in auth_args
# Check for either form of the github app key argument
assert any("--github-app-key" in arg for arg in auth_args)
def test_init_parser_adds_scoping_arguments(self):
"""Test that init_parser adds all scoping arguments"""
@@ -309,81 +303,3 @@ class Test_GitHubArguments_Integration:
assert args.personal_access_token == "test-token"
assert args.repository == []
assert args.organization == []
class Test_GitHubArguments_Validation:
def test_validate_arguments_both_key_methods_provided(self):
"""Test validation fails when both key methods are provided"""
from argparse import Namespace
args = Namespace()
args.github_app_key = "key-content"
args.github_app_key_path = "/path/to/key.pem"
args.github_app_id = "12345"
is_valid, error_msg = arguments.validate_arguments(args)
assert not is_valid
assert (
"Cannot specify both --github-app-key and --github-app-key-path simultaneously"
in error_msg
)
def test_validate_arguments_app_id_without_key(self):
"""Test validation fails when app ID is provided without any key"""
from argparse import Namespace
args = Namespace()
args.github_app_key = None
args.github_app_key_path = None
args.github_app_id = "12345"
is_valid, error_msg = arguments.validate_arguments(args)
assert not is_valid
assert (
"GitHub App ID requires either --github-app-key or --github-app-key-path"
in error_msg
)
def test_validate_arguments_valid_key_content(self):
"""Test validation passes with valid key content"""
from argparse import Namespace
args = Namespace()
args.github_app_key = "-----BEGIN RSA PRIVATE KEY-----"
args.github_app_key_path = None
args.github_app_id = "12345"
is_valid, error_msg = arguments.validate_arguments(args)
assert is_valid
assert error_msg == ""
def test_validate_arguments_valid_key_path(self):
"""Test validation passes with valid key path"""
from argparse import Namespace
args = Namespace()
args.github_app_key = None
args.github_app_key_path = "/path/to/key.pem"
args.github_app_id = "12345"
is_valid, error_msg = arguments.validate_arguments(args)
assert is_valid
assert error_msg == ""
def test_validate_arguments_no_app_auth(self):
"""Test validation passes when no app authentication is used"""
from argparse import Namespace
args = Namespace()
args.github_app_key = None
args.github_app_key_path = None
args.github_app_id = None
is_valid, error_msg = arguments.validate_arguments(args)
assert is_valid
assert error_msg == ""

View File

@@ -7,7 +7,6 @@ from prowler.providers.mongodbatlas.exceptions.exceptions import (
MongoDBAtlasAuthenticationError,
MongoDBAtlasCredentialsError,
MongoDBAtlasIdentityError,
MongoDBAtlasInvalidOrganizationIdError,
)
from prowler.providers.mongodbatlas.models import (
MongoDBAtlasIdentityInfo,
@@ -175,93 +174,6 @@ class TestMongodbatlasProvider:
assert connection.is_connected is False
assert connection.error is not None
def test_test_connection_with_matching_provider_id(self):
"""Test connection test with matching provider_id"""
with (
patch(
"prowler.providers.mongodbatlas.mongodbatlas_provider.MongodbatlasProvider.setup_session",
return_value=MongoDBAtlasSession(
public_key=ATLAS_PUBLIC_KEY,
private_key=ATLAS_PRIVATE_KEY,
),
),
patch(
"prowler.providers.mongodbatlas.mongodbatlas_provider.MongodbatlasProvider.setup_identity",
return_value=MongoDBAtlasIdentityInfo(
organization_id=ORGANIZATION_ID,
organization_name=ORGANIZATION_NAME,
roles=["ORGANIZATION_ADMIN"],
),
),
):
connection = MongodbatlasProvider.test_connection(
atlas_public_key=ATLAS_PUBLIC_KEY,
atlas_private_key=ATLAS_PRIVATE_KEY,
provider_id=ORGANIZATION_ID,
)
assert connection.is_connected is True
def test_test_connection_with_mismatched_provider_id(self):
"""Test connection test with mismatched provider_id raises error"""
different_org_id = "different_org_id"
with (
patch(
"prowler.providers.mongodbatlas.mongodbatlas_provider.MongodbatlasProvider.setup_session",
return_value=MongoDBAtlasSession(
public_key=ATLAS_PUBLIC_KEY,
private_key=ATLAS_PRIVATE_KEY,
),
),
patch(
"prowler.providers.mongodbatlas.mongodbatlas_provider.MongodbatlasProvider.setup_identity",
return_value=MongoDBAtlasIdentityInfo(
organization_id=ORGANIZATION_ID,
organization_name=ORGANIZATION_NAME,
roles=["ORGANIZATION_ADMIN"],
),
),
):
with pytest.raises(MongoDBAtlasInvalidOrganizationIdError) as exc_info:
MongodbatlasProvider.test_connection(
atlas_public_key=ATLAS_PUBLIC_KEY,
atlas_private_key=ATLAS_PRIVATE_KEY,
provider_id=different_org_id,
)
assert different_org_id in str(exc_info.value)
def test_test_connection_with_mismatched_provider_id_no_raise(self):
"""Test connection test with mismatched provider_id always raises error regardless of raise_on_exception"""
different_org_id = "different_org_id"
with (
patch(
"prowler.providers.mongodbatlas.mongodbatlas_provider.MongodbatlasProvider.setup_session",
return_value=MongoDBAtlasSession(
public_key=ATLAS_PUBLIC_KEY,
private_key=ATLAS_PRIVATE_KEY,
),
),
patch(
"prowler.providers.mongodbatlas.mongodbatlas_provider.MongodbatlasProvider.setup_identity",
return_value=MongoDBAtlasIdentityInfo(
organization_id=ORGANIZATION_ID,
organization_name=ORGANIZATION_NAME,
roles=["ORGANIZATION_ADMIN"],
),
),
):
# MongoDBAtlasInvalidOrganizationIdError is always raised regardless of raise_on_exception
with pytest.raises(MongoDBAtlasInvalidOrganizationIdError) as exc_info:
MongodbatlasProvider.test_connection(
atlas_public_key=ATLAS_PUBLIC_KEY,
atlas_private_key=ATLAS_PRIVATE_KEY,
provider_id=different_org_id,
raise_on_exception=False,
)
assert different_org_id in str(exc_info.value)
def test_provider_properties(self):
"""Test provider properties"""
with (

View File

@@ -64,8 +64,8 @@ You are a code reviewer for the Prowler UI project. Analyze the code changes (gi
**RULES TO CHECK:**
1. React Imports: NO `import * as React` or `import React, {` → Use `import { useState }`
2. TypeScript: NO union types like `type X = "a" | "b"` → Use const-based: `const X = {...} as const`
3. Tailwind: NO `var()` or hex colors in className → Use Tailwind utilities and semantic color classes (e.g., `bg-bg-neutral-tertiary`, `border-border-neutral-primary`)
4. cn(): Use for merging multiple classes or for conditionals (handles Tailwind conflicts with twMerge) → `cn(BUTTON_STYLES.base, BUTTON_STYLES.active, isLoading && "opacity-50")`
3. Tailwind: NO `var()` or hex colors in className → Use `className="bg-card-bg"`
4. cn(): ONLY for conditionals → NOT for static classes
5. React 19: NO `useMemo`/`useCallback` without reason
6. Zod v4: Use `.min(1)` not `.nonempty()`, `z.email()` not `z.string().email()`. All inputs must be validated with Zod.
7. File Org: 1 feature = local, 2+ features = shared
@@ -132,20 +132,3 @@ else
echo -e "${YELLOW}⏭️ Code review disabled (CODE_REVIEW_ENABLED=false)${NC}"
echo ""
fi
# Run healthcheck (typecheck and lint check)
echo -e "${BLUE}🏥 Running healthcheck...${NC}"
echo ""
cd ui || cd .
if npm run healthcheck; then
echo ""
echo -e "${GREEN}✅ Healthcheck passed${NC}"
echo ""
else
echo ""
echo -e "${RED}❌ Healthcheck failed${NC}"
echo -e "${RED}Fix type errors and linting issues before committing${NC}"
echo ""
exit 1
fi

File diff suppressed because it is too large Load Diff

View File

@@ -14,7 +14,6 @@ All notable changes to the **Prowler UI** are documented in this file.
### 🔄 Changed
- Resource ID moved up in the findings detail page [(#9141)](https://github.com/prowler-cloud/prowler/pull/9141)
- C5 compliance logo [(#9224)](https://github.com/prowler-cloud/prowler/pull/9224)
---

View File

@@ -120,14 +120,14 @@ export const getThreatScore = async ({
filters = {},
}: {
filters?: Record<string, string | string[] | undefined>;
} = {}) => {
}) => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/overviews/threat-score`);
const url = new URL(`${apiBaseUrl}/overviews/threatscore`);
// Handle multiple filters
Object.entries(filters).forEach(([key, value]) => {
if (key !== "filter[search]") {
if (value !== undefined && value !== null && value !== "") {
url.searchParams.append(key, String(value));
}
});
@@ -139,7 +139,7 @@ export const getThreatScore = async ({
return handleApiResponse(response);
} catch (error) {
console.error("Error fetching threat score:", error);
console.error("Error fetching threat score overview:", error);
return undefined;
}
};

View File

@@ -1,186 +0,0 @@
"use server";
const TIME_RANGE_OPTIONS = {
ONE_DAY: { value: "1D", days: 1 },
FIVE_DAYS: { value: "5D", days: 5 },
ONE_WEEK: { value: "1W", days: 7 },
ONE_MONTH: { value: "1M", days: 30 },
} as const;
type TimeRange =
(typeof TIME_RANGE_OPTIONS)[keyof typeof TIME_RANGE_OPTIONS]["value"];
const getFindingsSeverityTrends = async ({
filters = {},
}: {
filters?: Record<string, string | string[] | undefined>;
} = {}) => {
// TODO: Replace with actual API call when endpoint is available
// const headers = await getAuthHeaders({ contentType: false });
// const url = new URL(`${apiBaseUrl}/findings/severity/time-series`);
// Object.entries(filters).forEach(([key, value]) => {
// if (value) url.searchParams.append(key, String(value));
// });
// const response = await fetch(url.toString(), { headers });
// return handleApiResponse(response);
// Extract date range from filters to simulate different data based on selection
const startDateStr = filters["filter[inserted_at__gte]"] as
| string
| undefined;
const endDateStr = filters["filter[inserted_at__lte]"] as string | undefined;
// Generate mock data based on the date range
let mockData;
if (startDateStr && endDateStr) {
const startDate = new Date(startDateStr);
const endDate = new Date(endDateStr);
const daysDiff = Math.ceil(
(endDate.getTime() - startDate.getTime()) / (24 * 60 * 60 * 1000),
);
// Generate data points for each day in the range
const dataPoints = [];
for (let i = 0; i <= daysDiff; i++) {
const currentDate = new Date(startDate);
currentDate.setDate(currentDate.getDate() + i);
const dateStr = currentDate.toISOString().split("T")[0];
// Vary the data based on the day for visual difference
const dayOffset = i;
dataPoints.push({
type: "severity-time-series",
id: dateStr,
date: `${dateStr}T00:00:00Z`,
informational: Math.max(0, 380 + dayOffset * 15),
low: Math.max(0, 720 + dayOffset * 20),
medium: Math.max(0, 550 + dayOffset * 10),
high: Math.max(0, 1000 - dayOffset * 5),
critical: Math.max(0, 1200 - dayOffset * 30),
muted: Math.max(0, 500 - dayOffset * 25),
});
}
mockData = {
data: dataPoints,
links: {
self: `https://api.prowler.com/api/v1/findings/severity/time-series?start=${startDateStr}&end=${endDateStr}`,
},
meta: {
date_range: `${startDateStr} to ${endDateStr}`,
days: daysDiff,
granularity: "daily",
timezone: "UTC",
},
};
} else {
// Default 5-day data if no date range provided
mockData = {
data: [
{
type: "severity-time-series",
id: "2025-10-26",
date: "2025-10-26T00:00:00Z",
informational: 420,
low: 950,
medium: 720,
high: 1150,
critical: 1350,
muted: 600,
},
{
type: "severity-time-series",
id: "2025-10-27",
date: "2025-10-27T00:00:00Z",
informational: 450,
low: 1100,
medium: 850,
high: 1300,
critical: 1500,
muted: 700,
},
{
type: "severity-time-series",
id: "2025-10-28",
date: "2025-10-28T00:00:00Z",
informational: 400,
low: 850,
medium: 650,
high: 1200,
critical: 2000,
muted: 750,
},
{
type: "severity-time-series",
id: "2025-10-29",
date: "2025-10-29T00:00:00Z",
informational: 380,
low: 720,
medium: 550,
high: 1000,
critical: 1200,
muted: 500,
},
{
type: "severity-time-series",
id: "2025-11-10",
date: "2025-11-10T00:00:00Z",
informational: 500,
low: 750,
medium: 350,
high: 1000,
critical: 550,
muted: 100,
},
],
links: {
self: "https://api.prowler.com/api/v1/findings/severity/time-series?range=5D",
},
meta: {
time_range: "5D",
granularity: "daily",
timezone: "UTC",
},
};
}
return mockData;
};
export const getSeverityTrendsByTimeRange = async ({
timeRange,
filters = {},
}: {
timeRange: TimeRange;
filters?: Record<string, string | string[] | undefined>;
}) => {
// Find the days value from TIME_RANGE_OPTIONS
const timeRangeConfig = Object.values(TIME_RANGE_OPTIONS).find(
(option) => option.value === timeRange,
);
if (!timeRangeConfig) {
throw new Error(`Invalid time range: ${timeRange}`);
}
const endDate = new Date();
const startDate = new Date(
endDate.getTime() - timeRangeConfig.days * 24 * 60 * 60 * 1000,
);
// Format dates as ISO strings for API
const startDateStr = startDate.toISOString().split("T")[0];
const endDateStr = endDate.toISOString().split("T")[0];
// Add date filters to the request
const dateFilters = {
...filters,
"filter[inserted_at__gte]": startDateStr,
"filter[inserted_at__lte]": endDateStr,
};
return getFindingsSeverityTrends({ filters: dateFilters });
};
export { getFindingsSeverityTrends };

View File

@@ -8,7 +8,9 @@ export interface CriticalRequirement {
title: string;
}
export type SectionScores = Record<string, number>;
export interface SectionScores {
[sectionName: string]: number;
}
export interface ThreatScoreSnapshotAttributes {
id: string;
@@ -16,8 +18,8 @@ export interface ThreatScoreSnapshotAttributes {
scan: string | null;
provider: string | null;
compliance_id: string;
overall_score: string;
score_delta: string | null;
overall_score: string; // Decimal as string from API
score_delta: string | null; // Decimal as string from API
section_scores: SectionScores;
critical_requirements: CriticalRequirement[];
total_requirements: number;

View File

@@ -1,49 +0,0 @@
import { getFindingsByStatus } from "@/actions/overview/overview";
import { SearchParamsProps } from "@/types";
import { pickFilterParams } from "../../lib/filter-params";
import { StatusChart } from "../status-chart/status-chart";
export const CheckFindingsSSR = async ({
searchParams,
}: {
searchParams: SearchParamsProps | undefined | null;
}) => {
const filters = pickFilterParams(searchParams);
const findingsByStatus = await getFindingsByStatus({ filters });
if (!findingsByStatus) {
return (
<div className="flex h-[400px] w-full max-w-md items-center justify-center rounded-xl border border-zinc-900 bg-stone-950">
<p className="text-zinc-400">Failed to load findings data</p>
</div>
);
}
const {
fail = 0,
pass = 0,
muted_new = 0,
muted_changed = 0,
fail_new = 0,
pass_new = 0,
} = findingsByStatus?.data?.attributes || {};
const mutedTotal = muted_new + muted_changed;
return (
<StatusChart
failFindingsData={{
total: fail,
new: fail_new,
muted: mutedTotal,
}}
passFindingsData={{
total: pass,
new: pass_new,
muted: mutedTotal,
}}
/>
);
};

View File

@@ -1 +0,0 @@
export { CheckFindingsSSR } from "./check-findings.ssr";

View File

@@ -1,38 +0,0 @@
import { getFindingsSeverityTrends } from "@/actions/overview/severity-trends";
import { SearchParamsProps } from "@/types";
import { pickFilterParams } from "../../lib/filter-params";
import { FindingSeverityOverTime } from "./finding-severity-over-time";
export const FindingSeverityOverTimeDetailSSR = async ({
searchParams,
}: {
searchParams: SearchParamsProps | undefined | null;
}) => {
const filters = pickFilterParams(searchParams);
const severityTrends = await getFindingsSeverityTrends({ filters });
if (
!severityTrends ||
!severityTrends.data ||
severityTrends.data.length === 0
) {
return (
<div className="border-border-neutral-primary bg-bg-neutral-secondary flex h-[400px] w-full items-center justify-center rounded-xl border">
<p className="text-text-neutral-tertiary">
Failed to load severity trends data
</p>
</div>
);
}
return (
<div className="border-border-neutral-primary bg-bg-neutral-secondary overflow-visible rounded-lg border p-4">
<h3 className="text-text-neutral-primary mb-4 text-lg font-semibold">
Finding Severity Over Time
</h3>
<FindingSeverityOverTime data={severityTrends.data} />
</div>
);
};

View File

@@ -1,49 +0,0 @@
import { getFindingsSeverityTrends } from "@/actions/overview/severity-trends";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn";
import { SearchParamsProps } from "@/types";
import { pickFilterParams } from "../../lib/filter-params";
import {
FindingSeverityOverTime,
FindingSeverityOverTimeSkeleton,
} from "./finding-severity-over-time";
export { FindingSeverityOverTimeSkeleton };
export const FindingSeverityOverTimeSSR = async ({
searchParams,
}: {
searchParams: SearchParamsProps | undefined | null;
}) => {
const filters = pickFilterParams(searchParams);
const severityTrends = await getFindingsSeverityTrends({ filters });
if (
!severityTrends ||
!severityTrends.data ||
severityTrends.data.length === 0
) {
return (
<div className="border-border-neutral-primary bg-bg-neutral-secondary flex h-[400px] w-full items-center justify-center rounded-xl border">
<p className="text-text-neutral-tertiary">
Failed to load severity trends data
</p>
</div>
);
}
return (
<Card variant="base" className="flex h-full flex-col">
<CardHeader className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<CardTitle>Finding Severity Over Time</CardTitle>
</div>
</CardHeader>
<CardContent className="flex flex-1 flex-col px-6">
<FindingSeverityOverTime data={severityTrends.data} />
</CardContent>
</Card>
);
};

View File

@@ -1,140 +0,0 @@
"use client";
import { useState } from "react";
import { getSeverityTrendsByTimeRange } from "@/actions/overview/severity-trends";
import { LineChart } from "@/components/graphs/line-chart";
import { LineConfig, LineDataPoint } from "@/components/graphs/types";
import { Skeleton } from "@/components/shadcn";
import { type TimeRange, TimeRangeSelector } from "./time-range-selector";
interface SeverityDataPoint {
type: string;
id: string;
date: string;
informational: number;
low: number;
medium: number;
high: number;
critical: number;
muted?: number;
}
interface FindingSeverityOverTimeProps {
data: SeverityDataPoint[];
}
export const FindingSeverityOverTime = ({
data: initialData,
}: FindingSeverityOverTimeProps) => {
const [timeRange, setTimeRange] = useState<TimeRange>("5D");
const [data, setData] = useState<SeverityDataPoint[]>(initialData);
const [isLoading, setIsLoading] = useState(false);
const handleTimeRangeChange = async (newRange: TimeRange) => {
setTimeRange(newRange);
setIsLoading(true);
try {
const response = await getSeverityTrendsByTimeRange({
timeRange: newRange,
});
if (response?.data) {
setData(response.data);
}
} catch (error) {
console.error("Error fetching severity trends");
} finally {
setIsLoading(false);
}
};
// Transform API data into LineDataPoint format
const chartData: LineDataPoint[] = data.map((item) => {
const date = new Date(item.date);
const formattedDate = date.toLocaleDateString("en-US", {
month: "2-digit",
day: "2-digit",
});
return {
date: formattedDate,
informational: item.informational,
low: item.low,
medium: item.medium,
high: item.high,
critical: item.critical,
...(item.muted && { muted: item.muted }),
};
});
// Define line configurations for each severity level
const lines: LineConfig[] = [
{
dataKey: "informational",
color: "var(--color-bg-data-info)",
label: "Informational",
},
{
dataKey: "low",
color: "var(--color-bg-data-low)",
label: "Low",
},
{
dataKey: "medium",
color: "var(--color-bg-data-medium)",
label: "Medium",
},
{
dataKey: "high",
color: "var(--color-bg-data-high)",
label: "High",
},
{
dataKey: "critical",
color: "var(--color-bg-data-critical)",
label: "Critical",
},
];
// Only add muted line if data contains it
if (data.some((item) => item.muted !== undefined)) {
lines.push({
dataKey: "muted",
color: "var(--color-bg-data-muted)",
label: "Muted",
});
}
return (
<>
<div className="mb-8 w-fit">
<TimeRangeSelector
value={timeRange}
onChange={handleTimeRangeChange}
isLoading={isLoading}
/>
</div>
<div className="mb-4 w-full">
<LineChart data={chartData} lines={lines} height={400} />
</div>
</>
);
};
export function FindingSeverityOverTimeSkeleton() {
return (
<>
<div className="mb-8 w-fit">
<div className="flex gap-2">
{Array.from({ length: 4 }).map((_, index) => (
<Skeleton key={index} className="h-10 w-12 rounded-full" />
))}
</div>
</div>
<Skeleton className="h-[400px] w-full rounded-lg" />
</>
);
}

View File

@@ -1,3 +0,0 @@
export { FindingSeverityOverTime } from "./finding-severity-over-time";
export { FindingSeverityOverTimeSSR } from "./finding-severity-over-time.ssr";
export { TimeRangeSelector } from "./time-range-selector";

View File

@@ -1,60 +0,0 @@
"use client";
import { cn } from "@/lib/utils";
const TIME_RANGE_OPTIONS = {
ONE_DAY: "1D",
FIVE_DAYS: "5D",
ONE_WEEK: "1W",
ONE_MONTH: "1M",
} as const;
export type TimeRange =
(typeof TIME_RANGE_OPTIONS)[keyof typeof TIME_RANGE_OPTIONS];
interface TimeRangeSelectorProps {
value: TimeRange;
onChange: (range: TimeRange) => void | Promise<void>;
isLoading?: boolean;
}
const BUTTON_STYLES = {
base: "relative inline-flex items-center justify-center gap-2 px-6 py-3 text-sm font-medium transition-colors disabled:pointer-events-none disabled:opacity-50",
border: "border-r border-border-neutral-primary last:border-r-0",
text: "text-text-neutral-secondary hover:text-text-neutral-primary",
active: "data-[state=active]:text-text-neutral-primary",
underline:
"after:absolute after:bottom-1 after:left-1/2 after:h-px after:w-0 after:-translate-x-1/2 after:bg-emerald-400 after:transition-all data-[state=active]:after:w-8",
focus:
"focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
} as const;
export const TimeRangeSelector = ({
value,
onChange,
isLoading = false,
}: TimeRangeSelectorProps) => {
return (
<div className="border-border-neutral-tertiary bg-bg-neutral-tertiary inline-flex items-center gap-2 rounded-full border">
{Object.entries(TIME_RANGE_OPTIONS).map(([key, range]) => (
<button
key={key}
onClick={() => onChange(range as TimeRange)}
disabled={isLoading || false}
data-state={value === range ? "active" : "inactive"}
className={cn(
BUTTON_STYLES.base,
BUTTON_STYLES.border,
BUTTON_STYLES.text,
BUTTON_STYLES.active,
BUTTON_STYLES.underline,
BUTTON_STYLES.focus,
isLoading && "cursor-not-allowed opacity-50",
)}
>
{range}
</button>
))}
</div>
);
};

View File

@@ -1,51 +0,0 @@
"use client";
import { useState } from "react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/shadcn";
import { GRAPH_TABS, type TabId } from "./graphs-tabs-config";
interface GraphsTabsClientProps {
tabsContent: Record<TabId, React.ReactNode>;
}
export const GraphsTabsClient = ({ tabsContent }: GraphsTabsClientProps) => {
const [activeTab, setActiveTab] = useState<TabId>("threat-map");
const handleValueChange = (value: string) => {
setActiveTab(value as TabId);
};
return (
<Tabs
value={activeTab}
onValueChange={handleValueChange}
className="flex flex-1 flex-col"
>
<TabsList className="flex w-fit gap-2">
{GRAPH_TABS.map((tab) => (
<TabsTrigger
key={tab.id}
value={tab.id}
className="whitespace-nowrap"
>
{tab.label}
</TabsTrigger>
))}
</TabsList>
{GRAPH_TABS.map((tab) =>
activeTab === tab.id ? (
<TabsContent
key={tab.id}
value={tab.id}
className="mt-4 flex flex-1 overflow-visible"
>
{tabsContent[tab.id]}
</TabsContent>
) : null,
)}
</Tabs>
);
};

View File

@@ -1,20 +0,0 @@
export const GRAPH_TABS = [
{
id: "threat-map",
label: "Threat Map",
},
{
id: "risk-radar",
label: "Risk Radar",
},
{
id: "risk-pipeline",
label: "Risk Pipeline",
},
{
id: "risk-plot",
label: "Risk Plot",
},
] as const;
export type TabId = (typeof GRAPH_TABS)[number]["id"];

View File

@@ -1,61 +0,0 @@
import { Skeleton } from "@heroui/skeleton";
import { Suspense } from "react";
import { SearchParamsProps } from "@/types";
import { GraphsTabsClient } from "./graphs-tabs-client";
import { GRAPH_TABS, type TabId } from "./graphs-tabs-config";
import { RiskPipelineViewSSR } from "./risk-pipeline-view/risk-pipeline-view.ssr";
import { RiskPlotView } from "./risk-plot/risk-plot-view";
import { RiskRadarViewSSR } from "./risk-radar-view/risk-radar-view.ssr";
import { ThreatMapViewSSR } from "./threat-map-view/threat-map-view.ssr";
const LoadingFallback = () => (
<div
className="flex w-full flex-col space-y-4 rounded-lg border p-4"
style={{
borderColor: "var(--border-neutral-primary)",
backgroundColor: "var(--bg-neutral-secondary)",
}}
>
<Skeleton
className="h-6 w-1/3 rounded"
style={{ backgroundColor: "var(--bg-neutral-tertiary)" }}
/>
<Skeleton
className="h-[457px] w-full rounded"
style={{ backgroundColor: "var(--bg-neutral-tertiary)" }}
/>
</div>
);
type GraphComponent = React.ComponentType<{ searchParams: SearchParamsProps }>;
const GRAPH_COMPONENTS: Record<TabId, GraphComponent> = {
"threat-map": ThreatMapViewSSR as GraphComponent,
"risk-radar": RiskRadarViewSSR as GraphComponent,
"risk-pipeline": RiskPipelineViewSSR as GraphComponent,
"risk-plot": RiskPlotView as GraphComponent,
};
interface GraphsTabsWrapperProps {
searchParams: SearchParamsProps;
}
export const GraphsTabsWrapper = async ({
searchParams,
}: GraphsTabsWrapperProps) => {
const tabsContent = Object.fromEntries(
GRAPH_TABS.map((tab) => {
const Component = GRAPH_COMPONENTS[tab.id];
return [
tab.id,
<Suspense key={tab.id} fallback={<LoadingFallback />}>
<Component searchParams={searchParams} />
</Suspense>,
];
}),
) as Record<TabId, React.ReactNode>;
return <GraphsTabsClient tabsContent={tabsContent} />;
};

View File

@@ -1,39 +0,0 @@
import { SankeyChart } from "@/components/graphs/sankey-chart";
// Helper to simulate loading delay
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
// Mock data - replace with actual API call
const mockSankeyData = {
nodes: [
{ name: "AWS" },
{ name: "Azure" },
{ name: "Google Cloud" },
{ name: "Critical" },
{ name: "High" },
{ name: "Medium" },
{ name: "Low" },
],
links: [
{ source: 0, target: 3, value: 45 },
{ source: 0, target: 4, value: 120 },
{ source: 0, target: 5, value: 85 },
{ source: 1, target: 3, value: 28 },
{ source: 1, target: 4, value: 95 },
{ source: 1, target: 5, value: 62 },
{ source: 2, target: 3, value: 18 },
{ source: 2, target: 4, value: 72 },
{ source: 2, target: 5, value: 48 },
],
};
export async function RiskPipelineViewSSR() {
// TODO: Call server action to fetch sankey chart data
await delay(3000); // Simulating server action fetch time
return (
<div className="w-full flex-1 overflow-visible">
<SankeyChart data={mockSankeyData} height={460} />
</div>
);
}

View File

@@ -1,333 +0,0 @@
"use client";
import { useState } from "react";
import {
CartesianGrid,
Legend,
ResponsiveContainer,
Scatter,
ScatterChart,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { HorizontalBarChart } from "@/components/graphs/horizontal-bar-chart";
import { AlertPill } from "@/components/graphs/shared/alert-pill";
import { ChartLegend } from "@/components/graphs/shared/chart-legend";
import {
AXIS_FONT_SIZE,
CustomXAxisTick,
} from "@/components/graphs/shared/custom-axis-tick";
import { getSeverityColorByRiskScore } from "@/components/graphs/shared/utils";
import type { BarDataPoint } from "@/components/graphs/types";
const PROVIDER_COLORS = {
AWS: "var(--color-bg-data-aws)",
Azure: "var(--color-bg-data-azure)",
Google: "var(--color-bg-data-gcp)",
};
export interface ScatterPoint {
x: number;
y: number;
provider: string;
name: string;
severityData?: BarDataPoint[];
}
interface RiskPlotClientProps {
data: ScatterPoint[];
}
interface TooltipProps {
active?: boolean;
payload?: Array<{ payload: ScatterPoint }>;
}
interface ScatterDotProps {
cx: number;
cy: number;
payload: ScatterPoint;
selectedPoint: ScatterPoint | null;
onSelectPoint: (point: ScatterPoint) => void;
allData: ScatterPoint[];
}
interface LegendProps {
payload?: Array<{ value: string; color: string }>;
}
const CustomTooltip = ({ active, payload }: TooltipProps) => {
if (active && payload && payload.length) {
const data = payload[0].payload;
const severityColor = getSeverityColorByRiskScore(data.x);
return (
<div className="border-border-neutral-tertiary bg-bg-neutral-tertiary pointer-events-none min-w-[200px] rounded-xl border p-3 shadow-lg">
<p className="text-text-neutral-primary mb-2 text-sm font-semibold">
{data.name}
</p>
<p className="text-text-neutral-secondary text-sm font-medium">
{/* Dynamic color from getSeverityColorByRiskScore - required inline style */}
<span style={{ color: severityColor, fontWeight: "bold" }}>
{data.x}
</span>{" "}
Risk Score
</p>
<div className="mt-2">
<AlertPill value={data.y} />
</div>
</div>
);
}
return null;
};
const CustomScatterDot = ({
cx,
cy,
payload,
selectedPoint,
onSelectPoint,
allData,
}: ScatterDotProps) => {
const isSelected = selectedPoint?.name === payload.name;
const size = isSelected ? 18 : 8;
const selectedColor = "var(--bg-button-primary)"; // emerald-400
const fill = isSelected
? selectedColor
: PROVIDER_COLORS[payload.provider as keyof typeof PROVIDER_COLORS] ||
"var(--color-text-neutral-tertiary)";
const handleClick = () => {
const fullDataItem = allData?.find(
(d: ScatterPoint) => d.name === payload.name,
);
onSelectPoint?.(fullDataItem || payload);
};
return (
<g style={{ cursor: "pointer" }} onClick={handleClick}>
{isSelected && (
<>
<circle
cx={cx}
cy={cy}
r={size / 2 + 4}
fill="none"
stroke={selectedColor}
strokeWidth={1}
opacity={0.4}
/>
<circle
cx={cx}
cy={cy}
r={size / 2 + 8}
fill="none"
stroke={selectedColor}
strokeWidth={1}
opacity={0.2}
/>
</>
)}
<circle
cx={cx}
cy={cy}
r={size / 2}
fill={fill}
stroke={isSelected ? selectedColor : "transparent"}
strokeWidth={2}
/>
</g>
);
};
const CustomLegend = ({ payload }: LegendProps) => {
const items =
payload?.map((entry: { value: string; color: string }) => ({
label: entry.value,
color: entry.color,
})) || [];
return <ChartLegend items={items} />;
};
function createScatterDotShape(
selectedPoint: ScatterPoint | null,
onSelectPoint: (point: ScatterPoint) => void,
allData: ScatterPoint[],
) {
const ScatterDotShape = (props: unknown) => {
const dotProps = props as Omit<
ScatterDotProps,
"selectedPoint" | "onSelectPoint" | "allData"
>;
return (
<CustomScatterDot
{...dotProps}
selectedPoint={selectedPoint}
onSelectPoint={onSelectPoint}
allData={allData}
/>
);
};
ScatterDotShape.displayName = "ScatterDotShape";
return ScatterDotShape;
}
export function RiskPlotClient({ data }: RiskPlotClientProps) {
const [selectedPoint, setSelectedPoint] = useState<ScatterPoint | null>(null);
const dataByProvider = data.reduce(
(acc, point) => {
const provider = point.provider;
if (!acc[provider]) {
acc[provider] = [];
}
acc[provider].push(point);
return acc;
},
{} as Record<string, typeof data>,
);
const handleSelectPoint = (point: ScatterPoint) => {
if (selectedPoint?.name === point.name) {
setSelectedPoint(null);
} else {
setSelectedPoint(point);
}
};
return (
<div className="flex h-full w-full flex-col gap-4">
<div className="flex flex-1 gap-12">
{/* Plot Section - in Card */}
<div className="flex basis-[70%] flex-col">
<div
className="flex flex-1 flex-col rounded-lg border p-4"
style={{
borderColor: "var(--border-neutral-primary)",
backgroundColor: "var(--bg-neutral-secondary)",
}}
>
<div className="mb-4">
<h3
className="text-lg font-semibold"
style={{ color: "var(--text-neutral-primary)" }}
>
Risk Plot
</h3>
</div>
<div
className="relative w-full flex-1"
style={{ minHeight: "400px" }}
>
<ResponsiveContainer width="100%" height="100%">
<ScatterChart
margin={{ top: 20, right: 30, bottom: 60, left: 60 }}
>
<CartesianGrid
horizontal={true}
vertical={false}
strokeOpacity={1}
stroke="var(--border-neutral-secondary)"
/>
<XAxis
type="number"
dataKey="x"
name="Risk Score"
label={{
value: "Risk Score",
position: "bottom",
offset: 10,
fill: "var(--color-text-neutral-secondary)",
}}
tick={CustomXAxisTick}
tickLine={false}
domain={[0, 10]}
axisLine={false}
/>
<YAxis
type="number"
dataKey="y"
name="Failed Findings"
label={{
value: "Failed Findings",
angle: -90,
position: "left",
offset: 10,
fill: "var(--color-text-neutral-secondary)",
}}
tick={{
fill: "var(--color-text-neutral-secondary)",
fontSize: AXIS_FONT_SIZE,
}}
tickLine={false}
axisLine={false}
/>
<Tooltip content={<CustomTooltip />} />
<Legend
content={<CustomLegend />}
wrapperStyle={{ paddingTop: "40px" }}
/>
{Object.entries(dataByProvider).map(([provider, points]) => (
<Scatter
key={provider}
name={provider}
data={points}
fill={
PROVIDER_COLORS[
provider as keyof typeof PROVIDER_COLORS
] || "var(--color-text-neutral-tertiary)"
}
shape={createScatterDotShape(
selectedPoint,
handleSelectPoint,
data,
)}
/>
))}
</ScatterChart>
</ResponsiveContainer>
</div>
</div>
</div>
{/* Details Section - No Card */}
<div className="flex basis-[30%] flex-col items-center justify-center overflow-hidden">
{selectedPoint && selectedPoint.severityData ? (
<div className="flex w-full flex-col">
<div className="mb-4">
<h4
className="text-base font-semibold"
style={{ color: "var(--text-neutral-primary)" }}
>
{selectedPoint.name}
</h4>
<p
className="text-xs"
style={{ color: "var(--text-neutral-tertiary)" }}
>
Risk Score: {selectedPoint.x} | Failed Findings:{" "}
{selectedPoint.y}
</p>
</div>
<HorizontalBarChart data={selectedPoint.severityData} />
</div>
) : (
<div className="flex w-full items-center justify-center text-center">
<p
className="text-sm"
style={{ color: "var(--text-neutral-tertiary)" }}
>
Select a point on the plot to view details
</p>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -1,191 +0,0 @@
import { RiskPlotClient, type ScatterPoint } from "./risk-plot-client";
// Mock data - Risk Score (0-10) vs Failed Findings count
const mockScatterData: ScatterPoint[] = [
{
x: 9.2,
y: 1456,
provider: "AWS",
name: "Amazon RDS",
severityData: [
{ name: "Critical", value: 456 },
{ name: "High", value: 600 },
{ name: "Medium", value: 250 },
{ name: "Low", value: 120 },
{ name: "Info", value: 30 },
],
},
{
x: 8.5,
y: 892,
provider: "AWS",
name: "Amazon EC2",
severityData: [
{ name: "Critical", value: 280 },
{ name: "High", value: 350 },
{ name: "Medium", value: 180 },
{ name: "Low", value: 70 },
{ name: "Info", value: 12 },
],
},
{
x: 7.1,
y: 445,
provider: "AWS",
name: "Amazon S3",
severityData: [
{ name: "Critical", value: 140 },
{ name: "High", value: 180 },
{ name: "Medium", value: 90 },
{ name: "Low", value: 30 },
{ name: "Info", value: 5 },
],
},
{
x: 6.3,
y: 678,
provider: "AWS",
name: "AWS Lambda",
severityData: [
{ name: "Critical", value: 214 },
{ name: "High", value: 270 },
{ name: "Medium", value: 135 },
{ name: "Low", value: 54 },
{ name: "Info", value: 5 },
],
},
{
x: 4.2,
y: 156,
provider: "AWS",
name: "AWS Backup",
severityData: [
{ name: "Critical", value: 49 },
{ name: "High", value: 62 },
{ name: "Medium", value: 31 },
{ name: "Low", value: 12 },
{ name: "Info", value: 2 },
],
},
{
x: 8.8,
y: 1023,
provider: "Azure",
name: "Azure SQL Database",
severityData: [
{ name: "Critical", value: 323 },
{ name: "High", value: 410 },
{ name: "Medium", value: 205 },
{ name: "Low", value: 82 },
{ name: "Info", value: 3 },
],
},
{
x: 7.9,
y: 834,
provider: "Azure",
name: "Azure Virtual Machines",
severityData: [
{ name: "Critical", value: 263 },
{ name: "High", value: 334 },
{ name: "Medium", value: 167 },
{ name: "Low", value: 67 },
{ name: "Info", value: 3 },
],
},
{
x: 6.4,
y: 567,
provider: "Azure",
name: "Azure Storage",
severityData: [
{ name: "Critical", value: 179 },
{ name: "High", value: 227 },
{ name: "Medium", value: 113 },
{ name: "Low", value: 45 },
{ name: "Info", value: 3 },
],
},
{
x: 5.1,
y: 289,
provider: "Azure",
name: "Azure Key Vault",
severityData: [
{ name: "Critical", value: 91 },
{ name: "High", value: 115 },
{ name: "Medium", value: 58 },
{ name: "Low", value: 23 },
{ name: "Info", value: 2 },
],
},
{
x: 7.6,
y: 712,
provider: "Google",
name: "Cloud SQL",
severityData: [
{ name: "Critical", value: 225 },
{ name: "High", value: 285 },
{ name: "Medium", value: 142 },
{ name: "Low", value: 57 },
{ name: "Info", value: 3 },
],
},
{
x: 6.9,
y: 623,
provider: "Google",
name: "Compute Engine",
severityData: [
{ name: "Critical", value: 197 },
{ name: "High", value: 249 },
{ name: "Medium", value: 124 },
{ name: "Low", value: 50 },
{ name: "Info", value: 3 },
],
},
{
x: 5.8,
y: 412,
provider: "Google",
name: "Cloud Storage",
severityData: [
{ name: "Critical", value: 130 },
{ name: "High", value: 165 },
{ name: "Medium", value: 82 },
{ name: "Low", value: 33 },
{ name: "Info", value: 2 },
],
},
{
x: 4.5,
y: 198,
provider: "Google",
name: "Cloud Run",
severityData: [
{ name: "Critical", value: 63 },
{ name: "High", value: 79 },
{ name: "Medium", value: 39 },
{ name: "Low", value: 16 },
{ name: "Info", value: 1 },
],
},
{
x: 8.9,
y: 945,
provider: "AWS",
name: "Amazon RDS Aurora",
severityData: [
{ name: "Critical", value: 299 },
{ name: "High", value: 378 },
{ name: "Medium", value: 189 },
{ name: "Low", value: 76 },
{ name: "Info", value: 3 },
],
},
];
export function RiskPlotView() {
return <RiskPlotClient data={mockScatterData} />;
}

View File

@@ -1,71 +0,0 @@
"use client";
import { useState } from "react";
import { HorizontalBarChart } from "@/components/graphs/horizontal-bar-chart";
import { RadarChart } from "@/components/graphs/radar-chart";
import type { RadarDataPoint } from "@/components/graphs/types";
import { Card } from "@/components/shadcn/card/card";
interface RiskRadarViewClientProps {
data: RadarDataPoint[];
}
export function RiskRadarViewClient({ data }: RiskRadarViewClientProps) {
const [selectedPoint, setSelectedPoint] = useState<RadarDataPoint | null>(
null,
);
const handleSelectPoint = (point: RadarDataPoint | null) => {
setSelectedPoint(point);
};
return (
<div className="flex h-full w-full flex-col gap-4">
<div className="flex flex-1 gap-12 overflow-hidden">
{/* Radar Section */}
<div className="flex basis-[70%] flex-col overflow-hidden">
<Card variant="base" className="flex flex-1 flex-col overflow-hidden">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-neutral-primary text-lg font-semibold">
Risk Radar
</h3>
</div>
<div className="relative min-h-[400px] w-full flex-1">
<RadarChart
data={data}
height={400}
selectedPoint={selectedPoint}
onSelectPoint={handleSelectPoint}
/>
</div>
</Card>
</div>
{/* Details Section - No Card */}
<div className="flex basis-[30%] items-center overflow-hidden">
{selectedPoint && selectedPoint.severityData ? (
<div className="flex w-full flex-col">
<div className="mb-4">
<h4 className="text-neutral-primary text-base font-semibold">
{selectedPoint.category}
</h4>
<p className="text-neutral-tertiary text-xs">
{selectedPoint.value} Total Findings
</p>
</div>
<HorizontalBarChart data={selectedPoint.severityData} />
</div>
) : (
<div className="flex w-full items-center justify-center text-center">
<p className="text-neutral-tertiary text-sm">
Select a category on the radar to view details
</p>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -1,89 +0,0 @@
import type { RadarDataPoint } from "@/components/graphs/types";
import { RiskRadarViewClient } from "./risk-radar-view-client";
// Mock data - replace with actual API call
const mockRadarData: RadarDataPoint[] = [
{
category: "Amazon Kinesis",
value: 45,
change: 2,
severityData: [
{ name: "Critical", value: 32 },
{ name: "High", value: 65 },
{ name: "Medium", value: 18 },
{ name: "Low", value: 54 },
{ name: "Info", value: 1 },
],
},
{
category: "Amazon MQ",
value: 38,
change: -1,
severityData: [
{ name: "Critical", value: 28 },
{ name: "High", value: 58 },
{ name: "Medium", value: 16 },
{ name: "Low", value: 48 },
{ name: "Info", value: 2 },
],
},
{
category: "AWS Lambda",
value: 52,
change: 5,
severityData: [
{ name: "Critical", value: 40 },
{ name: "High", value: 72 },
{ name: "Medium", value: 20 },
{ name: "Low", value: 60 },
{ name: "Info", value: 3 },
],
},
{
category: "Amazon RDS",
value: 41,
change: 3,
severityData: [
{ name: "Critical", value: 30 },
{ name: "High", value: 60 },
{ name: "Medium", value: 17 },
{ name: "Low", value: 50 },
{ name: "Info", value: 1 },
],
},
{
category: "Amazon S3",
value: 48,
change: -2,
severityData: [
{ name: "Critical", value: 36 },
{ name: "High", value: 68 },
{ name: "Medium", value: 19 },
{ name: "Low", value: 56 },
{ name: "Info", value: 2 },
],
},
{
category: "Amazon VPC",
value: 55,
change: 4,
severityData: [
{ name: "Critical", value: 42 },
{ name: "High", value: 75 },
{ name: "Medium", value: 21 },
{ name: "Low", value: 62 },
{ name: "Info", value: 3 },
],
},
];
// Helper to simulate loading delay
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
export async function RiskRadarViewSSR() {
// TODO: Call server action to fetch radar chart data
await delay(3000); // Simulating server action fetch time
return <RiskRadarViewClient data={mockRadarData} />;
}

View File

@@ -1,98 +0,0 @@
import { ThreatMap } from "@/components/graphs/threat-map";
// Mock data - replace with actual API call
const mockThreatMapData = {
locations: [
{
id: "us-east-1",
name: "US East-1",
region: "North America",
coordinates: [-75.1551, 40.2206] as [number, number],
totalFindings: 455,
riskLevel: "critical" as const,
severityData: [
{ name: "Critical", value: 432 },
{ name: "High", value: 1232 },
{ name: "Medium", value: 221 },
{ name: "Low", value: 543 },
{ name: "Info", value: 10 },
],
change: 5,
},
{
id: "eu-west-1",
name: "EU West-1",
region: "Europe",
coordinates: [-6.2597, 53.3498] as [number, number],
totalFindings: 320,
riskLevel: "high" as const,
severityData: [
{ name: "Critical", value: 200 },
{ name: "High", value: 900 },
{ name: "Medium", value: 180 },
{ name: "Low", value: 400 },
{ name: "Info", value: 15 },
],
change: -2,
},
{
id: "ap-southeast-1",
name: "AP Southeast-1",
region: "Asia Pacific",
coordinates: [103.8198, 1.3521] as [number, number],
totalFindings: 280,
riskLevel: "high" as const,
severityData: [
{ name: "Critical", value: 150 },
{ name: "High", value: 800 },
{ name: "Medium", value: 160 },
{ name: "Low", value: 350 },
{ name: "Info", value: 8 },
],
change: 3,
},
{
id: "ca-central-1",
name: "CA Central-1",
region: "North America",
coordinates: [-95.7129, 56.1304] as [number, number],
totalFindings: 190,
riskLevel: "high" as const,
severityData: [
{ name: "Critical", value: 100 },
{ name: "High", value: 600 },
{ name: "Medium", value: 120 },
{ name: "Low", value: 280 },
{ name: "Info", value: 5 },
],
change: 1,
},
{
id: "ap-northeast-1",
name: "AP Northeast-1",
region: "Asia Pacific",
coordinates: [139.6917, 35.6895] as [number, number],
totalFindings: 240,
riskLevel: "high" as const,
severityData: [
{ name: "Critical", value: 120 },
{ name: "High", value: 700 },
{ name: "Medium", value: 140 },
{ name: "Low", value: 320 },
{ name: "Info", value: 12 },
],
change: 4,
},
],
regions: ["North America", "Europe", "Asia Pacific"],
};
export async function ThreatMapViewSSR() {
// TODO: Call server action to fetch threat map data
return (
<div className="w-full flex-1 overflow-hidden">
<ThreatMap data={mockThreatMapData} height={350} />
</div>
);
}

View File

@@ -1,5 +0,0 @@
export {
RiskSeverityChart,
RiskSeverityChartSkeleton,
} from "./risk-severity-chart";
export { RiskSeverityChartSSR } from "./risk-severity-chart.ssr";

View File

@@ -1,41 +0,0 @@
import { getFindingsBySeverity } from "@/actions/overview/overview";
import { SearchParamsProps } from "@/types";
import { pickFilterParams } from "../../lib/filter-params";
import { RiskSeverityChart } from "./risk-severity-chart";
export const RiskSeverityChartDetailSSR = async ({
searchParams,
}: {
searchParams: SearchParamsProps | undefined | null;
}) => {
const filters = pickFilterParams(searchParams);
const findingsBySeverity = await getFindingsBySeverity({ filters });
if (!findingsBySeverity) {
return (
<div className="flex h-[400px] w-full items-center justify-center rounded-xl border border-zinc-900 bg-stone-950">
<p className="text-zinc-400">Failed to load severity data</p>
</div>
);
}
const {
critical = 0,
high = 0,
medium = 0,
low = 0,
informational = 0,
} = findingsBySeverity?.data?.attributes || {};
return (
<RiskSeverityChart
critical={critical}
high={high}
medium={medium}
low={low}
informational={informational}
/>
);
};

View File

@@ -1,14 +0,0 @@
import { SearchParamsProps } from "@/types";
import { pickFilterParams } from "../../lib/filter-params";
import { RiskSeverityChartDetailSSR } from "./risk-severity-chart-detail.ssr";
export const RiskSeverityChartSSR = async ({
searchParams,
}: {
searchParams: SearchParamsProps | undefined | null;
}) => {
const filters = pickFilterParams(searchParams);
return <RiskSeverityChartDetailSSR searchParams={filters} />;
};

View File

@@ -1 +0,0 @@
export { StatusChart, StatusChartSkeleton } from "./status-chart";

View File

@@ -1,2 +0,0 @@
export { ThreatScore, ThreatScoreSkeleton } from "./threat-score";
export { ThreatScoreSSR } from "./threat-score.ssr";

View File

@@ -1,38 +0,0 @@
import { getThreatScore } from "@/actions/overview/overview";
import { SearchParamsProps } from "@/types";
import { pickFilterParams } from "../../lib/filter-params";
import { ThreatScore } from "./threat-score";
export const ThreatScoreSSR = async ({
searchParams,
}: {
searchParams: SearchParamsProps | undefined | null;
}) => {
const filters = pickFilterParams(searchParams);
const threatScoreData = await getThreatScore({ filters });
// If no data, pass undefined score and let component handle empty state
if (!threatScoreData?.data || threatScoreData.data.length === 0) {
return <ThreatScore />;
}
// Get the first snapshot (aggregated or single provider)
const snapshot = threatScoreData.data[0];
const attributes = snapshot.attributes;
// Parse score from decimal string to number and round to integer
const score = Math.round(parseFloat(attributes.overall_score));
const scoreDelta = attributes.score_delta
? Math.round(parseFloat(attributes.score_delta))
: null;
return (
<ThreatScore
score={score}
scoreDelta={scoreDelta}
sectionScores={attributes.section_scores}
criticalRequirements={attributes.critical_requirements}
/>
);
};

View File

@@ -1,12 +0,0 @@
import { SearchParamsProps } from "@/types";
const FILTER_PREFIX = "filter[";
export function pickFilterParams(
params: SearchParamsProps | undefined | null,
): Record<string, string | string[] | undefined> {
if (!params) return {};
return Object.fromEntries(
Object.entries(params).filter(([key]) => key.startsWith(FILTER_PREFIX)),
);
}

View File

@@ -1,21 +1,34 @@
import { Suspense } from "react";
import {
getFindingsBySeverity,
getFindingsByStatus,
getThreatScore,
} from "@/actions/overview/overview";
import { getProviders } from "@/actions/providers";
import { ContentLayout } from "@/components/ui";
import { SearchParamsProps } from "@/types";
import { AccountsSelector } from "./components/accounts-selector";
import { CheckFindingsSSR } from "./components/check-findings";
import {
FindingSeverityOverTimeSkeleton,
FindingSeverityOverTimeSSR,
} from "./components/finding-severity-over-time/finding-severity-over-time.ssr";
import { GraphsTabsWrapper } from "./components/graphs-tabs/graphs-tabs-wrapper";
import { ProviderTypeSelector } from "./components/provider-type-selector";
import { RiskSeverityChartSkeleton } from "./components/risk-severity-chart";
import { RiskSeverityChartSSR } from "./components/risk-severity-chart/risk-severity-chart.ssr";
import { StatusChartSkeleton } from "./components/status-chart";
import { ThreatScoreSkeleton, ThreatScoreSSR } from "./components/threat-score";
import {
RiskSeverityChart,
RiskSeverityChartSkeleton,
} from "./components/risk-severity-chart";
import { StatusChart, StatusChartSkeleton } from "./components/status-chart";
import { ThreatScore, ThreatScoreSkeleton } from "./components/threat-score";
const FILTER_PREFIX = "filter[";
// Extract only query params that start with "filter[" for API calls
function pickFilterParams(
params: SearchParamsProps | undefined | null,
): Record<string, string | string[] | undefined> {
if (!params) return {};
return Object.fromEntries(
Object.entries(params).filter(([key]) => key.startsWith(FILTER_PREFIX)),
);
}
export default async function NewOverviewPage({
searchParams,
@@ -31,28 +44,132 @@ export default async function NewOverviewPage({
<ProviderTypeSelector providers={providersData?.data ?? []} />
<AccountsSelector providers={providersData?.data ?? []} />
</div>
<div className="flex flex-col gap-6 md:flex-row md:flex-wrap md:items-stretch">
<Suspense fallback={<ThreatScoreSkeleton />}>
<ThreatScoreSSR searchParams={resolvedSearchParams} />
<SSRThreatScore searchParams={resolvedSearchParams} />
</Suspense>
<Suspense fallback={<StatusChartSkeleton />}>
<CheckFindingsSSR searchParams={resolvedSearchParams} />
<SSRCheckFindings searchParams={resolvedSearchParams} />
</Suspense>
<Suspense fallback={<RiskSeverityChartSkeleton />}>
<RiskSeverityChartSSR searchParams={resolvedSearchParams} />
<SSRRiskSeverityChart searchParams={resolvedSearchParams} />
</Suspense>
</div>
<div className="mt-6">
<Suspense fallback={<FindingSeverityOverTimeSkeleton />}>
<FindingSeverityOverTimeSSR searchParams={resolvedSearchParams} />
</Suspense>
</div>
<div className="mt-6">
<GraphsTabsWrapper searchParams={resolvedSearchParams} />
</div>
</ContentLayout>
);
}
const SSRCheckFindings = async ({
searchParams,
}: {
searchParams: SearchParamsProps | undefined | null;
}) => {
const filters = pickFilterParams(searchParams);
const findingsByStatus = await getFindingsByStatus({ filters });
if (!findingsByStatus) {
return (
<div className="flex h-[400px] w-full max-w-md items-center justify-center rounded-xl border border-zinc-900 bg-stone-950">
<p className="text-zinc-400">Failed to load findings data</p>
</div>
);
}
const {
fail = 0,
pass = 0,
muted_new = 0,
muted_changed = 0,
fail_new = 0,
pass_new = 0,
} = findingsByStatus?.data?.attributes || {};
const mutedTotal = muted_new + muted_changed;
return (
<StatusChart
failFindingsData={{
total: fail,
new: fail_new,
muted: mutedTotal,
}}
passFindingsData={{
total: pass,
new: pass_new,
muted: mutedTotal,
}}
/>
);
};
const SSRRiskSeverityChart = async ({
searchParams,
}: {
searchParams: SearchParamsProps | undefined | null;
}) => {
const filters = pickFilterParams(searchParams);
const findingsBySeverity = await getFindingsBySeverity({ filters });
if (!findingsBySeverity) {
return (
<div className="flex h-[400px] w-full items-center justify-center rounded-xl border border-zinc-900 bg-stone-950">
<p className="text-zinc-400">Failed to load severity data</p>
</div>
);
}
const {
critical = 0,
high = 0,
medium = 0,
low = 0,
informational = 0,
} = findingsBySeverity?.data?.attributes || {};
return (
<RiskSeverityChart
critical={critical}
high={high}
medium={medium}
low={low}
informational={informational}
/>
);
};
const SSRThreatScore = async ({
searchParams,
}: {
searchParams: SearchParamsProps | undefined | null;
}) => {
const filters = pickFilterParams(searchParams);
const threatScoreData = await getThreatScore({ filters });
// If no data, pass undefined score and let component handle empty state
if (!threatScoreData?.data || threatScoreData.data.length === 0) {
return <ThreatScore />;
}
// Get the first snapshot (aggregated or single provider)
const snapshot = threatScoreData.data[0];
const attributes = snapshot.attributes;
// Parse score from decimal string to number and round to integer
const score = Math.round(parseFloat(attributes.overall_score));
const scoreDelta = attributes.score_delta
? Math.round(parseFloat(attributes.score_delta))
: null;
return (
<ThreatScore
score={score}
scoreDelta={scoreDelta}
sectionScores={attributes.section_scores}
criticalRequirements={attributes.critical_requirements}
/>
);
};

View File

@@ -11,7 +11,7 @@
import { browserTracingIntegration } from "@sentry/browser";
import * as Sentry from "@sentry/nextjs";
const isDevelopment = process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT === "local";
const isDevelopment = process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT === "dev";
/**
* Initialize Sentry error tracking and performance monitoring

View File

@@ -23,15 +23,15 @@ const chartConfig = {
},
pass: {
label: "Pass",
color: "var(--color-bg-pass)",
color: "hsl(var(--chart-success))",
},
fail: {
label: "Fail",
color: "var(--color-bg-fail)",
color: "hsl(var(--chart-fail))",
},
manual: {
label: "Manual",
color: "var(--color-bg-warning)",
color: "hsl(var(--chart-warning))",
},
} satisfies ChartConfig;

View File

@@ -102,8 +102,8 @@ export function DonutChart({
{
name: "No data",
value: 1,
fill: "var(--border-neutral-tertiary)",
color: "var(--border-neutral-tertiary)",
fill: "var(--chart-border-emphasis)",
color: "var(--chart-border-emphasis)",
percentage: 0,
change: undefined,
},

View File

@@ -37,7 +37,10 @@ export function HorizontalBarChart({ data, title }: HorizontalBarChartProps) {
<div className="w-full space-y-6">
{title && (
<div>
<h3 className="text-text-neutral-primary text-lg font-semibold">
<h3
className="text-lg font-semibold"
style={{ color: "var(--text-neutral-primary)" }}
>
{title}
</h3>
</div>
@@ -56,15 +59,16 @@ export function HorizontalBarChart({ data, title }: HorizontalBarChartProps) {
return (
<div
key={item.name}
className="flex items-center gap-10"
className="flex gap-6"
onMouseEnter={() => !isEmpty && setHoveredIndex(index)}
onMouseLeave={() => !isEmpty && setHoveredIndex(null)}
>
{/* Label */}
<div className="w-20 shrink-0">
<span
className="text-text-neutral-secondary text-sm font-medium"
className="text-sm font-medium"
style={{
color: "var(--text-neutral-secondary)",
opacity: isFaded ? 0.5 : 1,
transition: "opacity 0.2s",
}}
@@ -134,8 +138,9 @@ export function HorizontalBarChart({ data, title }: HorizontalBarChartProps) {
{/* Percentage and Count */}
<div
className="text-text-neutral-secondary ml-6 flex w-[90px] shrink-0 items-center gap-2 text-sm"
className="flex w-[90px] shrink-0 items-center gap-2 text-sm"
style={{
color: "var(--text-neutral-secondary)",
opacity: isFaded ? 0.5 : 1,
transition: "opacity 0.2s",
}}
@@ -143,7 +148,12 @@ export function HorizontalBarChart({ data, title }: HorizontalBarChartProps) {
<span className="w-[26px] text-right font-medium">
{isEmpty ? "0" : item.percentage}%
</span>
<span className="font-medium"></span>
<span
className="font-medium"
style={{ color: "var(--text-neutral-secondary)" }}
>
</span>
<span className="font-bold">
{isEmpty ? "0" : item.value.toLocaleString()}
</span>

View File

@@ -7,4 +7,3 @@ export { RadialChart } from "./radial-chart";
export { SankeyChart } from "./sankey-chart";
export { ScatterPlot } from "./scatter-plot";
export { ChartLegend, type ChartLegendItem } from "./shared/chart-legend";
export { ThreatMap } from "./threat-map";

View File

@@ -4,30 +4,26 @@ import { Bell } from "lucide-react";
import { useState } from "react";
import {
CartesianGrid,
Legend,
Line,
LineChart as RechartsLine,
ResponsiveContainer,
Tooltip,
TooltipProps,
XAxis,
YAxis,
} from "recharts";
import {
ChartConfig,
ChartContainer,
ChartTooltip,
} from "@/components/ui/chart/Chart";
import { AlertPill } from "./shared/alert-pill";
import { ChartLegend } from "./shared/chart-legend";
import {
AXIS_FONT_SIZE,
CustomXAxisTickWithToday,
} from "./shared/custom-axis-tick";
import { CHART_COLORS } from "./shared/constants";
import { LineConfig, LineDataPoint } from "./types";
interface LineChartProps {
data: LineDataPoint[];
lines: LineConfig[];
xLabel?: string;
yLabel?: string;
height?: number;
}
@@ -52,8 +48,19 @@ const CustomLineTooltip = ({
const totalValue = typedPayload.reduce((sum, item) => sum + item.value, 0);
return (
<div className="border-border-neutral-tertiary bg-bg-neutral-tertiary pointer-events-none min-w-[200px] rounded-xl border p-3 shadow-lg">
<p className="text-text-neutral-secondary mb-3 text-xs">{label}</p>
<div
className="rounded-lg border p-3 shadow-lg"
style={{
backgroundColor: "var(--chart-background)",
borderColor: "var(--chart-border-emphasis)",
}}
>
<p
className="mb-3 text-xs"
style={{ color: "var(--chart-text-secondary)" }}
>
{label}
</p>
<div className="mb-3">
<AlertPill value={totalValue} textSize="sm" />
@@ -71,22 +78,31 @@ const CustomLineTooltip = ({
className="h-2 w-2 rounded-full"
style={{ backgroundColor: item.stroke }}
/>
<span className="text-text-neutral-primary text-sm">
<span
className="text-sm"
style={{ color: "var(--chart-text-primary)" }}
>
{item.value}
</span>
</div>
{newFindings !== undefined && (
<div className="flex items-center gap-2">
<Bell size={14} className="text-text-neutral-secondary" />
<span className="text-text-neutral-secondary text-xs">
<Bell size={14} style={{ color: "var(--chart-fail)" }} />
<span
className="text-xs"
style={{ color: "var(--chart-text-secondary)" }}
>
{newFindings} New Findings
</span>
</div>
)}
{change !== undefined && typeof change === "number" && (
<p className="text-text-neutral-secondary text-xs">
<p
className="text-xs"
style={{ color: "var(--chart-text-secondary)" }}
>
<span className="font-bold">
{(change as number) > 0 ? "+" : ""}
{change > 0 ? "+" : ""}
{change}%
</span>{" "}
Since Last Scan
@@ -100,84 +116,96 @@ const CustomLineTooltip = ({
);
};
const chartConfig = {
default: {
color: "var(--color-bg-data-azure)",
},
} satisfies ChartConfig;
const CustomLegend = ({ payload }: any) => {
const severityOrder = [
"Informational",
"Low",
"Medium",
"High",
"Critical",
"Muted",
];
export function LineChart({ data, lines, height = 400 }: LineChartProps) {
const [hoveredLine, setHoveredLine] = useState<string | null>(null);
const sortedPayload = [...payload].sort((a, b) => {
const indexA = severityOrder.indexOf(a.value);
const indexB = severityOrder.indexOf(b.value);
return indexA - indexB;
});
const legendItems = lines.map((line) => ({
label: line.label,
color: line.color,
const items = sortedPayload.map((entry: any) => ({
label: entry.value,
color: entry.color,
}));
return (
<div className="w-full">
<ChartContainer
config={chartConfig}
className="w-full overflow-hidden"
style={{ height, aspectRatio: "auto" }}
>
<RechartsLine
data={data}
margin={{
top: 10,
left: 0,
right: 8,
bottom: 20,
}}
>
<CartesianGrid
vertical={false}
strokeOpacity={1}
stroke="var(--border-neutral-secondary)"
/>
<XAxis
dataKey="date"
tickLine={false}
axisLine={false}
tickMargin={8}
tick={CustomXAxisTickWithToday}
/>
<YAxis
tickLine={false}
axisLine={false}
tickMargin={8}
tick={{
fill: "var(--color-text-neutral-secondary)",
fontSize: AXIS_FONT_SIZE,
}}
/>
<ChartTooltip cursor={false} content={<CustomLineTooltip />} />
{lines.map((line) => {
const isHovered = hoveredLine === line.dataKey;
const isFaded = hoveredLine !== null && !isHovered;
return (
<Line
key={line.dataKey}
type="natural"
dataKey={line.dataKey}
stroke={line.color}
strokeWidth={2}
strokeOpacity={isFaded ? 0.5 : 1}
name={line.label}
dot={{ fill: line.color, r: 4 }}
activeDot={{ r: 6 }}
onMouseEnter={() => setHoveredLine(line.dataKey)}
onMouseLeave={() => setHoveredLine(null)}
style={{ transition: "stroke-opacity 0.2s" }}
/>
);
})}
</RechartsLine>
</ChartContainer>
return <ChartLegend items={items} />;
};
<div className="mt-4">
<ChartLegend items={legendItems} />
</div>
</div>
export function LineChart({
data,
lines,
xLabel,
yLabel,
height = 400,
}: LineChartProps) {
const [hoveredLine, setHoveredLine] = useState<string | null>(null);
return (
<ResponsiveContainer width="100%" height={height}>
<RechartsLine
data={data}
margin={{ top: 20, right: 30, left: 20, bottom: 20 }}
>
<CartesianGrid strokeDasharray="3 3" stroke={CHART_COLORS.gridLine} />
<XAxis
dataKey="date"
label={
xLabel
? {
value: xLabel,
position: "insideBottom",
offset: -10,
fill: CHART_COLORS.textSecondary,
}
: undefined
}
tick={{ fill: CHART_COLORS.textSecondary, fontSize: 12 }}
/>
<YAxis
label={
yLabel
? {
value: yLabel,
angle: -90,
position: "insideLeft",
fill: CHART_COLORS.textSecondary,
}
: undefined
}
tick={{ fill: CHART_COLORS.textSecondary, fontSize: 12 }}
/>
<Tooltip content={<CustomLineTooltip />} />
<Legend content={<CustomLegend />} />
{lines.map((line) => {
const isHovered = hoveredLine === line.dataKey;
const isFaded = hoveredLine !== null && !isHovered;
return (
<Line
key={line.dataKey}
type="monotone"
dataKey={line.dataKey}
stroke={line.color}
strokeWidth={2}
strokeOpacity={isFaded ? 0.5 : 1}
name={line.label}
dot={{ fill: line.color, r: 4, opacity: isFaded ? 0.5 : 1 }}
activeDot={{ r: 6 }}
onMouseEnter={() => setHoveredLine(line.dataKey)}
onMouseLeave={() => setHoveredLine(null)}
style={{ transition: "stroke-opacity 0.2s" }}
/>
);
})}
</RechartsLine>
</ResponsiveContainer>
);
}

View File

@@ -29,11 +29,11 @@ const MAP_CONFIG = {
} as const;
const MAP_COLORS = {
landFill: "var(--border-neutral-tertiary)",
landStroke: "var(--border-neutral-secondary)",
pointDefault: "var(--bg-fail)",
pointSelected: "var(--bg-pass)",
pointHover: "var(--bg-fail)",
landFill: "var(--chart-border-emphasis)",
landStroke: "var(--chart-border)",
pointDefault: "#DB2B49",
pointSelected: "#86DA26",
pointHover: "#DB2B49",
} as const;
const RISK_LEVELS = {
@@ -119,10 +119,10 @@ function MapTooltip({
position: { x: number; y: number };
}) {
const CHART_COLORS = {
tooltipBorder: "var(--border-neutral-tertiary)",
tooltipBackground: "var(--bg-neutral-secondary)",
textPrimary: "var(--text-neutral-primary)",
textSecondary: "var(--text-neutral-secondary)",
tooltipBorder: "var(--chart-border-emphasis)",
tooltipBackground: "var(--chart-background)",
textPrimary: "var(--chart-text-primary)",
textSecondary: "var(--chart-text-secondary)",
};
return (
@@ -169,14 +169,14 @@ function MapTooltip({
function EmptyState() {
const CHART_COLORS = {
tooltipBorder: "var(--border-neutral-tertiary)",
tooltipBackground: "var(--bg-neutral-secondary)",
textSecondary: "var(--text-neutral-secondary)",
tooltipBorder: "var(--chart-border-emphasis)",
tooltipBackground: "var(--chart-background)",
textSecondary: "var(--chart-text-secondary)",
};
return (
<div
className="flex h-full min-h-[400px] w-full items-center justify-center rounded-lg border p-6"
className="flex h-full min-h-[400px] items-center justify-center rounded-lg border p-6"
style={{
borderColor: CHART_COLORS.tooltipBorder,
backgroundColor: CHART_COLORS.tooltipBackground,
@@ -198,7 +198,7 @@ function EmptyState() {
function LoadingState({ height }: { height: number }) {
const CHART_COLORS = {
textSecondary: "var(--text-neutral-secondary)",
textSecondary: "var(--chart-text-secondary)",
};
return (
@@ -382,10 +382,10 @@ export function MapChart({
]);
const CHART_COLORS = {
tooltipBorder: "var(--border-neutral-tertiary)",
tooltipBackground: "var(--bg-neutral-secondary)",
textPrimary: "var(--text-neutral-primary)",
textSecondary: "var(--text-neutral-secondary)",
tooltipBorder: "var(--chart-border-emphasis)",
tooltipBackground: "var(--chart-background)",
textPrimary: "var(--chart-text-primary)",
textSecondary: "var(--chart-text-secondary)",
};
return (

View File

@@ -1,6 +1,5 @@
"use client";
import { type MouseEvent } from "react";
import {
PolarAngleAxis,
PolarGrid,
@@ -15,6 +14,7 @@ import {
} from "@/components/ui/chart/Chart";
import { AlertPill } from "./shared/alert-pill";
import { CHART_COLORS } from "./shared/constants";
import { RadarDataPoint } from "./types";
interface RadarChartProps {
@@ -32,41 +32,36 @@ const chartConfig = {
},
} satisfies ChartConfig;
interface TooltipPayloadItem {
payload: RadarDataPoint;
}
interface TooltipProps {
active?: boolean;
payload?: TooltipPayloadItem[];
}
const CustomTooltip = ({ active, payload }: TooltipProps) => {
const CustomTooltip = ({ active, payload }: any) => {
if (active && payload && payload.length) {
const data = payload[0];
return (
<div className="border-border-neutral-tertiary bg-bg-neutral-tertiary pointer-events-none min-w-[200px] rounded-xl border p-3 shadow-lg">
<p className="text-text-neutral-primary text-sm font-semibold">
<div
className="rounded-lg border p-3 shadow-lg"
style={{
borderColor: CHART_COLORS.tooltipBorder,
backgroundColor: CHART_COLORS.tooltipBackground,
}}
>
<p
className="text-sm font-semibold"
style={{ color: CHART_COLORS.textPrimary }}
>
{data.payload.category}
</p>
<div className="mt-1">
<AlertPill value={data.payload.value} />
<AlertPill value={data.value} />
</div>
{data.payload.change !== undefined && (
<p className="text-text-neutral-secondary mt-1 text-sm font-medium">
<span
style={{
color:
data.payload.change > 0
? "var(--bg-pass-primary)"
: "var(--bg-data-critical)",
fontWeight: "bold",
}}
>
{(data.payload.change as number) > 0 ? "+" : ""}
{data.payload.change}%{" "}
</span>
since last scan
<p
className="mt-1 text-xs"
style={{ color: CHART_COLORS.textSecondary }}
>
<span className="font-bold">
{data.payload.change > 0 ? "+" : ""}
{data.payload.change}%
</span>{" "}
Since Last Scan
</p>
)}
</div>
@@ -75,46 +70,21 @@ const CustomTooltip = ({ active, payload }: TooltipProps) => {
return null;
};
interface DotShapeProps {
cx: number;
cy: number;
payload: RadarDataPoint & { name?: string };
key: string;
}
interface CustomDotProps extends DotShapeProps {
selectedPoint?: RadarDataPoint | null;
onSelectPoint?: (point: RadarDataPoint | null) => void;
data?: RadarDataPoint[];
}
const CustomDot = ({
cx,
cy,
payload,
selectedPoint,
onSelectPoint,
data,
}: CustomDotProps) => {
const currentCategory = payload.name || payload.category;
const CustomDot = (props: any) => {
const { cx, cy, payload, selectedPoint, onSelectPoint } = props;
const currentCategory = payload.category || payload.name;
const isSelected = selectedPoint?.category === currentCategory;
const handleClick = (e: MouseEvent) => {
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
if (onSelectPoint) {
// Re-evaluate selection status at click time, not from closure
const currentlySelected = selectedPoint?.category === currentCategory;
if (currentlySelected) {
if (isSelected) {
onSelectPoint(null);
} else {
const fullDataItem = data?.find(
(d: RadarDataPoint) => d.category === currentCategory,
);
const point: RadarDataPoint = {
const point = {
category: currentCategory,
value: payload.value,
change: payload.change,
severityData: fullDataItem?.severityData || payload.severityData,
};
onSelectPoint(point);
}
@@ -126,11 +96,12 @@ const CustomDot = ({
cx={cx}
cy={cy}
r={isSelected ? 9 : 6}
fill={
isSelected ? "var(--chart-success-color)" : "var(--chart-radar-primary)"
}
fillOpacity={1}
className={isSelected ? "drop-shadow-[0_0_8px_#86da26]" : ""}
style={{
fill: isSelected
? "var(--bg-button-primary)"
: "var(--bg-radar-button)",
cursor: onSelectPoint ? "pointer" : "default",
pointerEvents: "all",
}}
@@ -156,33 +127,30 @@ export function RadarChart({
<ChartTooltip cursor={false} content={<CustomTooltip />} />
<PolarAngleAxis
dataKey="category"
tick={{ fill: "var(--color-text-neutral-primary)" }}
tick={{ fill: CHART_COLORS.textPrimary }}
/>
<PolarGrid strokeOpacity={0.3} />
<Radar
dataKey={dataKey}
fill="var(--bg-radar-map)"
fillOpacity={1}
fill="var(--chart-radar-primary)"
fillOpacity={0.2}
activeDot={false}
dot={
onSelectPoint
? (dotProps: DotShapeProps) => {
const { key, cx, cy, payload } = dotProps;
? (dotProps: any) => {
const { key, ...rest } = dotProps;
return (
<CustomDot
key={key}
cx={cx}
cy={cy}
payload={payload}
{...rest}
selectedPoint={selectedPoint}
onSelectPoint={onSelectPoint}
data={data}
/>
);
}
: {
r: 6,
fill: "var(--bg-radar-map)",
fill: "var(--chart-radar-primary)",
fillOpacity: 1,
}
}

View File

@@ -1,9 +1,10 @@
"use client";
import { useEffect, useState } from "react";
import { useState } from "react";
import { Rectangle, ResponsiveContainer, Sankey, Tooltip } from "recharts";
import { ChartTooltip } from "./shared/chart-tooltip";
import { CHART_COLORS } from "./shared/constants";
interface SankeyNode {
name: string;
@@ -46,148 +47,54 @@ interface NodeTooltipState {
change?: number;
}
const TOOLTIP_OFFSET_PX = 10;
// Map color names to CSS variable names defined in globals.css
const COLOR_MAP: Record<string, string> = {
Success: "--color-bg-pass",
Fail: "--color-bg-fail",
AWS: "--color-bg-data-aws",
Azure: "--color-bg-data-azure",
"Google Cloud": "--color-bg-data-gcp",
Critical: "--color-bg-data-critical",
High: "--color-bg-data-high",
Medium: "--color-bg-data-medium",
Low: "--color-bg-data-low",
Info: "--color-bg-data-info",
Informational: "--color-bg-data-info",
// Note: Using hex colors directly because Recharts SVG fill doesn't resolve CSS variables
const COLORS: Record<string, string> = {
Success: "#86da26",
Fail: "#db2b49",
AWS: "#ff9900",
Azure: "#00bcd4",
Google: "#EA4335",
Critical: "#971348",
High: "#ff3077",
Medium: "#ff7d19",
Low: "#fdd34f",
Info: "#2e51b2",
Informational: "#2e51b2",
};
/**
* Compute color value from CSS variable name at runtime.
* SVG fill attributes cannot directly resolve CSS variables,
* so we extract computed values from globals.css CSS variables.
* Falls back to black (#000000) if variable not found or access fails.
*
* @param colorName - Key in COLOR_MAP (e.g., "AWS", "Fail")
* @returns Computed CSS variable value or fallback color
*/
const getColorVariable = (colorName: string): string => {
const varName = COLOR_MAP[colorName];
if (!varName) return "#000000";
try {
if (typeof document === "undefined") {
// SSR context - return fallback
return "#000000";
}
return (
getComputedStyle(document.documentElement)
.getPropertyValue(varName)
.trim() || "#000000"
);
} catch (error: unknown) {
// CSS variables not loaded or access failed - return fallback
return "#000000";
}
};
// Initialize all color variables from CSS
const initializeColors = (): Record<string, string> => {
const colors: Record<string, string> = {};
for (const [colorName] of Object.entries(COLOR_MAP)) {
colors[colorName] = getColorVariable(colorName);
}
return colors;
};
interface TooltipPayload {
payload: {
source?: { name: string };
target?: { name: string };
value?: number;
name?: string;
};
}
interface TooltipProps {
active?: boolean;
payload?: TooltipPayload[];
}
interface CustomNodeProps {
x: number;
y: number;
width: number;
height: number;
payload: SankeyNode & {
value: number;
newFindings?: number;
change?: number;
};
containerWidth: number;
colors: Record<string, string>;
onNodeHover?: (data: Omit<NodeTooltipState, "show">) => void;
onNodeMove?: (position: { x: number; y: number }) => void;
onNodeLeave?: () => void;
}
interface CustomLinkProps {
sourceX: number;
targetX: number;
sourceY: number;
targetY: number;
sourceControlX: number;
targetControlX: number;
linkWidth: number;
index: number;
payload: {
source?: { name: string };
target?: { name: string };
value?: number;
};
hoveredLink: number | null;
colors: Record<string, string>;
onLinkHover?: (index: number, data: Omit<LinkTooltipState, "show">) => void;
onLinkMove?: (position: { x: number; y: number }) => void;
onLinkLeave?: () => void;
}
const CustomTooltip = ({ active, payload }: TooltipProps) => {
const CustomTooltip = ({ active, payload }: any) => {
if (active && payload && payload.length) {
const data = payload[0].payload;
const sourceName = data.source?.name || data.name;
const targetName = data.target?.name;
const value = data.value;
return (
<div className="chart-tooltip">
<p className="chart-tooltip-title">
{sourceName}
{targetName ? `${targetName}` : ""}
<div
className="rounded-lg border p-3 shadow-lg"
style={{
borderColor: CHART_COLORS.tooltipBorder,
backgroundColor: CHART_COLORS.tooltipBackground,
}}
>
<p
className="text-sm font-semibold"
style={{ color: CHART_COLORS.textPrimary }}
>
{data.name}
</p>
{value && <p className="chart-tooltip-subtitle">{value}</p>}
{data.value && (
<p className="text-xs" style={{ color: CHART_COLORS.textSecondary }}>
Value: {data.value}
</p>
)}
</div>
);
}
return null;
};
const CustomNode = ({
x,
y,
width,
height,
payload,
containerWidth,
colors,
onNodeHover,
onNodeMove,
onNodeLeave,
}: CustomNodeProps) => {
const CustomNode = (props: any) => {
const { x, y, width, height, payload, containerWidth } = props;
const isOut = x + width + 6 > containerWidth;
const nodeName = payload.name;
const color = colors[nodeName] || "var(--color-text-neutral-tertiary)";
const color = COLORS[nodeName] || CHART_COLORS.defaultColor;
const isHidden = nodeName === "";
const hasTooltip = !isHidden && payload.newFindings;
@@ -197,7 +104,7 @@ const CustomNode = ({
const rect = e.currentTarget.closest("svg") as SVGSVGElement;
if (rect) {
const bbox = rect.getBoundingClientRect();
onNodeHover?.({
props.onNodeHover?.({
x: e.clientX - bbox.left,
y: e.clientY - bbox.top,
name: nodeName,
@@ -215,7 +122,7 @@ const CustomNode = ({
const rect = e.currentTarget.closest("svg") as SVGSVGElement;
if (rect) {
const bbox = rect.getBoundingClientRect();
onNodeMove?.({
props.onNodeMove?.({
x: e.clientX - bbox.left,
y: e.clientY - bbox.top,
});
@@ -224,7 +131,7 @@ const CustomNode = ({
const handleMouseLeave = () => {
if (!hasTooltip) return;
onNodeLeave?.();
props.onNodeLeave?.();
};
return (
@@ -249,7 +156,7 @@ const CustomNode = ({
x={isOut ? x - 6 : x + width + 6}
y={y + height / 2}
fontSize="14"
fill="var(--color-text-neutral-primary)"
fill={CHART_COLORS.textPrimary}
>
{nodeName}
</text>
@@ -258,7 +165,7 @@ const CustomNode = ({
x={isOut ? x - 6 : x + width + 6}
y={y + height / 2 + 13}
fontSize="12"
fill="var(--color-text-neutral-secondary)"
fill={CHART_COLORS.textSecondary}
>
{payload.value}
</text>
@@ -268,30 +175,26 @@ const CustomNode = ({
);
};
const CustomLink = ({
sourceX,
targetX,
sourceY,
targetY,
sourceControlX,
targetControlX,
linkWidth,
index,
payload,
hoveredLink,
colors,
onLinkHover,
onLinkMove,
onLinkLeave,
}: CustomLinkProps) => {
const sourceName = payload.source?.name || "";
const targetName = payload.target?.name || "";
const value = payload.value || 0;
const color = colors[sourceName] || "var(--color-text-neutral-tertiary)";
const CustomLink = (props: any) => {
const {
sourceX,
targetX,
sourceY,
targetY,
sourceControlX,
targetControlX,
linkWidth,
index,
} = props;
const sourceName = props.payload.source?.name || "";
const targetName = props.payload.target?.name || "";
const value = props.payload.value || 0;
const color = COLORS[sourceName] || CHART_COLORS.defaultColor;
const isHidden = targetName === "";
const isHovered = hoveredLink !== null && hoveredLink === index;
const hasHoveredLink = hoveredLink !== null;
const isHovered = props.hoveredLink !== null && props.hoveredLink === index;
const hasHoveredLink = props.hoveredLink !== null;
const pathD = `
M${sourceX},${sourceY + linkWidth / 2}
@@ -316,7 +219,7 @@ const CustomLink = ({
?.parentElement as unknown as SVGSVGElement;
if (rect) {
const bbox = rect.getBoundingClientRect();
onLinkHover?.(index, {
props.onLinkHover?.(index, {
x: e.clientX - bbox.left,
y: e.clientY - bbox.top,
sourceName,
@@ -332,7 +235,7 @@ const CustomLink = ({
?.parentElement as unknown as SVGSVGElement;
if (rect && isHovered) {
const bbox = rect.getBoundingClientRect();
onLinkMove?.({
props.onLinkMove?.({
x: e.clientX - bbox.left,
y: e.clientY - bbox.top,
});
@@ -340,7 +243,7 @@ const CustomLink = ({
};
const handleMouseLeave = () => {
onLinkLeave?.();
props.onLinkLeave?.();
};
return (
@@ -361,7 +264,6 @@ const CustomLink = ({
export function SankeyChart({ data, height = 400 }: SankeyChartProps) {
const [hoveredLink, setHoveredLink] = useState<number | null>(null);
const [colors, setColors] = useState<Record<string, string>>({});
const [linkTooltip, setLinkTooltip] = useState<LinkTooltipState>({
show: false,
x: 0,
@@ -381,11 +283,6 @@ export function SankeyChart({ data, height = 400 }: SankeyChartProps) {
color: "",
});
// Initialize colors from CSS variables on mount
useEffect(() => {
setColors(initializeColors());
}, []);
const handleLinkHover = (
index: number,
data: Omit<LinkTooltipState, "show">,
@@ -423,45 +320,26 @@ export function SankeyChart({ data, height = 400 }: SankeyChartProps) {
setNodeTooltip((prev) => ({ ...prev, show: false }));
};
// Create callback references that wrap custom props and Recharts-injected props
const wrappedCustomNode = (
props: Omit<
CustomNodeProps,
"colors" | "onNodeHover" | "onNodeMove" | "onNodeLeave"
>,
) => (
<CustomNode
{...props}
colors={colors}
onNodeHover={handleNodeHover}
onNodeMove={handleNodeMove}
onNodeLeave={handleNodeLeave}
/>
);
const wrappedCustomLink = (
props: Omit<
CustomLinkProps,
"colors" | "hoveredLink" | "onLinkHover" | "onLinkMove" | "onLinkLeave"
>,
) => (
<CustomLink
{...props}
colors={colors}
hoveredLink={hoveredLink}
onLinkHover={handleLinkHover}
onLinkMove={handleLinkMove}
onLinkLeave={handleLinkLeave}
/>
);
return (
<div className="relative">
<ResponsiveContainer width="100%" height={height}>
<Sankey
data={data}
node={wrappedCustomNode}
link={wrappedCustomLink}
node={
<CustomNode
onNodeHover={handleNodeHover}
onNodeMove={handleNodeMove}
onNodeLeave={handleNodeLeave}
/>
}
link={
<CustomLink
hoveredLink={hoveredLink}
onLinkHover={handleLinkHover}
onLinkMove={handleLinkMove}
onLinkLeave={handleLinkLeave}
/>
}
nodePadding={50}
margin={{ top: 20, right: 160, bottom: 20, left: 160 }}
sort={false}
@@ -473,9 +351,9 @@ export function SankeyChart({ data, height = 400 }: SankeyChartProps) {
<div
className="pointer-events-none absolute z-50"
style={{
left: `${Math.max(TOOLTIP_OFFSET_PX, linkTooltip.x)}px`,
top: `${Math.max(TOOLTIP_OFFSET_PX, linkTooltip.y)}px`,
transform: `translate(${TOOLTIP_OFFSET_PX}px, -100%)`,
left: `${Math.max(125, Math.min(linkTooltip.x, window.innerWidth - 125))}px`,
top: `${Math.max(linkTooltip.y - 80, 10)}px`,
transform: "translate(-50%, -100%)",
}}
>
<ChartTooltip
@@ -498,9 +376,9 @@ export function SankeyChart({ data, height = 400 }: SankeyChartProps) {
<div
className="pointer-events-none absolute z-50"
style={{
left: `${Math.max(TOOLTIP_OFFSET_PX, nodeTooltip.x)}px`,
top: `${Math.max(TOOLTIP_OFFSET_PX, nodeTooltip.y)}px`,
transform: `translate(${TOOLTIP_OFFSET_PX}px, -100%)`,
left: `${Math.max(125, Math.min(nodeTooltip.x, window.innerWidth - 125))}px`,
top: `${Math.max(nodeTooltip.y - 80, 10)}px`,
transform: "translate(-50%, -100%)",
}}
>
<ChartTooltip

View File

@@ -13,6 +13,7 @@ import {
import { AlertPill } from "./shared/alert-pill";
import { ChartLegend } from "./shared/chart-legend";
import { CHART_COLORS } from "./shared/constants";
import { getSeverityColorByRiskScore } from "./shared/utils";
import type { ScatterDataPoint } from "./types";
@@ -26,18 +27,12 @@ interface ScatterPlotProps {
}
const PROVIDER_COLORS = {
AWS: "var(--color-bg-data-aws)",
Azure: "var(--color-bg-data-azure)",
Google: "var(--color-bg-data-gcp)",
Default: "var(--color-text-neutral-tertiary)",
AWS: "var(--chart-provider-aws)",
Azure: "var(--chart-provider-azure)",
Google: "var(--chart-provider-google)",
};
interface ScatterTooltipProps {
active?: boolean;
payload?: Array<{ payload: ScatterDataPoint }>;
}
const CustomTooltip = ({ active, payload }: ScatterTooltipProps) => {
const CustomTooltip = ({ active, payload }: any) => {
if (active && payload && payload.length) {
const data = payload[0].payload;
const severityColor = getSeverityColorByRiskScore(data.x);
@@ -46,19 +41,19 @@ const CustomTooltip = ({ active, payload }: ScatterTooltipProps) => {
<div
className="rounded-lg border p-3 shadow-lg"
style={{
borderColor: "var(--color-border-neutral-tertiary)",
backgroundColor: "var(--color-bg-neutral-secondary)",
borderColor: CHART_COLORS.tooltipBorder,
backgroundColor: CHART_COLORS.tooltipBackground,
}}
>
<p
className="text-sm font-semibold"
style={{ color: "var(--color-text-neutral-primary)" }}
style={{ color: CHART_COLORS.textPrimary }}
>
{data.name}
</p>
<p
className="mt-1 text-xs"
style={{ color: "var(--color-text-neutral-secondary)" }}
style={{ color: CHART_COLORS.textSecondary }}
>
<span style={{ color: severityColor }}>{data.x}</span> Risk Score
</p>
@@ -71,27 +66,19 @@ const CustomTooltip = ({ active, payload }: ScatterTooltipProps) => {
return null;
};
interface ScatterDotProps {
cx: number;
cy: number;
payload: ScatterDataPoint;
selectedPoint?: ScatterDataPoint | null;
onSelectPoint?: (point: ScatterDataPoint) => void;
}
const CustomScatterDot = ({
cx,
cy,
payload,
selectedPoint,
onSelectPoint,
}: ScatterDotProps) => {
}: any) => {
const isSelected = selectedPoint?.name === payload.name;
const size = isSelected ? 18 : 8;
const fill = isSelected
? "#86DA26"
: PROVIDER_COLORS[payload.provider as keyof typeof PROVIDER_COLORS] ||
"var(--color-text-neutral-tertiary)";
CHART_COLORS.defaultColor;
return (
<circle
@@ -108,17 +95,8 @@ const CustomScatterDot = ({
);
};
interface LegendPayloadItem {
value: string;
color: string;
}
interface LegendProps {
payload?: LegendPayloadItem[];
}
const CustomLegend = ({ payload }: LegendProps) => {
const items = (payload || []).map((entry) => ({
const CustomLegend = ({ payload }: any) => {
const items = payload.map((entry: any) => ({
label: entry.value,
color: entry.color,
}));
@@ -158,22 +136,19 @@ export function ScatterPlot({
return (
<ResponsiveContainer width="100%" height={height}>
<ScatterChart margin={{ top: 20, right: 30, bottom: 60, left: 60 }}>
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--color-border-neutral-tertiary)"
/>
<ScatterChart margin={{ top: 20, right: 20, bottom: 20, left: 20 }}>
<CartesianGrid strokeDasharray="3 3" stroke={CHART_COLORS.gridLine} />
<XAxis
type="number"
dataKey="x"
name={xLabel}
label={{
value: xLabel,
position: "bottom",
offset: 10,
fill: "var(--color-text-neutral-secondary)",
position: "insideBottom",
offset: -10,
fill: CHART_COLORS.textSecondary,
}}
tick={{ fill: "var(--color-text-neutral-secondary)" }}
tick={{ fill: CHART_COLORS.textSecondary }}
domain={[0, 10]}
/>
<YAxis
@@ -183,11 +158,10 @@ export function ScatterPlot({
label={{
value: yLabel,
angle: -90,
position: "left",
offset: 10,
fill: "var(--color-text-neutral-secondary)",
position: "insideLeft",
fill: CHART_COLORS.textSecondary,
}}
tick={{ fill: "var(--color-text-neutral-secondary)" }}
tick={{ fill: CHART_COLORS.textSecondary }}
/>
<Tooltip content={<CustomTooltip />} />
<Legend content={<CustomLegend />} />
@@ -198,18 +172,15 @@ export function ScatterPlot({
data={points}
fill={
PROVIDER_COLORS[provider as keyof typeof PROVIDER_COLORS] ||
PROVIDER_COLORS.Default
CHART_COLORS.defaultColor
}
shape={(props: unknown) => {
const dotProps = props as ScatterDotProps;
return (
<CustomScatterDot
{...dotProps}
selectedPoint={selectedPoint}
onSelectPoint={handlePointClick}
/>
);
}}
shape={(props: any) => (
<CustomScatterDot
{...props}
selectedPoint={selectedPoint}
onSelectPoint={handlePointClick}
/>
)}
/>
))}
</ScatterChart>

View File

@@ -1,6 +1,6 @@
import { AlertTriangle } from "lucide-react";
import { cn } from "@/lib";
import { cn } from "@/lib/utils";
interface AlertPillProps {
value: number;
@@ -9,41 +9,30 @@ interface AlertPillProps {
textSize?: "xs" | "sm" | "base";
}
const TEXT_SIZE_CLASSES = {
sm: "text-sm",
base: "text-base",
xs: "text-xs",
} as const;
export function AlertPill({
value,
label = "Fail Findings",
iconSize = 12,
textSize = "xs",
}: AlertPillProps) {
const textSizeClass = TEXT_SIZE_CLASSES[textSize];
// Chart alert colors are theme-aware variables from globals.css
return (
<div className="flex items-center gap-2">
<div
className="flex items-center gap-1 rounded-full px-2 py-1"
style={{ backgroundColor: "var(--color-bg-fail-secondary)" }}
style={{ backgroundColor: "var(--chart-alert-bg)" }}
>
<AlertTriangle
size={iconSize}
style={{ color: "var(--color-text-error)" }}
style={{ color: "var(--chart-alert-text)" }}
/>
<span
className={cn(textSizeClass, "font-semibold")}
style={{ color: "var(--color-text-error)" }}
className={cn(`text-${textSize}`, "font-semibold")}
style={{ color: "var(--chart-alert-text)" }}
>
{value}
</span>
</div>
<span className="text-text-neutral-secondary text-sm font-medium">
{label}
</span>
<span className={cn(`text-${textSize}`, "text-slate-400")}>{label}</span>
</div>
);
}

View File

@@ -9,17 +9,20 @@ interface ChartLegendProps {
export function ChartLegend({ items }: ChartLegendProps) {
return (
<div className="border-border-neutral-tertiary bg-bg-neutral-tertiary inline-flex items-center gap-2 rounded-full border">
<div
className="mt-4 inline-flex gap-[46px] rounded-full border-2 px-[19px] py-[9px]"
style={{ borderColor: "var(--chart-border)" }}
>
{items.map((item, index) => (
<div
key={`legend-${index}`}
className="flex items-center gap-2 px-4 py-3"
>
<div key={`legend-${index}`} className="flex items-center gap-1">
<div
className="h-3 w-3 rounded"
style={{ backgroundColor: item.color }}
/>
<span className="text-text-neutral-secondary text-sm font-medium">
<span
className="text-xs"
style={{ color: "var(--chart-text-secondary)" }}
>
{item.label}
</span>
</div>

View File

@@ -3,18 +3,11 @@ import { Bell, VolumeX } from "lucide-react";
import { cn } from "@/lib/utils";
import { TooltipData } from "../types";
interface MultiSeriesPayloadEntry {
color?: string;
name?: string;
value?: string | number;
dataKey?: string;
payload?: Record<string, string | number | undefined>;
}
import { CHART_COLORS } from "./constants";
interface ChartTooltipProps {
active?: boolean;
payload?: MultiSeriesPayloadEntry[];
payload?: any[];
label?: string;
showColorIndicator?: boolean;
colorIndicatorShape?: "circle" | "square";
@@ -31,11 +24,17 @@ export function ChartTooltip({
return null;
}
const data: TooltipData = (payload[0].payload || payload[0]) as TooltipData;
const data: TooltipData = payload[0].payload || payload[0];
const color = payload[0].color || data.color;
return (
<div className="border-border-neutral-tertiary bg-bg-neutral-tertiary pointer-events-none min-w-[200px] rounded-xl border p-3 shadow-lg">
<div
className="min-w-[200px] rounded-lg border border-slate-200 bg-white p-3 shadow-lg dark:border-slate-600 dark:bg-slate-800"
style={{
borderColor: CHART_COLORS.tooltipBorder,
backgroundColor: CHART_COLORS.tooltipBackground,
}}
>
<div className="flex items-center gap-2">
{showColorIndicator && color && (
<div
@@ -46,12 +45,12 @@ export function ChartTooltip({
style={{ backgroundColor: color }}
/>
)}
<p className="text-text-neutral-primary text-sm font-semibold">
<p className="text-sm font-semibold text-slate-900 dark:text-white">
{label || data.name}
</p>
</div>
<p className="text-text-neutral-secondary mt-1 text-sm font-medium">
<p className="mt-1 text-xs text-slate-900 dark:text-white">
{typeof data.value === "number"
? data.value.toLocaleString()
: data.value}
@@ -60,8 +59,8 @@ export function ChartTooltip({
{data.newFindings !== undefined && data.newFindings > 0 && (
<div className="mt-1 flex items-center gap-2">
<Bell size={14} className="text-text-neutral-secondary" />
<span className="text-text-neutral-secondary text-sm font-medium">
<Bell size={14} className="text-slate-600 dark:text-slate-400" />
<span className="text-xs text-slate-600 dark:text-slate-400">
{data.newFindings} New Findings
</span>
</div>
@@ -69,8 +68,8 @@ export function ChartTooltip({
{data.new !== undefined && data.new > 0 && (
<div className="mt-1 flex items-center gap-2">
<Bell size={14} className="text-text-neutral-secondary" />
<span className="text-text-neutral-secondary text-sm font-medium">
<Bell size={14} className="text-slate-600 dark:text-slate-400" />
<span className="text-xs text-slate-600 dark:text-slate-400">
{data.new} New
</span>
</div>
@@ -78,17 +77,17 @@ export function ChartTooltip({
{data.muted !== undefined && data.muted > 0 && (
<div className="mt-1 flex items-center gap-2">
<VolumeX size={14} className="text-text-neutral-secondary" />
<span className="text-text-neutral-secondary text-sm font-medium">
<VolumeX size={14} className="text-slate-600 dark:text-slate-400" />
<span className="text-xs text-slate-600 dark:text-slate-400">
{data.muted} Muted
</span>
</div>
)}
{data.change !== undefined && (
<p className="text-text-neutral-secondary mt-1 text-sm font-medium">
<p className="mt-1 text-xs text-slate-600 dark:text-slate-400">
<span className="font-bold">
{(data.change as number) > 0 ? "+" : ""}
{data.change > 0 ? "+" : ""}
{data.change}%
</span>{" "}
Since Last Scan
@@ -111,29 +110,26 @@ export function MultiSeriesChartTooltip({
}
return (
<div className="border-border-neutral-tertiary bg-bg-neutral-tertiary pointer-events-none min-w-[200px] rounded-xl border p-3 shadow-lg">
<p className="text-text-neutral-primary mb-2 text-sm font-semibold">
<div className="min-w-[200px] rounded-lg border border-slate-200 bg-white p-3 shadow-lg dark:border-slate-600 dark:bg-slate-800">
<p className="mb-2 text-sm font-semibold text-slate-900 dark:text-white">
{label}
</p>
{payload.map((entry: MultiSeriesPayloadEntry, index: number) => (
{payload.map((entry: any, index: number) => (
<div key={index} className="flex items-center gap-2">
<div
className="h-2 w-2 rounded-full"
style={{ backgroundColor: entry.color }}
/>
<span className="text-text-neutral-secondary text-sm font-medium">
<span className="text-xs text-slate-900 dark:text-white">
{entry.name}:
</span>
<span className="text-text-neutral-secondary text-sm font-semibold">
<span className="text-xs font-semibold text-slate-900 dark:text-white">
{entry.value}
</span>
{entry.payload && entry.payload[`${entry.dataKey}_change`] && (
<span className="text-text-neutral-secondary text-sm font-medium">
(
{(entry.payload[`${entry.dataKey}_change`] as number) > 0
? "+"
: ""}
{entry.payload[`${entry.dataKey}_change`] && (
<span className="text-xs text-slate-600 dark:text-slate-400">
({entry.payload[`${entry.dataKey}_change`] > 0 ? "+" : ""}
{entry.payload[`${entry.dataKey}_change`]}%)
</span>
)}

View File

@@ -1,3 +1,34 @@
export const SEVERITY_COLORS = {
Informational: "var(--bg-data-info)",
Low: "var(--bg-data-low)",
Medium: "var(--bg-data-medium)",
High: "var(--bg-data-high)",
Critical: "var(--bg-data-critical)",
} as const;
export const PROVIDER_COLORS = {
AWS: "var(--chart-provider-aws)",
Azure: "var(--chart-provider-azure)",
Google: "var(--chart-provider-google)",
} as const;
export const STATUS_COLORS = {
Success: "var(--chart-success-color)",
Fail: "var(--chart-fail)",
} as const;
export const CHART_COLORS = {
tooltipBorder: "var(--chart-border-emphasis)",
tooltipBackground: "var(--chart-background)",
textPrimary: "var(--chart-text-primary)",
textSecondary: "var(--chart-text-secondary)",
gridLine: "var(--chart-border-emphasis)",
backgroundTrack: "rgba(51, 65, 85, 0.5)", // slate-700 with 50% opacity
alertPillBg: "var(--chart-alert-bg)",
alertPillText: "var(--chart-alert-text)",
defaultColor: "#64748b", // slate-500
} as const;
export const CHART_DIMENSIONS = {
defaultHeight: 400,
tooltipMinWidth: "200px",

View File

@@ -1,75 +0,0 @@
export const AXIS_FONT_SIZE = 14;
const TODAY_FONT_SIZE = 12;
interface CustomXAxisTickProps {
x: number;
y: number;
payload: {
value: string | number;
};
}
const getTodayFormatted = () => {
const today = new Date();
return today.toLocaleDateString("en-US", {
month: "2-digit",
day: "2-digit",
});
};
export const CustomXAxisTickWithToday = Object.assign(
function CustomXAxisTickWithToday(props: CustomXAxisTickProps) {
const { x, y, payload } = props;
const todayFormatted = getTodayFormatted();
const isToday = String(payload.value) === todayFormatted;
return (
<g transform={`translate(${x},${y})`}>
<text
x={0}
y={20}
dy={4}
textAnchor="middle"
fill="var(--color-text-neutral-secondary)"
fontSize={AXIS_FONT_SIZE}
>
{payload.value}
</text>
{isToday && (
<text
x={0}
y={36}
textAnchor="middle"
fill="var(--color-text-neutral-secondary)"
fontSize={TODAY_FONT_SIZE}
fontWeight={400}
>
(today)
</text>
)}
</g>
);
},
{ displayName: "CustomXAxisTickWithToday" },
);
export const CustomXAxisTick = Object.assign(
function CustomXAxisTick(props: CustomXAxisTickProps) {
const { x, y, payload } = props;
return (
<g transform={`translate(${x},${y})`}>
<text
x={0}
y={20}
dy={4}
textAnchor="middle"
fill="var(--color-text-neutral-secondary)"
fontSize={AXIS_FONT_SIZE}
>
{payload.value}
</text>
</g>
);
},
{ displayName: "CustomXAxisTick" },
);

View File

@@ -1,12 +1,4 @@
const SEVERITY_COLORS = {
Critical: "var(--color-bg-data-critical)",
High: "var(--color-bg-data-high)",
Medium: "var(--color-bg-data-medium)",
Low: "var(--color-bg-data-low)",
Informational: "var(--color-bg-data-info)",
Info: "var(--color-bg-data-info)",
Muted: "var(--color-bg-data-muted)",
};
import { SEVERITY_COLORS } from "./constants";
export function getSeverityColorByRiskScore(riskScore: number): string {
if (riskScore >= 7) return SEVERITY_COLORS.Critical;

View File

@@ -1,570 +0,0 @@
"use client";
import * as d3 from "d3";
import type {
Feature,
FeatureCollection,
GeoJsonProperties,
Geometry,
} from "geojson";
import { AlertTriangle, ChevronDown, Info, MapPin } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { feature } from "topojson-client";
import type {
GeometryCollection,
Objects,
Topology,
} from "topojson-specification";
import { Card } from "@/components/shadcn/card/card";
import { HorizontalBarChart } from "./horizontal-bar-chart";
import { BarDataPoint } from "./types";
// Constants
const MAP_CONFIG = {
defaultWidth: 688,
defaultHeight: 400,
pointRadius: 6,
selectedPointRadius: 8,
transitionDuration: 300,
} as const;
// SVG-specific colors: must use actual color values, not Tailwind classes
// as SVG fill/stroke attributes don't support class-based styling
// Retrieves computed CSS variable values from globals.css theme variables at runtime
// Fallback hex colors are used only when CSS variables cannot be computed (SSR context)
interface MapColorsConfig {
landFill: string;
landStroke: string;
pointDefault: string;
pointSelected: string;
pointHover: string;
}
const DEFAULT_MAP_COLORS: MapColorsConfig = {
// Fallback: gray-300 (neutral-300) - used for map land fill in light theme
landFill: "#d1d5db",
// Fallback: slate-300 - used for map borders
landStroke: "#cbd5e1",
// Fallback: red-600 - error color for points
pointDefault: "#dc2626",
// Fallback: emerald-500 - success color for selected points
pointSelected: "#10b981",
// Fallback: red-600 - error color for hover points
pointHover: "#dc2626",
};
function getMapColors(): MapColorsConfig {
if (typeof document === "undefined") return DEFAULT_MAP_COLORS;
const root = document.documentElement;
const style = getComputedStyle(root);
const getVar = (varName: string): string => {
const value = style.getPropertyValue(varName).trim();
return value && value.length > 0 ? value : "";
};
const colors: MapColorsConfig = {
landFill: getVar("--bg-neutral-map") || DEFAULT_MAP_COLORS.landFill,
landStroke:
getVar("--border-neutral-tertiary") || DEFAULT_MAP_COLORS.landStroke,
pointDefault:
getVar("--text-error-primary") || DEFAULT_MAP_COLORS.pointDefault,
pointSelected:
getVar("--bg-button-primary") || DEFAULT_MAP_COLORS.pointSelected,
pointHover: getVar("--text-error-primary") || DEFAULT_MAP_COLORS.pointHover,
};
return colors;
}
const RISK_LEVELS = {
LOW_HIGH: "low-high",
HIGH: "high",
CRITICAL: "critical",
} as const;
type RiskLevel = (typeof RISK_LEVELS)[keyof typeof RISK_LEVELS];
interface LocationPoint {
id: string;
name: string;
region: string;
coordinates: [number, number];
totalFindings: number;
riskLevel: RiskLevel;
severityData: BarDataPoint[];
change?: number;
}
interface ThreatMapData {
locations: LocationPoint[];
regions: string[];
}
interface ThreatMapProps {
data: ThreatMapData;
height?: number;
onLocationSelect?: (location: LocationPoint | null) => void;
}
// Utility functions
function createProjection(width: number, height: number) {
return d3
.geoNaturalEarth1()
.fitExtent(
[
[1, 1],
[width - 1, height - 1],
],
{ type: "Sphere" },
)
.precision(0.2);
}
async function fetchWorldData(): Promise<FeatureCollection | null> {
try {
const worldAtlasModule = await import("world-atlas/countries-110m.json");
const worldData = worldAtlasModule.default || worldAtlasModule;
const topology = worldData as unknown as Topology<Objects>;
return feature(
topology,
topology.objects.countries as GeometryCollection,
) as FeatureCollection;
} catch (error) {
console.error("Error loading world map data:", error);
return null;
}
}
// Helper: Create SVG element
function createSVGElement<T extends SVGElement>(
type: string,
attributes: Record<string, string>,
): T {
const element = document.createElementNS(
"http://www.w3.org/2000/svg",
type,
) as T;
Object.entries(attributes).forEach(([key, value]) => {
element.setAttribute(key, value);
});
return element;
}
// Components
function MapTooltip({
location,
position,
}: {
location: LocationPoint;
position: { x: number; y: number };
}) {
return (
<div
className="border-border-neutral-tertiary bg-bg-neutral-tertiary pointer-events-none absolute z-50 min-w-[200px] rounded-xl border p-3 shadow-lg"
style={{
left: `${position.x + 15}px`,
top: `${position.y + 15}px`,
transform: "translate(0, -50%)",
}}
>
<div className="flex items-center gap-2">
<MapPin size={14} className="text-text-neutral-secondary" />
<span className="text-text-neutral-primary text-sm font-semibold">
{location.name}
</span>
</div>
<div className="mt-1 flex items-center gap-2">
<AlertTriangle size={14} className="text-bg-data-critical" />
<span className="text-text-neutral-secondary text-sm font-medium">
{location.totalFindings.toLocaleString()} Fail Findings
</span>
</div>
{location.change !== undefined && (
<p className="text-text-neutral-secondary mt-1 text-sm font-medium">
<span
className="font-bold"
style={{
color:
location.change > 0
? "var(--bg-pass-primary)"
: "var(--bg-fail-primary)",
}}
>
{location.change > 0 ? "+" : ""}
{location.change}%{" "}
</span>
since last scan
</p>
)}
</div>
);
}
function EmptyState() {
return (
<div className="flex h-full min-h-[400px] w-full items-center justify-center">
<div className="text-center">
<Info size={48} className="mx-auto mb-2 text-slate-500" />
<p className="text-sm text-slate-400">
Select a location on the map to view details
</p>
</div>
</div>
);
}
function LoadingState({ height }: { height: number }) {
return (
<div className="flex items-center justify-center" style={{ height }}>
<div className="text-center">
<div className="mb-2 text-slate-400">Loading map...</div>
</div>
</div>
);
}
export function ThreatMap({
data,
height = MAP_CONFIG.defaultHeight,
}: ThreatMapProps) {
const svgRef = useRef<SVGSVGElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [selectedLocation, setSelectedLocation] =
useState<LocationPoint | null>(null);
const [hoveredLocation, setHoveredLocation] = useState<LocationPoint | null>(
null,
);
const [tooltipPosition, setTooltipPosition] = useState<{
x: number;
y: number;
} | null>(null);
const [selectedRegion, setSelectedRegion] = useState<string>("All Regions");
const [worldData, setWorldData] = useState<FeatureCollection | null>(null);
const [isLoadingMap, setIsLoadingMap] = useState(true);
const [dimensions, setDimensions] = useState<{
width: number;
height: number;
}>({
width: MAP_CONFIG.defaultWidth,
height,
});
const [mapColors, setMapColors] =
useState<MapColorsConfig>(DEFAULT_MAP_COLORS);
const filteredLocations =
selectedRegion === "All Regions"
? data.locations
: data.locations.filter((loc) => loc.region === selectedRegion);
// Monitor theme changes and update colors
useEffect(() => {
const updateColors = () => {
setMapColors(getMapColors());
};
// Update colors immediately
updateColors();
// Watch for theme changes (dark class on document)
const observer = new MutationObserver(() => {
updateColors();
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
return () => observer.disconnect();
}, []);
// Fetch world data once on mount
useEffect(() => {
let isMounted = true;
fetchWorldData()
.then((data) => {
if (isMounted && data) setWorldData(data);
})
.catch(console.error)
.finally(() => {
if (isMounted) setIsLoadingMap(false);
});
return () => {
isMounted = false;
};
}, []);
// Update dimensions on resize
useEffect(() => {
const updateDimensions = () => {
if (containerRef.current) {
setDimensions({ width: containerRef.current.clientWidth, height });
}
};
updateDimensions();
window.addEventListener("resize", updateDimensions);
return () => window.removeEventListener("resize", updateDimensions);
}, [height]);
// Render the map
useEffect(() => {
if (!svgRef.current || !worldData || isLoadingMap) return;
const svg = svgRef.current;
const { width, height } = dimensions;
svg.innerHTML = "";
const projection = createProjection(width, height);
const path = d3.geoPath().projection(projection);
const colors = mapColors;
// Render countries
const mapGroup = createSVGElement<SVGGElement>("g", {
class: "map-countries",
});
worldData.features?.forEach(
(feature: Feature<Geometry, GeoJsonProperties>) => {
const pathData = path(feature);
if (pathData) {
const pathElement = createSVGElement<SVGPathElement>("path", {
d: pathData,
fill: colors.landFill,
stroke: colors.landStroke,
"stroke-width": "0.5",
});
mapGroup.appendChild(pathElement);
}
},
);
svg.appendChild(mapGroup);
// Helper to update tooltip position
const updateTooltip = (e: MouseEvent) => {
const rect = svg.getBoundingClientRect();
setTooltipPosition({
x: e.clientX - rect.left,
y: e.clientY - rect.top,
});
};
// Helper to create glow rings
const createGlowRing = (
cx: string,
cy: string,
radiusOffset: number,
color: string,
opacity: string,
): SVGCircleElement => {
return createSVGElement<SVGCircleElement>("circle", {
cx,
cy,
r: radiusOffset.toString(),
fill: "none",
stroke: color,
"stroke-width": "1",
opacity,
});
};
// Helper to create circle with glow
const createCircle = (location: LocationPoint) => {
const projected = projection(location.coordinates);
if (!projected) return null;
const [x, y] = projected;
if (x < 0 || x > width || y < 0 || y > height) return null;
const isSelected = selectedLocation?.id === location.id;
const isHovered = hoveredLocation?.id === location.id;
const group = createSVGElement<SVGGElement>("g", {
class: "cursor-pointer",
});
const radius = isSelected
? MAP_CONFIG.selectedPointRadius
: MAP_CONFIG.pointRadius;
const color = isSelected ? colors.pointSelected : colors.pointDefault;
// Add glow rings for all points (unselected and selected)
group.appendChild(
createGlowRing(x.toString(), y.toString(), radius + 4, color, "0.4"),
);
group.appendChild(
createGlowRing(x.toString(), y.toString(), radius + 8, color, "0.2"),
);
const circle = createSVGElement<SVGCircleElement>("circle", {
cx: x.toString(),
cy: y.toString(),
r: radius.toString(),
fill: color,
class: isHovered && !isSelected ? "opacity-70" : "",
});
group.appendChild(circle);
group.addEventListener("click", () =>
setSelectedLocation(isSelected ? null : location),
);
group.addEventListener("mouseenter", (e) => {
setHoveredLocation(location);
updateTooltip(e);
});
group.addEventListener("mousemove", updateTooltip);
group.addEventListener("mouseleave", () => {
setHoveredLocation(null);
setTooltipPosition(null);
});
return group;
};
// Render points
const pointsGroup = createSVGElement<SVGGElement>("g", {
class: "threat-points",
});
// Unselected points first
filteredLocations.forEach((location) => {
if (selectedLocation?.id !== location.id) {
const circle = createCircle(location);
if (circle) pointsGroup.appendChild(circle);
}
});
// Selected point last (on top)
if (selectedLocation) {
const selectedData = filteredLocations.find(
(loc) => loc.id === selectedLocation.id,
);
if (selectedData) {
const circle = createCircle(selectedData);
if (circle) pointsGroup.appendChild(circle);
}
}
svg.appendChild(pointsGroup);
}, [
dimensions,
filteredLocations,
selectedLocation,
hoveredLocation,
worldData,
isLoadingMap,
mapColors,
]);
return (
<div className="flex h-full w-full flex-col gap-4">
<div className="flex flex-1 gap-12 overflow-hidden">
{/* Map Section - in Card */}
<div className="flex basis-[70%] flex-col overflow-hidden">
<Card
ref={containerRef}
variant="base"
className="flex flex-1 flex-col overflow-hidden"
>
<div className="mb-4 flex items-center justify-between">
<h3 className="text-text-neutral-primary text-lg font-semibold">
Threat Map
</h3>
<div className="relative">
<select
aria-label="Filter threat map by region"
value={selectedRegion}
onChange={(e) => setSelectedRegion(e.target.value)}
className="border-border-neutral-primary bg-bg-neutral-secondary text-text-neutral-primary appearance-none rounded-lg border px-4 py-2 pr-10 text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
>
<option value="All Regions">All Regions</option>
{data.regions.map((region) => (
<option key={region} value={region}>
{region}
</option>
))}
</select>
<ChevronDown
size={16}
className="text-text-neutral-tertiary pointer-events-none absolute top-1/2 right-3 -translate-y-1/2"
/>
</div>
</div>
<div className="relative w-full flex-1">
{isLoadingMap ? (
<LoadingState height={dimensions.height} />
) : (
<>
<div className="relative h-full w-full">
<svg
ref={svgRef}
width={dimensions.width}
height={dimensions.height}
className="h-full w-full"
style={{ maxWidth: "100%", maxHeight: "100%" }}
preserveAspectRatio="xMidYMid meet"
/>
{hoveredLocation && tooltipPosition && (
<MapTooltip
location={hoveredLocation}
position={tooltipPosition}
/>
)}
</div>
<div
className="mt-3 flex items-center gap-2"
role="status"
aria-label={`${filteredLocations.length} threat locations on map`}
>
<div
aria-hidden="true"
className="h-3 w-3 rounded-full"
style={{ backgroundColor: "var(--bg-data-critical)" }}
/>
<span
className="text-sm"
style={{ color: "var(--text-neutral-tertiary)" }}
>
{filteredLocations.length} Locations
</span>
</div>
</>
)}
</div>
</Card>
</div>
{/* Details Section - No Card */}
<div className="flex basis-[30%] items-center overflow-hidden">
{selectedLocation ? (
<div className="flex w-full flex-col">
<div className="mb-4">
<div
className="mb-1 flex items-center gap-2"
aria-label={`Selected location: ${selectedLocation.name}`}
>
<div
aria-hidden="true"
className="bg-pass-primary h-2 w-2 rounded-full"
/>
<h4 className="text-neutral-primary text-base font-semibold">
{selectedLocation.name}
</h4>
</div>
<p className="text-neutral-tertiary text-xs">
{selectedLocation.totalFindings.toLocaleString()} Total
Findings
</p>
</div>
<HorizontalBarChart data={selectedLocation.severityData} />
</div>
) : (
<EmptyState />
)}
</div>
</div>
</div>
);
}

View File

@@ -34,7 +34,6 @@ export interface RadarDataPoint {
category: string;
value: number;
change?: number;
severityData?: BarDataPoint[];
}
export interface ScatterDataPoint {

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 510 KiB

After

Width:  |  Height:  |  Size: 31 KiB

View File

@@ -23,27 +23,27 @@ export interface ChartConfig {
const chartConfig = {
critical: {
label: "Critical",
color: "var(--color-bg-data-critical)",
color: "hsl(var(--chart-critical))",
link: "/findings?filter%5Bstatus__in%5D=FAIL&filter%5Bseverity__in%5D=critical",
},
high: {
label: "High",
color: "var(--color-bg-data-high)",
color: "hsl(var(--chart-fail))",
link: "/findings?filter%5Bstatus__in%5D=FAIL&filter%5Bseverity__in%5D=high",
},
medium: {
label: "Medium",
color: "var(--color-bg-data-medium)",
color: "hsl(var(--chart-medium))",
link: "/findings?filter%5Bstatus__in%5D=FAIL&filter%5Bseverity__in%5D=medium",
},
low: {
label: "Low",
color: "var(--color-bg-data-low)",
color: "hsl(var(--chart-low))",
link: "/findings?filter%5Bstatus__in%5D=FAIL&filter%5Bseverity__in%5D=low",
},
informational: {
label: "Informational",
color: "var(--color-bg-data-info)",
color: "hsl(var(--chart-informational))",
link: "/findings?filter%5Bstatus__in%5D=FAIL&filter%5Bseverity__in%5D=informational",
},
} satisfies ChartConfig;

View File

@@ -49,15 +49,15 @@ const chartConfig = {
},
success: {
label: "Success",
color: "var(--color-bg-pass)",
color: "hsl(var(--chart-success))",
},
fail: {
label: "Fail",
color: "var(--color-bg-fail)",
color: "hsl(var(--chart-fail))",
},
muted: {
label: "Muted",
color: "var(--color-bg-neutral-tertiary)",
color: "hsl(var(--chart-muted))",
},
} satisfies ChartConfig;

View File

@@ -8,9 +8,9 @@ const TRIGGER_STYLES = {
active:
"data-[state=active]:text-slate-900 dark:data-[state=active]:text-white",
underline:
"after:absolute after:bottom-0 after:left-1/2 after:h-0.5 after:w-0 after:-translate-x-1/2 after:bg-emerald-400 after:transition-all data-[state=active]:after:w-[calc(100%-theme(spacing.5))]",
"after:absolute after:bottom-0 after:left-1/2 after:h-0.5 after:w-0 after:-translate-x-1/2 after:bg-[#20B853] after:transition-all data-[state=active]:after:w-[calc(100%-theme(spacing.5))]",
focus:
"focus-visible:ring-2 focus-visible:ring-emerald-400 focus-visible:ring-offset-2 focus-visible:ring-offset-white focus-visible:outline-none dark:focus-visible:ring-offset-slate-950",
"focus-visible:ring-2 focus-visible:ring-[#20B853] focus-visible:ring-offset-2 focus-visible:ring-offset-white focus-visible:outline-none dark:focus-visible:ring-offset-slate-950",
icon: "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
} as const;

View File

@@ -15,7 +15,7 @@ Sentry.init({
dsn: process.env.SENTRY_DSN,
// 🌍 Environment configuration
environment: process.env.SENTRY_ENVIRONMENT || "local",
environment: process.env.SENTRY_ENVIRONMENT || "development",
// 📦 Release tracking
release: process.env.SENTRY_RELEASE,

View File

@@ -5,37 +5,59 @@
/* ===== LIGHT THEME (ROOT) ===== */
:root {
/* ===== LEGACY VARIABLES (CHART COLORS) ===== */
--chart-info: #3c8dff;
--chart-warning: #fdfbd4;
--chart-warning-emphasis: #fec94d;
--chart-danger: #f77852;
--chart-danger-emphasis: #ff006a;
--chart-success-color: #16a34a;
--chart-fail: #dc2626;
--chart-radar-primary: #9d174d;
--chart-text-primary: #1f2937;
--chart-text-secondary: #6b7280;
--chart-border: #d1d5db;
--chart-border-emphasis: #9ca3af;
--chart-background: #f9fafb;
--chart-alert-bg: #fecdd3;
--chart-alert-text: #be123c;
/* Chart HSL values */
--chart-success: 146 80% 35%;
--chart-fail: 339 90% 51%;
--chart-muted: 45 93% 47%;
--chart-critical: 336 75% 39%;
--chart-high: 339 90% 51%;
--chart-medium: 26 100% 55%;
--chart-low: 46 97% 65%;
--chart-1: 12 76% 61%;
--chart-2: 160 60% 45%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
/* ===== NEW VARIABLES (PROWLER-307) - SEMANTIC COLORS ===== */
/* Data Background Colors */
--bg-data-azure: var(--color-sky-400);
--bg-data-kubernetes: var(--color-indigo-600);
--bg-data-aws: var(--color-amber-500);
--bg-data-gcp: var(--color-red-500);
--bg-data-m365: var(--color-green-400);
--bg-data-github: var(--color-slate-950);
/* Button Colors */
--bg-button-primary: var(--color-emerald-300);
--bg-button-primary: var(--color-emerald-400);
--bg-button-primary-hover: var(--color-teal-200);
--bg-button-primary-press: var(--color-emerald-400);
--bg-button-secondary: var(--color-slate-950);
--bg-button-secondary-press: var(--color-slate-800);
--bg-button-secondary-press: var(--color-indigo-100);
--bg-button-tertiary: var(--color-blue-600);
--bg-button-tertiary-hover: var(--color-blue-500);
--bg-button-tertiary-active: var(--color-indigo-600);
--bg-button-disabled: var(--color-neutral-300);
/* Radar Map */
--bg-radar-map: #b51c8033;
--bg-radar-button: #b51c80;
/* Neutral Map */
--bg-neutral-map: var(--color-neutral-300);
--bg-button-disabled: var(--color-gray-300);
/* Input Colors */
--bg-input-primary: var(--color-white);
--border-input-primary: var(--color-slate-400);
--border-input-primary-press: var(--color-slate-700);
--border-input-primary-fill: var(--color-slate-500);
/* Text Colors */
--text-neutral-primary: var(--color-slate-950);
--text-neutral-secondary: var(--color-zinc-800);
--text-neutral-tertiary: var(--color-zinc-500);
--text-error-primary: var(--color-red-600);
--text-success-primary: var(--color-green-600);
--border-input-primary: var(--color-slate-600);
/* Border Colors */
--border-error-primary: var(--color-red-500);
@@ -45,6 +67,12 @@
--border-tag-primary: var(--color-gray-400);
--border-data-emphasis: rgba(0, 0, 0, 0.1);
/* Text Colors */
--text-neutral-primary: var(--color-slate-950);
--text-neutral-secondary: var(--color-zinc-800);
--text-neutral-tertiary: var(--color-zinc-500);
--text-error-primary: var(--color-red-600);
/* Background Colors */
--bg-neutral-primary: #fdfdfd;
--bg-neutral-secondary: var(--color-white);
@@ -56,21 +84,12 @@
--bg-fail-primary: var(--color-rose-500);
--bg-fail-secondary: var(--color-rose-50);
/* Data Background Colors */
--bg-data-azure: var(--color-sky-400);
--bg-data-kubernetes: var(--color-indigo-600);
--bg-data-aws: var(--color-amber-500);
--bg-data-gcp: var(--color-red-500);
--bg-data-m365: var(--color-green-400);
--bg-data-github: var(--color-slate-950);
/* Severity Colors */
--bg-data-critical: #ff006a;
--bg-data-high: #f77852;
--bg-data-medium: #fdd34f;
--bg-data-low: #f5f3ce;
--bg-data-info: #3c8dff;
--bg-data-muted: var(--color-neutral-500);
/* Chart Dots */
--chart-dots: var(--color-neutral-200);
@@ -78,53 +97,38 @@
/* ===== DARK THEME ===== */
.dark {
/* ===== LEGACY VARIABLES (CHART COLORS) ===== */
--chart-info: #3c8dff;
--chart-warning: #fdfbd4;
--chart-warning-emphasis: #fec94d;
--chart-danger: #f77852;
--chart-danger-emphasis: #ff006a;
--chart-success-color: #86da26;
--chart-fail: #db2b49;
--chart-radar-primary: #b51c80;
--chart-text-primary: #ffffff;
--chart-text-secondary: #94a3b8;
--chart-border: #475569;
--chart-border-emphasis: #334155;
--chart-background: #1e293b;
--chart-alert-bg: #432232;
--chart-alert-text: #f54280;
/* Chart HSL values */
--chart-success: 146 80% 35%;
--chart-fail: 339 90% 51%;
--chart-muted: 45 93% 47%;
--chart-critical: 336 75% 39%;
--chart-high: 339 90% 51%;
--chart-medium: 26 100% 55%;
--chart-low: 46 97% 65%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
/* ===== NEW VARIABLES (PROWLER-307) - SEMANTIC COLORS ===== */
/* Button Colors */
--bg-button-primary: var(--color-emerald-300);
--bg-button-primary-hover: var(--color-teal-200);
--bg-button-primary-press: var(--color-emerald-400);
--bg-button-secondary: var(--color-white);
--bg-button-secondary-press: var(--color-emerald-100);
--bg-button-tertiary: var(--color-blue-300);
--bg-button-tertiary-hover: var(--color-blue-400);
--bg-button-tertiary-active: var(--color-blue-600);
--bg-button-disabled: var(--color-neutral-700);
/* Neutral Map */
--bg-neutral-map: var(--color-gray-800);
/* Input Colors */
--bg-input-primary: var(--color-neutral-900);
--border-input-primary: var(--color-neutral-800);
--border-input-primary-press: var(--color-neutral-800);
--border-input-primary-fill: var(--color-neutral-800);
/* Text Colors */
--text-neutral-primary: var(--color-zinc-100);
--text-neutral-secondary: var(--color-zinc-300);
--text-neutral-tertiary: var(--color-zinc-400);
--text-error-primary: var(--color-red-500);
--text-success-primary: var(--color-green-500);
/* Border Colors */
--border-error-primary: var(--color-red-400);
--border-neutral-primary: var(--color-zinc-800);
--border-neutral-secondary: var(--color-zinc-900);
--border-neutral-tertiary: var(--color-zinc-900);
--border-tag-primary: var(--color-slate-700);
--border-data-emphasis: rgba(255, 255, 255, 0.1);
/* Background Colors */
--bg-neutral-primary: var(--color-zinc-950);
--bg-neutral-secondary: var(--color-stone-950);
--bg-neutral-tertiary: #121110;
--bg-tag-primary: var(--color-slate-950);
--bg-pass-primary: var(--color-green-400);
--bg-pass-secondary: var(--color-emerald-900);
--bg-warning-primary: var(--color-orange-400);
--bg-fail-primary: var(--color-rose-500);
--bg-fail-secondary: #432232;
/* Data Background Colors */
--bg-data-azure: var(--color-sky-400);
--bg-data-kubernetes: var(--color-indigo-600);
@@ -133,13 +137,51 @@
--bg-data-m365: var(--color-green-400);
--bg-data-github: var(--color-neutral-100);
/* Button Colors */
--bg-button-primary: var(--color-emerald-400);
--bg-button-primary-hover: var(--color-teal-200);
--bg-button-secondary: var(--color-slate-700);
--bg-button-secondary-press: var(--color-slate-800);
--bg-button-tertiary: var(--color-blue-500);
--bg-button-tertiary-hover: var(--color-blue-700);
--bg-button-tertiary-active: var(--color-blue-800);
--bg-button-disabled: var(--color-gray-600);
/* Input Colors */
--bg-input-primary: var(--color-slate-900);
--border-input-primary: var(--color-slate-700);
/* Border Colors */
--border-error-primary: var(--color-rose-500);
--border-neutral-primary: var(--color-zinc-800);
--border-neutral-secondary: var(--color-zinc-900);
--border-neutral-tertiary: var(--color-zinc-900);
--border-tag-primary: var(--color-slate-700);
--border-data-emphasis: rgba(255, 255, 255, 0.1);
/* Text Colors */
--text-neutral-primary: var(--color-zinc-100);
--text-neutral-secondary: var(--color-zinc-300);
--text-neutral-tertiary: var(--color-zinc-500);
--text-error-primary: var(--color-rose-300);
/* Background Colors */
--bg-neutral-primary: var(--color-zinc-950);
--bg-neutral-secondary: var(--color-stone-950);
--bg-neutral-tertiary: #121110;
--bg-tag-primary: var(--color-slate-950);
--bg-warning-primary: var(--color-orange-400);
--bg-pass-primary: var(--color-green-400);
--bg-pass-secondary: var(--color-emerald-900);
--bg-fail-primary: var(--color-rose-500);
--bg-fail-secondary: #432232;
/* Severity Colors */
--bg-data-critical: #ff006a;
--bg-data-high: #f77852;
--bg-data-medium: #fec94d;
--bg-data-low: #fdfbd4;
--bg-data-info: #3c8dff;
--bg-data-muted: var(--color-neutral-500);
/* Chart Dots */
--chart-dots: var(--text-neutral-primary);
@@ -178,12 +220,10 @@
--color-bg-data-medium: var(--bg-data-medium);
--color-bg-data-low: var(--bg-data-low);
--color-bg-data-info: var(--bg-data-info);
--color-bg-data-muted: var(--bg-data-muted);
/* Button Colors */
--color-button-primary: var(--bg-button-primary);
--color-button-primary-hover: var(--bg-button-primary-hover);
--color-button-primary-press: var(--bg-button-primary-press);
--color-button-secondary: var(--bg-button-secondary);
--color-button-secondary-press: var(--bg-button-secondary-press);
--color-button-tertiary: var(--bg-button-tertiary);
@@ -194,14 +234,6 @@
/* Input Colors */
--color-input-primary: var(--bg-input-primary);
--color-input-border: var(--border-input-primary);
--color-input-border-press: var(--border-input-primary-press);
--color-input-border-fill: var(--border-input-primary-fill);
/* Neutral Map Colors */
--color-bg-neutral-map: var(--bg-neutral-map);
/* Success Colors */
--color-text-success: var(--text-success-primary);
/* Border Colors */
--color-border-error: var(--border-error-primary);

View File

@@ -1,151 +0,0 @@
#!/bin/bash
# Prowler UI - Pre-Commit Hook
# Optionally validates ONLY staged files against AGENTS.md standards using Claude Code
# Controlled by CODE_REVIEW_ENABLED in .env
set -e
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "🚀 Prowler UI - Pre-Commit Hook"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
# Load .env file (look in git root directory)
GIT_ROOT=$(git rev-parse --show-toplevel)
if [ -f "$GIT_ROOT/ui/.env" ]; then
CODE_REVIEW_ENABLED=$(grep "^CODE_REVIEW_ENABLED" "$GIT_ROOT/ui/.env" | cut -d'=' -f2 | tr -d ' ')
elif [ -f "$GIT_ROOT/.env" ]; then
CODE_REVIEW_ENABLED=$(grep "^CODE_REVIEW_ENABLED" "$GIT_ROOT/.env" | cut -d'=' -f2 | tr -d ' ')
elif [ -f ".env" ]; then
CODE_REVIEW_ENABLED=$(grep "^CODE_REVIEW_ENABLED" .env | cut -d'=' -f2 | tr -d ' ')
else
CODE_REVIEW_ENABLED="false"
fi
# Normalize the value to lowercase
CODE_REVIEW_ENABLED=$(echo "$CODE_REVIEW_ENABLED" | tr '[:upper:]' '[:lower:]')
echo -e "${BLUE} Code Review Status: ${CODE_REVIEW_ENABLED}${NC}"
echo ""
# Get staged files (what will be committed)
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(tsx?|jsx?)$' || true)
if [ "$CODE_REVIEW_ENABLED" = "true" ]; then
if [ -z "$STAGED_FILES" ]; then
echo -e "${YELLOW}⚠️ No TypeScript/JavaScript files staged to validate${NC}"
echo ""
else
echo -e "${YELLOW}🔍 Running Claude Code standards validation...${NC}"
echo ""
echo -e "${BLUE}📋 Files to validate:${NC}"
echo "$STAGED_FILES" | sed 's/^/ - /'
echo ""
echo -e "${BLUE}📤 Sending to Claude Code for validation...${NC}"
echo ""
# Build prompt with git diff of changes AND full context
VALIDATION_PROMPT=$(
cat <<'PROMPT_EOF'
You are a code reviewer for the Prowler UI project. Analyze the code changes (git diff with full context) below and validate they comply with AGENTS.md standards.
**CRITICAL: You MUST check BOTH the changed lines AND the surrounding context for violations.**
**RULES TO CHECK:**
1. React Imports: NO `import * as React` or `import React, {` → Use `import { useState }`
2. TypeScript: NO union types like `type X = "a" | "b"` → Use const-based: `const X = {...} as const`
3. Tailwind: NO `var()` or hex colors in className → Use Tailwind utilities and semantic color classes (e.g., `bg-bg-neutral-tertiary`, `border-border-neutral-primary`)
4. cn(): Use for merging multiple classes or for conditionals (handles Tailwind conflicts with twMerge) → `cn(BUTTON_STYLES.base, BUTTON_STYLES.active, isLoading && "opacity-50")`
5. React 19: NO `useMemo`/`useCallback` without reason
6. Zod v4: Use `.min(1)` not `.nonempty()`, `z.email()` not `z.string().email()`. All inputs must be validated with Zod.
7. File Org: 1 feature = local, 2+ features = shared
8. Directives: Server Actions need "use server", clients need "use client"
9. Implement DRY, KISS principles. (example: reusable components, avoid repetition)
10. Layout must work for all the responsive breakpoints (mobile, tablet, desktop)
11. ANY types cannot be used - CRITICAL: Check for `: any` in all visible lines
12. Use the components inside components/shadcn if possible
13. Check Accessibility best practices (like alt tags in images, semantic HTML, Aria labels, etc.)
=== GIT DIFF WITH CONTEXT ===
PROMPT_EOF
)
# Add git diff to prompt with more context (U5 = 5 lines before/after)
VALIDATION_PROMPT="$VALIDATION_PROMPT
$(git diff --cached -U5)"
VALIDATION_PROMPT="$VALIDATION_PROMPT
=== END DIFF ===
**IMPORTANT: Your response MUST start with exactly one of these lines:**
STATUS: PASSED
STATUS: FAILED
**If FAILED:** List each violation with File, Line Number, Rule Number, and Issue.
**If PASSED:** Confirm all visible code (including context) complies with AGENTS.md standards.
**Start your response now with STATUS:**"
# Send to Claude Code
if VALIDATION_OUTPUT=$(echo "$VALIDATION_PROMPT" | claude 2>&1); then
echo "$VALIDATION_OUTPUT"
echo ""
# Check result - STRICT MODE: fail if status unclear
if echo "$VALIDATION_OUTPUT" | grep -q "^STATUS: PASSED"; then
echo ""
echo -e "${GREEN}✅ VALIDATION PASSED${NC}"
echo ""
elif echo "$VALIDATION_OUTPUT" | grep -q "^STATUS: FAILED"; then
echo ""
echo -e "${RED}❌ VALIDATION FAILED${NC}"
echo -e "${RED}Fix violations before committing${NC}"
echo ""
exit 1
else
echo ""
echo -e "${RED}❌ VALIDATION ERROR${NC}"
echo -e "${RED}Could not determine validation status from Claude Code response${NC}"
echo -e "${YELLOW}Response must start with 'STATUS: PASSED' or 'STATUS: FAILED'${NC}"
echo ""
echo -e "${YELLOW}To bypass validation temporarily, set CODE_REVIEW_ENABLED=false in .env${NC}"
echo ""
exit 1
fi
else
echo -e "${YELLOW}⚠️ Claude Code not available${NC}"
fi
echo ""
fi
else
echo -e "${YELLOW}⏭️ Code review disabled (CODE_REVIEW_ENABLED=false)${NC}"
echo ""
fi
# Run healthcheck (typecheck and lint check)
echo -e "${BLUE}🏥 Running healthcheck...${NC}"
echo ""
cd ui || cd .
if npm run healthcheck; then
echo ""
echo -e "${GREEN}✅ Healthcheck passed${NC}"
echo ""
else
echo ""
echo -e "${RED}❌ Healthcheck failed${NC}"
echo -e "${RED}Fix type errors and linting issues before committing${NC}"
echo ""
exit 1
fi