catocli 2.1.0__py3-none-any.whl → 2.1.1__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 catocli might be problematic. Click here for more details.

@@ -18,6 +18,7 @@ profile_manager = get_profile_manager()
18
18
  CATO_DEBUG = bool(os.getenv("CATO_DEBUG", False))
19
19
  from ..parsers.raw import raw_parse
20
20
  from ..parsers.custom import custom_parse
21
+ from ..parsers.custom_private import private_parse
21
22
  from ..parsers.query_siteLocation import query_siteLocation_parse
22
23
  from ..parsers.mutation_accountManagement import mutation_accountManagement_parse
23
24
  from ..parsers.mutation_admin import mutation_admin_parse
@@ -82,6 +83,15 @@ def show_version_info(args, configuration=None):
82
83
  print("Unable to check for updates (check your internet connection)")
83
84
  return [{"success": True, "current_version": catocli.__version__, "latest_version": latest_version if not args.current_only else None}]
84
85
 
86
+ def load_private_settings():
87
+ # Load private settings from ~/.cato/settings.json
88
+ settings_file = os.path.expanduser("~/.cato/settings.json")
89
+ try:
90
+ with open(settings_file, 'r') as f:
91
+ return json.load(f)
92
+ except (FileNotFoundError, json.JSONDecodeError):
93
+ return {}
94
+
85
95
  def get_configuration(skip_api_key=False):
86
96
  configuration = Configuration()
87
97
  configuration.verify_ssl = False
@@ -104,10 +114,13 @@ def get_configuration(skip_api_key=False):
104
114
  print(f"Run 'catocli configure set --profile {profile_name}' to update your credentials.")
105
115
  exit(1)
106
116
 
117
+ # Use standard endpoint from profile for regular API calls
118
+ configuration.host = credentials['endpoint']
119
+
107
120
  # Only set API key if not using custom headers file
121
+ # (Private settings are handled separately in createPrivateRequest)
108
122
  if not skip_api_key:
109
123
  configuration.api_key["x-api-key"] = credentials['cato_token']
110
- configuration.host = credentials['endpoint']
111
124
  configuration.accountID = credentials['account_id']
112
125
 
113
126
  return configuration
@@ -136,6 +149,7 @@ version_parser.add_argument('--current-only', action='store_true', help='Show on
136
149
  version_parser.set_defaults(func=show_version_info)
137
150
 
138
151
  custom_parsers = custom_parse(subparsers)
152
+ private_parsers = private_parse(subparsers)
139
153
  raw_parsers = subparsers.add_parser('raw', help='Raw GraphQL', usage=get_help("raw"))
140
154
  raw_parser = raw_parse(raw_parsers)
141
155
  query_parser = subparsers.add_parser('query', help='Query', usage='catocli query <operationName> [options]')
@@ -239,6 +253,7 @@ def main(args=None):
239
253
  response = args.func(args, None)
240
254
  else:
241
255
  # Check if using headers file to determine if we should skip API key
256
+ # Note: Private settings should NOT affect regular API calls - only private commands
242
257
  using_headers_file = hasattr(args, 'headers_file') and args.headers_file
243
258
 
244
259
  # Get configuration from profiles
@@ -252,8 +267,8 @@ def main(args=None):
252
267
  custom_headers.update(parse_headers_from_file(args.headers_file))
253
268
  if custom_headers:
254
269
  configuration.custom_headers.update(custom_headers)
255
- # Handle account ID override
256
- if args.func.__name__ != "createRawRequest":
270
+ # Handle account ID override (applies to all commands except raw)
271
+ if args.func.__name__ not in ["createRawRequest"]:
257
272
  if hasattr(args, 'accountID') and args.accountID is not None:
258
273
  # Command line override takes precedence
259
274
  configuration.accountID = args.accountID
@@ -266,6 +281,9 @@ def main(args=None):
266
281
  else:
267
282
  if response!=None:
268
283
  print(json.dumps(response[0], sort_keys=True, indent=4))
284
+ except KeyboardInterrupt:
285
+ print('Operation cancelled by user (Ctrl+C).')
286
+ exit(130) # Standard exit code for SIGINT
269
287
  except Exception as e:
270
288
  if isinstance(e, AttributeError):
271
289
  print('Missing arguments. Usage: catocli <operation> -h')
@@ -275,4 +293,4 @@ def main(args=None):
275
293
  else:
276
294
  print('ERROR: ',e)
277
295
  traceback.print_exc()
278
- exit(1)
296
+ exit(1)
@@ -14,7 +14,7 @@ from .. import __version__
14
14
 
15
15
  # Cache settings
16
16
  CACHE_FILE = os.path.expanduser("~/.catocli_version_cache")
17
- CACHE_DURATION = 3600 * 24 # 24 hours in seconds
17
+ CACHE_DURATION = 3600 * 4 # 4 hours in seconds
18
18
 
19
19
  def get_cached_version_info():
20
20
  """Get cached version information if still valid"""
catocli/__init__.py CHANGED
@@ -1,2 +1,2 @@
1
- __version__ = "2.1.0"
1
+ __version__ = "2.1.1"
2
2
  __cato_host__ = "https://api.catonetworks.com/api/v1/graphql2"
@@ -107,6 +107,9 @@ def run_terraform_import(resource_address, resource_id, timeout=60, verbose=Fals
107
107
  print(f"Error: {result.stderr}")
108
108
  return False, result.stdout, result.stderr
109
109
 
110
+ except KeyboardInterrupt:
111
+ print(f"\nImport cancelled by user (Ctrl+C)")
112
+ raise # Re-raise to allow higher-level handling
110
113
  except subprocess.TimeoutExpired:
111
114
  print(f"Timeout: {resource_address} (exceeded {timeout}s)")
112
115
  return False, "", f"Command timed out after {timeout} seconds"
@@ -267,7 +270,11 @@ def import_if_rules_to_tf(args, configuration):
267
270
  "total_failed": total_failed,
268
271
  "module_name": args.module_name
269
272
  }]
