praetorian-cli 2.2.1__py3-none-any.whl → 2.2.3__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 (43) hide show
  1. praetorian_cli/handlers/add.py +25 -7
  2. praetorian_cli/handlers/aegis.py +107 -0
  3. praetorian_cli/handlers/delete.py +3 -2
  4. praetorian_cli/handlers/get.py +48 -2
  5. praetorian_cli/handlers/list.py +41 -9
  6. praetorian_cli/handlers/ssh_utils.py +154 -0
  7. praetorian_cli/handlers/test.py +7 -2
  8. praetorian_cli/handlers/update.py +3 -3
  9. praetorian_cli/main.py +1 -0
  10. praetorian_cli/sdk/chariot.py +71 -12
  11. praetorian_cli/sdk/entities/aegis.py +437 -0
  12. praetorian_cli/sdk/entities/assets.py +30 -12
  13. praetorian_cli/sdk/entities/scanners.py +13 -0
  14. praetorian_cli/sdk/entities/schema.py +27 -0
  15. praetorian_cli/sdk/entities/seeds.py +108 -56
  16. praetorian_cli/sdk/mcp_server.py +2 -3
  17. praetorian_cli/sdk/model/aegis.py +156 -0
  18. praetorian_cli/sdk/model/query.py +1 -1
  19. praetorian_cli/sdk/model/utils.py +2 -8
  20. praetorian_cli/sdk/test/pytest.ini +1 -0
  21. praetorian_cli/sdk/test/test_asset.py +2 -2
  22. praetorian_cli/sdk/test/test_seed.py +13 -14
  23. praetorian_cli/sdk/test/test_z_cli.py +22 -24
  24. praetorian_cli/sdk/test/ui_mocks.py +133 -0
  25. praetorian_cli/sdk/test/utils.py +16 -4
  26. praetorian_cli/ui/__init__.py +3 -0
  27. praetorian_cli/ui/aegis/__init__.py +5 -0
  28. praetorian_cli/ui/aegis/commands/__init__.py +2 -0
  29. praetorian_cli/ui/aegis/commands/help.py +81 -0
  30. praetorian_cli/ui/aegis/commands/info.py +136 -0
  31. praetorian_cli/ui/aegis/commands/job.py +381 -0
  32. praetorian_cli/ui/aegis/commands/list.py +14 -0
  33. praetorian_cli/ui/aegis/commands/set.py +32 -0
  34. praetorian_cli/ui/aegis/commands/ssh.py +87 -0
  35. praetorian_cli/ui/aegis/constants.py +20 -0
  36. praetorian_cli/ui/aegis/menu.py +395 -0
  37. praetorian_cli/ui/aegis/utils.py +162 -0
  38. {praetorian_cli-2.2.1.dist-info → praetorian_cli-2.2.3.dist-info}/METADATA +4 -1
  39. {praetorian_cli-2.2.1.dist-info → praetorian_cli-2.2.3.dist-info}/RECORD +43 -24
  40. {praetorian_cli-2.2.1.dist-info → praetorian_cli-2.2.3.dist-info}/WHEEL +0 -0
  41. {praetorian_cli-2.2.1.dist-info → praetorian_cli-2.2.3.dist-info}/entry_points.txt +0 -0
  42. {praetorian_cli-2.2.1.dist-info → praetorian_cli-2.2.3.dist-info}/licenses/LICENSE +0 -0
  43. {praetorian_cli-2.2.1.dist-info → praetorian_cli-2.2.3.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,133 @@
1
+ class MockConsole:
2
+ def __init__(self):
3
+ self.lines = []
4
+
5
+ def print(self, msg=""):
6
+ self.lines.append(str(msg))
7
+
8
+
9
+ class MockAegis:
10
+ def __init__(self, responses=None):
11
+ self.calls = []
12
+ self._responses = responses or {}
13
+
14
+ def ssh_to_agent(self, agent, options, user, display_info=True):
15
+ self.calls.append({
16
+ 'method': 'ssh_to_agent',
17
+ 'agent': agent,
18
+ 'options': list(options),
19
+ 'user': user,
20
+ 'display_info': display_info,
21
+ })
22
+
23
+ def run_job(self, agent, capabilities=None, config=None):
24
+ self.calls.append({
25
+ 'method': 'run_job',
26
+ 'agent': agent,
27
+ 'capabilities': capabilities,
28
+ 'config': config,
29
+ })
30
+ if capabilities is None:
31
+ return self._responses.get('list_caps', {'capabilities': []})
32
+ return self._responses.get('run', {'success': True, 'job_id': 'abc123', 'job_key': 'k', 'status': 'queued'})
33
+
34
+ # Newer TUI code uses these helpers
35
+ def validate_capability(self, name):
36
+ caps = self._responses.get('capabilities', {
37
+ 'windows-smb': {'name': 'windows-smb', 'description': 'Windows SMB capability', 'target': 'asset'},
38
+ 'linux-enum': {'name': 'linux-enum', 'description': 'Linux enum capability', 'target': 'asset'},
39
+ })
40
+ return caps.get(name)
41
+
42
+ def create_job_config(self, agent, credentials=None):
43
+ # Return provided credentials or an empty config as JSON-ready dict
44
+ return credentials or self._responses.get('config', {})
45
+
46
+ def get_available_ad_domains(self):
47
+ return self._responses.get('domains', ['example.local'])
48
+
49
+
50
+ class MockSDK:
51
+ def __init__(self, responses=None):
52
+ self.aegis = MockAegis(responses=responses)
53
+ self.jobs = MockJobs(responses=responses)
54
+
55
+
56
+ class MockJobs:
57
+ def __init__(self, responses=None):
58
+ self._responses = responses or {}
59
+ self.calls = []
60
+
61
+ def add(self, target_key, capabilities, config_json):
62
+ self.calls.append({
63
+ 'method': 'add',
64
+ 'target_key': target_key,
65
+ 'capabilities': capabilities,
66
+ 'config': config_json,
67
+ })
68
+ # Return a minimal job-like record the UI expects
69
+ return [self._responses.get('job', {
70
+ 'key': 'jobs#abc123deadbeef',
71
+ 'status': 'queued',
72
+ })]
73
+
74
+ def list(self, prefix_filter=None):
75
+ # Return (jobs, next_page_token)
76
+ jobs = self._responses.get('jobs', [])
77
+ return jobs, None
78
+
79
+
80
+ class MockCloudflaredStatus:
81
+ def __init__(self, hostname='cf.example.com', tunnel_name='tunnel-1', authorized_users=''):
82
+ self.hostname = hostname
83
+ self.tunnel_name = tunnel_name
84
+ self.authorized_users = authorized_users
85
+
86
+
87
+ class MockHealthCheck:
88
+ def __init__(self, cf_status=None):
89
+ self.cloudflared_status = cf_status or MockCloudflaredStatus()
90
+
91
+
92
+ class MockAgent:
93
+ def __init__(self, hostname="agent01", client_id="C.1"):
94
+ # Basic identity
95
+ self.hostname = hostname
96
+ self.client_id = client_id
97
+ # System info (optional in UI)
98
+ self.os = None
99
+ self.os_version = None
100
+ self.architecture = None
101
+ self.fqdn = None
102
+ # Activity/timestamps
103
+ self.last_seen_at = 0
104
+ # Networking
105
+ self.network_interfaces = []
106
+ # Tunnel/health
107
+ self.has_tunnel = True
108
+ self.health_check = MockHealthCheck()
109
+
110
+ def to_detailed_string(self):
111
+ return f"Agent {self.hostname} ({self.client_id})"
112
+
113
+
114
+ class MockMenuBase:
115
+ def __init__(self):
116
+ self.console = MockConsole()
117
+ self.paused = False
118
+ # Minimal color map used by the UI (optional)
119
+ self.colors = {
120
+ 'primary': 'cyan',
121
+ 'accent': 'magenta',
122
+ 'dim': 'dim',
123
+ 'success': 'green',
124
+ 'warning': 'yellow',
125
+ 'error': 'red',
126
+ }
127
+
128
+ def pause(self):
129
+ self.paused = True
130
+
131
+ def clear_screen(self):
132
+ # No-op for tests; add a blank line like the real UI would
133
+ self.console.print()
@@ -5,7 +5,7 @@ from random import randint
5
5
  from praetorian_cli.sdk.chariot import Chariot
6
6
  from praetorian_cli.sdk.keychain import Keychain
7
7
  from praetorian_cli.sdk.model.globals import Risk, Preseed
8
- from praetorian_cli.sdk.model.utils import risk_key, asset_key, ad_domain_key, attribute_key, seed_key, preseed_key, setting_key, configuration_key
8
+ from praetorian_cli.sdk.model.utils import risk_key, asset_key, ad_domain_key, attribute_key, seed_asset_key, preseed_key, setting_key, configuration_key
9
9
 
10
10
 
11
11
  def epoch_micro():
@@ -27,14 +27,26 @@ def random_dns():
27
27
  def random_ad_domain():
28
28
  return f'test-{epoch_micro()}.local'
29
29
 
30
+ def random_object_id():
31
+ domain_id_1 = randint(1000000000, 4294967295) # Start from 1 billion for realism
32
+ domain_id_2 = randint(1000000000, 4294967295)
33
+ domain_id_3 = randint(1000000000, 4294967295)
34
+
35
+ # Generate a random relative identifier (RID)
36
+ # Common ranges: 500-999 for built-in accounts, 1000+ for user accounts
37
+ relative_id = randint(1000, 999999)
38
+ return f"S-1-5-21-{domain_id_1}-{domain_id_2}-{domain_id_3}-{relative_id}"
39
+
40
+
30
41
  def make_test_values(o):
31
42
  o.asset_dns = random_dns()
32
43
  o.asset_name = random_ip()
33
44
  o.asset_key = asset_key(o.asset_dns, o.asset_name)
34
45
  o.ad_domain_name = random_ad_domain()
35
- o.ad_domain_key = ad_domain_key(o.ad_domain_name, o.ad_domain_name)
36
- o.seed_dns = random_dns()
37
- o.seed_key = seed_key('domain', o.seed_dns)
46
+ o.ad_object_id = random_object_id()
47
+ o.ad_domain_key = ad_domain_key(o.ad_domain_name, o.ad_object_id)
48
+ o.seed_asset_dns = random_dns()
49
+ o.seed_asset_key = seed_asset_key(o.seed_asset_dns)
38
50
  o.risk_name = f'test-risk-name-{epoch_micro()}'
39
51
  o.risk_key = risk_key(o.asset_dns, o.risk_name)
40
52
  o.comment = f'Test comment {epoch_micro()}'
@@ -0,0 +1,3 @@
1
+ """User interface (UI) modules for interactive consoles and TUI components."""
2
+
3
+
@@ -0,0 +1,5 @@
1
+ """Aegis interactive console (TUI) package."""
2
+
3
+ from .menu import run_aegis_menu, AegisMenu # re-export for convenience
4
+
5
+
@@ -0,0 +1,2 @@
1
+ """Command handlers for Aegis TUI."""
2
+
@@ -0,0 +1,81 @@
1
+ from rich.table import Table
2
+ from rich.box import MINIMAL
3
+ from ..constants import DEFAULT_COLORS
4
+
5
+
6
+
7
+
8
+
9
+ def handle_help(menu, args):
10
+ """Show help for commands or a specific command."""
11
+ colors = getattr(menu, 'colors', DEFAULT_COLORS)
12
+ if args and args[0] in ['ssh', 'list', 'info', 'job', 'set']:
13
+ menu.console.print(f"\nHelp for '{args[0]}' command - see main help for details\n")
14
+ menu.pause()
15
+ return
16
+
17
+ commands_table = Table(
18
+ show_header=True,
19
+ header_style=f"bold {colors['primary']}",
20
+ border_style=colors['dim'],
21
+ box=MINIMAL,
22
+ show_lines=False,
23
+ padding=(0, 2),
24
+ pad_edge=False
25
+ )
26
+
27
+ commands_table.add_column("COMMAND", style=f"bold {colors['success']}", min_width=20, no_wrap=True)
28
+ commands_table.add_column("DESCRIPTION", style="white", no_wrap=False)
29
+
30
+ commands_table.add_row("set <id>", "Select an agent by number, client_id, or hostname")
31
+ commands_table.add_row("list [--all]", "List online agents (--all shows offline too)")
32
+ commands_table.add_row("ssh [options]", "SSH to selected agent (use 'ssh --help' for options)")
33
+ commands_table.add_row("info [--raw]", "Show detailed information for selected agent")
34
+ commands_table.add_row("job list", "List recent jobs for selected agent")
35
+ commands_table.add_row("job capabilities [--details]", "List available capabilities")
36
+ commands_table.add_row("job run <capability>", "Run capability on selected agent")
37
+ commands_table.add_row("reload", "Refresh agent list from server")
38
+ commands_table.add_row("help [command]", "Show this help or command-specific help")
39
+ commands_table.add_row("clear", "Clear terminal screen")
40
+ commands_table.add_row("quit / exit", "Exit the interface")
41
+
42
+ menu.console.print()
43
+ menu.console.print(" Available Commands")
44
+ menu.console.print()
45
+ menu.console.print(commands_table)
46
+
47
+ examples_table = Table(
48
+ show_header=True,
49
+ header_style=f"bold {colors['warning']}",
50
+ border_style=colors['dim'],
51
+ box=MINIMAL,
52
+ show_lines=False,
53
+ padding=(0, 2),
54
+ pad_edge=False
55
+ )
56
+
57
+ examples_table.add_column("EXAMPLE", style=f"bold {colors['accent']}", min_width=25, no_wrap=True)
58
+ examples_table.add_column("DESCRIPTION", style=f"{colors['dim']}", no_wrap=False)
59
+
60
+ examples_table.add_row("set 1", "Select first agent")
61
+ examples_table.add_row("set abc", "Select agent by hostname")
62
+ examples_table.add_row("ssh -D 1080", "SSH with SOCKS proxy on port 1080")
63
+ examples_table.add_row("list --all", "Show all agents including offline")
64
+ examples_table.add_row("job list", "List recent jobs")
65
+ examples_table.add_row("job capabilities", "List available capabilities")
66
+ examples_table.add_row("job caps --details", "Show full capability descriptions")
67
+ examples_table.add_row("job run windows-enum", "Run capability on selected agent")
68
+ examples_table.add_row("info", "Show agent details")
69
+ examples_table.add_row("info --raw", "Show raw agent data (JSON format)")
70
+
71
+ menu.console.print()
72
+ menu.console.print(" Examples")
73
+ menu.console.print()
74
+ menu.console.print(examples_table)
75
+ menu.console.print()
76
+ menu.pause()
77
+
78
+
79
+ def complete(menu, text, tokens):
80
+ # Suggest commands after 'help '
81
+ return [c for c in menu.commands if c.startswith(text)]
@@ -0,0 +1,136 @@
1
+ def handle_info(menu, args):
2
+ """Show detailed information for the selected agent."""
3
+ if not menu.selected_agent:
4
+ menu.console.print("\n No agent selected. Use 'set <id>' to select one.\n")
5
+ menu.pause()
6
+ return
7
+
8
+ # Check for raw flag
9
+ raw = ('--raw' in args) or ('-r' in args)
10
+
11
+ try:
12
+ _show_agent_info(menu, menu.selected_agent, raw=raw)
13
+ except Exception as e:
14
+ menu.console.print(f"[red]Error getting agent info: {e}[/red]")
15
+ menu.pause()
16
+
17
+
18
+ def _show_agent_info(menu, agent, raw=False):
19
+ """Show detailed agent info with clean formatting"""
20
+ import json
21
+ from datetime import datetime
22
+
23
+ colors = getattr(menu, 'colors', {})
24
+ hostname = agent.hostname or 'Unknown'
25
+
26
+ # Clear screen and show header
27
+ menu.clear_screen()
28
+ menu.console.print()
29
+ menu.console.print(f" [{colors.get('primary', 'cyan')}]Agent Details[/{colors.get('primary', 'cyan')}]")
30
+ menu.console.print()
31
+
32
+ if raw:
33
+ # Raw JSON dump with minimal styling
34
+ menu.console.print(f" [{colors.get('dim', 'dim')}]Raw agent data:[/{colors.get('dim', 'dim')}]")
35
+ menu.console.print()
36
+ # Convert agent to dict for JSON serialization
37
+ agent_dict = agent.to_dict() if hasattr(agent, 'to_dict') else agent.__dict__
38
+ json_lines = json.dumps(agent_dict, default=str, indent=2).split('\n')
39
+ for line in json_lines:
40
+ menu.console.print(f" {line}")
41
+ menu.pause()
42
+ return
43
+
44
+ # Gather agent info
45
+ os_info = (agent.os or 'unknown').lower()
46
+ os_version = agent.os_version or ''
47
+ architecture = agent.architecture or 'Unknown'
48
+ fqdn = agent.fqdn or 'N/A'
49
+ client_id = agent.client_id or 'N/A'
50
+ last_seen = agent.last_seen_at or 0
51
+ health = agent.health_check
52
+ cf_status = health.cloudflared_status if health else None
53
+
54
+ # Get network interfaces and extract IP addresses
55
+ network_interfaces = agent.network_interfaces or []
56
+ ip_info = []
57
+
58
+ # Extract IPs from network interfaces
59
+ if network_interfaces:
60
+ for interface in network_interfaces:
61
+ if hasattr(interface, 'name'): # NetworkInterface object
62
+ # Get interface name
63
+ iface_name = interface.name or ''
64
+
65
+ # Get IP addresses from the ip_addresses field (it's a list)
66
+ ip_addresses = interface.ip_addresses or []
67
+
68
+ # Add each IP with interface name
69
+ for ip in ip_addresses:
70
+ if ip: # Skip empty strings
71
+ if iface_name and iface_name != 'lo': # Skip loopback
72
+ ip_info.append(f"{ip} ({iface_name})")
73
+ elif iface_name != 'lo':
74
+ ip_info.append(ip)
75
+
76
+ # Compute status
77
+ current_time = datetime.now().timestamp()
78
+ if last_seen > 0:
79
+ last_seen_seconds = last_seen / 1000000 if last_seen > 1000000000000 else last_seen
80
+ is_online = (current_time - last_seen_seconds) < 60
81
+ last_seen_str = datetime.fromtimestamp(last_seen_seconds).strftime("%Y-%m-%d %H:%M:%S")
82
+ if is_online:
83
+ status_text = f"[{colors.get('success', 'green')}]● online[/{colors.get('success', 'green')}]"
84
+ else:
85
+ status_text = f"[{colors.get('error', 'red')}]○ offline[/{colors.get('error', 'red')}]"
86
+ else:
87
+ last_seen_str = "never"
88
+ status_text = f"[{colors.get('error', 'red')}]○ offline[/{colors.get('error', 'red')}]"
89
+ is_online = False
90
+
91
+ # Simple, clean output
92
+ menu.console.print(f" [bold white]{hostname}[/bold white] {status_text}")
93
+ menu.console.print(f" [{colors.get('dim', 'dim')}]{fqdn}[/{colors.get('dim', 'dim')}]")
94
+ menu.console.print()
95
+
96
+ # System info
97
+ menu.console.print(f" [{colors.get('dim', 'dim')}]System[/{colors.get('dim', 'dim')}]")
98
+ menu.console.print(f" OS: {os_info} {os_version}")
99
+ menu.console.print(f" Architecture: {architecture}")
100
+ if ip_info:
101
+ if len(ip_info) == 1:
102
+ menu.console.print(f" IP: {ip_info[0]}")
103
+ else:
104
+ menu.console.print(f" IPs: {ip_info[0]}")
105
+ for ip in ip_info[1:]:
106
+ menu.console.print(f" {ip}")
107
+ menu.console.print(f" Client ID: {client_id[:40]}...")
108
+ menu.console.print(f" Last seen: {last_seen_str}")
109
+ menu.console.print()
110
+
111
+ # Tunnel info
112
+ if cf_status:
113
+ tunnel_name = cf_status.tunnel_name or 'N/A'
114
+ public_hostname = cf_status.hostname or 'N/A'
115
+ authorized_users = cf_status.authorized_users or ''
116
+
117
+ menu.console.print(f" [{colors.get('warning', 'yellow')}]Tunnel active[/{colors.get('warning', 'yellow')}]")
118
+ menu.console.print(f" Name: {tunnel_name}")
119
+ menu.console.print(f" Public: {public_hostname}")
120
+
121
+ if authorized_users:
122
+ users_list = [u.strip() for u in authorized_users.split(',')]
123
+ menu.console.print(f" Authorized: {', '.join(users_list)}")
124
+ else:
125
+ menu.console.print(f" [{colors.get('dim', 'dim')}]No tunnel configured[/{colors.get('dim', 'dim')}]")
126
+
127
+ menu.console.print()
128
+ menu.pause()
129
+
130
+
131
+ def complete(menu, text, tokens):
132
+ """Command completion for info command"""
133
+ opts = ['--raw', '-r']
134
+ if len(tokens) <= 2:
135
+ return [o for o in opts if o.startswith(text)]
136
+ return []