diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index 8f06514003..de7127ae5e 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -8,6 +8,11 @@ All notable changes to the **Prowler API** are documented in this file. - `VALKEY_SCHEME`, `VALKEY_USERNAME`, and `VALKEY_PASSWORD` environment variables to configure Celery broker TLS/auth connection details for Valkey/ElastiCache [(#10420)](https://github.com/prowler-cloud/prowler/pull/10420) +### 🔄 Changed + +- Attack Paths: Periodic cleanup of stale scans with dead-worker detection via Celery inspect, marking orphaned `EXECUTING` scans as `FAILED` and recovering `graph_data_ready` [(#10387)](https://github.com/prowler-cloud/prowler/pull/10387) +- Attack Paths: Replace `_provider_id` property with `_Provider_{uuid}` label for provider isolation, add regex-based label injection for custom queries [(#10402)](https://github.com/prowler-cloud/prowler/pull/10402) + ### 🐞 Fixed - Finding groups list/latest now apply computed status/severity filters and finding-level prefilters (delta, region, service, category, resource group, scan, resource type), plus `check_title` support for sort/filter consistency [(#10428)](https://github.com/prowler-cloud/prowler/pull/10428) @@ -30,10 +35,6 @@ All notable changes to the **Prowler API** are documented in this file. - Finding groups support `check_title` substring filtering [(#10377)](https://github.com/prowler-cloud/prowler/pull/10377) -### 🔄 Changed - -- Attack Paths: Periodic cleanup of stale scans with dead-worker detection via Celery inspect, marking orphaned `EXECUTING` scans as `FAILED` and recovering `graph_data_ready` [(#10387)](https://github.com/prowler-cloud/prowler/pull/10387) - ### 🐞 Fixed - Finding groups latest endpoint now aggregates the latest snapshot per provider before check-level totals, keeping impacted resources aligned across providers [(#10419)](https://github.com/prowler-cloud/prowler/pull/10419) diff --git a/api/src/backend/api/attack_paths/cypher_sanitizer.py b/api/src/backend/api/attack_paths/cypher_sanitizer.py new file mode 100644 index 0000000000..3772b4cbef --- /dev/null +++ b/api/src/backend/api/attack_paths/cypher_sanitizer.py @@ -0,0 +1,170 @@ +""" +Cypher sanitizer for custom (user-supplied) Attack Paths queries. + +Two responsibilities: + +1. **Validation** - reject queries containing SSRF or dangerous procedure + patterns (defense-in-depth; the primary control is ``neo4j.READ_ACCESS``). + +2. **Provider-scoped label injection** - inject a dynamic + ``_Provider_{uuid}`` label into every node pattern so the database can + use its native label index for provider isolation. + +Label-injection pipeline: + +1. **Protect** string literals and line comments (placeholder replacement). +2. **Split** by top-level clause keywords to track clause context. +3. **Pass A** - inject into *labeled* node patterns in ALL segments. +4. **Pass B** - inject into *bare* node patterns in MATCH segments only. +5. **Restore** protected regions. +""" + +import re + +from rest_framework.exceptions import ValidationError + +from tasks.jobs.attack_paths.config import get_provider_label + + +# Step 1 - String / comment protection +# Single combined regex: strings first, then line comments. +# The regex engine finds the leftmost match, so a string like 'https://prowler.com' +# is consumed as a string before the // inside it can match as a comment. +_PROTECTED_RE = re.compile(r"'(?:[^'\\]|\\.)*'|\"(?:[^\"\\]|\\.)*\"|//[^\n]*") + +# Step 2 - Clause splitting +# OPTIONAL MATCH must come before MATCH to avoid partial matching. +_CLAUSE_RE = re.compile( + r"\b(OPTIONAL\s+MATCH|MATCH|WHERE|RETURN|WITH|ORDER\s+BY" + r"|SKIP|LIMIT|UNION|UNWIND|CALL)\b", + re.IGNORECASE, +) + +# Pass A - Labeled node patterns (all segments) +# Matches node patterns that have at least one :Label. +# (? str: + """Inject provider label into all node patterns that have existing labels.""" + return _LABELED_NODE_RE.sub(rf"(\1:{label}\2", segment) + + +def _inject_bare(segment: str, label: str) -> str: + """Inject provider label into bare `(identifier)` node patterns.""" + + def _replace(match): + var = match.group(1) + props = match.group(2).strip() + if props: + return f"({var}:{label} {props})" + return f"({var}:{label})" + + return _BARE_NODE_RE.sub(_replace, segment) + + +def inject_provider_label(cypher: str, provider_id: str) -> str: + """Rewrite a Cypher query to scope every node pattern to a provider. + + Args: + cypher: The original Cypher query string. + provider_id: The provider UUID (will be converted to a label via + `get_provider_label`). + + Returns: + The rewritten Cypher with `:_Provider_{uuid}` appended to every + node pattern. + """ + label = get_provider_label(provider_id) + + # Step 1: Protect strings and comments (single pass, leftmost-first) + protected: list[str] = [] + + def _save(match): + protected.append(match.group(0)) + return f"\x00P{len(protected) - 1}\x00" + + work = _PROTECTED_RE.sub(_save, cypher) + + # Step 2: Split by clause keywords + parts = _CLAUSE_RE.split(work) + + # Steps 3-4: Apply injection passes per segment + result: list[str] = [] + current_clause: str | None = None + + for i, part in enumerate(parts): + if i % 2 == 1: + # Keyword token - normalize for clause tracking + current_clause = re.sub(r"\s+", " ", part.strip()).upper() + result.append(part) + else: + # Content segment - apply injection based on clause context + part = _inject_labeled(part, label) + if current_clause in _MATCH_CLAUSES: + part = _inject_bare(part, label) + result.append(part) + + work = "".join(result) + + # Step 5: Restore protected regions + for i, original in enumerate(protected): + work = work.replace(f"\x00P{i}\x00", original) + + return work + + +# --------------------------------------------------------------------------- +# Validation +# --------------------------------------------------------------------------- + +# Patterns that indicate SSRF or dangerous procedure calls +# Defense-in-depth layer - the primary control is `neo4j.READ_ACCESS` +_BLOCKED_PATTERNS = [ + re.compile(r"\bLOAD\s+CSV\b", re.IGNORECASE), + re.compile(r"\bapoc\.load\b", re.IGNORECASE), + re.compile(r"\bapoc\.import\b", re.IGNORECASE), + re.compile(r"\bapoc\.export\b", re.IGNORECASE), + re.compile(r"\bapoc\.cypher\b", re.IGNORECASE), + re.compile(r"\bapoc\.systemdb\b", re.IGNORECASE), + re.compile(r"\bapoc\.config\b", re.IGNORECASE), + re.compile(r"\bapoc\.periodic\b", re.IGNORECASE), + re.compile(r"\bapoc\.do\b", re.IGNORECASE), + re.compile(r"\bapoc\.trigger\b", re.IGNORECASE), + re.compile(r"\bapoc\.custom\b", re.IGNORECASE), +] + + +def validate_custom_query(cypher: str) -> None: + """Reject queries containing known SSRF or dangerous procedure patterns. + + Raises ValidationError if a blocked pattern is found. + String literals and comments are stripped before matching to avoid + false positives. + """ + stripped = _PROTECTED_RE.sub("", cypher) + for pattern in _BLOCKED_PATTERNS: + if pattern.search(stripped): + raise ValidationError({"query": "Query contains a blocked operation"}) diff --git a/api/src/backend/api/attack_paths/database.py b/api/src/backend/api/attack_paths/database.py index 02083991ac..f8e20b659e 100644 --- a/api/src/backend/api/attack_paths/database.py +++ b/api/src/backend/api/attack_paths/database.py @@ -11,8 +11,8 @@ from config.env import env from django.conf import settings from tasks.jobs.attack_paths.config import ( BATCH_SIZE, - PROVIDER_ID_PROPERTY, PROVIDER_RESOURCE_LABEL, + get_provider_label, ) from api.attack_paths.retryable_session import RetryableSession @@ -163,11 +163,8 @@ def drop_subgraph(database: str, provider_id: str) -> int: Uses batched deletion to avoid memory issues with large graphs. Silently returns 0 if the database doesn't exist. """ + provider_label = get_provider_label(provider_id) deleted_nodes = 0 - parameters = { - "provider_id": provider_id, - "batch_size": BATCH_SIZE, - } try: with get_session(database) as session: @@ -175,12 +172,12 @@ def drop_subgraph(database: str, provider_id: str) -> int: while deleted_count > 0: result = session.run( f""" - MATCH (n:{PROVIDER_RESOURCE_LABEL} {{{PROVIDER_ID_PROPERTY}: $provider_id}}) + MATCH (n:{PROVIDER_RESOURCE_LABEL}:`{provider_label}`) WITH n LIMIT $batch_size DETACH DELETE n RETURN COUNT(n) AS deleted_nodes_count """, - parameters, + {"batch_size": BATCH_SIZE}, ) deleted_count = result.single().get("deleted_nodes_count", 0) deleted_nodes += deleted_count @@ -199,15 +196,12 @@ def has_provider_data(database: str, provider_id: str) -> bool: Returns `False` if the database doesn't exist. """ - query = ( - f"MATCH (n:{PROVIDER_RESOURCE_LABEL} " - f"{{{PROVIDER_ID_PROPERTY}: $provider_id}}) " - "RETURN 1 LIMIT 1" - ) + provider_label = get_provider_label(provider_id) + query = f"MATCH (n:{PROVIDER_RESOURCE_LABEL}:`{provider_label}`) RETURN 1 LIMIT 1" try: with get_session(database, default_access_mode=neo4j.READ_ACCESS) as session: - result = session.run(query, {"provider_id": provider_id}) + result = session.run(query) return result.single() is not None except GraphDatabaseQueryException as exc: diff --git a/api/src/backend/api/attack_paths/queries/aws.py b/api/src/backend/api/attack_paths/queries/aws.py index b874de74bd..f50935c49f 100644 --- a/api/src/backend/api/attack_paths/queries/aws.py +++ b/api/src/backend/api/attack_paths/queries/aws.py @@ -3,7 +3,7 @@ from api.attack_paths.queries.types import ( AttackPathsQueryDefinition, AttackPathsQueryParameterDefinition, ) -from tasks.jobs.attack_paths.config import PROVIDER_ID_PROPERTY, PROWLER_FINDING_LABEL +from tasks.jobs.attack_paths.config import PROWLER_FINDING_LABEL # Custom Attack Path Queries @@ -16,8 +16,6 @@ AWS_INTERNET_EXPOSED_EC2_SENSITIVE_S3_ACCESS = AttackPathsQueryDefinition( description="Detect EC2 instances with SSH exposed to the internet that can assume higher-privileged roles to read tagged sensitive S3 buckets despite bucket-level public access blocks.", provider="aws", cypher=f""" - OPTIONAL MATCH (internet:Internet {{{PROVIDER_ID_PROPERTY}: $provider_id}}) - MATCH path_s3 = (aws:AWSAccount {{id: $provider_uid}})--(s3:S3Bucket)--(t:AWSTag) WHERE toLower(t.key) = toLower($tag_key) AND toLower(t.value) = toLower($tag_value) @@ -31,7 +29,7 @@ AWS_INTERNET_EXPOSED_EC2_SENSITIVE_S3_ACCESS = AttackPathsQueryDefinition( MATCH path_assume_role = (ec2)-[p:STS_ASSUMEROLE_ALLOW*1..9]-(r:AWSRole) - OPTIONAL MATCH (internet)-[can_access:CAN_ACCESS]->(ec2) + OPTIONAL MATCH (internet:Internet)-[can_access:CAN_ACCESS]->(ec2) WITH collect(path_s3) + collect(path_ec2) + collect(path_role) + collect(path_assume_role) AS paths, head(collect(internet)) AS internet, collect(can_access) AS can_access @@ -40,7 +38,7 @@ AWS_INTERNET_EXPOSED_EC2_SENSITIVE_S3_ACCESS = AttackPathsQueryDefinition( WITH paths, internet, can_access, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access """, @@ -79,7 +77,7 @@ AWS_RDS_INSTANCES = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -102,7 +100,7 @@ AWS_RDS_UNENCRYPTED_STORAGE = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -125,7 +123,7 @@ AWS_S3_ANONYMOUS_ACCESS_BUCKETS = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -149,7 +147,7 @@ AWS_IAM_STATEMENTS_ALLOW_ALL_ACTIONS = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -173,7 +171,7 @@ AWS_IAM_STATEMENTS_ALLOW_DELETE_POLICY = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -197,7 +195,7 @@ AWS_IAM_STATEMENTS_ALLOW_CREATE_ACTIONS = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -215,12 +213,10 @@ AWS_EC2_INSTANCES_INTERNET_EXPOSED = AttackPathsQueryDefinition( description="Find EC2 instances flagged as exposed to the internet within the selected account.", provider="aws", cypher=f""" - OPTIONAL MATCH (internet:Internet {{{PROVIDER_ID_PROPERTY}: $provider_id}}) - MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(ec2:EC2Instance) WHERE ec2.exposed_internet = true - OPTIONAL MATCH (internet)-[can_access:CAN_ACCESS]->(ec2) + OPTIONAL MATCH (internet:Internet)-[can_access:CAN_ACCESS]->(ec2) WITH collect(path) AS paths, head(collect(internet)) AS internet, collect(can_access) AS can_access UNWIND paths AS p @@ -228,7 +224,7 @@ AWS_EC2_INSTANCES_INTERNET_EXPOSED = AttackPathsQueryDefinition( WITH paths, internet, can_access, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access """, @@ -242,13 +238,11 @@ AWS_SECURITY_GROUPS_OPEN_INTERNET_FACING = AttackPathsQueryDefinition( description="Find internet-facing resources associated with security groups that allow inbound access from '0.0.0.0/0'.", provider="aws", cypher=f""" - OPTIONAL MATCH (internet:Internet {{{PROVIDER_ID_PROPERTY}: $provider_id}}) - MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(ec2:EC2Instance)--(sg:EC2SecurityGroup)--(ipi:IpPermissionInbound)--(ir:IpRange) WHERE ec2.exposed_internet = true AND ir.range = "0.0.0.0/0" - OPTIONAL MATCH (internet)-[can_access:CAN_ACCESS]->(ec2) + OPTIONAL MATCH (internet:Internet)-[can_access:CAN_ACCESS]->(ec2) WITH collect(path) AS paths, head(collect(internet)) AS internet, collect(can_access) AS can_access UNWIND paths AS p @@ -256,7 +250,7 @@ AWS_SECURITY_GROUPS_OPEN_INTERNET_FACING = AttackPathsQueryDefinition( WITH paths, internet, can_access, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access """, @@ -270,12 +264,10 @@ AWS_CLASSIC_ELB_INTERNET_EXPOSED = AttackPathsQueryDefinition( description="Find Classic Load Balancers exposed to the internet along with their listeners.", provider="aws", cypher=f""" - OPTIONAL MATCH (internet:Internet {{{PROVIDER_ID_PROPERTY}: $provider_id}}) - MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(elb:LoadBalancer)--(listener:ELBListener) WHERE elb.exposed_internet = true - OPTIONAL MATCH (internet)-[can_access:CAN_ACCESS]->(elb) + OPTIONAL MATCH (internet:Internet)-[can_access:CAN_ACCESS]->(elb) WITH collect(path) AS paths, head(collect(internet)) AS internet, collect(can_access) AS can_access UNWIND paths AS p @@ -283,7 +275,7 @@ AWS_CLASSIC_ELB_INTERNET_EXPOSED = AttackPathsQueryDefinition( WITH paths, internet, can_access, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access """, @@ -297,12 +289,10 @@ AWS_ELBV2_INTERNET_EXPOSED = AttackPathsQueryDefinition( description="Find ELBv2 load balancers exposed to the internet along with their listeners.", provider="aws", cypher=f""" - OPTIONAL MATCH (internet:Internet {{{PROVIDER_ID_PROPERTY}: $provider_id}}) - MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(elbv2:LoadBalancerV2)--(listener:ELBV2Listener) WHERE elbv2.exposed_internet = true - OPTIONAL MATCH (internet)-[can_access:CAN_ACCESS]->(elbv2) + OPTIONAL MATCH (internet:Internet)-[can_access:CAN_ACCESS]->(elbv2) WITH collect(path) AS paths, head(collect(internet)) AS internet, collect(can_access) AS can_access UNWIND paths AS p @@ -310,7 +300,7 @@ AWS_ELBV2_INTERNET_EXPOSED = AttackPathsQueryDefinition( WITH paths, internet, can_access, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access """, @@ -324,15 +314,13 @@ AWS_PUBLIC_IP_RESOURCE_LOOKUP = AttackPathsQueryDefinition( description="Given a public IP address, find the related AWS resource and its adjacent node within the selected account.", provider="aws", cypher=f""" - OPTIONAL MATCH (internet:Internet {{{PROVIDER_ID_PROPERTY}: $provider_id}}) - MATCH path = (aws:AWSAccount {{id: $provider_uid}})-[r]-(x)-[q]-(y) WHERE (x:EC2PrivateIp AND x.public_ip = $ip) OR (x:EC2Instance AND x.publicipaddress = $ip) OR (x:NetworkInterface AND x.public_ip = $ip) OR (x:ElasticIPAddress AND x.public_ip = $ip) - OPTIONAL MATCH (internet)-[can_access:CAN_ACCESS]->(x) + OPTIONAL MATCH (internet:Internet)-[can_access:CAN_ACCESS]->(x) WITH collect(path) AS paths, head(collect(internet)) AS internet, collect(can_access) AS can_access UNWIND paths AS p @@ -340,7 +328,7 @@ AWS_PUBLIC_IP_RESOURCE_LOOKUP = AttackPathsQueryDefinition( WITH paths, internet, can_access, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access """, @@ -403,7 +391,7 @@ AWS_APPRUNNER_PRIVESC_PASSROLE_CREATE_SERVICE = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -441,7 +429,7 @@ AWS_APPRUNNER_PRIVESC_UPDATE_SERVICE = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -511,7 +499,7 @@ AWS_BEDROCK_PRIVESC_PASSROLE_CODE_INTERPRETER = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -558,7 +546,7 @@ AWS_BEDROCK_PRIVESC_INVOKE_CODE_INTERPRETER = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -610,7 +598,7 @@ AWS_CLOUDFORMATION_PRIVESC_PASSROLE_CREATE_STACK = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -648,7 +636,7 @@ AWS_CLOUDFORMATION_PRIVESC_UPDATE_STACK = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -709,7 +697,7 @@ AWS_CLOUDFORMATION_PRIVESC_PASSROLE_CREATE_STACKSET = AttackPathsQueryDefinition WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -761,7 +749,7 @@ AWS_CLOUDFORMATION_PRIVESC_PASSROLE_UPDATE_STACKSET = AttackPathsQueryDefinition WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -808,7 +796,7 @@ AWS_CLOUDFORMATION_PRIVESC_CHANGESET = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -869,7 +857,7 @@ AWS_CODEBUILD_PRIVESC_PASSROLE_CREATE_PROJECT = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -907,7 +895,7 @@ AWS_CODEBUILD_PRIVESC_START_BUILD = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -945,7 +933,7 @@ AWS_CODEBUILD_PRIVESC_START_BUILD_BATCH = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -1006,7 +994,7 @@ AWS_CODEBUILD_PRIVESC_PASSROLE_CREATE_PROJECT_BATCH = AttackPathsQueryDefinition WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -1077,7 +1065,7 @@ AWS_DATAPIPELINE_PRIVESC_PASSROLE_CREATE_PIPELINE = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -1129,7 +1117,7 @@ AWS_EC2_PRIVESC_PASSROLE_IAM = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -1185,7 +1173,7 @@ AWS_EC2_PRIVESC_MODIFY_INSTANCE_ATTRIBUTE = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -1237,7 +1225,7 @@ AWS_EC2_PRIVESC_PASSROLE_SPOT_INSTANCES = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -1284,7 +1272,7 @@ AWS_EC2_PRIVESC_LAUNCH_TEMPLATE = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -1322,7 +1310,7 @@ AWS_EC2INSTANCECONNECT_PRIVESC_SEND_SSH_PUBLIC_KEY = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -1392,7 +1380,7 @@ AWS_ECS_PRIVESC_PASSROLE_CREATE_SERVICE = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -1462,7 +1450,7 @@ AWS_ECS_PRIVESC_PASSROLE_RUN_TASK = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -1523,7 +1511,7 @@ AWS_ECS_PRIVESC_PASSROLE_CREATE_SERVICE_EXISTING_CLUSTER = AttackPathsQueryDefin WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -1584,7 +1572,7 @@ AWS_ECS_PRIVESC_PASSROLE_RUN_TASK_EXISTING_CLUSTER = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -1645,7 +1633,7 @@ AWS_ECS_PRIVESC_PASSROLE_START_TASK_EXISTING_CLUSTER = AttackPathsQueryDefinitio WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -1692,7 +1680,7 @@ AWS_ECS_PRIVESC_EXECUTE_COMMAND = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -1744,7 +1732,7 @@ AWS_GLUE_PRIVESC_PASSROLE_DEV_ENDPOINT = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -1782,7 +1770,7 @@ AWS_GLUE_PRIVESC_UPDATE_DEV_ENDPOINT = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -1843,7 +1831,7 @@ AWS_GLUE_PRIVESC_PASSROLE_CREATE_JOB = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -1904,7 +1892,7 @@ AWS_GLUE_PRIVESC_PASSROLE_CREATE_JOB_TRIGGER = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -1965,7 +1953,7 @@ AWS_GLUE_PRIVESC_PASSROLE_UPDATE_JOB = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -2026,7 +2014,7 @@ AWS_GLUE_PRIVESC_PASSROLE_UPDATE_JOB_TRIGGER = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -2069,7 +2057,7 @@ AWS_IAM_PRIVESC_CREATE_POLICY_VERSION = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -2112,7 +2100,7 @@ AWS_IAM_PRIVESC_CREATE_ACCESS_KEY = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -2169,7 +2157,7 @@ AWS_IAM_PRIVESC_DELETE_CREATE_ACCESS_KEY = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -2212,7 +2200,7 @@ AWS_IAM_PRIVESC_CREATE_LOGIN_PROFILE = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -2252,7 +2240,7 @@ AWS_IAM_PRIVESC_PUT_ROLE_POLICY = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -2295,7 +2283,7 @@ AWS_IAM_PRIVESC_UPDATE_LOGIN_PROFILE = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -2335,7 +2323,7 @@ AWS_IAM_PRIVESC_PUT_USER_POLICY = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -2375,7 +2363,7 @@ AWS_IAM_PRIVESC_ATTACH_USER_POLICY = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -2415,7 +2403,7 @@ AWS_IAM_PRIVESC_ATTACH_ROLE_POLICY = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -2458,7 +2446,7 @@ AWS_IAM_PRIVESC_ATTACH_GROUP_POLICY = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -2501,7 +2489,7 @@ AWS_IAM_PRIVESC_PUT_GROUP_POLICY = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -2544,7 +2532,7 @@ AWS_IAM_PRIVESC_UPDATE_ASSUME_ROLE_POLICY = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -2587,7 +2575,7 @@ AWS_IAM_PRIVESC_ADD_USER_TO_GROUP = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -2630,7 +2618,7 @@ AWS_IAM_PRIVESC_ATTACH_ROLE_POLICY_ASSUME_ROLE = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -2687,7 +2675,7 @@ AWS_IAM_PRIVESC_ATTACH_USER_POLICY_CREATE_ACCESS_KEY = AttackPathsQueryDefinitio WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -2731,7 +2719,7 @@ AWS_IAM_PRIVESC_CREATE_POLICY_VERSION_ASSUME_ROLE = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -2774,7 +2762,7 @@ AWS_IAM_PRIVESC_PUT_ROLE_POLICY_ASSUME_ROLE = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -2831,7 +2819,7 @@ AWS_IAM_PRIVESC_PUT_USER_POLICY_CREATE_ACCESS_KEY = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -2888,7 +2876,7 @@ AWS_IAM_PRIVESC_ATTACH_ROLE_POLICY_UPDATE_ASSUME_ROLE = AttackPathsQueryDefiniti WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -2946,7 +2934,7 @@ AWS_IAM_PRIVESC_CREATE_POLICY_VERSION_UPDATE_ASSUME_ROLE = AttackPathsQueryDefin WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -3003,7 +2991,7 @@ AWS_IAM_PRIVESC_PUT_ROLE_POLICY_UPDATE_ASSUME_ROLE = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -3064,7 +3052,7 @@ AWS_LAMBDA_PRIVESC_PASSROLE_CREATE_FUNCTION = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -3125,7 +3113,7 @@ AWS_LAMBDA_PRIVESC_PASSROLE_CREATE_FUNCTION_EVENT_SOURCE = AttackPathsQueryDefin WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -3168,7 +3156,7 @@ AWS_LAMBDA_PRIVESC_UPDATE_FUNCTION_CODE = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -3225,7 +3213,7 @@ AWS_LAMBDA_PRIVESC_UPDATE_FUNCTION_CODE_INVOKE = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -3282,7 +3270,7 @@ AWS_LAMBDA_PRIVESC_UPDATE_FUNCTION_CODE_ADD_PERMISSION = AttackPathsQueryDefinit WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -3343,7 +3331,7 @@ AWS_LAMBDA_PRIVESC_PASSROLE_CREATE_FUNCTION_ADD_PERMISSION = AttackPathsQueryDef WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -3395,7 +3383,7 @@ AWS_SAGEMAKER_PRIVESC_PASSROLE_CREATE_NOTEBOOK = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -3447,7 +3435,7 @@ AWS_SAGEMAKER_PRIVESC_PASSROLE_CREATE_TRAINING_JOB = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -3499,7 +3487,7 @@ AWS_SAGEMAKER_PRIVESC_PASSROLE_CREATE_PROCESSING_JOB = AttackPathsQueryDefinitio WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -3542,7 +3530,7 @@ AWS_SAGEMAKER_PRIVESC_PRESIGNED_NOTEBOOK_URL = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -3612,7 +3600,7 @@ AWS_SAGEMAKER_PRIVESC_LIFECYCLE_CONFIG_NOTEBOOK = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -3650,7 +3638,7 @@ AWS_SSM_PRIVESC_START_SESSION = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -3688,7 +3676,7 @@ AWS_SSM_PRIVESC_SEND_COMMAND = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -3731,7 +3719,7 @@ AWS_STS_PRIVESC_ASSUME_ROLE = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, diff --git a/api/src/backend/api/attack_paths/queries/schema.py b/api/src/backend/api/attack_paths/queries/schema.py index f557a83dd9..5373d17508 100644 --- a/api/src/backend/api/attack_paths/queries/schema.py +++ b/api/src/backend/api/attack_paths/queries/schema.py @@ -1,13 +1,18 @@ -from tasks.jobs.attack_paths.config import PROVIDER_ID_PROPERTY, PROVIDER_RESOURCE_LABEL +from tasks.jobs.attack_paths.config import PROVIDER_RESOURCE_LABEL, get_provider_label + + +def get_cartography_schema_query(provider_id: str) -> str: + """Build the Cartography schema metadata query scoped to a provider label.""" + provider_label = get_provider_label(provider_id) + return f""" + MATCH (n:{PROVIDER_RESOURCE_LABEL}:`{provider_label}`) + WHERE n._module_name STARTS WITH 'cartography:' + AND NOT n._module_name IN ['cartography:ontology', 'cartography:prowler'] + AND n._module_version IS NOT NULL + RETURN n._module_name AS module_name, n._module_version AS module_version + LIMIT 1 + """ -CARTOGRAPHY_SCHEMA_METADATA = f""" - MATCH (n:{PROVIDER_RESOURCE_LABEL} {{{PROVIDER_ID_PROPERTY}: $provider_id}}) - WHERE n._module_name STARTS WITH 'cartography:' - AND NOT n._module_name IN ['cartography:ontology', 'cartography:prowler'] - AND n._module_version IS NOT NULL - RETURN n._module_name AS module_name, n._module_version AS module_version - LIMIT 1 -""" GITHUB_SCHEMA_URL = ( "https://github.com/cartography-cncf/cartography/blob/" diff --git a/api/src/backend/api/attack_paths/views_helpers.py b/api/src/backend/api/attack_paths/views_helpers.py index 8e3ee203b4..201527885e 100644 --- a/api/src/backend/api/attack_paths/views_helpers.py +++ b/api/src/backend/api/attack_paths/views_helpers.py @@ -1,22 +1,26 @@ import logging -import re from typing import Any, Iterable import neo4j + from rest_framework.exceptions import APIException, PermissionDenied, ValidationError from api.attack_paths import database as graph_database, AttackPathsQueryDefinition +from api.attack_paths.cypher_sanitizer import ( + inject_provider_label, + validate_custom_query, +) from api.attack_paths.queries.schema import ( - CARTOGRAPHY_SCHEMA_METADATA, GITHUB_SCHEMA_URL, RAW_SCHEMA_URL, + get_cartography_schema_query, ) from config.custom_logging import BackendLogger from tasks.jobs.attack_paths.config import ( INTERNAL_LABELS, INTERNAL_PROPERTIES, - PROVIDER_ID_PROPERTY, + get_provider_label, is_dynamic_isolation_label, ) @@ -72,7 +76,6 @@ def prepare_parameters( clean_parameters = { "provider_uid": str(provider_uid), - "provider_id": str(provider_id), } for definition_parameter in definition.parameters: @@ -123,38 +126,6 @@ def execute_query( # Custom query helpers -# Patterns that indicate SSRF or dangerous procedure calls -# Defense-in-depth layer - the primary control is `neo4j.READ_ACCESS` -_BLOCKED_PATTERNS = [ - re.compile(r"\bLOAD\s+CSV\b", re.IGNORECASE), - re.compile(r"\bapoc\.load\b", re.IGNORECASE), - re.compile(r"\bapoc\.import\b", re.IGNORECASE), - re.compile(r"\bapoc\.export\b", re.IGNORECASE), - re.compile(r"\bapoc\.cypher\b", re.IGNORECASE), - re.compile(r"\bapoc\.systemdb\b", re.IGNORECASE), - re.compile(r"\bapoc\.config\b", re.IGNORECASE), - re.compile(r"\bapoc\.periodic\b", re.IGNORECASE), - re.compile(r"\bapoc\.do\b", re.IGNORECASE), - re.compile(r"\bapoc\.trigger\b", re.IGNORECASE), - re.compile(r"\bapoc\.custom\b", re.IGNORECASE), -] - -# Strip string literals so patterns inside quotes don't cause false positives -# Handles escaped quotes (\' and \") inside strings -_STRING_LITERALS = re.compile(r"'(?:[^'\\]|\\.)*'|\"(?:[^\"\\]|\\.)*\"") - - -def validate_custom_query(cypher: str) -> None: - """Reject queries containing known SSRF or dangerous procedure patterns. - - Raises ValidationError if a blocked pattern is found. - String literals are stripped before matching to avoid false positives. - """ - stripped = _STRING_LITERALS.sub("", cypher) - for pattern in _BLOCKED_PATTERNS: - if pattern.search(stripped): - raise ValidationError({"query": "Query contains a blocked operation"}) - def normalize_custom_query_payload(raw_data): if not isinstance(raw_data, dict): @@ -173,7 +144,15 @@ def execute_custom_query( cypher: str, provider_id: str, ) -> dict[str, Any]: + # Defense-in-depth for custom queries: + # 1. neo4j.READ_ACCESS — prevents mutations at the driver level + # 2. inject_provider_label() — regex-based label injection scopes node patterns + # 3. _serialize_graph() — post-query filter drops nodes without the provider label + # + # Layer 2 is best-effort (regex can't fully parse Cypher); + # layer 3 is the safety net that guarantees provider isolation. validate_custom_query(cypher) + cypher = inject_provider_label(cypher, provider_id) try: graph = graph_database.execute_read_query( @@ -208,10 +187,7 @@ def get_cartography_schema( with graph_database.get_session( database_name, default_access_mode=neo4j.READ_ACCESS ) as session: - result = session.run( - CARTOGRAPHY_SCHEMA_METADATA, - {"provider_id": provider_id}, - ) + result = session.run(get_cartography_schema_query(provider_id)) record = result.single() except graph_database.GraphDatabaseQueryException as exc: logger.error(f"Cartography schema query failed: {exc}") @@ -255,10 +231,12 @@ def _truncate_graph(graph: dict[str, Any]) -> dict[str, Any]: def _serialize_graph(graph, provider_id: str) -> dict[str, Any]: + provider_label = get_provider_label(provider_id) + nodes = [] kept_node_ids = set() for node in graph.nodes: - if node._properties.get(PROVIDER_ID_PROPERTY) != provider_id: + if provider_label not in node.labels: continue kept_node_ids.add(node.element_id) @@ -273,14 +251,11 @@ def _serialize_graph(graph, provider_id: str) -> dict[str, Any]: filtered_count = len(graph.nodes) - len(nodes) if filtered_count > 0: logger.debug( - f"Filtered {filtered_count} nodes without matching provider_id={provider_id}" + f"Filtered {filtered_count} nodes without provider label {provider_label}" ) relationships = [] for relationship in graph.relationships: - if relationship._properties.get(PROVIDER_ID_PROPERTY) != provider_id: - continue - if ( relationship.start_node.element_id not in kept_node_ids or relationship.end_node.element_id not in kept_node_ids diff --git a/api/src/backend/api/tests/test_attack_paths.py b/api/src/backend/api/tests/test_attack_paths.py index 442a9c5dd2..019b6aa1f2 100644 --- a/api/src/backend/api/tests/test_attack_paths.py +++ b/api/src/backend/api/tests/test_attack_paths.py @@ -11,7 +11,7 @@ from api.attack_paths import database as graph_database from api.attack_paths import views_helpers from tasks.jobs.attack_paths.config import ( PROVIDER_ELEMENT_ID_PROPERTY, - PROVIDER_ID_PROPERTY, + get_provider_label, ) @@ -53,7 +53,7 @@ def test_prepare_parameters_includes_provider_and_casts( ) assert result["provider_uid"] == "123456789012" - assert result["provider_id"] == "test-provider-id" + assert "provider_id" not in result assert result["limit"] == 5 @@ -107,12 +107,12 @@ def test_execute_query_serializes_graph( parameters = {"provider_uid": "123"} provider_id = "test-provider-123" + plabel = get_provider_label(provider_id) node = attack_paths_graph_stub_classes.Node( element_id="node-1", - labels=["AWSAccount"], + labels=["AWSAccount", plabel], properties={ "name": "account", - PROVIDER_ID_PROPERTY: provider_id, "complex": { "items": [ attack_paths_graph_stub_classes.NativeValue("value"), @@ -121,15 +121,13 @@ def test_execute_query_serializes_graph( }, }, ) - node_2 = attack_paths_graph_stub_classes.Node( - "node-2", ["RDSInstance"], {PROVIDER_ID_PROPERTY: provider_id} - ) + node_2 = attack_paths_graph_stub_classes.Node("node-2", ["RDSInstance", plabel], {}) relationship = attack_paths_graph_stub_classes.Relationship( element_id="rel-1", rel_type="OWNS", start_node=node, end_node=node_2, - properties={"weight": 1, PROVIDER_ID_PROPERTY: provider_id}, + properties={"weight": 1}, ) graph = SimpleNamespace(nodes=[node, node_2], relationships=[relationship]) @@ -213,29 +211,27 @@ def test_execute_query_raises_permission_denied_on_read_only( ) -def test_serialize_graph_filters_by_provider_id(attack_paths_graph_stub_classes): +def test_serialize_graph_filters_by_provider_label(attack_paths_graph_stub_classes): provider_id = "provider-keep" + plabel = get_provider_label(provider_id) + other_label = get_provider_label("provider-other") - node_keep = attack_paths_graph_stub_classes.Node( - "n1", ["AWSAccount"], {PROVIDER_ID_PROPERTY: provider_id} - ) + node_keep = attack_paths_graph_stub_classes.Node("n1", ["AWSAccount", plabel], {}) node_drop = attack_paths_graph_stub_classes.Node( - "n2", ["AWSAccount"], {PROVIDER_ID_PROPERTY: "provider-other"} + "n2", ["AWSAccount", other_label], {} ) rel_keep = attack_paths_graph_stub_classes.Relationship( - "r1", "OWNS", node_keep, node_keep, {PROVIDER_ID_PROPERTY: provider_id} - ) - rel_drop_by_provider = attack_paths_graph_stub_classes.Relationship( - "r2", "OWNS", node_keep, node_drop, {PROVIDER_ID_PROPERTY: "provider-other"} + "r1", "OWNS", node_keep, node_keep, {} ) + # Relationship connecting a kept node to a dropped node — filtered by endpoint check rel_drop_orphaned = attack_paths_graph_stub_classes.Relationship( - "r3", "OWNS", node_keep, node_drop, {PROVIDER_ID_PROPERTY: provider_id} + "r2", "OWNS", node_keep, node_drop, {} ) graph = SimpleNamespace( nodes=[node_keep, node_drop], - relationships=[rel_keep, rel_drop_by_provider, rel_drop_orphaned], + relationships=[rel_keep, rel_drop_orphaned], ) result = views_helpers._serialize_graph(graph, provider_id) @@ -354,7 +350,6 @@ def test_serialize_properties_filters_internal_fields(): "_module_name": "cartography:aws", "_module_version": "0.98.0", # Provider isolation - PROVIDER_ID_PROPERTY: "42", PROVIDER_ELEMENT_ID_PROPERTY: "42:abc123", } @@ -449,14 +444,11 @@ def test_execute_custom_query_serializes_graph( attack_paths_graph_stub_classes, ): provider_id = "test-provider-123" - node_1 = attack_paths_graph_stub_classes.Node( - "node-1", ["AWSAccount"], {PROVIDER_ID_PROPERTY: provider_id} - ) - node_2 = attack_paths_graph_stub_classes.Node( - "node-2", ["RDSInstance"], {PROVIDER_ID_PROPERTY: provider_id} - ) + plabel = get_provider_label(provider_id) + node_1 = attack_paths_graph_stub_classes.Node("node-1", ["AWSAccount", plabel], {}) + node_2 = attack_paths_graph_stub_classes.Node("node-2", ["RDSInstance", plabel], {}) relationship = attack_paths_graph_stub_classes.Relationship( - "rel-1", "OWNS", node_1, node_2, {PROVIDER_ID_PROPERTY: provider_id} + "rel-1", "OWNS", node_1, node_2, {} ) graph_result = MagicMock() @@ -471,10 +463,11 @@ def test_execute_custom_query_serializes_graph( "db-tenant-test", "MATCH (n) RETURN n", provider_id ) - mock_execute.assert_called_once_with( - database="db-tenant-test", - cypher="MATCH (n) RETURN n", - ) + mock_execute.assert_called_once() + call_kwargs = mock_execute.call_args[1] + assert call_kwargs["database"] == "db-tenant-test" + # The cypher is rewritten with the provider label injection + assert plabel in call_kwargs["cypher"] assert len(result["nodes"]) == 2 assert result["relationships"][0]["label"] == "OWNS" assert result["truncated"] is False @@ -511,72 +504,6 @@ def test_execute_custom_query_wraps_graph_errors(): mock_logger.error.assert_called_once() -# -- validate_custom_query ------------------------------------------------ - - -@pytest.mark.parametrize( - "cypher", - [ - "LOAD CSV FROM 'http://169.254.169.254/' AS x RETURN x", - "load csv from 'http://evil.com' as row return row", - "CALL apoc.load.json('http://evil.com/') YIELD value RETURN value", - "CALL apoc.load.csvParams('http://evil.com/', {}, null) YIELD list RETURN list", - "CALL apoc.import.csv([{fileName: 'f'}], [], {}) YIELD node RETURN node", - "CALL apoc.export.csv.all('file.csv', {})", - "CALL apoc.cypher.run('CREATE (n)', {}) YIELD value RETURN value", - "CALL apoc.systemdb.graph() YIELD nodes RETURN nodes", - "CALL apoc.config.list() YIELD key, value RETURN key, value", - "CALL apoc.periodic.iterate('MATCH (n) RETURN n', 'DELETE n', {batchSize: 100})", - "CALL apoc.do.when(true, 'CREATE (n) RETURN n', '', {}) YIELD value RETURN value", - "CALL apoc.trigger.add('t', 'RETURN 1', {phase: 'before'})", - "CALL apoc.custom.asProcedure('myProc', 'RETURN 1')", - ], - ids=[ - "LOAD_CSV", - "LOAD_CSV_lowercase", - "apoc.load.json", - "apoc.load.csvParams", - "apoc.import.csv", - "apoc.export.csv", - "apoc.cypher.run", - "apoc.systemdb.graph", - "apoc.config.list", - "apoc.periodic.iterate", - "apoc.do.when", - "apoc.trigger.add", - "apoc.custom.asProcedure", - ], -) -def test_validate_custom_query_rejects_blocked_patterns(cypher): - with pytest.raises(ValidationError) as exc: - views_helpers.validate_custom_query(cypher) - - assert "blocked operation" in str(exc.value.detail) - - -@pytest.mark.parametrize( - "cypher", - [ - "MATCH (n:AWSAccount) RETURN n LIMIT 10", - "MATCH (a)-[r]->(b) RETURN a, r, b", - "MATCH (n) WHERE n.name CONTAINS 'load' RETURN n", - "CALL apoc.create.vNode(['Label'], {}) YIELD node RETURN node", - "MATCH (n) WHERE n.name = 'apoc.load.json' RETURN n", - 'MATCH (n) WHERE n.description = "LOAD CSV is cool" RETURN n', - ], - ids=[ - "simple_match", - "traversal", - "contains_load_substring", - "apoc_virtual_node", - "apoc_load_inside_single_quotes", - "load_csv_inside_double_quotes", - ], -) -def test_validate_custom_query_allows_clean_queries(cypher): - views_helpers.validate_custom_query(cypher) - - # -- _truncate_graph ---------------------------------------------------------- diff --git a/api/src/backend/api/tests/test_cypher_sanitizer.py b/api/src/backend/api/tests/test_cypher_sanitizer.py new file mode 100644 index 0000000000..a54afcd8bb --- /dev/null +++ b/api/src/backend/api/tests/test_cypher_sanitizer.py @@ -0,0 +1,429 @@ +"""Unit tests for the Cypher sanitizer (validation + provider-label injection).""" + +from unittest.mock import patch + +import pytest + +from rest_framework.exceptions import ValidationError + +from api.attack_paths.cypher_sanitizer import ( + inject_provider_label, + validate_custom_query, +) + +PROVIDER_ID = "019c41ee-7df3-7dec-a684-d839f95619f8" +LABEL = "_Provider_019c41ee7df37deca684d839f95619f8" + + +def _inject(cypher: str) -> str: + """Shortcut that patches `get_provider_label` to avoid config imports.""" + with patch( + "api.attack_paths.cypher_sanitizer.get_provider_label", return_value=LABEL + ): + return inject_provider_label(cypher, PROVIDER_ID) + + +# --------------------------------------------------------------------------- +# Pass A - Labeled node patterns (all clauses) +# --------------------------------------------------------------------------- + + +class TestLabeledNodes: + def test_single_label(self): + result = _inject("MATCH (n:AWSRole) RETURN n") + assert f"(n:AWSRole:{LABEL})" in result + + def test_label_with_properties(self): + result = _inject("MATCH (n:AWSRole {name: 'admin'}) RETURN n") + assert f"(n:AWSRole:{LABEL} {{name: 'admin'}})" in result + + def test_multiple_labels(self): + result = _inject("MATCH (n:AWSRole:AWSPrincipal) RETURN n") + assert f"(n:AWSRole:AWSPrincipal:{LABEL})" in result + + def test_anonymous_labeled(self): + result = _inject( + "MATCH (:AWSPrincipal {arn: 'ecs-tasks.amazonaws.com'}) RETURN 1" + ) + assert f"(:AWSPrincipal:{LABEL} {{arn: 'ecs-tasks.amazonaws.com'}})" in result + + def test_backtick_label(self): + result = _inject("MATCH (n:`My Label`) RETURN n") + assert f"(n:`My Label`:{LABEL})" in result + + def test_labeled_in_where_clause(self): + """Labeled nodes in WHERE (pattern existence) still get the label.""" + result = _inject( + "MATCH (n:AWSRole) WHERE EXISTS((n)-[:REL]->(:Target)) RETURN n" + ) + assert f"(n:AWSRole:{LABEL})" in result + assert f"(:Target:{LABEL})" in result + + def test_labeled_in_return_clause(self): + """Labeled nodes in RETURN still get the label (they're always node patterns).""" + result = _inject("MATCH (n:AWSRole) RETURN (n:AWSRole)") + assert result.count(f":AWSRole:{LABEL}") == 2 + + def test_labeled_in_optional_match(self): + result = _inject( + "OPTIONAL MATCH (pf:ProwlerFinding {status: 'FAIL'}) RETURN pf" + ) + assert f"(pf:ProwlerFinding:{LABEL} {{status: 'FAIL'}})" in result + + +# --------------------------------------------------------------------------- +# Pass B - Bare node patterns (MATCH/OPTIONAL MATCH only) +# --------------------------------------------------------------------------- + + +class TestBareNodes: + def test_bare_in_match(self): + result = _inject("MATCH (a)-[:HAS_POLICY]->(b) RETURN a, b") + assert f"(a:{LABEL})" in result + assert f"(b:{LABEL})" in result + + def test_bare_with_properties_in_match(self): + result = _inject("MATCH (n {name: 'x'}) RETURN n") + assert f"(n:{LABEL} {{name: 'x'}})" in result + + def test_bare_in_optional_match(self): + result = _inject("OPTIONAL MATCH (n)-[r]-(m) RETURN n") + assert f"(n:{LABEL})" in result + assert f"(m:{LABEL})" in result + + def test_bare_not_injected_in_return(self): + """Bare (identifier) in RETURN could be expression grouping.""" + cypher = "MATCH (n:AWSRole) RETURN (n)" + result = _inject(cypher) + # The labeled (n:AWSRole) gets the label, but the bare (n) in RETURN should not + assert f"(n:AWSRole:{LABEL})" in result + # Count how many times the label appears - should be 1 (from MATCH only) + assert result.count(LABEL) == 1 + + def test_bare_not_injected_in_where(self): + cypher = "MATCH (n:AWSRole) WHERE (n.x > 1) RETURN n" + result = _inject(cypher) + # (n.x > 1) is an expression group, not a node pattern - should be untouched + assert "(n.x > 1)" in result + + def test_bare_not_injected_in_with(self): + cypher = "MATCH (n:AWSRole) WITH (n) RETURN n" + result = _inject(cypher) + assert result.count(LABEL) == 1 + + def test_bare_not_injected_in_unwind(self): + cypher = "UNWIND nodes(path) as n OPTIONAL MATCH (n)-[r]-(m) RETURN n" + result = _inject(cypher) + # (n) and (m) in OPTIONAL MATCH get injected, but nodes(path) in UNWIND does not + assert f"(n:{LABEL})" in result + assert f"(m:{LABEL})" in result + + +# --------------------------------------------------------------------------- +# Function call exclusion +# --------------------------------------------------------------------------- + + +class TestFunctionCallExclusion: + @pytest.mark.parametrize( + "func_call", + [ + "collect(DISTINCT pf)", + "any(x IN stmt.action WHERE toLower(x) = 'iam:*')", + "toLower(action)", + "nodes(path)", + "count(n)", + "apoc.create.vNode(labels)", + "EXISTS(n.prop)", + "size(n.list)", + ], + ) + def test_function_calls_not_injected(self, func_call): + cypher = f"MATCH (n:AWSRole) WHERE {func_call} RETURN n" + result = _inject(cypher) + # The function call should remain unchanged + assert func_call in result + # Only the MATCH labeled node should get the label + assert result.count(LABEL) == 1 + + +# --------------------------------------------------------------------------- +# String and comment protection +# --------------------------------------------------------------------------- + + +class TestProtection: + def test_string_with_fake_node_pattern(self): + cypher = "MATCH (n:AWSRole) WHERE n.name = '(fake:Label)' RETURN n" + result = _inject(cypher) + assert "'(fake:Label)'" in result + assert result.count(LABEL) == 1 + + def test_double_quoted_string(self): + cypher = 'MATCH (n:AWSRole) WHERE n.name = "(fake:Label)" RETURN n' + result = _inject(cypher) + assert '"(fake:Label)"' in result + assert result.count(LABEL) == 1 + + def test_line_comment_with_node_pattern(self): + cypher = "// (n:Fake)\nMATCH (n:AWSRole) RETURN n" + result = _inject(cypher) + assert "// (n:Fake)" in result + assert result.count(LABEL) == 1 + + def test_string_containing_double_slash(self): + """Strings with // inside should be consumed as strings, not comments.""" + cypher = "MATCH (n:AWSRole {url: 'https://example.com'}) RETURN n" + result = _inject(cypher) + assert "'https://example.com'" in result + assert f"(n:AWSRole:{LABEL}" in result + + def test_escaped_quotes_in_string(self): + cypher = r"MATCH (n:AWSRole) WHERE n.name = 'it\'s a test' RETURN n" + result = _inject(cypher) + assert result.count(LABEL) == 1 + + +# --------------------------------------------------------------------------- +# Clause splitting +# --------------------------------------------------------------------------- + + +class TestClauseSplitting: + def test_case_insensitive_keywords(self): + cypher = "match (n:AWSRole) where n.x = 1 return n" + result = _inject(cypher) + assert f"(n:AWSRole:{LABEL})" in result + + def test_optional_match_with_extra_whitespace(self): + cypher = "OPTIONAL MATCH (n:AWSRole) RETURN n" + result = _inject(cypher) + assert f"(n:AWSRole:{LABEL})" in result + + def test_multiple_match_clauses(self): + cypher = ( + "MATCH (a:AWSAccount)--(b:AWSRole) " + "MATCH (b)--(c:AWSPolicy) " + "RETURN a, b, c" + ) + result = _inject(cypher) + assert f"(a:AWSAccount:{LABEL})" in result + assert f"(b:AWSRole:{LABEL})" in result + assert f"(c:AWSPolicy:{LABEL})" in result + # (b) in second MATCH is bare and gets injected + assert result.count(LABEL) == 4 # a, b (labeled), b (bare in 2nd MATCH), c + + +# --------------------------------------------------------------------------- +# Real-world query patterns from aws.py +# --------------------------------------------------------------------------- + + +class TestRealWorldQueries: + def test_basic_resource_query(self): + cypher = ( + "MATCH path = (aws:AWSAccount {id: $provider_uid})--(rds:RDSInstance)\n" + "UNWIND nodes(path) as n\n" + "OPTIONAL MATCH (n)-[pfr]-(pf:ProwlerFinding {status: 'FAIL'})\n" + "RETURN path, collect(DISTINCT pf) as dpf" + ) + result = _inject(cypher) + assert f"(aws:AWSAccount:{LABEL} {{id: $provider_uid}})" in result + assert f"(rds:RDSInstance:{LABEL})" in result + assert f"(n:{LABEL})" in result + assert f"(pf:ProwlerFinding:{LABEL} {{status: 'FAIL'}})" in result + assert "nodes(path)" in result # function call untouched + assert "collect(DISTINCT pf)" in result # function call untouched + + def test_privilege_escalation_query(self): + cypher = ( + "MATCH path_principal = (aws:AWSAccount {id: $uid})" + "--(principal:AWSPrincipal)--(pol:AWSPolicy)\n" + "WHERE pol.effect = 'Allow'\n" + "MATCH (principal)--(cfn_policy:AWSPolicy)" + "--(stmt_cfn:AWSPolicyStatement)\n" + "WHERE any(action IN stmt_cfn.action WHERE toLower(action) = 'iam:passrole')\n" + "MATCH path_target = (aws)--(target_role:AWSRole)" + "-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {arn: 'cloudformation.amazonaws.com'})\n" + "RETURN path_principal, path_target" + ) + result = _inject(cypher) + assert f"(aws:AWSAccount:{LABEL} {{id: $uid}})" in result + assert f"(principal:AWSPrincipal:{LABEL})" in result + assert f"(pol:AWSPolicy:{LABEL})" in result + assert f"(principal:{LABEL})" in result # bare in 2nd MATCH + assert f"(cfn_policy:AWSPolicy:{LABEL})" in result + assert f"(stmt_cfn:AWSPolicyStatement:{LABEL})" in result + assert f"(aws:{LABEL})" in result # bare in 3rd MATCH + assert f"(target_role:AWSRole:{LABEL})" in result + assert ( + f"(:AWSPrincipal:{LABEL} {{arn: 'cloudformation.amazonaws.com'}})" in result + ) + # Function calls in WHERE untouched + assert "any(action IN" in result + assert "toLower(action)" in result + + def test_custom_bare_query(self): + cypher = ( + "MATCH (a)-[:HAS_POLICY]->(b)\n" + "WHERE a.name CONTAINS 'admin'\n" + "RETURN a, b" + ) + result = _inject(cypher) + assert f"(a:{LABEL})" in result + assert f"(b:{LABEL})" in result + assert result.count(LABEL) == 2 + + def test_internet_via_path_connectivity(self): + """Post-refactor pattern: Internet reached via CAN_ACCESS, not standalone.""" + cypher = ( + "MATCH path = (aws:AWSAccount {id: $provider_uid})--(ec2:EC2Instance)\n" + "WHERE ec2.exposed_internet = true\n" + "OPTIONAL MATCH (internet:Internet)-[can_access:CAN_ACCESS]->(ec2)\n" + "RETURN path, internet, can_access" + ) + result = _inject(cypher) + assert f"(aws:AWSAccount:{LABEL}" in result + assert f"(ec2:EC2Instance:{LABEL})" in result + assert f"(internet:Internet:{LABEL})" in result + # ec2 in OPTIONAL MATCH is bare, but already labeled via Pass A won't match it + # because it has no label. It IS bare, so Pass B injects. + assert f"(ec2:{LABEL})" in result + + +# --------------------------------------------------------------------------- +# Edge cases +# --------------------------------------------------------------------------- + + +class TestEdgeCases: + def test_empty_query(self): + assert _inject("") == "" + + def test_no_node_patterns(self): + cypher = "RETURN 1 + 2" + assert _inject(cypher) == cypher + + def test_anonymous_empty_parens_not_injected(self): + """Empty () in MATCH is extremely rare but should not be injected.""" + cypher = "MATCH ()--(m:AWSRole) RETURN m" + result = _inject(cypher) + assert "()" in result # empty parens untouched + assert f"(m:AWSRole:{LABEL})" in result + + def test_fully_anonymous_query_bypasses_injection(self): + """All-anonymous patterns bypass injection entirely. + + MATCH ()--()--() has no labels and no variables, so neither Pass A + (labeled) nor Pass B (bare identifier) can inject the provider label. + This is safe because _serialize_graph() (Layer 3) filters every + returned node by provider label, dropping cross-provider data before + it reaches the user. + """ + cypher = "MATCH ()--()--() RETURN *" + result = _inject(cypher) + assert result == cypher # completely unmodified + assert LABEL not in result + + def test_relationship_patterns_untouched(self): + cypher = "MATCH (a:X)-[r:REL_TYPE {x: 1}]->(b:Y) RETURN a" + result = _inject(cypher) + assert "[r:REL_TYPE {x: 1}]" in result # relationship untouched + assert f"(a:X:{LABEL})" in result + assert f"(b:Y:{LABEL})" in result + + def test_call_subquery(self): + cypher = ( + "CALL {\n" + " MATCH (inner:AWSRole) RETURN inner\n" + "}\n" + "MATCH (outer:AWSAccount) RETURN outer, inner" + ) + result = _inject(cypher) + assert f"(inner:AWSRole:{LABEL})" in result + assert f"(outer:AWSAccount:{LABEL})" in result + + def test_multiple_protected_regions(self): + cypher = ( + "MATCH (n:X {a: 'hello'}) " 'WHERE n.b = "world" ' "// comment\n" "RETURN n" + ) + result = _inject(cypher) + assert "'hello'" in result + assert '"world"' in result + assert "// comment" in result + assert f"(n:X:{LABEL}" in result + + def test_idempotent_on_already_injected(self): + """Running injection twice should add the label twice (not ideal, but predictable).""" + first = _inject("MATCH (n:AWSRole) RETURN n") + second = _inject(first) + # The label appears twice (stacked) + assert second.count(LABEL) == 2 + + +# --------------------------------------------------------------------------- +# Validation +# --------------------------------------------------------------------------- + + +class TestValidation: + @pytest.mark.parametrize( + "cypher", + [ + "LOAD CSV FROM 'http://169.254.169.254/' AS x RETURN x", + "load csv from 'http://evil.com' as row return row", + "CALL apoc.load.json('http://evil.com/') YIELD value RETURN value", + "CALL apoc.load.csvParams('http://evil.com/', {}, null) YIELD list RETURN list", + "CALL apoc.import.csv([{fileName: 'f'}], [], {}) YIELD node RETURN node", + "CALL apoc.export.csv.all('file.csv', {})", + "CALL apoc.cypher.run('CREATE (n)', {}) YIELD value RETURN value", + "CALL apoc.systemdb.graph() YIELD nodes RETURN nodes", + "CALL apoc.config.list() YIELD key, value RETURN key, value", + "CALL apoc.periodic.iterate('MATCH (n) RETURN n', 'DELETE n', {batchSize: 100})", + "CALL apoc.do.when(true, 'CREATE (n) RETURN n', '', {}) YIELD value RETURN value", + "CALL apoc.trigger.add('t', 'RETURN 1', {phase: 'before'})", + "CALL apoc.custom.asProcedure('myProc', 'RETURN 1')", + ], + ids=[ + "LOAD_CSV", + "LOAD_CSV_lowercase", + "apoc.load.json", + "apoc.load.csvParams", + "apoc.import.csv", + "apoc.export.csv", + "apoc.cypher.run", + "apoc.systemdb.graph", + "apoc.config.list", + "apoc.periodic.iterate", + "apoc.do.when", + "apoc.trigger.add", + "apoc.custom.asProcedure", + ], + ) + def test_rejects_blocked_patterns(self, cypher): + with pytest.raises(ValidationError) as exc: + validate_custom_query(cypher) + + assert "blocked operation" in str(exc.value.detail) + + @pytest.mark.parametrize( + "cypher", + [ + "MATCH (n:AWSAccount) RETURN n LIMIT 10", + "MATCH (a)-[r]->(b) RETURN a, r, b", + "MATCH (n) WHERE n.name CONTAINS 'load' RETURN n", + "CALL apoc.create.vNode(['Label'], {}) YIELD node RETURN node", + "MATCH (n) WHERE n.name = 'apoc.load.json' RETURN n", + 'MATCH (n) WHERE n.description = "LOAD CSV is cool" RETURN n', + ], + ids=[ + "simple_match", + "traversal", + "contains_load_substring", + "apoc_virtual_node", + "apoc_load_inside_single_quotes", + "load_csv_inside_double_quotes", + ], + ) + def test_allows_clean_queries(self, cypher): + validate_custom_query(cypher) diff --git a/api/src/backend/tasks/jobs/attack_paths/config.py b/api/src/backend/tasks/jobs/attack_paths/config.py index 315d2ecd24..76dbdc1dc5 100644 --- a/api/src/backend/tasks/jobs/attack_paths/config.py +++ b/api/src/backend/tasks/jobs/attack_paths/config.py @@ -63,11 +63,9 @@ INTERNAL_LABELS: list[str] = [ ] # Provider isolation properties -PROVIDER_ID_PROPERTY = "_provider_id" PROVIDER_ELEMENT_ID_PROPERTY = "_provider_element_id" PROVIDER_ISOLATION_PROPERTIES: list[str] = [ - PROVIDER_ID_PROPERTY, PROVIDER_ELEMENT_ID_PROPERTY, ] diff --git a/api/src/backend/tasks/jobs/attack_paths/findings.py b/api/src/backend/tasks/jobs/attack_paths/findings.py index 9a9f365911..4a01b925d7 100644 --- a/api/src/backend/tasks/jobs/attack_paths/findings.py +++ b/api/src/backend/tasks/jobs/attack_paths/findings.py @@ -22,7 +22,6 @@ from tasks.jobs.attack_paths.config import ( get_provider_resource_label, get_root_node_label, ) -from tasks.jobs.attack_paths.indexes import IndexType, create_indexes from tasks.jobs.attack_paths.queries import ( ADD_RESOURCE_LABEL_TEMPLATE, CLEANUP_FINDINGS_TEMPLATE, @@ -84,11 +83,6 @@ def _to_neo4j_dict(record: dict[str, Any], resource_uid: str) -> dict[str, Any]: # ---------- -def create_findings_indexes(neo4j_session: neo4j.Session) -> None: - """Create indexes for Prowler findings and resource lookups.""" - create_indexes(neo4j_session, IndexType.FINDINGS) - - def analysis( neo4j_session: neo4j.Session, prowler_api_provider: Provider, @@ -196,7 +190,6 @@ def cleanup_findings( ) -> None: """Remove stale findings (classic Cartography behaviour).""" parameters = { - "provider_uid": str(prowler_api_provider.uid), "last_updated": config.update_tag, "batch_size": BATCH_SIZE, } diff --git a/api/src/backend/tasks/jobs/attack_paths/indexes.py b/api/src/backend/tasks/jobs/attack_paths/indexes.py index 1332cf50b9..94855e4082 100644 --- a/api/src/backend/tasks/jobs/attack_paths/indexes.py +++ b/api/src/backend/tasks/jobs/attack_paths/indexes.py @@ -1,5 +1,3 @@ -from enum import Enum - import neo4j from cartography.client.core.tx import run_write_query @@ -9,20 +7,12 @@ from tasks.jobs.attack_paths.config import ( INTERNET_NODE_LABEL, PROWLER_FINDING_LABEL, PROVIDER_ELEMENT_ID_PROPERTY, - PROVIDER_ID_PROPERTY, PROVIDER_RESOURCE_LABEL, ) logger = get_task_logger(__name__) -class IndexType(Enum): - """Types of indexes that can be created.""" - - FINDINGS = "findings" - SYNC = "sync" - - # Indexes for Prowler findings and resource lookups FINDINGS_INDEX_STATEMENTS = [ # Resource indexes for Prowler Finding lookups @@ -30,7 +20,6 @@ FINDINGS_INDEX_STATEMENTS = [ "CREATE INDEX aws_resource_id IF NOT EXISTS FOR (n:_AWSResource) ON (n.id);", # Prowler Finding indexes f"CREATE INDEX prowler_finding_id IF NOT EXISTS FOR (n:{PROWLER_FINDING_LABEL}) ON (n.id);", - f"CREATE INDEX prowler_finding_provider_uid IF NOT EXISTS FOR (n:{PROWLER_FINDING_LABEL}) ON (n.provider_uid);", f"CREATE INDEX prowler_finding_lastupdated IF NOT EXISTS FOR (n:{PROWLER_FINDING_LABEL}) ON (n.lastupdated);", f"CREATE INDEX prowler_finding_status IF NOT EXISTS FOR (n:{PROWLER_FINDING_LABEL}) ON (n.status);", # Internet node index for MERGE lookups @@ -40,30 +29,18 @@ FINDINGS_INDEX_STATEMENTS = [ # Indexes for provider resource sync operations SYNC_INDEX_STATEMENTS = [ f"CREATE INDEX provider_resource_element_id IF NOT EXISTS FOR (n:{PROVIDER_RESOURCE_LABEL}) ON (n.{PROVIDER_ELEMENT_ID_PROPERTY});", - f"CREATE INDEX provider_resource_provider_id IF NOT EXISTS FOR (n:{PROVIDER_RESOURCE_LABEL}) ON (n.{PROVIDER_ID_PROPERTY});", ] -def create_indexes(neo4j_session: neo4j.Session, index_type: IndexType) -> None: - """ - Create indexes for the specified type. - - Args: - `neo4j_session`: The Neo4j session to use - `index_type`: The type of indexes to create (FINDINGS or SYNC) - """ - if index_type == IndexType.FINDINGS: - logger.info("Creating indexes for Prowler Findings node types") - for statement in FINDINGS_INDEX_STATEMENTS: - run_write_query(neo4j_session, statement) - - elif index_type == IndexType.SYNC: - logger.info("Ensuring ProviderResource indexes exist") - for statement in SYNC_INDEX_STATEMENTS: - neo4j_session.run(statement) +def create_findings_indexes(neo4j_session: neo4j.Session) -> None: + """Create indexes for Prowler findings and resource lookups.""" + logger.info("Creating indexes for Prowler Findings node types") + for statement in FINDINGS_INDEX_STATEMENTS: + run_write_query(neo4j_session, statement) -def create_all_indexes(neo4j_session: neo4j.Session) -> None: - """Create all indexes (both findings and sync).""" - create_indexes(neo4j_session, IndexType.FINDINGS) - create_indexes(neo4j_session, IndexType.SYNC) +def create_sync_indexes(neo4j_session: neo4j.Session) -> None: + """Create indexes for provider resource sync operations.""" + logger.info("Ensuring ProviderResource indexes exist") + for statement in SYNC_INDEX_STATEMENTS: + neo4j_session.run(statement) diff --git a/api/src/backend/tasks/jobs/attack_paths/queries.py b/api/src/backend/tasks/jobs/attack_paths/queries.py index a641697789..64bbc428f8 100644 --- a/api/src/backend/tasks/jobs/attack_paths/queries.py +++ b/api/src/backend/tasks/jobs/attack_paths/queries.py @@ -3,7 +3,6 @@ from tasks.jobs.attack_paths.config import ( INTERNET_NODE_LABEL, PROWLER_FINDING_LABEL, PROVIDER_ELEMENT_ID_PROPERTY, - PROVIDER_ID_PROPERTY, PROVIDER_RESOURCE_LABEL, ) @@ -62,7 +61,6 @@ INSERT_FINDING_TEMPLATE = f""" finding.check_title = finding_data.check_title, finding.muted = finding_data.muted, finding.muted_reason = finding_data.muted_reason, - finding.provider_uid = $provider_uid, finding.firstseen = timestamp(), finding.lastupdated = $last_updated, finding._module_name = 'cartography:prowler', @@ -74,7 +72,6 @@ INSERT_FINDING_TEMPLATE = f""" MERGE (resource)-[rel:HAS_FINDING]->(finding) ON CREATE SET - rel.provider_uid = $provider_uid, rel.firstseen = timestamp(), rel.lastupdated = $last_updated, rel._module_name = 'cartography:prowler', @@ -84,7 +81,7 @@ INSERT_FINDING_TEMPLATE = f""" """ CLEANUP_FINDINGS_TEMPLATE = f""" - MATCH (finding:{PROWLER_FINDING_LABEL} {{provider_uid: $provider_uid}}) + MATCH (finding:{PROWLER_FINDING_LABEL}) WHERE finding.lastupdated < $last_updated WITH finding LIMIT $batch_size @@ -155,7 +152,6 @@ NODE_SYNC_TEMPLATE = f""" UNWIND $rows AS row MERGE (n:__NODE_LABELS__ {{{PROVIDER_ELEMENT_ID_PROPERTY}: row.provider_element_id}}) SET n += row.props - SET n.{PROVIDER_ID_PROPERTY} = $provider_id """ RELATIONSHIP_SYNC_TEMPLATE = f""" @@ -164,5 +160,4 @@ RELATIONSHIP_SYNC_TEMPLATE = f""" MATCH (t:{PROVIDER_RESOURCE_LABEL} {{{PROVIDER_ELEMENT_ID_PROPERTY}: row.end_element_id}}) MERGE (s)-[r:__REL_TYPE__ {{{PROVIDER_ELEMENT_ID_PROPERTY}: row.provider_element_id}}]->(t) SET r += row.props - SET r.{PROVIDER_ID_PROPERTY} = $provider_id """ diff --git a/api/src/backend/tasks/jobs/attack_paths/scan.py b/api/src/backend/tasks/jobs/attack_paths/scan.py index 20dba74da8..a53a6a530f 100644 --- a/api/src/backend/tasks/jobs/attack_paths/scan.py +++ b/api/src/backend/tasks/jobs/attack_paths/scan.py @@ -38,12 +38,12 @@ Pipeline steps: Stale findings from previous scans are cleaned up. 7. Sync the temp database into the tenant database: - - Drop the old provider subgraph (matched by _provider_id property). + - Drop the old provider subgraph (matched by dynamic _Provider_{uuid} label). graph_data_ready is set to False for all scans of this provider while the swap happens so the API doesn't serve partial data. - Copy nodes and relationships in batches. Every synced node gets a - _ProviderResource label and _provider_id / _provider_element_id - properties for multi-provider isolation. + _ProviderResource label and dynamic _Tenant_{uuid} / _Provider_{uuid} + isolation labels, plus a _provider_element_id property for MERGE keys. - Set graph_data_ready back to True. 8. Drop the temporary database, mark the AttackPathsScan as COMPLETED. @@ -62,7 +62,7 @@ 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 tasks.jobs.attack_paths import db_utils, findings, internet, sync, utils +from tasks.jobs.attack_paths import db_utils, findings, indexes, internet, sync, utils from tasks.jobs.attack_paths.config import get_cartography_ingestion_function from api.attack_paths import database as graph_database @@ -165,7 +165,7 @@ def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]: ) as tmp_neo4j_session: # Indexes creation cartography_create_indexes.run(tmp_neo4j_session, tmp_cartography_config) - findings.create_findings_indexes(tmp_neo4j_session) + indexes.create_findings_indexes(tmp_neo4j_session) db_utils.update_attack_paths_scan_progress(attack_paths_scan, 2) # The real scan, where iterates over cloud services @@ -221,8 +221,8 @@ def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]: cartography_create_indexes.run( tenant_neo4j_session, tenant_cartography_config ) - findings.create_findings_indexes(tenant_neo4j_session) - sync.create_sync_indexes(tenant_neo4j_session) + indexes.create_findings_indexes(tenant_neo4j_session) + indexes.create_sync_indexes(tenant_neo4j_session) logger.info(f"Deleting existing provider graph in {tenant_database_name}") db_utils.set_provider_graph_data_ready(attack_paths_scan, False) diff --git a/api/src/backend/tasks/jobs/attack_paths/sync.py b/api/src/backend/tasks/jobs/attack_paths/sync.py index 870aba6fa8..24ffa6cf48 100644 --- a/api/src/backend/tasks/jobs/attack_paths/sync.py +++ b/api/src/backend/tasks/jobs/attack_paths/sync.py @@ -10,8 +10,6 @@ from typing import Any import neo4j from celery.utils.log import get_task_logger - -from api.attack_paths import database as graph_database from tasks.jobs.attack_paths.config import ( PROVIDER_ISOLATION_PROPERTIES, PROVIDER_RESOURCE_LABEL, @@ -19,7 +17,6 @@ from tasks.jobs.attack_paths.config import ( get_provider_label, get_tenant_label, ) -from tasks.jobs.attack_paths.indexes import IndexType, create_indexes from tasks.jobs.attack_paths.queries import ( NODE_FETCH_QUERY, NODE_SYNC_TEMPLATE, @@ -28,14 +25,11 @@ from tasks.jobs.attack_paths.queries import ( render_cypher_template, ) +from api.attack_paths import database as graph_database + logger = get_task_logger(__name__) -def create_sync_indexes(neo4j_session) -> None: - """Create indexes for provider resource sync operations.""" - create_indexes(neo4j_session, IndexType.SYNC) - - def sync_graph( source_database: str, target_database: str, @@ -81,8 +75,8 @@ def sync_nodes( """ Sync nodes from source to target database. - Adds `_ProviderResource` label and `_provider_id` property to all nodes. - Also adds dynamic `_Tenant_{id}` and `_Provider_{id}` isolation labels. + Adds `_ProviderResource` label and dynamic `_Tenant_{id}` and `_Provider_{id}` + isolation labels to all nodes. Source and target sessions are opened sequentially per batch to avoid holding two Bolt connections simultaneously for the entire sync duration. @@ -119,13 +113,7 @@ def sync_nodes( query = render_cypher_template( NODE_SYNC_TEMPLATE, {"__NODE_LABELS__": node_labels} ) - target_session.run( - query, - { - "rows": batch, - "provider_id": provider_id, - }, - ) + target_session.run(query, {"rows": batch}) total_synced += batch_count logger.info( @@ -143,7 +131,7 @@ def sync_relationships( """ Sync relationships from source to target database. - Adds `_provider_id` property to all relationships. + Matches source and target nodes by `_provider_element_id` in the tenant database. Source and target sessions are opened sequentially per batch to avoid holding two Bolt connections simultaneously for the entire sync duration. @@ -174,13 +162,7 @@ def sync_relationships( query = render_cypher_template( RELATIONSHIP_SYNC_TEMPLATE, {"__REL_TYPE__": rel_type} ) - target_session.run( - query, - { - "rows": batch, - "provider_id": provider_id, - }, - ) + target_session.run(query, {"rows": batch}) total_synced += batch_count logger.info( 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 3d6f5ff0aa..0eae2224f7 100644 --- a/api/src/backend/tasks/tests/test_attack_paths_scan.py +++ b/api/src/backend/tasks/tests/test_attack_paths_scan.py @@ -6,6 +6,7 @@ from unittest.mock import MagicMock, call, patch import pytest from django_celery_results.models import TaskResult from tasks.jobs.attack_paths import findings as findings_module +from tasks.jobs.attack_paths import indexes as indexes_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.scan import run as attack_paths_run @@ -39,10 +40,10 @@ class TestAttackPathsRun: @patch("tasks.jobs.attack_paths.scan.db_utils.starting_attack_paths_scan") @patch("tasks.jobs.attack_paths.scan.sync.sync_graph") @patch("tasks.jobs.attack_paths.scan.graph_database.drop_subgraph") - @patch("tasks.jobs.attack_paths.scan.sync.create_sync_indexes") + @patch("tasks.jobs.attack_paths.scan.indexes.create_sync_indexes") @patch("tasks.jobs.attack_paths.scan.internet.analysis") @patch("tasks.jobs.attack_paths.scan.findings.analysis") - @patch("tasks.jobs.attack_paths.scan.findings.create_findings_indexes") + @patch("tasks.jobs.attack_paths.scan.indexes.create_findings_indexes") @patch("tasks.jobs.attack_paths.scan.cartography_ontology.run") @patch("tasks.jobs.attack_paths.scan.cartography_analysis.run") @patch("tasks.jobs.attack_paths.scan.cartography_create_indexes.run") @@ -189,7 +190,7 @@ class TestAttackPathsRun: @patch("tasks.jobs.attack_paths.scan.db_utils.starting_attack_paths_scan") @patch("tasks.jobs.attack_paths.scan.findings.analysis") @patch("tasks.jobs.attack_paths.scan.internet.analysis") - @patch("tasks.jobs.attack_paths.scan.findings.create_findings_indexes") + @patch("tasks.jobs.attack_paths.scan.indexes.create_findings_indexes") @patch("tasks.jobs.attack_paths.scan.cartography_analysis.run") @patch("tasks.jobs.attack_paths.scan.cartography_create_indexes.run") @patch("tasks.jobs.attack_paths.scan.graph_database.create_database") @@ -288,7 +289,7 @@ class TestAttackPathsRun: @patch("tasks.jobs.attack_paths.scan.db_utils.starting_attack_paths_scan") @patch("tasks.jobs.attack_paths.scan.findings.analysis") @patch("tasks.jobs.attack_paths.scan.internet.analysis") - @patch("tasks.jobs.attack_paths.scan.findings.create_findings_indexes") + @patch("tasks.jobs.attack_paths.scan.indexes.create_findings_indexes") @patch("tasks.jobs.attack_paths.scan.cartography_analysis.run") @patch("tasks.jobs.attack_paths.scan.cartography_create_indexes.run") @patch("tasks.jobs.attack_paths.scan.graph_database.create_database") @@ -391,7 +392,7 @@ class TestAttackPathsRun: @patch("tasks.jobs.attack_paths.scan.db_utils.starting_attack_paths_scan") @patch("tasks.jobs.attack_paths.scan.findings.analysis") @patch("tasks.jobs.attack_paths.scan.internet.analysis") - @patch("tasks.jobs.attack_paths.scan.findings.create_findings_indexes") + @patch("tasks.jobs.attack_paths.scan.indexes.create_findings_indexes") @patch("tasks.jobs.attack_paths.scan.cartography_analysis.run") @patch("tasks.jobs.attack_paths.scan.cartography_create_indexes.run") @patch("tasks.jobs.attack_paths.scan.graph_database.create_database") @@ -493,10 +494,10 @@ class TestAttackPathsRun: "tasks.jobs.attack_paths.scan.graph_database.drop_subgraph", side_effect=RuntimeError("drop failed"), ) - @patch("tasks.jobs.attack_paths.scan.sync.create_sync_indexes") + @patch("tasks.jobs.attack_paths.scan.indexes.create_sync_indexes") @patch("tasks.jobs.attack_paths.scan.internet.analysis") @patch("tasks.jobs.attack_paths.scan.findings.analysis") - @patch("tasks.jobs.attack_paths.scan.findings.create_findings_indexes") + @patch("tasks.jobs.attack_paths.scan.indexes.create_findings_indexes") @patch("tasks.jobs.attack_paths.scan.cartography_ontology.run") @patch("tasks.jobs.attack_paths.scan.cartography_analysis.run") @patch("tasks.jobs.attack_paths.scan.cartography_create_indexes.run") @@ -606,10 +607,10 @@ class TestAttackPathsRun: side_effect=RuntimeError("sync failed"), ) @patch("tasks.jobs.attack_paths.scan.graph_database.drop_subgraph") - @patch("tasks.jobs.attack_paths.scan.sync.create_sync_indexes") + @patch("tasks.jobs.attack_paths.scan.indexes.create_sync_indexes") @patch("tasks.jobs.attack_paths.scan.internet.analysis") @patch("tasks.jobs.attack_paths.scan.findings.analysis") - @patch("tasks.jobs.attack_paths.scan.findings.create_findings_indexes") + @patch("tasks.jobs.attack_paths.scan.indexes.create_findings_indexes") @patch("tasks.jobs.attack_paths.scan.cartography_ontology.run") @patch("tasks.jobs.attack_paths.scan.cartography_analysis.run") @patch("tasks.jobs.attack_paths.scan.cartography_create_indexes.run") @@ -719,10 +720,10 @@ class TestAttackPathsRun: @patch("tasks.jobs.attack_paths.scan.db_utils.starting_attack_paths_scan") @patch("tasks.jobs.attack_paths.scan.sync.sync_graph") @patch("tasks.jobs.attack_paths.scan.graph_database.drop_subgraph") - @patch("tasks.jobs.attack_paths.scan.sync.create_sync_indexes") + @patch("tasks.jobs.attack_paths.scan.indexes.create_sync_indexes") @patch("tasks.jobs.attack_paths.scan.internet.analysis") @patch("tasks.jobs.attack_paths.scan.findings.analysis") - @patch("tasks.jobs.attack_paths.scan.findings.create_findings_indexes") + @patch("tasks.jobs.attack_paths.scan.indexes.create_findings_indexes") @patch("tasks.jobs.attack_paths.scan.cartography_ontology.run") @patch("tasks.jobs.attack_paths.scan.cartography_analysis.run") @patch("tasks.jobs.attack_paths.scan.cartography_create_indexes.run") @@ -837,10 +838,10 @@ class TestAttackPathsRun: "tasks.jobs.attack_paths.scan.graph_database.drop_subgraph", side_effect=RuntimeError("drop failed"), ) - @patch("tasks.jobs.attack_paths.scan.sync.create_sync_indexes") + @patch("tasks.jobs.attack_paths.scan.indexes.create_sync_indexes") @patch("tasks.jobs.attack_paths.scan.internet.analysis") @patch("tasks.jobs.attack_paths.scan.findings.analysis") - @patch("tasks.jobs.attack_paths.scan.findings.create_findings_indexes") + @patch("tasks.jobs.attack_paths.scan.indexes.create_findings_indexes") @patch("tasks.jobs.attack_paths.scan.cartography_ontology.run") @patch("tasks.jobs.attack_paths.scan.cartography_analysis.run") @patch("tasks.jobs.attack_paths.scan.cartography_create_indexes.run") @@ -1250,7 +1251,7 @@ class TestAttackPathsFindingsHelpers: def test_create_findings_indexes_executes_all_statements(self): mock_session = MagicMock() with patch("tasks.jobs.attack_paths.indexes.run_write_query") as mock_run_write: - findings_module.create_findings_indexes(mock_session) + indexes_module.create_findings_indexes(mock_session) from tasks.jobs.attack_paths.indexes import FINDINGS_INDEX_STATEMENTS @@ -1312,7 +1313,6 @@ class TestAttackPathsFindingsHelpers: assert mock_session.run.call_count == 2 params = mock_session.run.call_args.args[1] - assert params["provider_uid"] == str(provider.uid) assert params["last_updated"] == config.update_tag def test_stream_findings_with_resources_returns_latest_scan_data( diff --git a/skills/prowler-attack-paths-query/SKILL.md b/skills/prowler-attack-paths-query/SKILL.md index dcbb2d406b..fb25d4fa43 100644 --- a/skills/prowler-attack-paths-query/SKILL.md +++ b/skills/prowler-attack-paths-query/SKILL.md @@ -1,13 +1,14 @@ --- name: prowler-attack-paths-query description: > - Creates Prowler Attack Paths openCypher queries for graph analysis (compatible with Neo4j and Neptune). - Trigger: When creating or updating Attack Paths queries that detect privilege escalation paths, - network exposure, or security misconfigurations in cloud environments. + Creates Prowler Attack Paths openCypher queries using the Cartography schema as the source of truth + for node labels, properties, and relationships. Also covers Prowler-specific additions (Internet node, + ProwlerFinding, internal isolation labels) and $provider_uid scoping for predefined queries. + Trigger: When creating or updating Attack Paths queries. license: Apache-2.0 metadata: author: prowler-cloud - version: "1.1" + version: "2.0" scope: [root, api] auto_invoke: - "Creating Attack Paths queries" @@ -20,7 +21,24 @@ allowed-tools: Read, Edit, Write, Glob, Grep, Bash, WebFetch, Task Attack Paths queries are openCypher queries that analyze cloud infrastructure graphs (ingested via Cartography) to detect security risks like privilege escalation paths, network exposure, and misconfigurations. -Queries are written in **openCypher Version 9** to ensure compatibility with both Neo4j and Amazon Neptune. +Queries are written in **openCypher Version 9** for compatibility with both Neo4j and Amazon Neptune. + +--- + +## Two query audiences + +This skill covers two types of queries with different isolation mechanisms: + +| | Predefined queries | Custom queries | +|---|---|---| +| **Where they live** | `api/src/backend/api/attack_paths/queries/{provider}.py` | User/LLM-supplied via the custom query API endpoint | +| **Provider isolation** | `AWSAccount {id: $provider_uid}` anchor + path connectivity | Automatic `_Provider_{uuid}` label injection via `cypher_sanitizer.py` | +| **What to write** | Chain every MATCH from the `aws` variable | Plain Cypher, no isolation boilerplate needed | +| **Internal labels** | Never use (`_ProviderResource`, `_Tenant_*`, `_Provider_*`) | Never use (injected automatically by the system) | + +**For predefined queries**: every node must be reachable from the `AWSAccount` root via graph traversal. This is the isolation boundary. + +**For custom queries**: write natural Cypher without isolation concerns. The query runner injects a `_Provider_{uuid}` label into every node pattern before execution, and a post-query filter catches edge cases. --- @@ -29,67 +47,44 @@ Queries are written in **openCypher Version 9** to ensure compatibility with bot Queries can be created from: 1. **pathfinding.cloud ID** (e.g., `ECS-001`, `GLUE-001`) - - The JSON index contains: `id`, `name`, `description`, `services`, `permissions`, `exploitationSteps`, `prerequisites`, etc. - Reference: https://github.com/DataDog/pathfinding.cloud - - **Fetching a single path by ID** - The aggregated `paths.json` is too large for WebFetch - (content gets truncated). Use Bash with `curl` and a JSON parser instead: - - Prefer `jq` (concise), fall back to `python3` (guaranteed in this Python project): + - The aggregated `paths.json` is too large for WebFetch. Use Bash: ```bash - # With jq + # Fetch a single path by ID curl -s https://raw.githubusercontent.com/DataDog/pathfinding.cloud/main/docs/paths.json \ | jq '.[] | select(.id == "ecs-002")' - # With python3 (fallback) - curl -s https://raw.githubusercontent.com/DataDog/pathfinding.cloud/main/docs/paths.json \ - | python3 -c "import json,sys; print(json.dumps(next((p for p in json.load(sys.stdin) if p['id']=='ecs-002'), None), indent=2))" - ``` - -2. **Listing Available Attack Paths** - - Use Bash to list available paths from the JSON index: - - ```bash - # List all path IDs and names (jq) + # List all path IDs and names curl -s https://raw.githubusercontent.com/DataDog/pathfinding.cloud/main/docs/paths.json \ | jq -r '.[] | "\(.id): \(.name)"' - # List all path IDs and names (python3 fallback) - curl -s https://raw.githubusercontent.com/DataDog/pathfinding.cloud/main/docs/paths.json \ - | python3 -c "import json,sys; [print(f\"{p['id']}: {p['name']}\") for p in json.load(sys.stdin)]" - - # List paths filtered by service prefix + # Filter by service prefix curl -s https://raw.githubusercontent.com/DataDog/pathfinding.cloud/main/docs/paths.json \ | jq -r '.[] | select(.id | startswith("ecs")) | "\(.id): \(.name)"' ``` -3. **Natural Language Description** - - User describes the Attack Paths in plain language - - Agent maps to appropriate openCypher patterns + If `jq` is not available, use `python3 -c "import json,sys; ..."` as a fallback. + +2. **Natural language description** from the user --- ## Query Structure -### File Location +### Provider scoping parameter -``` -api/src/backend/api/attack_paths/queries/{provider}.py -``` +One parameter is injected automatically by the query runner: -Example: `api/src/backend/api/attack_paths/queries/aws.py` +| Parameter | Property it matches | Used on | Purpose | +| --------------- | ------------------- | ------------ | -------------------------------- | +| `$provider_uid` | `id` | `AWSAccount` | Scopes to a specific AWS account | -### Query parameters for provider scoping +All other nodes are isolated by path connectivity from the `AWSAccount` anchor. -Two parameters exist. Both are injected automatically by the query runner. +### Imports -| Parameter | Property it matches | Used on | Purpose | -| --------------- | ------------------- | -------------- | ------------------------------------ | -| `$provider_uid` | `id` | `AWSAccount` | Scopes to a specific AWS account | -| `$provider_id` | `_provider_id` | Any other node | Scopes nodes to the provider context | - -### Privilege Escalation Query Pattern +All query files start with these imports: ```python from api.attack_paths.queries.types import ( @@ -97,56 +92,92 @@ from api.attack_paths.queries.types import ( AttackPathsQueryDefinition, AttackPathsQueryParameterDefinition, ) +from tasks.jobs.attack_paths.config import PROWLER_FINDING_LABEL +``` -# {REFERENCE_ID} (e.g., EC2-001, GLUE-001) +The `PROWLER_FINDING_LABEL` constant (value: `"ProwlerFinding"`) is used via f-string interpolation in all queries. Never hardcode the label string. + +### Privilege escalation sub-patterns + +There are four distinct privilege escalation patterns. Choose based on the attack type: + +| Sub-pattern | Target | `path_target` shape | Example | +|---|---|---|---| +| Self-escalation | Principal's own policies | `(aws)--(target_policy:AWSPolicy)--(principal)` | IAM-001 | +| Lateral to user | Other IAM users | `(aws)--(target_user:AWSUser)` | IAM-002 | +| Assume-role lateral | Assumable roles | `(aws)--(target_role:AWSRole)<-[:STS_ASSUMEROLE_ALLOW]-(principal)` | IAM-014 | +| PassRole + service | Service-trusting roles | `(aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(...)` | EC2-001 | + +#### Self-escalation (e.g., IAM-001) + +The principal modifies resources attached to itself. `path_target` loops back to `principal`: + +```python AWS_{QUERY_NAME} = AttackPathsQueryDefinition( id="aws-{kebab-case-name}", name="{Human-friendly label} ({REFERENCE_ID})", - short_description="{Brief explanation of the attack, no technical permissions.}", + short_description="{Brief explanation, no technical permissions.}", description="{Detailed description of the attack vector and impact.}", attribution=AttackPathsQueryAttribution( - text="pathfinding.cloud - {REFERENCE_ID} - {permission1} + {permission2}", + text="pathfinding.cloud - {REFERENCE_ID} - {permission}", link="https://pathfinding.cloud/paths/{reference_id_lowercase}", ), provider="aws", cypher=f""" - // Find principals with {permission1} + // Find principals with {permission} MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) WHERE stmt.effect = 'Allow' AND any(action IN stmt.action WHERE - toLower(action) = '{permission1_lowercase}' + toLower(action) = '{permission_lowercase}' OR toLower(action) = '{service}:*' OR action = '*' ) - // Find {permission2} - MATCH (principal)--(policy2:AWSPolicy)--(stmt2:AWSPolicyStatement) - WHERE stmt2.effect = 'Allow' - AND any(action IN stmt2.action WHERE - toLower(action) = '{permission2_lowercase}' - OR toLower(action) = '{service2}:*' - OR action = '*' + // Find target resources attached to the same principal + MATCH path_target = (aws)--(target_policy:AWSPolicy)--(principal) + WHERE target_policy.arn CONTAINS $provider_uid + AND any(resource IN stmt.resource WHERE + resource = '*' + OR target_policy.arn CONTAINS resource ) - // Find target resources (MUST chain from `aws` for provider isolation) - MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: '{service}.amazonaws.com'}}) - WHERE any(resource IN stmt.resource WHERE - resource = '*' - OR target_role.arn CONTAINS resource - OR resource CONTAINS target_role.name - ) + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n - UNWIND nodes(path_principal) + nodes(path_target) as n - OPTIONAL MATCH (n)-[pfr]-(pf:ProwlerFinding {{status: 'FAIL', provider_uid: $provider_uid}}) + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) - RETURN path_principal, path_target, - collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, parameters=[], ) ``` -### Network Exposure Query Pattern +#### Other sub-pattern `path_target` shapes + +The other 3 sub-patterns share the same `path_principal`, deduplication tail, and RETURN as self-escalation. Only the `path_target` MATCH differs: + +```cypher +// Lateral to user (e.g., IAM-002) - targets other IAM users +MATCH path_target = (aws)--(target_user:AWSUser) +WHERE any(resource IN stmt.resource WHERE resource = '*' OR target_user.arn CONTAINS resource OR resource CONTAINS target_user.name) + +// Assume-role lateral (e.g., IAM-014) - targets roles the principal can assume +MATCH path_target = (aws)--(target_role:AWSRole)<-[:STS_ASSUMEROLE_ALLOW]-(principal) +WHERE any(resource IN stmt.resource WHERE resource = '*' OR target_role.arn CONTAINS resource OR resource CONTAINS target_role.name) + +// PassRole + service (e.g., EC2-001) - targets roles trusting a service +MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {arn: '{service}.amazonaws.com'}) +WHERE any(resource IN stmt.resource WHERE resource = '*' OR target_role.arn CONTAINS resource OR resource CONTAINS target_role.name) +``` + +**Multi-permission**: PassRole queries require a second permission. Add `MATCH (principal)--(policy2:AWSPolicy)--(stmt2:AWSPolicyStatement)` with its own WHERE before `path_target`, then check BOTH `stmt.resource` AND `stmt2.resource` against the target. See IAM-015 or EC2-001 in `aws.py` for examples. + +### Network exposure pattern + +The Internet node is reached via `CAN_ACCESS` through the already-scoped resource, not via a standalone lookup: ```python AWS_{QUERY_NAME} = AttackPathsQueryDefinition( @@ -156,27 +187,29 @@ AWS_{QUERY_NAME} = AttackPathsQueryDefinition( description="{Detailed description.}", provider="aws", cypher=f""" - // Match the Internet sentinel node - OPTIONAL MATCH (internet:Internet {{_provider_id: $provider_id}}) - // Match exposed resources (MUST chain from `aws`) MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(resource:EC2Instance) WHERE resource.exposed_internet = true - // Link Internet to resource - OPTIONAL MATCH (internet)-[can_access:CAN_ACCESS]->(resource) + // Internet node reached via path connectivity through the resource + OPTIONAL MATCH (internet:Internet)-[can_access:CAN_ACCESS]->(resource) - UNWIND nodes(path) as n - OPTIONAL MATCH (n)-[pfr]-(pf:ProwlerFinding {{status: 'FAIL', provider_uid: $provider_uid}}) + WITH collect(path) AS paths, head(collect(internet)) AS internet, collect(can_access) AS can_access + UNWIND paths AS p + UNWIND nodes(p) AS n - RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, + WITH paths, internet, can_access, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access """, parameters=[], ) ``` -### Register in Query List +### Register in query list Add to the `{PROVIDER}_QUERIES` list at the bottom of the file: @@ -189,11 +222,11 @@ AWS_QUERIES: list[AttackPathsQueryDefinition] = [ --- -## Step-by-Step Creation Process +## Step-by-step creation process -### 1. Read the Queries Module +### 1. Read the queries module -**FIRST**, read all files in the queries module to understand the structure: +**FIRST**, read all files in the queries module to understand the structure, type definitions, registration, and existing style: ``` api/src/backend/api/attack_paths/queries/ @@ -203,94 +236,50 @@ api/src/backend/api/attack_paths/queries/ └── {provider}.py # Provider-specific queries (e.g., aws.py) ``` -Read these files to learn: +**DO NOT** use generic templates. Match the exact style of existing queries in the file. -- Type definitions and available fields -- How queries are registered -- Current query patterns, style, and naming conventions +### 2. Fetch and consult the Cartography schema -### 2. Determine Schema Source +**This is the most important step.** Every node label, property, and relationship in the query must exist in the Cartography schema for the pinned version. Do not guess or rely on memory. -Check the Cartography dependency in `api/pyproject.toml`: +Check `api/pyproject.toml` for the Cartography dependency, then fetch the schema: ```bash grep cartography api/pyproject.toml ``` -Parse the dependency to determine the schema source: - -**If git-based dependency** (e.g., `cartography @ git+https://github.com/prowler-cloud/cartography@0.126.1`): - -- Extract the repository (e.g., `prowler-cloud/cartography`) -- Extract the version/tag (e.g., `0.126.1`) -- Fetch schema from that repository at that tag - -**If PyPI dependency** (e.g., `cartography = "^0.126.0"` or `cartography>=0.126.0`): - -- Extract the version (e.g., `0.126.0`) -- Use the official `cartography-cncf` repository - -**Schema URL patterns** (ALWAYS use the specific version tag, not master/main): +Build the schema URL (ALWAYS use the specific tag, not master/main): ``` -# Official Cartography (cartography-cncf) -https://raw.githubusercontent.com/cartography-cncf/cartography/refs/tags/{version}/docs/root/modules/{provider}/schema.md +# Git dependency (prowler-cloud/cartography@0.126.1): +https://raw.githubusercontent.com/prowler-cloud/cartography/refs/tags/0.126.1/docs/root/modules/{provider}/schema.md -# Prowler fork (prowler-cloud) -https://raw.githubusercontent.com/prowler-cloud/cartography/refs/tags/{version}/docs/root/modules/{provider}/schema.md +# PyPI dependency (cartography = "^0.126.0"): +https://raw.githubusercontent.com/cartography-cncf/cartography/refs/tags/0.126.0/docs/root/modules/{provider}/schema.md ``` -**Examples**: +Read the schema to discover available node labels, properties, and relationships for the target resources. Internal labels (`_ProviderResource`, `_AWSResource`, `_Tenant_*`, `_Provider_*`) exist for isolation but should never appear in queries. -```bash -# For prowler-cloud/cartography@0.126.1 (git), fetch AWS schema: -https://raw.githubusercontent.com/prowler-cloud/cartography/refs/tags/0.126.1/docs/root/modules/aws/schema.md - -# For cartography = "^0.126.0" (PyPI), fetch AWS schema: -https://raw.githubusercontent.com/cartography-cncf/cartography/refs/tags/0.126.0/docs/root/modules/aws/schema.md -``` - -**IMPORTANT**: Always match the schema version to the dependency version in `pyproject.toml`. Using master/main may reference node labels or properties that don't exist in the deployed version. - -**Additional Prowler Labels**: The Attack Paths sync task adds labels that queries can reference: - -- `ProwlerFinding` - Prowler finding nodes with `status`, `provider_uid` properties -- `Internet` - Internet sentinel node with `_provider_id` property (used in network exposure queries) - -Other internal labels (`_ProviderResource`, `_AWSResource`, `_Tenant_*`, `_Provider_*`) exist for isolation but should never be used in queries. - -These are defined in `api/src/backend/tasks/jobs/attack_paths/config.py`. - -### 3. Consult the Schema for Available Data - -Use the Cartography schema to discover: - -- What node labels exist for the target resources -- What properties are available on those nodes -- What relationships connect the nodes - -This informs query design by showing what data is actually available to query. - -### 4. Create Query Definition +### 4. Create query definition Use the appropriate pattern (privilege escalation or network exposure) with: -- **id**: Auto-generated as `{provider}-{kebab-case-description}` -- **name**: Short, human-friendly label. No raw IAM permissions. For sourced queries (e.g., pathfinding.cloud), append the reference ID in parentheses: `"EC2 Instance Launch with Privileged Role (EC2-001)"`. If the name already has parentheses, prepend the ID inside them: `"ECS Service Creation with Privileged Role (ECS-003 - Existing Cluster)"`. -- **short_description**: Brief explanation of the attack, no technical permissions. E.g., "Launch EC2 instances with privileged IAM roles to gain their permissions via IMDS." -- **description**: Full technical explanation of the attack vector and impact. Plain text only, no HTML or technical permissions here. +- **id**: `{provider}-{kebab-case-description}` +- **name**: Short, human-friendly label. For sourced queries, append the reference ID: `"EC2 Instance Launch with Privileged Role (EC2-001)"`. +- **short_description**: Brief explanation, no technical permissions. +- **description**: Full technical explanation. Plain text only. - **provider**: Provider identifier (aws, azure, gcp, kubernetes, github) - **cypher**: The openCypher query with proper escaping -- **parameters**: Optional list of user-provided parameters (use `parameters=[]` if none needed) -- **attribution**: Optional `AttackPathsQueryAttribution(text, link)` for sourced queries. The `text` includes the source, reference ID, and technical permissions (e.g., `"pathfinding.cloud - EC2-001 - iam:PassRole + ec2:RunInstances"`). The `link` is the URL with a lowercase ID (e.g., `"https://pathfinding.cloud/paths/ec2-001"`). Omit (defaults to `None`) for non-sourced queries. +- **parameters**: Optional list of user-provided parameters (`parameters=[]` if none) +- **attribution**: Optional `AttackPathsQueryAttribution(text, link)` for sourced queries. The `text` includes source, reference ID, and permissions. The `link` uses a lowercase ID. Omit for non-sourced queries. -### 5. Add Query to Provider List +### 5. Add query to provider list Add the constant to the `{PROVIDER}_QUERIES` list. --- -## Query Naming Conventions +## Query naming conventions ### Query ID @@ -298,27 +287,19 @@ Add the constant to the `{PROVIDER}_QUERIES` list. {provider}-{category}-{description} ``` -Examples: +Examples: `aws-ec2-privesc-passrole-iam`, `aws-ec2-instances-internet-exposed` -- `aws-ec2-privesc-passrole-iam` -- `aws-iam-privesc-attach-role-policy-assume-role` -- `aws-ec2-instances-internet-exposed` - -### Query Constant Name +### Query constant name ``` {PROVIDER}_{CATEGORY}_{DESCRIPTION} ``` -Examples: - -- `AWS_EC2_PRIVESC_PASSROLE_IAM` -- `AWS_IAM_PRIVESC_ATTACH_ROLE_POLICY_ASSUME_ROLE` -- `AWS_EC2_INSTANCES_INTERNET_EXPOSED` +Examples: `AWS_EC2_PRIVESC_PASSROLE_IAM`, `AWS_EC2_INSTANCES_INTERNET_EXPOSED` --- -## Query Categories +## Query categories | Category | Description | Example | | -------------------- | ------------------------------ | ------------------------- | @@ -329,15 +310,15 @@ Examples: --- -## Common openCypher Patterns +## Common openCypher patterns -### Match Account and Principal +### Match account and principal ```cypher MATCH path_principal = (aws:AWSAccount {id: $provider_uid})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) ``` -### Check IAM Action Permissions +### Check IAM action permissions ```cypher WHERE stmt.effect = 'Allow' @@ -348,13 +329,21 @@ WHERE stmt.effect = 'Allow' ) ``` -### Find Roles Trusting a Service +### Find roles trusting a service ```cypher MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {arn: 'ec2.amazonaws.com'}) ``` -### Check Resource Scope +### Find roles the principal can assume + +Note the arrow direction - `STS_ASSUMEROLE_ALLOW` points from the role to the principal: + +```cypher +MATCH path_target = (aws)--(target_role:AWSRole)<-[:STS_ASSUMEROLE_ALLOW]-(principal) +``` + +### Check resource scope ```cypher WHERE any(resource IN stmt.resource WHERE @@ -364,26 +353,16 @@ WHERE any(resource IN stmt.resource WHERE ) ``` -### Match Internet Sentinel Node +### Internet node via path connectivity -Used in network exposure queries. The Internet node is a real graph node, scoped by `_provider_id`: +The Internet node is reached through `CAN_ACCESS` relationships to already-scoped resources. No standalone lookup needed: ```cypher -OPTIONAL MATCH (internet:Internet {_provider_id: $provider_id}) -``` - -### Link Internet to Exposed Resource - -The `CAN_ACCESS` relationship is a real graph relationship linking the Internet node to exposed resources: - -```cypher -OPTIONAL MATCH (internet)-[can_access:CAN_ACCESS]->(resource) +OPTIONAL MATCH (internet:Internet)-[can_access:CAN_ACCESS]->(resource) ``` ### Multi-label OR (match multiple resource types) -When a query needs to match different resource types in the same position, use label checks in WHERE: - ```cypher MATCH path = (aws:AWSAccount {id: $provider_uid})-[r]-(x)-[q]-(y) WHERE (x:EC2PrivateIp AND x.public_ip = $ip) @@ -392,173 +371,117 @@ WHERE (x:EC2PrivateIp AND x.public_ip = $ip) OR (x:ElasticIPAddress AND x.public_ip = $ip) ``` -### Include Prowler Findings +### Include Prowler findings + +Deduplicate nodes before the ProwlerFinding lookup to avoid redundant OPTIONAL MATCH calls on nodes that appear in multiple paths: ```cypher -UNWIND nodes(path_principal) + nodes(path_target) as n -OPTIONAL MATCH (n)-[pfr]-(pf:ProwlerFinding {status: 'FAIL', provider_uid: $provider_uid}) +WITH collect(path_principal) + collect(path_target) AS paths +UNWIND paths AS p +UNWIND nodes(p) AS n -RETURN path_principal, path_target, - collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr +WITH paths, collect(DISTINCT n) AS unique_nodes +UNWIND unique_nodes AS n +OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + +RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr ``` -For network exposure queries, also return the internet node and relationship: +For network exposure queries, aggregate the internet node and relationship alongside paths: ```cypher -RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, +WITH collect(path) AS paths, head(collect(internet)) AS internet, collect(can_access) AS can_access +UNWIND paths AS p +UNWIND nodes(p) AS n + +WITH paths, internet, can_access, collect(DISTINCT n) AS unique_nodes +UNWIND unique_nodes AS n +OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + +RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access ``` --- -## Common Node Labels by Provider +## Prowler-specific labels and relationships -### AWS +These are added by the sync task, not part of the Cartography schema. For all other node labels, properties, and relationships, **always consult the Cartography schema** (see step 2 below). -| Label | Description | -| --------------------- | --------------------------------------- | -| `AWSAccount` | AWS account root | -| `AWSPrincipal` | IAM principal (user, role, service) | -| `AWSRole` | IAM role | -| `AWSUser` | IAM user | -| `AWSPolicy` | IAM policy | -| `AWSPolicyStatement` | Policy statement | -| `AWSTag` | Resource tag (key/value) | -| `EC2Instance` | EC2 instance | -| `EC2SecurityGroup` | Security group | -| `EC2PrivateIp` | EC2 private IP (has `public_ip`) | -| `IpPermissionInbound` | Inbound security group rule | -| `IpRange` | IP range (e.g., `0.0.0.0/0`) | -| `NetworkInterface` | ENI (has `public_ip`) | -| `ElasticIPAddress` | Elastic IP (has `public_ip`) | -| `S3Bucket` | S3 bucket | -| `RDSInstance` | RDS database instance | -| `LoadBalancer` | Classic ELB | -| `LoadBalancerV2` | ALB/NLB | -| `ELBListener` | Classic ELB listener | -| `ELBV2Listener` | ALB/NLB listener | -| `LaunchTemplate` | EC2 launch template | -| `Internet` | Internet sentinel node (`_provider_id`) | - -### Common Relationships - -| Relationship | Description | -| ---------------------- | ---------------------------------- | -| `TRUSTS_AWS_PRINCIPAL` | Role trust relationship | -| `STS_ASSUMEROLE_ALLOW` | Can assume role | -| `CAN_ACCESS` | Internet-to-resource exposure link | -| `POLICY` | Has policy attached | -| `STATEMENT` | Policy has statement | +| Label/Relationship | Description | +| ---------------------- | -------------------------------------------------- | +| `ProwlerFinding` | Finding node (`status`, `severity`, `check_id`) | +| `Internet` | Internet sentinel node | +| `CAN_ACCESS` | Internet-to-resource exposure (relationship) | +| `HAS_FINDING` | Resource-to-finding link (relationship) | +| `TRUSTS_AWS_PRINCIPAL` | Role trust relationship | +| `STS_ASSUMEROLE_ALLOW` | Can assume role (direction: role -> principal) | --- ## Parameters -For queries requiring user input, define parameters: +For queries requiring user input: ```python parameters=[ AttackPathsQueryParameterDefinition( name="ip", label="IP address", + # data_type defaults to "string", cast defaults to str. + # For non-string params, set both: data_type="integer", cast=int description="Public IP address, e.g. 192.0.2.0.", placeholder="192.0.2.0", ), - AttackPathsQueryParameterDefinition( - name="tag_key", - label="Tag key", - description="Tag key to filter resources.", - placeholder="Environment", - ), ], ``` --- -## Best Practices +## Best practices -1. **Always scope by provider**: Use `{id: $provider_uid}` on `AWSAccount` nodes. Use `{_provider_id: $provider_id}` on any other node that needs provider scoping (e.g., `Internet`). - -2. **Use consistent naming**: Follow existing patterns in the file - -3. **Include Prowler findings**: Always add the OPTIONAL MATCH for ProwlerFinding nodes - -4. **Return distinct findings**: Use `collect(DISTINCT pf)` to avoid duplicates - -5. **Comment the query purpose**: Add inline comments explaining each MATCH clause - -6. **Validate schema first**: Ensure all node labels and properties exist in Cartography schema - -7. **Chain all MATCHes from the root account node**: Every `MATCH` clause must connect to the `aws` variable (or another variable already bound to the account's subgraph). The tenant database contains data from multiple providers — an unanchored `MATCH` would return nodes from all providers, breaking provider isolation. +1. **Chain all MATCHes from the root account node**: Every `MATCH` clause must connect to the `aws` variable (or another variable already bound to the account's subgraph). An unanchored `MATCH` would return nodes from all providers. ```cypher - // WRONG: matches ALL AWSRoles across all providers in the tenant DB + // WRONG: matches ALL AWSRoles across all providers MATCH (role:AWSRole) WHERE role.name = 'admin' // CORRECT: scoped to the specific account's subgraph MATCH (aws)--(role:AWSRole) WHERE role.name = 'admin' ``` - The `Internet` node is an exception: it uses `OPTIONAL MATCH` with `_provider_id` for scoping instead of chaining from `aws`. + **Exception**: A second-permission MATCH like `MATCH (principal)--(policy2:AWSPolicy)--(stmt2:AWSPolicyStatement)` is safe because `principal` is already bound to the account's subgraph by the first MATCH. It does not need to chain from `aws` again. + +2. **Include Prowler findings**: Always add `OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})` with `collect(DISTINCT pf)`. + +3. **Comment the query purpose**: Add inline comments explaining each MATCH clause. + +4. **Never use internal labels in queries**: `_ProviderResource`, `_AWSResource`, `_Tenant_*`, `_Provider_*` are for system isolation. They should never appear in predefined or custom query text. + +6. **Internet node uses path connectivity**: Reach it via `OPTIONAL MATCH (internet:Internet)-[can_access:CAN_ACCESS]->(resource)` where `resource` is already scoped by the account anchor. No standalone lookup. --- -## openCypher Compatibility +## openCypher compatibility -Queries must be written in **openCypher Version 9** to ensure compatibility with both Neo4j and Amazon Neptune. +Queries must be written in **openCypher Version 9** for compatibility with both Neo4j and Amazon Neptune. -> **Why Version 9?** Amazon Neptune implements openCypher Version 9. By targeting this specification, queries work on both Neo4j and Neptune without modification. +### Avoid these (not in openCypher spec) -### Avoid These (Not in openCypher spec) - -| Feature | Reason | Use instead | -| -------------------------- | ----------------------------------------------- | ------------------------------------------------------ | -| APOC procedures (`apoc.*`) | Neo4j-specific plugin, not available in Neptune | Real nodes and relationships in the graph | -| Neptune extensions | Not available in Neo4j | Standard openCypher | -| `reduce()` function | Not in openCypher spec | `UNWIND` + `collect()` | -| `FOREACH` clause | Not in openCypher spec | `WITH` + `UNWIND` + `SET` | -| Regex operator (`=~`) | Not supported in Neptune | `toLower()` + exact match, or `CONTAINS`/`STARTS WITH` | -| `CALL () { UNION }` | Complex, hard to maintain | Multi-label OR in WHERE (see patterns section) | +| Feature | Use instead | +| -------------------------- | ------------------------------------------------------ | +| APOC procedures (`apoc.*`) | Real nodes and relationships in the graph | +| Neptune extensions | Standard openCypher | +| `reduce()` function | `UNWIND` + `collect()` | +| `FOREACH` clause | `WITH` + `UNWIND` + `SET` | +| Regex operator (`=~`) | `toLower()` + exact match, or `CONTAINS`/`STARTS WITH`. One legacy query uses `=~` - do not add new usages | +| `CALL () { UNION }` | Multi-label OR in WHERE (see patterns section) | --- ## Reference -### pathfinding.cloud (Attack Path Definitions) - -- **Repository**: https://github.com/DataDog/pathfinding.cloud -- **All paths JSON**: `https://raw.githubusercontent.com/DataDog/pathfinding.cloud/main/docs/paths.json` -- Always use Bash with `curl | jq` to fetch paths (WebFetch truncates the large JSON) - -### Cartography Schema - -- **URL pattern**: `https://raw.githubusercontent.com/{org}/cartography/refs/tags/{version}/docs/root/modules/{provider}/schema.md` -- Always use the version from `api/pyproject.toml`, not master/main - -### openCypher Specification - -- **Neptune openCypher compliance** (what Neptune supports): https://docs.aws.amazon.com/neptune/latest/userguide/feature-opencypher-compliance.html -- **openCypher project** (spec, grammar, TCK): https://github.com/opencypher/openCypher - ---- - -## Learning from the Queries Module - -**IMPORTANT**: Before creating a new query, ALWAYS read the entire queries module: - -``` -api/src/backend/api/attack_paths/queries/ -├── __init__.py # Module exports -├── types.py # Type definitions -├── registry.py # Registry logic -└── {provider}.py # Provider queries (aws.py, etc.) -``` - -Use the existing queries to learn: - -- Query structure and formatting -- Variable naming conventions -- How to include Prowler findings -- Comment style - -**DO NOT** use generic templates. Match the exact style of existing queries in the file. +- **pathfinding.cloud**: https://github.com/DataDog/pathfinding.cloud (use `curl | jq`, not WebFetch) +- **Cartography schema**: `https://raw.githubusercontent.com/{org}/cartography/refs/tags/{version}/docs/root/modules/{provider}/schema.md` +- **Neptune openCypher compliance**: https://docs.aws.amazon.com/neptune/latest/userguide/feature-opencypher-compliance.html +- **openCypher spec**: https://github.com/opencypher/openCypher