diff --git a/CLAUDE.md b/CLAUDE.md index aa469c8..b75130d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,6 +51,9 @@ Commands are organized by resource type: - `commands/network.py`: Security zone management - `commands/security.py`: Security rule management - `commands/deployment.py`: Bandwidth allocation management +- `commands/local.py`: Device configuration version listing and XML download +- `commands/operations.py`: Device operations (route-table, fib-table, dns-proxy, interfaces, device-rules, bgp-export, logging-status) with sync/async job support +- `commands/incidents.py`: Security incident search and detail with filtering and JSON output ### Key Components @@ -179,7 +182,7 @@ Always refer to the appropriate style guide when writing or modifying code to en ## Important Notes -- The SDK version requires `pan-scm-sdk>=0.12.2` - verify compatibility when updating +- The SDK version requires `pan-scm-sdk>=0.13.0` - verify compatibility when updating - Mock mode allows full testing without API credentials - Bulk operations use YAML files - see `examples/` for formats - All commands support `--mock` flag for testing diff --git a/docs/about/release-notes.md b/docs/about/release-notes.md index 49ab9fe..0188501 100644 --- a/docs/about/release-notes.md +++ b/docs/about/release-notes.md @@ -2,6 +2,25 @@ This page contains the release history of the Strata Cloud Manager CLI, with the most recent releases at the top. +## Version 1.3.0 + +**Released:** April 2026 + +### Added + +- **SDK Upgrade**: Upgraded `pan-scm-sdk` from 0.12.x to 0.13.0, bringing Operations API, Incidents API, and region support. +- **Local Config Commands**: New `scm local list` and `scm local download` commands for listing device configuration versions and downloading configs as XML. +- **Device Operations Commands**: New `scm operations` command group with 7 operation types (route-table, fib-table, dns-proxy, interfaces, device-rules, bgp-export, logging-status) plus job status tracking. Commands default to sync mode (poll to completion) with `--async` flag for fire-and-forget. +- **Incidents Commands**: New `scm incidents list` and `scm incidents show` commands for searching and viewing security incidents from the Unified Incident Framework. Supports filtering by status, severity, and product. `--json` flag for automation. +- **Region Support**: Added `--region` option to `scm context create` for per-context region configuration. Added global `--region` flag to override region per-invocation. Region precedence: global flag > context > "americas" default. +- **Timeout Handling**: Added `GatewayTimeoutError` handling with recovery instructions in error messages. + +### Changed + +- **SDK Dependency**: Updated from `^0.12.2` to `^0.13.0`. +- **Context Schema**: Context YAML files now support an optional `region` field. Existing contexts without this field default to "americas". +- **Type Safety**: Added `type: ignore[arg-type]` annotations for SDK model constructors affected by stricter type stubs in 0.13.0. + ## Version 1.2.0 **Released:** March 31, 2026 diff --git a/docs/cli/incidents/index.md b/docs/cli/incidents/index.md new file mode 100644 index 0000000..e9afa7b --- /dev/null +++ b/docs/cli/incidents/index.md @@ -0,0 +1,51 @@ +# Incidents + +Search and view security incidents from the SCM Unified Incident Framework. + +## Commands + +### List Incidents + +```bash +# List all incidents +scm incidents list + +# Filter by status and severity +scm incidents list --status open --severity high + +# Filter by product +scm incidents list --product "Prisma Access" + +# JSON output for automation +scm incidents list --json +``` + +**Options:** + +| Option | Required | Description | +|--------|----------|-------------| +| `--status`, `-s` | No | Filter: open, closed, in_progress | +| `--severity` | No | Filter: critical, high, medium, low, informational | +| `--product`, `-p` | No | Filter by product name | +| `--json`, `-j` | No | Output as JSON | + +### Show Incident Detail + +```bash +scm incidents show INC-2026-04-001 +scm incidents show INC-2026-04-001 --json +``` + +Shows full incident detail including alerts and remediation steps. Use `--json` for the complete structured output. + +**Arguments:** + +| Argument | Required | Description | +|----------|----------|-------------| +| `incident_id` | Yes | Incident ID to show | + +**Options:** + +| Option | Required | Description | +|--------|----------|-------------| +| `--json`, `-j` | No | Output as JSON | diff --git a/docs/cli/local/index.md b/docs/cli/local/index.md new file mode 100644 index 0000000..670b7df --- /dev/null +++ b/docs/cli/local/index.md @@ -0,0 +1,39 @@ +# Local Config + +Manage local device configuration versions and downloads. + +## Commands + +### List Config Versions + +```bash +scm local list --device fw-01 +``` + +Lists available configuration versions for a device, showing version number, date, author, and description. + +**Options:** + +| Option | Required | Description | +|--------|----------|-------------| +| `--device`, `-d` | Yes | Device name | + +### Download Config + +```bash +# Output to stdout +scm local download --device fw-01 --version 42 + +# Save to file +scm local download --device fw-01 --version 42 --output config.xml +``` + +Downloads a specific configuration version as XML. Outputs to stdout by default; use `--output` to write to a file. + +**Options:** + +| Option | Required | Description | +|--------|----------|-------------| +| `--device`, `-d` | Yes | Device name | +| `--version`, `-v` | Yes | Config version number | +| `--output`, `-o` | No | Output file path (default: stdout) | diff --git a/docs/cli/operations/index.md b/docs/cli/operations/index.md new file mode 100644 index 0000000..c239e73 --- /dev/null +++ b/docs/cli/operations/index.md @@ -0,0 +1,70 @@ +# Device Operations + +Dispatch and monitor asynchronous device jobs for network diagnostics and status checks. + +## Commands + +All operation commands support: + +| Option | Default | Description | +|--------|---------|-------------| +| `--device`, `-d` | Required | Device name | +| `--async` | `False` | Return job ID without waiting | +| `--timeout`, `-t` | `300` | Sync polling timeout in seconds | + +### Route Table + +```bash +scm operations route-table --device fw-01 +scm operations route-table --device fw-01 --async +``` + +### FIB Table + +```bash +scm operations fib-table --device fw-01 +``` + +### DNS Proxy + +```bash +scm operations dns-proxy --device fw-01 +``` + +### Network Interfaces + +```bash +scm operations interfaces --device fw-01 +``` + +### Device Rules + +```bash +scm operations device-rules --device fw-01 +``` + +### BGP Export + +```bash +scm operations bgp-export --device fw-01 +``` + +### Logging Status + +```bash +scm operations logging-status --device fw-01 +``` + +### Job Status + +Check on an async job: + +```bash +scm operations status --job-id abc-123 +``` + +## Sync vs Async + +By default, commands block and poll until the operation completes, then display results as a table. Use `--async` to get the job ID immediately and check later with `scm operations status`. + +If sync polling exceeds `--timeout`, the CLI reports the job ID and last known state for manual follow-up. diff --git a/docs/superpowers/plans/2026-04-08-hardening-pass.md b/docs/superpowers/plans/2026-04-08-hardening-pass.md new file mode 100644 index 0000000..5e2c576 --- /dev/null +++ b/docs/superpowers/plans/2026-04-08-hardening-pass.md @@ -0,0 +1,782 @@ +# Hardening Pass Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fix credential file permissions, standardize error handling, correct documentation mismatches, and normalize style in context module. + +**Architecture:** Four independent categories (security, error handling, docs, style) applied as sequential commits on a single branch. All changes are small, isolated edits to existing files with no new modules or dependencies. + +**Tech Stack:** Python 3.10+, Typer, PyYAML, MkDocs Material + +--- + +## File Map + +| File | Action | Responsibility | +|------|--------|----------------| +| `src/scm_cli/utils/context.py` | Modify | Add chmod on write, permission warning on read | +| `src/scm_cli/utils/sdk_client.py` | Modify | Replace print() with typer.echo() in __init__ error block | +| `src/scm_cli/commands/context.py` | Modify | Narrow exception types, fix separators | +| `tests/test_context_utils.py` | Create | Tests for permission enforcement and warning | +| `docs/cli/deployment/service-connection.md` | Modify | Fix --subnets format, add folder scope note | +| `docs/cli/security/rules.md` | Modify | Add Move Security Rule section | +| `docs/cli/security/app-override-rule.md` | Modify | Add Move App Override Rule section | +| `docs/cli/security/authentication-rule.md` | Modify | Add Move Authentication Rule section | +| `docs/cli/security/decryption-rule.md` | Modify | Add Move Decryption Rule section | +| `docs/cli/objects/schedule.md` | Modify | Fix YAML key names (days_monday -> monday) | + +--- + +### Task 1: Security — Test credential file permissions + +**Files:** +- Create: `tests/test_context_utils.py` + +- [ ] **Step 1: Write failing tests for permission enforcement** + +```python +"""Tests for context utility functions — file permission enforcement.""" + +import os +import stat +import sys + +import pytest +import yaml + +from scm_cli.utils.context import ( + create_context, + ensure_context_dir, + get_context_config, +) + + +class TestFilePermissions: + """Tests for credential file permission enforcement.""" + + @pytest.fixture(autouse=True) + def setup_context_dir(self, tmp_path, monkeypatch): + """Redirect context paths to tmp_path for isolation.""" + self.ctx_dir = str(tmp_path / "contexts") + self.current_file = str(tmp_path / "current-context") + monkeypatch.setattr("scm_cli.utils.context.CONTEXT_DIR", self.ctx_dir) + monkeypatch.setattr("scm_cli.utils.context.CURRENT_CONTEXT_FILE", self.current_file) + + @pytest.mark.skipif(sys.platform == "win32", reason="POSIX permissions only") + def test_ensure_context_dir_sets_0700(self): + """Context directory should be created with 0700 permissions.""" + ensure_context_dir() + mode = stat.S_IMODE(os.stat(self.ctx_dir).st_mode) + assert mode == 0o700 + + @pytest.mark.skipif(sys.platform == "win32", reason="POSIX permissions only") + def test_create_context_sets_0600(self): + """Created context files should have 0600 permissions.""" + create_context( + context_name="test-ctx", + client_id="cid", + client_secret="csecret", + tsg_id="tsg", + ) + ctx_file = os.path.join(self.ctx_dir, "test-ctx.yaml") + mode = stat.S_IMODE(os.stat(ctx_file).st_mode) + assert mode == 0o600 + + @pytest.mark.skipif(sys.platform == "win32", reason="POSIX permissions only") + def test_get_context_config_warns_on_open_permissions(self, capsys): + """Reading a context file with overly permissive perms should warn.""" + # Create context then loosen permissions + create_context( + context_name="loose-ctx", + client_id="cid", + client_secret="csecret", + tsg_id="tsg", + ) + ctx_file = os.path.join(self.ctx_dir, "loose-ctx.yaml") + os.chmod(ctx_file, 0o644) + + get_context_config("loose-ctx") + + captured = capsys.readouterr() + assert "insecure permissions" in captured.err + assert "0644" in captured.err + + @pytest.mark.skipif(sys.platform == "win32", reason="POSIX permissions only") + def test_get_context_config_no_warning_on_correct_permissions(self, capsys): + """Reading a context file with 0600 perms should not warn.""" + create_context( + context_name="secure-ctx", + client_id="cid", + client_secret="csecret", + tsg_id="tsg", + ) + + get_context_config("secure-ctx") + + captured = capsys.readouterr() + assert "insecure permissions" not in captured.err + + def test_create_context_writes_valid_yaml(self): + """Created context file should contain valid YAML with correct fields.""" + create_context( + context_name="yaml-ctx", + client_id="cid", + client_secret="csecret", + tsg_id="tsg", + log_level="DEBUG", + ) + ctx_file = os.path.join(self.ctx_dir, "yaml-ctx.yaml") + with open(ctx_file) as f: + data = yaml.safe_load(f) + assert data["client_id"] == "cid" + assert data["client_secret"] == "csecret" + assert data["tsg_id"] == "tsg" + assert data["log_level"] == "DEBUG" +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pytest tests/test_context_utils.py -v` +Expected: FAIL — `test_ensure_context_dir_sets_0700` and `test_create_context_sets_0600` fail (default perms), `test_get_context_config_warns_on_open_permissions` fails (no warning printed). `test_create_context_writes_valid_yaml` and `test_get_context_config_no_warning_on_correct_permissions` may pass already. + +--- + +### Task 2: Security — Implement credential file permissions + +**Files:** +- Modify: `src/scm_cli/utils/context.py` + +- [ ] **Step 1: Add permission enforcement to `ensure_context_dir()`** + +In `src/scm_cli/utils/context.py`, replace: + +```python +def ensure_context_dir() -> None: + """Ensure the context directory exists.""" + Path(CONTEXT_DIR).mkdir(parents=True, exist_ok=True) + Path(CURRENT_CONTEXT_FILE).parent.mkdir(parents=True, exist_ok=True) +``` + +with: + +```python +def ensure_context_dir() -> None: + """Ensure the context directory exists with restrictive permissions.""" + Path(CONTEXT_DIR).mkdir(parents=True, exist_ok=True) + Path(CURRENT_CONTEXT_FILE).parent.mkdir(parents=True, exist_ok=True) + # Restrict directory permissions to owner-only on POSIX systems + if os.name != "nt": + os.chmod(CONTEXT_DIR, 0o700) +``` + +- [ ] **Step 2: Add permission enforcement to `create_context()`** + +In `src/scm_cli/utils/context.py`, replace: + +```python + with open(context_file, "w") as f: + yaml.dump(config, f, default_flow_style=False) +``` + +(the one inside `create_context`) with: + +```python + with open(context_file, "w") as f: + yaml.dump(config, f, default_flow_style=False) + + # Restrict file permissions to owner-only on POSIX systems + if os.name != "nt": + os.chmod(context_file, 0o600) +``` + +- [ ] **Step 3: Add permission warning to `get_context_config()`** + +In `src/scm_cli/utils/context.py`, add `import stat` and `import sys` to the imports at the top: + +```python +import os +import stat +import sys +from pathlib import Path +from typing import Any +``` + +Then replace: + +```python + with open(context_file) as f: + config = yaml.safe_load(f) + + return config or {} +``` + +(the block inside `get_context_config`) with: + +```python + # Warn if file permissions are too open on POSIX systems + if os.name != "nt": + file_mode = stat.S_IMODE(os.stat(context_file).st_mode) + if file_mode & 0o077: + print( + f"Warning: {context_file} has insecure permissions ({oct(file_mode)[2:]}), expected 0600", + file=sys.stderr, + ) + + with open(context_file) as f: + config = yaml.safe_load(f) + + return config or {} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pytest tests/test_context_utils.py -v` +Expected: All 6 tests PASS. + +- [ ] **Step 5: Run full test suite** + +Run: `pytest -v` +Expected: No regressions. All previously passing tests still pass. + +- [ ] **Step 6: Commit** + +```bash +git add src/scm_cli/utils/context.py tests/test_context_utils.py +git commit -m "fix: enforce 0600 perms on context credential files" +``` + +--- + +### Task 3: Error handling — `sdk_client.py` print→typer.echo + +**Files:** +- Modify: `src/scm_cli/utils/sdk_client.py:127-164` + +- [ ] **Step 1: Add typer import** + +In `src/scm_cli/utils/sdk_client.py`, add `import typer` to the imports section (after line 16, near the other imports): + +```python +import typer +``` + +- [ ] **Step 2: Replace print() calls with typer.echo()** + +Replace the entire `except (APIError, InvalidClientError) as e:` block (lines 127-164) with: + +```python + except (APIError, InvalidClientError) as e: + # Handle authentication failures gracefully + error_msg = str(e) + if "invalid_client" in error_msg or "Client authentication failed" in error_msg: + typer.echo( + "\n❌ Authentication failed: Invalid client credentials", + err=True, + ) + typer.echo( + f"\nCurrent context: {current_context or 'None set'}", + err=True, + ) + typer.echo( + f"Client ID: {credentials.get('client_id', 'Not set')}", + err=True, + ) + typer.echo(f"TSG ID: {credentials.get('tsg_id', 'Not set')}", err=True) + typer.echo("\nTo fix this issue:", err=True) + typer.echo( + " 1. Update context: scm context create --client-id --client-secret --tsg-id ", + err=True, + ) + typer.echo(" 2. Switch context: scm context use ", err=True) + typer.echo( + " 3. Use environment variables: SCM_CLIENT_ID, SCM_CLIENT_SECRET, SCM_TSG_ID", + err=True, + ) + raise SystemExit(1) from e + else: + typer.echo( + f"\n❌ Failed to initialize SDK client: {error_msg}", + err=True, + ) + raise SystemExit(1) from e +``` + +- [ ] **Step 3: Run full test suite** + +Run: `pytest -v` +Expected: No regressions. + +- [ ] **Step 4: Commit** + +```bash +git add src/scm_cli/utils/sdk_client.py +git commit -m "fix: replace print() with typer.echo() in sdk_client init" +``` + +--- + +### Task 4: Error handling — Narrow exception types in context modules + +**Files:** +- Modify: `src/scm_cli/utils/context.py:54-58` +- Modify: `src/scm_cli/commands/context.py:54,284` + +- [ ] **Step 1: Fix `utils/context.py` `get_current_context()`** + +In `src/scm_cli/utils/context.py`, replace: + +```python + except Exception as e: + print(f"Error reading current context: {e}") + return None +``` + +with: + +```python + except OSError as e: + print(f"Error reading current context: {e}", file=sys.stderr) + return None +``` + +- [ ] **Step 2: Fix `commands/context.py` `list_command()`** + +In `src/scm_cli/commands/context.py`, replace: + +```python + except Exception: + masked_id = "[error reading config]" +``` + +with: + +```python + except (ValueError, OSError): + masked_id = "[error reading config]" +``` + +- [ ] **Step 3: Fix `commands/context.py` `current_command()`** + +In `src/scm_cli/commands/context.py`, replace: + +```python + except Exception: + console.print("[red]Error reading context configuration[/red]") +``` + +with: + +```python + except (ValueError, OSError): + console.print("[red]Error reading context configuration[/red]") +``` + +- [ ] **Step 4: Run context tests** + +Run: `pytest tests/test_context_commands.py tests/test_context_utils.py -v` +Expected: All PASS. + +- [ ] **Step 5: Run full test suite** + +Run: `pytest -v` +Expected: No regressions. + +- [ ] **Step 6: Commit** + +```bash +git add src/scm_cli/utils/context.py src/scm_cli/commands/context.py +git commit -m "fix: narrow bare except clauses to specific exception types" +``` + +--- + +### Task 5: Docs — Fix service connection `--subnets` format and folder scope + +**Files:** +- Modify: `docs/cli/deployment/service-connection.md` + +- [ ] **Step 1: Fix options table** + +In `docs/cli/deployment/service-connection.md`, replace: + +``` +| `--subnets LIST` | Comma-separated list of subnets | No | +``` + +with: + +``` +| `--subnets TEXT` | Subnets (repeat flag for multiple) | No | +``` + +- [ ] **Step 2: Fix CLI example on line 54** + +Replace: + +```bash + --subnets "10.1.0.0/24,10.1.1.0/24" +``` + +with: + +```bash + --subnets 10.1.0.0/24 --subnets 10.1.1.0/24 +``` + +- [ ] **Step 3: Add folder scope note after options table** + +After line 44 (`\* One of --folder, --snippet, or --device is required.` — or the end of the options table if that line doesn't exist), add: + +```markdown + +> **Note:** Service connections are always scoped to the "Service Connections" folder. This is enforced by the CLI to match SCM API requirements. The `--folder`, `--snippet`, and `--device` options are not applicable for this resource type. +``` + +- [ ] **Step 4: Build docs to verify no rendering errors** + +Run: `make docs-build` +Expected: Build succeeds with no errors. + +- [ ] **Step 5: Commit** + +```bash +git add docs/cli/deployment/service-connection.md +git commit -m "docs: fix --subnets format, add folder scope note for service connections" +``` + +--- + +### Task 6: Docs — Add move command documentation to security rule docs + +**Files:** +- Modify: `docs/cli/security/rules.md` (append before Best Practices, line 382) +- Modify: `docs/cli/security/app-override-rule.md` (append before Best Practices, line 335) +- Modify: `docs/cli/security/authentication-rule.md` (append before Best Practices, line 340) +- Modify: `docs/cli/security/decryption-rule.md` (append before Best Practices, line 339) + +- [ ] **Step 1: Add Move section to `rules.md`** + +In `docs/cli/security/rules.md`, insert the following **before** the `## Best Practices` line (line 382): + +```markdown +## Move Security Rule + +Reposition a security rule within the rulebase. + +### Syntax + +```bash +scm move security rule [OPTIONS] +``` + +### Options + +| Option | Description | Required | +| --- | --- | --- | +| `--name TEXT` | Name of the rule to move | Yes | +| `--folder TEXT` | Folder containing the rule | Yes\* | +| `--snippet TEXT` | Snippet containing the rule | No\* | +| `--device TEXT` | Device containing the rule | No\* | +| `--destination TEXT` | Where to move: top, bottom, before, after | Yes | +| `--rulebase TEXT` | Rulebase: pre or post (default: pre) | No | +| `--destination-rule TEXT` | UUID of reference rule for before/after | When using before/after | + +\* One of --folder, --snippet, or --device is required. + +### Examples + +#### Move a Rule to the Top + +```bash +$ scm move security rule \ + --folder Texas \ + --name "Allow Web" \ + --destination top \ + --rulebase pre +Moved security rule 'Allow Web' to top in folder 'Texas' rulebase 'pre' +``` + +#### Move a Rule After Another Rule + +```bash +$ scm move security rule \ + --folder Texas \ + --name "Allow Web" \ + --destination after \ + --destination-rule 12345678-1234-1234-1234-123456789012 +Moved security rule 'Allow Web' to after in folder 'Texas' rulebase 'pre' +``` + +``` + +- [ ] **Step 2: Add Move section to `app-override-rule.md`** + +In `docs/cli/security/app-override-rule.md`, insert the following **before** the `## Best Practices` line (line 335): + +```markdown +## Move App Override Rule + +Reposition an app override rule within the rulebase. + +### Syntax + +```bash +scm move security app-override-rule [OPTIONS] +``` + +### Options + +| Option | Description | Required | +| --- | --- | --- | +| `--name TEXT` | Name of the rule to move | Yes | +| `--folder TEXT` | Folder containing the rule | Yes\* | +| `--snippet TEXT` | Snippet containing the rule | No\* | +| `--device TEXT` | Device containing the rule | No\* | +| `--destination TEXT` | Where to move: top, bottom, before, after | Yes | +| `--rulebase TEXT` | Rulebase: pre or post (default: pre) | No | +| `--destination-rule TEXT` | UUID of reference rule for before/after | When using before/after | + +\* One of --folder, --snippet, or --device is required. + +### Examples + +#### Move a Rule to the Top + +```bash +$ scm move security app-override-rule \ + --folder Texas \ + --name override-https \ + --destination top \ + --rulebase pre +Moved app override rule 'override-https' to top in folder 'Texas' rulebase 'pre' +``` + +``` + +- [ ] **Step 3: Add Move section to `authentication-rule.md`** + +In `docs/cli/security/authentication-rule.md`, insert the following **before** the `## Best Practices` line (line 340): + +```markdown +## Move Authentication Rule + +Reposition an authentication rule within the rulebase. + +### Syntax + +```bash +scm move security authentication-rule [OPTIONS] +``` + +### Options + +| Option | Description | Required | +| --- | --- | --- | +| `--name TEXT` | Name of the rule to move | Yes | +| `--folder TEXT` | Folder containing the rule | Yes\* | +| `--snippet TEXT` | Snippet containing the rule | No\* | +| `--device TEXT` | Device containing the rule | No\* | +| `--destination TEXT` | Where to move: top, bottom, before, after | Yes | +| `--rulebase TEXT` | Rulebase: pre or post (default: pre) | No | +| `--destination-rule TEXT` | UUID of reference rule for before/after | When using before/after | + +\* One of --folder, --snippet, or --device is required. + +### Examples + +#### Move a Rule to the Bottom + +```bash +$ scm move security authentication-rule \ + --folder Texas \ + --name auth-rule \ + --destination bottom \ + --rulebase pre +Moved authentication rule 'auth-rule' to bottom in folder 'Texas' rulebase 'pre' +``` + +``` + +- [ ] **Step 4: Add Move section to `decryption-rule.md`** + +In `docs/cli/security/decryption-rule.md`, insert the following **before** the `## Best Practices` line (line 339): + +```markdown +## Move Decryption Rule + +Reposition a decryption rule within the rulebase. + +### Syntax + +```bash +scm move security decryption-rule [OPTIONS] +``` + +### Options + +| Option | Description | Required | +| --- | --- | --- | +| `--name TEXT` | Name of the rule to move | Yes | +| `--folder TEXT` | Folder containing the rule | Yes\* | +| `--snippet TEXT` | Snippet containing the rule | No\* | +| `--device TEXT` | Device containing the rule | No\* | +| `--destination TEXT` | Where to move: top, bottom, before, after | Yes | +| `--rulebase TEXT` | Rulebase: pre or post (default: pre) | No | +| `--destination-rule TEXT` | UUID of reference rule for before/after | When using before/after | + +\* One of --folder, --snippet, or --device is required. + +### Examples + +#### Move a Rule to the Top + +```bash +$ scm move security decryption-rule \ + --folder Texas \ + --name decrypt-rule \ + --destination top \ + --rulebase pre +Moved decryption rule 'decrypt-rule' to top in folder 'Texas' rulebase 'pre' +``` + +``` + +- [ ] **Step 5: Build docs to verify no rendering errors** + +Run: `make docs-build` +Expected: Build succeeds with no errors. + +- [ ] **Step 6: Commit** + +```bash +git add docs/cli/security/rules.md docs/cli/security/app-override-rule.md docs/cli/security/authentication-rule.md docs/cli/security/decryption-rule.md +git commit -m "docs: add move command documentation for all security rule types" +``` + +--- + +### Task 7: Docs — Fix schedule YAML key names + +**Files:** +- Modify: `docs/cli/objects/schedule.md:137-141` + +- [ ] **Step 1: Fix YAML keys** + +In `docs/cli/objects/schedule.md`, replace: + +```yaml + days_monday: "09:00-17:00" + days_tuesday: "09:00-17:00" + days_wednesday: "09:00-17:00" + days_thursday: "09:00-17:00" + days_friday: "09:00-12:00" +``` + +with: + +```yaml + monday: "09:00-17:00" + tuesday: "09:00-17:00" + wednesday: "09:00-17:00" + thursday: "09:00-17:00" + friday: "09:00-12:00" +``` + +- [ ] **Step 2: Build docs to verify** + +Run: `make docs-build` +Expected: Build succeeds. + +- [ ] **Step 3: Commit** + +```bash +git add docs/cli/objects/schedule.md +git commit -m "docs: fix schedule YAML keys (days_monday -> monday)" +``` + +--- + +### Task 8: Style — Standardize context.py section separators + +**Files:** +- Modify: `src/scm_cli/commands/context.py` + +- [ ] **Step 1: Replace all section separators** + +In `src/scm_cli/commands/context.py`, replace every occurrence of the non-standard separator: + +```python +# ############################################################################ +``` + +with the project-standard 191-character separator: + +```python +# ============================================================================================================================================================================================= +``` + +There are 14 lines to replace (7 separator blocks, each with 2 identical lines — the top and bottom of each block). The blocks are at approximately lines: +- 24-25 (list command) +- 66-67 (show command) +- 112-113 (create command) +- 202-203 (use command) +- 234-235 (delete command) +- 268-269 (current command) +- 292-293 (test command) + +Each 3-line block looks like: + +```python +# ############################################################################ +# +# ############################################################################ +``` + +Replace each with: + +```python +# ============================================================================================================================================================================================= +# +# ============================================================================================================================================================================================= +``` + +The separator is 191 characters: `# ` followed by 189 `=` characters. + +- [ ] **Step 2: Run lint** + +Run: `make lint` +Expected: No new lint errors. + +- [ ] **Step 3: Commit** + +```bash +git add src/scm_cli/commands/context.py +git commit -m "style: standardize context.py section separators to 191-char format" +``` + +--- + +### Task 9: Final validation + +- [ ] **Step 1: Run full quality checks** + +Run: `make quality` +Expected: All checks pass (lint, format, mypy, tests). + +- [ ] **Step 2: Verify git log** + +Run: `git log --oneline -6` +Expected: 6 new commits in order: +1. `fix: enforce 0600 perms on context credential files` +2. `fix: replace print() with typer.echo() in sdk_client init` +3. `fix: narrow bare except clauses to specific exception types` +4. `docs: fix --subnets format, add folder scope note for service connections` +5. `docs: add move command documentation for all security rule types` +6. `docs: fix schedule YAML keys (days_monday -> monday)` +7. `style: standardize context.py section separators to 191-char format` + +--- + +## Unresolved Questions + +None — all design decisions were resolved during brainstorming. diff --git a/docs/superpowers/plans/2026-04-16-sdk-013-upgrade-plan.md b/docs/superpowers/plans/2026-04-16-sdk-013-upgrade-plan.md new file mode 100644 index 0000000..848c549 --- /dev/null +++ b/docs/superpowers/plans/2026-04-16-sdk-013-upgrade-plan.md @@ -0,0 +1,2387 @@ +# SDK 0.13.0 Upgrade Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Upgrade pan-scm-sdk to ^0.13.0 and add three new CLI command groups: `scm local`, `scm operations`, `scm incidents`. + +**Architecture:** Four sequential PRs. PR 1 (foundation) bumps the SDK, adds region support to contexts/client, and handles `JobTimeoutError`. PRs 2-4 each add one new command module with SDK client methods, mock mode, tests, and docs. Each PR is independently reviewable and shippable. + +**Tech Stack:** Python 3.10+, Typer, Rich, pan-scm-sdk 0.13.0, Pydantic v2, pytest, MkDocs Material + +**Design Spec:** `docs/superpowers/specs/2026-04-16-sdk-013-upgrade-design.md` +**GitHub Issues:** #212 (foundation), #213 (local), #214 (operations), #215 (incidents) + +--- + +## PR 1: Foundation — SDK Bump + Region Support (#212) + +### Task 1: Bump SDK dependency + +**Files:** +- Modify: `pyproject.toml:15` + +- [ ] **Step 1: Update pyproject.toml** + +Change the SDK version constraint: + +```toml +pan-scm-sdk = "^0.13.0" +``` + +- [ ] **Step 2: Update lock file** + +Run: `poetry update pan-scm-sdk` +Expected: Lock file updated, SDK 0.13.x resolved + +- [ ] **Step 3: Verify install** + +Run: `poetry show pan-scm-sdk` +Expected: Version 0.13.x shown + +- [ ] **Step 4: Run existing tests to confirm no regressions** + +Run: `poetry run pytest -x -q` +Expected: All existing tests pass + +- [ ] **Step 5: Commit** + +```bash +git add pyproject.toml poetry.lock +git commit -m "build: bump pan-scm-sdk to ^0.13.0" +``` + +--- + +### Task 2: Add region to context storage + +**Files:** +- Modify: `src/scm_cli/utils/context.py:90-126` +- Test: `tests/test_context_region.py` (create) + +- [ ] **Step 1: Write failing tests for region in context create** + +Create `tests/test_context_region.py`: + +```python +"""Tests for region support in context management.""" + +import os + +import yaml +import pytest + +from src.scm_cli.utils.context import create_context, get_context_config + + +class TestContextRegion: + """Test region field in context storage.""" + + def test_create_context_with_region(self, tmp_path, monkeypatch): + """Region is stored in context YAML when provided.""" + monkeypatch.setattr("src.scm_cli.utils.context.CONTEXT_DIR", str(tmp_path / "contexts")) + monkeypatch.setattr("src.scm_cli.utils.context.CURRENT_CONTEXT_FILE", str(tmp_path / "current-context")) + + create_context( + context_name="test-region", + client_id="cid", + client_secret="csec", + tsg_id="tsg", + region="europe", + ) + + context_file = tmp_path / "contexts" / "test-region.yaml" + assert context_file.exists() + with open(context_file) as f: + data = yaml.safe_load(f) + assert data["region"] == "europe" + + def test_create_context_default_region(self, tmp_path, monkeypatch): + """Region defaults to 'americas' when not provided.""" + monkeypatch.setattr("src.scm_cli.utils.context.CONTEXT_DIR", str(tmp_path / "contexts")) + monkeypatch.setattr("src.scm_cli.utils.context.CURRENT_CONTEXT_FILE", str(tmp_path / "current-context")) + + create_context( + context_name="test-default", + client_id="cid", + client_secret="csec", + tsg_id="tsg", + ) + + context_file = tmp_path / "contexts" / "test-default.yaml" + with open(context_file) as f: + data = yaml.safe_load(f) + assert data["region"] == "americas" + + def test_old_context_without_region_defaults(self, tmp_path, monkeypatch): + """Old context files without region field return 'americas'.""" + monkeypatch.setattr("src.scm_cli.utils.context.CONTEXT_DIR", str(tmp_path / "contexts")) + monkeypatch.setattr("src.scm_cli.utils.context.CURRENT_CONTEXT_FILE", str(tmp_path / "current-context")) + + os.makedirs(tmp_path / "contexts", exist_ok=True) + old_context = tmp_path / "contexts" / "legacy.yaml" + old_context.write_text("client_id: cid\nclient_secret: csec\ntsg_id: tsg\n") + + config = get_context_config("legacy") + assert config.get("region", "americas") == "americas" +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `poetry run pytest tests/test_context_region.py -v` +Expected: `test_create_context_with_region` FAILS (create_context doesn't accept `region`) + +- [ ] **Step 3: Update create_context to accept and store region** + +In `src/scm_cli/utils/context.py`, modify `create_context`: + +```python +def create_context( + context_name: str, + client_id: str = "", + client_secret: str = "", + tsg_id: str = "", + log_level: str = "INFO", + access_token: str | None = None, + region: str = "americas", +) -> None: + """Create or update a context configuration. + + Args: + ---- + context_name: Name of the context. + client_id: SCM client ID. + client_secret: SCM client secret. + tsg_id: Tenant Service Group ID. + log_level: Logging level (default: INFO). + access_token: Bearer token for direct auth (alternative to OAuth2). + region: SCM API region (default: americas). + + """ + ensure_context_dir() + + context_file = os.path.join(CONTEXT_DIR, f"{context_name}.yaml") + + config: dict[str, Any] = { + "log_level": log_level, + "region": region, + } + + if access_token: + config["access_token"] = access_token + else: + config["client_id"] = client_id + config["client_secret"] = client_secret + config["tsg_id"] = tsg_id + + with open(context_file, "w") as f: + yaml.dump(config, f, default_flow_style=False) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `poetry run pytest tests/test_context_region.py -v` +Expected: All 3 tests PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/scm_cli/utils/context.py tests/test_context_region.py +git commit -m "feat: add region field to context storage" +``` + +--- + +### Task 3: Add --region to context create command + +**Files:** +- Modify: `src/scm_cli/commands/context.py` (create_command function) +- Test: `tests/test_context_region.py` (extend) + +- [ ] **Step 1: Write failing test for --region CLI option** + +Add to `tests/test_context_region.py`: + +```python +from src.scm_cli.main import app as main_app +from src.scm_cli.commands import context as context_module + +main_app.add_typer(context_module.app, name="context") + + +class TestContextCreateRegionCLI: + """Test --region flag on context create command.""" + + def test_create_with_region_flag(self, runner, tmp_path, monkeypatch): + """--region flag stores region in context.""" + monkeypatch.setattr("src.scm_cli.utils.context.CONTEXT_DIR", str(tmp_path / "contexts")) + monkeypatch.setattr("src.scm_cli.utils.context.CURRENT_CONTEXT_FILE", str(tmp_path / "current-context")) + + result = runner.invoke(main_app, [ + "context", "create", "eu-prod", + "--client-id", "cid", + "--client-secret", "csec", + "--tsg-id", "tsg", + "--region", "europe", + "--no-set-current", + ]) + + assert result.exit_code == 0 + context_file = tmp_path / "contexts" / "eu-prod.yaml" + with open(context_file) as f: + data = yaml.safe_load(f) + assert data["region"] == "europe" + + def test_create_without_region_defaults_americas(self, runner, tmp_path, monkeypatch): + """Omitting --region stores 'americas' as default.""" + monkeypatch.setattr("src.scm_cli.utils.context.CONTEXT_DIR", str(tmp_path / "contexts")) + monkeypatch.setattr("src.scm_cli.utils.context.CURRENT_CONTEXT_FILE", str(tmp_path / "current-context")) + + result = runner.invoke(main_app, [ + "context", "create", "us-prod", + "--client-id", "cid", + "--client-secret", "csec", + "--tsg-id", "tsg", + "--no-set-current", + ]) + + assert result.exit_code == 0 + context_file = tmp_path / "contexts" / "us-prod.yaml" + with open(context_file) as f: + data = yaml.safe_load(f) + assert data["region"] == "americas" +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `poetry run pytest tests/test_context_region.py::TestContextCreateRegionCLI -v` +Expected: FAIL (no `--region` option exists yet) + +- [ ] **Step 3: Add --region option to context create command** + +In `src/scm_cli/commands/context.py`, add the `region` parameter to `create_command` (after the `set_current` parameter): + +```python + region: str = typer.Option( + "americas", + "--region", + "-r", + help="SCM API region (default: americas)", + ), +``` + +And pass it through to `create_context`: + +```python + create_context( + context_name=context_name, + client_id=client_id or "", + client_secret=client_secret or "", + tsg_id=tsg_id or "", + log_level=log_level, + access_token=access_token, + region=region, + ) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `poetry run pytest tests/test_context_region.py -v` +Expected: All 5 tests PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/scm_cli/commands/context.py tests/test_context_region.py +git commit -m "feat: add --region option to context create" +``` + +--- + +### Task 4: Add global --region flag and pass region to Scm client + +**Files:** +- Modify: `src/scm_cli/main.py:293-309` (callback) +- Modify: `src/scm_cli/utils/config.py:58-104` (get_auth_config) +- Test: `tests/test_context_region.py` (extend) + +- [ ] **Step 1: Write failing test for region in auth config** + +Add to `tests/test_context_region.py`: + +```python +from src.scm_cli.utils.config import get_auth_config + + +class TestRegionInAuthConfig: + """Test region flows through auth config.""" + + def test_auth_config_includes_region_from_context(self, tmp_path, monkeypatch): + """get_auth_config returns region from active context.""" + monkeypatch.setattr("src.scm_cli.utils.context.CONTEXT_DIR", str(tmp_path / "contexts")) + monkeypatch.setattr("src.scm_cli.utils.context.CURRENT_CONTEXT_FILE", str(tmp_path / "current-context")) + + os.makedirs(tmp_path / "contexts", exist_ok=True) + (tmp_path / "contexts" / "prod.yaml").write_text( + "client_id: cid\nclient_secret: csec\ntsg_id: tsg\nregion: europe\n" + ) + (tmp_path / "current-context").write_text("prod") + + # Re-init settings from patched context + import scm_cli.utils.config as config_mod + from scm_cli.utils.context import get_context_aware_settings + config_mod.settings = get_context_aware_settings() + + auth = get_auth_config() + assert auth["region"] == "europe" + + def test_auth_config_defaults_region_americas(self, monkeypatch): + """get_auth_config defaults region to americas when not in context.""" + monkeypatch.setenv("SCM_SCM_CLIENT_ID", "cid") + monkeypatch.setenv("SCM_SCM_CLIENT_SECRET", "csec") + monkeypatch.setenv("SCM_SCM_TSG_ID", "tsg") + + import scm_cli.utils.config as config_mod + from scm_cli.utils.context import get_context_aware_settings + config_mod.settings = get_context_aware_settings() + + auth = get_auth_config() + assert auth.get("region", "americas") == "americas" +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `poetry run pytest tests/test_context_region.py::TestRegionInAuthConfig -v` +Expected: FAIL (get_auth_config doesn't return region) + +- [ ] **Step 3: Update get_auth_config to include region** + +In `src/scm_cli/utils/config.py`, modify `get_auth_config`: + +```python +def get_auth_config() -> dict[str, str]: + """Get SCM API authentication configuration from dynaconf settings. + + Uses the following precedence order: + 1. Current context (set via 'scm context use') + 2. Environment variables (SCM_CLIENT_ID, etc.) + 3. Default settings + + Note: Legacy config file (~/.scm-cli/config.yaml) is no longer supported. + Use contexts for multi-tenant support. + + Returns + ------- + Dict containing client_id, client_secret, tsg_id, and region. + + Raises + ------ + ValueError: If required authentication parameters are missing. + + Examples + -------- + >>> auth = get_auth_config() + >>> client = Scm(**auth) #noqa + + """ + # Get authentication from settings (which already includes context awareness) + auth = { + "client_id": settings.get("client_id", ""), + "client_secret": settings.get("client_secret", ""), + "tsg_id": settings.get("tsg_id", ""), + } + + # For backward compatibility, also check the scm_ prefixed settings + # but only if the non-prefixed values are empty + if not auth["client_id"]: + auth["client_id"] = settings.get("scm_client_id", "") + if not auth["client_secret"]: + auth["client_secret"] = settings.get("scm_client_secret", "") + if not auth["tsg_id"]: + auth["tsg_id"] = settings.get("scm_tsg_id", "") + + # Check for missing parameters + missing = [k for k, v in auth.items() if not v] + if missing: + raise ValueError(f"Missing required authentication parameters: {', '.join(missing)}") + + # Add region (not required — defaults to americas) + auth["region"] = settings.get("region", "americas") + + return auth +``` + +- [ ] **Step 4: Add global --region flag to main.py callback** + +In `src/scm_cli/main.py`, add a module-level variable and update the callback: + +```python +# Module-level region override (set by --region flag) +_region_override: str | None = None + + +@app.callback() +def callback( + region: str | None = typer.Option( + None, + "--region", + help="Override SCM API region for this invocation", + ), +): + """Manage Palo Alto Networks Strata Cloud Manager (SCM) configurations. + + The CLI follows the pattern: [options] + + Examples + -------- + - scm set object address-group --folder Texas --name test123 --type static + - scm delete security security-rule --folder Texas --name test123 + - scm load network zone --file config/security_zones.yml + - scm show object address --folder Texas --list + - scm show object address --folder Texas --name webserver + - scm context test + + """ + global _region_override # noqa: PLW0603 + _region_override = region +``` + +Also add a helper function after the callback: + +```python +def get_region_override() -> str | None: + """Get the global --region override value, if set.""" + return _region_override +``` + +- [ ] **Step 5: Update sdk_client.py to use region override** + +In `src/scm_cli/utils/sdk_client.py`, in the `SCMClient.__init__` OAuth2 branch where `Scm()` is constructed, add region resolution: + +```python + # Resolve region: global flag > context > default + from scm_cli.main import get_region_override + region_override = get_region_override() + resolved_region = region_override or credentials.get("region", "americas") + + self.client = Scm( + client_id=self.client_id, + client_secret=self.client_secret, + tsg_id=self.tsg_id, + log_level=settings.get("log_level", "INFO"), + region=resolved_region, + ) +``` + +Apply the same pattern to the bearer token branch. + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `poetry run pytest tests/test_context_region.py -v` +Expected: All tests PASS + +Run: `poetry run pytest -x -q` +Expected: All existing tests still pass + +- [ ] **Step 7: Commit** + +```bash +git add src/scm_cli/main.py src/scm_cli/utils/config.py src/scm_cli/utils/sdk_client.py tests/test_context_region.py +git commit -m "feat: add global --region flag with context/override precedence" +``` + +--- + +### Task 5: Handle JobTimeoutError in sdk_client + +**Files:** +- Modify: `src/scm_cli/utils/sdk_client.py:20` (imports), `sdk_client.py:226-258` (_handle_api_exception) +- Test: `tests/test_context_region.py` (extend — or new file `tests/test_job_timeout.py`) + +- [ ] **Step 1: Write failing test for JobTimeoutError handling** + +Create `tests/test_job_timeout.py`: + +```python +"""Tests for JobTimeoutError handling.""" + +import pytest + +from src.scm_cli.utils.sdk_client import SCMClient + + +class TestJobTimeoutError: + """Test JobTimeoutError is handled properly.""" + + def test_handle_job_timeout_logs_and_reraises(self): + """_handle_api_exception logs JobTimeoutError with job_id and last_state.""" + from scm.exceptions import JobTimeoutError + + client = SCMClient.__new__(SCMClient) + client.logger = __import__("logging").getLogger("test") + client.client = None + + exc = JobTimeoutError(job_id="job-abc", last_state="running") + + with pytest.raises(JobTimeoutError): + client._handle_api_exception("dispatch", "N/A", "route-table", exc) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `poetry run pytest tests/test_job_timeout.py -v` +Expected: FAIL — either `JobTimeoutError` import fails (SDK not yet updated) or it falls through to the generic handler without specific logging + +- [ ] **Step 3: Add JobTimeoutError to imports and handler** + +In `src/scm_cli/utils/sdk_client.py`, update the import (line 20): + +```python +from scm.exceptions import APIError, AuthenticationError, ClientError, JobTimeoutError, NotFoundError +``` + +In `_handle_api_exception`, add a new branch before the generic `else` (after the `APIError` check): + +```python + elif isinstance(exception, JobTimeoutError): + self.logger.error( + f"Job {exception.job_id} timed out in state '{exception.last_state}' " + f"during {operation} of {resource_name}. " + f"Check with: scm operations status --job-id {exception.job_id}" + ) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `poetry run pytest tests/test_job_timeout.py -v` +Expected: PASS + +Run: `poetry run pytest -x -q` +Expected: All tests pass + +- [ ] **Step 5: Commit** + +```bash +git add src/scm_cli/utils/sdk_client.py tests/test_job_timeout.py +git commit -m "feat: handle JobTimeoutError with job_id and recovery message" +``` + +--- + +### Task 6: Quality gate and PR for foundation + +**Files:** None (verification only) + +- [ ] **Step 1: Run full quality checks** + +Run: `make quality` +Expected: lint, format, mypy, tests all pass + +- [ ] **Step 2: Create PR** + +```bash +git push -u origin cdot65/sdk-013-foundation +gh pr create --title "feat: upgrade pan-scm-sdk to 0.13.0 with region support" --body "$(cat <<'EOF' +## Summary +- Bump pan-scm-sdk from ^0.12.2 to ^0.13.0 +- Add `--region` option to `scm context create` (stored per-context, defaults to "americas") +- Add global `--region` flag to override region per-invocation +- Handle `JobTimeoutError` with job_id and recovery instructions +- Backward compatible: old contexts without region default to "americas" + +Closes #212 + +## Test plan +- [ ] `poetry show pan-scm-sdk` shows 0.13.x +- [ ] `scm context create test --client-id x --client-secret y --tsg-id z --region europe` stores region +- [ ] `scm context create test2 --client-id x --client-secret y --tsg-id z` defaults to americas +- [ ] All existing tests pass with 0 regressions + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +--- + +## PR 2: Local Config Commands (#213) + +### Task 7: Add SDK client methods for local config + +**Files:** +- Modify: `src/scm_cli/utils/sdk_client.py` (add methods before LazyClient class) +- Test: `tests/test_local_commands.py` (create) + +- [ ] **Step 1: Write failing tests for SDK client methods** + +Create `tests/test_local_commands.py`: + +```python +"""Tests for local config commands.""" + +import os + +import pytest +import yaml +from typer.testing import CliRunner + +from src.scm_cli.main import app +from src.scm_cli.utils.sdk_client import SCMClient + + +@pytest.fixture +def mock_local_env(monkeypatch, tmp_path): + """Set up mock environment for local config tests.""" + monkeypatch.setattr("src.scm_cli.utils.context.CURRENT_CONTEXT_FILE", str(tmp_path / "current-context")) + monkeypatch.setattr("src.scm_cli.utils.context.CONTEXT_DIR", str(tmp_path / "contexts")) + monkeypatch.setenv("SCM_CLIENT_ID", "") + monkeypatch.setenv("SCM_CLIENT_SECRET", "") + monkeypatch.setenv("SCM_TSG_ID", "") + monkeypatch.setenv("SCM_SCM_CLIENT_ID", "") + monkeypatch.setenv("SCM_SCM_CLIENT_SECRET", "") + monkeypatch.setenv("SCM_SCM_TSG_ID", "") + + +class TestLocalConfigSDKClient: + """Test SDK client methods for local config.""" + + def test_list_local_config_versions_mock(self): + """list_local_config_versions returns mock data when no client.""" + client = SCMClient.__new__(SCMClient) + client.client = None + client.logger = __import__("logging").getLogger("test") + + result = client.list_local_config_versions(device="fw-01") + assert isinstance(result, list) + assert len(result) > 0 + assert "version" in result[0] + assert "date" in result[0] + + def test_download_local_config_mock(self): + """download_local_config returns mock XML bytes when no client.""" + client = SCMClient.__new__(SCMClient) + client.client = None + client.logger = __import__("logging").getLogger("test") + + result = client.download_local_config(device="fw-01", version=42) + assert isinstance(result, bytes) + assert b" list[dict[str, Any]]: + """List configuration versions for a device. + + Args: + device: Device name to list versions for. + + Returns: + list[dict[str, Any]]: List of config version objects. + + """ + self.logger.info(f"Listing local config versions for device: {device}") + + if not self.client: + return [ + {"version": 42, "date": "2026-04-15 14:30", "author": "admin", "description": "Policy update"}, + {"version": 41, "date": "2026-04-14 09:12", "author": "auto-commit", "description": "Scheduled push"}, + {"version": 40, "date": "2026-04-13 11:45", "author": "admin", "description": "Initial config"}, + ] + + try: + results = self.client.local_config.list(device=device) + return [json.loads(r.model_dump_json(exclude_unset=True)) for r in results] + except Exception as e: + self._handle_api_exception("listing", "N/A", f"local config versions for {device}", e) + + def download_local_config(self, device: str, version: int) -> bytes: + """Download a configuration version as raw XML. + + Args: + device: Device name. + version: Config version number to download. + + Returns: + bytes: Raw XML configuration data. + + """ + self.logger.info(f"Downloading local config version {version} for device: {device}") + + if not self.client: + return b'\n\n \n \n \n \n \n' + + try: + return self.client.local_config.download(device=device, version=version) + except Exception as e: + self._handle_api_exception("downloading", "N/A", f"local config v{version} for {device}", e) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `poetry run pytest tests/test_local_commands.py::TestLocalConfigSDKClient -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/scm_cli/utils/sdk_client.py tests/test_local_commands.py +git commit -m "feat: add SDK client methods for local config" +``` + +--- + +### Task 8: Create local command module + +**Files:** +- Create: `src/scm_cli/commands/local.py` +- Modify: `src/scm_cli/main.py` (register) +- Test: `tests/test_local_commands.py` (extend) + +- [ ] **Step 1: Write failing tests for CLI commands** + +Add to `tests/test_local_commands.py`: + +```python +from src.scm_cli.commands import local as local_module + +app.add_typer(local_module.app, name="local") + + +class TestLocalList: + """Test local list command.""" + + def test_list_versions_mock(self, runner, mock_local_env): + """scm local list shows config versions in table.""" + result = runner.invoke(app, ["local", "list", "--device", "fw-01"]) + + if result.exit_code != 0: + print(f"Output: {result.output}") + print(f"Exception: {result.exception}") + assert result.exit_code == 0 + assert "42" in result.output + assert "admin" in result.output + + def test_list_versions_empty(self, runner, mock_local_env, monkeypatch): + """scm local list shows message when no versions found.""" + monkeypatch.setattr( + "src.scm_cli.utils.sdk_client.SCMClient.list_local_config_versions", + lambda self, device: [], + ) + result = runner.invoke(app, ["local", "list", "--device", "fw-01"]) + assert result.exit_code == 0 + assert "No config versions found" in result.output + + +class TestLocalDownload: + """Test local download command.""" + + def test_download_to_stdout(self, runner, mock_local_env): + """scm local download outputs XML to stdout.""" + result = runner.invoke(app, ["local", "download", "--device", "fw-01", "--version", "42"]) + + if result.exit_code != 0: + print(f"Output: {result.output}") + print(f"Exception: {result.exception}") + assert result.exit_code == 0 + assert "&1 | tail -5` +Expected: Build succeeds + +- [ ] **Step 4: Commit and create PR** + +```bash +git add docs/cli/local/index.md mkdocs.yml +git commit -m "docs: add local config command documentation" +git push -u origin cdot65/local-config-commands +gh pr create --title "feat: add scm local commands for config versions" --body "$(cat <<'EOF' +## Summary +- Add `scm local list --device ` to show config versions +- Add `scm local download --device --version ` for XML download +- Supports stdout (default) and `--output` file modes +- Mock mode support for testing + +Closes #213 + +## Test plan +- [ ] `scm local list --device fw-01` shows version table +- [ ] `scm local download --device fw-01 --version 42` outputs XML +- [ ] `scm local download --output test.xml` writes file +- [ ] All tests pass + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +--- + +## PR 3: Device Operations Commands (#214) + +### Task 10: Add SDK client methods for device operations + +**Files:** +- Modify: `src/scm_cli/utils/sdk_client.py` (add methods) +- Test: `tests/test_operations_commands.py` (create) + +- [ ] **Step 1: Write failing tests for SDK client methods** + +Create `tests/test_operations_commands.py`: + +```python +"""Tests for device operations commands.""" + +import pytest +from typer.testing import CliRunner + +from src.scm_cli.main import app +from src.scm_cli.utils.sdk_client import SCMClient + + +@pytest.fixture +def mock_ops_env(monkeypatch, tmp_path): + """Set up mock environment for operations tests.""" + monkeypatch.setattr("src.scm_cli.utils.context.CURRENT_CONTEXT_FILE", str(tmp_path / "current-context")) + monkeypatch.setattr("src.scm_cli.utils.context.CONTEXT_DIR", str(tmp_path / "contexts")) + monkeypatch.setenv("SCM_CLIENT_ID", "") + monkeypatch.setenv("SCM_CLIENT_SECRET", "") + monkeypatch.setenv("SCM_TSG_ID", "") + monkeypatch.setenv("SCM_SCM_CLIENT_ID", "") + monkeypatch.setenv("SCM_SCM_CLIENT_SECRET", "") + monkeypatch.setenv("SCM_SCM_TSG_ID", "") + + +class TestOperationsSDKClient: + """Test SDK client methods for device operations.""" + + def test_dispatch_operation_mock_sync(self): + """dispatch_device_operation returns results in sync mode.""" + client = SCMClient.__new__(SCMClient) + client.client = None + client.logger = __import__("logging").getLogger("test") + + result = client.dispatch_device_operation(device="fw-01", operation="route-table", sync=True) + assert isinstance(result, dict) + assert "status" in result + assert result["status"] == "completed" + assert "results" in result + + def test_dispatch_operation_mock_async(self): + """dispatch_device_operation returns job_id in async mode.""" + client = SCMClient.__new__(SCMClient) + client.client = None + client.logger = __import__("logging").getLogger("test") + + result = client.dispatch_device_operation(device="fw-01", operation="route-table", sync=False) + assert isinstance(result, dict) + assert "job_id" in result + + def test_get_operation_status_mock(self): + """get_device_operation_status returns mock status.""" + client = SCMClient.__new__(SCMClient) + client.client = None + client.logger = __import__("logging").getLogger("test") + + result = client.get_device_operation_status(job_id="job-abc") + assert isinstance(result, dict) + assert "job_id" in result + assert "state" in result +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `poetry run pytest tests/test_operations_commands.py::TestOperationsSDKClient -v` +Expected: FAIL — methods not defined + +- [ ] **Step 3: Implement SDK client methods** + +Add to `src/scm_cli/utils/sdk_client.py` before the `LazyClient` class: + +```python + # ========================================================================================================================================================================================== + # DEVICE OPERATIONS METHODS + # ========================================================================================================================================================================================== + + _OPERATION_MOCK_RESULTS = { + "route-table": [ + {"destination": "0.0.0.0/0", "next_hop": "10.0.0.1", "interface": "ethernet1/1", "metric": 10}, + {"destination": "10.1.0.0/16", "next_hop": "10.0.0.2", "interface": "ethernet1/2", "metric": 20}, + ], + "fib-table": [ + {"destination": "0.0.0.0/0", "interface": "ethernet1/1", "next_hop": "10.0.0.1", "flags": "u"}, + ], + "dns-proxy": [ + {"domain": "example.com", "primary": "8.8.8.8", "secondary": "8.8.4.4", "status": "active"}, + ], + "interfaces": [ + {"name": "ethernet1/1", "status": "up", "ip": "10.0.0.1/24", "speed": "1Gbps"}, + {"name": "ethernet1/2", "status": "up", "ip": "10.1.0.1/24", "speed": "1Gbps"}, + ], + "device-rules": [ + {"name": "allow-web", "action": "allow", "from": "trust", "to": "untrust"}, + ], + "bgp-export": [ + {"prefix": "10.0.0.0/8", "next_hop": "10.0.0.1", "as_path": "65001 65002"}, + ], + "logging-status": [ + {"service": "cortex-data-lake", "status": "connected", "last_log": "2026-04-16 10:30:00"}, + ], + } + + def dispatch_device_operation( + self, + device: str, + operation: str, + sync: bool = True, + timeout: int = 300, + ) -> dict[str, Any]: + """Dispatch a device operation job. + + Args: + device: Device name to run operation on. + operation: Operation type (route-table, fib-table, etc.). + sync: If True, poll until completion. If False, return job_id immediately. + timeout: Timeout in seconds for sync polling. + + Returns: + dict: Results if sync, or job_id if async. + + """ + self.logger.info(f"Dispatching {operation} for device {device} (sync={sync})") + + if not self.client: + if sync: + return { + "status": "completed", + "job_id": f"mock-job-{operation}", + "device": device, + "operation": operation, + "results": self._OPERATION_MOCK_RESULTS.get(operation, []), + } + return { + "job_id": f"mock-job-{operation}", + "device": device, + "operation": operation, + "status": "pending", + } + + try: + job = self.client.device_operations.dispatch( + device=device, + operation=operation, + ) + if sync: + result = self.client.device_operations.wait( + job_id=job.job_id, + timeout=timeout, + ) + return json.loads(result.model_dump_json(exclude_unset=True)) + return {"job_id": job.job_id, "device": device, "operation": operation, "status": "pending"} + except Exception as e: + self._handle_api_exception("dispatching", "N/A", f"{operation} for {device}", e) + + def get_device_operation_status(self, job_id: str) -> dict[str, Any]: + """Get status of a device operation job. + + Args: + job_id: The job ID to check. + + Returns: + dict: Job status information. + + """ + self.logger.info(f"Checking status of job {job_id}") + + if not self.client: + return { + "job_id": job_id, + "state": "completed", + "device": "fw-01", + "operation": "route-table", + "started": "2026-04-16 10:30:00", + "completed": "2026-04-16 10:30:42", + } + + try: + result = self.client.device_operations.status(job_id=job_id) + return json.loads(result.model_dump_json(exclude_unset=True)) + except Exception as e: + self._handle_api_exception("checking status", "N/A", f"job {job_id}", e) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `poetry run pytest tests/test_operations_commands.py::TestOperationsSDKClient -v` +Expected: All 3 PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/scm_cli/utils/sdk_client.py tests/test_operations_commands.py +git commit -m "feat: add SDK client methods for device operations" +``` + +--- + +### Task 11: Create operations command module + +**Files:** +- Create: `src/scm_cli/commands/operations.py` +- Modify: `src/scm_cli/main.py` (register) +- Test: `tests/test_operations_commands.py` (extend) + +- [ ] **Step 1: Write failing tests for CLI commands** + +Add to `tests/test_operations_commands.py`: + +```python +from src.scm_cli.commands import operations as ops_module + +app.add_typer(ops_module.app, name="operations") + + +class TestOperationsRouteTable: + """Test operations route-table command.""" + + def test_route_table_sync(self, runner, mock_ops_env): + """scm operations route-table shows results in sync mode.""" + result = runner.invoke(app, ["operations", "route-table", "--device", "fw-01"]) + + if result.exit_code != 0: + print(f"Output: {result.output}") + print(f"Exception: {result.exception}") + assert result.exit_code == 0 + assert "0.0.0.0/0" in result.output + assert "10.0.0.1" in result.output + + def test_route_table_async(self, runner, mock_ops_env): + """scm operations route-table --async returns job ID.""" + result = runner.invoke(app, ["operations", "route-table", "--device", "fw-01", "--async"]) + + assert result.exit_code == 0 + assert "mock-job-route-table" in result.output + + +class TestOperationsInterfaces: + """Test operations interfaces command.""" + + def test_interfaces_sync(self, runner, mock_ops_env): + """scm operations interfaces shows interface data.""" + result = runner.invoke(app, ["operations", "interfaces", "--device", "fw-01"]) + + assert result.exit_code == 0 + assert "ethernet1/1" in result.output + + +class TestOperationsStatus: + """Test operations status command.""" + + def test_status_check(self, runner, mock_ops_env): + """scm operations status shows job details.""" + result = runner.invoke(app, ["operations", "status", "--job-id", "job-abc"]) + + if result.exit_code != 0: + print(f"Output: {result.output}") + print(f"Exception: {result.exception}") + assert result.exit_code == 0 + assert "job-abc" in result.output + assert "completed" in result.output +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `poetry run pytest tests/test_operations_commands.py::TestOperationsRouteTable -v` +Expected: FAIL — module does not exist + +- [ ] **Step 3: Create the operations command module** + +Create `src/scm_cli/commands/operations.py`: + +```python +"""Device operations commands for scm-cli. + +This module provides commands to dispatch and monitor asynchronous device +jobs for route tables, FIB tables, DNS proxy, network interfaces, device +rules, BGP policy export, and logging service status. +""" + +import json + +import typer +from rich.console import Console +from rich.table import Table + +from ..utils.sdk_client import scm_client + +# ============================================================================================================================================================================================= +# TYPER APP CONFIGURATION +# ============================================================================================================================================================================================= + +app = typer.Typer(help="Dispatch and monitor device operations") +console = Console() + +# ============================================================================================================================================================================================= +# COMMAND OPTIONS +# ============================================================================================================================================================================================= + +DEVICE_OPTION = typer.Option(..., "--device", "-d", help="Device name") +ASYNC_OPTION = typer.Option(False, "--async", help="Return job ID without waiting for completion") +TIMEOUT_OPTION = typer.Option(300, "--timeout", "-t", help="Sync polling timeout in seconds") + + +# ============================================================================================================================================================================================= +# HELPER FUNCTIONS +# ============================================================================================================================================================================================= + +_OPERATION_COLUMNS: dict[str, list[tuple[str, str, str]]] = { + "route-table": [("destination", "Destination", "cyan"), ("next_hop", "Next Hop", "white"), ("interface", "Interface", "green"), ("metric", "Metric", "dim")], + "fib-table": [("destination", "Destination", "cyan"), ("interface", "Interface", "green"), ("next_hop", "Next Hop", "white"), ("flags", "Flags", "dim")], + "dns-proxy": [("domain", "Domain", "cyan"), ("primary", "Primary", "white"), ("secondary", "Secondary", "white"), ("status", "Status", "green")], + "interfaces": [("name", "Name", "cyan"), ("status", "Status", "green"), ("ip", "IP Address", "white"), ("speed", "Speed", "dim")], + "device-rules": [("name", "Name", "cyan"), ("action", "Action", "green"), ("from", "From", "white"), ("to", "To", "white")], + "bgp-export": [("prefix", "Prefix", "cyan"), ("next_hop", "Next Hop", "white"), ("as_path", "AS Path", "dim")], + "logging-status": [("service", "Service", "cyan"), ("status", "Status", "green"), ("last_log", "Last Log", "dim")], +} + + +def _run_operation( + device: str, + operation: str, + async_mode: bool, + timeout: int, +) -> None: + """Dispatch an operation and display results or job ID.""" + try: + result = scm_client.dispatch_device_operation( + device=device, + operation=operation, + sync=not async_mode, + timeout=timeout, + ) + + if async_mode: + job_id = result.get("job_id", "unknown") + typer.echo(f"Job dispatched: {job_id}") + typer.echo(f"Check status with: scm operations status --job-id {job_id}") + return + + results = result.get("results", []) + if not results: + typer.echo(f"No results returned for {operation}") + return + + columns = _OPERATION_COLUMNS.get(operation, []) + table = Table(title=f"{operation} — {device}") + for key, header, style in columns: + table.add_column(header, style=style) + + for row in results: + table.add_row(*[str(row.get(key, "")) for key, _, _ in columns]) + + console.print(table) + + except Exception as e: + typer.echo(f"Error running {operation}: {e!s}", err=True) + raise typer.Exit(code=1) from e + + +# ============================================================================================================================================================================================= +# OPERATION COMMANDS +# ============================================================================================================================================================================================= + +# --------------------------------------------------------------------------------- route-table --------------------------------------------------------------------------------- + + +@app.command("route-table") +def route_table( + device: str = DEVICE_OPTION, + async_mode: bool = ASYNC_OPTION, + timeout: int = TIMEOUT_OPTION, +): + """Retrieve device routing table. + + Examples + -------- + scm operations route-table --device fw-01 + scm operations route-table --device fw-01 --async + + """ + _run_operation(device, "route-table", async_mode, timeout) + + +# ---------------------------------------------------------------------------------- fib-table ---------------------------------------------------------------------------------- + + +@app.command("fib-table") +def fib_table( + device: str = DEVICE_OPTION, + async_mode: bool = ASYNC_OPTION, + timeout: int = TIMEOUT_OPTION, +): + """Retrieve forwarding information base table. + + Examples + -------- + scm operations fib-table --device fw-01 + + """ + _run_operation(device, "fib-table", async_mode, timeout) + + +# ---------------------------------------------------------------------------------- dns-proxy ---------------------------------------------------------------------------------- + + +@app.command("dns-proxy") +def dns_proxy( + device: str = DEVICE_OPTION, + async_mode: bool = ASYNC_OPTION, + timeout: int = TIMEOUT_OPTION, +): + """Query DNS proxy configuration and status. + + Examples + -------- + scm operations dns-proxy --device fw-01 + + """ + _run_operation(device, "dns-proxy", async_mode, timeout) + + +# --------------------------------------------------------------------------------- interfaces --------------------------------------------------------------------------------- + + +@app.command("interfaces") +def interfaces( + device: str = DEVICE_OPTION, + async_mode: bool = ASYNC_OPTION, + timeout: int = TIMEOUT_OPTION, +): + """Retrieve network interface status. + + Examples + -------- + scm operations interfaces --device fw-01 + + """ + _run_operation(device, "interfaces", async_mode, timeout) + + +# -------------------------------------------------------------------------------- device-rules -------------------------------------------------------------------------------- + + +@app.command("device-rules") +def device_rules( + device: str = DEVICE_OPTION, + async_mode: bool = ASYNC_OPTION, + timeout: int = TIMEOUT_OPTION, +): + """Retrieve applied security rules from device. + + Examples + -------- + scm operations device-rules --device fw-01 + + """ + _run_operation(device, "device-rules", async_mode, timeout) + + +# ---------------------------------------------------------------------------------- bgp-export --------------------------------------------------------------------------------- + + +@app.command("bgp-export") +def bgp_export( + device: str = DEVICE_OPTION, + async_mode: bool = ASYNC_OPTION, + timeout: int = TIMEOUT_OPTION, +): + """Export BGP routing policies. + + Examples + -------- + scm operations bgp-export --device fw-01 + + """ + _run_operation(device, "bgp-export", async_mode, timeout) + + +# ------------------------------------------------------------------------------- logging-status ------------------------------------------------------------------------------- + + +@app.command("logging-status") +def logging_status( + device: str = DEVICE_OPTION, + async_mode: bool = ASYNC_OPTION, + timeout: int = TIMEOUT_OPTION, +): + """Check logging service health. + + Examples + -------- + scm operations logging-status --device fw-01 + + """ + _run_operation(device, "logging-status", async_mode, timeout) + + +# ------------------------------------------------------------------------------------ status ----------------------------------------------------------------------------------- + + +@app.command("status") +def operation_status( + job_id: str = typer.Option(..., "--job-id", "-j", help="Job ID to check"), +): + """Check status of a dispatched device operation job. + + Examples + -------- + scm operations status --job-id abc-123 + + """ + try: + result = scm_client.get_device_operation_status(job_id=job_id) + + typer.echo(f"\nJob Details for ID: {result.get('job_id', job_id)}") + typer.echo("-" * 50) + for key, value in result.items(): + if value is not None and value != "" and value != []: + typer.echo(f" {key}: {value}") + + except Exception as e: + typer.echo(f"Error checking job status: {e!s}", err=True) + raise typer.Exit(code=1) from e +``` + +- [ ] **Step 4: Register in main.py** + +In `src/scm_cli/main.py`, update import: + +```python +from .commands import commit, context, deployment, identity, insights, jobs, local, mobile_agent, network, objects, operations, posture, security, setup +``` + +Add to top-level commands (alphabetical, between `local` and `posture`): + +```python +app.add_typer(operations.app, name="operations") +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `poetry run pytest tests/test_operations_commands.py -v` +Expected: All tests PASS + +Run: `poetry run pytest -x -q` +Expected: All tests pass + +- [ ] **Step 6: Commit** + +```bash +git add src/scm_cli/commands/operations.py src/scm_cli/main.py tests/test_operations_commands.py +git commit -m "feat: add scm operations commands for device jobs" +``` + +--- + +### Task 12: Add operations documentation and PR + +**Files:** +- Create: `docs/cli/operations/index.md` +- Modify: `mkdocs.yml` + +- [ ] **Step 1: Create documentation page** + +Create `docs/cli/operations/index.md`: + +```markdown +# Device Operations + +Dispatch and monitor asynchronous device jobs for network diagnostics and status checks. + +## Commands + +All operation commands support: + +| Option | Default | Description | +|--------|---------|-------------| +| `--device`, `-d` | Required | Device name | +| `--async` | `False` | Return job ID without waiting | +| `--timeout`, `-t` | `300` | Sync polling timeout in seconds | + +### Route Table + +```bash +scm operations route-table --device fw-01 +scm operations route-table --device fw-01 --async +``` + +### FIB Table + +```bash +scm operations fib-table --device fw-01 +``` + +### DNS Proxy + +```bash +scm operations dns-proxy --device fw-01 +``` + +### Network Interfaces + +```bash +scm operations interfaces --device fw-01 +``` + +### Device Rules + +```bash +scm operations device-rules --device fw-01 +``` + +### BGP Export + +```bash +scm operations bgp-export --device fw-01 +``` + +### Logging Status + +```bash +scm operations logging-status --device fw-01 +``` + +### Job Status + +Check on an async job: + +```bash +scm operations status --job-id abc-123 +``` + +## Sync vs Async + +By default, commands block and poll until the operation completes, then display results as a table. Use `--async` to get the job ID immediately and check later with `scm operations status`. + +If sync polling exceeds `--timeout`, the CLI reports the job ID and last known state for manual follow-up. +``` + +- [ ] **Step 2: Add to mkdocs.yml nav** + +Add after "Local Config" in the nav: + +```yaml + - Operations: + - Overview: cli/operations/index.md +``` + +- [ ] **Step 3: Verify docs build** + +Run: `poetry run mkdocs build --strict 2>&1 | tail -5` +Expected: Build succeeds + +- [ ] **Step 4: Commit and create PR** + +```bash +git add docs/cli/operations/index.md mkdocs.yml +git commit -m "docs: add device operations command documentation" +git push -u origin cdot65/operations-commands +gh pr create --title "feat: add scm operations commands for device jobs" --body "$(cat <<'EOF' +## Summary +- Add 7 device operation commands: route-table, fib-table, dns-proxy, interfaces, device-rules, bgp-export, logging-status +- Add `scm operations status --job-id ` for async job tracking +- Default sync (poll to completion), `--async` for fire-and-forget +- `--timeout` controls sync polling duration +- Mock mode support + +Closes #214 + +## Test plan +- [ ] `scm operations route-table --device fw-01` shows routing table +- [ ] `scm operations route-table --device fw-01 --async` returns job ID +- [ ] `scm operations status --job-id ` shows job details +- [ ] All 7 operation types return appropriate mock data +- [ ] All tests pass + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +--- + +## PR 4: Incidents Commands (#215) + +### Task 13: Add SDK client methods for incidents + +**Files:** +- Modify: `src/scm_cli/utils/sdk_client.py` (add methods) +- Test: `tests/test_incidents_commands.py` (create) + +- [ ] **Step 1: Write failing tests for SDK client methods** + +Create `tests/test_incidents_commands.py`: + +```python +"""Tests for incidents commands.""" + +import json + +import pytest +from typer.testing import CliRunner + +from src.scm_cli.main import app +from src.scm_cli.utils.sdk_client import SCMClient + + +@pytest.fixture +def mock_incidents_env(monkeypatch, tmp_path): + """Set up mock environment for incidents tests.""" + monkeypatch.setattr("src.scm_cli.utils.context.CURRENT_CONTEXT_FILE", str(tmp_path / "current-context")) + monkeypatch.setattr("src.scm_cli.utils.context.CONTEXT_DIR", str(tmp_path / "contexts")) + monkeypatch.setenv("SCM_CLIENT_ID", "") + monkeypatch.setenv("SCM_CLIENT_SECRET", "") + monkeypatch.setenv("SCM_TSG_ID", "") + monkeypatch.setenv("SCM_SCM_CLIENT_ID", "") + monkeypatch.setenv("SCM_SCM_CLIENT_SECRET", "") + monkeypatch.setenv("SCM_SCM_TSG_ID", "") + + +class TestIncidentsSDKClient: + """Test SDK client methods for incidents.""" + + def test_list_incidents_mock(self): + """list_incidents returns mock data when no client.""" + client = SCMClient.__new__(SCMClient) + client.client = None + client.logger = __import__("logging").getLogger("test") + + result = client.list_incidents() + assert isinstance(result, list) + assert len(result) >= 2 + assert "id" in result[0] + assert "status" in result[0] + assert "severity" in result[0] + + def test_list_incidents_filter_status(self): + """list_incidents filters by status in mock mode.""" + client = SCMClient.__new__(SCMClient) + client.client = None + client.logger = __import__("logging").getLogger("test") + + result = client.list_incidents(status="open") + assert all(i["status"] == "open" for i in result) + + def test_get_incident_mock(self): + """get_incident returns mock detail with alerts.""" + client = SCMClient.__new__(SCMClient) + client.client = None + client.logger = __import__("logging").getLogger("test") + + result = client.get_incident(incident_id="INC-2026-04-001") + assert isinstance(result, dict) + assert "id" in result + assert "alerts" in result + assert "remediation" in result + assert len(result["alerts"]) > 0 +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `poetry run pytest tests/test_incidents_commands.py::TestIncidentsSDKClient -v` +Expected: FAIL — methods not defined + +- [ ] **Step 3: Implement SDK client methods** + +Add to `src/scm_cli/utils/sdk_client.py` before the `LazyClient` class: + +```python + # ========================================================================================================================================================================================== + # INCIDENTS METHODS + # ========================================================================================================================================================================================== + + _MOCK_INCIDENTS = [ + { + "id": "INC-2026-04-001", + "status": "open", + "severity": "high", + "product": "Prisma Access", + "summary": "Suspicious lateral movement detected from 10.1.2.50", + "created": "2026-04-15 08:23:00", + "updated": "2026-04-16 02:15:00", + "alerts": [ + {"severity": "high", "description": "Unusual SMB traffic from 10.1.2.50 to 10.1.2.100", "timestamp": "2026-04-15 08:23"}, + {"severity": "high", "description": "Credential dumping tool detected on 10.1.2.50", "timestamp": "2026-04-15 08:25"}, + {"severity": "medium", "description": "DNS tunneling attempt from 10.1.2.50", "timestamp": "2026-04-15 08:30"}, + ], + "remediation": [ + "Isolate host 10.1.2.50 from network", + "Reset credentials for affected accounts", + "Scan 10.1.2.100 for indicators of compromise", + ], + }, + { + "id": "INC-2026-04-002", + "status": "open", + "severity": "critical", + "product": "NGFW", + "summary": "C2 callback detected from internal host", + "created": "2026-04-14 16:45:00", + "updated": "2026-04-15 09:00:00", + "alerts": [ + {"severity": "critical", "description": "Known C2 domain contacted by 10.2.1.30", "timestamp": "2026-04-14 16:45"}, + {"severity": "high", "description": "Encrypted payload exfiltration attempt", "timestamp": "2026-04-14 16:50"}, + ], + "remediation": [ + "Block C2 domain at firewall", + "Isolate 10.2.1.30", + "Forensic analysis of affected host", + ], + }, + { + "id": "INC-2026-03-088", + "status": "closed", + "severity": "medium", + "product": "Prisma Access", + "summary": "Policy violation — data exfiltration attempt", + "created": "2026-03-28 14:00:00", + "updated": "2026-03-29 11:30:00", + "alerts": [ + {"severity": "medium", "description": "Large file upload to unapproved cloud storage", "timestamp": "2026-03-28 14:00"}, + ], + "remediation": [ + "User counseling completed", + "DLP policy updated to block unapproved storage", + ], + }, + ] + + def list_incidents( + self, + status: str | None = None, + severity: str | None = None, + product: str | None = None, + ) -> list[dict[str, Any]]: + """Search incidents with optional filters. + + Args: + status: Filter by incident status (open, closed, in_progress). + severity: Filter by severity (critical, high, medium, low, informational). + product: Filter by product name. + + Returns: + list[dict[str, Any]]: List of incident objects. + + """ + self.logger.info(f"Listing incidents (status={status}, severity={severity}, product={product})") + + if not self.client: + results = list(self._MOCK_INCIDENTS) + if status: + results = [i for i in results if i["status"] == status] + if severity: + results = [i for i in results if i["severity"] == severity] + if product: + results = [i for i in results if i["product"] == product] + return results + + try: + kwargs: dict[str, Any] = {} + if status: + kwargs["status"] = status + if severity: + kwargs["severity"] = severity + if product: + kwargs["product"] = product + results = self.client.incidents.search(**kwargs) + return [json.loads(r.model_dump_json(exclude_unset=True)) for r in results] + except Exception as e: + self._handle_api_exception("searching", "N/A", "incidents", e) + + def get_incident(self, incident_id: str) -> dict[str, Any]: + """Get detailed incident information including alerts and remediation. + + Args: + incident_id: The incident ID to retrieve. + + Returns: + dict[str, Any]: Full incident detail. + + """ + self.logger.info(f"Getting incident detail: {incident_id}") + + if not self.client: + for inc in self._MOCK_INCIDENTS: + if inc["id"] == incident_id: + return inc + return self._MOCK_INCIDENTS[0] + + try: + result = self.client.incidents.get(incident_id=incident_id) + return json.loads(result.model_dump_json(exclude_unset=True)) + except Exception as e: + self._handle_api_exception("fetching", "N/A", f"incident {incident_id}", e) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `poetry run pytest tests/test_incidents_commands.py::TestIncidentsSDKClient -v` +Expected: All 4 PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/scm_cli/utils/sdk_client.py tests/test_incidents_commands.py +git commit -m "feat: add SDK client methods for incidents" +``` + +--- + +### Task 14: Create incidents command module + +**Files:** +- Create: `src/scm_cli/commands/incidents.py` +- Modify: `src/scm_cli/main.py` (register) +- Test: `tests/test_incidents_commands.py` (extend) + +- [ ] **Step 1: Write failing tests for CLI commands** + +Add to `tests/test_incidents_commands.py`: + +```python +from src.scm_cli.commands import incidents as incidents_module + +app.add_typer(incidents_module.app, name="incidents") + + +class TestIncidentsList: + """Test incidents list command.""" + + def test_list_incidents_table(self, runner, mock_incidents_env): + """scm incidents list shows summary table.""" + result = runner.invoke(app, ["incidents", "list"]) + + if result.exit_code != 0: + print(f"Output: {result.output}") + print(f"Exception: {result.exception}") + assert result.exit_code == 0 + assert "INC-2026-04-001" in result.output + assert "high" in result.output + + def test_list_incidents_filter_status(self, runner, mock_incidents_env): + """scm incidents list --status filters results.""" + result = runner.invoke(app, ["incidents", "list", "--status", "closed"]) + + assert result.exit_code == 0 + assert "INC-2026-03-088" in result.output + assert "INC-2026-04-001" not in result.output + + def test_list_incidents_json(self, runner, mock_incidents_env): + """scm incidents list --json outputs JSON.""" + result = runner.invoke(app, ["incidents", "list", "--json"]) + + assert result.exit_code == 0 + data = json.loads(result.output) + assert isinstance(data, list) + assert len(data) >= 2 + + def test_list_incidents_empty(self, runner, mock_incidents_env): + """scm incidents list shows message for no results.""" + result = runner.invoke(app, ["incidents", "list", "--severity", "informational"]) + + assert result.exit_code == 0 + assert "No incidents found" in result.output + + +class TestIncidentsShow: + """Test incidents show command.""" + + def test_show_incident_detail(self, runner, mock_incidents_env): + """scm incidents show displays formatted detail.""" + result = runner.invoke(app, ["incidents", "show", "INC-2026-04-001"]) + + if result.exit_code != 0: + print(f"Output: {result.output}") + print(f"Exception: {result.exception}") + assert result.exit_code == 0 + assert "INC-2026-04-001" in result.output + assert "Suspicious lateral movement" in result.output + assert "Alerts" in result.output + assert "Remediation" in result.output + + def test_show_incident_json(self, runner, mock_incidents_env): + """scm incidents show --json outputs full JSON.""" + result = runner.invoke(app, ["incidents", "show", "INC-2026-04-001", "--json"]) + + assert result.exit_code == 0 + data = json.loads(result.output) + assert data["id"] == "INC-2026-04-001" + assert "alerts" in data + assert "remediation" in data +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `poetry run pytest tests/test_incidents_commands.py::TestIncidentsList -v` +Expected: FAIL — module does not exist + +- [ ] **Step 3: Create the incidents command module** + +Create `src/scm_cli/commands/incidents.py`: + +```python +"""Incident management commands for scm-cli. + +This module provides commands to search and view security incidents +from the SCM Unified Incident Framework. +""" + +import json + +import typer +from rich.console import Console +from rich.table import Table + +from ..utils.sdk_client import scm_client + +# ============================================================================================================================================================================================= +# TYPER APP CONFIGURATION +# ============================================================================================================================================================================================= + +app = typer.Typer(help="Search and view security incidents") +console = Console() + +# ============================================================================================================================================================================================= +# COMMAND OPTIONS +# ============================================================================================================================================================================================= + +STATUS_OPTION = typer.Option(None, "--status", "-s", help="Filter by status (open, closed, in_progress)") +SEVERITY_OPTION = typer.Option(None, "--severity", help="Filter by severity (critical, high, medium, low, informational)") +PRODUCT_OPTION = typer.Option(None, "--product", "-p", help="Filter by product name") +JSON_OPTION = typer.Option(False, "--json", "-j", help="Output as JSON") + + +# ============================================================================================================================================================================================= +# INCIDENTS COMMANDS +# ============================================================================================================================================================================================= + +# ------------------------------------------------------------------------------------- list ------------------------------------------------------------------------------------ + + +@app.command("list") +def list_incidents( + status: str | None = STATUS_OPTION, + severity: str | None = SEVERITY_OPTION, + product: str | None = PRODUCT_OPTION, + json_output: bool = JSON_OPTION, +): + """Search security incidents with optional filters. + + Examples + -------- + scm incidents list + scm incidents list --status open --severity high + scm incidents list --product "Prisma Access" + scm incidents list --json + + """ + try: + incidents = scm_client.list_incidents( + status=status, + severity=severity, + product=product, + ) + + if json_output: + typer.echo(json.dumps(incidents, indent=2)) + return + + if not incidents: + typer.echo("No incidents found") + return + + table = Table(title="Security Incidents") + table.add_column("ID", style="cyan") + table.add_column("Status", style="white") + table.add_column("Severity", style="white") + table.add_column("Product", style="white") + table.add_column("Summary", style="dim", max_width=40) + table.add_column("Created", style="white") + + severity_styles = {"critical": "red bold", "high": "red", "medium": "yellow", "low": "green", "informational": "dim"} + + for inc in incidents: + sev = inc.get("severity", "") + sev_style = severity_styles.get(sev, "white") + status_val = inc.get("status", "") + status_style = "green" if status_val == "closed" else ("yellow" if status_val == "in_progress" else "white") + table.add_row( + str(inc.get("id", "")), + f"[{status_style}]{status_val}[/{status_style}]", + f"[{sev_style}]{sev}[/{sev_style}]", + str(inc.get("product", "")), + str(inc.get("summary", "")), + str(inc.get("created", "")), + ) + + console.print(table) + + except Exception as e: + typer.echo(f"Error listing incidents: {e!s}", err=True) + raise typer.Exit(code=1) from e + + +# ------------------------------------------------------------------------------------- show ------------------------------------------------------------------------------------ + + +@app.command("show") +def show_incident( + incident_id: str = typer.Argument(..., help="Incident ID to show"), + json_output: bool = JSON_OPTION, +): + """Show detailed incident information including alerts and remediation. + + Examples + -------- + scm incidents show INC-2026-04-001 + scm incidents show INC-2026-04-001 --json + + """ + try: + incident = scm_client.get_incident(incident_id=incident_id) + + if json_output: + typer.echo(json.dumps(incident, indent=2)) + return + + typer.echo(f"\nIncident: {incident.get('id', incident_id)}") + typer.echo(f"Status: {incident.get('status', '')}") + typer.echo(f"Severity: {incident.get('severity', '')}") + typer.echo(f"Product: {incident.get('product', '')}") + typer.echo(f"Created: {incident.get('created', '')}") + typer.echo(f"Updated: {incident.get('updated', '')}") + typer.echo(f"Summary: {incident.get('summary', '')}") + + alerts = incident.get("alerts", []) + if alerts: + typer.echo(f"\nAlerts ({len(alerts)}):") + for i, alert in enumerate(alerts, 1): + sev = alert.get("severity", "") + desc = alert.get("description", "") + ts = alert.get("timestamp", "") + typer.echo(f" {i}. [{sev}] {desc} {ts}") + + remediation = incident.get("remediation", []) + if remediation: + typer.echo("\nRemediation:") + for i, step in enumerate(remediation, 1): + typer.echo(f" {i}. {step}") + + typer.echo() + + except Exception as e: + typer.echo(f"Error showing incident: {e!s}", err=True) + raise typer.Exit(code=1) from e +``` + +- [ ] **Step 4: Register in main.py** + +In `src/scm_cli/main.py`, update import: + +```python +from .commands import commit, context, deployment, identity, incidents, insights, jobs, local, mobile_agent, network, objects, operations, posture, security, setup +``` + +Add to top-level commands (alphabetical, between `context` and `insights`): + +```python +app.add_typer(incidents.app, name="incidents") +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `poetry run pytest tests/test_incidents_commands.py -v` +Expected: All tests PASS + +Run: `poetry run pytest -x -q` +Expected: All tests pass + +- [ ] **Step 6: Commit** + +```bash +git add src/scm_cli/commands/incidents.py src/scm_cli/main.py tests/test_incidents_commands.py +git commit -m "feat: add scm incidents list and show commands" +``` + +--- + +### Task 15: Add incidents documentation and PR + +**Files:** +- Create: `docs/cli/incidents/index.md` +- Modify: `mkdocs.yml` + +- [ ] **Step 1: Create documentation page** + +Create `docs/cli/incidents/index.md`: + +```markdown +# Incidents + +Search and view security incidents from the SCM Unified Incident Framework. + +## Commands + +### List Incidents + +```bash +# List all incidents +scm incidents list + +# Filter by status and severity +scm incidents list --status open --severity high + +# Filter by product +scm incidents list --product "Prisma Access" + +# JSON output for automation +scm incidents list --json +``` + +**Options:** + +| Option | Required | Description | +|--------|----------|-------------| +| `--status`, `-s` | No | Filter: open, closed, in_progress | +| `--severity` | No | Filter: critical, high, medium, low, informational | +| `--product`, `-p` | No | Filter by product name | +| `--json`, `-j` | No | Output as JSON | + +### Show Incident Detail + +```bash +scm incidents show INC-2026-04-001 +scm incidents show INC-2026-04-001 --json +``` + +Shows full incident detail including alerts and remediation steps. Use `--json` for the complete structured output. + +**Arguments:** + +| Argument | Required | Description | +|----------|----------|-------------| +| `incident_id` | Yes | Incident ID to show | + +**Options:** + +| Option | Required | Description | +|--------|----------|-------------| +| `--json`, `-j` | No | Output as JSON | +``` + +- [ ] **Step 2: Add to mkdocs.yml nav** + +Add after "Operations" in the nav: + +```yaml + - Incidents: + - Overview: cli/incidents/index.md +``` + +- [ ] **Step 3: Verify docs build** + +Run: `poetry run mkdocs build --strict 2>&1 | tail -5` +Expected: Build succeeds + +- [ ] **Step 4: Run full quality gate** + +Run: `make quality` +Expected: All checks pass + +- [ ] **Step 5: Commit and create PR** + +```bash +git add docs/cli/incidents/index.md mkdocs.yml +git commit -m "docs: add incidents command documentation" +git push -u origin cdot65/incidents-commands +gh pr create --title "feat: add scm incidents commands for security incident management" --body "$(cat <<'EOF' +## Summary +- Add `scm incidents list` with filtering by status, severity, product +- Add `scm incidents show ` with alerts and remediation detail +- `--json` flag on both commands for automation +- Severity-colored table output +- Mock mode support + +Closes #215 + +## Test plan +- [ ] `scm incidents list` shows incident table +- [ ] `scm incidents list --status open` filters correctly +- [ ] `scm incidents list --json` outputs valid JSON +- [ ] `scm incidents show INC-2026-04-001` shows detail with alerts and remediation +- [ ] `scm incidents show INC-2026-04-001 --json` outputs full JSON +- [ ] All tests pass + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +--- + +## Summary + +| Task | PR | What | +|------|----|------| +| 1 | 1 | Bump SDK to ^0.13.0 | +| 2 | 1 | Add region to context storage | +| 3 | 1 | Add --region to context create CLI | +| 4 | 1 | Global --region flag + auth config plumbing | +| 5 | 1 | Handle JobTimeoutError | +| 6 | 1 | Quality gate + PR | +| 7 | 2 | SDK client methods for local config | +| 8 | 2 | Local command module + registration | +| 9 | 2 | Docs + PR | +| 10 | 3 | SDK client methods for device operations | +| 11 | 3 | Operations command module + registration | +| 12 | 3 | Docs + PR | +| 13 | 4 | SDK client methods for incidents | +| 14 | 4 | Incidents command module + registration | +| 15 | 4 | Docs + PR | diff --git a/docs/superpowers/specs/2026-04-08-hardening-pass-design.md b/docs/superpowers/specs/2026-04-08-hardening-pass-design.md new file mode 100644 index 0000000..7a80877 --- /dev/null +++ b/docs/superpowers/specs/2026-04-08-hardening-pass-design.md @@ -0,0 +1,137 @@ +# Hardening Pass: Security, Error Handling, Docs, Style + +**Date:** 2026-04-08 +**Delivery:** Single PR, commits grouped by category + +--- + +## 1. Security — Context Credential File Permissions + +### Problem + +Context credential files (`~/.scm-cli/contexts/*.yaml`) containing `client_secret` and `access_token` are written with default OS permissions (typically 0644), making them readable by any local user. + +### Changes + +**File: `src/scm_cli/utils/context.py`** + +#### 1a. Restrict directory permissions on creation + +In `ensure_context_dir()`: after creating `~/.scm-cli/contexts/`, call `os.chmod(CONTEXT_DIR, 0o700)`. + +#### 1b. Restrict file permissions on write + +In `create_context()`: after writing the YAML file, call `os.chmod(context_file, 0o600)`. + +#### 1c. Warn on read if permissions too open + +In `get_context_config()`: before reading, check `os.stat(context_file).st_mode & 0o777`. If more permissive than `0o600`, print a warning to stderr and continue: + +``` +Warning: /home/user/.scm-cli/contexts/prod.yaml has insecure permissions (0644), expected 0600 +``` + +#### 1d. Platform guard + +Skip all permission checks/enforcement on Windows (`os.name == "nt"`) since POSIX permissions don't apply. + +--- + +## 2. Error Handling Cleanup + +### 2a. `sdk_client.py` — Replace `print()` with `typer.echo()` + +**File: `src/scm_cli/utils/sdk_client.py` lines 130-164** + +Replace all `print(..., file=sys.stderr)` calls with `typer.echo(..., err=True)`. Remove the local `import sys` statements inside the two except blocks (lines 131 and 158) — do not remove any top-level `import sys` if one exists. Error messages and structure stay the same. + +### 2b. `utils/context.py` — Specific exception types + +**File: `src/scm_cli/utils/context.py` line 57** + +In `get_current_context()`: replace `except Exception as e` with `except OSError as e` (`PermissionError` is a subclass of `OSError`, so this covers both). Use `print(..., file=sys.stderr)` (utility module, not a command — `typer.echo` not appropriate here). + +### 2c. `commands/context.py` — Specific exception types + +**File: `src/scm_cli/commands/context.py`** + +- Line 54: `except Exception:` in `list_command` → `except (ValueError, OSError):` (since `PermissionError` is a subclass of `OSError`, no need to list it separately)` +- Line 284: `except Exception:` in `current_command` → `except (ValueError, OSError):` (since `PermissionError` is a subclass of `OSError`, no need to list it separately)` + +--- + +## 3. Documentation Fixes + +### 3a. Service connection `--subnets` format (GitHub #199) + +**File: `docs/cli/deployment/service-connection.md`** + +- Options table: change "Comma-separated list of subnets" → "Subnets (repeat flag for multiple)" +- CLI examples: change `--subnets "10.1.0.0/24,10.1.1.0/24"` → `--subnets 10.1.0.0/24 --subnets 10.1.1.0/24` +- Fix any JSON array format examples similarly + +### 3b. Service connection folder scope (GitHub #200) + +**File: `docs/cli/deployment/service-connection.md`** + +Add a note after the options table: + +> **Note:** Service connections are always scoped to the "Service Connections" folder. This matches SCM API requirements and is enforced by the CLI. + +### 3c. Move command documentation (4 rule types) + +Add a "Move" section to each of: + +- `docs/cli/security/rules.md` +- `docs/cli/security/app-override-rule.md` +- `docs/cli/security/authentication-rule.md` +- `docs/cli/security/decryption-rule.md` + +Each section documents: + +| Option | Description | Required | +|--------|-------------|----------| +| `--name` | Rule name to move | Yes | +| `--folder` | Folder scope | Yes (one of folder/snippet/device) | +| `--snippet` | Snippet scope | | +| `--device` | Device scope | | +| `--destination` | Position: top, bottom, before, after | Yes | +| `--rulebase` | Rulebase: pre, post | Yes | +| `--destination-rule` | Reference rule for before/after | When destination is before/after | + +Include a usage example for each rule type. + +### 3d. Schedule command YAML keys + +**File: `docs/cli/objects/schedule.md`** + +In the YAML format example (lines ~137-141): change `days_monday` → `monday`, `days_tuesday` → `tuesday`, etc. The `days_` prefix is an internal parameter name, not a YAML key. + +--- + +## 4. Style — `commands/context.py` Separators + +**File: `src/scm_cli/commands/context.py`** + +Replace all `# ############...` section separators with the project-standard 191-character `# ===...===` format. Applies to separators at lines 24-26, 66-68, 112-114, 202-204, 234-236, 268-270, 292-294. + +--- + +## Commit Plan + +| Order | Commit | Files | +|-------|--------|-------| +| 1 | `fix: enforce 0600 perms on context credential files` | `utils/context.py` | +| 2 | `fix: replace print() with typer.echo(), use specific exceptions` | `utils/sdk_client.py`, `utils/context.py`, `commands/context.py` | +| 3 | `docs: fix subnets format, add folder scope note, add move cmd docs, fix schedule YAML keys` | 6 docs files | +| 4 | `style: standardize context.py section separators` | `commands/context.py` | + +--- + +## Out of Scope + +- Splitting large files (sdk_client.py, objects.py) +- Full error handling sweep of all command modules (decorator rollout already covered) +- Test coverage expansion +- `show_context_info()` deduplication +- Insights API TODO stubs diff --git a/docs/superpowers/specs/2026-04-16-sdk-013-upgrade-design.md b/docs/superpowers/specs/2026-04-16-sdk-013-upgrade-design.md new file mode 100644 index 0000000..94bb43c --- /dev/null +++ b/docs/superpowers/specs/2026-04-16-sdk-013-upgrade-design.md @@ -0,0 +1,316 @@ +# SDK 0.13.0 Upgrade & New Command Modules — Design Spec + +**Date:** 2026-04-16 +**GitHub Issues:** #212, #213, #214, #215 +**SDK Version:** pan-scm-sdk 0.13.0 (April 2026) + +--- + +## Summary + +Upgrade pan-scm-sdk from ^0.12.2 to ^0.13.0 and add three new top-level CLI command groups wrapping the SDK's new services: `scm local` (device config versions), `scm operations` (device job dispatch/monitoring), and `scm incidents` (security incident search/detail). The work is structured as four sequential PRs with a shared foundation. + +## PR Sequence + +| Order | Issue | Branch | Scope | +|-------|-------|--------|-------| +| 1 | #212 | cdot65/sdk-013-foundation | SDK bump, region support, JobTimeoutError | +| 2 | #213 | cdot65/local-config-commands | `scm local` commands | +| 3 | #214 | cdot65/operations-commands | `scm operations` commands | +| 4 | #215 | cdot65/incidents-commands | `scm incidents` commands | + +PRs 2-4 depend on PR 1 but are independent of each other. + +--- + +## 1. Foundation (PR 1 — #212) + +### SDK Dependency + +```toml +# pyproject.toml +pan-scm-sdk = "^0.13.0" +``` + +### Region Support + +**Context YAML schema** gains optional `region` field: + +```yaml +# ~/.scm-cli/contexts/production.yaml +client_id: "abc123" +client_secret: "s3cret" +tsg_id: "123456789" +region: "europe" # optional, defaults to "americas" +``` + +**Precedence:** global `--region` flag > context's stored region > `"americas"` + +**Changes:** +- `commands/context.py`: Add `--region` option to `create` command (default "americas") +- `utils/context.py`: Store and read `region` field; `.get("region", "americas")` for backward compat +- `main.py`: Add global `--region` callback option on main app +- `client.py`: Pass `region=resolved_region` to `Scm()` constructor + +### JobTimeoutError Handling + +```python +# sdk_client.py — add to _handle_api_exception or as standalone handler +except JobTimeoutError as e: + logger.error("Job %s timed out in state '%s'", e.job_id, e.last_state) + typer.echo( + f"Job {e.job_id} timed out (state: {e.last_state}). " + f"Check with: scm operations status --job-id {e.job_id}", + err=True, + ) + raise typer.Exit(code=1) +``` + +### Tests + +- Region stored in context create +- Region override via global flag +- Old contexts without region field default to "americas" +- JobTimeoutError produces correct error message with job_id and last_state +- All existing tests pass (0 regressions) + +--- + +## 2. Local Config Commands (PR 2 — #213) + +### New File: `src/scm_cli/commands/local.py` + +**Subcommands:** +- `scm local list --device ` — table of config versions +- `scm local download --device --version [--output path]` — XML output + +### Table Output (list) + +``` +Version Date Author Description +────────────────────────────────────────────────────────── +42 2026-04-15 14:30 admin Policy update +41 2026-04-14 09:12 auto-commit Scheduled push +40 2026-04-13 11:45 admin Initial config +``` + +### Download Behavior + +- No `--output`: decode XML as UTF-8, write to stdout +- With `--output`: write raw bytes to file, print confirmation to stderr + +### SDK Client Methods + +```python +def list_local_config_versions(self, device: str) -> list[dict]: + """List configuration versions for a device.""" + +def download_local_config(self, device: str, version: int) -> bytes: + """Download a configuration version as raw XML using raw_response=True.""" +``` + +### Mock Mode + +Returns 3-5 sample versions with realistic dates; download returns a small XML snippet. + +### Registration + +```python +# main.py — top-level, alphabetical +app.add_typer(local_app, name="local") +``` + +--- + +## 3. Device Operations Commands (PR 3 — #214) + +### New File: `src/scm_cli/commands/operations.py` + +**Subcommands (7 operation types + status):** + +| Command | Operation | +|---------|-----------| +| `scm operations route-table --device ` | Retrieve routing table | +| `scm operations fib-table --device ` | Retrieve FIB table | +| `scm operations dns-proxy --device ` | DNS proxy status | +| `scm operations interfaces --device ` | Network interface status | +| `scm operations device-rules --device ` | Applied security rules | +| `scm operations bgp-export --device ` | BGP policy export | +| `scm operations logging-status --device ` | Logging service health | +| `scm operations status --job-id ` | Check job status | + +**All operation commands share these options:** +- `--device` (required) +- `--async` (flag, default False) — return job ID without polling +- `--timeout` (int, default 300) — sync polling timeout in seconds +- `--mock` (flag) + +### Sync Flow (default) + +``` +$ scm operations route-table --device fw-01 +Dispatching route-table job for fw-01... ✓ +Polling job abc-123... completed (42s) + +Destination Next Hop Interface Metric +────────────────────────────────────────────────────── +0.0.0.0/0 10.0.0.1 ethernet1/1 10 +10.1.0.0/16 10.0.0.2 ethernet1/2 20 +``` + +### Async Flow + +``` +$ scm operations route-table --device fw-01 --async +Job dispatched: abc-123 +Check status with: scm operations status --job-id abc-123 +``` + +### SDK Client Methods + +```python +def dispatch_device_operation(self, device: str, operation: str, sync: bool = True, timeout: int = 300) -> dict: + """Dispatch a device operation. Returns results if sync, job_id if async.""" + +def get_device_operation_status(self, job_id: str) -> dict: + """Get status of a dispatched device operation job.""" +``` + +The 7 command functions are thin wrappers passing the operation type string to the shared dispatch method. A helper dict maps operation type to table column definitions. + +### JobTimeoutError + +Caught at the command level — displays job_id and last_state with instructions to check via `scm operations status`. + +### Registration + +```python +# main.py — top-level, alphabetical +app.add_typer(operations_app, name="operations") +``` + +--- + +## 4. Incidents Commands (PR 4 — #215) + +### New File: `src/scm_cli/commands/incidents.py` + +**Subcommands:** +- `scm incidents list [--status ] [--severity ] [--product ] [--json]` +- `scm incidents show [--json]` + +### Table Output (list) + +``` +ID Status Severity Product Summary Created +───────────────────────────────────────────────────────────────────────────────────────────────────── +INC-2026-04-001 open high Prisma Access Suspicious lateral movement 2026-04-15 +INC-2026-04-002 open critical NGFW C2 callback detected 2026-04-14 +``` + +### Detail Output (show) + +``` +Incident: INC-2026-04-001 +Status: open +Severity: high +Product: Prisma Access +Created: 2026-04-15 08:23:00 +Updated: 2026-04-16 02:15:00 +Summary: Suspicious lateral movement detected from 10.1.2.50 + +Alerts (3): + 1. [high] Unusual SMB traffic from 10.1.2.50 to 10.1.2.100 2026-04-15 08:23 + 2. [high] Credential dumping tool detected on 10.1.2.50 2026-04-15 08:25 + 3. [medium] DNS tunneling attempt from 10.1.2.50 2026-04-15 08:30 + +Remediation: + 1. Isolate host 10.1.2.50 from network + 2. Reset credentials for affected accounts + 3. Scan 10.1.2.100 for indicators of compromise +``` + +### JSON Mode + +`--json` dumps the full SDK model without formatting. Users pipe to `jq` for filtering. + +### Filter Options + +- `--status`: open, closed, in_progress +- `--severity`: critical, high, medium, low, informational +- `--product`: free-form string matching SDK's product filter + +### SDK Client Methods + +```python +def list_incidents(self, status: str | None = None, severity: str | None = None, product: str | None = None) -> list[dict]: + """Search incidents with optional filters.""" + +def get_incident(self, incident_id: str) -> dict: + """Get incident detail including alerts and remediation steps.""" +``` + +### Validators + +Pydantic model for list filters with `Literal` types for status/severity if SDK exposes enums. + +### Mock Mode + +Returns 3-5 incidents with varying status/severity, 2-3 alerts per incident, remediation steps. + +### Registration + +```python +# main.py — top-level, alphabetical +app.add_typer(incidents_app, name="incidents") +``` + +--- + +## 5. Testing Strategy + +| PR | Test File | Key Cases | +|----|-----------|-----------| +| Foundation | extend `tests/test_context_commands.py`, `tests/test_client.py` + new region tests | Region context CRUD, override precedence, backward compat, JobTimeoutError | +| Local | `tests/test_local_commands.py` | List (success, empty, error), download stdout, download file, device not found, mock | +| Operations | `tests/test_operations_commands.py` | Sync per op type, async dispatch, timeout recovery, status check, mock | +| Incidents | `tests/test_incidents_commands.py` | List (no filters, each filter, combined, empty), show (found, not found), JSON output, mock | + +**Patterns:** `mock_scm_client` fixture, `mock_dynaconf_settings`, Typer `CliRunner`, assert stdout for tables, assert exit codes for errors. Unit tests only — no integration tests in these PRs. + +--- + +## 6. Files Changed Summary + +### New Files +- `src/scm_cli/commands/local.py` +- `src/scm_cli/commands/operations.py` +- `src/scm_cli/commands/incidents.py` +- `tests/test_local_commands.py` +- `tests/test_operations_commands.py` +- `tests/test_incidents_commands.py` +- `docs/cli/local/list.md` +- `docs/cli/local/download.md` +- `docs/cli/operations/*.md` (one per operation type + status) +- `docs/cli/incidents/list.md` +- `docs/cli/incidents/show.md` + +### Modified Files +- `pyproject.toml` — SDK version bump +- `src/scm_cli/main.py` — 3 new command group registrations + global `--region` +- `src/scm_cli/client.py` — pass region to `Scm()`, handle `raw_response` +- `src/scm_cli/commands/context.py` — `--region` on create +- `src/scm_cli/utils/context.py` — store/read region field +- `src/scm_cli/utils/sdk_client.py` — new methods + JobTimeoutError +- `src/scm_cli/utils/validators.py` — new models for incidents filters +- `mkdocs.yml` — nav entries for 3 new sections + +--- + +## Unresolved Questions + +1. SDK 0.13.0 Pydantic model field names for local config versions — need to inspect actual SDK models to confirm table column mapping +2. Device operations result schemas — each of the 7 types returns different data; table columns need to be defined per-type after inspecting SDK response models +3. Incident status/severity enum values — confirm whether SDK exposes these as Literals or free-form strings +4. Does `raw_response` on `Scm.request()` return `bytes` or a `Response` object — affects download implementation +5. Region valid values — is it free-form or an enum in the SDK? Determines whether to validate in CLI diff --git a/mkdocs.yml b/mkdocs.yml index 93ce192..8b178f4 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -161,6 +161,12 @@ nav: - Agent Version: cli/mobile-agent/agent-version.md - Auth Setting: cli/mobile-agent/auth-setting.md - SDK Reference: cli/mobile-agent/sdk-mobile-agent.md + - Local Config: + - Overview: cli/local/index.md + - Operations: + - Overview: cli/operations/index.md + - Incidents: + - Overview: cli/incidents/index.md - Jobs: cli/jobs.md - Commit: cli/commit.md - Insights: cli/insights.md diff --git a/pyproject.toml b/pyproject.toml index 1e0c9cc..f9a1f0e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "pan-scm-cli" -version = "1.2.1" +version = "1.3.0" description = "CICD and Network Engineer-friendly CLI tool for Palo Alto Networks Strata Cloud Manager" authors = ["Calvin Remsburg "] readme = "README.md" @@ -12,7 +12,7 @@ click = ">=8.0.0,<8.2" typer = "^0.15.4" pyyaml = "^6.0.2" pydantic = "^2.11.5" -pan-scm-sdk = "^0.12.2" +pan-scm-sdk = "^0.13.0" dynaconf = "^3.2.11" [tool.poetry.group.dev.dependencies] diff --git a/src/scm_cli/client.py b/src/scm_cli/client.py index 778618e..8cb6e1f 100644 --- a/src/scm_cli/client.py +++ b/src/scm_cli/client.py @@ -90,7 +90,7 @@ def get_scm_client(mock: bool = False) -> Any: auth_params = get_auth_config() try: # Use the Scm client from the pan-scm-sdk - client = Scm(**auth_params) + client = Scm(**auth_params) # type: ignore[arg-type] logger.info(f"Successfully initialized SDK client for TSG ID: {auth_params['tsg_id']}") return client except AuthenticationError as e: diff --git a/src/scm_cli/commands/context.py b/src/scm_cli/commands/context.py index d75d3ae..ee54d8d 100644 --- a/src/scm_cli/commands/context.py +++ b/src/scm_cli/commands/context.py @@ -21,9 +21,9 @@ console = Console() -# ############################################################################ +# ============================================================================================================================================================================================= # list command -# ############################################################################ +# ============================================================================================================================================================================================= @app.command("list", help="List all available contexts") def list_command(): """List all available contexts with the current context highlighted.""" @@ -63,9 +63,9 @@ def list_command(): console.print("Set a context with: [cyan]scm context use [/cyan]") -# ############################################################################ +# ============================================================================================================================================================================================= # show command -# ############################################################################ +# ============================================================================================================================================================================================= @app.command("show", help="Show details of a context") def show_command( context_name: str = typer.Argument( @@ -109,9 +109,9 @@ def show_command( raise typer.Exit(1) from e -# ############################################################################ +# ============================================================================================================================================================================================= # create command -# ############################################################################ +# ============================================================================================================================================================================================= @app.command("create", help="Create a new context") def create_command( context_name: str = typer.Argument(..., help="Name for the new context"), @@ -150,6 +150,12 @@ def create_command( "--set-current/--no-set-current", help="Set as current context after creation", ), + region: str = typer.Option( + "americas", + "--region", + "-r", + help="SCM API region (default: americas)", + ), ): """Create a new authentication context. @@ -186,6 +192,7 @@ def create_command( tsg_id=tsg_id or "", log_level=log_level, access_token=access_token, + region=region, ) console.print(f"[green]✓ Context '{context_name}' created successfully[/green]") @@ -199,9 +206,9 @@ def create_command( raise typer.Exit(1) from e -# ############################################################################ +# ============================================================================================================================================================================================= # use command -# ############################################################################ +# ============================================================================================================================================================================================= @app.command("use", help="Switch to a different context") def use_command( context_name: str = typer.Argument(..., help="Context name to switch to"), @@ -231,9 +238,9 @@ def use_command( raise typer.Exit(1) from e -# ############################################################################ +# ============================================================================================================================================================================================= # delete command -# ############################################################################ +# ============================================================================================================================================================================================= @app.command("delete", help="Delete a context") def delete_command( context_name: str = typer.Argument(..., help="Context name to delete"), @@ -265,9 +272,9 @@ def delete_command( raise typer.Exit(1) from e -# ############################################################################ +# ============================================================================================================================================================================================= # current command -# ############################################################################ +# ============================================================================================================================================================================================= @app.command("current", help="Show the current context") def current_command(): """Show the current active context.""" @@ -289,9 +296,9 @@ def current_command(): console.print("Or create one with: [cyan]scm context create [/cyan]") -# ############################################################################ +# ============================================================================================================================================================================================= # test command -# ############################################################################ +# ============================================================================================================================================================================================= @app.command("test", help="Test authentication for a context") def test_command( context_name: str = typer.Argument( diff --git a/src/scm_cli/commands/incidents.py b/src/scm_cli/commands/incidents.py new file mode 100644 index 0000000..e6d86b1 --- /dev/null +++ b/src/scm_cli/commands/incidents.py @@ -0,0 +1,148 @@ +"""Incident management commands for scm-cli. + +This module provides commands to search and view security incidents +from the SCM Unified Incident Framework. +""" + +import json + +import typer +from rich.console import Console +from rich.table import Table + +from ..utils.sdk_client import scm_client + +# ============================================================================================================================================================================================= +# TYPER APP CONFIGURATION +# ============================================================================================================================================================================================= + +app = typer.Typer(help="Search and view security incidents") +console = Console() + +# ============================================================================================================================================================================================= +# COMMAND OPTIONS +# ============================================================================================================================================================================================= + +STATUS_OPTION = typer.Option(None, "--status", "-s", help="Filter by status (open, closed, in_progress)") +SEVERITY_OPTION = typer.Option(None, "--severity", help="Filter by severity (critical, high, medium, low, informational)") +PRODUCT_OPTION = typer.Option(None, "--product", "-p", help="Filter by product name") +JSON_OPTION = typer.Option(False, "--json", "-j", help="Output as JSON") + + +# ============================================================================================================================================================================================= +# INCIDENTS COMMANDS +# ============================================================================================================================================================================================= + +# ------------------------------------------------------------------------------------- list ------------------------------------------------------------------------------------ + + +@app.command("list") +def list_incidents( + status: str | None = STATUS_OPTION, + severity: str | None = SEVERITY_OPTION, + product: str | None = PRODUCT_OPTION, + json_output: bool = JSON_OPTION, +): + """Search security incidents with optional filters. + + Examples + -------- + scm incidents list + scm incidents list --status open --severity high + scm incidents list --product "Prisma Access" + scm incidents list --json + + """ + try: + incidents = scm_client.list_incidents(status=status, severity=severity, product=product) + + if json_output: + typer.echo(json.dumps(incidents, indent=2)) + return + + if not incidents: + typer.echo("No incidents found") + return + + table = Table(title="Security Incidents") + table.add_column("ID", style="cyan", no_wrap=True) + table.add_column("Status", style="white") + table.add_column("Severity", style="white") + table.add_column("Product", style="white") + table.add_column("Summary", style="dim", max_width=40) + table.add_column("Created", style="white") + + severity_styles = {"critical": "red bold", "high": "red", "medium": "yellow", "low": "green", "informational": "dim"} + + for inc in incidents: + sev = inc.get("severity", "") + sev_style = severity_styles.get(sev, "white") + status_val = inc.get("status", "") + status_style = "green" if status_val == "closed" else ("yellow" if status_val == "in_progress" else "white") + table.add_row( + str(inc.get("id", "")), + f"[{status_style}]{status_val}[/{status_style}]", + f"[{sev_style}]{sev}[/{sev_style}]", + str(inc.get("product", "")), + str(inc.get("summary", "")), + str(inc.get("created", "")), + ) + + console.print(table) + + except Exception as e: + typer.echo(f"Error listing incidents: {e!s}", err=True) + raise typer.Exit(code=1) from e + + +# ------------------------------------------------------------------------------------- show ------------------------------------------------------------------------------------ + + +@app.command("show") +def show_incident( + incident_id: str = typer.Argument(..., help="Incident ID to show"), + json_output: bool = JSON_OPTION, +): + """Show detailed incident information including alerts and remediation. + + Examples + -------- + scm incidents show INC-2026-04-001 + scm incidents show INC-2026-04-001 --json + + """ + try: + incident = scm_client.get_incident(incident_id=incident_id) + + if json_output: + typer.echo(json.dumps(incident, indent=2)) + return + + typer.echo(f"\nIncident: {incident.get('id', incident_id)}") + typer.echo(f"Status: {incident.get('status', '')}") + typer.echo(f"Severity: {incident.get('severity', '')}") + typer.echo(f"Product: {incident.get('product', '')}") + typer.echo(f"Created: {incident.get('created', '')}") + typer.echo(f"Updated: {incident.get('updated', '')}") + typer.echo(f"Summary: {incident.get('summary', '')}") + + alerts = incident.get("alerts", []) + if alerts: + typer.echo(f"\nAlerts ({len(alerts)}):") + for i, alert in enumerate(alerts, 1): + sev = alert.get("severity", "") + desc = alert.get("description", "") + ts = alert.get("timestamp", "") + typer.echo(f" {i}. [{sev}] {desc} {ts}") + + remediation = incident.get("remediation", []) + if remediation: + typer.echo("\nRemediation:") + for i, step in enumerate(remediation, 1): + typer.echo(f" {i}. {step}") + + typer.echo() + + except Exception as e: + typer.echo(f"Error showing incident: {e!s}", err=True) + raise typer.Exit(code=1) from e diff --git a/src/scm_cli/commands/local.py b/src/scm_cli/commands/local.py new file mode 100644 index 0000000..f6246c7 --- /dev/null +++ b/src/scm_cli/commands/local.py @@ -0,0 +1,104 @@ +"""Local configuration management commands for scm-cli. + +This module provides commands to list device configuration versions +and download configuration files as XML. +""" + +import sys +from pathlib import Path + +import typer +from rich.console import Console +from rich.table import Table + +from ..utils.sdk_client import scm_client + +# ============================================================================================================================================================================================= +# TYPER APP CONFIGURATION +# ============================================================================================================================================================================================= + +app = typer.Typer(help="Manage local device configurations") +console = Console() + +# ============================================================================================================================================================================================= +# COMMAND OPTIONS +# ============================================================================================================================================================================================= + +DEVICE_OPTION = typer.Option(..., "--device", "-d", help="Device name") + +# ============================================================================================================================================================================================= +# LOCAL CONFIG COMMANDS +# ============================================================================================================================================================================================= + +# ------------------------------------------------------------------------------------ list ------------------------------------------------------------------------------------ + + +@app.command("list") +def list_versions( + device: str = DEVICE_OPTION, +): + """List configuration versions for a device. + + Examples + -------- + scm local list --device fw-01 + + """ + try: + versions = scm_client.list_local_config_versions(device=device) + + if not versions: + typer.echo("No config versions found") + return + + table = Table(title=f"Config Versions — {device}") + table.add_column("Version", style="cyan") + table.add_column("Date", style="white") + table.add_column("Author", style="green") + table.add_column("Description", style="dim") + + for v in versions: + table.add_row( + str(v.get("version", "")), + str(v.get("date", "")), + str(v.get("author", "")), + str(v.get("description", "")), + ) + + console.print(table) + + except Exception as e: + typer.echo(f"Error listing config versions: {e!s}", err=True) + raise typer.Exit(code=1) from e + + +# ----------------------------------------------------------------------------------- download ----------------------------------------------------------------------------------- + + +@app.command("download") +def download_config( + device: str = DEVICE_OPTION, + version: int = typer.Option(..., "--version", "-v", help="Config version number"), + output: str | None = typer.Option(None, "--output", "-o", help="Output file path (default: stdout)"), +): + """Download a device configuration version as XML. + + Examples + -------- + scm local download --device fw-01 --version 42 + scm local download --device fw-01 --version 42 --output config.xml + + """ + try: + xml_data = scm_client.download_local_config(device=device, version=version) + + if output: + Path(output).write_bytes(xml_data) + typer.echo(f"Config written to {output}", err=True) + else: + sys.stdout.buffer.write(xml_data) + sys.stdout.buffer.write(b"\n") + + except Exception as e: + typer.echo(f"Error downloading config: {e!s}", err=True) + raise typer.Exit(code=1) from e diff --git a/src/scm_cli/commands/operations.py b/src/scm_cli/commands/operations.py new file mode 100644 index 0000000..efe113e --- /dev/null +++ b/src/scm_cli/commands/operations.py @@ -0,0 +1,191 @@ +"""Device operations commands for scm-cli. + +This module provides commands to dispatch and monitor asynchronous device +jobs for route tables, FIB tables, DNS proxy, network interfaces, device +rules, BGP policy export, and logging service status. +""" + +import typer +from rich.console import Console +from rich.table import Table + +from ..utils.sdk_client import scm_client + +# ============================================================================================================================================================================================= +# TYPER APP CONFIGURATION +# ============================================================================================================================================================================================= + +app = typer.Typer(help="Dispatch and monitor device operations") +console = Console() + +# ============================================================================================================================================================================================= +# COMMAND OPTIONS +# ============================================================================================================================================================================================= + +DEVICE_OPTION = typer.Option(..., "--device", "-d", help="Device name") +ASYNC_OPTION = typer.Option(False, "--async", help="Return job ID without waiting for completion") +TIMEOUT_OPTION = typer.Option(300, "--timeout", "-t", help="Sync polling timeout in seconds") + + +# ============================================================================================================================================================================================= +# HELPER FUNCTIONS +# ============================================================================================================================================================================================= + +_OPERATION_COLUMNS: dict[str, list[tuple[str, str, str]]] = { + "route-table": [("destination", "Destination", "cyan"), ("next_hop", "Next Hop", "white"), ("interface", "Interface", "green"), ("metric", "Metric", "dim")], + "fib-table": [("destination", "Destination", "cyan"), ("interface", "Interface", "green"), ("next_hop", "Next Hop", "white"), ("flags", "Flags", "dim")], + "dns-proxy": [("domain", "Domain", "cyan"), ("primary", "Primary", "white"), ("secondary", "Secondary", "white"), ("status", "Status", "green")], + "interfaces": [("name", "Name", "cyan"), ("status", "Status", "green"), ("ip", "IP Address", "white"), ("speed", "Speed", "dim")], + "device-rules": [("name", "Name", "cyan"), ("action", "Action", "green"), ("from", "From", "white"), ("to", "To", "white")], + "bgp-export": [("prefix", "Prefix", "cyan"), ("next_hop", "Next Hop", "white"), ("as_path", "AS Path", "dim")], + "logging-status": [("service", "Service", "cyan"), ("status", "Status", "green"), ("last_log", "Last Log", "dim")], +} + + +def _run_operation(device: str, operation: str, async_mode: bool, timeout: int) -> None: + """Dispatch an operation and display results or job ID.""" + try: + result = scm_client.dispatch_device_operation( + device=device, + operation=operation, + sync=not async_mode, + timeout=timeout, + ) + + if async_mode: + job_id = result.get("job_id", "unknown") + typer.echo(f"Job dispatched: {job_id}") + typer.echo(f"Check status with: scm operations status --job-id {job_id}") + return + + results = result.get("results", []) + if not results: + typer.echo(f"No results returned for {operation}") + return + + columns = _OPERATION_COLUMNS.get(operation, []) + table = Table(title=f"{operation} — {device}") + for _key, header, style in columns: + table.add_column(header, style=style) + + for row in results: + table.add_row(*[str(row.get(key, "")) for key, _, _ in columns]) + + console.print(table) + + except Exception as e: + typer.echo(f"Error running {operation}: {e!s}", err=True) + raise typer.Exit(code=1) from e + + +# ============================================================================================================================================================================================= +# OPERATION COMMANDS +# ============================================================================================================================================================================================= + + +@app.command("route-table") +def route_table(device: str = DEVICE_OPTION, async_mode: bool = ASYNC_OPTION, timeout: int = TIMEOUT_OPTION): + """Retrieve device routing table. + + Examples + -------- + scm operations route-table --device fw-01 + scm operations route-table --device fw-01 --async + + """ + _run_operation(device, "route-table", async_mode, timeout) + + +@app.command("fib-table") +def fib_table(device: str = DEVICE_OPTION, async_mode: bool = ASYNC_OPTION, timeout: int = TIMEOUT_OPTION): + """Retrieve forwarding information base table. + + Examples + -------- + scm operations fib-table --device fw-01 + + """ + _run_operation(device, "fib-table", async_mode, timeout) + + +@app.command("dns-proxy") +def dns_proxy(device: str = DEVICE_OPTION, async_mode: bool = ASYNC_OPTION, timeout: int = TIMEOUT_OPTION): + """Query DNS proxy configuration and status. + + Examples + -------- + scm operations dns-proxy --device fw-01 + + """ + _run_operation(device, "dns-proxy", async_mode, timeout) + + +@app.command("interfaces") +def interfaces(device: str = DEVICE_OPTION, async_mode: bool = ASYNC_OPTION, timeout: int = TIMEOUT_OPTION): + """Retrieve network interface status. + + Examples + -------- + scm operations interfaces --device fw-01 + + """ + _run_operation(device, "interfaces", async_mode, timeout) + + +@app.command("device-rules") +def device_rules(device: str = DEVICE_OPTION, async_mode: bool = ASYNC_OPTION, timeout: int = TIMEOUT_OPTION): + """Retrieve applied security rules from device. + + Examples + -------- + scm operations device-rules --device fw-01 + + """ + _run_operation(device, "device-rules", async_mode, timeout) + + +@app.command("bgp-export") +def bgp_export(device: str = DEVICE_OPTION, async_mode: bool = ASYNC_OPTION, timeout: int = TIMEOUT_OPTION): + """Export BGP routing policies. + + Examples + -------- + scm operations bgp-export --device fw-01 + + """ + _run_operation(device, "bgp-export", async_mode, timeout) + + +@app.command("logging-status") +def logging_status(device: str = DEVICE_OPTION, async_mode: bool = ASYNC_OPTION, timeout: int = TIMEOUT_OPTION): + """Check logging service health. + + Examples + -------- + scm operations logging-status --device fw-01 + + """ + _run_operation(device, "logging-status", async_mode, timeout) + + +@app.command("status") +def operation_status(job_id: str = typer.Option(..., "--job-id", "-j", help="Job ID to check")): + """Check status of a dispatched device operation job. + + Examples + -------- + scm operations status --job-id abc-123 + + """ + try: + result = scm_client.get_device_operation_status(job_id=job_id) + + typer.echo(f"\nJob Details for ID: {result.get('job_id', job_id)}") + typer.echo("-" * 50) + for key, value in result.items(): + if value is not None and value != "" and value != []: + typer.echo(f" {key}: {value}") + + except Exception as e: + typer.echo(f"Error checking job status: {e!s}", err=True) + raise typer.Exit(code=1) from e diff --git a/src/scm_cli/main.py b/src/scm_cli/main.py index 433b97d..b1c3f8f 100644 --- a/src/scm_cli/main.py +++ b/src/scm_cli/main.py @@ -7,7 +7,7 @@ import typer # Import object type modules -from .commands import commit, context, deployment, identity, insights, jobs, mobile_agent, network, objects, posture, security, setup +from .commands import commit, context, deployment, identity, incidents, insights, jobs, local, mobile_agent, network, objects, operations, posture, security, setup # ============================================================================================================================================================================================= # MAIN CLI APPLICATION @@ -280,8 +280,11 @@ # Register top-level commands (alphabetical) app.add_typer(commit.app, name="commit") app.add_typer(context.app, name="context") +app.add_typer(incidents.app, name="incidents") app.add_typer(insights.app, name="insights") app.add_typer(jobs.app, name="jobs") +app.add_typer(local.app, name="local") +app.add_typer(operations.app, name="operations") app.add_typer(posture.posture_app, name="posture") @@ -289,9 +292,22 @@ # Use 'scm context test' to test the current context # Use 'scm context test ' to test a specific context without switching +_region_override: str | None = None + + +def get_region_override() -> str | None: + """Get the global --region override value, if set.""" + return _region_override + @app.callback() -def callback(): +def callback( + region: str | None = typer.Option( + None, + "--region", + help="Override SCM API region for this invocation", + ), +): """Manage Palo Alto Networks Strata Cloud Manager (SCM) configurations. The CLI follows the pattern: [options] @@ -306,7 +322,8 @@ def callback(): - scm context test """ - pass + global _region_override # noqa: PLW0603 + _region_override = region # ============================================================================================================================================================================================= diff --git a/src/scm_cli/utils/config.py b/src/scm_cli/utils/config.py index 8b29d2d..28f3e75 100644 --- a/src/scm_cli/utils/config.py +++ b/src/scm_cli/utils/config.py @@ -101,6 +101,9 @@ def get_auth_config() -> dict[str, str]: if missing: raise ValueError(f"Missing required authentication parameters: {', '.join(missing)}") + # Add region (not required — defaults to americas) + auth["region"] = settings.get("region", "americas") + return auth diff --git a/src/scm_cli/utils/context.py b/src/scm_cli/utils/context.py index 1f33e92..a3b44db 100644 --- a/src/scm_cli/utils/context.py +++ b/src/scm_cli/utils/context.py @@ -94,6 +94,7 @@ def create_context( tsg_id: str = "", log_level: str = "INFO", access_token: str | None = None, + region: str = "americas", ) -> None: """Create or update a context configuration. @@ -105,6 +106,7 @@ def create_context( tsg_id: Tenant Service Group ID. log_level: Logging level (default: INFO). access_token: Bearer token for direct auth (alternative to OAuth2). + region: SCM API region (default: americas). """ ensure_context_dir() @@ -113,6 +115,7 @@ def create_context( config: dict[str, Any] = { "log_level": log_level, + "region": region, } if access_token: diff --git a/src/scm_cli/utils/sdk_client.py b/src/scm_cli/utils/sdk_client.py index d66a3b3..ac1427f 100644 --- a/src/scm_cli/utils/sdk_client.py +++ b/src/scm_cli/utils/sdk_client.py @@ -17,7 +17,7 @@ from oauthlib.oauth2.rfc6749.errors import InvalidClientError from pydantic import ValidationError from scm.client import Scm -from scm.exceptions import APIError, AuthenticationError, ClientError, NotFoundError +from scm.exceptions import APIError, AuthenticationError, ClientError, GatewayTimeoutError, NotFoundError from .config import get_credentials, settings from .context import get_current_context @@ -97,9 +97,20 @@ def __init__(self): self.client_secret = "" # noqa: S105 self.tsg_id = "" self._bearer_token_mode = True + + # Resolve region: global flag > context > default + try: + from scm_cli.main import get_region_override + + region_override = get_region_override() + except ImportError: + region_override = None + resolved_region = region_override or settings.get("region", "americas") + self.client = Scm( access_token=access_token, log_level=settings.get("log_level", "INFO"), + region=resolved_region, ) self.logger.info("Successfully initialized SDK client with bearer token") else: @@ -109,11 +120,21 @@ def __init__(self): self.client_secret = credentials["client_secret"] self.tsg_id = credentials["tsg_id"] + # Resolve region: global flag > context > default + try: + from scm_cli.main import get_region_override + + region_override = get_region_override() + except ImportError: + region_override = None + resolved_region = region_override or credentials.get("region", "americas") + self.client = Scm( client_id=self.client_id, client_secret=self.client_secret, tsg_id=self.tsg_id, log_level=settings.get("log_level", "INFO"), + region=resolved_region, ) self.logger.info(f"Successfully initialized SDK client for TSG ID: {self.tsg_id}") except (ValueError, AuthenticationError) as e: @@ -250,6 +271,12 @@ def _handle_api_exception(self, operation: str, folder: str, resource_name: str, self.logger.error(f"SDK service not available for {resource_name}: {str(exception)}. This feature may not be implemented in the current pan-scm-sdk version.") elif isinstance(exception, ClientError): self.logger.error(f"Validation error during {operation} of {resource_name}: {str(exception)}") + elif isinstance(exception, GatewayTimeoutError): + self.logger.error( + f"Request timed out during {operation} of {resource_name}: {str(exception)}. " + "The operation may still be processing on the server. " + "Retry after a brief wait or check the SCM portal for current status." + ) elif isinstance(exception, APIError): self.logger.error(f"API error during {operation} of {resource_name}: {str(exception)}") else: @@ -7500,7 +7527,7 @@ def create_anti_spyware_profile( profile_data["id"] = existing_profile.id from scm.models.security import AntiSpywareProfileUpdateModel - update_model = AntiSpywareProfileUpdateModel(**profile_data) + update_model = AntiSpywareProfileUpdateModel(**profile_data) # type: ignore[arg-type] result = self.client.anti_spyware_profile.update(update_model) else: # Create a new profile @@ -8031,7 +8058,7 @@ def create_wildfire_antivirus_profile( profile_data["id"] = existing_profile.id from scm.models.security import WildfireAvProfileUpdateModel - update_model = WildfireAvProfileUpdateModel(**profile_data) + update_model = WildfireAvProfileUpdateModel(**profile_data) # type: ignore[arg-type] result = self.client.wildfire_antivirus_profile.update(update_model) else: # Create a new profile @@ -8601,7 +8628,7 @@ def create_vulnerability_protection_profile( profile_data["id"] = existing_profile.id from scm.models.security import VulnerabilityProfileUpdateModel - update_model = VulnerabilityProfileUpdateModel(**profile_data) + update_model = VulnerabilityProfileUpdateModel(**profile_data) # type: ignore[arg-type] result = self.client.vulnerability_protection_profile.update(update_model) else: # Create a new profile @@ -8871,7 +8898,7 @@ def create_url_category( data["id"] = existing.id from scm.models.security.url_categories import URLCategoriesUpdateModel - update_model = URLCategoriesUpdateModel(**data) + update_model = URLCategoriesUpdateModel(**data) # type: ignore[arg-type] result = self.client.url_category.update(update_model) result_dict = json.loads(result.model_dump_json(exclude_unset=True)) result_dict["__action__"] = "updated" @@ -10456,7 +10483,7 @@ def create_internal_dns_server( } if secondary: update_data["secondary"] = secondary - update_model = InternalDnsServersUpdateModel(**update_data) + update_model = InternalDnsServersUpdateModel(**update_data) # type: ignore[arg-type] updated = self.client.internal_dns_server.update(update_model) self.logger.info(f"Successfully updated internal DNS server '{name}'") result = json.loads(updated.model_dump_json(exclude_unset=True)) @@ -15997,6 +16024,274 @@ def fetch_bpa_report(self, report_url: str) -> dict[str, Any]: response.raise_for_status() return response.json() + # ====================================================================================================================================================================================== + # LOCAL CONFIG METHODS + # ====================================================================================================================================================================================== + + def list_local_config_versions(self, device: str) -> list[dict[str, Any]]: + """List configuration versions for a device. + + Args: + device: Device name to list versions for. + + Returns: + list[dict[str, Any]]: List of config version objects. + + """ + self.logger.info(f"Listing local config versions for device: {device}") + + if not self.client: + return [ + {"version": 42, "date": "2026-04-15 14:30", "author": "admin", "description": "Policy update"}, + {"version": 41, "date": "2026-04-14 09:12", "author": "auto-commit", "description": "Scheduled push"}, + {"version": 40, "date": "2026-04-13 11:45", "author": "admin", "description": "Initial config"}, + ] + + try: + results = self.client.local_config.list(device=device) + return [json.loads(r.model_dump_json(exclude_unset=True)) for r in results] + except Exception as e: + self._handle_api_exception("listing", "N/A", f"local config versions for {device}", e) + + def download_local_config(self, device: str, version: int) -> bytes: + """Download a configuration version as raw XML. + + Args: + device: Device name. + version: Config version number to download. + + Returns: + bytes: Raw XML configuration data. + + """ + self.logger.info(f"Downloading local config version {version} for device: {device}") + + if not self.client: + return b'\n\n \n \n \n \n \n' + + try: + return self.client.local_config.download(device=device, version=version) + except Exception as e: + self._handle_api_exception("downloading", "N/A", f"local config v{version} for {device}", e) + + # ====================================================================================================================================================================================== + # DEVICE OPERATIONS METHODS + # ====================================================================================================================================================================================== + + _OPERATION_MOCK_RESULTS = { + "route-table": [ + {"destination": "0.0.0.0/0", "next_hop": "10.0.0.1", "interface": "ethernet1/1", "metric": 10}, + {"destination": "10.1.0.0/16", "next_hop": "10.0.0.2", "interface": "ethernet1/2", "metric": 20}, + ], + "fib-table": [ + {"destination": "0.0.0.0/0", "interface": "ethernet1/1", "next_hop": "10.0.0.1", "flags": "u"}, + ], + "dns-proxy": [ + {"domain": "example.com", "primary": "8.8.8.8", "secondary": "8.8.4.4", "status": "active"}, + ], + "interfaces": [ + {"name": "ethernet1/1", "status": "up", "ip": "10.0.0.1/24", "speed": "1Gbps"}, + {"name": "ethernet1/2", "status": "up", "ip": "10.1.0.1/24", "speed": "1Gbps"}, + ], + "device-rules": [ + {"name": "allow-web", "action": "allow", "from": "trust", "to": "untrust"}, + ], + "bgp-export": [ + {"prefix": "10.0.0.0/8", "next_hop": "10.0.0.1", "as_path": "65001 65002"}, + ], + "logging-status": [ + {"service": "cortex-data-lake", "status": "connected", "last_log": "2026-04-16 10:30:00"}, + ], + } + + def dispatch_device_operation( + self, + device: str, + operation: str, + sync: bool = True, + timeout: int = 300, + ) -> dict[str, Any]: + """Dispatch a device operation job. + + Args: + device: Device name to run operation on. + operation: Operation type (route-table, fib-table, etc.). + sync: If True, poll until completion. If False, return job_id immediately. + timeout: Timeout in seconds for sync polling. + + Returns: + dict: Results if sync, or job_id if async. + + """ + self.logger.info(f"Dispatching {operation} for device {device} (sync={sync})") + + if not self.client: + if sync: + return { + "status": "completed", + "job_id": f"mock-job-{operation}", + "device": device, + "operation": operation, + "results": self._OPERATION_MOCK_RESULTS.get(operation, []), + } + return { + "job_id": f"mock-job-{operation}", + "device": device, + "operation": operation, + "status": "pending", + } + + try: + job = self.client.device_operations.dispatch( + device=device, + operation=operation, + ) + if sync: + result = self.client.device_operations.wait( + job_id=job.job_id, + timeout=timeout, + ) + return json.loads(result.model_dump_json(exclude_unset=True)) + return {"job_id": job.job_id, "device": device, "operation": operation, "status": "pending"} + except Exception as e: + self._handle_api_exception("dispatching", "N/A", f"{operation} for {device}", e) + + def get_device_operation_status(self, job_id: str) -> dict[str, Any]: + """Get status of a device operation job. + + Args: + job_id: The job ID to check. + + Returns: + dict: Job status information. + + """ + self.logger.info(f"Checking status of job {job_id}") + + if not self.client: + return { + "job_id": job_id, + "state": "completed", + "device": "fw-01", + "operation": "route-table", + "started": "2026-04-16 10:30:00", + "completed": "2026-04-16 10:30:42", + } + + try: + result = self.client.device_operations.status(job_id=job_id) + return json.loads(result.model_dump_json(exclude_unset=True)) + except Exception as e: + self._handle_api_exception("checking status", "N/A", f"job {job_id}", e) + + # ====================================================================================================================================================================================== + # INCIDENTS METHODS + # ====================================================================================================================================================================================== + + _MOCK_INCIDENTS = [ + { + "id": "INC-2026-04-001", + "status": "open", + "severity": "high", + "product": "Prisma Access", + "summary": "Suspicious lateral movement detected from 10.1.2.50", + "created": "2026-04-15 08:23:00", + "updated": "2026-04-16 02:15:00", + "alerts": [ + {"severity": "high", "description": "Unusual SMB traffic from 10.1.2.50 to 10.1.2.100", "timestamp": "2026-04-15 08:23"}, + {"severity": "high", "description": "Credential dumping tool detected on 10.1.2.50", "timestamp": "2026-04-15 08:25"}, + {"severity": "medium", "description": "DNS tunneling attempt from 10.1.2.50", "timestamp": "2026-04-15 08:30"}, + ], + "remediation": [ + "Isolate host 10.1.2.50 from network", + "Reset credentials for affected accounts", + "Scan 10.1.2.100 for indicators of compromise", + ], + }, + { + "id": "INC-2026-04-002", + "status": "open", + "severity": "critical", + "product": "NGFW", + "summary": "C2 callback detected from internal host", + "created": "2026-04-14 16:45:00", + "updated": "2026-04-15 09:00:00", + "alerts": [ + {"severity": "critical", "description": "Known C2 domain contacted by 10.2.1.30", "timestamp": "2026-04-14 16:45"}, + {"severity": "high", "description": "Encrypted payload exfiltration attempt", "timestamp": "2026-04-14 16:50"}, + ], + "remediation": [ + "Block C2 domain at firewall", + "Isolate 10.2.1.30", + "Forensic analysis of affected host", + ], + }, + { + "id": "INC-2026-03-088", + "status": "closed", + "severity": "medium", + "product": "Prisma Access", + "summary": "Policy violation — data exfiltration attempt", + "created": "2026-03-28 14:00:00", + "updated": "2026-03-29 11:30:00", + "alerts": [ + {"severity": "medium", "description": "Large file upload to unapproved cloud storage", "timestamp": "2026-03-28 14:00"}, + ], + "remediation": [ + "User counseling completed", + "DLP policy updated to block unapproved storage", + ], + }, + ] + + def list_incidents( + self, + status: str | None = None, + severity: str | None = None, + product: str | None = None, + ) -> list[dict[str, Any]]: + """Search incidents with optional filters.""" + self.logger.info(f"Listing incidents (status={status}, severity={severity}, product={product})") + + if not self.client: + results = list(self._MOCK_INCIDENTS) + if status: + results = [i for i in results if i["status"] == status] + if severity: + results = [i for i in results if i["severity"] == severity] + if product: + results = [i for i in results if i["product"] == product] + return results + + try: + kwargs: dict[str, Any] = {} + if status: + kwargs["status"] = status + if severity: + kwargs["severity"] = severity + if product: + kwargs["product"] = product + results = self.client.incidents.search(**kwargs) + return [json.loads(r.model_dump_json(exclude_unset=True)) for r in results] + except Exception as e: + self._handle_api_exception("searching", "N/A", "incidents", e) + + def get_incident(self, incident_id: str) -> dict[str, Any]: + """Get detailed incident information including alerts and remediation.""" + self.logger.info(f"Getting incident detail: {incident_id}") + + if not self.client: + for inc in self._MOCK_INCIDENTS: + if inc["id"] == incident_id: + return inc + return self._MOCK_INCIDENTS[0] + + try: + result = self.client.incidents.get(incident_id=incident_id) + return json.loads(result.model_dump_json(exclude_unset=True)) + except Exception as e: + self._handle_api_exception("fetching", "N/A", f"incident {incident_id}", e) + class LazyClient: """Lazy wrapper for SCMClient that delays initialization until first use.""" diff --git a/tests/test_context_region.py b/tests/test_context_region.py new file mode 100644 index 0000000..978d734 --- /dev/null +++ b/tests/test_context_region.py @@ -0,0 +1,181 @@ +"""Tests for region support in context management.""" + +import os + +import yaml +import pytest + +from src.scm_cli.utils.context import create_context, get_context_config +from src.scm_cli.utils.config import get_auth_config +from src.scm_cli.main import app as main_app +from src.scm_cli.commands import context as context_module + +main_app.add_typer(context_module.app, name="context") + + +class TestContextRegion: + """Test region field in context storage.""" + + def test_create_context_with_region(self, tmp_path, monkeypatch): + """Region is stored in context YAML when provided.""" + monkeypatch.setattr("src.scm_cli.utils.context.CONTEXT_DIR", str(tmp_path / "contexts")) + monkeypatch.setattr("src.scm_cli.utils.context.CURRENT_CONTEXT_FILE", str(tmp_path / "current-context")) + + create_context( + context_name="test-region", + client_id="cid", + client_secret="csec", + tsg_id="tsg", + region="europe", + ) + + context_file = tmp_path / "contexts" / "test-region.yaml" + assert context_file.exists() + with open(context_file) as f: + data = yaml.safe_load(f) + assert data["region"] == "europe" + + def test_create_context_default_region(self, tmp_path, monkeypatch): + """Region defaults to 'americas' when not provided.""" + monkeypatch.setattr("src.scm_cli.utils.context.CONTEXT_DIR", str(tmp_path / "contexts")) + monkeypatch.setattr("src.scm_cli.utils.context.CURRENT_CONTEXT_FILE", str(tmp_path / "current-context")) + + create_context( + context_name="test-default", + client_id="cid", + client_secret="csec", + tsg_id="tsg", + ) + + context_file = tmp_path / "contexts" / "test-default.yaml" + with open(context_file) as f: + data = yaml.safe_load(f) + assert data["region"] == "americas" + + def test_old_context_without_region_defaults(self, tmp_path, monkeypatch): + """Old context files without region field return 'americas'.""" + monkeypatch.setattr("src.scm_cli.utils.context.CONTEXT_DIR", str(tmp_path / "contexts")) + monkeypatch.setattr("src.scm_cli.utils.context.CURRENT_CONTEXT_FILE", str(tmp_path / "current-context")) + + os.makedirs(tmp_path / "contexts", exist_ok=True) + old_context = tmp_path / "contexts" / "legacy.yaml" + old_context.write_text("client_id: cid\nclient_secret: csec\ntsg_id: tsg\n") + + config = get_context_config("legacy") + assert config.get("region", "americas") == "americas" + + +class TestContextCreateRegionCLI: + """Test --region flag on context create command.""" + + def test_create_with_region_flag(self, runner, tmp_path, monkeypatch): + """--region flag stores region in context.""" + monkeypatch.setattr("src.scm_cli.utils.context.CONTEXT_DIR", str(tmp_path / "contexts")) + monkeypatch.setattr("src.scm_cli.utils.context.CURRENT_CONTEXT_FILE", str(tmp_path / "current-context")) + + result = runner.invoke(main_app, [ + "context", "create", "eu-prod", + "--client-id", "cid", + "--client-secret", "csec", + "--tsg-id", "tsg", + "--region", "europe", + "--no-set-current", + ]) + + assert result.exit_code == 0 + context_file = tmp_path / "contexts" / "eu-prod.yaml" + with open(context_file) as f: + data = yaml.safe_load(f) + assert data["region"] == "europe" + + def test_create_without_region_defaults_americas(self, runner, tmp_path, monkeypatch): + """Omitting --region stores 'americas' as default.""" + monkeypatch.setattr("src.scm_cli.utils.context.CONTEXT_DIR", str(tmp_path / "contexts")) + monkeypatch.setattr("src.scm_cli.utils.context.CURRENT_CONTEXT_FILE", str(tmp_path / "current-context")) + + result = runner.invoke(main_app, [ + "context", "create", "us-prod", + "--client-id", "cid", + "--client-secret", "csec", + "--tsg-id", "tsg", + "--no-set-current", + ]) + + assert result.exit_code == 0 + context_file = tmp_path / "contexts" / "us-prod.yaml" + with open(context_file) as f: + data = yaml.safe_load(f) + assert data["region"] == "americas" + + +class TestRegionInAuthConfig: + """Test region flows through auth config.""" + + def test_auth_config_includes_region_from_settings(self, monkeypatch): + """get_auth_config returns region from settings.""" + monkeypatch.setenv("SCM_SCM_CLIENT_ID", "cid") + monkeypatch.setenv("SCM_SCM_CLIENT_SECRET", "csec") + monkeypatch.setenv("SCM_SCM_TSG_ID", "tsg") + + import src.scm_cli.utils.config as src_config_mod + from scm_cli.utils.context import get_context_aware_settings + + monkeypatch.setattr(src_config_mod, "settings", get_context_aware_settings()) + + auth = get_auth_config() + assert "region" in auth + assert auth["region"] == "americas" # default when not set + + def test_auth_config_region_from_context(self, tmp_path, monkeypatch): + """get_auth_config picks up region from context settings.""" + import scm_cli.utils.context as ctx_mod + import src.scm_cli.utils.context as src_ctx_mod + import src.scm_cli.utils.config as src_config_mod + + # Patch both module instances (src. prefix and without) + ctx_dir = str(tmp_path / "contexts") + cur_ctx_file = str(tmp_path / "current-context") + for mod in (ctx_mod, src_ctx_mod): + monkeypatch.setattr(mod, "CONTEXT_DIR", ctx_dir) + monkeypatch.setattr(mod, "CURRENT_CONTEXT_FILE", cur_ctx_file) + monkeypatch.setattr(mod, "get_current_context", lambda: "eu") + + create_context( + context_name="eu", + client_id="cid", + client_secret="csec", + tsg_id="tsg", + region="europe", + ) + + # Must patch the src. module since get_auth_config was imported from there + new_settings = ctx_mod.get_context_aware_settings() + monkeypatch.setattr(src_config_mod, "settings", new_settings) + + auth = get_auth_config() + assert auth["region"] == "europe" + + +class TestRegionOverride: + """Test global --region flag override.""" + + def test_get_region_override_default_none(self): + """get_region_override returns None when no override set.""" + from scm_cli.main import get_region_override + + # Reset the module-level variable + import scm_cli.main as main_mod + + main_mod._region_override = None + assert get_region_override() is None + + def test_region_override_set(self): + """get_region_override returns value after being set.""" + from scm_cli.main import get_region_override + import sys + + main_mod = sys.modules["scm_cli.main"] + main_mod._region_override = "europe" + assert get_region_override() == "europe" + # Clean up + main_mod._region_override = None diff --git a/tests/test_incidents_commands.py b/tests/test_incidents_commands.py new file mode 100644 index 0000000..1ca5f5a --- /dev/null +++ b/tests/test_incidents_commands.py @@ -0,0 +1,111 @@ +"""Tests for incidents commands.""" + +import json + +import pytest + +from src.scm_cli.main import app +from src.scm_cli.commands import incidents as incidents_module +from src.scm_cli.utils.sdk_client import SCMClient + +app.add_typer(incidents_module.app, name="incidents") + + +@pytest.fixture +def mock_incidents_env(monkeypatch, tmp_path): + """Set up mock environment for incidents tests.""" + monkeypatch.setattr("src.scm_cli.utils.context.CURRENT_CONTEXT_FILE", str(tmp_path / "current-context")) + monkeypatch.setattr("src.scm_cli.utils.context.CONTEXT_DIR", str(tmp_path / "contexts")) + monkeypatch.setenv("SCM_CLIENT_ID", "") + monkeypatch.setenv("SCM_CLIENT_SECRET", "") + monkeypatch.setenv("SCM_TSG_ID", "") + monkeypatch.setenv("SCM_SCM_CLIENT_ID", "") + monkeypatch.setenv("SCM_SCM_CLIENT_SECRET", "") + monkeypatch.setenv("SCM_SCM_TSG_ID", "") + + +class TestIncidentsSDKClient: + """Test SDK client methods for incidents.""" + + def test_list_incidents_mock(self): + client = SCMClient.__new__(SCMClient) + client.client = None + client.logger = __import__("logging").getLogger("test") + result = client.list_incidents() + assert isinstance(result, list) + assert len(result) >= 2 + assert "id" in result[0] + assert "status" in result[0] + assert "severity" in result[0] + + def test_list_incidents_filter_status(self): + client = SCMClient.__new__(SCMClient) + client.client = None + client.logger = __import__("logging").getLogger("test") + result = client.list_incidents(status="open") + assert all(i["status"] == "open" for i in result) + + def test_get_incident_mock(self): + client = SCMClient.__new__(SCMClient) + client.client = None + client.logger = __import__("logging").getLogger("test") + result = client.get_incident(incident_id="INC-2026-04-001") + assert isinstance(result, dict) + assert "id" in result + assert "alerts" in result + assert "remediation" in result + assert len(result["alerts"]) > 0 + + +class TestIncidentsList: + """Test incidents list command.""" + + def test_list_incidents_table(self, runner, mock_incidents_env): + result = runner.invoke(app, ["incidents", "list"]) + if result.exit_code != 0: + print(f"Output: {result.output}") + print(f"Exception: {result.exception}") + assert result.exit_code == 0 + assert "INC-2026-04-001" in result.output + assert "high" in result.output + + def test_list_incidents_filter_status(self, runner, mock_incidents_env): + result = runner.invoke(app, ["incidents", "list", "--status", "closed"]) + assert result.exit_code == 0 + assert "INC-2026-03-088" in result.output + assert "INC-2026-04-001" not in result.output + + def test_list_incidents_json(self, runner, mock_incidents_env): + result = runner.invoke(app, ["incidents", "list", "--json"]) + assert result.exit_code == 0 + data = json.loads(result.output) + assert isinstance(data, list) + assert len(data) >= 2 + + def test_list_incidents_empty(self, runner, mock_incidents_env): + result = runner.invoke(app, ["incidents", "list", "--severity", "informational"]) + assert result.exit_code == 0 + assert "No incidents found" in result.output + + +class TestIncidentsShow: + """Test incidents show command.""" + + def test_show_incident_detail(self, runner, mock_incidents_env): + result = runner.invoke(app, ["incidents", "show", "INC-2026-04-001"]) + if result.exit_code != 0: + print(f"Output: {result.output}") + print(f"Exception: {result.exception}") + assert result.exit_code == 0 + assert "INC-2026-04-001" in result.output + assert "Suspicious lateral movement" in result.output + assert "Alerts" in result.output + assert "Remediation" in result.output + + def test_show_incident_json(self, runner, mock_incidents_env): + result = runner.invoke(app, ["incidents", "show", "INC-2026-04-001", "--json"]) + assert result.exit_code == 0 + data = json.loads(result.output) + assert data["id"] == "INC-2026-04-001" + assert "alerts" in data + assert "remediation" in data diff --git a/tests/test_job_timeout.py b/tests/test_job_timeout.py new file mode 100644 index 0000000..bcf815d --- /dev/null +++ b/tests/test_job_timeout.py @@ -0,0 +1,54 @@ +"""Tests for timeout error handling.""" + +import pytest + +from src.scm_cli.utils.sdk_client import SCMClient + + +class TestGatewayTimeoutError: + """Test GatewayTimeoutError is handled properly.""" + + def test_handle_gateway_timeout_logs_and_reraises(self): + """_handle_api_exception logs GatewayTimeoutError with timeout-specific message.""" + from scm.exceptions import GatewayTimeoutError + + client = SCMClient.__new__(SCMClient) + client.logger = __import__("logging").getLogger("test") + client.client = None + + exc = GatewayTimeoutError(message="gateway timeout", http_status_code=504) + + with pytest.raises(GatewayTimeoutError): + client._handle_api_exception("dispatch", "N/A", "route-table", exc) + + def test_handle_session_timeout_logs_and_reraises(self): + """_handle_api_exception logs SessionTimeoutError (subclass of GatewayTimeoutError).""" + from scm.exceptions import SessionTimeoutError + + client = SCMClient.__new__(SCMClient) + client.logger = __import__("logging").getLogger("test") + client.client = None + + exc = SessionTimeoutError(message="session timeout", http_status_code=504) + + with pytest.raises(SessionTimeoutError): + client._handle_api_exception("dispatch", "N/A", "route-table", exc) + + def test_gateway_timeout_log_message_content(self, caplog): + """Verify the log message contains timeout-specific details.""" + import logging + + from scm.exceptions import GatewayTimeoutError + + client = SCMClient.__new__(SCMClient) + client.logger = logging.getLogger("test_timeout_msg") + client.client = None + + exc = GatewayTimeoutError(message="gateway timeout", http_status_code=504) + + with caplog.at_level(logging.ERROR, logger="test_timeout_msg"), pytest.raises(GatewayTimeoutError): + client._handle_api_exception("create", "Texas", "fw-rule-01", exc) + + assert "timed out" in caplog.text + assert "fw-rule-01" in caplog.text + assert "create" in caplog.text diff --git a/tests/test_local_commands.py b/tests/test_local_commands.py new file mode 100644 index 0000000..5230a68 --- /dev/null +++ b/tests/test_local_commands.py @@ -0,0 +1,104 @@ +"""Tests for local config commands.""" + +import os + +import pytest + +from src.scm_cli.main import app +from src.scm_cli.commands import local as local_module +from src.scm_cli.utils.sdk_client import SCMClient + +app.add_typer(local_module.app, name="local") + + +@pytest.fixture +def mock_local_env(monkeypatch, tmp_path): + """Set up mock environment for local config tests.""" + monkeypatch.setattr("src.scm_cli.utils.context.CURRENT_CONTEXT_FILE", str(tmp_path / "current-context")) + monkeypatch.setattr("src.scm_cli.utils.context.CONTEXT_DIR", str(tmp_path / "contexts")) + monkeypatch.setenv("SCM_CLIENT_ID", "") + monkeypatch.setenv("SCM_CLIENT_SECRET", "") + monkeypatch.setenv("SCM_TSG_ID", "") + monkeypatch.setenv("SCM_SCM_CLIENT_ID", "") + monkeypatch.setenv("SCM_SCM_CLIENT_SECRET", "") + monkeypatch.setenv("SCM_SCM_TSG_ID", "") + + +class TestLocalConfigSDKClient: + """Test SDK client methods for local config.""" + + def test_list_local_config_versions_mock(self): + """list_local_config_versions returns mock data when no client.""" + client = SCMClient.__new__(SCMClient) + client.client = None + client.logger = __import__("logging").getLogger("test") + + result = client.list_local_config_versions(device="fw-01") + assert isinstance(result, list) + assert len(result) > 0 + assert "version" in result[0] + assert "date" in result[0] + + def test_download_local_config_mock(self): + """download_local_config returns mock XML bytes when no client.""" + client = SCMClient.__new__(SCMClient) + client.client = None + client.logger = __import__("logging").getLogger("test") + + result = client.download_local_config(device="fw-01", version=42) + assert isinstance(result, bytes) + assert b"