iam-policy-validator 1.7.0__py3-none-any.whl → 1.7.2__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 (38) hide show
  1. iam_policy_validator-1.7.2.dist-info/METADATA +428 -0
  2. {iam_policy_validator-1.7.0.dist-info → iam_policy_validator-1.7.2.dist-info}/RECORD +37 -36
  3. iam_validator/__version__.py +4 -2
  4. iam_validator/checks/action_condition_enforcement.py +22 -13
  5. iam_validator/checks/action_resource_matching.py +70 -36
  6. iam_validator/checks/condition_key_validation.py +7 -7
  7. iam_validator/checks/condition_type_mismatch.py +8 -6
  8. iam_validator/checks/full_wildcard.py +2 -8
  9. iam_validator/checks/mfa_condition_check.py +8 -8
  10. iam_validator/checks/principal_validation.py +24 -20
  11. iam_validator/checks/sensitive_action.py +3 -9
  12. iam_validator/checks/service_wildcard.py +2 -8
  13. iam_validator/checks/sid_uniqueness.py +1 -1
  14. iam_validator/checks/utils/sensitive_action_matcher.py +1 -2
  15. iam_validator/checks/utils/wildcard_expansion.py +1 -2
  16. iam_validator/checks/wildcard_action.py +2 -8
  17. iam_validator/checks/wildcard_resource.py +2 -8
  18. iam_validator/commands/validate.py +2 -2
  19. iam_validator/core/aws_fetcher.py +115 -22
  20. iam_validator/core/config/config_loader.py +1 -2
  21. iam_validator/core/config/defaults.py +16 -7
  22. iam_validator/core/constants.py +57 -0
  23. iam_validator/core/formatters/console.py +10 -1
  24. iam_validator/core/formatters/csv.py +2 -1
  25. iam_validator/core/formatters/enhanced.py +42 -8
  26. iam_validator/core/formatters/markdown.py +2 -1
  27. iam_validator/core/models.py +22 -7
  28. iam_validator/core/policy_checks.py +5 -4
  29. iam_validator/core/policy_loader.py +71 -14
  30. iam_validator/core/report.py +65 -24
  31. iam_validator/integrations/github_integration.py +4 -5
  32. iam_validator/utils/__init__.py +4 -0
  33. iam_validator/utils/regex.py +7 -8
  34. iam_validator/utils/terminal.py +22 -0
  35. iam_policy_validator-1.7.0.dist-info/METADATA +0 -1057
  36. {iam_policy_validator-1.7.0.dist-info → iam_policy_validator-1.7.2.dist-info}/WHEEL +0 -0
  37. {iam_policy_validator-1.7.0.dist-info → iam_policy_validator-1.7.2.dist-info}/entry_points.txt +0 -0
  38. {iam_policy_validator-1.7.0.dist-info → iam_policy_validator-1.7.2.dist-info}/licenses/LICENSE +0 -0
@@ -12,6 +12,7 @@ import logging
12
12
  import re
13
13
  from pathlib import Path
14
14
 
15
+ from iam_validator.core import constants
15
16
  from iam_validator.core.aws_fetcher import AWSServiceFetcher
16
17
  from iam_validator.core.check_registry import CheckRegistry
