mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-22 03:14:21 +00:00
Merge branch 'master' into fix/finding-groups-aggregated-filters
This commit is contained in:
27 files changed
+1232
-169
No files matched your search
@@ -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)
|
||||
|
||||
Generated
+23
-40
@@ -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
@@ -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"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
|
||||
+1
-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):
|
||||
|
||||
+80
@@ -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
|
||||
@@ -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];
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
+76
@@ -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();
|
||||
});
|
||||
});
|
||||
+70
@@ -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>
|
||||
);
|
||||
};
|
||||
+46
@@ -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();
|
||||
});
|
||||
});
|
||||
+30
@@ -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>
|
||||
);
|
||||
};
|
||||
+121
-1
@@ -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") ?? ""),
|
||||
);
|
||||
});
|
||||
});
|
||||
+116
-50
@@ -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>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
+123
@@ -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} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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";
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user