Skip to content

feat: add global --output-format json for structured CLI output - #773

Open
glenn-sq wants to merge 4 commits into
panther-labs:mainfrom
glenn-sq:feat/json-output-all-commands
Open

feat: add global --output-format json for structured CLI output#773
glenn-sq wants to merge 4 commits into
panther-labs:mainfrom
glenn-sq:feat/json-output-all-commands

Conversation

@glenn-sq

@glenn-sq glenn-sq commented Mar 10, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a global --output-format {text,json} option to all CLI commands, enabling structured JSON output for CI/CD integration and programmatic consumption. When enabled, all logging redirects to stderr and each command emits a single JSON object to stdout.

This is a more complete implementation of the feature requested in #634, extending JSON output beyond just the test command to cover the entire CLI surface. Supersedes #772.

Architecture

  • Global option: --output-format json (or PANTHER_OUTPUT_FORMAT=json envvar) is registered in the Typer app callback and applies to every command
  • Automatic coverage: Commands that don't produce custom JSON automatically receive a generic {command, status, return_code} envelope via the call_and_exit wrapper
  • Custom JSON: Commands with rich domain data produce structured, command-specific output
  • Clean stdout: All logging is redirected to stderr in JSON mode; helper functions that print informational text are suppressed
  • Interactive prompts: Skipped in JSON mode (e.g., bulk delete confirmation) with a log message to stderr

Commands with custom JSON output

Command JSON data includes
test Summary counts, per-detection results with function-level pass/fail/error, failures, invalid specs, skipped tests
validate Validation result, issues list, error details
upload Full API response with upload results
benchmark Timing statistics (mean/median/max/min), performance rating, per-iteration data
check-packs Missing items, items not in packs, experimental/deprecated violations
delete Deleted detection IDs and query names
merge Updated item IDs, merge conflict IDs, preview mode
migrate Migration status, conflicts, warnings
enrich-test-data Enriched analysis IDs
update-custom-schemas Per-schema results
init Project initialization status

All other commands (zip, release, fmt, check-connection, test-lookup-table, publish, explore, install, update) receive the generic envelope automatically.

Changes

New/modified files:

  • panther_analysis_tool/main.py — Global --output-format in app callback, _emit_json_result() generic envelope, _COMMANDS_WITH_OWN_JSON routing, test command JSON output, _emit_check_packs_json(), upload/enrich/update-schemas JSON paths
  • panther_analysis_tool/command/standard_args.pyOutputFormat(str, Enum) definition
  • panther_analysis_tool/command/validate.py_emit_validate_json() with validation result/issues
  • panther_analysis_tool/command/benchmark.py_emit_benchmark_json() with timing stats and performance rating
  • panther_analysis_tool/command/bulk_delete.py_emit_delete_json() with deletion results
  • panther_analysis_tool/command/merge.py_emit_merge_json() with update/conflict tracking
  • panther_analysis_tool/command/migrate.py_emit_migrate_json() leveraging existing MigrationStatus.to_dict()
  • panther_analysis_tool/command/init_project.py — JSON output with stdout suppression for helper functions
  • tests/unit/panther_analysis_tool/test_json_output.py — 47 unit tests

Version bump: 1.5.2 → 1.6.0

Usage Examples

test — Run tests with full JSON output
panther_analysis_tool --output-format json test \
  --path rules/ \
  --filter RuleID=Crowdstrike.Detection.Passthrough 2>/dev/null
{
  "summary": {
    "path": "rules/",
    "total": 1,
    "passed": 1,
    "failed": 0,
    "invalid": 0,
    "skipped": 0
  },
  "results": {
    "Crowdstrike.Detection.Passthrough": [
      {
        "name": "Low Severity Finding",
        "passed": true,
        "errored": false,
        "functions": [
          {"name": "rule", "status": "pass", "output": "true"},
          {"name": "title", "status": "pass", "output": "Crowdstrike Alert: NGAV on macbook"},
          {"name": "severity", "status": "pass", "output": "LOW"}
        ]
      }
    ]
  },
  "failed": {},
  "invalid": [],
  "skipped": []
}

Extract just the summary:

panther_analysis_tool --output-format json test --path rules/ 2>/dev/null | jq '.summary'

Extract a specific test result:

panther_analysis_tool --output-format json test \
  --path rules/ \
  --filter RuleID=Crowdstrike.Detection.Passthrough 2>/dev/null \
  | jq '.results["Crowdstrike.Detection.Passthrough"][0] | {name, passed}'
validate — Validate detections against a Panther deployment
panther_analysis_tool --output-format json validate --path rules/ 2>/dev/null
{
  "command": "validate",
  "return_code": 0,
  "status": "success",
  "data": {
    "valid": true,
    "error": null,
    "issues": []
  }
}

On failure:

