cartography 0.102.0rc2__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 +138 -98
- 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 -46
- 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 +44 -34
- cartography/models/aws/ec2/route_tables.py +50 -43
- cartography/models/aws/ec2/routes.py +45 -37
- 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.0rc2.dist-info → cartography-0.103.0rc1.dist-info}/METADATA +3 -14
- cartography-0.103.0rc1.dist-info/RECORD +396 -0
- {cartography-0.102.0rc2.dist-info → cartography-0.103.0rc1.dist-info}/WHEEL +1 -1
- cartography-0.102.0rc2.dist-info/RECORD +0 -381
- {cartography-0.102.0rc2.dist-info → cartography-0.103.0rc1.dist-info}/entry_points.txt +0 -0
- {cartography-0.102.0rc2.dist-info → cartography-0.103.0rc1.dist-info}/licenses/LICENSE +0 -0
- {cartography-0.102.0rc2.dist-info → cartography-0.103.0rc1.dist-info}/top_level.txt +0 -0
cartography/intel/github/util.py
CHANGED
|
@@ -13,7 +13,6 @@ from typing import Tuple
|
|
|
13
13
|
|
|
14
14
|
import requests
|
|
15
15
|
|
|
16
|
-
|
|
17
16
|
logger = logging.getLogger(__name__)
|
|
18
17
|
# Connect and read timeouts of 60 seconds each; see https://requests.readthedocs.io/en/master/user/advanced/#timeouts
|
|
19
18
|
_TIMEOUT = (60, 60)
|
|
@@ -26,25 +25,28 @@ class PaginatedGraphqlData(NamedTuple):
|
|
|
26
25
|
|
|
27
26
|
|
|
28
27
|
def handle_rate_limit_sleep(token: str) -> None:
|
|
29
|
-
|
|
28
|
+
"""
|
|
30
29
|
Check the remaining rate limit and sleep if remaining is below threshold
|
|
31
30
|
:param token: The Github API token as string.
|
|
32
|
-
|
|
33
|
-
response = requests.get(
|
|
31
|
+
"""
|
|
32
|
+
response = requests.get(
|
|
33
|
+
"https://api.github.com/rate_limit",
|
|
34
|
+
headers={"Authorization": f"token {token}"},
|
|
35
|
+
)
|
|
34
36
|
response.raise_for_status()
|
|
35
37
|
response_json = response.json()
|
|
36
|
-
rate_limit_obj = response_json[
|
|
37
|
-
remaining = rate_limit_obj[
|
|
38
|
+
rate_limit_obj = response_json["resources"]["graphql"]
|
|
39
|
+
remaining = rate_limit_obj["remaining"]
|
|
38
40
|
threshold = _GRAPHQL_RATE_LIMIT_REMAINING_THRESHOLD
|
|
39
41
|
if remaining > threshold:
|
|
40
42
|
return
|
|
41
|
-
reset_at = datetime.fromtimestamp(rate_limit_obj[
|
|
43
|
+
reset_at = datetime.fromtimestamp(rate_limit_obj["reset"], tz=tz.utc)
|
|
42
44
|
now = datetime.now(tz.utc)
|
|
43
45
|
# add an extra minute for safety
|
|
44
46
|
sleep_duration = reset_at - now + timedelta(minutes=1)
|
|
45
47
|
logger.warning(
|
|
46
|
-
f
|
|
47
|
-
f
|
|
48
|
+
f"Github graphql ratelimit has {remaining} remaining and is under threshold {threshold},"
|
|
49
|
+
f" sleeping until reset at {reset_at} for {sleep_duration}",
|
|
48
50
|
)
|
|
49
51
|
time.sleep(sleep_duration.seconds)
|
|
50
52
|
|
|
@@ -58,11 +60,11 @@ def call_github_api(query: str, variables: str, token: str, api_url: str) -> Dic
|
|
|
58
60
|
:param api_url: the URL to call for the API
|
|
59
61
|
:return: query results json
|
|
60
62
|
"""
|
|
61
|
-
headers = {
|
|
63
|
+
headers = {"Authorization": f"token {token}"}
|
|
62
64
|
try:
|
|
63
65
|
response = requests.post(
|
|
64
66
|
api_url,
|
|
65
|
-
json={
|
|
67
|
+
json={"query": query, "variables": variables},
|
|
66
68
|
headers=headers,
|
|
67
69
|
timeout=_TIMEOUT,
|
|
68
70
|
)
|
|
@@ -75,7 +77,7 @@ def call_github_api(query: str, variables: str, token: str, api_url: str) -> Dic
|
|
|
75
77
|
if "errors" in response_json:
|
|
76
78
|
logger.warning(
|
|
77
79
|
f'call_github_api() response has errors, please investigate. Raw response: {response_json["errors"]}; '
|
|
78
|
-
f
|
|
80
|
+
f"continuing sync.",
|
|
79
81
|
)
|
|
80
82
|
return response_json # type: ignore
|
|
81
83
|
|
|
@@ -101,8 +103,8 @@ def fetch_page(
|
|
|
101
103
|
"""
|
|
102
104
|
gql_vars = {
|
|
103
105
|
**kwargs,
|
|
104
|
-
|
|
105
|
-
|
|
106
|
+
"login": organization,
|
|
107
|
+
"cursor": cursor,
|
|
106
108
|
}
|
|
107
109
|
gql_vars_json = json.dumps(gql_vars)
|
|
108
110
|
response = call_github_api(query, gql_vars_json, token, api_url)
|
|
@@ -110,14 +112,14 @@ def fetch_page(
|
|
|
110
112
|
|
|
111
113
|
|
|
112
114
|
def fetch_all(
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
115
|
+
token: str,
|
|
116
|
+
api_url: str,
|
|
117
|
+
organization: str,
|
|
118
|
+
query: str,
|
|
119
|
+
resource_type: str,
|
|
120
|
+
retries: int = 5,
|
|
121
|
+
resource_inner_type: Optional[str] = None,
|
|
122
|
+
**kwargs: Any,
|
|
121
123
|
) -> Tuple[PaginatedGraphqlData, Dict[str, Any]]:
|
|
122
124
|
"""
|
|
123
125
|
Fetch and return all data items of the given `resource_type` and `field_name` from Github's paginated GraphQL API as
|
|
@@ -169,32 +171,32 @@ def fetch_all(
|
|
|
169
171
|
)
|
|
170
172
|
raise exc
|
|
171
173
|
elif retry > 0:
|
|
172
|
-
time.sleep(2
|
|
174
|
+
time.sleep(2**retry)
|
|
173
175
|
continue
|
|
174
176
|
|
|
175
|
-
if
|
|
177
|
+
if "data" not in resp:
|
|
176
178
|
logger.warning(
|
|
177
179
|
f'Got no "data" attribute in response: {resp}. '
|
|
178
|
-
f
|
|
179
|
-
f
|
|
180
|
+
f"Stopping requests for organization: {organization} and "
|
|
181
|
+
f"resource_type: {resource_type}",
|
|
180
182
|
)
|
|
181
183
|
has_next_page = False
|
|
182
184
|
continue
|
|
183
185
|
|
|
184
|
-
resource = resp[
|
|
186
|
+
resource = resp["data"]["organization"][resource_type]
|
|
185
187
|
if resource_inner_type:
|
|
186
|
-
resource = resp[
|
|
188
|
+
resource = resp["data"]["organization"][resource_type][resource_inner_type]
|
|
187
189
|
|
|
188
190
|
# Allow for paginating both nodes and edges fields of the GitHub GQL structure.
|
|
189
|
-
data.nodes.extend(resource.get(
|
|
190
|
-
data.edges.extend(resource.get(
|
|
191
|
+
data.nodes.extend(resource.get("nodes", []))
|
|
192
|
+
data.edges.extend(resource.get("edges", []))
|
|
191
193
|
|
|
192
|
-
cursor = resource[
|
|
193
|
-
has_next_page = resource[
|
|
194
|
+
cursor = resource["pageInfo"]["endCursor"]
|
|
195
|
+
has_next_page = resource["pageInfo"]["hasNextPage"]
|
|
194
196
|
if not org_data:
|
|
195
197
|
org_data = {
|
|
196
|
-
|
|
197
|
-
|
|
198
|
+
"url": resp["data"]["organization"]["url"],
|
|
199
|
+
"login": resp["data"]["organization"]["login"],
|
|
198
200
|
}
|
|
199
201
|
|
|
200
202
|
if not org_data:
|
|
@@ -20,17 +20,19 @@ from cartography.intel.gsuite import api
|
|
|
20
20
|
from cartography.util import timeit
|
|
21
21
|
|
|
22
22
|
OAUTH_SCOPES = [
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
"https://www.googleapis.com/auth/admin.directory.user.readonly",
|
|
24
|
+
"https://www.googleapis.com/auth/admin.directory.group.readonly",
|
|
25
|
+
"https://www.googleapis.com/auth/admin.directory.group.member",
|
|
26
26
|
]
|
|
27
27
|
|
|
28
28
|
logger = logging.getLogger(__name__)
|
|
29
29
|
|
|
30
|
-
Resources = namedtuple(
|
|
30
|
+
Resources = namedtuple("Resources", "admin")
|
|
31
31
|
|
|
32
32
|
|
|
33
|
-
def _get_admin_resource(
|
|
33
|
+
def _get_admin_resource(
|
|
34
|
+
credentials: OAuth2Credentials | ServiceAccountCredentials,
|
|
35
|
+
) -> Resource:
|
|
34
36
|
"""
|
|
35
37
|
Instantiates a Google API resource object to call the Google API.
|
|
36
38
|
Used to pull users and groups. See https://developers.google.com/admin-sdk/directory/v1/guides/manage-users
|
|
@@ -38,10 +40,17 @@ def _get_admin_resource(credentials: OAuth2Credentials | ServiceAccountCredentia
|
|
|
38
40
|
:param credentials: The credentials object
|
|
39
41
|
:return: An admin api resource object
|
|
40
42
|
"""
|
|
41
|
-
return googleapiclient.discovery.build(
|
|
43
|
+
return googleapiclient.discovery.build(
|
|
44
|
+
"admin",
|
|
45
|
+
"directory_v1",
|
|
46
|
+
credentials=credentials,
|
|
47
|
+
cache_discovery=False,
|
|
48
|
+
)
|
|
42
49
|
|
|
43
50
|
|
|
44
|
-
def _initialize_resources(
|
|
51
|
+
def _initialize_resources(
|
|
52
|
+
credentials: OAuth2Credentials | ServiceAccountCredentials,
|
|
53
|
+
) -> Resources:
|
|
45
54
|
"""
|
|
46
55
|
Create namedtuple of all resource objects necessary for Google API data gathering.
|
|
47
56
|
:param credentials: The credentials object
|
|
@@ -66,7 +75,7 @@ def start_gsuite_ingestion(neo4j_session: neo4j.Session, config: Config) -> None
|
|
|
66
75
|
}
|
|
67
76
|
|
|
68
77
|
creds: OAuth2Credentials | ServiceAccountCredentials
|
|
69
|
-
if config.gsuite_auth_method ==
|
|
78
|
+
if config.gsuite_auth_method == "delegated": # Legacy delegated method
|
|
70
79
|
if config.gsuite_config is None or not os.path.isfile(config.gsuite_config):
|
|
71
80
|
logger.warning(
|
|
72
81
|
(
|
|
@@ -75,36 +84,38 @@ def start_gsuite_ingestion(neo4j_session: neo4j.Session, config: Config) -> None
|
|
|
75
84
|
),
|
|
76
85
|
)
|
|
77
86
|
return
|
|
78
|
-
logger.info(
|
|
87
|
+
logger.info(
|
|
88
|
+
"Attempting to authenticate to GSuite using legacy delegated method"
|
|
89
|
+
)
|
|
79
90
|
try:
|
|
80
91
|
creds = service_account.Credentials.from_service_account_file(
|
|
81
92
|
config.gsuite_config,
|
|
82
93
|
scopes=OAUTH_SCOPES,
|
|
83
94
|
)
|
|
84
|
-
creds = creds.with_subject(os.environ.get(
|
|
95
|
+
creds = creds.with_subject(os.environ.get("GSUITE_DELEGATED_ADMIN"))
|
|
85
96
|
|
|
86
97
|
except DefaultCredentialsError as e:
|
|
87
98
|
logger.error(
|
|
88
99
|
(
|
|
89
100
|
"Unable to initialize GSuite creds. If you don't have GSuite data or don't want to load "
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
101
|
+
"Gsuite data then you can ignore this message. Otherwise, the error code is: %s "
|
|
102
|
+
"Make sure your GSuite credentials file (if any) is valid. "
|
|
103
|
+
"For more details see README"
|
|
93
104
|
),
|
|
94
105
|
e,
|
|
95
106
|
)
|
|
96
107
|
return
|
|
97
|
-
elif config.gsuite_auth_method ==
|
|
108
|
+
elif config.gsuite_auth_method == "oauth":
|
|
98
109
|
auth_tokens = json.loads(str(base64.b64decode(config.gsuite_config).decode()))
|
|
99
|
-
logger.info(
|
|
110
|
+
logger.info("Attempting to authenticate to GSuite using OAuth")
|
|
100
111
|
try:
|
|
101
112
|
creds = credentials.Credentials(
|
|
102
113
|
token=None,
|
|
103
|
-
client_id=auth_tokens[
|
|
104
|
-
client_secret=auth_tokens[
|
|
105
|
-
refresh_token=auth_tokens[
|
|
114
|
+
client_id=auth_tokens["client_id"],
|
|
115
|
+
client_secret=auth_tokens["client_secret"],
|
|
116
|
+
refresh_token=auth_tokens["refresh_token"],
|
|
106
117
|
expiry=None,
|
|
107
|
-
token_uri=auth_tokens[
|
|
118
|
+
token_uri=auth_tokens["token_uri"],
|
|
108
119
|
scopes=OAUTH_SCOPES,
|
|
109
120
|
)
|
|
110
121
|
creds.refresh(Request())
|
|
@@ -113,15 +124,15 @@ def start_gsuite_ingestion(neo4j_session: neo4j.Session, config: Config) -> None
|
|
|
113
124
|
logger.error(
|
|
114
125
|
(
|
|
115
126
|
"Unable to initialize GSuite creds. If you don't have GSuite data or don't want to load "
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
127
|
+
"Gsuite data then you can ignore this message. Otherwise, the error code is: %s "
|
|
128
|
+
"Make sure your GSuite credentials are configured correctly, your credentials are valid. "
|
|
129
|
+
"For more details see README"
|
|
119
130
|
),
|
|
120
131
|
e,
|
|
121
132
|
)
|
|
122
133
|
return
|
|
123
|
-
elif config.gsuite_auth_method ==
|
|
124
|
-
logger.info(
|
|
134
|
+
elif config.gsuite_auth_method == "default":
|
|
135
|
+
logger.info("Attempting to authenticate to GSuite using default credentials")
|
|
125
136
|
try:
|
|
126
137
|
creds, _ = default(scopes=OAUTH_SCOPES)
|
|
127
138
|
except DefaultCredentialsError as e:
|
|
@@ -137,5 +148,15 @@ def start_gsuite_ingestion(neo4j_session: neo4j.Session, config: Config) -> None
|
|
|
137
148
|
return
|
|
138
149
|
|
|
139
150
|
resources = _initialize_resources(creds)
|
|
140
|
-
api.sync_gsuite_users(
|
|
141
|
-
|
|
151
|
+
api.sync_gsuite_users(
|
|
152
|
+
neo4j_session,
|
|
153
|
+
resources.admin,
|
|
154
|
+
config.update_tag,
|
|
155
|
+
common_job_parameters,
|
|
156
|
+
)
|
|
157
|
+
api.sync_gsuite_groups(
|
|
158
|
+
neo4j_session,
|
|
159
|
+
resources.admin,
|
|
160
|
+
config.update_tag,
|
|
161
|
+
common_job_parameters,
|
|
162
|
+
)
|
cartography/intel/gsuite/api.py
CHANGED
|
@@ -9,7 +9,6 @@ from googleapiclient.errors import HttpError
|
|
|
9
9
|
from cartography.util import run_cleanup_job
|
|
10
10
|
from cartography.util import timeit
|
|
11
11
|
|
|
12
|
-
|
|
13
12
|
logger = logging.getLogger(__name__)
|
|
14
13
|
|
|
15
14
|
|
|
@@ -28,7 +27,11 @@ def get_all_groups(admin: Resource) -> List[Dict]:
|
|
|
28
27
|
See https://googleapis.github.io/google-api-python-client/docs/epy/googleapiclient.discovery-module.html#build.
|
|
29
28
|
:return: List of Google groups in domain
|
|
30
29
|
"""
|
|
31
|
-
request = admin.groups().list(
|
|
30
|
+
request = admin.groups().list(
|
|
31
|
+
customer="my_customer",
|
|
32
|
+
maxResults=20,
|
|
33
|
+
orderBy="email",
|
|
34
|
+
)
|
|
32
35
|
response_objects = []
|
|
33
36
|
while request is not None:
|
|
34
37
|
try:
|
|
@@ -36,13 +39,16 @@ def get_all_groups(admin: Resource) -> List[Dict]:
|
|
|
36
39
|
response_objects.append(resp)
|
|
37
40
|
request = admin.groups().list_next(request, resp)
|
|
38
41
|
except HttpError as e:
|
|
39
|
-
if
|
|
42
|
+
if (
|
|
43
|
+
e.resp.status == 403
|
|
44
|
+
and "Request had insufficient authentication scopes" in str(e)
|
|
45
|
+
):
|
|
40
46
|
logger.error(
|
|
41
47
|
"Missing required GSuite scopes. If using the gcloud CLI, ",
|
|
42
48
|
"run: gcloud auth application-default login --scopes="
|
|
43
49
|
'"https://www.googleapis.com/auth/admin.directory.user.readonly,'
|
|
44
|
-
|
|
45
|
-
|
|
50
|
+
"https://www.googleapis.com/auth/admin.directory.group.readonly,"
|
|
51
|
+
"https://www.googleapis.com/auth/admin.directory.group.member.readonly,"
|
|
46
52
|
'https://www.googleapis.com/auth/cloud-platform"',
|
|
47
53
|
)
|
|
48
54
|
raise
|
|
@@ -51,34 +57,34 @@ def get_all_groups(admin: Resource) -> List[Dict]:
|
|
|
51
57
|
|
|
52
58
|
@timeit
|
|
53
59
|
def transform_groups(response_objects: List[Dict]) -> List[Dict]:
|
|
54
|
-
"""
|
|
60
|
+
"""Strips list of API response objects to return list of group objects only
|
|
55
61
|
|
|
56
62
|
:param response_objects:
|
|
57
63
|
:return: list of dictionary objects as defined in /docs/root/modules/gsuite/schema.md
|
|
58
64
|
"""
|
|
59
65
|
groups: List[Dict] = []
|
|
60
66
|
for response_object in response_objects:
|
|
61
|
-
for group in response_object[
|
|
67
|
+
for group in response_object["groups"]:
|
|
62
68
|
groups.append(group)
|
|
63
69
|
return groups
|
|
64
70
|
|
|
65
71
|
|
|
66
72
|
@timeit
|
|
67
73
|
def transform_users(response_objects: List[Dict]) -> List[Dict]:
|
|
68
|
-
"""
|
|
74
|
+
"""Strips list of API response objects to return list of group objects only
|
|
69
75
|
:param response_objects:
|
|
70
76
|
:return: list of dictionary objects as defined in /docs/root/modules/gsuite/schema.md
|
|
71
77
|
"""
|
|
72
78
|
users: List[Dict] = []
|
|
73
79
|
for response_object in response_objects:
|
|
74
|
-
for user in response_object[
|
|
80
|
+
for user in response_object["users"]:
|
|
75
81
|
users.append(user)
|
|
76
82
|
return users
|
|
77
83
|
|
|
78
84
|
|
|
79
85
|
@timeit
|
|
80
86
|
def get_all_groups_for_email(admin: Resource, email: str) -> List[Dict]:
|
|
81
|
-
"""
|
|
87
|
+
"""Fetch all groups of which the given group is a member
|
|
82
88
|
|
|
83
89
|
Arguments:
|
|
84
90
|
email: A string representing the email address for the group
|
|
@@ -90,14 +96,14 @@ def get_all_groups_for_email(admin: Resource, email: str) -> List[Dict]:
|
|
|
90
96
|
groups: List[Dict] = []
|
|
91
97
|
while request is not None:
|
|
92
98
|
resp = request.execute(num_retries=GOOGLE_API_NUM_RETRIES)
|
|
93
|
-
groups = groups + resp.get(
|
|
99
|
+
groups = groups + resp.get("groups", [])
|
|
94
100
|
request = admin.groups().list_next(request, resp)
|
|
95
101
|
return groups
|
|
96
102
|
|
|
97
103
|
|
|
98
104
|
@timeit
|
|
99
105
|
def get_members_for_group(admin: Resource, group_email: str) -> List[Dict]:
|
|
100
|
-
"""
|
|
106
|
+
"""Get all members for a google group
|
|
101
107
|
|
|
102
108
|
:param group_email: A string representing the email address for the group
|
|
103
109
|
|
|
@@ -110,7 +116,7 @@ def get_members_for_group(admin: Resource, group_email: str) -> List[Dict]:
|
|
|
110
116
|
members: List[Dict] = []
|
|
111
117
|
while request is not None:
|
|
112
118
|
resp = request.execute(num_retries=GOOGLE_API_NUM_RETRIES)
|
|
113
|
-
members = members + resp.get(
|
|
119
|
+
members = members + resp.get("members", [])
|
|
114
120
|
request = admin.members().list_next(request, resp)
|
|
115
121
|
|
|
116
122
|
return members
|
|
@@ -128,7 +134,11 @@ def get_all_users(admin: Resource) -> List[Dict]:
|
|
|
128
134
|
:return: List of Google users in domain
|
|
129
135
|
see https://developers.google.com/admin-sdk/directory/v1/guides/manage-users#get_all_domain_users
|
|
130
136
|
"""
|
|
131
|
-
request = admin.users().list(
|
|
137
|
+
request = admin.users().list(
|
|
138
|
+
customer="my_customer",
|
|
139
|
+
maxResults=500,
|
|
140
|
+
orderBy="email",
|
|
141
|
+
)
|
|
132
142
|
response_objects = []
|
|
133
143
|
while request is not None:
|
|
134
144
|
resp = request.execute(num_retries=GOOGLE_API_NUM_RETRIES)
|
|
@@ -138,7 +148,11 @@ def get_all_users(admin: Resource) -> List[Dict]:
|
|
|
138
148
|
|
|
139
149
|
|
|
140
150
|
@timeit
|
|
141
|
-
def load_gsuite_groups(
|
|
151
|
+
def load_gsuite_groups(
|
|
152
|
+
neo4j_session: neo4j.Session,
|
|
153
|
+
groups: List[Dict],
|
|
154
|
+
gsuite_update_tag: int,
|
|
155
|
+
) -> None:
|
|
142
156
|
ingestion_qry = """
|
|
143
157
|
UNWIND $GroupData as group
|
|
144
158
|
MERGE (g:GSuiteGroup{id: group.id})
|
|
@@ -156,12 +170,16 @@ def load_gsuite_groups(neo4j_session: neo4j.Session, groups: List[Dict], gsuite_
|
|
|
156
170
|
g:GCPPrincipal,
|
|
157
171
|
g.lastupdated = $UpdateTag
|
|
158
172
|
"""
|
|
159
|
-
logger.info(f
|
|
173
|
+
logger.info(f"Ingesting {len(groups)} gsuite groups")
|
|
160
174
|
neo4j_session.run(ingestion_qry, GroupData=groups, UpdateTag=gsuite_update_tag)
|
|
161
175
|
|
|
162
176
|
|
|
163
177
|
@timeit
|
|
164
|
-
def load_gsuite_users(
|
|
178
|
+
def load_gsuite_users(
|
|
179
|
+
neo4j_session: neo4j.Session,
|
|
180
|
+
users: List[Dict],
|
|
181
|
+
gsuite_update_tag: int,
|
|
182
|
+
) -> None:
|
|
165
183
|
ingestion_qry = """
|
|
166
184
|
UNWIND $UserData as user
|
|
167
185
|
MERGE (u:GSuiteUser{id: user.id})
|
|
@@ -196,12 +214,17 @@ def load_gsuite_users(neo4j_session: neo4j.Session, users: List[Dict], gsuite_up
|
|
|
196
214
|
u:GCPPrincipal,
|
|
197
215
|
u.lastupdated = $UpdateTag
|
|
198
216
|
"""
|
|
199
|
-
logger.info(f
|
|
217
|
+
logger.info(f"Ingesting {len(users)} gsuite users")
|
|
200
218
|
neo4j_session.run(ingestion_qry, UserData=users, UpdateTag=gsuite_update_tag)
|
|
201
219
|
|
|
202
220
|
|
|
203
221
|
@timeit
|
|
204
|
-
def load_gsuite_members(
|
|
222
|
+
def load_gsuite_members(
|
|
223
|
+
neo4j_session: neo4j.Session,
|
|
224
|
+
group: Dict,
|
|
225
|
+
members: List[Dict],
|
|
226
|
+
gsuite_update_tag: int,
|
|
227
|
+
) -> None:
|
|
205
228
|
ingestion_qry = """
|
|
206
229
|
UNWIND $MemberData as member
|
|
207
230
|
MATCH (user:GSuiteUser {id: member.id}),(group:GSuiteGroup {id: $GroupID })
|
|
@@ -226,22 +249,33 @@ def load_gsuite_members(neo4j_session: neo4j.Session, group: Dict, members: List
|
|
|
226
249
|
SET
|
|
227
250
|
r.lastupdated = $UpdateTag
|
|
228
251
|
"""
|
|
229
|
-
neo4j_session.run(
|
|
252
|
+
neo4j_session.run(
|
|
253
|
+
membership_qry,
|
|
254
|
+
MemberData=members,
|
|
255
|
+
GroupID=group.get("id"),
|
|
256
|
+
UpdateTag=gsuite_update_tag,
|
|
257
|
+
)
|
|
230
258
|
|
|
231
259
|
|
|
232
260
|
@timeit
|
|
233
|
-
def cleanup_gsuite_users(
|
|
261
|
+
def cleanup_gsuite_users(
|
|
262
|
+
neo4j_session: neo4j.Session,
|
|
263
|
+
common_job_parameters: Dict,
|
|
264
|
+
) -> None:
|
|
234
265
|
run_cleanup_job(
|
|
235
|
-
|
|
266
|
+
"gsuite_ingest_users_cleanup.json",
|
|
236
267
|
neo4j_session,
|
|
237
268
|
common_job_parameters,
|
|
238
269
|
)
|
|
239
270
|
|
|
240
271
|
|
|
241
272
|
@timeit
|
|
242
|
-
def cleanup_gsuite_groups(
|
|
273
|
+
def cleanup_gsuite_groups(
|
|
274
|
+
neo4j_session: neo4j.Session,
|
|
275
|
+
common_job_parameters: Dict,
|
|
276
|
+
) -> None:
|
|
243
277
|
run_cleanup_job(
|
|
244
|
-
|
|
278
|
+
"gsuite_ingest_groups_cleanup.json",
|
|
245
279
|
neo4j_session,
|
|
246
280
|
common_job_parameters,
|
|
247
281
|
)
|
|
@@ -249,7 +283,10 @@ def cleanup_gsuite_groups(neo4j_session: neo4j.Session, common_job_parameters: D
|
|
|
249
283
|
|
|
250
284
|
@timeit
|
|
251
285
|
def sync_gsuite_users(
|
|
252
|
-
neo4j_session: neo4j.Session,
|
|
286
|
+
neo4j_session: neo4j.Session,
|
|
287
|
+
admin: Resource,
|
|
288
|
+
gsuite_update_tag: int,
|
|
289
|
+
common_job_parameters: Dict,
|
|
253
290
|
) -> None:
|
|
254
291
|
"""
|
|
255
292
|
GET GSuite user objects using the google admin api resource, load the data into Neo4j and clean up stale nodes.
|
|
@@ -261,7 +298,7 @@ def sync_gsuite_users(
|
|
|
261
298
|
:param common_job_parameters: Parameters to carry to the Neo4j jobs
|
|
262
299
|
:return: Nothing
|
|
263
300
|
"""
|
|
264
|
-
logger.debug(
|
|
301
|
+
logger.debug("Syncing GSuite Users")
|
|
265
302
|
resp_objs = get_all_users(admin)
|
|
266
303
|
users = transform_users(resp_objs)
|
|
267
304
|
load_gsuite_users(neo4j_session, users, gsuite_update_tag)
|
|
@@ -270,7 +307,10 @@ def sync_gsuite_users(
|
|
|
270
307
|
|
|
271
308
|
@timeit
|
|
272
309
|
def sync_gsuite_groups(
|
|
273
|
-
neo4j_session: neo4j.Session,
|
|
310
|
+
neo4j_session: neo4j.Session,
|
|
311
|
+
admin: Resource,
|
|
312
|
+
gsuite_update_tag: int,
|
|
313
|
+
common_job_parameters: Dict,
|
|
274
314
|
) -> None:
|
|
275
315
|
"""
|
|
276
316
|
GET GSuite group objects using the google admin api resource, load the data into Neo4j and clean up stale nodes.
|
|
@@ -282,7 +322,7 @@ def sync_gsuite_groups(
|
|
|
282
322
|
:param common_job_parameters: Parameters to carry to the Neo4j jobs
|
|
283
323
|
:return: Nothing
|
|
284
324
|
"""
|
|
285
|
-
logger.debug(
|
|
325
|
+
logger.debug("Syncing GSuite Groups")
|
|
286
326
|
resp_objs = get_all_groups(admin)
|
|
287
327
|
groups = transform_groups(resp_objs)
|
|
288
328
|
load_gsuite_groups(neo4j_session, groups, gsuite_update_tag)
|
|
@@ -292,8 +332,11 @@ def sync_gsuite_groups(
|
|
|
292
332
|
|
|
293
333
|
@timeit
|
|
294
334
|
def sync_gsuite_members(
|
|
295
|
-
groups: List[Dict],
|
|
335
|
+
groups: List[Dict],
|
|
336
|
+
neo4j_session: neo4j.Session,
|
|
337
|
+
admin: Resource,
|
|
338
|
+
gsuite_update_tag: int,
|
|
296
339
|
) -> None:
|
|
297
340
|
for group in groups:
|
|
298
|
-
members = get_members_for_group(admin, group[
|
|
341
|
+
members = get_members_for_group(admin, group["email"])
|
|
299
342
|
load_gsuite_members(neo4j_session, group, members, gsuite_update_tag)
|
|
@@ -1,13 +1,31 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
1
3
|
import neo4j
|
|
2
4
|
|
|
3
5
|
from cartography.config import Config
|
|
4
6
|
from cartography.intel.jamf import computers
|
|
5
7
|
from cartography.util import timeit
|
|
6
8
|
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
7
11
|
|
|
8
12
|
@timeit
|
|
9
13
|
def start_jamf_ingestion(neo4j_session: neo4j.Session, config: Config) -> None:
|
|
14
|
+
|
|
15
|
+
if not config.jamf_base_uri or not config.jamf_user or not config.jamf_password:
|
|
16
|
+
# If the config is not set, we don't want to run this module
|
|
17
|
+
logger.info(
|
|
18
|
+
"Jamf import is not configured - skipping this module. See docs to configure."
|
|
19
|
+
)
|
|
20
|
+
return
|
|
21
|
+
|
|
10
22
|
common_job_parameters = {
|
|
11
23
|
"UPDATE_TAG": config.update_tag,
|
|
12
24
|
}
|
|
13
|
-
computers.sync(
|
|
25
|
+
computers.sync(
|
|
26
|
+
neo4j_session,
|
|
27
|
+
config.jamf_base_uri,
|
|
28
|
+
config.jamf_user,
|
|
29
|
+
config.jamf_password,
|
|
30
|
+
common_job_parameters,
|
|
31
|
+
)
|
|
@@ -8,17 +8,24 @@ from cartography.intel.jamf.util import call_jamf_api
|
|
|
8
8
|
from cartography.util import run_cleanup_job
|
|
9
9
|
from cartography.util import timeit
|
|
10
10
|
|
|
11
|
-
|
|
12
11
|
logger = logging.getLogger(__name__)
|
|
13
12
|
|
|
14
13
|
|
|
15
14
|
@timeit
|
|
16
|
-
def get_computer_groups(
|
|
15
|
+
def get_computer_groups(
|
|
16
|
+
jamf_base_uri: str,
|
|
17
|
+
jamf_user: str,
|
|
18
|
+
jamf_password: str,
|
|
19
|
+
) -> List[Dict]:
|
|
17
20
|
return call_jamf_api("/computergroups", jamf_base_uri, jamf_user, jamf_password)
|
|
18
21
|
|
|
19
22
|
|
|
20
23
|
@timeit
|
|
21
|
-
def load_computer_groups(
|
|
24
|
+
def load_computer_groups(
|
|
25
|
+
data: Dict,
|
|
26
|
+
neo4j_session: neo4j.Session,
|
|
27
|
+
update_tag: int,
|
|
28
|
+
) -> None:
|
|
22
29
|
ingest_groups = """
|
|
23
30
|
UNWIND $JsonData as group
|
|
24
31
|
MERGE (g:JamfComputerGroup{id: group.id})
|
|
@@ -33,12 +40,19 @@ def load_computer_groups(data: Dict, neo4j_session: neo4j.Session, update_tag: i
|
|
|
33
40
|
|
|
34
41
|
@timeit
|
|
35
42
|
def cleanup(neo4j_session: neo4j.Session, common_job_parameters: Dict) -> None:
|
|
36
|
-
run_cleanup_job(
|
|
43
|
+
run_cleanup_job(
|
|
44
|
+
"jamf_import_computers_cleanup.json",
|
|
45
|
+
neo4j_session,
|
|
46
|
+
common_job_parameters,
|
|
47
|
+
)
|
|
37
48
|
|
|
38
49
|
|
|
39
50
|
@timeit
|
|
40
51
|
def sync_computer_groups(
|
|
41
|
-
neo4j_session: neo4j.Session,
|
|
52
|
+
neo4j_session: neo4j.Session,
|
|
53
|
+
update_tag: int,
|
|
54
|
+
jamf_base_uri: str,
|
|
55
|
+
jamf_user: str,
|
|
42
56
|
jamf_password: str,
|
|
43
57
|
) -> None:
|
|
44
58
|
groups = get_computer_groups(jamf_base_uri, jamf_user, jamf_password)
|
|
@@ -47,7 +61,16 @@ def sync_computer_groups(
|
|
|
47
61
|
|
|
48
62
|
@timeit
|
|
49
63
|
def sync(
|
|
50
|
-
neo4j_session: neo4j.Session,
|
|
64
|
+
neo4j_session: neo4j.Session,
|
|
65
|
+
jamf_base_uri: str,
|
|
66
|
+
jamf_user: str,
|
|
67
|
+
jamf_password: str,
|
|
51
68
|
common_job_parameters: Dict,
|
|
52
69
|
) -> None:
|
|
53
|
-
sync_computer_groups(
|
|
70
|
+
sync_computer_groups(
|
|
71
|
+
neo4j_session,
|
|
72
|
+
common_job_parameters["UPDATE_TAG"],
|
|
73
|
+
jamf_base_uri,
|
|
74
|
+
jamf_user,
|
|
75
|
+
jamf_password,
|
|
76
|
+
)
|