cartography 0.101.1rc2__py3-none-any.whl → 0.102.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/_version.py CHANGED
@@ -17,5 +17,5 @@ __version__: str
17
17
  __version_tuple__: VERSION_TUPLE
18
18
  version_tuple: VERSION_TUPLE
19
19
 
20
- __version__ = version = '0.101.1rc2'
21
- __version_tuple__ = version_tuple = (0, 101, 1)
20
+ __version__ = version = '0.102.0rc1'
21
+ __version_tuple__ = version_tuple = (0, 102, 0)
cartography/cli.py CHANGED
@@ -211,6 +211,30 @@ class CLI:
211
211
  'The name of environment variable containing Azure Client Secret for Service Principal Authentication.'
212
212
  ),
213
213
  )
214
+ parser.add_argument(
215
+ '--entra-tenant-id',
216
+ type=str,
217
+ default=None,
218
+ help=(
219
+ 'Entra Tenant Id for Service Principal Authentication.'
220
+ ),
221
+ )
222
+ parser.add_argument(
223
+ '--entra-client-id',
224
+ type=str,
225
+ default=None,
226
+ help=(
227
+ 'Entra Client Id for Service Principal Authentication.'
228
+ ),
229
+ )
230
+ parser.add_argument(
231
+ '--entra-client-secret-env-var',
232
+ type=str,
233
+ default=None,
234
+ help=(
235
+ 'The name of environment variable containing Entra Client Secret for Service Principal Authentication.'
236
+ ),
237
+ )
214
238
  parser.add_argument(
215
239
  '--aws-requested-syncs',
216
240
  type=str,
@@ -615,6 +639,16 @@ class CLI:
615
639
  else:
616
640
  config.azure_client_secret = None
617
641
 
642
+ # Entra config
643
+ if config.entra_tenant_id and config.entra_client_id and config.entra_client_secret_env_var:
644
+ logger.debug(
645
+ "Reading Client Secret for Entra Authentication from environment variable %s",
646
+ config.entra_client_secret_env_var,
647
+ )
648
+ config.entra_client_secret = os.environ.get(config.entra_client_secret_env_var)
649
+ else:
650
+ config.entra_client_secret = None
651
+
618
652
  # Okta config
619
653
  if config.okta_org_id and config.okta_api_key_env_var:
620
654
  logger.debug(f"Reading API key for Okta from environment variable {config.okta_api_key_env_var}")
@@ -798,5 +832,9 @@ def main(argv=None):
798
832
  logging.getLogger('botocore').setLevel(logging.WARNING)
799
833
  logging.getLogger('googleapiclient').setLevel(logging.WARNING)
800
834
  logging.getLogger('neo4j').setLevel(logging.WARNING)
835
+ logging.getLogger('azure.identity').setLevel(logging.WARNING)
836
+ logging.getLogger('httpx').setLevel(logging.WARNING)
837
+ logging.getLogger('azure.core.pipeline.policies.http_logging_policy').setLevel(logging.WARNING)
838
+
801
839
  argv = argv if argv is not None else sys.argv[1:]
802
840
  sys.exit(CLI(prog='cartography').main(argv))
cartography/config.py CHANGED
@@ -41,6 +41,12 @@ class Config:
41
41
  :param azure_client_id: Client Id for connecting in a Service Principal Authentication approach. Optional.
42
42
  :type azure_client_secret: str
43
43
  :param azure_client_secret: Client Secret for connecting in a Service Principal Authentication approach. Optional.
44
+ :type entra_tenant_id: str
45
+ :param entra_tenant_id: Tenant Id for connecting in a Service Principal Authentication approach. Optional.
46
+ :type entra_client_id: str
47
+ :param entra_client_id: Client Id for connecting in a Service Principal Authentication approach. Optional.
48
+ :type entra_client_secret: str
49
+ :param entra_client_secret: Client Secret for connecting in a Service Principal Authentication approach. Optional.
44
50
  :type aws_requested_syncs: str
45
51
  :param aws_requested_syncs: Comma-separated list of AWS resources to sync. Optional.
46
52
  :type analysis_job_directory: str
@@ -133,6 +139,9 @@ class Config:
133
139
  azure_tenant_id=None,
134
140
  azure_client_id=None,
135
141
  azure_client_secret=None,
142
+ entra_tenant_id=None,
143
+ entra_client_id=None,
144
+ entra_client_secret=None,
136
145
  aws_requested_syncs=None,
137
146
  analysis_job_directory=None,
138
147
  oci_sync_all_profiles=None,
@@ -191,6 +200,9 @@ class Config:
191
200
  self.azure_tenant_id = azure_tenant_id
192
201
  self.azure_client_id = azure_client_id
193
202
  self.azure_client_secret = azure_client_secret
203
+ self.entra_tenant_id = entra_tenant_id
204
+ self.entra_client_id = entra_client_id
205
+ self.entra_client_secret = entra_client_secret
194
206
  self.aws_requested_syncs = aws_requested_syncs
195
207
  self.analysis_job_directory = analysis_job_directory
196
208
  self.oci_sync_all_profiles = oci_sync_all_profiles
@@ -191,9 +191,6 @@ CREATE INDEX IF NOT EXISTS FOR (n:KMSGrant) ON (n.lastupdated);
191
191
  CREATE INDEX IF NOT EXISTS FOR (n:LaunchConfiguration) ON (n.id);
192
192
  CREATE INDEX IF NOT EXISTS FOR (n:LaunchConfiguration) ON (n.name);
193
193
  CREATE INDEX IF NOT EXISTS FOR (n:LaunchConfiguration) ON (n.lastupdated);
194
- CREATE INDEX IF NOT EXISTS FOR (n:LoadBalancer) ON (n.dnsname);
195
- CREATE INDEX IF NOT EXISTS FOR (n:LoadBalancer) ON (n.id);
196
- CREATE INDEX IF NOT EXISTS FOR (n:LoadBalancer) ON (n.lastupdated);
197
194
  CREATE INDEX IF NOT EXISTS FOR (n:LoadBalancerV2) ON (n.dnsname);
198
195
  CREATE INDEX IF NOT EXISTS FOR (n:LoadBalancerV2) ON (n.id);
199
196
  CREATE INDEX IF NOT EXISTS FOR (n:LoadBalancerV2) ON (n.lastupdated);
@@ -37,37 +37,18 @@ def get_launch_template_versions(
37
37
  boto3_session: boto3.session.Session,
38
38
  region: str,
39
39
  launch_templates: list[dict[str, Any]],
40
- ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
41
- found_versions: list[dict[str, Any]] = []
42
- found_templates: list[dict[str, Any]] = []
40
+ ) -> list[dict[str, Any]]:
41
+ template_versions: list[dict[str, Any]] = []
43
42
  for template in launch_templates:
44
43
  launch_template_id = template['LaunchTemplateId']
45
- try:
46
- versions = get_launch_template_versions_by_template(
47
- boto3_session,
48
- launch_template_id,
49
- region,
50
- )
51
- # If the call succeeded, the template still exists.
52
- # Add it and its versions (list might be empty if no versions exist).
53
- found_templates.append(template)
54
- found_versions.extend(versions)
55
- except botocore.exceptions.ClientError as e:
56
- if e.response['Error']['Code'] == 'InvalidLaunchTemplateId.NotFound':
57
- logger.warning(
58
- "Launch template %s no longer exists in region %s, skipping.",
59
- launch_template_id, region,
60
- )
61
- # Skip this template, don't add it or its versions
62
- continue
63
- else:
64
- # Re-raise any other client error
65
- raise
66
-
67
- return found_versions, found_templates
44
+ versions = get_launch_template_versions_by_template(boto3_session, launch_template_id, region)
45
+ template_versions.extend(versions)
46
+
47
+ return template_versions
68
48
 
69
49
 
70
50
  @timeit
51
+ @aws_handle_regions
71
52
  def get_launch_template_versions_by_template(
72
53
  boto3_session: boto3.session.Session,
73
54
  launch_template_id: str,
@@ -76,15 +57,27 @@ def get_launch_template_versions_by_template(
76
57
  client = boto3_session.client('ec2', region_name=region, config=get_botocore_config())
77
58
  v_paginator = client.get_paginator('describe_launch_template_versions')
78
59
  template_versions = []
79
- for versions_page in v_paginator.paginate(LaunchTemplateId=launch_template_id):
80
- template_versions.extend(versions_page['LaunchTemplateVersions'])
60
+ try:
61
+ for versions in v_paginator.paginate(LaunchTemplateId=launch_template_id):
62
+ template_versions.extend(versions['LaunchTemplateVersions'])
63
+ except botocore.exceptions.ClientError as e:
64
+ error_code = e.response['Error']['Code']
65
+ if error_code == 'InvalidLaunchTemplateId.NotFound':
66
+ logger.warning("Launch template %s no longer exists in region %s", launch_template_id, region)
67
+ else:
68
+ raise
81
69
  return template_versions
82
70
 
83
71
 
84
- def transform_launch_templates(templates: list[dict[str, Any]]) -> list[dict[str, Any]]:
72
+ def transform_launch_templates(templates: list[dict[str, Any]], versions: list[dict[str, Any]]) -> list[dict[str, Any]]:
73
+ valid_template_ids = {v['LaunchTemplateId'] for v in versions}
85
74
  result: list[dict[str, Any]] = []
86
75
  for template in templates:
76
+ if template['LaunchTemplateId'] not in valid_template_ids:
77
+ continue
78
+
87
79
  current = template.copy()
80
+ # Convert CreateTime to timestamp string
88
81
  current['CreateTime'] = str(int(current['CreateTime'].timestamp()))
89
82
  result.append(current)
90
83
  return result
@@ -176,11 +169,13 @@ def sync_ec2_launch_templates(
176
169
  for region in regions:
177
170
  logger.info(f"Syncing launch templates for region '{region}' in account '{current_aws_account_id}'.")
178
171
  templates = get_launch_templates(boto3_session, region)
179
- versions, found_templates = get_launch_template_versions(boto3_session, region, templates)
172
+ versions = get_launch_template_versions(boto3_session, region, templates)
180
173
 
181
- # Transform and load only the templates that were found to exist
182
- transformed_templates = transform_launch_templates(found_templates)
174
+ # Transform and load the templates that have versions
175
+ transformed_templates = transform_launch_templates(templates, versions)
183
176
  load_launch_templates(neo4j_session, transformed_templates, region, current_aws_account_id, update_tag)
177
+
178
+ # Transform and load the versions
184
179
  transformed_versions = transform_launch_template_versions(versions)
185
180
  load_launch_template_versions(neo4j_session, transformed_versions, region, current_aws_account_id, update_tag)
186
181
 
@@ -1,190 +1,168 @@
1
1
  import logging
2
- from typing import Dict
3
- from typing import List
4
2
 
5
3
  import boto3
6
4
  import neo4j
7
5
 
8
6
  from .util import get_botocore_config
7
+ from cartography.client.core.tx import load
8
+ from cartography.graph.job import GraphJob
9
+ from cartography.models.aws.ec2.load_balancer_listeners import ELBListenerSchema
10
+ from cartography.models.aws.ec2.load_balancers import LoadBalancerSchema
9
11
  from cartography.util import aws_handle_regions
10
- from cartography.util import run_cleanup_job
11
12
  from cartography.util import timeit
12
13
 
13
14
  logger = logging.getLogger(__name__)
14
15
 
15
16
 
17
+ def _get_listener_id(load_balancer_id: str, port: int, protocol: str) -> str:
18
+ """
19
+ Generate a unique ID for a load balancer listener.
20
+
21
+ Args:
22
+ load_balancer_id: The ID of the load balancer
23
+ port: The listener port
24
+ protocol: The listener protocol
25
+
26
+ Returns:
27
+ A unique ID string for the listener
28
+ """
29
+ return f"{load_balancer_id}{port}{protocol}"
30
+
31
+
32
+ def transform_load_balancer_listener_data(load_balancer_id: str, listener_data: list[dict]) -> list[dict]:
33
+ """
34
+ Transform load balancer listener data into a format suitable for cartography ingestion.
35
+
36
+ Args:
37
+ load_balancer_id: The ID of the load balancer
38
+ listener_data: List of listener data from AWS API
39
+
40
+ Returns:
41
+ List of transformed listener data
42
+ """
43
+ transformed = []
44
+ for listener in listener_data:
45
+ listener_info = listener['Listener']
46
+ transformed_listener = {
47
+ 'id': _get_listener_id(load_balancer_id, listener_info['LoadBalancerPort'], listener_info['Protocol']),
48
+ 'port': listener_info.get('LoadBalancerPort'),
49
+ 'protocol': listener_info.get('Protocol'),
50
+ 'instance_port': listener_info.get('InstancePort'),
51
+ 'instance_protocol': listener_info.get('InstanceProtocol'),
52
+ 'policy_names': listener.get('PolicyNames', []),
53
+ 'LoadBalancerId': load_balancer_id,
54
+ }
55
+ transformed.append(transformed_listener)
56
+ return transformed
57
+
58
+
59
+ def transform_load_balancer_data(load_balancers: list[dict]) -> tuple[list[dict], list[dict]]:
60
+ """
61
+ Transform load balancer data into a format suitable for cartography ingestion.
62
+
63
+ Args:
64
+ load_balancers: List of load balancer data from AWS API
65
+
66
+ Returns:
67
+ Tuple of (transformed load balancer data, transformed listener data)
68
+ """
69
+ transformed = []
70
+ listener_data = []
71
+
72
+ for lb in load_balancers:
73
+ load_balancer_id = lb['DNSName']
74
+ transformed_lb = {
75
+ 'id': load_balancer_id,
76
+ 'name': lb['LoadBalancerName'],
77
+ 'dnsname': lb['DNSName'],
78
+ 'canonicalhostedzonename': lb.get('CanonicalHostedZoneName'),
79
+ 'canonicalhostedzonenameid': lb.get('CanonicalHostedZoneNameID'),
80
+ 'scheme': lb.get('Scheme'),
81
+ 'createdtime': str(lb['CreatedTime']),
82
+ 'GROUP_NAME': lb.get('SourceSecurityGroup', {}).get('GroupName'),
83
+ 'GROUP_IDS': [str(group) for group in lb.get('SecurityGroups', [])],
84
+ 'INSTANCE_IDS': [instance['InstanceId'] for instance in lb.get('Instances', [])],
85
+ 'LISTENER_IDS': [
86
+ _get_listener_id(
87
+ load_balancer_id,
88
+ listener['Listener']['LoadBalancerPort'],
89
+ listener['Listener']['Protocol'],
90
+ ) for listener in lb.get('ListenerDescriptions', [])
91
+ ],
92
+ }
93
+ transformed.append(transformed_lb)
94
+
95
+ # Classic ELB listeners are not returned anywhere else in AWS, so we must parse them out
96
+ # of the describe_load_balancers response.
97
+ if lb.get('ListenerDescriptions'):
98
+ listener_data.extend(
99
+ transform_load_balancer_listener_data(
100
+ load_balancer_id,
101
+ lb.get('ListenerDescriptions', []),
102
+ ),
103
+ )
104
+
105
+ return transformed, listener_data
106
+
107
+
16
108
  @timeit
17
109
  @aws_handle_regions
18
- def get_loadbalancer_data(boto3_session: boto3.session.Session, region: str) -> List[Dict]:
110
+ def get_loadbalancer_data(boto3_session: boto3.session.Session, region: str) -> list[dict]:
19
111
  client = boto3_session.client('elb', region_name=region, config=get_botocore_config())
20
112
  paginator = client.get_paginator('describe_load_balancers')
21
- elbs: List[Dict] = []
113
+ elbs: list[dict] = []
22
114
  for page in paginator.paginate():
23
115
  elbs.extend(page['LoadBalancerDescriptions'])
24
116
  return elbs
25
117
 
26
118
 
27
119
  @timeit
28
- def load_load_balancer_listeners(
29
- neo4j_session: neo4j.Session, load_balancer_id: str, listener_data: List[Dict],
120
+ def load_load_balancers(
121
+ neo4j_session: neo4j.Session, data: list[dict], region: str, current_aws_account_id: str,
30
122
  update_tag: int,
31
123
  ) -> None:
32
- ingest_listener = """
33
- MATCH (elb:LoadBalancer{id: $LoadBalancerId})
34
- WITH elb
35
- UNWIND $Listeners as data
36
- MERGE (l:Endpoint:ELBListener{id: elb.id + toString(data.Listener.LoadBalancerPort) +
37
- toString(data.Listener.Protocol)})
38
- ON CREATE SET l.port = data.Listener.LoadBalancerPort, l.protocol = data.Listener.Protocol,
39
- l.firstseen = timestamp()
40
- SET l.instance_port = data.Listener.InstancePort, l.instance_protocol = data.Listener.InstanceProtocol,
41
- l.policy_names = data.PolicyNames,
42
- l.lastupdated = $update_tag
43
- WITH l, elb
44
- MERGE (elb)-[r:ELB_LISTENER]->(l)
45
- ON CREATE SET r.firstseen = timestamp()
46
- SET r.lastupdated = $update_tag
47
- """
48
-
49
- neo4j_session.run(
50
- ingest_listener,
51
- LoadBalancerId=load_balancer_id,
52
- Listeners=listener_data,
53
- update_tag=update_tag,
124
+ load(
125
+ neo4j_session,
126
+ LoadBalancerSchema(),
127
+ data,
128
+ Region=region,
129
+ AWS_ID=current_aws_account_id,
130
+ lastupdated=update_tag,
54
131
  )
55
132
 
56
133
 
57
134
  @timeit
58
- def load_load_balancer_subnets(
59
- neo4j_session: neo4j.Session, load_balancer_id: str, subnets_data: List[Dict],
60
- update_tag: int,
61
- ) -> None:
62
- ingest_load_balancer_subnet = """
63
- MATCH (elb:LoadBalancer{id: $ID}), (subnet:EC2Subnet{subnetid: $SUBNET_ID})
64
- MERGE (elb)-[r:SUBNET]->(subnet)
65
- ON CREATE SET r.firstseen = timestamp()
66
- SET r.lastupdated = $update_tag
67
- """
68
-
69
- for subnet_id in subnets_data:
70
- neo4j_session.run(
71
- ingest_load_balancer_subnet,
72
- ID=load_balancer_id,
73
- SUBNET_ID=subnet_id,
74
- update_tag=update_tag,
75
- )
76
-
77
-
78
- @timeit
79
- def load_load_balancers(
80
- neo4j_session: neo4j.Session, data: List[Dict], region: str, current_aws_account_id: str,
135
+ def load_load_balancer_listeners(
136
+ neo4j_session: neo4j.Session, data: list[dict], region: str, current_aws_account_id: str,
81
137
  update_tag: int,
82
138
  ) -> None:
83
- ingest_load_balancer = """
84
- MERGE (elb:LoadBalancer{id: $ID})
85
- ON CREATE SET elb.firstseen = timestamp(), elb.createdtime = $CREATED_TIME
86
- SET elb.lastupdated = $update_tag, elb.name = $NAME, elb.dnsname = $DNS_NAME,
87
- elb.canonicalhostedzonename = $HOSTED_ZONE_NAME, elb.canonicalhostedzonenameid = $HOSTED_ZONE_NAME_ID,
88
- elb.scheme = $SCHEME, elb.region = $Region
89
- WITH elb
90
- MATCH (aa:AWSAccount{id: $AWS_ACCOUNT_ID})
91
- MERGE (aa)-[r:RESOURCE]->(elb)
92
- ON CREATE SET r.firstseen = timestamp()
93
- SET r.lastupdated = $update_tag
94
- """
95
-
96
- ingest_load_balancersource_security_group = """
97
- MATCH (elb:LoadBalancer{id: $ID}),
98
- (group:EC2SecurityGroup{name: $GROUP_NAME})
99
- MERGE (elb)-[r:SOURCE_SECURITY_GROUP]->(group)
100
- ON CREATE SET r.firstseen = timestamp()
101
- SET r.lastupdated = $update_tag
102
- """
103
-
104
- ingest_load_balancer_security_group = """
105
- MATCH (elb:LoadBalancer{id: $ID}),
106
- (group:EC2SecurityGroup{groupid: $GROUP_ID})
107
- MERGE (elb)-[r:MEMBER_OF_EC2_SECURITY_GROUP]->(group)
108
- ON CREATE SET r.firstseen = timestamp()
109
- SET r.lastupdated = $update_tag
110
- """
111
-
112
- ingest_instances = """
113
- MATCH (elb:LoadBalancer{id: $ID}), (instance:EC2Instance{instanceid: $INSTANCE_ID})
114
- MERGE (elb)-[r:EXPOSE]->(instance)
115
- ON CREATE SET r.firstseen = timestamp()
116
- SET r.lastupdated = $update_tag
117
- WITH instance
118
- MATCH (aa:AWSAccount{id: $AWS_ACCOUNT_ID})
119
- MERGE (aa)-[r:RESOURCE]->(instance)
120
- ON CREATE SET r.firstseen = timestamp()
121
- SET r.lastupdated = $update_tag
122
- """
123
-
124
- for lb in data:
125
- load_balancer_id = lb["DNSName"]
126
-
127
- neo4j_session.run(
128
- ingest_load_balancer,
129
- ID=load_balancer_id,
130
- CREATED_TIME=str(lb["CreatedTime"]),
131
- NAME=lb["LoadBalancerName"],
132
- DNS_NAME=load_balancer_id,
133
- HOSTED_ZONE_NAME=lb.get("CanonicalHostedZoneName"),
134
- HOSTED_ZONE_NAME_ID=lb.get("CanonicalHostedZoneNameID"),
135
- SCHEME=lb.get("Scheme", ""),
136
- AWS_ACCOUNT_ID=current_aws_account_id,
137
- Region=region,
138
- update_tag=update_tag,
139
- )
140
-
141
- if lb["Subnets"]:
142
- load_load_balancer_subnets(neo4j_session, load_balancer_id, lb["Subnets"], update_tag)
143
-
144
- if lb["SecurityGroups"]:
145
- for group in lb["SecurityGroups"]:
146
- neo4j_session.run(
147
- ingest_load_balancer_security_group,
148
- ID=load_balancer_id,
149
- GROUP_ID=str(group),
150
- update_tag=update_tag,
151
- )
152
-
153
- if lb["SourceSecurityGroup"]:
154
- source_group = lb["SourceSecurityGroup"]
155
- neo4j_session.run(
156
- ingest_load_balancersource_security_group,
157
- ID=load_balancer_id,
158
- GROUP_NAME=source_group["GroupName"],
159
- update_tag=update_tag,
160
- )
161
-
162
- if lb["Instances"]:
163
- for instance in lb["Instances"]:
164
- neo4j_session.run(
165
- ingest_instances,
166
- ID=load_balancer_id,
167
- INSTANCE_ID=instance["InstanceId"],
168
- AWS_ACCOUNT_ID=current_aws_account_id,
169
- update_tag=update_tag,
170
- )
171
-
172
- if lb["ListenerDescriptions"]:
173
- load_load_balancer_listeners(neo4j_session, load_balancer_id, lb["ListenerDescriptions"], update_tag)
139
+ load(
140
+ neo4j_session,
141
+ ELBListenerSchema(),
142
+ data,
143
+ Region=region,
144
+ AWS_ID=current_aws_account_id,
145
+ lastupdated=update_tag,
146
+ )
174
147
 
175
148
 
176
149
  @timeit
177
- def cleanup_load_balancers(neo4j_session: neo4j.Session, common_job_parameters: Dict) -> None:
178
- run_cleanup_job('aws_ingest_load_balancers_cleanup.json', neo4j_session, common_job_parameters)
150
+ def cleanup_load_balancers(neo4j_session: neo4j.Session, common_job_parameters: dict) -> None:
151
+ GraphJob.from_node_schema(ELBListenerSchema(), common_job_parameters).run(neo4j_session)
152
+ GraphJob.from_node_schema(LoadBalancerSchema(), common_job_parameters).run(neo4j_session)
179
153
 
180
154
 
181
155
  @timeit
182
156
  def sync_load_balancers(
183
- neo4j_session: neo4j.Session, boto3_session: boto3.session.Session, regions: List[str], current_aws_account_id: str,
184
- update_tag: int, common_job_parameters: Dict,
157
+ neo4j_session: neo4j.Session, boto3_session: boto3.session.Session, regions: list[str], current_aws_account_id: str,
158
+ update_tag: int, common_job_parameters: dict,
185
159
  ) -> None:
186
160
  for region in regions:
187
161
  logger.info("Syncing EC2 load balancers for region '%s' in account '%s'.", region, current_aws_account_id)
188
162
  data = get_loadbalancer_data(boto3_session, region)
189
- load_load_balancers(neo4j_session, data, region, current_aws_account_id, update_tag)
163
+ transformed_data, listener_data = transform_load_balancer_data(data)
164
+
165
+ load_load_balancers(neo4j_session, transformed_data, region, current_aws_account_id, update_tag)
166
+ load_load_balancer_listeners(neo4j_session, listener_data, region, current_aws_account_id, update_tag)
167
+
190
168
  cleanup_load_balancers(neo4j_session, common_job_parameters)
@@ -0,0 +1,43 @@
1
+ import asyncio
2
+ import logging
3
+
4
+ import neo4j
5
+
6
+ from cartography.config import Config
7
+ from cartography.intel.entra.users import sync_entra_users
8
+ from cartography.util import timeit
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ @timeit
14
+ def start_entra_ingestion(neo4j_session: neo4j.Session, config: Config) -> None:
15
+ """
16
+ If this module is configured, perform ingestion of Entra data. Otherwise warn and exit
17
+ :param neo4j_session: Neo4J session for database interface
18
+ :param config: A cartography.config object
19
+ :return: None
20
+ """
21
+
22
+ if not config.entra_tenant_id or not config.entra_client_id or not config.entra_client_secret:
23
+ logger.info(
24
+ 'Entra import is not configured - skipping this module. '
25
+ 'See docs to configure.',
26
+ )
27
+ return
28
+
29
+ common_job_parameters = {
30
+ "UPDATE_TAG": config.update_tag,
31
+ "TENANT_ID": config.entra_tenant_id,
32
+ }
33
+
34
+ asyncio.run(
35
+ sync_entra_users(
36
+ neo4j_session,
37
+ config.entra_tenant_id,
38
+ config.entra_client_id,
39
+ config.entra_client_secret,
40
+ config.update_tag,
41
+ common_job_parameters,
42
+ ),
43
+ )
@@ -0,0 +1,205 @@
1
+ import logging
2
+ from typing import Any
3
+
4
+ import neo4j
5
+ from azure.identity import ClientSecretCredential
6
+ from msgraph import GraphServiceClient
7
+ from msgraph.generated.models.organization import Organization
8
+ from msgraph.generated.models.user import User
9
+
10
+ from cartography.client.core.tx import load
11
+ from cartography.graph.job import GraphJob
12
+ from cartography.models.entra.tenant import EntraTenantSchema
13
+ from cartography.models.entra.user import EntraUserSchema
14
+ from cartography.util import timeit
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ @timeit
20
+ async def get_tenant(client: GraphServiceClient) -> Organization:
21
+ """
22
+ Get tenant information from Microsoft Graph API
23
+ """
24
+ org = await client.organization.get()
25
+ return org.value[0] # Get the first (and typically only) tenant
26
+
27
+
28
+ @timeit
29
+ async def get_users(client: GraphServiceClient) -> list[User]:
30
+ """
31
+ Get all users from Microsoft Graph API with pagination support
32
+ """
33
+ all_users: list[User] = []
34
+ request_configuration = client.users.UsersRequestBuilderGetRequestConfiguration(
35
+ query_parameters=client.users.UsersRequestBuilderGetQueryParameters(
36
+ # Request more items per page to reduce number of API calls
37
+ top=999,
38
+ ),
39
+ )
40
+
41
+ page = await client.users.get(request_configuration=request_configuration)
42
+ while page:
43
+ all_users.extend(page.value)
44
+ if not page.odata_next_link:
45
+ break
46
+ page = await client.users.with_url(page.odata_next_link).get()
47
+
48
+ return all_users
49
+
50
+
51
+ @timeit
52
+ def transform_users(users: list[User]) -> list[dict[str, Any]]:
53
+ """
54
+ Transform the API response into the format expected by our schema
55
+ """
56
+ result: list[dict[str, Any]] = []
57
+ for user in users:
58
+ transformed_user = {
59
+ 'id': user.id,
60
+ 'user_principal_name': user.user_principal_name,
61
+ 'display_name': user.display_name,
62
+ 'given_name': user.given_name,
63
+ 'surname': user.surname,
64
+ 'mail': user.mail,
65
+ 'other_mails': user.other_mails,
66
+ 'preferred_language': user.preferred_language,
67
+ 'preferred_name': user.preferred_name,
68
+ 'state': user.state,
69
+ 'usage_location': user.usage_location,
70
+ 'user_type': user.user_type,
71
+ 'show_in_address_list': user.show_in_address_list,
72
+ 'sign_in_sessions_valid_from_date_time': user.sign_in_sessions_valid_from_date_time,
73
+ 'security_identifier': user.on_premises_security_identifier,
74
+ 'account_enabled': user.account_enabled,
75
+ 'age_group': user.age_group,
76
+ 'business_phones': user.business_phones,
77
+ 'city': user.city,
78
+ 'company_name': user.company_name,
79
+ 'consent_provided_for_minor': user.consent_provided_for_minor,
80
+ 'country': user.country,
81
+ 'created_date_time': user.created_date_time,
82
+ 'creation_type': user.creation_type,
83
+ 'deleted_date_time': user.deleted_date_time,
84
+ 'department': user.department,
85
+ 'employee_id': user.employee_id,
86
+ 'employee_type': user.employee_type,
87
+ 'external_user_state': user.external_user_state,
88
+ 'external_user_state_change_date_time': user.external_user_state_change_date_time,
89
+ 'hire_date': user.hire_date,
90
+ 'is_management_restricted': user.is_management_restricted,
91
+ 'is_resource_account': user.is_resource_account,
92
+ 'job_title': user.job_title,
93
+ 'last_password_change_date_time': user.last_password_change_date_time,
94
+ 'mail_nickname': user.mail_nickname,
95
+ 'office_location': user.office_location,
96
+ 'on_premises_distinguished_name': user.on_premises_distinguished_name,
97
+ 'on_premises_domain_name': user.on_premises_domain_name,
98
+ 'on_premises_immutable_id': user.on_premises_immutable_id,
99
+ 'on_premises_last_sync_date_time': user.on_premises_last_sync_date_time,
100
+ 'on_premises_sam_account_name': user.on_premises_sam_account_name,
101
+ 'on_premises_security_identifier': user.on_premises_security_identifier,
102
+ 'on_premises_sync_enabled': user.on_premises_sync_enabled,
103
+ 'on_premises_user_principal_name': user.on_premises_user_principal_name,
104
+ }
105
+ result.append(transformed_user)
106
+ return result
107
+
108
+
109
+ @timeit
110
+ def transform_tenant(tenant: Organization, tenant_id: str) -> dict[str, Any]:
111
+ """
112
+ Transform the tenant data into the format expected by our schema
113
+ """
114
+ return {
115
+ 'id': tenant_id,
116
+ 'created_date_time': tenant.created_date_time,
117
+ 'default_usage_location': tenant.default_usage_location,
118
+ 'deleted_date_time': tenant.deleted_date_time,
119
+ 'display_name': tenant.display_name,
120
+ 'marketing_notification_emails': tenant.marketing_notification_emails,
121
+ 'mobile_device_management_authority': tenant.mobile_device_management_authority,
122
+ 'on_premises_last_sync_date_time': tenant.on_premises_last_sync_date_time,
123
+ 'on_premises_sync_enabled': tenant.on_premises_sync_enabled,
124
+ 'partner_tenant_type': tenant.partner_tenant_type,
125
+ 'postal_code': tenant.postal_code,
126
+ 'preferred_language': tenant.preferred_language,
127
+ 'state': tenant.state,
128
+ 'street': tenant.street,
129
+ 'tenant_type': tenant.tenant_type,
130
+ }
131
+
132
+
133
+ @timeit
134
+ def load_tenant(
135
+ neo4j_session: neo4j.Session,
136
+ tenant: dict[str, Any],
137
+ update_tag: int,
138
+ ) -> None:
139
+ load(
140
+ neo4j_session,
141
+ EntraTenantSchema(),
142
+ [tenant],
143
+ lastupdated=update_tag,
144
+ )
145
+
146
+
147
+ @timeit
148
+ def load_users(
149
+ neo4j_session: neo4j.Session,
150
+ users: list[dict[str, Any]],
151
+ tenant_id: str,
152
+ update_tag: int,
153
+ ) -> None:
154
+ logger.info(f"Loading {len(users)} Entra users")
155
+ load(
156
+ neo4j_session,
157
+ EntraUserSchema(),
158
+ users,
159
+ lastupdated=update_tag,
160
+ TENANT_ID=tenant_id,
161
+ )
162
+
163
+
164
+ def cleanup(neo4j_session: neo4j.Session, common_job_parameters: dict[str, Any]) -> None:
165
+ GraphJob.from_node_schema(EntraUserSchema(), common_job_parameters).run(neo4j_session)
166
+
167
+
168
+ @timeit
169
+ async def sync_entra_users(
170
+ neo4j_session: neo4j.Session,
171
+ tenant_id: str,
172
+ client_id: str,
173
+ client_secret: str,
174
+ update_tag: int,
175
+ common_job_parameters: dict[str, Any],
176
+ ) -> None:
177
+ """
178
+ Sync Entra users and tenant information
179
+ :param neo4j_session: Neo4J session for database interface
180
+ :param tenant_id: Entra tenant ID
181
+ :param client_id: Entra application client ID
182
+ :param client_secret: Entra application client secret
183
+ :param update_tag: Timestamp used to determine data freshness
184
+ :param common_job_parameters: dict of other job parameters to carry to sub-jobs
185
+ :return: None
186
+ """
187
+ # Initialize Graph client
188
+ credential = ClientSecretCredential(
189
+ tenant_id=tenant_id,
190
+ client_id=client_id,
191
+ client_secret=client_secret,
192
+ )
193
+ client = GraphServiceClient(credential, scopes=['https://graph.microsoft.com/.default'])
194
+
195
+ # Get tenant information
196
+ tenant = await get_tenant(client)
197
+ users = await get_users(client)
198
+
199
+ transformed_users = transform_users(users)
200
+ transformed_tenant = transform_tenant(tenant, tenant_id)
201
+
202
+ load_tenant(neo4j_session, transformed_tenant, update_tag)
203
+ load_users(neo4j_session, transformed_users, tenant_id, update_tag)
204
+
205
+ cleanup(neo4j_session, common_job_parameters)
@@ -0,0 +1,68 @@
1
+ from dataclasses import dataclass
2
+
3
+ from cartography.models.core.common import PropertyRef
4
+ from cartography.models.core.nodes import CartographyNodeProperties
5
+ from cartography.models.core.nodes import CartographyNodeSchema
6
+ from cartography.models.core.nodes import ExtraNodeLabels
7
+ from cartography.models.core.relationships import CartographyRelProperties
8
+ from cartography.models.core.relationships import CartographyRelSchema
9
+ from cartography.models.core.relationships import LinkDirection
10
+ from cartography.models.core.relationships import make_target_node_matcher
11
+ from cartography.models.core.relationships import OtherRelationships
12
+ from cartography.models.core.relationships import TargetNodeMatcher
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class ELBListenerNodeProperties(CartographyNodeProperties):
17
+ id: PropertyRef = PropertyRef('id')
18
+ port: PropertyRef = PropertyRef('port')
19
+ protocol: PropertyRef = PropertyRef('protocol')
20
+ instance_port: PropertyRef = PropertyRef('instance_port')
21
+ instance_protocol: PropertyRef = PropertyRef('instance_protocol')
22
+ policy_names: PropertyRef = PropertyRef('policy_names')
23
+ lastupdated: PropertyRef = PropertyRef('lastupdated', set_in_kwargs=True)
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class ELBListenerToLoadBalancerRelProperties(CartographyRelProperties):
28
+ lastupdated: PropertyRef = PropertyRef('lastupdated', set_in_kwargs=True)
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class ELBListenerToLoadBalancer(CartographyRelSchema):
33
+ target_node_label: str = 'LoadBalancer'
34
+ target_node_matcher: TargetNodeMatcher = make_target_node_matcher(
35
+ {'id': PropertyRef('LoadBalancerId')},
36
+ )
37
+ direction: LinkDirection = LinkDirection.INWARD
38
+ rel_label: str = "ELB_LISTENER"
39
+ properties: ELBListenerToLoadBalancerRelProperties = ELBListenerToLoadBalancerRelProperties()
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class ELBListenerToAWSAccountRelProperties(CartographyRelProperties):
44
+ lastupdated: PropertyRef = PropertyRef('lastupdated', set_in_kwargs=True)
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class ELBListenerToAWSAccount(CartographyRelSchema):
49
+ target_node_label: str = 'AWSAccount'
50
+ target_node_matcher: TargetNodeMatcher = make_target_node_matcher(
51
+ {'id': PropertyRef('AWS_ID', set_in_kwargs=True)},
52
+ )
53
+ direction: LinkDirection = LinkDirection.INWARD
54
+ rel_label: str = "RESOURCE"
55
+ properties: ELBListenerToAWSAccountRelProperties = ELBListenerToAWSAccountRelProperties()
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class ELBListenerSchema(CartographyNodeSchema):
60
+ label: str = 'ELBListener'
61
+ properties: ELBListenerNodeProperties = ELBListenerNodeProperties()
62
+ extra_node_labels: ExtraNodeLabels = ExtraNodeLabels(['Endpoint'])
63
+ sub_resource_relationship: ELBListenerToAWSAccount = ELBListenerToAWSAccount()
64
+ other_relationships: OtherRelationships = OtherRelationships(
65
+ [
66
+ ELBListenerToLoadBalancer(),
67
+ ],
68
+ )
@@ -0,0 +1,102 @@
1
+ from dataclasses import dataclass
2
+
3
+ from cartography.models.core.common import PropertyRef
4
+ from cartography.models.core.nodes import CartographyNodeProperties
5
+ from cartography.models.core.nodes import CartographyNodeSchema
6
+ from cartography.models.core.relationships import CartographyRelProperties
7
+ from cartography.models.core.relationships import CartographyRelSchema
8
+ from cartography.models.core.relationships import LinkDirection
9
+ from cartography.models.core.relationships import make_target_node_matcher
10
+ from cartography.models.core.relationships import OtherRelationships
11
+ from cartography.models.core.relationships import TargetNodeMatcher
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class LoadBalancerNodeProperties(CartographyNodeProperties):
16
+ id: PropertyRef = PropertyRef('id')
17
+ name: PropertyRef = PropertyRef('name')
18
+ dnsname: PropertyRef = PropertyRef('dnsname', extra_index=True)
19
+ canonicalhostedzonename: PropertyRef = PropertyRef('canonicalhostedzonename')
20
+ canonicalhostedzonenameid: PropertyRef = PropertyRef('canonicalhostedzonenameid')
21
+ scheme: PropertyRef = PropertyRef('scheme')
22
+ region: PropertyRef = PropertyRef('Region', set_in_kwargs=True)
23
+ createdtime: PropertyRef = PropertyRef('createdtime')
24
+ lastupdated: PropertyRef = PropertyRef('lastupdated', set_in_kwargs=True)
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class LoadBalancerToAWSAccountRelProperties(CartographyRelProperties):
29
+ lastupdated: PropertyRef = PropertyRef('lastupdated', set_in_kwargs=True)
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class LoadBalancerToAWSAccount(CartographyRelSchema):
34
+ target_node_label: str = 'AWSAccount'
35
+ target_node_matcher: TargetNodeMatcher = make_target_node_matcher(
36
+ {'id': PropertyRef('AWS_ID', set_in_kwargs=True)},
37
+ )
38
+ direction: LinkDirection = LinkDirection.INWARD
39
+ rel_label: str = "RESOURCE"
40
+ properties: LoadBalancerToAWSAccountRelProperties = LoadBalancerToAWSAccountRelProperties()
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class LoadBalancerToSecurityGroupRelProperties(CartographyRelProperties):
45
+ lastupdated: PropertyRef = PropertyRef('lastupdated', set_in_kwargs=True)
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class LoadBalancerToSourceSecurityGroup(CartographyRelSchema):
50
+ target_node_label: str = 'EC2SecurityGroup'
51
+ target_node_matcher: TargetNodeMatcher = make_target_node_matcher(
52
+ {'name': PropertyRef('GROUP_NAME')},
53
+ )
54
+ direction: LinkDirection = LinkDirection.OUTWARD
55
+ rel_label: str = "SOURCE_SECURITY_GROUP"
56
+ properties: LoadBalancerToSecurityGroupRelProperties = LoadBalancerToSecurityGroupRelProperties()
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class LoadBalancerToEC2SecurityGroupRelProperties(CartographyRelProperties):
61
+ lastupdated: PropertyRef = PropertyRef('lastupdated', set_in_kwargs=True)
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class LoadBalancerToEC2SecurityGroup(CartographyRelSchema):
66
+ target_node_label: str = 'EC2SecurityGroup'
67
+ target_node_matcher: TargetNodeMatcher = make_target_node_matcher(
68
+ {'groupid': PropertyRef('GROUP_IDS', one_to_many=True)},
69
+ )
70
+ direction: LinkDirection = LinkDirection.OUTWARD
71
+ rel_label: str = "MEMBER_OF_EC2_SECURITY_GROUP"
72
+ properties: LoadBalancerToEC2SecurityGroupRelProperties = LoadBalancerToEC2SecurityGroupRelProperties()
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class LoadBalancerToEC2InstanceRelProperties(CartographyRelProperties):
77
+ lastupdated: PropertyRef = PropertyRef('lastupdated', set_in_kwargs=True)
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class LoadBalancerToEC2Instance(CartographyRelSchema):
82
+ target_node_label: str = 'EC2Instance'
83
+ target_node_matcher: TargetNodeMatcher = make_target_node_matcher(
84
+ {'instanceid': PropertyRef('INSTANCE_IDS', one_to_many=True)},
85
+ )
86
+ direction: LinkDirection = LinkDirection.OUTWARD
87
+ rel_label: str = "EXPOSE"
88
+ properties: LoadBalancerToEC2InstanceRelProperties = LoadBalancerToEC2InstanceRelProperties()
89
+
90
+
91
+ @dataclass(frozen=True)
92
+ class LoadBalancerSchema(CartographyNodeSchema):
93
+ label: str = 'LoadBalancer'
94
+ properties: LoadBalancerNodeProperties = LoadBalancerNodeProperties()
95
+ sub_resource_relationship: LoadBalancerToAWSAccount = LoadBalancerToAWSAccount()
96
+ other_relationships: OtherRelationships = OtherRelationships(
97
+ [
98
+ LoadBalancerToSourceSecurityGroup(),
99
+ LoadBalancerToEC2SecurityGroup(),
100
+ LoadBalancerToEC2Instance(),
101
+ ],
102
+ )
File without changes
@@ -0,0 +1,33 @@
1
+ from dataclasses import dataclass
2
+
3
+ from cartography.models.core.common import PropertyRef
4
+ from cartography.models.core.nodes import CartographyNodeProperties
5
+ from cartography.models.core.nodes import CartographyNodeSchema
6
+ from cartography.models.core.nodes import ExtraNodeLabels
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class EntraTenantNodeProperties(CartographyNodeProperties):
11
+ id: PropertyRef = PropertyRef('id')
12
+ created_date_time: PropertyRef = PropertyRef('created_date_time')
13
+ default_usage_location: PropertyRef = PropertyRef('default_usage_location')
14
+ deleted_date_time: PropertyRef = PropertyRef('deleted_date_time')
15
+ display_name: PropertyRef = PropertyRef('display_name')
16
+ marketing_notification_emails: PropertyRef = PropertyRef('marketing_notification_emails')
17
+ mobile_device_management_authority: PropertyRef = PropertyRef('mobile_device_management_authority')
18
+ on_premises_last_sync_date_time: PropertyRef = PropertyRef('on_premises_last_sync_date_time')
19
+ on_premises_sync_enabled: PropertyRef = PropertyRef('on_premises_sync_enabled')
20
+ partner_tenant_type: PropertyRef = PropertyRef('partner_tenant_type')
21
+ postal_code: PropertyRef = PropertyRef('postal_code')
22
+ preferred_language: PropertyRef = PropertyRef('preferred_language')
23
+ state: PropertyRef = PropertyRef('state')
24
+ street: PropertyRef = PropertyRef('street')
25
+ tenant_type: PropertyRef = PropertyRef('tenant_type')
26
+ lastupdated: PropertyRef = PropertyRef('lastupdated', set_in_kwargs=True)
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class EntraTenantSchema(CartographyNodeSchema):
31
+ label: str = 'AzureTenant'
32
+ properties: EntraTenantNodeProperties = EntraTenantNodeProperties()
33
+ extra_node_labels: ExtraNodeLabels = ExtraNodeLabels(['EntraTenant'])
@@ -0,0 +1,83 @@
1
+ from dataclasses import dataclass
2
+
3
+ from cartography.models.core.common import PropertyRef
4
+ from cartography.models.core.nodes import CartographyNodeProperties
5
+ from cartography.models.core.nodes import CartographyNodeSchema
6
+ from cartography.models.core.relationships import CartographyRelProperties
7
+ from cartography.models.core.relationships import CartographyRelSchema
8
+ from cartography.models.core.relationships import LinkDirection
9
+ from cartography.models.core.relationships import make_target_node_matcher
10
+ from cartography.models.core.relationships import TargetNodeMatcher
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class EntraUserNodeProperties(CartographyNodeProperties):
15
+ id: PropertyRef = PropertyRef('id')
16
+ user_principal_name: PropertyRef = PropertyRef('user_principal_name')
17
+ display_name: PropertyRef = PropertyRef('display_name')
18
+ given_name: PropertyRef = PropertyRef('given_name')
19
+ surname: PropertyRef = PropertyRef('surname')
20
+ # The underlying datatype calls this 'mail' but everything else in cartography uses 'email'
21
+ email: PropertyRef = PropertyRef('mail', extra_index=True)
22
+ other_mails: PropertyRef = PropertyRef('other_mails')
23
+ preferred_language: PropertyRef = PropertyRef('preferred_language')
24
+ preferred_name: PropertyRef = PropertyRef('preferred_name')
25
+ state: PropertyRef = PropertyRef('state')
26
+ usage_location: PropertyRef = PropertyRef('usage_location')
27
+ user_type: PropertyRef = PropertyRef('user_type')
28
+ show_in_address_list: PropertyRef = PropertyRef('show_in_address_list')
29
+ sign_in_sessions_valid_from_date_time: PropertyRef = PropertyRef('sign_in_sessions_valid_from_date_time')
30
+ security_identifier: PropertyRef = PropertyRef('security_identifier')
31
+ account_enabled: PropertyRef = PropertyRef('account_enabled')
32
+ city: PropertyRef = PropertyRef('city')
33
+ company_name: PropertyRef = PropertyRef('company_name')
34
+ consent_provided_for_minor: PropertyRef = PropertyRef('consent_provided_for_minor')
35
+ country: PropertyRef = PropertyRef('country')
36
+ created_date_time: PropertyRef = PropertyRef('created_date_time')
37
+ creation_type: PropertyRef = PropertyRef('creation_type')
38
+ deleted_date_time: PropertyRef = PropertyRef('deleted_date_time')
39
+ department: PropertyRef = PropertyRef('department')
40
+ employee_id: PropertyRef = PropertyRef('employee_id')
41
+ employee_type: PropertyRef = PropertyRef('employee_type')
42
+ external_user_state: PropertyRef = PropertyRef('external_user_state')
43
+ external_user_state_change_date_time: PropertyRef = PropertyRef('external_user_state_change_date_time')
44
+ hire_date: PropertyRef = PropertyRef('hire_date')
45
+ is_management_restricted: PropertyRef = PropertyRef('is_management_restricted')
46
+ is_resource_account: PropertyRef = PropertyRef('is_resource_account')
47
+ job_title: PropertyRef = PropertyRef('job_title')
48
+ last_password_change_date_time: PropertyRef = PropertyRef('last_password_change_date_time')
49
+ mail_nickname: PropertyRef = PropertyRef('mail_nickname')
50
+ office_location: PropertyRef = PropertyRef('office_location')
51
+ on_premises_distinguished_name: PropertyRef = PropertyRef('on_premises_distinguished_name')
52
+ on_premises_domain_name: PropertyRef = PropertyRef('on_premises_domain_name')
53
+ on_premises_immutable_id: PropertyRef = PropertyRef('on_premises_immutable_id')
54
+ on_premises_last_sync_date_time: PropertyRef = PropertyRef('on_premises_last_sync_date_time')
55
+ on_premises_sam_account_name: PropertyRef = PropertyRef('on_premises_sam_account_name')
56
+ on_premises_security_identifier: PropertyRef = PropertyRef('on_premises_security_identifier')
57
+ on_premises_sync_enabled: PropertyRef = PropertyRef('on_premises_sync_enabled')
58
+ on_premises_user_principal_name: PropertyRef = PropertyRef('on_premises_user_principal_name')
59
+ lastupdated: PropertyRef = PropertyRef('lastupdated', set_in_kwargs=True)
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class EntraTenantToUserRelProperties(CartographyRelProperties):
64
+ lastupdated: PropertyRef = PropertyRef('lastupdated', set_in_kwargs=True)
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ # (:EntraUser)<-[:RESOURCE]-(:AzureTenant)
69
+ class EntraUserToTenantRel(CartographyRelSchema):
70
+ target_node_label: str = 'AzureTenant'
71
+ target_node_matcher: TargetNodeMatcher = make_target_node_matcher(
72
+ {'id': PropertyRef('TENANT_ID', set_in_kwargs=True)},
73
+ )
74
+ direction: LinkDirection = LinkDirection.INWARD
75
+ rel_label: str = "RESOURCE"
76
+ properties: EntraTenantToUserRelProperties = EntraTenantToUserRelProperties()
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class EntraUserSchema(CartographyNodeSchema):
81
+ label: str = 'EntraUser'
82
+ properties: EntraUserNodeProperties = EntraUserNodeProperties()
83
+ sub_resource_relationship: EntraUserToTenantRel = EntraUserToTenantRel()
cartography/sync.py CHANGED
@@ -20,6 +20,7 @@ import cartography.intel.crowdstrike
20
20
  import cartography.intel.cve
21
21
  import cartography.intel.digitalocean
22
22
  import cartography.intel.duo
23
+ import cartography.intel.entra
23
24
  import cartography.intel.gcp
24
25
  import cartography.intel.github
25
26
  import cartography.intel.gsuite
@@ -42,6 +43,7 @@ TOP_LEVEL_MODULES = OrderedDict({ # preserve order so that the default sync alw
42
43
  'create-indexes': cartography.intel.create_indexes.run,
43
44
  'aws': cartography.intel.aws.start_aws_ingestion,
44
45
  'azure': cartography.intel.azure.start_azure_ingestion,
46
+ 'entra': cartography.intel.entra.start_entra_ingestion,
45
47
  'crowdstrike': cartography.intel.crowdstrike.start_crowdstrike_ingestion,
46
48
  'gcp': cartography.intel.gcp.start_gcp_ingestion,
47
49
  'gsuite': cartography.intel.gsuite.start_gsuite_ingestion,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cartography
3
- Version: 0.101.1rc2
3
+ Version: 0.102.0rc1
4
4
  Summary: Explore assets and their relationships across your technical infrastructure.
5
5
  Maintainer: Cartography Contributors
6
6
  License: apache2
@@ -47,6 +47,7 @@ Requires-Dist: msrestazure>=0.6.4
47
47
  Requires-Dist: azure-mgmt-storage>=16.0.0
48
48
  Requires-Dist: azure-mgmt-sql<=1.0.0
49
49
  Requires-Dist: azure-identity>=1.5.0
50
+ Requires-Dist: msgraph-sdk
50
51
  Requires-Dist: kubernetes>=22.6.0
51
52
  Requires-Dist: pdpyras>=4.3.0
52
53
  Requires-Dist: crowdstrike-falconpy>=0.5.1
@@ -61,6 +62,7 @@ Requires-Dist: pytest>=6.2.4; extra == "dev"
61
62
  Requires-Dist: pytest-mock; extra == "dev"
62
63
  Requires-Dist: pytest-cov==6.1.1; extra == "dev"
63
64
  Requires-Dist: pytest-rerunfailures; extra == "dev"
65
+ Requires-Dist: pytest-asyncio; extra == "dev"
64
66
  Requires-Dist: types-PyYAML; extra == "dev"
65
67
  Requires-Dist: types-requests<2.32.0.20250329; extra == "dev"
66
68
  Dynamic: license-file
@@ -97,6 +99,7 @@ You can learn more about the story behind Cartography in our [presentation at BS
97
99
  - [GitHub](https://cartography-cncf.github.io/cartography/modules/github/index.html) - repos, branches, users, teams
98
100
  - [DigitalOcean](https://cartography-cncf.github.io/cartography/modules/digitalocean/index.html)
99
101
  - [Microsoft Azure](https://cartography-cncf.github.io/cartography/modules/azure/index.html) - CosmosDB, SQL, Storage, Virtual Machine
102
+ - [Microsoft Entra ID](https://cartography-cncf.github.io/cartography/modules/entra/index.html) - Users
100
103
  - [Kubernetes](https://cartography-cncf.github.io/cartography/modules/kubernetes/index.html) - Cluster, Namespace, Service, Pod, Container
101
104
  - [PagerDuty](https://cartography-cncf.github.io/cartography/modules/pagerduty/index.html) - Users, teams, services, schedules, escalation policies, integrations, vendors
102
105
  - [Crowdstrike Falcon](https://cartography-cncf.github.io/cartography/modules/crowdstrike/index.html) - Hosts, Spotlight vulnerabilities, CVEs
@@ -1,11 +1,11 @@
1
1
  cartography/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
2
  cartography/__main__.py,sha256=JftXT_nUPkqcEh8uxCCT4n-OyHYqbldEgrDS-4ygy0U,101
3
- cartography/_version.py,sha256=lOruTfTM2wWzcS6Tb58hcXZGJqOXqfj431-1AWYroHk,518
4
- cartography/cli.py,sha256=-77DOKUQn3N-TDIi55V4RHLb3k36ZGZ64o1XgiT0qmE,33370
5
- cartography/config.py,sha256=ZcadsKmooAkti9Kv0eDl8Ec1PcZDu3lWobtNaCnwY3k,11872
3
+ cartography/_version.py,sha256=q6TYvfOuC2Uhe9vDriWNPGMCELT7yrGBm93mUcBnafo,518
4
+ cartography/cli.py,sha256=-fGIdBx3IwauUeYojsb-NnQPma2wtS7mFRgOETyfCg4,34796
5
+ cartography/config.py,sha256=xXM0OqsDl5Du55C-hr2LgjdtpU1_znPsmAgujrPGPgo,12553
6
6
  cartography/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
7
  cartography/stats.py,sha256=-KiqrNfUe_39z9TKAQamJwKs5XePnzXscEJocAuNiJs,4420
8
- cartography/sync.py,sha256=ziD63T_774gXSuD5zdz6fLGvv1Kt2ntQySSVbmcCZb8,9708
8
+ cartography/sync.py,sha256=LSDvK2vaMXhuNHvUkdncpDHAGdiJ7eP7-uDVhwLFjKM,9799
9
9
  cartography/util.py,sha256=VZgiHcAprn3nGzItee4_TggfsGWxWPTkLN-2MIhYUqM,14999
10
10
  cartography/client/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
11
  cartography/client/aws/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -13,7 +13,7 @@ cartography/client/aws/iam.py,sha256=dYsGikc36DEsSeR2XVOVFFUDwuU9yWj_EVkpgVYCFgM
13
13
  cartography/client/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
14
  cartography/client/core/tx.py,sha256=55Cf9DJGHHXQk4HmPOdFwr1eh9Pr1nzmIvs4XoCVr0g,10892
15
15
  cartography/data/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
16
- cartography/data/indexes.cypher,sha256=t5HfeNoMRswsOEaZN3vy8XuJEbbXWOQ06l-94vTnHCo,26742
16
+ cartography/data/indexes.cypher,sha256=aUHMiLPsEt09W61GyjJHfpkRJ07S2sGcpU9IReYxKC0,26551
17
17
  cartography/data/permission_relationships.yaml,sha256=RuKGGc_3ZUQ7ag0MssB8k_zaonCkVM5E8I_svBWTmGc,969
18
18
  cartography/data/jobs/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
19
19
  cartography/data/jobs/analysis/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -174,9 +174,9 @@ cartography/intel/aws/ec2/images.py,sha256=SLoxcy_PQgNomVMDMdutm0zXJCOLosiHJlN63
174
174
  cartography/intel/aws/ec2/instances.py,sha256=uI8eVJmeEybS8y_T8CVKAkwxJyVDCH7sbuEJYeWGSWY,12468
175
175
  cartography/intel/aws/ec2/internet_gateways.py,sha256=dI-4-85_3DGGZZBcY_DN6XqESx9P26S6jKok314lcnQ,2883
176
176
  cartography/intel/aws/ec2/key_pairs.py,sha256=g4imIo_5jk8upq9J4--erg-OZXG2i3cJMe6SnNCYj9s,2635
177
- cartography/intel/aws/ec2/launch_templates.py,sha256=Bv1mD5-44X1MkCh1Hif-uteozadfdgdQtnr9eX_kzjQ,6910
177
+ cartography/intel/aws/ec2/launch_templates.py,sha256=JNCT7WKvHcM8Z6D1MDR65GsBiqmd6bMuRQAMu9BWRKY,6634
178
178
  cartography/intel/aws/ec2/load_balancer_v2s.py,sha256=95FfQQn740gexINIHDJizOM4OKzRtQT_y2XQMipQ5Dg,8661
179
- cartography/intel/aws/ec2/load_balancers.py,sha256=1GwErzGqi3BKCARqfGJcD_r_D84rFKVy5kNMas9jAok,6756
179
+ cartography/intel/aws/ec2/load_balancers.py,sha256=ah9-lXvipzVDjGFqfpNCrEyBfdu-BdDeV2ZcPwJM78M,6013
180
180
  cartography/intel/aws/ec2/network_acls.py,sha256=_UiOx79OxcqH0ecRjcVMglAzz5XJ4aVYLlv6dl_ism4,6809
181
181
  cartography/intel/aws/ec2/network_interfaces.py,sha256=CzF8PooCYUQ2pk8DR8JDAhkWRUQSBj_27OsIfkL_-Cs,9199
182
182
  cartography/intel/aws/ec2/reserved_instances.py,sha256=jv8-VLI5KL8jN1QRI20yim8lzZ7I7wR8a5EF8DckahA,3122
@@ -220,6 +220,8 @@ cartography/intel/duo/phones.py,sha256=ueJheqSLD2xYcMus5eOiixPYS3_xVjgQzeomjV2a6
220
220
  cartography/intel/duo/tokens.py,sha256=bEEnjfc4waQnkRHVSnZLAeGE8wHOOZL7FA9m80GGQdQ,2396
221
221
  cartography/intel/duo/users.py,sha256=lc7ly_XKeUjJ50szw31WT_GiCrZfGKJv1zVUpmTchh4,4097
222
222
  cartography/intel/duo/web_authn_credentials.py,sha256=IbDf3CWqfEyI7f9zJugUvoDd6vZOECfb_7ANZaRYzuk,2636
223
+ cartography/intel/entra/__init__.py,sha256=Qtn2-ZTZA-_3IzopJG1r2F8fkLd2DJ3b1H1ZbeF4xUA,1185
224
+ cartography/intel/entra/users.py,sha256=Tv7LutCEZ4zo3E9MNT8Z8jwiyV2TTzCfvflGP3bz9c0,7523
223
225
  cartography/intel/gcp/__init__.py,sha256=sZHPfDCPZFCE5d6aj20Ow4AC0vrFxV7RCn_cMinCDmI,17650
224
226
  cartography/intel/gcp/compute.py,sha256=CH2cBdOwbLZCAbkfRJkkI-sFybXVKRWEUGDJANQmvyA,48333
225
227
  cartography/intel/gcp/crm.py,sha256=Uw5PILhVFhpM8gq7uu2v7F_YikDW3gsTZ3d7-e8Z1_k,12324
@@ -296,6 +298,8 @@ cartography/models/aws/ec2/keypair_instance.py,sha256=M1Ru8Z_2izW0cADAnQVVHaKsT_
296
298
  cartography/models/aws/ec2/launch_configurations.py,sha256=zdfWJEx93HXDXd_IzSEkhvcztkJI7_v_TCE_d8ZNAyI,2764
297
299
  cartography/models/aws/ec2/launch_template_versions.py,sha256=RitfnAuAj0XpFsCXkRbtUhHMAi8Vsvmtury231eKvGU,3897
298
300
  cartography/models/aws/ec2/launch_templates.py,sha256=GqiwFuMp72LNSt2eQlp2WfdU_vHsom-xKV5AaUewSHQ,2157
301
+ cartography/models/aws/ec2/load_balancer_listeners.py,sha256=M7oYOinOQkEUijJjhs1oCffB5VmLWYSa92tVWKwMSJQ,2879
302
+ cartography/models/aws/ec2/load_balancers.py,sha256=qJbPWePdO2vuyKzcVcSvKtHlEFMBkkUovZ826BaAcwg,4347
299
303
  cartography/models/aws/ec2/loadbalancerv2.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
300
304
  cartography/models/aws/ec2/network_acl_rules.py,sha256=4Rq2J-Dce8J6y9J6YIalmYtuQRWLp652LXO1Xg6XGPE,3951
301
305
  cartography/models/aws/ec2/network_acls.py,sha256=pJKsXdMLB8L79lmTYpLJfFJ6p7PWpf3rBN6eW6y-5hY,3419
@@ -342,6 +346,9 @@ cartography/models/duo/phone.py,sha256=oxgMmwKLRiCWbAhqrTKE4ILseu0j96GugEIV_hchR
342
346
  cartography/models/duo/token.py,sha256=BS_AvF-TAGzCY9Owtqxr8g_s6716dnzFOO1IwkckmVA,2668
343
347
  cartography/models/duo/user.py,sha256=ih3DH_QveAve4cX9dmIwC5gVN6_RNnuLK3bfJ5I9u6g,6554
344
348
  cartography/models/duo/web_authn_credential.py,sha256=OcZnfG5zCMlphxSltRcAXQ12hHYJjxrBt6A9L28g7Vk,2920
349
+ cartography/models/entra/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
350
+ cartography/models/entra/tenant.py,sha256=wxF6DTsLs653ObzPrEOj2xQ-YGtNJhg-E1desPQmmU0,1751
351
+ cartography/models/entra/user.py,sha256=Y6am84AEYdVJHIFUHHF5XSgwCQ-aOekeu4ZABJhnfp0,4765
345
352
  cartography/models/gcp/iam.py,sha256=N7OGmnRlkIFZOv0rh3QGGBmYV7WYy3-xeE4Wv7StGOE,3071
346
353
  cartography/models/github/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
347
354
  cartography/models/github/orgs.py,sha256=EcUmkeyoCJmkmzLsfKdUwwTE0N2IIwyaUrIK32dQybo,1106
@@ -362,9 +369,9 @@ cartography/models/snipeit/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJ
362
369
  cartography/models/snipeit/asset.py,sha256=FyRAaeXuZjMy0eUQcSDFcgEAF5lbLMlvqp1Tv9d3Lv4,3238
363
370
  cartography/models/snipeit/tenant.py,sha256=p4rFnpNNuF1W5ilGBbexDaETWTwavfb38RcQGoImkQI,679
364
371
  cartography/models/snipeit/user.py,sha256=MsB4MiCVNTH6JpESime7cOkB89autZOXQpL6Z0l7L6o,2113
365
- cartography-0.101.1rc2.dist-info/licenses/LICENSE,sha256=kvLEBRYaQ1RvUni6y7Ti9uHeooqnjPoo6n_-0JO1ETc,11351
366
- cartography-0.101.1rc2.dist-info/METADATA,sha256=EJwfITVKkdw-MJYwpKuAVYQ9uBgh3RECunXMu3GiyK8,11909
367
- cartography-0.101.1rc2.dist-info/WHEEL,sha256=CmyFI0kx5cdEMTLiONQRbGQwjIoR1aIYB7eCAQ4KPJ0,91
368
- cartography-0.101.1rc2.dist-info/entry_points.txt,sha256=GVIAWD0o0_K077qMA_k1oZU4v-M0a8GLKGJR8tZ-qH8,112
369
- cartography-0.101.1rc2.dist-info/top_level.txt,sha256=BHqsNJQiI6Q72DeypC1IINQJE59SLhU4nllbQjgJi9g,12
370
- cartography-0.101.1rc2.dist-info/RECORD,,
372
+ cartography-0.102.0rc1.dist-info/licenses/LICENSE,sha256=kvLEBRYaQ1RvUni6y7Ti9uHeooqnjPoo6n_-0JO1ETc,11351
373
+ cartography-0.102.0rc1.dist-info/METADATA,sha256=GkNEwQom09u69HGJrgQGXtYl7pBv1k6uTjsu9bmjUdQ,12087
374
+ cartography-0.102.0rc1.dist-info/WHEEL,sha256=CmyFI0kx5cdEMTLiONQRbGQwjIoR1aIYB7eCAQ4KPJ0,91
375
+ cartography-0.102.0rc1.dist-info/entry_points.txt,sha256=GVIAWD0o0_K077qMA_k1oZU4v-M0a8GLKGJR8tZ-qH8,112
376
+ cartography-0.102.0rc1.dist-info/top_level.txt,sha256=BHqsNJQiI6Q72DeypC1IINQJE59SLhU4nllbQjgJi9g,12
377
+ cartography-0.102.0rc1.dist-info/RECORD,,