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.
Files changed (58) hide show
  1. {plexavo-0.4.4 → plexavo-0.4.6}/PKG-INFO +3 -2
  2. {plexavo-0.4.4 → plexavo-0.4.6}/README.md +2 -1
  3. {plexavo-0.4.4 → plexavo-0.4.6}/plexavo/__init__.py +1 -1
  4. plexavo-0.4.6/plexavo/checks/compute.py +117 -0
  5. {plexavo-0.4.4 → plexavo-0.4.6}/plexavo/checks/iam.py +19 -25
  6. {plexavo-0.4.4 → plexavo-0.4.6}/plexavo/checks/storage.py +83 -23
  7. {plexavo-0.4.4 → plexavo-0.4.6}/plexavo/checks/usage.py +62 -3
  8. {plexavo-0.4.4 → plexavo-0.4.6}/plexavo/cli.py +11 -2
  9. {plexavo-0.4.4 → plexavo-0.4.6}/plexavo/principals.py +134 -15
  10. {plexavo-0.4.4 → plexavo-0.4.6}/plexavo/report/templates/report.html.j2 +22 -4
  11. {plexavo-0.4.4 → plexavo-0.4.6}/plexavo.egg-info/PKG-INFO +3 -2
  12. {plexavo-0.4.4 → plexavo-0.4.6}/plexavo.egg-info/SOURCES.txt +3 -0
  13. {plexavo-0.4.4 → plexavo-0.4.6}/pyproject.toml +1 -1
  14. {plexavo-0.4.4 → plexavo-0.4.6}/tests/test_attack_paths_offline.py +31 -0
  15. plexavo-0.4.6/tests/test_compute_offline.py +112 -0
  16. {plexavo-0.4.4 → plexavo-0.4.6}/tests/test_iam_offline.py +23 -0
  17. plexavo-0.4.6/tests/test_principals_offline.py +169 -0
  18. {plexavo-0.4.4 → plexavo-0.4.6}/tests/test_report_offline.py +11 -0
  19. {plexavo-0.4.4 → plexavo-0.4.6}/tests/test_storage_offline.py +75 -2
  20. {plexavo-0.4.4 → plexavo-0.4.6}/tests/test_usage_offline.py +113 -4
  21. {plexavo-0.4.4 → plexavo-0.4.6}/LICENSE +0 -0
  22. {plexavo-0.4.4 → plexavo-0.4.6}/plexavo/__main__.py +0 -0
  23. {plexavo-0.4.4 → plexavo-0.4.6}/plexavo/attack_paths.py +0 -0
  24. {plexavo-0.4.4 → plexavo-0.4.6}/plexavo/auth.py +0 -0
  25. {plexavo-0.4.4 → plexavo-0.4.6}/plexavo/aws_profile_setup.py +0 -0
  26. {plexavo-0.4.4 → plexavo-0.4.6}/plexavo/checks/__init__.py +0 -0
  27. {plexavo-0.4.4 → plexavo-0.4.6}/plexavo/checks/encryption.py +0 -0
  28. {plexavo-0.4.4 → plexavo-0.4.6}/plexavo/checks/iam_hygiene.py +0 -0
  29. {plexavo-0.4.4 → plexavo-0.4.6}/plexavo/checks/logging.py +0 -0
  30. {plexavo-0.4.4 → plexavo-0.4.6}/plexavo/checks/network.py +0 -0
  31. {plexavo-0.4.4 → plexavo-0.4.6}/plexavo/findings.py +0 -0
  32. {plexavo-0.4.4 → plexavo-0.4.6}/plexavo/interactive.py +0 -0
  33. {plexavo-0.4.4 → plexavo-0.4.6}/plexavo/report/__init__.py +0 -0
  34. {plexavo-0.4.4 → plexavo-0.4.6}/plexavo/report/ai_narration.py +0 -0
  35. {plexavo-0.4.4 → plexavo-0.4.6}/plexavo/report/fonts/DejaVuSans-Bold.ttf +0 -0
  36. {plexavo-0.4.4 → plexavo-0.4.6}/plexavo/report/fonts/DejaVuSans-BoldOblique.ttf +0 -0
  37. {plexavo-0.4.4 → plexavo-0.4.6}/plexavo/report/fonts/DejaVuSans-Oblique.ttf +0 -0
  38. {plexavo-0.4.4 → plexavo-0.4.6}/plexavo/report/fonts/DejaVuSans.ttf +0 -0
  39. {plexavo-0.4.4 → plexavo-0.4.6}/plexavo/report/fonts/GEIST-FONT-LICENSE.txt +0 -0
  40. {plexavo-0.4.4 → plexavo-0.4.6}/plexavo/report/html_report.py +0 -0
  41. {plexavo-0.4.4 → plexavo-0.4.6}/plexavo/report/json_report.py +0 -0
  42. {plexavo-0.4.4 → plexavo-0.4.6}/plexavo/report/pdf.py +0 -0
  43. {plexavo-0.4.4 → plexavo-0.4.6}/plexavo/scoring.py +0 -0
  44. {plexavo-0.4.4 → plexavo-0.4.6}/plexavo.egg-info/dependency_links.txt +0 -0
  45. {plexavo-0.4.4 → plexavo-0.4.6}/plexavo.egg-info/entry_points.txt +0 -0
  46. {plexavo-0.4.4 → plexavo-0.4.6}/plexavo.egg-info/requires.txt +0 -0
  47. {plexavo-0.4.4 → plexavo-0.4.6}/plexavo.egg-info/top_level.txt +0 -0
  48. {plexavo-0.4.4 → plexavo-0.4.6}/setup.cfg +0 -0
  49. {plexavo-0.4.4 → plexavo-0.4.6}/tests/test_ai_narration_offline.py +0 -0
  50. {plexavo-0.4.4 → plexavo-0.4.6}/tests/test_auth_offline.py +0 -0
  51. {plexavo-0.4.4 → plexavo-0.4.6}/tests/test_cli_entrypoint.py +0 -0
  52. {plexavo-0.4.4 → plexavo-0.4.6}/tests/test_encryption_offline.py +0 -0
  53. {plexavo-0.4.4 → plexavo-0.4.6}/tests/test_fail_on_offline.py +0 -0
  54. {plexavo-0.4.4 → plexavo-0.4.6}/tests/test_iam_hygiene_offline.py +0 -0
  55. {plexavo-0.4.4 → plexavo-0.4.6}/tests/test_json_report_offline.py +0 -0
  56. {plexavo-0.4.4 → plexavo-0.4.6}/tests/test_logging_offline.py +0 -0
  57. {plexavo-0.4.4 → plexavo-0.4.6}/tests/test_network_offline.py +0 -0
  58. {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.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
- **32 checks across 6 categories**, run against real AWS accounts:
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
- **32 checks across 6 categories**, run against real AWS accounts:
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 |
@@ -4,4 +4,4 @@ Runs entirely with your own local AWS credentials. Nothing is sent to
4
4
  anyone else. See README.md for usage, or `plexavo scan --help`.
5
5
  """
6
6
 
7
- __version__ = "0.4.4"
7
+ __version__ = "0.4.6"
@@ -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 resource_is_wildcard(stmt):
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 _normalize(stmt.get("Resource")):
401
- if target_arn not in admin_roles or target_arn == p.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 in the account."""
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 resource_is_wildcard(stmt):
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
- if e.response["Error"]["Code"] != "NoSuchPublicAccessBlockConfiguration":
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
- if e.response["Error"]["Code"] != "NoSuchBucketPolicy":
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=Severity.CRITICAL,
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)} to Principal:* — anyone on the internet, "
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
- acl = s3.get_bucket_acl(Bucket=name)
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
- config = s3.get_bucket_logging(Bucket=name)
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
- roles_raw = []
305
+ role_names = []
253
306
  paginator = iam.get_paginator("list_roles")
254
307
  for page in paginator.paginate():
255
- roles_raw.extend(page["Roles"])
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 = 9
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
- findings += storage_checks.run_all(session)
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)