Merge branch 'master' into fix/finding-groups-aggregated-filters

This commit is contained in:
Adrián Jesús Peña Rodríguez committed 2026-03-23 17:03:08 +01:00
commit ce9e1af176
27 files changed
+1232 -169

No files matched your search

+6
View File
@@ -14,6 +14,12 @@ All notable changes to the **Prowler API** are documented in this file.
- Replace stdlib XML parser with `defusedxml` in SAML metadata parsing to prevent XML bomb (billion laughs) DoS attacks [(#10165)](https://github.com/prowler-cloud/prowler/pull/10165)
## [1.22.2] (Prowler UNRELEASED)
### 🔐 Security
- Bump `flask` to 3.1.3 (CVE-2026-27205) and `werkzeug` to 3.1.6 (CVE-2026-27199) [(#10430)](https://github.com/prowler-cloud/prowler/pull/10430)
---
## [1.22.1] (Prowler v5.21.1)
+23 -40
View File
@@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand.
# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand.
[[package]]
name = "about-time"
@@ -2711,24 +2711,6 @@ files = [
{file = "defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69"},
]
[[package]]
name = "deprecated"
version = "1.3.1"
description = "Python @deprecated decorator to deprecate old python classes, functions or methods."
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7"
groups = ["main"]
files = [
{file = "deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f"},
{file = "deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223"},
]
[package.dependencies]
wrapt = ">=1.10,<3"
[package.extras]
dev = ["PyTest", "PyTest-Cov", "bump2version (<1)", "setuptools ; python_version >= \"3.12\"", "tox"]
[[package]]
name = "detect-secrets"
version = "1.5.0"
@@ -2983,7 +2965,7 @@ files = [
[package.dependencies]
autopep8 = "*"
Django = ">=4.2"
gprof2dot = ">=2017.9.19"
gprof2dot = ">=2017.09.19"
sqlparse = "*"
[[package]]
@@ -3370,14 +3352,14 @@ files = [
[[package]]
name = "flask"
version = "3.1.2"
version = "3.1.3"
description = "A simple framework for building complex web applications."
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "flask-3.1.2-py3-none-any.whl", hash = "sha256:ca1d8112ec8a6158cc29ea4858963350011b5c846a414cdb7a954aa9e967d03c"},
{file = "flask-3.1.2.tar.gz", hash = "sha256:bf656c15c80190ed628ad08cdfd3aaa35beb087855e2f494910aa3774cc4fd87"},
{file = "flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c"},
{file = "flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb"},
]
[package.dependencies]
@@ -4591,7 +4573,7 @@ files = [
[package.dependencies]
attrs = ">=22.2.0"
jsonschema-specifications = ">=2023.3.6"
jsonschema-specifications = ">=2023.03.6"
referencing = ">=0.28.4"
rpds-py = ">=0.7.1"
@@ -4799,7 +4781,7 @@ librabbitmq = ["librabbitmq (>=2.0.0) ; python_version < \"3.11\""]
mongodb = ["pymongo (==4.15.3)"]
msgpack = ["msgpack (==1.1.2)"]
pyro = ["pyro4 (==4.82)"]
qpid = ["qpid-python (==1.36.0.post1)", "qpid-tools (==1.36.0.post1)"]
qpid = ["qpid-python (==1.36.0-1)", "qpid-tools (==1.36.0-1)"]
redis = ["redis (>=4.5.2,!=4.5.5,!=5.0.2,<6.5)"]
slmq = ["softlayer_messaging (>=1.0.3)"]
sqlalchemy = ["sqlalchemy (>=1.4.48,<2.1)"]
@@ -4820,7 +4802,7 @@ files = [
]
[package.dependencies]
certifi = ">=14.5.14"
certifi = ">=14.05.14"
durationpy = ">=0.7"
google-auth = ">=1.0.1"
oauthlib = ">=3.2.2"
@@ -6654,7 +6636,7 @@ files = [
[[package]]
name = "prowler"
version = "5.19.0"
version = "5.22.0"
description = "Prowler is an Open Source security tool to perform AWS, GCP and Azure security best practices assessments, audits, incident response, continuous monitoring, hardening and forensics readiness. It contains hundreds of controls covering CIS, NIST 800, NIST CSF, CISA, RBI, FedRAMP, PCI-DSS, GDPR, HIPAA, FFIEC, SOC2, GXP, AWS Well-Architected Framework Security Pillar, AWS Foundational Technical Review (FTR), ENS (Spanish National Security Scheme) and your custom security frameworks."
optional = false
python-versions = ">3.9.1,<3.13"
@@ -6712,6 +6694,7 @@ colorama = "0.4.6"
cryptography = "44.0.3"
dash = "3.1.1"
dash-bootstrap-components = "2.0.3"
defusedxml = ">=0.7.1"
detect-secrets = "1.5.0"
dulwich = "0.23.0"
google-api-python-client = "2.163.0"
@@ -6729,7 +6712,7 @@ pandas = "2.2.3"
py-iam-expand = "0.1.0"
py-ocsf-models = "0.8.1"
pydantic = ">=2.0,<3.0"
pygithub = "2.5.0"
pygithub = "2.8.0"
python-dateutil = ">=2.9.0.post0,<3.0.0"
pytz = "2025.1"
schema = "0.7.5"
@@ -6737,12 +6720,13 @@ shodan = "1.31.0"
slack-sdk = "3.39.0"
tabulate = "0.9.0"
tzlocal = "5.3.1"
uuid6 = "2024.7.10"
[package.source]
type = "git"
url = "https://github.com/prowler-cloud/prowler.git"
reference = "master"
resolved_reference = "b31145616064bd6727139777dca1cea9b977346a"
resolved_reference = "41629137efdec1ade078e4386f738c8e0ffce94b"
[[package]]
name = "psutil"
@@ -7115,22 +7099,21 @@ typing-extensions = ">=4.14.1"
[[package]]
name = "pygithub"
version = "2.5.0"
version = "2.8.0"
description = "Use the full Github API v3"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "PyGithub-2.5.0-py3-none-any.whl", hash = "sha256:b0b635999a658ab8e08720bdd3318893ff20e2275f6446fcf35bf3f44f2c0fd2"},
{file = "pygithub-2.5.0.tar.gz", hash = "sha256:e1613ac508a9be710920d26eb18b1905ebd9926aa49398e88151c1b526aad3cf"},
{file = "pygithub-2.8.0-py3-none-any.whl", hash = "sha256:11a3473c1c2f1c39c525d0ee8c559f369c6d46c272cb7321c9b0cabc7aa1ce7d"},
{file = "pygithub-2.8.0.tar.gz", hash = "sha256:72f5f2677d86bc3a8843aa720c6ce4c1c42fb7500243b136e3d5e14ddb5c3386"},
]
[package.dependencies]
Deprecated = "*"
pyjwt = {version = ">=2.4.0", extras = ["crypto"]}
pynacl = ">=1.4.0"
requests = ">=2.14.0"
typing-extensions = ">=4.0.0"
typing-extensions = ">=4.5.0"
urllib3 = ">=1.26.0"
[[package]]
@@ -7182,7 +7165,7 @@ files = [
]
[package.dependencies]
astroid = ">=3.2.2,<=3.3.0.dev0"
astroid = ">=3.2.2,<=3.3.0-dev0"
colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""}
dill = [
{version = ">=0.3.7", markers = "python_version >= \"3.12\""},
@@ -8196,10 +8179,10 @@ files = [
]
[package.dependencies]
botocore = ">=1.37.4,<2.0a0"
botocore = ">=1.37.4,<2.0a.0"
[package.extras]
crt = ["botocore[crt] (>=1.37.4,<2.0a0)"]
crt = ["botocore[crt] (>=1.37.4,<2.0a.0)"]
[[package]]
name = "safety"
@@ -8785,14 +8768,14 @@ test = ["pytest", "websockets"]
[[package]]
name = "werkzeug"
version = "3.1.5"
version = "3.1.6"
description = "The comprehensive WSGI web application library."
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "werkzeug-3.1.5-py3-none-any.whl", hash = "sha256:5111e36e91086ece91f93268bb39b4a35c1e6f1feac762c9c822ded0a4e322dc"},
{file = "werkzeug-3.1.5.tar.gz", hash = "sha256:6a548b0e88955dd07ccb25539d7d0cc97417ee9e179677d22c7041c8f078ce67"},
{file = "werkzeug-3.1.6-py3-none-any.whl", hash = "sha256:7ddf3357bb9564e407607f988f683d72038551200c704012bb9a4c523d42f131"},
{file = "werkzeug-3.1.6.tar.gz", hash = "sha256:210c6bede5a420a913956b4791a7f4d6843a43b6fcee4dfa08a65e93007d0d25"},
]
[package.dependencies]
+1 -2
View File
@@ -225,8 +225,7 @@
"group": "Kubernetes",
"pages": [
"user-guide/providers/kubernetes/getting-started-k8s",
"user-guide/providers/kubernetes/misc",
"user-guide/cookbooks/kubernetes-in-cluster"
"user-guide/providers/kubernetes/misc"
]
},
{
+1
View File
@@ -8,6 +8,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
- Azure MySQL flexible server checks now compare configuration values case-insensitively to avoid false negatives when Azure returns lowercase values [(#10396)](https://github.com/prowler-cloud/prowler/pull/10396)
- Azure `vm_backup_enabled` and `vm_sufficient_daily_backup_retention_period` checks now compare VM names case-insensitively to avoid false negatives when Azure stores backup item names in a different case [(#10395)](https://github.com/prowler-cloud/prowler/pull/10395)
- `entra_non_privileged_user_has_mfa` skips disabled users to avoid false positives [(#10426)](https://github.com/prowler-cloud/prowler/pull/10426)
---
@@ -1587,6 +1587,7 @@
"ap-northeast-1",
"ap-south-1",
"ap-southeast-2",
"ca-central-1",
"eu-central-1",
"eu-west-1",
"eu-west-2",
@@ -1670,20 +1671,8 @@
"budgets": {
"regions": {
"aws": [
"ap-northeast-1",
"ap-northeast-2",
"ap-south-1",
"ap-southeast-1",
"ap-southeast-2",
"ca-central-1",
"eu-central-1",
"eu-west-1",
"eu-west-2",
"eu-west-3",
"sa-east-1",
"us-east-1",
"us-east-2",
"us-west-1",
"us-west-2"
],
"aws-cn": [
@@ -3439,7 +3428,6 @@
"datazone": {
"regions": {
"aws": [
"af-south-1",
"ap-east-1",
"ap-northeast-1",
"ap-northeast-2",
@@ -3452,7 +3440,6 @@
"eu-central-1",
"eu-central-2",
"eu-north-1",
"eu-south-2",
"eu-west-1",
"eu-west-2",
"eu-west-3",
@@ -6998,6 +6985,7 @@
"aws": [
"af-south-1",
"ap-east-1",
"ap-east-2",
"ap-northeast-1",
"ap-northeast-2",
"ap-northeast-3",
@@ -7022,6 +7010,7 @@
"il-central-1",
"me-central-1",
"me-south-1",
"mx-central-1",
"sa-east-1",
"us-east-1",
"us-east-2",
@@ -7695,6 +7684,7 @@
"ap-southeast-1",
"ap-southeast-2",
"ap-southeast-4",
"ap-southeast-5",
"ca-central-1",
"eu-central-1",
"eu-north-1",
@@ -7932,6 +7922,7 @@
"aws": [
"ap-southeast-2",
"eu-west-1",
"eu-west-2",
"us-east-1",
"us-west-2"
],
@@ -8255,6 +8246,7 @@
"ap-east-1",
"ap-northeast-1",
"ap-northeast-2",
"ap-northeast-3",
"ap-south-1",
"ap-southeast-1",
"ap-southeast-2",
@@ -8270,6 +8262,7 @@
"sa-east-1",
"us-east-1",
"us-east-2",
"us-west-1",
"us-west-2"
],
"aws-cn": [],
@@ -9877,6 +9870,7 @@
"eu-west-1",
"eu-west-2",
"il-central-1",
"sa-east-1",
"us-east-1",
"us-east-2",
"us-west-1",
@@ -11,7 +11,7 @@ class entra_non_privileged_user_has_mfa(Check):
for tenant_domain, users in entra_client.users.items():
for user in users.values():
if not is_privileged_user(
if user.account_enabled and not is_privileged_user(
user, entra_client.directory_roles[tenant_domain]
):
report = Check_Report_Azure(metadata=self.metadata(), resource=user)
@@ -3,7 +3,9 @@ from asyncio import gather
from typing import List, Optional
from uuid import UUID
from kiota_abstractions.base_request_configuration import RequestConfiguration
from msgraph import GraphServiceClient
from msgraph.generated.users.users_request_builder import UsersRequestBuilder
from pydantic.v1 import BaseModel
from prowler.lib.logger import logger
@@ -65,9 +67,16 @@ class Entra(AzureService):
logger.info("Entra - Getting users...")
users = {}
try:
request_configuration = RequestConfiguration(
query_parameters=UsersRequestBuilder.UsersRequestBuilderGetQueryParameters(
select=["id", "displayName", "accountEnabled"]
)
)
for tenant, client in self.clients.items():
users.update({tenant: {}})
users_response = await client.users.get()
users_response = await client.users.get(
request_configuration=request_configuration
)
registration_details = await self._get_user_registration_details(client)
try:
@@ -81,6 +90,9 @@ class Entra(AzureService):
is_mfa_capable=registration_details.get(
user.id, False
),
account_enabled=getattr(
user, "account_enabled", True
),
)
}
)
@@ -409,6 +421,7 @@ class User(BaseModel):
id: str
name: str
is_mfa_capable: bool = False
account_enabled: bool = True
class DefaultUserRolePermissions(BaseModel):
@@ -142,6 +142,86 @@ class Test_entra_non_privileged_user_has_mfa:
assert result[0].resource_id == user_id
assert result[0].subscription == f"Tenant: {DOMAIN}"
def test_entra_disabled_user_no_privileged_no_mfa(self):
entra_client = mock.MagicMock
user_id = str(uuid4())
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_azure_provider(),
),
mock.patch(
"prowler.providers.azure.services.entra.entra_non_privileged_user_has_mfa.entra_non_privileged_user_has_mfa.entra_client",
new=entra_client,
),
):
from prowler.providers.azure.services.entra.entra_non_privileged_user_has_mfa.entra_non_privileged_user_has_mfa import (
entra_non_privileged_user_has_mfa,
)
from prowler.providers.azure.services.entra.entra_service import (
DirectoryRole,
User,
)
user = User(
id=user_id,
name="foo",
is_mfa_capable=False,
account_enabled=False,
)
entra_client.users = {DOMAIN: {f"foo@{DOMAIN}": user}}
entra_client.directory_roles = {
DOMAIN: {
"Global Administrator": DirectoryRole(id=str(uuid4()), members=[])
}
}
check = entra_non_privileged_user_has_mfa()
result = check.execute()
assert len(result) == 0
def test_entra_disabled_user_no_privileged_mfa(self):
entra_client = mock.MagicMock
user_id = str(uuid4())
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_azure_provider(),
),
mock.patch(
"prowler.providers.azure.services.entra.entra_non_privileged_user_has_mfa.entra_non_privileged_user_has_mfa.entra_client",
new=entra_client,
),
):
from prowler.providers.azure.services.entra.entra_non_privileged_user_has_mfa.entra_non_privileged_user_has_mfa import (
entra_non_privileged_user_has_mfa,
)
from prowler.providers.azure.services.entra.entra_service import (
DirectoryRole,
User,
)
user = User(
id=user_id,
name="foo",
is_mfa_capable=True,
account_enabled=False,
)
entra_client.users = {DOMAIN: {f"foo@{DOMAIN}": user}}
entra_client.directory_roles = {
DOMAIN: {
"Global Administrator": DirectoryRole(id=str(uuid4()), members=[])
}
}
check = entra_non_privileged_user_has_mfa()
result = check.execute()
assert len(result) == 0
def test_entra_user_privileged_no_mfa(self):
entra_client = mock.MagicMock
user_id = str(uuid4())
@@ -147,6 +147,7 @@ class Test_Entra_Service:
assert entra_client.users[DOMAIN]["user-1@tenant1.es"].id == "id-1"
assert entra_client.users[DOMAIN]["user-1@tenant1.es"].name == "User 1"
assert entra_client.users[DOMAIN]["user-1@tenant1.es"].is_mfa_capable is False
assert entra_client.users[DOMAIN]["user-1@tenant1.es"].account_enabled is True
def test_get_authorization_policy(self):
entra_client = Entra(set_mocked_azure_provider())
@@ -229,8 +230,8 @@ def test_azure_entra__get_users_handles_pagination():
entra_service = Entra.__new__(Entra)
users_page_one = [
SimpleNamespace(id="user-1", display_name="User 1"),
SimpleNamespace(id="user-2", display_name="User 2"),
SimpleNamespace(id="user-1", display_name="User 1", account_enabled=False),
SimpleNamespace(id="user-2", display_name="User 2", account_enabled=True),
]
users_page_two = [
SimpleNamespace(id="user-3", display_name="User 3"),
@@ -288,9 +289,18 @@ def test_azure_entra__get_users_handles_pagination():
assert len(users["tenant-1"]) == 3
assert users_builder.get.await_count == 1
request_configuration = users_builder.get.await_args.kwargs["request_configuration"]
assert request_configuration.query_parameters.select == [
"id",
"displayName",
"accountEnabled",
]
with_url_mock.assert_called_once_with("next-link")
registration_details_builder.get.assert_awaited()
registration_details_builder.with_url.assert_not_called()
assert users["tenant-1"]["user-1"].is_mfa_capable is True
assert users["tenant-1"]["user-1"].account_enabled is False
assert users["tenant-1"]["user-2"].is_mfa_capable is True
assert users["tenant-1"]["user-2"].account_enabled is True
assert users["tenant-1"]["user-3"].is_mfa_capable is False
assert users["tenant-1"]["user-3"].account_enabled is True
+8
View File
@@ -2,6 +2,14 @@
All notable changes to the **Prowler UI** are documented in this file.
## [1.22.0] (Prowler UNRELEASED)
### 🚀 Added
- Attack Paths custom openCypher queries with Cartography schema guidance and clearer execution errors [(#10397)](https://github.com/prowler-cloud/prowler/pull/10397)
---
## [1.21.0] (Prowler v5.21.0)
### 🚀 Added
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import {
ATTACK_PATH_QUERY_IDS,
type AttackPathCartographySchemaAttributes,
type AttackPathQuery,
} from "@/types/attack-paths";
import { buildAttackPathQueries } from "./queries.adapter";
const presetQuery: AttackPathQuery = {
type: "attack-paths-scans",
id: "preset-query",
attributes: {
name: "Preset Query",
short_description: "Returns privileged attack paths",
description: "Returns privileged attack paths.",
provider: "aws",
attribution: null,
parameters: [],
},
};
describe("buildAttackPathQueries", () => {
it("prepends a custom query with a schema documentation link", () => {
// Given
const schema: AttackPathCartographySchemaAttributes = {
id: "aws-0.129.0",
provider: "aws",
cartography_version: "0.129.0",
schema_url:
"https://github.com/cartography-cncf/cartography/blob/0.129.0/docs/root/modules/aws/schema.md",
raw_schema_url:
"https://raw.githubusercontent.com/cartography-cncf/cartography/refs/tags/0.129.0/docs/root/modules/aws/schema.md",
};
// When
const result = buildAttackPathQueries([presetQuery], schema);
// Then
expect(result[0]).toMatchObject({
id: ATTACK_PATH_QUERY_IDS.CUSTOM,
attributes: {
name: "Custom openCypher query",
short_description: "Write and run your own read-only query",
documentation_link: {
text: "Cartography schema used by Prowler for AWS graphs",
link: schema.schema_url,
},
},
});
expect(result[1]).toEqual(presetQuery);
});
});
@@ -1,7 +1,10 @@
import { MetaDataProps } from "@/types";
import {
ATTACK_PATH_QUERY_IDS,
type AttackPathCartographySchemaAttributes,
AttackPathQueriesResponse,
AttackPathQuery,
QUERY_PARAMETER_INPUT_TYPES,
} from "@/types/attack-paths";
/**
@@ -53,3 +56,52 @@ export function adaptAttackPathQueriesResponse(
return { data: enrichedData, metadata };
}
const CUSTOM_QUERY_PLACEHOLDER = `MATCH (n)
RETURN n
LIMIT 25`;
const formatSchemaDocumentationLinkText = (
schema: AttackPathCartographySchemaAttributes,
): string => {
return `Cartography schema used by Prowler for ${schema.provider.toUpperCase()} graphs`;
};
const createCustomQuery = (
schema?: AttackPathCartographySchemaAttributes,
): AttackPathQuery => ({
type: "attack-paths-scans",
id: ATTACK_PATH_QUERY_IDS.CUSTOM,
attributes: {
name: "Custom openCypher query",
short_description: "Write and run your own read-only query",
description:
"Run a read-only openCypher query against the selected Attack Paths scan. Results are automatically scoped to the selected provider.",
provider: "custom",
attribution: null,
documentation_link: schema
? {
text: formatSchemaDocumentationLinkText(schema),
link: schema.schema_url,
}
: null,
parameters: [
{
name: "query",
label: "openCypher",
data_type: "string",
description: "",
placeholder: CUSTOM_QUERY_PLACEHOLDER,
required: true,
input_type: QUERY_PARAMETER_INPUT_TYPES.TEXTAREA,
},
],
},
});
export const buildAttackPathQueries = (
queries: AttackPathQuery[],
schema?: AttackPathCartographySchemaAttributes,
): AttackPathQuery[] => {
return [createCustomQuery(schema), ...queries];
};
+141 -1
View File
@@ -17,7 +17,11 @@ vi.mock("@/lib/server-actions-helper", () => ({
handleApiResponse: handleApiResponseMock,
}));
import { executeQuery } from "./queries";
import {
executeCustomQuery,
executeQuery,
getCartographySchema,
} from "./queries";
describe("executeQuery", () => {
beforeEach(() => {
@@ -65,3 +69,139 @@ describe("executeQuery", () => {
expect(handleApiResponseMock).not.toHaveBeenCalled();
});
});
describe("executeCustomQuery", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal("fetch", fetchMock);
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
handleApiResponseMock.mockResolvedValue({
data: {
type: "attack-paths-query-run-requests",
id: null,
attributes: {
nodes: [],
relationships: [],
},
},
});
});
it("posts the custom query to the dedicated endpoint", async () => {
// Given
fetchMock.mockResolvedValue(new Response(null, { status: 200 }));
// When
await executeCustomQuery(
"550e8400-e29b-41d4-a716-446655440000",
"MATCH (n) RETURN n LIMIT 10",
);
// Then
expect(fetchMock).toHaveBeenCalledWith(
"https://api.example.com/api/v1/attack-paths-scans/550e8400-e29b-41d4-a716-446655440000/queries/custom",
expect.objectContaining({
method: "POST",
body: JSON.stringify({
data: {
type: "attack-paths-custom-query-run-requests",
attributes: {
query: "MATCH (n) RETURN n LIMIT 10",
},
},
}),
}),
);
});
it("rejects empty custom queries before calling the API", async () => {
// When
const result = await executeCustomQuery(
"550e8400-e29b-41d4-a716-446655440000",
" ",
);
// Then
expect(result).toEqual({
error: "Custom query cannot be empty",
status: 400,
});
expect(fetchMock).not.toHaveBeenCalled();
expect(handleApiResponseMock).not.toHaveBeenCalled();
});
it("rejects custom queries longer than 10000 characters before calling the API", async () => {
// When
const result = await executeCustomQuery(
"550e8400-e29b-41d4-a716-446655440000",
"x".repeat(10001),
);
// Then
expect(result).toEqual({
error: "Custom query must be 10000 characters or fewer",
status: 400,
});
expect(fetchMock).not.toHaveBeenCalled();
expect(handleApiResponseMock).not.toHaveBeenCalled();
});
it("rejects custom queries with write operations before calling the API", async () => {
// When
const result = await executeCustomQuery(
"550e8400-e29b-41d4-a716-446655440000",
"MATCH (n) SET n.name = 'updated' RETURN n",
);
// Then
expect(result).toEqual({
error: "Only read-only queries are allowed",
status: 400,
});
expect(fetchMock).not.toHaveBeenCalled();
expect(handleApiResponseMock).not.toHaveBeenCalled();
});
});
describe("getCartographySchema", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal("fetch", fetchMock);
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
});
it("fetches the schema metadata for the selected scan", async () => {
// Given
const apiResponse = {
data: {
type: "attack-paths-cartography-schemas",
id: "aws-0.129.0",
attributes: {
id: "aws-0.129.0",
provider: "aws",
cartography_version: "0.129.0",
schema_url:
"https://github.com/cartography-cncf/cartography/blob/0.129.0/docs/root/modules/aws/schema.md",
raw_schema_url:
"https://raw.githubusercontent.com/cartography-cncf/cartography/refs/tags/0.129.0/docs/root/modules/aws/schema.md",
},
},
};
fetchMock.mockResolvedValue(new Response(null, { status: 200 }));
handleApiResponseMock.mockResolvedValue(apiResponse);
// When
const result = await getCartographySchema(
"550e8400-e29b-41d4-a716-446655440000",
);
// Then
expect(fetchMock).toHaveBeenCalledWith(
"https://api.example.com/api/v1/attack-paths-scans/550e8400-e29b-41d4-a716-446655440000/schema",
expect.objectContaining({
method: "GET",
}),
);
expect(result).toEqual(apiResponse);
});
});
+94
View File
@@ -3,12 +3,16 @@
import { z } from "zod";
import { apiBaseUrl, getAuthHeaders } from "@/lib";
import { customAttackPathQuerySchema } from "@/lib/attack-paths/custom-query";
import { handleApiResponse } from "@/lib/server-actions-helper";
import {
AttackPathCartographySchema,
AttackPathCartographySchemaResponse,
AttackPathQueriesResponse,
AttackPathQuery,
AttackPathQueryError,
AttackPathQueryResult,
ExecuteCustomQueryRequest,
ExecuteQueryRequest,
} from "@/types/attack-paths";
@@ -102,3 +106,93 @@ export const executeQuery = async (
};
}
};
/**
* Execute a custom openCypher query on an attack path scan
*/
export const executeCustomQuery = async (
scanId: string,
query: string,
): Promise<AttackPathQueryResult | AttackPathQueryError | undefined> => {
const validatedScanId = UUIDSchema.safeParse(scanId);
if (!validatedScanId.success) {
console.error("Invalid scan ID format");
return undefined;
}
const validatedQuery = customAttackPathQuerySchema.safeParse(query);
if (!validatedQuery.success) {
return {
error:
validatedQuery.error.issues[0]?.message ?? "Custom query is invalid.",
status: 400,
};
}
const headers = await getAuthHeaders({ contentType: true });
const requestBody: ExecuteCustomQueryRequest = {
data: {
type: "attack-paths-custom-query-run-requests",
attributes: {
query: validatedQuery.data,
},
},
};
try {
const response = await fetch(
`${apiBaseUrl}/attack-paths-scans/${validatedScanId.data}/queries/custom`,
{
headers,
method: "POST",
body: JSON.stringify(requestBody),
},
);
return (await handleApiResponse(response)) as
| AttackPathQueryResult
| AttackPathQueryError;
} catch (error) {
console.error("Error executing custom query on scan:", error);
return {
error:
"Server is temporarily unavailable. Please try again in a few minutes.",
status: 503,
};
}
};
/**
* Fetch cartography schema metadata for a specific attack path scan
*/
export const getCartographySchema = async (
scanId: string,
): Promise<{ data: AttackPathCartographySchema } | undefined> => {
const validatedScanId = UUIDSchema.safeParse(scanId);
if (!validatedScanId.success) {
console.error("Invalid scan ID format");
return undefined;
}
const headers = await getAuthHeaders({ contentType: false });
try {
const response = await fetch(
`${apiBaseUrl}/attack-paths-scans/${validatedScanId.data}/schema`,
{
headers,
method: "GET",
},
);
const apiResponse = (await handleApiResponse(
response,
)) as AttackPathCartographySchemaResponse;
return { data: apiResponse.data };
} catch (error) {
console.error("Error fetching cartography schema for scan:", error);
return undefined;
}
};
@@ -1,6 +1,8 @@
export { ExecuteButton } from "./execute-button";
export * from "./graph";
export * from "./node-detail";
export { QueryDescription } from "./query-description";
export { QueryExecutionError } from "./query-execution-error";
export { QueryParametersForm } from "./query-parameters-form";
export { QuerySelector } from "./query-selector";
export { ScanListTable } from "./scan-list-table";
@@ -0,0 +1,76 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import type { AttackPathQuery } from "@/types/attack-paths";
import { QueryDescription } from "./query-description";
const customQuery: AttackPathQuery = {
type: "attack-paths-scans",
id: "custom-query",
attributes: {
name: "Custom openCypher query",
short_description: "Write your own query",
description:
"Run a read-only openCypher query against the selected Attack Paths scan.",
provider: "aws",
attribution: null,
documentation_link: {
text: "Cartography schema used by Prowler for AWS graphs",
link: "https://example.com/schema",
},
parameters: [],
},
};
describe("QueryDescription", () => {
it("renders the schema documentation link inside an info alert", () => {
// Given
render(<QueryDescription query={customQuery} />);
// When
const alert = screen.getByRole("alert");
const link = screen.getByRole("link", {
name: /cartography schema used by prowler for aws graphs/i,
});
// Then
expect(alert).toBeInTheDocument();
expect(link).toHaveAttribute("href", "https://example.com/schema");
});
it("does not render unsafe documentation or attribution URLs as clickable links", () => {
// Given
const queryWithUnsafeLinks: AttackPathQuery = {
...customQuery,
attributes: {
...customQuery.attributes,
documentation_link: {
text: "Cartography schema used by Prowler for AWS graphs",
link: "javascript:alert('xss')",
},
attribution: {
text: "Unsafe source",
link: "javascript:alert('xss')",
},
},
};
// When
render(<QueryDescription query={queryWithUnsafeLinks} />);
// Then
expect(
screen.queryByRole("link", {
name: /cartography schema used by prowler for aws graphs/i,
}),
).not.toBeInTheDocument();
expect(
screen.queryByRole("link", { name: /unsafe source/i }),
).not.toBeInTheDocument();
expect(
screen.getByText(/cartography schema used by prowler for aws graphs/i),
).toBeInTheDocument();
expect(screen.getByText(/unsafe source/i)).toBeInTheDocument();
});
});
@@ -0,0 +1,70 @@
import { Info } from "lucide-react";
import { Alert, AlertDescription } from "@/components/shadcn";
import type { AttackPathQuery } from "@/types/attack-paths";
interface QueryDescriptionProps {
query: AttackPathQuery;
}
const isSafeUrl = (url: string): boolean => {
try {
const parsedUrl = new URL(url);
return parsedUrl.protocol === "https:" || parsedUrl.protocol === "http:";
} catch {
return false;
}
};
export const QueryDescription = ({ query }: QueryDescriptionProps) => {
const documentationLink = query.attributes.documentation_link;
const attribution = query.attributes.attribution;
return (
<Alert variant="info">
<Info className="text-bg-data-info mt-0.5 size-4 shrink-0" />
<AlertDescription className="w-full gap-2">
<p className="whitespace-pre-line">{query.attributes.description}</p>
{documentationLink && (
<p className="text-xs">
{isSafeUrl(documentationLink.link) ? (
<a
href={documentationLink.link}
target="_blank"
rel="noopener noreferrer"
className="font-medium underline"
>
{documentationLink.text}
</a>
) : (
<span className="font-medium">{documentationLink.text}</span>
)}
</p>
)}
{attribution && (
<p className="text-xs">
{isSafeUrl(attribution.link) ? (
<>
Source:{" "}
<a
href={attribution.link}
target="_blank"
rel="noopener noreferrer"
className="underline"
>
{attribution.text}
</a>
</>
) : (
<>
Source: <span>{attribution.text}</span>
</>
)}
</p>
)}
</AlertDescription>
</Alert>
);
};
@@ -0,0 +1,46 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { QueryExecutionError } from "./query-execution-error";
describe("QueryExecutionError", () => {
it("renders the default title and the raw query error details without extra copy", () => {
// Given
const error =
"Invalid input 'WHERE': expected 'MATCH' or 'WITH' (line 1, column 1)";
// When
render(<QueryExecutionError error={error} />);
// Then
expect(screen.getByRole("alert")).toBeInTheDocument();
expect(screen.getByText(/query execution failed/i)).toBeInTheDocument();
expect(
screen.queryByText(/the attack paths query could not be executed/i),
).not.toBeInTheDocument();
expect(screen.getByText(error)).toBeInTheDocument();
});
it("renders custom title and description when provided", () => {
// Given
const error = "Failed to load available queries";
// When
render(
<QueryExecutionError
title="Failed to load queries"
description="Available Attack Paths queries could not be loaded for this scan."
error={error}
/>,
);
// Then
expect(screen.getByText(/failed to load queries/i)).toBeInTheDocument();
expect(
screen.getByText(
/available attack paths queries could not be loaded for this scan/i,
),
).toBeInTheDocument();
expect(screen.getByText(error)).toBeInTheDocument();
});
});
@@ -0,0 +1,30 @@
import { CircleAlert } from "lucide-react";
import { Alert, AlertDescription, AlertTitle } from "@/components/shadcn";
interface QueryExecutionErrorProps {
error: string;
title?: string;
description?: string;
}
export const QueryExecutionError = ({
error,
title = "Query execution failed",
description,
}: QueryExecutionErrorProps) => {
return (
<Alert variant="error">
<CircleAlert className="size-4" />
<AlertTitle>{title}</AlertTitle>
<AlertDescription className="w-full gap-3">
{description ? <p>{description}</p> : null}
<div className="bg-bg-neutral-primary/70 border-border-neutral-secondary w-full rounded-md border px-3 py-2">
<pre className="text-text-error-primary font-mono text-xs break-words whitespace-pre-wrap">
{error}
</pre>
</div>
</AlertDescription>
</Alert>
);
};
@@ -1,8 +1,12 @@
import { render, screen } from "@testing-library/react";
import { useEffect } from "react";
import { FormProvider, useForm } from "react-hook-form";
import { describe, expect, it } from "vitest";
import type { AttackPathQuery } from "@/types/attack-paths";
import {
ATTACK_PATH_QUERY_IDS,
type AttackPathQuery,
} from "@/types/attack-paths";
import { QueryParametersForm } from "./query-parameters-form";
@@ -42,6 +46,64 @@ function TestForm() {
);
}
function TestCustomQueryForm() {
const customQuery: AttackPathQuery = {
type: "attack-paths-scans",
id: ATTACK_PATH_QUERY_IDS.CUSTOM,
attributes: {
name: "Custom openCypher query",
short_description: "Write your own query",
description: "Run a custom query against the graph.",
provider: "aws",
attribution: null,
parameters: [
{
name: "query",
label: "openCypher",
data_type: "string",
input_type: "textarea",
placeholder: "MATCH (n) RETURN n LIMIT 25",
description: "",
required: true,
},
],
},
};
const form = useForm({
defaultValues: {
query: "",
},
});
return (
<FormProvider {...form}>
<QueryParametersForm selectedQuery={customQuery} />
</FormProvider>
);
}
function TestFormWithError() {
const form = useForm({
defaultValues: {
tag_key: "",
},
});
useEffect(() => {
form.setError("tag_key", {
type: "manual",
message: "Tag key is required",
});
}, [form]);
return (
<FormProvider {...form}>
<QueryParametersForm selectedQuery={mockQuery} />
</FormProvider>
);
}
describe("QueryParametersForm", () => {
it("uses the field description as the placeholder instead of rendering helper text below", () => {
// Given
@@ -70,4 +132,62 @@ describe("QueryParametersForm", () => {
screen.queryByText("Tag key to filter the S3 bucket."),
).not.toBeInTheDocument();
});
it("renders a textarea when the parameter input type is textarea", () => {
// Given
render(<TestCustomQueryForm />);
// When
const input = screen.getByRole("textbox", { name: /opencypher/i });
const codeEditor = screen.getByTestId("query-code-editor");
// Then
expect(input.tagName).toBe("TEXTAREA");
expect(input).toHaveAttribute("data-slot", "textarea");
expect(input).toHaveAttribute("placeholder", "MATCH (n) RETURN n LIMIT 25");
expect(input).toHaveAttribute("spellcheck", "false");
expect(input).toHaveAttribute("autocomplete", "off");
expect(input).toHaveAttribute("autocorrect", "off");
expect(input).toHaveAttribute("autocapitalize", "none");
expect(input).toHaveClass(
"minimal-scrollbar",
"min-h-[320px]",
"font-mono",
"leading-6",
);
expect(codeEditor).toHaveClass(
"rounded-xl",
"border",
"bg-bg-neutral-primary",
);
expect(screen.getByText("Read-only")).toBeInTheDocument();
});
it("uses the design-system error token for field validation messages", async () => {
// Given
render(<TestFormWithError />);
// When
const errorMessage = await screen.findByText("Tag key is required");
// Then
expect(errorMessage).toHaveClass("text-text-error-primary", "text-xs");
});
it("connects field errors to the input for accessibility", async () => {
// Given
render(<TestFormWithError />);
// When
const input = screen.getByRole("textbox", { name: /tag key/i });
const errorMessage = await screen.findByText("Tag key is required");
// Then
expect(input).toHaveAttribute("aria-invalid", "true");
expect(errorMessage).toHaveAttribute("id");
expect(input).toHaveAttribute(
"aria-describedby",
expect.stringContaining(errorMessage.getAttribute("id") ?? ""),
);
});
});
@@ -1,9 +1,21 @@
"use client";
import { Controller, useFormContext } from "react-hook-form";
import { useFormContext } from "react-hook-form";
import { Input } from "@/components/shadcn";
import type { AttackPathQuery } from "@/types/attack-paths";
import { Input, Textarea } from "@/components/shadcn";
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { cn } from "@/lib/utils";
import {
ATTACK_PATH_QUERY_IDS,
type AttackPathQuery,
QUERY_PARAMETER_INPUT_TYPES,
} from "@/types/attack-paths";
interface QueryParametersFormProps {
selectedQuery: AttackPathQuery | null | undefined;
@@ -16,10 +28,7 @@ interface QueryParametersFormProps {
export const QueryParametersForm = ({
selectedQuery,
}: QueryParametersFormProps) => {
const {
control,
formState: { errors },
} = useFormContext();
const { control } = useFormContext();
if (!selectedQuery || !selectedQuery.attributes.parameters.length) {
return null;
@@ -36,23 +45,26 @@ export const QueryParametersForm = ({
className="grid grid-cols-1 gap-4 md:grid-cols-2"
>
{selectedQuery.attributes.parameters.map((param) => (
<Controller
<FormField
key={param.name}
name={param.name}
control={control}
render={({ field }) => {
render={({ field, fieldState }) => {
if (param.data_type === "boolean") {
return (
<div className="flex flex-col gap-2">
<FormItem className="flex flex-col gap-2">
<label className="flex cursor-pointer items-center gap-3">
<input
type="checkbox"
id={param.name}
checked={field.value === true || field.value === "true"}
onChange={(e) => field.onChange(e.target.checked)}
aria-label={param.label}
className="border-border-neutral-secondary bg-bg-neutral-primary text-text-primary focus:ring-primary dark:border-border-neutral-secondary dark:bg-bg-neutral-primary dark:text-text-primary h-4 w-4 rounded border focus:ring-2"
/>
<FormControl>
<input
type="checkbox"
checked={
field.value === true || field.value === "true"
}
onChange={(e) => field.onChange(e.target.checked)}
aria-label={param.label}
className="border-border-neutral-secondary bg-bg-neutral-primary text-text-primary focus:ring-primary dark:border-border-neutral-secondary dark:bg-bg-neutral-primary dark:text-text-primary h-4 w-4 rounded border focus:ring-2"
/>
</FormControl>
<div className="flex flex-col gap-1">
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
{param.label}
@@ -64,44 +76,98 @@ export const QueryParametersForm = ({
)}
</div>
</label>
</div>
<FormMessage className="text-xs" />
</FormItem>
);
}
const errorMessage = (() => {
const error = errors[param.name];
if (error && typeof error.message === "string") {
return error.message;
}
return undefined;
})();
const placeholder =
param.description ||
param.placeholder ||
`Enter ${param.label.toLowerCase()}`;
const isTextarea =
param.input_type === QUERY_PARAMETER_INPUT_TYPES.TEXTAREA;
const isCustomCodeEditor =
selectedQuery.id === ATTACK_PATH_QUERY_IDS.CUSTOM &&
param.name === "query" &&
isTextarea;
return (
<div className="flex flex-col gap-1.5">
<label
htmlFor={param.name}
className="text-text-neutral-tertiary text-xs font-medium"
>
{param.label}
{param.required && (
<span className="text-text-error-primary">*</span>
)}
</label>
<Input
{...field}
id={param.name}
type={param.data_type === "number" ? "number" : "text"}
placeholder={
param.description ||
param.placeholder ||
`Enter ${param.label.toLowerCase()}`
}
value={field.value ?? ""}
/>
{errorMessage && (
<span className="text-xs text-red-500">{errorMessage}</span>
<FormItem
className={cn(
"flex flex-col gap-1.5",
isTextarea && "md:col-span-2",
)}
</div>
>
{!isCustomCodeEditor && (
<FormLabel className="text-text-neutral-tertiary text-xs font-medium">
{param.label}
{param.required && (
<span className="text-text-error-primary">*</span>
)}
</FormLabel>
)}
{isCustomCodeEditor ? (
<div
data-testid="query-code-editor"
className={cn(
"border-border-neutral-secondary bg-bg-neutral-primary overflow-hidden rounded-xl border",
fieldState.invalid && "border-border-error-primary",
)}
>
<div className="border-border-neutral-secondary bg-bg-neutral-secondary flex items-center justify-between border-b px-4 py-2">
<span className="text-text-neutral-secondary text-xs font-medium">
{param.label}
{param.required && (
<span className="text-text-error-primary">*</span>
)}
</span>
<span className="text-text-neutral-tertiary text-[11px]">
Read-only
</span>
</div>
<FormControl>
<Textarea
{...field}
aria-label={param.label}
variant="ghost"
textareaSize="lg"
placeholder={placeholder}
value={field.value ?? ""}
rows={14}
spellCheck={false}
autoComplete="off"
autoCorrect="off"
autoCapitalize="none"
className="minimal-scrollbar min-h-[320px] rounded-none border-0 bg-transparent font-mono text-xs leading-6 hover:bg-transparent focus:bg-transparent focus:ring-0"
/>
</FormControl>
</div>
) : (
<FormControl>
{isTextarea ? (
<Textarea
{...field}
textareaSize="lg"
placeholder={placeholder}
value={field.value ?? ""}
className="min-h-40 font-mono text-xs"
/>
) : (
<Input
{...field}
type={
param.data_type === "number" ? "number" : "text"
}
placeholder={placeholder}
value={field.value ?? ""}
/>
)}
</FormControl>
)}
<FormMessage className="text-xs" />
</FormItem>
);
}}
/>
@@ -1,7 +1,12 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import {
CUSTOM_ATTACK_PATH_QUERY_MAX_LENGTH,
CUSTOM_ATTACK_PATH_QUERY_READ_ONLY_ERROR_MESSAGE,
} from "@/lib/attack-paths/custom-query";
import type { AttackPathQuery } from "@/types/attack-paths";
import { ATTACK_PATH_QUERY_IDS } from "@/types/attack-paths";
import { useQueryBuilder } from "./use-query-builder";
@@ -38,6 +43,27 @@ const mockQueries: AttackPathQuery[] = [
parameters: [],
},
},
{
type: "attack-paths-scans",
id: ATTACK_PATH_QUERY_IDS.CUSTOM,
attributes: {
name: "Custom openCypher query",
short_description: "Write your own query",
description: "Run a custom query against the graph.",
provider: "aws",
attribution: null,
parameters: [
{
name: "query",
label: "openCypher",
data_type: "string",
description: "",
required: true,
input_type: "textarea",
},
],
},
},
];
describe("useQueryBuilder", () => {
@@ -77,4 +103,101 @@ describe("useQueryBuilder", () => {
);
expect(result.current.getQueryParameters()).toBeUndefined();
});
it("rejects whitespace-only custom queries before execution", async () => {
// Given
const { result } = renderHook(() => useQueryBuilder(mockQueries));
act(() => {
result.current.handleQueryChange(ATTACK_PATH_QUERY_IDS.CUSTOM);
});
await waitFor(() => {
expect(result.current.selectedQueryData?.id).toBe(
ATTACK_PATH_QUERY_IDS.CUSTOM,
);
});
act(() => {
result.current.form.setValue("query", " ");
});
// When
let isValid = true;
await act(async () => {
isValid = await result.current.form.trigger("query");
});
// Then
expect(isValid).toBe(false);
expect(result.current.form.getFieldState("query").error?.message).toBe(
"Custom query cannot be empty",
);
});
it("rejects custom queries longer than the supported limit", async () => {
// Given
const { result } = renderHook(() => useQueryBuilder(mockQueries));
act(() => {
result.current.handleQueryChange(ATTACK_PATH_QUERY_IDS.CUSTOM);
});
await waitFor(() => {
expect(result.current.selectedQueryData?.id).toBe(
ATTACK_PATH_QUERY_IDS.CUSTOM,
);
});
act(() => {
result.current.form.setValue(
"query",
"x".repeat(CUSTOM_ATTACK_PATH_QUERY_MAX_LENGTH + 1),
);
});
// When
let isValid = true;
await act(async () => {
isValid = await result.current.form.trigger("query");
});
// Then
expect(isValid).toBe(false);
expect(result.current.form.getFieldState("query").error?.message).toBe(
`Custom query must be ${CUSTOM_ATTACK_PATH_QUERY_MAX_LENGTH} characters or fewer`,
);
});
it("rejects custom queries containing write operations", async () => {
// Given
const { result } = renderHook(() => useQueryBuilder(mockQueries));
act(() => {
result.current.handleQueryChange(ATTACK_PATH_QUERY_IDS.CUSTOM);
});
await waitFor(() => {
expect(result.current.selectedQueryData?.id).toBe(
ATTACK_PATH_QUERY_IDS.CUSTOM,
);
});
act(() => {
result.current.form.setValue("query", "CREATE (n:Test) RETURN n");
});
// When
let isValid = true;
await act(async () => {
isValid = await result.current.form.trigger("query");
});
// Then
expect(isValid).toBe(false);
expect(result.current.form.getFieldState("query").error?.message).toBe(
CUSTOM_ATTACK_PATH_QUERY_READ_ONLY_ERROR_MESSAGE,
);
expect(result.current.isExecutionBlocked).toBe(true);
});
});
@@ -5,15 +5,28 @@ import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import type { AttackPathQuery } from "@/types/attack-paths";
import {
CUSTOM_ATTACK_PATH_QUERY_READ_ONLY_ERROR_MESSAGE,
customAttackPathQuerySchema,
} from "@/lib/attack-paths/custom-query";
import {
ATTACK_PATH_QUERY_IDS,
type AttackPathQuery,
QUERY_PARAMETER_INPUT_TYPES,
} from "@/types/attack-paths";
const getValidationSchema = (query?: AttackPathQuery) => {
const schemaObject: Record<string, z.ZodTypeAny> = {};
query?.attributes.parameters.forEach((param) => {
let fieldSchema: z.ZodTypeAny = z
.string()
.min(1, `${param.label} is required`);
const isCustomQueryParameter =
query.id === ATTACK_PATH_QUERY_IDS.CUSTOM &&
param.name === "query" &&
param.input_type === QUERY_PARAMETER_INPUT_TYPES.TEXTAREA;
let fieldSchema: z.ZodTypeAny = isCustomQueryParameter
? customAttackPathQuerySchema
: z.string().min(1, `${param.label} is required`);
if (param.data_type === "number") {
fieldSchema = z.coerce.number().refine((val) => val >= 0, {
@@ -93,6 +106,11 @@ export const useQueryBuilder = (availableQueries: AttackPathQuery[]) => {
return form.formState.isValid;
};
const isExecutionBlocked =
selectedQueryData?.id === ATTACK_PATH_QUERY_IDS.CUSTOM &&
form.formState.errors.query?.message ===
CUSTOM_ATTACK_PATH_QUERY_READ_ONLY_ERROR_MESSAGE;
return {
selectedQuery,
selectedQueryData,
@@ -101,5 +119,6 @@ export const useQueryBuilder = (availableQueries: AttackPathQuery[]) => {
handleQueryChange,
getQueryParameters,
isFormValid,
isExecutionBlocked,
};
};
@@ -7,9 +7,12 @@ import { Suspense, useEffect, useRef, useState } from "react";
import { FormProvider } from "react-hook-form";
import {
buildAttackPathQueries,
executeCustomQuery,
executeQuery,
getAttackPathScans,
getAvailableQueries,
getCartographySchema,
} from "@/actions/attack-paths";
import { adaptQueryResultToGraphData } from "@/actions/attack-paths/query-result.adapter";
import { AutoRefresh } from "@/components/scans";
@@ -35,6 +38,7 @@ import type {
AttackPathScan,
GraphNode,
} from "@/types/attack-paths";
import { ATTACK_PATH_QUERY_IDS } from "@/types/attack-paths";
import {
AttackPathGraph,
@@ -43,6 +47,8 @@ import {
GraphLegend,
GraphLoading,
NodeDetailContent,
QueryDescription,
QueryExecutionError,
QueryParametersForm,
QuerySelector,
ScanListTable,
@@ -138,11 +144,21 @@ export default function AttackPathsPage() {
setQueriesLoading(true);
try {
const queriesData = await getAvailableQueries(scanId);
if (queriesData?.data) {
setQueries(queriesData.data);
const [queriesData, schemaData] = await Promise.all([
getAvailableQueries(scanId),
getCartographySchema(scanId),
]);
const availableQueries = buildAttackPathQueries(
queriesData?.data ?? [],
schemaData?.data.attributes,
);
if (availableQueries.length > 0) {
setQueries(availableQueries);
setQueriesError(null);
} else {
setQueries([]);
setQueriesError("Failed to load available queries");
toast({
title: "Error",
@@ -199,15 +215,12 @@ export default function AttackPathsPage() {
graphState.setError(null);
try {
const parameters = queryBuilder.getQueryParameters() as Record<
string,
string | number | boolean
>;
const result = await executeQuery(
scanId,
queryBuilder.selectedQuery,
parameters,
);
const parameters = queryBuilder.getQueryParameters();
const isCustomQuery =
queryBuilder.selectedQuery === ATTACK_PATH_QUERY_IDS.CUSTOM;
const result = isCustomQuery
? await executeCustomQuery(scanId, String(parameters?.query ?? ""))
: await executeQuery(scanId, queryBuilder.selectedQuery, parameters);
if (result && "error" in result) {
const apiError = result as AttackPathQueryError;
@@ -371,9 +384,10 @@ export default function AttackPathsPage() {
{queriesLoading ? (
<p className="text-sm">Loading queries...</p>
) : queriesError ? (
<p className="text-text-danger dark:text-text-danger text-sm">
{queriesError}
</p>
<QueryExecutionError
title="Failed to load queries"
error={queriesError}
/>
) : (
<>
<FormProvider {...queryBuilder.form}>
@@ -384,40 +398,9 @@ export default function AttackPathsPage() {
/>
{queryBuilder.selectedQueryData && (
<div className="bg-bg-neutral-tertiary text-text-neutral-secondary dark:text-text-neutral-secondary rounded-md px-3 py-2 text-sm">
<div className="flex items-start gap-2">
<Info
className="mt-0.5 size-4 shrink-0"
style={{ color: "var(--bg-data-info)" }}
/>
<p className="whitespace-pre-line">
{
queryBuilder.selectedQueryData.attributes
.description
}
</p>
</div>
{queryBuilder.selectedQueryData.attributes
.attribution && (
<p className="mt-2 text-xs">
Source:{" "}
<a
href={
queryBuilder.selectedQueryData.attributes
.attribution.link
}
target="_blank"
rel="noopener noreferrer"
className="underline"
>
{
queryBuilder.selectedQueryData.attributes
.attribution.text
}
</a>
</p>
)}
</div>
<QueryDescription
query={queryBuilder.selectedQueryData}
/>
)}
{queryBuilder.selectedQuery && (
@@ -430,15 +413,16 @@ export default function AttackPathsPage() {
<div className="flex justify-end gap-3">
<ExecuteButton
isLoading={graphState.loading}
isDisabled={!queryBuilder.selectedQuery}
isDisabled={
!queryBuilder.selectedQuery ||
queryBuilder.isExecutionBlocked
}
onExecute={handleExecuteQuery}
/>
</div>
{graphState.error && (
<div className="bg-bg-danger-secondary text-text-danger dark:bg-bg-danger-secondary dark:text-text-danger rounded p-3 text-sm">
{graphState.error}
</div>
<QueryExecutionError error={graphState.error} />
)}
</>
)}
+1
View File
@@ -18,4 +18,5 @@ export * from "./separator/separator";
export * from "./skeleton/skeleton";
export * from "./tabs/generic-tabs";
export * from "./tabs/tabs";
export * from "./textarea/textarea";
export * from "./tooltip";
+42
View File
@@ -0,0 +1,42 @@
import { z } from "zod";
export const CUSTOM_ATTACK_PATH_QUERY_MAX_LENGTH = 10000;
export const CUSTOM_ATTACK_PATH_QUERY_READ_ONLY_ERROR_MESSAGE =
"Only read-only queries are allowed";
const CUSTOM_ATTACK_PATH_QUERY_STRING_LITERALS =
/'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"/g;
const CUSTOM_ATTACK_PATH_BLOCKED_PATTERNS = [
/\bCREATE\b/i,
/\bMERGE\b/i,
/\bSET\b/i,
/\bREMOVE\b/i,
/\bDELETE\b/i,
/\bDETACH\s+DELETE\b/i,
/\bDROP\b/i,
/\bLOAD\s+CSV\b/i,
/\bapoc\.(?:load|import|export|cypher|systemdb|config|periodic|do|trigger|custom)\b/i,
] as const;
const containsBlockedOperation = (query: string): boolean => {
const normalizedQuery = query.replace(
CUSTOM_ATTACK_PATH_QUERY_STRING_LITERALS,
"",
);
return CUSTOM_ATTACK_PATH_BLOCKED_PATTERNS.some((pattern) =>
pattern.test(normalizedQuery),
);
};
export const customAttackPathQuerySchema = z
.string()
.max(
CUSTOM_ATTACK_PATH_QUERY_MAX_LENGTH,
`Custom query must be ${CUSTOM_ATTACK_PATH_QUERY_MAX_LENGTH} characters or fewer`,
)
.refine((value) => value.trim().length > 0, {
message: "Custom query cannot be empty",
})
.refine((value) => !containsBlockedOperation(value), {
message: CUSTOM_ATTACK_PATH_QUERY_READ_ONLY_ERROR_MESSAGE,
});
+50
View File
@@ -81,6 +81,18 @@ export const DATA_TYPES = {
type DataType = (typeof DATA_TYPES)[keyof typeof DATA_TYPES];
export const QUERY_PARAMETER_INPUT_TYPES = {
TEXT: "text",
TEXTAREA: "textarea",
} as const;
export type QueryParameterInputType =
(typeof QUERY_PARAMETER_INPUT_TYPES)[keyof typeof QUERY_PARAMETER_INPUT_TYPES];
export const ATTACK_PATH_QUERY_IDS = {
CUSTOM: "__custom-open-cypher__",
} as const;
// Query Types
export interface AttackPathQueryParameter {
name: string;
@@ -89,6 +101,7 @@ export interface AttackPathQueryParameter {
description: string;
placeholder?: string;
required?: boolean;
input_type?: QueryParameterInputType;
}
export interface AttackPathQueryAttribution {
@@ -96,6 +109,11 @@ export interface AttackPathQueryAttribution {
link: string;
}
export interface AttackPathQueryDocumentationLink {
text: string;
link: string;
}
export interface AttackPathQueryAttributes {
name: string;
short_description: string;
@@ -103,6 +121,7 @@ export interface AttackPathQueryAttributes {
provider: string;
parameters: AttackPathQueryParameter[];
attribution: AttackPathQueryAttribution | null;
documentation_link?: AttackPathQueryDocumentationLink | null;
}
export interface AttackPathQuery {
@@ -115,6 +134,24 @@ export interface AttackPathQueriesResponse {
data: AttackPathQuery[];
}
export interface AttackPathCartographySchemaAttributes {
id: string;
provider: string;
cartography_version: string;
schema_url: string;
raw_schema_url: string;
}
export interface AttackPathCartographySchema {
type: "attack-paths-cartography-schemas";
id: string;
attributes: AttackPathCartographySchemaAttributes;
}
export interface AttackPathCartographySchemaResponse {
data: AttackPathCartographySchema;
}
// Graph Data Types
// Property values from graph nodes can be any primitive type or arrays
export type GraphNodePropertyValue =
@@ -256,3 +293,16 @@ export interface ExecuteQueryRequestData {
export interface ExecuteQueryRequest {
data: ExecuteQueryRequestData;
}
export interface ExecuteCustomQueryRequestAttributes {
query: string;
}
export interface ExecuteCustomQueryRequestData {
type: "attack-paths-custom-query-run-requests";
attributes: ExecuteCustomQueryRequestAttributes;
}
export interface ExecuteCustomQueryRequest {
data: ExecuteCustomQueryRequestData;
}