aws-inventory-manager 0.17.12__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.
Files changed (152) hide show
  1. aws_inventory_manager-0.17.12.dist-info/LICENSE +21 -0
  2. aws_inventory_manager-0.17.12.dist-info/METADATA +1292 -0
  3. aws_inventory_manager-0.17.12.dist-info/RECORD +152 -0
  4. aws_inventory_manager-0.17.12.dist-info/WHEEL +5 -0
  5. aws_inventory_manager-0.17.12.dist-info/entry_points.txt +2 -0
  6. aws_inventory_manager-0.17.12.dist-info/top_level.txt +1 -0
  7. src/__init__.py +3 -0
  8. src/aws/__init__.py +11 -0
  9. src/aws/client.py +128 -0
  10. src/aws/credentials.py +191 -0
  11. src/aws/rate_limiter.py +177 -0
  12. src/cli/__init__.py +12 -0
  13. src/cli/config.py +130 -0
  14. src/cli/main.py +4046 -0
  15. src/cloudtrail/__init__.py +5 -0
  16. src/cloudtrail/query.py +642 -0
  17. src/config_service/__init__.py +21 -0
  18. src/config_service/collector.py +346 -0
  19. src/config_service/detector.py +256 -0
  20. src/config_service/resource_type_mapping.py +328 -0
  21. src/cost/__init__.py +5 -0
  22. src/cost/analyzer.py +226 -0
  23. src/cost/explorer.py +209 -0
  24. src/cost/reporter.py +237 -0
  25. src/delta/__init__.py +5 -0
  26. src/delta/calculator.py +206 -0
  27. src/delta/differ.py +185 -0
  28. src/delta/formatters.py +272 -0
  29. src/delta/models.py +154 -0
  30. src/delta/reporter.py +234 -0
  31. src/matching/__init__.py +6 -0
  32. src/matching/config.py +52 -0
  33. src/matching/normalizer.py +450 -0
  34. src/matching/prompts.py +33 -0
  35. src/models/__init__.py +21 -0
  36. src/models/config_diff.py +135 -0
  37. src/models/cost_report.py +87 -0
  38. src/models/deletion_operation.py +104 -0
  39. src/models/deletion_record.py +97 -0
  40. src/models/delta_report.py +122 -0
  41. src/models/efs_resource.py +80 -0
  42. src/models/elasticache_resource.py +90 -0
  43. src/models/group.py +318 -0
  44. src/models/inventory.py +133 -0
  45. src/models/protection_rule.py +123 -0
  46. src/models/report.py +288 -0
  47. src/models/resource.py +111 -0
  48. src/models/security_finding.py +102 -0
  49. src/models/snapshot.py +122 -0
  50. src/restore/__init__.py +20 -0
  51. src/restore/audit.py +175 -0
  52. src/restore/cleaner.py +461 -0
  53. src/restore/config.py +209 -0
  54. src/restore/deleter.py +976 -0
  55. src/restore/dependency.py +254 -0
  56. src/restore/safety.py +115 -0
  57. src/security/__init__.py +0 -0
  58. src/security/checks/__init__.py +0 -0
  59. src/security/checks/base.py +56 -0
  60. src/security/checks/ec2_checks.py +88 -0
  61. src/security/checks/elasticache_checks.py +149 -0
  62. src/security/checks/iam_checks.py +102 -0
  63. src/security/checks/rds_checks.py +140 -0
  64. src/security/checks/s3_checks.py +95 -0
  65. src/security/checks/secrets_checks.py +96 -0
  66. src/security/checks/sg_checks.py +142 -0
  67. src/security/cis_mapper.py +97 -0
  68. src/security/models.py +53 -0
  69. src/security/reporter.py +174 -0
  70. src/security/scanner.py +87 -0
  71. src/snapshot/__init__.py +6 -0
  72. src/snapshot/capturer.py +453 -0
  73. src/snapshot/filter.py +259 -0
  74. src/snapshot/inventory_storage.py +236 -0
  75. src/snapshot/report_formatter.py +250 -0
  76. src/snapshot/reporter.py +189 -0
  77. src/snapshot/resource_collectors/__init__.py +5 -0
  78. src/snapshot/resource_collectors/apigateway.py +140 -0
  79. src/snapshot/resource_collectors/backup.py +136 -0
  80. src/snapshot/resource_collectors/base.py +81 -0
  81. src/snapshot/resource_collectors/cloudformation.py +55 -0
  82. src/snapshot/resource_collectors/cloudwatch.py +109 -0
  83. src/snapshot/resource_collectors/codebuild.py +69 -0
  84. src/snapshot/resource_collectors/codepipeline.py +82 -0
  85. src/snapshot/resource_collectors/dynamodb.py +65 -0
  86. src/snapshot/resource_collectors/ec2.py +240 -0
  87. src/snapshot/resource_collectors/ecs.py +215 -0
  88. src/snapshot/resource_collectors/efs_collector.py +102 -0
  89. src/snapshot/resource_collectors/eks.py +200 -0
  90. src/snapshot/resource_collectors/elasticache_collector.py +79 -0
  91. src/snapshot/resource_collectors/elb.py +126 -0
  92. src/snapshot/resource_collectors/eventbridge.py +156 -0
  93. src/snapshot/resource_collectors/glue.py +199 -0
  94. src/snapshot/resource_collectors/iam.py +188 -0
  95. src/snapshot/resource_collectors/kms.py +111 -0
  96. src/snapshot/resource_collectors/lambda_func.py +139 -0
  97. src/snapshot/resource_collectors/rds.py +109 -0
  98. src/snapshot/resource_collectors/route53.py +86 -0
  99. src/snapshot/resource_collectors/s3.py +105 -0
  100. src/snapshot/resource_collectors/secretsmanager.py +70 -0
  101. src/snapshot/resource_collectors/sns.py +68 -0
  102. src/snapshot/resource_collectors/sqs.py +82 -0
  103. src/snapshot/resource_collectors/ssm.py +160 -0
  104. src/snapshot/resource_collectors/stepfunctions.py +74 -0
  105. src/snapshot/resource_collectors/vpcendpoints.py +79 -0
  106. src/snapshot/resource_collectors/waf.py +159 -0
  107. src/snapshot/storage.py +351 -0
  108. src/storage/__init__.py +21 -0
  109. src/storage/audit_store.py +419 -0
  110. src/storage/database.py +294 -0
  111. src/storage/group_store.py +763 -0
  112. src/storage/inventory_store.py +320 -0
  113. src/storage/resource_store.py +416 -0
  114. src/storage/schema.py +339 -0
  115. src/storage/snapshot_store.py +363 -0
  116. src/utils/__init__.py +12 -0
  117. src/utils/export.py +305 -0
  118. src/utils/hash.py +60 -0
  119. src/utils/logging.py +63 -0
  120. src/utils/pagination.py +41 -0
  121. src/utils/paths.py +51 -0
  122. src/utils/progress.py +41 -0
  123. src/utils/unsupported_resources.py +306 -0
  124. src/web/__init__.py +5 -0
  125. src/web/app.py +97 -0
  126. src/web/dependencies.py +69 -0
  127. src/web/routes/__init__.py +1 -0
  128. src/web/routes/api/__init__.py +18 -0
  129. src/web/routes/api/charts.py +156 -0
  130. src/web/routes/api/cleanup.py +186 -0
  131. src/web/routes/api/filters.py +253 -0
  132. src/web/routes/api/groups.py +305 -0
  133. src/web/routes/api/inventories.py +80 -0
  134. src/web/routes/api/queries.py +202 -0
  135. src/web/routes/api/resources.py +393 -0
  136. src/web/routes/api/snapshots.py +314 -0
  137. src/web/routes/api/views.py +260 -0
  138. src/web/routes/pages.py +198 -0
  139. src/web/services/__init__.py +1 -0
  140. src/web/templates/base.html +955 -0
  141. src/web/templates/components/navbar.html +31 -0
  142. src/web/templates/components/sidebar.html +104 -0
  143. src/web/templates/pages/audit_logs.html +86 -0
  144. src/web/templates/pages/cleanup.html +279 -0
  145. src/web/templates/pages/dashboard.html +227 -0
  146. src/web/templates/pages/diff.html +175 -0
  147. src/web/templates/pages/error.html +30 -0
  148. src/web/templates/pages/groups.html +721 -0
  149. src/web/templates/pages/queries.html +246 -0
  150. src/web/templates/pages/resources.html +2429 -0
  151. src/web/templates/pages/snapshot_detail.html +271 -0
  152. src/web/templates/pages/snapshots.html +429 -0
