cartography 0.104.0rc2__py3-none-any.whl → 0.105.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of cartography might be problematic. Click here for more details.

Files changed (44) hide show
  1. cartography/_version.py +2 -2
  2. cartography/cli.py +26 -1
  3. cartography/client/aws/__init__.py +19 -0
  4. cartography/client/aws/ecr.py +51 -0
  5. cartography/config.py +8 -0
  6. cartography/data/indexes.cypher +0 -3
  7. cartography/data/jobs/cleanup/aws_import_lambda_cleanup.json +1 -1
  8. cartography/graph/cleanupbuilder.py +151 -41
  9. cartography/intel/aws/acm.py +124 -0
  10. cartography/intel/aws/cloudtrail.py +3 -38
  11. cartography/intel/aws/ecr.py +8 -2
  12. cartography/intel/aws/iam.py +1 -1
  13. cartography/intel/aws/lambda_function.py +1 -1
  14. cartography/intel/aws/resources.py +2 -2
  15. cartography/intel/aws/s3.py +195 -4
  16. cartography/intel/aws/secretsmanager.py +19 -5
  17. cartography/intel/aws/sqs.py +36 -90
  18. cartography/intel/entra/__init__.py +11 -0
  19. cartography/intel/entra/groups.py +151 -0
  20. cartography/intel/entra/ou.py +21 -5
  21. cartography/intel/trivy/__init__.py +161 -0
  22. cartography/intel/trivy/scanner.py +363 -0
  23. cartography/models/aws/acm/certificate.py +75 -0
  24. cartography/models/aws/cloudtrail/trail.py +24 -0
  25. cartography/models/aws/s3/notification.py +24 -0
  26. cartography/models/aws/secretsmanager/secret_version.py +0 -2
  27. cartography/models/aws/sqs/__init__.py +0 -0
  28. cartography/models/aws/sqs/queue.py +89 -0
  29. cartography/models/core/nodes.py +15 -2
  30. cartography/models/entra/group.py +91 -0
  31. cartography/models/trivy/__init__.py +0 -0
  32. cartography/models/trivy/findings.py +66 -0
  33. cartography/models/trivy/fix.py +66 -0
  34. cartography/models/trivy/package.py +71 -0
  35. cartography/sync.py +2 -0
  36. {cartography-0.104.0rc2.dist-info → cartography-0.105.0.dist-info}/METADATA +3 -2
  37. {cartography-0.104.0rc2.dist-info → cartography-0.105.0.dist-info}/RECORD +42 -30
  38. cartography/intel/aws/efs.py +0 -93
  39. cartography/models/aws/efs/mount_target.py +0 -52
  40. /cartography/models/aws/{efs → acm}/__init__.py +0 -0
  41. {cartography-0.104.0rc2.dist-info → cartography-0.105.0.dist-info}/WHEEL +0 -0
  42. {cartography-0.104.0rc2.dist-info → cartography-0.105.0.dist-info}/entry_points.txt +0 -0
  43. {cartography-0.104.0rc2.dist-info → cartography-0.105.0.dist-info}/licenses/LICENSE +0 -0
  44. {cartography-0.104.0rc2.dist-info → cartography-0.105.0.dist-info}/top_level.txt +0 -0
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.104.0rc2'
21
- __version_tuple__ = version_tuple = (0, 104, 0, 'rc2')
20
+ __version__ = version = '0.105.0'
21
+ __version_tuple__ = version_tuple = (0, 105, 0)
cartography/cli.py CHANGED
@@ -637,6 +637,24 @@ class CLI:
637
637
  "Required if you are using the Anthropic intel module. Ignored otherwise."
638
638
  ),
639
639
  )
640
+ parser.add_argument(
641
+ "--trivy-s3-bucket",
642
+ type=str,
643
+ default=None,
644
+ help=(
645
+ "The S3 bucket name containing Trivy scan results. "
646
+ "Required if you are using the Trivy module. Ignored otherwise."
647
+ ),
648
+ )
649
+ parser.add_argument(
650
+ "--trivy-s3-prefix",
651
+ type=str,
652
+ default=None,
653
+ help=(
654
+ "The S3 prefix path containing Trivy scan results. "
655
+ "Required if you are using the Trivy module. Ignored otherwise."
656
+ ),
657
+ )
640
658
 
641
659
  return parser
642
660
 
