Compare commits
11 Commits
fix/ci-rem
...
fix/sdk-aw
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29e90badba | ||
|
|
d5354e8b1d | ||
|
|
a96e5890dc | ||
|
|
bb81c5dd2d | ||
|
|
c3acb818d9 | ||
|
|
e6fc59267b | ||
|
|
62f114f5d0 | ||
|
|
392ffd5a60 | ||
|
|
2f379f9663 | ||
|
|
b50a9c5bce | ||
|
|
b0d0e995ad |
4
.github/workflows/sdk-tests.yml
vendored
@@ -209,11 +209,11 @@ jobs:
|
||||
echo "AWS service_paths='${STEPS_AWS_SERVICES_OUTPUTS_SERVICE_PATHS}'"
|
||||
|
||||
if [ "${STEPS_AWS_SERVICES_OUTPUTS_RUN_ALL}" = "true" ]; then
|
||||
poetry run pytest -p no:randomly -n auto --cov=./prowler/providers/aws --cov-report=xml:aws_coverage.xml tests/providers/aws
|
||||
poetry run pytest -n auto --cov=./prowler/providers/aws --cov-report=xml:aws_coverage.xml tests/providers/aws
|
||||
elif [ -z "${STEPS_AWS_SERVICES_OUTPUTS_SERVICE_PATHS}" ]; then
|
||||
echo "No AWS service paths detected; skipping AWS tests."
|
||||
else
|
||||
poetry run pytest -p no:randomly -n auto --cov=./prowler/providers/aws --cov-report=xml:aws_coverage.xml ${STEPS_AWS_SERVICES_OUTPUTS_SERVICE_PATHS}
|
||||
poetry run pytest -n auto --cov=./prowler/providers/aws --cov-report=xml:aws_coverage.xml ${STEPS_AWS_SERVICES_OUTPUTS_SERVICE_PATHS}
|
||||
fi
|
||||
env:
|
||||
STEPS_AWS_SERVICES_OUTPUTS_RUN_ALL: ${{ steps.aws-services.outputs.run_all }}
|
||||
|
||||
@@ -7,6 +7,12 @@ All notable changes to the **Prowler API** are documented in this file.
|
||||
### 🔄 Changed
|
||||
|
||||
- Bump Poetry to `2.3.4` in Dockerfile and pre-commit hooks. Regenerate `api/poetry.lock` [(#10681)](https://github.com/prowler-cloud/prowler/pull/10681)
|
||||
- Attack Paths: Remove dead `cleanup_findings` no-op and its supporting `prowler_finding_lastupdated` index [(#10684)](https://github.com/prowler-cloud/prowler/pull/10684)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- Worker-beat race condition on cold start: replaced `sleep 15` with API service healthcheck dependency (Docker Compose) and init containers (Helm), aligned Gunicorn default port to `8080` [(#10603)](https://github.com/prowler-cloud/prowler/pull/10603)
|
||||
- API container startup crash on Linux due to root-owned bind-mount preventing JWT key generation [(#10646)](https://github.com/prowler-cloud/prowler/pull/10646)
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
|
||||
@@ -56,7 +56,6 @@ start_worker() {
|
||||
|
||||
start_worker_beat() {
|
||||
echo "Starting the worker-beat..."
|
||||
sleep 15
|
||||
poetry run python -m celery -A config.celery beat -l "${DJANGO_LOGGING_LEVEL:-info}" --scheduler django_celery_beat.schedulers:DatabaseScheduler
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ from config.django.production import LOGGING as DJANGO_LOGGERS, DEBUG # noqa: E
|
||||
from config.custom_logging import BackendLogger # noqa: E402
|
||||
|
||||
BIND_ADDRESS = env("DJANGO_BIND_ADDRESS", default="127.0.0.1")
|
||||
PORT = env("DJANGO_PORT", default=8000)
|
||||
PORT = env("DJANGO_PORT", default=8080)
|
||||
|
||||
# Server settings
|
||||
bind = f"{BIND_ADDRESS}:{PORT}"
|
||||
|
||||
@@ -5,7 +5,6 @@ This module handles:
|
||||
- Adding resource labels to Cartography nodes for efficient lookups
|
||||
- Loading Prowler findings into the graph
|
||||
- Linking findings to resources
|
||||
- Cleaning up stale findings
|
||||
"""
|
||||
|
||||
from collections import defaultdict
|
||||
@@ -24,7 +23,6 @@ from tasks.jobs.attack_paths.config import (
|
||||
)
|
||||
from tasks.jobs.attack_paths.queries import (
|
||||
ADD_RESOURCE_LABEL_TEMPLATE,
|
||||
CLEANUP_FINDINGS_TEMPLATE,
|
||||
INSERT_FINDING_TEMPLATE,
|
||||
render_cypher_template,
|
||||
)
|
||||
@@ -92,14 +90,13 @@ def analysis(
|
||||
"""
|
||||
Main entry point for Prowler findings analysis.
|
||||
|
||||
Adds resource labels, loads findings, and cleans up stale data.
|
||||
Adds resource labels and loads findings.
|
||||
"""
|
||||
add_resource_label(
|
||||
neo4j_session, prowler_api_provider.provider, str(prowler_api_provider.uid)
|
||||
)
|
||||
findings_data = stream_findings_with_resources(prowler_api_provider, scan_id)
|
||||
load_findings(neo4j_session, findings_data, prowler_api_provider, config)
|
||||
cleanup_findings(neo4j_session, prowler_api_provider, config)
|
||||
|
||||
|
||||
def add_resource_label(
|
||||
@@ -183,28 +180,6 @@ def load_findings(
|
||||
logger.info(f"Finished loading {total_records} records in {batch_num} batches")
|
||||
|
||||
|
||||
def cleanup_findings(
|
||||
neo4j_session: neo4j.Session,
|
||||
prowler_api_provider: Provider,
|
||||
config: CartographyConfig,
|
||||
) -> None:
|
||||
"""Remove stale findings (classic Cartography behaviour)."""
|
||||
parameters = {
|
||||
"last_updated": config.update_tag,
|
||||
"batch_size": BATCH_SIZE,
|
||||
}
|
||||
|
||||
batch = 1
|
||||
deleted_count = 1
|
||||
while deleted_count > 0:
|
||||
logger.info(f"Cleaning findings batch {batch}")
|
||||
|
||||
result = neo4j_session.run(CLEANUP_FINDINGS_TEMPLATE, parameters)
|
||||
|
||||
deleted_count = result.single().get("deleted_findings_count", 0)
|
||||
batch += 1
|
||||
|
||||
|
||||
# Findings Streaming (Generator-based)
|
||||
# -------------------------------------
|
||||
|
||||
|
||||
@@ -13,14 +13,13 @@ from tasks.jobs.attack_paths.config import (
|
||||
logger = get_task_logger(__name__)
|
||||
|
||||
|
||||
# Indexes for Prowler findings and resource lookups
|
||||
# Indexes for Prowler Findings and resource lookups
|
||||
FINDINGS_INDEX_STATEMENTS = [
|
||||
# Resource indexes for Prowler Finding lookups
|
||||
"CREATE INDEX aws_resource_arn IF NOT EXISTS FOR (n:_AWSResource) ON (n.arn);",
|
||||
"CREATE INDEX aws_resource_id IF NOT EXISTS FOR (n:_AWSResource) ON (n.id);",
|
||||
# Prowler Finding indexes
|
||||
f"CREATE INDEX prowler_finding_id IF NOT EXISTS FOR (n:{PROWLER_FINDING_LABEL}) ON (n.id);",
|
||||
f"CREATE INDEX prowler_finding_lastupdated IF NOT EXISTS FOR (n:{PROWLER_FINDING_LABEL}) ON (n.lastupdated);",
|
||||
f"CREATE INDEX prowler_finding_status IF NOT EXISTS FOR (n:{PROWLER_FINDING_LABEL}) ON (n.status);",
|
||||
# Internet node index for MERGE lookups
|
||||
f"CREATE INDEX internet_id IF NOT EXISTS FOR (n:{INTERNET_NODE_LABEL}) ON (n.id);",
|
||||
|
||||
@@ -80,17 +80,6 @@ INSERT_FINDING_TEMPLATE = f"""
|
||||
rel.lastupdated = $last_updated
|
||||
"""
|
||||
|
||||
CLEANUP_FINDINGS_TEMPLATE = f"""
|
||||
MATCH (finding:{PROWLER_FINDING_LABEL})
|
||||
WHERE finding.lastupdated < $last_updated
|
||||
|
||||
WITH finding LIMIT $batch_size
|
||||
|
||||
DETACH DELETE finding
|
||||
|
||||
RETURN COUNT(finding) AS deleted_findings_count
|
||||
"""
|
||||
|
||||
# Internet queries (used by internet.py)
|
||||
# ---------------------------------------
|
||||
|
||||
|
||||
@@ -1298,23 +1298,6 @@ class TestAttackPathsFindingsHelpers:
|
||||
assert params["last_updated"] == config.update_tag
|
||||
assert "findings_data" in params
|
||||
|
||||
def test_cleanup_findings_runs_batches(self, providers_fixture):
|
||||
provider = providers_fixture[0]
|
||||
config = SimpleNamespace(update_tag=1024)
|
||||
mock_session = MagicMock()
|
||||
|
||||
first_batch = MagicMock()
|
||||
first_batch.single.return_value = {"deleted_findings_count": 3}
|
||||
second_batch = MagicMock()
|
||||
second_batch.single.return_value = {"deleted_findings_count": 0}
|
||||
mock_session.run.side_effect = [first_batch, second_batch]
|
||||
|
||||
findings_module.cleanup_findings(mock_session, provider, config)
|
||||
|
||||
assert mock_session.run.call_count == 2
|
||||
params = mock_session.run.call_args.args[1]
|
||||
assert params["last_updated"] == config.update_tag
|
||||
|
||||
def test_stream_findings_with_resources_returns_latest_scan_data(
|
||||
self,
|
||||
tenants_fixture,
|
||||
|
||||
@@ -34,6 +34,10 @@ spec:
|
||||
securityContext:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.worker.initContainers }}
|
||||
initContainers:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: worker
|
||||
{{- with .Values.worker.securityContext }}
|
||||
|
||||
@@ -32,6 +32,10 @@ spec:
|
||||
securityContext:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.worker_beat.initContainers }}
|
||||
initContainers:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: worker-beat
|
||||
{{- with .Values.worker_beat.securityContext }}
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
services:
|
||||
api-dev-init:
|
||||
image: busybox:1.37.0
|
||||
volumes:
|
||||
- ./_data/api:/data
|
||||
command: ["sh", "-c", "chown -R 1000:1000 /data"]
|
||||
restart: "no"
|
||||
|
||||
api-dev:
|
||||
hostname: "prowler-api"
|
||||
image: prowler-api-dev
|
||||
@@ -21,12 +28,20 @@ services:
|
||||
- ./_data/api:/home/prowler/.config/prowler-api
|
||||
- outputs:/tmp/prowler_api_output
|
||||
depends_on:
|
||||
api-dev-init:
|
||||
condition: service_completed_successfully
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
valkey:
|
||||
condition: service_healthy
|
||||
neo4j:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:${DJANGO_PORT:-8080}/api/v1/ || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 60s
|
||||
entrypoint:
|
||||
- "/home/prowler/docker-entrypoint.sh"
|
||||
- "dev"
|
||||
@@ -139,11 +154,7 @@ services:
|
||||
- ./api/docker-entrypoint.sh:/home/prowler/docker-entrypoint.sh
|
||||
- outputs:/tmp/prowler_api_output
|
||||
depends_on:
|
||||
valkey:
|
||||
condition: service_healthy
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
neo4j:
|
||||
api-dev:
|
||||
condition: service_healthy
|
||||
ulimits:
|
||||
nofile:
|
||||
@@ -165,11 +176,7 @@ services:
|
||||
- path: ./.env
|
||||
required: false
|
||||
depends_on:
|
||||
valkey:
|
||||
condition: service_healthy
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
neo4j:
|
||||
api-dev:
|
||||
condition: service_healthy
|
||||
ulimits:
|
||||
nofile:
|
||||
|
||||
@@ -5,6 +5,13 @@
|
||||
# docker compose -f docker-compose-dev.yml up
|
||||
#
|
||||
services:
|
||||
api-init:
|
||||
image: busybox:1.37.0
|
||||
volumes:
|
||||
- ./_data/api:/data
|
||||
command: ["sh", "-c", "chown -R 1000:1000 /data"]
|
||||
restart: "no"
|
||||
|
||||
api:
|
||||
hostname: "prowler-api"
|
||||
image: prowlercloud/prowler-api:${PROWLER_API_VERSION:-stable}
|
||||
@@ -17,12 +24,20 @@ services:
|
||||
- ./_data/api:/home/prowler/.config/prowler-api
|
||||
- output:/tmp/prowler_api_output
|
||||
depends_on:
|
||||
api-init:
|
||||
condition: service_completed_successfully
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
valkey:
|
||||
condition: service_healthy
|
||||
neo4j:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:${DJANGO_PORT:-8080}/api/v1/ || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 60s
|
||||
entrypoint:
|
||||
- "/home/prowler/docker-entrypoint.sh"
|
||||
- "prod"
|
||||
@@ -114,9 +129,7 @@ services:
|
||||
volumes:
|
||||
- "output:/tmp/prowler_api_output"
|
||||
depends_on:
|
||||
valkey:
|
||||
condition: service_healthy
|
||||
postgres:
|
||||
api:
|
||||
condition: service_healthy
|
||||
ulimits:
|
||||
nofile:
|
||||
@@ -132,9 +145,7 @@ services:
|
||||
- path: ./.env
|
||||
required: false
|
||||
depends_on:
|
||||
valkey:
|
||||
condition: service_healthy
|
||||
postgres:
|
||||
api:
|
||||
condition: service_healthy
|
||||
ulimits:
|
||||
nofile:
|
||||
|
||||
@@ -15,8 +15,7 @@ This document describes the internal architecture of Prowler Lighthouse AI, enab
|
||||
|
||||
Lighthouse AI operates as a Langchain-based agent that connects Large Language Models (LLMs) with Prowler security data through the Model Context Protocol (MCP).
|
||||
|
||||
<img className="block dark:hidden" src="/images/lighthouse-architecture-light.png" alt="Prowler Lighthouse Architecture" />
|
||||
<img className="hidden dark:block" src="/images/lighthouse-architecture-dark.png" alt="Prowler Lighthouse Architecture" />
|
||||

|
||||
|
||||
### Three-Tier Architecture
|
||||
|
||||
|
||||
@@ -12,6 +12,24 @@
|
||||
"dark": "/images/prowler-logo-white.png",
|
||||
"light": "/images/prowler-logo-black.png"
|
||||
},
|
||||
"contextual": {
|
||||
"options": [
|
||||
"copy",
|
||||
"view",
|
||||
{
|
||||
"title": "Request a feature",
|
||||
"description": "Open a feature request on GitHub",
|
||||
"icon": "plus",
|
||||
"href": "https://github.com/prowler-cloud/prowler/issues/new?template=feature-request.yml"
|
||||
},
|
||||
{
|
||||
"title": "Report an issue",
|
||||
"description": "Open a bug report on GitHub",
|
||||
"icon": "bug",
|
||||
"href": "https://github.com/prowler-cloud/prowler/issues/new?template=bug_report.yml"
|
||||
}
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"tabs": [
|
||||
{
|
||||
@@ -133,6 +151,7 @@
|
||||
]
|
||||
},
|
||||
"user-guide/tutorials/prowler-app-attack-paths",
|
||||
"user-guide/tutorials/prowler-app-finding-groups",
|
||||
"user-guide/tutorials/prowler-cloud-public-ips",
|
||||
{
|
||||
"group": "Tutorials",
|
||||
|
||||
@@ -59,6 +59,10 @@ Prowler Lighthouse AI is powerful, but there are limitations:
|
||||
- **NextJS session dependence**: If your Prowler application session expires or logs out, Lighthouse AI will error out. Refresh and log back in to continue.
|
||||
- **Response quality**: The response quality depends on the selected LLM provider and model. Choose models with strong tool-calling capabilities for best results. We recommend `gpt-5` model from OpenAI.
|
||||
|
||||
## Architecture
|
||||
|
||||

|
||||
|
||||
## Extending Lighthouse AI
|
||||
|
||||
Lighthouse AI retrieves data through Prowler MCP. To add new capabilities, extend the Prowler MCP Server with additional tools and Lighthouse AI discovers them automatically.
|
||||
|
||||
@@ -46,8 +46,7 @@ Search and retrieve official Prowler documentation:
|
||||
|
||||
The following diagram illustrates the Prowler MCP Server architecture and its integration points:
|
||||
|
||||
<img className="block dark:hidden" src="/images/prowler_mcp_schema_light.png" alt="Prowler MCP Server Schema" />
|
||||
<img className="hidden dark:block" src="/images/prowler_mcp_schema_dark.png" alt="Prowler MCP Server Schema" />
|
||||

|
||||
|
||||
The architecture shows how AI assistants connect through the MCP protocol to access Prowler's three main components:
|
||||
- Prowler Cloud/App for security operations
|
||||
|
||||
BIN
docs/images/finding-groups-drawer.png
Normal file
|
After Width: | Height: | Size: 755 KiB |
BIN
docs/images/finding-groups-expanded.png
Normal file
|
After Width: | Height: | Size: 146 KiB |
BIN
docs/images/finding-groups-list.png
Normal file
|
After Width: | Height: | Size: 340 KiB |
BIN
docs/images/finding-groups-other-findings.png
Normal file
|
After Width: | Height: | Size: 410 KiB |
|
Before Width: | Height: | Size: 267 KiB |
|
Before Width: | Height: | Size: 265 KiB |
37
docs/images/lighthouse-architecture.mmd
Normal file
@@ -0,0 +1,37 @@
|
||||
flowchart TB
|
||||
browser([Browser])
|
||||
|
||||
subgraph NEXTJS["Next.js Server"]
|
||||
route["API Route<br/>(auth + context assembly)"]
|
||||
agent["LangChain Agent"]
|
||||
|
||||
subgraph TOOLS["Agent Tools"]
|
||||
metatools["Meta-tools<br/>describe_tool / execute_tool / load_skill"]
|
||||
end
|
||||
|
||||
mcpclient["MCP Client<br/>(HTTP transport)"]
|
||||
end
|
||||
|
||||
llm["LLM Provider<br/>(OpenAI / Bedrock / OpenAI-compatible)"]
|
||||
|
||||
subgraph MCP["Prowler MCP Server"]
|
||||
app_tools["prowler_app_* tools<br/>(auth required)"]
|
||||
hub_tools["prowler_hub_* tools<br/>(no auth)"]
|
||||
docs_tools["prowler_docs_* tools<br/>(no auth)"]
|
||||
end
|
||||
|
||||
api["Prowler API"]
|
||||
hub["hub.prowler.com"]
|
||||
docs["docs.prowler.com<br/>(Mintlify)"]
|
||||
|
||||
browser <-->|SSE stream| route
|
||||
route --> agent
|
||||
agent <-->|LLM API| llm
|
||||
agent --> metatools
|
||||
metatools --> mcpclient
|
||||
mcpclient -->|MCP HTTP · Bearer token<br/>for prowler_app_* only| app_tools
|
||||
mcpclient -->|MCP HTTP| hub_tools
|
||||
mcpclient -->|MCP HTTP| docs_tools
|
||||
app_tools -->|REST| api
|
||||
hub_tools -->|REST| hub
|
||||
docs_tools -->|REST| docs
|
||||
BIN
docs/images/lighthouse-architecture.png
Normal file
|
After Width: | Height: | Size: 286 KiB |
@@ -23,6 +23,8 @@ flowchart TB
|
||||
user --> ui
|
||||
user --> cli
|
||||
ui -->|REST| api
|
||||
ui -->|MCP HTTP| mcp
|
||||
mcp -->|REST| api
|
||||
api --> pg
|
||||
api --> valkey
|
||||
beat -->|enqueue jobs| valkey
|
||||
@@ -31,7 +33,5 @@ flowchart TB
|
||||
worker -->|Attack Paths| neo4j
|
||||
worker -->|invokes| sdk
|
||||
cli --> sdk
|
||||
api -. AI tools .-> mcp
|
||||
mcp -. context .-> api
|
||||
|
||||
sdk --> providers
|
||||
|
||||
|
Before Width: | Height: | Size: 268 KiB After Width: | Height: | Size: 348 KiB |
29
docs/images/prowler_mcp_schema.mmd
Normal file
@@ -0,0 +1,29 @@
|
||||
flowchart LR
|
||||
subgraph HOSTS["MCP Hosts"]
|
||||
chat["Chat Interfaces<br/>(Claude Desktop, LobeChat)"]
|
||||
ide["IDEs and Code Editors<br/>(Claude Code, Cursor)"]
|
||||
apps["Other AI Applications<br/>(5ire, custom agents)"]
|
||||
end
|
||||
|
||||
subgraph MCP["Prowler MCP Server"]
|
||||
app_tools["prowler_app_* tools<br/>(JWT or API key auth)<br/>Findings · Providers · Scans<br/>Resources · Muting · Compliance<br/>Attack Paths"]
|
||||
hub_tools["prowler_hub_* tools<br/>(no auth)<br/>Checks Catalog · Check Code<br/>Fixers · Compliance Frameworks"]
|
||||
docs_tools["prowler_docs_* tools<br/>(no auth)<br/>Search · Document Retrieval"]
|
||||
end
|
||||
|
||||
api["Prowler API<br/>(REST)"]
|
||||
hub["hub.prowler.com<br/>(REST)"]
|
||||
docs["docs.prowler.com<br/>(Mintlify)"]
|
||||
|
||||
chat -->|STDIO or HTTP| app_tools
|
||||
chat -->|STDIO or HTTP| hub_tools
|
||||
chat -->|STDIO or HTTP| docs_tools
|
||||
ide -->|STDIO or HTTP| app_tools
|
||||
ide -->|STDIO or HTTP| hub_tools
|
||||
ide -->|STDIO or HTTP| docs_tools
|
||||
apps -->|STDIO or HTTP| app_tools
|
||||
apps -->|STDIO or HTTP| hub_tools
|
||||
apps -->|STDIO or HTTP| docs_tools
|
||||
app_tools -->|REST| api
|
||||
hub_tools -->|REST| hub
|
||||
docs_tools -->|REST| docs
|
||||
BIN
docs/images/prowler_mcp_schema.png
Normal file
|
After Width: | Height: | Size: 371 KiB |
|
Before Width: | Height: | Size: 328 KiB |
|
Before Width: | Height: | Size: 332 KiB |
119
docs/user-guide/tutorials/prowler-app-finding-groups.mdx
Normal file
@@ -0,0 +1,119 @@
|
||||
---
|
||||
title: 'Finding Groups'
|
||||
description: 'Organize and triage security findings by check to reduce noise and prioritize remediation effectively.'
|
||||
---
|
||||
|
||||
import { VersionBadge } from "/snippets/version-badge.mdx"
|
||||
|
||||
<VersionBadge version="5.23.0" />
|
||||
|
||||
Finding Groups transforms security findings triage by grouping them by check instead of displaying a flat list. This dramatically reduces noise and enables faster, more effective prioritization.
|
||||
|
||||
## Triage Challenges with Flat Finding Lists
|
||||
|
||||
A real cloud environment produces thousands of findings per scan. A flat list makes it impossible to triage effectively:
|
||||
|
||||
- **Signal buried in noise**: the same misconfiguration repeated across 200 resources shows up as 200 rows, burying the signal in repetitive data
|
||||
- **Prioritization guesswork**: without grouping, understanding which issues affect the most resources requires manual counting and correlation
|
||||
- **Tedious muting**: muting a false positive globally requires manually acting on each individual finding across the list
|
||||
- **Lost context**: when investigating a single resource, related findings are scattered across the same flat list, making it hard to see the full picture
|
||||
|
||||
## How Finding Groups Addresses These Challenges
|
||||
|
||||
Finding Groups addresses these challenges by intelligently grouping findings by check.
|
||||
|
||||
### Grouped View at a Glance
|
||||
|
||||
Each row represents a single check title with key information immediately visible:
|
||||
|
||||
- **Severity** indicator for quick risk assessment
|
||||
- **Impacted providers** showing which cloud platforms are affected
|
||||
- **X of Y impacted resources** counter displaying how many resources fail this check
|
||||
|
||||
For example, `Vercel project has the Web Application Firewall enabled` across every affected project collapses to a single row — not one per project. Sort or filter by severity, provider, or status at the group level to triage top-down instead of drowning in per-resource rows.
|
||||
|
||||

|
||||
|
||||
### Expanding Groups for Details
|
||||
|
||||
Expand any group inline to see the failing resources with detailed information:
|
||||
|
||||
| Column | Description |
|
||||
|--------|-------------|
|
||||
| **UID** | Unique identifier for the resource |
|
||||
| **Service** | The cloud service the resource belongs to |
|
||||
| **Region** | Geographic region where the resource is deployed |
|
||||
| **Severity** | Risk level of the finding |
|
||||
| **Provider** | Cloud provider (AWS, Azure, GCP, Kubernetes, etc.) |
|
||||
| **Last Seen** | When the finding was last detected |
|
||||
| **Failing For** | Duration the resource has been in a failing state |
|
||||
|
||||

|
||||
|
||||
### Resource Detail Drawer
|
||||
|
||||
Select any resource to open the detail drawer with full finding context:
|
||||
|
||||
- **Risk**: the security risk associated with this finding
|
||||
- **Description**: detailed explanation of what was detected
|
||||
- **Status Extended**: additional status information and context
|
||||
- **Remediation**: step-by-step guidance to resolve the issue
|
||||
- **View in Prowler Hub**: direct link to explore the check in Prowler Hub
|
||||
- **Analyze This Finding With Lighthouse AI**: one-click AI-powered analysis for deeper insights
|
||||
|
||||

|
||||
|
||||
### Bulk Actions
|
||||
|
||||
Bulk-mute an entire group instead of chasing duplicates across the list. This is especially useful for:
|
||||
|
||||
- Known false positives that appear across many resources
|
||||
- Findings in development or test environments
|
||||
- Accepted risks that have been documented and approved
|
||||
|
||||
<Warning>
|
||||
Muting findings does not resolve underlying security issues. Review each finding carefully before muting to ensure it represents an acceptable risk or has been properly addressed.
|
||||
</Warning>
|
||||
|
||||
## Other Findings for This Resource
|
||||
|
||||
Inside the resource detail drawer, the **Other Findings For This Resource** tab lists every finding that hits the same resource — passing, failing, and muted — alongside the one currently being reviewed.
|
||||
|
||||

|
||||
|
||||
### Why This Matters
|
||||
|
||||
When reviewing "WAF not enabled" on a Vercel project, the tab immediately shows:
|
||||
|
||||
- Skew protection status
|
||||
- Rate limiting configuration
|
||||
- IP blocking settings
|
||||
- Custom firewall rules
|
||||
- Password protection findings
|
||||
|
||||
All for that same project, without navigating back to the main list and filtering by resource UID.
|
||||
|
||||
### Complete Context Within the Drawer
|
||||
|
||||
Pair the Other Findings tab with:
|
||||
|
||||
- **Scans tab**: scan history for this resource
|
||||
- **Events tab**: changes and events over time
|
||||
|
||||
This provides full context without leaving the drawer.
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Start with high severity groups**: focus on critical and high severity groups first for maximum impact.
|
||||
2. **Use filters strategically**: filter by provider or status at the group level to narrow the triage scope.
|
||||
3. **Leverage bulk mute**: when a finding represents a confirmed false positive, mute the entire group at once.
|
||||
4. **Check related findings**: review the Other Findings tab to understand the full security posture of a resource.
|
||||
5. **Track failure duration**: use the "Failing For" column to prioritize long-standing issues that may indicate systemic problems.
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. Navigate to the **Findings** section in Prowler Cloud/App.
|
||||
2. Toggle to the **Grouped View** to see findings organized by check.
|
||||
3. Select any group row to expand and see affected resources.
|
||||
4. Select a resource to open the detail drawer with full context.
|
||||
5. Use the **Other Findings For This Resource** tab to see all findings for that resource.
|
||||
@@ -25,8 +25,7 @@ Behind the scenes, Lighthouse AI works as follows:
|
||||
Lighthouse AI supports multiple LLM providers including OpenAI, Amazon Bedrock, and OpenAI-compatible services. For configuration details, see [Using Multiple LLM Providers with Lighthouse](/user-guide/tutorials/prowler-app-lighthouse-multi-llm).
|
||||
</Note>
|
||||
|
||||
<img className="block dark:hidden" src="/images/lighthouse-architecture-light.png" alt="Prowler Lighthouse Architecture" />
|
||||
<img className="hidden dark:block" src="/images/lighthouse-architecture-dark.png" alt="Prowler Lighthouse Architecture" />
|
||||

|
||||
|
||||
|
||||
<Note>
|
||||
|
||||
@@ -23,6 +23,11 @@ All notable changes to the **Prowler SDK** are documented in this file.
|
||||
- Bump Poetry to `2.3.4` and consolidate SDK workflows onto the `setup-python-poetry` composite action with opt-in lockfile regeneration [(#10681)](https://github.com/prowler-cloud/prowler/pull/10681)
|
||||
- Normalize Conditional Access platform values in Entra models and simplify platform-based checks [(#10635)](https://github.com/prowler-cloud/prowler/pull/10635)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- AWS SDK test isolation: autouse `mock_aws` fixture and leak detector in `conftest.py` to prevent tests from hitting real AWS endpoints, with idempotent organization setup for tests calling `set_mocked_aws_provider` multiple times [(#10605)](https://github.com/prowler-cloud/prowler/pull/10605)
|
||||
- Vercel firewall config handling for team-scoped projects and current API response shapes [(#10695)](https://github.com/prowler-cloud/prowler/pull/10695)
|
||||
|
||||
---
|
||||
|
||||
## [5.23.0] (Prowler v5.23.0)
|
||||
|
||||
@@ -55,6 +55,7 @@ class Project(VercelService):
|
||||
|
||||
# Parse password protection
|
||||
pwd_protection = proj.get("passwordProtection")
|
||||
security = proj.get("security", {}) or {}
|
||||
|
||||
self.projects[project_id] = VercelProject(
|
||||
id=project_id,
|
||||
@@ -75,6 +76,16 @@ class Project(VercelService):
|
||||
git_fork_protection=proj.get("gitForkProtection", True),
|
||||
git_repository=proj.get("link"),
|
||||
secure_compute=proj.get("secureCompute"),
|
||||
firewall_enabled=security.get("firewallEnabled"),
|
||||
firewall_config_version=(
|
||||
str(security.get("firewallConfigVersion"))
|
||||
if security.get("firewallConfigVersion") is not None
|
||||
else None
|
||||
),
|
||||
managed_rules=security.get(
|
||||
"managedRules", security.get("managedRulesets")
|
||||
),
|
||||
bot_id_enabled=security.get("botIdEnabled"),
|
||||
)
|
||||
|
||||
logger.info(f"Project - Found {len(self.projects)} project(s)")
|
||||
@@ -160,4 +171,8 @@ class VercelProject(BaseModel):
|
||||
git_fork_protection: bool = True
|
||||
git_repository: Optional[dict] = None
|
||||
secure_compute: Optional[dict] = None
|
||||
firewall_enabled: Optional[bool] = None
|
||||
firewall_config_version: Optional[str] = None
|
||||
managed_rules: Optional[dict] = None
|
||||
bot_id_enabled: Optional[bool] = None
|
||||
environment_variables: list[VercelEnvironmentVariable] = Field(default_factory=list)
|
||||
|
||||
@@ -26,10 +26,7 @@ class Security(VercelService):
|
||||
def _fetch_firewall_config(self, project):
|
||||
"""Fetch WAF/Firewall config for a single project."""
|
||||
try:
|
||||
data = self._get(
|
||||
"/v1/security/firewall/config",
|
||||
params={"projectId": project.id},
|
||||
)
|
||||
data = self._read_firewall_config(project)
|
||||
|
||||
if data is None:
|
||||
# 403 — plan limitation, store with managed_rulesets=None
|
||||
@@ -44,39 +41,60 @@ class Security(VercelService):
|
||||
)
|
||||
return
|
||||
|
||||
# Parse firewall config
|
||||
fw = data.get("firewallConfig", data) if isinstance(data, dict) else {}
|
||||
fw = self._normalize_firewall_config(data)
|
||||
|
||||
# Determine if firewall is enabled
|
||||
rules = fw.get("rules", []) or []
|
||||
managed = fw.get("managedRules", fw.get("managedRulesets"))
|
||||
if not fw:
|
||||
fallback_firewall_enabled = self._fallback_firewall_enabled(project)
|
||||
self.firewall_configs[project.id] = VercelFirewallConfig(
|
||||
project_id=project.id,
|
||||
project_name=project.name,
|
||||
team_id=project.team_id,
|
||||
firewall_enabled=(
|
||||
fallback_firewall_enabled
|
||||
if fallback_firewall_enabled is not None
|
||||
else False
|
||||
),
|
||||
managed_rulesets=self._fallback_managed_rulesets(project),
|
||||
name=project.name,
|
||||
id=project.id,
|
||||
)
|
||||
return
|
||||
|
||||
rules = [
|
||||
rule for rule in (fw.get("rules", []) or []) if self._is_active(rule)
|
||||
]
|
||||
managed = self._active_managed_rulesets(
|
||||
fw.get("managedRules", fw.get("managedRulesets", fw.get("crs")))
|
||||
)
|
||||
custom_rules = []
|
||||
ip_blocking = []
|
||||
ip_blocking = list(fw.get("ips", []) or [])
|
||||
rate_limiting = []
|
||||
|
||||
for rule in rules:
|
||||
rule_action = rule.get("action", {})
|
||||
action_type = (
|
||||
rule_action.get("type", "")
|
||||
if isinstance(rule_action, dict)
|
||||
else str(rule_action)
|
||||
)
|
||||
mitigate_action = self._mitigate_action(rule)
|
||||
|
||||
if action_type == "rate_limit" or rule.get("rateLimit"):
|
||||
if self._is_rate_limiting_rule(rule, mitigate_action):
|
||||
rate_limiting.append(rule)
|
||||
elif action_type in ("deny", "block") and self._is_ip_rule(rule):
|
||||
elif self._is_ip_rule(rule):
|
||||
ip_blocking.append(rule)
|
||||
else:
|
||||
custom_rules.append(rule)
|
||||
|
||||
firewall_enabled = bool(rules) or bool(managed)
|
||||
firewall_enabled = fw.get("firewallEnabled")
|
||||
if firewall_enabled is None:
|
||||
firewall_enabled = self._fallback_firewall_enabled(project)
|
||||
if firewall_enabled is None:
|
||||
firewall_enabled = bool(rules) or bool(ip_blocking) or bool(managed)
|
||||
|
||||
if not managed:
|
||||
managed = self._fallback_managed_rulesets(project)
|
||||
|
||||
self.firewall_configs[project.id] = VercelFirewallConfig(
|
||||
project_id=project.id,
|
||||
project_name=project.name,
|
||||
team_id=project.team_id,
|
||||
firewall_enabled=firewall_enabled,
|
||||
managed_rulesets=managed if managed is not None else {},
|
||||
managed_rulesets=managed,
|
||||
custom_rules=custom_rules,
|
||||
ip_blocking_rules=ip_blocking,
|
||||
rate_limiting_rules=rate_limiting,
|
||||
@@ -95,6 +113,117 @@ class Security(VercelService):
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
|
||||
def _read_firewall_config(self, project):
|
||||
"""Read the deployed firewall config via the documented endpoint.
|
||||
|
||||
See: https://vercel.com/docs/rest-api/security/read-firewall-configuration
|
||||
"""
|
||||
params = self._firewall_params(project)
|
||||
config_version = getattr(project, "firewall_config_version", None)
|
||||
|
||||
endpoints = []
|
||||
if config_version:
|
||||
endpoints.append(f"/v1/security/firewall/config/{config_version}")
|
||||
endpoints.append("/v1/security/firewall/config/active")
|
||||
|
||||
last_error = None
|
||||
for endpoint in endpoints:
|
||||
try:
|
||||
return self._get(endpoint, params=params)
|
||||
except Exception as error:
|
||||
last_error = error
|
||||
logger.warning(
|
||||
f"Security - Firewall config read failed for project "
|
||||
f"{project.id} (team={getattr(project, 'team_id', None)}) "
|
||||
f"on {endpoint} with params={params}: "
|
||||
f"{error.__class__.__name__}: {error}"
|
||||
)
|
||||
|
||||
if last_error is not None:
|
||||
logger.debug(
|
||||
f"Security - Falling back to firewall config wrapper for "
|
||||
f"{project.id} after {last_error.__class__.__name__}: {last_error}"
|
||||
)
|
||||
|
||||
return self._get("/v1/security/firewall/config", params=params)
|
||||
|
||||
@staticmethod
|
||||
def _firewall_params(project) -> dict:
|
||||
"""Build firewall request params, preserving team scope for team projects."""
|
||||
params = {"projectId": project.id}
|
||||
team_id = getattr(project, "team_id", None)
|
||||
|
||||
if isinstance(team_id, str) and team_id:
|
||||
params["teamId"] = team_id
|
||||
|
||||
return params
|
||||
|
||||
@staticmethod
|
||||
def _normalize_firewall_config(data: dict) -> dict:
|
||||
"""Normalize firewall responses across Vercel endpoint variants."""
|
||||
if not isinstance(data, dict):
|
||||
return {}
|
||||
|
||||
if "firewallConfig" in data and isinstance(data["firewallConfig"], dict):
|
||||
return data["firewallConfig"]
|
||||
|
||||
if any(key in data for key in ("active", "draft", "versions")):
|
||||
return data.get("active") or {}
|
||||
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
def _active_managed_rulesets(managed_rules: dict | None) -> dict:
|
||||
"""Return only active managed rulesets."""
|
||||
if not isinstance(managed_rules, dict):
|
||||
return {}
|
||||
|
||||
return {
|
||||
ruleset: config
|
||||
for ruleset, config in managed_rules.items()
|
||||
if not isinstance(config, dict) or config.get("active", False)
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _fallback_managed_rulesets(cls, project) -> dict:
|
||||
"""Return active managed rulesets from project metadata."""
|
||||
return cls._active_managed_rulesets(getattr(project, "managed_rules", None))
|
||||
|
||||
@staticmethod
|
||||
def _fallback_firewall_enabled(project) -> bool | None:
|
||||
"""Return firewall enabled state from project metadata when available."""
|
||||
return getattr(project, "firewall_enabled", None)
|
||||
|
||||
@staticmethod
|
||||
def _mitigate_action(rule: dict) -> dict:
|
||||
"""Extract the nested Vercel mitigation action payload for a rule."""
|
||||
action = rule.get("action", {})
|
||||
if not isinstance(action, dict):
|
||||
return {}
|
||||
|
||||
mitigate = action.get("mitigate")
|
||||
return mitigate if isinstance(mitigate, dict) else action
|
||||
|
||||
@staticmethod
|
||||
def _is_active(rule: dict) -> bool:
|
||||
"""Treat missing active flags as enabled for backwards compatibility."""
|
||||
return rule.get("active", True) is not False
|
||||
|
||||
@classmethod
|
||||
def _is_rate_limiting_rule(
|
||||
cls, rule: dict, mitigate_action: dict | None = None
|
||||
) -> bool:
|
||||
"""Check if a firewall rule enforces rate limiting."""
|
||||
if rule.get("rateLimit"):
|
||||
return True
|
||||
|
||||
mitigate = (
|
||||
mitigate_action
|
||||
if isinstance(mitigate_action, dict)
|
||||
else cls._mitigate_action(rule)
|
||||
)
|
||||
return bool(mitigate.get("rateLimit")) or mitigate.get("action") == "rate_limit"
|
||||
|
||||
@staticmethod
|
||||
def _is_ip_rule(rule: dict) -> bool:
|
||||
"""Check if a rule is an IP blocking rule based on conditions."""
|
||||
|
||||
46
tests/providers/aws/conftest.py
Normal file
@@ -0,0 +1,46 @@
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
|
||||
from moto import mock_aws
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _mock_aws_globally():
|
||||
"""Activate moto's mock_aws for every test under tests/providers/aws/.
|
||||
|
||||
This prevents any test from accidentally hitting real AWS endpoints,
|
||||
even if it forgets to add @mock_aws on the method. Tests that never
|
||||
call boto3 are unaffected (mock_aws is a no-op in that case).
|
||||
"""
|
||||
with mock_aws():
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _detect_aws_leaks():
|
||||
"""Fail the test if any HTTP request reaches a real AWS endpoint."""
|
||||
calls = []
|
||||
original_send = None
|
||||
|
||||
try:
|
||||
from botocore.httpsession import URLLib3Session
|
||||
|
||||
original_send = URLLib3Session.send
|
||||
except ImportError:
|
||||
yield
|
||||
return
|
||||
|
||||
def tracking_send(self, request):
|
||||
url = getattr(request, "url", str(request))
|
||||
if ".amazonaws.com" in url:
|
||||
calls.append(url)
|
||||
return original_send(self, request)
|
||||
|
||||
with patch.object(URLLib3Session, "send", tracking_send):
|
||||
yield
|
||||
|
||||
if calls:
|
||||
pytest.fail(
|
||||
f"Test leaked {len(calls)} real AWS call(s):\n"
|
||||
+ "\n".join(f" - {url}" for url in calls[:5])
|
||||
)
|
||||
@@ -116,6 +116,12 @@ def set_mocked_aws_provider(
|
||||
status: list[str] = [],
|
||||
create_default_organization: bool = True,
|
||||
) -> AwsProvider:
|
||||
if audited_regions is None:
|
||||
raise ValueError(
|
||||
"audited_regions is None, which means all 36 regions will be used. "
|
||||
"Pass an explicit list of regions instead."
|
||||
)
|
||||
|
||||
if create_default_organization:
|
||||
# Create default AWS Organization
|
||||
create_default_aws_organization()
|
||||
@@ -191,7 +197,13 @@ def create_default_aws_organization():
|
||||
mockdomain = "moto-example.org"
|
||||
mockemail = "@".join([mockname, mockdomain])
|
||||
|
||||
_ = organizations_client.create_organization(FeatureSet="ALL")["Organization"]["Id"]
|
||||
try:
|
||||
_ = organizations_client.create_organization(FeatureSet="ALL")["Organization"][
|
||||
"Id"
|
||||
]
|
||||
except organizations_client.exceptions.AlreadyInOrganizationException:
|
||||
return
|
||||
|
||||
account_id = organizations_client.create_account(
|
||||
AccountName=mockname, Email=mockemail
|
||||
)["CreateAccountStatus"]["AccountId"]
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
from unittest import mock
|
||||
|
||||
from prowler.providers.vercel.services.project.project_service import Project
|
||||
from tests.providers.vercel.vercel_fixtures import (
|
||||
PROJECT_ID,
|
||||
PROJECT_NAME,
|
||||
TEAM_ID,
|
||||
set_mocked_vercel_provider,
|
||||
)
|
||||
|
||||
|
||||
class TestProjectService:
|
||||
def test_list_projects_parses_security_metadata(self):
|
||||
service = Project.__new__(Project)
|
||||
service.provider = set_mocked_vercel_provider()
|
||||
service.projects = {}
|
||||
service._paginate = mock.MagicMock(
|
||||
return_value=[
|
||||
{
|
||||
"id": PROJECT_ID,
|
||||
"name": PROJECT_NAME,
|
||||
"accountId": TEAM_ID,
|
||||
"security": {
|
||||
"firewallEnabled": True,
|
||||
"firewallConfigVersion": 42,
|
||||
"managedRules": {
|
||||
"owasp": {"active": True, "action": "log"},
|
||||
"ai_bots": {"active": False, "action": "deny"},
|
||||
},
|
||||
"botIdEnabled": True,
|
||||
},
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
service._list_projects()
|
||||
|
||||
project = service.projects[PROJECT_ID]
|
||||
assert project.firewall_enabled is True
|
||||
assert project.firewall_config_version == "42"
|
||||
assert project.managed_rules == {
|
||||
"owasp": {"active": True, "action": "log"},
|
||||
"ai_bots": {"active": False, "action": "deny"},
|
||||
}
|
||||
assert project.bot_id_enabled is True
|
||||
@@ -0,0 +1,199 @@
|
||||
from unittest import mock
|
||||
|
||||
from prowler.providers.vercel.services.project.project_service import VercelProject
|
||||
from prowler.providers.vercel.services.security.security_service import Security
|
||||
from tests.providers.vercel.vercel_fixtures import PROJECT_ID, PROJECT_NAME, TEAM_ID
|
||||
|
||||
|
||||
class TestSecurityService:
|
||||
def test_fetch_firewall_config_reads_active_version_and_normalizes_response(self):
|
||||
project = VercelProject(id=PROJECT_ID, name=PROJECT_NAME, team_id=TEAM_ID)
|
||||
service = Security.__new__(Security)
|
||||
service.firewall_configs = {}
|
||||
|
||||
service._get = mock.MagicMock(
|
||||
return_value={
|
||||
"active": {
|
||||
"firewallEnabled": True,
|
||||
"managedRules": {
|
||||
"owasp": {"active": True, "action": "deny"},
|
||||
"ai_bots": {"active": False, "action": "deny"},
|
||||
},
|
||||
"rules": [
|
||||
{
|
||||
"id": "rule-custom",
|
||||
"name": "Block admin access",
|
||||
"active": True,
|
||||
"conditionGroup": [
|
||||
{
|
||||
"conditions": [
|
||||
{
|
||||
"type": "path",
|
||||
"op": "pre",
|
||||
"value": "/admin",
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"action": {
|
||||
"mitigate": {
|
||||
"action": "deny",
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "rule-rate-limit",
|
||||
"name": "Rate limit login",
|
||||
"active": True,
|
||||
"conditionGroup": [
|
||||
{
|
||||
"conditions": [
|
||||
{
|
||||
"type": "path",
|
||||
"op": "eq",
|
||||
"value": "/login",
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"action": {
|
||||
"mitigate": {
|
||||
"action": "deny",
|
||||
"rateLimit": {
|
||||
"algo": "fixed_window",
|
||||
"window": 60,
|
||||
"limit": 10,
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
"ips": [
|
||||
{
|
||||
"id": "ip-rule",
|
||||
"ip": "203.0.113.7",
|
||||
"action": "deny",
|
||||
}
|
||||
],
|
||||
},
|
||||
"draft": None,
|
||||
"versions": [1],
|
||||
}
|
||||
)
|
||||
|
||||
service._fetch_firewall_config(project)
|
||||
|
||||
service._get.assert_called_once_with(
|
||||
"/v1/security/firewall/config/active",
|
||||
params={"projectId": PROJECT_ID, "teamId": TEAM_ID},
|
||||
)
|
||||
|
||||
config = service.firewall_configs[PROJECT_ID]
|
||||
assert config.firewall_enabled is True
|
||||
assert config.managed_rulesets == {"owasp": {"active": True, "action": "deny"}}
|
||||
assert [rule["id"] for rule in config.custom_rules] == ["rule-custom"]
|
||||
assert [rule["id"] for rule in config.rate_limiting_rules] == [
|
||||
"rule-rate-limit"
|
||||
]
|
||||
assert [rule["id"] for rule in config.ip_blocking_rules] == ["ip-rule"]
|
||||
|
||||
def test_fetch_firewall_config_parses_crs_managed_rulesets(self):
|
||||
project = VercelProject(
|
||||
id=PROJECT_ID,
|
||||
name=PROJECT_NAME,
|
||||
team_id=TEAM_ID,
|
||||
firewall_config_version="1",
|
||||
)
|
||||
service = Security.__new__(Security)
|
||||
service.firewall_configs = {}
|
||||
|
||||
service._get = mock.MagicMock(
|
||||
return_value={
|
||||
"id": "waf_test",
|
||||
"version": 1,
|
||||
"firewallEnabled": True,
|
||||
"crs": {
|
||||
"gen": {"active": True, "action": "log"},
|
||||
"xss": {"active": True, "action": "deny"},
|
||||
"php": {"active": False, "action": "log"},
|
||||
},
|
||||
"rules": [],
|
||||
"ips": [],
|
||||
}
|
||||
)
|
||||
|
||||
service._fetch_firewall_config(project)
|
||||
|
||||
config = service.firewall_configs[PROJECT_ID]
|
||||
assert config.firewall_enabled is True
|
||||
assert config.managed_rulesets == {
|
||||
"gen": {"active": True, "action": "log"},
|
||||
"xss": {"active": True, "action": "deny"},
|
||||
}
|
||||
|
||||
def test_fetch_firewall_config_falls_back_to_wrapper_when_active_missing(self):
|
||||
project = VercelProject(id=PROJECT_ID, name=PROJECT_NAME, team_id=TEAM_ID)
|
||||
service = Security.__new__(Security)
|
||||
service.firewall_configs = {}
|
||||
|
||||
service._get = mock.MagicMock(
|
||||
side_effect=[
|
||||
Exception("404 active config not found"),
|
||||
{"active": None, "draft": None, "versions": []},
|
||||
]
|
||||
)
|
||||
|
||||
service._fetch_firewall_config(project)
|
||||
|
||||
assert service._get.call_args_list == [
|
||||
mock.call(
|
||||
"/v1/security/firewall/config/active",
|
||||
params={"projectId": PROJECT_ID, "teamId": TEAM_ID},
|
||||
),
|
||||
mock.call(
|
||||
"/v1/security/firewall/config",
|
||||
params={"projectId": PROJECT_ID, "teamId": TEAM_ID},
|
||||
),
|
||||
]
|
||||
|
||||
config = service.firewall_configs[PROJECT_ID]
|
||||
assert config.firewall_enabled is False
|
||||
assert config.managed_rulesets == {}
|
||||
assert config.custom_rules == []
|
||||
assert config.rate_limiting_rules == []
|
||||
assert config.ip_blocking_rules == []
|
||||
|
||||
def test_fetch_firewall_config_uses_project_security_metadata_when_config_empty(
|
||||
self,
|
||||
):
|
||||
project = VercelProject(
|
||||
id=PROJECT_ID,
|
||||
name=PROJECT_NAME,
|
||||
team_id=TEAM_ID,
|
||||
firewall_enabled=True,
|
||||
firewall_config_version="42",
|
||||
managed_rules={
|
||||
"owasp": {"active": True, "action": "log"},
|
||||
"ai_bots": {"active": False, "action": "deny"},
|
||||
},
|
||||
)
|
||||
service = Security.__new__(Security)
|
||||
service.firewall_configs = {}
|
||||
|
||||
service._get = mock.MagicMock(
|
||||
return_value={"active": None, "draft": None, "versions": []}
|
||||
)
|
||||
|
||||
service._fetch_firewall_config(project)
|
||||
|
||||
service._get.assert_called_once_with(
|
||||
"/v1/security/firewall/config/42",
|
||||
params={"projectId": PROJECT_ID, "teamId": TEAM_ID},
|
||||
)
|
||||
|
||||
config = service.firewall_configs[PROJECT_ID]
|
||||
assert config.firewall_enabled is True
|
||||
assert config.managed_rulesets == {"owasp": {"active": True, "action": "log"}}
|
||||
assert config.custom_rules == []
|
||||
assert config.rate_limiting_rules == []
|
||||
assert config.ip_blocking_rules == []
|
||||
@@ -7,6 +7,7 @@ All notable changes to the **Prowler UI** are documented in this file.
|
||||
### 🚀 Added
|
||||
|
||||
- Resources side drawer with redesigned detail panel [(#10673)](https://github.com/prowler-cloud/prowler/pull/10673)
|
||||
- Syntax highlighting for remediation code blocks in finding groups drawer with provider-aware auto-detection (Shell, HCL, YAML, Bicep) [(#10698)](https://github.com/prowler-cloud/prowler/pull/10698)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
|
||||
@@ -178,16 +178,30 @@ vi.mock("@/components/findings/markdown-container", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shared/query-code-editor", () => ({
|
||||
QUERY_EDITOR_LANGUAGE: {
|
||||
OPEN_CYPHER: "openCypher",
|
||||
PLAIN_TEXT: "plainText",
|
||||
SHELL: "shell",
|
||||
HCL: "hcl",
|
||||
BICEP: "bicep",
|
||||
YAML: "yaml",
|
||||
},
|
||||
QueryCodeEditor: ({
|
||||
ariaLabel,
|
||||
language,
|
||||
value,
|
||||
copyValue,
|
||||
}: {
|
||||
ariaLabel: string;
|
||||
language?: string;
|
||||
value: string;
|
||||
copyValue?: string;
|
||||
}) => (
|
||||
<div data-testid="query-code-editor" data-aria-label={ariaLabel}>
|
||||
<div
|
||||
data-testid="query-code-editor"
|
||||
data-aria-label={ariaLabel}
|
||||
data-language={language}
|
||||
>
|
||||
<span>{ariaLabel}</span>
|
||||
<span>{value}</span>
|
||||
<button
|
||||
@@ -512,6 +526,34 @@ describe("ResourceDetailDrawerContent — Fix 2: Remediation heading labels", ()
|
||||
expect(mockClipboardWriteText).toHaveBeenCalledWith("aws s3 ...");
|
||||
expect(screen.getByText("$ aws s3 ...")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should pass syntax highlighting languages to each remediation editor", () => {
|
||||
// Given
|
||||
render(
|
||||
<ResourceDetailDrawerContent
|
||||
isLoading={false}
|
||||
isNavigating={false}
|
||||
checkMeta={checkMetaWithCommands}
|
||||
currentIndex={0}
|
||||
totalResources={1}
|
||||
currentFinding={mockFinding}
|
||||
otherFindings={[]}
|
||||
onNavigatePrev={vi.fn()}
|
||||
onNavigateNext={vi.fn()}
|
||||
onMuteComplete={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
const editors = screen.getAllByTestId("query-code-editor");
|
||||
|
||||
// Then
|
||||
expect(editors[0]).toHaveAttribute("data-language", "shell");
|
||||
expect(editors[1]).toHaveAttribute("data-language", "hcl");
|
||||
expect(editors[2]).toHaveAttribute("data-language", "yaml");
|
||||
expect(editors[0]).toHaveAttribute("data-aria-label", "CLI Command");
|
||||
expect(editors[2]).toHaveAttribute("data-aria-label", "CloudFormation");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -44,7 +44,11 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/shadcn/tooltip";
|
||||
import { EventsTimeline } from "@/components/shared/events-timeline/events-timeline";
|
||||
import { QueryCodeEditor } from "@/components/shared/query-code-editor";
|
||||
import {
|
||||
QUERY_EDITOR_LANGUAGE,
|
||||
QueryCodeEditor,
|
||||
type QueryEditorLanguage,
|
||||
} from "@/components/shared/query-code-editor";
|
||||
import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet";
|
||||
import { CustomLink } from "@/components/ui/custom/custom-link";
|
||||
import { DateWithTime } from "@/components/ui/entities/date-with-time";
|
||||
@@ -81,19 +85,49 @@ function stripCodeFences(code: string): string {
|
||||
.trim();
|
||||
}
|
||||
|
||||
function resolveNativeIacConfig(providerType: string | undefined): {
|
||||
label: string;
|
||||
language: QueryEditorLanguage;
|
||||
} {
|
||||
switch (providerType) {
|
||||
case "aws":
|
||||
return {
|
||||
label: "CloudFormation",
|
||||
language: QUERY_EDITOR_LANGUAGE.YAML,
|
||||
};
|
||||
case "azure":
|
||||
return {
|
||||
label: "Bicep",
|
||||
language: QUERY_EDITOR_LANGUAGE.BICEP,
|
||||
};
|
||||
case "kubernetes":
|
||||
return {
|
||||
label: "Kubernetes Manifest",
|
||||
language: QUERY_EDITOR_LANGUAGE.YAML,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
label: "Native IaC",
|
||||
language: QUERY_EDITOR_LANGUAGE.PLAIN_TEXT,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function renderRemediationCodeBlock({
|
||||
label,
|
||||
value,
|
||||
copyValue,
|
||||
language = QUERY_EDITOR_LANGUAGE.PLAIN_TEXT,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
copyValue?: string;
|
||||
language?: QueryEditorLanguage;
|
||||
}) {
|
||||
return (
|
||||
<QueryCodeEditor
|
||||
ariaLabel={label}
|
||||
language="plainText"
|
||||
language={language}
|
||||
value={value}
|
||||
copyValue={copyValue}
|
||||
editable={false}
|
||||
@@ -351,6 +385,7 @@ export function ResourceDetailDrawerContent({
|
||||
? (f?.scan?.id ?? null)
|
||||
: null;
|
||||
const regionFilter = searchParams.get("filter[region__in]");
|
||||
const nativeIacConfig = resolveNativeIacConfig(f?.providerType);
|
||||
|
||||
const handleOpenCompliance = async (framework: string) => {
|
||||
if (!complianceScanId || resolvingFramework) {
|
||||
@@ -741,6 +776,7 @@ export function ResourceDetailDrawerContent({
|
||||
<div className="flex flex-col gap-1">
|
||||
{renderRemediationCodeBlock({
|
||||
label: "CLI Command",
|
||||
language: QUERY_EDITOR_LANGUAGE.SHELL,
|
||||
value: `$ ${stripCodeFences(checkMeta.remediation.code.cli)}`,
|
||||
copyValue: stripCodeFences(
|
||||
checkMeta.remediation.code.cli,
|
||||
@@ -753,6 +789,7 @@ export function ResourceDetailDrawerContent({
|
||||
<div className="flex flex-col gap-1">
|
||||
{renderRemediationCodeBlock({
|
||||
label: "Terraform",
|
||||
language: QUERY_EDITOR_LANGUAGE.HCL,
|
||||
value: stripCodeFences(
|
||||
checkMeta.remediation.code.terraform,
|
||||
),
|
||||
@@ -760,10 +797,11 @@ export function ResourceDetailDrawerContent({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{checkMeta.remediation.code.nativeiac && (
|
||||
{checkMeta.remediation.code.nativeiac && f && (
|
||||
<div className="flex flex-col gap-1">
|
||||
{renderRemediationCodeBlock({
|
||||
label: "CloudFormation",
|
||||
label: nativeIacConfig.label,
|
||||
language: nativeIacConfig.language,
|
||||
value: stripCodeFences(
|
||||
checkMeta.remediation.code.nativeiac,
|
||||
),
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import {
|
||||
HighlightStyle,
|
||||
StreamLanguage,
|
||||
type StringStream,
|
||||
syntaxHighlighting,
|
||||
} from "@codemirror/language";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
@@ -20,12 +21,16 @@ import { type HTMLAttributes, useState } from "react";
|
||||
import { Badge } from "@/components/shadcn";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const QUERY_EDITOR_LANGUAGE = {
|
||||
export const QUERY_EDITOR_LANGUAGE = {
|
||||
OPEN_CYPHER: "openCypher",
|
||||
PLAIN_TEXT: "plainText",
|
||||
SHELL: "shell",
|
||||
HCL: "hcl",
|
||||
BICEP: "bicep",
|
||||
YAML: "yaml",
|
||||
} as const;
|
||||
|
||||
type QueryEditorLanguage =
|
||||
export type QueryEditorLanguage =
|
||||
(typeof QUERY_EDITOR_LANGUAGE)[keyof typeof QUERY_EDITOR_LANGUAGE];
|
||||
|
||||
const OPEN_CYPHER_KEYWORDS = new Set([
|
||||
@@ -98,11 +103,204 @@ const OPEN_CYPHER_FUNCTIONS = new Set([
|
||||
"type",
|
||||
]);
|
||||
|
||||
const SHELL_KEYWORDS = new Set([
|
||||
"if",
|
||||
"then",
|
||||
"else",
|
||||
"elif",
|
||||
"fi",
|
||||
"for",
|
||||
"in",
|
||||
"do",
|
||||
"done",
|
||||
"while",
|
||||
"until",
|
||||
"case",
|
||||
"esac",
|
||||
"function",
|
||||
"return",
|
||||
"export",
|
||||
"local",
|
||||
"readonly",
|
||||
"declare",
|
||||
"typeset",
|
||||
"unset",
|
||||
"shift",
|
||||
"break",
|
||||
"continue",
|
||||
"select",
|
||||
"time",
|
||||
"trap",
|
||||
]);
|
||||
|
||||
const SHELL_COMMANDS = new Set([
|
||||
"aws",
|
||||
"az",
|
||||
"gcloud",
|
||||
"kubectl",
|
||||
"terraform",
|
||||
"echo",
|
||||
"grep",
|
||||
"sed",
|
||||
"awk",
|
||||
"curl",
|
||||
"wget",
|
||||
"chmod",
|
||||
"chown",
|
||||
"mkdir",
|
||||
"rm",
|
||||
"cp",
|
||||
"mv",
|
||||
"cat",
|
||||
"ls",
|
||||
]);
|
||||
|
||||
const HCL_KEYWORDS = new Set([
|
||||
"resource",
|
||||
"data",
|
||||
"variable",
|
||||
"output",
|
||||
"locals",
|
||||
"module",
|
||||
"provider",
|
||||
"terraform",
|
||||
"backend",
|
||||
"required_providers",
|
||||
"dynamic",
|
||||
"for_each",
|
||||
"count",
|
||||
"depends_on",
|
||||
"lifecycle",
|
||||
"provisioner",
|
||||
"connection",
|
||||
]);
|
||||
|
||||
const HCL_FUNCTIONS = new Set([
|
||||
"lookup",
|
||||
"merge",
|
||||
"join",
|
||||
"split",
|
||||
"length",
|
||||
"element",
|
||||
"concat",
|
||||
"format",
|
||||
"replace",
|
||||
"regex",
|
||||
"tolist",
|
||||
"tomap",
|
||||
"toset",
|
||||
"try",
|
||||
"can",
|
||||
"file",
|
||||
"templatefile",
|
||||
"jsonencode",
|
||||
"jsondecode",
|
||||
"yamlencode",
|
||||
"yamldecode",
|
||||
"base64encode",
|
||||
"base64decode",
|
||||
"md5",
|
||||
"sha256",
|
||||
"cidrsubnet",
|
||||
"cidrhost",
|
||||
]);
|
||||
|
||||
const BICEP_KEYWORDS = new Set([
|
||||
"resource",
|
||||
"module",
|
||||
"param",
|
||||
"var",
|
||||
"output",
|
||||
"type",
|
||||
"metadata",
|
||||
"import",
|
||||
"using",
|
||||
"extension",
|
||||
"targetScope",
|
||||
"existing",
|
||||
"if",
|
||||
"for",
|
||||
"in",
|
||||
"true",
|
||||
"false",
|
||||
"null",
|
||||
]);
|
||||
|
||||
const BICEP_DECORATORS = new Set([
|
||||
"description",
|
||||
"secure",
|
||||
"minLength",
|
||||
"maxLength",
|
||||
"minValue",
|
||||
"maxValue",
|
||||
"allowed",
|
||||
"metadata",
|
||||
"batchSize",
|
||||
"sys",
|
||||
]);
|
||||
|
||||
const BICEP_FUNCTIONS = new Set([
|
||||
"concat",
|
||||
"format",
|
||||
"toLower",
|
||||
"toUpper",
|
||||
"substring",
|
||||
"replace",
|
||||
"split",
|
||||
"join",
|
||||
"length",
|
||||
"contains",
|
||||
"empty",
|
||||
"first",
|
||||
"last",
|
||||
"indexOf",
|
||||
"array",
|
||||
"union",
|
||||
"intersection",
|
||||
"resourceId",
|
||||
"subscriptionResourceId",
|
||||
"tenantResourceId",
|
||||
"reference",
|
||||
"listKeys",
|
||||
"listAccountSas",
|
||||
"uniqueString",
|
||||
"guid",
|
||||
"base64",
|
||||
"uri",
|
||||
"environment",
|
||||
"subscription",
|
||||
"resourceGroup",
|
||||
"tenant",
|
||||
]);
|
||||
|
||||
interface OpenCypherParserState {
|
||||
inBlockComment: boolean;
|
||||
inString: "'" | '"' | null;
|
||||
}
|
||||
|
||||
interface ShellParserState {
|
||||
inString: "'" | '"' | null;
|
||||
}
|
||||
|
||||
interface HclParserState {
|
||||
inBlockComment: boolean;
|
||||
inString: '"' | null;
|
||||
expectBlockType: boolean;
|
||||
heredocTerminator: string | null;
|
||||
}
|
||||
|
||||
interface BicepParserState {
|
||||
inBlockComment: boolean;
|
||||
inString: "'" | null;
|
||||
expectResourceType: boolean;
|
||||
}
|
||||
|
||||
interface YamlParserState {
|
||||
inString: "'" | '"' | null;
|
||||
inBlockScalar: boolean;
|
||||
blockScalarIndent: number;
|
||||
}
|
||||
|
||||
const openCypherLanguage = StreamLanguage.define<OpenCypherParserState>({
|
||||
startState() {
|
||||
return {
|
||||
@@ -216,6 +414,595 @@ const openCypherLanguage = StreamLanguage.define<OpenCypherParserState>({
|
||||
},
|
||||
});
|
||||
|
||||
const shellLanguage = StreamLanguage.define<ShellParserState>({
|
||||
startState() {
|
||||
return {
|
||||
inString: null,
|
||||
};
|
||||
},
|
||||
token(stream, state) {
|
||||
if (state.inString) {
|
||||
let escaped = false;
|
||||
|
||||
while (!stream.eol()) {
|
||||
const next = stream.next();
|
||||
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (next === "\\" && state.inString === '"') {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (next === state.inString) {
|
||||
state.inString = null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (stream.eol()) {
|
||||
state.inString = null;
|
||||
}
|
||||
|
||||
return "string";
|
||||
}
|
||||
|
||||
if (stream.eatSpace()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (stream.peek() === "#") {
|
||||
stream.skipToEnd();
|
||||
return "comment";
|
||||
}
|
||||
|
||||
if (stream.match(/\$\([^)]+\)/)) {
|
||||
return "variableName";
|
||||
}
|
||||
|
||||
if (stream.match(/\$\{[A-Za-z_][\w]*\}/)) {
|
||||
return "variableName";
|
||||
}
|
||||
|
||||
if (stream.match(/\$[A-Za-z_][\w]*/)) {
|
||||
return "variableName";
|
||||
}
|
||||
|
||||
const quote = stream.peek();
|
||||
if (quote === "'" || quote === '"') {
|
||||
state.inString = quote;
|
||||
stream.next();
|
||||
return "string";
|
||||
}
|
||||
|
||||
if (stream.match(/--[A-Za-z0-9][\w-]*/)) {
|
||||
return "operator";
|
||||
}
|
||||
|
||||
if (stream.match(/-[A-Za-z0-9]+/)) {
|
||||
return "operator";
|
||||
}
|
||||
|
||||
if (stream.match(/\|\||&&|>>|[|><;]/)) {
|
||||
return "operator";
|
||||
}
|
||||
|
||||
if (stream.match(/\d+(?:\.\d+)?/)) {
|
||||
return "number";
|
||||
}
|
||||
|
||||
if (stream.match(/[A-Za-z_][\w-]*/)) {
|
||||
const currentValue = stream.current();
|
||||
const normalizedValue = currentValue.toLowerCase();
|
||||
|
||||
if (SHELL_KEYWORDS.has(normalizedValue)) {
|
||||
return "keyword";
|
||||
}
|
||||
|
||||
if (SHELL_COMMANDS.has(normalizedValue)) {
|
||||
return "function";
|
||||
}
|
||||
|
||||
return "variableName";
|
||||
}
|
||||
|
||||
stream.next();
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
const hclLanguage = StreamLanguage.define<HclParserState>({
|
||||
startState() {
|
||||
return {
|
||||
inBlockComment: false,
|
||||
inString: null,
|
||||
expectBlockType: false,
|
||||
heredocTerminator: null,
|
||||
};
|
||||
},
|
||||
token(stream, state) {
|
||||
if (state.heredocTerminator) {
|
||||
// Match the closing terminator on its own line, including indented <<-EOF forms.
|
||||
if (
|
||||
stream.sol() &&
|
||||
stream.match(new RegExp(`^\\s*${state.heredocTerminator}\\s*$`))
|
||||
) {
|
||||
state.heredocTerminator = null;
|
||||
return "keyword";
|
||||
}
|
||||
|
||||
stream.skipToEnd();
|
||||
return "string";
|
||||
}
|
||||
|
||||
if (state.inBlockComment) {
|
||||
while (!stream.eol()) {
|
||||
if (stream.match("*/")) {
|
||||
state.inBlockComment = false;
|
||||
break;
|
||||
}
|
||||
|
||||
stream.next();
|
||||
}
|
||||
|
||||
return "comment";
|
||||
}
|
||||
|
||||
if (state.inString) {
|
||||
let escaped = false;
|
||||
|
||||
while (!stream.eol()) {
|
||||
const next = stream.next();
|
||||
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (next === "\\") {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (next === state.inString) {
|
||||
state.inString = null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (stream.eol()) {
|
||||
state.inString = null;
|
||||
}
|
||||
|
||||
return "string";
|
||||
}
|
||||
|
||||
if (stream.eatSpace()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (stream.match("#") || stream.match("//")) {
|
||||
stream.skipToEnd();
|
||||
return "comment";
|
||||
}
|
||||
|
||||
if (stream.match("/*")) {
|
||||
state.inBlockComment = true;
|
||||
return "comment";
|
||||
}
|
||||
|
||||
if (stream.match(/\$\{[^}]+\}/)) {
|
||||
return "variableName";
|
||||
}
|
||||
|
||||
if (stream.peek() === '"') {
|
||||
if (state.expectBlockType) {
|
||||
state.expectBlockType = false;
|
||||
stream.next(); // opening "
|
||||
while (!stream.eol()) {
|
||||
const ch = stream.next();
|
||||
if (ch === "\\") {
|
||||
stream.next(); // skip escaped char
|
||||
} else if (ch === '"') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return "typeName";
|
||||
}
|
||||
|
||||
state.inString = '"';
|
||||
stream.next();
|
||||
return "string";
|
||||
}
|
||||
|
||||
if (stream.match(/[{}\[\]()]/)) {
|
||||
return "punctuation";
|
||||
}
|
||||
|
||||
// Heredoc (<<EOF, <<-EOF) — must be before generic operator matcher
|
||||
if (stream.match(/<<-?([A-Za-z_][\w]*)/)) {
|
||||
state.heredocTerminator = stream.current().replace(/^<<-?/, "");
|
||||
return "keyword";
|
||||
}
|
||||
|
||||
if (stream.match(/=>|\.\.\.|==|!=|>=|<=|[=><?:]/)) {
|
||||
return "operator";
|
||||
}
|
||||
|
||||
if (stream.match(/\b(?:true|false)\b/)) {
|
||||
return "keyword";
|
||||
}
|
||||
|
||||
if (stream.match(/\d+(?:\.\d+)?/)) {
|
||||
return "number";
|
||||
}
|
||||
|
||||
if (stream.match(/[A-Za-z_][\w-]*/)) {
|
||||
const currentValue = stream.current();
|
||||
const normalizedValue = currentValue.toLowerCase();
|
||||
|
||||
if (HCL_KEYWORDS.has(normalizedValue)) {
|
||||
state.expectBlockType =
|
||||
normalizedValue === "resource" || normalizedValue === "data";
|
||||
return "keyword";
|
||||
}
|
||||
|
||||
if (
|
||||
HCL_FUNCTIONS.has(normalizedValue) &&
|
||||
stream.match(/\s*(?=\()/, false)
|
||||
) {
|
||||
return "function";
|
||||
}
|
||||
|
||||
if (state.expectBlockType) {
|
||||
state.expectBlockType = false;
|
||||
return "typeName";
|
||||
}
|
||||
|
||||
if (stream.match(/\s*(?==)/, false)) {
|
||||
return "propertyName";
|
||||
}
|
||||
|
||||
return "variableName";
|
||||
}
|
||||
|
||||
stream.next();
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
const yamlLanguage = StreamLanguage.define<YamlParserState>({
|
||||
startState() {
|
||||
return {
|
||||
inString: null,
|
||||
inBlockScalar: false,
|
||||
blockScalarIndent: 0,
|
||||
};
|
||||
},
|
||||
token(stream, state) {
|
||||
// Block scalar continuation (| or > multiline strings)
|
||||
if (state.inBlockScalar) {
|
||||
// Blank lines are always part of a block scalar.
|
||||
if (stream.match(/^\s*$/)) {
|
||||
stream.skipToEnd();
|
||||
return "string";
|
||||
}
|
||||
|
||||
const indent = stream.indentation();
|
||||
|
||||
if (indent > state.blockScalarIndent) {
|
||||
stream.skipToEnd();
|
||||
return "string";
|
||||
}
|
||||
|
||||
state.inBlockScalar = false;
|
||||
state.blockScalarIndent = 0;
|
||||
}
|
||||
|
||||
// Continue quoted strings across tokens
|
||||
if (state.inString) {
|
||||
let escaped = false;
|
||||
|
||||
while (!stream.eol()) {
|
||||
const next = stream.next();
|
||||
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (next === "\\" && state.inString === '"') {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (next === state.inString) {
|
||||
state.inString = null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return "string";
|
||||
}
|
||||
|
||||
if (stream.eatSpace()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Comments
|
||||
if (stream.peek() === "#") {
|
||||
stream.skipToEnd();
|
||||
return "comment";
|
||||
}
|
||||
|
||||
// Document markers
|
||||
if (stream.sol() && (stream.match("---") || stream.match("..."))) {
|
||||
return "keyword";
|
||||
}
|
||||
|
||||
// Anchors & aliases
|
||||
if (stream.match(/[&*][A-Za-z_][\w]*/)) {
|
||||
return "variableName";
|
||||
}
|
||||
|
||||
// CloudFormation intrinsic tags (!Ref, !Sub, !GetAtt, etc.)
|
||||
if (stream.match(/![A-Za-z][A-Za-z0-9]*/)) {
|
||||
return "typeName";
|
||||
}
|
||||
|
||||
// Tags (!!str, !!map, etc.)
|
||||
if (stream.match(/!![A-Za-z]+/)) {
|
||||
return "typeName";
|
||||
}
|
||||
|
||||
// Quoted strings
|
||||
const quote = stream.peek();
|
||||
if (quote === "'" || quote === '"') {
|
||||
state.inString = quote;
|
||||
stream.next();
|
||||
return "string";
|
||||
}
|
||||
|
||||
// Block scalar indicators (| or >)
|
||||
if (
|
||||
stream.sol() === false &&
|
||||
(stream.peek() === "|" || stream.peek() === ">")
|
||||
) {
|
||||
const prevChar = stream.string.charAt(stream.pos - 1);
|
||||
|
||||
if (prevChar === " " || prevChar === ":") {
|
||||
stream.next();
|
||||
// Eat optional modifiers like |-, |+, |2
|
||||
stream.match(/[-+]?\d?/);
|
||||
state.inBlockScalar = true;
|
||||
state.blockScalarIndent = stream.indentation();
|
||||
return "operator";
|
||||
}
|
||||
}
|
||||
|
||||
// List item marker
|
||||
if (stream.match(/^-(?=\s)/)) {
|
||||
return "punctuation";
|
||||
}
|
||||
|
||||
// Key: value pattern — supports CloudFormation long-form intrinsics (Fn::Sub:)
|
||||
if (stream.match(/[A-Za-z_][\w./-]*(?:::[A-Za-z_][\w]*)*(?=\s*:(?!:))/)) {
|
||||
return "propertyName";
|
||||
}
|
||||
|
||||
// Booleans & null (YAML spec values)
|
||||
if (stream.match(/\b(?:true|false|yes|no|on|off|null)\b/i)) {
|
||||
return "keyword";
|
||||
}
|
||||
|
||||
// Numbers (integers, floats, hex, octal)
|
||||
if (
|
||||
stream.match(
|
||||
/^[-+]?(?:0x[0-9a-fA-F]+|0o[0-7]+|0b[01]+|\d+(?:\.\d+)?(?:[eE][-+]?\d+)?)/,
|
||||
)
|
||||
) {
|
||||
return "number";
|
||||
}
|
||||
|
||||
// Colon separator
|
||||
if (stream.match(":")) {
|
||||
return "punctuation";
|
||||
}
|
||||
|
||||
// Braces/brackets (flow style)
|
||||
if (stream.match(/[{}\[\],]/)) {
|
||||
return "punctuation";
|
||||
}
|
||||
|
||||
// Tilde (null alias)
|
||||
if (stream.match("~")) {
|
||||
return "keyword";
|
||||
}
|
||||
|
||||
// Unquoted strings / values — consume word
|
||||
if (stream.match(/[^\s#:,\[\]{}]+/)) {
|
||||
return "string";
|
||||
}
|
||||
|
||||
stream.next();
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
function readBicepStringSegment(
|
||||
stream: StringStream,
|
||||
includeOpeningQuote = false,
|
||||
) {
|
||||
if (includeOpeningQuote) {
|
||||
stream.next();
|
||||
}
|
||||
|
||||
while (!stream.eol()) {
|
||||
if (stream.match("${")) {
|
||||
stream.backUp(2);
|
||||
break;
|
||||
}
|
||||
|
||||
const next = stream.next();
|
||||
|
||||
if (next === "'" && stream.peek() === "'") {
|
||||
stream.next();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (next === "'") {
|
||||
stream.backUp(1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const bicepLanguage = StreamLanguage.define<BicepParserState>({
|
||||
startState() {
|
||||
return {
|
||||
inBlockComment: false,
|
||||
inString: null,
|
||||
expectResourceType: false,
|
||||
};
|
||||
},
|
||||
token(stream, state) {
|
||||
if (state.inBlockComment) {
|
||||
while (!stream.eol()) {
|
||||
if (stream.match("*/")) {
|
||||
state.inBlockComment = false;
|
||||
break;
|
||||
}
|
||||
|
||||
stream.next();
|
||||
}
|
||||
|
||||
return "comment";
|
||||
}
|
||||
|
||||
if (state.inString) {
|
||||
if (stream.match("${")) {
|
||||
let depth = 1;
|
||||
|
||||
while (!stream.eol() && depth > 0) {
|
||||
const next = stream.next();
|
||||
|
||||
if (next === "{") {
|
||||
depth += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (next === "}") {
|
||||
depth -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
return "variableName";
|
||||
}
|
||||
|
||||
readBicepStringSegment(stream);
|
||||
|
||||
if (stream.peek() !== "'") {
|
||||
return "string";
|
||||
}
|
||||
|
||||
if (stream.peek() === "'") {
|
||||
state.inString = null;
|
||||
stream.next();
|
||||
}
|
||||
|
||||
return "string";
|
||||
}
|
||||
|
||||
if (stream.eatSpace()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (stream.match("//")) {
|
||||
stream.skipToEnd();
|
||||
return "comment";
|
||||
}
|
||||
|
||||
if (stream.match("/*")) {
|
||||
state.inBlockComment = true;
|
||||
return "comment";
|
||||
}
|
||||
|
||||
if (stream.match(/@[A-Za-z_][\w]*/)) {
|
||||
const decorator = stream.current().slice(1);
|
||||
|
||||
if (BICEP_DECORATORS.has(decorator)) {
|
||||
return "keyword";
|
||||
}
|
||||
|
||||
return "keyword";
|
||||
}
|
||||
|
||||
if (stream.peek() === "'") {
|
||||
if (state.expectResourceType) {
|
||||
state.expectResourceType = false;
|
||||
// Consume the full quoted resource type including both quotes
|
||||
stream.next(); // opening '
|
||||
while (!stream.eol()) {
|
||||
const ch = stream.next();
|
||||
if (ch === "'" && stream.peek() === "'") {
|
||||
stream.next(); // escaped ''
|
||||
continue;
|
||||
}
|
||||
if (ch === "'") {
|
||||
break; // closing '
|
||||
}
|
||||
}
|
||||
return "typeName";
|
||||
}
|
||||
|
||||
stream.next(); // consume opening '
|
||||
state.inString = "'";
|
||||
return "string";
|
||||
}
|
||||
|
||||
if (stream.match(/[A-Za-z_][\w-]*(?=\s*:)/)) {
|
||||
return "propertyName";
|
||||
}
|
||||
|
||||
if (stream.match(/\?\?|==|!=|>=|<=|=|>|<|\?|:|!/)) {
|
||||
return "operator";
|
||||
}
|
||||
|
||||
if (stream.match(/[{}\[\]()]/)) {
|
||||
return "punctuation";
|
||||
}
|
||||
|
||||
if (stream.match(/-?\d+(?:\.\d+)?/)) {
|
||||
return "number";
|
||||
}
|
||||
|
||||
if (stream.match(/[A-Za-z_][\w]*/)) {
|
||||
const currentValue = stream.current();
|
||||
|
||||
if (BICEP_KEYWORDS.has(currentValue)) {
|
||||
state.expectResourceType =
|
||||
currentValue === "resource" || currentValue === "module";
|
||||
return "keyword";
|
||||
}
|
||||
|
||||
if (
|
||||
BICEP_FUNCTIONS.has(currentValue) &&
|
||||
stream.match(/\s*(?=\()/, false)
|
||||
) {
|
||||
return "function";
|
||||
}
|
||||
|
||||
return "variableName";
|
||||
}
|
||||
|
||||
stream.next();
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
const lightHighlightStyle = HighlightStyle.define([
|
||||
{ tag: tags.keyword, color: "#0550ae", fontWeight: "600" },
|
||||
{ tag: tags.string, color: "#0a3069" },
|
||||
@@ -380,6 +1167,14 @@ export const QueryCodeEditor = ({
|
||||
openCypherLanguage,
|
||||
syntaxHighlighting(editorHighlightStyle),
|
||||
);
|
||||
} else if (language === QUERY_EDITOR_LANGUAGE.SHELL) {
|
||||
extensions.push(shellLanguage, syntaxHighlighting(editorHighlightStyle));
|
||||
} else if (language === QUERY_EDITOR_LANGUAGE.HCL) {
|
||||
extensions.push(hclLanguage, syntaxHighlighting(editorHighlightStyle));
|
||||
} else if (language === QUERY_EDITOR_LANGUAGE.BICEP) {
|
||||
extensions.push(bicepLanguage, syntaxHighlighting(editorHighlightStyle));
|
||||
} else if (language === QUERY_EDITOR_LANGUAGE.YAML) {
|
||||
extensions.push(yamlLanguage, syntaxHighlighting(editorHighlightStyle));
|
||||
}
|
||||
|
||||
const handleCopy = async () => {
|
||||
|
||||