iam-policy-validator 1.7.1__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 (34) hide show
  1. {iam_policy_validator-1.7.1.dist-info → iam_policy_validator-1.7.2.dist-info}/METADATA +1 -2
  2. {iam_policy_validator-1.7.1.dist-info → iam_policy_validator-1.7.2.dist-info}/RECORD +34 -33
  3. iam_validator/__version__.py +4 -2
  4. iam_validator/checks/action_condition_enforcement.py +20 -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/wildcard_action.py +2 -8
  15. iam_validator/checks/wildcard_resource.py +2 -8
  16. iam_validator/commands/validate.py +2 -2
  17. iam_validator/core/aws_fetcher.py +115 -22
  18. iam_validator/core/config/config_loader.py +1 -2
  19. iam_validator/core/config/defaults.py +16 -7
  20. iam_validator/core/constants.py +57 -0
  21. iam_validator/core/formatters/console.py +10 -1
  22. iam_validator/core/formatters/csv.py +2 -1
  23. iam_validator/core/formatters/enhanced.py +42 -8
  24. iam_validator/core/formatters/markdown.py +2 -1
  25. iam_validator/core/models.py +22 -7
  26. iam_validator/core/policy_checks.py +5 -4
  27. iam_validator/core/policy_loader.py +71 -14
  28. iam_validator/core/report.py +65 -24
  29. iam_validator/integrations/github_integration.py +4 -5
  30. iam_validator/utils/__init__.py +4 -0
  31. iam_validator/utils/terminal.py +22 -0
  32. {iam_policy_validator-1.7.1.dist-info → iam_policy_validator-1.7.2.dist-info}/WHEEL +0 -0
  33. {iam_policy_validator-1.7.1.dist-info → iam_policy_validator-1.7.2.dist-info}/entry_points.txt +0 -0
  34. {iam_policy_validator-1.7.1.dist-info → iam_policy_validator-1.7.2.dist-info}/licenses/LICENSE +0 -0
@@ -39,8 +39,17 @@ class ConsoleFormatter(OutputFormatter):
39
39
  color = kwargs.get("color", True)
40
40
 
41
41
  # Capture the output from print_console_report
42
+ from iam_validator.utils import get_terminal_width
43
+
42
44
  string_buffer = StringIO()
43
- console = Console(file=string_buffer, force_terminal=color, width=120)
45
+ # Get terminal width for proper table column spacing
46
+ terminal_width = get_terminal_width()
47
+ console = Console(
48
+ file=string_buffer,
49
+ force_terminal=color,
50
+ width=terminal_width,
51
+ legacy_windows=False,
52
+ )
44
53
 
45
54
  # Create a generator instance with our custom console
46
55
  generator = ReportGenerator()
@@ -4,6 +4,7 @@ import csv
4
4
  import io
5
5
  from typing import Any
6
6
 
7
+ from iam_validator.core import constants
7
8
  from iam_validator.core.formatters.base import OutputFormatter
8
9
  from iam_validator.core.models import ValidationReport
9
10
 
@@ -58,7 +59,7 @@ class CSVFormatter(OutputFormatter):
58
59
  1
59
60
  for r in report.results
60
61
  for i in r.issues
61
- if i.severity in ("error", "critical", "high")
62
+ if i.severity in constants.HIGH_SEVERITY_LEVELS
62
63
  )
63
64
  warnings = sum(
64
65
  1 for r in report.results for i in r.issues if i.severity in ("warning", "medium")
@@ -10,6 +10,7 @@ from rich.text import Text
10
10
  from rich.tree import Tree
11
11
 
12
12
  from iam_validator.__version__ import __version__
13
+ from iam_validator.core import constants
13
14
  from iam_validator.core.formatters.base import OutputFormatter
14
15
  from iam_validator.core.models import PolicyValidationResult, ValidationReport
15
16
 
@@ -50,8 +51,14 @@ class EnhancedFormatter(OutputFormatter):
50
51
  show_severity_breakdown = kwargs.get("show_severity_breakdown", True)
51
52
 
52
53
  # Use StringIO to capture Rich console output
54
+ from iam_validator.utils import get_terminal_width
55
+
53
56
  string_buffer = StringIO()
54
- console = Console(file=string_buffer, force_terminal=color, width=120)
57
+ # Get terminal width for proper text wrapping
58
+ terminal_width = get_terminal_width()
59
+ console = Console(
60
+ file=string_buffer, force_terminal=color, width=terminal_width, legacy_windows=False
61
+ )
55
62
 
56
63
  # Header with title
57
64
  console.print()
@@ -60,7 +67,14 @@ class EnhancedFormatter(OutputFormatter):
60
67
  style="bold cyan",
61
68
  justify="center",
62
69
  )
