prelude-cli-beta 1446__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.
File without changes
@@ -0,0 +1,52 @@
1
+ import click
2
+
3
+ from prelude_cli_beta.views.auth import auth
4
+ from prelude_cli_beta.views.build import build
5
+ from prelude_cli_beta.views.configure import configure
6
+ from prelude_cli_beta.views.detect import detect
7
+ from prelude_cli_beta.views.generate import generate
8
+ from prelude_cli_beta.views.iam import iam
9
+ from prelude_cli_beta.views.jobs import jobs
10
+ from prelude_cli_beta.views.partner import partner
11
+ from prelude_cli_beta.views.scm import scm
12
+ from prelude_sdk_beta.models.account import Account, Keychain
13
+
14
+
15
+ def complete_profile(ctx, param, incomplete):
16
+ return [x for x in Keychain().read_keychain() if x.startswith(incomplete)]
17
+
18
+
19
+ @click.group(invoke_without_command=True)
20
+ @click.version_option()
21
+ @click.pass_context
22
+ @click.option(
23
+ "--profile",
24
+ default="default",
25
+ help="The prelude keychain profile to use",
26
+ show_default=True,
27
+ shell_complete=complete_profile,
28
+ )
29
+ @click.option(
30
+ "--resolve_enums",
31
+ is_flag=True,
32
+ help="Resolve enum values to their string representation",
33
+ )
34
+ def cli(ctx, profile, resolve_enums):
35
+ ctx.obj = Account.from_keychain(profile=profile, resolve_enums=resolve_enums)
36
+ if ctx.invoked_subcommand is None:
37
+ click.echo(ctx.get_help())
38
+
39
+
40
+ cli.add_command(auth)
41
+ cli.add_command(build)
42
+ cli.add_command(configure)
43
+ cli.add_command(detect)
44
+ cli.add_command(generate)
45
+ cli.add_command(iam)
46
+ cli.add_command(jobs)
47
+ cli.add_command(partner)
48
+ cli.add_command(scm)
49
+
50
+
51
+ if __name__ == "__main__":
52
+ cli()
File without changes
File without changes
@@ -0,0 +1,56 @@
1
+ import click
2
+ import webbrowser
3
+
4
+ from prelude_cli_beta.views.shared import Spinner, pretty_print
5
+
6
+
7
+ @click.group()
8
+ @click.pass_context
9
+ def auth(ctx):
10
+ """Authentication"""
11
+ pass
12
+
13
+
14
+ @auth.command("login")
15
+ @click.option("-p", "--password", type=str, help="password for login")
16
+ @click.option(
17
+ "-t",
18
+ "--temp_password",
19
+ type=str,
20
+ help="temporary password for login (if set, `password` will become your new password)",
21
+ )
22
+ @click.pass_obj
23
+ @pretty_print
24
+ def login(account, password, temp_password):
25
+ """Login using password or SSO"""
26
+ if not account.oidc:
27
+ password = password or click.prompt("Password", type=str, hide_input=True)
28
+ with Spinner(description="Logging in and saving tokens"):
29
+ new_password = password if temp_password else None
30
+ password = temp_password or password
31
+ return (
32
+ account.password_login(password, new_password),
33
+ "Login with password successful",
34
+ )
35
+
36
+ url = f"{account.hq.replace('api', 'platform')}/cli-auth?handle={account.handle}&provider={account.oidc}"
37
+ if account.oidc == "custom":
38
+ slug = account.slug or click.prompt("Please enter your account slug")
39
+ url += f"&slug={slug}"
40
+ webbrowser.open(url)
41
+ code = click.prompt(
42
+ f"Launching browser for authentication:\n\n{url}\n\nPlease enter your authorization code here"
43
+ )
44
+ verifier, authorization_code = code.split("/")
45
+ with Spinner(description="Logging in and saving tokens"):
46
+ tokens = account.exchange_authorization_code(authorization_code, verifier)
47
+ return tokens, "Login with SSO successful"
48
+
49
+
50
+ @auth.command("refresh")
51
+ @click.pass_obj
52
+ @pretty_print
53
+ def refresh(account):
54
+ """Refresh your tokens"""
55
+ with Spinner(description="Refreshing tokens"):
56
+ return account.refresh_tokens(), "New access tokens saved"
@@ -0,0 +1,488 @@
1
+ import importlib.resources as pkg_resources
2
+ import json
3
+ import os
4
+ import re
5
+ import time
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path, PurePath
8
+
9
+ import click
10
+
11
+ import prelude_cli_beta.templates as templates
12
+ from prelude_cli_beta.views.shared import Spinner, pretty_print
13
+ from prelude_sdk_beta.controllers.build_controller import BuildController
14
+ from prelude_sdk_beta.models.codes import Control, EDRResponse
15
+
16
+
17
+ UUID = re.compile(
18
+ "[a-f0-9]{8}-?[a-f0-9]{4}-?4[a-f0-9]{3}-?[89ab][a-f0-9]{3}-?[a-f0-9]{12}"
19
+ )
20
+
21
+
22
+ @click.group()
23
+ @click.pass_context
24
+ def build(ctx):
25
+ """Custom security tests"""
26
+ ctx.obj = BuildController(account=ctx.obj)
27
+
28
+
29
+ @build.command("clone-test")
30
+ @click.argument("source-test-id")
31
+ @click.pass_obj
32
+ @pretty_print
33
+ def clone_test(controller, source_test_id):
34
+ """Clone a security test"""
35
+
36
+ with Spinner(description="Creating new test"):
37
+ return controller.clone_test(
38
+ source_test_id=source_test_id,
39
+ )
40
+
41
+
42
+ @build.command("create-test")
43
+ @click.argument("name")
44
+ @click.option("-u", "--unit", required=True, help="unit identifier", type=str)
45
+ @click.option("-t", "--test", help="test identifier", default=None, type=str)
46
+ @click.option(
47
+ "-q", "--technique", help="MITRE ATT&CK code [e.g. T1557]", default=None, type=str
48
+ )
49
+ @click.pass_obj
50
+ @pretty_print
51
+ def create_test(controller, name, unit, test, technique):
52
+ """Create a security test"""
53
+
54
+ def create_template(template, name):
55
+ template_body = pkg_resources.read_text(templates, template)
56
+ template_body = template_body.replace("$ID", res["id"])
57
+ template_body = template_body.replace("$NAME", res["name"])
58
+ template_body = template_body.replace("$UNIT", res["unit"])
59
+ template_body = template_body.replace("$TECHNIQUE", res["technique"] or "")
60
+ template_body = template_body.replace("$TIME", str(datetime.now(timezone.utc)))
61
+
62
+ with Spinner(description="Applying default template to new test"):
63
+ controller.upload(
64
+ test_id=res["id"], filename=name, data=template_body.encode("utf-8")
65
+ )
66
+ res["attachments"] += [name]
67
+
68
+ dir = PurePath(res["id"], name)
69
+
70
+ with open(dir, "w", encoding="utf8") as code:
71
+ code.write(template_body)
72
+
73
+ with Spinner(description="Creating new test"):
74
+ res = controller.create_test(
75
+ name=name, unit=unit, test_id=test, technique=technique
76
+ )
77
+
78
+ if not test:
79
+ Path(res["id"]).mkdir(parents=True, exist_ok=True)
80
+ create_template(template="README.md", name="README.md")
81
+ create_template(template="template.go", name=f'{res["id"]}.go')
82
+ return res
83
+
84
+
85
+ @build.command("update-test")
86
+ @click.argument("test")
87
+ @click.option(
88
+ "-c",
89
+ "--crowdstrike_expected",
90
+ help="Crowdstrike expected outcome",
91
+ type=click.Choice(
92
+ [c.name for c in EDRResponse if c != EDRResponse.INVALID], case_sensitive=False
93
+ ),
94
+ )
95
+ @click.option("-n", "--name", help="test name", default=None, type=str)
96
+ @click.option("-u", "--unit", help="unit identifier", default=None, type=str)
97
+ @click.option(
98
+ "-q", "--technique", help="MITRE ATT&CK code [e.g. T1557]", default=None, type=str
99
+ )
100
+ @click.pass_obj
101
+ @pretty_print
102
+ def update_test(controller, test, crowdstrike_expected, name, unit, technique):
103
+ """Update a security test"""
104
+ with Spinner(description="Updating test"):
105
+ return controller.update_test(
106
+ test_id=test,
107
+ crowdstrike_expected_outcome=(
108
+ EDRResponse[crowdstrike_expected] if crowdstrike_expected else None
109
+ ),
110
+ name=name,
111
+ unit=unit,
112
+ technique=technique,
113
+ )
114
+
115
+
116
+ @build.command("delete-test")
117
+ @click.argument("test")
118
+ @click.option("-p", "--purge", is_flag=True, help="purge test and associated files")
119
+ @click.confirmation_option(prompt="Are you sure?")
120
+ @click.pass_obj
121
+ @pretty_print
122
+ def delete_test(controller, test, purge):
123
+ """Delete a test"""
124
+ with Spinner(description="Removing test"):
125
+ return controller.delete_test(test_id=test, purge=purge)
126
+
127
+
128
+ @build.command("compile-code-file")
129
+ @click.argument("path", type=click.Path(exists=True))
130
+ @click.option("-s", "--source-test-id", help="Include source test attachments")
131
+ @click.pass_obj
132
+ @pretty_print
133
+ def compile_code_file(controller, path, source_test_id):
134
+ """Test compile a go file, with test attachments if needed."""
135
+
136
+ with Spinner(description="Compiling code test") as spinner:
137
+ with open(path, "rb") as data:
138
+ data = controller.compile_code_string(
139
+ code=data.read(),
140
+ source_test_id=source_test_id,
141
+ )
142
+ if compile_job_id := data.get("job_id"):
143
+ spinner.update(spinner.task_ids[-1], description="Compiling")
144
+ while (
145
+ result := controller.get_compile_status(compile_job_id)
146
+ ) and result["status"] == "RUNNING":
147
+ time.sleep(2)
148
+ if result["status"] == "FAILED":
149
+ result["error"] = "Failed to compile"
150
+ data |= result
151
+ return data
152
+
153
+
154
+ @build.command("undelete-test")
155
+ @click.argument("test")
156
+ @click.pass_obj
157
+ @pretty_print
158
+ def undelete_test(controller, test):
159
+ """Undelete a test"""
160
+ with Spinner(description="Restoring test"):
161
+ return controller.undelete_test(test_id=test)
162
+
163
+
164
+ @build.command("upload")
165
+ @click.argument("path", type=click.Path(exists=True))
166
+ @click.option("-t", "--test", help="test identifier", default=None, type=str)
167
+ @click.pass_obj
168
+ @pretty_print
169
+ def upload_attachment(controller, path, test):
170
+ """Upload a test attachment from disk"""
171
+
172
+ def test_id():
173
+ match = UUID.search(path)
174
+ if match:
175
+ return match.group(0)
176
+ raise FileNotFoundError("You must supply a test ID or include it in the path")
177
+
178
+ def upload(p: Path, skip_compile=False):
179
+ if not p.is_file():
180
+ return
181
+
182
+ with open(p, "rb") as data:
183
+ with Spinner(description="Uploading to test") as spinner:
184
+ data = controller.upload(
185
+ test_id=identifier,
186
+ filename=p.name,
187
+ data=data.read(),
188
+ skip_compile=skip_compile,
189
+ )
190
+ if data.get("compile_job_id"):
191
+ spinner.update(spinner.task_ids[-1], description="Compiling")
192
+ while (
193
+ result := controller.get_compile_status(data["compile_job_id"])
194
+ ) and result["status"] == "RUNNING":
195
+ time.sleep(2)
196
+ if result["status"] == "FAILED":
197
+ result["error"] = "Failed to compile"
198
+ data |= result
199
+ res.append(data)
200
+
201
+ res = []
202
+ identifier = test or test_id()
203
+
204
+ if Path(path).is_file():
205
+ upload(p=Path(path))
206
+ else:
207
+ objs = list(Path(path).glob("*"))
208
+ for ind, obj in enumerate(objs):
209
+ try:
210
+ upload(p=Path(obj), skip_compile=ind != len(objs) - 1)
211
+ except ValueError as e:
212
+ res.append(dict(status="FAILED", reason=e.args[0]))
213
+ return res
214
+
215
+
216
+ @build.command("create-threat")
217
+ @click.argument("name")
218
+ @click.option(
219
+ "-p", "--published", help="date the threat was published", required=True, type=str
220
+ )
221
+ @click.option("--id", help="identifier", type=str)
222
+ @click.option(
223
+ "-s", "--source", help="source of threat (ex. www.cisa.gov)", default=None, type=str
224
+ )
225
+ @click.option(
226
+ "-i",
227
+ "--source_id",
228
+ help="ID of the threat, per the source (ex. aa23-075a)",
229
+ default=None,
230
+ type=str,
231
+ )
232
+ @click.option(
233
+ "-t", "--tests", help="comma-separated list of test IDs", default=None, type=str
234
+ )
235
+ @click.option(
236
+ "-d",
237
+ "--directory",
238
+ help="directory containing tests, detections, and hunt queries generated from threat_intel",
239
+ default=None,
240
+ type=click.Path(exists=True, dir_okay=True, file_okay=False),
241
+ )
242
+ @click.pass_obj
243
+ @pretty_print
244
+ def create_threat(controller, name, published, id, source_id, source, tests, directory):
245
+ """Create a security threat"""
246
+ with Spinner(description="Creating new threat"):
247
+ try:
248
+ created_tests = []
249
+ test_uploads = []
250
+ created_detections = []
251
+ created_queries = []
252
+ threat = None
253
+ if directory:
254
+ for technique_dir in os.listdir(directory):
255
+ with open(f"{directory}/{technique_dir}/config.json", "r") as f:
256
+ config = json.load(f)
257
+ test = controller.create_test(
258
+ name=config["name"],
259
+ unit=config["unit"],
260
+ technique=config["technique"],
261
+ )
262
+ created_tests.append(test)
263
+ with open(f"{directory}/{technique_dir}/test.go", "r") as f:
264
+ go_code = f.read()
265
+ test_uploads.append(
266
+ controller.upload(
267
+ test_id=test["id"],
268
+ filename=f'{test["id"]}.go',
269
+ data=go_code.encode(),
270
+ )
271
+ )
272
+ for sigma_file in Path(f"{directory}/{technique_dir}").glob(
273
+ "sigma*"
274
+ ):
275
+ with open(sigma_file, "r") as f:
276
+ rule = f.read()
277
+ created_detections.append(
278
+ controller.create_detection(
279
+ rule=rule, test_id=test["id"]
280
+ )
281
+ )
282
+ for query_file in Path(f"{directory}/{technique_dir}").glob(
283
+ "query*"
284
+ ):
285
+ with open(query_file, "r") as f:
286
+ query = json.load(f)
287
+ created_queries.append(
288
+ controller.create_threat_hunt(
289
+ name=query["name"],
290
+ query=query["query"],
291
+ test_id=test["id"],
292
+ )
293
+ )
294
+ tests = ",".join([t["id"] for t in created_tests])
295
+ threat = controller.create_threat(
296
+ name=name,
297
+ threat_id=id,
298
+ source_id=source_id,
299
+ source=source,
300
+ published=published,
301
+ tests=tests,
302
+ )
303
+ except FileNotFoundError as e:
304
+ raise Exception(e)
305
+ finally:
306
+ return dict(
307
+ threat=threat,
308
+ created_tests=created_tests,
309
+ test_uploads=test_uploads,
310
+ created_detections=created_detections,
311
+ created_threat_hunt_queries=created_queries,
312
+ )
313
+
314
+
315
+ @build.command("update-threat")
316
+ @click.argument("threat")
317
+ @click.option("-n", "--name", help="test name", default=None, type=str)
318
+ @click.option(
319
+ "-s", "--source", help="source of threat (ex. www.cisa.gov)", default=None, type=str
320
+ )
321
+ @click.option(
322
+ "-i",
323
+ "--source_id",
324
+ help="ID of the threat, per the source (ex. aa23-075a)",
325
+ default=None,
326
+ type=str,
327
+ )
328
+ @click.option(
329
+ "-p", "--published", help="date the threat was published", default=None, type=str
330
+ )
331
+ @click.option(
332
+ "-t", "--tests", help="comma-separated list of test IDs", default=None, type=str
333
+ )
334
+ @click.pass_obj
335
+ @pretty_print
336
+ def update_threat(controller, threat, name, source_id, source, published, tests):
337
+ """Create or update a security threat"""
338
+ with Spinner(description="Updating threat"):
339
+ return controller.update_threat(
340
+ threat_id=threat,
341
+ source_id=source_id,
342
+ name=name,
343
+ source=source,
344
+ published=published,
345
+ tests=tests,
346
+ )
347
+
348
+
349
+ @build.command("delete-threat")
350
+ @click.argument("threat")
351
+ @click.option("-p", "--purge", is_flag=True, help="purge threat")
352
+ @click.confirmation_option(prompt="Are you sure?")
353
+ @click.pass_obj
354
+ @pretty_print
355
+ def delete_threat(controller, threat, purge):
356
+ """Delete a threat"""
357
+ with Spinner(description="Removing threat"):
358
+ return controller.delete_threat(threat_id=threat, purge=purge)
359
+
360
+
361
+ @build.command("undelete-threat")
362
+ @click.argument("threat")
363
+ @click.pass_obj
364
+ @pretty_print
365
+ def undelete_threat(controller, threat):
366
+ """Undelete a threat"""
367
+ with Spinner(description="Restoring threat"):
368
+ return controller.undelete_threat(threat_id=threat)
369
+
370
+
371
+ @build.command("create-detection")
372
+ @click.argument("sigma_rule_file", type=click.Path(exists=True, dir_okay=False))
373
+ @click.option(
374
+ "-t", "--test", help="ID of the test this detection is for", required=True, type=str
375
+ )
376
+ @click.option("--detection_id", help="detection ID", default=None, type=str)
377
+ @click.option("--rule_id", help="rule ID", default=None, type=str)
378
+ @click.pass_obj
379
+ @pretty_print
380
+ def create_detection(controller, sigma_rule_file, test, detection_id, rule_id):
381
+ """Create a detection rule"""
382
+ with Spinner(description="Creating new detection"):
383
+ with open(sigma_rule_file, "r") as f:
384
+ rule = f.read()
385
+ return controller.create_detection(
386
+ rule=rule, test_id=test, detection_id=detection_id, rule_id=rule_id
387
+ )
388
+
389
+
390
+ @build.command("update-detection")
391
+ @click.argument("detection")
392
+ @click.option(
393
+ "--sigma_rule_file",
394
+ help="Sigma rule, from a yaml file",
395
+ default=None,
396
+ type=click.Path(exists=True, dir_okay=False),
397
+ )
398
+ @click.option(
399
+ "-t", "--test", help="ID of the test this detection is for", default=None, type=str
400
+ )
401
+ @click.pass_obj
402
+ @pretty_print
403
+ def update_detection(controller, detection, sigma_rule_file, test):
404
+ """Update a detection"""
405
+ with Spinner(description="Updating detection"):
406
+ with open(sigma_rule_file, "r") as f:
407
+ rule = f.read()
408
+ return controller.update_detection(
409
+ rule=rule,
410
+ test_id=test,
411
+ detection_id=detection,
412
+ )
413
+
414
+
415
+ @build.command("delete-detection")
416
+ @click.argument("detection")
417
+ @click.confirmation_option(prompt="Are you sure?")
418
+ @click.pass_obj
419
+ @pretty_print
420
+ def delete_detection(controller, detection):
421
+ """Delete a detection"""
422
+ with Spinner(description="Removing detection"):
423
+ return controller.delete_detection(detection_id=detection)
424
+
425
+
426
+ @build.command("create-threat-hunt")
427
+ @click.argument("name")
428
+ @click.option(
429
+ "-c",
430
+ "--control",
431
+ help="",
432
+ required=True,
433
+ type=click.Choice(
434
+ [Control.CROWDSTRIKE.name, Control.DEFENDER.name], case_sensitive=False
435
+ ),
436
+ )
437
+ @click.option("-q", "--query", help="Threat hunt query", required=True, type=str)
438
+ @click.option(
439
+ "-t",
440
+ "--test",
441
+ help="ID of the test this threat hunt query is for",
442
+ required=True,
443
+ type=str,
444
+ )
445
+ @click.option("--id", default=None, type=str)
446
+ @click.pass_obj
447
+ @pretty_print
448
+ def create_threat_hunt(controller, name, control, query, test, id):
449
+ """Create a threat hunt query"""
450
+ with Spinner(description="Creating new threat hunt"):
451
+ return controller.create_threat_hunt(
452
+ control=Control[control],
453
+ name=name,
454
+ query=query,
455
+ test_id=test,
456
+ threat_hunt_id=id,
457
+ )
458
+
459
+
460
+ @build.command("update-threat-hunt")
461
+ @click.argument("threat_hunt")
462
+ @click.option("-n", "--name", help="Name of this threat hunt query", type=str)
463
+ @click.option("-q", "--query", help="Threat hunt query", type=str)
464
+ @click.option(
465
+ "-t", "--test", help="ID of the test this threat hunt query is for", type=str
466
+ )
467
+ @click.pass_obj
468
+ @pretty_print
469
+ def update_threat_hunt(controller, threat_hunt, name, query, test):
470
+ """Update a threat hunt"""
471
+ with Spinner(description="Updating threat hunt"):
472
+ return controller.update_threat_hunt(
473
+ name=name,
474
+ query=query,
475
+ test_id=test,
476
+ threat_hunt_id=threat_hunt,
477
+ )
478
+
479
+
480
+ @build.command("delete-threat-hunt")
481
+ @click.argument("threat_hunt")
482
+ @click.confirmation_option(prompt="Are you sure?")
483
+ @click.pass_obj
484
+ @pretty_print
485
+ def delete_threat_hunt(controller, threat_hunt):
486
+ """Delete a threat hunt"""
487
+ with Spinner(description="Removing threat hunt"):
488
+ return controller.delete_threat_hunt(threat_hunt_id=threat_hunt)
@@ -0,0 +1,29 @@
1
+ import click
2
+
3
+
4
+ @click.command()
5
+ @click.pass_obj
6
+ def configure(account):
7
+ """Configure your local keychain"""
8
+ profile = click.prompt(
9
+ "Enter the profile name",
10
+ default=account.profile or "default",
11
+ show_default=True,
12
+ )
13
+ hq = click.prompt("Enter the Prelude API", default=account.hq, show_default=True)
14
+ account_id = click.prompt("Enter your account ID")
15
+ handle = click.prompt("Enter your user handle (email)")
16
+ oidc = click.prompt(
17
+ "If authenticating via OIDC, enter the provider name",
18
+ default="none",
19
+ type=click.Choice(["google", "custom", "none"], case_sensitive=False),
20
+ ).lower()
21
+ slug = None
22
+ if oidc == "none":
23
+ oidc = None
24
+ elif oidc == "custom":
25
+ slug = click.prompt("Please enter your account slug")
26
+ account.keychain.configure_keychain(
27
+ account=account_id, handle=handle, hq=hq, oidc=oidc, profile=profile, slug=slug
28
+ )
29
+ click.secho("Credentials saved", fg="green")