mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-05 03:12:14 +00:00
feat(aws): add pathfinding.cloud privilege-escalation coverage (#12237)
This commit is contained in:
@@ -0,0 +1 @@
|
||||
Attack Paths adds 20 AWS privilege-escalation detection queries from pathfinding.cloud, covering service PassRole escalations (Batch, Braket, Cognito Identity, ECS, EMR, EMR Serverless, GameLift, Glue, EC2 Image Builder, Kinesis Analytics, HealthOmics, EventBridge Scheduler, SSM, Step Functions), CodeDeploy and Step Functions existing-resource abuse, role permissions-boundary removal with role assumption, and IAM Identity Center permission-set policy injection
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,133 @@
|
||||
"""
|
||||
Structural validation for the pathfinding.cloud service privilege-escalation
|
||||
Attack Paths queries added in PROWLER-2279.
|
||||
|
||||
These assert the conventions documented in
|
||||
`docs/developer-guide/attack-paths-queries.mdx`: list-typed policy properties are
|
||||
reached through `HAS_*` child-item traversals (never read as node fields),
|
||||
predicate functions unsupported on Neptune (`any`/`all`/`none`, regex `=~`) are
|
||||
absent, the finding probe is typed and filters only on `status`, and the
|
||||
`RETURN` shape preserves the `paths, dpf, dpfr` contract.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
import pytest
|
||||
from api.attack_paths.queries.aws import AWS_QUERIES
|
||||
from api.attack_paths.queries.types import AttackPathsQueryDefinition
|
||||
|
||||
# IDs of the queries introduced for PROWLER-2279 (pathfinding.cloud coverage).
|
||||
PATHFINDING_2279_QUERY_IDS = [
|
||||
"aws-batch-privesc-passrole-submit-job",
|
||||
"aws-braket-privesc-passrole-create-job",
|
||||
"aws-cognito-privesc-passrole-set-identity-pool-roles",
|
||||
"aws-ecs-privesc-passrole-start-existing-task",
|
||||
"aws-emr-privesc-passrole-run-job-flow",
|
||||
"aws-emrserverless-privesc-passrole-start-job",
|
||||
"aws-gamelift-privesc-passrole-create-fleet",
|
||||
"aws-glue-privesc-passrole-create-session",
|
||||
"aws-imagebuilder-privesc-passrole-create-image",
|
||||
"aws-kinesisanalytics-privesc-passrole-create-application",
|
||||
"aws-omics-privesc-passrole-start-run",
|
||||
"aws-scheduler-privesc-passrole-create-schedule",
|
||||
"aws-ssm-privesc-passrole-automation",
|
||||
"aws-stepfunctions-privesc-passrole-create-state-machine",
|
||||
"aws-batch-privesc-submit-existing-job",
|
||||
"aws-codedeploy-privesc-create-deployment",
|
||||
"aws-stepfunctions-privesc-update-state-machine",
|
||||
"aws-iam-privesc-delete-role-boundary-assume-role",
|
||||
"aws-sso-privesc-attach-managed-policy-permission-set",
|
||||
"aws-sso-privesc-put-inline-policy-permission-set",
|
||||
]
|
||||
|
||||
_BY_ID = {q.id: q for q in AWS_QUERIES}
|
||||
NEW_QUERIES = [_BY_ID[qid] for qid in PATHFINDING_2279_QUERY_IDS if qid in _BY_ID]
|
||||
|
||||
NEPTUNE_UNSUPPORTED_PREDICATES = re.compile(r"\b(any|all|none)\s*\(", re.IGNORECASE)
|
||||
NORMALIZED_STATEMENT_FIELDS = ("action", "resource", "notaction", "notresource")
|
||||
|
||||
|
||||
def test_all_2279_queries_registered():
|
||||
missing = [qid for qid in PATHFINDING_2279_QUERY_IDS if qid not in _BY_ID]
|
||||
assert not missing, f"queries not registered in AWS_QUERIES: {missing}"
|
||||
|
||||
|
||||
class TestServicePrivescQuerySchema:
|
||||
@pytest.mark.parametrize("query", NEW_QUERIES, ids=lambda q: q.id)
|
||||
def test_is_query_definition(self, query):
|
||||
assert isinstance(query, AttackPathsQueryDefinition)
|
||||
|
||||
@pytest.mark.parametrize("query", NEW_QUERIES, ids=lambda q: q.id)
|
||||
def test_id_kebab_and_aws_prefixed(self, query):
|
||||
assert query.id.startswith("aws-")
|
||||
assert re.match(r"^[a-z0-9]+(-[a-z0-9]+)*$", query.id)
|
||||
|
||||
@pytest.mark.parametrize("query", NEW_QUERIES, ids=lambda q: q.id)
|
||||
def test_provider_is_aws(self, query):
|
||||
assert query.provider == "aws"
|
||||
|
||||
@pytest.mark.parametrize("query", NEW_QUERIES, ids=lambda q: q.id)
|
||||
def test_has_metadata(self, query):
|
||||
assert query.name and len(query.name) > 5
|
||||
assert query.short_description and len(query.short_description) > 10
|
||||
assert query.description and len(query.description) > 20
|
||||
assert isinstance(query.parameters, list)
|
||||
|
||||
@pytest.mark.parametrize("query", NEW_QUERIES, ids=lambda q: q.id)
|
||||
def test_attribution_links_pathfinding(self, query):
|
||||
assert query.attribution is not None
|
||||
assert "pathfinding.cloud" in query.attribution.text
|
||||
assert query.attribution.link.startswith("https://pathfinding.cloud/paths/")
|
||||
|
||||
|
||||
class TestServicePrivescQueryCypher:
|
||||
@pytest.mark.parametrize("query", NEW_QUERIES, ids=lambda q: q.id)
|
||||
def test_anchored_and_provider_scoped(self, query):
|
||||
assert "(aws:AWSAccount {id: $provider_uid})" in query.cypher
|
||||
|
||||
@pytest.mark.parametrize("query", NEW_QUERIES, ids=lambda q: q.id)
|
||||
def test_finding_label_interpolated(self, query):
|
||||
assert "PROWLER_FINDING_LABEL" not in query.cypher
|
||||
|
||||
@pytest.mark.parametrize("query", NEW_QUERIES, ids=lambda q: q.id)
|
||||
def test_typed_status_scoped_finding_probe(self, query):
|
||||
assert re.search(
|
||||
r"-\[pfr:HAS_FINDING\]-\(pf:ProwlerFinding \{status: 'FAIL'\}\)",
|
||||
query.cypher,
|
||||
), f"{query.id} lacks the typed, status-scoped finding probe"
|
||||
|
||||
@pytest.mark.parametrize("query", NEW_QUERIES, ids=lambda q: q.id)
|
||||
def test_return_contract(self, query):
|
||||
assert re.search(
|
||||
r"RETURN paths, collect\(DISTINCT pf\) as dpf, "
|
||||
r"collect\(DISTINCT pfr\) as dpfr",
|
||||
query.cypher,
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("query", NEW_QUERIES, ids=lambda q: q.id)
|
||||
def test_no_neptune_unsupported_predicates(self, query):
|
||||
m = NEPTUNE_UNSUPPORTED_PREDICATES.search(query.cypher)
|
||||
assert m is None, f"{query.id} uses '{m.group().strip()}' (not Neptune-safe)"
|
||||
|
||||
@pytest.mark.parametrize("query", NEW_QUERIES, ids=lambda q: q.id)
|
||||
def test_no_regex_operator(self, query):
|
||||
assert "=~" not in query.cypher
|
||||
|
||||
@pytest.mark.parametrize("query", NEW_QUERIES, ids=lambda q: q.id)
|
||||
def test_does_not_read_normalized_list_fields(self, query):
|
||||
for field in NORMALIZED_STATEMENT_FIELDS:
|
||||
assert not re.search(rf"\.{field}\b", query.cypher), (
|
||||
f"{query.id} reads normalized list field '.{field}' as a property; "
|
||||
f"traverse the HAS_{field.upper()} edge instead"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("query", NEW_QUERIES, ids=lambda q: q.id)
|
||||
def test_read_only(self, query):
|
||||
no_comments = "\n".join(
|
||||
line
|
||||
for line in query.cypher.split("\n")
|
||||
if not line.strip().startswith("//")
|
||||
)
|
||||
assert not re.search(
|
||||
r"\b(CREATE|MERGE|SET|DELETE|REMOVE|DETACH)\b", no_comments, re.IGNORECASE
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
The IAM privilege-escalation check now detects 22 additional pathfinding.cloud escalation paths across AWS Batch, Braket, CodeDeploy, Cognito Identity, ECS, EMR, EMR Serverless, GameLift, Glue, EC2 Image Builder, Kinesis Analytics, HealthOmics, EventBridge Scheduler, SSM Automation, Step Functions, IAM permissions boundaries, and IAM Identity Center (SSO)
|
||||
@@ -342,6 +342,122 @@ privilege_escalation_policies_combination = {
|
||||
"bedrock-agentcore:StartBrowserSession",
|
||||
"bedrock-agentcore:ConnectBrowserAutomationStream",
|
||||
},
|
||||
# Batch-based privilege escalation patterns (pathfinding.cloud BATCH-001/002)
|
||||
"PassRole+BatchRegisterJobDef+SubmitJob": {
|
||||
"iam:PassRole",
|
||||
"batch:RegisterJobDefinition",
|
||||
"batch:SubmitJob",
|
||||
},
|
||||
# Prerequisite: Existing Batch job definition with admin role
|
||||
"BatchSubmitJob": {"batch:SubmitJob"},
|
||||
# Braket-based privilege escalation patterns (pathfinding.cloud BRAKET-001)
|
||||
"PassRole+BraketCreateJob": {
|
||||
"iam:PassRole",
|
||||
"braket:CreateJob",
|
||||
},
|
||||
# CodeDeploy-based privilege escalation patterns (pathfinding.cloud CODEDEPLOY-001)
|
||||
# Prerequisite: Existing CodeDeploy application and deployment group with admin role
|
||||
"CodeDeployCreateDeployment": {
|
||||
"codedeploy:CreateDeployment",
|
||||
"codedeploy:RegisterApplicationRevision",
|
||||
"codedeploy:GetDeploymentConfig",
|
||||
},
|
||||
# Cognito Identity-based privilege escalation patterns (pathfinding.cloud COGNITOIDENTITY-001)
|
||||
"PassRole+CognitoSetIdentityPoolRoles": {
|
||||
"iam:PassRole",
|
||||
"cognito-identity:SetIdentityPoolRoles",
|
||||
},
|
||||
# ECS StartTask on an existing cluster (pathfinding.cloud ECS-009)
|
||||
"PassRole+ECSStartTaskExistingCluster": {
|
||||
"iam:PassRole",
|
||||
"ecs:StartTask",
|
||||
},
|
||||
# EMR-based privilege escalation patterns (pathfinding.cloud EMR-001)
|
||||
"PassRole+EMRRunJobFlow": {
|
||||
"iam:PassRole",
|
||||
"elasticmapreduce:RunJobFlow",
|
||||
},
|
||||
# EMR Serverless-based privilege escalation patterns (pathfinding.cloud EMRSERVERLESS-001)
|
||||
"PassRole+EMRServerlessCreateApp+StartJobRun": {
|
||||
"iam:PassRole",
|
||||
"emr-serverless:CreateApplication",
|
||||
"emr-serverless:StartJobRun",
|
||||
},
|
||||
# GameLift-based privilege escalation patterns (pathfinding.cloud GAMELIFT-001)
|
||||
"PassRole+GameLiftCreateBuild+CreateFleet": {
|
||||
"iam:PassRole",
|
||||
"gamelift:CreateBuild",
|
||||
"gamelift:CreateFleet",
|
||||
"gamelift:RequestUploadCredentials",
|
||||
},
|
||||
# Glue interactive session-based privilege escalation patterns (pathfinding.cloud GLUE-007)
|
||||
"PassRole+GlueCreateSession+RunStatement": {
|
||||
"iam:PassRole",
|
||||
"glue:CreateSession",
|
||||
"glue:RunStatement",
|
||||
},
|
||||
# EC2 Image Builder-based privilege escalation patterns (pathfinding.cloud IMAGEBUILDER-001)
|
||||
"PassRole+ImageBuilderCreateComponent+CreateImage": {
|
||||
"iam:PassRole",
|
||||
"imagebuilder:CreateComponent",
|
||||
"imagebuilder:CreateImageRecipe",
|
||||
"imagebuilder:CreateInfrastructureConfiguration",
|
||||
"imagebuilder:CreateImage",
|
||||
},
|
||||
# Kinesis Data Analytics-based privilege escalation patterns (pathfinding.cloud KINESISANALYTICS-001)
|
||||
"PassRole+KinesisAnalyticsCreateApp+StartApp": {
|
||||
"iam:PassRole",
|
||||
"kinesisanalytics:CreateApplication",
|
||||
"kinesisanalytics:StartApplication",
|
||||
},
|
||||
# HealthOmics-based privilege escalation patterns (pathfinding.cloud OMICS-001)
|
||||
"PassRole+OmicsCreateWorkflow+StartRun": {
|
||||
"iam:PassRole",
|
||||
"omics:CreateWorkflow",
|
||||
"omics:StartRun",
|
||||
"s3:GetObject",
|
||||
},
|
||||
# EventBridge Scheduler-based privilege escalation patterns (pathfinding.cloud SCHEDULER-001)
|
||||
"PassRole+SchedulerCreateSchedule": {
|
||||
"iam:PassRole",
|
||||
"scheduler:CreateSchedule",
|
||||
},
|
||||
# SSM Automation document-based privilege escalation patterns (pathfinding.cloud SSM-003)
|
||||
"PassRole+SSMCreateDocument+StartAutomation": {
|
||||
"iam:PassRole",
|
||||
"ssm:CreateDocument",
|
||||
"ssm:StartAutomationExecution",
|
||||
},
|
||||
# Step Functions-based privilege escalation patterns (pathfinding.cloud STEPFUNCTIONS-001)
|
||||
"PassRole+StepFunctionsCreateStateMachine+StartExecution": {
|
||||
"iam:PassRole",
|
||||
"states:CreateStateMachine",
|
||||
"states:StartExecution",
|
||||
},
|
||||
# Prerequisite: Existing Step Functions state machine with admin role (pathfinding.cloud STEPFUNCTIONS-002)
|
||||
"StepFunctionsUpdateStateMachine+StartExecution": {
|
||||
"states:UpdateStateMachine",
|
||||
"states:StartExecution",
|
||||
},
|
||||
# IAM permissions boundary removal self-escalation (pathfinding.cloud IAM-022)
|
||||
"iam:DeleteUserPermissionsBoundary": {"iam:DeleteUserPermissionsBoundary"},
|
||||
# Role permissions boundary removal plus role assumption (pathfinding.cloud IAM-023)
|
||||
"AssumeRole+DeleteRolePermissionsBoundary": {
|
||||
"sts:AssumeRole",
|
||||
"iam:DeleteRolePermissionsBoundary",
|
||||
},
|
||||
# IAM Identity Center (SSO)-based privilege escalation patterns (pathfinding.cloud SSO-001)
|
||||
"SSOCreatePermissionSet+CreateAccountAssignment+AttachManagedPolicy": {
|
||||
"sso:CreatePermissionSet",
|
||||
"sso:CreateAccountAssignment",
|
||||
"sso:AttachManagedPolicyToPermissionSet",
|
||||
},
|
||||
# Prerequisite: Existing permission set assigned to the attacker (pathfinding.cloud SSO-002)
|
||||
"sso:AttachManagedPolicyToPermissionSet": {
|
||||
"sso:AttachManagedPolicyToPermissionSet"
|
||||
},
|
||||
# Prerequisite: Existing permission set assigned to the attacker (pathfinding.cloud SSO-003)
|
||||
"sso:PutInlinePolicyToPermissionSet": {"sso:PutInlinePolicyToPermissionSet"},
|
||||
# TO-DO: We have to handle AssumeRole just if the resource is * and without conditions
|
||||
# "sts:AssumeRole": {"sts:AssumeRole"},
|
||||
}
|
||||
|
||||
@@ -169,3 +169,119 @@ class Test_PrivilegeEscalation:
|
||||
assert (
|
||||
f"'{pattern}'" in result
|
||||
), f"Expected pattern '{pattern}' not found in result: {result}"
|
||||
|
||||
# New privilege-escalation paths incorporated from pathfinding.cloud (PROWLER-2279):
|
||||
# a policy granting exactly the path's required actions must be flagged.
|
||||
PATHFINDING_2279_COMBOS = [
|
||||
(
|
||||
"batch-001",
|
||||
["iam:PassRole", "batch:RegisterJobDefinition", "batch:SubmitJob"],
|
||||
),
|
||||
("batch-002", ["batch:SubmitJob"]),
|
||||
("braket-001", ["iam:PassRole", "braket:CreateJob"]),
|
||||
(
|
||||
"codedeploy-001",
|
||||
[
|
||||
"codedeploy:CreateDeployment",
|
||||
"codedeploy:RegisterApplicationRevision",
|
||||
"codedeploy:GetDeploymentConfig",
|
||||
],
|
||||
),
|
||||
(
|
||||
"cognitoidentity-001",
|
||||
["iam:PassRole", "cognito-identity:SetIdentityPoolRoles"],
|
||||
),
|
||||
("ecs-009", ["iam:PassRole", "ecs:StartTask"]),
|
||||
("emr-001", ["iam:PassRole", "elasticmapreduce:RunJobFlow"]),
|
||||
(
|
||||
"emrserverless-001",
|
||||
[
|
||||
"iam:PassRole",
|
||||
"emr-serverless:CreateApplication",
|
||||
"emr-serverless:StartJobRun",
|
||||
],
|
||||
),
|
||||
(
|
||||
"gamelift-001",
|
||||
[
|
||||
"iam:PassRole",
|
||||
"gamelift:CreateBuild",
|
||||
"gamelift:CreateFleet",
|
||||
"gamelift:RequestUploadCredentials",
|
||||
],
|
||||
),
|
||||
("glue-007", ["iam:PassRole", "glue:CreateSession", "glue:RunStatement"]),
|
||||
(
|
||||
"imagebuilder-001",
|
||||
[
|
||||
"iam:PassRole",
|
||||
"imagebuilder:CreateComponent",
|
||||
"imagebuilder:CreateImageRecipe",
|
||||
"imagebuilder:CreateInfrastructureConfiguration",
|
||||
"imagebuilder:CreateImage",
|
||||
],
|
||||
),
|
||||
(
|
||||
"kinesisanalytics-001",
|
||||
[
|
||||
"iam:PassRole",
|
||||
"kinesisanalytics:CreateApplication",
|
||||
"kinesisanalytics:StartApplication",
|
||||
],
|
||||
),
|
||||
(
|
||||
"omics-001",
|
||||
["iam:PassRole", "omics:CreateWorkflow", "omics:StartRun", "s3:GetObject"],
|
||||
),
|
||||
("scheduler-001", ["iam:PassRole", "scheduler:CreateSchedule"]),
|
||||
(
|
||||
"ssm-003",
|
||||
["iam:PassRole", "ssm:CreateDocument", "ssm:StartAutomationExecution"],
|
||||
),
|
||||
(
|
||||
"stepfunctions-001",
|
||||
["iam:PassRole", "states:CreateStateMachine", "states:StartExecution"],
|
||||
),
|
||||
(
|
||||
"stepfunctions-002",
|
||||
["states:UpdateStateMachine", "states:StartExecution"],
|
||||
),
|
||||
("iam-022", ["iam:DeleteUserPermissionsBoundary"]),
|
||||
("iam-023", ["iam:DeleteRolePermissionsBoundary", "sts:AssumeRole"]),
|
||||
(
|
||||
"sso-001",
|
||||
[
|
||||
"sso:CreatePermissionSet",
|
||||
"sso:CreateAccountAssignment",
|
||||
"sso:AttachManagedPolicyToPermissionSet",
|
||||
],
|
||||
),
|
||||
("sso-002", ["sso:AttachManagedPolicyToPermissionSet"]),
|
||||
("sso-003", ["sso:PutInlinePolicyToPermissionSet"]),
|
||||
]
|
||||
|
||||
def test_check_privilege_escalation_pathfinding_2279_paths_detected(self):
|
||||
for path_id, actions in self.PATHFINDING_2279_COMBOS:
|
||||
policy = {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{"Effect": "Allow", "Action": actions, "Resource": "*"}],
|
||||
}
|
||||
result = check_privilege_escalation(policy)
|
||||
assert result, f"pathfinding {path_id} not detected for actions {actions}"
|
||||
for action in actions:
|
||||
assert (
|
||||
f"'{action}'" in result
|
||||
), f"pathfinding {path_id}: action {action} missing from result {result}"
|
||||
|
||||
def test_check_privilege_escalation_multi_action_path_requires_a_second_action(
|
||||
self,
|
||||
):
|
||||
# A PassRole-only policy must not, on its own, flag any of the new
|
||||
# PassRole+service paths (guards against over-broad single-action combos).
|
||||
policy = {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{"Effect": "Allow", "Action": ["iam:PassRole"], "Resource": "*"}
|
||||
],
|
||||
}
|
||||
assert check_privilege_escalation(policy) == ""
|
||||
|
||||
Reference in New Issue
Block a user