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.
- praetorian_cli/handlers/add.py +25 -7
- praetorian_cli/handlers/aegis.py +107 -0
- praetorian_cli/handlers/delete.py +3 -2
- praetorian_cli/handlers/get.py +48 -2
- praetorian_cli/handlers/list.py +41 -9
- praetorian_cli/handlers/ssh_utils.py +154 -0
- praetorian_cli/handlers/test.py +7 -2
- praetorian_cli/handlers/update.py +3 -3
- praetorian_cli/main.py +1 -0
- praetorian_cli/sdk/chariot.py +71 -12
- praetorian_cli/sdk/entities/aegis.py +437 -0
- praetorian_cli/sdk/entities/assets.py +30 -12
- praetorian_cli/sdk/entities/scanners.py +13 -0
- praetorian_cli/sdk/entities/schema.py +27 -0
- praetorian_cli/sdk/entities/seeds.py +108 -56
- praetorian_cli/sdk/mcp_server.py +2 -3
- praetorian_cli/sdk/model/aegis.py +156 -0
- praetorian_cli/sdk/model/query.py +1 -1
- praetorian_cli/sdk/model/utils.py +2 -8
- praetorian_cli/sdk/test/pytest.ini +1 -0
- praetorian_cli/sdk/test/test_asset.py +2 -2
- praetorian_cli/sdk/test/test_seed.py +13 -14
- praetorian_cli/sdk/test/test_z_cli.py +22 -24
- praetorian_cli/sdk/test/ui_mocks.py +133 -0
- praetorian_cli/sdk/test/utils.py +16 -4
- praetorian_cli/ui/__init__.py +3 -0
- praetorian_cli/ui/aegis/__init__.py +5 -0
- praetorian_cli/ui/aegis/commands/__init__.py +2 -0
- praetorian_cli/ui/aegis/commands/help.py +81 -0
- praetorian_cli/ui/aegis/commands/info.py +136 -0
- praetorian_cli/ui/aegis/commands/job.py +381 -0
- praetorian_cli/ui/aegis/commands/list.py +14 -0
- praetorian_cli/ui/aegis/commands/set.py +32 -0
- praetorian_cli/ui/aegis/commands/ssh.py +87 -0
- praetorian_cli/ui/aegis/constants.py +20 -0
- praetorian_cli/ui/aegis/menu.py +395 -0
- praetorian_cli/ui/aegis/utils.py +162 -0
- {praetorian_cli-2.2.1.dist-info → praetorian_cli-2.2.3.dist-info}/METADATA +4 -1
- {praetorian_cli-2.2.1.dist-info → praetorian_cli-2.2.3.dist-info}/RECORD +43 -24
- {praetorian_cli-2.2.1.dist-info → praetorian_cli-2.2.3.dist-info}/WHEEL +0 -0
- {praetorian_cli-2.2.1.dist-info → praetorian_cli-2.2.3.dist-info}/entry_points.txt +0 -0
- {praetorian_cli-2.2.1.dist-info → praetorian_cli-2.2.3.dist-info}/licenses/LICENSE +0 -0
- {praetorian_cli-2.2.1.dist-info → praetorian_cli-2.2.3.dist-info}/top_level.txt +0 -0
praetorian_cli/handlers/add.py
CHANGED
|
@@ -196,21 +196,39 @@ def attribute(sdk, key, name, value):
|
|
|
196
196
|
|
|
197
197
|
@add.command()
|
|
198
198
|
@cli_handler
|
|
199
|
-
@click.option('-
|
|
199
|
+
@click.option('-t', '--type', 'seed_type', default='asset', help='Asset type (e.g., asset, addomain)')
|
|
200
200
|
@click.option('-s', '--status', type=click.Choice([s.value for s in Seed]),
|
|
201
201
|
default=Seed.PENDING.value, help='The status of the seed', show_default=True)
|
|
202
|
-
|
|
202
|
+
@click.option('-f', '--field', 'field_list', multiple=True,
|
|
203
|
+
help='Field in format name:value (can be specified multiple times)')
|
|
204
|
+
def seed(sdk, seed_type, status, field_list):
|
|
203
205
|
""" Add a seed
|
|
204
206
|
|
|
205
|
-
Add a seed to the Chariot database.
|
|
206
|
-
|
|
207
|
+
Add a seed to the Chariot database. Seeds are now assets with special labeling.
|
|
208
|
+
You can specify the asset type and provide dynamic fields using --fields.
|
|
207
209
|
|
|
208
210
|
\b
|
|
209
211
|
Example usages:
|
|
210
|
-
- praetorian chariot add seed --dns
|
|
211
|
-
- praetorian chariot add seed --dns
|
|
212
|
+
- praetorian chariot add seed --type asset --field dns:example.com
|
|
213
|
+
- praetorian chariot add seed --type asset --field dns:example.com --status A
|
|
214
|
+
- praetorian chariot add seed --type asset --field dns:example.com --field name:1.2.3.4
|
|
215
|
+
- praetorian chariot add seed --type addomain --field domain:corp.local --field objectid:S-1-5-21-2701466056-1043032755-2418290285
|
|
212
216
|
"""
|
|
213
|
-
|
|
217
|
+
# Collect dynamic fields from the --fields option
|
|
218
|
+
dynamic_fields = {}
|
|
219
|
+
|
|
220
|
+
# Parse field_list entries (name:value format)
|
|
221
|
+
for field in field_list:
|
|
222
|
+
if ':' in field:
|
|
223
|
+
# Split only once to allow colons in the value
|
|
224
|
+
name, value = field.split(':', 1)
|
|
225
|
+
dynamic_fields[name] = value
|
|
226
|
+
else:
|
|
227
|
+
error(f"Field '{field}' is not in the format name:value")
|
|
228
|
+
return
|
|
229
|
+
|
|
230
|
+
# Call the updated add method with type and dynamic fields
|
|
231
|
+
sdk.seeds.add(status=status, seed_type=seed_type, **dynamic_fields)
|
|
214
232
|
|
|
215
233
|
|
|
216
234
|
@add.command()
|
|
@@ -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())
|
|
@@ -88,11 +88,12 @@ def seed(chariot, key):
|
|
|
88
88
|
|
|
89
89
|
\b
|
|
90
90
|
Arguments:
|
|
91
|
-
- KEY: the key of an existing seed
|
|
91
|
+
- KEY: the key of an existing seed (now uses asset key format)
|
|
92
92
|
|
|
93
93
|
\b
|
|
94
94
|
Example usage:
|
|
95
|
-
- praetorian chariot delete seed "#
|
|
95
|
+
- praetorian chariot delete seed "#asset#example.com#example.com"
|
|
96
|
+
- praetorian chariot delete seed "#addomain#corp.local#corp.local"
|
|
96
97
|
"""
|
|
97
98
|
chariot.seeds.delete(key)
|
|
98
99
|
|
praetorian_cli/handlers/get.py
CHANGED
|
@@ -186,11 +186,12 @@ def seed(chariot, key):
|
|
|
186
186
|
|
|
187
187
|
\b
|
|
188
188
|
Argument:
|
|
189
|
-
- KEY: the key of an existing
|
|
189
|
+
- KEY: the key of an existing seed (now uses asset key format)
|
|
190
190
|
|
|
191
191
|
\b
|
|
192
192
|
Example usages:
|
|
193
|
-
- praetorian chariot get
|
|
193
|
+
- praetorian chariot get seed "#asset#example.com#example.com"
|
|
194
|
+
- praetorian chariot get seed "#addomain#corp.local#corp.local"
|
|
194
195
|
"""
|
|
195
196
|
print_json(chariot.seeds.get(key))
|
|
196
197
|
|
|
@@ -278,3 +279,48 @@ def credential(chariot, credential_id, category, type, format, parameters):
|
|
|
278
279
|
result = chariot.credentials.get(credential_id, category, type, [format], **params)
|
|
279
280
|
output = chariot.credentials.format_output(result)
|
|
280
281
|
click.echo(output)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
@get.command()
|
|
285
|
+
@cli_handler
|
|
286
|
+
@click.argument('key', required=True)
|
|
287
|
+
def scanner(chariot, key):
|
|
288
|
+
""" Get scanner details
|
|
289
|
+
|
|
290
|
+
\b
|
|
291
|
+
Argument:
|
|
292
|
+
- KEY: the key of an existing scanner record
|
|
293
|
+
|
|
294
|
+
\b
|
|
295
|
+
Example usage:
|
|
296
|
+
- praetorian chariot get scanner "#scanner#127.0.0.1"
|
|
297
|
+
"""
|
|
298
|
+
print_json(chariot.scanners.get(key))
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
@get.command()
|
|
302
|
+
@cli_handler
|
|
303
|
+
@click.option('-t', '--type', help='Optional specific entity type (e.g., asset, risk, attribute)')
|
|
304
|
+
@click.option('-d', '--details', is_flag=True, help='Further retrieve the details of the schema')
|
|
305
|
+
def schema(chariot, type, details):
|
|
306
|
+
""" Get Chariot entity schema
|
|
307
|
+
|
|
308
|
+
\b
|
|
309
|
+
Returns the JSON schema for Chariot entities. Optionally filter for a
|
|
310
|
+
specific entity type.
|
|
311
|
+
|
|
312
|
+
\b
|
|
313
|
+
Example usages:
|
|
314
|
+
- praetorian chariot get schema
|
|
315
|
+
- praetorian chariot get schema --type asset
|
|
316
|
+
- praetorian chariot get schema --type asset --details
|
|
317
|
+
"""
|
|
318
|
+
data = chariot.schema.get(type)
|
|
319
|
+
if type:
|
|
320
|
+
data = {type: data[type]}
|
|
321
|
+
|
|
322
|
+
if details:
|
|
323
|
+
print_json(data)
|
|
324
|
+
else:
|
|
325
|
+
for hit in data:
|
|
326
|
+
click.echo(hit)
|
praetorian_cli/handlers/list.py
CHANGED
|
@@ -26,7 +26,7 @@ def assets(chariot, filter, model_type, details, offset, page):
|
|
|
26
26
|
- praetorian chariot list assets --page all
|
|
27
27
|
- praetorian chariot list assets --type repository
|
|
28
28
|
"""
|
|
29
|
-
render_list_results(chariot.assets.list(filter, model_type,
|
|
29
|
+
render_list_results(chariot.assets.list(filter, model_type, pagination_size(page)), details)
|
|
30
30
|
|
|
31
31
|
|
|
32
32
|
@list.command()
|
|
@@ -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):
|
|
@@ -162,24 +178,23 @@ def attributes(chariot, filter, key, details, offset, page):
|
|
|
162
178
|
|
|
163
179
|
@list.command()
|
|
164
180
|
@list_params('DNS')
|
|
165
|
-
@click.option('-t', '--type',
|
|
181
|
+
@click.option('-t', '--type', help='Filter by seed type (e.g., asset, addomain)')
|
|
166
182
|
def seeds(chariot, type, filter, details, offset, page):
|
|
167
183
|
""" List seeds
|
|
168
184
|
|
|
169
|
-
Retrieve and display a list of seeds.
|
|
185
|
+
Retrieve and display a list of seeds. Seeds are now assets with the 'Seed' label.
|
|
170
186
|
|
|
171
187
|
\b
|
|
172
188
|
Example usages:
|
|
173
189
|
- praetorian chariot list seeds
|
|
174
|
-
- praetorian chariot list seeds --type
|
|
175
|
-
- praetorian chariot list seeds --type
|
|
190
|
+
- praetorian chariot list seeds --type asset
|
|
191
|
+
- praetorian chariot list seeds --type addomain
|
|
192
|
+
- praetorian chariot list seeds --type asset --filter example.com
|
|
176
193
|
- praetorian chariot list seeds --details
|
|
177
194
|
- praetorian chariot list seeds --page all
|
|
178
195
|
"""
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
render_list_results(chariot.seeds.list(type, filter, offset, pagination_size(page)), details)
|
|
196
|
+
# Note: filter restriction removed since we're using different key format now
|
|
197
|
+
render_list_results(chariot.seeds.list(type, filter, pagination_size(page)), details)
|
|
183
198
|
|
|
184
199
|
|
|
185
200
|
@list.command()
|
|
@@ -336,3 +351,20 @@ def capabilities(chariot, name, target, executor):
|
|
|
336
351
|
- praetorian chariot list capabilities --name nuclei --target attribute --executor chariot
|
|
337
352
|
"""
|
|
338
353
|
print_json(chariot.capabilities.list(name, target, executor))
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
@list.command()
|
|
357
|
+
@list_params('IP address')
|
|
358
|
+
def scanners(chariot, filter, details, offset, page):
|
|
359
|
+
""" List scanners
|
|
360
|
+
|
|
361
|
+
Retrieve and display a list of scanner records that track IP addresses used by chariot.
|
|
362
|
+
|
|
363
|
+
\b
|
|
364
|
+
Example usages:
|
|
365
|
+
- praetorian chariot list scanners
|
|
366
|
+
- praetorian chariot list scanners --filter 127.0.0.1
|
|
367
|
+
- praetorian chariot list scanners --details
|
|
368
|
+
- praetorian chariot list scanners --page all
|
|
369
|
+
"""
|
|
370
|
+
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, ""
|
praetorian_cli/handlers/test.py
CHANGED
|
@@ -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
|
|
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)
|
|
@@ -65,12 +65,12 @@ def seed(chariot, key, status):
|
|
|
65
65
|
|
|
66
66
|
\b
|
|
67
67
|
Example usages:
|
|
68
|
-
- praetorian chariot update seed "#
|
|
69
|
-
- praetorian chariot update seed "#
|
|
68
|
+
- praetorian chariot update seed "#asset#example.com#example.com" -s A
|
|
69
|
+
- praetorian chariot update seed "#asset#1.1.1.0/24#1.1.1.0/24" -s F
|
|
70
70
|
"""
|
|
71
|
+
|
|
71
72
|
chariot.seeds.update(key, status)
|
|
72
73
|
|
|
73
|
-
|
|
74
74
|
@update.command()
|
|
75
75
|
@cli_handler
|
|
76
76
|
@click.argument('key', required=True)
|
praetorian_cli/main.py
CHANGED
praetorian_cli/sdk/chariot.py
CHANGED
|
@@ -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,8 @@ 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
|
|
19
|
+
from praetorian_cli.sdk.entities.schema import Schema
|
|
17
20
|
from praetorian_cli.sdk.entities.search import Search
|
|
18
21
|
from praetorian_cli.sdk.entities.seeds import Seeds
|
|
19
22
|
from praetorian_cli.sdk.entities.settings import Settings
|
|
@@ -39,14 +42,17 @@ class Chariot:
|
|
|
39
42
|
self.definitions = Definitions(self)
|
|
40
43
|
self.attributes = Attributes(self)
|
|
41
44
|
self.search = Search(self)
|
|
45
|
+
self.scanners = Scanners(self)
|
|
42
46
|
self.webhook = Webhook(self)
|
|
43
47
|
self.statistics = Statistics(self)
|
|
48
|
+
self.aegis = Aegis(self)
|
|
44
49
|
self.agents = Agents(self)
|
|
45
50
|
self.settings = Settings(self)
|
|
46
51
|
self.configurations = Configurations(self)
|
|
47
52
|
self.keys = Keys(self)
|
|
48
53
|
self.capabilities = Capabilities(self)
|
|
49
54
|
self.credentials = Credentials(self)
|
|
55
|
+
self.schema = Schema(self)
|
|
50
56
|
self.proxy = proxy
|
|
51
57
|
|
|
52
58
|
if self.proxy == '' and os.environ.get('CHARIOT_PROXY'):
|
|
@@ -66,26 +72,16 @@ class Chariot:
|
|
|
66
72
|
kwargs['proxies'] = {'http': self.proxy, 'https': self.proxy}
|
|
67
73
|
kwargs['verify'] = False
|
|
68
74
|
|
|
69
|
-
self.
|
|
75
|
+
self.add_beta_url_param(kwargs)
|
|
70
76
|
|
|
71
77
|
return requests.request(method, url, headers=self.keychain.headers(), **kwargs)
|
|
72
78
|
|
|
73
|
-
def
|
|
74
|
-
if method == 'GET' or method == 'DELETE':
|
|
75
|
-
self._add_beta_url_param(kwargs)
|
|
76
|
-
else:
|
|
77
|
-
self._add_beta_json_param(kwargs)
|
|
78
|
-
|
|
79
|
-
def _add_beta_url_param(self, kwargs: dict):
|
|
79
|
+
def add_beta_url_param(self, kwargs: dict):
|
|
80
80
|
if 'params' in kwargs:
|
|
81
81
|
kwargs['params']['beta'] = 'true'
|
|
82
82
|
else:
|
|
83
83
|
kwargs['params'] = {'beta': 'true'}
|
|
84
84
|
|
|
85
|
-
def _add_beta_json_param(self, kwargs: dict):
|
|
86
|
-
if 'json' in kwargs:
|
|
87
|
-
kwargs['json']['beta'] = True
|
|
88
|
-
|
|
89
85
|
def my(self, params: dict, pages=1) -> dict:
|
|
90
86
|
final_resp = dict()
|
|
91
87
|
|
|
@@ -256,6 +252,69 @@ class Chariot:
|
|
|
256
252
|
|
|
257
253
|
server = MCPServer(self, allowable_tools)
|
|
258
254
|
return anyio.run(server.start)
|
|
255
|
+
|
|
256
|
+
def get_current_user(self) -> tuple:
|
|
257
|
+
"""
|
|
258
|
+
Get current user information for Aegis functionality.
|
|
259
|
+
|
|
260
|
+
Returns:
|
|
261
|
+
tuple: (user_email, username) where user_email is the login email
|
|
262
|
+
and username is the SSH username derived from the email
|
|
263
|
+
"""
|
|
264
|
+
# Try to get username from keychain first (for username/password auth)
|
|
265
|
+
user_email = self.keychain.username()
|
|
266
|
+
|
|
267
|
+
# If no username in keychain (API key auth), try to get it from JWT token
|
|
268
|
+
if not user_email and self.keychain.has_api_key():
|
|
269
|
+
token = self.keychain.token()
|
|
270
|
+
payload = decode_jwt_payload(token)
|
|
271
|
+
if payload:
|
|
272
|
+
# Extract email from the 'email' field in the JWT payload
|
|
273
|
+
user_email = payload.get('email')
|
|
274
|
+
else:
|
|
275
|
+
# If JWT decoding fails, fall back to the account parameter
|
|
276
|
+
raise Exception("Failed to decode JWT token")
|
|
277
|
+
|
|
278
|
+
# Extract username from email (part before @) for SSH access
|
|
279
|
+
username = user_email.split('@')[0] if user_email and '@' in user_email else user_email
|
|
280
|
+
return user_email, username
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def decode_jwt_payload(token: str) -> dict | None:
|
|
284
|
+
"""
|
|
285
|
+
Decode the payload from a JWT token.
|
|
286
|
+
|
|
287
|
+
Args:
|
|
288
|
+
token: JWT token string in format header.payload.signature
|
|
289
|
+
|
|
290
|
+
Returns:
|
|
291
|
+
dict: Decoded payload contents, or None if decoding fails
|
|
292
|
+
|
|
293
|
+
Example:
|
|
294
|
+
>>> token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6InVzZXJAZXhhbXBsZS5jb20ifQ.signature"
|
|
295
|
+
>>> payload = decode_jwt_payload(token)
|
|
296
|
+
>>> print(payload.get('email'))
|
|
297
|
+
user@example.com
|
|
298
|
+
"""
|
|
299
|
+
try:
|
|
300
|
+
import json
|
|
301
|
+
import base64
|
|
302
|
+
|
|
303
|
+
# JWT tokens have 3 parts: header.payload.signature
|
|
304
|
+
parts = token.split('.')
|
|
305
|
+
if len(parts) != 3:
|
|
306
|
+
return None
|
|
307
|
+
|
|
308
|
+
payload_part = parts[1]
|
|
309
|
+
# Add padding if needed for base64 decoding
|
|
310
|
+
payload_part += '=' * (4 - len(payload_part) % 4)
|
|
311
|
+
payload = json.loads(base64.b64decode(payload_part))
|
|
312
|
+
|
|
313
|
+
return payload
|
|
314
|
+
except Exception:
|
|
315
|
+
return None
|
|
316
|
+
|
|
317
|
+
|
|
259
318
|
def is_query_limit_failure(response: requests.Response) -> bool:
|
|
260
319
|
return response.status_code == 413 and 'reduce page size' in response.text
|
|
261
320
|
|