iam-policy-validator 1.14.0__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 (106) hide show
  1. iam_policy_validator-1.14.0.dist-info/METADATA +782 -0
  2. iam_policy_validator-1.14.0.dist-info/RECORD +106 -0
  3. iam_policy_validator-1.14.0.dist-info/WHEEL +4 -0
  4. iam_policy_validator-1.14.0.dist-info/entry_points.txt +2 -0
  5. iam_policy_validator-1.14.0.dist-info/licenses/LICENSE +21 -0
  6. iam_validator/__init__.py +27 -0
  7. iam_validator/__main__.py +11 -0
  8. iam_validator/__version__.py +9 -0
  9. iam_validator/checks/__init__.py +45 -0
  10. iam_validator/checks/action_condition_enforcement.py +1442 -0
  11. iam_validator/checks/action_resource_matching.py +472 -0
  12. iam_validator/checks/action_validation.py +67 -0
  13. iam_validator/checks/condition_key_validation.py +88 -0
  14. iam_validator/checks/condition_type_mismatch.py +257 -0
  15. iam_validator/checks/full_wildcard.py +62 -0
  16. iam_validator/checks/mfa_condition_check.py +105 -0
  17. iam_validator/checks/policy_size.py +114 -0
  18. iam_validator/checks/policy_structure.py +556 -0
  19. iam_validator/checks/policy_type_validation.py +331 -0
  20. iam_validator/checks/principal_validation.py +708 -0
  21. iam_validator/checks/resource_validation.py +135 -0
  22. iam_validator/checks/sensitive_action.py +438 -0
  23. iam_validator/checks/service_wildcard.py +98 -0
  24. iam_validator/checks/set_operator_validation.py +153 -0
  25. iam_validator/checks/sid_uniqueness.py +146 -0
  26. iam_validator/checks/trust_policy_validation.py +509 -0
  27. iam_validator/checks/utils/__init__.py +17 -0
  28. iam_validator/checks/utils/action_parser.py +149 -0
  29. iam_validator/checks/utils/policy_level_checks.py +190 -0
  30. iam_validator/checks/utils/sensitive_action_matcher.py +293 -0
  31. iam_validator/checks/utils/wildcard_expansion.py +86 -0
  32. iam_validator/checks/wildcard_action.py +58 -0
  33. iam_validator/checks/wildcard_resource.py +374 -0
  34. iam_validator/commands/__init__.py +31 -0
  35. iam_validator/commands/analyze.py +549 -0
  36. iam_validator/commands/base.py +48 -0
  37. iam_validator/commands/cache.py +393 -0
  38. iam_validator/commands/completion.py +471 -0
  39. iam_validator/commands/download_services.py +255 -0
  40. iam_validator/commands/post_to_pr.py +86 -0
  41. iam_validator/commands/query.py +485 -0
  42. iam_validator/commands/validate.py +830 -0
  43. iam_validator/core/__init__.py +13 -0
  44. iam_validator/core/access_analyzer.py +671 -0
  45. iam_validator/core/access_analyzer_report.py +640 -0
  46. iam_validator/core/aws_fetcher.py +29 -0
  47. iam_validator/core/aws_service/__init__.py +21 -0
  48. iam_validator/core/aws_service/cache.py +108 -0
  49. iam_validator/core/aws_service/client.py +205 -0
  50. iam_validator/core/aws_service/fetcher.py +641 -0
  51. iam_validator/core/aws_service/parsers.py +149 -0
  52. iam_validator/core/aws_service/patterns.py +51 -0
  53. iam_validator/core/aws_service/storage.py +291 -0
  54. iam_validator/core/aws_service/validators.py +380 -0
  55. iam_validator/core/check_registry.py +679 -0
  56. iam_validator/core/cli.py +134 -0
  57. iam_validator/core/codeowners.py +245 -0
  58. iam_validator/core/condition_validators.py +626 -0
  59. iam_validator/core/config/__init__.py +81 -0
  60. iam_validator/core/config/aws_api.py +35 -0
  61. iam_validator/core/config/aws_global_conditions.py +160 -0
  62. iam_validator/core/config/category_suggestions.py +181 -0
  63. iam_validator/core/config/check_documentation.py +390 -0
  64. iam_validator/core/config/condition_requirements.py +258 -0
  65. iam_validator/core/config/config_loader.py +670 -0
  66. iam_validator/core/config/defaults.py +739 -0
  67. iam_validator/core/config/principal_requirements.py +421 -0
  68. iam_validator/core/config/sensitive_actions.py +672 -0
  69. iam_validator/core/config/service_principals.py +132 -0
  70. iam_validator/core/config/wildcards.py +127 -0
  71. iam_validator/core/constants.py +149 -0
  72. iam_validator/core/diff_parser.py +325 -0
  73. iam_validator/core/finding_fingerprint.py +131 -0
  74. iam_validator/core/formatters/__init__.py +27 -0
  75. iam_validator/core/formatters/base.py +147 -0
  76. iam_validator/core/formatters/console.py +68 -0
  77. iam_validator/core/formatters/csv.py +171 -0
  78. iam_validator/core/formatters/enhanced.py +481 -0
  79. iam_validator/core/formatters/html.py +672 -0
  80. iam_validator/core/formatters/json.py +33 -0
  81. iam_validator/core/formatters/markdown.py +64 -0
  82. iam_validator/core/formatters/sarif.py +251 -0
  83. iam_validator/core/ignore_patterns.py +297 -0
  84. iam_validator/core/ignore_processor.py +309 -0
  85. iam_validator/core/ignored_findings.py +400 -0
  86. iam_validator/core/label_manager.py +197 -0
  87. iam_validator/core/models.py +404 -0
  88. iam_validator/core/policy_checks.py +220 -0
  89. iam_validator/core/policy_loader.py +785 -0
  90. iam_validator/core/pr_commenter.py +780 -0
  91. iam_validator/core/report.py +942 -0
  92. iam_validator/integrations/__init__.py +28 -0
  93. iam_validator/integrations/github_integration.py +1821 -0
  94. iam_validator/integrations/ms_teams.py +442 -0
  95. iam_validator/sdk/__init__.py +220 -0
  96. iam_validator/sdk/arn_matching.py +382 -0
  97. iam_validator/sdk/context.py +222 -0
  98. iam_validator/sdk/exceptions.py +48 -0
  99. iam_validator/sdk/helpers.py +177 -0
  100. iam_validator/sdk/policy_utils.py +451 -0
  101. iam_validator/sdk/query_utils.py +454 -0
  102. iam_validator/sdk/shortcuts.py +283 -0
  103. iam_validator/utils/__init__.py +35 -0
  104. iam_validator/utils/cache.py +105 -0
  105. iam_validator/utils/regex.py +205 -0
  106. iam_validator/utils/terminal.py +22 -0
