mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-16 16:34:24 +00:00
feat(api): replace _provider_id property with label-based isolation and regex injection for custom queries (#10402)
This commit is contained in:
1 parent
cc197ea901
commit
8bfeee238b
16 files changed
+1006
-649
No files matched your search
+5
-4
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
# (?<!\w)\( - open paren NOT preceded by a word char (excludes function calls).
|
||||
# Group 1: optional variable + one or more :Label
|
||||
# Group 2: optional {properties} + closing paren
|
||||
_LABELED_NODE_RE = re.compile(
|
||||
r"(?<!\w)\("
|
||||
r"("
|
||||
r"\s*(?:[a-zA-Z_]\w*)?"
|
||||
r"(?:\s*:\s*(?:`[^`]*`|[a-zA-Z_]\w*))+"
|
||||
r")"
|
||||
r"("
|
||||
r"\s*(?:\{[^}]*\})?"
|
||||
r"\s*\)"
|
||||
r")"
|
||||
)
|
||||
|
||||
# Pass B - Bare node patterns (MATCH segments only)
|
||||
# Matches (identifier) or (identifier {properties}) without any :Label.
|
||||
# Only applied in MATCH/OPTIONAL MATCH segments.
|
||||
_BARE_NODE_RE = re.compile(
|
||||
r"(?<!\w)\(" r"(\s*[a-zA-Z_]\w*)" r"(\s*(?:\{[^}]*\})?)" r"\s*\)"
|
||||
)
|
||||
|
||||
_MATCH_CLAUSES = frozenset({"MATCH", "OPTIONAL MATCH"})
|
||||
|
||||
|
||||
def _inject_labeled(segment: str, label: str) -> 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"})
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
""",
|
||||
|
||||
@@ -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/"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ----------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
"""
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user