cyntrisec 0.1.7__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cyntrisec/__init__.py +3 -0
- cyntrisec/__main__.py +6 -0
- cyntrisec/aws/__init__.py +6 -0
- cyntrisec/aws/collectors/__init__.py +17 -0
- cyntrisec/aws/collectors/ec2.py +30 -0
- cyntrisec/aws/collectors/iam.py +116 -0
- cyntrisec/aws/collectors/lambda_.py +45 -0
- cyntrisec/aws/collectors/network.py +70 -0
- cyntrisec/aws/collectors/rds.py +38 -0
- cyntrisec/aws/collectors/s3.py +68 -0
- cyntrisec/aws/collectors/usage.py +188 -0
- cyntrisec/aws/credentials.py +153 -0
- cyntrisec/aws/normalizers/__init__.py +17 -0
- cyntrisec/aws/normalizers/ec2.py +115 -0
- cyntrisec/aws/normalizers/iam.py +182 -0
- cyntrisec/aws/normalizers/lambda_.py +83 -0
- cyntrisec/aws/normalizers/network.py +225 -0
- cyntrisec/aws/normalizers/rds.py +130 -0
- cyntrisec/aws/normalizers/s3.py +184 -0
- cyntrisec/aws/relationship_builder.py +1359 -0
- cyntrisec/aws/scanner.py +303 -0
- cyntrisec/cli/__init__.py +5 -0
- cyntrisec/cli/analyze.py +747 -0
- cyntrisec/cli/ask.py +412 -0
- cyntrisec/cli/can.py +307 -0
- cyntrisec/cli/comply.py +226 -0
- cyntrisec/cli/cuts.py +231 -0
- cyntrisec/cli/diff.py +332 -0
- cyntrisec/cli/errors.py +105 -0
- cyntrisec/cli/explain.py +348 -0
- cyntrisec/cli/main.py +114 -0
- cyntrisec/cli/manifest.py +893 -0
- cyntrisec/cli/output.py +117 -0
- cyntrisec/cli/remediate.py +643 -0
- cyntrisec/cli/report.py +462 -0
- cyntrisec/cli/scan.py +207 -0
- cyntrisec/cli/schemas.py +391 -0
- cyntrisec/cli/serve.py +164 -0
- cyntrisec/cli/setup.py +260 -0
- cyntrisec/cli/validate.py +101 -0
- cyntrisec/cli/waste.py +323 -0
- cyntrisec/core/__init__.py +31 -0
- cyntrisec/core/business_config.py +110 -0
- cyntrisec/core/business_logic.py +131 -0
- cyntrisec/core/compliance.py +437 -0
- cyntrisec/core/cost_estimator.py +301 -0
- cyntrisec/core/cuts.py +360 -0
- cyntrisec/core/diff.py +361 -0
- cyntrisec/core/graph.py +202 -0
- cyntrisec/core/paths.py +830 -0
- cyntrisec/core/schema.py +317 -0
- cyntrisec/core/simulator.py +371 -0
- cyntrisec/core/waste.py +309 -0
- cyntrisec/mcp/__init__.py +5 -0
- cyntrisec/mcp/server.py +862 -0
- cyntrisec/storage/__init__.py +7 -0
- cyntrisec/storage/filesystem.py +344 -0
- cyntrisec/storage/memory.py +113 -0
- cyntrisec/storage/protocol.py +92 -0
- cyntrisec-0.1.7.dist-info/METADATA +672 -0
- cyntrisec-0.1.7.dist-info/RECORD +65 -0
- cyntrisec-0.1.7.dist-info/WHEEL +4 -0
- cyntrisec-0.1.7.dist-info/entry_points.txt +2 -0
- cyntrisec-0.1.7.dist-info/licenses/LICENSE +190 -0
- cyntrisec-0.1.7.dist-info/licenses/NOTICE +5 -0
cyntrisec/cli/ask.py
ADDED
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ask command - Natural language queries over scan results.
|
|
3
|
+
|
|
4
|
+
Maps simple natural language questions to existing commands/results and
|
|
5
|
+
returns agent-friendly structured output.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
from rich.panel import Panel
|
|
15
|
+
|
|
16
|
+
from cyntrisec.cli.errors import EXIT_CODE_MAP, CyntriError, ErrorCode, handle_errors
|
|
17
|
+
from cyntrisec.cli.output import (
|
|
18
|
+
build_artifact_paths,
|
|
19
|
+
emit_agent_or_json,
|
|
20
|
+
resolve_format,
|
|
21
|
+
suggested_actions,
|
|
22
|
+
)
|
|
23
|
+
from cyntrisec.cli.schemas import AskResponse
|
|
24
|
+
|
|
25
|
+
console = Console()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@handle_errors
|
|
29
|
+
def ask_cmd(
|
|
30
|
+
query: str = typer.Argument(..., help="Natural language question"),
|
|
31
|
+
snapshot_id: str | None = typer.Option(
|
|
32
|
+
None,
|
|
33
|
+
"--snapshot",
|
|
34
|
+
"-s",
|
|
35
|
+
help="Snapshot UUID (default: latest; scan_id accepted)",
|
|
36
|
+
),
|
|
37
|
+
format: str | None = typer.Option(
|
|
38
|
+
None,
|
|
39
|
+
"--format",
|
|
40
|
+
"-f",
|
|
41
|
+
help="Output format: text, json, agent (defaults to json when piped)",
|
|
42
|
+
),
|
|
43
|
+
):
|
|
44
|
+
"""
|
|
45
|
+
Ask natural language questions about the security graph.
|
|
46
|
+
|
|
47
|
+
Examples:
|
|
48
|
+
cyntrisec ask "what can reach the production database?"
|
|
49
|
+
cyntrisec ask "show me public s3 buckets"
|
|
50
|
+
cyntrisec ask "which roles have admin access?"
|
|
51
|
+
"""
|
|
52
|
+
from cyntrisec.storage import FileSystemStorage
|
|
53
|
+
|
|
54
|
+
output_format = resolve_format(
|
|
55
|
+
format,
|
|
56
|
+
default_tty="text",
|
|
57
|
+
allowed=["text", "json", "agent"],
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
classification = _classify_query(query)
|
|
61
|
+
intent = classification["intent"]
|
|
62
|
+
storage = FileSystemStorage()
|
|
63
|
+
snapshot = storage.get_snapshot(snapshot_id)
|
|
64
|
+
|
|
65
|
+
if not snapshot:
|
|
66
|
+
raise CyntriError(
|
|
67
|
+
error_code=ErrorCode.SNAPSHOT_NOT_FOUND,
|
|
68
|
+
message="No scan data found. Run 'cyntrisec scan' first.",
|
|
69
|
+
exit_code=EXIT_CODE_MAP["usage"],
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
response = _execute_intent(classification, query, storage, snapshot_id)
|
|
73
|
+
|
|
74
|
+
if output_format in {"json", "agent"}:
|
|
75
|
+
actions = suggested_actions(response["suggested_actions"])
|
|
76
|
+
payload = {
|
|
77
|
+
"query": query,
|
|
78
|
+
"intent": intent,
|
|
79
|
+
"results": response["results"],
|
|
80
|
+
"snapshot_id": str(snapshot.id),
|
|
81
|
+
"entities": classification["entities"],
|
|
82
|
+
"resolved": response.get("resolved_query", intent),
|
|
83
|
+
}
|
|
84
|
+
emit_agent_or_json(
|
|
85
|
+
output_format,
|
|
86
|
+
payload,
|
|
87
|
+
suggested=actions,
|
|
88
|
+
artifact_paths=build_artifact_paths(storage, snapshot_id),
|
|
89
|
+
schema=AskResponse,
|
|
90
|
+
)
|
|
91
|
+
raise typer.Exit(0)
|
|
92
|
+
|
|
93
|
+
_print_text_response(query, intent, response, snapshot)
|
|
94
|
+
raise typer.Exit(0)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _classify_query(query: str) -> dict:
|
|
98
|
+
"""Heuristic intent classification with lightweight scoring and entity extraction."""
|
|
99
|
+
q = query.lower()
|
|
100
|
+
entities = _extract_entities(query)
|
|
101
|
+
|
|
102
|
+
intents = {
|
|
103
|
+
"attack_paths": ["reach", "path", "attack", "kill chain", "route"],
|
|
104
|
+
"public_s3": ["bucket", "s3"], # Removed "public" - handled in scoring
|
|
105
|
+
"public_ec2": ["ec2", "instance", "server", "compute"], # New intent for EC2
|
|
106
|
+
"admin_roles": ["admin", "root", "privileged", "full access"],
|
|
107
|
+
"compliance": ["compliance", "cis", "soc2", "benchmark"],
|
|
108
|
+
"access_check": ["can", "access", "reach", "allowed"],
|
|
109
|
+
"waste": ["unused", "waste", "reduce", "permissions", "cost"],
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
scores = {}
|
|
113
|
+
for intent, keywords in intents.items():
|
|
114
|
+
score = sum(1 for kw in keywords if kw in q)
|
|
115
|
+
# boost if entities suggest buckets/roles
|
|
116
|
+
if intent == "public_s3" and entities.get("buckets"):
|
|
117
|
+
score += 2
|
|
118
|
+
# Boost S3 intent only if "public" AND "bucket" or "s3" are present
|
|
119
|
+
if intent == "public_s3" and "public" in q and ("bucket" in q or "s3" in q):
|
|
120
|
+
score += 3
|
|
121
|
+
# Boost EC2 intent if "public" AND EC2-related keywords are present
|
|
122
|
+
if intent == "public_ec2" and "public" in q and any(kw in q for kw in ["ec2", "instance", "server"]):
|
|
123
|
+
score += 3
|
|
124
|
+
if intent == "admin_roles" and entities.get("roles"):
|
|
125
|
+
score += 2
|
|
126
|
+
if intent == "access_check" and (entities.get("arns") or entities.get("roles")):
|
|
127
|
+
score += 2
|
|
128
|
+
if intent == "waste" and ("cost" in q or "spend" in q):
|
|
129
|
+
score += 1
|
|
130
|
+
scores[intent] = score
|
|
131
|
+
|
|
132
|
+
# choose highest scoring intent or default general
|
|
133
|
+
intent = max(scores, key=scores.get)
|
|
134
|
+
if scores[intent] == 0:
|
|
135
|
+
intent = "general"
|
|
136
|
+
|
|
137
|
+
return {"intent": intent, "scores": scores, "entities": entities}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _extract_entities(query: str) -> dict:
|
|
141
|
+
"""Extract simple entities like bucket names, ARNs, and role-like tokens."""
|
|
142
|
+
buckets = re.findall(r"s3://[\w\-.]+", query)
|
|
143
|
+
buckets += re.findall(r"\b([a-z0-9\-\.]+bucket[a-z0-9\-\.]*)\b", query, re.IGNORECASE)
|
|
144
|
+
arns = re.findall(r"arn:[^\s]+", query)
|
|
145
|
+
roles = re.findall(r"\b[A-Za-z0-9+=,.@_-]+Role\b", query, re.IGNORECASE)
|
|
146
|
+
roles += re.findall(r"\brole[:/ ]+([A-Za-z0-9+=,.@_-]+)\b", query, re.IGNORECASE)
|
|
147
|
+
return {
|
|
148
|
+
"buckets": list({b for b in buckets}),
|
|
149
|
+
"arns": list({a for a in arns}),
|
|
150
|
+
"roles": list({r for r in roles}),
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _execute_intent(classification: dict, query: str, storage, snapshot_id: str | None):
|
|
155
|
+
"""Execute intent using existing data; returns results and suggested actions."""
|
|
156
|
+
intent = classification["intent"]
|
|
157
|
+
entities = classification["entities"]
|
|
158
|
+
|
|
159
|
+
if intent == "attack_paths":
|
|
160
|
+
paths = storage.get_attack_paths(snapshot_id)
|
|
161
|
+
top = [
|
|
162
|
+
{
|
|
163
|
+
"attack_vector": p.attack_vector,
|
|
164
|
+
"risk_score": float(p.risk_score),
|
|
165
|
+
"source": str(p.source_asset_id),
|
|
166
|
+
"target": str(p.target_asset_id),
|
|
167
|
+
}
|
|
168
|
+
for p in sorted(paths, key=lambda p: float(p.risk_score), reverse=True)[:5]
|
|
169
|
+
]
|
|
170
|
+
return {
|
|
171
|
+
"results": {"attack_paths": top, "count": len(paths)},
|
|
172
|
+
"resolved_query": "list_top_attack_paths",
|
|
173
|
+
"suggested_actions": [
|
|
174
|
+
("cyntrisec analyze paths --format agent", "List full attack paths"),
|
|
175
|
+
("cyntrisec cuts --format agent", "Get remediations to block paths"),
|
|
176
|
+
],
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if intent == "public_s3":
|
|
180
|
+
assets = storage.get_assets(snapshot_id)
|
|
181
|
+
public_buckets = [
|
|
182
|
+
{"name": a.name, "arn": a.arn or a.aws_resource_id}
|
|
183
|
+
for a in assets
|
|
184
|
+
if "s3" in a.asset_type.lower()
|
|
185
|
+
and ("public" in a.name.lower() or a.properties.get("public"))
|
|
186
|
+
]
|
|
187
|
+
if not public_buckets and entities.get("buckets"):
|
|
188
|
+
public_buckets = [{"name": b, "arn": ""} for b in entities["buckets"]]
|
|
189
|
+
return {
|
|
190
|
+
"results": {"public_buckets": public_buckets, "count": len(public_buckets)},
|
|
191
|
+
"resolved_query": "list_public_buckets",
|
|
192
|
+
"suggested_actions": [
|
|
193
|
+
("cyntrisec explain finding s3_public_bucket", "See why public buckets are risky"),
|
|
194
|
+
(
|
|
195
|
+
"cyntrisec can <principal> access s3://bucket --format agent",
|
|
196
|
+
"Verify specific access",
|
|
197
|
+
),
|
|
198
|
+
],
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if intent == "public_ec2":
|
|
202
|
+
assets = storage.get_assets(snapshot_id)
|
|
203
|
+
public_instances = [
|
|
204
|
+
{"name": a.name, "arn": a.arn or a.aws_resource_id, "public_ip": a.properties.get("public_ip")}
|
|
205
|
+
for a in assets
|
|
206
|
+
if a.asset_type == "ec2:instance"
|
|
207
|
+
and (a.properties.get("public_ip") or a.properties.get("public_dns_name"))
|
|
208
|
+
]
|
|
209
|
+
return {
|
|
210
|
+
"results": {"public_ec2_instances": public_instances, "count": len(public_instances)},
|
|
211
|
+
"resolved_query": "list_public_ec2_instances",
|
|
212
|
+
"suggested_actions": [
|
|
213
|
+
("cyntrisec explain finding ec2-public-ip", "See why public EC2 instances are risky"),
|
|
214
|
+
("cyntrisec analyze paths --format agent", "Review attack paths involving these instances"),
|
|
215
|
+
],
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if intent == "admin_roles":
|
|
219
|
+
assets = storage.get_assets(snapshot_id)
|
|
220
|
+
roles = [
|
|
221
|
+
{"name": a.name, "arn": a.arn or a.aws_resource_id}
|
|
222
|
+
for a in assets
|
|
223
|
+
if a.asset_type == "iam:role" and re.search(r"admin", a.name, re.IGNORECASE)
|
|
224
|
+
]
|
|
225
|
+
if not roles and entities.get("roles"):
|
|
226
|
+
roles = [{"name": r, "arn": ""} for r in entities["roles"]]
|
|
227
|
+
return {
|
|
228
|
+
"results": {"admin_like_roles": roles, "count": len(roles)},
|
|
229
|
+
"resolved_query": "list_admin_like_roles",
|
|
230
|
+
"suggested_actions": [
|
|
231
|
+
(
|
|
232
|
+
"cyntrisec can <role> access <resource> --format agent",
|
|
233
|
+
"Validate least privilege",
|
|
234
|
+
),
|
|
235
|
+
("cyntrisec waste --format agent", "Find unused permissions"),
|
|
236
|
+
],
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if intent == "access_check":
|
|
240
|
+
principal = entities["roles"][0] if entities.get("roles") else None
|
|
241
|
+
target = (
|
|
242
|
+
entities["arns"][0]
|
|
243
|
+
if entities.get("arns")
|
|
244
|
+
else (entities["buckets"][0] if entities.get("buckets") else None)
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
# Query graph data for access-related information
|
|
248
|
+
paths = storage.get_attack_paths(snapshot_id)
|
|
249
|
+
assets = storage.get_assets(snapshot_id)
|
|
250
|
+
|
|
251
|
+
# Build asset lookup for matching targets
|
|
252
|
+
asset_lookup = {}
|
|
253
|
+
for a in assets:
|
|
254
|
+
asset_lookup[str(a.id)] = a
|
|
255
|
+
if a.arn:
|
|
256
|
+
asset_lookup[a.arn.lower()] = a
|
|
257
|
+
if a.name:
|
|
258
|
+
asset_lookup[a.name.lower()] = a
|
|
259
|
+
if a.aws_resource_id:
|
|
260
|
+
asset_lookup[a.aws_resource_id.lower()] = a
|
|
261
|
+
|
|
262
|
+
# Find paths to/from the target
|
|
263
|
+
relevant_paths = []
|
|
264
|
+
if target:
|
|
265
|
+
target_lower = target.lower()
|
|
266
|
+
for p in paths:
|
|
267
|
+
target_asset = asset_lookup.get(str(p.target_asset_id))
|
|
268
|
+
source_asset = asset_lookup.get(str(p.source_asset_id))
|
|
269
|
+
|
|
270
|
+
# Check if target matches the path's target or source
|
|
271
|
+
target_matches = (
|
|
272
|
+
target_asset and (
|
|
273
|
+
target_lower in (target_asset.arn or "").lower() or
|
|
274
|
+
target_lower in (target_asset.name or "").lower() or
|
|
275
|
+
target_lower in (target_asset.aws_resource_id or "").lower()
|
|
276
|
+
)
|
|
277
|
+
)
|
|
278
|
+
source_matches = (
|
|
279
|
+
source_asset and (
|
|
280
|
+
target_lower in (source_asset.arn or "").lower() or
|
|
281
|
+
target_lower in (source_asset.name or "").lower() or
|
|
282
|
+
target_lower in (source_asset.aws_resource_id or "").lower()
|
|
283
|
+
)
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
if target_matches or source_matches:
|
|
287
|
+
relevant_paths.append(p)
|
|
288
|
+
|
|
289
|
+
target_display = target or query
|
|
290
|
+
|
|
291
|
+
# If we found relevant paths, return graph results
|
|
292
|
+
if relevant_paths:
|
|
293
|
+
top_paths = sorted(relevant_paths, key=lambda p: float(p.risk_score), reverse=True)[:5]
|
|
294
|
+
return {
|
|
295
|
+
"results": {
|
|
296
|
+
"paths_to_target": len(relevant_paths),
|
|
297
|
+
"target": target_display,
|
|
298
|
+
"top_paths": [
|
|
299
|
+
{
|
|
300
|
+
"attack_vector": p.attack_vector,
|
|
301
|
+
"risk_score": float(p.risk_score),
|
|
302
|
+
"source": str(p.source_asset_id),
|
|
303
|
+
"target": str(p.target_asset_id),
|
|
304
|
+
"path_length": p.path_length,
|
|
305
|
+
}
|
|
306
|
+
for p in top_paths
|
|
307
|
+
],
|
|
308
|
+
},
|
|
309
|
+
"resolved_query": "graph_access_check",
|
|
310
|
+
"suggested_actions": [
|
|
311
|
+
("cyntrisec analyze paths --format agent", "View all attack paths"),
|
|
312
|
+
("cyntrisec cuts --format agent", "Get remediations to block paths"),
|
|
313
|
+
(
|
|
314
|
+
f"cyntrisec can <principal> access {target or '<resource>'} --live --format agent",
|
|
315
|
+
"Run live access simulation for precise results",
|
|
316
|
+
),
|
|
317
|
+
],
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
# No paths found - return helpful response with graph context
|
|
321
|
+
principal_display = principal or "<principal>"
|
|
322
|
+
resource_display = target_display
|
|
323
|
+
return {
|
|
324
|
+
"results": {
|
|
325
|
+
"paths_to_target": 0,
|
|
326
|
+
"target": target_display,
|
|
327
|
+
"message": f"No attack paths found to '{target_display}' in the graph.",
|
|
328
|
+
},
|
|
329
|
+
"resolved_query": "graph_access_check",
|
|
330
|
+
"suggested_actions": [
|
|
331
|
+
(
|
|
332
|
+
f"cyntrisec can {principal_display} access {resource_display} --live --format agent",
|
|
333
|
+
"Run live access simulation",
|
|
334
|
+
),
|
|
335
|
+
("cyntrisec analyze paths --format agent", "View all attack paths"),
|
|
336
|
+
],
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if intent == "waste":
|
|
340
|
+
return {
|
|
341
|
+
"results": {"message": "Analyze unused permissions to reduce blast radius."},
|
|
342
|
+
"resolved_query": "waste_candidates",
|
|
343
|
+
"suggested_actions": [
|
|
344
|
+
("cyntrisec waste --format agent", "Find unused permissions"),
|
|
345
|
+
],
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if intent == "compliance":
|
|
349
|
+
return {
|
|
350
|
+
"results": {"message": "Run compliance checks with 'cyntrisec comply --format agent'."},
|
|
351
|
+
"resolved_query": "compliance_check",
|
|
352
|
+
"suggested_actions": [
|
|
353
|
+
("cyntrisec comply --format agent", "Check CIS/SOC2 compliance"),
|
|
354
|
+
(
|
|
355
|
+
"cyntrisec explain control CIS-AWS:1.1 --format agent",
|
|
356
|
+
"Explain specific controls",
|
|
357
|
+
),
|
|
358
|
+
],
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return {
|
|
362
|
+
"results": {
|
|
363
|
+
"message": "Query understood. Use analyze paths/findings, cuts, can, or comply for details."
|
|
364
|
+
},
|
|
365
|
+
"resolved_query": "general_help",
|
|
366
|
+
"suggested_actions": [
|
|
367
|
+
("cyntrisec analyze paths --format agent", "View attack paths"),
|
|
368
|
+
("cyntrisec analyze findings --format agent", "Review findings by severity"),
|
|
369
|
+
],
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _print_text_response(query: str, intent: str, response: dict, snapshot):
|
|
374
|
+
"""Render a simple text response."""
|
|
375
|
+
console.print()
|
|
376
|
+
console.print(
|
|
377
|
+
Panel(
|
|
378
|
+
f"Query: {query}\n"
|
|
379
|
+
f"Intent: {intent}\n"
|
|
380
|
+
f"Snapshot: {snapshot.aws_account_id if snapshot else 'unknown'}",
|
|
381
|
+
title="cyntrisec ask",
|
|
382
|
+
border_style="cyan",
|
|
383
|
+
)
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
results = response.get("results", {})
|
|
387
|
+
if intent == "attack_paths":
|
|
388
|
+
paths = results.get("attack_paths", [])
|
|
389
|
+
if not paths:
|
|
390
|
+
console.print("[yellow]No attack paths found.[/yellow]")
|
|
391
|
+
else:
|
|
392
|
+
for p in paths:
|
|
393
|
+
console.print(f"- {p['attack_vector']} (risk {p['risk_score']:.2f})")
|
|
394
|
+
elif intent == "public_s3":
|
|
395
|
+
buckets = results.get("public_buckets", [])
|
|
396
|
+
if not buckets:
|
|
397
|
+
console.print("[yellow]No public buckets detected by heuristics.[/yellow]")
|
|
398
|
+
else:
|
|
399
|
+
for b in buckets:
|
|
400
|
+
console.print(f"- {b['name']} ({b['arn']})")
|
|
401
|
+
elif intent == "admin_roles":
|
|
402
|
+
roles = results.get("admin_like_roles", [])
|
|
403
|
+
if not roles:
|
|
404
|
+
console.print("[yellow]No admin-like roles detected by name pattern.[/yellow]")
|
|
405
|
+
else:
|
|
406
|
+
for r in roles:
|
|
407
|
+
console.print(f"- {r['name']} ({r['arn']})")
|
|
408
|
+
else:
|
|
409
|
+
console.print(results.get("message", ""))
|
|
410
|
+
|
|
411
|
+
console.print()
|
|
412
|
+
console.print("[dim]Use --format agent for structured responses and follow-ups.[/dim]")
|