270
-
273
+
274
+ except KeyboardInterrupt:
275
+ print("\nImport process cancelled by user (Ctrl+C).")
276
+ print("Partial imports may have been completed.")
277
+ return [{"success": False, "error": "Import cancelled by user"}]
271
278
  except Exception as e:
272
279
  print(f"ERROR: {str(e)}")
273
280
  return [{"success": False, "error": str(e)}]
@@ -445,7 +452,11 @@ def import_wf_rules_to_tf(args, configuration):
445
452
  "total_failed": total_failed,
446
453
  "module_name": args.module_name
447
454
  }]
448
-
455
+
456
+ except KeyboardInterrupt:
457
+ print("\nImport process cancelled by user (Ctrl+C).")
458
+ print("Partial imports may have been completed.")
459
+ return [{"success": False, "error": "Import cancelled by user"}]
449
460
  except Exception as e:
450
461
  print(f"ERROR: {str(e)}")
451
462
  return [{"success": False, "error": str(e)}]
@@ -174,6 +174,9 @@ def run_terraform_import(resource_address, resource_id, timeout=60, verbose=Fals
174
174
  print(f"Error: {result.stderr}")
175
175
  return False, result.stdout, result.stderr
176
176
 
177
+ except KeyboardInterrupt:
178
+ print(f"\nImport cancelled by user (Ctrl+C)")
179
+ raise # Re-raise to allow higher-level handling
177
180
  except subprocess.TimeoutExpired:
178
181
  print(f"Timeout: {resource_address} (exceeded {timeout}s)")
179
182
  return False, "", f"Command timed out after {timeout} seconds"
@@ -951,7 +954,11 @@ def import_socket_sites_to_tf(args, configuration):
951
954
  "total_failed": total_failed,
952
955
  "module_name": args.module_name
953
956
  }]
954
-
957
+
958
+ except KeyboardInterrupt:
959
+ print("\nImport process cancelled by user (Ctrl+C).")
960
+ print("Partial imports may have been completed.")
961
+ return [{"success": False, "error": "Import cancelled by user"}]
955
962
  except Exception as e:
956
963
  print(f"ERROR: {str(e)}")
957
964
  return [{"success": False, "error": str(e)}]
