plexavo 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- plexavo/__init__.py +7 -0
- plexavo/auth.py +61 -0
- plexavo/checks/__init__.py +0 -0
- plexavo/checks/encryption.py +117 -0
- plexavo/checks/iam.py +459 -0
- plexavo/checks/iam_hygiene.py +326 -0
- plexavo/checks/logging.py +144 -0
- plexavo/checks/network.py +276 -0
- plexavo/checks/storage.py +139 -0
- plexavo/checks/usage.py +257 -0
- plexavo/cli.py +216 -0
- plexavo/findings.py +40 -0
- plexavo/principals.py +272 -0
- plexavo/report/__init__.py +0 -0
- plexavo/report/ai_narration.py +428 -0
- plexavo/report/fonts/DejaVuSans-Bold.ttf +0 -0
- plexavo/report/fonts/DejaVuSans-BoldOblique.ttf +0 -0
- plexavo/report/fonts/DejaVuSans-Oblique.ttf +0 -0
- plexavo/report/fonts/DejaVuSans.ttf +0 -0
- plexavo/report/fonts/GEIST-FONT-LICENSE.txt +92 -0
- plexavo/report/html_report.py +120 -0
- plexavo/report/pdf.py +236 -0
- plexavo/report/templates/report.html.j2 +329 -0
- plexavo/scoring.py +73 -0
- plexavo-0.1.0.dist-info/METADATA +197 -0
- plexavo-0.1.0.dist-info/RECORD +30 -0
- plexavo-0.1.0.dist-info/WHEEL +5 -0
- plexavo-0.1.0.dist-info/entry_points.txt +2 -0
- plexavo-0.1.0.dist-info/licenses/LICENSE +661 -0
- plexavo-0.1.0.dist-info/top_level.txt +1 -0
plexavo/__init__.py
ADDED
plexavo/auth.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Local AWS credential handling.
|
|
2
|
+
|
|
3
|
+
Design constraint: nothing is ever handed to anyone else. Plexavo uses
|
|
4
|
+
the caller's own configured credentials directly — the default profile,
|
|
5
|
+
a named profile, or environment variables — exactly the same resolution
|
|
6
|
+
order the AWS CLI itself uses. There is no cross-account access, no
|
|
7
|
+
CloudFormation role, and no server in between. Run it yourself, with
|
|
8
|
+
your own credentials, and nothing leaves your machine.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import boto3
|
|
14
|
+
from botocore.exceptions import NoCredentialsError, ProfileNotFound
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_local_session(profile_name: str | None = None, region: str | None = None) -> boto3.Session:
|
|
18
|
+
"""Build a session from the caller's own local credentials.
|
|
19
|
+
|
|
20
|
+
Raises RuntimeError with a clear message on failure — auth failures
|
|
21
|
+
should stop the scan immediately, not surface as a confusing
|
|
22
|
+
downstream permission error buried inside an individual check.
|
|
23
|
+
"""
|
|
24
|
+
try:
|
|
25
|
+
session = boto3.Session(profile_name=profile_name)
|
|
26
|
+
except ProfileNotFound as e:
|
|
27
|
+
raise RuntimeError(
|
|
28
|
+
f"{e}. Run `aws configure list-profiles` to see what's "
|
|
29
|
+
"available, or `aws configure` to set up the default profile."
|
|
30
|
+
) from e
|
|
31
|
+
|
|
32
|
+
resolved_region = region or session.region_name
|
|
33
|
+
if not resolved_region:
|
|
34
|
+
raise RuntimeError(
|
|
35
|
+
"No AWS region configured. Run `aws configure` to set a "
|
|
36
|
+
"default region, or pass --region explicitly."
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# boto3.Session() doesn't fail on missing credentials until you
|
|
40
|
+
# actually make a call — force that check now, with a clear message,
|
|
41
|
+
# instead of letting it surface later as an opaque error from deep
|
|
42
|
+
# inside the first check that happens to run.
|
|
43
|
+
try:
|
|
44
|
+
boto3.Session(
|
|
45
|
+
profile_name=profile_name, region_name=resolved_region
|
|
46
|
+
).client("sts").get_caller_identity()
|
|
47
|
+
except NoCredentialsError as e:
|
|
48
|
+
raise RuntimeError(
|
|
49
|
+
"No AWS credentials found. Run `aws configure` "
|
|
50
|
+
f"{f'--profile {profile_name} ' if profile_name else ''}"
|
|
51
|
+
"to set them up."
|
|
52
|
+
) from e
|
|
53
|
+
|
|
54
|
+
return boto3.Session(profile_name=profile_name, region_name=resolved_region)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def get_account_id(session: boto3.Session) -> str:
|
|
58
|
+
"""Confirm which account we actually landed in — always verify this
|
|
59
|
+
before running checks, so a misconfigured profile doesn't silently
|
|
60
|
+
scan the wrong account."""
|
|
61
|
+
return session.client("sts").get_caller_identity()["Account"]
|
|
File without changes
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Category 6: Encryption and Data Protection — checks 29-31, all Medium.
|
|
2
|
+
|
|
3
|
+
check_29 (EBS) and check_30 (RDS) are straightforward flag-checks, no
|
|
4
|
+
surprises expected.
|
|
5
|
+
|
|
6
|
+
check_31 (S3 default encryption) carries the SAME risk that broke
|
|
7
|
+
STOR-19's original "missing PAB" ground truth, flagged proactively this
|
|
8
|
+
time instead of discovered by a failed apply: AWS made SSE-S3 the
|
|
9
|
+
mandatory, automatic default for every S3 bucket in January 2023,
|
|
10
|
+
applied irrevocably (you can only upgrade to SSE-KMS, never disable
|
|
11
|
+
encryption entirely). A bucket created today will very likely ALWAYS
|
|
12
|
+
have at least a default AES256 configuration, meaning
|
|
13
|
+
ServerSideEncryptionConfigurationNotFoundError may be unreachable for
|
|
14
|
+
any bucket created post-January-2023. The check itself is still correct
|
|
15
|
+
and valuable — for real customer accounts with buckets that predate
|
|
16
|
+
this change and were never touched since — it's just probably not
|
|
17
|
+
something we can ground-truth with a freshly created bucket. We'll know
|
|
18
|
+
for certain once Terraform is applied; not assuming either way.
|
|
19
|
+
|
|
20
|
+
Confirmed via the real S3 service model before writing this (not
|
|
21
|
+
guessed): neither ServerSideEncryptionConfigurationNotFoundError here,
|
|
22
|
+
nor the two S3 error codes storage.py hit, are modeled as distinct
|
|
23
|
+
exception classes. Generic ClientError + error-code inspection used from
|
|
24
|
+
the start this time.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from botocore.exceptions import ClientError
|
|
28
|
+
|
|
29
|
+
from plexavo.findings import Finding, Severity
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def check_29_unencrypted_ebs_volumes(ec2) -> list[Finding]:
|
|
33
|
+
"""ENC-29: EBS volume with Encrypted=False."""
|
|
34
|
+
findings = []
|
|
35
|
+
paginator = ec2.get_paginator("describe_volumes")
|
|
36
|
+
for page in paginator.paginate():
|
|
37
|
+
for vol in page["Volumes"]:
|
|
38
|
+
if vol.get("Encrypted"):
|
|
39
|
+
continue
|
|
40
|
+
vol_id = vol["VolumeId"]
|
|
41
|
+
attachments = vol.get("Attachments", [])
|
|
42
|
+
attached_to = (
|
|
43
|
+
", ".join(a["InstanceId"] for a in attachments)
|
|
44
|
+
if attachments else "not attached to any instance"
|
|
45
|
+
)
|
|
46
|
+
findings.append(Finding(
|
|
47
|
+
check_id="ENC-29",
|
|
48
|
+
title="Unencrypted EBS Volume",
|
|
49
|
+
severity=Severity.MEDIUM,
|
|
50
|
+
resource_arn=vol_id,
|
|
51
|
+
raw_detail=f"EBS volume '{vol_id}' ({attached_to}) is not encrypted. Data "
|
|
52
|
+
f"at rest on this volume — and any snapshot taken from it — is "
|
|
53
|
+
f"stored in plaintext.",
|
|
54
|
+
account_context=f"attached_to={attached_to}",
|
|
55
|
+
))
|
|
56
|
+
return findings
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def check_30_unencrypted_rds_instances(rds) -> list[Finding]:
|
|
60
|
+
"""ENC-30: RDS instance with StorageEncrypted=False."""
|
|
61
|
+
findings = []
|
|
62
|
+
paginator = rds.get_paginator("describe_db_instances")
|
|
63
|
+
for page in paginator.paginate():
|
|
64
|
+
for db in page["DBInstances"]:
|
|
65
|
+
if db.get("StorageEncrypted"):
|
|
66
|
+
continue
|
|
67
|
+
db_id = db["DBInstanceIdentifier"]
|
|
68
|
+
engine = db.get("Engine", "unknown")
|
|
69
|
+
findings.append(Finding(
|
|
70
|
+
check_id="ENC-30",
|
|
71
|
+
title="Unencrypted RDS Instance",
|
|
72
|
+
severity=Severity.MEDIUM,
|
|
73
|
+
resource_arn=db.get("DBInstanceArn", db_id),
|
|
74
|
+
raw_detail=f"RDS instance '{db_id}' ({engine}) has StorageEncrypted=False. "
|
|
75
|
+
f"Data at rest — the database files, automated backups, and "
|
|
76
|
+
f"snapshots — is stored unencrypted.",
|
|
77
|
+
account_context=f"engine={engine}",
|
|
78
|
+
))
|
|
79
|
+
return findings
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def check_31_s3_missing_default_encryption(s3, bucket_names: list) -> list[Finding]:
|
|
83
|
+
"""ENC-31: bucket has no default server-side encryption configuration.
|
|
84
|
+
See module docstring on why this may rarely fire on modern buckets."""
|
|
85
|
+
findings = []
|
|
86
|
+
for name in bucket_names:
|
|
87
|
+
try:
|
|
88
|
+
s3.get_bucket_encryption(Bucket=name)
|
|
89
|
+
continue # has some encryption config, whatever it is
|
|
90
|
+
except ClientError as e:
|
|
91
|
+
if e.response["Error"]["Code"] != "ServerSideEncryptionConfigurationNotFoundError":
|
|
92
|
+
raise
|
|
93
|
+
findings.append(Finding(
|
|
94
|
+
check_id="ENC-31",
|
|
95
|
+
title="S3 Bucket Without Default Encryption",
|
|
96
|
+
severity=Severity.MEDIUM,
|
|
97
|
+
resource_arn=f"arn:aws:s3:::{name}",
|
|
98
|
+
raw_detail=f"Bucket '{name}' has no default server-side encryption "
|
|
99
|
+
f"configuration. Objects uploaded without an explicit "
|
|
100
|
+
f"encryption header are stored in plaintext.",
|
|
101
|
+
account_context=f"bucket={name}",
|
|
102
|
+
))
|
|
103
|
+
return findings
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def run_all(session) -> list[Finding]:
|
|
107
|
+
"""Run ENC-29 through ENC-31 against every relevant resource in the account."""
|
|
108
|
+
ec2 = session.client("ec2")
|
|
109
|
+
rds = session.client("rds")
|
|
110
|
+
s3 = session.client("s3")
|
|
111
|
+
bucket_names = [b["Name"] for b in s3.list_buckets()["Buckets"]]
|
|
112
|
+
|
|
113
|
+
findings = []
|
|
114
|
+
findings += check_29_unencrypted_ebs_volumes(ec2)
|
|
115
|
+
findings += check_30_unencrypted_rds_instances(rds)
|
|
116
|
+
findings += check_31_s3_missing_default_encryption(s3, bucket_names)
|
|
117
|
+
return findings
|
plexavo/checks/iam.py
ADDED
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
"""Category 1: IAM Privilege Escalation Paths — checks 1-6 (Critical severity).
|
|
2
|
+
|
|
3
|
+
This is the core differentiator. Each function takes the full list of
|
|
4
|
+
Principal objects (already loaded with policies + group-inherited
|
|
5
|
+
policies + permission boundary via principals.py) and returns a list of
|
|
6
|
+
Finding objects.
|
|
7
|
+
|
|
8
|
+
IMPORTANT on false positives: check_04 (PassRole+Compute) and check_05
|
|
9
|
+
(AssumeRole Chain) both verify the prerequisite actually exists before
|
|
10
|
+
flagging. check_02/03/04/05/06 additionally verify the relevant action
|
|
11
|
+
isn't blocked by an explicit Deny (identity policy or permission
|
|
12
|
+
boundary) before flagging. These verification steps are the exact gap
|
|
13
|
+
the blueprint calls out in PMapper/CloudSplaining — don't strip them out
|
|
14
|
+
to "simplify" later.
|
|
15
|
+
|
|
16
|
+
v3 changes (Deny statements + permission boundaries):
|
|
17
|
+
- Every check now calls _apply_deny_and_boundary() before finalizing a finding: an
|
|
18
|
+
unconditioned matching Deny suppresses the finding entirely (it's not
|
|
19
|
+
actually exploitable); a conditioned Deny downgrades Critical to High
|
|
20
|
+
with a note, since we can't be certain the condition always applies.
|
|
21
|
+
- check_01 (wildcard admin) only suppresses on a FULL wildcard Deny
|
|
22
|
+
(Action:*/Resource:* or NotAction/NotResource equivalent) — partial
|
|
23
|
+
per-service Denies don't suppress it. Stated limitation, not an
|
|
24
|
+
oversight: resolving exactly what remains after a partial Deny needs
|
|
25
|
+
real policy simulation, out of MVP scope.
|
|
26
|
+
- is_admin_equivalent() now returns False for any principal with a
|
|
27
|
+
permission boundary that ISN'T itself a wildcard grant, regardless of
|
|
28
|
+
what the identity policy says — a non-wildcard boundary caps effective
|
|
29
|
+
permissions below admin. Also returns False if has_full_wildcard_deny()
|
|
30
|
+
is true, even with AdministratorAccess attached (a real break-glass
|
|
31
|
+
pattern: broad Allow + full Deny).
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
from plexavo.findings import Finding, Severity
|
|
37
|
+
from plexavo.principals import (
|
|
38
|
+
Principal,
|
|
39
|
+
statement_grants,
|
|
40
|
+
statement_has_wildcard_action,
|
|
41
|
+
resource_is_wildcard,
|
|
42
|
+
resource_includes,
|
|
43
|
+
find_blocking_deny,
|
|
44
|
+
has_full_wildcard_deny,
|
|
45
|
+
action_within_boundary,
|
|
46
|
+
_normalize,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
SELF_ESCALATION_ACTIONS = {
|
|
50
|
+
"iam:AttachUserPolicy",
|
|
51
|
+
"iam:PutUserPolicy",
|
|
52
|
+
"iam:AttachRolePolicy",
|
|
53
|
+
"iam:PutRolePolicy",
|
|
54
|
+
"iam:AttachGroupPolicy",
|
|
55
|
+
"iam:PutGroupPolicy",
|
|
56
|
+
"iam:AddUserToGroup",
|
|
57
|
+
"iam:CreateAccessKey",
|
|
58
|
+
"iam:CreateLoginProfile",
|
|
59
|
+
"iam:UpdateLoginProfile",
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
COMPUTE_LAUNCH_ACTIONS = {
|
|
63
|
+
"ec2:RunInstances",
|
|
64
|
+
"lambda:CreateFunction",
|
|
65
|
+
"lambda:UpdateFunctionConfiguration",
|
|
66
|
+
"ecs:RunTask",
|
|
67
|
+
"ecs:CreateService",
|
|
68
|
+
"glue:CreateJob",
|
|
69
|
+
"sagemaker:CreateNotebookInstance",
|
|
70
|
+
"apprunner:CreateService",
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
COMPUTE_SERVICE_PRINCIPALS = {
|
|
74
|
+
"ec2.amazonaws.com",
|
|
75
|
+
"lambda.amazonaws.com",
|
|
76
|
+
"ecs-tasks.amazonaws.com",
|
|
77
|
+
"glue.amazonaws.com",
|
|
78
|
+
"sagemaker.amazonaws.com",
|
|
79
|
+
"apprunner.amazonaws.com",
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
BROAD_OTHER_SERVICE_WILDCARDS = {"ec2:*", "s3:*", "rds:*", "lambda:*", "dynamodb:*"}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _all_statements(principal: Principal):
|
|
86
|
+
for policy_name, statements in principal.policies:
|
|
87
|
+
for stmt in statements:
|
|
88
|
+
yield policy_name, stmt
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _condition_adjustment(stmt: dict) -> tuple[Severity, str]:
|
|
92
|
+
"""A Condition block on the ALLOW statement itself means we can't
|
|
93
|
+
safely claim this grant is unconditional. Downgrade Critical -> High
|
|
94
|
+
and say so, rather than suppressing or overclaiming."""
|
|
95
|
+
condition = stmt.get("Condition")
|
|
96
|
+
if condition:
|
|
97
|
+
keys = ", ".join(condition.keys())
|
|
98
|
+
return (
|
|
99
|
+
Severity.HIGH,
|
|
100
|
+
f" NOTE: this grant is scoped by a Condition block ({keys}) — "
|
|
101
|
+
f"not evaluated automatically; verify manually whether it "
|
|
102
|
+
f"meaningfully restricts access.",
|
|
103
|
+
)
|
|
104
|
+
return Severity.CRITICAL, ""
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _apply_deny_and_boundary(principal: Principal, action: str, resource_arn: str | None,
|
|
108
|
+
severity: Severity, note: str):
|
|
109
|
+
"""Combined evaluator: explicit Deny (identity + boundary) can suppress
|
|
110
|
+
or downgrade; a permission boundary that doesn't allow this action caps
|
|
111
|
+
it away entirely (implicit deny by omission — not a Condition-style
|
|
112
|
+
uncertainty, so this always suppresses rather than downgrades).
|
|
113
|
+
Returns (severity, note) or None if the finding should be suppressed."""
|
|
114
|
+
blocked, is_conditioned = find_blocking_deny(principal, action, resource_arn)
|
|
115
|
+
if blocked and not is_conditioned:
|
|
116
|
+
return None
|
|
117
|
+
if blocked and is_conditioned:
|
|
118
|
+
severity = Severity.HIGH if severity == Severity.CRITICAL else severity
|
|
119
|
+
note = note + (
|
|
120
|
+
" Also potentially blocked by a conditioned Deny elsewhere in "
|
|
121
|
+
"this principal's policies — verify manually."
|
|
122
|
+
)
|
|
123
|
+
if not action_within_boundary(principal, action, resource_arn):
|
|
124
|
+
return None
|
|
125
|
+
return severity, note
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _principal_has_wildcard_grant(principal: Principal) -> bool:
|
|
129
|
+
return any(
|
|
130
|
+
stmt.get("Effect") == "Allow"
|
|
131
|
+
and statement_has_wildcard_action(stmt)
|
|
132
|
+
and resource_is_wildcard(stmt)
|
|
133
|
+
for _, statements in principal.policies
|
|
134
|
+
for stmt in statements
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def is_admin_equivalent(principal: Principal) -> bool:
|
|
139
|
+
"""True if this principal is 'admin-equivalent' for escalation-target
|
|
140
|
+
purposes:
|
|
141
|
+
- False immediately if a full wildcard Deny exists (break-glass
|
|
142
|
+
pattern: broad Allow neutered by an equally broad Deny).
|
|
143
|
+
- False if a permission boundary is attached and it ISN'T itself a
|
|
144
|
+
wildcard grant — a non-wildcard boundary caps real permissions
|
|
145
|
+
below admin regardless of the identity policy.
|
|
146
|
+
- Otherwise True if there's a literal Action:*/Resource:* grant, OR
|
|
147
|
+
iam:* combined with broad wildcards on 2+ other core services
|
|
148
|
+
(heuristic for functionally-admin custom policies).
|
|
149
|
+
This is a heuristic, not full policy simulation — see principals.py
|
|
150
|
+
docstring for the exact limitation (boundary Allow-sets aren't used
|
|
151
|
+
to compute a true intersection, only boundary Deny statements and
|
|
152
|
+
"is the boundary itself wildcard" are considered).
|
|
153
|
+
"""
|
|
154
|
+
if has_full_wildcard_deny(principal):
|
|
155
|
+
return False
|
|
156
|
+
|
|
157
|
+
if principal.has_permission_boundary and not action_within_boundary(principal, "*", None):
|
|
158
|
+
return False
|
|
159
|
+
|
|
160
|
+
if _principal_has_wildcard_grant(principal):
|
|
161
|
+
return True
|
|
162
|
+
|
|
163
|
+
granted_iam_star = False
|
|
164
|
+
broad_other_services = set()
|
|
165
|
+
for _policy_name, statements in principal.policies:
|
|
166
|
+
for stmt in statements:
|
|
167
|
+
if stmt.get("Effect") != "Allow" or "Action" not in stmt:
|
|
168
|
+
continue
|
|
169
|
+
for a in _normalize(stmt.get("Action")):
|
|
170
|
+
al = a.lower()
|
|
171
|
+
if al == "iam:*":
|
|
172
|
+
granted_iam_star = True
|
|
173
|
+
elif al in BROAD_OTHER_SERVICE_WILDCARDS:
|
|
174
|
+
broad_other_services.add(al)
|
|
175
|
+
return granted_iam_star and len(broad_other_services) >= 2
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def check_01_wildcard_admin(principals: list[Principal]) -> list[Finding]:
|
|
179
|
+
"""IAM-01: Any principal with effectively Action:* on effectively Resource:*,
|
|
180
|
+
unless neutered by a Deny or capped below wildcard by a permission boundary."""
|
|
181
|
+
findings = []
|
|
182
|
+
for p in principals:
|
|
183
|
+
for policy_name, stmt in _all_statements(p):
|
|
184
|
+
if stmt.get("Effect") != "Allow":
|
|
185
|
+
continue
|
|
186
|
+
if statement_has_wildcard_action(stmt) and resource_is_wildcard(stmt):
|
|
187
|
+
severity, note = _condition_adjustment(stmt)
|
|
188
|
+
result = _apply_deny_and_boundary(p, "*", None, severity, note)
|
|
189
|
+
if result is None:
|
|
190
|
+
continue
|
|
191
|
+
severity, note = result
|
|
192
|
+
findings.append(Finding(
|
|
193
|
+
check_id="IAM-01",
|
|
194
|
+
title="Wildcard Admin Access",
|
|
195
|
+
severity=severity,
|
|
196
|
+
resource_arn=p.arn,
|
|
197
|
+
raw_detail=f"Policy '{policy_name}' on {p.type} '{p.name}' grants "
|
|
198
|
+
f"effectively unrestricted access on all resources "
|
|
199
|
+
f"(full administrator access).{note}",
|
|
200
|
+
account_context=f"policy={policy_name}",
|
|
201
|
+
))
|
|
202
|
+
return findings
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def check_02_self_escalation(principals: list[Principal]) -> list[Finding]:
|
|
206
|
+
"""IAM-02: Principal can attach/put policies, join groups, or create
|
|
207
|
+
credentials on a resource scope that includes itself."""
|
|
208
|
+
findings = []
|
|
209
|
+
for p in principals:
|
|
210
|
+
for policy_name, stmt in _all_statements(p):
|
|
211
|
+
if stmt.get("Effect") != "Allow":
|
|
212
|
+
continue
|
|
213
|
+
granted = statement_grants(stmt, SELF_ESCALATION_ACTIONS)
|
|
214
|
+
if not granted:
|
|
215
|
+
continue
|
|
216
|
+
if not (resource_is_wildcard(stmt) or resource_includes(stmt, p.arn)):
|
|
217
|
+
continue
|
|
218
|
+
|
|
219
|
+
target_resource = None if resource_is_wildcard(stmt) else p.arn
|
|
220
|
+
severity, note = _condition_adjustment(stmt)
|
|
221
|
+
remaining = set()
|
|
222
|
+
any_conditioned_deny = False
|
|
223
|
+
for action in granted:
|
|
224
|
+
result = _apply_deny_and_boundary(p, action, target_resource, severity, note)
|
|
225
|
+
if result is None:
|
|
226
|
+
continue # this specific action is unconditionally blocked
|
|
227
|
+
action_severity, _ = result
|
|
228
|
+
if action_severity != severity:
|
|
229
|
+
any_conditioned_deny = True
|
|
230
|
+
remaining.add(action)
|
|
231
|
+
if not remaining:
|
|
232
|
+
continue # every granted action was unconditionally denied
|
|
233
|
+
|
|
234
|
+
if any_conditioned_deny:
|
|
235
|
+
severity = Severity.HIGH if severity == Severity.CRITICAL else severity
|
|
236
|
+
note += " Some of this access may additionally be blocked by a conditioned Deny — verify manually."
|
|
237
|
+
|
|
238
|
+
findings.append(Finding(
|
|
239
|
+
check_id="IAM-02",
|
|
240
|
+
title="IAM Self-Escalation",
|
|
241
|
+
severity=severity,
|
|
242
|
+
resource_arn=p.arn,
|
|
243
|
+
raw_detail=f"Policy '{policy_name}' on {p.type} '{p.name}' grants "
|
|
244
|
+
f"{', '.join(sorted(remaining))} on a resource scope that "
|
|
245
|
+
f"includes itself — this principal can grant itself any "
|
|
246
|
+
f"additional permission or credential.{note}",
|
|
247
|
+
account_context=f"policy={policy_name}",
|
|
248
|
+
))
|
|
249
|
+
return findings
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def check_03_create_policy_version(principals: list[Principal], all_managed_policy_owners: dict) -> list[Finding]:
|
|
253
|
+
"""IAM-03: iam:CreatePolicyVersion on a managed policy this principal doesn't own."""
|
|
254
|
+
findings = []
|
|
255
|
+
for p in principals:
|
|
256
|
+
for policy_name, stmt in _all_statements(p):
|
|
257
|
+
if stmt.get("Effect") != "Allow":
|
|
258
|
+
continue
|
|
259
|
+
granted = statement_grants(stmt, {"iam:CreatePolicyVersion"})
|
|
260
|
+
if not granted:
|
|
261
|
+
continue
|
|
262
|
+
severity, note = _condition_adjustment(stmt)
|
|
263
|
+
|
|
264
|
+
if resource_is_wildcard(stmt):
|
|
265
|
+
result = _apply_deny_and_boundary(p, "iam:CreatePolicyVersion", None, severity, note)
|
|
266
|
+
if result is None:
|
|
267
|
+
continue
|
|
268
|
+
severity, note = result
|
|
269
|
+
findings.append(Finding(
|
|
270
|
+
check_id="IAM-03",
|
|
271
|
+
title="CreatePolicyVersion Escalation",
|
|
272
|
+
severity=severity,
|
|
273
|
+
resource_arn=p.arn,
|
|
274
|
+
raw_detail=f"Policy '{policy_name}' on {p.type} '{p.name}' grants "
|
|
275
|
+
f"iam:CreatePolicyVersion on all policies. This principal "
|
|
276
|
+
f"can rewrite any managed policy in the account to grant "
|
|
277
|
+
f"itself full access and set it as the default version.{note}",
|
|
278
|
+
account_context=f"policy={policy_name}",
|
|
279
|
+
))
|
|
280
|
+
else:
|
|
281
|
+
for r in _normalize(stmt.get("Resource")):
|
|
282
|
+
owner = all_managed_policy_owners.get(r)
|
|
283
|
+
if owner is None or owner == p.arn:
|
|
284
|
+
continue
|
|
285
|
+
result = _apply_deny_and_boundary(p, "iam:CreatePolicyVersion", r, severity, note)
|
|
286
|
+
if result is None:
|
|
287
|
+
continue
|
|
288
|
+
r_severity, r_note = result
|
|
289
|
+
findings.append(Finding(
|
|
290
|
+
check_id="IAM-03",
|
|
291
|
+
title="CreatePolicyVersion Escalation",
|
|
292
|
+
severity=r_severity,
|
|
293
|
+
resource_arn=p.arn,
|
|
294
|
+
raw_detail=f"Policy '{policy_name}' on {p.type} '{p.name}' "
|
|
295
|
+
f"grants iam:CreatePolicyVersion on '{r}', a policy "
|
|
296
|
+
f"it doesn't own. It can rewrite that policy to grant "
|
|
297
|
+
f"broader access.{r_note}",
|
|
298
|
+
account_context=f"policy={policy_name}",
|
|
299
|
+
))
|
|
300
|
+
return findings
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def check_04_passrole_compute(principals: list[Principal]) -> list[Finding]:
|
|
304
|
+
"""IAM-04: PassRole + compute launch, verified against a real high-priv
|
|
305
|
+
target role whose trust policy actually allows a compute service to
|
|
306
|
+
assume it, and that iam:PassRole to that specific target isn't Denied."""
|
|
307
|
+
findings = []
|
|
308
|
+
|
|
309
|
+
dangerous_roles = {}
|
|
310
|
+
for p in principals:
|
|
311
|
+
if p.type != "role" or not p.trust_policy:
|
|
312
|
+
continue
|
|
313
|
+
trust_statements = _normalize(p.trust_policy.get("Statement"))
|
|
314
|
+
trusts_compute = False
|
|
315
|
+
for stmt in trust_statements:
|
|
316
|
+
if stmt.get("Effect") != "Allow":
|
|
317
|
+
continue
|
|
318
|
+
principal_field = stmt.get("Principal", {})
|
|
319
|
+
services = _normalize(principal_field.get("Service")) if isinstance(principal_field, dict) else []
|
|
320
|
+
if any(s in COMPUTE_SERVICE_PRINCIPALS for s in services):
|
|
321
|
+
trusts_compute = True
|
|
322
|
+
break
|
|
323
|
+
if trusts_compute and is_admin_equivalent(p):
|
|
324
|
+
dangerous_roles[p.arn] = p.name
|
|
325
|
+
|
|
326
|
+
if not dangerous_roles:
|
|
327
|
+
return findings
|
|
328
|
+
|
|
329
|
+
for p in principals:
|
|
330
|
+
for policy_name, stmt in _all_statements(p):
|
|
331
|
+
if stmt.get("Effect") != "Allow":
|
|
332
|
+
continue
|
|
333
|
+
if not statement_grants(stmt, {"iam:PassRole"}):
|
|
334
|
+
continue
|
|
335
|
+
has_launch = any(
|
|
336
|
+
statement_grants(s, COMPUTE_LAUNCH_ACTIONS)
|
|
337
|
+
for _, s in _all_statements(p)
|
|
338
|
+
)
|
|
339
|
+
if not has_launch:
|
|
340
|
+
continue
|
|
341
|
+
reachable = {
|
|
342
|
+
arn: name for arn, name in dangerous_roles.items()
|
|
343
|
+
if resource_includes(stmt, arn)
|
|
344
|
+
}
|
|
345
|
+
base_severity, base_note = _condition_adjustment(stmt)
|
|
346
|
+
for role_arn, role_name in reachable.items():
|
|
347
|
+
if role_arn == p.arn:
|
|
348
|
+
continue
|
|
349
|
+
result = _apply_deny_and_boundary(p, "iam:PassRole", role_arn, base_severity, base_note)
|
|
350
|
+
if result is None:
|
|
351
|
+
continue
|
|
352
|
+
severity, note = result
|
|
353
|
+
findings.append(Finding(
|
|
354
|
+
check_id="IAM-04",
|
|
355
|
+
title="PassRole + Compute Privilege Escalation",
|
|
356
|
+
severity=severity,
|
|
357
|
+
resource_arn=p.arn,
|
|
358
|
+
raw_detail=f"{p.type} '{p.name}' has iam:PassRole (policy '{policy_name}') "
|
|
359
|
+
f"plus a compute-launch permission, and can pass the "
|
|
360
|
+
f"high-privilege role '{role_name}' ({role_arn}) — which trusts "
|
|
361
|
+
f"a compute service — to a new EC2 instance or Lambda function, "
|
|
362
|
+
f"gaining that role's permissions.{note}",
|
|
363
|
+
account_context=f"target_role={role_arn}",
|
|
364
|
+
))
|
|
365
|
+
return findings
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def check_05_assumerole_chain_to_admin(principals: list[Principal]) -> list[Finding]:
|
|
369
|
+
"""IAM-05: Role A can assume Role B, and Role B has admin-level access.
|
|
370
|
+
One-hop only for MVP, as the blueprint specifies."""
|
|
371
|
+
findings = []
|
|
372
|
+
roles_by_arn = {p.arn: p for p in principals if p.type == "role"}
|
|
373
|
+
admin_roles = {p.arn for p in principals if p.type == "role" and is_admin_equivalent(p)}
|
|
374
|
+
|
|
375
|
+
for p in principals:
|
|
376
|
+
for policy_name, stmt in _all_statements(p):
|
|
377
|
+
if stmt.get("Effect") != "Allow":
|
|
378
|
+
continue
|
|
379
|
+
if not statement_grants(stmt, {"sts:AssumeRole"}):
|
|
380
|
+
continue
|
|
381
|
+
if resource_is_wildcard(stmt):
|
|
382
|
+
continue # that's check IAM-06, not IAM-05
|
|
383
|
+
base_severity, base_note = _condition_adjustment(stmt)
|
|
384
|
+
for target_arn in _normalize(stmt.get("Resource")):
|
|
385
|
+
if target_arn not in admin_roles or target_arn == p.arn:
|
|
386
|
+
continue
|
|
387
|
+
result = _apply_deny_and_boundary(p, "sts:AssumeRole", target_arn, base_severity, base_note)
|
|
388
|
+
if result is None:
|
|
389
|
+
continue
|
|
390
|
+
severity, note = result
|
|
391
|
+
target_name = roles_by_arn[target_arn].name
|
|
392
|
+
findings.append(Finding(
|
|
393
|
+
check_id="IAM-05",
|
|
394
|
+
title="AssumeRole Chain to Admin",
|
|
395
|
+
severity=severity,
|
|
396
|
+
resource_arn=p.arn,
|
|
397
|
+
raw_detail=f"{p.type} '{p.name}' (policy '{policy_name}') can call "
|
|
398
|
+
f"sts:AssumeRole on '{target_name}' ({target_arn}), which "
|
|
399
|
+
f"has administrator-equivalent access. One hop from "
|
|
400
|
+
f"'{p.name}' reaches full admin.{note}",
|
|
401
|
+
account_context=f"target_role={target_arn}",
|
|
402
|
+
))
|
|
403
|
+
return findings
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def check_06_wildcard_assumerole(principals: list[Principal]) -> list[Finding]:
|
|
407
|
+
"""IAM-06: sts:AssumeRole on effectively Resource:* — can assume any role in the account."""
|
|
408
|
+
findings = []
|
|
409
|
+
for p in principals:
|
|
410
|
+
for policy_name, stmt in _all_statements(p):
|
|
411
|
+
if stmt.get("Effect") != "Allow":
|
|
412
|
+
continue
|
|
413
|
+
if not statement_grants(stmt, {"sts:AssumeRole"}):
|
|
414
|
+
continue
|
|
415
|
+
if not resource_is_wildcard(stmt):
|
|
416
|
+
continue
|
|
417
|
+
severity, note = _condition_adjustment(stmt)
|
|
418
|
+
result = _apply_deny_and_boundary(p, "sts:AssumeRole", None, severity, note)
|
|
419
|
+
if result is None:
|
|
420
|
+
continue
|
|
421
|
+
severity, note = result
|
|
422
|
+
findings.append(Finding(
|
|
423
|
+
check_id="IAM-06",
|
|
424
|
+
title="Wildcard AssumeRole",
|
|
425
|
+
severity=severity,
|
|
426
|
+
resource_arn=p.arn,
|
|
427
|
+
raw_detail=f"Policy '{policy_name}' on {p.type} '{p.name}' grants "
|
|
428
|
+
f"sts:AssumeRole on effectively all resources — this "
|
|
429
|
+
f"principal can assume ANY role in the account, including "
|
|
430
|
+
f"admin roles created after this scan.{note}",
|
|
431
|
+
account_context=f"policy={policy_name}",
|
|
432
|
+
))
|
|
433
|
+
return findings
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def run_all(session, principals: list[Principal]) -> list[Finding]:
|
|
437
|
+
"""Run checks 1-6 and return the combined finding list."""
|
|
438
|
+
iam = session.client("iam")
|
|
439
|
+
|
|
440
|
+
policy_owners = {}
|
|
441
|
+
paginator = iam.get_paginator("list_policies")
|
|
442
|
+
for page in paginator.paginate(Scope="Local"):
|
|
443
|
+
for policy in page["Policies"]:
|
|
444
|
+
entities = iam.list_entities_for_policy(PolicyArn=policy["Arn"])
|
|
445
|
+
owner_arn = None
|
|
446
|
+
if entities["PolicyUsers"]:
|
|
447
|
+
owner_arn = f"arn:aws:iam::{policy['Arn'].split(':')[4]}:user/{entities['PolicyUsers'][0]['UserName']}"
|
|
448
|
+
elif entities["PolicyRoles"]:
|
|
449
|
+
owner_arn = f"arn:aws:iam::{policy['Arn'].split(':')[4]}:role/{entities['PolicyRoles'][0]['RoleName']}"
|
|
450
|
+
policy_owners[policy["Arn"]] = owner_arn
|
|
451
|
+
|
|
452
|
+
findings = []
|
|
453
|
+
findings += check_01_wildcard_admin(principals)
|
|
454
|
+
findings += check_02_self_escalation(principals)
|
|
455
|
+
findings += check_03_create_policy_version(principals, policy_owners)
|
|
456
|
+
findings += check_04_passrole_compute(principals)
|
|
457
|
+
findings += check_05_assumerole_chain_to_admin(principals)
|
|
458
|
+
findings += check_06_wildcard_assumerole(principals)
|
|
459
|
+
return findings
|