iam-policy-validator 1.7.1__py3-none-any.whl → 1.8.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.7.1.dist-info → iam_policy_validator-1.8.0.dist-info}/METADATA +22 -7
- iam_policy_validator-1.8.0.dist-info/RECORD +87 -0
- iam_validator/__version__.py +4 -2
- iam_validator/checks/__init__.py +5 -3
- iam_validator/checks/action_condition_enforcement.py +81 -36
- iam_validator/checks/action_resource_matching.py +75 -37
- iam_validator/checks/action_validation.py +1 -1
- iam_validator/checks/condition_key_validation.py +7 -7
- iam_validator/checks/condition_type_mismatch.py +10 -8
- iam_validator/checks/full_wildcard.py +2 -8
- iam_validator/checks/mfa_condition_check.py +8 -8
- iam_validator/checks/policy_structure.py +577 -0
- iam_validator/checks/policy_type_validation.py +48 -32
- iam_validator/checks/principal_validation.py +86 -150
- iam_validator/checks/resource_validation.py +8 -8
- iam_validator/checks/sensitive_action.py +9 -11
- iam_validator/checks/service_wildcard.py +4 -10
- iam_validator/checks/set_operator_validation.py +11 -11
- iam_validator/checks/sid_uniqueness.py +8 -4
- iam_validator/checks/trust_policy_validation.py +512 -0
- iam_validator/checks/utils/sensitive_action_matcher.py +26 -26
- iam_validator/checks/utils/wildcard_expansion.py +1 -1
- iam_validator/checks/wildcard_action.py +5 -9
- iam_validator/checks/wildcard_resource.py +5 -9
- iam_validator/commands/validate.py +8 -14
- iam_validator/core/__init__.py +1 -2
- iam_validator/core/access_analyzer.py +1 -1
- iam_validator/core/access_analyzer_report.py +2 -2
- iam_validator/core/aws_fetcher.py +159 -64
- iam_validator/core/check_registry.py +83 -79
- iam_validator/core/config/condition_requirements.py +69 -17
- iam_validator/core/config/config_loader.py +1 -2
- iam_validator/core/config/defaults.py +74 -59
- iam_validator/core/config/service_principals.py +40 -3
- 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/ignore_patterns.py +297 -0
- iam_validator/core/models.py +35 -10
- iam_validator/core/policy_checks.py +34 -474
- iam_validator/core/policy_loader.py +98 -18
- 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/terminal.py +22 -0
- iam_policy_validator-1.7.1.dist-info/RECORD +0 -83
- {iam_policy_validator-1.7.1.dist-info → iam_policy_validator-1.8.0.dist-info}/WHEEL +0 -0
- {iam_policy_validator-1.7.1.dist-info → iam_policy_validator-1.8.0.dist-info}/entry_points.txt +0 -0
- {iam_policy_validator-1.7.1.dist-info → iam_policy_validator-1.8.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -29,8 +29,10 @@ import json
|
|
|
29
29
|
import logging
|
|
30
30
|
from collections.abc import Generator
|
|
31
31
|
from pathlib import Path
|
|
32
|
+
from typing import overload
|
|
32
33
|
|
|
33
34
|
import yaml
|
|
35
|
+
from pydantic import ValidationError
|
|
34
36
|
|
|
35
37
|
from iam_validator.core.models import IAMPolicy
|
|
36
38
|
|
|
@@ -44,6 +46,8 @@ class PolicyLoader:
|
|
|
44
46
|
"""
|
|
45
47
|
|
|
46
48
|
SUPPORTED_EXTENSIONS = {".json", ".yaml", ".yml"}
|
|
49
|
+
# Directories to skip when scanning recursively (cache, build artifacts, etc.)
|
|
50
|
+
SKIP_DIRECTORIES = {".cache", ".git", "node_modules", "__pycache__", ".venv", "venv"}
|
|
47
51
|
|
|
48
52
|
def __init__(self, max_file_size_mb: int = 100) -> None:
|
|
49
53
|
"""Initialize the policy loader.
|
|
@@ -53,6 +57,8 @@ class PolicyLoader:
|
|
|
53
57
|
"""
|
|
54
58
|
self.loaded_policies: list[tuple[str, IAMPolicy]] = []
|
|
55
59
|
self.max_file_size_bytes = max_file_size_mb * 1024 * 1024
|
|
60
|
+
# Track parsing/validation errors for reporting
|
|
61
|
+
self.parsing_errors: list[tuple[str, str]] = [] # (file_path, error_message)
|
|
56
62
|
|
|
57
63
|
@staticmethod
|
|
58
64
|
def _find_statement_line_numbers(file_content: str) -> list[int]:
|
|
@@ -142,26 +148,38 @@ class PolicyLoader:
|
|
|
142
148
|
return False
|
|
143
149
|
return True
|
|
144
150
|
except OSError as e:
|
|
145
|
-
logger.error(
|
|
151
|
+
logger.error("Failed to check file size for %s: %s", path, e)
|
|
146
152
|
return False
|
|
147
153
|
|
|
148
|
-
|
|
154
|
+
@overload
|
|
155
|
+
def load_from_file(self, file_path: str, return_raw_dict: bool = False) -> IAMPolicy | None: ...
|
|
156
|
+
|
|
157
|
+
@overload
|
|
158
|
+
def load_from_file(
|
|
159
|
+
self, file_path: str, return_raw_dict: bool = True
|
|
160
|
+
) -> tuple[IAMPolicy, dict] | None: ...
|
|
161
|
+
|
|
162
|
+
def load_from_file(
|
|
163
|
+
self, file_path: str, return_raw_dict: bool = False
|
|
164
|
+
) -> IAMPolicy | tuple[IAMPolicy, dict] | None:
|
|
149
165
|
"""Load a single IAM policy from a file.
|
|
150
166
|
|
|
151
167
|
Args:
|
|
152
168
|
file_path: Path to the policy file
|
|
169
|
+
return_raw_dict: If True, return tuple of (policy, raw_dict) for validation
|
|
153
170
|
|
|
154
171
|
Returns:
|
|
155
|
-
Parsed IAMPolicy or
|
|
172
|
+
Parsed IAMPolicy, or tuple of (IAMPolicy, raw_dict) if return_raw_dict=True,
|
|
173
|
+
or None if loading fails
|
|
156
174
|
"""
|
|
157
175
|
path = Path(file_path)
|
|
158
176
|
|
|
159
177
|
if not path.exists():
|
|
160
|
-
logger.error(
|
|
178
|
+
logger.error("File not found: %s", file_path)
|
|
161
179
|
return None
|
|
162
180
|
|
|
163
181
|
if not path.is_file():
|
|
164
|
-
logger.error(
|
|
182
|
+
logger.error("Not a file: %s", file_path)
|
|
165
183
|
return None
|
|
166
184
|
|
|
167
185
|
if path.suffix.lower() not in self.SUPPORTED_EXTENSIONS:
|
|
@@ -193,21 +211,52 @@ class PolicyLoader:
|
|
|
193
211
|
|
|
194
212
|
# Attach line numbers to statements
|
|
195
213
|
if statement_line_numbers:
|
|
196
|
-
for idx, statement in enumerate(policy.statement):
|
|
214
|
+
for idx, statement in enumerate(policy.statement or []):
|
|
197
215
|
if idx < len(statement_line_numbers):
|
|
198
216
|
statement.line_number = statement_line_numbers[idx]
|
|
199
217
|
|
|
200
|
-
logger.info(
|
|
201
|
-
return policy
|
|
218
|
+
logger.info("Successfully loaded policy from %s", file_path)
|
|
219
|
+
return (policy, data) if return_raw_dict else policy
|
|
202
220
|
|
|
203
221
|
except json.JSONDecodeError as e:
|
|
204
|
-
|
|
222
|
+
error_msg = f"Invalid JSON: {e}"
|
|
223
|
+
logger.error("Invalid JSON in %s: %s", file_path, e)
|
|
224
|
+
self.parsing_errors.append((file_path, error_msg))
|
|
205
225
|
return None
|
|
206
226
|
except yaml.YAMLError as e:
|
|
207
|
-
|
|
227
|
+
error_msg = f"Invalid YAML: {e}"
|
|
228
|
+
logger.error("Invalid YAML in %s: %s", file_path, e)
|
|
229
|
+
self.parsing_errors.append((file_path, error_msg))
|
|
230
|
+
return None
|
|
231
|
+
except ValidationError as e:
|
|
232
|
+
# Handle Pydantic validation errors with helpful messages
|
|
233
|
+
error_messages = []
|
|
234
|
+
for error in e.errors():
|
|
235
|
+
loc = ".".join(str(x) for x in error["loc"])
|
|
236
|
+
error_type = error["type"]
|
|
237
|
+
|
|
238
|
+
# Provide user-friendly messages for common errors
|
|
239
|
+
if error_type == "extra_forbidden":
|
|
240
|
+
# Extract the field name that has a typo
|
|
241
|
+
field_name = error["loc"][-1] if error["loc"] else "unknown"
|
|
242
|
+
error_messages.append(
|
|
243
|
+
f"Unknown field '{field_name}' at {loc}. "
|
|
244
|
+
f"This might be a typo. Did you mean 'Condition', 'Action', or 'Resource'?"
|
|
245
|
+
)
|
|
246
|
+
else:
|
|
247
|
+
error_messages.append(f"{loc}: {error['msg']}")
|
|
248
|
+
|
|
249
|
+
error_summary = "\n ".join(error_messages)
|
|
250
|
+
logger.error(
|
|
251
|
+
"Policy validation failed for %s:\n %s",
|
|
252
|
+
file_path,
|
|
253
|
+
error_summary,
|
|
254
|
+
)
|
|
255
|
+
# Track parsing error for GitHub reporting
|
|
256
|
+
self.parsing_errors.append((file_path, error_summary))
|
|
208
257
|
return None
|
|
209
258
|
except Exception as e:
|
|
210
|
-
logger.error(
|
|
259
|
+
logger.error("Failed to load policy from %s: %s", file_path, e)
|
|
211
260
|
return None
|
|
212
261
|
|
|
213
262
|
def load_from_directory(
|
|
@@ -225,23 +274,27 @@ class PolicyLoader:
|
|
|
225
274
|
path = Path(directory_path)
|
|
226
275
|
|
|
227
276
|
if not path.exists():
|
|
228
|
-
logger.error(
|
|
277
|
+
logger.error("Directory not found: %s", directory_path)
|
|
229
278
|
return []
|
|
230
279
|
|
|
231
280
|
if not path.is_dir():
|
|
232
|
-
logger.error(
|
|
281
|
+
logger.error("Not a directory: %s", directory_path)
|
|
233
282
|
return []
|
|
234
283
|
|
|
235
284
|
policies: list[tuple[str, IAMPolicy]] = []
|
|
236
285
|
pattern = "**/*" if recursive else "*"
|
|
237
286
|
|
|
238
287
|
for file_path in path.glob(pattern):
|
|
288
|
+
# Skip directories that shouldn't be scanned
|
|
289
|
+
if any(skip_dir in file_path.parts for skip_dir in self.SKIP_DIRECTORIES):
|
|
290
|
+
continue
|
|
291
|
+
|
|
239
292
|
if file_path.is_file() and file_path.suffix.lower() in self.SUPPORTED_EXTENSIONS:
|
|
240
293
|
policy = self.load_from_file(str(file_path))
|
|
241
294
|
if policy:
|
|
242
295
|
policies.append((str(file_path), policy))
|
|
243
296
|
|
|
244
|
-
logger.info(
|
|
297
|
+
logger.info("Loaded %d policies from %s", len(policies), directory_path)
|
|
245
298
|
return policies
|
|
246
299
|
|
|
247
300
|
def load_from_path(self, path: str, recursive: bool = True) -> list[tuple[str, IAMPolicy]]:
|
|
@@ -262,7 +315,7 @@ class PolicyLoader:
|
|
|
262
315
|
elif path_obj.is_dir():
|
|
263
316
|
return self.load_from_directory(path, recursive)
|
|
264
317
|
else:
|
|
265
|
-
logger.error(
|
|
318
|
+
logger.error("Path not found: %s", path)
|
|
266
319
|
return []
|
|
267
320
|
|
|
268
321
|
def load_from_paths(
|
|
@@ -283,7 +336,7 @@ class PolicyLoader:
|
|
|
283
336
|
policies = self.load_from_path(path.strip(), recursive)
|
|
284
337
|
all_policies.extend(policies)
|
|
285
338
|
|
|
286
|
-
logger.info(
|
|
339
|
+
logger.info("Loaded %d total policies from %d path(s)", len(all_policies), len(paths))
|
|
287
340
|
return all_policies
|
|
288
341
|
|
|
289
342
|
def _get_policy_files(self, path: str, recursive: bool = True) -> Generator[Path, None, None]:
|
|
@@ -307,10 +360,14 @@ class PolicyLoader:
|
|
|
307
360
|
elif path_obj.is_dir():
|
|
308
361
|
pattern = "**/*" if recursive else "*"
|
|
309
362
|
for file_path in path_obj.glob(pattern):
|
|
363
|
+
# Skip directories that shouldn't be scanned
|
|
364
|
+
if any(skip_dir in file_path.parts for skip_dir in self.SKIP_DIRECTORIES):
|
|
365
|
+
continue
|
|
366
|
+
|
|
310
367
|
if file_path.is_file() and file_path.suffix.lower() in self.SUPPORTED_EXTENSIONS:
|
|
311
368
|
yield file_path
|
|
312
369
|
else:
|
|
313
|
-
logger.error(
|
|
370
|
+
logger.error("Path not found: %s", path)
|
|
314
371
|
|
|
315
372
|
def stream_from_path(
|
|
316
373
|
self, path: str, recursive: bool = True
|
|
@@ -391,6 +448,29 @@ class PolicyLoader:
|
|
|
391
448
|
policy = IAMPolicy.model_validate(data)
|
|
392
449
|
logger.info("Successfully parsed policy from string")
|
|
393
450
|
return policy
|
|
451
|
+
except json.JSONDecodeError as e:
|
|
452
|
+
logger.error("Invalid JSON: %s", e)
|
|
453
|
+
return None
|
|
454
|
+
except ValidationError as e:
|
|
455
|
+
# Handle Pydantic validation errors with helpful messages
|
|
456
|
+
error_messages = []
|
|
457
|
+
for error in e.errors():
|
|
458
|
+
loc = ".".join(str(x) for x in error["loc"])
|
|
459
|
+
error_type = error["type"]
|
|
460
|
+
|
|
461
|
+
# Provide user-friendly messages for common errors
|
|
462
|
+
if error_type == "extra_forbidden":
|
|
463
|
+
# Extract the field name that has a typo
|
|
464
|
+
field_name = error["loc"][-1] if error["loc"] else "unknown"
|
|
465
|
+
error_messages.append(
|
|
466
|
+
f"Unknown field '{field_name}' at {loc}. "
|
|
467
|
+
f"This might be a typo. Did you mean 'Condition', 'Action', or 'Resource'?"
|
|
468
|
+
)
|
|
469
|
+
else:
|
|
470
|
+
error_messages.append(f"{loc}: {error['msg']}")
|
|
471
|
+
|
|
472
|
+
logger.error("Policy validation failed:\n %s", "\n ".join(error_messages))
|
|
473
|
+
return None
|
|
394
474
|
except Exception as e:
|
|
395
|
-
logger.error(
|
|
475
|
+
logger.error("Failed to parse policy string: %s", e)
|
|
396
476
|
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
|
]
|
|
@@ -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
|
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
iam_validator/__init__.py,sha256=APnMR3Fu4fHhxfsHBvUM2dJIwazgvLKQbfOsSgFPidg,693
|
|
2
|
-
iam_validator/__main__.py,sha256=to_nz3n_IerJpVVZZ6WSFlFR5s_06J0csfPOTfQZG8g,197
|
|
3
|
-
iam_validator/__version__.py,sha256=hhtguX-fvQWNuUzOdKMFXPyDTbhXhMm7VQgXxQD2xCQ,206
|
|
4
|
-
iam_validator/checks/__init__.py,sha256=eDiDlVon0CwWGSBnZgM-arn1i5R5ZSG89pgR-ifETxE,1782
|
|
5
|
-
iam_validator/checks/action_condition_enforcement.py,sha256=n-F7NEmQm76Hs-Aj5qxgXney3MpkzbWElZUu1Ig73pw,36723
|
|
6
|
-
iam_validator/checks/action_resource_matching.py,sha256=X9dqWy1s_-h1rA81wZRLOxAVLmUHlGVPjxMo0WKIlwM,17433
|
|
7
|
-
iam_validator/checks/action_validation.py,sha256=IpxtTsk58f2zEZ-xzAoyHw4QK8BCRV43OffP-8ydf9E,2578
|
|
8
|
-
iam_validator/checks/condition_key_validation.py,sha256=E-doe2QjvKSkyjXZO9TBp0QS7M0Fv2oYYQQ9738QNxg,3918
|
|
9
|
-
iam_validator/checks/condition_type_mismatch.py,sha256=qAbP6pP_vM1aBvIBRHji56XLH_5cQI4cDhpMQe19CHM,10588
|
|
10
|
-
iam_validator/checks/full_wildcard.py,sha256=0_F6h4goWlc3DuZwo1F9YGw5hvpnkfZxYSDxhXXK50I,2449
|
|
11
|
-
iam_validator/checks/mfa_condition_check.py,sha256=s7K2r9hxlJI1KWk8qXl-JOWE6jLIhpxooK26Pr7acKs,4915
|
|
12
|
-
iam_validator/checks/policy_size.py,sha256=ibgmrErpkz6OfUAN6bFuHe1KHzpzzra9gHwNtVAkPWc,5729
|
|
13
|
-
iam_validator/checks/policy_type_validation.py,sha256=9qmrA8CXwsVpCU4rT0RrqDXgVOzNamMEpdg3cXWAtBI,15213
|
|
14
|
-
iam_validator/checks/principal_validation.py,sha256=Bm4pH6eiJLDa9ID7UyM63phgffh-P5DpPpSBUbYyVn8,29851
|
|
15
|
-
iam_validator/checks/resource_validation.py,sha256=fGi9QuX-lIHDtLm8xB3VReFFhbZpQ2Yub-FKRafQCkw,5984
|
|
16
|
-
iam_validator/checks/sensitive_action.py,sha256=mdl4g67HBioYTvAvar9CaTjxfaPvpYkNo9phL4E1c1w,9794
|
|
17
|
-
iam_validator/checks/service_wildcard.py,sha256=CiQQoti06nqVgvH-HpBIjoW23tnTJqDU4S-ZnM1DwsA,4218
|
|
18
|
-
iam_validator/checks/set_operator_validation.py,sha256=1XjOdf-xk-m6m1bODuHsELZccriGqOJTDI-HCcuId80,7464
|
|
19
|
-
iam_validator/checks/sid_uniqueness.py,sha256=1Ux9W1hPPhzgdCzfxwxvD-nSBRo1SyrxFWlnTXDcOys,6887
|
|
20
|
-
iam_validator/checks/wildcard_action.py,sha256=XAVuk5L9dQqiWPgd3HJXGNmYr2bh2szJMVVcHSBXb_8,2140
|
|
21
|
-
iam_validator/checks/wildcard_resource.py,sha256=IEpyoU4mA3t2kRxSwVavtROYIyF0Bq1xZJAL9P7XbVQ,5582
|
|
22
|
-
iam_validator/checks/utils/__init__.py,sha256=j0X4ibUB6RGx2a-kNoJnlVZwHfoEvzZsIeTmJIAoFzA,45
|
|
23
|
-
iam_validator/checks/utils/policy_level_checks.py,sha256=2V60C0zhKfsFPjQ-NMlD3EemtwA9S6-4no8nETgXdQE,5274
|
|
24
|
-
iam_validator/checks/utils/sensitive_action_matcher.py,sha256=tcWK4nImpSVNia0FUsN2uLK9LM5EnzjRFtaPQLHZaLw,10667
|
|
25
|
-
iam_validator/checks/utils/wildcard_expansion.py,sha256=fSSoquVdVZaVWS_qBxAx7LMOzxgHed4ffQ6OAZnuqos,3132
|
|
26
|
-
iam_validator/commands/__init__.py,sha256=M-5bo8w0TCWydK0cXgJyPD2fmk8bpQs-3b26YbgLzlc,565
|
|
27
|
-
iam_validator/commands/analyze.py,sha256=rvLBJ5_A3HB530xtixhaIsC19QON68olEQnn8TievgI,20784
|
|
28
|
-
iam_validator/commands/base.py,sha256=5baCCMwxz7pdQ6XMpWfXFNz7i1l5dB8Qv9dKKR04Gzs,1074
|
|
29
|
-
iam_validator/commands/cache.py,sha256=p4ucRVuh42sbK3Lk0b610L3ofAR5TnUreF00fpO6VFg,14219
|
|
30
|
-
iam_validator/commands/download_services.py,sha256=KKz3ybMLT8DQUf9aFZ0tilJ-o1b6PE8Pf1pC4K6cT8I,9175
|
|
31
|
-
iam_validator/commands/post_to_pr.py,sha256=CvUXs2xvO-UhluxdfNM6F0TCWD8hDBEOiYw60fm1Dms,2363
|
|
32
|
-
iam_validator/commands/validate.py,sha256=Eik-w613zCnX7hUHziBq4k5la3e3qJ0CO1__7aw-gBk,23554
|
|
33
|
-
iam_validator/core/__init__.py,sha256=1FvJPMrbzJfS9YbRUJCshJLd5gzWwR9Fd_slS0Aq9c8,416
|
|
34
|
-
iam_validator/core/access_analyzer.py,sha256=8GgkR-vCkCtSxtXGywvQNBPYq-rvDLexUuLSyflq0V4,24520
|
|
35
|
-
iam_validator/core/access_analyzer_report.py,sha256=O17gagknvkNMTTlq7BrLM68FjlCEm4LjIKD9oqxEbPg,24860
|
|
36
|
-
iam_validator/core/aws_fetcher.py,sha256=cZFo5JMSoNLx1tpM6NzYr2cnq8Bvc2KQx2nJDmo69lc,36504
|
|
37
|
-
iam_validator/core/check_registry.py,sha256=cMjtJROkZOLzXxl-mTdLYHdxyajNnOsaHGs-EeaSZ7k,21741
|
|
38
|
-
iam_validator/core/cli.py,sha256=PkXiZjlgrQ21QustBbspefYsdbxst4gxoClyG2_HQR8,3843
|
|
39
|
-
iam_validator/core/condition_validators.py,sha256=7zBjlcf2xGFKGbcFrXSLvWT5tFhWxoqwzhsJqS2E8uY,21524
|
|
40
|
-
iam_validator/core/constants.py,sha256=oblMWsjoroIhwjYgZdcyLxaATsGeR99zQwRg6h59Nlo,3145
|
|
41
|
-
iam_validator/core/models.py,sha256=59yqvHoX3nCSJyQDmWCuEsQzNz9PNiF7um7A1wti-2w,12176
|
|
42
|
-
iam_validator/core/policy_checks.py,sha256=Uz2yCsqRaoIja31F4ZM-39a1pHv51yZqKyWWkGUZKNY,26489
|
|
43
|
-
iam_validator/core/policy_loader.py,sha256=TR7SpzlRG3TwH4HBGEFUuhNOmxIR8Cud2SQ-AmHWBpM,14040
|
|
44
|
-
iam_validator/core/pr_commenter.py,sha256=MU-t7SfdHUpSc6BDbh8_dNAbxDiG-bZBCry-jUXivAc,15066
|
|
45
|
-
iam_validator/core/report.py,sha256=j6uWlFL6Xavl4BnpaQtQoxFOEgKEiuY0IYBq8I9DH5Q,34134
|
|
46
|
-
iam_validator/core/config/__init__.py,sha256=CWSyIA7kEyzrskEenjYbs9Iih10BXRpiY9H2dHg61rU,2671
|
|
47
|
-
iam_validator/core/config/aws_api.py,sha256=HLIzOItQ0A37wxHcgWck6ZFO0wmNY8JNTiWMMK6JKYU,1248
|
|
48
|
-
iam_validator/core/config/aws_global_conditions.py,sha256=gdmMxXGBy95B3uYUG-J7rnM6Ixgc6L7Y9Pcd2XAMb60,7170
|
|
49
|
-
iam_validator/core/config/category_suggestions.py,sha256=QlrYi4BTkxDSTlL7NZGE9BWN-atWetZ6XjkI9F_7YzI,4370
|
|
50
|
-
iam_validator/core/config/condition_requirements.py,sha256=1PuADTB9pLqh-kNUGC7kSU6LMLtXMSc003tvI7qKeAY,5170
|
|
51
|
-
iam_validator/core/config/config_loader.py,sha256=7YkuPnroR-Up5CUTQOXIyS_b732WrzNn8o1EH9O6lyI,17730
|
|
52
|
-
iam_validator/core/config/defaults.py,sha256=w5ievxkqki3zYr7NaREoWtVx5rTfxBpZlgoNdovcILs,27112
|
|
53
|
-
iam_validator/core/config/principal_requirements.py,sha256=VCX7fBDgeDTJQyoz7_x7GI7Kf9O1Eu-sbihoHOrKv6o,15105
|
|
54
|
-
iam_validator/core/config/sensitive_actions.py,sha256=uATDIp_TD3OQQlsYTZp79qd1mSK2Bf9hJ0JwcqLBr84,25344
|
|
55
|
-
iam_validator/core/config/service_principals.py,sha256=gQSROsxUWBD6P2F9qP320UZV4lHGlsyvHSkMyy0njrU,2685
|
|
56
|
-
iam_validator/core/config/wildcards.py,sha256=H_v6hb-rZ0UUz4cul9lxkVI39e6knaK4Y-MbWz2Ebpw,3228
|
|
57
|
-
iam_validator/core/formatters/__init__.py,sha256=fnCKAEBXItnOf2m4rhVs7zwMaTxbG6ESh3CF8V5j5ec,868
|
|
58
|
-
iam_validator/core/formatters/base.py,sha256=SShDeDiy5mYQnS6BpA8xYg91N-KX1EObkOtlrVHqx1Q,4451
|
|
59
|
-
iam_validator/core/formatters/console.py,sha256=lX4Yp4bTW61fxe0fCiHuO6bCZtC_6cjCwqDNQ55nT_8,1937
|
|
60
|
-
iam_validator/core/formatters/csv.py,sha256=2FaN6Y_0TPMFOb3A3tNtj0-9bkEc5P-6eZ7eLROIqFE,5899
|
|
61
|
-
iam_validator/core/formatters/enhanced.py,sha256=S0UgYKFOgILfOqwnBC8-WFab3F1CiEko33g0nbaswtk,17085
|
|
62
|
-
iam_validator/core/formatters/html.py,sha256=j4sQi-wXiD9kCHldW5JCzbJe0frhiP5uQI9KlH3Sj_g,22994
|
|
63
|
-
iam_validator/core/formatters/json.py,sha256=A7gZ8P32GEdbDvrSn6v56yQ4fOP_kyMaoFVXG2bgnew,939
|
|
64
|
-
iam_validator/core/formatters/markdown.py,sha256=aPAY6FpZBHsVBDag3FAsB_X9CZzznFjX9dQr0ysDrTE,2251
|
|
65
|
-
iam_validator/core/formatters/sarif.py,sha256=O3pn7whqFq5xxk-tuoqSb2k4Fk5ai_A2SKX_ph8GLV4,10469
|
|
66
|
-
iam_validator/integrations/__init__.py,sha256=7Hlor_X9j0NZaEjFuSvoXAAuSKQ-zgY19Rk-Dz3JpKo,616
|
|
67
|
-
iam_validator/integrations/github_integration.py,sha256=QoPkaxdRDQTzmHN4cKEXoGcn8BRv37JW4IvD2W5jEtc,26474
|
|
68
|
-
iam_validator/integrations/ms_teams.py,sha256=t2PlWuTDb6GGH-eDU1jnOKd8D1w4FCB68bahGA7MJcE,14475
|
|
69
|
-
iam_validator/sdk/__init__.py,sha256=fRDSXAclGmCU3KDft4StL8JUcpAsdzwIRf8mVj461q0,5306
|
|
70
|
-
iam_validator/sdk/arn_matching.py,sha256=HSDpLltOYISq-SoPebAlM89mKOaUaghq_04urchEFDA,12778
|
|
71
|
-
iam_validator/sdk/context.py,sha256=SBFeedu8rhCzFA-zC2cH4wLZxEJT6XOW30hIZAyXPVU,6826
|
|
72
|
-
iam_validator/sdk/exceptions.py,sha256=tm91TxIwU157U_UHN7w5qICf_OhU11agj6pV5W_YP-4,1023
|
|
73
|
-
iam_validator/sdk/helpers.py,sha256=OVBg4xrW95LT74wXCg1LQkba9kw5RfFqeCLuTqhgL-A,5697
|
|
74
|
-
iam_validator/sdk/policy_utils.py,sha256=CZS1OGSdiWsd2lsCwg0BDcUNWa61tUwgvn-P5rKqeN8,12987
|
|
75
|
-
iam_validator/sdk/shortcuts.py,sha256=EVNSYV7rv4TFH03ulsZ3mS1UVmTSp2jKpc2AXs4j1q4,8531
|
|
76
|
-
iam_validator/utils/__init__.py,sha256=V8u-SSdnL4a7NwF-yg9x0JRl5epKAXEs2f5RiwK2qPo,856
|
|
77
|
-
iam_validator/utils/cache.py,sha256=wOQKOBeoG6QqC5f0oXcHz63Cjtu_-SsSS-0pTSwyAiM,3254
|
|
78
|
-
iam_validator/utils/regex.py,sha256=xHoMECttb7qaMhts-c9b0GIxdhHNZTt-UBr7wNhWfzg,6219
|
|
79
|
-
iam_policy_validator-1.7.1.dist-info/METADATA,sha256=WCjnDcJ38j-LRUz2EwdT2b2lX2IOTkW3xT1SLtCxiWY,15343
|
|
80
|
-
iam_policy_validator-1.7.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
81
|
-
iam_policy_validator-1.7.1.dist-info/entry_points.txt,sha256=8HtWd8O7mvPiPdZR5YbzY8or_qcqLM4-pKaFdhtFT8M,62
|
|
82
|
-
iam_policy_validator-1.7.1.dist-info/licenses/LICENSE,sha256=AMnbFTBDcK4_MITe2wiQBkj0vg-jjBBhsc43ydC7tt4,1098
|
|
83
|
-
iam_policy_validator-1.7.1.dist-info/RECORD,,
|
|
File without changes
|
{iam_policy_validator-1.7.1.dist-info → iam_policy_validator-1.8.0.dist-info}/entry_points.txt
RENAMED
|
File without changes
|
{iam_policy_validator-1.7.1.dist-info → iam_policy_validator-1.8.0.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|