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/analyze.py
ADDED
|
@@ -0,0 +1,747 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Analyze Commands - Analyze scan results.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
import uuid
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
|
|
13
|
+
log = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
from cyntrisec.cli.errors import EXIT_CODE_MAP, CyntriError, ErrorCode, handle_errors
|
|
16
|
+
from cyntrisec.cli.output import (
|
|
17
|
+
build_artifact_paths,
|
|
18
|
+
emit_agent_or_json,
|
|
19
|
+
resolve_format,
|
|
20
|
+
suggested_actions,
|
|
21
|
+
)
|
|
22
|
+
from cyntrisec.cli.schemas import (
|
|
23
|
+
AnalyzeFindingsResponse,
|
|
24
|
+
AnalyzePathsResponse,
|
|
25
|
+
BusinessAnalysisResponse,
|
|
26
|
+
)
|
|
27
|
+
from cyntrisec.core.cost_estimator import CostEstimator
|
|
28
|
+
|
|
29
|
+
analyze_app = typer.Typer(help="Analyze scan results")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@analyze_app.command("paths")
|
|
33
|
+
@handle_errors
|
|
34
|
+
def analyze_paths(
|
|
35
|
+
scan_id: str | None = typer.Option(
|
|
36
|
+
None,
|
|
37
|
+
"--scan",
|
|
38
|
+
"-s",
|
|
39
|
+
help="Scan ID (default: latest)",
|
|
40
|
+
),
|
|
41
|
+
min_risk: float = typer.Option(
|
|
42
|
+
0.0,
|
|
43
|
+
"--min-risk",
|
|
44
|
+
help="Minimum risk score (0-1)",
|
|
45
|
+
),
|
|
46
|
+
limit: int = typer.Option(
|
|
47
|
+
20,
|
|
48
|
+
"--limit",
|
|
49
|
+
"-n",
|
|
50
|
+
help="Maximum number of paths to show",
|
|
51
|
+
),
|
|
52
|
+
format: str | None = typer.Option(
|
|
53
|
+
None,
|
|
54
|
+
"--format",
|
|
55
|
+
"-f",
|
|
56
|
+
help="Output format: table, json, agent (defaults to json when piped)",
|
|
57
|
+
),
|
|
58
|
+
verify: bool = typer.Option(
|
|
59
|
+
False,
|
|
60
|
+
"--verify",
|
|
61
|
+
help="Verify paths using AWS Policy Simulator (requires AWS credentials)",
|
|
62
|
+
),
|
|
63
|
+
):
|
|
64
|
+
"""
|
|
65
|
+
Show attack paths from scan results.
|
|
66
|
+
|
|
67
|
+
Attack paths are routes from internet-facing entry points
|
|
68
|
+
to sensitive targets through the infrastructure.
|
|
69
|
+
|
|
70
|
+
Examples:
|
|
71
|
+
|
|
72
|
+
cyntrisec analyze paths --min-risk 0.5
|
|
73
|
+
|
|
74
|
+
cyntrisec analyze paths --format json | jq '.paths[:5]'
|
|
75
|
+
"""
|
|
76
|
+
from cyntrisec.storage import FileSystemStorage
|
|
77
|
+
|
|
78
|
+
storage = FileSystemStorage()
|
|
79
|
+
snapshot = storage.get_snapshot(scan_id)
|
|
80
|
+
if not snapshot:
|
|
81
|
+
raise CyntriError(
|
|
82
|
+
error_code=ErrorCode.SNAPSHOT_NOT_FOUND,
|
|
83
|
+
message="No scan data found. Run 'cyntrisec scan' first.",
|
|
84
|
+
exit_code=EXIT_CODE_MAP["usage"],
|
|
85
|
+
)
|
|
86
|
+
paths = storage.get_attack_paths(scan_id)
|
|
87
|
+
output_format = resolve_format(
|
|
88
|
+
format,
|
|
89
|
+
default_tty="table",
|
|
90
|
+
allowed=["table", "json", "agent"],
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# Filter by risk
|
|
94
|
+
if min_risk > 0:
|
|
95
|
+
paths = [p for p in paths if float(p.risk_score) >= min_risk]
|
|
96
|
+
|
|
97
|
+
# Sort by risk
|
|
98
|
+
paths.sort(key=lambda p: float(p.risk_score), reverse=True)
|
|
99
|
+
|
|
100
|
+
total_paths = len(paths)
|
|
101
|
+
paths = paths[:limit]
|
|
102
|
+
|
|
103
|
+
# Verify paths if requested
|
|
104
|
+
if verify:
|
|
105
|
+
from cyntrisec.aws.credentials import CredentialProvider
|
|
106
|
+
from cyntrisec.core.simulator import PolicySimulator
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
# Initialize simulator
|
|
110
|
+
provider = CredentialProvider()
|
|
111
|
+
session = provider.default_session()
|
|
112
|
+
simulator = PolicySimulator(session)
|
|
113
|
+
|
|
114
|
+
# Hydrate assets for verification
|
|
115
|
+
all_assets = {a.id: a for a in storage.get_assets(scan_id)}
|
|
116
|
+
|
|
117
|
+
typer.echo("Verifying paths with AWS Policy Simulator...", err=True)
|
|
118
|
+
|
|
119
|
+
for path in paths:
|
|
120
|
+
if not path.path_asset_ids or len(path.path_asset_ids) < 2:
|
|
121
|
+
continue
|
|
122
|
+
|
|
123
|
+
# Verify last hop if it's a capability edge
|
|
124
|
+
# (Ideally we'd verify the whole chain, but let's start with the immediate impact)
|
|
125
|
+
target_id = path.path_asset_ids[-1]
|
|
126
|
+
source_id = path.path_asset_ids[-2]
|
|
127
|
+
|
|
128
|
+
source_asset = all_assets.get(uuid.UUID(str(source_id)))
|
|
129
|
+
target_asset = all_assets.get(uuid.UUID(str(target_id)))
|
|
130
|
+
|
|
131
|
+
if not source_asset or not target_asset:
|
|
132
|
+
continue
|
|
133
|
+
|
|
134
|
+
# Only check if source is an IAM principal
|
|
135
|
+
if source_asset.asset_type not in ("iam:role", "iam:user"):
|
|
136
|
+
continue
|
|
137
|
+
|
|
138
|
+
# Find the relationship to get the action
|
|
139
|
+
# We need the graph or relationship list, but storage.get_attack_paths relies on
|
|
140
|
+
# stored paths which usually have IDs. We don't have relationships loaded here easily.
|
|
141
|
+
# However, we can infer action from edge type if we loaded relationships, OR
|
|
142
|
+
# we can try common actions based on target type.
|
|
143
|
+
|
|
144
|
+
# For now, let's use the simulator's inference
|
|
145
|
+
try:
|
|
146
|
+
result = simulator.can_access(
|
|
147
|
+
principal_arn=source_asset.arn or source_asset.aws_resource_id,
|
|
148
|
+
target_resource=target_asset.arn or target_asset.aws_resource_id
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
if result.can_access:
|
|
152
|
+
if path.confidence_level != "high":
|
|
153
|
+
path.confidence_level = "high"
|
|
154
|
+
path.confidence_reason = f"Verified via AWS Policy Simulator (Action: {result.action})"
|
|
155
|
+
else:
|
|
156
|
+
path.confidence_level = "low"
|
|
157
|
+
path.confidence_reason = "Verification Failed: AWS Policy Simulator denied access"
|
|
158
|
+
|
|
159
|
+
except Exception as ex:
|
|
160
|
+
log.debug("Path verification failed for %s: %s", path.id, ex)
|
|
161
|
+
|
|
162
|
+
except Exception as e:
|
|
163
|
+
typer.echo(f"Verification failed: {e}", err=True)
|
|
164
|
+
# Don't fail the command, just warn
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
if output_format in {"json", "agent"}:
|
|
168
|
+
artifact_paths = build_artifact_paths(storage, scan_id)
|
|
169
|
+
data = {
|
|
170
|
+
"paths": [p.model_dump(mode="json") for p in paths],
|
|
171
|
+
"returned": len(paths),
|
|
172
|
+
"total": total_paths,
|
|
173
|
+
}
|
|
174
|
+
snapshot_uuid = str(snapshot.id)
|
|
175
|
+
actions = suggested_actions(
|
|
176
|
+
[
|
|
177
|
+
(
|
|
178
|
+
f"cyntrisec cuts --snapshot {snapshot_uuid}",
|
|
179
|
+
"Prioritize fixes that block these paths",
|
|
180
|
+
),
|
|
181
|
+
(
|
|
182
|
+
"cyntrisec explain path instance-compromise",
|
|
183
|
+
"Get human-friendly context for a path",
|
|
184
|
+
),
|
|
185
|
+
]
|
|
186
|
+
)
|
|
187
|
+
emit_agent_or_json(
|
|
188
|
+
output_format,
|
|
189
|
+
data,
|
|
190
|
+
suggested=actions,
|
|
191
|
+
artifact_paths=artifact_paths,
|
|
192
|
+
schema=AnalyzePathsResponse,
|
|
193
|
+
)
|
|
194
|
+
return
|
|
195
|
+
|
|
196
|
+
# Table format
|
|
197
|
+
if not paths:
|
|
198
|
+
typer.echo("No attack paths found.")
|
|
199
|
+
return
|
|
200
|
+
|
|
201
|
+
typer.echo(f"{'Risk':<8} {'Conf':<6} {'Vector':<25} {'Length':<8} {'Entry':<8} {'Impact':<8}")
|
|
202
|
+
typer.echo("-" * 75)
|
|
203
|
+
|
|
204
|
+
for p in paths:
|
|
205
|
+
risk = float(p.risk_score)
|
|
206
|
+
conf = (p.confidence_level or "UNK")[:3]
|
|
207
|
+
vector = p.attack_vector[:24]
|
|
208
|
+
length = p.path_length
|
|
209
|
+
entry = float(p.entry_confidence)
|
|
210
|
+
impact = float(p.impact_score)
|
|
211
|
+
|
|
212
|
+
# Color coding
|
|
213
|
+
color = None
|
|
214
|
+
if risk >= 0.7:
|
|
215
|
+
color = typer.colors.RED
|
|
216
|
+
elif risk >= 0.4:
|
|
217
|
+
color = typer.colors.YELLOW
|
|
218
|
+
|
|
219
|
+
line = f"{risk:<8.3f} {conf:<6} {vector:<25} {length:<8} {entry:<8.3f} {impact:<8.3f}"
|
|
220
|
+
if color:
|
|
221
|
+
typer.secho(line, fg=color)
|
|
222
|
+
else:
|
|
223
|
+
typer.echo(line)
|
|
224
|
+
|
|
225
|
+
typer.echo("")
|
|
226
|
+
typer.echo(f"Total: {len(paths)} paths")
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@analyze_app.command("findings")
|
|
230
|
+
@handle_errors
|
|
231
|
+
def analyze_findings(
|
|
232
|
+
scan_id: str | None = typer.Option(
|
|
233
|
+
None,
|
|
234
|
+
"--scan",
|
|
235
|
+
"-s",
|
|
236
|
+
help="Scan ID (default: latest)",
|
|
237
|
+
),
|
|
238
|
+
severity: str | None = typer.Option(
|
|
239
|
+
None,
|
|
240
|
+
"--severity",
|
|
241
|
+
help="Filter by severity: critical, high, medium, low, info",
|
|
242
|
+
),
|
|
243
|
+
format: str | None = typer.Option(
|
|
244
|
+
None,
|
|
245
|
+
"--format",
|
|
246
|
+
"-f",
|
|
247
|
+
help="Output format: table, json, agent (defaults to json when piped)",
|
|
248
|
+
),
|
|
249
|
+
):
|
|
250
|
+
"""
|
|
251
|
+
Show security findings from scan results.
|
|
252
|
+
"""
|
|
253
|
+
from cyntrisec.storage import FileSystemStorage
|
|
254
|
+
|
|
255
|
+
storage = FileSystemStorage()
|
|
256
|
+
snapshot = storage.get_snapshot(scan_id)
|
|
257
|
+
if not snapshot:
|
|
258
|
+
raise CyntriError(
|
|
259
|
+
error_code=ErrorCode.SNAPSHOT_NOT_FOUND,
|
|
260
|
+
message="No scan data found. Run 'cyntrisec scan' first.",
|
|
261
|
+
exit_code=EXIT_CODE_MAP["usage"],
|
|
262
|
+
)
|
|
263
|
+
findings = storage.get_findings(scan_id)
|
|
264
|
+
output_format = resolve_format(
|
|
265
|
+
format,
|
|
266
|
+
default_tty="table",
|
|
267
|
+
allowed=["table", "json", "agent"],
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
# Filter by severity
|
|
271
|
+
if severity:
|
|
272
|
+
findings = [f for f in findings if f.severity == severity]
|
|
273
|
+
|
|
274
|
+
# Sort by severity
|
|
275
|
+
severity_order = {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4}
|
|
276
|
+
findings.sort(key=lambda f: severity_order.get(f.severity, 5))
|
|
277
|
+
|
|
278
|
+
if output_format in {"json", "agent"}:
|
|
279
|
+
artifact_paths = build_artifact_paths(storage, scan_id)
|
|
280
|
+
data = {
|
|
281
|
+
"findings": [f.model_dump(mode="json") for f in findings],
|
|
282
|
+
"total": len(findings),
|
|
283
|
+
"filter": severity or "any",
|
|
284
|
+
}
|
|
285
|
+
actions = suggested_actions(
|
|
286
|
+
[
|
|
287
|
+
(
|
|
288
|
+
f"cyntrisec explain finding {findings[0].finding_type}" if findings else "",
|
|
289
|
+
"See remediation context for the most common finding" if findings else "",
|
|
290
|
+
),
|
|
291
|
+
("cyntrisec comply --format agent", "Map findings to compliance controls"),
|
|
292
|
+
]
|
|
293
|
+
)
|
|
294
|
+
emit_agent_or_json(
|
|
295
|
+
output_format,
|
|
296
|
+
data,
|
|
297
|
+
suggested=actions,
|
|
298
|
+
artifact_paths=artifact_paths,
|
|
299
|
+
schema=AnalyzeFindingsResponse,
|
|
300
|
+
)
|
|
301
|
+
return
|
|
302
|
+
|
|
303
|
+
if not findings:
|
|
304
|
+
typer.echo("No findings found.")
|
|
305
|
+
return
|
|
306
|
+
|
|
307
|
+
typer.echo(f"{'Severity':<10} {'Type':<35} {'Title':<50}")
|
|
308
|
+
typer.echo("-" * 95)
|
|
309
|
+
|
|
310
|
+
for f in findings:
|
|
311
|
+
sev = f.severity.upper()[:9]
|
|
312
|
+
ftype = f.finding_type[:34]
|
|
313
|
+
title = f.title[:49]
|
|
314
|
+
typer.echo(f"{sev:<10} {ftype:<35} {title:<50}")
|
|
315
|
+
|
|
316
|
+
typer.echo("")
|
|
317
|
+
typer.echo(f"Total: {len(findings)} findings")
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
@analyze_app.command("stats")
|
|
321
|
+
@handle_errors
|
|
322
|
+
def analyze_stats(
|
|
323
|
+
scan_id: str | None = typer.Option(
|
|
324
|
+
None,
|
|
325
|
+
"--scan",
|
|
326
|
+
"-s",
|
|
327
|
+
help="Scan ID (default: latest)",
|
|
328
|
+
),
|
|
329
|
+
format: str | None = typer.Option(
|
|
330
|
+
None,
|
|
331
|
+
"--format",
|
|
332
|
+
"-f",
|
|
333
|
+
help="Output format: text, json, agent (defaults to json when piped)",
|
|
334
|
+
),
|
|
335
|
+
):
|
|
336
|
+
"""
|
|
337
|
+
Show summary statistics for a scan.
|
|
338
|
+
"""
|
|
339
|
+
from cyntrisec.storage import FileSystemStorage
|
|
340
|
+
|
|
341
|
+
storage = FileSystemStorage()
|
|
342
|
+
output_format = resolve_format(
|
|
343
|
+
format,
|
|
344
|
+
default_tty="text",
|
|
345
|
+
allowed=["text", "json", "agent"],
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
snapshot = storage.get_snapshot(scan_id)
|
|
349
|
+
if not snapshot:
|
|
350
|
+
if output_format in {"json", "agent"}:
|
|
351
|
+
from cyntrisec.cli.errors import ErrorCode
|
|
352
|
+
emit_agent_or_json(
|
|
353
|
+
output_format,
|
|
354
|
+
{},
|
|
355
|
+
status="error",
|
|
356
|
+
error_code=ErrorCode.SNAPSHOT_NOT_FOUND.value,
|
|
357
|
+
message="No scan found.",
|
|
358
|
+
)
|
|
359
|
+
raise typer.Exit(2)
|
|
360
|
+
typer.echo("No scan found.", err=True)
|
|
361
|
+
raise typer.Exit(2)
|
|
362
|
+
|
|
363
|
+
assets = storage.get_assets(scan_id)
|
|
364
|
+
findings = storage.get_findings(scan_id)
|
|
365
|
+
paths = storage.get_attack_paths(scan_id)
|
|
366
|
+
resolved_scan_id = storage.resolve_scan_id(scan_id)
|
|
367
|
+
|
|
368
|
+
if output_format in {"json", "agent"}:
|
|
369
|
+
from cyntrisec.cli.schemas import AnalyzeStatsResponse
|
|
370
|
+
payload = {
|
|
371
|
+
"snapshot_id": str(snapshot.id),
|
|
372
|
+
"scan_id": resolved_scan_id,
|
|
373
|
+
"account_id": snapshot.aws_account_id,
|
|
374
|
+
"asset_count": len(assets),
|
|
375
|
+
"relationship_count": snapshot.relationship_count,
|
|
376
|
+
"finding_count": len(findings),
|
|
377
|
+
"path_count": len(paths),
|
|
378
|
+
"regions": snapshot.regions,
|
|
379
|
+
"status": snapshot.status.value if hasattr(snapshot.status, 'value') else str(snapshot.status),
|
|
380
|
+
}
|
|
381
|
+
actions = suggested_actions(
|
|
382
|
+
[
|
|
383
|
+
(f"cyntrisec analyze paths --scan {resolved_scan_id or 'latest'}", "View attack paths"),
|
|
384
|
+
(f"cyntrisec analyze findings --scan {resolved_scan_id or 'latest'}", "View security findings"),
|
|
385
|
+
]
|
|
386
|
+
)
|
|
387
|
+
emit_agent_or_json(
|
|
388
|
+
output_format,
|
|
389
|
+
payload,
|
|
390
|
+
suggested=actions,
|
|
391
|
+
artifact_paths=build_artifact_paths(storage, scan_id),
|
|
392
|
+
schema=AnalyzeStatsResponse,
|
|
393
|
+
)
|
|
394
|
+
raise typer.Exit(0)
|
|
395
|
+
|
|
396
|
+
typer.echo("=== Scan Statistics ===")
|
|
397
|
+
typer.echo("")
|
|
398
|
+
typer.echo(f"Account: {snapshot.aws_account_id}")
|
|
399
|
+
typer.echo(f"Regions: {', '.join(snapshot.regions)}")
|
|
400
|
+
typer.echo(f"Status: {snapshot.status}")
|
|
401
|
+
typer.echo(f"Started: {snapshot.started_at}")
|
|
402
|
+
typer.echo(f"Completed: {snapshot.completed_at}")
|
|
403
|
+
typer.echo("")
|
|
404
|
+
|
|
405
|
+
typer.echo("--- Counts ---")
|
|
406
|
+
typer.echo(f"Assets: {len(assets)}")
|
|
407
|
+
typer.echo(f"Findings: {len(findings)}")
|
|
408
|
+
typer.echo(f"Attack paths: {len(paths)}")
|
|
409
|
+
typer.echo("")
|
|
410
|
+
|
|
411
|
+
# Asset types
|
|
412
|
+
asset_types = {}
|
|
413
|
+
for a in assets:
|
|
414
|
+
asset_types[a.asset_type] = asset_types.get(a.asset_type, 0) + 1
|
|
415
|
+
|
|
416
|
+
typer.echo("--- Assets by Type ---")
|
|
417
|
+
for t, count in sorted(asset_types.items(), key=lambda x: -x[1])[:10]:
|
|
418
|
+
typer.echo(f" {t}: {count}")
|
|
419
|
+
|
|
420
|
+
# Finding severities
|
|
421
|
+
severities = {}
|
|
422
|
+
for f in findings:
|
|
423
|
+
severities[f.severity] = severities.get(f.severity, 0) + 1
|
|
424
|
+
|
|
425
|
+
if severities:
|
|
426
|
+
typer.echo("")
|
|
427
|
+
typer.echo("--- Findings by Severity ---")
|
|
428
|
+
for sev in ["critical", "high", "medium", "low", "info"]:
|
|
429
|
+
if sev in severities:
|
|
430
|
+
typer.echo(f" {sev}: {severities[sev]}")
|
|
431
|
+
|
|
432
|
+
# Attack path stats
|
|
433
|
+
if paths:
|
|
434
|
+
risks = [float(p.risk_score) for p in paths]
|
|
435
|
+
typer.echo("")
|
|
436
|
+
typer.echo("--- Attack Paths ---")
|
|
437
|
+
typer.echo(f" Highest risk: {max(risks):.3f}")
|
|
438
|
+
typer.echo(f" Average risk: {sum(risks) / len(risks):.3f}")
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
@analyze_app.command("business")
|
|
442
|
+
@handle_errors
|
|
443
|
+
def analyze_business(
|
|
444
|
+
entrypoints: str | None = typer.Option(
|
|
445
|
+
None,
|
|
446
|
+
"--entrypoints",
|
|
447
|
+
"-e",
|
|
448
|
+
help="Comma-separated business entrypoint names/arns",
|
|
449
|
+
),
|
|
450
|
+
business_entrypoint: list[str] | None = typer.Option(
|
|
451
|
+
None,
|
|
452
|
+
"--business-entrypoint",
|
|
453
|
+
"-b",
|
|
454
|
+
help="Repeatable business entrypoint (name or ARN)",
|
|
455
|
+
),
|
|
456
|
+
business_tags: list[str] | None = typer.Option(
|
|
457
|
+
None,
|
|
458
|
+
"--business-tag",
|
|
459
|
+
help="Tag filters marking business assets (repeatable, key=value or comma-separated)",
|
|
460
|
+
),
|
|
461
|
+
business_config: str | None = typer.Option(
|
|
462
|
+
None,
|
|
463
|
+
"--business-config",
|
|
464
|
+
help="Path to business config (JSON or YAML) with entrypoints/tags",
|
|
465
|
+
),
|
|
466
|
+
report: bool = typer.Option(
|
|
467
|
+
False,
|
|
468
|
+
"--report",
|
|
469
|
+
help="Output coverage report for business-required assets",
|
|
470
|
+
),
|
|
471
|
+
scan_id: str | None = typer.Option(
|
|
472
|
+
None,
|
|
473
|
+
"--scan",
|
|
474
|
+
"-s",
|
|
475
|
+
help="Scan ID (default: latest)",
|
|
476
|
+
),
|
|
477
|
+
format: str | None = typer.Option(
|
|
478
|
+
None,
|
|
479
|
+
"--format",
|
|
480
|
+
"-f",
|
|
481
|
+
help="Output format: table, json, agent (defaults to json when piped)",
|
|
482
|
+
),
|
|
483
|
+
cost_source: str = typer.Option(
|
|
484
|
+
"estimate",
|
|
485
|
+
"--cost-source",
|
|
486
|
+
help="Cost data source: estimate (static), pricing-api, cost-explorer",
|
|
487
|
+
),
|
|
488
|
+
):
|
|
489
|
+
"""
|
|
490
|
+
Analyze business-required entrypoints vs attack-reachable assets.
|
|
491
|
+
|
|
492
|
+
Waste ~= attackable assets minus business-required set.
|
|
493
|
+
"""
|
|
494
|
+
from cyntrisec.storage import FileSystemStorage
|
|
495
|
+
|
|
496
|
+
output_format = resolve_format(
|
|
497
|
+
format,
|
|
498
|
+
default_tty="table",
|
|
499
|
+
allowed=["table", "json", "agent"],
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
cost_estimator = CostEstimator(source=cost_source)
|
|
503
|
+
storage = FileSystemStorage()
|
|
504
|
+
assets = storage.get_assets(scan_id)
|
|
505
|
+
snapshot = storage.get_snapshot(scan_id)
|
|
506
|
+
|
|
507
|
+
if not assets or not snapshot:
|
|
508
|
+
raise CyntriError(
|
|
509
|
+
error_code=ErrorCode.SNAPSHOT_NOT_FOUND,
|
|
510
|
+
message="No scan data found. Run 'cyntrisec scan' first.",
|
|
511
|
+
exit_code=EXIT_CODE_MAP["usage"],
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
# Parse business config from all sources
|
|
515
|
+
config = _parse_business_config(
|
|
516
|
+
entrypoints, business_entrypoint, business_tags, business_config
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
# Classify assets
|
|
520
|
+
path_assets = {aid for p in storage.get_attack_paths(scan_id) for aid in p.path_asset_ids}
|
|
521
|
+
analysis = _classify_assets(assets, config, path_assets)
|
|
522
|
+
|
|
523
|
+
# Sort waste by cost priority
|
|
524
|
+
sorted_waste = cost_estimator.sort_by_cost_priority(analysis["waste_candidates"])
|
|
525
|
+
|
|
526
|
+
# Build result
|
|
527
|
+
result = _build_business_result(
|
|
528
|
+
config, analysis, sorted_waste, path_assets, cost_estimator, report
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
if output_format in {"json", "agent"}:
|
|
532
|
+
emit_agent_or_json(
|
|
533
|
+
output_format,
|
|
534
|
+
result,
|
|
535
|
+
suggested=suggested_actions(
|
|
536
|
+
[
|
|
537
|
+
("cyntrisec waste --format agent", "Review unused permissions/waste"),
|
|
538
|
+
(
|
|
539
|
+
"cyntrisec cuts --format agent",
|
|
540
|
+
"Prioritize fixes to reduce attackable surface",
|
|
541
|
+
),
|
|
542
|
+
(
|
|
543
|
+
"cyntrisec analyze business --report --format agent",
|
|
544
|
+
"Show full business coverage report",
|
|
545
|
+
),
|
|
546
|
+
]
|
|
547
|
+
),
|
|
548
|
+
artifact_paths=build_artifact_paths(storage, scan_id),
|
|
549
|
+
schema=BusinessAnalysisResponse,
|
|
550
|
+
)
|
|
551
|
+
return
|
|
552
|
+
|
|
553
|
+
_output_business_table(analysis)
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def _parse_business_config(
|
|
557
|
+
entrypoints: str | None,
|
|
558
|
+
business_entrypoint: list[str] | None,
|
|
559
|
+
business_tags: list[str] | None,
|
|
560
|
+
business_config: str | None,
|
|
561
|
+
) -> dict:
|
|
562
|
+
"""Parse business configuration from all input sources."""
|
|
563
|
+
entry_list = [e.strip() for e in (entrypoints.split(",") if entrypoints else []) if e.strip()]
|
|
564
|
+
if business_entrypoint:
|
|
565
|
+
entry_list.extend([e for e in business_entrypoint if e])
|
|
566
|
+
|
|
567
|
+
tag_filters = _parse_tag_filters(business_tags)
|
|
568
|
+
critical_assets: set = set()
|
|
569
|
+
|
|
570
|
+
if business_config:
|
|
571
|
+
cfg_path = Path(business_config)
|
|
572
|
+
if not cfg_path.exists():
|
|
573
|
+
raise CyntriError(
|
|
574
|
+
error_code=ErrorCode.INVALID_QUERY,
|
|
575
|
+
message=f"Business config not found at {business_config}",
|
|
576
|
+
exit_code=EXIT_CODE_MAP["usage"],
|
|
577
|
+
)
|
|
578
|
+
loaded = _load_config_file(cfg_path)
|
|
579
|
+
if isinstance(loaded, dict):
|
|
580
|
+
entry_list.extend([str(e) for e in loaded.get("entrypoints", [])])
|
|
581
|
+
for key, value in (loaded.get("tags", {}) or {}).items():
|
|
582
|
+
tag_filters[str(key)] = str(value)
|
|
583
|
+
critical_assets = {str(item) for item in loaded.get("critical_assets", []) or []}
|
|
584
|
+
|
|
585
|
+
return {
|
|
586
|
+
"entry_list": [e for e in entry_list if e],
|
|
587
|
+
"tag_filters": tag_filters,
|
|
588
|
+
"critical_assets": critical_assets,
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def _parse_tag_filters(business_tags: list[str] | None) -> dict:
|
|
593
|
+
"""Parse tag filters from CLI option."""
|
|
594
|
+
tag_filters: dict[str, str] = {}
|
|
595
|
+
if not business_tags:
|
|
596
|
+
return tag_filters
|
|
597
|
+
|
|
598
|
+
for raw in business_tags:
|
|
599
|
+
for pair in raw.split(","):
|
|
600
|
+
if not pair:
|
|
601
|
+
continue
|
|
602
|
+
if "=" not in pair:
|
|
603
|
+
raise CyntriError(
|
|
604
|
+
error_code=ErrorCode.INVALID_QUERY,
|
|
605
|
+
message=f"Invalid tag filter '{pair}'. Use key=value.",
|
|
606
|
+
exit_code=EXIT_CODE_MAP["usage"],
|
|
607
|
+
)
|
|
608
|
+
key, value = pair.split("=", 1)
|
|
609
|
+
tag_filters[key.strip()] = value.strip()
|
|
610
|
+
|
|
611
|
+
return tag_filters
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def _load_config_file(cfg_path: Path) -> dict:
|
|
615
|
+
"""Load YAML or JSON config file."""
|
|
616
|
+
text = cfg_path.read_text(encoding="utf-8")
|
|
617
|
+
try:
|
|
618
|
+
import yaml # type: ignore
|
|
619
|
+
|
|
620
|
+
return yaml.safe_load(text) or {}
|
|
621
|
+
except Exception:
|
|
622
|
+
pass
|
|
623
|
+
try:
|
|
624
|
+
import json
|
|
625
|
+
|
|
626
|
+
return json.loads(text)
|
|
627
|
+
except Exception:
|
|
628
|
+
raise CyntriError(
|
|
629
|
+
error_code=ErrorCode.SCHEMA_MISMATCH,
|
|
630
|
+
message="Failed to parse business config (expected YAML or JSON).",
|
|
631
|
+
exit_code=EXIT_CODE_MAP["usage"],
|
|
632
|
+
)
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def _classify_assets(assets, config: dict, path_assets: set) -> dict:
|
|
636
|
+
"""Classify assets into business, attackable, and waste categories."""
|
|
637
|
+
entry_list = config["entry_list"]
|
|
638
|
+
tag_filters = config["tag_filters"]
|
|
639
|
+
critical_assets = config["critical_assets"]
|
|
640
|
+
|
|
641
|
+
business_assets = []
|
|
642
|
+
attackable = []
|
|
643
|
+
waste_candidates = []
|
|
644
|
+
|
|
645
|
+
for asset in assets:
|
|
646
|
+
attackable_flag = (
|
|
647
|
+
asset.is_internet_facing or asset.is_sensitive_target or asset.id in path_assets
|
|
648
|
+
)
|
|
649
|
+
if attackable_flag:
|
|
650
|
+
attackable.append(asset)
|
|
651
|
+
|
|
652
|
+
reasons = _get_business_reasons(asset, entry_list, tag_filters, critical_assets)
|
|
653
|
+
|
|
654
|
+
if reasons:
|
|
655
|
+
business_assets.append(
|
|
656
|
+
{
|
|
657
|
+
"name": asset.name,
|
|
658
|
+
"asset_type": asset.asset_type,
|
|
659
|
+
"asset_id": str(asset.id),
|
|
660
|
+
"reason": ",".join(reasons),
|
|
661
|
+
"tags": asset.tags,
|
|
662
|
+
}
|
|
663
|
+
)
|
|
664
|
+
elif attackable_flag:
|
|
665
|
+
waste_candidates.append(asset)
|
|
666
|
+
|
|
667
|
+
return {
|
|
668
|
+
"business_assets": business_assets,
|
|
669
|
+
"attackable": attackable,
|
|
670
|
+
"waste_candidates": waste_candidates,
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def _get_business_reasons(asset, entry_list, tag_filters, critical_assets) -> list:
|
|
675
|
+
"""Determine why an asset is considered business-required."""
|
|
676
|
+
reasons = []
|
|
677
|
+
if asset.name in entry_list or (asset.arn and asset.arn in entry_list):
|
|
678
|
+
reasons.append("entrypoint")
|
|
679
|
+
if tag_filters and all(asset.tags.get(k) == v for k, v in tag_filters.items()):
|
|
680
|
+
reasons.append("tags")
|
|
681
|
+
if asset.name in critical_assets or (asset.arn and asset.arn in critical_assets):
|
|
682
|
+
reasons.append("config-critical")
|
|
683
|
+
return reasons
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def _build_business_result(
|
|
687
|
+
config, analysis, sorted_waste, path_assets, cost_estimator, report
|
|
688
|
+
) -> dict:
|
|
689
|
+
"""Build the result dictionary for business analysis."""
|
|
690
|
+
return {
|
|
691
|
+
"entrypoints_requested": config["entry_list"],
|
|
692
|
+
"entrypoints_found": [a["name"] for a in analysis["business_assets"]],
|
|
693
|
+
"attackable_count": len(analysis["attackable"]),
|
|
694
|
+
"business_required_count": len(analysis["business_assets"]),
|
|
695
|
+
"waste_candidates": [
|
|
696
|
+
_build_waste_candidate_dict(a, path_assets, cost_estimator) for a in sorted_waste[:20]
|
|
697
|
+
],
|
|
698
|
+
"waste_candidate_count": len(analysis["waste_candidates"]),
|
|
699
|
+
"business_assets": analysis["business_assets"] if report else None,
|
|
700
|
+
"unknown_assets": [
|
|
701
|
+
{
|
|
702
|
+
"name": a.name,
|
|
703
|
+
"asset_type": a.asset_type,
|
|
704
|
+
"asset_id": str(a.id),
|
|
705
|
+
"reason": "attackable_not_business",
|
|
706
|
+
"tags": a.tags,
|
|
707
|
+
}
|
|
708
|
+
for a in analysis["waste_candidates"][:20]
|
|
709
|
+
]
|
|
710
|
+
if report
|
|
711
|
+
else None,
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def _output_business_table(analysis: dict) -> None:
|
|
716
|
+
"""Output business analysis in table format."""
|
|
717
|
+
typer.echo("=== Business vs Attackable Analysis ===")
|
|
718
|
+
typer.echo(f"Attackable assets: {len(analysis['attackable'])}")
|
|
719
|
+
typer.echo(f"Business-required (provided): {len(analysis['business_assets'])}")
|
|
720
|
+
typer.echo(f"Waste candidates (attackable minus business): {len(analysis['waste_candidates'])}")
|
|
721
|
+
if analysis["waste_candidates"]:
|
|
722
|
+
typer.echo("\nSample waste candidates:")
|
|
723
|
+
for a in analysis["waste_candidates"][:10]:
|
|
724
|
+
typer.echo(f"- {a.name} ({a.asset_type})")
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def _build_waste_candidate_dict(asset, path_assets, cost_estimator):
|
|
728
|
+
"""Build waste candidate dict with optional cost estimate."""
|
|
729
|
+
result = {
|
|
730
|
+
"name": asset.name,
|
|
731
|
+
"asset_type": asset.asset_type,
|
|
732
|
+
"asset_id": str(asset.id),
|
|
733
|
+
"reason": "in attack paths" if asset.id in path_assets else "internet-facing/sensitive",
|
|
734
|
+
"monthly_cost_usd": float(asset.monthly_cost_usd)
|
|
735
|
+
if getattr(asset, "monthly_cost_usd", None)
|
|
736
|
+
else None,
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
if cost_estimator:
|
|
740
|
+
estimate = cost_estimator.estimate(asset)
|
|
741
|
+
if estimate:
|
|
742
|
+
result["monthly_cost_usd_estimate"] = float(estimate.monthly_cost_usd_estimate)
|
|
743
|
+
result["cost_source"] = estimate.cost_source
|
|
744
|
+
result["confidence"] = estimate.confidence
|
|
745
|
+
result["assumptions"] = estimate.assumptions
|
|
746
|
+
|
|
747
|
+
return result
|