praetorian-cli 2.2.1__py3-none-any.whl → 2.2.2__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.
Files changed (30) hide show
  1. praetorian_cli/handlers/aegis.py +107 -0
  2. praetorian_cli/handlers/get.py +17 -0
  3. praetorian_cli/handlers/list.py +33 -0
  4. praetorian_cli/handlers/ssh_utils.py +154 -0
  5. praetorian_cli/handlers/test.py +7 -2
  6. praetorian_cli/main.py +1 -0
  7. praetorian_cli/sdk/chariot.py +67 -0
  8. praetorian_cli/sdk/entities/aegis.py +437 -0
  9. praetorian_cli/sdk/entities/scanners.py +13 -0
  10. praetorian_cli/sdk/model/aegis.py +156 -0
  11. praetorian_cli/sdk/test/pytest.ini +1 -0
  12. praetorian_cli/sdk/test/ui_mocks.py +133 -0
  13. praetorian_cli/ui/__init__.py +3 -0
  14. praetorian_cli/ui/aegis/__init__.py +5 -0
  15. praetorian_cli/ui/aegis/commands/__init__.py +2 -0
  16. praetorian_cli/ui/aegis/commands/help.py +81 -0
  17. praetorian_cli/ui/aegis/commands/info.py +136 -0
  18. praetorian_cli/ui/aegis/commands/job.py +381 -0
  19. praetorian_cli/ui/aegis/commands/list.py +14 -0
  20. praetorian_cli/ui/aegis/commands/set.py +32 -0
  21. praetorian_cli/ui/aegis/commands/ssh.py +87 -0
  22. praetorian_cli/ui/aegis/constants.py +20 -0
  23. praetorian_cli/ui/aegis/menu.py +395 -0
  24. praetorian_cli/ui/aegis/utils.py +162 -0
  25. {praetorian_cli-2.2.1.dist-info → praetorian_cli-2.2.2.dist-info}/METADATA +4 -1
  26. {praetorian_cli-2.2.1.dist-info → praetorian_cli-2.2.2.dist-info}/RECORD +30 -12
  27. {praetorian_cli-2.2.1.dist-info → praetorian_cli-2.2.2.dist-info}/WHEEL +0 -0
  28. {praetorian_cli-2.2.1.dist-info → praetorian_cli-2.2.2.dist-info}/entry_points.txt +0 -0
  29. {praetorian_cli-2.2.1.dist-info → praetorian_cli-2.2.2.dist-info}/licenses/LICENSE +0 -0
  30. {praetorian_cli-2.2.1.dist-info → praetorian_cli-2.2.2.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,107 @@
1
+ import click
2
+ from praetorian_cli.handlers.chariot import chariot
3
+ from praetorian_cli.handlers.cli_decorators import cli_handler
4
+
5
+
6
+ @chariot.group(invoke_without_command=True)
7
+ @cli_handler
8
+ @click.pass_context
9
+ def aegis(ctx, sdk):
10
+ """Aegis management commands"""
11
+ if ctx.invoked_subcommand is None:
12
+ # No subcommand was invoked, run the default interactive interface
13
+ from praetorian_cli.ui.aegis.menu import run_aegis_menu
14
+ run_aegis_menu(sdk)
15
+
16
+
17
+ # Add the shared commands to the CLI group
18
+ # Each shared command gets wrapped to inject the CLI context
19
+
20
+ @aegis.command('list')
21
+ @cli_handler
22
+ @click.option('--details', is_flag=True, help='Show detailed agent information')
23
+ @click.option('--filter', help='Filter agents by hostname or other properties')
24
+ @click.pass_context
25
+ def list_agents(ctx, sdk, details, filter):
26
+ """List Aegis agents with optional details"""
27
+ click.echo(sdk.aegis.format_agents_list(details=details, filter_text=filter))
28
+
29
+
30
+ @aegis.command('ssh')
31
+ @cli_handler
32
+ @click.argument('client_id', required=True)
33
+ @click.option('-u', '--user', help='SSH username (prepends user@ to hostname)')
34
+ @click.argument('args', nargs=-1)
35
+ @click.pass_context
36
+ def ssh(ctx, sdk, client_id, user, args):
37
+ """Connect to an Aegis agent via SSH.
38
+
39
+ Pass native ssh flags after client_id; they are forwarded to ssh.
40
+
41
+ Common options (forwarded to ssh):
42
+ -L [bind_address:]port:host:hostport Local port forward (repeatable)
43
+ -R [bind_address:]port:host:hostport Remote port forward (repeatable)
44
+ -D [bind_address:]port Dynamic SOCKS proxy
45
+ -i IDENTITY_FILE Identity (private key) file
46
+ -l USER Remote username (alternative to -u/--user)
47
+ -o OPTION=VALUE Extra ssh config option
48
+ -p PORT SSH port
49
+ -v/-vv/-vvv Verbose output
50
+ """
51
+ agent = sdk.aegis.get_by_client_id(client_id)
52
+ if not agent:
53
+ click.echo(f"Agent not found: {client_id}", err=True)
54
+ return
55
+
56
+ options = list(args)
57
+ sdk.aegis.ssh_to_agent(agent=agent, options=options, user=user, display_info=True)
58
+
59
+
60
+ @aegis.command('job')
61
+ @cli_handler
62
+ @click.option('-c', '--capability', 'capabilities', multiple=True, help='Capability to run (e.g., windows-smb-snaffler)')
63
+ @click.option('--config', help='JSON configuration string for the job')
64
+ @click.argument('client_id', required=True)
65
+ @click.pass_context
66
+ def job(ctx, sdk, capabilities, config, client_id):
67
+ """Run a job on an Aegis agent"""
68
+ agent = sdk.aegis.get_by_client_id(client_id)
69
+ if not agent:
70
+ click.echo(f"Agent not found: {client_id}", err=True)
71
+ return
72
+
73
+ try:
74
+ result = sdk.aegis.run_job(
75
+ agent,
76
+ list(capabilities) if capabilities else None,
77
+ config
78
+ )
79
+
80
+ if 'capabilities' in result:
81
+ click.echo("Available capabilities:")
82
+ for cap in result['capabilities']:
83
+ name = cap.get('name', 'unknown')
84
+ desc = cap.get('description', '')[:50]
85
+ click.echo(f" {name:<25} {desc}")
86
+ elif result.get('success'):
87
+ click.echo("✓ Job queued successfully")
88
+ click.echo(f" Job ID: {result.get('job_id', 'unknown')}")
89
+ click.echo(f" Status: {result.get('status', 'unknown')}")
90
+ else:
91
+ click.echo("Error: Unknown error", err=True)
92
+ except Exception as e:
93
+ click.echo(f"Error: {e}", err=True)
94
+
95
+
96
+ @aegis.command('info')
97
+ @cli_handler
98
+ @click.argument('client_id', required=True)
99
+ @click.pass_context
100
+ def info(ctx, sdk, client_id):
101
+ """Show detailed information for an agent"""
102
+ agent = sdk.aegis.get_by_client_id(client_id)
103
+ if not agent:
104
+ click.echo(f"Agent not found: {client_id}", err=True)
105
+ return
106
+
107
+ click.echo(agent.to_detailed_string())
@@ -278,3 +278,20 @@ def credential(chariot, credential_id, category, type, format, parameters):
278
278
  result = chariot.credentials.get(credential_id, category, type, [format], **params)
279
279
  output = chariot.credentials.format_output(result)
280
280
  click.echo(output)
281
+
282
+
283
+ @get.command()
284
+ @cli_handler
285
+ @click.argument('key', required=True)
286
+ def scanner(chariot, key):
287
+ """ Get scanner details
288
+
289
+ \b
290
+ Argument:
291
+ - KEY: the key of an existing scanner record
292
+
293
+ \b
294
+ Example usage:
295
+ - praetorian chariot get scanner "#scanner#127.0.0.1"
296
+ """
297
+ print_json(chariot.scanners.get(key))
@@ -64,6 +64,22 @@ def accounts(chariot, filter, details, offset, page):
64
64
  render_list_results(chariot.accounts.list(filter, offset, pagination_size(page)), details)
65
65
 
66
66
 
67
+ @list.command()
68
+ @list_params('Aegis ID', has_filter=False)
69
+ def aegis(chariot, details, offset, page):
70
+ """ List Aegis
71
+
72
+ Retrieve and display a list of Aegis instances.
73
+
74
+ \b
75
+ Example usages:
76
+ - praetorian chariot list aegis
77
+ - praetorian chariot list aegis --details
78
+ - praetorian chariot list aegis --page all
79
+ """
80
+ render_list_results(chariot.aegis.list(offset, pagination_size(page)), details)
81
+
82
+
67
83
  @list.command()
68
84
  @list_params('integration name')
69
85
  def integrations(chariot, filter, details, offset, page):
@@ -336,3 +352,20 @@ def capabilities(chariot, name, target, executor):
336
352
  - praetorian chariot list capabilities --name nuclei --target attribute --executor chariot
337
353
  """
338
354
  print_json(chariot.capabilities.list(name, target, executor))
355
+
356
+
357
+ @list.command()
358
+ @list_params('IP address')
359
+ def scanners(chariot, filter, details, offset, page):
360
+ """ List scanners
361
+
362
+ Retrieve and display a list of scanner records that track IP addresses used by chariot.
363
+
364
+ \b
365
+ Example usages:
366
+ - praetorian chariot list scanners
367
+ - praetorian chariot list scanners --filter 127.0.0.1
368
+ - praetorian chariot list scanners --details
369
+ - praetorian chariot list scanners --page all
370
+ """
371
+ render_list_results(chariot.scanners.list(filter, offset, pagination_size(page)), details)
@@ -0,0 +1,154 @@
1
+ """
2
+ Shared SSH utilities for Aegis CLI and TUI commands
3
+ """
4
+
5
+ from typing import List, Dict, Optional
6
+ from praetorian_cli.sdk.model.aegis import Agent
7
+
8
+
9
+ class SSHArgumentParser:
10
+ """Shared SSH argument parser for both CLI and TUI interfaces"""
11
+
12
+ def __init__(self, console=None):
13
+ self.console = console
14
+
15
+ def parse_ssh_args(self, args: List[str]) -> Optional[Dict]:
16
+ """
17
+ Parse SSH command arguments and return options dict
18
+ Returns None if parsing fails with error messages displayed
19
+ """
20
+ options = {
21
+ 'local_forward': [],
22
+ 'remote_forward': [],
23
+ 'dynamic_forward': None,
24
+ 'key': None,
25
+ 'ssh_opts': None,
26
+ 'user': None,
27
+ 'passthrough': [] # collect unknown flags to pass through
28
+ }
29
+
30
+ i = 0
31
+ while i < len(args):
32
+ arg = args[i]
33
+
34
+ if arg in ['-L', '-l', '--local-forward']:
35
+ if i + 1 >= len(args):
36
+ self._print_error("Error: -L requires a port forwarding specification")
37
+ self._print_error("Example: ssh -L 8080:localhost:80", dim=True)
38
+ return None
39
+ options['local_forward'].append(args[i + 1])
40
+ i += 2
41
+
42
+ elif arg in ['-R', '-r', '--remote-forward']:
43
+ if i + 1 >= len(args):
44
+ self._print_error("Error: -R requires a port forwarding specification")
45
+ self._print_error("Example: ssh -R 9090:localhost:3000", dim=True)
46
+ return None
47
+ options['remote_forward'].append(args[i + 1])
48
+ i += 2
49
+
50
+ elif arg in ['-D', '-d', '--dynamic-forward']:
51
+ if i + 1 >= len(args):
52
+ self._print_error("Error: -D requires a port number")
53
+ self._print_error("Example: ssh -D 1080", dim=True)
54
+ return None
55
+ try:
56
+ port = int(args[i + 1])
57
+ if port < 1 or port > 65535:
58
+ raise ValueError()
59
+ options['dynamic_forward'] = str(port)
60
+ except ValueError:
61
+ self._print_error(f"Error: Invalid port number '{args[i + 1]}'")
62
+ self._print_error("Port must be a number between 1 and 65535", dim=True)
63
+ return None
64
+ i += 2
65
+
66
+ elif arg in ['-i', '-I', '--key']:
67
+ if i + 1 >= len(args):
68
+ self._print_error("Error: -i requires a key file path")
69
+ self._print_error("Example: ssh -i ~/.ssh/my_key", dim=True)
70
+ return None
71
+ options['key'] = args[i + 1]
72
+ i += 2
73
+
74
+ elif arg in ['-u', '-U', '--user']:
75
+ if i + 1 >= len(args):
76
+ self._print_error("Error: -u requires a username")
77
+ self._print_error("Example: ssh -u root", dim=True)
78
+ return None
79
+ options['user'] = args[i + 1]
80
+ i += 2
81
+
82
+ elif arg.startswith('-'):
83
+ # Collect unknown options and their arguments if any
84
+ options['passthrough'].append(arg)
85
+ # If next token is a value and current looks like expects arg (heuristic), include it
86
+ if i + 1 < len(args) and not args[i + 1].startswith('-'):
87
+ options['passthrough'].append(args[i + 1])
88
+ i += 2
89
+ else:
90
+ i += 1
91
+ else:
92
+ self._print_error(f"Error: Unexpected argument '{arg}'")
93
+ return None
94
+
95
+ return options
96
+
97
+ def validate_agent_ssh_availability(self, agent) -> bool:
98
+ """
99
+ Check if SSH is available for the given agent
100
+ Returns True if available, False otherwise with error messages displayed
101
+ """
102
+ is_valid, error_msg = validate_agent_for_ssh(agent)
103
+ if not is_valid:
104
+ self._print_error(error_msg)
105
+ return False
106
+ return True
107
+
108
+ def has_ssh_options(self, options: Dict) -> bool:
109
+ """Check if any actual SSH options were provided (not just passthrough)"""
110
+ return bool(
111
+ options['local_forward'] or
112
+ options['remote_forward'] or
113
+ options['dynamic_forward'] or
114
+ options['key'] or
115
+ options['user']
116
+ )
117
+
118
+ def _print_error(self, message: str, dim: bool = False):
119
+ """Print error message using console if available, otherwise plain print"""
120
+ if self.console:
121
+ if dim:
122
+ self.console.print(f"[dim]{message}[/dim]")
123
+ else:
124
+ self.console.print(f"[red]{message}[/red]")
125
+ else:
126
+ # Fallback for CLI usage
127
+ print(f"Error: {message}" if not dim else message)
128
+
129
+
130
+ def validate_agent_for_ssh(agent: Agent) -> tuple[bool, str]:
131
+ """
132
+ Validate if an agent is ready for SSH connections
133
+ Returns (is_valid, error_message)
134
+ """
135
+ if not agent:
136
+ return False, "No agent specified"
137
+
138
+ client_id = agent.client_id
139
+ hostname = agent.hostname or 'Unknown'
140
+ has_tunnel = agent.has_tunnel
141
+
142
+ if not client_id:
143
+ return False, "Agent missing client_id"
144
+
145
+ # Check if Cloudflare tunnel is available
146
+ if not has_tunnel:
147
+ return False, f"SSH not available for {hostname} - no active tunnel"
148
+
149
+ # Check if tunnel has a public hostname
150
+ public_hostname = agent.health_check.cloudflared_status.hostname if has_tunnel else None
151
+ if not public_hostname:
152
+ return False, f"No public hostname found in tunnel configuration for {hostname}"
153
+
154
+ return True, ""
@@ -1,4 +1,6 @@
1
1
  import os
2
+ import sys
3
+ import subprocess
2
4
 
3
5
  import click
4
6
  import pytest
@@ -10,7 +12,7 @@ from praetorian_cli.handlers.cli_decorators import cli_handler
10
12
 
11
13
  @chariot.command()
12
14
  @cli_handler
13
- @click.option('-s', '--suite', type=click.Choice(['coherence', 'cli']), help='Run a specific test suite')
15
+ @click.option('-s', '--suite', type=click.Choice(['coherence', 'cli', 'tui']), help='Run a specific test suite')
14
16
  @click.argument('key', required=False)
15
17
  def test(chariot, key, suite):
16
18
  """ Run integration test suite """
@@ -21,4 +23,7 @@ def test(chariot, key, suite):
21
23
  command.extend(['-k', key])
22
24
  if suite:
23
25
  command.extend(['-m', suite])
24
- pytest.main(command)
26
+ # Run pytest in a subprocess to isolate from CLI pre-imports
27
+ args = [sys.executable, '-m', 'pytest'] + command
28
+ result = subprocess.run(args)
29
+ raise SystemExit(result.returncode)
praetorian_cli/main.py CHANGED
@@ -1,6 +1,7 @@
1
1
  import click
2
2
 
3
3
  import praetorian_cli.handlers.add
4
+ import praetorian_cli.handlers.aegis
4
5
  import praetorian_cli.handlers.agent
5
6
  import praetorian_cli.handlers.delete
6
7
  import praetorian_cli.handlers.enrich
@@ -1,6 +1,7 @@
1
1
  import json, requests, os
2
2
 
3
3
  from praetorian_cli.sdk.entities.accounts import Accounts
4
+ from praetorian_cli.sdk.entities.aegis import Aegis
4
5
  from praetorian_cli.sdk.entities.agents import Agents
5
6
  from praetorian_cli.sdk.entities.assets import Assets
6
7
  from praetorian_cli.sdk.entities.attributes import Attributes
@@ -14,6 +15,7 @@ from praetorian_cli.sdk.entities.jobs import Jobs
14
15
  from praetorian_cli.sdk.entities.keys import Keys
15
16
  from praetorian_cli.sdk.entities.preseeds import Preseeds
16
17
  from praetorian_cli.sdk.entities.risks import Risks
18
+ from praetorian_cli.sdk.entities.scanners import Scanners
17
19
  from praetorian_cli.sdk.entities.search import Search
18
20
  from praetorian_cli.sdk.entities.seeds import Seeds
19
21
  from praetorian_cli.sdk.entities.settings import Settings
@@ -39,8 +41,10 @@ class Chariot:
39
41
  self.definitions = Definitions(self)
40
42
  self.attributes = Attributes(self)
41
43
  self.search = Search(self)
44
+ self.scanners = Scanners(self)
42
45
  self.webhook = Webhook(self)
43
46
  self.statistics = Statistics(self)
47
+ self.aegis = Aegis(self)
44
48
  self.agents = Agents(self)
45
49
  self.settings = Settings(self)
46
50
  self.configurations = Configurations(self)
@@ -256,6 +260,69 @@ class Chariot:
256
260
 
257
261
  server = MCPServer(self, allowable_tools)
258
262
  return anyio.run(server.start)
263
+
264
+ def get_current_user(self) -> tuple:
265
+ """
266
+ Get current user information for Aegis functionality.
267
+
268
+ Returns:
269
+ tuple: (user_email, username) where user_email is the login email
270
+ and username is the SSH username derived from the email
271
+ """
272
+ # Try to get username from keychain first (for username/password auth)
273
+ user_email = self.keychain.username()
274
+
275
+ # If no username in keychain (API key auth), try to get it from JWT token
276
+ if not user_email and self.keychain.has_api_key():
277
+ token = self.keychain.token()
278
+ payload = decode_jwt_payload(token)
279
+ if payload:
280
+ # Extract email from the 'email' field in the JWT payload
281
+ user_email = payload.get('email')
282
+ else:
283
+ # If JWT decoding fails, fall back to the account parameter
284
+ raise Exception("Failed to decode JWT token")
285
+
286
+ # Extract username from email (part before @) for SSH access
287
+ username = user_email.split('@')[0] if user_email and '@' in user_email else user_email
288
+ return user_email, username
289
+
290
+
291
+ def decode_jwt_payload(token: str) -> dict | None:
292
+ """
293
+ Decode the payload from a JWT token.
294
+
295
+ Args:
296
+ token: JWT token string in format header.payload.signature
297
+
298
+ Returns:
299
+ dict: Decoded payload contents, or None if decoding fails
300
+
301
+ Example:
302
+ >>> token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6InVzZXJAZXhhbXBsZS5jb20ifQ.signature"
303
+ >>> payload = decode_jwt_payload(token)
304
+ >>> print(payload.get('email'))
305
+ user@example.com
306
+ """
307
+ try:
308
+ import json
309
+ import base64
310
+
311
+ # JWT tokens have 3 parts: header.payload.signature
312
+ parts = token.split('.')
313
+ if len(parts) != 3:
314
+ return None
315
+
316
+ payload_part = parts[1]
317
+ # Add padding if needed for base64 decoding
318
+ payload_part += '=' * (4 - len(payload_part) % 4)
319
+ payload = json.loads(base64.b64decode(payload_part))
320
+
321
+ return payload
322
+ except Exception:
323
+ return None
324
+
325
+
259
326
  def is_query_limit_failure(response: requests.Response) -> bool:
260
327
  return response.status_code == 413 and 'reduce page size' in response.text
261
328