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
|
@@ -0,0 +1,643 @@
|
|
|
1
|
+
"""
|
|
2
|
+
remediate command - Generate remediation plans (plan/apply).
|
|
3
|
+
|
|
4
|
+
Current implementation generates a remediation plan using existing scan data
|
|
5
|
+
and minimal cut analysis. Apply is a stub that requires explicit enablement.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import typer
|
|
16
|
+
from rich import box
|
|
17
|
+
from rich.console import Console
|
|
18
|
+
from rich.panel import Panel
|
|
19
|
+
from rich.table import Table
|
|
20
|
+
from typer.models import OptionInfo
|
|
21
|
+
|
|
22
|
+
from cyntrisec.cli.errors import EXIT_CODE_MAP, CyntriError, ErrorCode, handle_errors
|
|
23
|
+
from cyntrisec.cli.output import (
|
|
24
|
+
build_artifact_paths,
|
|
25
|
+
emit_agent_or_json,
|
|
26
|
+
resolve_format,
|
|
27
|
+
suggested_actions,
|
|
28
|
+
)
|
|
29
|
+
from cyntrisec.cli.schemas import RemediateResponse
|
|
30
|
+
from cyntrisec.core.cuts import MinCutFinder
|
|
31
|
+
from cyntrisec.core.graph import GraphBuilder
|
|
32
|
+
from cyntrisec.storage import FileSystemStorage
|
|
33
|
+
|
|
34
|
+
console = Console()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@handle_errors
|
|
38
|
+
def remediate_cmd(
|
|
39
|
+
max_cuts: int = typer.Option(
|
|
40
|
+
5,
|
|
41
|
+
"--max-cuts",
|
|
42
|
+
help="Maximum remediations to include in the plan",
|
|
43
|
+
),
|
|
44
|
+
dry_run: bool = typer.Option(
|
|
45
|
+
False,
|
|
46
|
+
"--dry-run",
|
|
47
|
+
help="Simulate apply (mark actions as pending) and write plan to disk",
|
|
48
|
+
),
|
|
49
|
+
apply: bool = typer.Option(
|
|
50
|
+
False,
|
|
51
|
+
"--apply",
|
|
52
|
+
help="Apply the remediation plan (writes plan + marks actions pending)",
|
|
53
|
+
),
|
|
54
|
+
terraform_output: str | None = typer.Option(
|
|
55
|
+
None,
|
|
56
|
+
"--terraform-output",
|
|
57
|
+
help="Path to write Terraform hints (default: cyntrisec-remediation.tf when applying)",
|
|
58
|
+
),
|
|
59
|
+
terraform_dir: str | None = typer.Option(
|
|
60
|
+
None,
|
|
61
|
+
"--terraform-dir",
|
|
62
|
+
help="Directory to write Terraform module (default: cyntrisec-remediation-tf)",
|
|
63
|
+
),
|
|
64
|
+
execute_terraform: bool = typer.Option(
|
|
65
|
+
False,
|
|
66
|
+
"--execute-terraform",
|
|
67
|
+
help="UNSAFE: execute terraform apply locally. Requires --enable-unsafe-write-mode.",
|
|
68
|
+
),
|
|
69
|
+
terraform_plan: bool = typer.Option(
|
|
70
|
+
False,
|
|
71
|
+
"--terraform-plan",
|
|
72
|
+
help="Run terraform init + plan only against the generated module",
|
|
73
|
+
),
|
|
74
|
+
terraform_cmd: str = typer.Option(
|
|
75
|
+
"terraform",
|
|
76
|
+
"--terraform-cmd",
|
|
77
|
+
help="Terraform binary to invoke when using --execute-terraform",
|
|
78
|
+
),
|
|
79
|
+
terraform_include_output: bool = typer.Option(
|
|
80
|
+
False,
|
|
81
|
+
"--terraform-include-output",
|
|
82
|
+
help="Include truncated terraform stdout/stderr in output (may contain secrets).",
|
|
83
|
+
),
|
|
84
|
+
enable_unsafe_write_mode: bool = typer.Option(
|
|
85
|
+
False,
|
|
86
|
+
"--enable-unsafe-write-mode",
|
|
87
|
+
help="Required to allow --apply/--execute-terraform (defaults to off for safety)",
|
|
88
|
+
),
|
|
89
|
+
yes: bool = typer.Option(
|
|
90
|
+
False,
|
|
91
|
+
"--yes",
|
|
92
|
+
help="Skip confirmation when using --apply",
|
|
93
|
+
),
|
|
94
|
+
output: str | None = typer.Option(
|
|
95
|
+
None,
|
|
96
|
+
"--output",
|
|
97
|
+
"-o",
|
|
98
|
+
help="Write plan/apply payload to a file (json)",
|
|
99
|
+
),
|
|
100
|
+
snapshot_id: str | None = typer.Option(
|
|
101
|
+
None,
|
|
102
|
+
"--snapshot",
|
|
103
|
+
help="Snapshot UUID (default: latest; scan_id accepted)",
|
|
104
|
+
),
|
|
105
|
+
format: str | None = typer.Option(
|
|
106
|
+
None,
|
|
107
|
+
"--format",
|
|
108
|
+
"-f",
|
|
109
|
+
help="Output format: table, json, agent (defaults to json when piped)",
|
|
110
|
+
),
|
|
111
|
+
):
|
|
112
|
+
"""
|
|
113
|
+
Generate or apply remediation plans.
|
|
114
|
+
|
|
115
|
+
Use existing scan data and minimal-cut analysis to propose fixes that
|
|
116
|
+
block attack paths. Apply/terraform are gated and disabled by default.
|
|
117
|
+
"""
|
|
118
|
+
output_format = resolve_format(
|
|
119
|
+
format,
|
|
120
|
+
default_tty="table",
|
|
121
|
+
allowed=["table", "json", "agent"],
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
if isinstance(output, OptionInfo):
|
|
125
|
+
output = None
|
|
126
|
+
if isinstance(terraform_output, OptionInfo):
|
|
127
|
+
terraform_output = None
|
|
128
|
+
if isinstance(terraform_dir, OptionInfo):
|
|
129
|
+
terraform_dir = None
|
|
130
|
+
|
|
131
|
+
storage = FileSystemStorage()
|
|
132
|
+
assets = storage.get_assets(snapshot_id)
|
|
133
|
+
relationships = storage.get_relationships(snapshot_id)
|
|
134
|
+
paths = storage.get_attack_paths(snapshot_id)
|
|
135
|
+
snapshot = storage.get_snapshot(snapshot_id)
|
|
136
|
+
|
|
137
|
+
if not assets or not snapshot:
|
|
138
|
+
raise CyntriError(
|
|
139
|
+
error_code=ErrorCode.SNAPSHOT_NOT_FOUND,
|
|
140
|
+
message="No scan data found. Run 'cyntrisec scan' first.",
|
|
141
|
+
exit_code=EXIT_CODE_MAP["usage"],
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
if not paths:
|
|
145
|
+
status_console = console if output_format == "table" else Console(stderr=True)
|
|
146
|
+
status_console.print("[green]No attack paths found. Nothing to remediate.[/green]")
|
|
147
|
+
raise typer.Exit(0)
|
|
148
|
+
|
|
149
|
+
graph = GraphBuilder().build(assets=assets, relationships=relationships)
|
|
150
|
+
result = MinCutFinder().find_cuts(graph, paths, max_cuts=max_cuts)
|
|
151
|
+
plan = _build_plan(result, graph)
|
|
152
|
+
|
|
153
|
+
apply_output = None
|
|
154
|
+
mode = "plan"
|
|
155
|
+
|
|
156
|
+
if apply or dry_run or execute_terraform or terraform_plan:
|
|
157
|
+
mode, apply_output = _handle_apply_mode(
|
|
158
|
+
plan=plan,
|
|
159
|
+
snapshot=snapshot,
|
|
160
|
+
apply=apply,
|
|
161
|
+
dry_run=dry_run,
|
|
162
|
+
execute_terraform=execute_terraform,
|
|
163
|
+
terraform_plan=terraform_plan,
|
|
164
|
+
terraform_include_output=terraform_include_output,
|
|
165
|
+
enable_unsafe_write_mode=enable_unsafe_write_mode,
|
|
166
|
+
yes=yes,
|
|
167
|
+
output=output,
|
|
168
|
+
terraform_output=terraform_output,
|
|
169
|
+
terraform_dir=terraform_dir,
|
|
170
|
+
terraform_cmd=terraform_cmd,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
if output_format in {"json", "agent"}:
|
|
174
|
+
# Determine status and applied based on mode
|
|
175
|
+
if dry_run:
|
|
176
|
+
status = "dry_run"
|
|
177
|
+
applied = False
|
|
178
|
+
elif apply_output:
|
|
179
|
+
results = apply_output.get("results") or []
|
|
180
|
+
failed = any(
|
|
181
|
+
item.get("status") in {"terraform_failed", "terraform_plan_failed"} for item in results
|
|
182
|
+
)
|
|
183
|
+
applied = any(item.get("status") == "terraform_invoked" for item in results)
|
|
184
|
+
if failed:
|
|
185
|
+
status = "terraform_failed"
|
|
186
|
+
applied = False
|
|
187
|
+
elif mode == "terraform-plan":
|
|
188
|
+
status = "terraform_plan_ok"
|
|
189
|
+
applied = False
|
|
190
|
+
elif applied:
|
|
191
|
+
status = "applied"
|
|
192
|
+
else:
|
|
193
|
+
status = "planned"
|
|
194
|
+
else:
|
|
195
|
+
status = "planned"
|
|
196
|
+
applied = False
|
|
197
|
+
|
|
198
|
+
payload = {
|
|
199
|
+
"snapshot_id": str(snapshot.id) if snapshot else None,
|
|
200
|
+
"account_id": snapshot.aws_account_id if snapshot else None,
|
|
201
|
+
"total_paths": result.total_paths,
|
|
202
|
+
"paths_blocked": result.paths_blocked,
|
|
203
|
+
"coverage": result.coverage,
|
|
204
|
+
"plan": plan,
|
|
205
|
+
"applied": applied,
|
|
206
|
+
"mode": mode,
|
|
207
|
+
"output_path": apply_output["output_path"] if apply_output else None,
|
|
208
|
+
"terraform_path": apply_output["terraform_path"] if apply_output else None,
|
|
209
|
+
"terraform_dir": apply_output["terraform_dir"] if apply_output else None,
|
|
210
|
+
"apply": apply_output,
|
|
211
|
+
}
|
|
212
|
+
actions = suggested_actions(
|
|
213
|
+
[
|
|
214
|
+
(
|
|
215
|
+
"cyntrisec can <principal> access <resource>",
|
|
216
|
+
"Verify access is closed after remediation",
|
|
217
|
+
),
|
|
218
|
+
("cyntrisec diff --format agent", "Detect regressions after applying fixes"),
|
|
219
|
+
]
|
|
220
|
+
)
|
|
221
|
+
emit_agent_or_json(
|
|
222
|
+
output_format,
|
|
223
|
+
payload,
|
|
224
|
+
suggested=actions,
|
|
225
|
+
status=status,
|
|
226
|
+
artifact_paths=build_artifact_paths(storage, snapshot_id),
|
|
227
|
+
schema=RemediateResponse,
|
|
228
|
+
)
|
|
229
|
+
raise typer.Exit(0)
|
|
230
|
+
|
|
231
|
+
_output_table(
|
|
232
|
+
plan,
|
|
233
|
+
result,
|
|
234
|
+
snapshot,
|
|
235
|
+
applied=bool(apply_output),
|
|
236
|
+
output_path=apply_output["output_path"] if apply_output else None,
|
|
237
|
+
terraform_path=apply_output["terraform_path"] if apply_output else terraform_output,
|
|
238
|
+
mode=mode,
|
|
239
|
+
)
|
|
240
|
+
raise typer.Exit(0)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _build_plan(result, graph):
|
|
244
|
+
"""Construct a remediation plan with human + IaC hints."""
|
|
245
|
+
plan = []
|
|
246
|
+
for i, rem in enumerate(result.remediations, 1):
|
|
247
|
+
source_asset = graph.asset(rem.relationship.source_asset_id) if graph else None
|
|
248
|
+
target_asset = graph.asset(rem.relationship.target_asset_id) if graph else None
|
|
249
|
+
terraform = _terraform_snippet(
|
|
250
|
+
rem.action,
|
|
251
|
+
rem.source_name,
|
|
252
|
+
rem.target_name,
|
|
253
|
+
rem.relationship_type,
|
|
254
|
+
source_arn=source_asset.arn if source_asset else None,
|
|
255
|
+
target_arn=target_asset.arn if target_asset else None,
|
|
256
|
+
)
|
|
257
|
+
plan.append(
|
|
258
|
+
{
|
|
259
|
+
"priority": i,
|
|
260
|
+
"action": rem.action,
|
|
261
|
+
"description": rem.description,
|
|
262
|
+
"source": rem.source_name,
|
|
263
|
+
"target": rem.target_name,
|
|
264
|
+
"relationship_type": rem.relationship_type,
|
|
265
|
+
"paths_blocked": len(rem.paths_blocked),
|
|
266
|
+
"terraform": terraform,
|
|
267
|
+
}
|
|
268
|
+
)
|
|
269
|
+
return plan
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _terraform_snippet(
|
|
273
|
+
action: str,
|
|
274
|
+
source: str,
|
|
275
|
+
target: str,
|
|
276
|
+
relationship_type: str,
|
|
277
|
+
*,
|
|
278
|
+
source_arn: str | None = None,
|
|
279
|
+
target_arn: str | None = None,
|
|
280
|
+
) -> str:
|
|
281
|
+
"""Generate a minimal Terraform hint for the remediation."""
|
|
282
|
+
if relationship_type == "ALLOWS_TRAFFIC_TO":
|
|
283
|
+
return (
|
|
284
|
+
"# Restrict security group ingress\n"
|
|
285
|
+
'resource "aws_security_group_rule" "restrict_ingress" {\n'
|
|
286
|
+
f' description = "Restrict {source} -> {target}"\n'
|
|
287
|
+
' type = "ingress"\n'
|
|
288
|
+
" from_port = 0\n"
|
|
289
|
+
" to_port = 0\n"
|
|
290
|
+
' protocol = "tcp"\n'
|
|
291
|
+
' cidr_blocks = ["10.0.0.0/8"]\n'
|
|
292
|
+
"}\n"
|
|
293
|
+
)
|
|
294
|
+
if relationship_type == "MAY_ACCESS":
|
|
295
|
+
resources_line = (
|
|
296
|
+
f' resources = ["{target_arn}"]\n' if target_arn else " resources = []\n"
|
|
297
|
+
)
|
|
298
|
+
return (
|
|
299
|
+
"# Tighten IAM policy\n"
|
|
300
|
+
f"# TODO: replace resources for {target} if empty\n"
|
|
301
|
+
'data "aws_iam_policy_document" "restricted" {\n'
|
|
302
|
+
" statement {\n"
|
|
303
|
+
f' sid = "Limit{source}Access"\n'
|
|
304
|
+
' effect = "Allow"\n'
|
|
305
|
+
' actions = ["*"]\n'
|
|
306
|
+
f"{resources_line}"
|
|
307
|
+
" }\n"
|
|
308
|
+
"}\n"
|
|
309
|
+
)
|
|
310
|
+
if relationship_type == "CAN_ASSUME":
|
|
311
|
+
identifiers_line = (
|
|
312
|
+
f' identifiers = ["{source_arn}"]\n'
|
|
313
|
+
if source_arn
|
|
314
|
+
else " identifiers = []\n"
|
|
315
|
+
)
|
|
316
|
+
return (
|
|
317
|
+
"# Restrict role trust policy\n"
|
|
318
|
+
f"# TODO: replace trusted principal for {source} if empty\n"
|
|
319
|
+
'data "aws_iam_policy_document" "assume_role" {\n'
|
|
320
|
+
" statement {\n"
|
|
321
|
+
' effect = "Allow"\n'
|
|
322
|
+
" principals {\n"
|
|
323
|
+
' type = "AWS"\n'
|
|
324
|
+
f"{identifiers_line}"
|
|
325
|
+
" }\n"
|
|
326
|
+
' actions = ["sts:AssumeRole"]\n'
|
|
327
|
+
" }\n"
|
|
328
|
+
"}\n"
|
|
329
|
+
)
|
|
330
|
+
return "# Review and update access between resources."
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _output_table(
|
|
334
|
+
plan,
|
|
335
|
+
result,
|
|
336
|
+
snapshot,
|
|
337
|
+
*,
|
|
338
|
+
applied: bool,
|
|
339
|
+
output_path: str | None,
|
|
340
|
+
terraform_path: str | None,
|
|
341
|
+
mode: str,
|
|
342
|
+
):
|
|
343
|
+
"""Render a remediation plan as a table."""
|
|
344
|
+
console.print()
|
|
345
|
+
console.print(
|
|
346
|
+
Panel(
|
|
347
|
+
f"[bold]Remediation Plan[/bold]\n"
|
|
348
|
+
f"Account: {snapshot.aws_account_id if snapshot else 'unknown'}\n"
|
|
349
|
+
f"Attack Paths: {result.total_paths} -> {result.paths_blocked} blocked "
|
|
350
|
+
f"({result.coverage:.0%} coverage)",
|
|
351
|
+
title="cyntrisec remediate",
|
|
352
|
+
border_style="cyan",
|
|
353
|
+
)
|
|
354
|
+
)
|
|
355
|
+
console.print()
|
|
356
|
+
|
|
357
|
+
if not plan:
|
|
358
|
+
console.print("[yellow]No remediations identified.[/yellow]")
|
|
359
|
+
return
|
|
360
|
+
|
|
361
|
+
table = Table(
|
|
362
|
+
title=f"Top {len(plan)} Remediations",
|
|
363
|
+
box=box.ROUNDED,
|
|
364
|
+
show_header=True,
|
|
365
|
+
header_style="bold cyan",
|
|
366
|
+
)
|
|
367
|
+
table.add_column("#", width=3, style="dim")
|
|
368
|
+
table.add_column("Action", width=15)
|
|
369
|
+
table.add_column("Description", min_width=40)
|
|
370
|
+
table.add_column("Blocks", justify="right", width=8)
|
|
371
|
+
|
|
372
|
+
for item in plan:
|
|
373
|
+
table.add_row(
|
|
374
|
+
str(item["priority"]),
|
|
375
|
+
item["action"],
|
|
376
|
+
item["description"],
|
|
377
|
+
f"{item['paths_blocked']} paths",
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
console.print(table)
|
|
381
|
+
console.print("[dim]Use --format json|agent for IaC snippets and automation.[/dim]")
|
|
382
|
+
if applied:
|
|
383
|
+
console.print(
|
|
384
|
+
f"[green]{mode.title()} written to {output_path or 'cyntrisec-remediation-plan.json'}[/green]"
|
|
385
|
+
)
|
|
386
|
+
console.print(
|
|
387
|
+
f"[green]Terraform hints written to {terraform_path or 'cyntrisec-remediation.tf'}[/green]"
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _handle_apply_mode(
|
|
392
|
+
plan: list[dict],
|
|
393
|
+
snapshot,
|
|
394
|
+
apply: bool,
|
|
395
|
+
dry_run: bool,
|
|
396
|
+
execute_terraform: bool,
|
|
397
|
+
terraform_plan: bool,
|
|
398
|
+
terraform_include_output: bool,
|
|
399
|
+
enable_unsafe_write_mode: bool,
|
|
400
|
+
yes: bool,
|
|
401
|
+
output: str | None,
|
|
402
|
+
terraform_output: str | None,
|
|
403
|
+
terraform_dir: str | None,
|
|
404
|
+
terraform_cmd: str,
|
|
405
|
+
):
|
|
406
|
+
"""Handle apply, dry-run, and terraform execution logic."""
|
|
407
|
+
if (apply or execute_terraform or terraform_plan) and not enable_unsafe_write_mode:
|
|
408
|
+
raise CyntriError(
|
|
409
|
+
error_code=ErrorCode.INVALID_QUERY,
|
|
410
|
+
message="Apply/terraform execution is disabled. Use --enable-unsafe-write-mode to proceed.",
|
|
411
|
+
exit_code=EXIT_CODE_MAP["usage"],
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
mode = "apply" if apply else ("terraform-plan" if terraform_plan else "dry_run")
|
|
415
|
+
|
|
416
|
+
# Skip confirmation for dry-run and terraform-plan since they are read-only.
|
|
417
|
+
if not dry_run and not terraform_plan and not yes:
|
|
418
|
+
confirm = typer.confirm(
|
|
419
|
+
"This will write the remediation plan to disk and mark actions as pending. Proceed?",
|
|
420
|
+
default=False,
|
|
421
|
+
err=True,
|
|
422
|
+
)
|
|
423
|
+
if not confirm:
|
|
424
|
+
raise typer.Exit(1)
|
|
425
|
+
|
|
426
|
+
if execute_terraform and not yes:
|
|
427
|
+
confirm_tf = typer.confirm(
|
|
428
|
+
"You requested to run terraform locally. Continue?",
|
|
429
|
+
default=False,
|
|
430
|
+
err=True,
|
|
431
|
+
)
|
|
432
|
+
if not confirm_tf:
|
|
433
|
+
raise typer.Exit(1)
|
|
434
|
+
|
|
435
|
+
plan_path = output or "cyntrisec-remediation-plan.json"
|
|
436
|
+
tf_module_dir = terraform_dir or "cyntrisec-remediation-tf"
|
|
437
|
+
tf_path = terraform_output or str(Path(tf_module_dir) / "main.tf")
|
|
438
|
+
|
|
439
|
+
apply_results, plan_result = _apply_plan(
|
|
440
|
+
plan,
|
|
441
|
+
snapshot,
|
|
442
|
+
plan_path,
|
|
443
|
+
tf_module_dir,
|
|
444
|
+
tf_path,
|
|
445
|
+
dry_run=not apply,
|
|
446
|
+
execute_terraform=execute_terraform and apply,
|
|
447
|
+
terraform_plan=terraform_plan,
|
|
448
|
+
terraform_cmd=terraform_cmd,
|
|
449
|
+
terraform_include_output=terraform_include_output,
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
apply_output = {
|
|
453
|
+
"mode": mode,
|
|
454
|
+
"output_path": plan_path,
|
|
455
|
+
"terraform_path": tf_path,
|
|
456
|
+
"terraform_dir": tf_module_dir,
|
|
457
|
+
"results": apply_results,
|
|
458
|
+
"plan_exit_code": plan_result.get("exit_code") if plan_result else None,
|
|
459
|
+
"plan_summary": plan_result.get("summary") if plan_result else None,
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
return mode, apply_output
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _write_plan_file(plan: list[dict], path: str, snapshot):
|
|
466
|
+
"""Write remediation plan to a JSON file."""
|
|
467
|
+
import json
|
|
468
|
+
|
|
469
|
+
payload = {
|
|
470
|
+
"snapshot_id": str(getattr(snapshot, "id", None)) if snapshot else None,
|
|
471
|
+
"account_id": getattr(snapshot, "aws_account_id", None) if snapshot else None,
|
|
472
|
+
"plan": plan,
|
|
473
|
+
}
|
|
474
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
475
|
+
json.dump(payload, f, indent=2)
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _apply_plan(
|
|
479
|
+
plan: list[dict],
|
|
480
|
+
snapshot,
|
|
481
|
+
plan_path: str,
|
|
482
|
+
tf_dir: str,
|
|
483
|
+
tf_main_path: str,
|
|
484
|
+
*,
|
|
485
|
+
dry_run: bool,
|
|
486
|
+
execute_terraform: bool,
|
|
487
|
+
terraform_plan: bool,
|
|
488
|
+
terraform_cmd: str,
|
|
489
|
+
terraform_include_output: bool,
|
|
490
|
+
) -> tuple[list[dict], dict | None]:
|
|
491
|
+
"""
|
|
492
|
+
Apply or simulate apply of the remediation plan.
|
|
493
|
+
|
|
494
|
+
Writes plan and Terraform hints to disk. Optionally runs terraform plan/apply.
|
|
495
|
+
Returns (items, plan_result).
|
|
496
|
+
"""
|
|
497
|
+
_write_plan_file(plan, plan_path, snapshot)
|
|
498
|
+
tf_main = _write_terraform_files(plan, tf_dir, tf_main_path)
|
|
499
|
+
status = "pending_dry_run" if dry_run else "pending_apply_via_terraform"
|
|
500
|
+
tf_result = None
|
|
501
|
+
plan_result = None
|
|
502
|
+
|
|
503
|
+
if terraform_plan:
|
|
504
|
+
plan_result = _run_terraform_plan(terraform_cmd, tf_dir, include_output=terraform_include_output)
|
|
505
|
+
status = "terraform_plan_ok" if plan_result.get("ok") else "terraform_plan_failed"
|
|
506
|
+
elif execute_terraform and not dry_run:
|
|
507
|
+
tf_result = _run_terraform(terraform_cmd, tf_dir, include_output=terraform_include_output)
|
|
508
|
+
status = "terraform_invoked" if tf_result.get("ok") else "terraform_failed"
|
|
509
|
+
|
|
510
|
+
items = [
|
|
511
|
+
{
|
|
512
|
+
"priority": item["priority"],
|
|
513
|
+
"action": item["action"],
|
|
514
|
+
"description": item["description"],
|
|
515
|
+
"status": status,
|
|
516
|
+
"paths_blocked": item["paths_blocked"],
|
|
517
|
+
"terraform_path": tf_main,
|
|
518
|
+
"terraform_result": tf_result or plan_result,
|
|
519
|
+
}
|
|
520
|
+
for item in plan
|
|
521
|
+
]
|
|
522
|
+
return items, plan_result
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _write_terraform_files(plan: list[dict], dir_path: str, main_path: str) -> str:
|
|
526
|
+
"""Write aggregated Terraform hints as a simple module."""
|
|
527
|
+
dirp = Path(dir_path)
|
|
528
|
+
dirp.mkdir(parents=True, exist_ok=True)
|
|
529
|
+
main_file = Path(main_path) if main_path else dirp / "main.tf"
|
|
530
|
+
body = "\n\n".join(item.get("terraform") or "# no terraform snippet" for item in plan)
|
|
531
|
+
header = "# Cyntrisec remediation hints - review and adapt before apply\n"
|
|
532
|
+
main_file.write_text(header + body, encoding="utf-8")
|
|
533
|
+
|
|
534
|
+
readme = dirp / "README.md"
|
|
535
|
+
if not readme.exists():
|
|
536
|
+
readme.write_text(
|
|
537
|
+
"# Cyntrisec remediation\n\n"
|
|
538
|
+
"This module is generated as a starting point. Review and customize before applying.\n",
|
|
539
|
+
encoding="utf-8",
|
|
540
|
+
)
|
|
541
|
+
return str(main_file)
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _safe_output(text: str, limit: int = 4096) -> str:
|
|
545
|
+
"""
|
|
546
|
+
Truncate and sanitize output to prevent excessive logging and secret leakage.
|
|
547
|
+
"""
|
|
548
|
+
if not text:
|
|
549
|
+
return ""
|
|
550
|
+
|
|
551
|
+
text = re.sub(r"(?i)\b(AKIA|ASIA)[0-9A-Z]{16}\b", "[REDACTED_AWS_ACCESS_KEY_ID]", text)
|
|
552
|
+
text = re.sub(
|
|
553
|
+
r'(?i)(\"?(?:aws_secret_access_key|aws_session_token|secret_access_key|password|secret|token)\"?\s*[:=]\s*)\"?[^\s\",]+\"?',
|
|
554
|
+
r"\1[REDACTED]",
|
|
555
|
+
text,
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
# Truncate if too long
|
|
559
|
+
if len(text) > limit:
|
|
560
|
+
text = text[:limit] + f"\n...[truncated {len(text)-limit} chars]..."
|
|
561
|
+
|
|
562
|
+
return text
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def _decode_bytes(value: object) -> str:
|
|
566
|
+
if value is None:
|
|
567
|
+
return ""
|
|
568
|
+
if isinstance(value, bytes):
|
|
569
|
+
return value.decode(errors="replace")
|
|
570
|
+
return str(value)
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def _run_terraform(terraform_cmd: str, tf_dir: str, *, include_output: bool = False) -> dict:
|
|
574
|
+
"""
|
|
575
|
+
Run terraform apply -auto-approve against the generated hints.
|
|
576
|
+
|
|
577
|
+
Returns a dict with command and status. If terraform is missing, returns error.
|
|
578
|
+
"""
|
|
579
|
+
if not shutil.which(terraform_cmd):
|
|
580
|
+
return {"ok": False, "error": f"terraform command '{terraform_cmd}' not found"}
|
|
581
|
+
|
|
582
|
+
init_cmd = [terraform_cmd, f"-chdir={tf_dir}", "init", "-input=false"]
|
|
583
|
+
apply_cmd = [terraform_cmd, f"-chdir={tf_dir}", "apply", "-auto-approve"]
|
|
584
|
+
try:
|
|
585
|
+
init_result = subprocess.run(init_cmd, check=True, capture_output=True)
|
|
586
|
+
apply_result = subprocess.run(apply_cmd, check=True, capture_output=True)
|
|
587
|
+
return {
|
|
588
|
+
"ok": True,
|
|
589
|
+
"command": " ".join(apply_cmd),
|
|
590
|
+
"stdout": _safe_output(_decode_bytes(apply_result.stdout)) if include_output else "",
|
|
591
|
+
"stderr": _safe_output(_decode_bytes(apply_result.stderr)) if include_output else "",
|
|
592
|
+
"exit_code": apply_result.returncode,
|
|
593
|
+
"init_stdout": _safe_output(_decode_bytes(init_result.stdout)) if include_output else "",
|
|
594
|
+
}
|
|
595
|
+
except subprocess.CalledProcessError as e:
|
|
596
|
+
return {
|
|
597
|
+
"ok": False,
|
|
598
|
+
"error": str(e),
|
|
599
|
+
"command": " ".join(apply_cmd),
|
|
600
|
+
"exit_code": e.returncode,
|
|
601
|
+
"stdout": _safe_output(_decode_bytes(getattr(e, "stdout", b""))) if include_output else "",
|
|
602
|
+
"stderr": _safe_output(_decode_bytes(getattr(e, "stderr", b""))) if include_output else "",
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def _run_terraform_plan(terraform_cmd: str, tf_dir: str, *, include_output: bool = False) -> dict:
|
|
607
|
+
"""
|
|
608
|
+
Run terraform plan (no apply) to validate generated module.
|
|
609
|
+
"""
|
|
610
|
+
if not shutil.which(terraform_cmd):
|
|
611
|
+
return {
|
|
612
|
+
"ok": False,
|
|
613
|
+
"error": f"terraform command '{terraform_cmd}' not found",
|
|
614
|
+
"exit_code": None,
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
init_cmd = [terraform_cmd, f"-chdir={tf_dir}", "init", "-input=false"]
|
|
618
|
+
plan_cmd = [terraform_cmd, f"-chdir={tf_dir}", "plan", "-input=false", "-no-color"]
|
|
619
|
+
try:
|
|
620
|
+
init_result = subprocess.run(init_cmd, check=True, capture_output=True)
|
|
621
|
+
plan_result = subprocess.run(plan_cmd, check=True, capture_output=True)
|
|
622
|
+
stdout_text = _decode_bytes(plan_result.stdout)
|
|
623
|
+
summary = None
|
|
624
|
+
for line in reversed(stdout_text.splitlines()):
|
|
625
|
+
if "Plan:" in line:
|
|
626
|
+
summary = line.strip()
|
|
627
|
+
break
|
|
628
|
+
return {
|
|
629
|
+
"ok": True,
|
|
630
|
+
"exit_code": plan_result.returncode,
|
|
631
|
+
"stdout": _safe_output(stdout_text) if include_output else "",
|
|
632
|
+
"stderr": _safe_output(_decode_bytes(plan_result.stderr)) if include_output else "",
|
|
633
|
+
"summary": summary,
|
|
634
|
+
"init_stdout": _safe_output(_decode_bytes(init_result.stdout)) if include_output else "",
|
|
635
|
+
}
|
|
636
|
+
except subprocess.CalledProcessError as e:
|
|
637
|
+
return {
|
|
638
|
+
"ok": False,
|
|
639
|
+
"exit_code": e.returncode,
|
|
640
|
+
"error": str(e),
|
|
641
|
+
"stdout": _safe_output(_decode_bytes(getattr(e, "stdout", b""))) if include_output else "",
|
|
642
|
+
"stderr": _safe_output(_decode_bytes(getattr(e, "stderr", b""))) if include_output else "",
|
|
643
|
+
}
|