63
- console.print(Panel(title, border_style="bright_blue", padding=(1, 0)))
70
+ console.print(
71
+ Panel(
72
+ title,
73
+ border_style=constants.CONSOLE_HEADER_COLOR,
74
+ padding=(1, 0),
75
+ width=constants.CONSOLE_PANEL_WIDTH,
76
+ )
77
+ )
64
78
  console.print()
65
79
 
66
80
  # Executive Summary with progress bars (optional)
@@ -73,7 +87,7 @@ class EnhancedFormatter(OutputFormatter):
73
87
  self._print_severity_breakdown(console, report)
74
88
 
75
89
  console.print()
76
- console.print(Rule(title="[bold]Detailed Results", style="bright_blue"))
90
+ console.print(Rule(title="[bold]Detailed Results", style=constants.CONSOLE_HEADER_COLOR))
77
91
  console.print()
78
92
 
79
93
  # Detailed results using tree structure
@@ -140,8 +154,9 @@ class EnhancedFormatter(OutputFormatter):
140
154
  Panel(
141
155
  metrics_table,
142
156
  title="📊 Executive Summary",
143
- border_style="bright_blue",
157
+ border_style=constants.CONSOLE_HEADER_COLOR,
144
158
  padding=(1, 2),
159
+ width=constants.CONSOLE_PANEL_WIDTH,
145
160
  )
146
161
  )
147
162
 
@@ -225,7 +240,12 @@ class EnhancedFormatter(OutputFormatter):
225
240
  )
226
241
 
227
242
  console.print(
228
- Panel(severity_table, title="🎯 Issue Severity Breakdown", border_style="bright_blue")
243
+ Panel(
244
+ severity_table,
245
+ title="🎯 Issue Severity Breakdown",
246
+ border_style=constants.CONSOLE_HEADER_COLOR,
247
+ width=constants.CONSOLE_PANEL_WIDTH,
248
+ )
229
249
  )
230
250
 
231
251
  def _format_policy_result_modern(
@@ -247,7 +267,7 @@ class EnhancedFormatter(OutputFormatter):
247
267
  elif result.is_valid and result.issues:
248
268
  # Valid IAM policy but has security findings
249
269
  # Check severity to determine the appropriate status
250
- has_critical = any(i.severity in ("error", "critical", "high") for i in result.issues)
270
+ has_critical = any(i.severity in constants.HIGH_SEVERITY_LEVELS for i in result.issues)
251
271
  if has_critical:
252
272
  icon = "⚠️"
253
273
  color = "red"
@@ -386,6 +406,13 @@ class EnhancedFormatter(OutputFormatter):
386
406
  suggestion_text.append(issue.suggestion, style="italic yellow")
387
407
  msg_node.add(suggestion_text)
388
408
 
409
+ # Example (if present, show with indentation)
410
+ if issue.example:
411
+ msg_node.add(Text("Example:", style="bold cyan"))
412
+ # Show example code with syntax highlighting
413
+ example_text = Text(issue.example, style="dim")
414
+ msg_node.add(example_text)
415
+
389
416
  def _print_final_status(self, console: Console, report: ValidationReport) -> None:
390
417
  """Print final status panel."""
391
418
  if report.invalid_policies == 0 and report.total_issues == 0:
@@ -400,7 +427,7 @@ class EnhancedFormatter(OutputFormatter):
400
427
  # Valid IAM policies but may have security findings
401
428
  # Check if there are critical/high security issues
402
429
  has_critical = any(
403
- i.severity in ("error", "critical", "high")
430
+ i.severity in constants.HIGH_SEVERITY_LEVELS
404
431
  for r in report.results
405
432
  for i in r.issues
406
433
  )
@@ -437,4 +464,11 @@ class EnhancedFormatter(OutputFormatter):
437
464
  final_text.append("\n\n")
438
465
  final_text.append(message)
439
466
 
440
- console.print(Panel(final_text, border_style=border_color, padding=(1, 2)))
467
+ console.print(
468
+ Panel(
469
+ final_text,
470
+ border_style=border_color,
471
+ padding=(1, 2),
472
+ width=constants.CONSOLE_PANEL_WIDTH,
473
+ )
474
+ )
@@ -1,5 +1,6 @@
1
1
  """Markdown formatter - placeholder for existing functionality."""
2
2
 
3
+ from iam_validator.core import constants
3
4
  from iam_validator.core.formatters.base import OutputFormatter
4
5
  from iam_validator.core.models import ValidationReport
5
6
 
@@ -34,7 +35,7 @@ class MarkdownFormatter(OutputFormatter):
34
35
  1
35
36
  for r in report.results
36
37
  for i in r.issues
37
- if i.severity in ("error", "critical", "high")
38
+ if i.severity in constants.HIGH_SEVERITY_LEVELS
38
39
  )
