diff --git a/api_module_mapping.md b/api_module_mapping.md index 6e91dd7..c52e509 100644 --- a/api_module_mapping.md +++ b/api_module_mapping.md @@ -296,6 +296,8 @@ Following shows mapping between SecOps [REST Resource](https://cloud.google.com/ |logTypes.getLogTypeSetting |v1alpha| | | |logTypes.legacySubmitParserExtension |v1alpha| | | |logTypes.list |v1alpha| | | +|logTypes.getParserAnalysisReport |v1alpha|chronicle.parser.get_analysis_report |secops log-type get-analysis-report | +|logTypes.triggerGitHubChecks |v1alpha|chronicle.parser.trigger_github_checks |secops log-type trigger-checks | |logTypes.logs.export |v1alpha| | | |logTypes.logs.get |v1alpha| | | |logTypes.logs.import |v1alpha|chronicle.log_ingest.ingest_log |secops log ingest | diff --git a/src/secops/chronicle/case.py b/src/secops/chronicle/case.py index 0e78d98..a6e84df 100644 --- a/src/secops/chronicle/case.py +++ b/src/secops/chronicle/case.py @@ -173,7 +173,7 @@ def execute_bulk_assign( Raises: APIError: If the API request fails """ - body = {"casesIds": case_ids, "username": username} + body = {"casesIds": case_ids, "userName": username} return chronicle_request( client, diff --git a/src/secops/chronicle/client.py b/src/secops/chronicle/client.py index 353044e..94e2097 100644 --- a/src/secops/chronicle/client.py +++ b/src/secops/chronicle/client.py @@ -351,6 +351,10 @@ create_watchlist as _create_watchlist, update_watchlist as _update_watchlist, ) +from secops.chronicle.parser import ( + get_analysis_report as _get_analysis_report, + trigger_github_checks as _trigger_github_checks, +) from secops.exceptions import SecOpsError @@ -778,6 +782,51 @@ def update_watchlist( update_mask, ) + def get_analysis_report( + self, + log_type: str, + parser_id: str, + report_id: str, + ) -> dict[str, Any]: + """Get a parser analysis report. + Args: + log_type: Log type of the parser. + parser_id: The ID of the parser. + report_id: The ID of the analysis report. + Returns: + Dictionary containing the analysis report. + Raises: + APIError: If the API request fails. + """ + return _get_analysis_report( + self, + log_type=log_type, + parser_id=parser_id, + report_id=report_id, + ) + + def trigger_github_checks( + self, + associated_pr: str, + log_type: str, + ) -> dict[str, Any]: + """Trigger GitHub checks for a parser. + + Args: + associated_pr: The PR string (e.g., "owner/repo/pull/123"). + log_type: The string name of the LogType enum. + + Returns: + Dictionary containing the response details. + + Raises: + SecOpsError: If gRPC modules or client stub are not available. + APIError: If the gRPC API request fails. + """ + return _trigger_github_checks( + self, associated_pr=associated_pr, log_type=log_type + ) + def get_stats( self, query: str, diff --git a/src/secops/chronicle/parser.py b/src/secops/chronicle/parser.py index f5f9f41..e9dae24 100644 --- a/src/secops/chronicle/parser.py +++ b/src/secops/chronicle/parser.py @@ -16,13 +16,16 @@ import base64 import json +import logging from typing import Any +from secops.chronicle.models import APIVersion from secops.chronicle.utils.format_utils import remove_none_values from secops.chronicle.utils.request_utils import ( chronicle_paginated_request, chronicle_request, ) +from secops.exceptions import APIError, SecOpsError # Constants for size limits MAX_LOG_SIZE = 10 * 1024 * 1024 # 10MB per log @@ -433,3 +436,121 @@ def run_parser( print(f"Warning: Failed to parse statedump: {e}") return result + + +def trigger_github_checks( + client: "ChronicleClient", + associated_pr: str, + log_type: str, + timeout: int = 60, +) -> dict[str, Any]: + """Trigger GitHub checks for a parser. + + Args: + client: ChronicleClient instance + associated_pr: The PR string (e.g., "owner/repo/pull/123"). + log_type: The string name of the LogType enum. + timeout: Optional RPC timeout in seconds (default: 60). + + Returns: + Dictionary containing the response details. + + Raises: + SecOpsError: If input is invalid. + APIError: If the API request fails. + """ + + if not isinstance(log_type, str) or len(log_type.strip()) < 2: + raise SecOpsError("log_type must be a valid string of length >= 2") + + if not isinstance(associated_pr, str) or not associated_pr.strip(): + raise SecOpsError("associated_pr must be a non-empty string") + + pr_parts = associated_pr.split("/") + if len(pr_parts) != 4 or pr_parts[2] != "pull" or not pr_parts[3].isdigit(): + raise SecOpsError( + "associated_pr must be in the format 'owner/repo/pull/'" + ) + if not isinstance(timeout, int) or timeout < 0: + raise SecOpsError("timeout must be a non-negative integer") + + try: + parsers = list_parsers(client, log_type=log_type) + except APIError as e: + raise APIError( + f"Failed to fetch parsers for log type {log_type}: {e}" + ) from e + + if not parsers: + logging.info( + "No parsers found for log type %s. Using fallback parser ID.", + log_type, + ) + parser_name = f"logTypes/{log_type}/parsers/-" + else: + if len(parsers) > 1: + logging.warning( + "Multiple parsers found for log type %s. Using the first one.", + log_type, + ) + parser_name = parsers[0]["name"] + + endpoint_path = f"{parser_name}:runAnalysis" + payload = { + "report_type": "GITHUB_PARSER_VALIDATION", + "pull_request": associated_pr, + } + + return chronicle_request( + client=client, + method="POST", + api_version="v1alpha", + endpoint_path=endpoint_path, + json=payload, + timeout=timeout, + ) + + +def get_analysis_report( + client: "ChronicleClient", + log_type: str, + parser_id: str, + report_id: str, + timeout: int = 60, +) -> dict[str, Any]: + """Get a parser analysis report. + + Args: + client: ChronicleClient instance + log_type: Log type of the parser. + parser_id: The ID of the parser. + report_id: The ID of the analysis report. + timeout: Optional timeout in seconds (default: 60). + + Returns: + Dictionary containing the analysis report. + + Raises: + SecOpsError: If input is invalid. + APIError: If the API request fails. + """ + if not isinstance(log_type, str) or not log_type.strip(): + raise SecOpsError("log_type must be a non-empty string") + if not isinstance(parser_id, str) or not parser_id.strip(): + raise SecOpsError("parser_id must be a non-empty string") + if not isinstance(report_id, str) or not report_id.strip(): + raise SecOpsError("report_id must be a non-empty string") + if not isinstance(timeout, int) or timeout < 0: + raise SecOpsError("timeout must be a non-negative integer") + + endpoint_path = ( + f"logTypes/{log_type}/parsers/{parser_id}/analysisReports/{report_id}" + ) + + return chronicle_request( + client=client, + method="GET", + api_version=APIVersion.V1ALPHA, + endpoint_path=endpoint_path, + timeout=timeout, + ) diff --git a/src/secops/cli/cli_client.py b/src/secops/cli/cli_client.py index 5b31923..bb406d7 100644 --- a/src/secops/cli/cli_client.py +++ b/src/secops/cli/cli_client.py @@ -26,6 +26,7 @@ from secops.cli.commands.investigation import setup_investigation_command from secops.cli.commands.iocs import setup_iocs_command from secops.cli.commands.log import setup_log_command +from secops.cli.commands.log_type import setup_log_type_commands from secops.cli.commands.log_processing import ( setup_log_processing_command, ) @@ -168,6 +169,7 @@ def build_parser() -> argparse.ArgumentParser: setup_investigation_command(subparsers) setup_iocs_command(subparsers) setup_log_command(subparsers) + setup_log_type_commands(subparsers) setup_log_processing_command(subparsers) setup_parser_command(subparsers) setup_parser_extension_command(subparsers) diff --git a/src/secops/cli/commands/log_type.py b/src/secops/cli/commands/log_type.py new file mode 100644 index 0000000..e14e3b4 --- /dev/null +++ b/src/secops/cli/commands/log_type.py @@ -0,0 +1,124 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""CLI for ParserValidationToolingService under Log Type command group""" + +import sys + +from secops.cli.utils.formatters import output_formatter +from secops.exceptions import APIError, SecOpsError + + +def setup_log_type_commands(subparsers): + """Set up the log_type service commands for Parser Validation.""" + log_type_parser = subparsers.add_parser( + "log-type", + help="Log Type related operations (including Parser Validation)", + ) + + log_type_subparsers = log_type_parser.add_subparsers( + title="Log Type Commands", + dest="log_type_command", + help="Log Type sub-command to execute", + ) + + if sys.version_info >= (3, 7): + log_type_subparsers.required = True + + log_type_parser.set_defaults( + func=lambda args, chronicle: log_type_parser.print_help() + ) + + # --- trigger-checks command --- + trigger_github_checks_parser = log_type_subparsers.add_parser( + "trigger-checks", help="Trigger GitHub checks for a parser" + ) + trigger_github_checks_parser.add_argument( + "--associated-pr", + "--associated_pr", + required=True, + help='The PR string (e.g., "owner/repo/pull/123").', + ) + trigger_github_checks_parser.add_argument( + "--log-type", + "--log_type", + required=True, + help='The string name of the LogType enum (e.g., "DUMMY_LOGTYPE").', + ) + trigger_github_checks_parser.set_defaults( + func=handle_trigger_checks_command + ) + + # --- get-analysis-report command --- + get_report_parser = log_type_subparsers.add_parser( + "get-analysis-report", help="Get a parser analysis report" + ) + get_report_parser.add_argument( + "--log-type", + "--log_type", + required=True, + help="The log type of the parser.", + ) + get_report_parser.add_argument( + "--parser-id", + "--parser_id", + required=True, + help="The ID of the parser.", + ) + get_report_parser.add_argument( + "--report-id", + "--report_id", + required=True, + help="The ID of the analysis report.", + ) + get_report_parser.set_defaults(func=handle_get_analysis_report_command) + + +def handle_trigger_checks_command(args, chronicle): + """Handle trigger checks command.""" + try: + result = chronicle.trigger_github_checks( + associated_pr=args.associated_pr, + log_type=args.log_type, + ) + output_formatter(result, args.output) + except APIError as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + except SecOpsError as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + except Exception as e: # pylint: disable=broad-exception-caught + print(f"Error triggering GitHub checks: {e}", file=sys.stderr) + sys.exit(1) + + +def handle_get_analysis_report_command(args, chronicle): + """Handle get analysis report command.""" + try: + result = chronicle.get_analysis_report( + log_type=args.log_type, + parser_id=args.parser_id, + report_id=args.report_id, + ) + output_formatter(result, args.output) + except APIError as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + except SecOpsError as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + except Exception as e: # pylint: disable=broad-exception-caught + print(f"Error fetching analysis report: {e}", file=sys.stderr) + sys.exit(1) diff --git a/src/secops/cli/commands/rule.py b/src/secops/cli/commands/rule.py index 8661663..32ee489 100644 --- a/src/secops/cli/commands/rule.py +++ b/src/secops/cli/commands/rule.py @@ -14,7 +14,6 @@ # """Google SecOps CLI rule commands""" -from ast import arg import json import sys diff --git a/tests/chronicle/test_case.py b/tests/chronicle/test_case.py index 6eb8438..83a0c3c 100644 --- a/tests/chronicle/test_case.py +++ b/tests/chronicle/test_case.py @@ -133,7 +133,7 @@ def test_execute_bulk_assign_success(chronicle_client): assert call_args[1]["endpoint_path"] == "cases:executeBulkAssign" assert call_args[1]["json"] == { "casesIds": [123, 456], - "username": "user@example.com", + "userName": "user@example.com", } assert result == {} diff --git a/tests/chronicle/test_client_parser_validation.py b/tests/chronicle/test_client_parser_validation.py new file mode 100644 index 0000000..7c39acc --- /dev/null +++ b/tests/chronicle/test_client_parser_validation.py @@ -0,0 +1,63 @@ +"""Test parser validation methods on ChronicleClient.""" + +from unittest.mock import MagicMock +import pytest + +from secops.chronicle.client import ChronicleClient + + +@pytest.fixture +def mock_client(): + """Create a mock ChronicleClient.""" + client = ChronicleClient( + project_id="test-project", + customer_id="test-customer", + auth=MagicMock(), + ) + # Mock the parser validation service stub + client.parser_validation_service_stub = MagicMock() + return client + + +def test_trigger_github_checks(mock_client, monkeypatch): + """Test ChronicleClient.trigger_github_checks.""" + # Mock the underlying implementation to avoid gRPC dependency in tests + mock_impl = MagicMock( + return_value={"message": "Success", "details": "Started"} + ) + monkeypatch.setattr( + "secops.chronicle.client._trigger_github_checks", mock_impl + ) + + result = mock_client.trigger_github_checks( + associated_pr="owner/repo/pull/123", + log_type="DUMMY_LOGTYPE", + ) + + assert result == {"message": "Success", "details": "Started"} + mock_impl.assert_called_once_with( + mock_client, + associated_pr="owner/repo/pull/123", + log_type="DUMMY_LOGTYPE", + ) + + +def test_get_analysis_report(mock_client, monkeypatch): + """Test ChronicleClient.get_analysis_report.""" + # Mock the underlying implementation + mock_impl = MagicMock(return_value={"reportId": "123"}) + monkeypatch.setattr( + "secops.chronicle.client._get_analysis_report", mock_impl + ) + + result = mock_client.get_analysis_report( + log_type="DEF", parser_id="XYZ", report_id="123" + ) + + assert result == {"reportId": "123"} + mock_impl.assert_called_once_with( + mock_client, + log_type="DEF", + parser_id="XYZ", + report_id="123", + ) diff --git a/tests/cli/test_log_type.py b/tests/cli/test_log_type.py new file mode 100644 index 0000000..9d4ee9f --- /dev/null +++ b/tests/cli/test_log_type.py @@ -0,0 +1,98 @@ +"""Unit tests for Log Type CLI commands.""" + +from unittest.mock import MagicMock +from argparse import Namespace +import pytest + +from secops.cli.commands.log_type import ( + handle_trigger_checks_command, + handle_get_analysis_report_command, +) +from secops.exceptions import APIError, SecOpsError + + +def test_handle_trigger_checks_command_success(): + """Test successful trigger_checks command execution.""" + args = Namespace( + associated_pr="owner/repo/pull/123", + log_type="DUMMY_LOGTYPE", + output="json", + ) + mock_chronicle = MagicMock() + mock_chronicle.trigger_github_checks.return_value = { + "message": "Success", + "details": "Details", + } + + try: + handle_trigger_checks_command(args, mock_chronicle) + except SystemExit: + pytest.fail("Command exited unexpectedly") + + mock_chronicle.trigger_github_checks.assert_called_once_with( + associated_pr="owner/repo/pull/123", + log_type="DUMMY_LOGTYPE", + ) + + +def test_handle_trigger_checks_command_api_error(capsys): + """Test trigger_checks command with APIError.""" + args = Namespace( + associated_pr="owner/repo/pull/123", + log_type="BRO_DNS", + output="json", + ) + mock_chronicle = MagicMock() + mock_chronicle.trigger_github_checks.side_effect = APIError("API fault") + + with pytest.raises(SystemExit) as exc: + handle_trigger_checks_command(args, mock_chronicle) + + assert exc.value.code == 1 + err = capsys.readouterr().err + assert "Error: API fault" in err + + +def test_handle_get_analysis_report_command_success(): + """Test successful get_analysis_report command execution.""" + args = Namespace( + log_type="DEF", + parser_id="XYZ", + report_id="123", + output="json", + ) + mock_chronicle = MagicMock() + mock_chronicle.get_analysis_report.return_value = { + "reportId": "123", + "status": "COMPLETED", + } + + try: + handle_get_analysis_report_command(args, mock_chronicle) + except SystemExit: + pytest.fail("Command exited unexpectedly") + + mock_chronicle.get_analysis_report.assert_called_once_with( + log_type="DEF", + parser_id="XYZ", + report_id="123", + ) + + +def test_handle_get_analysis_report_command_secops_error(capsys): + """Test get_analysis_report command with SecOpsError.""" + args = Namespace( + log_type="DEF", + parser_id="XYZ", + report_id="123", + output="json", + ) + mock_chronicle = MagicMock() + mock_chronicle.get_analysis_report.side_effect = SecOpsError("Invalid input") + + with pytest.raises(SystemExit) as exc: + handle_get_analysis_report_command(args, mock_chronicle) + + assert exc.value.code == 1 + err = capsys.readouterr().err + assert "Error: Invalid input" in err diff --git a/tests/cli/test_log_type_integration.py b/tests/cli/test_log_type_integration.py new file mode 100644 index 0000000..0ca9ba5 --- /dev/null +++ b/tests/cli/test_log_type_integration.py @@ -0,0 +1,90 @@ +"""Integration tests for Log Type CLI commands.""" + +import json +import subprocess +import pytest + + +@pytest.mark.integration +def test_cli_log_type_lifecycle(cli_env, common_args): + """Test the complete log-type lifecycle commands.""" + + print("\nTesting log-type trigger-checks command") + + # We need a stable test fixture for the associated_pr. Since PRs are ephemeral, + # we will trigger a check for a dummy PR and expect either a successful trigger + # or a specific graceful failure (like 404 PR not found) to prove the CLI routing works. + trigger_cmd = ( + ["secops"] + + common_args + + [ + "--project-id", + "140410331797", + "--customer-id", + "ebdc4bb9-878b-11e7-8455-10604b7cb5c1", + "log-type", + "trigger-checks", + # google/secops-wrapper/pull/1 is just a dummy sample PR to fulfill validation + "--associated-pr", + "google/secops-wrapper/pull/617", + "--log-type", + "DUMMY_LOGTYPE", + ] + ) + + result = subprocess.run(trigger_cmd, env=cli_env, capture_output=True, text=True) + + # Note: Depending on the backend environment, triggering a check on a fake PR/CustomerID + # might actually return a 400/404 APIError rather than a 0 exit code. + # We assert that the CLI executed and returned *something* from the server, + # even if it's an API error about the fake customer ID. + if result.returncode == 0: + try: + output = json.loads(result.stdout) + assert isinstance(output, dict) + print("Successfully triggered checks (or received valid JSON response)") + except json.JSONDecodeError: + pytest.fail(f"Could not decode JSON from successful exit: {result.stdout}") + else: + # If the backend rejects the fake data, we prove the CLI correctly caught the APIError + assert "API error" in result.stderr or "Error" in result.stderr + print(f"Server gracefully rejected the dummy trigger data: {result.stderr.strip()}") + + print("\nTesting log-type get-analysis-report command") + + # We supply a dummy log type, parser, and report ID. The backend will likely 404, proving the routing works. + get_cmd = ( + ["secops"] + + common_args + + [ + "--project-id", + "140410331797", + "--customer-id", + "ebdc4bb9-878b-11e7-8455-10604b7cb5c1", + "log-type", + "get-analysis-report", + "--log-type", + "DUMMY_LOGTYPE", + "--parser-id", + "xyz", + "--report-id", + "123" + ] + ) + + get_result = subprocess.run(get_cmd, env=cli_env, capture_output=True, text=True) + + if get_result.returncode == 0: + try: + output = json.loads(get_result.stdout) + assert isinstance(output, dict) + print("Successfully retrieved report") + except json.JSONDecodeError: + pytest.fail(f"Could not decode JSON: {get_result.stdout}") + else: + # We expect a 404 or similar API error since the report name is fake + assert "API error" in get_result.stderr or "Error" in get_result.stderr + print(f"Server gracefully rejected dummy report name: {get_result.stderr.strip()}") + +if __name__ == "__main__": + pytest.main(["-v", __file__, "-m", "integration"])