iam-policy-validator 1.10.2__py3-none-any.whl → 1.11.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.11.0.dist-info/METADATA +782 -0
- {iam_policy_validator-1.10.2.dist-info → iam_policy_validator-1.11.0.dist-info}/RECORD +26 -22
- iam_validator/__version__.py +1 -1
- iam_validator/checks/action_condition_enforcement.py +27 -14
- iam_validator/checks/sensitive_action.py +123 -11
- iam_validator/checks/utils/policy_level_checks.py +47 -10
- iam_validator/checks/wildcard_resource.py +29 -7
- iam_validator/commands/__init__.py +6 -0
- iam_validator/commands/completion.py +420 -0
- iam_validator/commands/query.py +485 -0
- iam_validator/commands/validate.py +21 -26
- iam_validator/core/config/category_suggestions.py +77 -0
- iam_validator/core/config/condition_requirements.py +105 -54
- iam_validator/core/config/defaults.py +110 -6
- iam_validator/core/config/wildcards.py +3 -0
- iam_validator/core/diff_parser.py +321 -0
- iam_validator/core/formatters/enhanced.py +34 -27
- iam_validator/core/models.py +2 -0
- iam_validator/core/pr_commenter.py +179 -51
- iam_validator/core/report.py +19 -17
- iam_validator/integrations/github_integration.py +250 -1
- iam_validator/sdk/__init__.py +33 -0
- iam_validator/sdk/query_utils.py +454 -0
- iam_policy_validator-1.10.2.dist-info/METADATA +0 -549
- {iam_policy_validator-1.10.2.dist-info → iam_policy_validator-1.11.0.dist-info}/WHEEL +0 -0
- {iam_policy_validator-1.10.2.dist-info → iam_policy_validator-1.11.0.dist-info}/entry_points.txt +0 -0
- {iam_policy_validator-1.10.2.dist-info → iam_policy_validator-1.11.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,321 @@
|
|
|
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
|
+
if not patch or not isinstance(patch, str):
|
|
91
|
+
logger.debug(f"No patch available for {filename}, skipping diff parsing")
|
|
92
|
+
# Still track the file with empty change sets
|
|
93
|
+
parsed[filename] = ParsedDiff(
|
|
94
|
+
file_path=filename,
|
|
95
|
+
changed_lines=set(),
|
|
96
|
+
added_lines=set(),
|
|
97
|
+
deleted_lines=set(),
|
|
98
|
+
status=status,
|
|
99
|
+
)
|
|
100
|
+
continue
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
diff = DiffParser.parse_unified_diff(patch)
|
|
104
|
+
parsed[filename] = ParsedDiff(
|
|
105
|
+
file_path=filename,
|
|
106
|
+
changed_lines=diff["changed_lines"],
|
|
107
|
+
added_lines=diff["added_lines"],
|
|
108
|
+
deleted_lines=diff["deleted_lines"],
|
|
109
|
+
status=status,
|
|
110
|
+
)
|
|
111
|
+
logger.debug(
|
|
112
|
+
f"Parsed diff for {filename}: {len(diff['changed_lines'])} changed lines"
|
|
113
|
+
)
|
|
114
|
+
except Exception as e: # pylint: disable=broad-exception-caught
|
|
115
|
+
logger.warning(f"Failed to parse diff for {filename}: {e}")
|
|
116
|
+
# Track file with empty change sets on parse error
|
|
117
|
+
parsed[filename] = ParsedDiff(
|
|
118
|
+
file_path=filename,
|
|
119
|
+
changed_lines=set(),
|
|
120
|
+
added_lines=set(),
|
|
121
|
+
deleted_lines=set(),
|
|
122
|
+
status=status,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
return parsed
|
|
126
|
+
|
|
127
|
+
@staticmethod
|
|
128
|
+
def parse_unified_diff(patch: str) -> dict[str, set[int]]:
|
|
129
|
+
"""Parse a unified diff patch to extract changed line numbers.
|
|
130
|
+
|
|
131
|
+
Unified diff format uses @@ headers to indicate line ranges:
|
|
132
|
+
@@ -old_start,old_count +new_start,new_count @@
|
|
133
|
+
|
|
134
|
+
Lines starting with:
|
|
135
|
+
- '-' are deletions (old side line numbers)
|
|
136
|
+
- '+' are additions (new side line numbers)
|
|
137
|
+
- ' ' are context (both sides)
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
patch: Unified diff string from GitHub API
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
Dict with keys:
|
|
144
|
+
- changed_lines: All added/modified lines (new side)
|
|
145
|
+
- added_lines: Only added lines (new side)
|
|
146
|
+
- deleted_lines: Only deleted lines (old side)
|
|
147
|
+
|
|
148
|
+
Example:
|
|
149
|
+
>>> patch = '''@@ -5,3 +5,4 @@
|
|
150
|
+
... context line
|
|
151
|
+
... -deleted line
|
|
152
|
+
... +added line
|
|
153
|
+
... +another added line
|
|
154
|
+
... context line'''
|
|
155
|
+
>>> result = DiffParser.parse_unified_diff(patch)
|
|
156
|
+
>>> result['added_lines']
|
|
157
|
+
{6, 7}
|
|
158
|
+
"""
|
|
159
|
+
changed_lines: set[int] = set()
|
|
160
|
+
added_lines: set[int] = set()
|
|
161
|
+
deleted_lines: set[int] = set()
|
|
162
|
+
|
|
163
|
+
# Pattern to match @@ -old_start,old_count +new_start,new_count @@ headers
|
|
164
|
+
# Handles variations: @@ -5,3 +5,4 @@, @@ -5 +5,2 @@, etc.
|
|
165
|
+
hunk_header_pattern = re.compile(r"^@@\s+-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s+@@")
|
|
166
|
+
|
|
167
|
+
lines = patch.split("\n")
|
|
168
|
+
current_new_line = 0
|
|
169
|
+
current_old_line = 0
|
|
170
|
+
|
|
171
|
+
for line in lines:
|
|
172
|
+
# Check for hunk header
|
|
173
|
+
match = hunk_header_pattern.match(line)
|
|
174
|
+
if match:
|
|
175
|
+
old_start = int(match.group(1))
|
|
176
|
+
new_start = int(match.group(3))
|
|
177
|
+
current_old_line = old_start
|
|
178
|
+
current_new_line = new_start
|
|
179
|
+
continue
|
|
180
|
+
|
|
181
|
+
# Process diff lines
|
|
182
|
+
if not line:
|
|
183
|
+
continue
|
|
184
|
+
|
|
185
|
+
first_char = line[0]
|
|
186
|
+
|
|
187
|
+
if first_char == "+":
|
|
188
|
+
# Addition (new side only)
|
|
189
|
+
added_lines.add(current_new_line)
|
|
190
|
+
changed_lines.add(current_new_line)
|
|
191
|
+
current_new_line += 1
|
|
192
|
+
elif first_char == "-":
|
|
193
|
+
# Deletion (old side only)
|
|
194
|
+
deleted_lines.add(current_old_line)
|
|
195
|
+
current_old_line += 1
|
|
196
|
+
elif first_char == " ":
|
|
197
|
+
# Context line (both sides)
|
|
198
|
+
current_new_line += 1
|
|
199
|
+
current_old_line += 1
|
|
200
|
+
# Ignore lines that don't start with +, -, or space (e.g., \ No newline)
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
"changed_lines": changed_lines,
|
|
204
|
+
"added_lines": added_lines,
|
|
205
|
+
"deleted_lines": deleted_lines,
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
@staticmethod
|
|
209
|
+
def get_modified_statements(
|
|
210
|
+
line_mapping: dict[int, int],
|
|
211
|
+
changed_lines: set[int],
|
|
212
|
+
policy_file: str,
|
|
213
|
+
) -> dict[int, StatementLocation]:
|
|
214
|
+
"""Determine which statements were modified based on changed lines.
|
|
215
|
+
|
|
216
|
+
A statement is considered modified if ANY line within its range appears
|
|
217
|
+
in the changed_lines set.
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
line_mapping: Dict mapping statement index to statement start line
|
|
221
|
+
(from PRCommenter._get_line_mapping())
|
|
222
|
+
changed_lines: Set of line numbers that were changed in the PR
|
|
223
|
+
policy_file: Path to the policy file (to determine statement end lines)
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
Dict mapping statement indices to StatementLocation objects
|
|
227
|
+
Only includes statements that were modified.
|
|
228
|
+
|
|
229
|
+
Example:
|
|
230
|
+
>>> line_mapping = {0: 3, 1: 10, 2: 20} # Statement starts
|
|
231
|
+
>>> changed_lines = {5, 6} # Lines changed in statement 0
|
|
232
|
+
>>> result = get_modified_statements(line_mapping, changed_lines, "policy.json")
|
|
233
|
+
>>> result[0].has_changes
|
|
234
|
+
True
|
|
235
|
+
>>> 1 in result # Statement 1 not modified
|
|
236
|
+
False
|
|
237
|
+
"""
|
|
238
|
+
if not line_mapping or not changed_lines:
|
|
239
|
+
return {}
|
|
240
|
+
|
|
241
|
+
# Determine end line for each statement
|
|
242
|
+
statement_ranges: dict[int, tuple[int, int]] = {}
|
|
243
|
+
sorted_indices = sorted(line_mapping.keys())
|
|
244
|
+
|
|
245
|
+
for i, stmt_idx in enumerate(sorted_indices):
|
|
246
|
+
start_line = line_mapping[stmt_idx]
|
|
247
|
+
|
|
248
|
+
# End line is either:
|
|
249
|
+
# 1. One line before next statement starts, OR
|
|
250
|
+
# 2. EOF for the last statement
|
|
251
|
+
if i < len(sorted_indices) - 1:
|
|
252
|
+
next_start = line_mapping[sorted_indices[i + 1]]
|
|
253
|
+
end_line = next_start - 1
|
|
254
|
+
else:
|
|
255
|
+
# For last statement, try to read file to get actual end
|
|
256
|
+
end_line = DiffParser.get_statement_end_line(policy_file, start_line)
|
|
257
|
+
|
|
258
|
+
statement_ranges[stmt_idx] = (start_line, end_line)
|
|
259
|
+
|
|
260
|
+
# Check which statements have changes
|
|
261
|
+
modified_statements: dict[int, StatementLocation] = {}
|
|
262
|
+
|
|
263
|
+
for stmt_idx, (start_line, end_line) in statement_ranges.items():
|
|
264
|
+
# Check if any line in this statement's range was changed
|
|
265
|
+
statement_lines = set(range(start_line, end_line + 1))
|
|
266
|
+
has_changes = bool(statement_lines & changed_lines)
|
|
267
|
+
|
|
268
|
+
if has_changes:
|
|
269
|
+
modified_statements[stmt_idx] = StatementLocation(
|
|
270
|
+
statement_index=stmt_idx,
|
|
271
|
+
start_line=start_line,
|
|
272
|
+
end_line=end_line,
|
|
273
|
+
has_changes=True,
|
|
274
|
+
)
|
|
275
|
+
logger.debug(f"Statement {stmt_idx} (lines {start_line}-{end_line}) was modified")
|
|
276
|
+
|
|
277
|
+
return modified_statements
|
|
278
|
+
|
|
279
|
+
@staticmethod
|
|
280
|
+
def get_statement_end_line(policy_file: str, start_line: int) -> int:
|
|
281
|
+
"""Find the end line of a statement block starting at start_line.
|
|
282
|
+
|
|
283
|
+
Tracks brace depth to find where the statement object closes.
|
|
284
|
+
|
|
285
|
+
Args:
|
|
286
|
+
policy_file: Path to policy file
|
|
287
|
+
start_line: Line number where statement starts (1-indexed)
|
|
288
|
+
|
|
289
|
+
Returns:
|
|
290
|
+
Line number where statement ends (1-indexed)
|
|
291
|
+
"""
|
|
292
|
+
try:
|
|
293
|
+
with open(policy_file, encoding="utf-8") as f:
|
|
294
|
+
lines = f.readlines()
|
|
295
|
+
|
|
296
|
+
# Start counting from the statement's opening brace
|
|
297
|
+
brace_depth = 0
|
|
298
|
+
in_statement = False
|
|
299
|
+
|
|
300
|
+
for line_num in range(start_line - 1, len(lines)): # Convert to 0-indexed
|
|
301
|
+
line = lines[line_num]
|
|
302
|
+
|
|
303
|
+
# Track braces
|
|
304
|
+
for char in line:
|
|
305
|
+
if char == "{":
|
|
306
|
+
brace_depth += 1
|
|
307
|
+
in_statement = True
|
|
308
|
+
elif char == "}":
|
|
309
|
+
brace_depth -= 1
|
|
310
|
+
|
|
311
|
+
# Found the closing brace for this statement
|
|
312
|
+
if in_statement and brace_depth == 0:
|
|
313
|
+
return line_num + 1 # Convert back to 1-indexed
|
|
314
|
+
|
|
315
|
+
# If we couldn't find the end, return a reasonable default
|
|
316
|
+
# (start_line + 20 or end of file)
|
|
317
|
+
return min(start_line + 20, len(lines))
|
|
318
|
+
|
|
319
|
+
except Exception as e: # pylint: disable=broad-exception-caught
|
|
320
|
+
logger.debug(f"Could not determine statement end line: {e}")
|
|
321
|
+
return start_line + 10 # Reasonable default
|
|
@@ -61,7 +61,6 @@ class EnhancedFormatter(OutputFormatter):
|
|
|
61
61
|
)
|
|
62
62
|
|
|
63
63
|
# Header with title
|
|
64
|
-
console.print()
|
|
65
64
|
title = Text(
|
|
66
65
|
f"IAM Policy Validation Report (v{__version__})",
|
|
67
66
|
style="bold cyan",
|
|
@@ -75,7 +74,6 @@ class EnhancedFormatter(OutputFormatter):
|
|
|
75
74
|
width=constants.CONSOLE_PANEL_WIDTH,
|
|
76
75
|
)
|
|
77
76
|
)
|
|
78
|
-
console.print()
|
|
79
77
|
|
|
80
78
|
# Executive Summary with progress bars (optional)
|
|
81
79
|
if show_summary:
|
|
@@ -83,12 +81,15 @@ class EnhancedFormatter(OutputFormatter):
|
|
|
83
81
|
|
|
84
82
|
# Severity breakdown if there are issues (optional)
|
|
85
83
|
if show_severity_breakdown and report.total_issues > 0:
|
|
86
|
-
console.print()
|
|
87
84
|
self._print_severity_breakdown(console, report)
|
|
88
85
|
|
|
89
|
-
console.print(
|
|
90
|
-
|
|
91
|
-
|
|
86
|
+
console.print(
|
|
87
|
+
Rule(
|
|
88
|
+
title="[bold]Detailed Results",
|
|
89
|
+
style=constants.CONSOLE_HEADER_COLOR,
|
|
90
|
+
),
|
|
91
|
+
width=constants.CONSOLE_PANEL_WIDTH,
|
|
92
|
+
)
|
|
92
93
|
|
|
93
94
|
# Detailed results using tree structure
|
|
94
95
|
for idx, result in enumerate(report.results, 1):
|
|
@@ -99,7 +100,7 @@ class EnhancedFormatter(OutputFormatter):
|
|
|
99
100
|
self._print_final_status(console, report)
|
|
100
101
|
|
|
101
102
|
# Get the formatted output
|
|
102
|
-
output = string_buffer.getvalue()
|
|
103
|
+
output = string_buffer.getvalue().rstrip("\n")
|
|
103
104
|
string_buffer.close()
|
|
104
105
|
|
|
105
106
|
return output
|
|
@@ -305,13 +306,10 @@ class EnhancedFormatter(OutputFormatter):
|
|
|
305
306
|
if not result.issues:
|
|
306
307
|
console.print(header)
|
|
307
308
|
console.print(" [dim italic]No issues detected[/dim italic]")
|
|
308
|
-
console.print()
|
|
309
309
|
return
|
|
310
310
|
|
|
311
311
|
console.print(header)
|
|
312
312
|
console.print(f" [dim]{len(result.issues)} issue(s) found[/dim]")
|
|
313
|
-
console.print()
|
|
314
|
-
|
|
315
313
|
# Create tree structure for issues
|
|
316
314
|
tree = Tree(f"[bold]Issues ({len(result.issues)})[/bold]", guide_style="bright_black")
|
|
317
315
|
|
|
@@ -372,10 +370,14 @@ class EnhancedFormatter(OutputFormatter):
|
|
|
372
370
|
def _add_issue_to_tree(self, branch: Tree, issue, color: str) -> None:
|
|
373
371
|
"""Add an issue to a tree branch."""
|
|
374
372
|
# Build location string (use 1-indexed statement numbers for user-facing output)
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
373
|
+
# Handle policy-level issues (statement_index = -1)
|
|
374
|
+
if issue.statement_index == -1:
|
|
375
|
+
location = "Policy-level"
|
|
376
|
+
else:
|
|
377
|
+
statement_num = issue.statement_index + 1
|
|
378
|
+
location = f"Statement {statement_num}"
|
|
379
|
+
if issue.statement_sid:
|
|
380
|
+
location = f"{issue.statement_sid} (#{statement_num})"
|
|
379
381
|
if issue.line_number is not None:
|
|
380
382
|
location += f" @L{issue.line_number}"
|
|
381
383
|
|
|
@@ -399,19 +401,24 @@ class EnhancedFormatter(OutputFormatter):
|
|
|
399
401
|
details.append(f"Condition: {issue.condition_key}")
|
|
400
402
|
msg_node.add(Text(" • ".join(details), style="dim cyan"))
|
|
401
403
|
|
|
402
|
-
# Suggestion
|
|
403
|
-
if issue.suggestion:
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
404
|
+
# Suggestion and Example - combine into single node to reduce spacing
|
|
405
|
+
if issue.suggestion or issue.example:
|
|
406
|
+
combined_text = Text()
|
|
407
|
+
|
|
408
|
+
# Add suggestion
|
|
409
|
+
if issue.suggestion:
|
|
410
|
+
combined_text.append("💡 ", style="yellow")
|
|
411
|
+
combined_text.append(issue.suggestion, style="italic yellow")
|
|
412
|
+
|
|
413
|
+
# Add example on same node (reduces vertical spacing)
|
|
414
|
+
if issue.example:
|
|
415
|
+
if issue.suggestion:
|
|
416
|
+
combined_text.append("\n", style="yellow") # Single newline separator
|
|
417
|
+
combined_text.append("Example:", style="bold cyan")
|
|
418
|
+
combined_text.append("\n")
|
|
419
|
+
combined_text.append(issue.example, style="dim")
|
|
408
420
|
|
|
409
|
-
|
|
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)
|
|
421
|
+
msg_node.add(combined_text)
|
|
415
422
|
|
|
416
423
|
def _print_final_status(self, console: Console, report: ValidationReport) -> None:
|
|
417
424
|
"""Print final status panel."""
|
|
@@ -461,7 +468,7 @@ class EnhancedFormatter(OutputFormatter):
|
|
|
461
468
|
# Combine status and message
|
|
462
469
|
final_text = Text()
|
|
463
470
|
final_text.append(status)
|
|
464
|
-
final_text.append("\n\n
|
|
471
|
+
final_text.append("\n") # Reduced from \n\n to single newline
|
|
465
472
|
final_text.append(message)
|
|
466
473
|
|
|
467
474
|
console.print(
|
iam_validator/core/models.py
CHANGED
|
@@ -233,6 +233,8 @@ class ValidationIssue(BaseModel):
|
|
|
233
233
|
if include_identifier:
|
|
234
234
|
parts.append(f"{constants.REVIEW_IDENTIFIER}\n")
|
|
235
235
|
parts.append(f"{constants.BOT_IDENTIFIER}\n")
|
|
236
|
+
# Add issue type identifier to allow multiple issues at same line
|
|
237
|
+
parts.append(f"<!-- issue-type: {self.issue_type} -->\n")
|
|
236
238
|
|
|
237
239
|
# Build statement context for better navigation
|
|
238
240
|
statement_context = f"Statement[{self.statement_index}]"
|