cartography 0.115.0__py3-none-any.whl → 0.116.1__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 (28) hide show
  1. cartography/_version.py +2 -2
  2. cartography/client/core/tx.py +1 -1
  3. cartography/intel/aws/ecr_image_layers.py +664 -0
  4. cartography/intel/aws/resources.py +2 -0
  5. cartography/intel/azure/__init__.py +8 -0
  6. cartography/intel/azure/resource_groups.py +82 -0
  7. cartography/models/aws/ecr/image.py +21 -0
  8. cartography/models/aws/ecr/image_layer.py +107 -0
  9. cartography/models/azure/resource_groups.py +52 -0
  10. cartography/rules/README.md +1 -0
  11. cartography/rules/__init__.py +0 -0
  12. cartography/rules/cli.py +342 -0
  13. cartography/rules/data/__init__.py +0 -0
  14. cartography/rules/data/frameworks/__init__.py +12 -0
  15. cartography/rules/data/frameworks/mitre_attack/__init__.py +14 -0
  16. cartography/rules/data/frameworks/mitre_attack/requirements/__init__.py +0 -0
  17. cartography/rules/data/frameworks/mitre_attack/requirements/t1190_exploit_public_facing_application/__init__.py +135 -0
  18. cartography/rules/formatters.py +46 -0
  19. cartography/rules/runners.py +338 -0
  20. cartography/rules/spec/__init__.py +0 -0
  21. cartography/rules/spec/model.py +88 -0
  22. cartography/rules/spec/result.py +46 -0
  23. {cartography-0.115.0.dist-info → cartography-0.116.1.dist-info}/METADATA +19 -4
  24. {cartography-0.115.0.dist-info → cartography-0.116.1.dist-info}/RECORD +28 -11
  25. {cartography-0.115.0.dist-info → cartography-0.116.1.dist-info}/entry_points.txt +1 -0
  26. {cartography-0.115.0.dist-info → cartography-0.116.1.dist-info}/WHEEL +0 -0
  27. {cartography-0.115.0.dist-info → cartography-0.116.1.dist-info}/licenses/LICENSE +0 -0
  28. {cartography-0.115.0.dist-info → cartography-0.116.1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,135 @@
1
+ """
2
+ MITRE ATT&CK Framework
3
+ Security framework based on MITRE ATT&CK tactics and techniques
4
+ """
5
+
6
+ from cartography.rules.spec.model import Fact
7
+ from cartography.rules.spec.model import Module
8
+ from cartography.rules.spec.model import Requirement
9
+
10
+ # AWS
11
+ _aws_ec2_instance_internet_exposed = Fact(
12
+ id="aws_ec2_instance_internet_exposed",
13
+ name="Internet-Exposed EC2 Instances on Common Management Ports",
14
+ description=(
15
+ "EC2 instances exposed to the internet on ports 22, 3389, 3306, 5432, 6379, 9200, 27017"
16
+ ),
17
+ cypher_query="""
18
+ MATCH (a:AWSAccount)-[:RESOURCE]->(ec2:EC2Instance)-[:MEMBER_OF_EC2_SECURITY_GROUP]->(sg:EC2SecurityGroup)<-[:MEMBER_OF_EC2_SECURITY_GROUP]-(rule:IpPermissionInbound)
19
+ MATCH (rule)<-[:MEMBER_OF_IP_RULE]-(ip:IpRange{range:'0.0.0.0/0'})
20
+ WHERE rule.fromport IN [22, 3389, 3306, 5432, 6379, 9200, 27017]
21
+ RETURN a.name AS account, ec2.instanceid AS instance, rule.fromport AS port, sg.groupid AS sg order by account, instance, port, sg
22
+ """,
23
+ cypher_visual_query="""
24
+ MATCH p=(a:AWSAccount)-[:RESOURCE]->(ec2:EC2Instance)-[:MEMBER_OF_EC2_SECURITY_GROUP]->(sg:EC2SecurityGroup)<-[:MEMBER_OF_EC2_SECURITY_GROUP]-(rule:IpPermissionInbound)
25
+ MATCH p2=(rule)<-[:MEMBER_OF_IP_RULE]-(ip:IpRange{range:'0.0.0.0/0'})
26
+ WHERE rule.fromport IN [22, 3389, 3306, 5432, 6379, 9200, 27017]
27
+ RETURN *
28
+ """,
29
+ module=Module.AWS,
30
+ )
31
+ _aws_s3_public = Fact(
32
+ id="aws_s3_public",
33
+ name="Internet-Accessible S3 Storage Attack Surface",
34
+ description=("AWS S3 buckets accessible from the internet"),
35
+ cypher_query="""
36
+ MATCH (b:S3Bucket)
37
+ WHERE b.anonymous_access = true
38
+ OR (b.anonymous_actions IS NOT NULL AND size(b.anonymous_actions) > 0)
39
+ OR EXISTS {
40
+ MATCH (b)-[:POLICY_STATEMENT]->(stmt:S3PolicyStatement)
41
+ WHERE stmt.effect = 'Allow'
42
+ AND (stmt.principal = '*' OR stmt.principal CONTAINS 'AllUsers')
43
+ }
44
+ RETURN b.name AS bucket,
45
+ b.region AS region,
46
+ b.anonymous_access AS public_access,
47
+ b.anonymous_actions AS public_actions
48
+ """,
49
+ cypher_visual_query="""
50
+ MATCH (b:S3Bucket)
51
+ WHERE b.anonymous_access = true
52
+ OR (b.anonymous_actions IS NOT NULL AND size(b.anonymous_actions) > 0)
53
+ OR EXISTS {
54
+ MATCH (b)-[:POLICY_STATEMENT]->(stmt:S3PolicyStatement)
55
+ WHERE stmt.effect = 'Allow'
56
+ AND (stmt.principal = '*' OR stmt.principal CONTAINS 'AllUsers')
57
+ }
58
+ WITH b
59
+ OPTIONAL MATCH p=(b)-[:POLICY_STATEMENT]->(:S3PolicyStatement)
60
+ RETURN *
61
+ """,
62
+ module=Module.AWS,
63
+ )
64
+ _aws_rds_public_access = Fact(
65
+ id="aws_rds_public_access",
66
+ name="Internet-Accessible RDS Database Attack Surface",
67
+ description="AWS RDS instances accessible from the internet",
68
+ cypher_query="""
69
+ MATCH (rds:RDSInstance)
70
+ WHERE rds.publicly_accessible = true
71
+ RETURN rds.id AS instance_id,
72
+ rds.engine AS engine,
73
+ rds.db_instance_class AS instance_class,
74
+ rds.endpoint_address AS endpoint,
75
+ rds.endpoint_port AS port,
76
+ rds.region AS region,
77
+ rds.storage_encrypted AS encrypted
78
+ """,
79
+ cypher_visual_query="""
80
+ MATCH p1=(rds:RDSInstance{publicly_accessible: true})
81
+ OPTIONAL MATCH p2=(rds)-[:MEMBER_OF_EC2_SECURITY_GROUP]->(sg:EC2SecurityGroup)
82
+ OPTIONAL MATCH p3=(rds)-[:MEMBER_OF_EC2_SECURITY_GROUP]->(sg:EC2SecurityGroup)<-[:MEMBER_OF_EC2_SECURITY_GROUP]-(rule:IpPermissionInbound:IpRule)
83
+ OPTIONAL MATCH p4=(rds)-[:MEMBER_OF_EC2_SECURITY_GROUP]->(sg:EC2SecurityGroup)<-[:MEMBER_OF_EC2_SECURITY_GROUP]-(rule:IpPermissionInbound:IpRule)<-[:MEMBER_OF_IP_RULE]-(ip:IpRange)
84
+ RETURN *
85
+ """,
86
+ module=Module.AWS,
87
+ )
88
+
89
+ # Azure
90
+ _azure_storage_public_blob_access = Fact(
91
+ id="azure_storage_public_blob_access",
92
+ name="Azure Storage Accounts with Public Blob Containers",
93
+ description=(
94
+ "Azure Storage Accounts that have blob containers with public access. "
95
+ "If a storage blob container has public_access set to 'Container' or 'Blob', "
96
+ "it means that the container is publicly accessible."
97
+ ),
98
+ cypher_query="""
99
+ MATCH (sa:AzureStorageAccount)-[:USES]->(bs:AzureStorageBlobService)-[:CONTAINS]->(bc:AzureStorageBlobContainer)
100
+ WHERE bc.publicaccess IN ['Container', 'Blob']
101
+ RETURN sa.name AS storage_account,
102
+ sa.resourcegroup AS resource_group,
103
+ sa.location AS location,
104
+ bc.name AS container_name,
105
+ bc.publicaccess AS public_access
106
+ """,
107
+ cypher_visual_query="""
108
+ MATCH p=(sa:AzureStorageAccount)-[:USES]->(bs:AzureStorageBlobService)-[:CONTAINS]->(bc:AzureStorageBlobContainer)
109
+ WHERE bc.publicaccess IN ['Container', 'Blob']
110
+ RETURN *
111
+ """,
112
+ module=Module.AZURE,
113
+ )
114
+
115
+ t1190 = Requirement(
116
+ id="t1190",
117
+ name="Exploit Public-Facing Application",
118
+ description="Adversaries may attempt to take advantage of a weakness in an Internet-facing computer or program using software, data, or commands in order to cause unintended or unanticipated behavior.",
119
+ facts=(
120
+ # AWS
121
+ _aws_ec2_instance_internet_exposed,
122
+ _aws_s3_public,
123
+ _aws_rds_public_access,
124
+ # Azure
125
+ _azure_storage_public_blob_access,
126
+ ),
127
+ requirement_url="https://attack.mitre.org/techniques/T1190/",
128
+ # TODO: should we have a per-framework class represent the attributes?
129
+ attributes={
130
+ "tactic": "initial_access",
131
+ "technique_id": "T1190",
132
+ "services": ["ec2", "s3", "rds", "azure_storage"],
133
+ "providers": ["AWS", "AZURE"],
134
+ },
135
+ )
@@ -0,0 +1,46 @@
1
+ """
2
+ Output formatting utilities for Cartography rules.
3
+ """
4
+
5
+ import re
6
+ from urllib.parse import quote
7
+
8
+
9
+ def _generate_neo4j_browser_url(neo4j_uri: str, cypher_query: str) -> str:
10
+ """Generate a clickable Neo4j Browser URL with pre-populated query."""
11
+ # Handle different Neo4j URI protocols
12
+ if neo4j_uri.startswith("bolt://"):
13
+ browser_uri = neo4j_uri.replace("bolt://", "http://", 1)
14
+ elif neo4j_uri.startswith("bolt+s://"):
15
+ browser_uri = neo4j_uri.replace("bolt+s://", "https://", 1)
16
+ elif neo4j_uri.startswith("bolt+ssc://"):
17
+ browser_uri = neo4j_uri.replace("bolt+ssc://", "https://", 1)
18
+ elif neo4j_uri.startswith("neo4j://"):
19
+ browser_uri = neo4j_uri.replace("neo4j://", "http://", 1)
20
+ elif neo4j_uri.startswith("neo4j+s://"):
21
+ browser_uri = neo4j_uri.replace("neo4j+s://", "https://", 1)
22
+ elif neo4j_uri.startswith("neo4j+ssc://"):
23
+ browser_uri = neo4j_uri.replace("neo4j+ssc://", "https://", 1)
24
+ else:
25
+ browser_uri = neo4j_uri
26
+
27
+ # Handle port mapping for local instances
28
+ if ":7687" in browser_uri and (
29
+ "localhost" in browser_uri or "127.0.0.1" in browser_uri
30
+ ):
31
+ browser_uri = browser_uri.replace(":7687", ":7474")
32
+
33
+ # For Neo4j Aura (cloud), remove the port as it uses standard HTTPS port
34
+ if ".databases.neo4j.io" in browser_uri:
35
+ # Remove any port number for Aura URLs
36
+ browser_uri = re.sub(r":\d+", "", browser_uri)
37
+
38
+ # Ensure the URL ends properly
39
+ if not browser_uri.endswith("/"):
40
+ browser_uri += "/"
41
+
42
+ # URL encode the cypher query
43
+ encoded_query = quote(cypher_query.strip())
44
+
45
+ # Construct the Neo4j Browser URL with pre-populated query
46
+ return f"{browser_uri}browser/?cmd=edit&arg={encoded_query}"
@@ -0,0 +1,338 @@
1
+ """
2
+ Framework and Fact execution logic for Cartography rules.
3
+ """
4
+
5
+ import json
6
+ from dataclasses import asdict
7
+
8
+ from neo4j import Driver
9
+ from neo4j import GraphDatabase
10
+
11
+ from cartography.client.core.tx import read_list_of_dicts_tx
12
+ from cartography.rules.data.frameworks import FRAMEWORKS
13
+ from cartography.rules.formatters import _generate_neo4j_browser_url
14
+ from cartography.rules.spec.model import Fact
15
+ from cartography.rules.spec.model import Framework
16
+ from cartography.rules.spec.model import Requirement
17
+ from cartography.rules.spec.result import FactResult
18
+ from cartography.rules.spec.result import FrameworkResult
19
+ from cartography.rules.spec.result import RequirementResult
20
+
21
+
22
+ def _run_fact(
23
+ fact: Fact,
24
+ requirement: Requirement,
25
+ framework: Framework,
26
+ driver: Driver,
27
+ database: str,
28
+ fact_counter: int,
29
+ total_facts: int,
30
+ output_format: str,
31
+ neo4j_uri: str,
32
+ ):
33
+ """Execute a single fact and return the result."""
34
+ if output_format == "text":
35
+ print(f"\n\033[1mFact {fact_counter}/{total_facts}: {fact.name}\033[0m")
36
+ print(f" \033[36m{'Framework:':<12}\033[0m {framework.name}")
37
+ # Display requirement with optional clickable link
38
+ if requirement.requirement_url:
39
+ print(
40
+ f" \033[36m{'Requirement:':<12}\033[0m \033]8;;{requirement.requirement_url}\033\\{requirement.id}\033]8;;\033\\ - {requirement.name}"
41
+ )
42
+ else:
43
+ print(
44
+ f" \033[36m{'Requirement:':<12}\033[0m {requirement.id} - {requirement.name}"
45
+ )
46
+ print(f" \033[36m{'Fact ID:':<12}\033[0m {fact.id}")
47
+ print(f" \033[36m{'Description:':<12}\033[0m {fact.description}")
48
+ print(f" \033[36m{'Provider:':<12}\033[0m {fact.module.value}")
49
+
50
+ # Generate and display clickable Neo4j Browser URL
51
+ browser_url = _generate_neo4j_browser_url(neo4j_uri, fact.cypher_visual_query)
52
+ print(
53
+ f" \033[36m{'Neo4j Query:':<12}\033[0m \033]8;;{browser_url}\033\\Click to run visual query\033]8;;\033\\"
54
+ )
55
+
56
+ with driver.session(database=database) as session:
57
+ findings = session.execute_read(read_list_of_dicts_tx, fact.cypher_query)
58
+ finding_count = len(findings)
59
+
60
+ if output_format == "text":
61
+ if finding_count > 0:
62
+ print(f" \033[36m{'Results:':<12}\033[0m {finding_count} item(s) found")
63
+
64
+ # Show sample findings
65
+ print(" Sample results:")
66
+ for idx, finding in enumerate(findings[:3]): # Show first 3
67
+ # Format finding nicely
68
+ formatted_items = []
69
+ for key, value in finding.items():
70
+ if value is not None:
71
+ # Truncate long values
72
+ str_value = str(value)
73
+ if len(str_value) > 50:
74
+ str_value = str_value[:47] + "..."
75
+ formatted_items.append(f"{key}={str_value}")
76
+
77
+ if formatted_items:
78
+ print(f" {idx + 1}. {', '.join(formatted_items)}")
79
+
80
+ if finding_count > 3:
81
+ print(
82
+ f" ... and {finding_count - 3} more (use --output json to see all)"
83
+ )
84
+ else:
85
+ print(f" \033[36m{'Results:':<12}\033[0m No items found")
86
+
87
+ # Create and return fact result
88
+ return FactResult(
89
+ fact_id=fact.id,
90
+ fact_name=fact.name,
91
+ fact_description=fact.description,
92
+ fact_provider=fact.module.value,
93
+ finding_count=finding_count,
94
+ findings=findings if output_format == "json" else findings[:10],
95
+ )
96
+
97
+
98
+ def _run_single_requirement(
99
+ requirement: Requirement,
100
+ framework: Framework,
101
+ driver: Driver,
102
+ database: str,
103
+ output_format: str,
104
+ neo4j_uri: str,
105
+ fact_counter_start: int,
106
+ total_facts: int,
107
+ fact_filter: str | None = None,
108
+ ) -> tuple[RequirementResult, int]:
109
+ """
110
+ Execute a single requirement and return its result.
111
+
112
+ Returns:
113
+ A tuple of (RequirementResult, facts_executed_count)
114
+ """
115
+ # Filter facts if needed
116
+ facts_to_run = requirement.facts
117
+ if fact_filter:
118
+ facts_to_run = tuple(
119
+ f for f in requirement.facts if f.id.lower() == fact_filter.lower()
120
+ )
121
+
122
+ fact_results = []
123
+ requirement_findings = 0
124
+ fact_counter = fact_counter_start
125
+
126
+ for fact in facts_to_run:
127
+ fact_counter += 1
128
+ fact_result = _run_fact(
129
+ fact,
130
+ requirement,
131
+ framework,
132
+ driver,
133
+ database,
134
+ fact_counter,
135
+ total_facts,
136
+ output_format,
137
+ neo4j_uri,
138
+ )
139
+ fact_results.append(fact_result)
140
+ requirement_findings += fact_result.finding_count
141
+
142
+ # Create requirement result
143
+ requirement_result = RequirementResult(
144
+ requirement_id=requirement.id,
145
+ requirement_name=requirement.name,
146
+ requirement_url=requirement.requirement_url,
147
+ facts=fact_results,
148
+ total_facts=len(fact_results),
149
+ total_findings=requirement_findings,
150
+ )
151
+
152
+ return requirement_result, len(facts_to_run)
153
+
154
+
155
+ def _run_single_framework(
156
+ framework_name: str,
157
+ driver: GraphDatabase.driver,
158
+ database: str,
159
+ output_format: str,
160
+ neo4j_uri: str,
161
+ requirement_filter: str | None = None,
162
+ fact_filter: str | None = None,
163
+ ) -> FrameworkResult:
164
+ """Execute a single framework and return results."""
165
+ framework = FRAMEWORKS[framework_name]
166
+
167
+ # Filter requirements if needed
168
+ requirements_to_run = framework.requirements
169
+ if requirement_filter:
170
+ requirements_to_run = tuple(
171
+ req
172
+ for req in framework.requirements
173
+ if req.id.lower() == requirement_filter.lower()
174
+ )
175
+
176
+ # Count total facts for display (before filtering)
177
+ total_facts_display = sum(len(req.facts) for req in requirements_to_run)
178
+
179
+ if output_format == "text":
180
+ print(f"Executing {framework.name} framework")
181
+ if requirement_filter:
182
+ print(f"Filtered to requirement: {requirement_filter}")
183
+ if fact_filter:
184
+ print(f"Filtered to fact: {fact_filter}")
185
+ print(f"Requirements: {len(requirements_to_run)}")
186
+ print(f"Total facts: {total_facts_display}")
187
+
188
+ # Execute requirements and collect results
189
+ total_findings = 0
190
+ total_facts_executed = 0
191
+ requirement_results = []
192
+ fact_counter = 0
193
+
194
+ for requirement in requirements_to_run:
195
+ requirement_result, facts_executed = _run_single_requirement(
196
+ requirement,
197
+ framework,
198
+ driver,
199
+ database,
200
+ output_format,
201
+ neo4j_uri,
202
+ fact_counter,
203
+ total_facts_display,
204
+ fact_filter,
205
+ )
206
+ requirement_results.append(requirement_result)
207
+ total_findings += requirement_result.total_findings
208
+ total_facts_executed += facts_executed
209
+ fact_counter += facts_executed
210
+
211
+ # Create and return framework result
212
+ return FrameworkResult(
213
+ framework_id=framework.id,
214
+ framework_name=framework.name,
215
+ framework_version=framework.version,
216
+ requirements=requirement_results,
217
+ total_requirements=len(requirements_to_run),
218
+ total_facts=total_facts_executed, # Use actual executed count
219
+ total_findings=total_findings,
220
+ )
221
+
222
+
223
+ def _format_and_output_results(
224
+ all_results: list[FrameworkResult],
225
+ framework_names: list[str],
226
+ output_format: str,
227
+ total_requirements: int,
228
+ total_facts: int,
229
+ total_findings: int,
230
+ ):
231
+ """Format and output the results of framework execution."""
232
+ if output_format == "json":
233
+ combined_output = [asdict(result) for result in all_results]
234
+ print(json.dumps(combined_output, indent=2))
235
+ else:
236
+ # Text summary
237
+ print("\n" + "=" * 60)
238
+ if len(framework_names) == 1:
239
+ print(f"EXECUTION SUMMARY - {FRAMEWORKS[framework_names[0]].name}")
240
+ else:
241
+ print("OVERALL SUMMARY")
242
+ print("=" * 60)
243
+
244
+ if len(framework_names) > 1:
245
+ print(f"Frameworks executed: {len(framework_names)}")
246
+ print(f"Requirements: {total_requirements}")
247
+ print(f"Total facts: {total_facts}")
248
+ print(f"Total results: {total_findings}")
249
+
250
+ if total_findings > 0:
251
+ print(
252
+ f"\n\033[36mFramework execution completed with {total_findings} total results\033[0m"
253
+ )
254
+ else:
255
+ print("\n\033[90mFramework execution completed with no results\033[0m")
256
+
257
+
258
+ def run_frameworks(
259
+ framework_names: list[str],
260
+ uri: str,
261
+ neo4j_user: str,
262
+ neo4j_password: str,
263
+ neo4j_database: str,
264
+ output_format: str = "text",
265
+ requirement_filter: str | None = None,
266
+ fact_filter: str | None = None,
267
+ ):
268
+ """
269
+ Execute the specified frameworks and present results.
270
+
271
+ :param framework_names: The names of the frameworks to execute.
272
+ :param uri: The URI of the Neo4j database. E.g. "bolt://localhost:7687" or "neo4j+s://tenant123.databases.neo4j.io:7687"
273
+ :param neo4j_user: The username for the Neo4j database.
274
+ :param neo4j_password: The password for the Neo4j database.
275
+ :param neo4j_database: The name of the Neo4j database.
276
+ :param output_format: Either "text" or "json". Defaults to "text".
277
+ :param requirement_filter: Optional requirement ID to filter execution (case-insensitive).
278
+ :param fact_filter: Optional fact ID to filter execution (case-insensitive).
279
+ :return: The exit code.
280
+ """
281
+ # Validate all frameworks exist
282
+ for framework_name in framework_names:
283
+ if framework_name not in FRAMEWORKS:
284
+ if output_format == "text":
285
+ print(f"Unknown framework: {framework_name}")
286
+ print(f"Available frameworks: {', '.join(FRAMEWORKS.keys())}")
287
+ return 1
288
+
289
+ # Connect to Neo4j
290
+ if output_format == "text":
291
+ print(f"Connecting to Neo4j at {uri}...")
292
+ driver = GraphDatabase.driver(uri, auth=(neo4j_user, neo4j_password))
293
+
294
+ try:
295
+ driver.verify_connectivity()
296
+
297
+ # Execute frameworks
298
+ all_results = []
299
+ total_requirements = 0
300
+ total_facts = 0
301
+ total_findings = 0
302
+
303
+ for i, framework_name in enumerate(framework_names):
304
+ if output_format == "text" and len(framework_names) > 1:
305
+ if i > 0:
306
+ print("\n" + "=" * 60)
307
+ print(
308
+ f"Executing framework {i + 1}/{len(framework_names)}: {framework_name}"
309
+ )
310
+
311
+ framework_result = _run_single_framework(
312
+ framework_name,
313
+ driver,
314
+ neo4j_database,
315
+ output_format,
316
+ uri,
317
+ requirement_filter,
318
+ fact_filter,
319
+ )
320
+ all_results.append(framework_result)
321
+
322
+ total_requirements += framework_result.total_requirements
323
+ total_facts += framework_result.total_facts
324
+ total_findings += framework_result.total_findings
325
+
326
+ # Output results
327
+ _format_and_output_results(
328
+ all_results,
329
+ framework_names,
330
+ output_format,
331
+ total_requirements,
332
+ total_facts,
333
+ total_findings,
334
+ )
335
+
336
+ return 0
337
+ finally:
338
+ driver.close()
File without changes
@@ -0,0 +1,88 @@
1
+ from dataclasses import dataclass
2
+ from enum import Enum
3
+ from typing import Any
4
+
5
+
6
+ class Module(str, Enum):
7
+ """Services that can be monitored"""
8
+
9
+ AWS = "AWS"
10
+ """Amazon Web Services"""
11
+
12
+ AZURE = "Azure"
13
+ """Microsoft Azure"""
14
+
15
+ GCP = "GCP"
16
+ """Google Cloud Platform"""
17
+
18
+ GITHUB = "GitHub"
19
+ """GitHub source code management"""
20
+
21
+ OKTA = "Okta"
22
+ """Okta identity and access management"""
23
+
24
+ CROSS_CLOUD = "CROSS_CLOUD"
25
+ """Multi-cloud or provider-agnostic rules"""
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class Fact:
30
+ """A Fact gathers information about the environment using a Cypher query."""
31
+
32
+ id: str
33
+ """A descriptive identifier for the Fact. Should be globally unique within Cartography."""
34
+ name: str
35
+ """A descriptive name for the Fact."""
36
+ description: str
37
+ """More details about the Fact. Information on details that we're querying for."""
38
+ module: Module
39
+ """The Module that the Fact is associated with e.g. AWS, Azure, GCP, etc."""
40
+ # TODO can we lint the queries. full-on integ tests here are overkill though.
41
+ cypher_query: str
42
+ """The Cypher query to gather information about the environment. Returns data field by field e.g. `RETURN node.prop1, node.prop2`."""
43
+ cypher_visual_query: str
44
+ """
45
+ Same as `cypher_query`, returns it in a visual format for the web interface with `.. RETURN *`.
46
+ Often includes additional relationships to help give context.
47
+ """
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class Requirement:
52
+ """
53
+ A requirement within a security framework with one or more facts.
54
+
55
+ Notes:
56
+ - `attributes` is reserved for metadata such as tags, categories, or references.
57
+ - Do NOT put evaluation logic, thresholds, or org-specific preferences here.
58
+ """
59
+
60
+ id: str
61
+ name: str
62
+ description: str
63
+ facts: tuple[Fact, ...]
64
+ attributes: dict[str, Any] | None = None
65
+ """
66
+ Metadata attributes for the requirement. Example:
67
+ ```json
68
+ {
69
+ "tactic": "initial_access",
70
+ "technique_id": "T1190",
71
+ "services": ["ec2", "s3", "rds", "azure_storage"],
72
+ "providers": ["AWS", "AZURE"],
73
+ }
74
+ ```
75
+ """
76
+ requirement_url: str | None = None
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class Framework:
81
+ """A security framework containing requirements for comprehensive assessment."""
82
+
83
+ id: str
84
+ name: str
85
+ description: str
86
+ version: str
87
+ requirements: tuple[Requirement, ...]
88
+ source_url: str | None = None
@@ -0,0 +1,46 @@
1
+ # Execution result classes
2
+ from dataclasses import dataclass
3
+ from typing import Any
4
+
5
+
6
+ @dataclass
7
+ class FactResult:
8
+ """
9
+ Results for a single Fact.
10
+ """
11
+
12
+ fact_id: str
13
+ fact_name: str
14
+ fact_description: str
15
+ fact_provider: str
16
+ finding_count: int = 0
17
+ findings: list[dict[str, Any]] | None = None
18
+
19
+
20
+ @dataclass
21
+ class RequirementResult:
22
+ """
23
+ Results for a single requirement, containing all its Facts.
24
+ """
25
+
26
+ requirement_id: str
27
+ requirement_name: str
28
+ requirement_url: str | None
29
+ facts: list[FactResult]
30
+ total_facts: int
31
+ total_findings: int
32
+
33
+
34
+ @dataclass
35
+ class FrameworkResult:
36
+ """
37
+ The formal object output by `--output json` from the `cartography-rules` CLI.
38
+ """
39
+
40
+ framework_id: str
41
+ framework_name: str
42
+ framework_version: str
43
+ requirements: list[RequirementResult]
44
+ total_requirements: int
45
+ total_facts: int
46
+ total_findings: int