@@ -0,0 +1,134 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Private commands parser for custom GraphQL payloads
4
+ Dynamically loads commands from ~/.cato/settings.json
5
+ """
6
+
7
+ import os
8
+ import json
9
+ import argparse
10
+ from ..parserApiClient import createPrivateRequest, get_private_help
11
+
12
+
13
+ def load_private_settings():
14
+ """Load private settings from ~/.cato/settings.json"""
15
+ settings_file = os.path.expanduser("~/.cato/settings.json")
16
+ try:
17
+ with open(settings_file, 'r') as f:
18
+ settings = json.load(f)
19
+ return settings.get('privateCommands', {})
20
+ except (FileNotFoundError, json.JSONDecodeError, KeyError):
21
+ return {}
22
+
23
+
24
+ def private_parse(subparsers):
25
+ """Check for private settings and create private parser if found"""
26
+ private_commands = load_private_settings()
27
+
28
+ if not private_commands:
29
+ return None
30
+
31
+ # Create the private subparser
32
+ private_parser = subparsers.add_parser(
33
+ 'private',
34
+ help='Private custom commands (configured in ~/.cato/settings.json)',
35
+ usage='catocli private <command> [options]'
36
+ )
37
+
38
+ private_subparsers = private_parser.add_subparsers(
39
+ description='Available private commands',
40
+ help='Private command help'
41
+ )
42
+
43
+ # Dynamically create subparsers for each private command
44
+ for command_name, command_config in private_commands.items():
45
+ create_private_command_parser(
46
+ private_subparsers,
47
+ command_name,
48
+ command_config
49
+ )
50
+
51
+ return private_parser
52
+
53
+
54
+ def create_private_command_parser(subparsers, command_name, command_config):
55
+ """Create a parser for a specific private command"""
56
+
57
+ # Create the command parser
58
+ cmd_parser = subparsers.add_parser(
59
+ command_name,
60
+ help=f'Execute private command: {command_name}',
61
+ usage=get_private_help(command_name, command_config)
62
+ )
63
+
64
+ # Add standard arguments
65
+ cmd_parser.add_argument(
66
+ 'json',
67
+ nargs='?',
68
+ default='{}',
69
+ help='Variables in JSON format (defaults to empty object if not provided).'
70
+ )
71
+ cmd_parser.add_argument(
72
+ '-t',
73
+ const=True,
74
+ default=False,
75
+ nargs='?',
76
+ help='Print GraphQL query without sending API call'
77
+ )
78
+ cmd_parser.add_argument(
79
+ '-v',
80
+ const=True,
81
+ default=False,
82
+ nargs='?',
83
+ help='Verbose output'
84
+ )
85
+ cmd_parser.add_argument(
86
+ '-p',
87
+ const=True,
88
+ default=False,
89
+ nargs='?',
90
+ help='Pretty print'
91
+ )
92
+ cmd_parser.add_argument(
93
+ '-H', '--header',
94
+ action='append',
95
+ dest='headers',
96
+ help='Add custom headers in "Key: Value" format. Can be used multiple times.'
97
+ )
98
+ cmd_parser.add_argument(
99
+ '--headers-file',
100
+ dest='headers_file',
101
+ help='Load headers from a file. Each line should contain a header in "Key: Value" format.'
102
+ )
103
+
104
+ # Add standard accountID argument (like other commands)
105
+ cmd_parser.add_argument(
106
+ '-accountID',
107
+ help='Override the account ID from profile with this value.'
108
+ )
109
+
110
+ # Add dynamic arguments based on command configuration (excluding accountId since it's handled above)
111
+ if 'arguments' in command_config:
112
+ for arg in command_config['arguments']:
113
+ arg_name = arg.get('name')
114
+ # Skip accountId since it's handled by the standard -accountID argument
115
+ if arg_name and arg_name.lower() != 'accountid':
116
+ arg_type = arg.get('type', 'string')
117
+ arg_default = arg.get('default')
118
+ arg_help = f"Argument: {arg_name}"
119
+
120
+ if arg_default:
121
+ arg_help += f" (default: {arg_default})"
122
+
123
+ cmd_parser.add_argument(
124
+ f'--{arg_name}',
125
+ help=arg_help,
126
+ default=arg_default
127
+ )
128
+
129
+ # Set the function to handle this command
130
+ cmd_parser.set_defaults(
131
+ func=createPrivateRequest,
132
+ private_command=command_name,
133
+ private_config=command_config
134
+ )
@@ -7,6 +7,7 @@ from graphql_client.api_client import ApiException
7
7
  import logging
8
8
  import pprint
9
9
  import uuid
10
+ import string
10
11
  from urllib3.filepost import encode_multipart_formdata
11
12
 
12
13
  def createRequest(args, configuration):
@@ -24,7 +25,12 @@ def createRequest(args, configuration):
24
25
  elif not params["t"] and params["json"] is None:
25
26
  # Default to empty object if no json provided and not using -t flag
26
27
  variablesObj = {}
27
- if "accountId" in operation["args"]:
28
+ # Special handling for eventsFeed and auditFeed which use accountIDs array
29
+ if operationName in ["query.eventsFeed", "query.auditFeed"]:
30
+ # Only add accountIDs if not already provided in JSON
31
+ if "accountIDs" not in variablesObj:
32
+ variablesObj["accountIDs"] = [configuration.accountID]
33
+ elif "accountId" in operation["args"]:
28
34
  variablesObj["accountId"] = configuration.accountID
29
35
  else:
30
36
  variablesObj["accountID"] = configuration.accountID
@@ -438,6 +444,339 @@ def createRawBinaryRequest(args, configuration):
438
444
  print(f"ERROR: Failed to send multipart request: {error_str}")
439
445
  return None
440
446
 
447
+ def get_private_help(command_name, command_config):
448
+ """Generate comprehensive help text for a private command"""
449
+ usage = f"catocli private {command_name}"
450
+
451
+ # Create comprehensive JSON example with all arguments (excluding accountId)
452
+ if 'arguments' in command_config:
453
+ json_example = {}
454
+ for arg in command_config['arguments']:
455
+ arg_name = arg.get('name')
456
+ # Skip accountId since it's handled by standard -accountID CLI argument
457
+ if arg_name and arg_name.lower() != 'accountid':
458
+ if 'example' in arg:
459
+ # Use explicit example if provided
460
+ json_example[arg_name] = arg['example']
461
+ elif 'default' in arg:
462
+ # Use default value if available
463
+ json_example[arg_name] = arg['default']
464
+ else:
465
+ # Generate placeholder based on type
466
+ arg_type = arg.get('type', 'string')
467
+ if arg_type == 'string':
468
+ json_example[arg_name] = f"<{arg_name}>"
469
+ elif arg_type == 'object':
470
+ if 'struct' in arg:
471
+ # Use struct definition
472
+ json_example[arg_name] = arg['struct']
473
+ else:
474
+ json_example[arg_name] = {}
475
+ else:
476
+ json_example[arg_name] = f"<{arg_name}>"
477
+
478
+ if json_example:
479
+ # Format JSON nicely for readability in help
480
+ json_str = json.dumps(json_example, indent=2)
481
+ usage += f" '{json_str}'"
482
+
483
+ # Add common options
484
+ usage += " [-t] [-v] [-p]"
485
+
486
+ # Add command-specific arguments with descriptions (excluding accountId)
487
+ if 'arguments' in command_config:
488
+ filtered_args = [arg for arg in command_config['arguments'] if arg.get('name', '').lower() != 'accountid']
489
+ if filtered_args:
490
+ usage += "\n\nArguments:"
491
+ for arg in filtered_args:
492
+ arg_name = arg.get('name')
493
+ arg_type = arg.get('type', 'string')
494
+ arg_default = arg.get('default')
495
+ arg_example = arg.get('example')
496
+
497
+ if arg_name:
498
+ usage += f"\n --{arg_name}: {arg_type}"
499
+ if arg_default is not None:
500
+ usage += f" (default: {arg_default})"
501
+ if arg_example is not None and arg_example != arg_default:
502
+ usage += f" (example: {json.dumps(arg_example) if isinstance(arg_example, (dict, list)) else arg_example})"
503
+
504
+ # Add standard accountID information
505
+ usage += "\n\nStandard Arguments:"
506
+ usage += "\n -accountID: Account ID (taken from profile, can be overridden)"
507
+
508
+ # Add payload file info if available
509
+ if 'payloadFilePath' in command_config:
510
+ usage += f"\n\nPayload template: {command_config['payloadFilePath']}"
511
+
512
+ # Add batch processing info if configured
513
+ if 'batchSize' in command_config:
514
+ usage += f"\nBatch size: {command_config['batchSize']}"
515
+ if 'paginationParam' in command_config:
516
+ usage += f" (pagination: {command_config['paginationParam']})"
517
+
518
+ return usage
519
+
520
+
521
+ def load_payload_template(command_config):
522
+ """Load and return the GraphQL payload template for a private command"""
523
+ try:
524
+ payload_path = command_config.get('payloadFilePath')
525
+ if not payload_path:
526
+ raise ValueError(f"Missing payloadFilePath in command configuration")
527
+
528
+ # Construct the full path relative to the settings directory
529
+ settings_dir = os.path.expanduser("~/.cato")
530
+ full_payload_path = os.path.join(settings_dir, payload_path)
531
+
532
+ # Load the payload file using the standard JSON loading mechanism
533
+ try:
534
+ with open(full_payload_path, 'r') as f:
535
+ return json.load(f)
536
+ except FileNotFoundError:
537
+ raise ValueError(f"Payload file not found: {full_payload_path}")
538
+ except json.JSONDecodeError as e:
539
+ raise ValueError(f"Invalid JSON in payload file {full_payload_path}: {e}")
540
+ except Exception as e:
541
+ raise ValueError(f"Failed to load payload template: {e}")
542
+
543
+
544
+ def set_nested_value(obj, path, value):
545
+ """Set a value at a nested path in an object using jQuery-style JSON path syntax
546
+
547
+ Supports:
548
+ - Simple dot notation: 'a.b.c'
549
+ - Array access: 'a.b[0].c' or 'a.b[0]'
550
+ - Mixed paths: 'variables.filters[0].search'
551
+ - Deep nesting: 'data.results[0].items[1].properties.name'
552
+ """
553
+ import re
554
+
555
+ # Parse the path into components handling both dot notation and array indices
556
+ # Split by dots first, then handle array indices
557
+ path_parts = []
558
+ for part in path.split('.'):
559
+ # Check if this part contains array notation like 'items[0]'
560
+ array_matches = re.findall(r'([^\[]+)(?:\[(\d+)\])?', part)
561
+ for match in array_matches:
562
+ key, index = match
563
+ if key: # Add the key part
564
+ path_parts.append(key)
565
+ if index: # Add the array index part
566
+ path_parts.append(int(index))
567
+
568
+ current = obj
569
+
570
+ # Navigate to the parent of the target location
571
+ for i, part in enumerate(path_parts[:-1]):
572
+ next_part = path_parts[i + 1]
573
+
574
+ if isinstance(part, int):
575
+ # Current part is an array index
576
+ if not isinstance(current, list):
577
+ raise ValueError(f"Expected array at path component {i}, got {type(current).__name__}")
578
+
579
+ # Extend array if necessary
580
+ while len(current) <= part:
581
+ current.append(None)
582
+
583
+ # Initialize the array element if it doesn't exist
584
+ if current[part] is None:
585
+ if isinstance(next_part, int):
586
+ current[part] = [] # Next part is array index, so create array
587
+ else:
588
+ current[part] = {} # Next part is object key, so create object
589
+
590
+ current = current[part]
591
+
592
+ else:
593
+ # Current part is an object key
594
+ if not isinstance(current, dict):
595
+ raise ValueError(f"Expected object at path component {i}, got {type(current).__name__}")
596
+
597
+ # Create the key if it doesn't exist
598
+ if part not in current:
599
+ if isinstance(next_part, int):
600
+ current[part] = [] # Next part is array index, so create array
601
+ else:
602
+ current[part] = {} # Next part is object key, so create object
603
+
604
+ current = current[part]
605
+
606
+ # Set the final value
607
+ final_part = path_parts[-1]
608
+ if isinstance(final_part, int):
609
+ # Final part is an array index
610
+ if not isinstance(current, list):
611
+ raise ValueError(f"Expected array at final path component, got {type(current).__name__}")
612
+
613
+ # Extend array if necessary
614
+ while len(current) <= final_part:
615
+ current.append(None)
616
+
617
+ current[final_part] = value
618
+ else:
619
+ # Final part is an object key
620
+ if not isinstance(current, dict):
621
+ raise ValueError(f"Expected object at final path component, got {type(current).__name__}")
622
+
623
+ current[final_part] = value
624
+
625
+
626
+ def apply_template_variables(template, variables, private_config):
627
+ """Apply variables to the template using path-based insertion and template replacement"""
628
+ if not template or not isinstance(template, dict):
629
+ return template
630
+
631
+ # Make a deep copy to avoid modifying the original
632
+ import copy
633
+ result = copy.deepcopy(template)
634
+
635
+ # First, handle path-based variable insertion from private_config
636
+ if private_config and 'arguments' in private_config:
637
+ for arg in private_config['arguments']:
638
+ arg_name = arg.get('name')
639
+ arg_paths = arg.get('path', [])
640
+
641
+ if arg_name and arg_name in variables and arg_paths:
642
+ # Insert the variable value at each specified path
643
+ for path in arg_paths:
644
+ try:
645
+ set_nested_value(result, path, variables[arg_name])
646
+ except Exception as e:
647
+ # If path insertion fails, continue to template replacement
648
+ pass
649
+
650
+ # Second, handle traditional template variable replacement as fallback
651
+ def traverse_and_replace(obj, path=""):
652
+ if isinstance(obj, dict):
653
+ for key, value in list(obj.items()):
654
+ new_path = f"{path}.{key}" if path else key
655
+
656
+ # Check if this is a template variable (string that starts with '{{')
657
+ if isinstance(value, str) and value.startswith('{{') and value.endswith('}}'):
658
+ # Extract variable name
659
+ var_name = value[2:-2].strip()
660
+
661
+ # Replace with actual value if available
662
+ if var_name in variables:
663
+ obj[key] = variables[var_name]
664
+
665
+ # Recursively process nested objects
666
+ else:
667
+ traverse_and_replace(value, new_path)
668
+
669
+ elif isinstance(obj, list):
670
+ for i, item in enumerate(obj):
671
+ traverse_and_replace(item, f"{path}[{i}]")
672
+
673
+ traverse_and_replace(result)
674
+ return result
675
+
676
+
677
+ def createPrivateRequest(args, configuration):
678
+ """Handle private command execution using GraphQL payload templates"""
679
+ params = vars(args)
680
+
681
+ # Get the private command configuration
682
+ private_command = params.get('private_command')
683
+ private_config = params.get('private_config')
684
+
685
+ if not private_command or not private_config:
686
+ print("ERROR: Missing private command configuration")
687
+ return None
688
+
689
+ # Load private settings and apply ONLY for private commands
690
+ try:
691
+ settings_file = os.path.expanduser("~/.cato/settings.json")
692
+ with open(settings_file, 'r') as f:
693
+ private_settings = json.load(f)
694
+ except (FileNotFoundError, json.JSONDecodeError):
695
+ private_settings = {}
696
+
697
+ # Override endpoint if specified in private settings
698
+ if 'baseUrl' in private_settings:
699
+ configuration.host = private_settings['baseUrl']
700
+
701
+ # Add custom headers from private settings
702
+ if 'headers' in private_settings and isinstance(private_settings['headers'], dict):
703
+ if not hasattr(configuration, 'custom_headers'):
704
+ configuration.custom_headers = {}
705
+ for key, value in private_settings['headers'].items():
706
+ configuration.custom_headers[key] = value
707
+
708
+ # Parse input JSON variables
709
+ try:
710
+ variables = json.loads(params.get('json', '{}'))
711
+ except ValueError as e:
712
+ print(f"ERROR: Invalid JSON input: {e}")
713
+ return None
714
+
715
+ # Apply default values from settings configuration first
716
+ for arg in private_config.get('arguments', []):
717
+ arg_name = arg.get('name')
718
+ if arg_name and 'default' in arg:
719
+ variables[arg_name] = arg['default']
720
+
721
+ # Apply profile account ID as fallback (lower priority than settings defaults)
722
+ # Only apply if accountId is not already set by settings defaults
723
+ if configuration and hasattr(configuration, 'accountID'):
724
+ if 'accountID' not in variables and 'accountId' not in variables:
725
+ # Use both naming conventions to support different payload templates
726
+ variables['accountID'] = configuration.accountID
727
+ variables['accountId'] = configuration.accountID
728
+ # If accountId/accountID exists but not the other variation, add both
729
+ elif 'accountID' in variables and 'accountId' not in variables:
730
+ variables['accountId'] = variables['accountID']
731
+ elif 'accountId' in variables and 'accountID' not in variables:
732
+ variables['accountID'] = variables['accountId']
733
+
734
+ # Apply CLI argument values (highest priority - overrides everything)
735
+ for arg in private_config.get('arguments', []):
736
+ arg_name = arg.get('name')
737
+ if arg_name:
738
+ # Handle special case for accountId - CLI uses -accountID but config uses accountId
739
+ if arg_name.lower() == 'accountid':
740
+ if hasattr(args, 'accountID') and getattr(args, 'accountID') is not None:
741
+ arg_value = getattr(args, 'accountID')
742
+ variables['accountID'] = arg_value
743
+ variables['accountId'] = arg_value
744
+ elif hasattr(args, 'accountId') and getattr(args, 'accountId') is not None:
745
+ arg_value = getattr(args, 'accountId')
746
+ variables['accountID'] = arg_value
747
+ variables['accountId'] = arg_value
748
+ # Handle other arguments normally
749
+ else:
750
+ if hasattr(args, arg_name):
751
+ arg_value = getattr(args, arg_name)
752
+ if arg_value is not None:
753
+ variables[arg_name] = arg_value
754
+
755
+ # Load the payload template
756
+ try:
757
+ payload_template = load_payload_template(private_config)
758
+ except ValueError as e:
759
+ print(f"ERROR: {e}")
760
+ return None
761
+
762
+ # Apply variables to the template using path-based insertion
763
+ body = apply_template_variables(payload_template, variables, private_config)
764
+
765
+ # Test mode - just print the request
766
+ if params.get('t'):
767
+ if params.get('p'):
768
+ print(json.dumps(body, indent=2, sort_keys=True))
769
+ else:
770
+ print(json.dumps(body))
771
+ return None
772
+
773
+ # Execute the GraphQL request using custom method (no User-Agent header)
774
+ try:
775
+ return sendPrivateGraphQLRequest(configuration, body, params)
776
+ except Exception as e:
777
+ return e
778
+
779
+
441
780
  def sendMultipartRequest(configuration, form_fields, files, params):
442
781
  """Send a multipart/form-data request directly using urllib3"""
443
782
  import urllib3
@@ -520,3 +859,90 @@ def sendMultipartRequest(configuration, form_fields, files, params):
520
859
  error_str = f"Exception of type {type(e).__name__}"
521
860
  print(f"ERROR: Network/request error: {error_str}")
522
861
  return None
862
+
863
+
864
+ def sendPrivateGraphQLRequest(configuration, body, params):
865
+ """Send a GraphQL request for private commands without User-Agent header"""
866
+ import urllib3
867
+
868
+ # Create pool manager
869
+ pool_manager = urllib3.PoolManager(
870
+ cert_reqs='CERT_NONE' if not getattr(configuration, 'verify_ssl', False) else 'CERT_REQUIRED'
871
+ )
872
+
873
+ # Prepare headers WITHOUT User-Agent
874
+ headers = {
875
+ 'Content-Type': 'application/json'
876
+ }
877
+
878
+ # Add API key if not using headers file or custom headers
879
+ using_custom_headers = hasattr(configuration, 'custom_headers') and configuration.custom_headers
880
+ if not using_custom_headers and hasattr(configuration, 'api_key') and configuration.api_key and 'x-api-key' in configuration.api_key:
881
+ headers['x-api-key'] = configuration.api_key['x-api-key']
882
+
883
+ # Add custom headers
884
+ if using_custom_headers:
885
+ headers.update(configuration.custom_headers)
886
+
887
+ # Encode headers to handle Unicode characters properly
888
+ encoded_headers = {}
889
+ for key, value in headers.items():
890
+ # Ensure header values are properly encoded as strings
891
+ if isinstance(value, str):
892
+ # Replace problematic Unicode characters that can't be encoded in latin-1
893
+ value = value.encode('utf-8', errors='replace').decode('latin-1', errors='replace')
894
+ encoded_headers[key] = value
895
+ headers = encoded_headers
896
+
897
+ # Verbose output
898
+ if params.get("v") == True:
899
+ print(f"Host: {getattr(configuration, 'host', 'unknown')}")
900
+ masked_headers = headers.copy()
901
+ if 'x-api-key' in masked_headers:
902
+ masked_headers['x-api-key'] = '***MASKED***'
903
+ # Also mask Cookie for privacy
904
+ if 'Cookie' in masked_headers:
905
+ masked_headers['Cookie'] = '***MASKED***'
906
+ print(f"Request Headers: {json.dumps(masked_headers, indent=4, sort_keys=True)}")
907
+ print(f"Request Data: {json.dumps(body, indent=4, sort_keys=True)}\n")
908
+
909
+ # Prepare request body
910
+ body_data = json.dumps(body).encode('utf-8')
911
+
912
+ try:
913
+ # Make the request
914
+ resp = pool_manager.request(
915
+ 'POST',
916
+ getattr(configuration, 'host', 'https://api.catonetworks.com/api/v1/graphql'),
917
+ body=body_data,
918
+ headers=headers
919
+ )
920
+
921
+ # Parse response
922
+ if resp.status < 200 or resp.status >= 300:
923
+ reason = resp.reason if resp.reason is not None else "Unknown Error"
924
+ error_msg = f"HTTP {resp.status}: {reason}"
925
+ if resp.data:
926
+ try:
927
+ error_msg += f"\n{resp.data.decode('utf-8')}"
928
+ except Exception:
929
+ error_msg += f"\n{resp.data}"
930
+ print(f"ERROR: {error_msg}")
931
+ return None
932
+
933
+ try:
934
+ response_data = json.loads(resp.data.decode('utf-8'))
935
+ except json.JSONDecodeError:
936
+ response_data = resp.data.decode('utf-8')
937
+
938
+ # Return in the same format as the regular API client
939
+ return [response_data]
940
+
941
+ except Exception as e:
942
+ # Safely handle exception string conversion
943
+ try:
944
+ error_str = str(e)
945
+ except Exception:
946
+ error_str = f"Exception of type {type(e).__name__}"
947
+ print(f"ERROR: Network/request error: {error_str}")
948
+ return None
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: catocli
3
- Version: 2.1.0
3
+ Version: 2.1.1
4
4
  Summary: Cato Networks cli wrapper for the GraphQL API.
5
5
  Home-page: https://github.com/Cato-Networks/cato-cli
6
6
  Author: Cato Networks
@@ -1,9 +1,9 @@
1
- catocli/__init__.py,sha256=Ag4gOmOVdoJyCHR6YaHm80lnUUfmywqRfwwWYgEoyHo,85
1
+ catocli/__init__.py,sha256=HqBAr27AlpkoQiOSD0gHsZCdJJQ2kVVSjSyJkHAs1nI,85
2
2
  catocli/__main__.py,sha256=6Z0ns_k_kUcz1Qtrn1u7UyUnqB-3e85jM_nppOwFsv4,217
3
- catocli/Utils/clidriver.py,sha256=wxghLxwNkMhMUI5dV4v1AYz39sXC0xJF-FrwH8KEBZw,13239
3
+ catocli/Utils/clidriver.py,sha256=RHZ95heniZSCCHlkP8mNBe22CPdzR78fANxL5F6Q1q8,13991
4
4
  catocli/Utils/profile_manager.py,sha256=Stch-cU2rLA7r07hETIRsvG_JxpQx3Q42QwpfD7Qd2I,6379
5
- catocli/Utils/version_checker.py,sha256=rakF8LIeB1D_bbsT1cX6q4IaxYpGw4Yeq35BWa7QbEA,6698
6
- catocli/parsers/parserApiClient.py,sha256=lWn0T9IFKelfJPoY-oNhJT3EF-x7hGZqoMgPIWFgsic,20611
5
+ catocli/Utils/version_checker.py,sha256=tCtsCn7xxMIxOm6cWJSA_yPt0j4mNMK4iWSJej0yM6A,6696
6
+ catocli/parsers/parserApiClient.py,sha256=pSzh6IIXxPhytiLUG47DvC6bI3VU7xRczosyRrYoujs,35505
7
7
  catocli/parsers/configure/__init__.py,sha256=Kq4OYGq_MXOQBHm8vxNkzJqCXp7zremYBfQ4Ai3_Lb4,3286
8
8
  catocli/parsers/configure/configure.py,sha256=GyaOeuf0ZK3-wsZaczmO1OsKVJiPK04h6iI1u-EaKQc,12217
9
9
  catocli/parsers/custom/README.md,sha256=UvCWAtF3Yh0UsvADb0ve1qJupgYHeyGu6V3Z0O5HEvo,8180
@@ -14,9 +14,10 @@ catocli/parsers/custom/export_rules/export_rules.py,sha256=gXig4NC29yC5nW48ri-j0
14
14
  catocli/parsers/custom/export_sites/__init__.py,sha256=WnTRNA4z4IS0rKR9r_UfuGv3bxp8nlt4YNH1Sed4toU,1378
15
15
  catocli/parsers/custom/export_sites/export_sites.py,sha256=DQxCZXGEN35F_kEbAAXo57t0-B-7koVRbe7unExo7F4,24670
16
16
  catocli/parsers/custom/import_rules_to_tf/__init__.py,sha256=jFCFceFv8zDW7nGLZOXkFE6NXAMPYRwKQwTbhSawYdM,3908
17
- catocli/parsers/custom/import_rules_to_tf/import_rules_to_tf.py,sha256=kHzd3Iw9H6UeuSB-HE6vp3ZY38kGJk_ATgnDI2kus7k,18135
17
+ catocli/parsers/custom/import_rules_to_tf/import_rules_to_tf.py,sha256=WlyTDxUtWchu9CDs73ILHDfNXCAh7jFARBEKk5WWhwM,18714
18
18
  catocli/parsers/custom/import_sites_to_tf/__init__.py,sha256=0EyCGY2qV4wxMoFfqBPs5FoMc0Ufr7J7Ciygb6Xx8Fc,2925
19
- catocli/parsers/custom/import_sites_to_tf/import_sites_to_tf.py,sha256=9ymOJJgsTLWWJCu9JP8FpBPouwNqgMsnKBg5YlH698k,42520
19
+ catocli/parsers/custom/import_sites_to_tf/import_sites_to_tf.py,sha256=KND-eQOS_rFYVAX01Gm5ChxE8DtHZ1edS0SizoBqyx4,42880
20
+ catocli/parsers/custom_private/__init__.py,sha256=Xy5ODq-JZMBuIZ7HxBpbYlfn09p-NyxMU45TT6Tw9Sc,4170
20
21
  catocli/parsers/mutation/README.md,sha256=mdOfOY0sOVGnf9q-GVgtGc7mtxFKigD9q0ILJAw8l0I,32
21
22
  catocli/parsers/mutation_accountManagement/README.md,sha256=8ipbdTU8FAAfiH3IYlTZkTbLpPtO0fMblra7Zi8vwAI,249
22
23
  catocli/parsers/mutation_accountManagement/__init__.py,sha256=8sDLa3dko18IePLDuskvkKNLYi0CtER-ZEFnAUaTGwg,6380
@@ -335,13 +336,13 @@ catocli/parsers/query_xdr_stories/README.md,sha256=kbnrCmTq7o0T1LMgN0w8lAoiwB3NZ
335
336
  catocli/parsers/query_xdr_story/README.md,sha256=Csl2tE511miGOrISgIJyJXVEz2v1ONrMdvYywMeKv1Q,930
336
337
  catocli/parsers/raw/README.md,sha256=qvDLcg7U12yqacIQStOPtkqTsTLltJ06SSVmbqvlZhY,739
337
338
  catocli/parsers/raw/__init__.py,sha256=wXknxAeTYqjbOG_NFXsenjciD_bagT7P6Oxi2CuV8hg,1077
338
- catocli-2.1.0.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
339
+ catocli-2.1.1.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
339
340
  graphql_client/__init__.py,sha256=2nxD4YsWoOnALXi5cXbmtIN_i0NL_eyDTQRTxs52mkI,315
340
341
  graphql_client/api_client.py,sha256=2Rc1Zo1xH9Jnk1AO68kLSofTShkZwSVF-WkVtczfIc4,5786
341
342
  graphql_client/api_client_types.py,sha256=dM3zl6FA5SSp6nR6KmLfTL1BKaXX9uPMCZAm4v_FiUs,11569
342
343
  graphql_client/configuration.py,sha256=zzcdWjK1HdOxEbWg12ciWAlKV5sVpoiOzHiL5PMTBWU,7706
343
344
  graphql_client/api/__init__.py,sha256=UjpnW5LhQScrcYDQhCfk5YkA4pGpEx0JT_G0gUI23Rg,88
344
- graphql_client/api/call_api.py,sha256=-TWfnqV7Y9YGA53mlndKxw5tkcZkszh2uQkFkxheQT8,3028
345
+ graphql_client/api/call_api.py,sha256=K8IvubNIr6zsXPPiO7l126WnR-kCohyTZG9w4TPYBGw,3874
345
346
  graphql_client/models/__init__.py,sha256=beY1dq6H1z0OOWRwvCN84LFOilZKMD_GjrYHuLKJI5c,275
346
347
  graphql_client/models/no_schema.py,sha256=NorUxY0NQA6reSS8Os_iF0GRk6cCYxhH7_Su0B9_eJk,1972
347
348
  models/mutation.accountManagement.addAccount.json,sha256=2gm59WwGde-8MVU2L4BIB5EpQ4ErBlE20qEaesTO5e8,55479
@@ -587,7 +588,7 @@ models/query.socketPortMetricsTimeSeries.json,sha256=61CJTpxNtWPS_7CnAQFW_rIe_ae
587
588
  models/query.subDomains.json,sha256=ySJ-gsBzC8EX4WrtJpasLAGkSK7-3e3orhPbIyx_B8s,7041
588
589
  models/query.xdr.stories.json,sha256=2VTpZjf47WbC4MYMoyvOxikkSUJRH2kwAmRLRAMluYk,3835486
589
590
  models/query.xdr.story.json,sha256=3WOa45mNzQRXjy3LEQoRw9gHX29AUOISWhu7ohvdhwc,2838778
590
- schema/catolib.py,sha256=eshJsUuN48eGNDKB5VjaI8S3J3cBUddVb_trmL1JsXQ,57093
591
+ schema/catolib.py,sha256=sProS90AZV8tXuAXhc7H1ycO1tX7o4nvrpWU5ypZrsU,57845
591
592
  schema/importSchema.py,sha256=9xg9N0MjgQUiPRczOpk0sTY1Nx9K2F6MRhpUyRTNqZ4,2795
592
593
  schema/remove_policyid.py,sha256=DHGEL-Yb8mqNSPelDmmqYtHZi-tjZd2jwA4_ycgC9Rc,2590
593
594
  schema/remove_policyid_mutations.py,sha256=TDNC5iMVqGtF_t9s9A4TkNCLMHAYmo77o3jKqjt8Uz8,2944
@@ -637,8 +638,8 @@ vendor/urllib3/util/timeout.py,sha256=4eT1FVeZZU7h7mYD1Jq2OXNe4fxekdNvhoWUkZusRp
637
638
  vendor/urllib3/util/url.py,sha256=wHORhp80RAXyTlAIkTqLFzSrkU7J34ZDxX-tN65MBZk,15213
638
639
  vendor/urllib3/util/util.py,sha256=j3lbZK1jPyiwD34T8IgJzdWEZVT-4E-0vYIJi9UjeNA,1146
639
640
  vendor/urllib3/util/wait.py,sha256=_ph8IrUR3sqPqi0OopQgJUlH4wzkGeM5CiyA7XGGtmI,4423
640
- catocli-2.1.0.dist-info/METADATA,sha256=rgM6HsdOt6uKB67_BPERpphXTyKISRNwIkM3i3deoCo,1286
641
- catocli-2.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
642
- catocli-2.1.0.dist-info/entry_points.txt,sha256=p4k9Orre6aWcqVrNmBbckmCs39h-1naMxRo2AjWmWZ4,50
643
- catocli-2.1.0.dist-info/top_level.txt,sha256=F4qSgcjcW5wR9EFrO8Ud06F7ZQGFr04a9qALNQDyVxU,52
644
- catocli-2.1.0.dist-info/RECORD,,
641
+ catocli-2.1.1.dist-info/METADATA,sha256=-3qr5BfA-dEs1v2rJObAzEwpkdKayegIMCt7ymG1lYs,1286
642
+ catocli-2.1.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
643
+ catocli-2.1.1.dist-info/entry_points.txt,sha256=p4k9Orre6aWcqVrNmBbckmCs39h-1naMxRo2AjWmWZ4,50
644
+ catocli-2.1.1.dist-info/top_level.txt,sha256=F4qSgcjcW5wR9EFrO8Ud06F7ZQGFr04a9qALNQDyVxU,52
645
+ catocli-2.1.1.dist-info/RECORD,,
@@ -59,9 +59,27 @@ class CallApi(object):
59
59
  header_params['x-api-key'] = self.api_client.configuration.api_key['x-api-key']
60
60
  header_params['User-Agent'] = "Cato-CLI-v"+self.api_client.configuration.version
61
61
 
62
- # Add custom headers from configuration
62
+ # Add custom headers from configuration with proper encoding handling
63
63
  if using_custom_headers:
64
- header_params.update(self.api_client.configuration.custom_headers)
64
+ for key, value in self.api_client.configuration.custom_headers.items():
65
+ # Ensure header values can be encoded as latin-1 (HTTP header encoding)
66
+ try:
67
+ # Try to encode as latin-1 first (HTTP standard)
68
+ if isinstance(value, str):
69
+ value.encode('latin-1')
70
+ header_params[key] = value
71
+ except UnicodeEncodeError:
72
+ # If latin-1 encoding fails, encode as UTF-8 and then decode as latin-1
73
+ # This handles Unicode characters by converting them to percent-encoded format
74
+ if isinstance(value, str):
75
+ try:
76
+ # URL encode problematic Unicode characters
77
+ import urllib.parse
78
+ encoded_value = urllib.parse.quote(value, safe=':;=?@&')
79
+ header_params[key] = encoded_value
80
+ except Exception:
81
+ # As a last resort, replace problematic characters
82
+ header_params[key] = value.encode('ascii', 'replace').decode('ascii')
65
83
 
66
84
  if args.get("v")==True:
67
85
  print("Host: ",self.api_client.configuration.host)
schema/catolib.py CHANGED
@@ -534,6 +534,7 @@ profile_manager = get_profile_manager()
534
534
  CATO_DEBUG = bool(os.getenv("CATO_DEBUG", False))
535
535
  from ..parsers.raw import raw_parse
536
536
  from ..parsers.custom import custom_parse
537
+ from ..parsers.custom_private import private_parse
537
538
  from ..parsers.query_siteLocation import query_siteLocation_parse
538
539
  """
