mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-23 12:31:54 +00:00
Merge branch 'attack-paths-demo' into attack-paths-demo-extras
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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/
|
||||
|
||||
Generated
+970
-44
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
@@ -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.")
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
+67
-193
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user