Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
49 changes: 45 additions & 4 deletions CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
13 changes: 13 additions & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -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
15 changes: 4 additions & 11 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,11 +1,4 @@
pytest
pytest-cov
build
black
packaging
pathspec
protobuf
pylint
twine
python-dotenv
requests
google-auth>=2.0.0
google-auth-httplib2>=0.1.0
google-api-python-client>=2.0.0
requests>=2.25.1
21 changes: 21 additions & 0 deletions src/secops/cli/cli_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
23 changes: 18 additions & 5 deletions src/secops/cli/commands/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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:
Expand All @@ -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,)
Expand All @@ -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}")

Expand Down
13 changes: 13 additions & 0 deletions src/secops/cli/constants.py
Original file line number Diff line number Diff line change
@@ -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"
60 changes: 46 additions & 14 deletions src/secops/cli/utils/common_args.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
),
Expand Down
Loading
Loading