tgwrap 0.8.12__py3-none-any.whl → 0.11.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.
- tgwrap/analyze.py +62 -18
- tgwrap/cli.py +117 -25
- tgwrap/deploy.py +10 -3
- tgwrap/inspector-resources-template.yml +63 -0
- tgwrap/inspector.py +438 -0
- tgwrap/main.py +583 -126
- tgwrap/printer.py +3 -0
- {tgwrap-0.8.12.dist-info → tgwrap-0.11.2.dist-info}/METADATA +163 -6
- tgwrap-0.11.2.dist-info/RECORD +13 -0
- {tgwrap-0.8.12.dist-info → tgwrap-0.11.2.dist-info}/WHEEL +1 -1
- tgwrap-0.8.12.dist-info/RECORD +0 -11
- {tgwrap-0.8.12.dist-info → tgwrap-0.11.2.dist-info}/LICENSE +0 -0
- {tgwrap-0.8.12.dist-info → tgwrap-0.11.2.dist-info}/entry_points.txt +0 -0
tgwrap/inspector.py
ADDED
@@ -0,0 +1,438 @@
|
|
1
|
+
"""Verify a given Azure environment based on some verification config file"""
|
2
|
+
|
3
|
+
import os
|
4
|
+
import yaml
|
5
|
+
import requests
|
6
|
+
import json
|
7
|
+
import re
|
8
|
+
|
9
|
+
from typing import Tuple, Dict, List
|
10
|
+
from enum import Enum
|
11
|
+
from jinja2 import Template
|
12
|
+
|
13
|
+
from azure.identity import DefaultAzureCredential
|
14
|
+
from azure.core.exceptions import ClientAuthenticationError
|
15
|
+
from azure.mgmt.authorization import AuthorizationManagementClient
|
16
|
+
|
17
|
+
from .printer import Printer
|
18
|
+
|
19
|
+
class ResourceInspectionStatus(Enum):
|
20
|
+
OK = "Successfully inspected the resource"
|
21
|
+
NOK = "Resource does not have the right status"
|
22
|
+
NEX = "Resource does not exist"
|
23
|
+
NS = "Resource type is not supported"
|
24
|
+
|
25
|
+
class RoleAssignmentInspectionStatus(Enum):
|
26
|
+
OK = "Successfully inspected the role assignments"
|
27
|
+
NC = "Role assignments not checked"
|
28
|
+
NOK = "(Some) role assignments missing"
|
29
|
+
|
30
|
+
class AzureInspector():
|
31
|
+
_printer = None
|
32
|
+
_subscription_id = None
|
33
|
+
_domain = None
|
34
|
+
_substack = None
|
35
|
+
_stage = None
|
36
|
+
_config = {}
|
37
|
+
_credential = None
|
38
|
+
_client = None
|
39
|
+
_graph_token = None
|
40
|
+
_mngt_token = None
|
41
|
+
_role_definitions = {}
|
42
|
+
_result = {}
|
43
|
+
|
44
|
+
@property
|
45
|
+
def printer(self):
|
46
|
+
return self._printer
|
47
|
+
|
48
|
+
@property
|
49
|
+
def subscription_id(self):
|
50
|
+
return self._subscription_id
|
51
|
+
|
52
|
+
@property
|
53
|
+
def domain(self):
|
54
|
+
return self._domain
|
55
|
+
|
56
|
+
@property
|
57
|
+
def substack(self):
|
58
|
+
return self._substack
|
59
|
+
|
60
|
+
@property
|
61
|
+
def stage(self):
|
62
|
+
return self._stage
|
63
|
+
|
64
|
+
@property
|
65
|
+
def config(self):
|
66
|
+
return self._config
|
67
|
+
|
68
|
+
@property
|
69
|
+
def credential(self):
|
70
|
+
return self._credential
|
71
|
+
|
72
|
+
@property
|
73
|
+
def client(self):
|
74
|
+
return self._client
|
75
|
+
|
76
|
+
@property
|
77
|
+
def graph_token(self):
|
78
|
+
return self._graph_token
|
79
|
+
|
80
|
+
@property
|
81
|
+
def mngt_token(self):
|
82
|
+
return self._mngt_token
|
83
|
+
|
84
|
+
@property
|
85
|
+
def role_definitions(self):
|
86
|
+
return self._role_definitions
|
87
|
+
|
88
|
+
@property
|
89
|
+
def result(self):
|
90
|
+
return self._result
|
91
|
+
|
92
|
+
@result.setter
|
93
|
+
def result(self, value):
|
94
|
+
self._result = value
|
95
|
+
|
96
|
+
def __init__(self, subscription_id:str, domain:str, substack:str, stage:str, config_file:str, verbose:bool, managed_identity_client_id:str = None):
|
97
|
+
self._printer = Printer(verbose)
|
98
|
+
self._subscription_id = subscription_id
|
99
|
+
self._domain = domain
|
100
|
+
self._substack = substack
|
101
|
+
self._stage = stage
|
102
|
+
|
103
|
+
# load the config file
|
104
|
+
self._config = self._load_config(config_file)
|
105
|
+
|
106
|
+
# get a credential
|
107
|
+
self._credential = DefaultAzureCredential(
|
108
|
+
# if you are using a user-assigned identity, the client id must be specified here!
|
109
|
+
managed_identity_client_id = managed_identity_client_id,
|
110
|
+
)
|
111
|
+
|
112
|
+
self._client = AuthorizationManagementClient(
|
113
|
+
credential=self.credential,
|
114
|
+
subscription_id=self.subscription_id,
|
115
|
+
)
|
116
|
+
|
117
|
+
# Retrieve all role definitions
|
118
|
+
self.printer.verbose('Retrieve all role definitions')
|
119
|
+
role_definitions_iter = self.client.role_definitions.list(
|
120
|
+
scope=f'/subscriptions/{self.subscription_id}'
|
121
|
+
)
|
122
|
+
for rd in role_definitions_iter:
|
123
|
+
self._role_definitions[rd.role_name.lower()] = rd.name
|
124
|
+
|
125
|
+
def _get_value_from_dict(self, data:str, property:str) -> str:
|
126
|
+
"""Get a value from a dict with a string that indicates the hierarchy with a dot"""
|
127
|
+
|
128
|
+
keys = property.split('.')
|
129
|
+
value = data
|
130
|
+
for key in keys:
|
131
|
+
if value:
|
132
|
+
value = value.get(key)
|
133
|
+
|
134
|
+
return value
|
135
|
+
|
136
|
+
def _load_config(self, config_file:str) -> Dict:
|
137
|
+
with open(config_file, 'r') as file:
|
138
|
+
try:
|
139
|
+
data = yaml.safe_load(file)
|
140
|
+
return data
|
141
|
+
except yaml.YAMLError as exc:
|
142
|
+
self.printer.error(f"Error loading YAML file: {exc}")
|
143
|
+
return None
|
144
|
+
|
145
|
+
def _load_yaml_template(self, template_file:str, inputs:Dict) -> Dict:
|
146
|
+
with open(template_file, 'r') as file:
|
147
|
+
try:
|
148
|
+
template_content = file.read()
|
149
|
+
template = Template(template_content)
|
150
|
+
rendered_yaml = template.render(inputs)
|
151
|
+
|
152
|
+
data = yaml.safe_load(rendered_yaml)
|
153
|
+
return data
|
154
|
+
except yaml.YAMLError as exc:
|
155
|
+
self.printer.error(f"Error loading YAML template file: {exc}")
|
156
|
+
return None
|
157
|
+
|
158
|
+
def _invoke_api(self, url:str, token:str, method:str='get', data:Dict=None) -> Dict:
|
159
|
+
"""Invoke the Azure API"""
|
160
|
+
|
161
|
+
self.printer.verbose(f'Invoke {method.upper()} on {url}')
|
162
|
+
headers = {
|
163
|
+
'Authorization': f'Bearer {token}',
|
164
|
+
'Content-Type': 'application/json'
|
165
|
+
}
|
166
|
+
resp = None
|
167
|
+
try:
|
168
|
+
if method.lower() == 'get':
|
169
|
+
resp = requests.get(url, headers=headers)
|
170
|
+
elif method.lower() == 'post':
|
171
|
+
resp = requests.post(url, headers=headers, json=data)
|
172
|
+
elif method.lower() == 'delete':
|
173
|
+
resp = requests.delete(url, headers=headers)
|
174
|
+
else:
|
175
|
+
raise ValueError(f'Method {method} not recognised')
|
176
|
+
|
177
|
+
resp.raise_for_status()
|
178
|
+
return resp.json()
|
179
|
+
except requests.exceptions.HTTPError as e:
|
180
|
+
self.printer.verbose(f'Error occurred:\n{e.response.text}')
|
181
|
+
return None
|
182
|
+
|
183
|
+
def inspect(self):
|
184
|
+
try:
|
185
|
+
self._graph_token = self.credential.get_token('https://graph.microsoft.com/.default').token
|
186
|
+
self._mngt_token = self.credential.get_token('https://management.azure.com/.default').token
|
187
|
+
except ClientAuthenticationError as e:
|
188
|
+
self.printer.error(
|
189
|
+
f'Could not retrieve an azure token, are you logged in?',
|
190
|
+
print_line_before=True,
|
191
|
+
print_line_after=True,
|
192
|
+
)
|
193
|
+
raise (e)
|
194
|
+
|
195
|
+
self.result = {}
|
196
|
+
|
197
|
+
try:
|
198
|
+
# read the config
|
199
|
+
location_code = self.config['location'].get('code', 'westeurope')
|
200
|
+
location_full = self.config['location'].get('full', 'West Europe')
|
201
|
+
resources = self.config.get('resources', [])
|
202
|
+
groups = self.config.get('entra_id_groups', {})
|
203
|
+
|
204
|
+
# read the map that determines how to test a particular resource type
|
205
|
+
resource_types = self._load_yaml_template(
|
206
|
+
template_file=os.path.join(os.path.dirname(__file__), 'inspector-resources-template.yml'),
|
207
|
+
inputs={
|
208
|
+
'location_code': location_code,
|
209
|
+
'location_full': location_full,
|
210
|
+
},
|
211
|
+
)
|
212
|
+
|
213
|
+
# first check the groups, this will also enrich the map with the IDs of the actual groups
|
214
|
+
# build a list
|
215
|
+
groups_to_verify = []
|
216
|
+
for role, name in groups.items():
|
217
|
+
groups_to_verify.append({
|
218
|
+
'identifier': name.format(
|
219
|
+
domain=self.domain,
|
220
|
+
substack=self.substack,
|
221
|
+
stage=self.stage,
|
222
|
+
),
|
223
|
+
'type': 'entra_id_group',
|
224
|
+
'role': role,
|
225
|
+
})
|
226
|
+
# and verify
|
227
|
+
groups = self.inspect_resources(
|
228
|
+
resources=groups_to_verify,
|
229
|
+
resource_types=resource_types,
|
230
|
+
groups=groups,
|
231
|
+
)
|
232
|
+
|
233
|
+
# now inspect the resources
|
234
|
+
self.inspect_resources(
|
235
|
+
resources=resources,
|
236
|
+
resource_types=resource_types,
|
237
|
+
groups=groups,
|
238
|
+
)
|
239
|
+
|
240
|
+
return self.result
|
241
|
+
finally:
|
242
|
+
# ensure new messages are printed on a new line, as the progress indicator does not create a new line
|
243
|
+
self.printer.normal('')
|
244
|
+
|
245
|
+
def inspect_resources(self, resources:List, resource_types:Dict, groups:Dict) -> Dict:
|
246
|
+
for resource in resources:
|
247
|
+
self.printer.progress_indicator()
|
248
|
+
|
249
|
+
# get some identifiers from the config and replace with real values
|
250
|
+
identifier = resource['identifier'].format(
|
251
|
+
subscription_id=self.subscription_id,
|
252
|
+
domain=self.domain,
|
253
|
+
substack=self.substack,
|
254
|
+
stage=self.stage,
|
255
|
+
)
|
256
|
+
# it might be that we have an alternative id, as sometimes these are shortened because of length restrictions
|
257
|
+
alternative_ids = []
|
258
|
+
for id in resource.get('alternative_ids', []):
|
259
|
+
alternative_ids.append(
|
260
|
+
id.format(
|
261
|
+
subscription_id=self.subscription_id,
|
262
|
+
domain=self.domain,
|
263
|
+
substack=self.substack,
|
264
|
+
stage=self.stage,
|
265
|
+
)
|
266
|
+
)
|
267
|
+
|
268
|
+
resource_group = resource.get('resource_group', '').format(
|
269
|
+
domain=self.domain,
|
270
|
+
substack=self.substack,
|
271
|
+
stage=self.stage,
|
272
|
+
)
|
273
|
+
|
274
|
+
type = resource['type']
|
275
|
+
self.result[identifier] = {
|
276
|
+
"type": type,
|
277
|
+
}
|
278
|
+
this_result = self.result[identifier]
|
279
|
+
|
280
|
+
self.printer.verbose(f'Inspect {identifier} ({resource_group})')
|
281
|
+
|
282
|
+
# now we can start inspecting these
|
283
|
+
if type in resource_types:
|
284
|
+
resource_type = resource_types[type]
|
285
|
+
url = resource_type['url'].format(
|
286
|
+
subscription_id=self.subscription_id,
|
287
|
+
resource_group=resource_group,
|
288
|
+
name=identifier,
|
289
|
+
)
|
290
|
+
|
291
|
+
# which token to use?
|
292
|
+
graph_api = False
|
293
|
+
if 'graph.microsoft.com' in url:
|
294
|
+
graph_api = True
|
295
|
+
token = self.graph_token
|
296
|
+
elif 'management.azure.com' in url:
|
297
|
+
token = self.mngt_token
|
298
|
+
else:
|
299
|
+
self.printer.error(f'Do not have token for url: {url}')
|
300
|
+
break
|
301
|
+
|
302
|
+
resp = self._invoke_api(url=url, token=token)
|
303
|
+
|
304
|
+
# if we don't get anything back, try alternative ids (if we have some)
|
305
|
+
if (not resp or (graph_api and len(resp.get('value', [])) == 0)):
|
306
|
+
for id in alternative_ids:
|
307
|
+
resp = self._invoke_api(url=url, token=token)
|
308
|
+
url = resource_type['url'].format(
|
309
|
+
subscription_id=self.subscription_id,
|
310
|
+
resource_group=resource_group,
|
311
|
+
name=id,
|
312
|
+
)
|
313
|
+
resp = self._invoke_api(url=url, token=token)
|
314
|
+
|
315
|
+
# now check if we have something, we stop at first hit
|
316
|
+
if (not graph_api and resp) or (graph_api and len(resp.get('value', [])) > 0):
|
317
|
+
identifier = id
|
318
|
+
break
|
319
|
+
|
320
|
+
if not resp or (graph_api and len(resp.get('value', [])) == 0):
|
321
|
+
status = ResourceInspectionStatus.NEX.name
|
322
|
+
if resource_group:
|
323
|
+
msg = f"Resource {identifier} (in {resource_group}) of type {type} not found"
|
324
|
+
else:
|
325
|
+
msg = f"Resource {identifier} of type {type} not found"
|
326
|
+
ra_status = RoleAssignmentInspectionStatus.NC.name
|
327
|
+
ra_msg = ''
|
328
|
+
else:
|
329
|
+
self.printer.verbose(json.dumps(resp, indent=2))
|
330
|
+
|
331
|
+
status = ResourceInspectionStatus.OK.name # we're innocent until proven otherwise
|
332
|
+
msg = ""
|
333
|
+
for property, expected_value in resource_type['properties'].items():
|
334
|
+
property_value = self._get_value_from_dict(data=resp, property=property)
|
335
|
+
if isinstance(property_value, str) and not re.match(expected_value, property_value):
|
336
|
+
status = ResourceInspectionStatus.NOK.name
|
337
|
+
msg = msg + f"Property {property} has value {property_value}, expected regex {expected_value}"
|
338
|
+
elif isinstance(property_value, bool) and not property_value:
|
339
|
+
status = ResourceInspectionStatus.NOK.name
|
340
|
+
msg = msg + f"Property {property} has value {property_value}, expected boolean {expected_value}"
|
341
|
+
|
342
|
+
# set the default status that we haven't check the role assignments
|
343
|
+
ra_status = RoleAssignmentInspectionStatus.NC.name
|
344
|
+
ra_msg = 'Role assignments not checked'
|
345
|
+
|
346
|
+
# if this is an entra id group, we store the object id
|
347
|
+
if type == 'entra_id_group':
|
348
|
+
role = resource['role']
|
349
|
+
id = resp['value'][0]['id']
|
350
|
+
groups[role] = {
|
351
|
+
'id': id,
|
352
|
+
'name': identifier,
|
353
|
+
}
|
354
|
+
elif 'role_assignments' in resource:
|
355
|
+
|
356
|
+
ra_status, ra_msg = self.check_role_assignments(
|
357
|
+
resource=resource,
|
358
|
+
groups=groups,
|
359
|
+
url=url,
|
360
|
+
)
|
361
|
+
|
362
|
+
if not msg:
|
363
|
+
if resource_group:
|
364
|
+
msg = f"Resource {identifier} (in {resource_group}) of type {type} OK"
|
365
|
+
else:
|
366
|
+
msg = f"Resource {identifier} of type {type} OK"
|
367
|
+
if not ra_msg and ra_status == RoleAssignmentInspectionStatus.OK.name:
|
368
|
+
ra_msg = f"Role Assignments for {identifier} (in {resource_group}) of type {type} are OK"
|
369
|
+
|
370
|
+
this_result["inspect_status_code"] = status
|
371
|
+
this_result["inspect_status"] = ResourceInspectionStatus[status].value
|
372
|
+
this_result["inspect_message"] = msg
|
373
|
+
|
374
|
+
if not type == 'entra_id_group':
|
375
|
+
this_result["rbac_assignment_status_code"] = ra_status
|
376
|
+
this_result["rbac_assignment_status"] = RoleAssignmentInspectionStatus[ra_status].value
|
377
|
+
if ra_msg:
|
378
|
+
this_result["rbac_assignment_message"] = ra_msg
|
379
|
+
else:
|
380
|
+
self.printer.error(f'\nResource type {type} is not configured in tgwrap!')
|
381
|
+
this_result["inspect_status_code"] = ResourceInspectionStatus.NS.name
|
382
|
+
this_result["inspect_status"] = ResourceInspectionStatus.NS.value
|
383
|
+
this_result["inspect_message"] = f'Resource type {type} is not supported'
|
384
|
+
|
385
|
+
# the groups dict is updated with the IDs of the actual groups
|
386
|
+
return groups
|
387
|
+
|
388
|
+
def check_role_assignments(self, resource:Dict, groups:Dict, url:str):
|
389
|
+
|
390
|
+
status = RoleAssignmentInspectionStatus.NC.name
|
391
|
+
msg = ''
|
392
|
+
|
393
|
+
# extract the scope from the url
|
394
|
+
base_url=url.split('?')[0].rstrip('/')
|
395
|
+
scope = base_url.replace('https://management.azure.com', '')
|
396
|
+
self.printer.verbose('\nGet permissions over scope: ', scope)
|
397
|
+
|
398
|
+
# first get the role assignments for each (unique) principals
|
399
|
+
principals = set()
|
400
|
+
for ra in resource['role_assignments']:
|
401
|
+
principals.update(ra.keys())
|
402
|
+
|
403
|
+
principals = list(principals)
|
404
|
+
principal_assignments = {} # here we collect all assignments for this scope per principal
|
405
|
+
for p in principals:
|
406
|
+
principal_assignments[p] = []
|
407
|
+
|
408
|
+
if not p in groups:
|
409
|
+
self.printer.error(f'\nCannot find the group {p} in groups: {groups}')
|
410
|
+
continue
|
411
|
+
elif not 'id' in groups[p]:
|
412
|
+
self.printer.error(f'\nCannot find the id for group {p}: {groups[p]}')
|
413
|
+
continue
|
414
|
+
|
415
|
+
principal_id = groups[p]['id']
|
416
|
+
assignments = self.client.role_assignments.list_for_scope(
|
417
|
+
scope=scope,
|
418
|
+
filter=f"principalId eq '{principal_id}'",
|
419
|
+
)
|
420
|
+
|
421
|
+
# get the IDs of all roles assigned for the given scope
|
422
|
+
for a in assignments:
|
423
|
+
principal_assignments[p].append(a.role_definition_id.split('/')[-1]) # we only want the guid of the role definition
|
424
|
+
|
425
|
+
# assume the role assignments will be fine
|
426
|
+
status = RoleAssignmentInspectionStatus.OK.name
|
427
|
+
for role in resource['role_assignments']:
|
428
|
+
principal = list(role.keys())[0]
|
429
|
+
role = list(role.values())[0]
|
430
|
+
|
431
|
+
if role.lower() not in self.role_definitions:
|
432
|
+
raise ValueError(f"Role: '{role}' is not found.")
|
433
|
+
|
434
|
+
if (self.role_definitions[role.lower()] not in principal_assignments[principal]):
|
435
|
+
status = RoleAssignmentInspectionStatus.NOK.name
|
436
|
+
msg = msg + f'Principal {principal} has NOT role {role} assigned; '
|
437
|
+
|
438
|
+
return status, msg
|