From 79c1f8c2ece87272fd5ba7da2ff67bba5085b954 Mon Sep 17 00:00:00 2001 From: Josema Camacho Date: Wed, 4 Mar 2026 12:01:45 +0100 Subject: [PATCH] fix(api): fix exposed_internet not set on ELB/ELBv2 nodes in attack paths --- api/CHANGELOG.md | 6 ++ .../backend/tasks/jobs/attack_paths/aws.py | 26 ++++++- ...aphy_overrides_aws_ec2_asset_exposure.json | 31 ++++++++ .../cartography_overrides_aws_sync_order.py | 76 +++++++++++++++++++ .../tasks/tests/test_attack_paths_scan.py | 35 +++++++++ 5 files changed, 173 insertions(+), 1 deletion(-) create mode 100644 api/src/backend/tasks/jobs/attack_paths/cartography_overrides_aws_ec2_asset_exposure.json create mode 100644 api/src/backend/tasks/jobs/attack_paths/cartography_overrides_aws_sync_order.py diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index 439f07957c..8075e9e65f 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to the **Prowler API** are documented in this file. +## [1.20.1] (UNRELEASED) + +### 🐞 Fixed + +- Attack Paths: Fix `exposed_internet` never set on ELB/ELBv2 nodes due to cartography sync ordering and em-dash typo in analysis Cypher [(#10250)](https://github.com/prowler-cloud/prowler/pull/10250) + ## [1.20.0] (Prowler v5.19.0) ### 🚀 Added diff --git a/api/src/backend/tasks/jobs/attack_paths/aws.py b/api/src/backend/tasks/jobs/attack_paths/aws.py index 9242946181..5143ed1694 100644 --- a/api/src/backend/tasks/jobs/attack_paths/aws.py +++ b/api/src/backend/tasks/jobs/attack_paths/aws.py @@ -7,7 +7,10 @@ import aioboto3 import boto3 import neo4j +from pathlib import Path + from cartography.config import Config as CartographyConfig +from cartography.graph.job import GraphJob from cartography.intel import aws as cartography_aws from celery.utils.log import get_task_logger @@ -17,6 +20,7 @@ from api.models import ( ) from prowler.providers.common.provider import Provider as ProwlerSDKProvider from tasks.jobs.attack_paths import db_utils, utils +from tasks.jobs.attack_paths.cartography_overrides_aws_sync_order import AWS_SYNC_ORDER logger = get_task_logger(__name__) @@ -47,7 +51,11 @@ def start_aws_ingestion( boto3_session = get_boto3_session(prowler_api_provider, prowler_sdk_provider) regions: list[str] = list(prowler_sdk_provider._enabled_regions) - requested_syncs = list(cartography_aws.RESOURCE_FUNCTIONS.keys()) + # requested_syncs = list(cartography_aws.RESOURCE_FUNCTIONS.keys()) + # TODO: Uncomment the line above and remove the block below when Cartography fixes the RESOURCE_FUNCTIONS ordering + requested_syncs = [ + s for s in AWS_SYNC_ORDER if s in cartography_aws.RESOURCE_FUNCTIONS + ] sync_args = cartography_aws._build_aws_sync_kwargs( neo4j_session, @@ -144,6 +152,22 @@ def start_aws_ingestion( cartography_aws._perform_aws_analysis( requested_syncs, neo4j_session, common_job_parameters ) + + # TODO: Remove when Cartography fixes em dashes in `aws_ec2_asset_exposure.json` + # Statements 5 and 6 use U+2014 (em dash) instead of U+002D (hyphen) in Cypher + # arrows, so `exposed_internet` is never set on ELB/ELBv2 nodes + logger.info( + f"Running ELB asset exposure fix for AWS account {prowler_api_provider.uid}" + ) + elb_fix_path = ( + Path(__file__).parent / "cartography_overrides_aws_ec2_asset_exposure.json" + ) + GraphJob.run_from_json_file( + elb_fix_path, + neo4j_session, + common_job_parameters, + ) + db_utils.update_attack_paths_scan_progress(attack_paths_scan, 94) return failed_syncs diff --git a/api/src/backend/tasks/jobs/attack_paths/cartography_overrides_aws_ec2_asset_exposure.json b/api/src/backend/tasks/jobs/attack_paths/cartography_overrides_aws_ec2_asset_exposure.json new file mode 100644 index 0000000000..a32a470382 --- /dev/null +++ b/api/src/backend/tasks/jobs/attack_paths/cartography_overrides_aws_ec2_asset_exposure.json @@ -0,0 +1,31 @@ +{ + "statements": [ + { + "query": "MATCH (n:LoadBalancer) WHERE n.exposed_internet IS NOT NULL WITH n LIMIT $LIMIT_SIZE REMOVE n.exposed_internet, n.exposed_internet_type", + "iterative": true, + "iterationsize": 1000 + }, + { + "query": "MATCH (n:LoadBalancerV2) WHERE n.exposed_internet IS NOT NULL WITH n LIMIT $LIMIT_SIZE REMOVE n.exposed_internet, n.exposed_internet_type", + "iterative": true, + "iterationsize": 1000 + }, + { + "query": "MATCH (cidr:IpRange{range:'0.0.0.0/0'})-->(perm:IpPermissionInbound)-->(sg:EC2SecurityGroup)<-[:MEMBER_OF_EC2_SECURITY_GROUP]-(elbv2:LoadBalancerV2{scheme: 'internet-facing'})-->(listener:ELBV2Listener)\nWHERE listener.port>=perm.fromport AND listener.port<=perm.toport\nSET elbv2.exposed_internet = true", + "iterative": false + }, + { + "query": "MATCH (cidr:IpRange{range:'0.0.0.0/0'})-->(perm:IpPermissionInbound)-->(sg:EC2SecurityGroup)<-[:SOURCE_SECURITY_GROUP]-(elb:LoadBalancer{scheme: 'internet-facing'})-->(listener:ELBListener)\nWHERE listener.port>=perm.fromport AND listener.port<=perm.toport\nSET elb.exposed_internet = true", + "iterative": false + }, + { + "query": "MATCH (elb:LoadBalancer{exposed_internet: true})-[:EXPOSE]->(e:EC2Instance)\nWITH e\nWHERE (e.exposed_internet_type IS NULL) OR (NOT 'elb' IN e.exposed_internet_type)\nSET e.exposed_internet = true, e.exposed_internet_type = coalesce(e.exposed_internet_type, []) + 'elb'", + "iterative": false + }, + { + "query": "MATCH (elbv2:LoadBalancerV2{exposed_internet: true})-[:EXPOSE]->(e:EC2Instance)\nWITH e\nWHERE (e.exposed_internet_type IS NULL) OR (NOT 'elbv2' IN e.exposed_internet_type)\nSET e.exposed_internet = true, e.exposed_internet_type = coalesce(e.exposed_internet_type, []) + 'elbv2'", + "iterative": false + } + ], + "name": "ELB/ELBv2 asset internet exposure (cartography em-dash fix)" +} diff --git a/api/src/backend/tasks/jobs/attack_paths/cartography_overrides_aws_sync_order.py b/api/src/backend/tasks/jobs/attack_paths/cartography_overrides_aws_sync_order.py new file mode 100644 index 0000000000..ed10737bee --- /dev/null +++ b/api/src/backend/tasks/jobs/attack_paths/cartography_overrides_aws_sync_order.py @@ -0,0 +1,76 @@ +# TODO: Remove this file when Cartography fixes the RESOURCE_FUNCTIONS ordering. +# https://github.com/cartography-cncf/cartography/issues/XXXX +# +# Explicit sync order for cartography AWS resource functions. +# Based on cartography_aws.RESOURCE_FUNCTIONS (v0.129.0) with one change: +# ec2:security_group moved before ec2:load_balancer, ec2:load_balancer_v2, +# and ec2:network_interface. These resources use OPTIONAL MATCH to link to +# EC2SecurityGroup nodes. On a fresh database the target nodes must exist +# first, otherwise MEMBER_OF_EC2_SECURITY_GROUP edges are silently dropped +# and exposed_internet is never set. + +AWS_SYNC_ORDER: list[str] = [ + "iam", + "iaminstanceprofiles", + "s3", + "kms", + "dynamodb", + "ec2:launch_templates", + "ec2:autoscalinggroup", + "ec2:instance", + "ec2:images", + "ec2:keypair", + "ec2:security_group", # moved here (was after ec2:network_interface) + "ec2:subnet", + "ec2:load_balancer", # depends on ec2:security_group + "ec2:load_balancer_v2", # depends on ec2:security_group + "ec2:network_acls", + "ec2:network_interface", # depends on ec2:security_group + "ec2:tgw", + "ec2:vpc", + "ec2:vpc_endpoint", + "ec2:route_table", + "ec2:vpc_peering", + "ec2:internet_gateway", + "ec2:reserved_instances", + "ec2:volumes", + "ec2:snapshots", + "ecr", + "ecr:image_layers", + "ecs", + "eks", + "elasticache", + "elastic_ip_addresses", + "emr", + "lambda_function", + "rds", + "redshift", + "route53", + "elasticsearch", + "permission_relationships", + "resourcegroupstaggingapi", + "apigateway", + "apigatewayv2", + "bedrock", + "cloudfront", + "secretsmanager", + "securityhub", + "s3accountpublicaccessblock", + "sagemaker", + "sns", + "sqs", + "ssm", + "acm:certificate", + "inspector", + "config", + "identitycenter", + "cloudtrail", + "cloudtrail_management_events", + "cloudwatch", + "efs", + "guardduty", + "codebuild", + "cognito", + "eventbridge", + "glue", +] diff --git a/api/src/backend/tasks/tests/test_attack_paths_scan.py b/api/src/backend/tasks/tests/test_attack_paths_scan.py index 8132b7dad3..6dab938ddc 100644 --- a/api/src/backend/tasks/tests/test_attack_paths_scan.py +++ b/api/src/backend/tasks/tests/test_attack_paths_scan.py @@ -3,9 +3,11 @@ from types import SimpleNamespace from unittest.mock import MagicMock, call, patch import pytest +from cartography.intel import aws as cartography_aws from tasks.jobs.attack_paths import findings as findings_module from tasks.jobs.attack_paths import internet as internet_module from tasks.jobs.attack_paths import sync as sync_module +from tasks.jobs.attack_paths.cartography_overrides_aws_sync_order import AWS_SYNC_ORDER from tasks.jobs.attack_paths.config import ( get_deprecated_provider_resource_label, ) @@ -1494,3 +1496,36 @@ class TestAttackPathsDbUtilsGraphDataReady: ap_scan_b.refresh_from_db() assert ap_scan_a.graph_data_ready is False assert ap_scan_b.graph_data_ready is True + + +class TestAWSSyncOrder: + """Validate AWS_SYNC_ORDER covers all cartography resources and enforces SG ordering.""" + + def test_security_group_before_dependents(self): + """ec2:security_group must sync before resources that link to SGs via OPTIONAL MATCH.""" + sg_dependents = [ + "ec2:load_balancer", + "ec2:load_balancer_v2", + "ec2:network_interface", + ] + sg_idx = AWS_SYNC_ORDER.index("ec2:security_group") + for dep in sg_dependents: + assert ( + AWS_SYNC_ORDER.index(dep) > sg_idx + ), f"{dep} must come after ec2:security_group in AWS_SYNC_ORDER" + + def test_sync_order_covers_all_resource_functions(self): + """Every key in cartography RESOURCE_FUNCTIONS must appear in AWS_SYNC_ORDER.""" + missing = set(cartography_aws.RESOURCE_FUNCTIONS.keys()) - set(AWS_SYNC_ORDER) + assert missing == set(), ( + f"AWS_SYNC_ORDER is missing cartography resources: {missing}. " + "Add them to AWS_SYNC_ORDER in aws.py." + ) + + def test_sync_order_has_no_stale_entries(self): + """Every key in AWS_SYNC_ORDER must exist in cartography RESOURCE_FUNCTIONS.""" + stale = set(AWS_SYNC_ORDER) - set(cartography_aws.RESOURCE_FUNCTIONS.keys()) + assert stale == set(), ( + f"AWS_SYNC_ORDER has entries not in cartography: {stale}. " + "Remove them or update the cartography version." + )