Compare commits

...

11 Commits

Author SHA1 Message Date
Josema Camacho
29e90badba Merge branch 'master' of github.com:prowler-cloud/prowler into fix/sdk-aws-test-mock-isolation 2026-04-15 13:04:35 +02:00
Alan Buscaglia
d5354e8b1d feat(ui): add syntax highlighting to finding groups remediation code (#10698) 2026-04-15 12:58:35 +02:00
Rubén De la Torre Vico
a96e5890dc docs: replace Excalidraw diagrams with Mermaid and fix architecture connections (#10697) 2026-04-15 12:51:29 +02:00
Pepe Fagoaga
bb81c5dd2d docs: add contextual menu for copy and issue/feat (#10699) 2026-04-15 12:50:29 +02:00
Daniel Barranquero
c3acb818d9 fix(vercel): handle team-scoped firewall config responses (#10695) 2026-04-15 11:59:20 +02:00
Andoni Alonso
e6fc59267b docs: add Finding Groups documentation page (#10696)
Co-authored-by: Pepe Fagoaga <pepe@prowler.com>
2026-04-15 11:58:39 +02:00
Josema Camacho
62f114f5d0 refactor(api): remove dead cleanup_findings no-op from attack-paths module (#10684) 2026-04-15 09:16:38 +02:00
Pepe Fagoaga
392ffd5a60 fix(beat): make it dependant from API service (#10603)
Co-authored-by: Josema Camacho <josema@prowler.com>
2026-04-14 18:35:26 +02:00
Josema Camacho
2f379f9663 fix(sdk): restore single quotes in sdk-tests.yml, keep only -p no:randomly removal 2026-04-08 13:42:14 +02:00
Josema Camacho
b50a9c5bce fix(sdk): address copilot review comments on error message and unused parameter 2026-04-08 12:10:33 +02:00
Josema Camacho
b0d0e995ad fix(sdk): add autouse mock_aws fixture and leak detector to prevent AWS test leaks 2026-04-08 10:28:23 +02:00
43 changed files with 1621 additions and 112 deletions

View File

@@ -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 }}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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}"

View File

@@ -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)
# -------------------------------------

View File

@@ -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);",

View File

@@ -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)
# ---------------------------------------

View File

@@ -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,

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -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:

View File

@@ -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:

View File

@@ -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" />
![Prowler Lighthouse Architecture](/images/lighthouse-architecture.png)
### Three-Tier Architecture

View File

@@ -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",

View File

@@ -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
![Prowler Lighthouse Architecture](/images/lighthouse-architecture.png)
## 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.

View File

@@ -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" />
![Prowler MCP Server Schema](/images/prowler_mcp_schema.png)
The architecture shows how AI assistants connect through the MCP protocol to access Prowler's three main components:
- Prowler Cloud/App for security operations

Binary file not shown.

After

Width:  |  Height:  |  Size: 755 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 410 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 267 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 265 KiB

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 KiB

View File

@@ -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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 268 KiB

After

Width:  |  Height:  |  Size: 348 KiB

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 371 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 328 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 332 KiB

View 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.
![Finding Groups list view](/images/finding-groups-list.png)
### 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 |
![Finding Groups expanded view](/images/finding-groups-expanded.png)
### 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
![Finding Groups resource detail drawer](/images/finding-groups-drawer.png)
### 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.
![Other Findings For This Resource tab](/images/finding-groups-other-findings.png)
### 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.

View File

@@ -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" />
![Prowler Lighthouse Architecture](/images/lighthouse-architecture.png)
<Note>

View File

@@ -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)

View File

@@ -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)

View File

@@ -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."""

View 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])
)

View File

@@ -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"]

View File

@@ -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

View File

@@ -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 == []

View File

@@ -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

View File

@@ -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");
});
});
// ---------------------------------------------------------------------------

View File

@@ -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,
),

View File

@@ -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 () => {