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.
- iam_policy_validator-1.14.0.dist-info/METADATA +782 -0
- iam_policy_validator-1.14.0.dist-info/RECORD +106 -0
- iam_policy_validator-1.14.0.dist-info/WHEEL +4 -0
- iam_policy_validator-1.14.0.dist-info/entry_points.txt +2 -0
- iam_policy_validator-1.14.0.dist-info/licenses/LICENSE +21 -0
- iam_validator/__init__.py +27 -0
- iam_validator/__main__.py +11 -0
- iam_validator/__version__.py +9 -0
- iam_validator/checks/__init__.py +45 -0
- iam_validator/checks/action_condition_enforcement.py +1442 -0
- iam_validator/checks/action_resource_matching.py +472 -0
- iam_validator/checks/action_validation.py +67 -0
- iam_validator/checks/condition_key_validation.py +88 -0
- iam_validator/checks/condition_type_mismatch.py +257 -0
- iam_validator/checks/full_wildcard.py +62 -0
- iam_validator/checks/mfa_condition_check.py +105 -0
- iam_validator/checks/policy_size.py +114 -0
- iam_validator/checks/policy_structure.py +556 -0
- iam_validator/checks/policy_type_validation.py +331 -0
- iam_validator/checks/principal_validation.py +708 -0
- iam_validator/checks/resource_validation.py +135 -0
- iam_validator/checks/sensitive_action.py +438 -0
- iam_validator/checks/service_wildcard.py +98 -0
- iam_validator/checks/set_operator_validation.py +153 -0
- iam_validator/checks/sid_uniqueness.py +146 -0
- iam_validator/checks/trust_policy_validation.py +509 -0
- iam_validator/checks/utils/__init__.py +17 -0
- iam_validator/checks/utils/action_parser.py +149 -0
- iam_validator/checks/utils/policy_level_checks.py +190 -0
- iam_validator/checks/utils/sensitive_action_matcher.py +293 -0
- iam_validator/checks/utils/wildcard_expansion.py +86 -0
- iam_validator/checks/wildcard_action.py +58 -0
- iam_validator/checks/wildcard_resource.py +374 -0
- iam_validator/commands/__init__.py +31 -0
- iam_validator/commands/analyze.py +549 -0
- iam_validator/commands/base.py +48 -0
- iam_validator/commands/cache.py +393 -0
- iam_validator/commands/completion.py +471 -0
- iam_validator/commands/download_services.py +255 -0
- iam_validator/commands/post_to_pr.py +86 -0
- iam_validator/commands/query.py +485 -0
- iam_validator/commands/validate.py +830 -0
- iam_validator/core/__init__.py +13 -0
- iam_validator/core/access_analyzer.py +671 -0
- iam_validator/core/access_analyzer_report.py +640 -0
- iam_validator/core/aws_fetcher.py +29 -0
- iam_validator/core/aws_service/__init__.py +21 -0
- iam_validator/core/aws_service/cache.py +108 -0
- iam_validator/core/aws_service/client.py +205 -0
- iam_validator/core/aws_service/fetcher.py +641 -0
- iam_validator/core/aws_service/parsers.py +149 -0
- iam_validator/core/aws_service/patterns.py +51 -0
- iam_validator/core/aws_service/storage.py +291 -0
- iam_validator/core/aws_service/validators.py +380 -0
- iam_validator/core/check_registry.py +679 -0
- iam_validator/core/cli.py +134 -0
- iam_validator/core/codeowners.py +245 -0
- iam_validator/core/condition_validators.py +626 -0
- iam_validator/core/config/__init__.py +81 -0
- iam_validator/core/config/aws_api.py +35 -0
- iam_validator/core/config/aws_global_conditions.py +160 -0
- iam_validator/core/config/category_suggestions.py +181 -0
- iam_validator/core/config/check_documentation.py +390 -0
- iam_validator/core/config/condition_requirements.py +258 -0
- iam_validator/core/config/config_loader.py +670 -0
- iam_validator/core/config/defaults.py +739 -0
- iam_validator/core/config/principal_requirements.py +421 -0
- iam_validator/core/config/sensitive_actions.py +672 -0
- iam_validator/core/config/service_principals.py +132 -0
- iam_validator/core/config/wildcards.py +127 -0
- iam_validator/core/constants.py +149 -0
- iam_validator/core/diff_parser.py +325 -0
- iam_validator/core/finding_fingerprint.py +131 -0
- iam_validator/core/formatters/__init__.py +27 -0
- iam_validator/core/formatters/base.py +147 -0
- iam_validator/core/formatters/console.py +68 -0
- iam_validator/core/formatters/csv.py +171 -0
- iam_validator/core/formatters/enhanced.py +481 -0
- iam_validator/core/formatters/html.py +672 -0
- iam_validator/core/formatters/json.py +33 -0
- iam_validator/core/formatters/markdown.py +64 -0
- iam_validator/core/formatters/sarif.py +251 -0
- iam_validator/core/ignore_patterns.py +297 -0
- iam_validator/core/ignore_processor.py +309 -0
- iam_validator/core/ignored_findings.py +400 -0
- iam_validator/core/label_manager.py +197 -0
- iam_validator/core/models.py +404 -0
- iam_validator/core/policy_checks.py +220 -0
- iam_validator/core/policy_loader.py +785 -0
- iam_validator/core/pr_commenter.py +780 -0
- iam_validator/core/report.py +942 -0
- iam_validator/integrations/__init__.py +28 -0
- iam_validator/integrations/github_integration.py +1821 -0
- iam_validator/integrations/ms_teams.py +442 -0
- iam_validator/sdk/__init__.py +220 -0
- iam_validator/sdk/arn_matching.py +382 -0
- iam_validator/sdk/context.py +222 -0
- iam_validator/sdk/exceptions.py +48 -0
- iam_validator/sdk/helpers.py +177 -0
- iam_validator/sdk/policy_utils.py +451 -0
- iam_validator/sdk/query_utils.py +454 -0
- iam_validator/sdk/shortcuts.py +283 -0
- iam_validator/utils/__init__.py +35 -0
- iam_validator/utils/cache.py +105 -0
- iam_validator/utils/regex.py +205 -0
- 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()
|