@@ -0,0 +1,102 @@
1
+ """IAM security checks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timezone
6
+ from typing import List
7
+
8
+ from ...models.security_finding import SecurityFinding, Severity
9
+ from ...models.snapshot import Snapshot
10
+ from .base import SecurityCheck
11
+
12
+
13
+ class IAMCredentialAgeCheck(SecurityCheck):
14
+ """Check for IAM users with access keys older than 90 days.
15
+
16
+ CIS AWS Foundations Benchmark: 1.3 or 1.4
17
+ Severity: MEDIUM
18
+ """
19
+
20
+ @property
21
+ def check_id(self) -> str:
22
+ """Return check identifier."""
23
+ return "iam_credential_age"
24
+
25
+ @property
26
+ def severity(self) -> Severity:
27
+ """Return check severity."""
28
+ return Severity.MEDIUM
29
+
30
+ def execute(self, snapshot: Snapshot) -> List[SecurityFinding]:
31
+ """Execute IAM credential age check.
32
+
33
+ Checks for:
34
+ - IAM users with access keys older than 90 days
35
+
36
+ Args:
37
+ snapshot: Snapshot to scan
38
+
39
+ Returns:
40
+ List of findings for IAM users with old credentials
41
+ """
42
+ findings: List[SecurityFinding] = []
43
+ threshold_days = 90
44
+
45
+ for resource in snapshot.resources:
46
+ # Only check IAM users
47
+ if not resource.resource_type.startswith("iam:user"):
48
+ continue
49
+
50
+ if resource.raw_config is None:
51
+ continue
52
+
53
+ # Check access keys
54
+ access_keys = resource.raw_config.get("AccessKeys", [])
55
+ for key in access_keys:
56
+ create_date_str = key.get("CreateDate")
57
+ if not create_date_str:
58
+ continue
59
+
60
+ # Parse the date string (handle ISO format)
61
+ if isinstance(create_date_str, str):
62
+ # Remove trailing 'Z' if present and parse as ISO format
63
+ create_date_str = create_date_str.rstrip("Z")
64
+ if "+" in create_date_str:
65
+ # Has timezone
66
+ create_date = datetime.fromisoformat(create_date_str)
67
+ else:
68
+ # No timezone, add UTC
69
+ create_date = datetime.fromisoformat(create_date_str).replace(tzinfo=timezone.utc)
70
+ else:
71
+ # Assume it's already a datetime object
72
+ create_date = create_date_str
73
+ if create_date.tzinfo is None:
74
+ create_date = create_date.replace(tzinfo=timezone.utc)
75
+
76
+ # Calculate age in days
77
+ days_old = (datetime.now(timezone.utc) - create_date).days
78
+
79
+ # Check if key is older than threshold (> 90 days, not >= 90 days)
80
+ if days_old > threshold_days:
81
+ finding = SecurityFinding(
82
+ resource_arn=resource.arn,
83
+ finding_type=self.check_id,
84
+ severity=self.severity,
85
+ description=f"IAM user '{resource.name}' has an access key that is {days_old} days old, "
86
+ f"exceeding the {threshold_days}-day threshold. Old credentials increase security risk.",
87
+ remediation="Rotate the access key for this IAM user. "
88
+ "Create a new access key, update all applications using the old key, "
89
+ "then deactivate and delete the old key. "
90
+ "Consider using IAM roles instead of long-term credentials.",
91
+ cis_control="1.4",
92
+ metadata={
93
+ "user_name": resource.name,
94
+ "key_age_days": days_old,
95
+ "key_id": key.get("AccessKeyId", "unknown"),
96
+ },
97
+ )
98
+ findings.append(finding)
99
+ # Only report one finding per user (for the oldest key)
100
+ break
101
+
102
+ return findings
@@ -0,0 +1,140 @@
1
+ """RDS security checks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict, List
6
+
7
+ from ...models.security_finding import SecurityFinding, Severity
8
+ from ...models.snapshot import Snapshot
9
+ from .base import SecurityCheck
10
+
11
+
12
+ class RDSPublicCheck(SecurityCheck):
13
+ """Check for RDS instances with security issues.
14
+
15
+ Checks for:
16
+ - Publicly accessible RDS instances (PubliclyAccessible=True)
17
+ - Unencrypted RDS storage (StorageEncrypted=False)
18
+
19
+ Severity: HIGH
20
+ """
21
+
22
+ @property
23
+ def check_id(self) -> str:
24
+ """Return check identifier."""
25
+ return "rds_publicly_accessible"
26
+
27
+ @property
28
+ def severity(self) -> Severity:
29
+ """Return check severity."""
30
+ return Severity.HIGH
31
+
32
+ def execute(self, snapshot: Snapshot) -> List[SecurityFinding]:
33
+ """Execute RDS security checks.
34
+
35
+ Checks for:
36
+ - PubliclyAccessible set to True
37
+ - StorageEncrypted set to False
38
+
39
+ Args:
40
+ snapshot: Snapshot to scan
41
+
42
+ Returns:
43
+ List of findings for RDS security issues
44
+ """
45
+ findings: List[SecurityFinding] = []
46
+
47
+ for resource in snapshot.resources:
48
+ # Only check RDS resources
49
+ if not resource.resource_type.startswith("rds:"):
50
+ continue
51
+
52
+ if resource.raw_config is None:
53
+ continue
54
+
55
+ # Check for public accessibility
56
+ if self._is_publicly_accessible(resource.raw_config):
57
+ finding = self._create_public_finding(resource.name, resource.arn, resource.region)
58
+ findings.append(finding)
59
+
60
+ # Check for unencrypted storage
61
+ if not self._is_encrypted(resource.raw_config):
62
+ finding = self._create_encryption_finding(resource.name, resource.arn, resource.region)
63
+ findings.append(finding)
64
+
65
+ return findings
66
+
67
+ def _is_publicly_accessible(self, config: Dict[str, Any]) -> bool:
68
+ """Check if RDS instance is publicly accessible.
69
+
70
+ Args:
71
+ config: RDS instance raw configuration
72
+
73
+ Returns:
74
+ True if PubliclyAccessible is True
75
+ """
76
+ return config.get("PubliclyAccessible", False)
77
+
78
+ def _is_encrypted(self, config: Dict[str, Any]) -> bool:
79
+ """Check if RDS instance storage is encrypted.
80
+
81
+ Args:
82
+ config: RDS instance raw configuration
83
+
84
+ Returns:
85
+ True if StorageEncrypted is True
86
+ """
87
+ return config.get("StorageEncrypted", False)
88
+
89
+ def _create_public_finding(self, db_identifier: str, arn: str, region: str) -> SecurityFinding:
90
+ """Create a finding for publicly accessible RDS instance.
91
+
92
+ Args:
93
+ db_identifier: Database instance identifier
94
+ arn: Resource ARN
95
+ region: AWS region
96
+
97
+ Returns:
98
+ SecurityFinding for public accessibility issue
99
+ """
100
+ return SecurityFinding(
101
+ resource_arn=arn,
102
+ finding_type=self.check_id,
103
+ severity=self.severity,
104
+ description=f"RDS instance '{db_identifier}' is publicly accessible. "
105
+ f"The database is configured with PubliclyAccessible=True, which allows "
106
+ f"connections from the internet if security groups permit.",
107
+ remediation="Disable public accessibility for this RDS instance. "
108
+ "Use AWS CLI: 'aws rds modify-db-instance --db-instance-identifier "
109
+ f"{db_identifier} --no-publicly-accessible' or modify the instance "
110
+ "in the RDS console by setting 'Publicly accessible' to 'No' under "
111
+ "Connectivity & security settings.",
112
+ metadata={"db_identifier": db_identifier, "region": region},
113
+ )
114
+
115
+ def _create_encryption_finding(self, db_identifier: str, arn: str, region: str) -> SecurityFinding:
116
+ """Create a finding for unencrypted RDS instance.
117
+
118
+ Args:
119
+ db_identifier: Database instance identifier
120
+ arn: Resource ARN
121
+ region: AWS region
122
+
123
+ Returns:
124
+ SecurityFinding for encryption issue
125
+ """
126
+ return SecurityFinding(
127
+ resource_arn=arn,
128
+ finding_type=self.check_id,
129
+ severity=self.severity,
130
+ description=f"RDS instance '{db_identifier}' does not have storage encryption enabled. "
131
+ f"The database is configured with StorageEncrypted=False, which means data at rest "
132
+ f"is not encrypted.",
133
+ remediation="Enable encryption for this RDS instance. Note: Encryption cannot be "
134
+ "enabled on existing instances. You must create a snapshot, copy it with encryption "
135
+ "enabled, and restore a new instance from the encrypted snapshot. "
136
+ "Use AWS CLI: 'aws rds copy-db-snapshot --source-db-snapshot-identifier <snapshot-id> "
137
+ "--target-db-snapshot-identifier <encrypted-snapshot-id> --kms-key-id <kms-key>' "
138
+ "then restore from the encrypted snapshot.",
139
+ metadata={"db_identifier": db_identifier, "region": region},
140
+ )
@@ -0,0 +1,95 @@
1
+ """S3 security checks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict, List
6
+
7
+ from ...models.security_finding import SecurityFinding, Severity
8
+ from ...models.snapshot import Snapshot
9
+ from .base import SecurityCheck
10
+
11
+
12
+ class S3PublicBucketCheck(SecurityCheck):
13
+ """Check for publicly accessible S3 buckets.
14
+
15
+ CIS AWS Foundations Benchmark: 2.1.5
16
+ Severity: CRITICAL
17
+ """
18
+
19
+ @property
20
+ def check_id(self) -> str:
21
+ """Return check identifier."""
22
+ return "s3_public_bucket"
23
+
24
+ @property
25
+ def severity(self) -> Severity:
26
+ """Return check severity."""
27
+ return Severity.CRITICAL
28
+
29
+ def execute(self, snapshot: Snapshot) -> List[SecurityFinding]:
30
+ """Execute S3 public bucket check.
31
+
32
+ Checks for:
33
+ - Public access block configuration disabled
34
+ - Bucket ACLs allowing public read/write
35
+ - Bucket policies allowing public access
36
+
37
+ Args:
38
+ snapshot: Snapshot to scan
39
+
40
+ Returns:
41
+ List of findings for publicly accessible buckets
42
+ """
43
+ findings: List[SecurityFinding] = []
44
+
45
+ for resource in snapshot.resources:
46
+ # Only check S3 buckets
47
+ if not resource.resource_type.startswith("s3:"):
48
+ continue
49
+
50
+ if resource.raw_config is None:
51
+ continue
52
+
53
+ # Check PublicAccessBlockConfiguration
54
+ is_public = self._is_bucket_public(resource.raw_config)
55
+
56
+ if is_public:
57
+ finding = SecurityFinding(
58
+ resource_arn=resource.arn,
59
+ finding_type=self.check_id,
60
+ severity=self.severity,
61
+ description=f"S3 bucket '{resource.name}' is publicly accessible. "
62
+ f"Public access block configuration is disabled, allowing public access to the bucket.",
63
+ remediation="Enable S3 Block Public Access settings for this bucket. "
64
+ "Go to the S3 console, select the bucket, choose 'Permissions', "
65
+ "then 'Block Public Access' and enable all four settings.",
66
+ cis_control="2.1.5",
67
+ metadata={"bucket_name": resource.name, "region": resource.region},
68
+ )
69
+ findings.append(finding)
70
+
71
+ return findings
72
+
73
+ def _is_bucket_public(self, config: Dict[str, Any]) -> bool:
74
+ """Check if bucket configuration indicates public access.
75
+
76
+ Args:
77
+ config: Bucket raw configuration
78
+
79
+ Returns:
80
+ True if bucket appears to be publicly accessible
81
+ """
82
+ # Check PublicAccessBlockConfiguration
83
+ public_access_block = config.get("PublicAccessBlockConfiguration", {})
84
+
85
+ # If any of these are False, bucket might be public
86
+ block_public_acls = public_access_block.get("BlockPublicAcls", False)
87
+ ignore_public_acls = public_access_block.get("IgnorePublicAcls", False)
88
+ block_public_policy = public_access_block.get("BlockPublicPolicy", False)
89
+ restrict_public_buckets = public_access_block.get("RestrictPublicBuckets", False)
90
+
91
+ # Bucket is considered public if ANY of the blocks are disabled (False)
92
+ if not (block_public_acls and ignore_public_acls and block_public_policy and restrict_public_buckets):
93
+ return True
94
+
95
+ return False
@@ -0,0 +1,96 @@
1
+ """Secrets Manager security checks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timezone
6
+ from typing import List
7
+
8
+ from ...models.security_finding import SecurityFinding, Severity
9
+ from ...models.snapshot import Snapshot
10
+ from .base import SecurityCheck
11
+
12
+
13
+ class SecretsRotationCheck(SecurityCheck):
14
+ """Check for Secrets Manager secrets not rotated in 90+ days.
15
+
16
+ Severity: MEDIUM
17
+ """
18
+
19
+ @property
20
+ def check_id(self) -> str:
21
+ """Return check identifier."""
22
+ return "secrets_rotation_age"
23
+
24
+ @property
25
+ def severity(self) -> Severity:
26
+ """Return check severity."""
27
+ return Severity.MEDIUM
28
+
29
+ def execute(self, snapshot: Snapshot) -> List[SecurityFinding]:
30
+ """Execute secrets rotation check.
31
+
32
+ Checks for:
33
+ - Secrets Manager secrets not rotated in 90+ days
34
+
35
+ Args:
36
+ snapshot: Snapshot to scan
37
+
38
+ Returns:
39
+ List of findings for secrets not rotated recently
40
+ """
41
+ findings: List[SecurityFinding] = []
42
+ threshold_days = 90
43
+
44
+ for resource in snapshot.resources:
45
+ # Only check Secrets Manager secrets
46
+ if not resource.resource_type.startswith("secretsmanager:"):
47
+ continue
48
+
49
+ if resource.raw_config is None:
50
+ continue
51
+
52
+ # Check last rotated date
53
+ last_rotated_str = resource.raw_config.get("LastRotatedDate")
54
+ if not last_rotated_str:
55
+ continue
56
+
57
+ # Parse the date string (handle ISO format)
58
+ if isinstance(last_rotated_str, str):
59
+ # Remove trailing 'Z' if present and parse as ISO format
60
+ last_rotated_str = last_rotated_str.rstrip("Z")
61
+ if "+" in last_rotated_str:
62
+ # Has timezone
63
+ last_rotated = datetime.fromisoformat(last_rotated_str)
64
+ else:
65
+ # No timezone, add UTC
66
+ last_rotated = datetime.fromisoformat(last_rotated_str).replace(tzinfo=timezone.utc)
67
+ else:
68
+ # Assume it's already a datetime object
69
+ last_rotated = last_rotated_str
70
+ if last_rotated.tzinfo is None:
71
+ last_rotated = last_rotated.replace(tzinfo=timezone.utc)
72
+
73
+ # Calculate days since rotation
74
+ days_since_rotation = (datetime.now(timezone.utc) - last_rotated).days
75
+
76
+ # Check if secret hasn't been rotated recently (> 90 days, not >= 90 days)
77
+ if days_since_rotation > threshold_days:
78
+ finding = SecurityFinding(
79
+ resource_arn=resource.arn,
80
+ finding_type=self.check_id,
81
+ severity=self.severity,
82
+ description=f"Secret '{resource.name}' has not been rotated in {days_since_rotation} days, "
83
+ f"exceeding the {threshold_days}-day threshold. "
84
+ f"Regular rotation reduces risk of credential compromise.",
85
+ remediation="Rotate the secret in AWS Secrets Manager. "
86
+ "You can manually rotate the secret or enable automatic rotation. "
87
+ "Go to the Secrets Manager console, select the secret, and choose 'Rotate secret immediately'.",
88
+ metadata={
89
+ "secret_name": resource.name,
90
+ "days_since_rotation": days_since_rotation,
91
+ "region": resource.region,
92
+ },
93
+ )
94
+ findings.append(finding)
95
+
96
+ return findings
@@ -0,0 +1,142 @@
1
+ """Security Group security checks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict, List
6
+
7
+ from ...models.security_finding import SecurityFinding, Severity
8
+ from ...models.snapshot import Snapshot
9
+ from .base import SecurityCheck
10
+
11
+
12
+ class SecurityGroupOpenCheck(SecurityCheck):
13
+ """Check for security groups with critical ports open to 0.0.0.0/0.
14
+
15
+ Critical ports checked:
16
+ - 22 (SSH) - CIS 5.2
17
+ - 3389 (RDP) - CIS 5.3
18
+ - 3306 (MySQL)
19
+ - 5432 (PostgreSQL)
20
+ - 1433 (Microsoft SQL Server)
21
+ - 27017 (MongoDB)
22
+
23
+ Severity: HIGH
24
+ """
25
+
26
+ # Critical ports to check and their descriptions
27
+ CRITICAL_PORTS = {
28
+ 22: ("SSH", "5.2"),
29
+ 3389: ("RDP", "5.3"),
30
+ 3306: ("MySQL", "5.2"),
31
+ 5432: ("PostgreSQL", "5.2"),
32
+ 1433: ("Microsoft SQL Server", "5.2"),
33
+ 27017: ("MongoDB", "5.2"),
34
+ }
35
+
36
+ @property
37
+ def check_id(self) -> str:
38
+ """Return check identifier."""
39
+ return "security_group_open"
40
+
41
+ @property
42
+ def severity(self) -> Severity:
43
+ """Return check severity."""
44
+ return Severity.HIGH
45
+
46
+ def execute(self, snapshot: Snapshot) -> List[SecurityFinding]:
47
+ """Execute security group open port check.
48
+
49
+ Checks for critical ports (22, 3389, 3306, 5432, 1433, 27017) that are
50
+ open to 0.0.0.0/0 in security group ingress rules.
51
+
52
+ Args:
53
+ snapshot: Snapshot to scan
54
+
55
+ Returns:
56
+ List of findings for security groups with open critical ports
57
+ """
58
+ findings: List[SecurityFinding] = []
59
+
60
+ for resource in snapshot.resources:
61
+ # Only check EC2 security groups
62
+ if not resource.resource_type.startswith("ec2:security-group"):
63
+ continue
64
+
65
+ if resource.raw_config is None:
66
+ continue
67
+
68
+ # Check for open critical ports
69
+ open_ports = self._find_open_critical_ports(resource.raw_config)
70
+
71
+ # Create one finding per open port
72
+ for port in open_ports:
73
+ port_name, cis_control = self.CRITICAL_PORTS[port]
74
+
75
+ finding = SecurityFinding(
76
+ resource_arn=resource.arn,
77
+ finding_type=self.check_id,
78
+ severity=self.severity,
79
+ description=f"Security group '{resource.name}' has port {port} ({port_name}) "
80
+ f"open to 0.0.0.0/0. This allows unrestricted access from the internet.",
81
+ remediation=f"Restrict access to port {port} by removing the 0.0.0.0/0 CIDR range "
82
+ f"from the security group ingress rules. Only allow access from specific, "
83
+ f"trusted IP addresses or CIDR blocks that require access.",
84
+ cis_control=cis_control,
85
+ metadata={
86
+ "group_id": resource.raw_config.get("GroupId", ""),
87
+ "port": port,
88
+ "protocol": "tcp",
89
+ "cidr": "0.0.0.0/0",
90
+ },
91
+ )
92
+ findings.append(finding)
93
+
94
+ return findings
95
+
96
+ def _find_open_critical_ports(self, config: Dict[str, Any]) -> List[int]:
97
+ """Find critical ports that are open to 0.0.0.0/0.
98
+
99
+ Args:
100
+ config: Security group raw configuration
101
+
102
+ Returns:
103
+ List of critical port numbers that are open to 0.0.0.0/0
104
+ """
105
+ open_ports: List[int] = []
106
+ ip_permissions = config.get("IpPermissions", [])
107
+
108
+ for permission in ip_permissions:
109
+ # Get the port range
110
+ from_port = permission.get("FromPort")
111
+ to_port = permission.get("ToPort")
112
+
113
+ # Skip if no ports defined
114
+ if from_port is None or to_port is None:
115
+ continue
116
+
117
+ # Check if any critical port is in this range
118
+ for critical_port in self.CRITICAL_PORTS.keys():
119
+ if from_port <= critical_port <= to_port:
120
+ # Check if this port is open to 0.0.0.0/0
121
+ if self._is_open_to_world(permission):
122
+ open_ports.append(critical_port)
123
+
124
+ return open_ports
125
+
126
+ def _is_open_to_world(self, permission: Dict[str, Any]) -> bool:
127
+ """Check if a permission allows access from 0.0.0.0/0.
128
+
129
+ Args:
130
+ permission: IP permission dictionary from IpPermissions
131
+
132
+ Returns:
133
+ True if permission includes 0.0.0.0/0
134
+ """
135
+ ip_ranges = permission.get("IpRanges", [])
136
+
137
+ for ip_range in ip_ranges:
138
+ cidr = ip_range.get("CidrIp", "")
139
+ if cidr == "0.0.0.0/0":
140
+ return True
141
+
142
+ return False
@@ -0,0 +1,97 @@
1
+ """CIS Benchmark mapper for security findings."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Dict, List, Optional
6
+
7
+ from src.models.security_finding import SecurityFinding
8
+
9
+
10
+ class CISMapper:
11
+ """Maps security findings to CIS AWS Foundations Benchmark controls."""
12
+
13
+ # CIS control ID to human-readable name mapping
14
+ CIS_CONTROLS = {
15
+ "2.1.5": "S3 Bucket Public Access Block",
16
+ "5.2": "Security Groups - SSH Access",
17
+ "5.3": "Security Groups - RDP Access",
18
+ "1.3": "IAM Access Keys Rotated",
19
+ "1.4": "IAM Credentials Unused",
20
+ }
21
+
22
+ def get_cis_control(self, finding: SecurityFinding) -> Optional[str]:
23
+ """Get the CIS control ID from a security finding.
24
+
25
+ Args:
26
+ finding: SecurityFinding to extract control ID from
27
+
28
+ Returns:
29
+ CIS control ID string or None if not mapped
30
+ """
31
+ return finding.cis_control
32
+
33
+ def get_control_name(self, control_id: str) -> Optional[str]:
34
+ """Get the human-readable name for a CIS control ID.
35
+
36
+ Args:
37
+ control_id: CIS control ID (e.g., "2.1.5")
38
+
39
+ Returns:
40
+ Human-readable control name or None if not found
41
+ """
42
+ return self.CIS_CONTROLS.get(control_id)
43
+
44
+ def get_summary(self, findings: List[SecurityFinding]) -> dict:
45
+ """Generate a summary of CIS control pass/fail statistics.
46
+
47
+ Args:
48
+ findings: List of security findings
49
+
50
+ Returns:
51
+ Dictionary with total_controls_checked, controls_failed, controls_passed
52
+ """
53
+ # If no findings, return zeros
54
+ if not findings:
55
+ return {
56
+ "total_controls_checked": 0,
57
+ "controls_failed": 0,
58
+ "controls_passed": 0,
59
+ }
60
+
61
+ # Count unique CIS controls that have findings (failures)
62
+ failed_controls = set()
63
+ for finding in findings:
64
+ if finding.cis_control:
65
+ failed_controls.add(finding.cis_control)
66
+
67
+ controls_failed = len(failed_controls)
68
+
69
+ # For this implementation, we assume all known CIS controls were checked
70
+ # and only the ones with findings failed
71
+ total_controls_checked = len(self.CIS_CONTROLS)
72
+ controls_passed = total_controls_checked - controls_failed
73
+
74
+ return {
75
+ "total_controls_checked": total_controls_checked,
76
+ "controls_failed": controls_failed,
77
+ "controls_passed": controls_passed,
78
+ }
79
+
80
+ def group_by_control(self, findings: List[SecurityFinding]) -> Dict[str, List[SecurityFinding]]:
81
+ """Group security findings by their CIS control ID.
82
+
83
+ Args:
84
+ findings: List of security findings
85
+
86
+ Returns:
87
+ Dictionary mapping CIS control IDs to lists of findings
88
+ """
89
+ grouped: Dict[str, List[SecurityFinding]] = {}
90
+
91
+ for finding in findings:
92
+ if finding.cis_control:
93
+ if finding.cis_control not in grouped:
94
+ grouped[finding.cis_control] = []
95
+ grouped[finding.cis_control].append(finding)
96
+
97
+ return grouped