@@ -0,0 +1,325 @@
1
+ """Diff Parser Module.
2
+
3
+ This module parses GitHub PR diff information to extract changed line numbers.
4
+ It supports GitHub's unified diff format and provides utilities for determining
5
+ which lines and statements were modified in a PR.
6
+ """
7
+
8
+ import logging
9
+ import re
10
+ from dataclasses import dataclass
11
+ from typing import Any
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ @dataclass
17
+ class ParsedDiff:
18
+ """Parsed GitHub PR diff information for a single file.
19
+
20
+ Attributes:
21
+ file_path: Relative path to the file from repository root
22
+ changed_lines: Set of all line numbers that were added or modified (new side)
23
+ added_lines: Set of line numbers that were added (new side)
24
+ deleted_lines: Set of line numbers that were deleted (old side)
25
+ status: File status (added, modified, removed, renamed)
26
+ """
27
+
28
+ file_path: str
29
+ changed_lines: set[int]
30
+ added_lines: set[int]
31
+ deleted_lines: set[int]
32
+ status: str
33
+
34
+
35
+ @dataclass
36
+ class StatementLocation:
37
+ """Location information for a statement in a policy file.
38
+
39
+ Attributes:
40
+ statement_index: Zero-based index of the statement
41
+ start_line: First line number of the statement (1-indexed)
42
+ end_line: Last line number of the statement (1-indexed)
43
+ has_changes: True if any line in this range was modified
44
+ """
45
+
46
+ statement_index: int
47
+ start_line: int
48
+ end_line: int
49
+ has_changes: bool
50
+
51
+
52
+ class DiffParser:
53
+ """Parser for GitHub PR diff information."""
54
+
55
+ @staticmethod
56
+ def parse_pr_files(pr_files: list[dict[str, Any]]) -> dict[str, ParsedDiff]:
57
+ """Parse GitHub PR files response to extract changed line information.
58
+
59
+ Args:
60
+ pr_files: List of file dicts from GitHub API's get_pr_files() call.
61
+ Each dict contains: filename, status, patch, additions, deletions
62
+
63
+ Returns:
64
+ Dict mapping file paths to ParsedDiff objects
65
+
66
+ Example:
67
+ >>> pr_files = [{
68
+ ... "filename": "policies/policy.json",
69
+ ... "status": "modified",
70
+ ... "patch": "@@ -5,3 +5,4 @@\\n context\\n-old\\n+new\\n+added"
71
+ ... }]
72
+ >>> result = DiffParser.parse_pr_files(pr_files)
73
+ >>> result["policies/policy.json"].changed_lines
74
+ {6, 7}
75
+ """
76
+ parsed: dict[str, ParsedDiff] = {}
77
+
78
+ for file_info in pr_files:
79
+ if not isinstance(file_info, dict):
80
+ continue
81
+
82
+ filename = file_info.get("filename")
83
+ if not filename or not isinstance(filename, str):
84
+ continue
85
+
86
+ status = file_info.get("status", "modified")
87
+ patch = file_info.get("patch")
88
+
89
+ # Files without patches (e.g., binary files, very large files)
90
+ # For added/modified files, we use a "no_patch" marker to indicate
91
+ # that we should allow comments on any line (handled in pr_commenter)
92
+ if not patch or not isinstance(patch, str):
93
+ logger.debug(f"No patch available for {filename} (status={status})")
94
+ # Mark as "no_patch" so pr_commenter can handle this specially
95
+ # For added/modified files without patch, we'll allow inline comments
96
+ # on any line since GitHub likely truncated the diff due to size
97
+ parsed[filename] = ParsedDiff(
98
+ file_path=filename,
99
+ changed_lines=set(), # Empty, but status indicates handling
100
+ added_lines=set(),
101
+ deleted_lines=set(),
102
+ status=f"{status}_no_patch", # Mark as no_patch variant
103
+ )
104
+ continue
105
+
106
+ try:
107
+ diff = DiffParser.parse_unified_diff(patch)
108
+ parsed[filename] = ParsedDiff(
109
+ file_path=filename,
110
+ changed_lines=diff["changed_lines"],
111
+ added_lines=diff["added_lines"],
112
+ deleted_lines=diff["deleted_lines"],
113
+ status=status,
114
+ )
115
+ logger.debug(
116
+ f"Parsed diff for {filename}: {len(diff['changed_lines'])} changed lines"
117
+ )
118
+ except Exception as e: # pylint: disable=broad-exception-caught
119
+ logger.warning(f"Failed to parse diff for {filename}: {e}")
120
+ # Track file with empty change sets on parse error
121
+ parsed[filename] = ParsedDiff(
122
+ file_path=filename,
123
+ changed_lines=set(),
124
+ added_lines=set(),
125
+ deleted_lines=set(),
126
+ status=status,
127
+ )
128
+
129
+ return parsed
130
+
131
+ @staticmethod
132
+ def parse_unified_diff(patch: str) -> dict[str, set[int]]:
133
+ """Parse a unified diff patch to extract changed line numbers.
134
+
135
+ Unified diff format uses @@ headers to indicate line ranges:
136
+ @@ -old_start,old_count +new_start,new_count @@
137
+
138
+ Lines starting with:
139
+ - '-' are deletions (old side line numbers)
140
+ - '+' are additions (new side line numbers)
141
+ - ' ' are context (both sides)
142
+
143
+ Args:
144
+ patch: Unified diff string from GitHub API
145
+
146
+ Returns:
147
+ Dict with keys:
148
+ - changed_lines: All added/modified lines (new side)
149
+ - added_lines: Only added lines (new side)
150
+ - deleted_lines: Only deleted lines (old side)
151
+
152
+ Example:
153
+ >>> patch = '''@@ -5,3 +5,4 @@
154
+ ... context line
155
+ ... -deleted line
156
+ ... +added line
157
+ ... +another added line
158
+ ... context line'''
159
+ >>> result = DiffParser.parse_unified_diff(patch)
160
+ >>> result['added_lines']
161
+ {6, 7}
162
+ """
163
+ changed_lines: set[int] = set()
164
+ added_lines: set[int] = set()
165
+ deleted_lines: set[int] = set()
166
+
167
+ # Pattern to match @@ -old_start,old_count +new_start,new_count @@ headers
168
+ # Handles variations: @@ -5,3 +5,4 @@, @@ -5 +5,2 @@, etc.
169
+ hunk_header_pattern = re.compile(r"^@@\s+-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s+@@")
170
+
171
+ lines = patch.split("\n")
172
+ current_new_line = 0
173
+ current_old_line = 0
174
+
175
+ for line in lines:
176
+ # Check for hunk header
177
+ match = hunk_header_pattern.match(line)
178
+ if match:
179
+ old_start = int(match.group(1))
180
+ new_start = int(match.group(3))
181
+ current_old_line = old_start
182
+ current_new_line = new_start
183
+ continue
184
+
185
+ # Process diff lines
186
+ if not line:
187
+ continue
188
+
189
+ first_char = line[0]
190
+
191
+ if first_char == "+":
192
+ # Addition (new side only)
193
+ added_lines.add(current_new_line)
194
+ changed_lines.add(current_new_line)
195
+ current_new_line += 1
196
+ elif first_char == "-":
197
+ # Deletion (old side only)
198
+ deleted_lines.add(current_old_line)
199
+ current_old_line += 1
200
+ elif first_char == " ":
201
+ # Context line (both sides)
202
+ current_new_line += 1
203
+ current_old_line += 1
204
+ # Ignore lines that don't start with +, -, or space (e.g., \ No newline)
205
+
206
+ return {
207
+ "changed_lines": changed_lines,
208
+ "added_lines": added_lines,
209
+ "deleted_lines": deleted_lines,
210
+ }
211
+
212
+ @staticmethod
213
+ def get_modified_statements(
214
+ line_mapping: dict[int, int],
215
+ changed_lines: set[int],
216
+ policy_file: str,
217
+ ) -> dict[int, StatementLocation]:
218
+ """Determine which statements were modified based on changed lines.
219
+
220
+ A statement is considered modified if ANY line within its range appears
221
+ in the changed_lines set.
222
+
223
+ Args:
224
+ line_mapping: Dict mapping statement index to statement start line
225
+ (from PRCommenter._get_line_mapping())
226
+ changed_lines: Set of line numbers that were changed in the PR
227
+ policy_file: Path to the policy file (to determine statement end lines)
228
+
229
+ Returns:
230
+ Dict mapping statement indices to StatementLocation objects
231
+ Only includes statements that were modified.
232
+
233
+ Example:
234
+ >>> line_mapping = {0: 3, 1: 10, 2: 20} # Statement starts
235
+ >>> changed_lines = {5, 6} # Lines changed in statement 0
236
+ >>> result = get_modified_statements(line_mapping, changed_lines, "policy.json")
237
+ >>> result[0].has_changes
238
+ True
239
+ >>> 1 in result # Statement 1 not modified
240
+ False
241
+ """
242
+ if not line_mapping or not changed_lines:
243
+ return {}
244
+
245
+ # Determine end line for each statement
246
+ statement_ranges: dict[int, tuple[int, int]] = {}
247
+ sorted_indices = sorted(line_mapping.keys())
248
+
249
+ for i, stmt_idx in enumerate(sorted_indices):
250
+ start_line = line_mapping[stmt_idx]
251
+
252
+ # End line is either:
253
+ # 1. One line before next statement starts, OR
254
+ # 2. EOF for the last statement
255
+ if i < len(sorted_indices) - 1:
256
+ next_start = line_mapping[sorted_indices[i + 1]]
257
+ end_line = next_start - 1
258
+ else:
259
+ # For last statement, try to read file to get actual end
260
+ end_line = DiffParser.get_statement_end_line(policy_file, start_line)
261
+
262
+ statement_ranges[stmt_idx] = (start_line, end_line)
263
+
264
+ # Check which statements have changes
265
+ modified_statements: dict[int, StatementLocation] = {}
266
+
267
+ for stmt_idx, (start_line, end_line) in statement_ranges.items():
268
+ # Check if any line in this statement's range was changed
269
+ statement_lines = set(range(start_line, end_line + 1))
270
+ has_changes = bool(statement_lines & changed_lines)
271
+
272
+ if has_changes:
273
+ modified_statements[stmt_idx] = StatementLocation(
274
+ statement_index=stmt_idx,
275
+ start_line=start_line,
276
+ end_line=end_line,
277
+ has_changes=True,
278
+ )
279
+ logger.debug(f"Statement {stmt_idx} (lines {start_line}-{end_line}) was modified")
280
+
281
+ return modified_statements
282
+
283
+ @staticmethod
284
+ def get_statement_end_line(policy_file: str, start_line: int) -> int:
285
+ """Find the end line of a statement block starting at start_line.
286
+
287
+ Tracks brace depth to find where the statement object closes.
288
+
289
+ Args:
290
+ policy_file: Path to policy file
291
+ start_line: Line number where statement starts (1-indexed)
292
+
293
+ Returns:
294
+ Line number where statement ends (1-indexed)
295
+ """
296
+ try:
297
+ with open(policy_file, encoding="utf-8") as f:
298
+ lines = f.readlines()
299
+
300
+ # Start counting from the statement's opening brace
301
+ brace_depth = 0
302
+ in_statement = False
303
+
304
+ for line_num in range(start_line - 1, len(lines)): # Convert to 0-indexed
305
+ line = lines[line_num]
306
+
307
+ # Track braces
308
+ for char in line:
309
+ if char == "{":
310
+ brace_depth += 1
311
+ in_statement = True
312
+ elif char == "}":
313
+ brace_depth -= 1
314
+
315
+ # Found the closing brace for this statement
316
+ if in_statement and brace_depth == 0:
317
+ return line_num + 1 # Convert back to 1-indexed
318
+
319
+ # If we couldn't find the end, return a reasonable default
320
+ # (start_line + 20 or end of file)
321
+ return min(start_line + 20, len(lines))
322
+
323
+ except Exception as e: # pylint: disable=broad-exception-caught
324
+ logger.debug(f"Could not determine statement end line: {e}")
325
+ return start_line + 10 # Reasonable default
@@ -0,0 +1,131 @@
1
+ """Finding fingerprint generation for stable issue identification.
2
+
3
+ This module provides a way to generate unique, deterministic identifiers
4
+ for validation findings that survive code changes within a PR. These
5
+ fingerprints are used to track which findings have been ignored by CODEOWNERS.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ from dataclasses import dataclass
12
+ from typing import TYPE_CHECKING
13
+
14
+ if TYPE_CHECKING:
15
+ from iam_validator.core.models import ValidationIssue
16
+
17
+
18
+ def compute_finding_hash(
19
+ file_path: str,
20
+ check_id: str | None,
21
+ issue_type: str,
22
+ statement_sid: str | None,
23
+ statement_index: int,
24
+ action: str | None = None,
25
+ resource: str | None = None,
26
+ condition_key: str | None = None,
27
+ ) -> str:
28
+ """Compute a deterministic 16-character hash for a finding.
29
+
30
+ This is a standalone function to avoid cyclic imports when called
31
+ from models.py.
32
+
33
+ Args:
34
+ file_path: Relative path to the policy file
35
+ check_id: Check that found the issue
36
+ issue_type: Type of issue
37
+ statement_sid: Statement ID if available
38
+ statement_index: Statement index
39
+ action: Specific action (optional)
40
+ resource: Specific resource (optional)
41
+ condition_key: Condition key (optional)
42
+
43
+ Returns:
44
+ 16-character hex string uniquely identifying this finding
45
+ """
46
+ # Use SID if available, otherwise fall back to index
47
+ statement_id = statement_sid if statement_sid else f"idx:{statement_index}"
48
+
49
+ components = [
50
+ file_path,
51
+ check_id or "",
52
+ issue_type,
53
+ statement_id,
54
+ action or "",
55
+ resource or "",
56
+ condition_key or "",
57
+ ]
58
+ combined = "|".join(components)
59
+ return hashlib.sha256(combined.encode()).hexdigest()[:16]
60
+
61
+
62
+ @dataclass(frozen=True, slots=True)
63
+ class FindingFingerprint:
64
+ """Unique identifier for a validation finding across runs.
65
+
66
+ The fingerprint is based on stable attributes of the finding that
67
+ are unlikely to change when code is modified within the same PR.
68
+ Uses frozen=True for immutability and slots=True for memory efficiency.
69
+
70
+ Attributes:
71
+ file_path: Relative path to the policy file
72
+ check_id: Check that found the issue (e.g., "sensitive_action")
73
+ issue_type: Type of issue (e.g., "invalid_action")
74
+ statement_sid: Statement ID if available (more stable than index)
75
+ statement_index: Fallback when no SID is present
76
+ action: Specific action (for action-related issues)
77
+ resource: Specific resource (for resource-related issues)
78
+ condition_key: Specific condition key (for condition issues)
79
+ """
80
+
81
+ file_path: str
82
+ check_id: str
83
+ issue_type: str
84
+ statement_sid: str | None
85
+ statement_index: int
86
+ action: str | None
87
+ resource: str | None
88
+ condition_key: str | None
89
+
90
+ def to_hash(self) -> str:
91
+ """Generate deterministic 16-character hash for storage.
92
+
93
+ The hash is based on all fingerprint components joined with a
94
+ delimiter. Uses SHA-256 truncated to 16 characters for a good
95
+ balance between uniqueness and storage efficiency.
96
+
97
+ Returns:
98
+ 16-character hex string uniquely identifying this finding
99
+ """
100
+ return compute_finding_hash(
101
+ file_path=self.file_path,
102
+ check_id=self.check_id,
103
+ issue_type=self.issue_type,
104
+ statement_sid=self.statement_sid,
105
+ statement_index=self.statement_index,
106
+ action=self.action,
107
+ resource=self.resource,
108
+ condition_key=self.condition_key,
109
+ )
110
+
111
+ @classmethod
112
+ def from_issue(cls, issue: ValidationIssue, file_path: str) -> FindingFingerprint:
113
+ """Create fingerprint from a ValidationIssue.
114
+
115
+ Args:
116
+ issue: The validation issue to fingerprint
117
+ file_path: Relative path to the policy file
118
+
119
+ Returns:
120
+ FindingFingerprint instance for the issue
121
+ """
122
+ return cls(
123
+ file_path=file_path,
124
+ check_id=issue.check_id or "",
125
+ issue_type=issue.issue_type,
126
+ statement_sid=issue.statement_sid,
127
+ statement_index=issue.statement_index,
128
+ action=issue.action,
129
+ resource=issue.resource,
130
+ condition_key=issue.condition_key,
131
+ )
@@ -0,0 +1,27 @@
1
+ """Output formatters for IAM Policy Validator."""
2
+
3
+ from iam_validator.core.formatters.base import (
4
+ FormatterRegistry,
5
+ OutputFormatter,
6
+ get_global_registry,
7
+ )
8
+ from iam_validator.core.formatters.console import ConsoleFormatter
9
+ from iam_validator.core.formatters.csv import CSVFormatter
10
+ from iam_validator.core.formatters.enhanced import EnhancedFormatter
11
+ from iam_validator.core.formatters.html import HTMLFormatter
12
+ from iam_validator.core.formatters.json import JSONFormatter
13
+ from iam_validator.core.formatters.markdown import MarkdownFormatter
14
+ from iam_validator.core.formatters.sarif import SARIFFormatter
15
+
16
+ __all__ = [
17
+ "OutputFormatter",
18
+ "FormatterRegistry",
19
+ "get_global_registry",
20
+ "ConsoleFormatter",
21
+ "EnhancedFormatter",
22
+ "JSONFormatter",
23
+ "MarkdownFormatter",
24
+ "SARIFFormatter",
25
+ "CSVFormatter",
26
+ "HTMLFormatter",
27
+ ]
@@ -0,0 +1,147 @@
1
+ """Base formatter and registry for output formatters."""
2
+
3
+ import logging
4
+ from abc import ABC, abstractmethod
5
+ from typing import Any
6
+
7
+ from iam_validator.core.models import ValidationReport
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ class OutputFormatter(ABC):
13
+ """Base class for all output formatters."""
14
+
15
+ @property
16
+ @abstractmethod
17
+ def format_id(self) -> str:
18
+ """Unique identifier for this formatter (e.g., 'json', 'markdown')."""
19
+ pass
20
+
21
+ @property
22
+ @abstractmethod
23
+ def description(self) -> str:
24
+ """Human-readable description of the formatter."""
25
+ pass
26
+
27
+ @property
28
+ def file_extension(self) -> str:
29
+ """Default file extension for this format."""
30
+ return "txt"
31
+
32
+ @property
33
+ def content_type(self) -> str:
34
+ """MIME content type for this format."""
35
+ return "text/plain"
36
+
37
+ @abstractmethod
38
+ def format(self, report: ValidationReport, **kwargs: Any) -> str:
39
+ """Format the validation report.
40
+
41
+ Args:
42
+ report: The validation report to format
43
+ **kwargs: Additional formatter-specific options
44
+
45
+ Returns:
46
+ Formatted string representation of the report
47
+ """
48
+ pass
49
+
50
+ def format_to_file(self, report: ValidationReport, filepath: str, **kwargs: Any) -> None:
51
+ """Format report and write to file.
52
+
53
+ Args:
54
+ report: The validation report to format
55
+ filepath: Path to output file
56
+ **kwargs: Additional formatter-specific options
57
+ """
58
+ output = self.format(report, **kwargs)
59
+ with open(filepath, "w", encoding="utf-8") as f:
60
+ f.write(output)
61
+ logger.info(f"Report written to {filepath} using {self.format_id} formatter")
62
+
63
+
64
+ class FormatterRegistry:
65
+ """Registry for managing output formatters."""
66
+
67
+ def __init__(self) -> None:
68
+ """Initialize the formatter registry."""
69
+ self._formatters: dict[str, OutputFormatter] = {}
70
+
71
+ def register(self, formatter: OutputFormatter) -> None:
72
+ """Register a new formatter.
73
+
74
+ Args:
75
+ formatter: OutputFormatter instance to register
76
+ """
77
+ self._formatters[formatter.format_id] = formatter
78
+ logger.debug(f"Registered formatter: {formatter.format_id}")
79
+
80
+ def unregister(self, format_id: str) -> None:
81
+ """Unregister a formatter.
82
+
83
+ Args:
84
+ format_id: ID of the formatter to unregister
85
+ """
86
+ if format_id in self._formatters:
87
+ del self._formatters[format_id]
88
+ logger.debug(f"Unregistered formatter: {format_id}")
89
+
90
+ def get_formatter(self, format_id: str) -> OutputFormatter | None:
91
+ """Get a formatter by ID.
92
+
93
+ Args:
94
+ format_id: ID of the formatter to retrieve
95
+
96
+ Returns:
97
+ OutputFormatter instance or None if not found
98
+ """
99
+ return self._formatters.get(format_id)
100
+
101
+ def list_formatters(self) -> dict[str, str]:
102
+ """List all registered formatters.
103
+
104
+ Returns:
105
+ Dictionary of format_id -> description
106
+ """
107
+ return {fmt_id: formatter.description for fmt_id, formatter in self._formatters.items()}
108
+
109
+ def format_report(self, report: ValidationReport, format_id: str, **kwargs: Any) -> str:
110
+ """Format a report using the specified formatter.
111
+
112
+ Args:
113
+ report: The validation report to format
114
+ format_id: ID of the formatter to use
115
+ **kwargs: Additional formatter-specific options
116
+
117
+ Returns:
118
+ Formatted string representation
119
+
120
+ Raises:
121
+ ValueError: If formatter not found
122
+ """
123
+ formatter = self.get_formatter(format_id)
124
+ if not formatter:
125
+ available = ", ".join(self._formatters.keys())
126
+ raise ValueError(f"Formatter '{format_id}' not found. Available: {available}")
127
+
128
+ return formatter.format(report, **kwargs)
129
+
130
+
131
+ # Global formatter registry
132
+ _global_registry = FormatterRegistry()
133
+
134
+
135
+ def get_global_registry() -> FormatterRegistry:
136
+ """Get the global formatter registry."""
137
+ return _global_registry
138
+
139
+
140
+ def register_formatter(formatter: OutputFormatter) -> None:
141
+ """Register a formatter in the global registry."""
142
+ _global_registry.register(formatter)
143
+
144
+
145
+ def get_formatter(format_id: str) -> OutputFormatter | None:
146
+ """Get a formatter from the global registry."""
147
+ return _global_registry.get_formatter(format_id)
@@ -0,0 +1,68 @@
1
+ """Console formatter - Classic console output using report.py."""
2
+
3
+ from io import StringIO
4
+
5
+ from rich.console import Console
6
+
7
+ from iam_validator.core.formatters.base import OutputFormatter
8
+ from iam_validator.core.models import ValidationReport
9
+
10
+
11
+ class ConsoleFormatter(OutputFormatter):
12
+ """Classic console formatter - uses the standard report.py print_console_report output."""
13
+
14
+ @property
15
+ def format_id(self) -> str:
16
+ return "console"
17
+
18
+ @property
19
+ def description(self) -> str:
20
+ return "Classic console output with colors and tables"
21
+
22
+ def format(self, report: ValidationReport, **kwargs) -> str:
23
+ """Format validation report using the classic console output from report.py.
24
+
25
+ This delegates to the ReportGenerator.print_console_report method,
26
+ capturing its output to return as a string.
27
+
28
+ Args:
29
+ report: Validation report to format
30
+ **kwargs: Additional options (color: bool = True)
31
+
32
+ Returns:
33
+ Formatted string with classic console output
34
+ """
35
+ # Import here to avoid circular dependency
36
+ from iam_validator.core.report import ReportGenerator
37
+
38
+ # Allow disabling color for plain text output
39
+ color = kwargs.get("color", True)
40
+
41
+ # Capture the output from print_console_report
42
+ from iam_validator.utils import get_terminal_width
43
+
44
+ string_buffer = StringIO()
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
+ )
53
+
54
+ # Create a generator instance with our custom console
55
+ generator = ReportGenerator()
56
+
57
+ # Replace the console temporarily to capture output
58
+ original_console = generator.console
59
+ generator.console = console
60
+
61
+ # Call the actual print_console_report method
62
+ generator.print_console_report(report)
63
+
64
+ # Restore original console
65
+ generator.console = original_console
66
+
67
+ # Return captured output
68
+ return string_buffer.getvalue()