@@ -914,7 +932,7 @@ class CLI:
914
932
  config.snipeit_token = os.environ.get("SNIPEIT_TOKEN")
915
933
  else:
916
934
  logger.warning("A SnipeIT base URI was provided but a token was not.")
917
- config.kandji_token = None
935
+ config.snipeit_token = None
918
936
  else:
919
937
  logger.warning("A SnipeIT base URI was not provided.")
920
938
  config.snipeit_base_uri = None
@@ -955,6 +973,13 @@ class CLI:
955
973
  else:
956
974
  config.anthropic_apikey = None
957
975
 
976
+ # Trivy config
977
+ if config.trivy_s3_bucket:
978
+ logger.debug(f"Trivy S3 bucket: {config.trivy_s3_bucket}")
979
+
980
+ if config.trivy_s3_prefix:
981
+ logger.debug(f"Trivy S3 prefix: {config.trivy_s3_prefix}")
982
+
958
983
  # Run cartography
959
984
  try:
960
985
  return cartography.sync.run_with_config(self.sync, config)
@@ -0,0 +1,19 @@
1
+ from typing import List
2
+
3
+ import neo4j
4
+
5
+ from cartography.client.core.tx import read_list_of_values_tx
6
+ from cartography.util import timeit
7
+
8
+
9
+ @timeit
10
+ def list_accounts(neo4j_session: neo4j.Session) -> List[str]:
11
+ """
12
+ :param neo4j_session: The neo4j session object.
13
+ :return: A list of all AWS account IDs in the graph
14
+ """
15
+ # See https://community.neo4j.com/t/extract-list-of-nodes-and-labels-from-path/13665/4
16
+ query = """
17
+ MATCH (a:AWSAccount) RETURN a.id
18
+ """
19
+ return neo4j_session.read_transaction(read_list_of_values_tx, query)
@@ -0,0 +1,51 @@
1
+ from typing import Set
2
+ from typing import Tuple
3
+
4
+ import neo4j
5
+
6
+ from cartography.client.core.tx import read_list_of_tuples_tx
7
+ from cartography.util import timeit
8
+
9
+
10
+ @timeit
11
+ def get_ecr_images(
12
+ neo4j_session: neo4j.Session, aws_account_id: str
13
+ ) -> Set[Tuple[str, str, str, str, str]]:
14
+ """
15
+ Queries the graph for all ECR images and their parent images.
16
+ Returns 5-tuples of ECR repository regions, tags, URIs, names, and binary digests. This is used to identify which
17
+ images to scan.
18
+ :param neo4j_session: The neo4j session object.
19
+ :param aws_account_id: The AWS account ID to get ECR repo data for.
20
+ :return: 5-tuples of repo region, image tag, image URI, repo_name, and image_digest.
21
+ """
22
+ # See https://community.neo4j.com/t/extract-list-of-nodes-and-labels-from-path/13665/4
23
+ query = """
24
+ MATCH (e1:ECRRepositoryImage)<-[:REPO_IMAGE]-(repo:ECRRepository)
25
+ MATCH (repo)<-[:RESOURCE]-(:AWSAccount {id: $AWS_ID})
26
+
27
+ // OPTIONAL traversal of parent hierarchy
28
+ OPTIONAL MATCH path = (e1)-[:PARENT*1..]->(ancestor:ECRRepositoryImage)
29
+ WITH e1,
30
+ CASE
31
+ WHEN path IS NULL THEN [e1]
32
+ ELSE [n IN nodes(path) | n] + [e1]
33
+ END AS repo_img_collection_unflattened
34
+
35
+ // Flatten and dedupe
36
+ UNWIND repo_img_collection_unflattened AS repo_img
37
+ WITH DISTINCT repo_img
38
+
39
+ // Match image metadata
40
+ MATCH (er:ECRRepository)-[:REPO_IMAGE]->(repo_img)-[:IMAGE]->(img:ECRImage)
41
+
42
+ RETURN DISTINCT
43
+ er.region AS region,
44
+ repo_img.tag AS tag,
45
+ repo_img.id AS uri,
46
+ er.name AS repo_name,
47
+ img.digest AS digest
48
+ """
49
+ return neo4j_session.read_transaction(
50
+ read_list_of_tuples_tx, query, AWS_ID=aws_account_id
51
+ )
cartography/config.py CHANGED
@@ -137,6 +137,10 @@ class Config:
137
137
  :param openai_org_id: OpenAI organization id. Optional.
