diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d6467ce..056b89b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.38.0] - 2026-03-31 +### Added +- CLI local configuration support with `--local` flag for config set and view commands +- `SECOPS_LOCAL_CONFIG_DIR` environment variable support for managing multiple local configurations + +### Updated +- CLI argument parsing to properly handle global flags placed after subcommands + ## [0.37.0] - 2026-03-11 ### Added - Comprehensive case management functionality for Chronicle diff --git a/CLI.md b/CLI.md index 2031ce67..db325310 100644 --- a/CLI.md +++ b/CLI.md @@ -23,11 +23,18 @@ gcloud auth application-default login ## Configuration -The CLI allows you to save your credentials and other common settings in a configuration file, so you don't have to specify them in every command. +The CLI allows you to save your credentials and other common settings in configuration files. The CLI supports two configuration scopes: + +- **Global configuration**: Stored in `~/.secops/config.json` and applies to all projects +- **Local configuration**: Stored in `./.secops/config.json` in the current directory and applies only to the current project + +Local configuration takes precedence over global configuration when both are present. ### Saving Configuration -Save your Chronicle instance ID, project ID, and region: +#### Global Configuration + +Save your Chronicle instance ID, project ID, and region globally: ```bash secops config set --customer-id "your-instance-id" --project-id "your-project-id" --region "us" @@ -60,16 +67,50 @@ secops config set --time-window 48 secops config set --start-time "2023-07-01T00:00:00Z" --end-time "2023-07-02T00:00:00Z" ``` -The configuration is stored in `~/.secops/config.json`. +#### Local Configuration + +Use the `--local` flag to save configuration for the current project only: + +```bash +secops config set --local --customer-id "project-specific-id" --project-id "project-a" +``` + +This is useful when working with multiple projects or environments. + +#### Managing Multiple Projects + +You can use the `SECOPS_LOCAL_CONFIG_DIR` environment variable to switch between different project configurations: + +```bash +# Setup configuration for Project A +export SECOPS_LOCAL_CONFIG_DIR=/path/to/project-a/.secops +mkdir -p $SECOPS_LOCAL_CONFIG_DIR +secops config set --local --project-id project-a --customer-id instance-a + +# Setup configuration for Project B +export SECOPS_LOCAL_CONFIG_DIR=/path/to/project-b/.secops +mkdir -p $SECOPS_LOCAL_CONFIG_DIR +secops config set --local --project-id project-b --customer-id instance-b + +# Use Project A config +export SECOPS_LOCAL_CONFIG_DIR=/path/to/project-a/.secops +secops search --query "..." +``` ### Viewing Configuration -View your current configuration settings: +View your current global configuration: ```bash secops config view ``` +View local configuration: + +```bash +secops config view --local +``` + ### Clearing Configuration Clear all saved configuration: diff --git a/pyproject.toml b/pyproject.toml index 18d0d7d4..510c6f3c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "secops" -version = "0.37.0" +version = "0.38.0" description = "Python SDK for wrapping the Google SecOps API for common use cases" readme = "README.md" requires-python = ">=3.10" diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 00000000..ae41aa47 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,13 @@ +pytest>=7.0.0 +pytest-cov>=3.0.0 +tox>=3.24.0 +python-dotenv>=0.17.1 +build +black +packaging +pathspec +protobuf +pylint +twine +sphinx>=4.0.0 +sphinx-rtd-theme>=1.0.0 diff --git a/requirements.txt b/requirements.txt index c5fa30d6..5c319275 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,11 +1,4 @@ -pytest -pytest-cov -build -black -packaging -pathspec -protobuf -pylint -twine -python-dotenv -requests \ No newline at end of file +google-auth>=2.0.0 +google-auth-httplib2>=0.1.0 +google-api-python-client>=2.0.0 +requests>=2.25.1 diff --git a/src/secops/cli/cli_client.py b/src/secops/cli/cli_client.py index 4c483656..5b31923a 100644 --- a/src/secops/cli/cli_client.py +++ b/src/secops/cli/cli_client.py @@ -190,9 +190,30 @@ def build_parser() -> argparse.ArgumentParser: setup_watchlist_command(subparsers) setup_rule_retrohunt_command(subparsers) + # Add common args to all subparsers to support global flags after subcommand + # e.g. "secops search ... --output json" + # We use suppress_defaults=True so that if the flag is NOT provided, + # it doesn't override the global default (or the one from the specific + # command if it exists) + _apply_common_args_recursively(parser) + return parser +def _apply_common_args_recursively(parser: argparse.ArgumentParser) -> None: + """Recursively add common args to all subparsers. + + Args: + parser: Parser to traverse + """ + for action in getattr(parser, "_actions", []): + if hasattr(action, "choices") and isinstance(action.choices, dict): + for subparser in action.choices.values(): + add_common_args(subparser, suppress_defaults=True) + add_chronicle_args(subparser, suppress_defaults=True) + _apply_common_args_recursively(subparser) + + def run(args: argparse.Namespace, parser: argparse.ArgumentParser) -> None: """Run the CLI diff --git a/src/secops/cli/commands/config.py b/src/secops/cli/commands/config.py index 04e99930..0b67ca19 100644 --- a/src/secops/cli/commands/config.py +++ b/src/secops/cli/commands/config.py @@ -39,6 +39,11 @@ def setup_config_command(subparsers): set_parser = config_subparsers.add_parser( "set", help="Set configuration values" ) + set_parser.add_argument( + "--local", + action="store_true", + help="Save configuration to current directory (.secops/config.json)", + ) add_chronicle_args(set_parser) add_common_args(set_parser) add_time_range_args(set_parser) @@ -48,6 +53,11 @@ def setup_config_command(subparsers): view_parser = config_subparsers.add_parser( "view", help="View current configuration" ) + view_parser.add_argument( + "--local", + action="store_true", + help="View configuration of current directory (.secops/config.json)", + ) view_parser.set_defaults(func=handle_config_view_command) # Clear config command @@ -64,7 +74,8 @@ def handle_config_set_command(args, chronicle=None): args: Command line arguments chronicle: Not used for this command """ - config = load_config() + scope = "local" if args.local else "global" + config = load_config(scope=scope) # Update config with new values if args.customer_id: @@ -84,8 +95,9 @@ def handle_config_set_command(args, chronicle=None): if args.time_window is not None: config["time_window"] = args.time_window - save_config(config) - print(f"Configuration saved to {CONFIG_FILE}") + save_config(config, local=args.local) + target = "local" if args.local else "global" + print(f"Configuration saved to {target} config file") # Unused argument _ = (chronicle,) @@ -98,13 +110,14 @@ def handle_config_view_command(args, chronicle=None): args: Command line arguments chronicle: Not used for this command """ - config = load_config() + scope = "local" if args.local else "global" + config = load_config(scope=scope) if not config: print("No configuration found.") return - print("Current configuration:") + print(f"Current {scope} configuration:") for key, value in config.items(): print(f" {key}: {value}") diff --git a/src/secops/cli/constants.py b/src/secops/cli/constants.py index 5f1a79ed..5ee8301e 100644 --- a/src/secops/cli/constants.py +++ b/src/secops/cli/constants.py @@ -1,7 +1,20 @@ """Constants for CLI""" +import os from pathlib import Path # Define config directory and file paths +# Global config (user home) CONFIG_DIR = Path.home() / ".secops" CONFIG_FILE = CONFIG_DIR / "config.json" + +# Local config (current working directory or from env var) +# If SECOPS_LOCAL_CONFIG_DIR is set, use it. +# Otherwise, default to current working directory + .secops +_local_config_env = os.environ.get("SECOPS_LOCAL_CONFIG_DIR") +if _local_config_env: + LOCAL_CONFIG_DIR = Path(_local_config_env) +else: + LOCAL_CONFIG_DIR = Path.cwd() / ".secops" + +LOCAL_CONFIG_FILE = LOCAL_CONFIG_DIR / "config.json" diff --git a/src/secops/cli/utils/common_args.py b/src/secops/cli/utils/common_args.py index 7490801d..a00dbcb7 100644 --- a/src/secops/cli/utils/common_args.py +++ b/src/secops/cli/utils/common_args.py @@ -19,62 +19,94 @@ from secops.cli.utils.config_utils import load_config -def add_common_args(parser: argparse.ArgumentParser) -> None: +def _add_argument_if_not_exists( + parser: argparse.ArgumentParser, *args: str, **kwargs +) -> None: + """Add argument to parser only if it typically doesn't exist. + + Args: + parser: Parser to add argument to + *args: Positional arguments (flags) + **kwargs: Keyword arguments + """ + try: + parser.add_argument(*args, **kwargs) + except argparse.ArgumentError: + # Argument already exists, so we can skip it + return + + +def add_common_args( + parser: argparse.ArgumentParser, suppress_defaults: bool = False +) -> None: """Add common arguments to a parser. Args: parser: Parser to add arguments to + suppress_defaults: If True, do not set default values + (let parent parser handle it) """ config = load_config() + default_base = argparse.SUPPRESS if suppress_defaults else None - parser.add_argument( + _add_argument_if_not_exists( + parser, "--service-account", "--service_account", dest="service_account", - default=config.get("service_account"), + default=default_base or config.get("service_account"), help="Path to service account JSON file", ) - parser.add_argument( + _add_argument_if_not_exists( + parser, "--output", choices=["json", "text"], - default="json", + default=default_base or "json", help="Output format", ) -def add_chronicle_args(parser: argparse.ArgumentParser) -> None: +def add_chronicle_args( + parser: argparse.ArgumentParser, suppress_defaults: bool = False +) -> None: """Add Chronicle-specific arguments to a parser. Args: parser: Parser to add arguments to + suppress_defaults: If True, set default values to argparse.SUPPRESS """ config = load_config() + default_base = argparse.SUPPRESS if suppress_defaults else None - parser.add_argument( + _add_argument_if_not_exists( + parser, "--customer-id", "--customer_id", dest="customer_id", - default=config.get("customer_id"), + default=default_base or config.get("customer_id"), help="Chronicle instance ID", ) - parser.add_argument( + _add_argument_if_not_exists( + parser, "--project-id", "--project_id", dest="project_id", - default=config.get("project_id"), + default=default_base or config.get("project_id"), help="GCP project ID", ) - parser.add_argument( + _add_argument_if_not_exists( + parser, "--region", - default=config.get("region", "us"), + default=default_base or config.get("region", "us"), help="Chronicle API region", ) - parser.add_argument( + _add_argument_if_not_exists( + parser, "--api-version", "--api_version", dest="api_version", choices=["v1", "v1beta", "v1alpha"], - default=config.get("api_version", "v1alpha"), + default=default_base or config.get("api_version", "v1alpha"), help=( "Default API version for Chronicle requests " "(default: v1alpha)" ), diff --git a/src/secops/cli/utils/config_utils.py b/src/secops/cli/utils/config_utils.py index 91bd4bd3..67941a70 100644 --- a/src/secops/cli/utils/config_utils.py +++ b/src/secops/cli/utils/config_utils.py @@ -18,43 +18,105 @@ import sys from typing import Any -from secops.cli.constants import CONFIG_DIR, CONFIG_FILE +from secops.cli.constants import ( + CONFIG_DIR, + CONFIG_FILE, + LOCAL_CONFIG_DIR, + LOCAL_CONFIG_FILE, +) -def load_config() -> dict[str, Any]: - """Load configuration from config file. - - Returns: - Dictionary containing configuration values - """ - if not CONFIG_FILE.exists(): +def _load_json_file(path) -> dict[str, Any]: + """Helper to safely load JSON file.""" + if not path.exists(): return {} - try: - with open(CONFIG_FILE, encoding="utf-8") as f: + with open(path, encoding="utf-8") as f: return json.load(f) except (json.JSONDecodeError, OSError): print( - f"Warning: Failed to load config from {CONFIG_FILE}", + f"Warning: Failed to load config from {path}", file=sys.stderr, ) return {} -def save_config(config: dict[str, Any]) -> None: +def load_config(scope: str = "merged") -> dict[str, Any]: + """Load configuration from config files. + + Args: + scope: Which config to load - "global", "local", or "merged". + "global": Only global config (~/.secops/config.json) + "local": Only local config (./.secops/config.json) + "merged": Merge both configs (local overrides global) + Default is "merged". + + Returns: + Dictionary containing configuration values + + Raises: + ValueError: If scope is not one of "global", "local", "merged" + """ + if scope == "global": + return _load_json_file(CONFIG_FILE) + elif scope == "local": + return _load_json_file(LOCAL_CONFIG_FILE) + elif scope == "merged": + global_config = _load_json_file(CONFIG_FILE) + local_config = _load_json_file(LOCAL_CONFIG_FILE) + return {**global_config, **local_config} + else: + raise ValueError( + f"Invalid scope '{scope}'. Must be 'global', 'local', or " + f"'merged'." + ) + + +def save_config(config: dict[str, Any], local: bool = False) -> None: """Save configuration to config file. Args: config: Dictionary containing configuration values to save + local: If True, save to local config file (./.secops/config.json) + If False, save to global config file (~/.secops/config.json) """ + target_dir = LOCAL_CONFIG_DIR if local else CONFIG_DIR + target_file = LOCAL_CONFIG_FILE if local else CONFIG_FILE + # Create config directory if it doesn't exist - CONFIG_DIR.mkdir(exist_ok=True) + target_dir.mkdir(exist_ok=True) try: - with open(CONFIG_FILE, "w", encoding="utf-8") as f: + # Load existing config to preserve other values if we are doing a + # partial update? + # For now, we assume 'config' contains the full desired state for + # that scope OR we should probably read the existing file and update it. + # But 'config set' usually reads existing config, updates it, and passes + # it here. + # However, load_config() returns MERGED config. + # If we save that to local, we might copy global values to local. + # That's a risk. + # Ideally, we should only save the *changes* + # or specific values to local?? + # But commonly 'save_config' takes the whole dict. + # Let's assume the caller handles what to save, but for 'set', + # we need to be careful. + + # ACTUALLY, to avoid polluting local with global values, + # we should probably read the TARGET file specifically before saving + # if we want to merge. + # specific implementation details depend on how 'set' is implemented. + # For this refactor, let's keep it simple: overwrite the file with + # provided config. + # BUT 'set' command loads MERGED config. + # FAULKNER: We need to filter? Or just accept that 'set' might + # duplicate? + # Let's stick to simple overwrite for now, but 'set' needs to check. + + with open(target_file, "w", encoding="utf-8") as f: json.dump(config, f, indent=2) except OSError as e: print( - f"Error: Failed to save config to {CONFIG_FILE}: {e}", + f"Error: Failed to save config to {target_file}: {e}", file=sys.stderr, )