{
  "command": "validate",
  "return_code": 1,
  "status": "error",
  "data": {
    "valid": false,
    "error": "Validation failed",
    "issues": [{"severity": "error", "message": "Invalid field reference"}]
  }
}
upload — Upload detections to Panther
panther_analysis_tool --output-format json upload --path rules/ 2>/dev/null
{
  "command": "upload",
  "return_code": 0,
  "status": "success",
  "data": {
    "rules": {"new": 0, "modified": 2, "total": 15},
    "policies": {"new": 0, "modified": 0, "total": 3}
  }
}
benchmark — Performance test a detection rule
panther_analysis_tool --output-format json benchmark \
  --path rules/my_rule.py --iterations 10 2>/dev/null
{
  "command": "benchmark",
  "return_code": 0,
  "status": "success",
  "data": {
    "rule": "my_rule.yml",
    "hour": "2026-03-10T12:00:00",
    "iterations_completed": 10,
    "had_error": false,
    "read_time_seconds": {"mean": 0.45, "median": 0.42, "max": 0.78, "min": 0.31},
    "processing_time_seconds": {"mean": 1.23, "median": 1.15, "max": 2.01, "min": 0.89},
    "performance_rating": "highly_performant",
    "iterations": [{"read_time_nanos": 420000000, "processing_time_nanos": 1150000000}]
  }
}
check-packs — Validate pack completeness
panther_analysis_tool --output-format json check-packs --path . 2>/dev/null

Success:

{
  "command": "check-packs",
  "return_code": 0,
  "status": "success"
}

Missing items:

{
  "command": "check-packs",
  "return_code": 1,
  "status": "error",
  "data": {
    "missing_items": [{"path": "packs/core.yml", "missing": ["Rule.Missing"]}]
  }
}
delete — Bulk delete detections or saved queries
panther_analysis_tool --output-format json delete \
  --analysis-id Rule.Old Rule.Deprecated 2>/dev/null
{
  "command": "delete",
  "return_code": 0,
  "status": "success",
  "data": {
    "detections": ["Rule.Old", "Rule.Deprecated"]
  }
}

Note: Interactive confirmation is skipped in JSON mode. A log message is emitted to stderr.

merge — Merge analysis items with latest Panther content
panther_analysis_tool --output-format json merge 2>/dev/null
{
  "command": "merge",
  "return_code": 0,
  "status": "success",
  "data": {
    "preview": false,
    "updated_items": ["AWS.CloudTrail.Root.Activity", "AWS.S3.BucketPolicy"],
    "merge_conflicts": ["Custom.SSO.Login"]
  }
}
migrate — Migrate detections to latest format
panther_analysis_tool --output-format json migrate 2>/dev/null
{
  "command": "migrate",
  "return_code": 0,
  "status": "success",
  "data": {
    "single_item": false,
    "has_conflicts": false,
    "has_warnings": true,
    "empty": false,
    "migrated": ["Rule.A", "Rule.B"],
    "warnings": ["Rule.C: deprecated field 'Threshold'"]
  }
}
enrich-test-data — Enrich test data with live Panther data
panther_analysis_tool --output-format json enrich-test-data --path rules/ 2>/dev/null
{
  "command": "enrich-test-data",
  "return_code": 0,
  "status": "success",
  "data": {
    "enriched_ids": ["AWS.CloudTrail.Root.Activity", "AWS.S3.ServerAccess.Logging"]
  }
}
update-custom-schemas — Update custom log schemas
panther_analysis_tool --output-format json update-custom-schemas --path schemas/ 2>/dev/null
{
  "command": "update-custom-schemas",
  "return_code": 0,
  "status": "success",
  "data": {
    "results": [
      {"failed": false, "summary": "Custom.MyLog: updated successfully"},
      {"failed": false, "summary": "Custom.AuditLog: created"}
    ]
  }
}
init — Initialize a new Panther project
panther_analysis_tool --output-format json init 2>/dev/null
{
  "command": "init",
  "return_code": 0,
  "status": "success",
  "data": {
    "pat_root_created": false
  }
}
Generic envelope — Commands without custom JSON (e.g., zip, fmt)
panther_analysis_tool --output-format json zip --path rules/ --out ./dist 2>/dev/null
{
  "command": "zip",
  "return_code": 0,
  "status": "success",
  "message": "Created archive at ./dist/panther-analysis-all.zip"
}

On error:

{
  "command": "check_connection",
  "return_code": 1,
  "status": "error",
  "errors": [{"error": "Connection refused"}]
}

Testing

47 unit tests covering:

  • Core serialization: _serialize_function_result, _serialize_test_result — pass/fail/error states, function name stripping
  • Test command output: _print_json_output, _print_json_error — all-passing, failures, invalid specs, skipped, negative-passed clamping, non-detection invalids
  • Global infrastructure: is_json_mode, get_output_format, _emit_json_result, _command_emits_own_json — mode switching, generic envelope, routing
  • Command helpers: _emit_check_packs_json, _emit_validate_json, _emit_delete_json, _emit_merge_json — valid JSON output, edge cases
  • Enum: OutputFormat values, string comparison, str subclass