138
138
  :type anthropic_apikey: string
139
139
  :param anthropic_apikey: Anthropic API key. Optional.
140
+ :type trivy_s3_bucket: str
141
+ :param trivy_s3_bucket: The S3 bucket name containing Trivy scan results. Optional.
142
+ :type trivy_s3_prefix: str
143
+ :param trivy_s3_prefix: The S3 prefix path containing Trivy scan results. Optional.
140
144
  """
141
145
 
142
146
  def __init__(
@@ -209,6 +213,8 @@ class Config:
209
213
  openai_apikey=None,
210
214
  openai_org_id=None,
211
215
  anthropic_apikey=None,
216
+ trivy_s3_bucket=None,
217
+ trivy_s3_prefix=None,
212
218
  ):
213
219
  self.neo4j_uri = neo4j_uri
214
220
  self.neo4j_user = neo4j_user
@@ -278,3 +284,5 @@ class Config:
278
284
  self.openai_apikey = openai_apikey
279
285
  self.openai_org_id = openai_org_id
280
286
  self.anthropic_apikey = anthropic_apikey
287
+ self.trivy_s3_bucket = trivy_s3_bucket
288
+ self.trivy_s3_prefix = trivy_s3_prefix
@@ -227,9 +227,6 @@ CREATE INDEX IF NOT EXISTS FOR (n:OCITenancy) ON (n.lastupdated);
227
227
  CREATE INDEX IF NOT EXISTS FOR (n:OCIUser) ON (n.ocid);
228
228
  CREATE INDEX IF NOT EXISTS FOR (n:OCIUser) ON (n.name);
229
229
  CREATE INDEX IF NOT EXISTS FOR (n:OCIUser) ON (n.lastupdated);
230
- CREATE INDEX IF NOT EXISTS FOR (n:Package) ON (n.id);
231
- CREATE INDEX IF NOT EXISTS FOR (n:Package) ON (n.name);
232
- CREATE INDEX IF NOT EXISTS FOR (n:Package) ON (n.lastupdated);
233
230
  CREATE INDEX IF NOT EXISTS FOR (n:PagerDutyEscalationPolicy) ON (n.id);
234
231
  CREATE INDEX IF NOT EXISTS FOR (n:PagerDutyEscalationPolicy) ON (n.name);
235
232
  CREATE INDEX IF NOT EXISTS FOR (n:PagerDutyEscalationPolicy) ON (n.lastupdated);
@@ -31,7 +31,7 @@
31
31
  "iterationsize": 100
32
32
  },
33
33
  {
34
- "query": "MATCH (:AWSAccount{id: $AWS_ID})-[:RESOURCE]->(:AWSLambda)-[r:STS_ASSUME_ROLE_ALLOW]->(:AWSPrincipal) WHERE r.lastupdated <> $UPDATE_TAG WITH r LIMIT $LIMIT_SIZE DELETE r",
34
+ "query": "MATCH (:AWSAccount{id: $AWS_ID})-[:RESOURCE]->(:AWSLambda)-[r:STS_ASSUMEROLE_ALLOW]->(:AWSPrincipal) WHERE r.lastupdated <> $UPDATE_TAG WITH r LIMIT $LIMIT_SIZE DELETE r",
35
35
  "iterative": true,
36
36
  "iterationsize": 100
37
37
  },
@@ -15,33 +15,40 @@ from cartography.models.core.relationships import TargetNodeMatcher
15
15
  def build_cleanup_queries(node_schema: CartographyNodeSchema) -> List[str]:
16
16
  """
17
17
  Generates queries to clean up stale nodes and relationships from the given CartographyNodeSchema.
18
+ Properly handles cases where a node schema has a scoped cleanup or not.
18
19
  Note that auto-cleanups for a node with no relationships is not currently supported.
19
-
20
- Algorithm:
21
- 1. If node_schema has no relationships at all, return empty.
22
-
23
- Otherwise,
24
-
25
- 1. If node_schema doesn't have a sub_resource relationship, generate queries only to clean up its other
26
- relationships. No nodes will be cleaned up.
27
-
28
- Otherwise,
29
-
30
- 1. First delete all stale nodes attached to the node_schema's sub resource
31
- 2. Delete all stale node to sub resource relationships
32
- - We don't expect this to be very common (never for AWS resources, at least), but in case it is possible for an
33
- asset to change sub resources, we want to handle it properly.
34
- 3. For all relationships defined on the node schema, delete all stale ones.
35
20
  :param node_schema: The given CartographyNodeSchema
