iam-policy-validator 1.7.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.

Potentially problematic release.


This version of iam-policy-validator might be problematic. Click here for more details.

Files changed (83) hide show
  1. iam_policy_validator-1.7.0.dist-info/METADATA +1057 -0
  2. iam_policy_validator-1.7.0.dist-info/RECORD +83 -0
  3. iam_policy_validator-1.7.0.dist-info/WHEEL +4 -0
  4. iam_policy_validator-1.7.0.dist-info/entry_points.txt +2 -0
  5. iam_policy_validator-1.7.0.dist-info/licenses/LICENSE +21 -0
  6. iam_validator/__init__.py +27 -0
  7. iam_validator/__main__.py +11 -0
  8. iam_validator/__version__.py +7 -0
  9. iam_validator/checks/__init__.py +43 -0
  10. iam_validator/checks/action_condition_enforcement.py +884 -0
  11. iam_validator/checks/action_resource_matching.py +441 -0
  12. iam_validator/checks/action_validation.py +72 -0
  13. iam_validator/checks/condition_key_validation.py +92 -0
  14. iam_validator/checks/condition_type_mismatch.py +259 -0
  15. iam_validator/checks/full_wildcard.py +71 -0
  16. iam_validator/checks/mfa_condition_check.py +112 -0
  17. iam_validator/checks/policy_size.py +147 -0
  18. iam_validator/checks/policy_type_validation.py +305 -0
  19. iam_validator/checks/principal_validation.py +776 -0
  20. iam_validator/checks/resource_validation.py +138 -0
  21. iam_validator/checks/sensitive_action.py +254 -0
  22. iam_validator/checks/service_wildcard.py +107 -0
  23. iam_validator/checks/set_operator_validation.py +157 -0
  24. iam_validator/checks/sid_uniqueness.py +170 -0
  25. iam_validator/checks/utils/__init__.py +1 -0
  26. iam_validator/checks/utils/policy_level_checks.py +143 -0
  27. iam_validator/checks/utils/sensitive_action_matcher.py +294 -0
  28. iam_validator/checks/utils/wildcard_expansion.py +87 -0
  29. iam_validator/checks/wildcard_action.py +67 -0
  30. iam_validator/checks/wildcard_resource.py +135 -0
  31. iam_validator/commands/__init__.py +25 -0
  32. iam_validator/commands/analyze.py +531 -0
  33. iam_validator/commands/base.py +48 -0
  34. iam_validator/commands/cache.py +392 -0
  35. iam_validator/commands/download_services.py +255 -0
  36. iam_validator/commands/post_to_pr.py +86 -0
  37. iam_validator/commands/validate.py +600 -0
  38. iam_validator/core/__init__.py +14 -0
  39. iam_validator/core/access_analyzer.py +671 -0
  40. iam_validator/core/access_analyzer_report.py +640 -0
  41. iam_validator/core/aws_fetcher.py +940 -0
  42. iam_validator/core/check_registry.py +607 -0
  43. iam_validator/core/cli.py +134 -0
  44. iam_validator/core/condition_validators.py +626 -0
  45. iam_validator/core/config/__init__.py +81 -0
  46. iam_validator/core/config/aws_api.py +35 -0
  47. iam_validator/core/config/aws_global_conditions.py +160 -0
  48. iam_validator/core/config/category_suggestions.py +104 -0
  49. iam_validator/core/config/condition_requirements.py +155 -0
  50. iam_validator/core/config/config_loader.py +472 -0
  51. iam_validator/core/config/defaults.py +523 -0
  52. iam_validator/core/config/principal_requirements.py +421 -0
  53. iam_validator/core/config/sensitive_actions.py +672 -0
  54. iam_validator/core/config/service_principals.py +95 -0
  55. iam_validator/core/config/wildcards.py +124 -0
  56. iam_validator/core/constants.py +74 -0
  57. iam_validator/core/formatters/__init__.py +27 -0
  58. iam_validator/core/formatters/base.py +147 -0
  59. iam_validator/core/formatters/console.py +59 -0
  60. iam_validator/core/formatters/csv.py +170 -0
  61. iam_validator/core/formatters/enhanced.py +440 -0
  62. iam_validator/core/formatters/html.py +672 -0
  63. iam_validator/core/formatters/json.py +33 -0
  64. iam_validator/core/formatters/markdown.py +63 -0
  65. iam_validator/core/formatters/sarif.py +251 -0
  66. iam_validator/core/models.py +327 -0
  67. iam_validator/core/policy_checks.py +656 -0
  68. iam_validator/core/policy_loader.py +396 -0
  69. iam_validator/core/pr_commenter.py +424 -0
  70. iam_validator/core/report.py +872 -0
  71. iam_validator/integrations/__init__.py +28 -0
  72. iam_validator/integrations/github_integration.py +815 -0
  73. iam_validator/integrations/ms_teams.py +442 -0
  74. iam_validator/sdk/__init__.py +187 -0
  75. iam_validator/sdk/arn_matching.py +382 -0
  76. iam_validator/sdk/context.py +222 -0
  77. iam_validator/sdk/exceptions.py +48 -0
  78. iam_validator/sdk/helpers.py +177 -0
  79. iam_validator/sdk/policy_utils.py +425 -0
  80. iam_validator/sdk/shortcuts.py +283 -0
  81. iam_validator/utils/__init__.py +31 -0
  82. iam_validator/utils/cache.py +105 -0
  83. iam_validator/utils/regex.py +206 -0
