Skip to content
Open
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
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 @@ -188,9 +188,30 @@ def build_parser() -> argparse.ArgumentParser:
setup_dashboard_query_command(subparsers)
setup_watchlist_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 parser._actions:
if isinstance(action, argparse._SubParsersAction) and action.choices:
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
17 changes: 15 additions & 2 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 @@ -64,6 +69,12 @@ def handle_config_set_command(args, chronicle=None):
args: Command line arguments
chronicle: Not used for this command
"""
# If saving to local, we should probably start with empty or current local config
# to avoid polluting it with global values.
# But for now, we follow the pattern of loading merged config and updating it.
# Optimization: If --local, maybe we should only load local config?
# But load_config() returns merged.
# Let's use load_config() but be aware we might save merged values to local.
config = load_config()

# Update config with new values
Expand All @@ -84,13 +95,15 @@ 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,)



def handle_config_view_command(args, chronicle=None):
"""Handle config view command.

Expand Down
14 changes: 14 additions & 0 deletions src/secops/cli/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,19 @@
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"

import os

# 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"
57 changes: 43 additions & 14 deletions src/secops/cli/utils/common_args.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,63 +18,92 @@

from secops.cli.utils.config_utils import load_config

def _add_argument_if_not_exists(
parser: argparse.ArgumentParser, *args: str, **kwargs
) -> None:
"""Add argument to parser only if it typically doesn't exist.

def add_common_args(parser: argparse.ArgumentParser) -> None:
Args:
parser: Parser to add argument to
*args: Positional arguments (flags)
**kwargs: Keyword arguments
"""
for option_string in args:
if option_string in parser._option_string_actions:
return
parser.add_argument(*args, **kwargs)


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
71 changes: 56 additions & 15 deletions src/secops/cli/utils/config_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,43 +18,84 @@
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() -> dict[str, Any]:
"""Load configuration from config files.

Merges global config (~/.secops/config.json) with
local config (./.secops/config.json).
Local config takes precedence.

Returns:
Dictionary containing configuration values
"""
global_config = _load_json_file(CONFIG_FILE)
local_config = _load_json_file(LOCAL_CONFIG_FILE)

# Merge: Local overrides Global
return {**global_config, **local_config}


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,
)
Loading