17
18
  from iam_validator.core.models import (
@@ -495,10 +496,10 @@ async def validate_policies(
495
496
 
496
497
  config = ConfigLoader.load_config(explicit_path=config_path, allow_missing=True)
497
498
  cache_enabled = config.get_setting("cache_enabled", True)
498
- cache_ttl_hours = config.get_setting("cache_ttl_hours", 168)
499
+ cache_ttl_hours = config.get_setting("cache_ttl_hours", constants.DEFAULT_CACHE_TTL_HOURS)
499
500
  cache_directory = config.get_setting("cache_directory", None)
500
501
  aws_services_dir = config.get_setting("aws_services_dir", None)
501
- cache_ttl_seconds = cache_ttl_hours * 3600
502
+ cache_ttl_seconds = cache_ttl_hours * constants.SECONDS_PER_HOUR
502
503
 
503
504
  async with AWSServiceFetcher(
504
505
  enable_cache=cache_enabled,
@@ -566,10 +567,10 @@ async def validate_policies(
566
567
 
567
568
  # Get cache settings from config
568
569
  cache_enabled = config.get_setting("cache_enabled", True)
569
- cache_ttl_hours = config.get_setting("cache_ttl_hours", 168) # 7 days default
570
+ cache_ttl_hours = config.get_setting("cache_ttl_hours", constants.DEFAULT_CACHE_TTL_HOURS)
570
571
  cache_directory = config.get_setting("cache_directory", None)
571
572
  aws_services_dir = config.get_setting("aws_services_dir", None)
572
- cache_ttl_seconds = cache_ttl_hours * 3600
573
+ cache_ttl_seconds = cache_ttl_hours * constants.SECONDS_PER_HOUR
573
574
 
574
575
  # Validate policies using registry
575
576
  async with AWSServiceFetcher(
@@ -31,6 +31,7 @@ from collections.abc import Generator
31
31
  from pathlib import Path
32
32
 
33
33
  import yaml
34
+ from pydantic import ValidationError
34
35
 
35
36
  from iam_validator.core.models import IAMPolicy
36
37
 
@@ -53,6 +54,8 @@ class PolicyLoader:
53
54
  """
54
55
  self.loaded_policies: list[tuple[str, IAMPolicy]] = []
55
56
  self.max_file_size_bytes = max_file_size_mb * 1024 * 1024
57
+ # Track parsing/validation errors for reporting
58
+ self.parsing_errors: list[tuple[str, str]] = [] # (file_path, error_message)
56
59
 
57
60
  @staticmethod
58
61
  def _find_statement_line_numbers(file_content: str) -> list[int]:
@@ -142,7 +145,7 @@ class PolicyLoader:
142
145
  return False
143
146
  return True
144
147
  except OSError as e:
145
- logger.error(f"Failed to check file size for {path}: {e}")
148
+ logger.error("Failed to check file size for %s: %s", path, e)
146
149
  return False
147
150
 
148
151
  def load_from_file(self, file_path: str) -> IAMPolicy | None:
@@ -157,11 +160,11 @@ class PolicyLoader:
157
160
  path = Path(file_path)
158
161
 
159
162
  if not path.exists():
160
- logger.error(f"File not found: {file_path}")
163
+ logger.error("File not found: %s", file_path)
161
164
  return None
162
165
 
163
166
  if not path.is_file():
164
- logger.error(f"Not a file: {file_path}")
167
+ logger.error("Not a file: %s", file_path)
165
168
  return None
166
169
 
167
170
  if path.suffix.lower() not in self.SUPPORTED_EXTENSIONS:
@@ -197,17 +200,48 @@ class PolicyLoader:
197
200
  if idx < len(statement_line_numbers):
198
201
  statement.line_number = statement_line_numbers[idx]
199
202
 
200
- logger.info(f"Successfully loaded policy from {file_path}")
203
+ logger.info("Successfully loaded policy from %s", file_path)
201
204
  return policy
202
205
 
203
206
  except json.JSONDecodeError as e:
204
- logger.error(f"Invalid JSON in {file_path}: {e}")
207
+ error_msg = f"Invalid JSON: {e}"
208
+ logger.error("Invalid JSON in %s: %s", file_path, e)
209
+ self.parsing_errors.append((file_path, error_msg))
205
210
  return None
206
211
  except yaml.YAMLError as e:
207
- logger.error(f"Invalid YAML in {file_path}: {e}")
212
+ error_msg = f"Invalid YAML: {e}"
213
+ logger.error("Invalid YAML in %s: %s", file_path, e)
214
+ self.parsing_errors.append((file_path, error_msg))
215
+ return None
216
+ except ValidationError as e:
217
+ # Handle Pydantic validation errors with helpful messages
218
+ error_messages = []
219
+ for error in e.errors():
220
+ loc = ".".join(str(x) for x in error["loc"])
221
+ error_type = error["type"]
222
+
223
+ # Provide user-friendly messages for common errors
224
+ if error_type == "extra_forbidden":
225
+ # Extract the field name that has a typo
226
+ field_name = error["loc"][-1] if error["loc"] else "unknown"
227
+ error_messages.append(
228
+ f"Unknown field '{field_name}' at {loc}. "
229
+ f"This might be a typo. Did you mean 'Condition', 'Action', or 'Resource'?"
230
+ )
231
+ else:
232
+ error_messages.append(f"{loc}: {error['msg']}")
233
+
234
+ error_summary = "\n ".join(error_messages)
235
+ logger.error(
236
+ "Policy validation failed for %s:\n %s",
237
+ file_path,
238
+ error_summary,
239
+ )
240
+ # Track parsing error for GitHub reporting
241
+ self.parsing_errors.append((file_path, error_summary))
208
242
  return None
209
243
  except Exception as e:
210
- logger.error(f"Failed to load policy from {file_path}: {e}")
244
+ logger.error("Failed to load policy from %s: %s", file_path, e)
211
245
  return None
212
246
 
213
247
  def load_from_directory(
@@ -225,11 +259,11 @@ class PolicyLoader:
225
259
  path = Path(directory_path)
226
260
 
227
261
  if not path.exists():
228
- logger.error(f"Directory not found: {directory_path}")
262
+ logger.error("Directory not found: %s", directory_path)
229
263
  return []
230
264
 
231
265
  if not path.is_dir():
232
- logger.error(f"Not a directory: {directory_path}")
266
+ logger.error("Not a directory: %s", directory_path)
233
267
  return []
234
268
 
235
269
  policies: list[tuple[str, IAMPolicy]] = []
@@ -241,7 +275,7 @@ class PolicyLoader:
241
275
  if policy:
242
276
  policies.append((str(file_path), policy))
243
277
 
244
- logger.info(f"Loaded {len(policies)} policies from {directory_path}")
278
+ logger.info("Loaded %d policies from %s", len(policies), directory_path)
245
279
  return policies
246
280
 
247
281
  def load_from_path(self, path: str, recursive: bool = True) -> list[tuple[str, IAMPolicy]]:
@@ -262,7 +296,7 @@ class PolicyLoader:
262
296
  elif path_obj.is_dir():
263
297
  return self.load_from_directory(path, recursive)
264
298
  else:
265
- logger.error(f"Path not found: {path}")
299
+ logger.error("Path not found: %s", path)
266
300
  return []
267
301
 
268
302
  def load_from_paths(
@@ -283,7 +317,7 @@ class PolicyLoader:
283
317
  policies = self.load_from_path(path.strip(), recursive)
284
318
  all_policies.extend(policies)
285
319
 
286
- logger.info(f"Loaded {len(all_policies)} total policies from {len(paths)} path(s)")
320
+ logger.info("Loaded %d total policies from %d path(s)", len(all_policies), len(paths))
287
321
  return all_policies
288
322
 
289
323
  def _get_policy_files(self, path: str, recursive: bool = True) -> Generator[Path, None, None]:
@@ -310,7 +344,7 @@ class PolicyLoader:
310
344
  if file_path.is_file() and file_path.suffix.lower() in self.SUPPORTED_EXTENSIONS:
311
345
  yield file_path
312
346
  else:
313
- logger.error(f"Path not found: {path}")
347
+ logger.error("Path not found: %s", path)
314
348
 
315
349
  def stream_from_path(
316
350
  self, path: str, recursive: bool = True
@@ -391,6 +425,29 @@ class PolicyLoader:
391
425
  policy = IAMPolicy.model_validate(data)
392
426
  logger.info("Successfully parsed policy from string")
393
427
  return policy
428
+ except json.JSONDecodeError as e:
429
+ logger.error("Invalid JSON: %s", e)
430
+ return None
431
+ except ValidationError as e:
432
+ # Handle Pydantic validation errors with helpful messages
433
+ error_messages = []
434
+ for error in e.errors():
435
+ loc = ".".join(str(x) for x in error["loc"])
436
+ error_type = error["type"]
437
+
438
+ # Provide user-friendly messages for common errors
439
+ if error_type == "extra_forbidden":
440
+ # Extract the field name that has a typo
441
+ field_name = error["loc"][-1] if error["loc"] else "unknown"
442
+ error_messages.append(
443
+ f"Unknown field '{field_name}' at {loc}. "
444
+ f"This might be a typo. Did you mean 'Condition', 'Action', or 'Resource'?"
445
+ )
446
+ else:
447
+ error_messages.append(f"{loc}: {error['msg']}")
448
+
449
+ logger.error("Policy validation failed:\n %s", "\n ".join(error_messages))
450
+ return None
394
451
  except Exception as e:
395
- logger.error(f"Failed to parse policy string: {e}")
452
+ logger.error("Failed to parse policy string: %s", e)
396
453
  return None
@@ -12,6 +12,7 @@ from rich.table import Table
12
12
  from rich.text import Text
13
13
 
14
14
  from iam_validator.__version__ import __version__
15
+ from iam_validator.core import constants
15
16
  from iam_validator.core.formatters import (
16
17
  ConsoleFormatter,
17
18
  CSVFormatter,
@@ -71,11 +72,16 @@ class ReportGenerator:
71
72
  """
72
73
  return self.formatter_registry.format_report(report, format_id, **kwargs)
73
74
 
74
- def generate_report(self, results: list[PolicyValidationResult]) -> ValidationReport:
75
+ def generate_report(
76
+ self,
77
+ results: list[PolicyValidationResult],
78
+ parsing_errors: list[tuple[str, str]] | None = None,
79
+ ) -> ValidationReport:
75
80
  """Generate a validation report from results.
76
81
 
77
82
  Args:
78
83
  results: List of policy validation results
84
+ parsing_errors: Optional list of (file_path, error_message) for files that failed to parse
79
85
 
80
86
  Returns:
81
87
  ValidationReport
@@ -106,6 +112,7 @@ class ReportGenerator:
106
112
  validity_issues=validity_issues,
107
113
  security_issues=security_issues,
108
114
  results=results,
115
+ parsing_errors=parsing_errors or [],
109
116
  )
110
117
 
111
118
  def print_console_report(self, report: ValidationReport) -> None:
@@ -150,6 +157,7 @@ class ReportGenerator:
150
157
  summary_text,
151
158
  title=f"Validation Summary (iam-validator v{__version__})",
152
159
  border_style="blue",
160
+ width=constants.CONSOLE_PANEL_WIDTH,
153
161
  )
154
162
  )
155
163
 
@@ -177,11 +185,12 @@ class ReportGenerator:
177
185
  self.console.print(" [dim]No issues found[/dim]")
178
186
  return
179
187
 
180
- # Create issues table with adjusted column widths for better readability
181
- table = Table(show_header=True, header_style="bold", box=None, padding=(0, 1))
182
- table.add_column("Severity", style="cyan", width=12, no_wrap=False)
183
- table.add_column("Type", style="magenta", width=25, no_wrap=False)
184
- table.add_column("Message", style="white", no_wrap=False)
188
+ # Create issues table with flexible column widths
189
+ # Use wider columns and more padding to better utilize terminal width
190
+ table = Table(show_header=True, header_style="bold", box=None, padding=(0, 2), expand=True)
191
+ table.add_column("Severity", style="cyan", no_wrap=True, min_width=12)
192
+ table.add_column("Type", style="magenta", no_wrap=False, min_width=32)
193
+ table.add_column("Message", style="white", no_wrap=False, ratio=3)
185
194
 
186
195
  for issue in result.issues:
187
196
  severity_style = {
@@ -207,6 +216,8 @@ class ReportGenerator:
207
216
  message = f"{location}: {issue.message}"
208
217
  if issue.suggestion:
209
218
  message += f"\n → {issue.suggestion}"
219
+ if issue.example:
220
+ message += f"\n[dim]Example:[/dim]\n[dim]{issue.example}[/dim]"
210
221
 
211
222
  table.add_row(severity_style, issue.issue_type, message)
212
223
 
@@ -224,13 +235,15 @@ class ReportGenerator:
224
235
  return report.model_dump_json(indent=2)
225
236
 
226
237
  def generate_github_comment_parts(
227
- self, report: ValidationReport, max_length_per_part: int = 60000
238
+ self,
239
+ report: ValidationReport,
240
+ max_length_per_part: int = constants.GITHUB_COMMENT_SPLIT_LIMIT,
228
241
  ) -> list[str]:
229
242
  """Generate GitHub PR comment(s), splitting into multiple parts if needed.
230
243
 
231
244
  Args:
232
245
  report: Validation report
233
- max_length_per_part: Maximum character length per comment part (default 60000)
246
+ max_length_per_part: Maximum character length per comment part (default from GITHUB_COMMENT_SPLIT_LIMIT)
234
247
 
235
248
  Returns:
236
249
  List of comment parts (each under max_length_per_part)
@@ -260,9 +273,9 @@ class ReportGenerator:
260
273
  Estimated character count
261
274
  """
262
275
  # Rough estimate: ~500 chars per issue + overhead
263
- base_overhead = 2000 # Header + footer
264
- chars_per_issue = 500
265
- return base_overhead + (report.total_issues * chars_per_issue)
276
+ return constants.COMMENT_BASE_OVERHEAD_CHARS + (
277
+ report.total_issues * constants.COMMENT_CHARS_PER_ISSUE_ESTIMATE
278
+ )
266
279
 
267
280
  def _generate_split_comments(self, report: ValidationReport, max_length: int) -> list[str]:
268
281
  """Split a large report into multiple comment parts.
@@ -289,13 +302,13 @@ class ReportGenerator:
289
302
  # - Part indicator: "**(Part N/M)**\n\n" (estimated ~20 chars)
290
303
  # - HTML comment identifier: "<!-- iam-policy-validator -->\n" (~35 chars)
291
304
  # - Safety buffer for formatting
292
- continuation_overhead = 200
305
+ continuation_overhead = constants.COMMENT_CONTINUATION_OVERHEAD_CHARS
293
306
 
294
307
  # Sort results to prioritize errors - support both IAM validity and security severities
295
308
  sorted_results = sorted(
296
309
  [(idx, r) for idx, r in enumerate(report.results, 1) if r.issues],
297
310
  key=lambda x: (
298
- -sum(1 for i in x[1].issues if i.severity in ("error", "critical", "high")),
311
+ -sum(1 for i in x[1].issues if i.severity in constants.HIGH_SEVERITY_LEVELS),
299
312
  -len(x[1].issues),
300
313
  ),
301
314
  )
@@ -410,7 +423,7 @@ class ReportGenerator:
410
423
  1
411
424
  for r in report.results
412
425
  for i in r.issues
413
- if i.severity in ("error", "critical", "high")
426
+ if i.severity in constants.HIGH_SEVERITY_LEVELS
414
427
  )
415
428
  warnings = sum(
416
429
  1 for r in report.results for i in r.issues if i.severity in ("warning", "medium")
@@ -456,9 +469,9 @@ class ReportGenerator:
456
469
  lines.append("")
457
470
 
458
471
  # Group issues by severity - support both IAM validity and security severities
459
- errors = [i for i in result.issues if i.severity in ("error", "critical", "high")]
460
- warnings = [i for i in result.issues if i.severity in ("warning", "medium")]
461
- infos = [i for i in result.issues if i.severity in ("info", "low")]
472
+ errors = [i for i in result.issues if i.severity in constants.HIGH_SEVERITY_LEVELS]
473
+ warnings = [i for i in result.issues if i.severity in constants.MEDIUM_SEVERITY_LEVELS]
474
+ infos = [i for i in result.issues if i.severity in constants.LOW_SEVERITY_LEVELS]
462
475
 
463
476
  if errors:
464
477
  lines.append("### 🔴 Errors")
@@ -510,12 +523,16 @@ class ReportGenerator:
510
523
 
511
524
  return "\n".join(parts)
512
525
 
513
- def generate_github_comment(self, report: ValidationReport, max_length: int = 65000) -> str:
526
+ def generate_github_comment(
527
+ self,
528
+ report: ValidationReport,
529
+ max_length: int = constants.GITHUB_MAX_COMMENT_LENGTH,
530
+ ) -> str:
514
531
  """Generate a GitHub-flavored markdown comment for PR reviews.
515
532
 
516
533
  Args:
517
534
  report: Validation report
518
- max_length: Maximum character length (GitHub limit is 65536, we use 65000 for safety)
535
+ max_length: Maximum character length (default from GITHUB_MAX_COMMENT_LENGTH constant)
519
536
 
520
537
  Returns:
521
538
  Markdown formatted string
@@ -523,7 +540,8 @@ class ReportGenerator:
523
540
  lines = []
524
541
 
525
542
  # Header with emoji and status badge
526
- if report.invalid_policies == 0:
543
+ has_parsing_errors = len(report.parsing_errors) > 0
544
+ if report.invalid_policies == 0 and not has_parsing_errors:
527
545
  lines.append("# 🎉 IAM Policy Validation Passed!")
528
546
  status_badge = (
529
547
  "![Status](https://img.shields.io/badge/status-passed-success?style=flat-square)"
@@ -558,7 +576,7 @@ class ReportGenerator:
558
576
  1
559
577
  for r in report.results
560
578
  for i in r.issues
561
- if i.severity in ("error", "critical", "high")
579
+ if i.severity in constants.HIGH_SEVERITY_LEVELS
562
580
  )
563
581
  warnings = sum(
564
582
  1 for r in report.results for i in r.issues if i.severity in ("warning", "medium")
@@ -579,6 +597,29 @@ class ReportGenerator:
579
597
  lines.append(f"| 🔵 **Info** | {infos} |")
580
598
  lines.append("")
581
599
 
600
+ # Parsing errors section (if any)
601
+ if report.parsing_errors:
602
+ lines.append("### ⚠️ Parsing Errors")
603
+ lines.append("")
604
+ lines.append(
605
+ f"**{len(report.parsing_errors)} file(s) failed to parse** and were excluded from validation:"
606
+ )
607
+ lines.append("")
608
+ for file_path, error_msg in report.parsing_errors:
609
+ # Extract just the filename for cleaner display
610
+ from pathlib import Path
611
+
612
+ filename = Path(file_path).name
613
+ lines.append(f"- **`{filename}`**")
614
+ lines.append(" ```")
615
+ lines.append(f" {error_msg}")
616
+ lines.append(" ```")
617
+ lines.append("")
618
+ lines.append(
619
+ "> **Note:** Fix these parsing errors first before validation can proceed on these files."
620
+ )
621
+ lines.append("")
622
+
582
623
  # Store header for later (we always include this)
583
624
  header_content = "\n".join(lines)
584
625
 
@@ -594,7 +635,7 @@ class ReportGenerator:
594
635
  footer_content = "\n".join(footer_lines)
595
636
 
596
637
  # Calculate remaining space for details
597
- base_length = len(header_content) + len(footer_content) + 100 # 100 for safety
638
+ base_length = len(header_content) + len(footer_content) + constants.FORMATTING_SAFETY_BUFFER
598
639
  available_length = max_length - base_length
599
640
 
600
641
  # Detailed findings
@@ -611,7 +652,7 @@ class ReportGenerator:
611
652
  sorted_results = sorted(
612
653
  [(idx, r) for idx, r in enumerate(report.results, 1) if r.issues],
613
654
  key=lambda x: (
614
- -sum(1 for i in x[1].issues if i.severity in ("error", "critical", "high")),
655
+ -sum(1 for i in x[1].issues if i.severity in constants.HIGH_SEVERITY_LEVELS),
615
656
  -len(x[1].issues),
616
657
  ),
617
658
  )
@@ -623,7 +664,7 @@ class ReportGenerator:
623
664
  policy_lines = []
624
665
 
625
666
  # Group issues by severity - support both IAM validity and security severities
626
- errors = [i for i in result.issues if i.severity in ("error", "critical", "high")]
667
+ errors = [i for i in result.issues if i.severity in constants.HIGH_SEVERITY_LEVELS]
627
668
  warnings = [i for i in result.issues if i.severity in ("warning", "medium")]
628
669
  infos = [i for i in result.issues if i.severity in ("info", "low")]
629
670
 
@@ -6,11 +6,14 @@ including posting PR comments, line comments, labels, and retrieving PR informat
6
6
 
7
7
  import logging
8
8
  import os
9
+ import re
9
10
  from enum import Enum
10
11
  from typing import Any
11
12
 
12
13
  import httpx
13
14
 
15
+ from iam_validator.core import constants
16
+
14
17
  logger = logging.getLogger(__name__)
15
18
 
16
19
 
@@ -134,8 +137,6 @@ class GitHubIntegration:
134
137
 
135
138
  # Basic sanitization - alphanumeric, hyphens, underscores, dots
136
139
  # GitHub allows these characters in usernames and repo names
137
- import re
138
-
139
140
  valid_pattern = re.compile(r"^[a-zA-Z0-9._-]+$")
140
141
  if not valid_pattern.match(owner) or not valid_pattern.match(repo):
141
142
  logger.warning(
@@ -199,8 +200,6 @@ class GitHubIntegration:
199
200
  return "https://api.github.com"
200
201
 
201
202
  # Basic URL validation
202
- import re
203
-
204
203
  # Simple URL pattern check
205
204
  url_pattern = re.compile(r"^https://[a-zA-Z0-9.-]+(?:/.*)?$")
206
205
  if not url_pattern.match(api_url):
@@ -506,7 +505,7 @@ class GitHubIntegration:
506
505
  return True
507
506
  return False
508
507
 
509
- async def cleanup_bot_review_comments(self, identifier: str = "🤖 IAM Policy Validator") -> int:
508
+ async def cleanup_bot_review_comments(self, identifier: str = constants.BOT_IDENTIFIER) -> int:
510
509
  """Delete all review comments from the bot (from previous runs).
511
510
 
512
511
  This ensures old/outdated comments are removed before posting new ones.
@@ -10,6 +10,7 @@ see iam_validator.checks.utils instead.
10
10
  Organization:
11
11
  - cache.py: Generic caching implementations (LRUCache with TTL)
12
12
  - regex.py: Regex pattern caching and compilation utilities
13
+ - terminal.py: Terminal width detection utilities
13
14
  """
14
15
 
15
16
  from iam_validator.utils.cache import LRUCache
@@ -19,6 +20,7 @@ from iam_validator.utils.regex import (
19
20
  compile_and_cache,
20
21
  get_cached_pattern,
21
22
  )
23
+ from iam_validator.utils.terminal import get_terminal_width
22
24
 
23
25
  __all__ = [
24
26
  # Cache utilities
@@ -28,4 +30,6 @@ __all__ = [
28
30
  "compile_and_cache",
29
31
  "get_cached_pattern",
30
32
  "clear_pattern_cache",
33
+ # Terminal utilities
34
+ "get_terminal_width",
31
35
  ]
@@ -13,13 +13,12 @@ Performance benefits:
13
13
  import re
14
14
  from collections.abc import Callable
15
15
  from functools import wraps
16
- from re import Pattern
17
16
 
18
17
 
19
18
  def cached_pattern(
20
19
  flags: int = 0,
21
20
  maxsize: int = 128,
22
- ) -> Callable[[Callable[[], str]], Callable[[], Pattern]]:
21
+ ) -> Callable[[Callable[[], str]], Callable[[], re.Pattern]]:
23
22
  r"""Decorator that caches compiled regex patterns.
24
23
 
25
24
  This decorator transforms a function that returns a regex pattern string
@@ -60,12 +59,12 @@ def cached_pattern(
60
59
  Cached calls: ~0.1-0.5μs (cache lookup) → 20-100x faster
61
60
  """
62
61
 
63
- def decorator(func: Callable[[], str]) -> Callable[[], Pattern]:
62
+ def decorator(func: Callable[[], str]) -> Callable[[], re.Pattern]:
64
63
  # Use a cache per function to avoid key collisions
65
64
  cache = {}
66
65
 
67
66
  @wraps(func)
68
- def wrapper() -> Pattern:
67
+ def wrapper() -> re.Pattern:
69
68
  # Use function name as cache key (since each decorated function
70
69
  # returns the same pattern string)
71
70
  cache_key = func.__name__
@@ -84,7 +83,7 @@ def cached_pattern(
84
83
  return decorator
85
84
 
86
85
 
87
- def compile_and_cache(pattern: str, flags: int = 0, maxsize: int = 512) -> Pattern:
86
+ def compile_and_cache(pattern: str, flags: int = 0, maxsize: int = 512) -> re.Pattern:
88
87
  """Compile a regex pattern with automatic caching.
89
88
 
90
89
  This is a functional interface (not a decorator) that compiles and caches
@@ -116,17 +115,17 @@ def compile_and_cache(pattern: str, flags: int = 0, maxsize: int = 512) -> Patte
116
115
  from functools import lru_cache
117
116
 
118
117
  @lru_cache(maxsize=maxsize)
119
- def _compile(pattern_str: str, flags: int) -> Pattern:
118
+ def _compile(pattern_str: str, flags: int) -> re.Pattern:
120
119
  return re.compile(pattern_str, flags)
121
120
 
122
121
  return _compile(pattern, flags)
123
122
 
124
123
 
125
124
  # Singleton instance for shared pattern compilation
126
- _pattern_cache: dict[tuple[str, int], Pattern] = {}
125
+ _pattern_cache: dict[tuple[str, int], re.Pattern] = {}
127
126
 
128
127
 
129
- def get_cached_pattern(pattern: str, flags: int = 0) -> Pattern:
128
+ def get_cached_pattern(pattern: str, flags: int = 0) -> re.Pattern:
130
129
  """Get a compiled pattern from the shared cache.
131
130
 
132
131
  This provides a simple, stateless way to get cached patterns without
@@ -0,0 +1,22 @@
1
+ """Terminal utilities for console output formatting."""
2
+
3
+ import shutil
4
+
5
+
6
+ def get_terminal_width(min_width: int = 80, max_width: int = 150, fallback: int = 100) -> int:
7
+ """Get the current terminal width with reasonable bounds.
8
+
9
+ Args:
10
+ min_width: Minimum width to return (default: 80)
11
+ max_width: Maximum width to return (default: 150)
12
+ fallback: Fallback width if detection fails (default: 100)
13
+
14
+ Returns:
15
+ Terminal width within the specified bounds
16
+ """
17
+ try:
18
+ terminal_width = shutil.get_terminal_size().columns
19
+ # Ensure width is within reasonable bounds
20
+ return max(min(terminal_width, max_width), min_width)
21
+ except Exception:
22
+ return fallback