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,437 @@
1
+ from typing import List, Optional
2
+ import subprocess
3
+ from praetorian_cli.sdk.model.aegis import Agent
4
+ from praetorian_cli.handlers.ssh_utils import validate_agent_for_ssh
5
+
6
+
7
+ def normalize_to_list(value, item_keys: List[str] = None) -> List:
8
+ keys = item_keys or ["items", "data", "capabilities", "assets"]
9
+ if value is None:
10
+ return []
11
+ if isinstance(value, tuple) and value:
12
+ value = value[0]
13
+ if isinstance(value, list):
14
+ return value
15
+ if isinstance(value, dict):
16
+ for k in keys:
17
+ if k in value and isinstance(value[k], list):
18
+ return value[k]
19
+ return []
20
+ return []
21
+
22
+
23
+ class Aegis:
24
+ """ The methods in this class are to be accessed from sdk.aegis, where sdk
25
+ is an instance of Chariot. """
26
+
27
+ def __init__(self, api):
28
+ self.api = api
29
+
30
+
31
+ def list(self) -> tuple:
32
+ """
33
+ List all Aegis agents.
34
+
35
+ Retrieves all Aegis agents from the account, returning them as Agent
36
+ objects with detailed information including system specs, network
37
+ interfaces, and tunnel connectivity status.
38
+
39
+ :return: A tuple containing (list of Agent objects, None for compatibility)
40
+ :rtype: tuple
41
+
42
+ **Example Usage:**
43
+ >>> # List all Aegis agents
44
+ >>> agents, _ = sdk.aegis.list()
45
+
46
+ >>> # Check agent properties
47
+ >>> for agent in agents:
48
+ >>> print(f"Agent: {agent.hostname}")
49
+ >>> print(f"OS: {agent.os}")
50
+ >>> print(f"Has tunnel: {agent.has_tunnel}")
51
+ >>> print(f"Online: {agent.is_online}")
52
+
53
+ **Agent Object Properties:**
54
+ - client_id: Unique identifier for the agent
55
+ - hostname: Agent hostname
56
+ - os: Operating system (e.g., 'linux', 'windows')
57
+ - network_interfaces: List of NetworkInterface objects
58
+ - has_tunnel: Boolean indicating if Cloudflare tunnel is active
59
+ - is_online: Boolean indicating if agent is currently online
60
+ """
61
+ try:
62
+ agents_data = self.api.get('/aegis/agent')
63
+
64
+ # Return Agent objects
65
+ agents = []
66
+ for agent_data in agents_data:
67
+ agent = Agent.from_dict(agent_data)
68
+ agents.append(agent)
69
+ return agents, None
70
+ except Exception as e:
71
+ raise Exception(f"Failed to list Aegis agents: {e}")
72
+
73
+ def get_by_client_id(self, client_id: str) -> Optional[Agent]:
74
+ """
75
+ Get a specific Aegis agent by client ID.
76
+
77
+ :param client_id: The unique client identifier for the agent
78
+ :type client_id: str
79
+ :return: Agent object if found, None if not found
80
+ :rtype: Agent or None
81
+
82
+ **Example Usage:**
83
+ >>> # Get specific agent
84
+ >>> agent = sdk.aegis.get_by_client_id("C.6e012b467f9faf82-OG9F0")
85
+ >>> if agent:
86
+ >>> print(f"Found agent: {agent.hostname}")
87
+ >>> print(f"Tunnel status: {agent.has_tunnel}")
88
+ """
89
+ try:
90
+ agents_data, _ = self.list()
91
+ for agent in agents_data:
92
+ if agent.client_id == client_id:
93
+ return agent
94
+ return None
95
+ except Exception as e:
96
+ raise Exception(f"Failed to get agent {client_id}: {e}")
97
+
98
+ def get_capabilities(self, surface_filter: str = None, agent_os: str = None) -> List[dict]:
99
+ """
100
+ Get Aegis capabilities with optional filtering.
101
+
102
+ Retrieves available capabilities that can be executed by Aegis agents,
103
+ with optional filtering by attack surface and operating system.
104
+
105
+ :param surface_filter: Filter by attack surface type (e.g., 'internal', 'external')
106
+ :type surface_filter: str or None
107
+ :param agent_os: Filter by agent operating system (e.g., 'windows', 'linux')
108
+ :type agent_os: str or None
109
+ :return: List of capability dictionaries
110
+ :rtype: list
111
+
112
+ **Example Usage:**
113
+ >>> # Get all Aegis capabilities
114
+ >>> caps = sdk.aegis.get_capabilities()
115
+
116
+ >>> # Get internal surface capabilities only
117
+ >>> internal_caps = sdk.aegis.get_capabilities(surface_filter='internal')
118
+
119
+ >>> # Get Windows capabilities for internal surface
120
+ >>> win_caps = sdk.aegis.get_capabilities(surface_filter='internal', agent_os='windows')
121
+
122
+ **Capability Object Properties:**
123
+ Each capability contains:
124
+ - name: Capability name (e.g., 'windows-smb-snaffler')
125
+ - description: Human-readable description
126
+ - target: Target type ('asset', 'addomain', etc.)
127
+ - surface: Attack surface ('internal', 'external')
128
+ - parameters: List of configurable parameters
129
+ """
130
+ try:
131
+ capabilities_response = self.api.capabilities.list(executor='aegis')
132
+
133
+ # Handle different response formats
134
+ if isinstance(capabilities_response, tuple):
135
+ all_capabilities, _ = capabilities_response
136
+ elif isinstance(capabilities_response, list):
137
+ all_capabilities = capabilities_response
138
+ elif isinstance(capabilities_response, dict):
139
+ all_capabilities = capabilities_response.get('capabilities',
140
+ capabilities_response.get('data',
141
+ capabilities_response.get('items', [])))
142
+ else:
143
+ all_capabilities = []
144
+
145
+ # Ensure we have a list and all items are dicts
146
+ caps = normalize_to_list(all_capabilities, ["capabilities", "data", "items"]) or []
147
+ caps = [c for c in caps if isinstance(c, dict)]
148
+
149
+ # Apply surface filter
150
+ if surface_filter:
151
+ caps = [
152
+ cap for cap in caps
153
+ if isinstance(cap, dict) and cap.get('surface', '').lower() == surface_filter.lower()
154
+ ]
155
+
156
+ # Apply OS filter
157
+ if agent_os:
158
+ caps = [
159
+ cap for cap in caps
160
+ if isinstance(cap, dict) and cap.get('name', '').lower().startswith(f'{agent_os.lower()}-')
161
+ ]
162
+
163
+ return caps
164
+
165
+ except Exception as e:
166
+ raise Exception(f"Failed to get Aegis capabilities: {e}")
167
+
168
+ def validate_capability(self, capability_name: str) -> Optional[dict]:
169
+ """
170
+ Validate and get capability information by name.
171
+
172
+ :param capability_name: Name of the capability to validate
173
+ :type capability_name: str
174
+ :return: Capability information if valid, None if not found
175
+ :rtype: dict or None
176
+
177
+ **Example Usage:**
178
+ >>> # Validate a capability
179
+ >>> cap_info = sdk.aegis.validate_capability('windows-smb-snaffler')
180
+ >>> if cap_info:
181
+ >>> print(f"Valid capability: {cap_info['name']}")
182
+ >>> print(f"Target type: {cap_info['target']}")
183
+ """
184
+ try:
185
+ caps = self.get_capabilities()
186
+ for cap in caps:
187
+ if isinstance(cap, dict) and cap.get('name', '').lower() == capability_name.lower():
188
+ return cap
189
+ return None
190
+ except Exception:
191
+ return None
192
+
193
+ def create_job_config(self, agent, credentials=None):
194
+ """
195
+ Create job configuration for Aegis agent.
196
+
197
+ :param agent: Agent object containing client_id and other metadata
198
+ :type agent: Agent
199
+ :param credentials: Optional dictionary containing Username/Password for authentication
200
+ :type credentials: dict or None
201
+ :return: Job configuration dictionary
202
+ :rtype: dict
203
+
204
+ **Example Usage:**
205
+ >>> # Basic config without credentials
206
+ >>> config = sdk.aegis.create_job_config(agent)
207
+
208
+ >>> # Config with credentials
209
+ >>> creds = {"Username": "admin", "Password": "secret"}
210
+ >>> config = sdk.aegis.create_job_config(agent, creds)
211
+ """
212
+ config = {
213
+ "aegis": "true",
214
+ "client_id": agent.client_id or '',
215
+ "manual": "true"
216
+ }
217
+
218
+ if credentials:
219
+ config.update(credentials)
220
+
221
+ return config
222
+
223
+ def get_available_ad_domains(self) -> List[str]:
224
+ """
225
+ Get available Active Directory domains from assets.
226
+
227
+ Retrieves all AD domain assets from the account, extracting domain names
228
+ from both the DNS field and asset keys for comprehensive coverage.
229
+
230
+ :return: List of available AD domain names
231
+ :rtype: list
232
+
233
+ **Example Usage:**
234
+ >>> # Get all available AD domains
235
+ >>> domains = sdk.aegis.get_available_ad_domains()
236
+ >>> print(f"Found {len(domains)} domains: {domains}")
237
+
238
+ >>> # Use for job targeting
239
+ >>> if 'contoso.com' in domains:
240
+ >>> target_key = f"#addomain#contoso.com#contoso.com"
241
+
242
+ **Domain Discovery:**
243
+ Domains are extracted from:
244
+ - Asset DNS field (primary method)
245
+ - Asset key field format: #addomain#domain.com#domain.com (fallback)
246
+ """
247
+ domains = []
248
+ try:
249
+ assets_resp = self.api.assets.list(asset_type='addomain')
250
+
251
+ # Handle different response formats
252
+ if isinstance(assets_resp, tuple):
253
+ assets, _ = assets_resp
254
+ else:
255
+ assets = assets_resp
256
+
257
+ if isinstance(assets, dict):
258
+ assets = assets.get('assets', assets.get('data', assets.get('items', [])))
259
+ elif isinstance(assets, list):
260
+ pass # assets is already a list
261
+ else:
262
+ assets = []
263
+
264
+ for asset in (assets or []):
265
+ if isinstance(asset, dict):
266
+ dns = asset.get('dns', '')
267
+ key = asset.get('key', '')
268
+
269
+ # Try DNS field first
270
+ if dns and dns not in domains:
271
+ domains.append(dns)
272
+ # If no DNS, try to extract from key format: #addomain#domain.com#domain.com
273
+ elif key and '#addomain#' in key:
274
+ parts = key.split('#')
275
+ if len(parts) >= 3 and parts[1] == 'addomain':
276
+ domain = parts[2]
277
+ if domain and domain not in domains:
278
+ domains.append(domain)
279
+
280
+ return sorted(domains)
281
+
282
+ except Exception as e:
283
+ raise Exception(f"Failed to get available domains: {e}")
284
+
285
+ def ssh_to_agent(self, agent: Agent, options: List[str] = None, user: str = None, display_info: bool = True) -> int:
286
+ """SSH to an Aegis agent using Cloudflare tunnel."""
287
+
288
+ options = options or []
289
+
290
+ # Determine SSH username using the centralized method
291
+ if not user:
292
+ _, user = self.api.get_current_user()
293
+
294
+ is_valid, error_msg = validate_agent_for_ssh(agent)
295
+ if not is_valid:
296
+ raise Exception(error_msg)
297
+
298
+ hostname = agent.hostname or 'Unknown'
299
+ cf_status = agent.health_check.cloudflared_status
300
+ public_hostname = cf_status.hostname
301
+ authorized_users = cf_status.authorized_users or ''
302
+ tunnel_name = cf_status.tunnel_name or 'N/A'
303
+
304
+ # Check if user is authorized (if authorization is configured)
305
+ if authorized_users:
306
+ users_list = [u.strip() for u in authorized_users.split(',')]
307
+ if user not in users_list:
308
+ print(f"User '{user}' may not be authorized for tunnel. Authorized users: {', '.join(users_list)}")
309
+
310
+ ssh_command = ['ssh', '-o', 'ConnectTimeout=10', '-o', 'ServerAliveInterval=30']
311
+ ssh_command.extend(options)
312
+ ssh_command.append(f'{user}@{public_hostname}')
313
+
314
+ # Parse forwarding options for display (simple extraction from SSH flags)
315
+ local_forward = []
316
+ remote_forward = []
317
+ dynamic_forward = []
318
+
319
+ # Extract forwarding info for display
320
+ i = 0
321
+ while i < len(options):
322
+ if options[i] == '-L' and i + 1 < len(options):
323
+ local_forward.append(options[i + 1])
324
+ i += 2
325
+ elif options[i] == '-R' and i + 1 < len(options):
326
+ remote_forward.append(options[i + 1])
327
+ i += 2
328
+ elif options[i] == '-D' and i + 1 < len(options):
329
+ dynamic_forward.append(options[i + 1])
330
+ i += 2
331
+ else:
332
+ i += 1
333
+
334
+ if display_info:
335
+ print(f"\033[1;36m→ Connecting to {hostname}\033[0m")
336
+ print(f"\033[34m Gateway: {public_hostname}\033[0m")
337
+ print(f"\033[33m Tunnel: {tunnel_name}\033[0m")
338
+ print(f"\033[35m User: {user}\033[0m")
339
+
340
+ if local_forward:
341
+ print(f"\033[32m Local: {', '.join(local_forward)}\033[0m")
342
+ if remote_forward:
343
+ print(f"\033[31m Remote: {', '.join(remote_forward)}\033[0m")
344
+ if dynamic_forward:
345
+ socks = ', '.join([f"localhost:{p}" for p in dynamic_forward])
346
+ print(f"\033[35m SOCKS: {socks}\033[0m")
347
+ print("")
348
+
349
+ result = subprocess.run(ssh_command)
350
+ return result.returncode
351
+
352
+ def run_job(self, agent: Agent, capabilities: list = None, config: str = None):
353
+ """
354
+ Run a job on an Aegis agent.
355
+
356
+ If no capabilities are provided, returns available capability dicts under
357
+ the 'capabilities' key. When capabilities are provided, returns a dict
358
+ with keys: 'success', 'job_id', 'job_key', 'status'. Errors raise.
359
+ """
360
+ if not capabilities:
361
+ caps = self.get_capabilities(surface_filter='internal')
362
+ return {
363
+ 'capabilities': sorted(caps, key=lambda x: x.get('name', '')),
364
+ }
365
+
366
+ hostname = agent.hostname or 'unknown'
367
+ target_key = f"#asset#{hostname}#{hostname}"
368
+
369
+ jobs = self.api.jobs.add(target_key, list(capabilities), config)
370
+ if not jobs:
371
+ raise Exception("No job returned from API")
372
+
373
+ job = jobs[0] if isinstance(jobs, list) else jobs
374
+ job_key = job.get('key', '')
375
+ status = job.get('status', 'unknown')
376
+
377
+ return {
378
+ 'success': True,
379
+ 'job_id': job_key.split('#')[-1][:12] if job_key else 'unknown',
380
+ 'job_key': job_key,
381
+ 'status': status,
382
+ }
383
+
384
+ def format_agents_list(self, details: bool = False, filter_text: str = None):
385
+ """
386
+ Format agents list for display with optional filtering and details.
387
+
388
+ Retrieves all Aegis agents and formats them for CLI display, with optional
389
+ filtering by hostname, client ID, or OS, and optional detailed information
390
+ including system specs and tunnel status.
391
+
392
+ :param details: Whether to show detailed agent information
393
+ :type details: bool
394
+ :param filter_text: Filter agents by hostname, client_id, or OS (case-insensitive)
395
+ :type filter_text: str or None
396
+ :return: Formatted agent list information
397
+ :rtype: str
398
+
399
+ **Example Usage:**
400
+ >>> # Simple agent list
401
+ >>> result = sdk.aegis.format_agents_list()
402
+ >>> print(result)
403
+
404
+ >>> # Detailed agent list with filtering
405
+ >>> result = sdk.aegis.format_agents_list(details=True, filter_text="windows")
406
+ >>> print(result)
407
+ """
408
+ agents_data, _ = self.list()
409
+
410
+ if not agents_data:
411
+ return "No agents found."
412
+
413
+ if filter_text:
414
+ filter_lower = filter_text.lower()
415
+ agents_data = [agent for agent in agents_data
416
+ if filter_lower in agent.hostname.lower() or
417
+ filter_lower in agent.client_id.lower() or
418
+ filter_lower in agent.os.lower()]
419
+
420
+ if not agents_data:
421
+ return f"No agents found matching filter: {filter_text}"
422
+
423
+ if details:
424
+ detailed_lines = []
425
+ for i, agent in enumerate(agents_data, 1):
426
+ agent_details = agent.to_detailed_string()
427
+ # Add agent number to the first line
428
+ lines = agent_details.split('\n')
429
+ if lines:
430
+ lines[0] = f"[{i:2d}] {lines[0].lstrip()}"
431
+ detailed_lines.append('\n'.join(lines))
432
+ return '\n\n'.join(detailed_lines)
433
+ else:
434
+ lines = []
435
+ for i, agent in enumerate(agents_data, 1):
436
+ lines.append(f"[{i:2d}] {str(agent)}")
437
+ return '\n'.join(lines)
@@ -1,5 +1,6 @@
1
+ from praetorian_cli.handlers.utils import error
1
2
  from praetorian_cli.sdk.model.globals import Asset, Kind