@@ -0,0 +1,95 @@
1
+ """
2
+ Default service principals for resource policy validation.
3
+
4
+ These are AWS service principals that are commonly used and considered safe
5
+ in resource-based policies (S3 bucket policies, SNS topic policies, etc.).
6
+ """
7
+
8
+ from typing import Final
9
+
10
+ # ============================================================================
11
+ # Allowed Service Principals
12
+ # ============================================================================
13
+ # These AWS service principals are commonly used in resource policies
14
+ # and are generally considered safe to allow
15
+
16
+ DEFAULT_SERVICE_PRINCIPALS: Final[tuple[str, ...]] = (
17
+ "cloudfront.amazonaws.com",
18
+ "s3.amazonaws.com",
19
+ "sns.amazonaws.com",
20
+ "lambda.amazonaws.com",
21
+ "logs.amazonaws.com",
22
+ "events.amazonaws.com",
23
+ "elasticloadbalancing.amazonaws.com",
24
+ "cloudtrail.amazonaws.com",
25
+ "config.amazonaws.com",
26
+ "backup.amazonaws.com",
27
+ "cloudwatch.amazonaws.com",
28
+ "monitoring.amazonaws.com",
29
+ "ec2.amazonaws.com",
30
+ "ecs-tasks.amazonaws.com",
31
+ "eks.amazonaws.com",
32
+ "apigateway.amazonaws.com",
33
+ )
34
+
35
+
36
+ def get_service_principals() -> tuple[str, ...]:
37
+ """
38
+ Get tuple of allowed service principals.
39
+
40
+ Returns:
41
+ Tuple of AWS service principal names
42
+ """
43
+ return DEFAULT_SERVICE_PRINCIPALS
44
+
45
+
46
+ def is_allowed_service_principal(principal: str) -> bool:
47
+ """
48
+ Check if a principal is an allowed service principal.
49
+
50
+ Args:
51
+ principal: Principal to check (e.g., "lambda.amazonaws.com")
52
+
53
+ Returns:
54
+ True if principal is in allowed list
55
+
56
+ Performance: O(n) but small list (~16 items)
57
+ """
58
+ return principal in DEFAULT_SERVICE_PRINCIPALS
59
+
60
+
61
+ def get_service_principals_by_category() -> dict[str, tuple[str, ...]]:
62
+ """
63
+ Get service principals organized by service category.
64
+
65
+ Returns:
66
+ Dictionary mapping categories to service principal tuples
67
+ """
68
+ return {
69
+ "storage": (
70
+ "s3.amazonaws.com",
71
+ "backup.amazonaws.com",
72
+ ),
73
+ "compute": (
74
+ "lambda.amazonaws.com",
75
+ "ec2.amazonaws.com",
76
+ "ecs-tasks.amazonaws.com",
77
+ "eks.amazonaws.com",
78
+ ),
79
+ "networking": (
80
+ "cloudfront.amazonaws.com",
81
+ "elasticloadbalancing.amazonaws.com",
82
+ "apigateway.amazonaws.com",
83
+ ),
84
+ "monitoring": (
85
+ "logs.amazonaws.com",
86
+ "cloudwatch.amazonaws.com",
87
+ "monitoring.amazonaws.com",
88
+ "cloudtrail.amazonaws.com",
89
+ ),
90
+ "messaging": (
91
+ "sns.amazonaws.com",
92
+ "events.amazonaws.com",
93
+ ),
94
+ "management": ("config.amazonaws.com",),
95
+ }
@@ -0,0 +1,124 @@
1
+ """
2
+ Default wildcard configurations for security best practices checks.
3
+
4
+ These wildcards define which actions are considered "safe" to use with
5
+ Resource: "*" (e.g., read-only describe operations).
6
+
7
+ Using Python tuples instead of YAML lists provides:
8
+ - Zero parsing overhead
9
+ - Immutable by default (tuples)
10
+ - Better performance
11
+ - Easy PyPI packaging
12
+ """
13
+
14
+ from typing import Final
15
+
16
+ # ============================================================================
17
+ # Allowed Wildcards for Resource: "*"
18
+ # ============================================================================
19
+ # These action patterns are considered safe to use with wildcard resources
20
+ # They are typically read-only operations that need broad resource access
21
+
22
+ DEFAULT_ALLOWED_WILDCARDS: Final[tuple[str, ...]] = (
23
+ # Auto Scaling
24
+ "autoscaling:Describe*",
25
+ # CloudWatch
26
+ "cloudwatch:Describe*",
27
+ "cloudwatch:Get*",
28
+ "cloudwatch:List*",
29
+ # DynamoDB
30
+ "dynamodb:Describe*",
31
+ # EC2
32
+ "ec2:Describe*",
33
+ # Elastic Load Balancing
34
+ "elasticloadbalancing:Describe*",
35
+ # IAM (non-sensitive read operations)
36
+ "iam:Get*",
37
+ "iam:List*",
38
+ # KMS
39
+ "kms:Describe*",
40
+ # Lambda
41
+ "lambda:Get*",
42
+ "lambda:List*",
43
+ # CloudWatch Logs
44
+ "logs:Describe*",
45
+ "logs:Filter*",
46
+ "logs:Get*",
47
+ # RDS
48
+ "rds:Describe*",
49
+ # Route53
50
+ "route53:Get*",
51
+ "route53:List*",
52
+ # S3 (safe read operations only)
53
+ "s3:Describe*",
54
+ "s3:GetBucket*",
55
+ "s3:GetM*",
56
+ "s3:List*",
57
+ # SQS
58
+ "sqs:Get*",
59
+ "sqs:List*",
60
+ # API Gateway
61
+ "apigateway:GET",
62
+ )
63
+
64
+ # ============================================================================
65
+ # Service-Level Wildcards (Allowed Services)
66
+ # ============================================================================
67
+ # Services that are allowed to use service-level wildcards like "logs:*"
68
+ # These are typically low-risk monitoring/logging services
69
+
70
+ DEFAULT_SERVICE_WILDCARDS: Final[tuple[str, ...]] = (
71
+ "logs",
72
+ "cloudwatch",
73
+ "xray",
74
+ )
75
+
76
+
77
+ def get_allowed_wildcards() -> tuple[str, ...]:
78
+ """
79
+ Get tuple of allowed wildcard action patterns.
80
+
81
+ Returns:
82
+ Tuple of action patterns that are safe to use with Resource: "*"
83
+ """
84
+ return DEFAULT_ALLOWED_WILDCARDS
85
+
86
+
87
+ def get_allowed_service_wildcards() -> tuple[str, ...]:
88
+ """
89
+ Get tuple of services allowed to use service-level wildcards.
90
+
91
+ Returns:
92
+ Tuple of service names (e.g., "logs", "cloudwatch")
93
+ """
94
+ return DEFAULT_SERVICE_WILDCARDS
95
+
96
+
97
+ def is_allowed_wildcard(pattern: str) -> bool:
98
+ """
99
+ Check if a wildcard pattern is in the allowed list.
100
+
101
+ Args:
102
+ pattern: Action pattern to check (e.g., "s3:List*")
103
+
104
+ Returns:
105
+ True if pattern is in allowed wildcards
106
+
107
+ Performance: O(n) but typically small list (~25 items)
108
+ """
109
+ return pattern in DEFAULT_ALLOWED_WILDCARDS
110
+
111
+
112
+ def is_allowed_service_wildcard(service: str) -> bool:
113
+ """
114
+ Check if a service is allowed to use service-level wildcards.
115
+
116
+ Args:
117
+ service: Service name (e.g., "logs", "s3")
118
+
119
+ Returns:
120
+ True if service is in allowed list
121
+
122
+ Performance: O(n) but very small list (~3 items)
123
+ """
124
+ return service in DEFAULT_SERVICE_WILDCARDS
@@ -0,0 +1,74 @@
1
+ """
2
+ Core constants for IAM Policy Validator.
3
+
4
+ This module defines constants used across the validator to ensure consistency
5
+ and provide a single source of truth for shared values. These constants are
6
+ based on AWS service limits and documentation.
7
+
8
+ References:
9
+ - AWS IAM Policy Size Limits: https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_iam-quotas.html
10
+ - AWS ARN Format: https://docs.aws.amazon.com/IAM/latest/UserGuide/reference-arns.html
11
+ """
12
+
13
+ # ============================================================================
14
+ # ARN Validation
15
+ # ============================================================================
16
+
17
+ # ARN Validation Pattern
18
+ # This pattern is specifically designed for validation and allows wildcards (*) in region and account fields
19
+ # Unlike the parsing pattern in CompiledPatterns, this is more lenient for validation purposes
20
+ # Supports all AWS partitions: aws, aws-cn, aws-us-gov, aws-eusc, aws-iso*
21
+ DEFAULT_ARN_VALIDATION_PATTERN = r"^arn:(aws|aws-cn|aws-us-gov|aws-eusc|aws-iso|aws-iso-b|aws-iso-e|aws-iso-f):[a-z0-9\-]+:[a-z0-9\-*]*:[0-9*]*:.+$"
22
+
23
+ # Maximum allowed ARN length to prevent ReDoS attacks
24
+ # AWS maximum ARN length is approximately 2048 characters
25
+ MAX_ARN_LENGTH = 2048
26
+
27
+ # ============================================================================
28
+ # AWS IAM Policy Size Limits
29
+ # ============================================================================
30
+ # These limits are enforced by AWS and policies exceeding them will be rejected
31
+ # Note: AWS does not count whitespace when calculating policy size
32
+
33
+ # Managed policy maximum size (characters, excluding whitespace)
34
+ MAX_MANAGED_POLICY_SIZE = 6144
35
+
36
+ # Inline policy maximum size for IAM users (characters, excluding whitespace)
37
+ MAX_INLINE_USER_POLICY_SIZE = 2048
38
+
39
+ # Inline policy maximum size for IAM groups (characters, excluding whitespace)
40
+ MAX_INLINE_GROUP_POLICY_SIZE = 5120
41
+
42
+ # Inline policy maximum size for IAM roles (characters, excluding whitespace)
43
+ MAX_INLINE_ROLE_POLICY_SIZE = 10240
44
+
45
+ # Policy size limits dictionary (for backward compatibility and easy lookup)
46
+ AWS_POLICY_SIZE_LIMITS = {
47
+ "managed": MAX_MANAGED_POLICY_SIZE,
48
+ "inline_user": MAX_INLINE_USER_POLICY_SIZE,
49
+ "inline_group": MAX_INLINE_GROUP_POLICY_SIZE,
50
+ "inline_role": MAX_INLINE_ROLE_POLICY_SIZE,
51
+ }
52
+
53
+ # ============================================================================
54
+ # Configuration Defaults
55
+ # ============================================================================
56
+
57
+ # Default configuration file names (searched in order)
58
+ DEFAULT_CONFIG_FILENAMES = [
59
+ "iam-validator.yaml",
60
+ "iam-validator.yml",
61
+ ".iam-validator.yaml",
62
+ ".iam-validator.yml",
63
+ ]
64
+
65
+ # ============================================================================
66
+ # GitHub Integration
67
+ # ============================================================================
68
+
69
+ # Bot identifier for GitHub comments and reviews
70
+ BOT_IDENTIFIER = "🤖 IAM Policy Validator"
71
+
72
+ # HTML comment markers for identifying bot-generated content (for cleanup/updates)
73
+ SUMMARY_IDENTIFIER = "<!-- iam-policy-validator-summary -->"
74
+ REVIEW_IDENTIFIER = "<!-- iam-policy-validator-review -->"
@@ -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,59 @@
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
+ string_buffer = StringIO()
43
+ console = Console(file=string_buffer, force_terminal=color, width=120)
44
+
45
+ # Create a generator instance with our custom console
46
+ generator = ReportGenerator()
47
+
48
+ # Replace the console temporarily to capture output
49
+ original_console = generator.console
50
+ generator.console = console
51
+
52
+ # Call the actual print_console_report method
53
+ generator.print_console_report(report)
54
+
55
+ # Restore original console
56
+ generator.console = original_console
57
+
58
+ # Return captured output
59
+ return string_buffer.getvalue()
@@ -0,0 +1,170 @@
1
+ """CSV formatter for IAM Policy Validator."""
2
+
3
+ import csv
4
+ import io
5
+ from typing import Any
6
+
7
+ from iam_validator.core.formatters.base import OutputFormatter
8
+ from iam_validator.core.models import ValidationReport
9
+
10
+
11
+ class CSVFormatter(OutputFormatter):
12
+ """Formats validation results as CSV for spreadsheet analysis."""
13
+
14
+ @property
15
+ def format_id(self) -> str:
16
+ return "csv"
17
+
18
+ @property
19
+ def description(self) -> str:
20
+ return "CSV format for spreadsheet import and analysis"
21
+
22
+ @property
23
+ def file_extension(self) -> str:
24
+ return "csv"
25
+
26
+ @property
27
+ def content_type(self) -> str:
28
+ return "text/csv"
29
+
30
+ def format(self, report: ValidationReport, **kwargs) -> str:
31
+ """Format report as CSV.
32
+
33
+ Args:
34
+ report: The validation report
35
+ **kwargs: Additional options like 'include_summary'
36
+
37
+ Returns:
38
+ CSV string
39
+ """
40
+ include_summary = kwargs.get("include_summary", True)
41
+ include_header = kwargs.get("include_header", True)
42
+
43
+ output = io.StringIO()
44
+ writer = csv.writer(output, quoting=csv.QUOTE_MINIMAL)
45
+
46
+ if include_summary:
47
+ self._write_summary_section(writer, report)
48
+ writer.writerow([]) # Empty row separator
49
+
50
+ self._write_issues_section(writer, report, include_header)
51
+
52
+ return output.getvalue()
53
+
54
+ def _write_summary_section(self, writer: Any, report: ValidationReport) -> None:
55
+ """Write summary statistics to CSV."""
56
+ # Count issues by severity - support both IAM validity and security severities
57
+ errors = sum(
58
+ 1
59
+ for r in report.results
60
+ for i in r.issues
61
+ if i.severity in ("error", "critical", "high")
62
+ )
63
+ warnings = sum(
64
+ 1 for r in report.results for i in r.issues if i.severity in ("warning", "medium")
65
+ )
66
+ infos = sum(1 for r in report.results for i in r.issues if i.severity in ("info", "low"))
67
+
68
+ writer.writerow(["Summary Statistics"])
69
+ writer.writerow(["Metric", "Value"])
70
+ writer.writerow(["Total Policies", report.total_policies])
71
+ writer.writerow(["Valid Policies (IAM)", report.valid_policies])
72
+ writer.writerow(["Invalid Policies (IAM)", report.invalid_policies])
73
+ writer.writerow(["Policies with Security Findings", report.policies_with_security_issues])
74
+ writer.writerow(["Total Issues", report.total_issues])
75
+ writer.writerow(["Validity Issues", report.validity_issues])
76
+ writer.writerow(["Security Issues", report.security_issues])
77
+ writer.writerow([""])
78
+ writer.writerow(["Legacy Severity Breakdown"])
79
+ writer.writerow(["Errors", errors])
80
+ writer.writerow(["Warnings", warnings])
81
+ writer.writerow(["Info", infos])
82
+
83
+ def _write_issues_section(
84
+ self, writer: Any, report: ValidationReport, include_header: bool
85
+ ) -> None:
86
+ """Write detailed issues to CSV."""
87
+ if include_header:
88
+ writer.writerow(
89
+ [
90
+ "Policy File",
91
+ "Statement Index",
92
+ "Statement SID",
93
+ "Line Number",
94
+ "Severity",
95
+ "Issue Type",
96
+ "Action",
97
+ "Resource",
98
+ "Condition Key",
99
+ "Message",
100
+ "Suggestion",
101
+ ]
102
+ )
103
+
104
+ for policy_result in report.results:
105
+ for issue in policy_result.issues:
106
+ writer.writerow(
107
+ [
108
+ policy_result.policy_file,
109
+ (issue.statement_index + 1 if issue.statement_index is not None else ""),
110
+ issue.statement_sid or "",
111
+ issue.line_number or "",
112
+ issue.severity,
113
+ issue.issue_type or "",
114
+ issue.action or "",
115
+ issue.resource or "",
116
+ issue.condition_key or "",
117
+ issue.message,
118
+ issue.suggestion or "",
119
+ ]
120
+ )
121
+
122
+ def format_pivot_table(self, report: ValidationReport) -> str:
123
+ """Format report as a pivot table CSV for analysis.
124
+
125
+ Groups issues by check_id and severity for easy analysis.
126
+ """
127
+ output = io.StringIO()
128
+ writer = csv.writer(output)
129
+
130
+ # Create pivot data
131
+ pivot_data = self._create_pivot_data(report)
132
+
133
+ # Write header
134
+ writer.writerow(["Issue Type", "Severity", "Count", "Policy Files"])
135
+
136
+ # Write pivot rows
137
+ for (issue_type, severity), data in sorted(pivot_data.items()):
138
+ writer.writerow(
139
+ [
140
+ issue_type or "unknown",
141
+ severity,
142
+ data["count"],
143
+ "; ".join(data["files"][:5]) + ("..." if len(data["files"]) > 5 else ""),
144
+ ]
145
+ )
146
+
147
+ return output.getvalue()
148
+
149
+ def _create_pivot_data(self, report: ValidationReport) -> dict[tuple, dict[str, Any]]:
150
+ """Create pivot table data structure."""
151
+ pivot_data = {}
152
+
153
+ for policy_result in report.results:
154
+ for issue in policy_result.issues:
155
+ key = (issue.issue_type or "unknown", issue.severity)
156
+
157
+ if key not in pivot_data:
158
+ pivot_data[key] = {
159
+ "count": 0,
160
+ "files": set(),
161
+ }
162
+
163
+ pivot_data[key]["count"] += 1
164
+ pivot_data[key]["files"].add(policy_result.policy_file)
165
+
166
+ # Convert sets to lists
167
+ for key in pivot_data:
168
+ pivot_data[key]["files"] = sorted(list(pivot_data[key]["files"]))
169
+
170
+ return pivot_data