plexavo 0.4.4__tar.gz → 0.4.6__tar.gz
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-0.4.4 → plexavo-0.4.6}/PKG-INFO +3 -2
- {plexavo-0.4.4 → plexavo-0.4.6}/README.md +2 -1
- {plexavo-0.4.4 → plexavo-0.4.6}/plexavo/__init__.py +1 -1
- plexavo-0.4.6/plexavo/checks/compute.py +117 -0
- {plexavo-0.4.4 → plexavo-0.4.6}/plexavo/checks/iam.py +19 -25
- {plexavo-0.4.4 → plexavo-0.4.6}/plexavo/checks/storage.py +83 -23
- {plexavo-0.4.4 → plexavo-0.4.6}/plexavo/checks/usage.py +62 -3
- {plexavo-0.4.4 → plexavo-0.4.6}/plexavo/cli.py +11 -2
- {plexavo-0.4.4 → plexavo-0.4.6}/plexavo/principals.py +134 -15
- {plexavo-0.4.4 → plexavo-0.4.6}/plexavo/report/templates/report.html.j2 +22 -4
- {plexavo-0.4.4 → plexavo-0.4.6}/plexavo.egg-info/PKG-INFO +3 -2
- {plexavo-0.4.4 → plexavo-0.4.6}/plexavo.egg-info/SOURCES.txt +3 -0
- {plexavo-0.4.4 → plexavo-0.4.6}/pyproject.toml +1 -1
- {plexavo-0.4.4 → plexavo-0.4.6}/tests/test_attack_paths_offline.py +31 -0
- plexavo-0.4.6/tests/test_compute_offline.py +112 -0
- {plexavo-0.4.4 → plexavo-0.4.6}/tests/test_iam_offline.py +23 -0
- plexavo-0.4.6/tests/test_principals_offline.py +169 -0
- {plexavo-0.4.4 → plexavo-0.4.6}/tests/test_report_offline.py +11 -0
- {plexavo-0.4.4 → plexavo-0.4.6}/tests/test_storage_offline.py +75 -2
- {plexavo-0.4.4 → plexavo-0.4.6}/tests/test_usage_offline.py +113 -4
- {plexavo-0.4.4 → plexavo-0.4.6}/LICENSE +0 -0
- {plexavo-0.4.4 → plexavo-0.4.6}/plexavo/__main__.py +0 -0
- {plexavo-0.4.4 → plexavo-0.4.6}/plexavo/attack_paths.py +0 -0
- {plexavo-0.4.4 → plexavo-0.4.6}/plexavo/auth.py +0 -0
- {plexavo-0.4.4 → plexavo-0.4.6}/plexavo/aws_profile_setup.py +0 -0
- {plexavo-0.4.4 → plexavo-0.4.6}/plexavo/checks/__init__.py +0 -0
- {plexavo-0.4.4 → plexavo-0.4.6}/plexavo/checks/encryption.py +0 -0
- {plexavo-0.4.4 → plexavo-0.4.6}/plexavo/checks/iam_hygiene.py +0 -0
- {plexavo-0.4.4 → plexavo-0.4.6}/plexavo/checks/logging.py +0 -0
- {plexavo-0.4.4 → plexavo-0.4.6}/plexavo/checks/network.py +0 -0
- {plexavo-0.4.4 → plexavo-0.4.6}/plexavo/findings.py +0 -0
- {plexavo-0.4.4 → plexavo-0.4.6}/plexavo/interactive.py +0 -0
- {plexavo-0.4.4 → plexavo-0.4.6}/plexavo/report/__init__.py +0 -0
- {plexavo-0.4.4 → plexavo-0.4.6}/plexavo/report/ai_narration.py +0 -0
- {plexavo-0.4.4 → plexavo-0.4.6}/plexavo/report/fonts/DejaVuSans-Bold.ttf +0 -0
- {plexavo-0.4.4 → plexavo-0.4.6}/plexavo/report/fonts/DejaVuSans-BoldOblique.ttf +0 -0
- {plexavo-0.4.4 → plexavo-0.4.6}/plexavo/report/fonts/DejaVuSans-Oblique.ttf +0 -0
- {plexavo-0.4.4 → plexavo-0.4.6}/plexavo/report/fonts/DejaVuSans.ttf +0 -0
- {plexavo-0.4.4 → plexavo-0.4.6}/plexavo/report/fonts/GEIST-FONT-LICENSE.txt +0 -0
- {plexavo-0.4.4 → plexavo-0.4.6}/plexavo/report/html_report.py +0 -0
- {plexavo-0.4.4 → plexavo-0.4.6}/plexavo/report/json_report.py +0 -0
- {plexavo-0.4.4 → plexavo-0.4.6}/plexavo/report/pdf.py +0 -0
- {plexavo-0.4.4 → plexavo-0.4.6}/plexavo/scoring.py +0 -0
- {plexavo-0.4.4 → plexavo-0.4.6}/plexavo.egg-info/dependency_links.txt +0 -0
- {plexavo-0.4.4 → plexavo-0.4.6}/plexavo.egg-info/entry_points.txt +0 -0
- {plexavo-0.4.4 → plexavo-0.4.6}/plexavo.egg-info/requires.txt +0 -0
- {plexavo-0.4.4 → plexavo-0.4.6}/plexavo.egg-info/top_level.txt +0 -0
- {plexavo-0.4.4 → plexavo-0.4.6}/setup.cfg +0 -0
- {plexavo-0.4.4 → plexavo-0.4.6}/tests/test_ai_narration_offline.py +0 -0
- {plexavo-0.4.4 → plexavo-0.4.6}/tests/test_auth_offline.py +0 -0
- {plexavo-0.4.4 → plexavo-0.4.6}/tests/test_cli_entrypoint.py +0 -0
- {plexavo-0.4.4 → plexavo-0.4.6}/tests/test_encryption_offline.py +0 -0
- {plexavo-0.4.4 → plexavo-0.4.6}/tests/test_fail_on_offline.py +0 -0
- {plexavo-0.4.4 → plexavo-0.4.6}/tests/test_iam_hygiene_offline.py +0 -0
- {plexavo-0.4.4 → plexavo-0.4.6}/tests/test_json_report_offline.py +0 -0
- {plexavo-0.4.4 → plexavo-0.4.6}/tests/test_logging_offline.py +0 -0
- {plexavo-0.4.4 → plexavo-0.4.6}/tests/test_network_offline.py +0 -0
- {plexavo-0.4.4 → plexavo-0.4.6}/tests/test_scoring.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: plexavo
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.6
|
|
4
4
|
Summary: Open-source AWS misconfiguration scanner — runs with your own local AWS credentials, nothing sent to anyone else.
|
|
5
5
|
Author: Kavee
|
|
6
6
|
License: AGPL-3.0-or-later
|
|
@@ -85,12 +85,13 @@ command to fix it.
|
|
|
85
85
|
|
|
86
86
|
## What it checks
|
|
87
87
|
|
|
88
|
-
**
|
|
88
|
+
**33 checks across 7 categories**, run against real AWS accounts:
|
|
89
89
|
|
|
90
90
|
| Category | What Plexavo looks for |
|
|
91
91
|
|---|---|
|
|
92
92
|
| **IAM** | Privilege escalation paths, wildcard admin, cross-account trust, root usage, dormant credentials |
|
|
93
93
|
| **Network** | Security groups and RDS instances exposed to the internet |
|
|
94
|
+
| **EC2 Hardening** | Instance metadata service (IMDS) not requiring IMDSv2 - the setting behind the 2019 Capital One breach |
|
|
94
95
|
| **Storage** | Public S3 buckets via ACLs, bucket policies, or missing Block Public Access; buckets with no access logging |
|
|
95
96
|
| **Encryption** | Unencrypted EBS volumes, RDS instances, S3 buckets |
|
|
96
97
|
| **Logging** | CloudTrail coverage and encryption, GuardDuty status |
|
|
@@ -56,12 +56,13 @@ command to fix it.
|
|
|
56
56
|
|
|
57
57
|
## What it checks
|
|
58
58
|
|
|
59
|
-
**
|
|
59
|
+
**33 checks across 7 categories**, run against real AWS accounts:
|
|
60
60
|
|
|
61
61
|
| Category | What Plexavo looks for |
|
|
62
62
|
|---|---|
|
|
63
63
|
| **IAM** | Privilege escalation paths, wildcard admin, cross-account trust, root usage, dormant credentials |
|
|
64
64
|
| **Network** | Security groups and RDS instances exposed to the internet |
|
|
65
|
+
| **EC2 Hardening** | Instance metadata service (IMDS) not requiring IMDSv2 - the setting behind the 2019 Capital One breach |
|
|
65
66
|
| **Storage** | Public S3 buckets via ACLs, bucket policies, or missing Block Public Access; buckets with no access logging |
|
|
66
67
|
| **Encryption** | Unencrypted EBS volumes, RDS instances, S3 buckets |
|
|
67
68
|
| **Logging** | CloudTrail coverage and encryption, GuardDuty status |
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Category 7: EC2 Instance Hardening — check 32.
|
|
2
|
+
|
|
3
|
+
Added after scanning a hand-built "capital_one stack" terraform demo that
|
|
4
|
+
reproduces the 2019 Capital One breach shape (internet-facing instance,
|
|
5
|
+
IMDSv1 reachable, an over-permissioned role attached) and finding Plexavo
|
|
6
|
+
had nothing at all looking at the metadata service — the exact setting
|
|
7
|
+
that let the real attack escalate an SSRF into stolen AWS credentials.
|
|
8
|
+
|
|
9
|
+
Deliberately its own category rather than folded into Network: NET-01/02/03
|
|
10
|
+
answer "can something on the internet reach a port on this instance,"
|
|
11
|
+
which is a security-group question. IMDSv1 reachability is not gated by
|
|
12
|
+
the instance's own security group at all — the request that matters is
|
|
13
|
+
loopback-style, from a process already running ON the instance (an SSRF
|
|
14
|
+
bug in the app is the classic path) to the link-local metadata address
|
|
15
|
+
169.254.169.254. So, unlike every NET-0x check, this one deliberately does
|
|
16
|
+
NOT require a public IP or an internet-open security group rule to fire —
|
|
17
|
+
gating it on network exposure the way NET-01/02/03 do would make it blind
|
|
18
|
+
to the exact scenario it exists to catch.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from plexavo.findings import Finding, Severity
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def list_instances_with_metadata(ec2) -> list[dict]:
|
|
25
|
+
"""{'instance_id', 'name', 'http_tokens', 'http_endpoint', 'has_role'}
|
|
26
|
+
for every running/pending instance. Stopped instances are excluded —
|
|
27
|
+
same reasoning as network.py's list_public_instances: a finding should
|
|
28
|
+
describe what's actually reachable right now, not a past or future
|
|
29
|
+
state. MetadataOptions is always present on a real DescribeInstances
|
|
30
|
+
response; the .get() defaults here only guard hand-built test fixtures
|
|
31
|
+
that omit it."""
|
|
32
|
+
instances = []
|
|
33
|
+
paginator = ec2.get_paginator("describe_instances")
|
|
34
|
+
for page in paginator.paginate(Filters=[
|
|
35
|
+
{"Name": "instance-state-name", "Values": ["pending", "running"]}
|
|
36
|
+
]):
|
|
37
|
+
for reservation in page["Reservations"]:
|
|
38
|
+
for instance in reservation["Instances"]:
|
|
39
|
+
name = next(
|
|
40
|
+
(t["Value"] for t in instance.get("Tags", []) if t["Key"] == "Name"),
|
|
41
|
+
instance["InstanceId"],
|
|
42
|
+
)
|
|
43
|
+
metadata_options = instance.get("MetadataOptions", {})
|
|
44
|
+
instances.append({
|
|
45
|
+
"instance_id": instance["InstanceId"],
|
|
46
|
+
"name": name,
|
|
47
|
+
"http_tokens": metadata_options.get("HttpTokens", "optional"),
|
|
48
|
+
"http_endpoint": metadata_options.get("HttpEndpoint", "enabled"),
|
|
49
|
+
"has_role": bool(instance.get("IamInstanceProfile")),
|
|
50
|
+
})
|
|
51
|
+
return instances
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def check_32_imdsv2_not_enforced(instances: list[dict]) -> list[Finding]:
|
|
55
|
+
"""EC2-32: the instance's metadata service accepts unauthenticated
|
|
56
|
+
IMDSv1 requests (HttpTokens != 'required') instead of requiring the
|
|
57
|
+
session-token-authenticated IMDSv2.
|
|
58
|
+
|
|
59
|
+
An instance with HttpEndpoint == 'disabled' has no metadata service to
|
|
60
|
+
reach at all — correctly not flagged, that's the strongest posture,
|
|
61
|
+
not a gap.
|
|
62
|
+
|
|
63
|
+
Severity is genuinely different depending on whether there's anything
|
|
64
|
+
for a stolen token to retrieve:
|
|
65
|
+
- CRITICAL when an IAM instance profile is attached — this is the
|
|
66
|
+
exact chain the 2019 Capital One breach used: SSRF into the app,
|
|
67
|
+
SSRF used to query IMDS, IMDSv1 hands back the attached role's
|
|
68
|
+
temporary credentials with no proof the caller ever reached IMDS
|
|
69
|
+
intentionally, credentials used to reach whatever that role can
|
|
70
|
+
reach.
|
|
71
|
+
- MEDIUM when no role is attached — still a real hardening gap (the
|
|
72
|
+
metadata service still exposes the instance identity document, and
|
|
73
|
+
any secrets accidentally left in user-data), but there's no AWS
|
|
74
|
+
credential sitting behind this specific instance for IMDSv1 to hand
|
|
75
|
+
over, so it doesn't get the same severity as the credential-theft
|
|
76
|
+
case above."""
|
|
77
|
+
findings = []
|
|
78
|
+
for inst in instances:
|
|
79
|
+
if inst["http_endpoint"] != "enabled":
|
|
80
|
+
continue
|
|
81
|
+
if inst["http_tokens"] == "required":
|
|
82
|
+
continue
|
|
83
|
+
if inst["has_role"]:
|
|
84
|
+
severity = Severity.CRITICAL
|
|
85
|
+
role_note = (
|
|
86
|
+
"This instance also has an IAM role attached, so a stolen session token "
|
|
87
|
+
"can retrieve that role's real, temporary AWS credentials — this is the "
|
|
88
|
+
"exact chain (SSRF -> IMDSv1 -> stolen instance-role credentials) that "
|
|
89
|
+
"caused the 2019 Capital One breach."
|
|
90
|
+
)
|
|
91
|
+
else:
|
|
92
|
+
severity = Severity.MEDIUM
|
|
93
|
+
role_note = (
|
|
94
|
+
"No IAM role is attached to this instance, so there are no AWS "
|
|
95
|
+
"credentials for a stolen token to retrieve here — but the metadata "
|
|
96
|
+
"service can still leak the instance identity document and any secrets "
|
|
97
|
+
"left in user-data."
|
|
98
|
+
)
|
|
99
|
+
findings.append(Finding(
|
|
100
|
+
check_id="EC2-32",
|
|
101
|
+
title="IMDSv2 Not Enforced",
|
|
102
|
+
severity=severity,
|
|
103
|
+
resource_arn=inst["instance_id"],
|
|
104
|
+
raw_detail=f"EC2 instance '{inst['name']}' ({inst['instance_id']}) accepts the "
|
|
105
|
+
f"legacy, unauthenticated IMDSv1 metadata requests instead of "
|
|
106
|
+
f"requiring IMDSv2's session token. {role_note}",
|
|
107
|
+
account_context=f"iam_role_attached={inst['has_role']}",
|
|
108
|
+
evidence=f"http_tokens={inst['http_tokens']}, iam_role_attached={inst['has_role']}",
|
|
109
|
+
))
|
|
110
|
+
return findings
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def run_all(session) -> list[Finding]:
|
|
114
|
+
"""Run EC2-32 against every running/pending instance in the account."""
|
|
115
|
+
ec2 = session.client("ec2")
|
|
116
|
+
instances = list_instances_with_metadata(ec2)
|
|
117
|
+
return check_32_imdsv2_not_enforced(instances)
|
|
@@ -40,9 +40,11 @@ from plexavo.principals import (
|
|
|
40
40
|
statement_has_wildcard_action,
|
|
41
41
|
resource_is_wildcard,
|
|
42
42
|
resource_includes,
|
|
43
|
+
resource_grants_any_role,
|
|
43
44
|
find_blocking_deny,
|
|
44
45
|
has_full_wildcard_deny,
|
|
45
46
|
action_within_boundary,
|
|
47
|
+
_condition_adjustment,
|
|
46
48
|
_normalize,
|
|
47
49
|
)
|
|
48
50
|
|
|
@@ -88,25 +90,6 @@ def _all_statements(principal: Principal):
|
|
|
88
90
|
yield policy_name, stmt
|
|
89
91
|
|
|
90
92
|
|
|
91
|
-
def _condition_adjustment(stmt: dict) -> tuple[Severity, str, 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 via confidence/evidence — never as free text baked into
|
|
95
|
-
raw_detail, so the report can show "this needs a human look" as a
|
|
96
|
-
distinct, structured fact instead of a sentence buried in prose.
|
|
97
|
-
Returns (severity, confidence, evidence)."""
|
|
98
|
-
condition = stmt.get("Condition")
|
|
99
|
-
if condition:
|
|
100
|
-
keys = ", ".join(condition.keys())
|
|
101
|
-
return (
|
|
102
|
-
Severity.HIGH,
|
|
103
|
-
"Likely — see note",
|
|
104
|
-
f"Grant is scoped by a Condition block ({keys}) — not evaluated automatically; "
|
|
105
|
-
f"whether it meaningfully restricts access needs a manual look.",
|
|
106
|
-
)
|
|
107
|
-
return Severity.CRITICAL, "Confirmed", ""
|
|
108
|
-
|
|
109
|
-
|
|
110
93
|
def _apply_deny_and_boundary(principal: Principal, action: str, resource_arn: str | None,
|
|
111
94
|
severity: Severity, confidence: str, evidence: str):
|
|
112
95
|
"""Combined evaluator: explicit Deny (identity + boundary) can suppress
|
|
@@ -394,11 +377,11 @@ def check_05_assumerole_chain_to_admin(principals: list[Principal]) -> list[Find
|
|
|
394
377
|
continue
|
|
395
378
|
if not statement_grants(stmt, {"sts:AssumeRole"}):
|
|
396
379
|
continue
|
|
397
|
-
if
|
|
398
|
-
continue # that's check IAM-06, not IAM-05
|
|
380
|
+
if resource_grants_any_role(stmt):
|
|
381
|
+
continue # that's check IAM-06, not IAM-05 - includes "role/*", not just a literal "*"
|
|
399
382
|
base_severity, base_confidence, base_evidence = _condition_adjustment(stmt)
|
|
400
|
-
for target_arn in
|
|
401
|
-
if target_arn
|
|
383
|
+
for target_arn in admin_roles:
|
|
384
|
+
if target_arn == p.arn or not resource_includes(stmt, target_arn):
|
|
402
385
|
continue
|
|
403
386
|
result = _apply_deny_and_boundary(p, "sts:AssumeRole", target_arn, base_severity, base_confidence, base_evidence)
|
|
404
387
|
if result is None:
|
|
@@ -422,7 +405,18 @@ def check_05_assumerole_chain_to_admin(principals: list[Principal]) -> list[Find
|
|
|
422
405
|
|
|
423
406
|
|
|
424
407
|
def check_06_wildcard_assumerole(principals: list[Principal]) -> list[Finding]:
|
|
425
|
-
"""IAM-06: sts:AssumeRole on effectively Resource:* — can assume any role
|
|
408
|
+
"""IAM-06: sts:AssumeRole on effectively Resource:* — can assume any role
|
|
409
|
+
in the account. Fires on a literal "*" AND on a role-scoped prefix
|
|
410
|
+
wildcard like "arn:aws:iam::123456789012:role/*" (resource_grants_any_role)
|
|
411
|
+
- the previous version only recognized the literal "*" case, silently
|
|
412
|
+
missing the far more common role/* form (Hall of Bugs, found by
|
|
413
|
+
fusiontechstrategies). Known residual gap, not expanded here: a Deny or
|
|
414
|
+
boundary scoped to the same role/* pattern (rather than a literal "*")
|
|
415
|
+
is not recognized as suppressing this finding, since
|
|
416
|
+
_apply_deny_and_boundary's resource_arn=None path only checks
|
|
417
|
+
resource_is_wildcard on the Deny/boundary side too - narrower than a
|
|
418
|
+
fix would need to be, but the safe-direction failure mode (a rare
|
|
419
|
+
over-report, never an under-report)."""
|
|
426
420
|
findings = []
|
|
427
421
|
for p in principals:
|
|
428
422
|
for policy_name, stmt in _all_statements(p):
|
|
@@ -430,7 +424,7 @@ def check_06_wildcard_assumerole(principals: list[Principal]) -> list[Finding]:
|
|
|
430
424
|
continue
|
|
431
425
|
if not statement_grants(stmt, {"sts:AssumeRole"}):
|
|
432
426
|
continue
|
|
433
|
-
if not
|
|
427
|
+
if not resource_grants_any_role(stmt):
|
|
434
428
|
continue
|
|
435
429
|
severity, confidence, evidence = _condition_adjustment(stmt)
|
|
436
430
|
result = _apply_deny_and_boundary(p, "sts:AssumeRole", None, severity, confidence, evidence)
|
|
@@ -15,7 +15,7 @@ import json
|
|
|
15
15
|
from botocore.exceptions import ClientError
|
|
16
16
|
|
|
17
17
|
from plexavo.findings import Finding, Severity
|
|
18
|
-
from plexavo.principals import _normalize
|
|
18
|
+
from plexavo.principals import _normalize, _condition_adjustment
|
|
19
19
|
|
|
20
20
|
PUBLIC_GROUP_URIS = {
|
|
21
21
|
"http://acs.amazonaws.com/groups/global/AllUsers": "AllUsers (anyone on the internet, no AWS account needed)",
|
|
@@ -29,7 +29,7 @@ def list_buckets(s3) -> list:
|
|
|
29
29
|
return [b["Name"] for b in s3.list_buckets()["Buckets"]]
|
|
30
30
|
|
|
31
31
|
|
|
32
|
-
def check_19_missing_public_access_block(s3, bucket_names: list) -> list[Finding]:
|
|
32
|
+
def check_19_missing_public_access_block(s3, bucket_names: list, skipped: set | None = None) -> list[Finding]:
|
|
33
33
|
"""STOR-19: bucket-level PublicAccessBlock missing entirely, or present
|
|
34
34
|
but with any of the 4 settings disabled.
|
|
35
35
|
|
|
@@ -39,7 +39,14 @@ def check_19_missing_public_access_block(s3, bucket_names: list) -> list[Finding
|
|
|
39
39
|
distinct exception shape (unlike, say, IAM's NoSuchEntityException),
|
|
40
40
|
so the dynamic-exception-class pattern silently never matches and the
|
|
41
41
|
code never raises AttributeError only because it's also never
|
|
42
|
-
triggered — a latent bug, not a working code path, until this fix.
|
|
42
|
+
triggered — a latent bug, not a working code path, until this fix.
|
|
43
|
+
|
|
44
|
+
A bucket the caller's own principal can't read (AccessDenied, e.g. an
|
|
45
|
+
explicit Deny in that bucket's own policy) is skipped for this check
|
|
46
|
+
rather than raised — one unreadable bucket used to crash the entire
|
|
47
|
+
scan (Hall of Bugs, found by fusiontechstrategies). Its name is added
|
|
48
|
+
to `skipped` so the caller can report it as unevaluated instead of
|
|
49
|
+
silently dropping it from coverage."""
|
|
43
50
|
findings = []
|
|
44
51
|
for name in bucket_names:
|
|
45
52
|
try:
|
|
@@ -50,7 +57,12 @@ def check_19_missing_public_access_block(s3, bucket_names: list) -> list[Finding
|
|
|
50
57
|
detail = (f"has PublicAccessBlock configured, but "
|
|
51
58
|
f"{', '.join(off)} {'is' if len(off) == 1 else 'are'} disabled")
|
|
52
59
|
except ClientError as e:
|
|
53
|
-
|
|
60
|
+
code = e.response["Error"]["Code"]
|
|
61
|
+
if code == "AccessDenied":
|
|
62
|
+
if skipped is not None:
|
|
63
|
+
skipped.add(name)
|
|
64
|
+
continue
|
|
65
|
+
if code != "NoSuchPublicAccessBlockConfiguration":
|
|
54
66
|
raise
|
|
55
67
|
detail = "has NO PublicAccessBlock configuration at all — none of the four protections are in place"
|
|
56
68
|
findings.append(Finding(
|
|
@@ -65,16 +77,31 @@ def check_19_missing_public_access_block(s3, bucket_names: list) -> list[Finding
|
|
|
65
77
|
return findings
|
|
66
78
|
|
|
67
79
|
|
|
68
|
-
def check_20_public_bucket_policy(s3, bucket_names: list) -> list[Finding]:
|
|
80
|
+
def check_20_public_bucket_policy(s3, bucket_names: list, skipped: set | None = None) -> list[Finding]:
|
|
69
81
|
"""STOR-20: bucket policy grants Allow to Principal:* (or {"AWS":"*"}).
|
|
70
82
|
Same ClientError pattern as check_19, same real reason — confirmed
|
|
71
|
-
NoSuchBucketPolicy isn't modeled as a distinct S3 exception either.
|
|
83
|
+
NoSuchBucketPolicy isn't modeled as a distinct S3 exception either.
|
|
84
|
+
AccessDenied is skipped, not raised — see check_19's docstring.
|
|
85
|
+
|
|
86
|
+
A Condition block on the statement (e.g. aws:PrincipalOrgID, aws:SourceVpc)
|
|
87
|
+
downgrades Critical/Confirmed to High/"Likely — see note" via the shared
|
|
88
|
+
_condition_adjustment (moved from checks/iam.py, same mechanism every IAM
|
|
89
|
+
check already used) instead of asserting unconditional public access —
|
|
90
|
+
Hall of Bugs, found by fusiontechstrategies: a Principal:* bucket policy
|
|
91
|
+
scoped to an org/VPC via Condition is a common, legitimate pattern
|
|
92
|
+
(CloudFront OAC, org-wide internal buckets), and this previously fired
|
|
93
|
+
at full Critical/Confirmed regardless."""
|
|
72
94
|
findings = []
|
|
73
95
|
for name in bucket_names:
|
|
74
96
|
try:
|
|
75
97
|
policy_str = s3.get_bucket_policy(Bucket=name)["Policy"]
|
|
76
98
|
except ClientError as e:
|
|
77
|
-
|
|
99
|
+
code = e.response["Error"]["Code"]
|
|
100
|
+
if code == "AccessDenied":
|
|
101
|
+
if skipped is not None:
|
|
102
|
+
skipped.add(name)
|
|
103
|
+
continue
|
|
104
|
+
if code != "NoSuchBucketPolicy":
|
|
78
105
|
raise
|
|
79
106
|
continue
|
|
80
107
|
policy = json.loads(policy_str)
|
|
@@ -88,25 +115,42 @@ def check_20_public_bucket_policy(s3, bucket_names: list) -> list[Finding]:
|
|
|
88
115
|
if not is_public:
|
|
89
116
|
continue
|
|
90
117
|
actions = _normalize(stmt.get("Action"))
|
|
118
|
+
severity, confidence, evidence = _condition_adjustment(stmt)
|
|
119
|
+
if stmt.get("Condition"):
|
|
120
|
+
access_phrase = ("to Principal:* — but scoped by a Condition block, so whether "
|
|
121
|
+
"this is actually publicly reachable depends on that condition; "
|
|
122
|
+
"see evidence")
|
|
123
|
+
else:
|
|
124
|
+
access_phrase = ("to Principal:* — anyone on the internet, no AWS account "
|
|
125
|
+
"required, can perform these actions")
|
|
91
126
|
findings.append(Finding(
|
|
92
127
|
check_id="STOR-20",
|
|
93
128
|
title="S3 Bucket Policy Allows Public Access",
|
|
94
|
-
severity=
|
|
129
|
+
severity=severity,
|
|
95
130
|
resource_arn=f"arn:aws:s3:::{name}",
|
|
96
131
|
raw_detail=f"Bucket '{name}' has a bucket policy statement granting "
|
|
97
|
-
f"{', '.join(actions)}
|
|
98
|
-
f"no AWS account required, can perform these actions.",
|
|
132
|
+
f"{', '.join(actions)} {access_phrase}.",
|
|
99
133
|
account_context=f"bucket={name}",
|
|
134
|
+
confidence=confidence,
|
|
135
|
+
evidence=evidence,
|
|
100
136
|
))
|
|
101
137
|
return findings
|
|
102
138
|
|
|
103
139
|
|
|
104
|
-
def check_21_public_acl(s3, bucket_names: list) -> list[Finding]:
|
|
140
|
+
def check_21_public_acl(s3, bucket_names: list, skipped: set | None = None) -> list[Finding]:
|
|
105
141
|
"""STOR-21: bucket ACL grants any permission to the AllUsers or
|
|
106
|
-
AuthenticatedUsers built-in groups.
|
|
142
|
+
AuthenticatedUsers built-in groups. AccessDenied is skipped, not
|
|
143
|
+
raised — see check_19's docstring."""
|
|
107
144
|
findings = []
|
|
108
145
|
for name in bucket_names:
|
|
109
|
-
|
|
146
|
+
try:
|
|
147
|
+
acl = s3.get_bucket_acl(Bucket=name)
|
|
148
|
+
except ClientError as e:
|
|
149
|
+
if e.response["Error"]["Code"] != "AccessDenied":
|
|
150
|
+
raise
|
|
151
|
+
if skipped is not None:
|
|
152
|
+
skipped.add(name)
|
|
153
|
+
continue
|
|
110
154
|
for grant in acl.get("Grants", []):
|
|
111
155
|
grantee = grant.get("Grantee", {})
|
|
112
156
|
if grantee.get("Type") != "Group":
|
|
@@ -128,7 +172,7 @@ def check_21_public_acl(s3, bucket_names: list) -> list[Finding]:
|
|
|
128
172
|
return findings
|
|
129
173
|
|
|
130
174
|
|
|
131
|
-
def check_22_access_logging_disabled(s3, bucket_names: list) -> list[Finding]:
|
|
175
|
+
def check_22_access_logging_disabled(s3, bucket_names: list, skipped: set | None = None) -> list[Finding]:
|
|
132
176
|
"""STOR-22: bucket has no server access logging configured.
|
|
133
177
|
|
|
134
178
|
GetBucketLogging always returns 200, even when logging was never set
|
|
@@ -142,10 +186,19 @@ def check_22_access_logging_disabled(s3, bucket_names: list) -> list[Finding]:
|
|
|
142
186
|
it on. Whether object-level access is actually unreviewable after the
|
|
143
187
|
fact depends on whether it's captured another way (e.g. CloudTrail S3
|
|
144
188
|
data events), which Plexavo does not check — so the finding language
|
|
145
|
-
stays careful not to claim investigation is impossible.
|
|
189
|
+
stays careful not to claim investigation is impossible.
|
|
190
|
+
|
|
191
|
+
AccessDenied is skipped, not raised — see check_19's docstring."""
|
|
146
192
|
findings = []
|
|
147
193
|
for name in bucket_names:
|
|
148
|
-
|
|
194
|
+
try:
|
|
195
|
+
config = s3.get_bucket_logging(Bucket=name)
|
|
196
|
+
except ClientError as e:
|
|
197
|
+
if e.response["Error"]["Code"] != "AccessDenied":
|
|
198
|
+
raise
|
|
199
|
+
if skipped is not None:
|
|
200
|
+
skipped.add(name)
|
|
201
|
+
continue
|
|
149
202
|
if "LoggingEnabled" in config:
|
|
150
203
|
continue
|
|
151
204
|
findings.append(Finding(
|
|
@@ -165,14 +218,21 @@ def check_22_access_logging_disabled(s3, bucket_names: list) -> list[Finding]:
|
|
|
165
218
|
return findings
|
|
166
219
|
|
|
167
220
|
|
|
168
|
-
def run_all(session) -> list[Finding]:
|
|
221
|
+
def run_all(session) -> tuple[list[Finding], list[str]]:
|
|
169
222
|
"""Run STOR-19 through STOR-22 against every bucket in the account —
|
|
170
|
-
not just testbed buckets. A real scan must check everything.
|
|
223
|
+
not just testbed buckets. A real scan must check everything.
|
|
224
|
+
|
|
225
|
+
Returns (findings, skipped_buckets): a bucket the caller's principal
|
|
226
|
+
can't read (AccessDenied on any of the 4 per-bucket calls) is skipped
|
|
227
|
+
rather than crashing the whole scan, and its name comes back in
|
|
228
|
+
skipped_buckets so the caller can report it as unevaluated instead of
|
|
229
|
+
silently under-reporting. See check_19's docstring for why."""
|
|
171
230
|
s3 = session.client("s3")
|
|
172
231
|
bucket_names = list_buckets(s3)
|
|
232
|
+
skipped: set = set()
|
|
173
233
|
findings = []
|
|
174
|
-
findings += check_19_missing_public_access_block(s3, bucket_names)
|
|
175
|
-
findings += check_20_public_bucket_policy(s3, bucket_names)
|
|
176
|
-
findings += check_21_public_acl(s3, bucket_names)
|
|
177
|
-
findings += check_22_access_logging_disabled(s3, bucket_names)
|
|
178
|
-
return findings
|
|
234
|
+
findings += check_19_missing_public_access_block(s3, bucket_names, skipped)
|
|
235
|
+
findings += check_20_public_bucket_policy(s3, bucket_names, skipped)
|
|
236
|
+
findings += check_21_public_acl(s3, bucket_names, skipped)
|
|
237
|
+
findings += check_22_access_logging_disabled(s3, bucket_names, skipped)
|
|
238
|
+
return findings, sorted(skipped)
|
|
@@ -71,6 +71,22 @@ Real, stated scope limits for check_26, not hidden:
|
|
|
71
71
|
yet — not because they're provably excess. This is a real, structural
|
|
72
72
|
limitation of usage-based analysis on quiet accounts, not a bug to
|
|
73
73
|
fix; noted here so it isn't mistaken for one during grading.
|
|
74
|
+
- CONFIRMED REAL BUG, found via a Hall of Bugs report (fusiontechstrategies):
|
|
75
|
+
CloudTrail's LookupEvents API returns MANAGEMENT events only, never data
|
|
76
|
+
events, regardless of trail configuration or actual activity — confirmed
|
|
77
|
+
against AWS's own CloudTrail documentation. Actions like s3:GetObject/
|
|
78
|
+
s3:PutObject, dynamodb:GetItem/PutItem, lambda:InvokeFunction, KMS crypto
|
|
79
|
+
operations, and SQS message operations are ALL data-plane, so a role
|
|
80
|
+
actively calling them nonstop still shows zero usage in this check's
|
|
81
|
+
source data — the exact opposite of "unused." Fixed the same way the
|
|
82
|
+
wildcard-action gap above is already handled: _KNOWN_DATA_PLANE_ACTIONS
|
|
83
|
+
excludes them from _explicit_granted_actions' candidate set entirely,
|
|
84
|
+
rather than confidently (and wrongly) calling them unused. That list is a
|
|
85
|
+
small, explicitly non-exhaustive set of the well-documented, high-
|
|
86
|
+
confidence AWS CloudTrail data-event categories — same honesty pattern as
|
|
87
|
+
_IAM_TO_CLOUDTRAIL_EVENTNAME_EXCEPTIONS below, not a full per-service
|
|
88
|
+
audit. A data-plane action for a service not yet in this list can still
|
|
89
|
+
be incorrectly flagged until confirmed through real use and added.
|
|
74
90
|
"""
|
|
75
91
|
|
|
76
92
|
import json
|
|
@@ -88,6 +104,27 @@ _IAM_TO_CLOUDTRAIL_EVENTNAME_EXCEPTIONS = {
|
|
|
88
104
|
"s3:listallmybuckets": "s3:listbuckets",
|
|
89
105
|
}
|
|
90
106
|
|
|
107
|
+
_KNOWN_DATA_PLANE_ACTIONS = {
|
|
108
|
+
# Actions CloudTrail's LookupEvents can NEVER see, regardless of how
|
|
109
|
+
# actively they're called — see module docstring (Hall of Bugs, found
|
|
110
|
+
# by fusiontechstrategies). A small, explicitly non-exhaustive set of
|
|
111
|
+
# the well-documented AWS CloudTrail data-event categories, not a full
|
|
112
|
+
# per-service audit. Add entries here as more are confirmed, don't
|
|
113
|
+
# assume this list is complete.
|
|
114
|
+
"s3:getobject", "s3:putobject", "s3:deleteobject",
|
|
115
|
+
"s3:getobjectacl", "s3:putobjectacl",
|
|
116
|
+
"s3:getobjecttagging", "s3:putobjecttagging", "s3:deleteobjecttagging",
|
|
117
|
+
"s3:getobjectversion", "s3:deleteobjectversion", "s3:restoreobject",
|
|
118
|
+
"dynamodb:getitem", "dynamodb:putitem", "dynamodb:updateitem", "dynamodb:deleteitem",
|
|
119
|
+
"dynamodb:query", "dynamodb:scan",
|
|
120
|
+
"dynamodb:batchgetitem", "dynamodb:batchwriteitem",
|
|
121
|
+
"dynamodb:transactgetitems", "dynamodb:transactwriteitems",
|
|
122
|
+
"lambda:invokefunction",
|
|
123
|
+
"kms:decrypt", "kms:encrypt", "kms:generatedatakey",
|
|
124
|
+
"kms:generatedatakeywithoutplaintext", "kms:reencrypt",
|
|
125
|
+
"sqs:sendmessage", "sqs:receivemessage", "sqs:deletemessage",
|
|
126
|
+
}
|
|
127
|
+
|
|
91
128
|
|
|
92
129
|
def _is_action_used(granted_action: str, used_actions: set) -> bool:
|
|
93
130
|
"""True if granted_action was actually used — checked directly, or
|
|
@@ -103,7 +140,11 @@ def _explicit_granted_actions(principal: Principal) -> set:
|
|
|
103
140
|
Allow statements grant. Excludes '*', any 'service:*' wildcard, Deny
|
|
104
141
|
statements, and NotAction statements (same reasoning as wildcards —
|
|
105
142
|
NotAction implies an unbounded 'everything except X' set, not a
|
|
106
|
-
concrete list to check usage against)
|
|
143
|
+
concrete list to check usage against), AND any known data-plane
|
|
144
|
+
action (see _KNOWN_DATA_PLANE_ACTIONS / module docstring) — CloudTrail
|
|
145
|
+
LookupEvents structurally cannot observe those either way, so they're
|
|
146
|
+
excluded from the candidate set the same way wildcards are, not
|
|
147
|
+
confidently called 'unused'."""
|
|
107
148
|
granted = set()
|
|
108
149
|
for _, statements in principal.policies:
|
|
109
150
|
for stmt in statements:
|
|
@@ -121,6 +162,8 @@ def _explicit_granted_actions(principal: Principal) -> set:
|
|
|
121
162
|
# slipping through the old check (action.endswith(":*")),
|
|
122
163
|
# which only matches the exact literal ":*" suffix.
|
|
123
164
|
continue
|
|
165
|
+
if action in _KNOWN_DATA_PLANE_ACTIONS:
|
|
166
|
+
continue
|
|
124
167
|
granted.add(action)
|
|
125
168
|
return granted
|
|
126
169
|
|
|
@@ -200,6 +243,16 @@ def check_27_roles_not_assumed(iam_roles_raw: list, lookback_days: int = 90) ->
|
|
|
200
243
|
window, using IAM's own RoleLastUsed field directly — no CloudTrail
|
|
201
244
|
needed, same pattern as IAM-09/10.
|
|
202
245
|
|
|
246
|
+
iam_roles_raw must come from per-role get_role calls, NOT list_roles
|
|
247
|
+
directly — confirmed unreliable (Hall of Bugs, found by
|
|
248
|
+
fusiontechstrategies): a role assumed minutes earlier, with RoleLastUsed
|
|
249
|
+
correctly populated via get_role, can still come back empty from
|
|
250
|
+
list_roles, making this check flag every role in the account as "never
|
|
251
|
+
assumed" regardless of actual activity. Same fix already applied to
|
|
252
|
+
PermissionsBoundary in principals.py — get_role per role is the
|
|
253
|
+
documented-reliable path, list_roles' own summary data isn't. See
|
|
254
|
+
run_all() below for where the per-role fetch happens.
|
|
255
|
+
|
|
203
256
|
AWS-managed service-linked roles (path starts with
|
|
204
257
|
/aws-service-role/) are deliberately excluded — confirmed as a real
|
|
205
258
|
gap via a live scan that flagged AWSServiceRoleForRDS,
|
|
@@ -249,10 +302,16 @@ def run_all(session, principals: list) -> list:
|
|
|
249
302
|
cloudtrail = session.client("cloudtrail")
|
|
250
303
|
iam = session.client("iam")
|
|
251
304
|
|
|
252
|
-
|
|
305
|
+
role_names = []
|
|
253
306
|
paginator = iam.get_paginator("list_roles")
|
|
254
307
|
for page in paginator.paginate():
|
|
255
|
-
|
|
308
|
+
role_names.extend(r["RoleName"] for r in page["Roles"])
|
|
309
|
+
|
|
310
|
+
# list_roles' own RoleLastUsed is unreliable — see check_27's docstring.
|
|
311
|
+
# get_role per role, one extra call each, is the documented-reliable
|
|
312
|
+
# path; the same tradeoff principals.py already makes for
|
|
313
|
+
# PermissionsBoundary.
|
|
314
|
+
roles_raw = [iam.get_role(RoleName=name)["Role"] for name in role_names]
|
|
256
315
|
|
|
257
316
|
findings = []
|
|
258
317
|
findings += check_26_unused_permissions(cloudtrail, principals)
|
|
@@ -31,6 +31,7 @@ from plexavo import attack_paths
|
|
|
31
31
|
from plexavo.checks import iam as iam_checks
|
|
32
32
|
from plexavo.checks import iam_hygiene
|
|
33
33
|
from plexavo.checks import network as network_checks
|
|
34
|
+
from plexavo.checks import compute as compute_checks
|
|
34
35
|
from plexavo.checks import storage as storage_checks
|
|
35
36
|
from plexavo.checks import encryption as encryption_checks
|
|
36
37
|
from plexavo.checks import logging as logging_checks
|
|
@@ -203,7 +204,7 @@ def _run_scan_impl(args, json_mode: bool) -> None:
|
|
|
203
204
|
principals = []
|
|
204
205
|
chains = []
|
|
205
206
|
stage_num = 0
|
|
206
|
-
total_stages =
|
|
207
|
+
total_stages = 10
|
|
207
208
|
start_time = time.monotonic()
|
|
208
209
|
flavor_pool = FLAVOR_WORDS.copy()
|
|
209
210
|
random.shuffle(flavor_pool)
|
|
@@ -238,8 +239,16 @@ def _run_scan_impl(args, json_mode: bool) -> None:
|
|
|
238
239
|
stage("Running checks NET-01 through NET-04")
|
|
239
240
|
findings += network_checks.run_all(session)
|
|
240
241
|
|
|
242
|
+
stage("Running check EC2-32 (IMDSv2 enforcement)")
|
|
243
|
+
findings += compute_checks.run_all(session)
|
|
244
|
+
|
|
241
245
|
stage("Running checks STOR-19 through STOR-22")
|
|
242
|
-
|
|
246
|
+
storage_findings, skipped_buckets = storage_checks.run_all(session)
|
|
247
|
+
findings += storage_findings
|
|
248
|
+
if skipped_buckets:
|
|
249
|
+
console.print(f"[yellow]{len(skipped_buckets)} bucket(s) could not be fully evaluated for "
|
|
250
|
+
f"STOR-19/20/21/22 (AccessDenied) — treat their public-access/ACL/logging "
|
|
251
|
+
f"status as unknown, not confirmed safe: {', '.join(skipped_buckets)}[/yellow]")
|
|
243
252
|
|
|
244
253
|
stage("Running checks ENC-29 through ENC-31")
|
|
245
254
|
findings += encryption_checks.run_all(session)
|