Merge branch 'attack-paths-demo' into attack-paths-demo-extras

This commit is contained in:
Andoni A.
2026-01-14 17:06:33 +01:00
18 changed files with 1315 additions and 309 deletions
+1
View File
@@ -47,6 +47,7 @@ NEO4J_PORT=7687
NEO4J_USER=neo4j
NEO4J_PASSWORD=neo4j_password
# Neo4j settings
NEO4J_DBMS_MAX__DATABASES=1000000
NEO4J_SERVER_MEMORY_PAGECACHE_SIZE=1G
NEO4J_SERVER_MEMORY_HEAP_INITIAL__SIZE=1G
NEO4J_SERVER_MEMORY_HEAP_MAX__SIZE=1G
-5
View File
@@ -2,9 +2,6 @@ FROM python:3.12.10-slim-bookworm AS build
LABEL maintainer="https://github.com/prowler-cloud/api"
ARG CARTOGRAPHY_VERSION=0.117.0
ENV CARTOGRAPHY_VERSION=${CARTOGRAPHY_VERSION}
ARG POWERSHELL_VERSION=7.5.0
ENV POWERSHELL_VERSION=${POWERSHELL_VERSION}
@@ -82,8 +79,6 @@ ENV PATH="/home/prowler/.local/bin:$PATH"
RUN poetry install --no-root && \
rm -rf ~/.cache/pip
RUN poetry run python -m pip install cartography==${CARTOGRAPHY_VERSION}
RUN poetry run python "$(poetry env info --path)/src/prowler/prowler/providers/m365/lib/powershell/m365_powershell.py"
COPY src/backend/ ./backend/
+970 -44
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -24,7 +24,7 @@ dependencies = [
"drf-spectacular-jsonapi==0.5.1",
"gunicorn==23.0.0",
"lxml==5.3.2",
"prowler @ git+https://github.com/prowler-cloud/prowler.git@master",
"prowler @ git+https://github.com/prowler-cloud/prowler.git@attack-paths-demo",
"psycopg2-binary==2.9.9",
"pytest-celery[redis] (>=1.0.1,<2.0.0)",
"sentry-sdk[django] (>=2.20.0,<3.0.0)",
@@ -37,6 +37,7 @@ dependencies = [
"matplotlib (>=3.10.6,<4.0.0)",
"reportlab (>=4.4.4,<5.0.0)",
"neo4j (<6.0.0)",
"cartography @ git+https://github.com/prowler-cloud/cartography@master",
]
description = "Prowler's API (Django/DRF)"
license = "Apache-2.0"
+17 -4
View File
@@ -6,15 +6,18 @@ from typing import Iterator
from uuid import UUID
import neo4j
import neo4j.exceptions
from django.conf import settings
import neo4j.exceptions
from api.attack_paths.retryable_session import RetryableSession
# Without this Celery goes crazy with Neo4j logging
logging.getLogger("neo4j").setLevel(logging.ERROR)
logging.getLogger("neo4j").propagate = False
SERVICE_UNAVAILABLE_MAX_RETRIES = 3
# Module-level process-wide driver singleton
_driver: neo4j.Driver | None = None
_lock = threading.Lock()
@@ -62,14 +65,24 @@ def close_driver() -> None: # TODO: Use it
@contextmanager
def get_session(database: str | None = None) -> Iterator[neo4j.Session]:
def get_session(database: str | None = None) -> Iterator[RetryableSession]:
session_wrapper: RetryableSession | None = None
try:
with get_driver().session(database=database) as session:
yield session
session_wrapper = RetryableSession(
session_factory=lambda: get_driver().session(database=database),
close_driver=close_driver, # Just to avoid circular imports
max_retries=SERVICE_UNAVAILABLE_MAX_RETRIES,
)
yield session_wrapper
except neo4j.exceptions.Neo4jError as exc:
raise GraphDatabaseQueryException(message=exc.message, code=exc.code)
finally:
if session_wrapper is not None:
session_wrapper.close()
def create_database(database: str) -> None:
query = "CREATE DATABASE $database IF NOT EXISTS"
@@ -0,0 +1,87 @@
import logging
from collections.abc import Callable
from typing import Any
import neo4j
import neo4j.exceptions
logger = logging.getLogger(__name__)
class RetryableSession:
"""
Wrapper around `neo4j.Session` that retries `neo4j.exceptions.ServiceUnavailable` errors.
"""
def __init__(
self,
session_factory: Callable[[], neo4j.Session],
close_driver: Callable[[], None], # Just to avoid circular imports
max_retries: int,
) -> None:
self._session_factory = session_factory
self._close_driver = close_driver
self._max_retries = max(0, max_retries)
self._session = self._session_factory()
def close(self) -> None:
if self._session is not None:
self._session.close()
self._session = None
def __enter__(self) -> "RetryableSession":
return self
def __exit__(self, exc_type: Any, exc: Any, exc_tb: Any) -> None:
self.close()
def run(self, *args: Any, **kwargs: Any) -> Any:
return self._call_with_retry("run", *args, **kwargs)
def write_transaction(self, *args: Any, **kwargs: Any) -> Any:
return self._call_with_retry("write_transaction", *args, **kwargs)
def read_transaction(self, *args: Any, **kwargs: Any) -> Any:
return self._call_with_retry("read_transaction", *args, **kwargs)
def execute_write(self, *args: Any, **kwargs: Any) -> Any:
return self._call_with_retry("execute_write", *args, **kwargs)
def execute_read(self, *args: Any, **kwargs: Any) -> Any:
return self._call_with_retry("execute_read", *args, **kwargs)
def __getattr__(self, item: str) -> Any:
return getattr(self._session, item)
def _call_with_retry(self, method_name: str, *args: Any, **kwargs: Any) -> Any:
attempt = 0
last_exc: neo4j.exceptions.ServiceUnavailable | None = None
while attempt <= self._max_retries:
try:
method = getattr(self._session, method_name)
return method(*args, **kwargs)
except (
neo4j.exceptions.ServiceUnavailable
) as exc: # pragma: no cover - depends on infra
last_exc = exc
attempt += 1
if attempt > self._max_retries:
raise
logger.warning(
f"Neo4j session {method_name} failed with ServiceUnavailable ({attempt}/{self._max_retries} attempts). Retrying..."
)
self._refresh_session()
raise last_exc if last_exc else RuntimeError("Unexpected retry loop exit")
def _refresh_session(self) -> None:
if self._session is not None:
self._session.close()
self._close_driver()
self._session = self._session_factory()
+26 -5
View File
@@ -3,6 +3,7 @@
from typing import Any
import aioboto3
import boto3
import neo4j
@@ -28,7 +29,7 @@ def start_aws_ingestion(
attack_paths_scan: ProwlerAPIAttackPathsScan,
) -> dict[str, dict[str, str]]:
"""
Code based on Cartography version 0.117.0, specifically on `cartography.intel.aws.__init__.py`.
Code based on Cartography version 0.122.0, specifically on `cartography.intel.aws.__init__.py`.
For the scan progress updates:
- The caller of this function (`tasks.jobs.attack_paths.scan.run`) has set it to 2.
@@ -41,6 +42,7 @@ def start_aws_ingestion(
"permission_relationships_file": cartography_config.permission_relationships_file,
"aws_guardduty_severity_threshold": cartography_config.aws_guardduty_severity_threshold,
"aws_cloudtrail_management_events_lookback_hours": cartography_config.aws_cloudtrail_management_events_lookback_hours,
"experimental_aws_inspector_batch": cartography_config.experimental_aws_inspector_batch,
}
boto3_session = get_boto3_session(prowler_api_provider, prowler_sdk_provider)
@@ -157,6 +159,10 @@ def get_boto3_session(
return boto3_session
def get_aioboto3_session(boto3_session: boto3.Session) -> aioboto3.Session:
return aioboto3.Session(botocore_session=boto3_session._session)
def sync_aws_account(
prowler_api_provider: ProwlerAPIProvider,
requested_syncs: list[str],
@@ -167,7 +173,9 @@ def sync_aws_account(
max_progress = (
87 # `cartography_aws.RESOURCE_FUNCTIONS["permission_relationships"]` - 1
)
n_steps = len(requested_syncs)
n_steps = (
len(requested_syncs) - 2
) # Excluding `permission_relationships` and `resourcegroupstaggingapi`
progress_step = (max_progress - current_progress) / n_steps
failed_syncs = {}
@@ -185,15 +193,28 @@ def sync_aws_account(
)
try:
# `ecr:image_layers` uses `aioboto3_session` instead of `boto3_session`
if func_name == "ecr:image_layers":
cartography_aws.RESOURCE_FUNCTIONS[func_name](
neo4j_session=sync_args.get("neo4j_session"),
aioboto3_session=get_aioboto3_session(
sync_args.get("boto3_session")
),
regions=sync_args.get("regions"),
current_aws_account_id=sync_args.get("current_aws_account_id"),
update_tag=sync_args.get("update_tag"),
common_job_parameters=sync_args.get("common_job_parameters"),
)
# Skip permission relationships and tags for now because they rely on data already being in the graph
if func_name not in [
elif func_name in [
"permission_relationships",
"resourcegroupstaggingapi",
]:
cartography_aws.RESOURCE_FUNCTIONS[func_name](**sync_args)
continue
else:
continue
cartography_aws.RESOURCE_FUNCTIONS[func_name](**sync_args)
except Exception as e:
exception_message = utils.stringify_exception(
@@ -80,7 +80,7 @@ CLEANUP_STATEMENT = """
def create_indexes(neo4j_session: neo4j.Session) -> None:
"""
Code based on Cartography version 0.117.0, specifically on `cartography.intel.create_indexes.run`.
Code based on Cartography version 0.122.0, specifically on `cartography.intel.create_indexes.run`.
"""
logger.info("Creating indexes for Prowler node types.")
+34 -14
View File
@@ -7,6 +7,7 @@ from typing import Any, Callable
from cartography.config import Config as CartographyConfig
from cartography.intel import analysis as cartography_analysis
from cartography.intel import create_indexes as cartography_create_indexes
from cartography.intel import ontology as cartography_ontology
from celery.utils.log import get_task_logger
from api.attack_paths import database as graph_database
@@ -35,7 +36,7 @@ def get_cartography_ingestion_function(provider_type: str) -> Callable | None:
def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]:
"""
Code based on Cartography version 0.117.0, specifically on `cartography.cli.main`, `cartography.cli.CLI.main`,
Code based on Cartography version 0.122.0, specifically on `cartography.cli.main`, `cartography.cli.CLI.main`,
`cartography.sync.run_with_config` and `cartography.sync.Sync.run`.
"""
ingestion_exceptions = {} # This will hold any exceptions raised during ingestion
@@ -45,19 +46,35 @@ def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]:
prowler_api_provider = ProwlerAPIProvider.objects.get(scan__pk=scan_id)
prowler_sdk_provider = initialize_prowler_provider(prowler_api_provider)
# If the provider is still not supported, just return the current `ingestion_exceptions`, that is empty
if not get_cartography_ingestion_function(prowler_api_provider.provider):
# Attack Paths Scan necessary objects
cartography_ingestion_function = get_cartography_ingestion_function(
prowler_api_provider.provider
)
attack_paths_scan = db_utils.retrieve_attack_paths_scan(tenant_id, scan_id)
# Checks before starting the scan
if not cartography_ingestion_function:
ingestion_exceptions = {
"global_error": f"Provider {prowler_api_provider.provider} is not supported for Attack Paths scans"
}
if attack_paths_scan:
db_utils.finish_attack_paths_scan(
attack_paths_scan, StateChoices.COMPLETED, ingestion_exceptions
)
logger.warning(
f"Provider {prowler_api_provider.provider} is not supported for Attack Paths scans"
)
return ingestion_exceptions
# Getting the Attack Paths Scan object and starting it
attack_paths_scan = db_utils.retrieve_attack_paths_scan(tenant_id, scan_id)
if not attack_paths_scan:
logger.warning(
f"No Attack Paths Scan found for scan {scan_id} and tenant {tenant_id}, let's create it then"
)
attack_paths_scan = db_utils.create_attack_paths_scan(
tenant_id, scan_id, prowler_api_provider.id
)
else:
if not attack_paths_scan:
logger.warning(
f"No Attack Paths Scan found for scan {scan_id} and tenant {tenant_id}, let's create it then"
)
attack_paths_scan = db_utils.create_attack_paths_scan(
tenant_id, scan_id, prowler_api_provider.id
)
# While creating the Cartography configuration, attributes `neo4j_user` and `neo4j_password` are not really needed in this config object
cartography_config = CartographyConfig(
@@ -91,7 +108,7 @@ def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]:
# The real scan, where iterates over cloud services
ingestion_exceptions = _call_within_event_loop(
get_cartography_ingestion_function(prowler_api_provider.provider),
cartography_ingestion_function,
neo4j_session,
cartography_config,
prowler_api_provider,
@@ -100,9 +117,12 @@ def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]:
)
# Post-processing: Just keeping it to be more Cartography compliant
cartography_analysis.run(neo4j_session, cartography_config)
cartography_ontology.run(neo4j_session, cartography_config)
db_utils.update_attack_paths_scan_progress(attack_paths_scan, 95)
cartography_analysis.run(neo4j_session, cartography_config)
db_utils.update_attack_paths_scan_progress(attack_paths_scan, 96)
# Adding Prowler nodes and relationships
prowler.analysis(
neo4j_session, prowler_api_provider, scan_id, cartography_config
@@ -74,6 +74,9 @@ class TestAttackPathsRun:
patch(
"tasks.jobs.attack_paths.scan.cartography_analysis.run"
) as mock_cartography_analysis,
patch(
"tasks.jobs.attack_paths.scan.cartography_ontology.run"
) as mock_cartography_ontology,
patch(
"tasks.jobs.attack_paths.scan.prowler.create_indexes"
) as mock_prowler_indexes,
@@ -115,6 +118,7 @@ class TestAttackPathsRun:
mock_cartography_indexes.assert_called_once_with(mock_session, config)
mock_prowler_indexes.assert_called_once_with(mock_session)
mock_cartography_analysis.assert_called_once_with(mock_session, config)
mock_cartography_ontology.assert_called_once_with(mock_session, config)
mock_prowler_analysis.assert_called_once_with(
mock_session,
provider,
+11 -10
View File
@@ -91,18 +91,19 @@ services:
# Auth
- NEO4J_AUTH=${NEO4J_USER}/${NEO4J_PASSWORD}
# Memory limits
- NEO4J_server_memory_pagecache_size=${NEO4J_SERVER_MEMORY_PAGECACHE_SIZE}
- NEO4J_server_memory_heap_initial__size=${NEO4J_SERVER_MEMORY_HEAP_INITIAL__SIZE}
- NEO4J_server_memory_heap_max__size=${NEO4J_SERVER_MEMORY_HEAP_MAX__SIZE}
- NEO4J_dbms_max__databases=${NEO4J_DBMS_MAX__DATABASES:-1000000}
- NEO4J_server_memory_pagecache_size=${NEO4J_SERVER_MEMORY_PAGECACHE_SIZE:-1G}
- NEO4J_server_memory_heap_initial__size=${NEO4J_SERVER_MEMORY_HEAP_INITIAL__SIZE:-1G}
- NEO4J_server_memory_heap_max__size=${NEO4J_SERVER_MEMORY_HEAP_MAX__SIZE:-1G}
# APOC
- apoc.export.file.enabled=${NEO4J_POC_EXPORT_FILE_ENABLED}
- apoc.import.file.enabled=${NEO4J_APOC_IMPORT_FILE_ENABLED}
- apoc.import.file.use_neo4j_config=${NEO4J_APOC_IMPORT_FILE_USE_NEO4J_CONFIG}
- NEO4J_PLUGINS=${NEO4J_PLUGINS}
- NEO4J_dbms_security_procedures_allowlist=${NEO4J_DBMS_SECURITY_PROCEDURES_ALLOWLIST}
- NEO4J_dbms_security_procedures_unrestricted=${NEO4J_DBMS_SECURITY_PROCEDURES_UNRESTRICTED}
- apoc.export.file.enabled=${NEO4J_POC_EXPORT_FILE_ENABLED:-true}
- apoc.import.file.enabled=${NEO4J_APOC_IMPORT_FILE_ENABLED:-true}
- apoc.import.file.use_neo4j_config=${NEO4J_APOC_IMPORT_FILE_USE_NEO4J_CONFIG:-true}
- "NEO4J_PLUGINS=${NEO4J_PLUGINS:-[\"apoc\"]}"
- "NEO4J_dbms_security_procedures_allowlist=${NEO4J_DBMS_SECURITY_PROCEDURES_ALLOWLIST:-apoc.*}"
- "NEO4J_dbms_security_procedures_unrestricted=${NEO4J_DBMS_SECURITY_PROCEDURES_UNRESTRICTED:-apoc.*}"
# Networking
- dbms.connector.bolt.listen_address=${NEO4J_DBMS_CONNECTOR_BOLT_LISTEN_ADDRESS}
- "dbms.connector.bolt.listen_address=${NEO4J_DBMS_CONNECTOR_BOLT_LISTEN_ADDRESS:-0.0.0.0:7687}"
# 7474 is the UI port
ports:
- 7474:7474
+11 -10
View File
@@ -73,18 +73,19 @@ services:
# Auth
- NEO4J_AUTH=${NEO4J_USER}/${NEO4J_PASSWORD}
# Memory limits
- NEO4J_server_memory_pagecache_size=${NEO4J_SERVER_MEMORY_PAGECACHE_SIZE}
- NEO4J_server_memory_heap_initial__size=${NEO4J_SERVER_MEMORY_HEAP_INITIAL__SIZE}
- NEO4J_server_memory_heap_max__size=${NEO4J_SERVER_MEMORY_HEAP_MAX__SIZE}
- NEO4J_dbms_max__databases=${NEO4J_DBMS_MAX__DATABASES:-1000000}
- NEO4J_server_memory_pagecache_size=${NEO4J_SERVER_MEMORY_PAGECACHE_SIZE:-1G}
- NEO4J_server_memory_heap_initial__size=${NEO4J_SERVER_MEMORY_HEAP_INITIAL__SIZE:-1G}
- NEO4J_server_memory_heap_max__size=${NEO4J_SERVER_MEMORY_HEAP_MAX__SIZE:-1G}
# APOC
- apoc.export.file.enabled=${NEO4J_POC_EXPORT_FILE_ENABLED}
- apoc.import.file.enabled=${NEO4J_APOC_IMPORT_FILE_ENABLED}
- apoc.import.file.use_neo4j_config=${NEO4J_APOC_IMPORT_FILE_USE_NEO4J_CONFIG}
- NEO4J_PLUGINS=${NEO4J_PLUGINS}
- NEO4J_dbms_security_procedures_allowlist=${NEO4J_DBMS_SECURITY_PROCEDURES_ALLOWLIST}
- NEO4J_dbms_security_procedures_unrestricted=${NEO4J_DBMS_SECURITY_PROCEDURES_UNRESTRICTED}
- apoc.export.file.enabled=${NEO4J_POC_EXPORT_FILE_ENABLED:-true}
- apoc.import.file.enabled=${NEO4J_APOC_IMPORT_FILE_ENABLED:-true}
- apoc.import.file.use_neo4j_config=${NEO4J_APOC_IMPORT_FILE_USE_NEO4J_CONFIG:-true}
- "NEO4J_PLUGINS=${NEO4J_PLUGINS:-[\"apoc\"]}"
- "NEO4J_dbms_security_procedures_allowlist=${NEO4J_DBMS_SECURITY_PROCEDURES_ALLOWLIST:-apoc.*}"
- "NEO4J_dbms_security_procedures_unrestricted=${NEO4J_DBMS_SECURITY_PROCEDURES_UNRESTRICTED:-apoc.*}"
# Networking
- dbms.connector.bolt.listen_address=${NEO4J_DBMS_CONNECTOR_BOLT_LISTEN_ADDRESS}
- "dbms.connector.bolt.listen_address=${NEO4J_DBMS_CONNECTOR_BOLT_LISTEN_ADDRESS:-0.0.0.0:7687}"
ports:
- ${NEO4J_PORT:-7687}:7687
healthcheck:
Generated
+26 -19
View File
@@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 2.2.1 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"
@@ -938,34 +938,34 @@ files = [
[[package]]
name = "boto3"
version = "1.39.15"
version = "1.40.61"
description = "The AWS SDK for Python"
optional = false
python-versions = ">=3.9"
groups = ["main", "dev"]
files = [
{file = "boto3-1.39.15-py3-none-any.whl", hash = "sha256:38fc54576b925af0075636752de9974e172c8a2cf7133400e3e09b150d20fb6a"},
{file = "boto3-1.39.15.tar.gz", hash = "sha256:b4483625f0d8c35045254dee46cd3c851bbc0450814f20b9b25bee1b5c0d8409"},
{file = "boto3-1.40.61-py3-none-any.whl", hash = "sha256:6b9c57b2a922b5d8c17766e29ed792586a818098efe84def27c8f582b33f898c"},
{file = "boto3-1.40.61.tar.gz", hash = "sha256:d6c56277251adf6c2bdd25249feae625abe4966831676689ff23b4694dea5b12"},
]
[package.dependencies]
botocore = ">=1.39.15,<1.40.0"
botocore = ">=1.40.61,<1.41.0"
jmespath = ">=0.7.1,<2.0.0"
s3transfer = ">=0.13.0,<0.14.0"
s3transfer = ">=0.14.0,<0.15.0"
[package.extras]
crt = ["botocore[crt] (>=1.21.0,<2.0a0)"]
[[package]]
name = "botocore"
version = "1.39.15"
version = "1.40.61"
description = "Low-level, data-driven core of boto 3."
optional = false
python-versions = ">=3.9"
groups = ["main", "dev"]
files = [
{file = "botocore-1.39.15-py3-none-any.whl", hash = "sha256:eb9cfe918ebfbfb8654e1b153b29f0c129d586d2c0d7fb4032731d49baf04cff"},
{file = "botocore-1.39.15.tar.gz", hash = "sha256:2aa29a717f14f8c7ca058c2e297aaed0aa10ecea24b91514eee802814d1b7600"},
{file = "botocore-1.40.61-py3-none-any.whl", hash = "sha256:17ebae412692fd4824f99cde0f08d50126dc97954008e5ba2b522eb049238aa7"},
{file = "botocore-1.40.61.tar.gz", hash = "sha256:a2487ad69b090f9cccd64cf07c7021cd80ee9c0655ad974f87045b02f3ef52cd"},
]
[package.dependencies]
@@ -977,7 +977,7 @@ urllib3 = [
]
[package.extras]
crt = ["awscrt (==0.23.8)"]
crt = ["awscrt (==0.27.6)"]
[[package]]
name = "cachetools"
@@ -2366,6 +2366,8 @@ python-versions = "*"
groups = ["dev"]
files = [
{file = "jsonpath-ng-1.7.0.tar.gz", hash = "sha256:f6f5f7fd4e5ff79c785f1573b394043b39849fb2bb47bcead935d12b00beab3c"},
{file = "jsonpath_ng-1.7.0-py2-none-any.whl", hash = "sha256:898c93fc173f0c336784a3fa63d7434297544b7198124a68f9a3ef9597b0ae6e"},
{file = "jsonpath_ng-1.7.0-py3-none-any.whl", hash = "sha256:f3d7f9e848cba1b6da28c55b1c26ff915dc9e0b1ba7e752a53d6da8d5cbd00b6"},
]
[package.dependencies]
@@ -4884,6 +4886,7 @@ files = [
{file = "ruamel.yaml.clib-0.2.12-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f66efbc1caa63c088dead1c4170d148eabc9b80d95fb75b6c92ac0aad2437d76"},
{file = "ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:22353049ba4181685023b25b5b51a574bce33e7f51c759371a7422dcae5402a6"},
{file = "ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:932205970b9f9991b34f55136be327501903f7c66830e9760a8ffb15b07f05cd"},
{file = "ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a52d48f4e7bf9005e8f0a89209bf9a73f7190ddf0489eee5eb51377385f59f2a"},
{file = "ruamel.yaml.clib-0.2.12-cp310-cp310-win32.whl", hash = "sha256:3eac5a91891ceb88138c113f9db04f3cebdae277f5d44eaa3651a4f573e6a5da"},
{file = "ruamel.yaml.clib-0.2.12-cp310-cp310-win_amd64.whl", hash = "sha256:ab007f2f5a87bd08ab1499bdf96f3d5c6ad4dcfa364884cb4549aa0154b13a28"},
{file = "ruamel.yaml.clib-0.2.12-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:4a6679521a58256a90b0d89e03992c15144c5f3858f40d7c18886023d7943db6"},
@@ -4892,6 +4895,7 @@ files = [
{file = "ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:811ea1594b8a0fb466172c384267a4e5e367298af6b228931f273b111f17ef52"},
{file = "ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:cf12567a7b565cbf65d438dec6cfbe2917d3c1bdddfce84a9930b7d35ea59642"},
{file = "ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7dd5adc8b930b12c8fc5b99e2d535a09889941aa0d0bd06f4749e9a9397c71d2"},
{file = "ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1492a6051dab8d912fc2adeef0e8c72216b24d57bd896ea607cb90bb0c4981d3"},
{file = "ruamel.yaml.clib-0.2.12-cp311-cp311-win32.whl", hash = "sha256:bd0a08f0bab19093c54e18a14a10b4322e1eacc5217056f3c063bd2f59853ce4"},
{file = "ruamel.yaml.clib-0.2.12-cp311-cp311-win_amd64.whl", hash = "sha256:a274fb2cb086c7a3dea4322ec27f4cb5cc4b6298adb583ab0e211a4682f241eb"},
{file = "ruamel.yaml.clib-0.2.12-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:20b0f8dc160ba83b6dcc0e256846e1a02d044e13f7ea74a3d1d56ede4e48c632"},
@@ -4900,6 +4904,7 @@ files = [
{file = "ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:749c16fcc4a2b09f28843cda5a193e0283e47454b63ec4b81eaa2242f50e4ccd"},
{file = "ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bf165fef1f223beae7333275156ab2022cffe255dcc51c27f066b4370da81e31"},
{file = "ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:32621c177bbf782ca5a18ba4d7af0f1082a3f6e517ac2a18b3974d4edf349680"},
{file = "ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b82a7c94a498853aa0b272fd5bc67f29008da798d4f93a2f9f289feb8426a58d"},
{file = "ruamel.yaml.clib-0.2.12-cp312-cp312-win32.whl", hash = "sha256:e8c4ebfcfd57177b572e2040777b8abc537cdef58a2120e830124946aa9b42c5"},
{file = "ruamel.yaml.clib-0.2.12-cp312-cp312-win_amd64.whl", hash = "sha256:0467c5965282c62203273b838ae77c0d29d7638c8a4e3a1c8bdd3602c10904e4"},
{file = "ruamel.yaml.clib-0.2.12-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4c8c5d82f50bb53986a5e02d1b3092b03622c02c2eb78e29bec33fd9593bae1a"},
@@ -4908,6 +4913,7 @@ files = [
{file = "ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:96777d473c05ee3e5e3c3e999f5d23c6f4ec5b0c38c098b3a5229085f74236c6"},
{file = "ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:3bc2a80e6420ca8b7d3590791e2dfc709c88ab9152c00eeb511c9875ce5778bf"},
{file = "ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:e188d2699864c11c36cdfdada94d781fd5d6b0071cd9c427bceb08ad3d7c70e1"},
{file = "ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f6f3eac23941b32afccc23081e1f50612bdbe4e982012ef4f5797986828cd01"},
{file = "ruamel.yaml.clib-0.2.12-cp313-cp313-win32.whl", hash = "sha256:6442cb36270b3afb1b4951f060eccca1ce49f3d087ca1ca4563a6eb479cb3de6"},
{file = "ruamel.yaml.clib-0.2.12-cp313-cp313-win_amd64.whl", hash = "sha256:e5b8daf27af0b90da7bb903a876477a9e6d7270be6146906b276605997c7e9a3"},
{file = "ruamel.yaml.clib-0.2.12-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:fc4b630cd3fa2cf7fce38afa91d7cfe844a9f75d7f0f36393fa98815e911d987"},
@@ -4916,6 +4922,7 @@ files = [
{file = "ruamel.yaml.clib-0.2.12-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e2f1c3765db32be59d18ab3953f43ab62a761327aafc1594a2a1fbe038b8b8a7"},
{file = "ruamel.yaml.clib-0.2.12-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:d85252669dc32f98ebcd5d36768f5d4faeaeaa2d655ac0473be490ecdae3c285"},
{file = "ruamel.yaml.clib-0.2.12-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:e143ada795c341b56de9418c58d028989093ee611aa27ffb9b7f609c00d813ed"},
{file = "ruamel.yaml.clib-0.2.12-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2c59aa6170b990d8d2719323e628aaf36f3bfbc1c26279c0eeeb24d05d2d11c7"},
{file = "ruamel.yaml.clib-0.2.12-cp39-cp39-win32.whl", hash = "sha256:beffaed67936fbbeffd10966a4eb53c402fafd3d6833770516bf7314bc6ffa12"},
{file = "ruamel.yaml.clib-0.2.12-cp39-cp39-win_amd64.whl", hash = "sha256:040ae85536960525ea62868b642bdb0c2cc6021c9f9d507810c0c604e66f5a7b"},
{file = "ruamel.yaml.clib-0.2.12.tar.gz", hash = "sha256:6c8fbb13ec503f99a91901ab46e0b07ae7941cd527393187039aec586fdfd36f"},
@@ -4923,14 +4930,14 @@ files = [
[[package]]
name = "s3transfer"
version = "0.13.1"
version = "0.14.0"
description = "An Amazon S3 Transfer Manager"
optional = false
python-versions = ">=3.9"
groups = ["main", "dev"]
files = [
{file = "s3transfer-0.13.1-py3-none-any.whl", hash = "sha256:a981aa7429be23fe6dfc13e80e4020057cbab622b08c0315288758d67cabc724"},
{file = "s3transfer-0.13.1.tar.gz", hash = "sha256:c3fdba22ba1bd367922f27ec8032d6a1cf5f10c934fb5d68cf60fd5a23d936cf"},
{file = "s3transfer-0.14.0-py3-none-any.whl", hash = "sha256:ea3b790c7077558ed1f02a3072fb3cb992bbbd253392f4b6e9e8976941c7d456"},
{file = "s3transfer-0.14.0.tar.gz", hash = "sha256:eff12264e7c8b4985074ccce27a3b38a485bb7f7422cc8046fee9be4983e4125"},
]
[package.dependencies]
@@ -5075,18 +5082,18 @@ files = [
[[package]]
name = "slack-sdk"
version = "3.34.0"
version = "3.39.0"
description = "The Slack API Platform SDK for Python"
optional = false
python-versions = ">=3.6"
python-versions = ">=3.7"
groups = ["main"]
files = [
{file = "slack_sdk-3.34.0-py2.py3-none-any.whl", hash = "sha256:c61f57f310d85be83466db5a98ab6ae3bb2e5587437b54fa0daa8fae6a0feffa"},
{file = "slack_sdk-3.34.0.tar.gz", hash = "sha256:ff61db7012160eed742285ea91f11c72b7a38a6500a7f6c5335662b4bc6b853d"},
{file = "slack_sdk-3.39.0-py2.py3-none-any.whl", hash = "sha256:b1556b2f5b8b12b94e5ea3f56c4f2c7f04462e4e1013d325c5764ff118044fa8"},
{file = "slack_sdk-3.39.0.tar.gz", hash = "sha256:6a56be10dc155c436ff658c6b776e1c082e29eae6a771fccf8b0a235822bbcb1"},
]
[package.extras]
optional = ["SQLAlchemy (>=1.4,<3)", "aiodns (>1.0)", "aiohttp (>=3.7.3,<4)", "boto3 (<=2)", "websocket-client (>=1,<2)", "websockets (>=9.1,<15)"]
optional = ["SQLAlchemy (>=1.4,<3)", "aiodns (>1.0)", "aiohttp (>=3.7.3,<4)", "boto3 (<=2)", "websocket-client (>=1,<2)", "websockets (>=9.1,<16)"]
[[package]]
name = "sniffio"
@@ -5688,4 +5695,4 @@ type = ["pytest-mypy"]
[metadata]
lock-version = "2.1"
python-versions = ">3.9.1,<3.13"
content-hash = "a367e65bc43c0a16495a3d0f6eab8b356cc49b509e329b61c6641cd87f374ff4"
content-hash = "82015f7b4b08e419ac5d28eab1a2d4b563b1980c84679e020ed3d42d3b4e9b85"
+3 -3
View File
@@ -40,8 +40,8 @@ dependencies = [
"azure-mgmt-loganalytics==12.0.0",
"azure-monitor-query==2.0.0",
"azure-storage-blob==12.24.1",
"boto3==1.39.15",
"botocore==1.39.15",
"boto3==1.40.61",
"botocore==1.40.61",
"colorama==0.4.6",
"cryptography==44.0.1",
"dash==3.1.1",
@@ -64,7 +64,7 @@ dependencies = [
"pytz==2025.1",
"schema==0.7.5",
"shodan==1.31.0",
"slack-sdk==3.34.0",
"slack-sdk==3.39.0",
"tabulate==0.9.0",
"tzlocal==5.3.1",
"py-iam-expand==0.1.0",
@@ -18,6 +18,8 @@ import {
formatNodeLabel,
getNodeBorderColor,
getNodeColor,
getPathEdges,
GRAPH_ALERT_BORDER_COLOR,
GRAPH_EDGE_COLOR,
GRAPH_EDGE_HIGHLIGHT_COLOR,
GRAPH_SELECTION_COLOR,
@@ -35,7 +37,6 @@ interface AttackPathGraphProps {
data: AttackPathGraphData;
onNodeClick?: (node: GraphNode) => void;
selectedNodeId?: string | null;
isFilteredView?: boolean;
ref?: Ref<AttackPathGraphRef>;
}
@@ -58,7 +59,7 @@ const HEXAGON_HEIGHT = 55; // Height for finding hexagons
const AttackPathGraphComponent = forwardRef<
AttackPathGraphRef,
AttackPathGraphProps
>(({ data, onNodeClick, selectedNodeId, isFilteredView = false }, ref) => {
>(({ data, onNodeClick, selectedNodeId }, ref) => {
const svgRef = useRef<SVGSVGElement>(null);
const [zoomLevel, setZoomLevel] = useState(1);
const zoomBehaviorRef = useRef<ZoomBehavior<SVGSVGElement, unknown> | null>(
@@ -92,49 +93,6 @@ const AttackPathGraphComponent = forwardRef<
selectedNodeIdRef.current = selectedNodeId ?? null;
}, [selectedNodeId]);
// Helper function to find edges in the path from a given node
// Upstream: follows only ONE parent path (first parent at each level) to avoid lighting up siblings
// Downstream: follows ALL children recursively
const getPathEdges = (
nodeId: string,
edges: Array<{ sourceId: string; targetId: string }>,
): Set<string> => {
const pathEdgeIds = new Set<string>();
const visitedNodes = new Set<string>();
// Traverse upstream - only follow ONE parent at each level (first found)
// This creates a single path to the root, not all paths
const traverseUpstream = (currentNodeId: string) => {
if (visitedNodes.has(`up-${currentNodeId}`)) return;
visitedNodes.add(`up-${currentNodeId}`);
// Find the first parent edge only
const parentEdge = edges.find((edge) => edge.targetId === currentNodeId);
if (parentEdge) {
pathEdgeIds.add(`${parentEdge.sourceId}-${parentEdge.targetId}`);
traverseUpstream(parentEdge.sourceId);
}
};
// Traverse downstream (find ALL targets from this node)
const traverseDownstream = (currentNodeId: string) => {
if (visitedNodes.has(`down-${currentNodeId}`)) return;
visitedNodes.add(`down-${currentNodeId}`);
edges.forEach((edge) => {
if (edge.sourceId === currentNodeId) {
pathEdgeIds.add(`${edge.sourceId}-${edge.targetId}`);
traverseDownstream(edge.targetId);
}
});
};
traverseUpstream(nodeId);
traverseDownstream(nodeId);
return pathEdgeIds;
};
// Update ref when onNodeClick changes
useEffect(() => {
onNodeClickRef.current = onNodeClick;
@@ -143,7 +101,6 @@ const AttackPathGraphComponent = forwardRef<
// Update selected node styling and edge highlighting without re-rendering
useEffect(() => {
if (nodeShapesRef.current) {
const ALERT_BORDER_COLOR = "#ef4444"; // Red 500
nodeShapesRef.current
.attr("stroke", (d: NodeData) => {
const isFinding = d.data.labels.some((label) =>
@@ -153,7 +110,7 @@ const AttackPathGraphComponent = forwardRef<
// Resources with findings always keep red border
if (!isFinding && hasFindings) {
return ALERT_BORDER_COLOR;
return GRAPH_ALERT_BORDER_COLOR;
}
// Selected nodes get highlight color (orange)
if (d.id === selectedNodeId) {
@@ -167,26 +124,12 @@ const AttackPathGraphComponent = forwardRef<
label.toLowerCase().includes("finding"),
);
const hasFindings = resourcesWithFindingsRef.current.has(d.id);
const isSelected = d.id === selectedNodeId;
if (isSelected) return 4;
if (!isFinding && hasFindings) return 2.5;
return isFinding ? 2 : 1.5;
})
.attr("filter", (d: NodeData) => {
const isFinding = d.data.labels.some((label) =>
label.toLowerCase().includes("finding"),
);
const hasFindings = resourcesWithFindingsRef.current.has(d.id);
const isSelected = d.id === selectedNodeId;
if (isSelected) return "url(#selectedGlow)";
if (!isFinding && hasFindings) return "url(#redGlow)";
return isFinding ? "url(#glow)" : null;
})
.attr("class", (d: NodeData) => {
const isSelected = d.id === selectedNodeId;
return isSelected ? "node-shape selected-node" : "node-shape";
// Resources with findings keep their wider stroke
if (!isFinding && hasFindings) {
return 2.5;
}
return d.id === selectedNodeId ? 3 : isFinding ? 2 : 1.5;
});
}
@@ -209,7 +152,6 @@ const AttackPathGraphComponent = forwardRef<
}
}, [selectedNodeId]);
useImperativeHandle(ref, () => ({
zoomIn: () => {
if (svgSelectionRef.current && zoomBehaviorRef.current) {
@@ -303,19 +245,16 @@ const AttackPathGraphComponent = forwardRef<
});
g.setDefaultEdgeLabel(() => ({}));
// Initially hide finding nodes - they are shown when user clicks on a node
// In filtered view, show all nodes since they're already filtered to the selected path
// Initially hide finding nodes
const initialHiddenNodes = new Set<string>();
if (!isFilteredView) {
data.nodes.forEach((node) => {
const isFinding = node.labels.some((label) =>
label.toLowerCase().includes("finding"),
);
if (isFinding) {
initialHiddenNodes.add(node.id);
}
});
}
data.nodes.forEach((node) => {
const isFinding = node.labels.some((label) =>
label.toLowerCase().includes("finding"),
);
if (isFinding) {
initialHiddenNodes.add(node.id);
}
});
hiddenNodeIdsRef.current = initialHiddenNodes;
// Create a map to store original node data
@@ -431,19 +370,9 @@ const AttackPathGraphComponent = forwardRef<
.attr("dx", "0")
.attr("dy", "0")
.attr("stdDeviation", "4")
.attr("flood-color", "#ef4444")
.attr("flood-color", GRAPH_ALERT_BORDER_COLOR)
.attr("flood-opacity", "0.6");
// Orange glow filter for selected/filtered node
const selectedGlowFilter = defs.append("filter").attr("id", "selectedGlow");
selectedGlowFilter
.append("feDropShadow")
.attr("dx", "0")
.attr("dy", "0")
.attr("stdDeviation", "6")
.attr("flood-color", GRAPH_EDGE_HIGHLIGHT_COLOR)
.attr("flood-opacity", "0.8");
// Arrow marker (default white) - refX=10 places the arrow tip exactly at the line endpoint
defs
.append("marker")
@@ -472,7 +401,7 @@ const AttackPathGraphComponent = forwardRef<
.attr("d", "M 0 0 L 10 5 L 0 10 z")
.attr("fill", GRAPH_EDGE_HIGHLIGHT_COLOR);
// Add CSS animation for dashed lines, resource edge styles, and selected node pulse
// Add CSS animation for dashed lines and resource edge styles
svg.append("style").text(`
@keyframes dash {
to {
@@ -485,20 +414,6 @@ const AttackPathGraphComponent = forwardRef<
.resource-edge {
stroke-opacity: 1;
}
@keyframes selectedPulse {
0%, 100% {
stroke-opacity: 1;
stroke-width: 4px;
}
50% {
stroke-opacity: 0.6;
stroke-width: 6px;
}
}
.selected-node {
animation: selectedPulse 1.2s ease-in-out infinite;
filter: url(#selectedGlow);
}
`);
const linkGroup = container.append("g").attr("class", "links");
@@ -595,21 +510,24 @@ const AttackPathGraphComponent = forwardRef<
return hasFinding ? "animated-edge" : "resource-edge";
})
.attr("marker-end", "url(#arrowhead)")
.style("visibility", (d) => {
.each(function (d) {
// Resource-to-resource edges are ALWAYS visible
// Finding edges are only visible when the finding node is visible
const sourceIsFinding = isNodeFinding(d.sourceId);
const targetIsFinding = isNodeFinding(d.targetId);
// Hide edges connected to findings in full view (shown when user clicks on a node or in filtered view)
if (
!isFilteredView &&
(sourceIsFinding || targetIsFinding)
) {
return "hidden";
let visibility = "visible";
if (sourceIsFinding || targetIsFinding) {
const sourceHidden = hiddenNodeIdsRef.current.has(d.sourceId);
const targetHidden = hiddenNodeIdsRef.current.has(d.targetId);
visibility = sourceHidden || targetHidden ? "hidden" : "visible";
}
return "visible";
select(this).style("visibility", visibility);
});
// Store linkElements reference for hover interactions
// D3 selection types don't match our ref type exactly; safe cast for internal use
linkElementsRef.current = linkElements as unknown as ReturnType<
typeof select<SVGLineElement, unknown>
>;
@@ -635,10 +553,9 @@ const AttackPathGraphComponent = forwardRef<
.attr("class", "node")
.attr("transform", (d) => `translate(${d.x},${d.y})`)
.attr("cursor", "pointer")
.style("display", (d) => {
// Hide findings in full view (they are shown when user clicks on a node or in filtered view)
return hiddenNodeIdsRef.current.has(d.id) ? "none" : null;
})
.style("display", (d) =>
hiddenNodeIdsRef.current.has(d.id) ? "none" : null,
)
.on("mouseenter", function (_event: PointerEvent, d) {
// Highlight entire path from this node
const pathEdges = getPathEdges(d.id, edgesData);
@@ -692,11 +609,10 @@ const AttackPathGraphComponent = forwardRef<
label.toLowerCase().includes("finding"),
);
const hasFindings = resourcesWithFindings.has(d.id);
const ALERT_BORDER_COLOR = "#ef4444";
// Determine the correct border color
if (!isFinding && hasFindings) {
nodeShape.attr("stroke", ALERT_BORDER_COLOR);
nodeShape.attr("stroke", GRAPH_ALERT_BORDER_COLOR);
} else if (d.id === selectedId) {
nodeShape.attr("stroke", GRAPH_EDGE_HIGHLIGHT_COLOR);
} else {
@@ -912,23 +828,17 @@ const AttackPathGraphComponent = forwardRef<
// Store in ref for use in selection updates
resourcesWithFindingsRef.current = resourcesWithFindings;
// Red alert color for resources with findings
const ALERT_BORDER_COLOR = "#ef4444"; // Red 500
// Add shapes - hexagons for findings, rounded pill shapes for resources
nodeElements.each(function (d) {
const group = select(this);
const isFinding = d.data.labels.some((label) =>
label.toLowerCase().includes("finding"),
);
const isPrivilegeEscalation = d.data.labels.some(
(label) => label === "PrivilegeEscalation",
);
const nodeColor = getNodeColor(d.data.labels, d.data.properties);
const borderColor = getNodeBorderColor(d.data.labels, d.data.properties);
const hasFindings = resourcesWithFindings.has(d.id);
if (isFinding || isPrivilegeEscalation) {
if (isFinding) {
// Hexagon for findings - always has glow
const w = HEXAGON_WIDTH;
const h = HEXAGON_HEIGHT;
@@ -942,41 +852,31 @@ const AttackPathGraphComponent = forwardRef<
L ${-w / 2} 0
Z
`;
const isSelected = d.id === selectedNodeId;
group
.append("path")
.attr("d", hexPath)
.attr("fill", nodeColor)
.attr("fill-opacity", 0.85)
.attr("stroke", isSelected ? GRAPH_EDGE_HIGHLIGHT_COLOR : borderColor)
.attr("stroke-width", isSelected ? 4 : 2)
.attr("filter", isSelected ? "url(#selectedGlow)" : "url(#glow)")
.attr("class", isSelected ? "node-shape selected-node" : "node-shape");
.attr(
"stroke",
d.id === selectedNodeId ? GRAPH_SELECTION_COLOR : borderColor,
)
.attr("stroke-width", d.id === selectedNodeId ? 3 : 2)
.attr("filter", "url(#glow)")
.attr("class", "node-shape");
} else {
// Check if this is an Internet node
const isInternet = d.data.labels.some(
(label) => label.toLowerCase() === "internet",
);
const isSelected = d.id === selectedNodeId;
// Resources with findings get red border and red glow (even when selected)
// Selected nodes get orange border
const strokeColor = hasFindings
? ALERT_BORDER_COLOR
: isSelected
? GRAPH_EDGE_HIGHLIGHT_COLOR
? GRAPH_ALERT_BORDER_COLOR
: d.id === selectedNodeId
? GRAPH_SELECTION_COLOR
: borderColor;
// Determine filter: selected takes priority, then hasFindings, then default
const nodeFilter = isSelected
? "url(#selectedGlow)"
: hasFindings
? "url(#redGlow)"
: "url(#glow)";
const nodeClass = isSelected ? "node-shape selected-node" : "node-shape";
if (isInternet) {
// Globe shape for Internet nodes - larger than regular nodes
const radius = NODE_HEIGHT * 0.8;
@@ -990,9 +890,12 @@ const AttackPathGraphComponent = forwardRef<
.attr("fill", nodeColor)
.attr("fill-opacity", 0.85)
.attr("stroke", strokeColor)
.attr("stroke-width", isSelected ? 4 : hasFindings ? 2.5 : 1.5)
.attr("filter", nodeFilter)
.attr("class", nodeClass);
.attr(
"stroke-width",
hasFindings ? 2.5 : d.id === selectedNodeId ? 3 : 1.5,
)
.attr("filter", hasFindings ? "url(#redGlow)" : "url(#glow)")
.attr("class", "node-shape");
// Horizontal ellipse (equator)
group
@@ -1030,14 +933,17 @@ const AttackPathGraphComponent = forwardRef<
.attr("fill", nodeColor)
.attr("fill-opacity", 0.85)
.attr("stroke", strokeColor)
.attr("stroke-width", isSelected ? 4 : hasFindings ? 2.5 : 1.5)
.attr("filter", nodeFilter)
.attr("class", nodeClass);
.attr(
"stroke-width",
hasFindings ? 2.5 : d.id === selectedNodeId ? 3 : 1.5,
)
.attr("filter", hasFindings ? "url(#redGlow)" : null)
.attr("class", "node-shape");
}
}
});
// Store references for updating selection later
// Store reference for updating selection later (select all shapes)
const nodeShapes = nodeElements.selectAll(".node-shape");
nodeShapesRef.current = nodeShapes as unknown as ReturnType<
typeof select<SVGRectElement, NodeData>
@@ -1049,9 +955,6 @@ const AttackPathGraphComponent = forwardRef<
const isFinding = d.data.labels.some((label) =>
label.toLowerCase().includes("finding"),
);
const isPrivilegeEscalation = d.data.labels.some(
(label) => label === "PrivilegeEscalation",
);
// Create text container - white text with shadow for readability
const textGroup = group
@@ -1062,7 +965,7 @@ const AttackPathGraphComponent = forwardRef<
.attr("fill", "#ffffff")
.style("text-shadow", "0 1px 2px rgba(0,0,0,0.5)");
if (isFinding || isPrivilegeEscalation) {
if (isFinding) {
// For findings: show check_title/name (severity is shown by color)
const title = String(
d.data.properties?.check_title ||
@@ -1070,47 +973,18 @@ const AttackPathGraphComponent = forwardRef<
d.data.properties?.id ||
"Finding",
);
const subtitle = isPrivilegeEscalation
? String(d.data.properties?.name || "")
: "";
const maxChars = 24;
const displayTitle =
title.length > maxChars
? title.substring(0, maxChars) + "..."
: title;
if (subtitle) {
// Title (main)
textGroup
.append("tspan")
.attr("x", 0)
.attr("dy", "-0.3em")
.attr("font-size", "11px")
.attr("font-weight", "600")
.text(displayTitle);
// Subtitle (name)
const displaySubtitle =
subtitle.length > maxChars
? subtitle.substring(0, maxChars) + "..."
: subtitle;
textGroup
.append("tspan")
.attr("x", 0)
.attr("dy", "1.3em")
.attr("font-size", "10px")
.attr("fill", "rgba(255,255,255,0.85)")
.text(displaySubtitle);
} else {
// Single line for regular findings
textGroup
.append("tspan")
.attr("x", 0)
.attr("font-size", "11px")
.attr("font-weight", "600")
.text(displayTitle);
}
textGroup
.append("tspan")
.attr("x", 0)
.attr("font-size", "11px")
.attr("font-weight", "600")
.text(displayTitle);
} else {
// For resources: show name with type below
const name = String(
@@ -1227,7 +1101,7 @@ const AttackPathGraphComponent = forwardRef<
}
}, 100);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data, isFilteredView]);
}, [data]);
return (
<svg
@@ -56,6 +56,7 @@ export const GRAPH_EDGE_HIGHLIGHT_COLOR = "#f97316"; // Orange 500 (on hover)
export const GRAPH_EDGE_GLOW_COLOR = "#fb923c";
export const GRAPH_SELECTION_COLOR = "#ffffff";
export const GRAPH_BORDER_COLOR = "#374151";
export const GRAPH_ALERT_BORDER_COLOR = "#ef4444"; // Red 500 - for resources with findings
/**
* Get node fill color based on labels and properties
@@ -0,0 +1,52 @@
/**
* Utility functions for attack path graph operations
*/
/**
* Find edges in the path from a given node.
* Upstream: follows only ONE parent path (first parent at each level) to avoid lighting up siblings
* Downstream: follows ALL children recursively
*
* @param nodeId - The starting node ID
* @param edges - Array of edges with sourceId and targetId
* @returns Set of edge IDs in the format "sourceId-targetId"
*/
export const getPathEdges = (
nodeId: string,
edges: Array<{ sourceId: string; targetId: string }>,
): Set<string> => {
const pathEdgeIds = new Set<string>();
const visitedNodes = new Set<string>();
// Traverse upstream - only follow ONE parent at each level (first found)
// This creates a single path to the root, not all paths
const traverseUpstream = (currentNodeId: string) => {
if (visitedNodes.has(`up-${currentNodeId}`)) return;
visitedNodes.add(`up-${currentNodeId}`);
// Find the first parent edge only
const parentEdge = edges.find((edge) => edge.targetId === currentNodeId);
if (parentEdge) {
pathEdgeIds.add(`${parentEdge.sourceId}-${parentEdge.targetId}`);
traverseUpstream(parentEdge.sourceId);
}
};
// Traverse downstream (find ALL targets from this node)
const traverseDownstream = (currentNodeId: string) => {
if (visitedNodes.has(`down-${currentNodeId}`)) return;
visitedNodes.add(`down-${currentNodeId}`);
edges.forEach((edge) => {
if (edge.sourceId === currentNodeId) {
pathEdgeIds.add(`${edge.sourceId}-${edge.targetId}`);
traverseDownstream(edge.targetId);
}
});
};
traverseUpstream(nodeId);
traverseDownstream(nodeId);
return pathEdgeIds;
};
@@ -4,9 +4,11 @@ export {
exportGraphAsSVG,
} from "./export";
export { formatNodeLabel, formatNodeLabels } from "./format";
export { getPathEdges } from "./graph-utils";
export {
getNodeBorderColor,
getNodeColor,
GRAPH_ALERT_BORDER_COLOR,
GRAPH_EDGE_COLOR,
GRAPH_EDGE_HIGHLIGHT_COLOR,
GRAPH_NODE_BORDER_COLORS,