36
21
  :return: A list of Neo4j queries to clean up nodes and relationships.
37
22
  """
23
+ # If the node has no relationships, do not delete the node. Leave this behind for the user to manage.
24
+ # Oftentimes these are SyncMetadata nodes.
38
25
  if (
39
26
  not node_schema.sub_resource_relationship
40
27
  and not node_schema.other_relationships
41
28
  ):
42
29
  return []
43
30
 
44
- if not node_schema.sub_resource_relationship:
31
+ # Case 1 [Standard]: the node has a sub resource and scoped cleanup is true => clean up stale nodes
32
+ # of this type, scoped to the sub resource. Continue on to clean up the other_relationships too.
33
+ if node_schema.sub_resource_relationship and node_schema.scoped_cleanup:
34
+ queries = _build_cleanup_node_and_rel_queries(
35
+ node_schema,
36
+ node_schema.sub_resource_relationship,
37
+ )
38
+
39
+ # Case 2: The node has a sub resource but scoped cleanup is false => this does not make sense
40
+ # because if have a sub resource, we are implying that we are doing scoped cleanup.
41
+ elif node_schema.sub_resource_relationship and not node_schema.scoped_cleanup:
42
+ raise ValueError(
43
+ f"This is not expected: {node_schema.label} has a sub_resource_relationship but scoped_cleanup=False."
44
+ "Please check the class definition for this node schema. It doesn't make sense for a node to have a "
45
+ "sub resource relationship and an unscoped cleanup. Doing this will cause all stale nodes of this type "
46
+ "to be deleted regardless of the sub resource they are attached to."
47
+ )
48
+
49
+ # Case 3: The node has no sub resource but scoped cleanup is true => do not delete any nodes, but clean up stale relationships.
50
+ # Return early.
51
+ elif not node_schema.sub_resource_relationship and node_schema.scoped_cleanup:
45
52
  queries = []
46
53
  other_rels = (
47
54
  node_schema.other_relationships.rels
@@ -53,17 +60,20 @@ def build_cleanup_queries(node_schema: CartographyNodeSchema) -> List[str]:
53
60
  queries.append(query)
54
61
  return queries
55
62
 
56
- result = _build_cleanup_node_and_rel_queries(
57
- node_schema,
58
- node_schema.sub_resource_relationship,
59
- )
63
+ # Case 4: The node has no sub resource and scoped cleanup is false => clean up the stale nodes. Continue on to clean up the other_relationships too.
64
+ else:
65
+ queries = [_build_cleanup_node_query_unscoped(node_schema)]
66
+
60
67
  if node_schema.other_relationships:
61
68
  for rel in node_schema.other_relationships.rels:
62
- # [0] is the delete node query, [1] is the delete relationship query. We only want the latter.
63
- _, rel_query = _build_cleanup_node_and_rel_queries(node_schema, rel)
64
- result.append(rel_query)
69
+ if node_schema.scoped_cleanup:
70
+ # [0] is the delete node query, [1] is the delete relationship query. We only want the latter.
71
+ _, rel_query = _build_cleanup_node_and_rel_queries(node_schema, rel)
72
+ queries.append(rel_query)
73
+ else:
74
+ queries.append(_build_cleanup_rel_queries_unscoped(node_schema, rel))
65
75
 
66
- return result
76
+ return queries
67
77
 
68
78
 
69
79
  def _build_cleanup_rel_query_no_sub_resource(
@@ -94,6 +104,46 @@ def _build_cleanup_rel_query_no_sub_resource(
94
104
  )
95
105
 
96
106
 
107
+ def _build_match_statement_for_cleanup(node_schema: CartographyNodeSchema) -> str:
108
+ """
109
+ Helper function to build a MATCH statement for a given node schema for cleanup.
110
+ """
111
+ if not node_schema.sub_resource_relationship and not node_schema.scoped_cleanup:
112
+ template = Template("MATCH (n:$node_label)")
113
+ return template.safe_substitute(
114
+ node_label=node_schema.label,
115
+ )
116
+
117
+ # if it has a sub resource relationship defined, we need to match on the sub resource to make sure we only delete
118
+ # nodes that are attached to the sub resource.
119
+ template = Template(
120
+ "MATCH (n:$node_label)$sub_resource_link(:$sub_resource_label{$match_sub_res_clause})"
121
+ )
122
+ sub_resource_link = ""
123
+ sub_resource_label = ""
124
+ match_sub_res_clause = ""
125
+
126
+ if node_schema.sub_resource_relationship:
127
+ # Draw sub resource rel with correct direction
128
+ if node_schema.sub_resource_relationship.direction == LinkDirection.INWARD:
129
+ sub_resource_link_template = Template("<-[s:$SubResourceRelLabel]-")
130
+ else:
131
+ sub_resource_link_template = Template("-[s:$SubResourceRelLabel]->")
132
+ sub_resource_link = sub_resource_link_template.safe_substitute(
133
+ SubResourceRelLabel=node_schema.sub_resource_relationship.rel_label,
134
+ )
135
+ sub_resource_label = node_schema.sub_resource_relationship.target_node_label
136
+ match_sub_res_clause = _build_match_clause(
137
+ node_schema.sub_resource_relationship.target_node_matcher,
138
+ )
139
+ return template.safe_substitute(
140
+ node_label=node_schema.label,
141
+ sub_resource_link=sub_resource_link,
142
+ sub_resource_label=sub_resource_label,
143
+ match_sub_res_clause=match_sub_res_clause,
144
+ )
145
+
146
+
97
147
  def _build_cleanup_node_and_rel_queries(
98
148
  node_schema: CartographyNodeSchema,
99
149
  selected_relationship: CartographyRelSchema,
@@ -120,15 +170,6 @@ def _build_cleanup_node_and_rel_queries(
120
170
  "verify the node class definition for the relationships that it has.",
121
171
  )
122
172
 
123
- # Draw sub resource rel with correct direction
124
- if node_schema.sub_resource_relationship.direction == LinkDirection.INWARD:
125
- sub_resource_link_template = Template("<-[s:$SubResourceRelLabel]-")
126
- else:
127
- sub_resource_link_template = Template("-[s:$SubResourceRelLabel]->")
128
- sub_resource_link = sub_resource_link_template.safe_substitute(
129
- SubResourceRelLabel=node_schema.sub_resource_relationship.rel_label,
130
- )
131
-
132
173
  # The cleanup node query must always be before the cleanup rel query
133
174
  delete_action_clauses = [
134
175
  """