2
- from praetorian_cli.sdk.model.query import Relationship, Node, Query, asset_of_key, RISK_NODE, ATTRIBUTE_NODE
3
+ from praetorian_cli.sdk.model.query import Relationship, Node, Query, Filter, KIND_TO_LABEL, asset_of_key, RISK_NODE, ATTRIBUTE_NODE
3
4
 
4
5
 
5
6
  class Assets:
@@ -76,27 +77,44 @@ class Assets:
76
77
  """
77
78
  return self.api.delete_by_key('asset', key)
78
79
 
79
- def list(self, prefix_filter='', asset_type='', offset=None, pages=100000) -> tuple:
80
+ def list(self, key_prefix='', asset_type='', pages=100000) -> tuple:
80
81
  """
81
82
  List assets.
82
83
 
83
- :param prefix_filter: Supply this to perform prefix-filtering of the asset keys after the "#asset#" portion of the asset key. Asset keys read '#asset#{dns}#{name}'
84
- :type prefix_filter: str
84
+ :param key_prefix: Supply this to perform prefix-filtering of the asset key. E.g., '#asset#example.com' or '#addomain#sevenkingdoms'
85
+ :type key_prefix: str
85
86
  :param asset_type: The type of asset to filter by
86
87
  :type asset_type: str
87
- :param offset: The offset of the page you want to retrieve results. If this is not supplied, this function retrieves from the first page
88
- :type offset: str or None
89
88
  :param pages: The number of pages of results to retrieve. <mcp>Start with one page of results unless specifically requested.</mcp>
