iam-policy-validator 1.7.0__py3-none-any.whl → 1.7.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- iam_policy_validator-1.7.2.dist-info/METADATA +428 -0
- {iam_policy_validator-1.7.0.dist-info → iam_policy_validator-1.7.2.dist-info}/RECORD +37 -36
- iam_validator/__version__.py +4 -2
- iam_validator/checks/action_condition_enforcement.py +22 -13
- iam_validator/checks/action_resource_matching.py +70 -36
- iam_validator/checks/condition_key_validation.py +7 -7
- iam_validator/checks/condition_type_mismatch.py +8 -6
- iam_validator/checks/full_wildcard.py +2 -8
- iam_validator/checks/mfa_condition_check.py +8 -8
- iam_validator/checks/principal_validation.py +24 -20
- iam_validator/checks/sensitive_action.py +3 -9
- iam_validator/checks/service_wildcard.py +2 -8
- iam_validator/checks/sid_uniqueness.py +1 -1
- iam_validator/checks/utils/sensitive_action_matcher.py +1 -2
- iam_validator/checks/utils/wildcard_expansion.py +1 -2
- iam_validator/checks/wildcard_action.py +2 -8
- iam_validator/checks/wildcard_resource.py +2 -8
- iam_validator/commands/validate.py +2 -2
- iam_validator/core/aws_fetcher.py +115 -22
- iam_validator/core/config/config_loader.py +1 -2
- iam_validator/core/config/defaults.py +16 -7
- iam_validator/core/constants.py +57 -0
- iam_validator/core/formatters/console.py +10 -1
- iam_validator/core/formatters/csv.py +2 -1
- iam_validator/core/formatters/enhanced.py +42 -8
- iam_validator/core/formatters/markdown.py +2 -1
- iam_validator/core/models.py +22 -7
- iam_validator/core/policy_checks.py +5 -4
- iam_validator/core/policy_loader.py +71 -14
- iam_validator/core/report.py +65 -24
- iam_validator/integrations/github_integration.py +4 -5
- iam_validator/utils/__init__.py +4 -0
- iam_validator/utils/regex.py +7 -8
- iam_validator/utils/terminal.py +22 -0
- iam_policy_validator-1.7.0.dist-info/METADATA +0 -1057
- {iam_policy_validator-1.7.0.dist-info → iam_policy_validator-1.7.2.dist-info}/WHEEL +0 -0
- {iam_policy_validator-1.7.0.dist-info → iam_policy_validator-1.7.2.dist-info}/entry_points.txt +0 -0
- {iam_policy_validator-1.7.0.dist-info → iam_policy_validator-1.7.2.dist-info}/licenses/LICENSE +0 -0
|
@@ -12,6 +12,7 @@ import logging
|
|
|
12
12
|
import re
|
|
13
13
|
from pathlib import Path
|
|
14
14
|
|
|
15
|
+
from iam_validator.core import constants
|
|
15
16
|
from iam_validator.core.aws_fetcher import AWSServiceFetcher
|
|
16
17
|
from iam_validator.core.check_registry import CheckRegistry
|
|
17
18
|
from iam_validator.core.models import (
|
|
@@ -495,10 +496,10 @@ async def validate_policies(
|
|
|
495
496
|
|
|
496
497
|
config = ConfigLoader.load_config(explicit_path=config_path, allow_missing=True)
|
|
497
498
|
cache_enabled = config.get_setting("cache_enabled", True)
|
|
498
|
-
cache_ttl_hours = config.get_setting("cache_ttl_hours",
|
|
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 *
|
|
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",
|
|
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 *
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
203
|
+
logger.info("Successfully loaded policy from %s", file_path)
|
|
201
204
|
return policy
|
|
202
205
|
|
|
203
206
|
except json.JSONDecodeError as e:
|
|
204
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
262
|
+
logger.error("Directory not found: %s", directory_path)
|
|
229
263
|
return []
|
|
230
264
|
|
|
231
265
|
if not path.is_dir():
|
|
232
|
-
logger.error(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
452
|
+
logger.error("Failed to parse policy string: %s", e)
|
|
396
453
|
return None
|
iam_validator/core/report.py
CHANGED
|
@@ -12,6 +12,7 @@ from rich.table import Table
|
|
|
12
12
|
from rich.text import Text
|
|
13
13
|
|
|
14
14
|
from iam_validator.__version__ import __version__
|
|
15
|
+
from iam_validator.core import constants
|
|
15
16
|
from iam_validator.core.formatters import (
|
|
16
17
|
ConsoleFormatter,
|
|
17
18
|
CSVFormatter,
|
|
@@ -71,11 +72,16 @@ class ReportGenerator:
|
|
|
71
72
|
"""
|
|
72
73
|
return self.formatter_registry.format_report(report, format_id, **kwargs)
|
|
73
74
|
|
|
74
|
-
def generate_report(
|
|
75
|
+
def generate_report(
|
|
76
|
+
self,
|
|
77
|
+
results: list[PolicyValidationResult],
|
|
78
|
+
parsing_errors: list[tuple[str, str]] | None = None,
|
|
79
|
+
) -> ValidationReport:
|
|
75
80
|
"""Generate a validation report from results.
|
|
76
81
|
|
|
77
82
|
Args:
|
|
78
83
|
results: List of policy validation results
|
|
84
|
+
parsing_errors: Optional list of (file_path, error_message) for files that failed to parse
|
|
79
85
|
|
|
80
86
|
Returns:
|
|
81
87
|
ValidationReport
|
|
@@ -106,6 +112,7 @@ class ReportGenerator:
|
|
|
106
112
|
validity_issues=validity_issues,
|
|
107
113
|
security_issues=security_issues,
|
|
108
114
|
results=results,
|
|
115
|
+
parsing_errors=parsing_errors or [],
|
|
109
116
|
)
|
|
110
117
|
|
|
111
118
|
def print_console_report(self, report: ValidationReport) -> None:
|
|
@@ -150,6 +157,7 @@ class ReportGenerator:
|
|
|
150
157
|
summary_text,
|
|
151
158
|
title=f"Validation Summary (iam-validator v{__version__})",
|
|
152
159
|
border_style="blue",
|
|
160
|
+
width=constants.CONSOLE_PANEL_WIDTH,
|
|
153
161
|
)
|
|
154
162
|
)
|
|
155
163
|
|
|
@@ -177,11 +185,12 @@ class ReportGenerator:
|
|
|
177
185
|
self.console.print(" [dim]No issues found[/dim]")
|
|
178
186
|
return
|
|
179
187
|
|
|
180
|
-
# Create issues table with
|
|
181
|
-
|
|
182
|
-
table
|
|
183
|
-
table.add_column("
|
|
184
|
-
table.add_column("
|
|
188
|
+
# Create issues table with flexible column widths
|
|
189
|
+
# Use wider columns and more padding to better utilize terminal width
|
|
190
|
+
table = Table(show_header=True, header_style="bold", box=None, padding=(0, 2), expand=True)
|
|
191
|
+
table.add_column("Severity", style="cyan", no_wrap=True, min_width=12)
|
|
192
|
+
table.add_column("Type", style="magenta", no_wrap=False, min_width=32)
|
|
193
|
+
table.add_column("Message", style="white", no_wrap=False, ratio=3)
|
|
185
194
|
|
|
186
195
|
for issue in result.issues:
|
|
187
196
|
severity_style = {
|
|
@@ -207,6 +216,8 @@ class ReportGenerator:
|
|
|
207
216
|
message = f"{location}: {issue.message}"
|
|
208
217
|
if issue.suggestion:
|
|
209
218
|
message += f"\n → {issue.suggestion}"
|
|
219
|
+
if issue.example:
|
|
220
|
+
message += f"\n[dim]Example:[/dim]\n[dim]{issue.example}[/dim]"
|
|
210
221
|
|
|
211
222
|
table.add_row(severity_style, issue.issue_type, message)
|
|
212
223
|
|
|
@@ -224,13 +235,15 @@ class ReportGenerator:
|
|
|
224
235
|
return report.model_dump_json(indent=2)
|
|
225
236
|
|
|
226
237
|
def generate_github_comment_parts(
|
|
227
|
-
self,
|
|
238
|
+
self,
|
|
239
|
+
report: ValidationReport,
|
|
240
|
+
max_length_per_part: int = constants.GITHUB_COMMENT_SPLIT_LIMIT,
|
|
228
241
|
) -> list[str]:
|
|
229
242
|
"""Generate GitHub PR comment(s), splitting into multiple parts if needed.
|
|
230
243
|
|
|
231
244
|
Args:
|
|
232
245
|
report: Validation report
|
|
233
|
-
max_length_per_part: Maximum character length per comment part (default
|
|
246
|
+
max_length_per_part: Maximum character length per comment part (default from GITHUB_COMMENT_SPLIT_LIMIT)
|
|
234
247
|
|
|
235
248
|
Returns:
|
|
236
249
|
List of comment parts (each under max_length_per_part)
|
|
@@ -260,9 +273,9 @@ class ReportGenerator:
|
|
|
260
273
|
Estimated character count
|
|
261
274
|
"""
|
|
262
275
|
# Rough estimate: ~500 chars per issue + overhead
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
276
|
+
return constants.COMMENT_BASE_OVERHEAD_CHARS + (
|
|
277
|
+
report.total_issues * constants.COMMENT_CHARS_PER_ISSUE_ESTIMATE
|
|
278
|
+
)
|
|
266
279
|
|
|
267
280
|
def _generate_split_comments(self, report: ValidationReport, max_length: int) -> list[str]:
|
|
268
281
|
"""Split a large report into multiple comment parts.
|
|
@@ -289,13 +302,13 @@ class ReportGenerator:
|
|
|
289
302
|
# - Part indicator: "**(Part N/M)**\n\n" (estimated ~20 chars)
|
|
290
303
|
# - HTML comment identifier: "<!-- iam-policy-validator -->\n" (~35 chars)
|
|
291
304
|
# - Safety buffer for formatting
|
|
292
|
-
continuation_overhead =
|
|
305
|
+
continuation_overhead = constants.COMMENT_CONTINUATION_OVERHEAD_CHARS
|
|
293
306
|
|
|
294
307
|
# Sort results to prioritize errors - support both IAM validity and security severities
|
|
295
308
|
sorted_results = sorted(
|
|
296
309
|
[(idx, r) for idx, r in enumerate(report.results, 1) if r.issues],
|
|
297
310
|
key=lambda x: (
|
|
298
|
-
-sum(1 for i in x[1].issues if i.severity in
|
|
311
|
+
-sum(1 for i in x[1].issues if i.severity in constants.HIGH_SEVERITY_LEVELS),
|
|
299
312
|
-len(x[1].issues),
|
|
300
313
|
),
|
|
301
314
|
)
|
|
@@ -410,7 +423,7 @@ class ReportGenerator:
|
|
|
410
423
|
1
|
|
411
424
|
for r in report.results
|
|
412
425
|
for i in r.issues
|
|
413
|
-
if i.severity in
|
|
426
|
+
if i.severity in constants.HIGH_SEVERITY_LEVELS
|
|
414
427
|
)
|
|
415
428
|
warnings = sum(
|
|
416
429
|
1 for r in report.results for i in r.issues if i.severity in ("warning", "medium")
|
|
@@ -456,9 +469,9 @@ class ReportGenerator:
|
|
|
456
469
|
lines.append("")
|
|
457
470
|
|
|
458
471
|
# Group issues by severity - support both IAM validity and security severities
|
|
459
|
-
errors = [i for i in result.issues if i.severity in
|
|
460
|
-
warnings = [i for i in result.issues if i.severity in
|
|
461
|
-
infos = [i for i in result.issues if i.severity in
|
|
472
|
+
errors = [i for i in result.issues if i.severity in constants.HIGH_SEVERITY_LEVELS]
|
|
473
|
+
warnings = [i for i in result.issues if i.severity in constants.MEDIUM_SEVERITY_LEVELS]
|
|
474
|
+
infos = [i for i in result.issues if i.severity in constants.LOW_SEVERITY_LEVELS]
|
|
462
475
|
|
|
463
476
|
if errors:
|
|
464
477
|
lines.append("### 🔴 Errors")
|
|
@@ -510,12 +523,16 @@ class ReportGenerator:
|
|
|
510
523
|
|
|
511
524
|
return "\n".join(parts)
|
|
512
525
|
|
|
513
|
-
def generate_github_comment(
|
|
526
|
+
def generate_github_comment(
|
|
527
|
+
self,
|
|
528
|
+
report: ValidationReport,
|
|
529
|
+
max_length: int = constants.GITHUB_MAX_COMMENT_LENGTH,
|
|
530
|
+
) -> str:
|
|
514
531
|
"""Generate a GitHub-flavored markdown comment for PR reviews.
|
|
515
532
|
|
|
516
533
|
Args:
|
|
517
534
|
report: Validation report
|
|
518
|
-
max_length: Maximum character length (
|
|
535
|
+
max_length: Maximum character length (default from GITHUB_MAX_COMMENT_LENGTH constant)
|
|
519
536
|
|
|
520
537
|
Returns:
|
|
521
538
|
Markdown formatted string
|
|
@@ -523,7 +540,8 @@ class ReportGenerator:
|
|
|
523
540
|
lines = []
|
|
524
541
|
|
|
525
542
|
# Header with emoji and status badge
|
|
526
|
-
|
|
543
|
+
has_parsing_errors = len(report.parsing_errors) > 0
|
|
544
|
+
if report.invalid_policies == 0 and not has_parsing_errors:
|
|
527
545
|
lines.append("# 🎉 IAM Policy Validation Passed!")
|
|
528
546
|
status_badge = (
|
|
529
547
|
""
|
|
@@ -558,7 +576,7 @@ class ReportGenerator:
|
|
|
558
576
|
1
|
|
559
577
|
for r in report.results
|
|
560
578
|
for i in r.issues
|
|
561
|
-
if i.severity in
|
|
579
|
+
if i.severity in constants.HIGH_SEVERITY_LEVELS
|
|
562
580
|
)
|
|
563
581
|
warnings = sum(
|
|
564
582
|
1 for r in report.results for i in r.issues if i.severity in ("warning", "medium")
|
|
@@ -579,6 +597,29 @@ class ReportGenerator:
|
|
|
579
597
|
lines.append(f"| 🔵 **Info** | {infos} |")
|
|
580
598
|
lines.append("")
|
|
581
599
|
|
|
600
|
+
# Parsing errors section (if any)
|
|
601
|
+
if report.parsing_errors:
|
|
602
|
+
lines.append("### ⚠️ Parsing Errors")
|
|
603
|
+
lines.append("")
|
|
604
|
+
lines.append(
|
|
605
|
+
f"**{len(report.parsing_errors)} file(s) failed to parse** and were excluded from validation:"
|
|
606
|
+
)
|
|
607
|
+
lines.append("")
|
|
608
|
+
for file_path, error_msg in report.parsing_errors:
|
|
609
|
+
# Extract just the filename for cleaner display
|
|
610
|
+
from pathlib import Path
|
|
611
|
+
|
|
612
|
+
filename = Path(file_path).name
|
|
613
|
+
lines.append(f"- **`{filename}`**")
|
|
614
|
+
lines.append(" ```")
|
|
615
|
+
lines.append(f" {error_msg}")
|
|
616
|
+
lines.append(" ```")
|
|
617
|
+
lines.append("")
|
|
618
|
+
lines.append(
|
|
619
|
+
"> **Note:** Fix these parsing errors first before validation can proceed on these files."
|
|
620
|
+
)
|
|
621
|
+
lines.append("")
|
|
622
|
+
|
|
582
623
|
# Store header for later (we always include this)
|
|
583
624
|
header_content = "\n".join(lines)
|
|
584
625
|
|
|
@@ -594,7 +635,7 @@ class ReportGenerator:
|
|
|
594
635
|
footer_content = "\n".join(footer_lines)
|
|
595
636
|
|
|
596
637
|
# Calculate remaining space for details
|
|
597
|
-
base_length = len(header_content) + len(footer_content) +
|
|
638
|
+
base_length = len(header_content) + len(footer_content) + constants.FORMATTING_SAFETY_BUFFER
|
|
598
639
|
available_length = max_length - base_length
|
|
599
640
|
|
|
600
641
|
# Detailed findings
|
|
@@ -611,7 +652,7 @@ class ReportGenerator:
|
|
|
611
652
|
sorted_results = sorted(
|
|
612
653
|
[(idx, r) for idx, r in enumerate(report.results, 1) if r.issues],
|
|
613
654
|
key=lambda x: (
|
|
614
|
-
-sum(1 for i in x[1].issues if i.severity in
|
|
655
|
+
-sum(1 for i in x[1].issues if i.severity in constants.HIGH_SEVERITY_LEVELS),
|
|
615
656
|
-len(x[1].issues),
|
|
616
657
|
),
|
|
617
658
|
)
|
|
@@ -623,7 +664,7 @@ class ReportGenerator:
|
|
|
623
664
|
policy_lines = []
|
|
624
665
|
|
|
625
666
|
# Group issues by severity - support both IAM validity and security severities
|
|
626
|
-
errors = [i for i in result.issues if i.severity in
|
|
667
|
+
errors = [i for i in result.issues if i.severity in constants.HIGH_SEVERITY_LEVELS]
|
|
627
668
|
warnings = [i for i in result.issues if i.severity in ("warning", "medium")]
|
|
628
669
|
infos = [i for i in result.issues if i.severity in ("info", "low")]
|
|
629
670
|
|
|
@@ -6,11 +6,14 @@ including posting PR comments, line comments, labels, and retrieving PR informat
|
|
|
6
6
|
|
|
7
7
|
import logging
|
|
8
8
|
import os
|
|
9
|
+
import re
|
|
9
10
|
from enum import Enum
|
|
10
11
|
from typing import Any
|
|
11
12
|
|
|
12
13
|
import httpx
|
|
13
14
|
|
|
15
|
+
from iam_validator.core import constants
|
|
16
|
+
|
|
14
17
|
logger = logging.getLogger(__name__)
|
|
15
18
|
|
|
16
19
|
|
|
@@ -134,8 +137,6 @@ class GitHubIntegration:
|
|
|
134
137
|
|
|
135
138
|
# Basic sanitization - alphanumeric, hyphens, underscores, dots
|
|
136
139
|
# GitHub allows these characters in usernames and repo names
|
|
137
|
-
import re
|
|
138
|
-
|
|
139
140
|
valid_pattern = re.compile(r"^[a-zA-Z0-9._-]+$")
|
|
140
141
|
if not valid_pattern.match(owner) or not valid_pattern.match(repo):
|
|
141
142
|
logger.warning(
|
|
@@ -199,8 +200,6 @@ class GitHubIntegration:
|
|
|
199
200
|
return "https://api.github.com"
|
|
200
201
|
|
|
201
202
|
# Basic URL validation
|
|
202
|
-
import re
|
|
203
|
-
|
|
204
203
|
# Simple URL pattern check
|
|
205
204
|
url_pattern = re.compile(r"^https://[a-zA-Z0-9.-]+(?:/.*)?$")
|
|
206
205
|
if not url_pattern.match(api_url):
|
|
@@ -506,7 +505,7 @@ class GitHubIntegration:
|
|
|
506
505
|
return True
|
|
507
506
|
return False
|
|
508
507
|
|
|
509
|
-
async def cleanup_bot_review_comments(self, identifier: str =
|
|
508
|
+
async def cleanup_bot_review_comments(self, identifier: str = constants.BOT_IDENTIFIER) -> int:
|
|
510
509
|
"""Delete all review comments from the bot (from previous runs).
|
|
511
510
|
|
|
512
511
|
This ensures old/outdated comments are removed before posting new ones.
|
iam_validator/utils/__init__.py
CHANGED
|
@@ -10,6 +10,7 @@ see iam_validator.checks.utils instead.
|
|
|
10
10
|
Organization:
|
|
11
11
|
- cache.py: Generic caching implementations (LRUCache with TTL)
|
|
12
12
|
- regex.py: Regex pattern caching and compilation utilities
|
|
13
|
+
- terminal.py: Terminal width detection utilities
|
|
13
14
|
"""
|
|
14
15
|
|
|
15
16
|
from iam_validator.utils.cache import LRUCache
|
|
@@ -19,6 +20,7 @@ from iam_validator.utils.regex import (
|
|
|
19
20
|
compile_and_cache,
|
|
20
21
|
get_cached_pattern,
|
|
21
22
|
)
|
|
23
|
+
from iam_validator.utils.terminal import get_terminal_width
|
|
22
24
|
|
|
23
25
|
__all__ = [
|
|
24
26
|
# Cache utilities
|
|
@@ -28,4 +30,6 @@ __all__ = [
|
|
|
28
30
|
"compile_and_cache",
|
|
29
31
|
"get_cached_pattern",
|
|
30
32
|
"clear_pattern_cache",
|
|
33
|
+
# Terminal utilities
|
|
34
|
+
"get_terminal_width",
|
|
31
35
|
]
|
iam_validator/utils/regex.py
CHANGED
|
@@ -13,13 +13,12 @@ Performance benefits:
|
|
|
13
13
|
import re
|
|
14
14
|
from collections.abc import Callable
|
|
15
15
|
from functools import wraps
|
|
16
|
-
from re import Pattern
|
|
17
16
|
|
|
18
17
|
|
|
19
18
|
def cached_pattern(
|
|
20
19
|
flags: int = 0,
|
|
21
20
|
maxsize: int = 128,
|
|
22
|
-
) -> Callable[[Callable[[], str]], Callable[[], Pattern]]:
|
|
21
|
+
) -> Callable[[Callable[[], str]], Callable[[], re.Pattern]]:
|
|
23
22
|
r"""Decorator that caches compiled regex patterns.
|
|
24
23
|
|
|
25
24
|
This decorator transforms a function that returns a regex pattern string
|
|
@@ -60,12 +59,12 @@ def cached_pattern(
|
|
|
60
59
|
Cached calls: ~0.1-0.5μs (cache lookup) → 20-100x faster
|
|
61
60
|
"""
|
|
62
61
|
|
|
63
|
-
def decorator(func: Callable[[], str]) -> Callable[[], Pattern]:
|
|
62
|
+
def decorator(func: Callable[[], str]) -> Callable[[], re.Pattern]:
|
|
64
63
|
# Use a cache per function to avoid key collisions
|
|
65
64
|
cache = {}
|
|
66
65
|
|
|
67
66
|
@wraps(func)
|
|
68
|
-
def wrapper() -> Pattern:
|
|
67
|
+
def wrapper() -> re.Pattern:
|
|
69
68
|
# Use function name as cache key (since each decorated function
|
|
70
69
|
# returns the same pattern string)
|
|
71
70
|
cache_key = func.__name__
|
|
@@ -84,7 +83,7 @@ def cached_pattern(
|
|
|
84
83
|
return decorator
|
|
85
84
|
|
|
86
85
|
|
|
87
|
-
def compile_and_cache(pattern: str, flags: int = 0, maxsize: int = 512) -> Pattern:
|
|
86
|
+
def compile_and_cache(pattern: str, flags: int = 0, maxsize: int = 512) -> re.Pattern:
|
|
88
87
|
"""Compile a regex pattern with automatic caching.
|
|
89
88
|
|
|
90
89
|
This is a functional interface (not a decorator) that compiles and caches
|
|
@@ -116,17 +115,17 @@ def compile_and_cache(pattern: str, flags: int = 0, maxsize: int = 512) -> Patte
|
|
|
116
115
|
from functools import lru_cache
|
|
117
116
|
|
|
118
117
|
@lru_cache(maxsize=maxsize)
|
|
119
|
-
def _compile(pattern_str: str, flags: int) -> Pattern:
|
|
118
|
+
def _compile(pattern_str: str, flags: int) -> re.Pattern:
|
|
120
119
|
return re.compile(pattern_str, flags)
|
|
121
120
|
|
|
122
121
|
return _compile(pattern, flags)
|
|
123
122
|
|
|
124
123
|
|
|
125
124
|
# Singleton instance for shared pattern compilation
|
|
126
|
-
_pattern_cache: dict[tuple[str, int], Pattern] = {}
|
|
125
|
+
_pattern_cache: dict[tuple[str, int], re.Pattern] = {}
|
|
127
126
|
|
|
128
127
|
|
|
129
|
-
def get_cached_pattern(pattern: str, flags: int = 0) -> Pattern:
|
|
128
|
+
def get_cached_pattern(pattern: str, flags: int = 0) -> re.Pattern:
|
|
130
129
|
"""Get a compiled pattern from the shared cache.
|
|
131
130
|
|
|
132
131
|
This provides a simple, stateless way to get cached patterns without
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Terminal utilities for console output formatting."""
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def get_terminal_width(min_width: int = 80, max_width: int = 150, fallback: int = 100) -> int:
|
|
7
|
+
"""Get the current terminal width with reasonable bounds.
|
|
8
|
+
|
|
9
|
+
Args:
|
|
10
|
+
min_width: Minimum width to return (default: 80)
|
|
11
|
+
max_width: Maximum width to return (default: 150)
|
|
12
|
+
fallback: Fallback width if detection fails (default: 100)
|
|
13
|
+
|
|
14
|
+
Returns:
|
|
15
|
+
Terminal width within the specified bounds
|
|
16
|
+
"""
|
|
17
|
+
try:
|
|
18
|
+
terminal_width = shutil.get_terminal_size().columns
|
|
19
|
+
# Ensure width is within reasonable bounds
|
|
20
|
+
return max(min(terminal_width, max_width), min_width)
|
|
21
|
+
except Exception:
|
|
22
|
+
return fallback
|