cartography 0.114.0__py3-none-any.whl → 0.116.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.
- cartography/_version.py +2 -2
- cartography/cli.py +2 -2
- cartography/client/core/tx.py +12 -1
- cartography/intel/aws/config.py +7 -3
- cartography/intel/aws/ecr.py +9 -9
- cartography/intel/aws/ecr_image_layers.py +664 -0
- cartography/intel/aws/identitycenter.py +240 -13
- cartography/intel/aws/lambda_function.py +69 -2
- cartography/intel/aws/organizations.py +3 -1
- cartography/intel/aws/permission_relationships.py +3 -1
- cartography/intel/aws/redshift.py +9 -4
- cartography/intel/aws/resources.py +2 -0
- cartography/intel/aws/route53.py +53 -3
- cartography/intel/aws/securityhub.py +3 -1
- cartography/intel/azure/__init__.py +16 -0
- cartography/intel/azure/logic_apps.py +101 -0
- cartography/intel/azure/resource_groups.py +82 -0
- cartography/intel/create_indexes.py +2 -1
- cartography/intel/dns.py +5 -2
- cartography/intel/gcp/dns.py +2 -1
- cartography/intel/github/repos.py +3 -6
- cartography/intel/gsuite/api.py +17 -4
- cartography/intel/okta/applications.py +9 -4
- cartography/intel/okta/awssaml.py +5 -2
- cartography/intel/okta/factors.py +3 -1
- cartography/intel/okta/groups.py +5 -2
- cartography/intel/okta/organization.py +3 -1
- cartography/intel/okta/origins.py +3 -1
- cartography/intel/okta/roles.py +5 -2
- cartography/intel/okta/users.py +3 -1
- cartography/models/aws/ecr/image.py +21 -0
- cartography/models/aws/ecr/image_layer.py +107 -0
- cartography/models/aws/identitycenter/awspermissionset.py +24 -1
- cartography/models/aws/identitycenter/awssogroup.py +70 -0
- cartography/models/aws/identitycenter/awsssouser.py +37 -1
- cartography/models/aws/lambda_function/lambda_function.py +2 -0
- cartography/models/azure/logic_apps.py +56 -0
- cartography/models/azure/resource_groups.py +52 -0
- cartography/models/entra/user.py +18 -0
- cartography/rules/README.md +1 -0
- cartography/rules/__init__.py +0 -0
- cartography/rules/cli.py +342 -0
- cartography/rules/data/__init__.py +0 -0
- cartography/rules/data/frameworks/__init__.py +12 -0
- cartography/rules/data/frameworks/mitre_attack/__init__.py +14 -0
- cartography/rules/data/frameworks/mitre_attack/requirements/__init__.py +0 -0
- cartography/rules/data/frameworks/mitre_attack/requirements/t1190_exploit_public_facing_application/__init__.py +135 -0
- cartography/rules/formatters.py +46 -0
- cartography/rules/runners.py +338 -0
- cartography/rules/spec/__init__.py +0 -0
- cartography/rules/spec/model.py +88 -0
- cartography/rules/spec/result.py +46 -0
- {cartography-0.114.0.dist-info → cartography-0.116.0.dist-info}/METADATA +19 -4
- {cartography-0.114.0.dist-info → cartography-0.116.0.dist-info}/RECORD +58 -38
- {cartography-0.114.0.dist-info → cartography-0.116.0.dist-info}/entry_points.txt +1 -0
- {cartography-0.114.0.dist-info → cartography-0.116.0.dist-info}/WHEEL +0 -0
- {cartography-0.114.0.dist-info → cartography-0.116.0.dist-info}/licenses/LICENSE +0 -0
- {cartography-0.114.0.dist-info → cartography-0.116.0.dist-info}/top_level.txt +0 -0
cartography/rules/cli.py
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Cartography RunRules CLI
|
|
3
|
+
|
|
4
|
+
Execute security frameworks and present facts about your environment.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import builtins
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
from enum import Enum
|
|
11
|
+
from typing import Generator
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
from typing_extensions import Annotated
|
|
15
|
+
|
|
16
|
+
from cartography.rules.data.frameworks import FRAMEWORKS
|
|
17
|
+
from cartography.rules.runners import run_frameworks
|
|
18
|
+
from cartography.rules.spec.model import Fact
|
|
19
|
+
from cartography.rules.spec.model import Requirement
|
|
20
|
+
|
|
21
|
+
app = typer.Typer(
|
|
22
|
+
help="Execute Cartography security frameworks",
|
|
23
|
+
no_args_is_help=True,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class OutputFormat(str, Enum):
|
|
28
|
+
"""Output format options."""
|
|
29
|
+
|
|
30
|
+
text = "text"
|
|
31
|
+
json = "json"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def complete_frameworks(incomplete: str) -> Generator[str, None, None]:
|
|
35
|
+
"""Autocomplete framework names."""
|
|
36
|
+
for name in FRAMEWORKS.keys():
|
|
37
|
+
if name.startswith(incomplete):
|
|
38
|
+
yield name
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def complete_frameworks_with_all(incomplete: str) -> Generator[str, None, None]:
|
|
42
|
+
"""Autocomplete framework names plus 'all'."""
|
|
43
|
+
for name in builtins.list(FRAMEWORKS.keys()) + ["all"]:
|
|
44
|
+
if name.startswith(incomplete):
|
|
45
|
+
yield name
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def complete_requirements(
|
|
49
|
+
ctx: typer.Context, incomplete: str
|
|
50
|
+
) -> Generator[str, None, None]:
|
|
51
|
+
"""Autocomplete requirement IDs based on selected framework."""
|
|
52
|
+
framework = ctx.params.get("framework")
|
|
53
|
+
if not framework or framework not in FRAMEWORKS:
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
for req in FRAMEWORKS[framework].requirements:
|
|
57
|
+
if req.id.lower().startswith(incomplete.lower()):
|
|
58
|
+
yield req.id
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def complete_facts(ctx: typer.Context, incomplete: str) -> Generator[str, None, None]:
|
|
62
|
+
"""Autocomplete fact IDs based on selected framework and requirement."""
|
|
63
|
+
framework = ctx.params.get("framework")
|
|
64
|
+
requirement_id = ctx.params.get("requirement")
|
|
65
|
+
|
|
66
|
+
if not framework or framework not in FRAMEWORKS:
|
|
67
|
+
return
|
|
68
|
+
if not requirement_id:
|
|
69
|
+
return
|
|
70
|
+
|
|
71
|
+
# Find the requirement
|
|
72
|
+
for req in FRAMEWORKS[framework].requirements:
|
|
73
|
+
if req.id.lower() == requirement_id.lower():
|
|
74
|
+
for fact in req.facts:
|
|
75
|
+
if fact.id.lower().startswith(incomplete.lower()):
|
|
76
|
+
yield fact.id
|
|
77
|
+
break
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@app.command() # type: ignore[misc]
|
|
81
|
+
def list(
|
|
82
|
+
framework: Annotated[
|
|
83
|
+
str | None,
|
|
84
|
+
typer.Argument(
|
|
85
|
+
help="Framework name (e.g., mitre-attack)",
|
|
86
|
+
autocompletion=complete_frameworks,
|
|
87
|
+
),
|
|
88
|
+
] = None,
|
|
89
|
+
requirement: Annotated[
|
|
90
|
+
str | None,
|
|
91
|
+
typer.Argument(
|
|
92
|
+
help="Requirement ID (e.g., T1190)",
|
|
93
|
+
autocompletion=complete_requirements,
|
|
94
|
+
),
|
|
95
|
+
] = None,
|
|
96
|
+
) -> None:
|
|
97
|
+
"""
|
|
98
|
+
List available frameworks, requirements, and facts.
|
|
99
|
+
|
|
100
|
+
\b
|
|
101
|
+
Examples:
|
|
102
|
+
cartography-rules list
|
|
103
|
+
cartography-rules list mitre-attack
|
|
104
|
+
cartography-rules list mitre-attack T1190
|
|
105
|
+
"""
|
|
106
|
+
# List all frameworks
|
|
107
|
+
if not framework:
|
|
108
|
+
typer.secho("\nAvailable Frameworks\n", bold=True)
|
|
109
|
+
for fw_name, fw in FRAMEWORKS.items():
|
|
110
|
+
typer.secho(f"{fw_name}", fg=typer.colors.CYAN)
|
|
111
|
+
typer.echo(f" Name: {fw.name}")
|
|
112
|
+
typer.echo(f" Version: {fw.version}")
|
|
113
|
+
typer.echo(f" Requirements: {len(fw.requirements)}")
|
|
114
|
+
total_facts = sum(len(req.facts) for req in fw.requirements)
|
|
115
|
+
typer.echo(f" Total Facts: {total_facts}")
|
|
116
|
+
if fw.source_url:
|
|
117
|
+
typer.echo(f" Source: {fw.source_url}")
|
|
118
|
+
typer.echo()
|
|
119
|
+
return
|
|
120
|
+
|
|
121
|
+
# Validate framework
|
|
122
|
+
if framework not in FRAMEWORKS:
|
|
123
|
+
typer.secho(
|
|
124
|
+
f"Error: Unknown framework '{framework}'", fg=typer.colors.RED, err=True
|
|
125
|
+
)
|
|
126
|
+
typer.echo(f"Available: {', '.join(FRAMEWORKS.keys())}", err=True)
|
|
127
|
+
raise typer.Exit(1)
|
|
128
|
+
|
|
129
|
+
fw = FRAMEWORKS[framework]
|
|
130
|
+
|
|
131
|
+
# List all requirements in framework
|
|
132
|
+
if not requirement:
|
|
133
|
+
typer.secho(f"\n{fw.name}", bold=True)
|
|
134
|
+
typer.echo(f"Version: {fw.version}\n")
|
|
135
|
+
for r in fw.requirements:
|
|
136
|
+
typer.secho(f"{r.id}", fg=typer.colors.CYAN)
|
|
137
|
+
typer.echo(f" Name: {r.name}")
|
|
138
|
+
typer.echo(f" Facts: {len(r.facts)}")
|
|
139
|
+
if r.requirement_url:
|
|
140
|
+
typer.echo(f" URL: {r.requirement_url}")
|
|
141
|
+
typer.echo()
|
|
142
|
+
return
|
|
143
|
+
|
|
144
|
+
# Find and list facts in requirement
|
|
145
|
+
req: Requirement | None = None
|
|
146
|
+
for r in fw.requirements:
|
|
147
|
+
if r.id.lower() == requirement.lower():
|
|
148
|
+
req = r
|
|
149
|
+
break
|
|
150
|
+
|
|
151
|
+
if not req:
|
|
152
|
+
typer.secho(
|
|
153
|
+
f"Error: Requirement '{requirement}' not found",
|
|
154
|
+
fg=typer.colors.RED,
|
|
155
|
+
err=True,
|
|
156
|
+
)
|
|
157
|
+
typer.echo("\nAvailable requirements:", err=True)
|
|
158
|
+
for r in fw.requirements:
|
|
159
|
+
typer.echo(f" {r.id}", err=True)
|
|
160
|
+
raise typer.Exit(1)
|
|
161
|
+
|
|
162
|
+
typer.secho(f"\n{req.name}\n", bold=True)
|
|
163
|
+
typer.echo(f"ID: {req.id}")
|
|
164
|
+
if req.requirement_url:
|
|
165
|
+
typer.echo(f"URL: {req.requirement_url}")
|
|
166
|
+
typer.secho(f"\nFacts ({len(req.facts)})\n", bold=True)
|
|
167
|
+
|
|
168
|
+
for fact in req.facts:
|
|
169
|
+
typer.secho(f"{fact.id}", fg=typer.colors.CYAN)
|
|
170
|
+
typer.echo(f" Name: {fact.name}")
|
|
171
|
+
typer.echo(f" Description: {fact.description}")
|
|
172
|
+
typer.echo(f" Provider: {fact.module.value}")
|
|
173
|
+
typer.echo()
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@app.command() # type: ignore[misc]
|
|
177
|
+
def run(
|
|
178
|
+
framework: Annotated[
|
|
179
|
+
str,
|
|
180
|
+
typer.Argument(
|
|
181
|
+
help="Framework to execute (or 'all' for all frameworks)",
|
|
182
|
+
autocompletion=complete_frameworks_with_all,
|
|
183
|
+
),
|
|
184
|
+
],
|
|
185
|
+
requirement: Annotated[
|
|
186
|
+
str | None,
|
|
187
|
+
typer.Argument(
|
|
188
|
+
help="Specific requirement ID to run",
|
|
189
|
+
autocompletion=complete_requirements,
|
|
190
|
+
),
|
|
191
|
+
] = None,
|
|
192
|
+
fact: Annotated[
|
|
193
|
+
str | None,
|
|
194
|
+
typer.Argument(
|
|
195
|
+
help="Specific fact ID to run",
|
|
196
|
+
autocompletion=complete_facts,
|
|
197
|
+
),
|
|
198
|
+
] = None,
|
|
199
|
+
uri: Annotated[
|
|
200
|
+
str,
|
|
201
|
+
typer.Option(help="Neo4j URI", envvar="NEO4J_URI"),
|
|
202
|
+
] = "bolt://localhost:7687",
|
|
203
|
+
user: Annotated[
|
|
204
|
+
str,
|
|
205
|
+
typer.Option(help="Neo4j username", envvar="NEO4J_USER"),
|
|
206
|
+
] = "neo4j",
|
|
207
|
+
database: Annotated[
|
|
208
|
+
str,
|
|
209
|
+
typer.Option(help="Neo4j database name", envvar="NEO4J_DATABASE"),
|
|
210
|
+
] = "neo4j",
|
|
211
|
+
neo4j_password_env_var: Annotated[
|
|
212
|
+
str | None,
|
|
213
|
+
typer.Option(help="Environment variable containing Neo4j password"),
|
|
214
|
+
] = None,
|
|
215
|
+
neo4j_password_prompt: Annotated[
|
|
216
|
+
bool,
|
|
217
|
+
typer.Option(help="Prompt for Neo4j password interactively"),
|
|
218
|
+
] = False,
|
|
219
|
+
output: Annotated[
|
|
220
|
+
OutputFormat,
|
|
221
|
+
typer.Option(help="Output format"),
|
|
222
|
+
] = OutputFormat.text,
|
|
223
|
+
) -> None:
|
|
224
|
+
"""
|
|
225
|
+
Execute a security framework.
|
|
226
|
+
|
|
227
|
+
\b
|
|
228
|
+
Examples:
|
|
229
|
+
cartography-rules run all
|
|
230
|
+
cartography-rules run mitre-attack
|
|
231
|
+
cartography-rules run mitre-attack T1190
|
|
232
|
+
cartography-rules run mitre-attack T1190 aws_rds_public_access
|
|
233
|
+
"""
|
|
234
|
+
# Validate framework
|
|
235
|
+
valid_frameworks = builtins.list(FRAMEWORKS.keys()) + ["all"]
|
|
236
|
+
if framework not in valid_frameworks:
|
|
237
|
+
typer.secho(
|
|
238
|
+
f"Error: Unknown framework '{framework}'", fg=typer.colors.RED, err=True
|
|
239
|
+
)
|
|
240
|
+
typer.echo(f"Available: {', '.join(valid_frameworks)}", err=True)
|
|
241
|
+
raise typer.Exit(1)
|
|
242
|
+
|
|
243
|
+
# Validate fact requires requirement
|
|
244
|
+
if fact and not requirement:
|
|
245
|
+
typer.secho(
|
|
246
|
+
"Error: Cannot specify fact without requirement",
|
|
247
|
+
fg=typer.colors.RED,
|
|
248
|
+
err=True,
|
|
249
|
+
)
|
|
250
|
+
raise typer.Exit(1)
|
|
251
|
+
|
|
252
|
+
# Validate filtering with 'all'
|
|
253
|
+
if framework == "all" and (requirement or fact):
|
|
254
|
+
typer.secho(
|
|
255
|
+
"Error: Cannot filter by requirement/fact when running all frameworks",
|
|
256
|
+
fg=typer.colors.RED,
|
|
257
|
+
err=True,
|
|
258
|
+
)
|
|
259
|
+
raise typer.Exit(1)
|
|
260
|
+
|
|
261
|
+
# Validate requirement exists
|
|
262
|
+
if requirement and framework != "all":
|
|
263
|
+
fw = FRAMEWORKS[framework]
|
|
264
|
+
req: Requirement | None = None
|
|
265
|
+
for r in fw.requirements:
|
|
266
|
+
if r.id.lower() == requirement.lower():
|
|
267
|
+
req = r
|
|
268
|
+
break
|
|
269
|
+
|
|
270
|
+
if not req:
|
|
271
|
+
typer.secho(
|
|
272
|
+
f"Error: Requirement '{requirement}' not found",
|
|
273
|
+
fg=typer.colors.RED,
|
|
274
|
+
err=True,
|
|
275
|
+
)
|
|
276
|
+
typer.echo("\nAvailable requirements:", err=True)
|
|
277
|
+
for r in fw.requirements:
|
|
278
|
+
typer.echo(f" {r.id}", err=True)
|
|
279
|
+
raise typer.Exit(1)
|
|
280
|
+
|
|
281
|
+
# Validate fact exists
|
|
282
|
+
if fact:
|
|
283
|
+
fact_found: Fact | None = None
|
|
284
|
+
for f in req.facts:
|
|
285
|
+
if f.id.lower() == fact.lower():
|
|
286
|
+
fact_found = f
|
|
287
|
+
break
|
|
288
|
+
|
|
289
|
+
if not fact_found:
|
|
290
|
+
typer.secho(
|
|
291
|
+
f"Error: Fact '{fact}' not found in requirement '{requirement}'",
|
|
292
|
+
fg=typer.colors.RED,
|
|
293
|
+
err=True,
|
|
294
|
+
)
|
|
295
|
+
typer.echo("\nAvailable facts:", err=True)
|
|
296
|
+
for f in req.facts:
|
|
297
|
+
typer.echo(f" {f.id}", err=True)
|
|
298
|
+
raise typer.Exit(1)
|
|
299
|
+
|
|
300
|
+
# Get password
|
|
301
|
+
password = None
|
|
302
|
+
if neo4j_password_prompt:
|
|
303
|
+
password = typer.prompt("Neo4j password", hide_input=True)
|
|
304
|
+
elif neo4j_password_env_var:
|
|
305
|
+
password = os.environ.get(neo4j_password_env_var)
|
|
306
|
+
else:
|
|
307
|
+
password = os.getenv("NEO4J_PASSWORD")
|
|
308
|
+
if not password:
|
|
309
|
+
password = typer.prompt("Neo4j password", hide_input=True)
|
|
310
|
+
|
|
311
|
+
# Determine frameworks to run
|
|
312
|
+
if framework == "all":
|
|
313
|
+
frameworks_to_run = builtins.list(FRAMEWORKS.keys())
|
|
314
|
+
else:
|
|
315
|
+
frameworks_to_run = [framework]
|
|
316
|
+
|
|
317
|
+
# Execute
|
|
318
|
+
try:
|
|
319
|
+
exit_code = run_frameworks(
|
|
320
|
+
frameworks_to_run,
|
|
321
|
+
uri,
|
|
322
|
+
user,
|
|
323
|
+
password,
|
|
324
|
+
database,
|
|
325
|
+
output.value,
|
|
326
|
+
requirement_filter=requirement,
|
|
327
|
+
fact_filter=fact,
|
|
328
|
+
)
|
|
329
|
+
raise typer.Exit(exit_code)
|
|
330
|
+
except KeyboardInterrupt:
|
|
331
|
+
raise typer.Exit(130)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def main():
|
|
335
|
+
"""Entrypoint for cartography-rules CLI."""
|
|
336
|
+
logging.basicConfig(level=logging.INFO)
|
|
337
|
+
logging.getLogger("neo4j").setLevel(logging.ERROR)
|
|
338
|
+
app()
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
if __name__ == "__main__":
|
|
342
|
+
main()
|
|
File without changes
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Framework Registry
|
|
3
|
+
|
|
4
|
+
Central registry of all available security frameworks for Cartography Rules.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from cartography.rules.data.frameworks.mitre_attack import mitre_attack_framework
|
|
8
|
+
|
|
9
|
+
# Framework registry - all available frameworks
|
|
10
|
+
FRAMEWORKS = {
|
|
11
|
+
"mitre-attack": mitre_attack_framework,
|
|
12
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# MITRE ATT&CK Framework
|
|
2
|
+
from cartography.rules.data.frameworks.mitre_attack.requirements.t1190_exploit_public_facing_application import (
|
|
3
|
+
t1190,
|
|
4
|
+
)
|
|
5
|
+
from cartography.rules.spec.model import Framework
|
|
6
|
+
|
|
7
|
+
mitre_attack_framework = Framework(
|
|
8
|
+
id="MITRE_ATTACK",
|
|
9
|
+
name="MITRE ATT&CK",
|
|
10
|
+
description="Comprehensive security assessment framework based on MITRE ATT&CK tactics and techniques",
|
|
11
|
+
version="1.0",
|
|
12
|
+
requirements=(t1190,),
|
|
13
|
+
source_url="https://attack.mitre.org/",
|
|
14
|
+
)
|
|
File without changes
|
|
@@ -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}"
|