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.
Files changed (65) hide show
  1. cyntrisec/__init__.py +3 -0
  2. cyntrisec/__main__.py +6 -0
  3. cyntrisec/aws/__init__.py +6 -0
  4. cyntrisec/aws/collectors/__init__.py +17 -0
  5. cyntrisec/aws/collectors/ec2.py +30 -0
  6. cyntrisec/aws/collectors/iam.py +116 -0
  7. cyntrisec/aws/collectors/lambda_.py +45 -0
  8. cyntrisec/aws/collectors/network.py +70 -0
  9. cyntrisec/aws/collectors/rds.py +38 -0
  10. cyntrisec/aws/collectors/s3.py +68 -0
  11. cyntrisec/aws/collectors/usage.py +188 -0
  12. cyntrisec/aws/credentials.py +153 -0
  13. cyntrisec/aws/normalizers/__init__.py +17 -0
  14. cyntrisec/aws/normalizers/ec2.py +115 -0
  15. cyntrisec/aws/normalizers/iam.py +182 -0
  16. cyntrisec/aws/normalizers/lambda_.py +83 -0
  17. cyntrisec/aws/normalizers/network.py +225 -0
  18. cyntrisec/aws/normalizers/rds.py +130 -0
  19. cyntrisec/aws/normalizers/s3.py +184 -0
  20. cyntrisec/aws/relationship_builder.py +1359 -0
  21. cyntrisec/aws/scanner.py +303 -0
  22. cyntrisec/cli/__init__.py +5 -0
  23. cyntrisec/cli/analyze.py +747 -0
  24. cyntrisec/cli/ask.py +412 -0
  25. cyntrisec/cli/can.py +307 -0
  26. cyntrisec/cli/comply.py +226 -0
  27. cyntrisec/cli/cuts.py +231 -0
  28. cyntrisec/cli/diff.py +332 -0
  29. cyntrisec/cli/errors.py +105 -0
  30. cyntrisec/cli/explain.py +348 -0
  31. cyntrisec/cli/main.py +114 -0
  32. cyntrisec/cli/manifest.py +893 -0
  33. cyntrisec/cli/output.py +117 -0
  34. cyntrisec/cli/remediate.py +643 -0
  35. cyntrisec/cli/report.py +462 -0
  36. cyntrisec/cli/scan.py +207 -0
  37. cyntrisec/cli/schemas.py +391 -0
  38. cyntrisec/cli/serve.py +164 -0
  39. cyntrisec/cli/setup.py +260 -0
  40. cyntrisec/cli/validate.py +101 -0
  41. cyntrisec/cli/waste.py +323 -0
  42. cyntrisec/core/__init__.py +31 -0
  43. cyntrisec/core/business_config.py +110 -0
  44. cyntrisec/core/business_logic.py +131 -0
  45. cyntrisec/core/compliance.py +437 -0
  46. cyntrisec/core/cost_estimator.py +301 -0
  47. cyntrisec/core/cuts.py +360 -0
  48. cyntrisec/core/diff.py +361 -0
  49. cyntrisec/core/graph.py +202 -0
  50. cyntrisec/core/paths.py +830 -0
  51. cyntrisec/core/schema.py +317 -0
  52. cyntrisec/core/simulator.py +371 -0
  53. cyntrisec/core/waste.py +309 -0
  54. cyntrisec/mcp/__init__.py +5 -0
  55. cyntrisec/mcp/server.py +862 -0
  56. cyntrisec/storage/__init__.py +7 -0
  57. cyntrisec/storage/filesystem.py +344 -0
  58. cyntrisec/storage/memory.py +113 -0
  59. cyntrisec/storage/protocol.py +92 -0
  60. cyntrisec-0.1.7.dist-info/METADATA +672 -0
  61. cyntrisec-0.1.7.dist-info/RECORD +65 -0
  62. cyntrisec-0.1.7.dist-info/WHEEL +4 -0
  63. cyntrisec-0.1.7.dist-info/entry_points.txt +2 -0
  64. cyntrisec-0.1.7.dist-info/licenses/LICENSE +190 -0
  65. 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
+ }