539
540
  for parserName in parsers:
@@ -563,6 +564,15 @@ def show_version_info(args, configuration=None):
563
564
  print("Unable to check for updates (check your internet connection)")
564
565
  return [{"success": True, "current_version": catocli.__version__, "latest_version": latest_version if not args.current_only else None}]
565
566
 
567
+ def load_private_settings():
568
+ # Load private settings from ~/.cato/settings.json
569
+ settings_file = os.path.expanduser("~/.cato/settings.json")
570
+ try:
571
+ with open(settings_file, 'r') as f:
572
+ return json.load(f)
573
+ except (FileNotFoundError, json.JSONDecodeError):
574
+ return {}
575
+
566
576
  def get_configuration(skip_api_key=False):
567
577
  configuration = Configuration()
568
578
  configuration.verify_ssl = False
@@ -585,10 +595,13 @@ def get_configuration(skip_api_key=False):
585
595
  print(f"Run 'catocli configure set --profile {profile_name}' to update your credentials.")
586
596
  exit(1)
587
597
 
598
+ # Use standard endpoint from profile for regular API calls
599
+ configuration.host = credentials['endpoint']
600
+
588
601
  # Only set API key if not using custom headers file
602
+ # (Private settings are handled separately in createPrivateRequest)
589
603
  if not skip_api_key:
590
604
  configuration.api_key["x-api-key"] = credentials['cato_token']
591
- configuration.host = credentials['endpoint']
592
605
  configuration.accountID = credentials['account_id']
593
606
 
594
607
  return configuration
@@ -613,6 +626,7 @@ version_parser.add_argument('--current-only', action='store_true', help='Show on
613
626
  version_parser.set_defaults(func=show_version_info)
614
627
 
615
628
  custom_parsers = custom_parse(subparsers)
629
+ private_parsers = private_parse(subparsers)
616
630
  raw_parsers = subparsers.add_parser('raw', help='Raw GraphQL', usage=get_help("raw"))
617
631
  raw_parser = raw_parse(raw_parsers)
618
632
  query_parser = subparsers.add_parser('query', help='Query', usage='catocli query <operationName> [options]')
@@ -681,6 +695,7 @@ def main(args=None):
681
695
  response = args.func(args, None)
682
696
  else:
683
697
  # Check if using headers file to determine if we should skip API key
698
+ # Note: Private settings should NOT affect regular API calls - only private commands
684
699
  using_headers_file = hasattr(args, 'headers_file') and args.headers_file
685
700
 
686
701
  # Get configuration from profiles
@@ -694,8 +709,8 @@ def main(args=None):
694
709
  custom_headers.update(parse_headers_from_file(args.headers_file))
695
710
  if custom_headers:
696
711
  configuration.custom_headers.update(custom_headers)
697
- # Handle account ID override
698
- if args.func.__name__ != "createRawRequest":
712
+ # Handle account ID override (applies to all commands except raw)
713
+ if args.func.__name__ not in ["createRawRequest"]:
699
714
  if hasattr(args, 'accountID') and args.accountID is not None:
700
715
  # Command line override takes precedence
701
716
  configuration.accountID = args.accountID
@@ -708,6 +723,9 @@ def main(args=None):
708
723
  else:
709
724
  if response!=None:
710
725
  print(json.dumps(response[0], sort_keys=True, indent=4))
726
+ except KeyboardInterrupt:
727
+ print('Operation cancelled by user (Ctrl+C).')
728
+ exit(130) # Standard exit code for SIGINT
711
729
  except Exception as e:
712
730
  if isinstance(e, AttributeError):
713
731
  print('Missing arguments. Usage: catocli <operation> -h')
@@ -717,7 +735,7 @@ def main(args=None):
717
735
  else:
718
736
  print('ERROR: ',e)
719
737
  traceback.print_exc()
720
- exit(1)
738
+ exit(1)
721
739
  """
722
740
  writeFile("../catocli/Utils/clidriver.py",cliDriverStr)
723
741