@@ -161,19 +202,14 @@ def _build_cleanup_node_and_rel_queries(
161
202
  # Ensure the node is attached to the sub resource and delete the node
162
203
  query_template = Template(
163
204
  """
164
- MATCH (n:$node_label)$sub_resource_link(:$sub_resource_label{$match_sub_res_clause})
205
+ $match_statement
165
206
  $selected_rel_clause
166
207
  $delete_action_clause
167
208
  """,
168
209
  )
169
210
  return [
170
211
  query_template.safe_substitute(
171
- node_label=node_schema.label,
172
- sub_resource_link=sub_resource_link,
173
- sub_resource_label=node_schema.sub_resource_relationship.target_node_label,
174
- match_sub_res_clause=_build_match_clause(
175
- node_schema.sub_resource_relationship.target_node_matcher,
176
- ),
212
+ match_statement=_build_match_statement_for_cleanup(node_schema),
177
213
  selected_rel_clause=(
178
214
  ""
179
215
  if selected_relationship == node_schema.sub_resource_relationship
@@ -185,6 +221,80 @@ def _build_cleanup_node_and_rel_queries(
185
221
  ]
186
222
 
187
223
 
224
+ def _build_cleanup_node_query_unscoped(
225
+ node_schema: CartographyNodeSchema,
226
+ ) -> str:
227
+ """
228
+ Generates a cleanup query for a node_schema to allow unscoped cleanup.
229
+ """
230
+ if node_schema.scoped_cleanup:
231
+ raise ValueError(
232
+ f"_build_cleanup_node_query_for_unscoped_cleanup() failed: '{node_schema.label}' does not have "
233
+ "scoped_cleanup=False, so we cannot generate a query to clean it up. Please verify that the class "
234
+ "definition is what you expect.",
235
+ )
236
+
237
+ # The cleanup node query must always be before the cleanup rel query
238
+ delete_action_clause = """
239
+ WHERE n.lastupdated <> $UPDATE_TAG
240
+ WITH n LIMIT $LIMIT_SIZE
241
+ DETACH DELETE n;
242
+ """
243
+
244
+ # Ensure the node is attached to the sub resource and delete the node
245
+ query_template = Template(
246
+ """
247
+ $match_statement
248
+ $delete_action_clause
249
+ """,
250
+ )
251
+ return query_template.safe_substitute(
252
+ match_statement=_build_match_statement_for_cleanup(node_schema),
253
+ delete_action_clause=delete_action_clause,
254
+ )
255
+
256
+
257
+ def _build_cleanup_rel_queries_unscoped(
258
+ node_schema: CartographyNodeSchema,
259
+ selected_relationship: CartographyRelSchema,
260
+ ) -> str:
261
+ """
262
+ Generates relationship cleanup query for a node_schema with scoped_cleanup=False.
263
+ """
264
+ if node_schema.scoped_cleanup:
265
+ raise ValueError(
266
+ f"_build_cleanup_node_and_rel_queries_unscoped() failed: '{node_schema.label}' does not have "
267
+ "scoped_cleanup=False, so we cannot generate a query to clean it up. Please verify that the class "
268
+ "definition is what you expect.",
269
+ )
270
+ if not rel_present_on_node_schema(node_schema, selected_relationship):
271
+ raise ValueError(
272
+ f"_build_cleanup_node_query(): Attempted to build cleanup query for node '{node_schema.label}' and "
273
+ f"relationship {selected_relationship.rel_label} but that relationship is not present on the node. Please "
274
+ "verify the node class definition for the relationships that it has.",
275
+ )
276
+
277
+ # The cleanup node query must always be before the cleanup rel query
278
+ delete_action_clause = """WHERE r.lastupdated <> $UPDATE_TAG
279
+ WITH r LIMIT $LIMIT_SIZE
280
+ DELETE r;
281
+ """
282
+
283
+ # Ensure the node is attached to the sub resource and delete the node
284
+ query_template = Template(
285
+ """
286
+ $match_statement
287
+ $selected_rel_clause
288
+ $delete_action_clause
289
+ """,
290
+ )
291
+ return query_template.safe_substitute(
292
+ match_statement=_build_match_statement_for_cleanup(node_schema),
293
+ selected_rel_clause=_build_selected_rel_clause(selected_relationship),
294
+ delete_action_clause=delete_action_clause,
295
+ )
296
+
297
+
188
298
  def _build_selected_rel_clause(selected_relationship: CartographyRelSchema) -> str:
189
299
  """
190
300
  Draw selected relationship with correct direction. Returns a string that looks like either
@@ -0,0 +1,124 @@
1
+ import logging
2
+ from typing import Any
3
+
4
+ import boto3
5
+ import neo4j
6
+
7
+ from cartography.client.core.tx import load
8
+ from cartography.graph.job import GraphJob
9
+ from cartography.models.aws.acm.certificate import ACMCertificateSchema
10
+ from cartography.stats import get_stats_client
11
+ from cartography.util import aws_handle_regions
12
+ from cartography.util import merge_module_sync_metadata
13
+ from cartography.util import timeit
14
+
15
+ logger = logging.getLogger(__name__)
16
+ stat_handler = get_stats_client(__name__)
17
+
18
+
19
+ @timeit
20
+ @aws_handle_regions
21
+ def get_acm_certificates(
22
+ boto3_session: boto3.session.Session, region: str
23
+ ) -> list[dict[str, Any]]:
24
+ client = boto3_session.client("acm", region_name=region)
25
+ paginator = client.get_paginator("list_certificates")
26
+ summaries: list[dict[str, Any]] = []
27
+ for page in paginator.paginate():
28
+ summaries.extend(page.get("CertificateSummaryList", []))
29
+
30
+ details: list[dict[str, Any]] = []
31
+ for summary in summaries:
32
+ arn = summary["CertificateArn"]
33
+ resp = client.describe_certificate(CertificateArn=arn)
34
+ details.append(resp["Certificate"])
35
+ return details
36
+
37
+
38
+ def transform_acm_certificates(
39
+ certificates: list[dict[str, Any]], region: str
40
+ ) -> list[dict[str, Any]]:
41
+ transformed: list[dict[str, Any]] = []
42
+ for cert in certificates:
43
+ item: dict[str, Any] = {
44
+ "Arn": cert["CertificateArn"],
45
+ "DomainName": cert.get("DomainName"),
46
+ "Type": cert.get("Type"),
47
+ "Status": cert.get("Status"),
48
+ "KeyAlgorithm": cert.get("KeyAlgorithm"),
49
+ "SignatureAlgorithm": cert.get("SignatureAlgorithm"),
50
+ "NotBefore": cert.get("NotBefore"),
51
+ "NotAfter": cert.get("NotAfter"),
52
+ "InUseBy": cert.get("InUseBy", []),
53
+ "Region": region,
54
+ }
55
+ # Extract ELBV2 Listener ARNs for relationship creation
56
+ listener_arns = [a for a in item["InUseBy"] if ":listener/" in a]
57
+ if listener_arns:
58
+ item["ELBV2ListenerArns"] = listener_arns
59
+ transformed.append(item)
60
+ return transformed
61
+
62
+
63
+ @timeit
64
+ def load_acm_certificates(
65
+ neo4j_session: neo4j.Session,
66
+ data: list[dict[str, Any]],
67
+ region: str,
68
+ current_aws_account_id: str,
69
+ update_tag: int,
70
+ ) -> None:
71
+ logger.info(f"Loading {len(data)} ACM certificates for region {region} into graph.")
72
+ load(
73
+ neo4j_session,
74
+ ACMCertificateSchema(),
75
+ data,
76
+ lastupdated=update_tag,
77
+ Region=region,
78
+ AWS_ID=current_aws_account_id,
79
+ )
80
+
81
+
82
+ @timeit
83
+ def cleanup_acm_certificates(
84
+ neo4j_session: neo4j.Session, common_job_parameters: dict[str, Any]
85
+ ) -> None:
86
+ logger.debug("Running ACM certificate cleanup job.")
87
+ GraphJob.from_node_schema(ACMCertificateSchema(), common_job_parameters).run(
88
+ neo4j_session
89
+ )
90
+
91
+
92
+ @timeit
93
+ def sync(
94
+ neo4j_session: neo4j.Session,
95
+ boto3_session: boto3.session.Session,
96
+ regions: list[str],
97
+ current_aws_account_id: str,
98
+ update_tag: int,
99
+ common_job_parameters: dict[str, Any],
100
+ ) -> None:
101
+ for region in regions:
102
+ logger.info(
103
+ f"Syncing ACM certificates for region {region} in account {current_aws_account_id}."
104
+ )
105
+ certs = get_acm_certificates(boto3_session, region)
106
+ transformed = transform_acm_certificates(certs, region)
107
+ load_acm_certificates(
108
+ neo4j_session,
109
+ transformed,
110
+ region,
111
+ current_aws_account_id,
112
+ update_tag,
113
+ )
114
+
115
+ cleanup_acm_certificates(neo4j_session, common_job_parameters)
116
+
117
+ merge_module_sync_metadata(
118
+ neo4j_session,
119
+ group_type="AWSAccount",
120
+ group_id=current_aws_account_id,
121
+ synced_type="ACMCertificate",
122
+ update_tag=update_tag,
123
+ stat_handler=stat_handler,
124
+ )
@@ -4,7 +4,6 @@ from typing import Dict
4
4
  from typing import List
5
5
 
6
6
  import boto3
7
- import botocore.exceptions
8
7
  import neo4j
9
8
 
10
9
  from cartography.client.core.tx import load
@@ -25,10 +24,8 @@ def get_cloudtrail_trails(
25
24
  client = boto3_session.client(
26
25
  "cloudtrail", region_name=region, config=get_botocore_config()
27
26
  )
28
- paginator = client.get_paginator("list_trails")
29
- trails = []
30
- for page in paginator.paginate():
31
- trails.extend(page["Trails"])
27
+
28
+ trails = client.describe_trails()["trailList"]
32
29
 
33
30
  # CloudTrail multi-region trails are shown in list_trails,
34
31
  # but the get_trail call only works in the home region
@@ -36,28 +33,6 @@ def get_cloudtrail_trails(
36
33
  return trails_filtered
37
34
 
38
35
 
39
- @timeit
40
- def get_cloudtrail_trail(
41
- boto3_session: boto3.Session,
42
- region: str,
43
- trail_name: str,
44
- ) -> Dict[str, Any]:
45
- client = boto3_session.client(
46
- "cloudtrail", region_name=region, config=get_botocore_config()
47
- )
48
- trail_details: Dict[str, Any] = {}
49
- try:
50
- response = client.get_trail(Name=trail_name)
51
- trail_details = response["Trail"]
52
- except botocore.exceptions.ClientError as e:
53
- code = e.response["Error"]["Code"]
54
- msg = e.response["Error"]["Message"]
55
- logger.warning(
56
- f"Could not run CloudTrail get_trail due to boto3 error {code}: {msg}. Skipping.",
57
- )
58
- return trail_details
59
-
60
-
61
36
  @timeit
62
37
  def load_cloudtrail_trails(
63
38
  neo4j_session: neo4j.Session,
@@ -105,20 +80,10 @@ def sync(
105
80
  f"Syncing CloudTrail for region '{region}' in account '{current_aws_account_id}'.",
106
81
  )
107
82
  trails = get_cloudtrail_trails(boto3_session, region)
108
- trail_data: List[Dict[str, Any]] = []
109
- for trail in trails:
110
- trail_name = trail["Name"]
111
- trail_details = get_cloudtrail_trail(
112
- boto3_session,
113
- region,
114
- trail_name,
115
- )
116
- if trail_details:
117
- trail_data.append(trail_details)
118
83
 
119
84
  load_cloudtrail_trails(
120
85
  neo4j_session,
121
- trail_data,
86
+ trails,
122
87
  region,
123
88
  current_aws_account_id,
124
89
  update_tag,
@@ -107,9 +107,12 @@ def load_ecr_repositories(
107
107
  def transform_ecr_repository_images(repo_data: Dict) -> List[Dict]:
108
108
  """
109
109
  Ensure that we only load ECRImage nodes to the graph if they have a defined imageDigest field.
110
+ Process repositories in a consistent order to handle overlapping image digests deterministically.
110
111
  """
111
112
  repo_images_list = []
112
- for repo_uri, repo_images in repo_data.items():
113
+ # Sort repository URIs to ensure consistent processing order
114
+ for repo_uri in sorted(repo_data.keys()):
115
+ repo_images = repo_data[repo_uri]
113
116
  for img in repo_images:
114
117
  if "imageDigest" in img and img["imageDigest"]:
115
118
  img["repo_uri"] = repo_uri
@@ -214,7 +217,9 @@ def _get_image_data(
214
217
  )
215
218
  image_data[repo["repositoryUri"]] = repo_image_obj
216
219
 
217
- to_synchronous(*[async_get_images(repo) for repo in repositories])
220
+ # Sort repositories by name to ensure consistent processing order
221
+ sorted_repos = sorted(repositories, key=lambda x: x["repositoryName"])
222
+ to_synchronous(*[async_get_images(repo) for repo in sorted_repos])
218
223
 
219
224
  return image_data
220
225
 
@@ -237,6 +242,7 @@ def sync(
237
242
  image_data = {}
238
243
  repositories = get_ecr_repositories(boto3_session, region)
239
244
  image_data = _get_image_data(boto3_session, region, repositories)
245
+ # len here should be 1!
240
246
  load_ecr_repositories(
241
247
  neo4j_session,
242
248
  repositories,
@@ -507,7 +507,7 @@ def sync_assumerole_relationships(
507
507
  common_job_parameters: Dict,
508
508
  ) -> None:
509
509
  # Must be called after load_role
510
- # Computes and syncs the STS_ASSUME_ROLE allow relationship
510
+ # Computes and syncs the STS_ASSUMEROLE_ALLOW relationship
511
511
  logger.info(
512
512
  "Syncing assume role mappings for account '%s'.",
513
513
  current_aws_account_id,
@@ -74,7 +74,7 @@ def load_lambda_functions(
74
74
  SET r.lastupdated = $aws_update_tag
75
75
  WITH lambda, lf
76
76
  MATCH (role:AWSPrincipal{arn: lf.Role})
77
- MERGE (lambda)-[r:STS_ASSUME_ROLE_ALLOW]->(role)
77
+ MERGE (lambda)-[r:STS_ASSUMEROLE_ALLOW]->(role)
78
78
  ON CREATE SET r.firstseen = timestamp()
79
79
  SET r.lastupdated = $aws_update_tag
80
80
  """