cartography 0.102.0rc1__py3-none-any.whl → 0.103.0rc1__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 cartography might be problematic. Click here for more details.
- cartography/__main__.py +1 -2
- cartography/_version.py +2 -2
- cartography/cli.py +302 -253
- cartography/client/core/tx.py +39 -18
- cartography/config.py +4 -0
- cartography/driftdetect/__main__.py +1 -2
- cartography/driftdetect/add_shortcut.py +10 -2
- cartography/driftdetect/cli.py +71 -75
- cartography/driftdetect/detect_deviations.py +7 -3
- cartography/driftdetect/get_states.py +20 -8
- cartography/driftdetect/model.py +5 -5
- cartography/driftdetect/serializers.py +8 -6
- cartography/driftdetect/storage.py +2 -2
- cartography/graph/cleanupbuilder.py +35 -15
- cartography/graph/job.py +46 -17
- cartography/graph/querybuilder.py +165 -80
- cartography/graph/statement.py +35 -26
- cartography/intel/analysis.py +4 -1
- cartography/intel/aws/__init__.py +114 -55
- cartography/intel/aws/apigateway.py +134 -63
- cartography/intel/aws/cloudtrail.py +127 -0
- cartography/intel/aws/config.py +56 -20
- cartography/intel/aws/dynamodb.py +108 -40
- cartography/intel/aws/ec2/__init__.py +2 -2
- cartography/intel/aws/ec2/auto_scaling_groups.py +181 -78
- cartography/intel/aws/ec2/elastic_ip_addresses.py +41 -13
- cartography/intel/aws/ec2/images.py +49 -20
- cartography/intel/aws/ec2/instances.py +234 -136
- cartography/intel/aws/ec2/internet_gateways.py +40 -11
- cartography/intel/aws/ec2/key_pairs.py +44 -20
- cartography/intel/aws/ec2/launch_templates.py +101 -59
- cartography/intel/aws/ec2/load_balancer_v2s.py +104 -39
- cartography/intel/aws/ec2/load_balancers.py +82 -42
- cartography/intel/aws/ec2/network_acls.py +89 -65
- cartography/intel/aws/ec2/network_interfaces.py +146 -87
- cartography/intel/aws/ec2/reserved_instances.py +45 -16
- cartography/intel/aws/ec2/route_tables.py +327 -0
- cartography/intel/aws/ec2/security_groups.py +71 -21
- cartography/intel/aws/ec2/snapshots.py +61 -22
- cartography/intel/aws/ec2/subnets.py +54 -18
- cartography/intel/aws/ec2/tgw.py +100 -34
- cartography/intel/aws/ec2/util.py +1 -1
- cartography/intel/aws/ec2/volumes.py +69 -41
- cartography/intel/aws/ec2/vpc.py +37 -12
- cartography/intel/aws/ec2/vpc_peerings.py +83 -24
- cartography/intel/aws/ecr.py +88 -32
- cartography/intel/aws/ecs.py +83 -47
- cartography/intel/aws/eks.py +55 -29
- cartography/intel/aws/elasticache.py +42 -18
- cartography/intel/aws/elasticsearch.py +57 -20
- cartography/intel/aws/emr.py +61 -23
- cartography/intel/aws/iam.py +401 -145
- cartography/intel/aws/iam_instance_profiles.py +22 -22
- cartography/intel/aws/identitycenter.py +71 -37
- cartography/intel/aws/inspector.py +159 -89
- cartography/intel/aws/kms.py +92 -38
- cartography/intel/aws/lambda_function.py +103 -34
- cartography/intel/aws/organizations.py +30 -10
- cartography/intel/aws/permission_relationships.py +133 -51
- cartography/intel/aws/rds.py +249 -85
- cartography/intel/aws/redshift.py +107 -46
- cartography/intel/aws/resourcegroupstaggingapi.py +120 -66
- cartography/intel/aws/resources.py +53 -44
- cartography/intel/aws/route53.py +108 -61
- cartography/intel/aws/s3.py +168 -83
- cartography/intel/aws/s3accountpublicaccessblock.py +157 -0
- cartography/intel/aws/secretsmanager.py +24 -12
- cartography/intel/aws/securityhub.py +20 -9
- cartography/intel/aws/sns.py +166 -0
- cartography/intel/aws/sqs.py +60 -28
- cartography/intel/aws/ssm.py +70 -30
- cartography/intel/aws/util/arns.py +7 -7
- cartography/intel/aws/util/common.py +31 -4
- cartography/intel/azure/__init__.py +78 -19
- cartography/intel/azure/compute.py +101 -27
- cartography/intel/azure/cosmosdb.py +496 -170
- cartography/intel/azure/sql.py +296 -105
- cartography/intel/azure/storage.py +322 -113
- cartography/intel/azure/subscription.py +39 -23
- cartography/intel/azure/tenant.py +13 -4
- cartography/intel/azure/util/credentials.py +95 -55
- cartography/intel/bigfix/__init__.py +2 -2
- cartography/intel/bigfix/computers.py +93 -65
- cartography/intel/create_indexes.py +3 -2
- cartography/intel/crowdstrike/__init__.py +11 -9
- cartography/intel/crowdstrike/endpoints.py +5 -1
- cartography/intel/crowdstrike/spotlight.py +8 -3
- cartography/intel/cve/__init__.py +46 -13
- cartography/intel/cve/feed.py +48 -12
- cartography/intel/digitalocean/__init__.py +22 -13
- cartography/intel/digitalocean/compute.py +75 -108
- cartography/intel/digitalocean/management.py +44 -80
- cartography/intel/digitalocean/platform.py +48 -43
- cartography/intel/dns.py +36 -10
- cartography/intel/duo/__init__.py +21 -16
- cartography/intel/duo/api_host.py +14 -9
- cartography/intel/duo/endpoints.py +50 -45
- cartography/intel/duo/groups.py +18 -14
- cartography/intel/duo/phones.py +37 -34
- cartography/intel/duo/tokens.py +26 -23
- cartography/intel/duo/users.py +54 -50
- cartography/intel/duo/web_authn_credentials.py +30 -25
- cartography/intel/entra/__init__.py +25 -7
- cartography/intel/entra/ou.py +112 -0
- cartography/intel/entra/users.py +69 -63
- cartography/intel/gcp/__init__.py +185 -49
- cartography/intel/gcp/compute.py +418 -231
- cartography/intel/gcp/crm.py +96 -43
- cartography/intel/gcp/dns.py +60 -19
- cartography/intel/gcp/gke.py +72 -38
- cartography/intel/gcp/iam.py +61 -41
- cartography/intel/gcp/storage.py +84 -55
- cartography/intel/github/__init__.py +13 -11
- cartography/intel/github/repos.py +270 -137
- cartography/intel/github/teams.py +170 -88
- cartography/intel/github/users.py +70 -39
- cartography/intel/github/util.py +36 -34
- cartography/intel/gsuite/__init__.py +47 -26
- cartography/intel/gsuite/api.py +73 -30
- cartography/intel/jamf/__init__.py +19 -1
- cartography/intel/jamf/computers.py +30 -7
- cartography/intel/jamf/util.py +7 -2
- cartography/intel/kandji/__init__.py +6 -3
- cartography/intel/kandji/devices.py +14 -8
- cartography/intel/kubernetes/namespaces.py +7 -4
- cartography/intel/kubernetes/pods.py +7 -4
- cartography/intel/kubernetes/services.py +8 -4
- cartography/intel/lastpass/__init__.py +2 -2
- cartography/intel/lastpass/users.py +23 -12
- cartography/intel/oci/__init__.py +44 -11
- cartography/intel/oci/iam.py +134 -38
- cartography/intel/oci/organizations.py +13 -6
- cartography/intel/oci/utils.py +43 -20
- cartography/intel/okta/__init__.py +66 -15
- cartography/intel/okta/applications.py +42 -20
- cartography/intel/okta/awssaml.py +93 -33
- cartography/intel/okta/factors.py +16 -4
- cartography/intel/okta/groups.py +56 -29
- cartography/intel/okta/organization.py +5 -1
- cartography/intel/okta/origins.py +6 -2
- cartography/intel/okta/roles.py +15 -5
- cartography/intel/okta/users.py +20 -8
- cartography/intel/okta/utils.py +6 -4
- cartography/intel/pagerduty/__init__.py +8 -7
- cartography/intel/pagerduty/escalation_policies.py +18 -6
- cartography/intel/pagerduty/schedules.py +12 -4
- cartography/intel/pagerduty/services.py +11 -4
- cartography/intel/pagerduty/teams.py +8 -3
- cartography/intel/pagerduty/users.py +3 -1
- cartography/intel/pagerduty/vendors.py +3 -1
- cartography/intel/semgrep/__init__.py +24 -6
- cartography/intel/semgrep/dependencies.py +50 -28
- cartography/intel/semgrep/deployment.py +3 -1
- cartography/intel/semgrep/findings.py +42 -18
- cartography/intel/snipeit/__init__.py +17 -3
- cartography/intel/snipeit/asset.py +12 -6
- cartography/intel/snipeit/user.py +8 -5
- cartography/intel/snipeit/util.py +9 -4
- cartography/models/aws/apigateway.py +21 -17
- cartography/models/aws/apigatewaycertificate.py +28 -22
- cartography/models/aws/apigatewayresource.py +28 -20
- cartography/models/aws/apigatewaystage.py +33 -25
- cartography/models/aws/cloudtrail/__init__.py +0 -0
- cartography/models/aws/cloudtrail/trail.py +61 -0
- cartography/models/aws/dynamodb/gsi.py +30 -22
- cartography/models/aws/dynamodb/tables.py +25 -17
- cartography/models/aws/ec2/auto_scaling_groups.py +102 -82
- cartography/models/aws/ec2/images.py +36 -34
- cartography/models/aws/ec2/instances.py +51 -45
- cartography/models/aws/ec2/keypair.py +21 -16
- cartography/models/aws/ec2/keypair_instance.py +28 -21
- cartography/models/aws/ec2/launch_configurations.py +30 -26
- cartography/models/aws/ec2/launch_template_versions.py +48 -38
- cartography/models/aws/ec2/launch_templates.py +21 -17
- cartography/models/aws/ec2/load_balancer_listeners.py +27 -23
- cartography/models/aws/ec2/load_balancers.py +47 -37
- cartography/models/aws/ec2/network_acl_rules.py +38 -30
- cartography/models/aws/ec2/network_acls.py +38 -29
- cartography/models/aws/ec2/networkinterface_instance.py +52 -39
- cartography/models/aws/ec2/networkinterfaces.py +53 -37
- cartography/models/aws/ec2/privateip_networkinterface.py +32 -22
- cartography/models/aws/ec2/reservations.py +18 -14
- cartography/models/aws/ec2/route_table_associations.py +97 -0
- cartography/models/aws/ec2/route_tables.py +128 -0
- cartography/models/aws/ec2/routes.py +85 -0
- cartography/models/aws/ec2/securitygroup_instance.py +29 -20
- cartography/models/aws/ec2/securitygroup_networkinterface.py +24 -15
- cartography/models/aws/ec2/subnet_instance.py +24 -19
- cartography/models/aws/ec2/subnet_networkinterface.py +40 -31
- cartography/models/aws/ec2/volumes.py +47 -40
- cartography/models/aws/eks/clusters.py +23 -21
- cartography/models/aws/emr.py +32 -30
- cartography/models/aws/iam/instanceprofile.py +33 -24
- cartography/models/aws/identitycenter/awsidentitycenter.py +18 -14
- cartography/models/aws/identitycenter/awspermissionset.py +37 -29
- cartography/models/aws/identitycenter/awsssouser.py +23 -21
- cartography/models/aws/inspector/findings.py +77 -65
- cartography/models/aws/inspector/packages.py +35 -29
- cartography/models/aws/s3/__init__.py +0 -0
- cartography/models/aws/s3/account_public_access_block.py +51 -0
- cartography/models/aws/sns/__init__.py +0 -0
- cartography/models/aws/sns/topic.py +50 -0
- cartography/models/aws/ssm/instance_information.py +51 -39
- cartography/models/aws/ssm/instance_patch.py +32 -26
- cartography/models/bigfix/bigfix_computer.py +42 -38
- cartography/models/bigfix/bigfix_root.py +3 -3
- cartography/models/core/common.py +12 -10
- cartography/models/core/nodes.py +5 -2
- cartography/models/core/relationships.py +14 -6
- cartography/models/crowdstrike/hosts.py +37 -35
- cartography/models/cve/cve.py +34 -32
- cartography/models/cve/cve_feed.py +6 -6
- cartography/models/digitalocean/__init__.py +0 -0
- cartography/models/digitalocean/account.py +21 -0
- cartography/models/digitalocean/droplet.py +56 -0
- cartography/models/digitalocean/project.py +48 -0
- cartography/models/duo/api_host.py +3 -3
- cartography/models/duo/endpoint.py +43 -41
- cartography/models/duo/group.py +14 -14
- cartography/models/duo/phone.py +27 -27
- cartography/models/duo/token.py +16 -16
- cartography/models/duo/user.py +46 -44
- cartography/models/duo/web_authn_credential.py +27 -19
- cartography/models/entra/ou.py +48 -0
- cartography/models/entra/tenant.py +24 -18
- cartography/models/entra/user.py +64 -48
- cartography/models/gcp/iam.py +23 -23
- cartography/models/github/orgs.py +5 -4
- cartography/models/github/teams.py +37 -31
- cartography/models/github/users.py +34 -23
- cartography/models/kandji/device.py +22 -16
- cartography/models/kandji/tenant.py +6 -4
- cartography/models/lastpass/tenant.py +3 -3
- cartography/models/lastpass/user.py +32 -28
- cartography/models/semgrep/dependencies.py +36 -24
- cartography/models/semgrep/deployment.py +5 -5
- cartography/models/semgrep/findings.py +58 -42
- cartography/models/semgrep/locations.py +27 -21
- cartography/models/snipeit/asset.py +30 -21
- cartography/models/snipeit/tenant.py +6 -4
- cartography/models/snipeit/user.py +19 -12
- cartography/stats.py +3 -3
- cartography/sync.py +107 -31
- cartography/util.py +84 -62
- {cartography-0.102.0rc1.dist-info → cartography-0.103.0rc1.dist-info}/METADATA +3 -14
- cartography-0.103.0rc1.dist-info/RECORD +396 -0
- {cartography-0.102.0rc1.dist-info → cartography-0.103.0rc1.dist-info}/WHEEL +1 -1
- cartography-0.102.0rc1.dist-info/RECORD +0 -377
- {cartography-0.102.0rc1.dist-info → cartography-0.103.0rc1.dist-info}/entry_points.txt +0 -0
- {cartography-0.102.0rc1.dist-info → cartography-0.103.0rc1.dist-info}/licenses/LICENSE +0 -0
- {cartography-0.102.0rc1.dist-info → cartography-0.103.0rc1.dist-info}/top_level.txt +0 -0
|
@@ -15,7 +15,6 @@ from cartography.intel.okta.utils import create_api_client
|
|
|
15
15
|
from cartography.intel.okta.utils import is_last_page
|
|
16
16
|
from cartography.util import timeit
|
|
17
17
|
|
|
18
|
-
|
|
19
18
|
logger = logging.getLogger(__name__)
|
|
20
19
|
|
|
21
20
|
|
|
@@ -36,9 +35,9 @@ def _get_okta_applications(api_client: ApiClient) -> List[Dict]:
|
|
|
36
35
|
paged_response = api_client.get(next_url)
|
|
37
36
|
else:
|
|
38
37
|
params = {
|
|
39
|
-
|
|
38
|
+
"limit": 500,
|
|
40
39
|
}
|
|
41
|
-
paged_response = api_client.get_path(
|
|
40
|
+
paged_response = api_client.get_path("/", params)
|
|
42
41
|
except OktaError as okta_error:
|
|
43
42
|
logger.debug(f"Got error while listing applications {okta_error}")
|
|
44
43
|
break
|
|
@@ -73,11 +72,13 @@ def _get_application_assigned_users(api_client: ApiClient, app_id: str) -> List[
|
|
|
73
72
|
paged_response = api_client.get(next_url)
|
|
74
73
|
else:
|
|
75
74
|
params = {
|
|
76
|
-
|
|
75
|
+
"limit": 500,
|
|
77
76
|
}
|
|
78
|
-
paged_response = api_client.get_path(f
|
|
77
|
+
paged_response = api_client.get_path(f"/{app_id}/users", params)
|
|
79
78
|
except OktaError as okta_error:
|
|
80
|
-
logger.debug(
|
|
79
|
+
logger.debug(
|
|
80
|
+
f"Got error while going through list application assigned users {okta_error}",
|
|
81
|
+
)
|
|
81
82
|
break
|
|
82
83
|
|
|
83
84
|
app_users.append(paged_response.text)
|
|
@@ -110,11 +111,13 @@ def _get_application_assigned_groups(api_client: ApiClient, app_id: str) -> List
|
|
|
110
111
|
paged_response = api_client.get(next_url)
|
|
111
112
|
else:
|
|
112
113
|
params = {
|
|
113
|
-
|
|
114
|
+
"limit": 500,
|
|
114
115
|
}
|
|
115
|
-
paged_response = api_client.get_path(f
|
|
116
|
+
paged_response = api_client.get_path(f"/{app_id}/groups", params)
|
|
116
117
|
except OktaError as okta_error:
|
|
117
|
-
logger.debug(
|
|
118
|
+
logger.debug(
|
|
119
|
+
f"Got error while going through list application assigned groups {okta_error}",
|
|
120
|
+
)
|
|
118
121
|
break
|
|
119
122
|
|
|
120
123
|
app_groups.append(paged_response.text)
|
|
@@ -130,7 +133,9 @@ def _get_application_assigned_groups(api_client: ApiClient, app_id: str) -> List
|
|
|
130
133
|
|
|
131
134
|
|
|
132
135
|
@timeit
|
|
133
|
-
def transform_application_assigned_users_list(
|
|
136
|
+
def transform_application_assigned_users_list(
|
|
137
|
+
assigned_user_list: List[str],
|
|
138
|
+
) -> List[str]:
|
|
134
139
|
"""
|
|
135
140
|
Transform application users Okta data
|
|
136
141
|
:param assigned_user_list: Okta data on assigned users
|
|
@@ -161,7 +166,9 @@ def transform_application_assigned_users(json_app_data: str) -> List[str]:
|
|
|
161
166
|
|
|
162
167
|
|
|
163
168
|
@timeit
|
|
164
|
-
def transform_application_assigned_groups_list(
|
|
169
|
+
def transform_application_assigned_groups_list(
|
|
170
|
+
assigned_group_list: List[str],
|
|
171
|
+
) -> List[Dict]:
|
|
165
172
|
group_list: List[Dict] = []
|
|
166
173
|
|
|
167
174
|
for current in assigned_group_list:
|
|
@@ -206,14 +213,16 @@ def transform_okta_application(okta_application: Dict) -> Dict:
|
|
|
206
213
|
app_props["label"] = okta_application["label"]
|
|
207
214
|
if "created" in okta_application and okta_application["created"]:
|
|
208
215
|
app_props["created"] = datetime.strptime(
|
|
209
|
-
okta_application["created"],
|
|
216
|
+
okta_application["created"],
|
|
217
|
+
"%Y-%m-%dT%H:%M:%S.%fZ",
|
|
210
218
|
).strftime("%m/%d/%Y, %H:%M:%S")
|
|
211
219
|
else:
|
|
212
220
|
app_props["created"] = None
|
|
213
221
|
|
|
214
222
|
if "lastUpdated" in okta_application and okta_application["lastUpdated"]:
|
|
215
223
|
app_props["okta_last_updated"] = datetime.strptime(
|
|
216
|
-
okta_application["lastUpdated"],
|
|
224
|
+
okta_application["lastUpdated"],
|
|
225
|
+
"%Y-%m-%dT%H:%M:%S.%fZ",
|
|
217
226
|
).strftime("%m/%d/%Y, %H:%M:%S")
|
|
218
227
|
else:
|
|
219
228
|
app_props["okta_last_updated"] = None
|
|
@@ -222,7 +231,8 @@ def transform_okta_application(okta_application: Dict) -> Dict:
|
|
|
222
231
|
|
|
223
232
|
if "activated" in okta_application and okta_application["activated"]:
|
|
224
233
|
app_props["activated"] = datetime.strptime(
|
|
225
|
-
okta_application["activated"],
|
|
234
|
+
okta_application["activated"],
|
|
235
|
+
"%Y-%m-%dT%H:%M:%S.%fZ",
|
|
226
236
|
).strftime("%m/%d/%Y, %H:%M:%S")
|
|
227
237
|
else:
|
|
228
238
|
app_props["activated"] = None
|
|
@@ -234,7 +244,9 @@ def transform_okta_application(okta_application: Dict) -> Dict:
|
|
|
234
244
|
|
|
235
245
|
|
|
236
246
|
@timeit
|
|
237
|
-
def transform_okta_application_extract_replyurls(
|
|
247
|
+
def transform_okta_application_extract_replyurls(
|
|
248
|
+
okta_application: Dict,
|
|
249
|
+
) -> Optional[str]:
|
|
238
250
|
"""
|
|
239
251
|
Extracts the reply uri information from an okta app
|
|
240
252
|
"""
|
|
@@ -247,7 +259,9 @@ def transform_okta_application_extract_replyurls(okta_application: Dict) -> Opti
|
|
|
247
259
|
|
|
248
260
|
@timeit
|
|
249
261
|
def _load_okta_applications(
|
|
250
|
-
neo4j_session: neo4j.Session,
|
|
262
|
+
neo4j_session: neo4j.Session,
|
|
263
|
+
okta_org_id: str,
|
|
264
|
+
app_list: List[Dict],
|
|
251
265
|
okta_update_tag: int,
|
|
252
266
|
) -> None:
|
|
253
267
|
"""
|
|
@@ -289,7 +303,9 @@ def _load_okta_applications(
|
|
|
289
303
|
|
|
290
304
|
@timeit
|
|
291
305
|
def _load_application_user(
|
|
292
|
-
neo4j_session: neo4j.Session,
|
|
306
|
+
neo4j_session: neo4j.Session,
|
|
307
|
+
app_id: str,
|
|
308
|
+
user_list: List[str],
|
|
293
309
|
okta_update_tag: int,
|
|
294
310
|
) -> None:
|
|
295
311
|
"""
|
|
@@ -321,7 +337,9 @@ def _load_application_user(
|
|
|
321
337
|
|
|
322
338
|
@timeit
|
|
323
339
|
def _load_application_group(
|
|
324
|
-
neo4j_session: neo4j.Session,
|
|
340
|
+
neo4j_session: neo4j.Session,
|
|
341
|
+
app_id: str,
|
|
342
|
+
group_list: List[str],
|
|
325
343
|
okta_update_tag: int,
|
|
326
344
|
) -> None:
|
|
327
345
|
"""
|
|
@@ -353,7 +371,9 @@ def _load_application_group(
|
|
|
353
371
|
|
|
354
372
|
@timeit
|
|
355
373
|
def _load_application_reply_urls(
|
|
356
|
-
neo4j_session: neo4j.Session,
|
|
374
|
+
neo4j_session: neo4j.Session,
|
|
375
|
+
app_id: str,
|
|
376
|
+
reply_urls: List[str],
|
|
357
377
|
okta_update_tag: int,
|
|
358
378
|
) -> None:
|
|
359
379
|
"""
|
|
@@ -390,7 +410,9 @@ def _load_application_reply_urls(
|
|
|
390
410
|
|
|
391
411
|
@timeit
|
|
392
412
|
def sync_okta_applications(
|
|
393
|
-
neo4j_session: neo4j.Session,
|
|
413
|
+
neo4j_session: neo4j.Session,
|
|
414
|
+
okta_org_id: str,
|
|
415
|
+
okta_update_tag: int,
|
|
394
416
|
okta_api_key: str,
|
|
395
417
|
) -> None:
|
|
396
418
|
"""
|
|
@@ -12,19 +12,25 @@ from cartography.client.core.tx import read_list_of_dicts_tx
|
|
|
12
12
|
from cartography.client.core.tx import read_single_value_tx
|
|
13
13
|
from cartography.util import timeit
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
GroupRole = namedtuple('GroupRole', ['okta_group_id', 'aws_role_arn'])
|
|
15
|
+
AccountRole = namedtuple("AccountRole", ["account_id", "role_name"])
|
|
16
|
+
OktaGroup = namedtuple("OktaGroup", ["group_id", "group_name"])
|
|
17
|
+
GroupRole = namedtuple("GroupRole", ["okta_group_id", "aws_role_arn"])
|
|
19
18
|
|
|
20
19
|
logger = logging.getLogger(__name__)
|
|
21
20
|
|
|
22
21
|
|
|
23
22
|
def _parse_regex(regex_string: str) -> str:
|
|
24
|
-
return
|
|
23
|
+
return (
|
|
24
|
+
regex_string.replace("{{accountid}}", "P<accountid>")
|
|
25
|
+
.replace("{{role}}", "P<role>")
|
|
26
|
+
.strip()
|
|
27
|
+
)
|
|
25
28
|
|
|
26
29
|
|
|
27
|
-
def _parse_okta_group_name(
|
|
30
|
+
def _parse_okta_group_name(
|
|
31
|
+
okta_group_name: str,
|
|
32
|
+
mapping_regex: str,
|
|
33
|
+
) -> AccountRole | None:
|
|
28
34
|
"""
|
|
29
35
|
Extract AWS account id and AWS role name from the given Okta group name using the given mapping regex.
|
|
30
36
|
"""
|
|
@@ -37,16 +43,25 @@ def _parse_okta_group_name(okta_group_name: str, mapping_regex: str) -> AccountR
|
|
|
37
43
|
return None
|
|
38
44
|
|
|
39
45
|
|
|
40
|
-
def transform_okta_group_to_aws_role(
|
|
46
|
+
def transform_okta_group_to_aws_role(
|
|
47
|
+
group_id: str,
|
|
48
|
+
group_name: str,
|
|
49
|
+
mapping_regex: str,
|
|
50
|
+
) -> Optional[Dict]:
|
|
41
51
|
account_role = _parse_okta_group_name(group_name, mapping_regex)
|
|
42
52
|
if account_role:
|
|
43
|
-
role_arn =
|
|
53
|
+
role_arn = (
|
|
54
|
+
f"arn:aws:iam::{account_role.account_id}:role/{account_role.role_name}"
|
|
55
|
+
)
|
|
44
56
|
return {"groupid": group_id, "role": role_arn}
|
|
45
57
|
return None
|
|
46
58
|
|
|
47
59
|
|
|
48
60
|
@timeit
|
|
49
|
-
def query_for_okta_to_aws_role_mapping(
|
|
61
|
+
def query_for_okta_to_aws_role_mapping(
|
|
62
|
+
neo4j_session: neo4j.Session,
|
|
63
|
+
mapping_regex: str,
|
|
64
|
+
) -> List[Dict]:
|
|
50
65
|
"""
|
|
51
66
|
Query the graph for all groups associated with the amazon_aws application and map them to AWSRoles
|
|
52
67
|
:param neo4j_session: session from the Neo4j server
|
|
@@ -61,12 +76,16 @@ def query_for_okta_to_aws_role_mapping(neo4j_session: neo4j.Session, mapping_reg
|
|
|
61
76
|
for res in results:
|
|
62
77
|
has_results = True
|
|
63
78
|
# input: okta group id, okta group name. output: aws role arn.
|
|
64
|
-
mapping = transform_okta_group_to_aws_role(
|
|
79
|
+
mapping = transform_okta_group_to_aws_role(
|
|
80
|
+
res["group.id"],
|
|
81
|
+
res["group.name"],
|
|
82
|
+
mapping_regex,
|
|
83
|
+
)
|
|
65
84
|
if mapping:
|
|
66
85
|
group_to_role_mapping.append(mapping)
|
|
67
86
|
|
|
68
87
|
if has_results and not group_to_role_mapping:
|
|
69
|
-
logger.
|
|
88
|
+
logger.warning(
|
|
70
89
|
"AWS Okta Application present, but no mappings were found. "
|
|
71
90
|
"Please verify the mapping regex is correct",
|
|
72
91
|
)
|
|
@@ -76,7 +95,8 @@ def query_for_okta_to_aws_role_mapping(neo4j_session: neo4j.Session, mapping_reg
|
|
|
76
95
|
|
|
77
96
|
@timeit
|
|
78
97
|
def _load_okta_group_to_aws_roles(
|
|
79
|
-
neo4j_session: neo4j.Session,
|
|
98
|
+
neo4j_session: neo4j.Session,
|
|
99
|
+
group_to_role: List[Dict],
|
|
80
100
|
okta_update_tag: int,
|
|
81
101
|
) -> None:
|
|
82
102
|
"""
|
|
@@ -104,7 +124,10 @@ def _load_okta_group_to_aws_roles(
|
|
|
104
124
|
|
|
105
125
|
|
|
106
126
|
@timeit
|
|
107
|
-
def _load_human_can_assume_role(
|
|
127
|
+
def _load_human_can_assume_role(
|
|
128
|
+
neo4j_session: neo4j.Session,
|
|
129
|
+
okta_update_tag: int,
|
|
130
|
+
) -> None:
|
|
108
131
|
"""
|
|
109
132
|
Add the CAN_ASSUME_ROLE relationship between Humans and the AWSRoles they can assume
|
|
110
133
|
:param neo4j_session: session with the Neo4j server
|
|
@@ -123,7 +146,10 @@ def _load_human_can_assume_role(neo4j_session: neo4j.Session, okta_update_tag: i
|
|
|
123
146
|
)
|
|
124
147
|
|
|
125
148
|
|
|
126
|
-
def get_awssso_okta_groups(
|
|
149
|
+
def get_awssso_okta_groups(
|
|
150
|
+
neo4j_session: neo4j.Session,
|
|
151
|
+
okta_org_id: str,
|
|
152
|
+
) -> list[OktaGroup]:
|
|
127
153
|
"""
|
|
128
154
|
Return list of all Okta group ids in the current Okta organization tied to Okta Applications with name
|
|
129
155
|
"amazon_aws_sso".
|
|
@@ -133,11 +159,21 @@ def get_awssso_okta_groups(neo4j_session: neo4j.Session, okta_org_id: str) -> li
|
|
|
133
159
|
<-[:RESOURCE]-(:OktaOrganization{id: $okta_org_id})
|
|
134
160
|
RETURN g.id as group_id, g.name as group_name
|
|
135
161
|
"""
|
|
136
|
-
result = neo4j_session.read_transaction(
|
|
137
|
-
|
|
162
|
+
result = neo4j_session.read_transaction(
|
|
163
|
+
read_list_of_dicts_tx,
|
|
164
|
+
query,
|
|
165
|
+
okta_org_id=okta_org_id,
|
|
166
|
+
)
|
|
167
|
+
return [
|
|
168
|
+
OktaGroup(group_name=og["group_name"], group_id=og["group_id"]) for og in result
|
|
169
|
+
]
|
|
138
170
|
|
|
139
171
|
|
|
140
|
-
def get_awssso_role_arn(
|
|
172
|
+
def get_awssso_role_arn(
|
|
173
|
+
account_id: str,
|
|
174
|
+
role_hint: str,
|
|
175
|
+
neo4j_session: neo4j.Session,
|
|
176
|
+
) -> str | None:
|
|
141
177
|
"""
|
|
142
178
|
Attempt to return the AWS role ARN for the given AWS account ID and role hint string.
|
|
143
179
|
This function exists to handle that AWS SSO roles have a 'AWSReservedSSO' prefix and a hashed suffix
|
|
@@ -153,13 +189,18 @@ def get_awssso_role_arn(account_id: str, role_hint: str, neo4j_session: neo4j.Se
|
|
|
153
189
|
WHERE SPLIT(role.name, '_')[1..-1][0] = $role_hint
|
|
154
190
|
RETURN role.arn AS role_arn
|
|
155
191
|
"""
|
|
156
|
-
return neo4j_session.read_transaction(
|
|
192
|
+
return neo4j_session.read_transaction(
|
|
193
|
+
read_single_value_tx,
|
|
194
|
+
query,
|
|
195
|
+
account_id=account_id,
|
|
196
|
+
role_hint=role_hint,
|
|
197
|
+
)
|
|
157
198
|
|
|
158
199
|
|
|
159
200
|
def query_for_okta_to_awssso_role_mapping(
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
201
|
+
neo4j_session: neo4j.Session,
|
|
202
|
+
awssso_okta_groups: list[OktaGroup],
|
|
203
|
+
mapping_regex: str,
|
|
163
204
|
) -> list[GroupRole]:
|
|
164
205
|
"""
|
|
165
206
|
Input:
|
|
@@ -176,13 +217,21 @@ def query_for_okta_to_awssso_role_mapping(
|
|
|
176
217
|
logger.info(f"Okta group {group.group_name} has no associated AWS SSO role")
|
|
177
218
|
continue
|
|
178
219
|
|
|
179
|
-
role_arn = get_awssso_role_arn(
|
|
220
|
+
role_arn = get_awssso_role_arn(
|
|
221
|
+
account_role.account_id,
|
|
222
|
+
account_role.role_name,
|
|
223
|
+
neo4j_session,
|
|
224
|
+
)
|
|
180
225
|
if role_arn:
|
|
181
226
|
result.append(GroupRole(group.group_id, role_arn))
|
|
182
227
|
return result
|
|
183
228
|
|
|
184
229
|
|
|
185
|
-
def _load_awssso_tx(
|
|
230
|
+
def _load_awssso_tx(
|
|
231
|
+
tx: neo4j.Transaction,
|
|
232
|
+
group_to_role: list[GroupRole],
|
|
233
|
+
okta_update_tag: int,
|
|
234
|
+
) -> None:
|
|
186
235
|
ingest_statement = """
|
|
187
236
|
UNWIND $GROUP_TO_ROLE as app_data
|
|
188
237
|
MATCH (role:AWSRole{arn: app_data.aws_role_arn})
|
|
@@ -199,19 +248,19 @@ def _load_awssso_tx(tx: neo4j.Transaction, group_to_role: list[GroupRole], okta_
|
|
|
199
248
|
|
|
200
249
|
|
|
201
250
|
def _load_okta_group_to_awssso_roles(
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
251
|
+
neo4j_session: neo4j.Session,
|
|
252
|
+
group_to_role: list[GroupRole],
|
|
253
|
+
okta_update_tag: int,
|
|
205
254
|
) -> None:
|
|
206
255
|
neo4j_session.write_transaction(_load_awssso_tx, group_to_role, okta_update_tag)
|
|
207
256
|
|
|
208
257
|
|
|
209
258
|
@timeit
|
|
210
259
|
def sync_okta_aws_saml(
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
260
|
+
neo4j_session: neo4j.Session,
|
|
261
|
+
mapping_regex: str,
|
|
262
|
+
okta_update_tag: int,
|
|
263
|
+
okta_org_id: str,
|
|
215
264
|
) -> None:
|
|
216
265
|
"""
|
|
217
266
|
Sync okta integration with saml. This will link OktaGroups to the AWSRoles they enable.
|
|
@@ -228,10 +277,21 @@ def sync_okta_aws_saml(
|
|
|
228
277
|
logger.info("Syncing Okta SAML Integration")
|
|
229
278
|
|
|
230
279
|
# Query for the aws application and its associated groups
|
|
231
|
-
group_to_role_mapping = query_for_okta_to_aws_role_mapping(
|
|
280
|
+
group_to_role_mapping = query_for_okta_to_aws_role_mapping(
|
|
281
|
+
neo4j_session,
|
|
282
|
+
mapping_regex,
|
|
283
|
+
)
|
|
232
284
|
_load_okta_group_to_aws_roles(neo4j_session, group_to_role_mapping, okta_update_tag)
|
|
233
285
|
_load_human_can_assume_role(neo4j_session, okta_update_tag)
|
|
234
286
|
|
|
235
287
|
sso_okta_groups = get_awssso_okta_groups(neo4j_session, okta_org_id)
|
|
236
|
-
group_to_ssorole_mapping = query_for_okta_to_awssso_role_mapping(
|
|
237
|
-
|
|
288
|
+
group_to_ssorole_mapping = query_for_okta_to_awssso_role_mapping(
|
|
289
|
+
neo4j_session,
|
|
290
|
+
sso_okta_groups,
|
|
291
|
+
mapping_regex,
|
|
292
|
+
)
|
|
293
|
+
_load_okta_group_to_awssso_roles(
|
|
294
|
+
neo4j_session,
|
|
295
|
+
group_to_ssorole_mapping,
|
|
296
|
+
okta_update_tag,
|
|
297
|
+
)
|
|
@@ -79,12 +79,16 @@ def transform_okta_user_factor(okta_factor_info: Factor) -> Dict:
|
|
|
79
79
|
factor_props["provider"] = okta_factor_info.provider
|
|
80
80
|
factor_props["status"] = okta_factor_info.status
|
|
81
81
|
if okta_factor_info.created:
|
|
82
|
-
factor_props["created"] = okta_factor_info.created.strftime(
|
|
82
|
+
factor_props["created"] = okta_factor_info.created.strftime(
|
|
83
|
+
"%m/%d/%Y, %H:%M:%S",
|
|
84
|
+
)
|
|
83
85
|
else:
|
|
84
86
|
factor_props["created"] = None
|
|
85
87
|
|
|
86
88
|
if okta_factor_info.lastUpdated:
|
|
87
|
-
factor_props["okta_last_updated"] = okta_factor_info.lastUpdated.strftime(
|
|
89
|
+
factor_props["okta_last_updated"] = okta_factor_info.lastUpdated.strftime(
|
|
90
|
+
"%m/%d/%Y, %H:%M:%S",
|
|
91
|
+
)
|
|
88
92
|
else:
|
|
89
93
|
factor_props["okta_last_updated"] = None
|
|
90
94
|
|
|
@@ -93,7 +97,12 @@ def transform_okta_user_factor(okta_factor_info: Factor) -> Dict:
|
|
|
93
97
|
|
|
94
98
|
|
|
95
99
|
@timeit
|
|
96
|
-
def _load_user_factors(
|
|
100
|
+
def _load_user_factors(
|
|
101
|
+
neo4j_session: neo4j.Session,
|
|
102
|
+
user_id: str,
|
|
103
|
+
factors: List[Dict],
|
|
104
|
+
okta_update_tag: int,
|
|
105
|
+
) -> None:
|
|
97
106
|
"""
|
|
98
107
|
Add user factors into the graph
|
|
99
108
|
:param neo4j_session: session with the Neo4j server
|
|
@@ -131,7 +140,10 @@ def _load_user_factors(neo4j_session: neo4j.Session, user_id: str, factors: List
|
|
|
131
140
|
|
|
132
141
|
@timeit
|
|
133
142
|
def sync_users_factors(
|
|
134
|
-
neo4j_session: neo4j.Session,
|
|
143
|
+
neo4j_session: neo4j.Session,
|
|
144
|
+
okta_org_id: str,
|
|
145
|
+
okta_update_tag: int,
|
|
146
|
+
okta_api_key: str,
|
|
135
147
|
sync_state: OktaSyncState,
|
|
136
148
|
) -> None:
|
|
137
149
|
"""
|
cartography/intel/okta/groups.py
CHANGED
|
@@ -39,9 +39,9 @@ def _get_okta_groups(api_client: ApiClient) -> List[str]:
|
|
|
39
39
|
paged_response = api_client.get(next_url)
|
|
40
40
|
else:
|
|
41
41
|
params = {
|
|
42
|
-
|
|
42
|
+
"limit": 10000,
|
|
43
43
|
}
|
|
44
|
-
paged_response = api_client.get_path(
|
|
44
|
+
paged_response = api_client.get_path("/", params)
|
|
45
45
|
|
|
46
46
|
paged_results = PagedResults(paged_response, UserGroup)
|
|
47
47
|
|
|
@@ -75,9 +75,9 @@ def get_okta_group_members(api_client: ApiClient, group_id: str) -> List[Dict]:
|
|
|
75
75
|
paged_response = api_client.get(next_url)
|
|
76
76
|
else:
|
|
77
77
|
params = {
|
|
78
|
-
|
|
78
|
+
"limit": 1000,
|
|
79
79
|
}
|
|
80
|
-
paged_response = api_client.get_path(f
|
|
80
|
+
paged_response = api_client.get_path(f"/{group_id}/users", params)
|
|
81
81
|
except OktaError:
|
|
82
82
|
logger.error(f"OktaError while listing members of group {group_id}")
|
|
83
83
|
raise
|
|
@@ -95,7 +95,9 @@ def get_okta_group_members(api_client: ApiClient, group_id: str) -> List[Dict]:
|
|
|
95
95
|
|
|
96
96
|
|
|
97
97
|
@timeit
|
|
98
|
-
def transform_okta_group_list(
|
|
98
|
+
def transform_okta_group_list(
|
|
99
|
+
okta_group_list: List[UserGroup],
|
|
100
|
+
) -> Tuple[List[Dict], List[str]]:
|
|
99
101
|
groups: List[Dict] = []
|
|
100
102
|
groups_id: List[str] = []
|
|
101
103
|
|
|
@@ -128,7 +130,9 @@ def transform_okta_group(okta_group: UserGroup) -> Dict:
|
|
|
128
130
|
group_props["dn"] = None
|
|
129
131
|
|
|
130
132
|
if okta_group.profile.windowsDomainQualifiedName:
|
|
131
|
-
group_props["windows_domain_qualified_name"] =
|
|
133
|
+
group_props["windows_domain_qualified_name"] = (
|
|
134
|
+
okta_group.profile.windowsDomainQualifiedName
|
|
135
|
+
)
|
|
132
136
|
else:
|
|
133
137
|
group_props["windows_domain_qualified_name"] = None
|
|
134
138
|
|
|
@@ -146,27 +150,31 @@ def transform_okta_group_member_list(okta_member_list: List[Dict]) -> List[Dict]
|
|
|
146
150
|
"""
|
|
147
151
|
transformed_member_list: List[Dict] = []
|
|
148
152
|
for user in okta_member_list:
|
|
149
|
-
transformed_member_list.append(
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
153
|
+
transformed_member_list.append(
|
|
154
|
+
{
|
|
155
|
+
"first_name": user["profile"]["firstName"],
|
|
156
|
+
"last_name": user["profile"]["lastName"],
|
|
157
|
+
"login": user["profile"]["login"],
|
|
158
|
+
"email": user["profile"]["email"],
|
|
159
|
+
"second_email": user["profile"].get("secondEmail"),
|
|
160
|
+
"id": user["id"],
|
|
161
|
+
"created": user["created"],
|
|
162
|
+
"activated": user.get("activated"),
|
|
163
|
+
"status_changed": user.get("status_changed"),
|
|
164
|
+
"last_login": user.get("last_login"),
|
|
165
|
+
"okta_last_updated": user.get("okta_last_updated"),
|
|
166
|
+
"password_changed": user.get("password_changed"),
|
|
167
|
+
"transition_to_status": user.get("transitioningToStatus"),
|
|
168
|
+
},
|
|
169
|
+
)
|
|
164
170
|
return transformed_member_list
|
|
165
171
|
|
|
166
172
|
|
|
167
173
|
@timeit
|
|
168
174
|
def _load_okta_groups(
|
|
169
|
-
neo4j_session: neo4j.Session,
|
|
175
|
+
neo4j_session: neo4j.Session,
|
|
176
|
+
okta_org_id: str,
|
|
177
|
+
group_list: List[Dict],
|
|
170
178
|
okta_update_tag: int,
|
|
171
179
|
) -> None:
|
|
172
180
|
"""
|
|
@@ -206,7 +214,9 @@ def _load_okta_groups(
|
|
|
206
214
|
|
|
207
215
|
@timeit
|
|
208
216
|
def load_okta_group_members(
|
|
209
|
-
neo4j_session: neo4j.Session,
|
|
217
|
+
neo4j_session: neo4j.Session,
|
|
218
|
+
group_id: str,
|
|
219
|
+
member_list: List[Dict],
|
|
210
220
|
okta_update_tag: int,
|
|
211
221
|
) -> None:
|
|
212
222
|
"""
|
|
@@ -240,7 +250,7 @@ def load_okta_group_members(
|
|
|
240
250
|
ON CREATE SET r.firstseen = timestamp()
|
|
241
251
|
SET r.lastupdated = $okta_update_tag
|
|
242
252
|
"""
|
|
243
|
-
logging.info(f
|
|
253
|
+
logging.info(f"Loading {len(member_list)} members of group {group_id}")
|
|
244
254
|
neo4j_session.run(
|
|
245
255
|
ingest,
|
|
246
256
|
GROUP_ID=group_id,
|
|
@@ -251,7 +261,9 @@ def load_okta_group_members(
|
|
|
251
261
|
|
|
252
262
|
@timeit
|
|
253
263
|
def sync_okta_group_membership(
|
|
254
|
-
neo4j_session: neo4j.Session,
|
|
264
|
+
neo4j_session: neo4j.Session,
|
|
265
|
+
api_client: ApiClient,
|
|
266
|
+
group_list_info: List[Dict],
|
|
255
267
|
okta_update_tag: int,
|
|
256
268
|
) -> None:
|
|
257
269
|
"""
|
|
@@ -266,13 +278,23 @@ def sync_okta_group_membership(
|
|
|
266
278
|
for group_info in group_list_info:
|
|
267
279
|
group_id = group_info["id"]
|
|
268
280
|
members_data: List[Dict] = get_okta_group_members(api_client, group_id)
|
|
269
|
-
transformed_member_data: List[Dict] = transform_okta_group_member_list(
|
|
270
|
-
|
|
281
|
+
transformed_member_data: List[Dict] = transform_okta_group_member_list(
|
|
282
|
+
members_data,
|
|
283
|
+
)
|
|
284
|
+
load_okta_group_members(
|
|
285
|
+
neo4j_session,
|
|
286
|
+
group_id,
|
|
287
|
+
transformed_member_data,
|
|
288
|
+
okta_update_tag,
|
|
289
|
+
)
|
|
271
290
|
|
|
272
291
|
|
|
273
292
|
@timeit
|
|
274
293
|
def sync_okta_groups(
|
|
275
|
-
neo4_session: neo4j.Session,
|
|
294
|
+
neo4_session: neo4j.Session,
|
|
295
|
+
okta_org_id: str,
|
|
296
|
+
okta_update_tag: int,
|
|
297
|
+
okta_api_key: str,
|
|
276
298
|
sync_state: OktaSyncState,
|
|
277
299
|
) -> None:
|
|
278
300
|
"""
|
|
@@ -295,4 +317,9 @@ def sync_okta_groups(
|
|
|
295
317
|
|
|
296
318
|
_load_okta_groups(neo4_session, okta_org_id, group_list_info, okta_update_tag)
|
|
297
319
|
|
|
298
|
-
sync_okta_group_membership(
|
|
320
|
+
sync_okta_group_membership(
|
|
321
|
+
neo4_session,
|
|
322
|
+
api_client,
|
|
323
|
+
group_list_info,
|
|
324
|
+
okta_update_tag,
|
|
325
|
+
)
|
|
@@ -9,7 +9,11 @@ logger = logging.getLogger(__name__)
|
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
@timeit
|
|
12
|
-
def create_okta_organization(
|
|
12
|
+
def create_okta_organization(
|
|
13
|
+
neo4j_session: neo4j.Session,
|
|
14
|
+
organization: str,
|
|
15
|
+
okta_update_tag: int,
|
|
16
|
+
) -> None:
|
|
13
17
|
"""
|
|
14
18
|
Create Okta organization in the graph
|
|
15
19
|
:param neo4_session: session with the Neo4j server
|
|
@@ -61,7 +61,9 @@ def transform_trusted_origins(data: str) -> List[Dict]:
|
|
|
61
61
|
|
|
62
62
|
@timeit
|
|
63
63
|
def _load_trusted_origins(
|
|
64
|
-
neo4j_session: neo4j.Session,
|
|
64
|
+
neo4j_session: neo4j.Session,
|
|
65
|
+
okta_org_id: str,
|
|
66
|
+
trusted_list: List[Dict],
|
|
65
67
|
okta_update_tag: int,
|
|
66
68
|
) -> None:
|
|
67
69
|
"""
|
|
@@ -104,7 +106,9 @@ def _load_trusted_origins(
|
|
|
104
106
|
|
|
105
107
|
@timeit
|
|
106
108
|
def sync_trusted_origins(
|
|
107
|
-
neo4j_session: neo4j.Session,
|
|
109
|
+
neo4j_session: neo4j.Session,
|
|
110
|
+
okta_org_id: str,
|
|
111
|
+
okta_update_tag: int,
|
|
108
112
|
okta_api_key: str,
|
|
109
113
|
) -> None:
|
|
110
114
|
"""
|