39
40
  warnings = sum(
40
41
  1 for r in report.results for i in r.issues if i.severity in ("warning", "medium")
@@ -8,6 +8,8 @@ from typing import Any, ClassVar, Literal
8
8
 
9
9
  from pydantic import BaseModel, ConfigDict, Field
10
10
 
11
+ from iam_validator.core import constants
12
+
11
13
  # Policy Type Constants
12
14
  PolicyType = Literal[
13
15
  "IDENTITY_POLICY",
@@ -89,9 +91,8 @@ class ServiceDetail(BaseModel):
89
91
  resources_list: list[ResourceType] = Field(default_factory=list, alias="Resources")
90
92
  condition_keys_list: list[ConditionKey] = Field(default_factory=list, alias="ConditionKeys")
91
93
 
92
- def model_post_init(self, __context: Any) -> None:
94
+ def model_post_init(self, __context: Any, /) -> None:
93
95
  """Convert lists to dictionaries for easier lookup."""
94
- del __context # Unused
95
96
  # Convert actions list to dict
96
97
  self.actions = {action.name: action for action in self.actions_list}
97
98
  # Convert resources list to dict
@@ -104,7 +105,7 @@ class ServiceDetail(BaseModel):
104
105
  class Statement(BaseModel):
105
106
  """IAM policy statement."""
106
107
 
107
- model_config = ConfigDict(populate_by_name=True)
108
+ model_config = ConfigDict(populate_by_name=True, extra="forbid")
108
109
 
109
110
  sid: str | None = Field(default=None, alias="Sid")
110
111
  effect: str = Field(alias="Effect")
@@ -134,7 +135,7 @@ class Statement(BaseModel):
134
135
  class IAMPolicy(BaseModel):
135
136
  """IAM policy document."""
136
137
 
137
- model_config = ConfigDict(populate_by_name=True)
138
+ model_config = ConfigDict(populate_by_name=True, extra="forbid")
138
139
 
139
140
  version: str = Field(alias="Version")
140
141
  statement: list[Statement] = Field(alias="Statement")
@@ -161,6 +162,7 @@ class ValidationIssue(BaseModel):
161
162
  resource: str | None = None
162
163
  condition_key: str | None = None
163
164
  suggestion: str | None = None
165
+ example: str | None = None # Code example (JSON/YAML) - formatted separately for GitHub
164
166
  line_number: int | None = None # Line number in the policy file (if available)
165
167
 
166
168
  # Severity level constants (ClassVar to avoid Pydantic treating them as fields)
@@ -225,8 +227,8 @@ class ValidationIssue(BaseModel):
225
227
 
226
228
  # Add identifier for bot comment cleanup (HTML comment - not visible to users)
227
229
  if include_identifier:
228
- parts.append("<!-- iam-policy-validator-review -->\n")
229
- parts.append("🤖 **IAM Policy Validator**\n")
230
+ parts.append(f"{constants.REVIEW_IDENTIFIER}\n")
231
+ parts.append(f"{constants.BOT_IDENTIFIER}\n")
230
232
 
231
233
  # Build statement context for better navigation
232
234
  statement_context = f"Statement[{self.statement_index}]"
@@ -241,7 +243,9 @@ class ValidationIssue(BaseModel):
241
243
  parts.append(self.message)
242
244
 
243
245
  # Put additional details in collapsible section if there are any
244
- has_details = bool(self.action or self.resource or self.condition_key or self.suggestion)
246
+ has_details = bool(
247
+ self.action or self.resource or self.condition_key or self.suggestion or self.example
248
+ )
245
249
 
246
250
  if has_details:
247
251
  parts.append("")
@@ -266,6 +270,14 @@ class ValidationIssue(BaseModel):
266
270
  parts.append("**💡 Suggested Fix:**")
267
271
  parts.append("")
268
272
  parts.append(self.suggestion)
273
+ parts.append("")
274
+
275
+ # Add example if present (formatted as JSON code block for GitHub)
276
+ if self.example:
277
+ parts.append("**Example:**")
278
+ parts.append("```json")
279
+ parts.append(self.example)
280
+ parts.append("```")
269
281
 
270
282
  parts.append("")
271
283
  parts.append("</details>")
@@ -298,6 +310,9 @@ class ValidationReport(BaseModel):
298
310
  validity_issues: int = 0 # Count of IAM validity issues (error/warning/info)
299
311
  security_issues: int = 0 # Count of security issues (critical/high/medium/low)
300
312
  results: list[PolicyValidationResult] = Field(default_factory=list)
313
+ parsing_errors: list[tuple[str, str]] = Field(
314
+ default_factory=list
315
+ ) # (file_path, error_message)
301
316
 
302
317
  def get_summary(self) -> str:
303
318
  """Generate a human-readable summary."""
@@ -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