plexavo 0.2.8__tar.gz → 0.3.0__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.2.8 → plexavo-0.3.0}/PKG-INFO +7 -1
- {plexavo-0.2.8 → plexavo-0.3.0}/README.md +6 -0
- {plexavo-0.2.8 → plexavo-0.3.0}/plexavo/__init__.py +1 -1
- plexavo-0.3.0/plexavo/attack_paths.py +398 -0
- {plexavo-0.2.8 → plexavo-0.3.0}/plexavo/cli.py +74 -3
- {plexavo-0.2.8 → plexavo-0.3.0}/plexavo/findings.py +20 -0
- {plexavo-0.2.8 → plexavo-0.3.0}/plexavo/interactive.py +1 -0
- plexavo-0.3.0/plexavo/report/html_report.py +268 -0
- {plexavo-0.2.8 → plexavo-0.3.0}/plexavo/report/templates/report.html.j2 +227 -2
- {plexavo-0.2.8 → plexavo-0.3.0}/plexavo.egg-info/PKG-INFO +7 -1
- {plexavo-0.2.8 → plexavo-0.3.0}/plexavo.egg-info/SOURCES.txt +3 -0
- {plexavo-0.2.8 → plexavo-0.3.0}/pyproject.toml +1 -1
- plexavo-0.3.0/tests/test_attack_paths_offline.py +364 -0
- {plexavo-0.2.8 → plexavo-0.3.0}/tests/test_cli_entrypoint.py +5 -0
- plexavo-0.3.0/tests/test_fail_on_offline.py +68 -0
- {plexavo-0.2.8 → plexavo-0.3.0}/tests/test_report_offline.py +65 -0
- plexavo-0.2.8/plexavo/report/html_report.py +0 -128
- {plexavo-0.2.8 → plexavo-0.3.0}/LICENSE +0 -0
- {plexavo-0.2.8 → plexavo-0.3.0}/plexavo/__main__.py +0 -0
- {plexavo-0.2.8 → plexavo-0.3.0}/plexavo/auth.py +0 -0
- {plexavo-0.2.8 → plexavo-0.3.0}/plexavo/aws_profile_setup.py +0 -0
- {plexavo-0.2.8 → plexavo-0.3.0}/plexavo/checks/__init__.py +0 -0
- {plexavo-0.2.8 → plexavo-0.3.0}/plexavo/checks/encryption.py +0 -0
- {plexavo-0.2.8 → plexavo-0.3.0}/plexavo/checks/iam.py +0 -0
- {plexavo-0.2.8 → plexavo-0.3.0}/plexavo/checks/iam_hygiene.py +0 -0
- {plexavo-0.2.8 → plexavo-0.3.0}/plexavo/checks/logging.py +0 -0
- {plexavo-0.2.8 → plexavo-0.3.0}/plexavo/checks/network.py +0 -0
- {plexavo-0.2.8 → plexavo-0.3.0}/plexavo/checks/storage.py +0 -0
- {plexavo-0.2.8 → plexavo-0.3.0}/plexavo/checks/usage.py +0 -0
- {plexavo-0.2.8 → plexavo-0.3.0}/plexavo/principals.py +0 -0
- {plexavo-0.2.8 → plexavo-0.3.0}/plexavo/report/__init__.py +0 -0
- {plexavo-0.2.8 → plexavo-0.3.0}/plexavo/report/ai_narration.py +0 -0
- {plexavo-0.2.8 → plexavo-0.3.0}/plexavo/report/fonts/DejaVuSans-Bold.ttf +0 -0
- {plexavo-0.2.8 → plexavo-0.3.0}/plexavo/report/fonts/DejaVuSans-BoldOblique.ttf +0 -0
- {plexavo-0.2.8 → plexavo-0.3.0}/plexavo/report/fonts/DejaVuSans-Oblique.ttf +0 -0
- {plexavo-0.2.8 → plexavo-0.3.0}/plexavo/report/fonts/DejaVuSans.ttf +0 -0
- {plexavo-0.2.8 → plexavo-0.3.0}/plexavo/report/fonts/GEIST-FONT-LICENSE.txt +0 -0
- {plexavo-0.2.8 → plexavo-0.3.0}/plexavo/report/pdf.py +0 -0
- {plexavo-0.2.8 → plexavo-0.3.0}/plexavo/scoring.py +0 -0
- {plexavo-0.2.8 → plexavo-0.3.0}/plexavo.egg-info/dependency_links.txt +0 -0
- {plexavo-0.2.8 → plexavo-0.3.0}/plexavo.egg-info/entry_points.txt +0 -0
- {plexavo-0.2.8 → plexavo-0.3.0}/plexavo.egg-info/requires.txt +0 -0
- {plexavo-0.2.8 → plexavo-0.3.0}/plexavo.egg-info/top_level.txt +0 -0
- {plexavo-0.2.8 → plexavo-0.3.0}/setup.cfg +0 -0
- {plexavo-0.2.8 → plexavo-0.3.0}/tests/test_ai_narration_offline.py +0 -0
- {plexavo-0.2.8 → plexavo-0.3.0}/tests/test_auth_offline.py +0 -0
- {plexavo-0.2.8 → plexavo-0.3.0}/tests/test_encryption_offline.py +0 -0
- {plexavo-0.2.8 → plexavo-0.3.0}/tests/test_iam_hygiene_offline.py +0 -0
- {plexavo-0.2.8 → plexavo-0.3.0}/tests/test_iam_offline.py +0 -0
- {plexavo-0.2.8 → plexavo-0.3.0}/tests/test_logging_offline.py +0 -0
- {plexavo-0.2.8 → plexavo-0.3.0}/tests/test_network_offline.py +0 -0
- {plexavo-0.2.8 → plexavo-0.3.0}/tests/test_scoring.py +0 -0
- {plexavo-0.2.8 → plexavo-0.3.0}/tests/test_storage_offline.py +0 -0
- {plexavo-0.2.8 → plexavo-0.3.0}/tests/test_usage_offline.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: plexavo
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
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
|
|
@@ -134,6 +134,12 @@ python -m plexavo # Windows Option 2
|
|
|
134
134
|
<img src="assets/screenshot-cli.png" alt="Plexavo interactive CLI" width="700">
|
|
135
135
|
</div>
|
|
136
136
|
|
|
137
|
+
### Running it on a schedule
|
|
138
|
+
|
|
139
|
+
Plexavo can also run unattended from cron or a GitHub Actions workflow,
|
|
140
|
+
and flag a run when something regresses so you get notified without
|
|
141
|
+
opening a report. See [`docs/automation.md`](docs/automation.md).
|
|
142
|
+
|
|
137
143
|
## The report
|
|
138
144
|
|
|
139
145
|
Reports are generated as HTML, PDF, or both. Every finding gets a free,
|
|
@@ -105,6 +105,12 @@ python -m plexavo # Windows Option 2
|
|
|
105
105
|
<img src="assets/screenshot-cli.png" alt="Plexavo interactive CLI" width="700">
|
|
106
106
|
</div>
|
|
107
107
|
|
|
108
|
+
### Running it on a schedule
|
|
109
|
+
|
|
110
|
+
Plexavo can also run unattended from cron or a GitHub Actions workflow,
|
|
111
|
+
and flag a run when something regresses so you get notified without
|
|
112
|
+
opening a report. See [`docs/automation.md`](docs/automation.md).
|
|
113
|
+
|
|
108
114
|
## The report
|
|
109
115
|
|
|
110
116
|
Reports are generated as HTML, PDF, or both. Every finding gets a free,
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
"""Attack-path chaining: connects findings and relationship data from
|
|
2
|
+
existing checks into end-to-end attack paths, when a real, unblocked
|
|
3
|
+
connection exists between them - not just findings that happen to
|
|
4
|
+
co-occur in the same scan.
|
|
5
|
+
|
|
6
|
+
Runs after all other checks complete. Capped at MAX_CHAINS chains - a
|
|
7
|
+
curated top-N of the clearest paths, not an exhaustive graph traversal
|
|
8
|
+
(the build plan is explicit: 2-3 hardcoded templates, no generic graph
|
|
9
|
+
engine).
|
|
10
|
+
|
|
11
|
+
Every edge that depends on an IAM policy grant is verified against the
|
|
12
|
+
same Deny/permission-boundary standard the one-hop IAM escalation checks
|
|
13
|
+
already use (plexavo/principals.py: find_blocking_deny,
|
|
14
|
+
action_within_boundary), and the same admin-equivalence heuristic
|
|
15
|
+
(plexavo/checks/iam.py: is_admin_equivalent) - reused directly here, not
|
|
16
|
+
reimplemented, so this can never silently drift from what those checks
|
|
17
|
+
already consider "actually exploitable." A chain that looks plausible on
|
|
18
|
+
the policy alone but is actually blocked in practice must never be shown
|
|
19
|
+
as real - this is the accuracy risk the build plan calls out as more
|
|
20
|
+
important than shipping fast.
|
|
21
|
+
|
|
22
|
+
Two templates, both requiring the entry EC2 instance to be internet-
|
|
23
|
+
exposed on some port (the same exposure data analyze_security_groups
|
|
24
|
+
already computes for NET-01/02/03, just read for chain purposes too):
|
|
25
|
+
|
|
26
|
+
- ec2-role-s3: Internet -> EC2 -> IAM role (attached via instance
|
|
27
|
+
profile) -> S3 bucket (the role's own policy grants
|
|
28
|
+
GetObject/PutObject/DeleteObject on it).
|
|
29
|
+
- ec2-role-role: Internet -> EC2 -> IAM role (attached via instance
|
|
30
|
+
profile) -> another, admin-equivalent IAM role (the
|
|
31
|
+
first role can sts:AssumeRole into it, whether that
|
|
32
|
+
grant names the target role specifically - the IAM-05
|
|
33
|
+
relationship - or is a Resource:* wildcard grant - the
|
|
34
|
+
IAM-06 relationship) - re-scoped to just this one role
|
|
35
|
+
rather than every principal in the account. Tried
|
|
36
|
+
before ec2-role-s3 when both match the same instance:
|
|
37
|
+
reaching an admin-equivalent role is account-wide
|
|
38
|
+
compromise, a strictly worse outcome than one bucket's
|
|
39
|
+
data, so it's the story that should surface.
|
|
40
|
+
|
|
41
|
+
Deliberately out of scope, per the build plan: cross-account trust
|
|
42
|
+
resolution, and any chain shape beyond these two fixed templates.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
47
|
+
from dataclasses import dataclass, field
|
|
48
|
+
|
|
49
|
+
from botocore.exceptions import ClientError
|
|
50
|
+
|
|
51
|
+
from plexavo.findings import Finding
|
|
52
|
+
from plexavo.principals import (
|
|
53
|
+
Principal,
|
|
54
|
+
_normalize,
|
|
55
|
+
find_blocking_deny,
|
|
56
|
+
action_within_boundary,
|
|
57
|
+
statement_grants,
|
|
58
|
+
resource_is_wildcard,
|
|
59
|
+
)
|
|
60
|
+
from plexavo.checks.network import analyze_security_groups, ADMIN_PORTS, DATABASE_PORTS
|
|
61
|
+
from plexavo.checks.iam import is_admin_equivalent
|
|
62
|
+
|
|
63
|
+
MAX_CHAINS = 2
|
|
64
|
+
|
|
65
|
+
S3_DATA_ACTIONS = {"s3:GetObject", "s3:PutObject", "s3:DeleteObject"}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class ChainNode:
|
|
70
|
+
kind: str # "internet" | "ec2" | "iam-role" | "s3"
|
|
71
|
+
title: str # e.g. "EC2 instance"
|
|
72
|
+
detail: str # human-readable, e.g. "i-0a3f9c21 - sg-0d41 allows port 22 from the internet"
|
|
73
|
+
resource_id: str # stable, slug-friendly id for this node - instance id / role
|
|
74
|
+
# name / bucket name. Used for the report's clickable jump
|
|
75
|
+
# anchors (#3) and as the key for chain-participation counting.
|
|
76
|
+
check_id: str | None = None # an existing Finding this node is grounded in (e.g. "NET-01"), if any
|
|
77
|
+
finding_resource_arn: str | None = None # the exact Finding.resource_arn form this node
|
|
78
|
+
# corresponds to (e.g. the role's full ARN, not just
|
|
79
|
+
# its name) - used only to link this node back to a
|
|
80
|
+
# real Finding for chain-impact purposes
|
|
81
|
+
# (apply_chain_impact below). None for the "internet"
|
|
82
|
+
# node, which isn't a scannable resource at all.
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass
|
|
86
|
+
class AttackChain:
|
|
87
|
+
chain_id: str # "path-1", "path-2"
|
|
88
|
+
template: str # "ec2-role-s3" | "ec2-role-role"
|
|
89
|
+
nodes: list = field(default_factory=list)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _exposure_detail(sg_map: dict, sg_ids: list) -> tuple[str, str] | None:
|
|
93
|
+
"""(sg_id, human-readable exposure text) for the first sg among sg_ids
|
|
94
|
+
that's actually open to the internet, or None. Reads the exact
|
|
95
|
+
exposure facts analyze_security_groups already computes for
|
|
96
|
+
NET-01/02/03 - no new exposure logic, just a different presentation
|
|
97
|
+
of the same data."""
|
|
98
|
+
for sg_id in sg_ids:
|
|
99
|
+
info = sg_map.get(sg_id)
|
|
100
|
+
if not info:
|
|
101
|
+
continue
|
|
102
|
+
if info["all_ports_open"]:
|
|
103
|
+
scope = "all ports and protocols" if info["all_protocols"] else "all ports (one protocol)"
|
|
104
|
+
return sg_id, f"{info['name']} ({sg_id}) allows {scope} from the internet"
|
|
105
|
+
if info["open_ranges"]:
|
|
106
|
+
lo, hi = info["open_ranges"][0]
|
|
107
|
+
label = ADMIN_PORTS.get(lo) or DATABASE_PORTS.get(lo)
|
|
108
|
+
port_desc = f"port {lo}" if lo == hi else f"ports {lo}-{hi}"
|
|
109
|
+
if label:
|
|
110
|
+
port_desc += f" ({label})"
|
|
111
|
+
return sg_id, f"{info['name']} ({sg_id}) allows {port_desc} from the internet"
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _resolve_instance_profile_role(iam, profile_arn: str) -> str | None:
|
|
116
|
+
profile_name = profile_arn.rsplit("/", 1)[-1]
|
|
117
|
+
try:
|
|
118
|
+
profile = iam.get_instance_profile(InstanceProfileName=profile_name)["InstanceProfile"]
|
|
119
|
+
except ClientError:
|
|
120
|
+
return None
|
|
121
|
+
roles = profile.get("Roles", [])
|
|
122
|
+
return roles[0]["Arn"] if roles else None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _list_exposed_instances_with_role(ec2, iam) -> list[dict]:
|
|
126
|
+
"""Public, running/pending EC2 instances that are internet-exposed on
|
|
127
|
+
some port AND have an IAM instance profile attached, resolved to the
|
|
128
|
+
role's ARN.
|
|
129
|
+
|
|
130
|
+
New data collection: describe_instances already returns
|
|
131
|
+
IamInstanceProfile, but nothing in the codebase resolves that profile
|
|
132
|
+
to its role today. Deliberately NOT reusing network.py's
|
|
133
|
+
list_public_instances - that function is shared by NET-01/02/03/04
|
|
134
|
+
and already tested against a fixed tuple shape; this queries the same
|
|
135
|
+
API independently instead of changing a shipped function's contract.
|
|
136
|
+
"""
|
|
137
|
+
sg_map = analyze_security_groups(ec2)
|
|
138
|
+
profile_role_cache: dict[str, str | None] = {}
|
|
139
|
+
results = []
|
|
140
|
+
|
|
141
|
+
paginator = ec2.get_paginator("describe_instances")
|
|
142
|
+
for page in paginator.paginate(Filters=[
|
|
143
|
+
{"Name": "instance-state-name", "Values": ["pending", "running"]}
|
|
144
|
+
]):
|
|
145
|
+
for reservation in page["Reservations"]:
|
|
146
|
+
for instance in reservation["Instances"]:
|
|
147
|
+
public_ip = instance.get("PublicIpAddress")
|
|
148
|
+
if not public_ip:
|
|
149
|
+
continue
|
|
150
|
+
profile = instance.get("IamInstanceProfile")
|
|
151
|
+
if not profile or not profile.get("Arn"):
|
|
152
|
+
continue
|
|
153
|
+
sg_ids = [sg["GroupId"] for sg in instance.get("SecurityGroups", [])]
|
|
154
|
+
exposure = _exposure_detail(sg_map, sg_ids)
|
|
155
|
+
if not exposure:
|
|
156
|
+
continue
|
|
157
|
+
sg_id, exposure_text = exposure
|
|
158
|
+
|
|
159
|
+
profile_arn = profile["Arn"]
|
|
160
|
+
if profile_arn not in profile_role_cache:
|
|
161
|
+
profile_role_cache[profile_arn] = _resolve_instance_profile_role(iam, profile_arn)
|
|
162
|
+
role_arn = profile_role_cache[profile_arn]
|
|
163
|
+
if not role_arn:
|
|
164
|
+
continue
|
|
165
|
+
|
|
166
|
+
name = next(
|
|
167
|
+
(t["Value"] for t in instance.get("Tags", []) if t["Key"] == "Name"),
|
|
168
|
+
instance["InstanceId"],
|
|
169
|
+
)
|
|
170
|
+
results.append({
|
|
171
|
+
"instance_id": instance["InstanceId"],
|
|
172
|
+
"name": name,
|
|
173
|
+
"public_ip": public_ip,
|
|
174
|
+
"sg_id": sg_id,
|
|
175
|
+
"exposure_text": exposure_text,
|
|
176
|
+
"role_arn": role_arn,
|
|
177
|
+
})
|
|
178
|
+
return results
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _role_s3_grants(principal: Principal, bucket_names: list[str]) -> list[tuple[str, set]]:
|
|
182
|
+
"""Buckets this role's own policies grant s3:GetObject/PutObject/
|
|
183
|
+
DeleteObject on (directly, or via s3:*/Action:* wildcards -
|
|
184
|
+
_action_matches in principals.py already resolves those), that
|
|
185
|
+
survive Deny/permission-boundary verification. Returns
|
|
186
|
+
[(bucket_name, granted_actions)].
|
|
187
|
+
|
|
188
|
+
Resource matching is a deliberate prefix check against the bucket's
|
|
189
|
+
own ARN (exact bucket ARN, or anything under "bucket/") rather than
|
|
190
|
+
principals.resource_includes' exact-match - a policy scoped to a
|
|
191
|
+
sub-prefix or a specific key still genuinely grants access into this
|
|
192
|
+
bucket, and missing that would be a false negative on a real path.
|
|
193
|
+
"""
|
|
194
|
+
grants: dict[str, set] = {}
|
|
195
|
+
for _policy_name, statements in principal.policies:
|
|
196
|
+
for stmt in statements:
|
|
197
|
+
if stmt.get("Effect") != "Allow":
|
|
198
|
+
continue
|
|
199
|
+
granted = statement_grants(stmt, S3_DATA_ACTIONS)
|
|
200
|
+
if not granted:
|
|
201
|
+
continue
|
|
202
|
+
resources = _normalize(stmt.get("Resource"))
|
|
203
|
+
is_wildcard = resource_is_wildcard(stmt)
|
|
204
|
+
for bucket in bucket_names:
|
|
205
|
+
bucket_arn = f"arn:aws:s3:::{bucket}"
|
|
206
|
+
matches = is_wildcard or any(
|
|
207
|
+
r == bucket_arn or r.startswith(bucket_arn + "/") for r in resources
|
|
208
|
+
)
|
|
209
|
+
if not matches:
|
|
210
|
+
continue
|
|
211
|
+
grants.setdefault(bucket, set()).update(granted)
|
|
212
|
+
|
|
213
|
+
confirmed = []
|
|
214
|
+
for bucket, actions in grants.items():
|
|
215
|
+
# Representative resource ARN for the Deny/boundary check, same
|
|
216
|
+
# approximation pattern checks/iam.py's check_04 already uses
|
|
217
|
+
# when passing a single representative target_role_arn.
|
|
218
|
+
representative_arn = f"arn:aws:s3:::{bucket}/*"
|
|
219
|
+
still_granted = set()
|
|
220
|
+
for action in actions:
|
|
221
|
+
blocked, conditioned = find_blocking_deny(principal, action, representative_arn)
|
|
222
|
+
if blocked and not conditioned:
|
|
223
|
+
continue
|
|
224
|
+
if not action_within_boundary(principal, action, representative_arn):
|
|
225
|
+
continue
|
|
226
|
+
still_granted.add(action)
|
|
227
|
+
if still_granted:
|
|
228
|
+
confirmed.append((bucket, still_granted))
|
|
229
|
+
return confirmed
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _role_admin_assume_targets(principal: Principal, admin_roles: dict) -> list[tuple[str, str]]:
|
|
233
|
+
"""Admin-equivalent roles this principal can sts:AssumeRole into,
|
|
234
|
+
verified against Deny/boundary - the same relationship IAM-05 checks,
|
|
235
|
+
re-scoped to just this one principal instead of every principal in
|
|
236
|
+
the account, PLUS the IAM-06 (wildcard AssumeRole) relationship
|
|
237
|
+
folded in: a Resource:* grant means every admin-equivalent role is a
|
|
238
|
+
candidate target, each still verified individually against
|
|
239
|
+
Deny/boundary before being accepted. Deliberately broader than a
|
|
240
|
+
literal replay of check_05 alone - excluding the wildcard case would
|
|
241
|
+
mean the single most dangerous grant (assume ANY role) produces no
|
|
242
|
+
chain while a narrower, less severe one does, which is exactly
|
|
243
|
+
backwards for a feature whose job is showing real risk.
|
|
244
|
+
admin_roles is {role_arn: role_name}."""
|
|
245
|
+
targets = []
|
|
246
|
+
for _policy_name, statements in principal.policies:
|
|
247
|
+
for stmt in statements:
|
|
248
|
+
if stmt.get("Effect") != "Allow":
|
|
249
|
+
continue
|
|
250
|
+
if not statement_grants(stmt, {"sts:AssumeRole"}):
|
|
251
|
+
continue
|
|
252
|
+
if resource_is_wildcard(stmt):
|
|
253
|
+
candidate_arns = set(admin_roles)
|
|
254
|
+
else:
|
|
255
|
+
candidate_arns = set(_normalize(stmt.get("Resource"))) & set(admin_roles)
|
|
256
|
+
for target_arn in sorted(candidate_arns):
|
|
257
|
+
if target_arn == principal.arn:
|
|
258
|
+
continue
|
|
259
|
+
blocked, conditioned = find_blocking_deny(principal, "sts:AssumeRole", target_arn)
|
|
260
|
+
if blocked and not conditioned:
|
|
261
|
+
continue
|
|
262
|
+
if not action_within_boundary(principal, "sts:AssumeRole", target_arn):
|
|
263
|
+
continue
|
|
264
|
+
targets.append((target_arn, admin_roles[target_arn]))
|
|
265
|
+
return targets
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def build_attack_chains(session, principals: list[Principal]) -> list[AttackChain]:
|
|
269
|
+
"""Build up to MAX_CHAINS attack chains from this scan's data.
|
|
270
|
+
Single-account only - cross-account trust resolution is explicitly
|
|
271
|
+
out of scope (see module docstring and the build plan).
|
|
272
|
+
|
|
273
|
+
At most one chain per exposed instance (the first template that
|
|
274
|
+
matches - role-to-admin-role tried before S3, since reaching an
|
|
275
|
+
admin-equivalent role is account-wide compromise and a strictly
|
|
276
|
+
worse outcome than one bucket's data; showing the S3 path instead
|
|
277
|
+
when both exist would bury the more severe story), so two instances
|
|
278
|
+
that happen to share the same role/grant don't both spend the
|
|
279
|
+
two-chain budget on near-duplicate paths."""
|
|
280
|
+
ec2 = session.client("ec2")
|
|
281
|
+
iam = session.client("iam")
|
|
282
|
+
s3 = session.client("s3")
|
|
283
|
+
|
|
284
|
+
exposed = _list_exposed_instances_with_role(ec2, iam)
|
|
285
|
+
if not exposed:
|
|
286
|
+
return []
|
|
287
|
+
|
|
288
|
+
roles_by_arn = {p.arn: p for p in principals if p.type == "role"}
|
|
289
|
+
admin_roles = {
|
|
290
|
+
p.arn: p.name for p in principals
|
|
291
|
+
if p.type == "role" and is_admin_equivalent(p)
|
|
292
|
+
}
|
|
293
|
+
bucket_names = [b["Name"] for b in s3.list_buckets()["Buckets"]]
|
|
294
|
+
|
|
295
|
+
chains: list[AttackChain] = []
|
|
296
|
+
|
|
297
|
+
for entry in exposed:
|
|
298
|
+
if len(chains) >= MAX_CHAINS:
|
|
299
|
+
break
|
|
300
|
+
role = roles_by_arn.get(entry["role_arn"])
|
|
301
|
+
if role is None:
|
|
302
|
+
continue
|
|
303
|
+
|
|
304
|
+
internet_node = ChainNode(
|
|
305
|
+
kind="internet", title="Internet", detail="Unauthenticated, from anywhere",
|
|
306
|
+
resource_id="internet",
|
|
307
|
+
)
|
|
308
|
+
ec2_node = ChainNode(
|
|
309
|
+
kind="ec2", title="EC2 instance",
|
|
310
|
+
detail=f"{entry['instance_id']} - {entry['exposure_text']}",
|
|
311
|
+
resource_id=entry["instance_id"],
|
|
312
|
+
finding_resource_arn=entry["instance_id"], # NET-01/02/03 use the bare instance id as resource_arn too
|
|
313
|
+
)
|
|
314
|
+
role_node = ChainNode(
|
|
315
|
+
kind="iam-role", title="IAM role",
|
|
316
|
+
detail=f"{role.name} - attached via instance profile",
|
|
317
|
+
resource_id=role.name,
|
|
318
|
+
finding_resource_arn=role.arn,
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
assume_targets = _role_admin_assume_targets(role, admin_roles)
|
|
322
|
+
if assume_targets:
|
|
323
|
+
target_arn, target_name = assume_targets[0]
|
|
324
|
+
target_role_node = ChainNode(
|
|
325
|
+
kind="iam-role", title="IAM role",
|
|
326
|
+
detail=f"{target_name} - admin-equivalent, reachable via sts:AssumeRole",
|
|
327
|
+
resource_id=target_name,
|
|
328
|
+
finding_resource_arn=target_arn,
|
|
329
|
+
)
|
|
330
|
+
chains.append(AttackChain(
|
|
331
|
+
chain_id=f"path-{len(chains) + 1}", template="ec2-role-role",
|
|
332
|
+
nodes=[internet_node, ec2_node, role_node, target_role_node],
|
|
333
|
+
))
|
|
334
|
+
continue
|
|
335
|
+
|
|
336
|
+
s3_grants = _role_s3_grants(role, bucket_names)
|
|
337
|
+
if s3_grants:
|
|
338
|
+
bucket, actions = s3_grants[0]
|
|
339
|
+
s3_node = ChainNode(
|
|
340
|
+
kind="s3", title="S3 bucket",
|
|
341
|
+
detail=f"{bucket} - role policy grants {', '.join(sorted(actions))}",
|
|
342
|
+
finding_resource_arn=f"arn:aws:s3:::{bucket}",
|
|
343
|
+
resource_id=bucket,
|
|
344
|
+
)
|
|
345
|
+
chains.append(AttackChain(
|
|
346
|
+
chain_id=f"path-{len(chains) + 1}", template="ec2-role-s3",
|
|
347
|
+
nodes=[internet_node, ec2_node, role_node, s3_node],
|
|
348
|
+
))
|
|
349
|
+
|
|
350
|
+
return chains
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def compute_chain_participation(chains: list[AttackChain]) -> dict:
|
|
354
|
+
"""For every (kind, resource_id) node across all given chains
|
|
355
|
+
(excluding the "internet" node - not a fixable resource), how many
|
|
356
|
+
of those chains it appears in. This is the actual prioritization
|
|
357
|
+
signal the build plan calls for: remediating a node that appears in
|
|
358
|
+
N chains breaks all N of them at once - severity alone doesn't tell
|
|
359
|
+
you that.
|
|
360
|
+
|
|
361
|
+
Returns {(kind, resource_id): count}. The denominator for a "breaks
|
|
362
|
+
N of M" display is simply len(chains) - the total chains this scan
|
|
363
|
+
produced (capped at MAX_CHAINS)."""
|
|
364
|
+
counts: dict = {}
|
|
365
|
+
for chain in chains:
|
|
366
|
+
# A set, not a list - a node kind could in principle repeat within
|
|
367
|
+
# one chain (it doesn't with the current two templates, but this
|
|
368
|
+
# stays correct if a future template ever revisits a resource).
|
|
369
|
+
node_keys = {(n.kind, n.resource_id) for n in chain.nodes if n.kind != "internet"}
|
|
370
|
+
for key in node_keys:
|
|
371
|
+
counts[key] = counts.get(key, 0) + 1
|
|
372
|
+
return counts
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def apply_chain_impact(findings: list[Finding], chains: list[AttackChain]) -> None:
|
|
376
|
+
"""Sets Finding.chain_breaks_count on every finding whose resource_arn
|
|
377
|
+
matches a chain node's finding_resource_arn - e.g. the EC2 instance
|
|
378
|
+
behind a chain's entry point is very likely already flagged by
|
|
379
|
+
NET-01/02/03, and that existing finding now carries "this sits inside
|
|
380
|
+
N attack chain(s)" as prioritization metadata.
|
|
381
|
+
|
|
382
|
+
Mutates findings in place; findings not touched by any chain are left
|
|
383
|
+
at the dataclass default of 0. Purely additive - never reads or
|
|
384
|
+
writes severity or score_penalty. See the no-optics-tuning rule: this
|
|
385
|
+
changes how findings are PRIORITIZED for reading order, never what
|
|
386
|
+
was detected or how severe it's scored."""
|
|
387
|
+
node_counts = compute_chain_participation(chains)
|
|
388
|
+
resource_to_count: dict = {}
|
|
389
|
+
for chain in chains:
|
|
390
|
+
for node in chain.nodes:
|
|
391
|
+
if node.finding_resource_arn is None:
|
|
392
|
+
continue
|
|
393
|
+
count = node_counts.get((node.kind, node.resource_id), 0)
|
|
394
|
+
existing = resource_to_count.get(node.finding_resource_arn, 0)
|
|
395
|
+
resource_to_count[node.finding_resource_arn] = max(existing, count)
|
|
396
|
+
|
|
397
|
+
for finding in findings:
|
|
398
|
+
finding.chain_breaks_count = resource_to_count.get(finding.resource_arn, 0)
|
|
@@ -16,6 +16,7 @@ import os
|
|
|
16
16
|
import random
|
|
17
17
|
import sys
|
|
18
18
|
import time
|
|
19
|
+
from collections import Counter
|
|
19
20
|
from contextlib import nullcontext
|
|
20
21
|
|
|
21
22
|
from rich.console import Console
|
|
@@ -25,6 +26,7 @@ from rich.table import Table
|
|
|
25
26
|
from plexavo import __version__
|
|
26
27
|
from plexavo.auth import get_local_session, get_account_id
|
|
27
28
|
from plexavo.principals import list_all_principals
|
|
29
|
+
from plexavo import attack_paths
|
|
28
30
|
from plexavo.checks import iam as iam_checks
|
|
29
31
|
from plexavo.checks import iam_hygiene
|
|
30
32
|
from plexavo.checks import network as network_checks
|
|
@@ -32,6 +34,7 @@ from plexavo.checks import storage as storage_checks
|
|
|
32
34
|
from plexavo.checks import encryption as encryption_checks
|
|
33
35
|
from plexavo.checks import logging as logging_checks
|
|
34
36
|
from plexavo.checks import usage as usage_checks
|
|
37
|
+
from plexavo.findings import Severity
|
|
35
38
|
from plexavo.scoring import calculate_score
|
|
36
39
|
from plexavo.report.ai_narration import explain_finding, COMMON_CHECK_TEMPLATES
|
|
37
40
|
from plexavo.report.html_report import build_report_data, generate_html
|
|
@@ -59,6 +62,49 @@ FLAVOR_WORDS = [
|
|
|
59
62
|
"Scrutinizing bucket policies",
|
|
60
63
|
]
|
|
61
64
|
|
|
65
|
+
# --fail-on keyword -> Severity. One entry per Severity member; the test
|
|
66
|
+
# suite asserts that stays true.
|
|
67
|
+
FAIL_ON_LEVELS = {
|
|
68
|
+
"critical": Severity.CRITICAL,
|
|
69
|
+
"high": Severity.HIGH,
|
|
70
|
+
"medium": Severity.MEDIUM,
|
|
71
|
+
"low": Severity.LOW,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
# Exit status when --fail-on trips. Deliberately not 1: exit 1 already
|
|
75
|
+
# means "the scan could not run" (bad creds, no region), which a
|
|
76
|
+
# scheduled job wants to treat differently from "the scan ran fine and
|
|
77
|
+
# found something at your threshold".
|
|
78
|
+
FAIL_ON_EXIT_CODE = 2
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _findings_at_or_above(findings, level: str) -> list:
|
|
82
|
+
"""Findings whose severity is at or above `level` (a FAIL_ON_LEVELS key)."""
|
|
83
|
+
threshold = FAIL_ON_LEVELS[level].rank
|
|
84
|
+
return [f for f in findings if f.severity.rank >= threshold]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _apply_fail_on(level, findings) -> None:
|
|
88
|
+
"""When --fail-on is set, exit FAIL_ON_EXIT_CODE if any finding is at
|
|
89
|
+
or above `level`. Called only after any report has been written, so a
|
|
90
|
+
failing run still leaves you the report."""
|
|
91
|
+
if not level:
|
|
92
|
+
return
|
|
93
|
+
triggering = _findings_at_or_above(findings, level)
|
|
94
|
+
label = level.capitalize()
|
|
95
|
+
if not triggering:
|
|
96
|
+
console.print(f"\n[green]--fail-on {level}: no findings at or above {label}.[/green]")
|
|
97
|
+
return
|
|
98
|
+
counts = Counter(f.severity.value for f in triggering)
|
|
99
|
+
breakdown = ", ".join(
|
|
100
|
+
f"{counts[s]} {s}" for s in ("Critical", "High", "Medium", "Low") if counts.get(s)
|
|
101
|
+
)
|
|
102
|
+
console.print(
|
|
103
|
+
f"\n[bold red]--fail-on {level}: {len(triggering)} finding(s) at or above "
|
|
104
|
+
f"{label} ({breakdown}). Exiting with status {FAIL_ON_EXIT_CODE}.[/bold red]"
|
|
105
|
+
)
|
|
106
|
+
sys.exit(FAIL_ON_EXIT_CODE)
|
|
107
|
+
|
|
62
108
|
|
|
63
109
|
def _build_parser() -> argparse.ArgumentParser:
|
|
64
110
|
parser = argparse.ArgumentParser(
|
|
@@ -95,6 +141,12 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
95
141
|
"--explain replaces that with a full AI narrative for every finding instead.")
|
|
96
142
|
scan.add_argument("--report-pdf", metavar="PATH", default=None,
|
|
97
143
|
help="Write a PDF report to PATH. Same --explain behavior as --report-html.")
|
|
144
|
+
scan.add_argument("--fail-on", choices=["critical", "high", "medium", "low"], default=None,
|
|
145
|
+
help="After the scan, exit with status 2 if any finding is at or above this "
|
|
146
|
+
"severity. Any report is still written first. Meant for scheduled runs: a "
|
|
147
|
+
"non-zero exit makes GitHub Actions or cron flag the run, so a regression "
|
|
148
|
+
"reaches you without opening a report by hand. Exit 1 still means the scan "
|
|
149
|
+
"itself could not run. Off by default. See docs/automation.md.")
|
|
98
150
|
return parser
|
|
99
151
|
|
|
100
152
|
|
|
@@ -119,8 +171,9 @@ def _run_scan(args) -> None:
|
|
|
119
171
|
is_tty = console.is_terminal
|
|
120
172
|
findings = []
|
|
121
173
|
principals = []
|
|
174
|
+
chains = []
|
|
122
175
|
stage_num = 0
|
|
123
|
-
total_stages =
|
|
176
|
+
total_stages = 9
|
|
124
177
|
start_time = time.monotonic()
|
|
125
178
|
flavor_pool = FLAVOR_WORDS.copy()
|
|
126
179
|
random.shuffle(flavor_pool)
|
|
@@ -167,6 +220,10 @@ def _run_scan(args) -> None:
|
|
|
167
220
|
stage("Running checks USE-26, USE-27 (usage analysis)")
|
|
168
221
|
findings += usage_checks.run_all(session, principals)
|
|
169
222
|
|
|
223
|
+
stage("Building attack-path chains")
|
|
224
|
+
chains = attack_paths.build_attack_chains(session, principals)
|
|
225
|
+
attack_paths.apply_chain_impact(findings, chains)
|
|
226
|
+
|
|
170
227
|
result = calculate_score(findings)
|
|
171
228
|
rating_color = {"Excellent": "green", "Good": "green", "Fair": "yellow", "Poor": "orange3", "Critical": "red"}
|
|
172
229
|
console.print(f"\n[bold {rating_color[result.rating]}]{result.summary_line()}[/bold {rating_color[result.rating]}]")
|
|
@@ -176,8 +233,18 @@ def _run_scan(args) -> None:
|
|
|
176
233
|
else:
|
|
177
234
|
console.print()
|
|
178
235
|
|
|
179
|
-
if
|
|
236
|
+
if chains:
|
|
237
|
+
console.print(f"[bold]{len(chains)} attack path{'s' if len(chains) != 1 else ''} identified[/bold] "
|
|
238
|
+
f"— see the Visual Attack Path tab in the HTML report for detail.\n")
|
|
239
|
+
|
|
240
|
+
# Chains use their own independent exposure/relationship data, not
|
|
241
|
+
# the findings list — in principle a chain can exist even when every
|
|
242
|
+
# individual check came back clean, so "nothing to report" means both
|
|
243
|
+
# are empty, not just findings. Otherwise a real chain would silently
|
|
244
|
+
# never make it into a report.
|
|
245
|
+
if not findings and not chains:
|
|
180
246
|
console.print("[green]No findings.[/green]")
|
|
247
|
+
_apply_fail_on(args.fail_on, findings)
|
|
181
248
|
return
|
|
182
249
|
|
|
183
250
|
table = Table(title=f"Findings ({len(findings)})")
|
|
@@ -270,7 +337,7 @@ def _run_scan(args) -> None:
|
|
|
270
337
|
console.print("\n[yellow]Report includes free template remediation where available, raw technical "
|
|
271
338
|
"detail otherwise — pass --explain for full AI-written explanations on every "
|
|
272
339
|
"finding instead.[/yellow]")
|
|
273
|
-
report_data = build_report_data(findings, result, account_id, explanations)
|
|
340
|
+
report_data = build_report_data(findings, result, account_id, explanations, chains=chains)
|
|
274
341
|
|
|
275
342
|
if args.report_html:
|
|
276
343
|
html = generate_html(report_data)
|
|
@@ -285,6 +352,10 @@ def _run_scan(args) -> None:
|
|
|
285
352
|
generate_pdf(report_data, args.report_pdf)
|
|
286
353
|
console.print(f"[green]PDF report written to {os.path.abspath(args.report_pdf)}[/green]")
|
|
287
354
|
|
|
355
|
+
# Last thing in the scan: any report is on disk by now, so a non-zero
|
|
356
|
+
# exit here never costs you the report.
|
|
357
|
+
_apply_fail_on(args.fail_on, findings)
|
|
358
|
+
|
|
288
359
|
|
|
289
360
|
def main():
|
|
290
361
|
parser = _build_parser()
|
|
@@ -19,6 +19,19 @@ class Severity(Enum):
|
|
|
19
19
|
Severity.LOW: 1,
|
|
20
20
|
}[self]
|
|
21
21
|
|
|
22
|
+
@property
|
|
23
|
+
def rank(self) -> int:
|
|
24
|
+
"""Ordering for threshold comparisons, Critical highest. Kept
|
|
25
|
+
separate from score_penalty: that one is a scoring weight, this
|
|
26
|
+
one is just an ordinal so `--fail-on` can ask "at or above X".
|
|
27
|
+
"""
|
|
28
|
+
return {
|
|
29
|
+
Severity.CRITICAL: 4,
|
|
30
|
+
Severity.HIGH: 3,
|
|
31
|
+
Severity.MEDIUM: 2,
|
|
32
|
+
Severity.LOW: 1,
|
|
33
|
+
}[self]
|
|
34
|
+
|
|
22
35
|
|
|
23
36
|
@dataclass
|
|
24
37
|
class Finding:
|
|
@@ -35,6 +48,12 @@ class Finding:
|
|
|
35
48
|
evidence: str = "" # the concrete account-state fact backing this finding — e.g.
|
|
36
49
|
# "granted=15, used=0, unused=15" or "last_used=never" — shown to the
|
|
37
50
|
# reader directly, not just fed silently into the AI prompt
|
|
51
|
+
chain_breaks_count: int = 0 # how many attack chains (plexavo/attack_paths.py) touch this
|
|
52
|
+
# finding's resource_arn — 0 means it isn't part of any chain.
|
|
53
|
+
# Set in a post-processing pass strictly after chains are built
|
|
54
|
+
# (attack_paths.apply_chain_impact), never during detection.
|
|
55
|
+
# Pure prioritization metadata — deliberately never feeds
|
|
56
|
+
# severity or score_penalty, see the no-optics-tuning rule.
|
|
38
57
|
|
|
39
58
|
def to_dict(self) -> dict:
|
|
40
59
|
return {
|
|
@@ -46,4 +65,5 @@ class Finding:
|
|
|
46
65
|
"account_context": self.account_context,
|
|
47
66
|
"confidence": self.confidence,
|
|
48
67
|
"evidence": self.evidence,
|
|
68
|
+
"chain_breaks_count": self.chain_breaks_count,
|
|
49
69
|
}
|