90
89
  :type pages: int
91
90
  :return: A tuple containing (list of assets, next page offset)
92
91
  :rtype: tuple
93
92
  """
94
- dns_prefix = ''
95
- if prefix_filter:
96
- dns_prefix = f'group:{prefix_filter}'
97
- if asset_type == '':
98
- asset_type = Kind.ASSET.value
99
- return self.api.search.by_term(dns_prefix, asset_type, offset, pages)
93
+
94
+ if asset_type in KIND_TO_LABEL:
95
+ asset_type = KIND_TO_LABEL[asset_type]
96
+ elif not asset_type:
97
+ asset_type = Node.Label.ASSET
98
+ else:
99
+ raise ValueError(f'Invalid asset type: {asset_type}')
100
+
101
+ node = Node(
102
+ labels=[asset_type],
103
+ filters=[]
104
+ )
105
+
106
+ key_filter = Filter(
107
+ field=Filter.Field.KEY,
108
+ operator=Filter.Operator.STARTS_WITH,
109
+ value=key_prefix
110
+ )
111
+
112
+ if key_prefix:
113
+ node.filters.append(key_filter)
114
+
115
+ query = Query(node=node)
116
+
117
+ return self.api.search.by_query(query, pages)
100
118
 
101
119
  def attributes(self, key):
102
120
  """
