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.
Files changed (54) hide show
  1. {plexavo-0.2.8 → plexavo-0.3.0}/PKG-INFO +7 -1
  2. {plexavo-0.2.8 → plexavo-0.3.0}/README.md +6 -0
  3. {plexavo-0.2.8 → plexavo-0.3.0}/plexavo/__init__.py +1 -1
  4. plexavo-0.3.0/plexavo/attack_paths.py +398 -0
  5. {plexavo-0.2.8 → plexavo-0.3.0}/plexavo/cli.py +74 -3
  6. {plexavo-0.2.8 → plexavo-0.3.0}/plexavo/findings.py +20 -0
  7. {plexavo-0.2.8 → plexavo-0.3.0}/plexavo/interactive.py +1 -0
  8. plexavo-0.3.0/plexavo/report/html_report.py +268 -0
  9. {plexavo-0.2.8 → plexavo-0.3.0}/plexavo/report/templates/report.html.j2 +227 -2
  10. {plexavo-0.2.8 → plexavo-0.3.0}/plexavo.egg-info/PKG-INFO +7 -1
  11. {plexavo-0.2.8 → plexavo-0.3.0}/plexavo.egg-info/SOURCES.txt +3 -0
  12. {plexavo-0.2.8 → plexavo-0.3.0}/pyproject.toml +1 -1
  13. plexavo-0.3.0/tests/test_attack_paths_offline.py +364 -0
  14. {plexavo-0.2.8 → plexavo-0.3.0}/tests/test_cli_entrypoint.py +5 -0
  15. plexavo-0.3.0/tests/test_fail_on_offline.py +68 -0
  16. {plexavo-0.2.8 → plexavo-0.3.0}/tests/test_report_offline.py +65 -0
  17. plexavo-0.2.8/plexavo/report/html_report.py +0 -128
  18. {plexavo-0.2.8 → plexavo-0.3.0}/LICENSE +0 -0
  19. {plexavo-0.2.8 → plexavo-0.3.0}/plexavo/__main__.py +0 -0
  20. {plexavo-0.2.8 → plexavo-0.3.0}/plexavo/auth.py +0 -0
  21. {plexavo-0.2.8 → plexavo-0.3.0}/plexavo/aws_profile_setup.py +0 -0
  22. {plexavo-0.2.8 → plexavo-0.3.0}/plexavo/checks/__init__.py +0 -0
  23. {plexavo-0.2.8 → plexavo-0.3.0}/plexavo/checks/encryption.py +0 -0
  24. {plexavo-0.2.8 → plexavo-0.3.0}/plexavo/checks/iam.py +0 -0
  25. {plexavo-0.2.8 → plexavo-0.3.0}/plexavo/checks/iam_hygiene.py +0 -0
  26. {plexavo-0.2.8 → plexavo-0.3.0}/plexavo/checks/logging.py +0 -0
  27. {plexavo-0.2.8 → plexavo-0.3.0}/plexavo/checks/network.py +0 -0
  28. {plexavo-0.2.8 → plexavo-0.3.0}/plexavo/checks/storage.py +0 -0
  29. {plexavo-0.2.8 → plexavo-0.3.0}/plexavo/checks/usage.py +0 -0
  30. {plexavo-0.2.8 → plexavo-0.3.0}/plexavo/principals.py +0 -0
  31. {plexavo-0.2.8 → plexavo-0.3.0}/plexavo/report/__init__.py +0 -0
  32. {plexavo-0.2.8 → plexavo-0.3.0}/plexavo/report/ai_narration.py +0 -0
  33. {plexavo-0.2.8 → plexavo-0.3.0}/plexavo/report/fonts/DejaVuSans-Bold.ttf +0 -0
  34. {plexavo-0.2.8 → plexavo-0.3.0}/plexavo/report/fonts/DejaVuSans-BoldOblique.ttf +0 -0
  35. {plexavo-0.2.8 → plexavo-0.3.0}/plexavo/report/fonts/DejaVuSans-Oblique.ttf +0 -0
  36. {plexavo-0.2.8 → plexavo-0.3.0}/plexavo/report/fonts/DejaVuSans.ttf +0 -0
  37. {plexavo-0.2.8 → plexavo-0.3.0}/plexavo/report/fonts/GEIST-FONT-LICENSE.txt +0 -0
  38. {plexavo-0.2.8 → plexavo-0.3.0}/plexavo/report/pdf.py +0 -0
  39. {plexavo-0.2.8 → plexavo-0.3.0}/plexavo/scoring.py +0 -0
  40. {plexavo-0.2.8 → plexavo-0.3.0}/plexavo.egg-info/dependency_links.txt +0 -0
  41. {plexavo-0.2.8 → plexavo-0.3.0}/plexavo.egg-info/entry_points.txt +0 -0
  42. {plexavo-0.2.8 → plexavo-0.3.0}/plexavo.egg-info/requires.txt +0 -0
  43. {plexavo-0.2.8 → plexavo-0.3.0}/plexavo.egg-info/top_level.txt +0 -0
  44. {plexavo-0.2.8 → plexavo-0.3.0}/setup.cfg +0 -0
  45. {plexavo-0.2.8 → plexavo-0.3.0}/tests/test_ai_narration_offline.py +0 -0
  46. {plexavo-0.2.8 → plexavo-0.3.0}/tests/test_auth_offline.py +0 -0
  47. {plexavo-0.2.8 → plexavo-0.3.0}/tests/test_encryption_offline.py +0 -0
  48. {plexavo-0.2.8 → plexavo-0.3.0}/tests/test_iam_hygiene_offline.py +0 -0
  49. {plexavo-0.2.8 → plexavo-0.3.0}/tests/test_iam_offline.py +0 -0
  50. {plexavo-0.2.8 → plexavo-0.3.0}/tests/test_logging_offline.py +0 -0
  51. {plexavo-0.2.8 → plexavo-0.3.0}/tests/test_network_offline.py +0 -0
  52. {plexavo-0.2.8 → plexavo-0.3.0}/tests/test_scoring.py +0 -0
  53. {plexavo-0.2.8 → plexavo-0.3.0}/tests/test_storage_offline.py +0 -0
  54. {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.2.8
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,
@@ -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.2.8"
7
+ __version__ = "0.3.0"
@@ -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 = 8
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 not findings:
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
  }
@@ -327,4 +327,5 @@ def run_interactive() -> None:
327
327
  explain_limit=25,
328
328
  report_html=options["report_html"],
329
329
  report_pdf=options["report_pdf"],
330
+ fail_on=None, # scheduled-run flag only; a person watching the score doesn't need it
330
331
  ))