mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-04-05 06:56:58 +00:00
Compare commits
13 Commits
prwlr-7751
...
DEVREL-98-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60350794da | ||
|
|
6a876a3205 | ||
|
|
e9f6bc8604 | ||
|
|
dc71d86c35 | ||
|
|
7a54900a62 | ||
|
|
1d62b8d64e | ||
|
|
0f5dc165bb | ||
|
|
676a11bb13 | ||
|
|
4b80544f0a | ||
|
|
6815a9dd86 | ||
|
|
00441e776d | ||
|
|
4037927c61 | ||
|
|
20337b7e0c |
11
.env
11
.env
@@ -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
73
.gitignore
vendored
@@ -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*
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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
18987
docs/api-reference/openapi.yml
Normal file
File diff suppressed because it is too large
Load Diff
@@ -329,6 +329,10 @@
|
||||
{
|
||||
"tab": "Public Roadmap",
|
||||
"href": "https://roadmap.prowler.com/"
|
||||
},
|
||||
{
|
||||
"tab": "API Reference",
|
||||
"openapi": "api-reference/openapi.yml"
|
||||
}
|
||||
],
|
||||
"global": {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -84,17 +84,6 @@ For detailed instructions on how to create the role, see [Authentication > Assum
|
||||

|
||||

|
||||
|
||||
<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)
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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": ""
|
||||
|
||||
@@ -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": ""
|
||||
|
||||
@@ -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": [],
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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": ""
|
||||
|
||||
@@ -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": [],
|
||||
|
||||
@@ -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": [],
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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, ""
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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": [],
|
||||
|
||||
@@ -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": [],
|
||||
|
||||
@@ -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": [],
|
||||
|
||||
@@ -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": [],
|
||||
|
||||
@@ -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="")
|
||||
|
||||
@@ -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 == ""
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
1390
ui/AGENTS.md
1390
ui/AGENTS.md
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export { CheckFindingsSSR } from "./check-findings.ssr";
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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"];
|
||||
@@ -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} />;
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export {
|
||||
RiskSeverityChart,
|
||||
RiskSeverityChartSkeleton,
|
||||
} from "./risk-severity-chart";
|
||||
export { RiskSeverityChartSSR } from "./risk-severity-chart.ssr";
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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} />;
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export { StatusChart, StatusChartSkeleton } from "./status-chart";
|
||||
@@ -1,2 +0,0 @@
|
||||
export { ThreatScore, ThreatScoreSkeleton } from "./threat-score";
|
||||
export { ThreatScoreSSR } from "./threat-score.ssr";
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" },
|
||||
);
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 |
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user