@@ -0,0 +1,13 @@
1
+ class Scanners:
2
+
3
+ def __init__(self, api):
4
+ self.api = api
5
+
6
+ def get(self, key):
7
+ """ Get scanner details by exact key """
8
+ return self.api.search.by_exact_key(key)
9
+
10
+ def list(self, filter='', offset='', page_size=100):
11
+ """ List scanners with optional filtering """
12
+ search_term = f"#scanner#{filter}"
13
+ return self.api.search.by_term(search_term, None, offset, page_size)
@@ -0,0 +1,27 @@
1
+ class Schema:
2
+ """Access Chariot entity schemas via the SDK.
3
+
4
+ Methods in this class are accessed from `sdk.schema`, where `sdk` is an
5
+ instance of `Chariot`.
6
+ """
7
+
8
+ def __init__(self, api):
9
+ self.api = api
10
+
11
+ def get(self, entity_type: str | None = None) -> dict:
12
+ """Get schema information for entity types.
13
+
14
+ Args:
15
+ entity_type: Optional specific entity type. If provided and it exists,
16
+ only that schema is returned. If not provided, all schemas are returned.
17
+
18
+ Returns:
19
+ dict: Schema information.
20
+ """
21
+ result = self.api.get('schema', )
22
+ if entity_type:
23
+ if entity_type not in result:
24
+ return {}
25
+ return {entity_type: result[entity_type]}
26
+ return result
27
+