aws-inventory-manager 0.13.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.
Potentially problematic release.
This version of aws-inventory-manager might be problematic. Click here for more details.
- aws_inventory_manager-0.13.2.dist-info/LICENSE +21 -0
- aws_inventory_manager-0.13.2.dist-info/METADATA +1226 -0
- aws_inventory_manager-0.13.2.dist-info/RECORD +145 -0
- aws_inventory_manager-0.13.2.dist-info/WHEEL +5 -0
- aws_inventory_manager-0.13.2.dist-info/entry_points.txt +2 -0
- aws_inventory_manager-0.13.2.dist-info/top_level.txt +1 -0
- src/__init__.py +3 -0
- src/aws/__init__.py +11 -0
- src/aws/client.py +128 -0
- src/aws/credentials.py +191 -0
- src/aws/rate_limiter.py +177 -0
- src/cli/__init__.py +12 -0
- src/cli/config.py +130 -0
- src/cli/main.py +3626 -0
- src/config_service/__init__.py +21 -0
- src/config_service/collector.py +346 -0
- src/config_service/detector.py +256 -0
- src/config_service/resource_type_mapping.py +328 -0
- src/cost/__init__.py +5 -0
- src/cost/analyzer.py +226 -0
- src/cost/explorer.py +209 -0
- src/cost/reporter.py +237 -0
- src/delta/__init__.py +5 -0
- src/delta/calculator.py +206 -0
- src/delta/differ.py +185 -0
- src/delta/formatters.py +272 -0
- src/delta/models.py +154 -0
- src/delta/reporter.py +234 -0
- src/models/__init__.py +21 -0
- src/models/config_diff.py +135 -0
- src/models/cost_report.py +87 -0
- src/models/deletion_operation.py +104 -0
- src/models/deletion_record.py +97 -0
- src/models/delta_report.py +122 -0
- src/models/efs_resource.py +80 -0
- src/models/elasticache_resource.py +90 -0
- src/models/group.py +318 -0
- src/models/inventory.py +133 -0
- src/models/protection_rule.py +123 -0
- src/models/report.py +288 -0
- src/models/resource.py +111 -0
- src/models/security_finding.py +102 -0
- src/models/snapshot.py +122 -0
- src/restore/__init__.py +20 -0
- src/restore/audit.py +175 -0
- src/restore/cleaner.py +461 -0
- src/restore/config.py +209 -0
- src/restore/deleter.py +976 -0
- src/restore/dependency.py +254 -0
- src/restore/safety.py +115 -0
- src/security/__init__.py +0 -0
- src/security/checks/__init__.py +0 -0
- src/security/checks/base.py +56 -0
- src/security/checks/ec2_checks.py +88 -0
- src/security/checks/elasticache_checks.py +149 -0
- src/security/checks/iam_checks.py +102 -0
- src/security/checks/rds_checks.py +140 -0
- src/security/checks/s3_checks.py +95 -0
- src/security/checks/secrets_checks.py +96 -0
- src/security/checks/sg_checks.py +142 -0
- src/security/cis_mapper.py +97 -0
- src/security/models.py +53 -0
- src/security/reporter.py +174 -0
- src/security/scanner.py +87 -0
- src/snapshot/__init__.py +6 -0
- src/snapshot/capturer.py +451 -0
- src/snapshot/filter.py +259 -0
- src/snapshot/inventory_storage.py +236 -0
- src/snapshot/report_formatter.py +250 -0
- src/snapshot/reporter.py +189 -0
- src/snapshot/resource_collectors/__init__.py +5 -0
- src/snapshot/resource_collectors/apigateway.py +140 -0
- src/snapshot/resource_collectors/backup.py +136 -0
- src/snapshot/resource_collectors/base.py +81 -0
- src/snapshot/resource_collectors/cloudformation.py +55 -0
- src/snapshot/resource_collectors/cloudwatch.py +109 -0
- src/snapshot/resource_collectors/codebuild.py +69 -0
- src/snapshot/resource_collectors/codepipeline.py +82 -0
- src/snapshot/resource_collectors/dynamodb.py +65 -0
- src/snapshot/resource_collectors/ec2.py +240 -0
- src/snapshot/resource_collectors/ecs.py +215 -0
- src/snapshot/resource_collectors/efs_collector.py +102 -0
- src/snapshot/resource_collectors/eks.py +200 -0
- src/snapshot/resource_collectors/elasticache_collector.py +79 -0
- src/snapshot/resource_collectors/elb.py +126 -0
- src/snapshot/resource_collectors/eventbridge.py +156 -0
- src/snapshot/resource_collectors/iam.py +188 -0
- src/snapshot/resource_collectors/kms.py +111 -0
- src/snapshot/resource_collectors/lambda_func.py +139 -0
- src/snapshot/resource_collectors/rds.py +109 -0
- src/snapshot/resource_collectors/route53.py +86 -0
- src/snapshot/resource_collectors/s3.py +105 -0
- src/snapshot/resource_collectors/secretsmanager.py +70 -0
- src/snapshot/resource_collectors/sns.py +68 -0
- src/snapshot/resource_collectors/sqs.py +82 -0
- src/snapshot/resource_collectors/ssm.py +160 -0
- src/snapshot/resource_collectors/stepfunctions.py +74 -0
- src/snapshot/resource_collectors/vpcendpoints.py +79 -0
- src/snapshot/resource_collectors/waf.py +159 -0
- src/snapshot/storage.py +351 -0
- src/storage/__init__.py +21 -0
- src/storage/audit_store.py +419 -0
- src/storage/database.py +294 -0
- src/storage/group_store.py +749 -0
- src/storage/inventory_store.py +320 -0
- src/storage/resource_store.py +413 -0
- src/storage/schema.py +288 -0
- src/storage/snapshot_store.py +346 -0
- src/utils/__init__.py +12 -0
- src/utils/export.py +305 -0
- src/utils/hash.py +60 -0
- src/utils/logging.py +63 -0
- src/utils/pagination.py +41 -0
- src/utils/paths.py +51 -0
- src/utils/progress.py +41 -0
- src/utils/unsupported_resources.py +306 -0
- src/web/__init__.py +5 -0
- src/web/app.py +97 -0
- src/web/dependencies.py +69 -0
- src/web/routes/__init__.py +1 -0
- src/web/routes/api/__init__.py +18 -0
- src/web/routes/api/charts.py +156 -0
- src/web/routes/api/cleanup.py +186 -0
- src/web/routes/api/filters.py +253 -0
- src/web/routes/api/groups.py +305 -0
- src/web/routes/api/inventories.py +80 -0
- src/web/routes/api/queries.py +202 -0
- src/web/routes/api/resources.py +379 -0
- src/web/routes/api/snapshots.py +314 -0
- src/web/routes/api/views.py +260 -0
- src/web/routes/pages.py +198 -0
- src/web/services/__init__.py +1 -0
- src/web/templates/base.html +949 -0
- src/web/templates/components/navbar.html +31 -0
- src/web/templates/components/sidebar.html +104 -0
- src/web/templates/pages/audit_logs.html +86 -0
- src/web/templates/pages/cleanup.html +279 -0
- src/web/templates/pages/dashboard.html +227 -0
- src/web/templates/pages/diff.html +175 -0
- src/web/templates/pages/error.html +30 -0
- src/web/templates/pages/groups.html +721 -0
- src/web/templates/pages/queries.html +246 -0
- src/web/templates/pages/resources.html +2251 -0
- src/web/templates/pages/snapshot_detail.html +271 -0
- 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
|