narvy-cli 1.0.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.
- narvy/__init__.py +3 -0
- narvy/android/__init__.py +0 -0
- narvy/android/source_analyzer.py +268 -0
- narvy/android/split_bundle.py +475 -0
- narvy/android_rule_context.py +196 -0
- narvy/apk_memory_preflight.py +248 -0
- narvy/auth.py +40 -0
- narvy/ci_templates/bitbucket.yml +25 -0
- narvy/ci_templates/github.yml +60 -0
- narvy/ci_templates/gitlab.yml +32 -0
- narvy/cloud/__init__.py +0 -0
- narvy/cloud/aws_scan.py +1188 -0
- narvy/comment_filter.py +134 -0
- narvy/crypto_taint_lite.py +82 -0
- narvy/decompiler.py +337 -0
- narvy/doctor.py +244 -0
- narvy/host/__init__.py +0 -0
- narvy/host/audit.py +157 -0
- narvy/host/host_knowledge.py +982 -0
- narvy/host/lynis_bootstrap.py +400 -0
- narvy/host/lynis_parser.py +358 -0
- narvy/host/narvy_checks.py +789 -0
- narvy/host/report.py +69 -0
- narvy/host/ssh_exec.py +219 -0
- narvy/ios/__init__.py +0 -0
- narvy/ios/binary_analyzer.py +1201 -0
- narvy/ios/plist_checks.py +249 -0
- narvy/ios/source_analyzer.py +301 -0
- narvy/ios/third_party_filter.py +242 -0
- narvy/ios/trust_all_context.py +66 -0
- narvy/main.py +1983 -0
- narvy/native_hardening.py +503 -0
- narvy/reporter.py +151 -0
- narvy/rule_engine.py +57 -0
- narvy/rules/android/config.yml +77 -0
- narvy/rules/android/crypto.yml +34 -0
- narvy/rules/android/secrets.yml +161 -0
- narvy/rules/android/storage.yml +24 -0
- narvy/rules/android/webview.yml +23 -0
- narvy/rules/android.yml +219 -0
- narvy/rules/ios/objc/crypto.yml +56 -0
- narvy/rules/ios/objc/network.yml +67 -0
- narvy/rules/ios/objc/secrets.yml +79 -0
- narvy/rules/ios/objc/storage.yml +45 -0
- narvy/rules/ios/objc/webview.yml +45 -0
- narvy/rules/ios_swift.yml +327 -0
- narvy/rules/web/go.yml +2837 -0
- narvy/rules/web/java.yml +1576 -0
- narvy/rules/web/javascript.yml +3683 -0
- narvy/rules/web/kotlin.yml +413 -0
- narvy/rules/web/local/csharp_narvy/config.yml +61 -0
- narvy/rules/web/local/csharp_narvy/crypto.yml +64 -0
- narvy/rules/web/local/csharp_narvy/deserialization.yml +59 -0
- narvy/rules/web/local/csharp_narvy/injection.yml +122 -0
- narvy/rules/web/local/csharp_narvy/xxe.yml +48 -0
- narvy/rules/web/local/java_narvy/auth_jwt.yml +134 -0
- narvy/rules/web/local/java_narvy/deserialization.yml +108 -0
- narvy/rules/web/local/java_narvy/mybatis.yml +39 -0
- narvy/rules/web/local/java_narvy/snakeyaml.yml +34 -0
- narvy/rules/web/local/java_narvy/spring_authz.yml +32 -0
- narvy/rules/web/local/java_narvy/spring_config.yml +67 -0
- narvy/rules/web/local/java_narvy/spring_hardening.yml +291 -0
- narvy/rules/web/local/java_narvy/sqli.yml +235 -0
- narvy/rules/web/local/java_narvy/xxe.yml +212 -0
- narvy/rules/web/php.yml +1644 -0
- narvy/rules/web/python.yml +3967 -0
- narvy/rules/web/ruby.yml +703 -0
- narvy/rules/web/rust.yml +258 -0
- narvy/rules/web/secrets.yml +1420 -0
- narvy/rules/web/secrets_supplement.yml +383 -0
- narvy/sca/__init__.py +1 -0
- narvy/sca/android_deps.py +349 -0
- narvy/sca/ios_deps.py +578 -0
- narvy/sca/osv_client.py +617 -0
- narvy/sca/web_deps.py +955 -0
- narvy/scope_config.py +195 -0
- narvy/semgrep_engine.py +219 -0
- narvy/stack_protector_evidence.py +96 -0
- narvy/third_party_filter.py +211 -0
- narvy/uploader.py +92 -0
- narvy/weak_prng_context.py +270 -0
- narvy/web/__init__.py +0 -0
- narvy/web/nuclei_binary.py +105 -0
- narvy/web/scan_blocklist.py +96 -0
- narvy/web/scanner.py +842 -0
- narvy/web/source_analyzer.py +714 -0
- narvy/web/ssrf_guard.py +374 -0
- narvy_cli-1.0.0.dist-info/METADATA +165 -0
- narvy_cli-1.0.0.dist-info/RECORD +93 -0
- narvy_cli-1.0.0.dist-info/WHEEL +5 -0
- narvy_cli-1.0.0.dist-info/entry_points.txt +2 -0
- narvy_cli-1.0.0.dist-info/licenses/LICENSE +202 -0
- narvy_cli-1.0.0.dist-info/top_level.txt +1 -0
narvy/cloud/aws_scan.py
ADDED
|
@@ -0,0 +1,1188 @@
|
|
|
1
|
+
"""AWS cloud posture scanner: security-group, IAM, storage and logging checks
|
|
2
|
+
across every enabled region. Credentials resolve through boto3's normal chain.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import boto3
|
|
6
|
+
from botocore.exceptions import ClientError, NoCredentialsError, EndpointConnectionError
|
|
7
|
+
import json
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
import time
|
|
10
|
+
import os
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
class CustomJSONEncoder(json.JSONEncoder):
|
|
17
|
+
def default(self, obj):
|
|
18
|
+
if isinstance(obj, datetime):
|
|
19
|
+
return obj.isoformat()
|
|
20
|
+
return super().default(obj)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
SEV_CRITICAL = 'critical'
|
|
24
|
+
SEV_HIGH = 'high'
|
|
25
|
+
SEV_MEDIUM = 'medium'
|
|
26
|
+
SEV_LOW = 'low'
|
|
27
|
+
SEV_INFO = 'info'
|
|
28
|
+
|
|
29
|
+
# Web ports are deliberately absent: world-open is their normal configuration.
|
|
30
|
+
SENSITIVE_PORTS = {
|
|
31
|
+
22: ('SSH', SEV_CRITICAL),
|
|
32
|
+
3389: ('RDP', SEV_CRITICAL),
|
|
33
|
+
3306: ('MySQL/MariaDB', SEV_CRITICAL),
|
|
34
|
+
5432: ('PostgreSQL', SEV_CRITICAL),
|
|
35
|
+
6379: ('Redis', SEV_CRITICAL),
|
|
36
|
+
27017: ('MongoDB', SEV_CRITICAL),
|
|
37
|
+
1433: ('MSSQL', SEV_CRITICAL),
|
|
38
|
+
9200: ('Elasticsearch', SEV_HIGH),
|
|
39
|
+
9300: ('Elasticsearch transport', SEV_HIGH),
|
|
40
|
+
5601: ('Kibana', SEV_HIGH),
|
|
41
|
+
11211: ('Memcached', SEV_HIGH),
|
|
42
|
+
23: ('Telnet', SEV_CRITICAL),
|
|
43
|
+
21: ('FTP', SEV_HIGH),
|
|
44
|
+
2375: ('Docker API', SEV_CRITICAL),
|
|
45
|
+
2376: ('Docker API (TLS)', SEV_HIGH),
|
|
46
|
+
5984: ('CouchDB', SEV_HIGH),
|
|
47
|
+
7001: ('WebLogic', SEV_HIGH),
|
|
48
|
+
8020: ('Hadoop', SEV_HIGH),
|
|
49
|
+
9000: ('Misc admin', SEV_MEDIUM),
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
WEB_PORTS = {80, 443, 8080, 8443}
|
|
53
|
+
|
|
54
|
+
_SEV_ORDER = {SEV_CRITICAL: 4, SEV_HIGH: 3, SEV_MEDIUM: 2, SEV_LOW: 1, SEV_INFO: 0}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _port_range(rule):
|
|
58
|
+
"""Return (from_port, to_port) for an IpPermission rule."""
|
|
59
|
+
proto = rule.get('IpProtocol')
|
|
60
|
+
if proto == '-1':
|
|
61
|
+
return (0, 65535)
|
|
62
|
+
fp = rule.get('FromPort')
|
|
63
|
+
tp = rule.get('ToPort')
|
|
64
|
+
if fp is None and tp is None:
|
|
65
|
+
return (0, 65535)
|
|
66
|
+
if fp is None:
|
|
67
|
+
fp = tp
|
|
68
|
+
if tp is None:
|
|
69
|
+
tp = fp
|
|
70
|
+
return (fp, tp)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _classify_open_rule(from_port, to_port):
|
|
74
|
+
"""Given a port range open to 0.0.0.0/0, return (severity, label)."""
|
|
75
|
+
span = to_port - from_port
|
|
76
|
+
if from_port == 0 and to_port == 65535:
|
|
77
|
+
return (SEV_CRITICAL, 'ALL ports (0-65535)')
|
|
78
|
+
|
|
79
|
+
hits = []
|
|
80
|
+
for port, (name, sev) in SENSITIVE_PORTS.items():
|
|
81
|
+
if from_port <= port <= to_port:
|
|
82
|
+
hits.append((sev, f'{name} ({port})'))
|
|
83
|
+
if hits:
|
|
84
|
+
hits.sort(key=lambda h: _SEV_ORDER[h[0]], reverse=True)
|
|
85
|
+
return (hits[0][0], ', '.join(h[1] for h in hits))
|
|
86
|
+
|
|
87
|
+
covered = set(range(from_port, to_port + 1)) if span <= 64 else None
|
|
88
|
+
if covered is not None and covered and covered.issubset(WEB_PORTS):
|
|
89
|
+
return (SEV_INFO, 'web port(s)')
|
|
90
|
+
|
|
91
|
+
if span > 64:
|
|
92
|
+
return (SEV_MEDIUM, f'wide range {from_port}-{to_port}')
|
|
93
|
+
|
|
94
|
+
return (SEV_MEDIUM, f'port {from_port}-{to_port}')
|
|
95
|
+
|
|
96
|
+
def _boto3_client(service, endpoint_url=None, **kwargs):
|
|
97
|
+
"""boto3.client wrapper threading an optional endpoint_url override."""
|
|
98
|
+
if endpoint_url is not None:
|
|
99
|
+
kwargs['endpoint_url'] = endpoint_url
|
|
100
|
+
return boto3.client(service, **kwargs)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def get_all_regions(endpoint_url=None):
|
|
104
|
+
logger.info(f"getting all regions ...")
|
|
105
|
+
|
|
106
|
+
ec2 = _boto3_client('ec2', endpoint_url=endpoint_url, region_name='us-east-1')
|
|
107
|
+
response = ec2.describe_regions()
|
|
108
|
+
logger.info(f"all regions got")
|
|
109
|
+
|
|
110
|
+
return [region['RegionName'] for region in response['Regions']]
|
|
111
|
+
|
|
112
|
+
def analyze_security_groups(region, endpoint_url=None):
|
|
113
|
+
"""Flag security-group rules exposing ports to 0.0.0.0/0, one finding per rule."""
|
|
114
|
+
ec2 = _boto3_client('ec2', endpoint_url=endpoint_url, region_name=region)
|
|
115
|
+
response = ec2.describe_security_groups()
|
|
116
|
+
vulnerable_groups = []
|
|
117
|
+
|
|
118
|
+
for sg in response['SecurityGroups']:
|
|
119
|
+
for rule in sg['IpPermissions']:
|
|
120
|
+
open_cidrs = [r['CidrIp'] for r in rule.get('IpRanges', [])
|
|
121
|
+
if r.get('CidrIp') == '0.0.0.0/0']
|
|
122
|
+
if not open_cidrs:
|
|
123
|
+
continue
|
|
124
|
+
|
|
125
|
+
from_port, to_port = _port_range(rule)
|
|
126
|
+
severity, label = _classify_open_rule(from_port, to_port)
|
|
127
|
+
proto = rule.get('IpProtocol', '-1')
|
|
128
|
+
proto_label = 'all' if proto == '-1' else proto
|
|
129
|
+
if from_port == to_port:
|
|
130
|
+
port_str = str(from_port)
|
|
131
|
+
else:
|
|
132
|
+
port_str = f'{from_port}-{to_port}'
|
|
133
|
+
|
|
134
|
+
if severity == SEV_INFO:
|
|
135
|
+
issue = (f"Web port(s) {port_str}/{proto_label} open to 0.0.0.0/0 "
|
|
136
|
+
f"(expected for an internet-facing web server)")
|
|
137
|
+
rec = ("This is normal for a public web server. Confirm the host is "
|
|
138
|
+
"intended to be internet-facing; otherwise restrict the CIDR.")
|
|
139
|
+
else:
|
|
140
|
+
issue = (f"Sensitive port(s) {label} ({port_str}/{proto_label}) "
|
|
141
|
+
f"open to 0.0.0.0/0")
|
|
142
|
+
rec = (f"Restrict {port_str}/{proto_label} to specific CIDRs or a "
|
|
143
|
+
f"bastion/security-group reference. Exposing {label} to the "
|
|
144
|
+
f"entire internet is a direct attack surface.")
|
|
145
|
+
|
|
146
|
+
vulnerable_groups.append({
|
|
147
|
+
'GroupId': sg['GroupId'],
|
|
148
|
+
'GroupName': sg['GroupName'],
|
|
149
|
+
'VpcId': sg.get('VpcId'),
|
|
150
|
+
'OpenPorts': port_str,
|
|
151
|
+
'Protocol': proto_label,
|
|
152
|
+
'CidrIp': '0.0.0.0/0',
|
|
153
|
+
'Region': region,
|
|
154
|
+
'severity': severity,
|
|
155
|
+
'Issue': issue,
|
|
156
|
+
'Recommendation': rec,
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
return vulnerable_groups
|
|
160
|
+
|
|
161
|
+
def analyze_s3_buckets(endpoint_url=None):
|
|
162
|
+
s3 = _boto3_client('s3', endpoint_url=endpoint_url)
|
|
163
|
+
response = s3.list_buckets()
|
|
164
|
+
vulnerable_buckets = []
|
|
165
|
+
|
|
166
|
+
for bucket in response['Buckets']:
|
|
167
|
+
try:
|
|
168
|
+
acl = s3.get_bucket_acl(Bucket=bucket['Name'])
|
|
169
|
+
for grant in acl['Grants']:
|
|
170
|
+
if grant['Grantee'].get('URI') == 'http://acs.amazonaws.com/groups/global/AllUsers':
|
|
171
|
+
vulnerable_buckets.append({
|
|
172
|
+
'BucketName': bucket['Name'],
|
|
173
|
+
'PublicAccess': True,
|
|
174
|
+
'severity': SEV_CRITICAL,
|
|
175
|
+
'Issue': 'S3 bucket grants public access to AllUsers',
|
|
176
|
+
'Recommendation': "Remove public access to this bucket unless it's explicitly required. Use AWS S3 Block Public Access feature and review bucket policies."
|
|
177
|
+
})
|
|
178
|
+
break
|
|
179
|
+
except ClientError as e:
|
|
180
|
+
logger.error(f"Error checking ACL for bucket {bucket['Name']}: {str(e)}")
|
|
181
|
+
|
|
182
|
+
return vulnerable_buckets
|
|
183
|
+
|
|
184
|
+
def _has_console_access(iam, user_name):
|
|
185
|
+
"""True iff the IAM user has a console login profile."""
|
|
186
|
+
try:
|
|
187
|
+
iam.get_login_profile(UserName=user_name)
|
|
188
|
+
return True
|
|
189
|
+
except ClientError as e:
|
|
190
|
+
if e.response['Error']['Code'] == 'NoSuchEntity':
|
|
191
|
+
return False
|
|
192
|
+
# Any other error: assume console user rather than drop a real human.
|
|
193
|
+
logger.error(f"login-profile check failed for {user_name}: {e}")
|
|
194
|
+
return True
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def analyze_iam_users(endpoint_url=None):
|
|
198
|
+
"""Flag missing MFA, for console users only: MFA does not apply to API keys."""
|
|
199
|
+
iam = _boto3_client('iam', endpoint_url=endpoint_url)
|
|
200
|
+
response = iam.list_users()
|
|
201
|
+
users_without_mfa = []
|
|
202
|
+
|
|
203
|
+
for user in response['Users']:
|
|
204
|
+
if not _has_console_access(iam, user['UserName']):
|
|
205
|
+
continue
|
|
206
|
+
mfa_devices = iam.list_mfa_devices(UserName=user['UserName'])
|
|
207
|
+
if not mfa_devices['MFADevices']:
|
|
208
|
+
users_without_mfa.append({
|
|
209
|
+
'UserName': user['UserName'],
|
|
210
|
+
'UserId': user['UserId'],
|
|
211
|
+
'CreateDate': user['CreateDate'].isoformat(),
|
|
212
|
+
'severity': SEV_MEDIUM,
|
|
213
|
+
'Issue': f"Console user {user['UserName']} has no MFA device",
|
|
214
|
+
'Recommendation': "Enable Multi-Factor Authentication (MFA) for this console user to enhance account security."
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
return users_without_mfa
|
|
218
|
+
|
|
219
|
+
def _as_list(value):
|
|
220
|
+
"""IAM policy fields can be a scalar or a list; normalize to a list."""
|
|
221
|
+
if value is None:
|
|
222
|
+
return []
|
|
223
|
+
return value if isinstance(value, list) else [value]
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _statement_is_wildcard_admin(stmt):
|
|
227
|
+
"""Returns (is_admin, has_action_wildcard, has_resource_wildcard)."""
|
|
228
|
+
if not isinstance(stmt, dict):
|
|
229
|
+
return (False, False, False)
|
|
230
|
+
if stmt.get('Effect') != 'Allow':
|
|
231
|
+
return (False, False, False)
|
|
232
|
+
actions = _as_list(stmt.get('Action'))
|
|
233
|
+
resources = _as_list(stmt.get('Resource'))
|
|
234
|
+
action_wild = any(a == '*' or (isinstance(a, str) and a.endswith(':*'))
|
|
235
|
+
for a in actions)
|
|
236
|
+
full_action_wild = any(a == '*' for a in actions)
|
|
237
|
+
resource_wild = any(r == '*' for r in resources)
|
|
238
|
+
return (full_action_wild and resource_wild, action_wild, resource_wild)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def analyze_iam_policies(endpoint_url=None):
|
|
242
|
+
"""Flag customer-managed policies whose statements grant wildcard access."""
|
|
243
|
+
iam = _boto3_client('iam', endpoint_url=endpoint_url)
|
|
244
|
+
policies = iam.list_policies(Scope='Local')['Policies']
|
|
245
|
+
risky_policies = []
|
|
246
|
+
|
|
247
|
+
for policy in policies:
|
|
248
|
+
policy_version = iam.get_policy_version(
|
|
249
|
+
PolicyArn=policy['Arn'],
|
|
250
|
+
VersionId=policy['DefaultVersionId']
|
|
251
|
+
)['PolicyVersion']
|
|
252
|
+
|
|
253
|
+
document = policy_version['Document']
|
|
254
|
+
if isinstance(document, str):
|
|
255
|
+
try:
|
|
256
|
+
document = json.loads(document)
|
|
257
|
+
except (ValueError, TypeError):
|
|
258
|
+
continue
|
|
259
|
+
|
|
260
|
+
worst = None
|
|
261
|
+
for stmt in _as_list(document.get('Statement')):
|
|
262
|
+
is_admin, action_wild, resource_wild = _statement_is_wildcard_admin(stmt)
|
|
263
|
+
if is_admin:
|
|
264
|
+
worst = (SEV_HIGH, 'Action:* on Resource:* (full admin)')
|
|
265
|
+
break
|
|
266
|
+
if action_wild and resource_wild:
|
|
267
|
+
if worst is None:
|
|
268
|
+
worst = (SEV_MEDIUM, 'service-wide Action wildcard on Resource:*')
|
|
269
|
+
|
|
270
|
+
if worst is not None:
|
|
271
|
+
risky_policies.append({
|
|
272
|
+
'PolicyName': policy['PolicyName'],
|
|
273
|
+
'PolicyId': policy['PolicyId'],
|
|
274
|
+
'Arn': policy['Arn'],
|
|
275
|
+
'severity': worst[0],
|
|
276
|
+
'Issue': f'Over-permissive policy: {worst[1]}',
|
|
277
|
+
'Recommendation': "Restrict this policy: replace Action:*/Resource:* with the specific actions and resource ARNs the workload needs (least privilege)."
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
return risky_policies
|
|
281
|
+
|
|
282
|
+
def analyze_vpc_configuration(region, endpoint_url=None):
|
|
283
|
+
"""Report non-default VPCs with an internet-gateway default route, as INFO."""
|
|
284
|
+
ec2 = _boto3_client('ec2', endpoint_url=endpoint_url, region_name=region)
|
|
285
|
+
vpcs = ec2.describe_vpcs()['Vpcs']
|
|
286
|
+
findings = []
|
|
287
|
+
|
|
288
|
+
for vpc in vpcs:
|
|
289
|
+
# Every default VPC ships an IGW public route, so it carries no signal.
|
|
290
|
+
if vpc.get('IsDefault'):
|
|
291
|
+
continue
|
|
292
|
+
route_tables = ec2.describe_route_tables(
|
|
293
|
+
Filters=[{'Name': 'vpc-id', 'Values': [vpc['VpcId']]}])['RouteTables']
|
|
294
|
+
for rt in route_tables:
|
|
295
|
+
for route in rt['Routes']:
|
|
296
|
+
if (route.get('GatewayId', '').startswith('igw-')
|
|
297
|
+
and route.get('DestinationCidrBlock') == '0.0.0.0/0'):
|
|
298
|
+
findings.append({
|
|
299
|
+
'VpcId': vpc['VpcId'],
|
|
300
|
+
'RouteTableId': rt['RouteTableId'],
|
|
301
|
+
'Region': region,
|
|
302
|
+
'severity': SEV_INFO,
|
|
303
|
+
'Issue': 'Non-default VPC has a public (IGW) route for 0.0.0.0/0',
|
|
304
|
+
'Recommendation': ("Informational: this subnet is public. "
|
|
305
|
+
"Confirm internet-facing workloads belong here.")
|
|
306
|
+
})
|
|
307
|
+
break
|
|
308
|
+
|
|
309
|
+
return findings
|
|
310
|
+
|
|
311
|
+
def analyze_rds_instances(region, endpoint_url=None):
|
|
312
|
+
rds = _boto3_client('rds', endpoint_url=endpoint_url, region_name=region)
|
|
313
|
+
instances = rds.describe_db_instances()['DBInstances']
|
|
314
|
+
vulnerable_instances = []
|
|
315
|
+
|
|
316
|
+
for instance in instances:
|
|
317
|
+
if not instance['StorageEncrypted']:
|
|
318
|
+
vulnerable_instances.append({
|
|
319
|
+
'DBInstanceIdentifier': instance['DBInstanceIdentifier'],
|
|
320
|
+
'Engine': instance['Engine'],
|
|
321
|
+
'severity': SEV_MEDIUM,
|
|
322
|
+
'Issue': 'Unencrypted Storage',
|
|
323
|
+
'Recommendation': 'Enable storage encryption for this RDS instance to protect data at rest.'
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
if instance['PubliclyAccessible']:
|
|
327
|
+
vulnerable_instances.append({
|
|
328
|
+
'DBInstanceIdentifier': instance['DBInstanceIdentifier'],
|
|
329
|
+
'Engine': instance['Engine'],
|
|
330
|
+
'severity': SEV_HIGH,
|
|
331
|
+
'Issue': 'Publicly Accessible',
|
|
332
|
+
'Recommendation': 'Disable public accessibility unless absolutely necessary. Use VPC and security groups to control access.'
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
return vulnerable_instances
|
|
336
|
+
|
|
337
|
+
def analyze_lambda_functions(region, endpoint_url=None):
|
|
338
|
+
lambda_client = _boto3_client('lambda', endpoint_url=endpoint_url, region_name=region)
|
|
339
|
+
functions = lambda_client.list_functions()['Functions']
|
|
340
|
+
vulnerable_functions = []
|
|
341
|
+
|
|
342
|
+
for function in functions:
|
|
343
|
+
if not function.get('KMSKeyArn'):
|
|
344
|
+
vulnerable_functions.append({
|
|
345
|
+
'FunctionName': function['FunctionName'],
|
|
346
|
+
'Region': region,
|
|
347
|
+
'severity': SEV_LOW,
|
|
348
|
+
'Issue': 'No customer-managed KMS Key for Environment Variables',
|
|
349
|
+
'Recommendation': 'Use a customer-managed KMS key to encrypt environment variables (Lambda encrypts at rest with an AWS-managed key by default).'
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
return vulnerable_functions
|
|
353
|
+
|
|
354
|
+
def analyze_ecs_clusters(region, endpoint_url=None):
|
|
355
|
+
ecs = _boto3_client('ecs', endpoint_url=endpoint_url, region_name=region)
|
|
356
|
+
clusters = ecs.list_clusters()['clusterArns']
|
|
357
|
+
vulnerable_clusters = []
|
|
358
|
+
|
|
359
|
+
for cluster_arn in clusters:
|
|
360
|
+
tasks = ecs.list_tasks(cluster=cluster_arn)['taskArns']
|
|
361
|
+
for task_arn in tasks:
|
|
362
|
+
task_details = ecs.describe_tasks(cluster=cluster_arn, tasks=[task_arn])['tasks'][0]
|
|
363
|
+
if not task_details.get('containers')[0].get('networkInterfaces'):
|
|
364
|
+
vulnerable_clusters.append({
|
|
365
|
+
'ClusterArn': cluster_arn,
|
|
366
|
+
'TaskArn': task_arn,
|
|
367
|
+
'Issue': 'Task using host network mode',
|
|
368
|
+
'Recommendation': 'Host network mode gives the task direct access to the host network namespace (host loopback, host-bound ports), bypassing the per-task elastic network interface and security group that awsvpc mode provides. Switch to awsvpc mode so this task gets its own isolated network interface.'
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
return vulnerable_clusters
|
|
372
|
+
|
|
373
|
+
def analyze_elastic_beanstalk(region, endpoint_url=None):
|
|
374
|
+
eb = _boto3_client('elasticbeanstalk', endpoint_url=endpoint_url, region_name=region)
|
|
375
|
+
environments = eb.describe_environments()['Environments']
|
|
376
|
+
vulnerable_environments = []
|
|
377
|
+
|
|
378
|
+
for env in environments:
|
|
379
|
+
config = eb.describe_configuration_settings(
|
|
380
|
+
ApplicationName=env['ApplicationName'],
|
|
381
|
+
EnvironmentName=env['EnvironmentName']
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
for setting in config['ConfigurationSettings'][0]['OptionSettings']:
|
|
385
|
+
if setting['OptionName'] == 'SecurityGroups' and setting['Value'] == '':
|
|
386
|
+
vulnerable_environments.append({
|
|
387
|
+
'EnvironmentName': env['EnvironmentName'],
|
|
388
|
+
'ApplicationName': env['ApplicationName'],
|
|
389
|
+
'Issue': 'No Security Group Specified',
|
|
390
|
+
'Recommendation': 'With no security group attached, this environment falls back to whatever AWS assigns by default (typically the VPC default security group), not one scoped to this application\'s actual ports. Attach a security group that allows only the ports this environment actually needs.'
|
|
391
|
+
})
|
|
392
|
+
elif setting['OptionName'] == 'SSLCertificateId' and setting['Value'] == '':
|
|
393
|
+
vulnerable_environments.append({
|
|
394
|
+
'EnvironmentName': env['EnvironmentName'],
|
|
395
|
+
'ApplicationName': env['ApplicationName'],
|
|
396
|
+
'Issue': 'No SSL Certificate',
|
|
397
|
+
'Recommendation': 'Configure an SSL certificate to encrypt data in transit.'
|
|
398
|
+
})
|
|
399
|
+
|
|
400
|
+
return vulnerable_environments
|
|
401
|
+
|
|
402
|
+
def analyze_api_gateway(region, endpoint_url=None):
|
|
403
|
+
apigw = _boto3_client('apigateway', endpoint_url=endpoint_url, region_name=region)
|
|
404
|
+
apis = apigw.get_rest_apis()['items']
|
|
405
|
+
vulnerable_apis = []
|
|
406
|
+
|
|
407
|
+
for api in apis:
|
|
408
|
+
stages = apigw.get_stages(restApiId=api['id'])['item']
|
|
409
|
+
for stage in stages:
|
|
410
|
+
if not stage.get('methodSettings') or not stage['methodSettings'].get('*/*', {}).get('dataTraceEnabled'):
|
|
411
|
+
vulnerable_apis.append({
|
|
412
|
+
'ApiName': api['name'],
|
|
413
|
+
'StageName': stage['stageName'],
|
|
414
|
+
'Issue': 'Data Tracing Disabled',
|
|
415
|
+
'Recommendation': 'Without X-Ray tracing, a misbehaving or compromised backend integration leaves no per-request trace during an incident - there is no way to distinguish normal latency from a request that reached an unexpected downstream resource.'
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
if not stage.get('clientCertificateId'):
|
|
419
|
+
vulnerable_apis.append({
|
|
420
|
+
'ApiName': api['name'],
|
|
421
|
+
'StageName': stage['stageName'],
|
|
422
|
+
'Issue': 'No Client-Side SSL Certificate',
|
|
423
|
+
'Recommendation': 'Without a client-side certificate, the backend integration cannot cryptographically verify a request actually came through this API Gateway stage, rather than a direct call to the backend that bypasses Gateway-level throttling and auth.'
|
|
424
|
+
})
|
|
425
|
+
|
|
426
|
+
return vulnerable_apis
|
|
427
|
+
|
|
428
|
+
def analyze_dynamodb(region, endpoint_url=None):
|
|
429
|
+
dynamodb = _boto3_client('dynamodb', endpoint_url=endpoint_url, region_name=region)
|
|
430
|
+
tables = dynamodb.list_tables()['TableNames']
|
|
431
|
+
vulnerable_tables = []
|
|
432
|
+
|
|
433
|
+
for table_name in tables:
|
|
434
|
+
table = dynamodb.describe_table(TableName=table_name)['Table']
|
|
435
|
+
if not table.get('SSEDescription') or table['SSEDescription']['Status'] != 'ENABLED':
|
|
436
|
+
vulnerable_tables.append({
|
|
437
|
+
'TableName': table_name,
|
|
438
|
+
'Issue': 'Server-Side Encryption Not Enabled',
|
|
439
|
+
'Recommendation': 'Enable server-side encryption to protect data at rest.'
|
|
440
|
+
})
|
|
441
|
+
|
|
442
|
+
if not table.get('StreamSpecification') or not table['StreamSpecification'].get('StreamEnabled'):
|
|
443
|
+
vulnerable_tables.append({
|
|
444
|
+
'TableName': table_name,
|
|
445
|
+
'Issue': 'DynamoDB Streams Not Enabled',
|
|
446
|
+
'Recommendation': 'Without Streams enabled, there is no change-data-capture record of item-level writes - an unauthorized modification or delete leaves no trail to reconstruct after the fact.'
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
return vulnerable_tables
|
|
450
|
+
def analyze_eks(region, endpoint_url=None):
|
|
451
|
+
eks = _boto3_client('eks', endpoint_url=endpoint_url, region_name=region)
|
|
452
|
+
clusters = eks.list_clusters()['clusters']
|
|
453
|
+
vulnerable_clusters = []
|
|
454
|
+
|
|
455
|
+
for cluster_name in clusters:
|
|
456
|
+
cluster = eks.describe_cluster(name=cluster_name)['cluster']
|
|
457
|
+
if not cluster['resourcesVpcConfig'].get('endpointPublicAccess'):
|
|
458
|
+
vulnerable_clusters.append({
|
|
459
|
+
'ClusterName': cluster_name,
|
|
460
|
+
'Issue': 'No public access endpoint',
|
|
461
|
+
'Recommendation': 'Consider enabling public access endpoint with restricted access for better management.'
|
|
462
|
+
})
|
|
463
|
+
if not cluster.get('encryptionConfig'):
|
|
464
|
+
vulnerable_clusters.append({
|
|
465
|
+
'ClusterName': cluster_name,
|
|
466
|
+
'Issue': 'Encryption not configured',
|
|
467
|
+
'Recommendation': 'Enable envelope encryption of Kubernetes secrets using AWS KMS.'
|
|
468
|
+
})
|
|
469
|
+
|
|
470
|
+
return vulnerable_clusters
|
|
471
|
+
|
|
472
|
+
def analyze_redshift(region, endpoint_url=None):
|
|
473
|
+
redshift = _boto3_client('redshift', endpoint_url=endpoint_url, region_name=region)
|
|
474
|
+
clusters = redshift.describe_clusters()['Clusters']
|
|
475
|
+
vulnerable_clusters = []
|
|
476
|
+
|
|
477
|
+
for cluster in clusters:
|
|
478
|
+
if not cluster['Encrypted']:
|
|
479
|
+
vulnerable_clusters.append({
|
|
480
|
+
'ClusterIdentifier': cluster['ClusterIdentifier'],
|
|
481
|
+
'Issue': 'Encryption not enabled',
|
|
482
|
+
'Recommendation': 'Enable encryption for the Redshift cluster to protect data at rest.'
|
|
483
|
+
})
|
|
484
|
+
if cluster['PubliclyAccessible']:
|
|
485
|
+
vulnerable_clusters.append({
|
|
486
|
+
'ClusterIdentifier': cluster['ClusterIdentifier'],
|
|
487
|
+
'Issue': 'Publicly accessible',
|
|
488
|
+
'Recommendation': 'Disable public accessibility unless absolutely necessary. Use VPC endpoints for secure access.'
|
|
489
|
+
})
|
|
490
|
+
|
|
491
|
+
return vulnerable_clusters
|
|
492
|
+
|
|
493
|
+
def analyze_secrets_manager(region, endpoint_url=None):
|
|
494
|
+
secrets = _boto3_client('secretsmanager', endpoint_url=endpoint_url, region_name=region)
|
|
495
|
+
secret_list = secrets.list_secrets()['SecretList']
|
|
496
|
+
vulnerable_secrets = []
|
|
497
|
+
|
|
498
|
+
for secret in secret_list:
|
|
499
|
+
if not secret.get('RotationEnabled'):
|
|
500
|
+
vulnerable_secrets.append({
|
|
501
|
+
'SecretName': secret['Name'],
|
|
502
|
+
'Issue': 'Rotation not enabled',
|
|
503
|
+
'Recommendation': 'Enable automatic rotation for the secret to enhance security.'
|
|
504
|
+
})
|
|
505
|
+
|
|
506
|
+
return vulnerable_secrets
|
|
507
|
+
|
|
508
|
+
def analyze_sqs(region, endpoint_url=None):
|
|
509
|
+
sqs = _boto3_client('sqs', endpoint_url=endpoint_url, region_name=region)
|
|
510
|
+
queues = sqs.list_queues()
|
|
511
|
+
vulnerable_queues = []
|
|
512
|
+
|
|
513
|
+
if 'QueueUrls' in queues:
|
|
514
|
+
for queue_url in queues['QueueUrls']:
|
|
515
|
+
attributes = sqs.get_queue_attributes(QueueUrl=queue_url, AttributeNames=['Policy'])
|
|
516
|
+
if 'Policy' in attributes['Attributes']:
|
|
517
|
+
policy = json.loads(attributes['Attributes']['Policy'])
|
|
518
|
+
for statement in policy['Statement']:
|
|
519
|
+
if statement['Effect'] == 'Allow' and statement['Principal'] == '*':
|
|
520
|
+
vulnerable_queues.append({
|
|
521
|
+
'QueueUrl': queue_url,
|
|
522
|
+
'severity': SEV_HIGH,
|
|
523
|
+
'Issue': 'Public access policy',
|
|
524
|
+
'Recommendation': 'Review and restrict the queue policy to prevent public access.'
|
|
525
|
+
})
|
|
526
|
+
|
|
527
|
+
return vulnerable_queues
|
|
528
|
+
|
|
529
|
+
def analyze_sns(region, endpoint_url=None):
|
|
530
|
+
sns = _boto3_client('sns', endpoint_url=endpoint_url, region_name=region)
|
|
531
|
+
topics = sns.list_topics()['Topics']
|
|
532
|
+
vulnerable_topics = []
|
|
533
|
+
|
|
534
|
+
for topic in topics:
|
|
535
|
+
attributes = sns.get_topic_attributes(TopicArn=topic['TopicArn'])
|
|
536
|
+
if 'Policy' in attributes['Attributes']:
|
|
537
|
+
policy = json.loads(attributes['Attributes']['Policy'])
|
|
538
|
+
for statement in policy['Statement']:
|
|
539
|
+
if statement['Effect'] == 'Allow' and statement['Principal'] == '*':
|
|
540
|
+
vulnerable_topics.append({
|
|
541
|
+
'TopicArn': topic['TopicArn'],
|
|
542
|
+
'severity': SEV_HIGH,
|
|
543
|
+
'Issue': 'Public access policy',
|
|
544
|
+
'Recommendation': 'Review and restrict the topic policy to prevent public access.'
|
|
545
|
+
})
|
|
546
|
+
|
|
547
|
+
return vulnerable_topics
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def analyze_glue(region, endpoint_url=None):
|
|
551
|
+
glue = _boto3_client('glue', endpoint_url=endpoint_url, region_name=region)
|
|
552
|
+
jobs = glue.get_jobs()['Jobs']
|
|
553
|
+
vulnerable_jobs = []
|
|
554
|
+
|
|
555
|
+
for job in jobs:
|
|
556
|
+
if not job.get('SecurityConfiguration'):
|
|
557
|
+
vulnerable_jobs.append({
|
|
558
|
+
'JobName': job['Name'],
|
|
559
|
+
'Issue': 'No Security Configuration',
|
|
560
|
+
'Recommendation': 'Attach a security configuration to encrypt data and use job bookmarks.'
|
|
561
|
+
})
|
|
562
|
+
|
|
563
|
+
return vulnerable_jobs
|
|
564
|
+
|
|
565
|
+
def analyze_emr(region, endpoint_url=None):
|
|
566
|
+
emr = _boto3_client('emr', endpoint_url=endpoint_url, region_name=region)
|
|
567
|
+
clusters = emr.list_clusters(ClusterStates=['RUNNING', 'WAITING'])['Clusters']
|
|
568
|
+
vulnerable_clusters = []
|
|
569
|
+
|
|
570
|
+
for cluster in clusters:
|
|
571
|
+
cluster_id = cluster['Id']
|
|
572
|
+
cluster_info = emr.describe_cluster(ClusterId=cluster_id)['Cluster']
|
|
573
|
+
|
|
574
|
+
if not cluster_info.get('KerberosAttributes'):
|
|
575
|
+
vulnerable_clusters.append({
|
|
576
|
+
'ClusterId': cluster_id,
|
|
577
|
+
'Issue': 'Kerberos not enabled',
|
|
578
|
+
'Recommendation': 'Enable Kerberos authentication for stronger security.'
|
|
579
|
+
})
|
|
580
|
+
|
|
581
|
+
if cluster_info['Ec2InstanceAttributes'].get('EmrManagedMasterSecurityGroup') == cluster_info['Ec2InstanceAttributes'].get('EmrManagedSlaveSecurityGroup'):
|
|
582
|
+
vulnerable_clusters.append({
|
|
583
|
+
'ClusterId': cluster_id,
|
|
584
|
+
'Issue': 'Same security group for master and slave nodes',
|
|
585
|
+
'Recommendation': 'Use separate security groups for master and slave nodes.'
|
|
586
|
+
})
|
|
587
|
+
|
|
588
|
+
return vulnerable_clusters
|
|
589
|
+
|
|
590
|
+
def check_cis_compliance(endpoint_url=None):
|
|
591
|
+
iam = _boto3_client('iam', endpoint_url=endpoint_url)
|
|
592
|
+
compliance_issues = []
|
|
593
|
+
|
|
594
|
+
root_user = iam.get_account_summary()
|
|
595
|
+
if root_user['SummaryMap']['AccountAccessKeysPresent'] > 0:
|
|
596
|
+
compliance_issues.append({
|
|
597
|
+
'CheckId': 'CIS 1.1',
|
|
598
|
+
'severity': SEV_CRITICAL,
|
|
599
|
+
'Issue': 'Root account has access keys',
|
|
600
|
+
'Recommendation': ('Delete all access keys associated with the root '
|
|
601
|
+
'account immediately. Root keys grant unrestricted, '
|
|
602
|
+
'unrevocable account-wide access and cannot be scoped.')
|
|
603
|
+
})
|
|
604
|
+
|
|
605
|
+
users = iam.list_users()['Users']
|
|
606
|
+
for user in users:
|
|
607
|
+
try:
|
|
608
|
+
login_profile = iam.get_login_profile(UserName=user['UserName'])
|
|
609
|
+
mfa_devices = iam.list_mfa_devices(UserName=user['UserName'])['MFADevices']
|
|
610
|
+
if login_profile and not mfa_devices:
|
|
611
|
+
compliance_issues.append({
|
|
612
|
+
'CheckId': 'CIS 1.2',
|
|
613
|
+
'severity': SEV_MEDIUM,
|
|
614
|
+
'UserName': user['UserName'],
|
|
615
|
+
'Issue': f"User {user['UserName']} has console access without MFA",
|
|
616
|
+
'Recommendation': 'Enable MFA for all IAM users with console access.'
|
|
617
|
+
})
|
|
618
|
+
except ClientError as e:
|
|
619
|
+
if e.response['Error']['Code'] != 'NoSuchEntity':
|
|
620
|
+
logger.error(f"Error checking MFA for user {user['UserName']}: {str(e)}")
|
|
621
|
+
|
|
622
|
+
try:
|
|
623
|
+
credential_report = None
|
|
624
|
+
for _ in range(5):
|
|
625
|
+
try:
|
|
626
|
+
credential_report = iam.get_credential_report()['Content']
|
|
627
|
+
break
|
|
628
|
+
except ClientError as e:
|
|
629
|
+
if e.response['Error']['Code'] == 'ReportNotPresent':
|
|
630
|
+
logger.error("Credential report not found. Generating new report...")
|
|
631
|
+
iam.generate_credential_report()
|
|
632
|
+
time.sleep(10)
|
|
633
|
+
else:
|
|
634
|
+
raise
|
|
635
|
+
|
|
636
|
+
if credential_report is None:
|
|
637
|
+
logger.error("Failed to generate or retrieve credential report after multiple attempts.")
|
|
638
|
+
return compliance_issues
|
|
639
|
+
|
|
640
|
+
credential_report = credential_report.decode('utf-8').split('\n')
|
|
641
|
+
for row in credential_report[1:]:
|
|
642
|
+
user_data = row.split(',')
|
|
643
|
+
if len(user_data) < 5:
|
|
644
|
+
continue
|
|
645
|
+
if user_data[3] == 'true' and user_data[4] != 'N/A' and user_data[4] != 'no_information':
|
|
646
|
+
# Credential-report timestamps come either Z-suffixed or with
|
|
647
|
+
# an explicit +00:00 offset; normalize both.
|
|
648
|
+
_ts = user_data[4].strip().replace('Z', '+00:00')
|
|
649
|
+
try:
|
|
650
|
+
last_used = datetime.fromisoformat(_ts)
|
|
651
|
+
except ValueError:
|
|
652
|
+
last_used = datetime.strptime(_ts, "%Y-%m-%dT%H:%M:%S+00:00")
|
|
653
|
+
if last_used.tzinfo is not None:
|
|
654
|
+
last_used = last_used.replace(tzinfo=None)
|
|
655
|
+
if (datetime.now() - last_used).days > 90:
|
|
656
|
+
compliance_issues.append({
|
|
657
|
+
'CheckId': 'CIS 1.3',
|
|
658
|
+
'severity': SEV_LOW,
|
|
659
|
+
'Issue': f"User {user_data[0]} has unused credentials for over 90 days",
|
|
660
|
+
'Recommendation': 'Disable or remove unused credentials.'
|
|
661
|
+
})
|
|
662
|
+
|
|
663
|
+
except Exception as e:
|
|
664
|
+
logger.error(f"Error processing credential report: {str(e)}")
|
|
665
|
+
|
|
666
|
+
return compliance_issues
|
|
667
|
+
|
|
668
|
+
def analyze_kms(region, endpoint_url=None):
|
|
669
|
+
kms = _boto3_client('kms', endpoint_url=endpoint_url, region_name=region)
|
|
670
|
+
keys = kms.list_keys()['Keys']
|
|
671
|
+
vulnerable_keys = []
|
|
672
|
+
|
|
673
|
+
for key in keys:
|
|
674
|
+
key_info = kms.describe_key(KeyId=key['KeyId'])['KeyMetadata']
|
|
675
|
+
if not key_info.get('KeyManager') == 'AWS':
|
|
676
|
+
if not key_info.get('Enabled'):
|
|
677
|
+
vulnerable_keys.append({
|
|
678
|
+
'KeyId': key['KeyId'],
|
|
679
|
+
'Issue': 'Disabled key',
|
|
680
|
+
'Recommendation': 'Review and enable the key if needed, or schedule for deletion.'
|
|
681
|
+
})
|
|
682
|
+
if not kms.get_key_rotation_status(KeyId=key['KeyId'])['KeyRotationEnabled']:
|
|
683
|
+
vulnerable_keys.append({
|
|
684
|
+
'KeyId': key['KeyId'],
|
|
685
|
+
'Issue': 'Key rotation not enabled',
|
|
686
|
+
'Recommendation': 'Without rotation, the same key material stays in use indefinitely - anyone who obtains the key (via a leaked grant, an over-permissioned IAM policy, or a past compromise) retains decrypt capability forever. Automatic rotation limits how much data any single key version protects.'
|
|
687
|
+
})
|
|
688
|
+
|
|
689
|
+
return vulnerable_keys
|
|
690
|
+
|
|
691
|
+
def analyze_config(region, endpoint_url=None):
|
|
692
|
+
config = _boto3_client('config', endpoint_url=endpoint_url, region_name=region)
|
|
693
|
+
recorders = config.describe_configuration_recorders()['ConfigurationRecorders']
|
|
694
|
+
issues = []
|
|
695
|
+
|
|
696
|
+
if not recorders:
|
|
697
|
+
issues.append({
|
|
698
|
+
'severity': SEV_MEDIUM,
|
|
699
|
+
'Issue': 'AWS Config not enabled',
|
|
700
|
+
'Recommendation': 'Enable AWS Config to track resource inventory and changes.'
|
|
701
|
+
})
|
|
702
|
+
else:
|
|
703
|
+
for recorder in recorders:
|
|
704
|
+
if not recorder.get('recordingGroup', {}).get('allSupported'):
|
|
705
|
+
issues.append({
|
|
706
|
+
'RecorderName': recorder['name'],
|
|
707
|
+
'severity': SEV_LOW,
|
|
708
|
+
'Issue': 'Not recording all supported resource types',
|
|
709
|
+
'Recommendation': 'Configure AWS Config to record all supported resource types.'
|
|
710
|
+
})
|
|
711
|
+
|
|
712
|
+
return issues
|
|
713
|
+
|
|
714
|
+
def analyze_cloudtrail(region, endpoint_url=None):
|
|
715
|
+
cloudtrail = _boto3_client('cloudtrail', endpoint_url=endpoint_url, region_name=region)
|
|
716
|
+
trails = cloudtrail.describe_trails()['trailList']
|
|
717
|
+
issues = []
|
|
718
|
+
|
|
719
|
+
if not trails:
|
|
720
|
+
issues.append({
|
|
721
|
+
'severity': SEV_HIGH,
|
|
722
|
+
'Issue': 'No CloudTrail trails configured',
|
|
723
|
+
'Recommendation': 'Set up a CloudTrail trail to log AWS account activity.'
|
|
724
|
+
})
|
|
725
|
+
else:
|
|
726
|
+
for trail in trails:
|
|
727
|
+
if not trail.get('IsMultiRegionTrail'):
|
|
728
|
+
issues.append({
|
|
729
|
+
'TrailName': trail['Name'],
|
|
730
|
+
'severity': SEV_MEDIUM,
|
|
731
|
+
'Issue': 'Trail is not multi-region',
|
|
732
|
+
'Recommendation': 'Configure the trail to log events from all regions for comprehensive auditing.'
|
|
733
|
+
})
|
|
734
|
+
if not trail.get('LogFileValidationEnabled'):
|
|
735
|
+
issues.append({
|
|
736
|
+
'TrailName': trail['Name'],
|
|
737
|
+
'severity': SEV_LOW,
|
|
738
|
+
'Issue': 'Log file validation not enabled',
|
|
739
|
+
'Recommendation': 'Enable log file validation to ensure the integrity of your logs.'
|
|
740
|
+
})
|
|
741
|
+
|
|
742
|
+
return issues
|
|
743
|
+
|
|
744
|
+
def analyze_cloudwatch(region, endpoint_url=None):
|
|
745
|
+
cloudwatch = _boto3_client('cloudwatch', endpoint_url=endpoint_url, region_name=region)
|
|
746
|
+
logs = _boto3_client('logs', endpoint_url=endpoint_url, region_name=region)
|
|
747
|
+
alarms = cloudwatch.describe_alarms()['MetricAlarms']
|
|
748
|
+
log_groups = logs.describe_log_groups()['logGroups']
|
|
749
|
+
issues = []
|
|
750
|
+
|
|
751
|
+
if not alarms:
|
|
752
|
+
issues.append({
|
|
753
|
+
'severity': SEV_INFO,
|
|
754
|
+
'Issue': 'No CloudWatch alarms configured',
|
|
755
|
+
'Recommendation': 'Set up CloudWatch alarms to monitor key metrics and receive notifications.'
|
|
756
|
+
})
|
|
757
|
+
|
|
758
|
+
for log_group in log_groups:
|
|
759
|
+
if not log_group.get('retentionInDays'):
|
|
760
|
+
issues.append({
|
|
761
|
+
'LogGroupName': log_group['logGroupName'],
|
|
762
|
+
'severity': SEV_INFO,
|
|
763
|
+
'Issue': 'No retention period set',
|
|
764
|
+
'Recommendation': 'Set a retention period for the log group to manage storage and comply with policies.'
|
|
765
|
+
})
|
|
766
|
+
|
|
767
|
+
return issues
|
|
768
|
+
|
|
769
|
+
def _compute_passed_checks(report_lists):
|
|
770
|
+
"""Build the list of account-level controls that produced no finding."""
|
|
771
|
+
passed = []
|
|
772
|
+
|
|
773
|
+
def _passed(control, detail):
|
|
774
|
+
passed.append({'control': control, 'status': 'pass', 'detail': detail})
|
|
775
|
+
|
|
776
|
+
cis = report_lists.get('cis_compliance') or []
|
|
777
|
+
if not any(c.get('CheckId') == 'CIS 1.1' for c in cis if isinstance(c, dict)):
|
|
778
|
+
_passed('CIS 1.1 - root account has no access keys',
|
|
779
|
+
'No access keys on the root account.')
|
|
780
|
+
if not any(c.get('CheckId') == 'CIS 1.2' for c in cis if isinstance(c, dict)):
|
|
781
|
+
_passed('CIS 1.2 - all console users have MFA',
|
|
782
|
+
'No console user is missing MFA.')
|
|
783
|
+
|
|
784
|
+
ct = report_lists.get('cloudtrail') or []
|
|
785
|
+
if not any('No CloudTrail' in str(c.get('Issue', '')) for c in ct if isinstance(c, dict)):
|
|
786
|
+
_passed('CloudTrail enabled', 'At least one CloudTrail trail is configured.')
|
|
787
|
+
|
|
788
|
+
cfg = report_lists.get('config') or []
|
|
789
|
+
if not any('not enabled' in str(c.get('Issue', '')).lower() for c in cfg if isinstance(c, dict)):
|
|
790
|
+
_passed('AWS Config enabled', 'A configuration recorder is present.')
|
|
791
|
+
|
|
792
|
+
if not (report_lists.get('s3_buckets') or []):
|
|
793
|
+
_passed('No public S3 buckets', 'No bucket grants public AllUsers access.')
|
|
794
|
+
|
|
795
|
+
sgs = report_lists.get('ec2_security_groups') or []
|
|
796
|
+
if not any(isinstance(s, dict) and s.get('severity') in (SEV_CRITICAL, SEV_HIGH)
|
|
797
|
+
for s in sgs):
|
|
798
|
+
_passed('No sensitive ports open to 0.0.0.0/0',
|
|
799
|
+
'No SSH/RDP/DB/cache port exposed to the internet.')
|
|
800
|
+
|
|
801
|
+
rds = report_lists.get('rds_instances') or []
|
|
802
|
+
if not any('Publicly' in str(r.get('Issue', '')) for r in rds if isinstance(r, dict)):
|
|
803
|
+
_passed('No publicly-accessible RDS instances',
|
|
804
|
+
'No RDS instance is publicly accessible.')
|
|
805
|
+
|
|
806
|
+
return passed
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
def generate_report(vulnerable_groups, vulnerable_buckets, users_without_mfa, risky_policies, vulnerable_vpcs,
|
|
810
|
+
vulnerable_rds, vulnerable_lambdas, vulnerable_ecs, vulnerable_eb, vulnerable_apis,
|
|
811
|
+
vulnerable_dynamodb, vulnerable_cf, gd_issues, waf_issues, vulnerable_eks,
|
|
812
|
+
vulnerable_redshift, vulnerable_secrets, vulnerable_sqs, vulnerable_sns,
|
|
813
|
+
vulnerable_glue_jobs, vulnerable_emr_clusters, compliance_issues,
|
|
814
|
+
vulnerable_kms_keys, config_issues, cloudtrail_issues, cloudwatch_issues):
|
|
815
|
+
report = {
|
|
816
|
+
'timestamp': datetime.now().isoformat(),
|
|
817
|
+
'ec2_security_groups': vulnerable_groups,
|
|
818
|
+
's3_buckets': vulnerable_buckets,
|
|
819
|
+
'iam_users': users_without_mfa,
|
|
820
|
+
'iam_policies': risky_policies,
|
|
821
|
+
'vpc_configuration': vulnerable_vpcs,
|
|
822
|
+
'rds_instances': vulnerable_rds,
|
|
823
|
+
'lambda_functions': vulnerable_lambdas,
|
|
824
|
+
'ecs_clusters': vulnerable_ecs,
|
|
825
|
+
'elastic_beanstalk': vulnerable_eb,
|
|
826
|
+
'api_gateway': vulnerable_apis,
|
|
827
|
+
'dynamodb': vulnerable_dynamodb,
|
|
828
|
+
'cloudfront': vulnerable_cf,
|
|
829
|
+
'guardduty': gd_issues,
|
|
830
|
+
'waf': waf_issues,
|
|
831
|
+
'eks_clusters': vulnerable_eks,
|
|
832
|
+
'redshift_clusters': vulnerable_redshift,
|
|
833
|
+
'secrets_manager': vulnerable_secrets,
|
|
834
|
+
'sqs_queues': vulnerable_sqs,
|
|
835
|
+
'sns_topics': vulnerable_sns,
|
|
836
|
+
'glue_jobs': vulnerable_glue_jobs,
|
|
837
|
+
'emr_clusters': vulnerable_emr_clusters,
|
|
838
|
+
'cis_compliance': compliance_issues,
|
|
839
|
+
'kms_keys': vulnerable_kms_keys,
|
|
840
|
+
'config': config_issues,
|
|
841
|
+
'cloudtrail': cloudtrail_issues,
|
|
842
|
+
'cloudwatch': cloudwatch_issues,
|
|
843
|
+
'summary': {
|
|
844
|
+
'vulnerable_security_groups': len(vulnerable_groups),
|
|
845
|
+
'vulnerable_s3_buckets': len(vulnerable_buckets),
|
|
846
|
+
'users_without_mfa': len(users_without_mfa),
|
|
847
|
+
'risky_iam_policies': len(risky_policies),
|
|
848
|
+
'vulnerable_vpcs': len(vulnerable_vpcs),
|
|
849
|
+
'vulnerable_rds_instances': len(vulnerable_rds),
|
|
850
|
+
'vulnerable_lambda_functions': len(vulnerable_lambdas),
|
|
851
|
+
'vulnerable_ecs_clusters': len(vulnerable_ecs),
|
|
852
|
+
'vulnerable_elastic_beanstalk': len(vulnerable_eb),
|
|
853
|
+
'vulnerable_api_gateway': len(vulnerable_apis),
|
|
854
|
+
'vulnerable_dynamodb_tables': len(vulnerable_dynamodb),
|
|
855
|
+
'vulnerable_cloudfront_distributions': len(vulnerable_cf),
|
|
856
|
+
'guardduty_issues': len(gd_issues),
|
|
857
|
+
'waf_issues': len(waf_issues),
|
|
858
|
+
'vulnerable_eks_clusters': len(vulnerable_eks),
|
|
859
|
+
'vulnerable_redshift_clusters': len(vulnerable_redshift),
|
|
860
|
+
'vulnerable_secrets': len(vulnerable_secrets),
|
|
861
|
+
'vulnerable_sqs_queues': len(vulnerable_sqs),
|
|
862
|
+
'vulnerable_sns_topics': len(vulnerable_sns),
|
|
863
|
+
'vulnerable_glue_jobs': len(vulnerable_glue_jobs),
|
|
864
|
+
'vulnerable_emr_clusters': len(vulnerable_emr_clusters),
|
|
865
|
+
'cis_compliance_issues': len(compliance_issues),
|
|
866
|
+
'vulnerable_kms_keys': len(vulnerable_kms_keys),
|
|
867
|
+
'config_issues': len(config_issues),
|
|
868
|
+
'cloudtrail_issues': len(cloudtrail_issues),
|
|
869
|
+
'cloudwatch_issues': len(cloudwatch_issues)
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
category_default_sev = {
|
|
874
|
+
'ec2_security_groups': SEV_MEDIUM,
|
|
875
|
+
's3_buckets': SEV_CRITICAL,
|
|
876
|
+
'iam_users': SEV_MEDIUM,
|
|
877
|
+
'iam_policies': SEV_MEDIUM,
|
|
878
|
+
'vpc_configuration': SEV_INFO,
|
|
879
|
+
'rds_instances': SEV_MEDIUM,
|
|
880
|
+
'lambda_functions': SEV_LOW,
|
|
881
|
+
'ecs_clusters': SEV_LOW,
|
|
882
|
+
'elastic_beanstalk': SEV_LOW,
|
|
883
|
+
'api_gateway': SEV_LOW,
|
|
884
|
+
'dynamodb': SEV_LOW,
|
|
885
|
+
'cloudfront': SEV_LOW,
|
|
886
|
+
'guardduty': SEV_HIGH,
|
|
887
|
+
'waf': SEV_LOW,
|
|
888
|
+
'eks_clusters': SEV_MEDIUM,
|
|
889
|
+
'redshift_clusters': SEV_MEDIUM,
|
|
890
|
+
'secrets_manager': SEV_LOW,
|
|
891
|
+
'sqs_queues': SEV_HIGH,
|
|
892
|
+
'sns_topics': SEV_HIGH,
|
|
893
|
+
'glue_jobs': SEV_LOW,
|
|
894
|
+
'emr_clusters': SEV_LOW,
|
|
895
|
+
'cis_compliance': SEV_MEDIUM,
|
|
896
|
+
'kms_keys': SEV_MEDIUM,
|
|
897
|
+
'config': SEV_MEDIUM,
|
|
898
|
+
'cloudtrail': SEV_HIGH,
|
|
899
|
+
'cloudwatch': SEV_INFO,
|
|
900
|
+
}
|
|
901
|
+
severity_counts = {SEV_CRITICAL: 0, SEV_HIGH: 0, SEV_MEDIUM: 0,
|
|
902
|
+
SEV_LOW: 0, SEV_INFO: 0}
|
|
903
|
+
for category, default_sev in category_default_sev.items():
|
|
904
|
+
items = report.get(category) or []
|
|
905
|
+
if not isinstance(items, list):
|
|
906
|
+
continue
|
|
907
|
+
for item in items:
|
|
908
|
+
if not isinstance(item, dict):
|
|
909
|
+
continue
|
|
910
|
+
sev = str(item.get('severity', '')).lower()
|
|
911
|
+
if sev not in severity_counts:
|
|
912
|
+
sev = default_sev
|
|
913
|
+
item['severity'] = sev
|
|
914
|
+
severity_counts[sev] += 1
|
|
915
|
+
|
|
916
|
+
report['severity_summary'] = severity_counts
|
|
917
|
+
report['total_findings'] = sum(severity_counts.values())
|
|
918
|
+
|
|
919
|
+
report['passed_checks'] = _compute_passed_checks(report)
|
|
920
|
+
|
|
921
|
+
return report
|
|
922
|
+
|
|
923
|
+
def analyze_waf(region, endpoint_url=None):
|
|
924
|
+
waf_regional = _boto3_client('waf-regional', endpoint_url=endpoint_url, region_name=region)
|
|
925
|
+
waf_global = _boto3_client('waf', endpoint_url=endpoint_url, region_name='us-east-1') # WAF global lives only in us-east-1
|
|
926
|
+
issues = []
|
|
927
|
+
|
|
928
|
+
try:
|
|
929
|
+
regional_web_acls = waf_regional.list_web_acls()['WebACLs']
|
|
930
|
+
for acl in regional_web_acls:
|
|
931
|
+
rules = waf_regional.get_web_acl(WebACLId=acl['WebACLId'])['WebACL']['Rules']
|
|
932
|
+
if not rules:
|
|
933
|
+
issues.append({
|
|
934
|
+
'WebACLId': acl['WebACLId'],
|
|
935
|
+
'Name': acl['Name'],
|
|
936
|
+
'Type': 'Regional',
|
|
937
|
+
'Region': region,
|
|
938
|
+
'Issue': 'No rules in Web ACL',
|
|
939
|
+
'Recommendation': 'Add rules to the regional Web ACL to protect against common web exploits.'
|
|
940
|
+
})
|
|
941
|
+
except ClientError as e:
|
|
942
|
+
if e.response['Error']['Code'] == 'AccessDeniedException':
|
|
943
|
+
logger.error(f"No access to WAF in region {region}. Skipping.")
|
|
944
|
+
else:
|
|
945
|
+
logger.error(f"Error accessing regional WAF in {region}: {str(e)}")
|
|
946
|
+
|
|
947
|
+
try:
|
|
948
|
+
global_web_acls = waf_global.list_web_acls()['WebACLs']
|
|
949
|
+
for acl in global_web_acls:
|
|
950
|
+
rules = waf_global.get_web_acl(WebACLId=acl['WebACLId'])['WebACL']['Rules']
|
|
951
|
+
if not rules:
|
|
952
|
+
issues.append({
|
|
953
|
+
'WebACLId': acl['WebACLId'],
|
|
954
|
+
'Name': acl['Name'],
|
|
955
|
+
'Type': 'Global',
|
|
956
|
+
'Issue': 'No rules in Web ACL',
|
|
957
|
+
'Recommendation': 'Add rules to the global Web ACL to protect against common web exploits.'
|
|
958
|
+
})
|
|
959
|
+
except ClientError as e:
|
|
960
|
+
logger.error(f"Error accessing global WAF: {str(e)}")
|
|
961
|
+
|
|
962
|
+
return issues
|
|
963
|
+
|
|
964
|
+
def analyze_cloudfront(endpoint_url=None):
|
|
965
|
+
cf = _boto3_client('cloudfront', endpoint_url=endpoint_url)
|
|
966
|
+
distributions = cf.list_distributions()['DistributionList']['Items'] if 'Items' in cf.list_distributions()['DistributionList'] else []
|
|
967
|
+
vulnerable_distributions = []
|
|
968
|
+
|
|
969
|
+
for dist in distributions:
|
|
970
|
+
if not dist['ViewerCertificate'].get('CloudFrontDefaultCertificate') and not dist['ViewerCertificate'].get('ACMCertificateArn'):
|
|
971
|
+
vulnerable_distributions.append({
|
|
972
|
+
'DistributionId': dist['Id'],
|
|
973
|
+
'DomainName': dist['DomainName'],
|
|
974
|
+
'Issue': 'No SSL/TLS Certificate',
|
|
975
|
+
'Recommendation': 'Configure a custom SSL/TLS certificate or use the CloudFront default certificate.'
|
|
976
|
+
})
|
|
977
|
+
|
|
978
|
+
if not dist['WebACLId']:
|
|
979
|
+
vulnerable_distributions.append({
|
|
980
|
+
'DistributionId': dist['Id'],
|
|
981
|
+
'DomainName': dist['DomainName'],
|
|
982
|
+
'Issue': 'No Web ACL associated',
|
|
983
|
+
'Recommendation': 'With no Web ACL, this distribution has no layer-7 filtering in front of it - common exploit patterns (SQLi/XSS payloads, known bad IPs, rate-abuse) reach the origin unfiltered. Associate a WAF Web ACL to filter these before they hit the origin.'
|
|
984
|
+
})
|
|
985
|
+
|
|
986
|
+
return vulnerable_distributions
|
|
987
|
+
|
|
988
|
+
def analyze_guardduty(region, endpoint_url=None):
|
|
989
|
+
gd = _boto3_client('guardduty', endpoint_url=endpoint_url, region_name=region)
|
|
990
|
+
detectors = gd.list_detectors()['DetectorIds']
|
|
991
|
+
issues = []
|
|
992
|
+
|
|
993
|
+
for detector_id in detectors:
|
|
994
|
+
detector = gd.get_detector(DetectorId=detector_id)
|
|
995
|
+
if not detector['Status'] == 'ENABLED':
|
|
996
|
+
issues.append({
|
|
997
|
+
'DetectorId': detector_id,
|
|
998
|
+
'Issue': 'GuardDuty Disabled',
|
|
999
|
+
'Recommendation': 'With GuardDuty disabled, this region has no automated detection for compromised credentials, reconnaissance, or known-malicious IP/domain activity against this account - an active intrusion would go unnoticed until it surfaces some other way. Enable GuardDuty in this region.'
|
|
1000
|
+
})
|
|
1001
|
+
|
|
1002
|
+
findings = gd.list_findings(DetectorId=detector_id, FindingCriteria={'Criterion': {'severity': {'Gte': 7}}})
|
|
1003
|
+
if findings['FindingIds']:
|
|
1004
|
+
issues.append({
|
|
1005
|
+
'DetectorId': detector_id,
|
|
1006
|
+
'Issue': f"{len(findings['FindingIds'])} High Severity Findings",
|
|
1007
|
+
'Recommendation': 'Investigate and address high severity GuardDuty findings.'
|
|
1008
|
+
})
|
|
1009
|
+
|
|
1010
|
+
return issues
|
|
1011
|
+
def _verify_credentials(endpoint_url=None):
|
|
1012
|
+
"""Raise immediately if boto3 cannot authenticate at all."""
|
|
1013
|
+
sts = _boto3_client('sts', endpoint_url=endpoint_url, region_name='us-east-1')
|
|
1014
|
+
try:
|
|
1015
|
+
sts.get_caller_identity()
|
|
1016
|
+
except NoCredentialsError as e:
|
|
1017
|
+
raise RuntimeError(
|
|
1018
|
+
"No AWS credentials found (checked ~/.aws/credentials, env vars, "
|
|
1019
|
+
"IAM role). Run `aws configure` or set AWS_ACCESS_KEY_ID / "
|
|
1020
|
+
"AWS_SECRET_ACCESS_KEY, then re-run."
|
|
1021
|
+
) from e
|
|
1022
|
+
except EndpointConnectionError as e:
|
|
1023
|
+
raise RuntimeError(f"Could not reach the AWS STS endpoint: {e}") from e
|
|
1024
|
+
except ClientError as e:
|
|
1025
|
+
code = e.response.get('Error', {}).get('Code', 'Unknown')
|
|
1026
|
+
raise RuntimeError(
|
|
1027
|
+
f"AWS rejected these credentials ({code}: "
|
|
1028
|
+
f"{e.response.get('Error', {}).get('Message', str(e))}). "
|
|
1029
|
+
f"They may be expired, revoked, or malformed - run `aws sts "
|
|
1030
|
+
f"get-caller-identity` yourself to confirm, then re-run."
|
|
1031
|
+
) from e
|
|
1032
|
+
|
|
1033
|
+
|
|
1034
|
+
def analyze_aws_security(credentials, region=None, endpoint_url=None):
|
|
1035
|
+
"""Run every posture check and return the report; credentials={} or None uses boto3's ambient chain."""
|
|
1036
|
+
original_env = {}
|
|
1037
|
+
env_keys = ['AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', 'AWS_SESSION_TOKEN']
|
|
1038
|
+
for key in env_keys:
|
|
1039
|
+
original_env[key] = os.environ.get(key)
|
|
1040
|
+
|
|
1041
|
+
has_explicit_creds = bool(credentials and credentials.get('aws_access_key_id'))
|
|
1042
|
+
|
|
1043
|
+
try:
|
|
1044
|
+
# Only set env vars when credentials are passed explicitly: writing
|
|
1045
|
+
# empty strings would clobber an ambient aws configure credential.
|
|
1046
|
+
if has_explicit_creds:
|
|
1047
|
+
os.environ['AWS_ACCESS_KEY_ID'] = credentials.get('aws_access_key_id', '')
|
|
1048
|
+
os.environ['AWS_SECRET_ACCESS_KEY'] = credentials.get('aws_secret_access_key', '')
|
|
1049
|
+
if 'aws_session_token' in credentials:
|
|
1050
|
+
os.environ['AWS_SESSION_TOKEN'] = credentials['aws_session_token']
|
|
1051
|
+
|
|
1052
|
+
logger.info("Starting AWS Security Analysis (programmatic)...")
|
|
1053
|
+
|
|
1054
|
+
# Deliberately not wrapped: an auth failure must propagate instead of
|
|
1055
|
+
# letting the per-check handlers below report an empty, clean scan.
|
|
1056
|
+
_verify_credentials(endpoint_url=endpoint_url)
|
|
1057
|
+
|
|
1058
|
+
all_vulnerable_groups = []
|
|
1059
|
+
all_vulnerable_vpcs = []
|
|
1060
|
+
all_vulnerable_rds = []
|
|
1061
|
+
all_vulnerable_lambdas = []
|
|
1062
|
+
all_vulnerable_ecs = []
|
|
1063
|
+
all_vulnerable_eb = []
|
|
1064
|
+
all_vulnerable_apis = []
|
|
1065
|
+
all_vulnerable_dynamodb = []
|
|
1066
|
+
all_gd_issues = []
|
|
1067
|
+
all_vulnerable_eks = []
|
|
1068
|
+
all_vulnerable_redshift = []
|
|
1069
|
+
all_vulnerable_secrets = []
|
|
1070
|
+
all_vulnerable_sqs = []
|
|
1071
|
+
all_vulnerable_sns = []
|
|
1072
|
+
all_vulnerable_glue_jobs = []
|
|
1073
|
+
all_vulnerable_emr_clusters = []
|
|
1074
|
+
all_vulnerable_kms_keys = []
|
|
1075
|
+
all_config_issues = []
|
|
1076
|
+
all_cloudtrail_issues = []
|
|
1077
|
+
all_cloudwatch_issues = []
|
|
1078
|
+
all_waf_issues = []
|
|
1079
|
+
|
|
1080
|
+
try:
|
|
1081
|
+
if region:
|
|
1082
|
+
regions = [region]
|
|
1083
|
+
else:
|
|
1084
|
+
regions = get_all_regions(endpoint_url=endpoint_url)
|
|
1085
|
+
logger.info(f"Scanning {len(regions)} region(s)")
|
|
1086
|
+
except Exception as e:
|
|
1087
|
+
logger.error(f"Error getting regions: {str(e)}")
|
|
1088
|
+
regions = []
|
|
1089
|
+
|
|
1090
|
+
for reg in regions:
|
|
1091
|
+
logger.info(f"Analyzing region: {reg}")
|
|
1092
|
+
try:
|
|
1093
|
+
all_vulnerable_groups.extend(analyze_security_groups(reg, endpoint_url=endpoint_url))
|
|
1094
|
+
all_vulnerable_vpcs.extend(analyze_vpc_configuration(reg, endpoint_url=endpoint_url))
|
|
1095
|
+
all_vulnerable_rds.extend(analyze_rds_instances(reg, endpoint_url=endpoint_url))
|
|
1096
|
+
all_vulnerable_lambdas.extend(analyze_lambda_functions(reg, endpoint_url=endpoint_url))
|
|
1097
|
+
all_vulnerable_ecs.extend(analyze_ecs_clusters(reg, endpoint_url=endpoint_url))
|
|
1098
|
+
all_vulnerable_eb.extend(analyze_elastic_beanstalk(reg, endpoint_url=endpoint_url))
|
|
1099
|
+
all_vulnerable_apis.extend(analyze_api_gateway(reg, endpoint_url=endpoint_url))
|
|
1100
|
+
all_vulnerable_dynamodb.extend(analyze_dynamodb(reg, endpoint_url=endpoint_url))
|
|
1101
|
+
all_gd_issues.extend(analyze_guardduty(reg, endpoint_url=endpoint_url))
|
|
1102
|
+
all_vulnerable_eks.extend(analyze_eks(reg, endpoint_url=endpoint_url))
|
|
1103
|
+
all_vulnerable_redshift.extend(analyze_redshift(reg, endpoint_url=endpoint_url))
|
|
1104
|
+
all_vulnerable_secrets.extend(analyze_secrets_manager(reg, endpoint_url=endpoint_url))
|
|
1105
|
+
all_vulnerable_sqs.extend(analyze_sqs(reg, endpoint_url=endpoint_url))
|
|
1106
|
+
all_vulnerable_sns.extend(analyze_sns(reg, endpoint_url=endpoint_url))
|
|
1107
|
+
all_vulnerable_glue_jobs.extend(analyze_glue(reg, endpoint_url=endpoint_url))
|
|
1108
|
+
all_vulnerable_emr_clusters.extend(analyze_emr(reg, endpoint_url=endpoint_url))
|
|
1109
|
+
all_vulnerable_kms_keys.extend(analyze_kms(reg, endpoint_url=endpoint_url))
|
|
1110
|
+
all_waf_issues.extend(analyze_waf(reg, endpoint_url=endpoint_url))
|
|
1111
|
+
except Exception as e:
|
|
1112
|
+
logger.error(f"Error analyzing region {reg}: {str(e)}")
|
|
1113
|
+
|
|
1114
|
+
# Account-scoped checks: run once against the primary region only.
|
|
1115
|
+
primary_region = regions[0] if regions else (region or 'us-east-1')
|
|
1116
|
+
try:
|
|
1117
|
+
all_cloudtrail_issues.extend(
|
|
1118
|
+
analyze_cloudtrail(primary_region, endpoint_url=endpoint_url))
|
|
1119
|
+
except Exception as e:
|
|
1120
|
+
logger.error(f"Error analyzing CloudTrail: {str(e)}")
|
|
1121
|
+
try:
|
|
1122
|
+
all_config_issues.extend(
|
|
1123
|
+
analyze_config(primary_region, endpoint_url=endpoint_url))
|
|
1124
|
+
except Exception as e:
|
|
1125
|
+
logger.error(f"Error analyzing Config: {str(e)}")
|
|
1126
|
+
try:
|
|
1127
|
+
all_cloudwatch_issues.extend(
|
|
1128
|
+
analyze_cloudwatch(primary_region, endpoint_url=endpoint_url))
|
|
1129
|
+
except Exception as e:
|
|
1130
|
+
logger.error(f"Error analyzing CloudWatch: {str(e)}")
|
|
1131
|
+
|
|
1132
|
+
try:
|
|
1133
|
+
logger.info("Analyzing S3 Buckets...")
|
|
1134
|
+
vulnerable_buckets = analyze_s3_buckets(endpoint_url=endpoint_url)
|
|
1135
|
+
except Exception as e:
|
|
1136
|
+
logger.error(f"Error analyzing S3 buckets: {str(e)}")
|
|
1137
|
+
vulnerable_buckets = []
|
|
1138
|
+
|
|
1139
|
+
try:
|
|
1140
|
+
logger.info("Analyzing IAM Users for MFA...")
|
|
1141
|
+
users_without_mfa = analyze_iam_users(endpoint_url=endpoint_url)
|
|
1142
|
+
except Exception as e:
|
|
1143
|
+
logger.error(f"Error analyzing IAM users: {str(e)}")
|
|
1144
|
+
users_without_mfa = []
|
|
1145
|
+
|
|
1146
|
+
try:
|
|
1147
|
+
logger.info("Analyzing IAM Policies...")
|
|
1148
|
+
risky_policies = analyze_iam_policies(endpoint_url=endpoint_url)
|
|
1149
|
+
except Exception as e:
|
|
1150
|
+
logger.error(f"Error analyzing IAM policies: {str(e)}")
|
|
1151
|
+
risky_policies = []
|
|
1152
|
+
|
|
1153
|
+
try:
|
|
1154
|
+
logger.info("Analyzing CloudFront Distributions...")
|
|
1155
|
+
vulnerable_cf = analyze_cloudfront(endpoint_url=endpoint_url)
|
|
1156
|
+
except Exception as e:
|
|
1157
|
+
logger.error(f"Error analyzing CloudFront: {str(e)}")
|
|
1158
|
+
vulnerable_cf = []
|
|
1159
|
+
|
|
1160
|
+
try:
|
|
1161
|
+
logger.info("Checking CIS Compliance...")
|
|
1162
|
+
compliance_issues = check_cis_compliance(endpoint_url=endpoint_url)
|
|
1163
|
+
except Exception as e:
|
|
1164
|
+
logger.error(f"Error checking CIS compliance: {str(e)}")
|
|
1165
|
+
compliance_issues = []
|
|
1166
|
+
|
|
1167
|
+
logger.info("Generating report")
|
|
1168
|
+
report = generate_report(
|
|
1169
|
+
all_vulnerable_groups, vulnerable_buckets, users_without_mfa, risky_policies,
|
|
1170
|
+
all_vulnerable_vpcs, all_vulnerable_rds, all_vulnerable_lambdas, all_vulnerable_ecs,
|
|
1171
|
+
all_vulnerable_eb, all_vulnerable_apis, all_vulnerable_dynamodb, vulnerable_cf,
|
|
1172
|
+
all_gd_issues, all_waf_issues, all_vulnerable_eks, all_vulnerable_redshift,
|
|
1173
|
+
all_vulnerable_secrets, all_vulnerable_sqs, all_vulnerable_sns,
|
|
1174
|
+
all_vulnerable_glue_jobs, all_vulnerable_emr_clusters, compliance_issues,
|
|
1175
|
+
all_vulnerable_kms_keys, all_config_issues, all_cloudtrail_issues, all_cloudwatch_issues
|
|
1176
|
+
)
|
|
1177
|
+
|
|
1178
|
+
logger.info("AWS security analysis complete")
|
|
1179
|
+
return report
|
|
1180
|
+
|
|
1181
|
+
finally:
|
|
1182
|
+
for key in env_keys:
|
|
1183
|
+
if original_env[key] is None:
|
|
1184
|
+
os.environ.pop(key, None)
|
|
1185
|
+
else:
|
|
1186
|
+
os.environ[key] = original_env[key]
|
|
1187
|
+
|
|
1188
|
+
|