All 630 existing tests pass with zero regressions.

Closes #634

Adds structured JSON output mode to `pat test` via `--output-format json`.
When enabled, all logging is redirected to stderr and a single JSON object
is printed to stdout containing summary counts, per-detection test results,
failures, invalid specs, and skipped tests.

Audit fixes included:
- Clamp num_passed to zero to prevent negative counts in JSON summary
- Guard setup_data_models print() with logging.error() to avoid stdout pollution
- Emit JSON error envelope on early-exit paths (empty specs, no filter match)
- Defensive error access in _serialize_function_result for non-dict errors
- Use OutputFormat(str, Enum) for Typer-level validation and shell completion
- Guard handler.setStream() with isinstance check for non-StreamHandler types
- Buffer errored test results in _run_tests for consistent JSON output
- Use compact JSON (no indent) for machine-consumable output
- Revert cosmetic f-string and cast() changes to reduce diff noise
- Add 19 unit tests covering all JSON serialization and output paths

Version bump to 1.6.0.

Made-with: Cursor
Adds a global --output-format {text,json} option (and PANTHER_OUTPUT_FORMAT
envvar) that enables structured JSON output across all CLI commands.

When enabled, all logging redirects to stderr and commands emit a single
JSON object to stdout.  Commands with rich domain data (test, validate,
benchmark, check-packs, upload, delete, merge, migrate, enrich-test-data,
update-custom-schemas, init) produce custom JSON envelopes.  All other
commands receive a generic {command, status, return_code} envelope
automatically via the call_and_exit wrapper.

Key implementation details:
- Global callback in Typer app sets module-level _output_format and
  redirects logging StreamHandlers to stderr in JSON mode
- _COMMANDS_WITH_OWN_JSON frozenset routes command output correctly
- Test command uses flat schema with summary/results/failed/invalid/skipped
- num_passed calculation uses only detection-related invalid specs
- Interactive prompts (e.g. bulk delete confirmation) are skipped in
  JSON mode with a log message to stderr
- stdout pollution from helper functions is suppressed in JSON mode

47 unit tests covering all JSON serialization, output functions, global
infrastructure, and command-level helpers.

Version bump to 1.6.0.

Closes panther-labs#634

Made-with: Cursor
@glenn-sq
glenn-sq marked this pull request as ready for review March 10, 2026 18:22
@glenn-sq
glenn-sq requested a review from a team March 10, 2026 18:22
@cursor

cursor Bot commented Mar 10, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Touches the CLI entrypoint and output paths across many commands; while behavior is gated behind --output-format json, mistakes could still affect command exit/output handling and CI integrations consuming stdout/stderr.

Overview
Adds a global --output-format {text,json} (or PANTHER_OUTPUT_FORMAT) that switches CLI results to a single structured JSON object on stdout and redirects logging to stderr, with a generic {command,status,return_code} envelope emitted automatically by call_and_exit for commands that don’t implement their own JSON.

Implements command-specific JSON payloads for test (new structured schema + buffering/error capture), validate, upload, benchmark, check-packs, delete, merge, migrate, init, enrich-test-data, and update-custom-schemas, including JSON-mode behavior changes like suppressing helper stdout and skipping interactive confirmation. Adds unit coverage for the JSON infrastructure and bumps version 1.5.21.6.0.

Written by Cursor Bugbot for commit 94e28e1. This will update automatically on new commits. Configure here.

@glenn-sq
glenn-sq marked this pull request as draft March 10, 2026 18:23

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 3 potential issues.

Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Comment thread panther_analysis_tool/command/bulk_delete.py
Comment thread panther_analysis_tool/command/benchmark.py
Comment thread panther_analysis_tool/command/migrate.py
Extract output format state into panther_analysis_tool/output.py to
break circular imports between main and command modules. This replaces
lazy in-function imports with clean top-level imports from the new
shared module.

- Add missing json, Dict, Any imports in benchmark.py
- Fix misplaced type: ignore comments after black reformatting
- Add targeted pylint inline suppressions for complexity warnings
  introduced by JSON output code paths
- Suppress invalid-name on OutputFormat enum members (lowercase by design)

Made-with: Cursor
Commands in _COMMANDS_WITH_OWN_JSON suppress the generic JSON envelope
in call_and_exit, so every return path must emit its own JSON in JSON
mode. Previously, error paths in bulk_delete, benchmark, and migrate
produced no stdout output, breaking CI/CD consumers.

- bulk_delete: emit error JSON on all 5 failure paths
- benchmark: add _emit_benchmark_error_json helper for all early returns
- migrate: emit error JSON on EditorCommandNotFoundError
- Apply isort/black formatting fixes

Made-with: Cursor
@glenn-sq
glenn-sq marked this pull request as ready for review March 10, 2026 19:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add JSON output format option to test command for structured test results

1 participant