From 4625c4c13df95d733019d5650225e4d8ceec602f Mon Sep 17 00:00:00 2001 From: Isha Shree Date: Mon, 23 Mar 2026 06:56:02 +0000 Subject: [PATCH 01/11] Adding new APIs in cli --- api_module_mapping.md | 2 + src/secops/chronicle/client.py | 42 +++++ src/secops/chronicle/parser_validation.py | 159 ++++++++++++++++++ src/secops/cli/cli_client.py | 2 + src/secops/cli/commands/log_type.py | 104 ++++++++++++ .../test_client_parser_validation.py | 60 +++++++ tests/cli/test_log_type.py | 92 ++++++++++ tests/cli/test_log_type_integration.py | 91 ++++++++++ 8 files changed, 552 insertions(+) create mode 100644 src/secops/chronicle/parser_validation.py create mode 100644 src/secops/cli/commands/log_type.py create mode 100644 tests/chronicle/test_client_parser_validation.py create mode 100644 tests/cli/test_log_type.py create mode 100644 tests/cli/test_log_type_integration.py diff --git a/api_module_mapping.md b/api_module_mapping.md index bcfa632d..e9c51926 100644 --- a/api_module_mapping.md +++ b/api_module_mapping.md @@ -285,6 +285,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_validation.get_analysis_report |secops log-type get-analysis-report | +|logTypes.triggerGitHubChecks |v1alpha|chronicle.parser_validation.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/client.py b/src/secops/chronicle/client.py index 9b892272..45e5ed2a 100644 --- a/src/secops/chronicle/client.py +++ b/src/secops/chronicle/client.py @@ -334,6 +334,10 @@ create_watchlist as _create_watchlist, update_watchlist as _update_watchlist, ) +from secops.chronicle.parser_validation import ( + get_analysis_report as _get_analysis_report, + trigger_github_checks as _trigger_github_checks, +) from secops.exceptions import SecOpsError @@ -761,6 +765,44 @@ def update_watchlist( update_mask, ) + def get_analysis_report(self, name: str) -> dict[str, Any]: + """Get a parser analysis report. + Args: + name: The full resource name of the analysis report. + Returns: + Dictionary containing the analysis report. + Raises: + APIError: If the API request fails. + """ + return _get_analysis_report(self, name) + + def trigger_github_checks( + self, + associated_pr: str, + log_type: str, + customer_id: str | None = None, + ) -> 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. + customer_id: The customer UUID string. + + 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, + customer_id=customer_id, + ) + def get_stats( self, query: str, diff --git a/src/secops/chronicle/parser_validation.py b/src/secops/chronicle/parser_validation.py new file mode 100644 index 00000000..7c576de3 --- /dev/null +++ b/src/secops/chronicle/parser_validation.py @@ -0,0 +1,159 @@ +# 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. +# +"""Chronicle parser validation functionality.""" + +from typing import TYPE_CHECKING, Any +import logging +import re + +from secops.exceptions import APIError, SecOpsError + +if TYPE_CHECKING: + from secops.chronicle.client import ChronicleClient + + +def trigger_github_checks( + client: "ChronicleClient", + associated_pr: str, + log_type: str, + customer_id: str | None = None, + 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. + customer_id: Optional. The customer UUID string. Defaults to client + configured ID. + 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 customer_id is not None: + if not isinstance(customer_id, str) or not re.match( + r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-" + r"[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", + customer_id, + ): + raise SecOpsError( + "customer_id must be a valid UUID string" + ) + 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") + + eff_customer_id = customer_id or client.customer_id + instance_id = client.instance_id + if eff_customer_id and eff_customer_id != client.customer_id: + # Dev and staging use 'us' as the location + region = "us" if client.region in ["dev", "staging"] else client.region + instance_id = ( + f"projects/{client.project_id}/locations/" + f"{region}/instances/{eff_customer_id}" + ) + + # The backend expects the resource name to be in the format: + # projects/*/locations/*/instances/*/logTypes/*/parsers/ + base_url = client.base_url(version="v1alpha") + + # First get the list of parsers for this log_type to find a valid + # parser UUID + parsers_url = f"{base_url}/{instance_id}/logTypes/{log_type}/parsers" + parsers_resp = client.session.get(parsers_url, timeout=timeout) + if not parsers_resp.ok: + raise APIError( + f"Failed to fetch parsers for log type {log_type}: " + f"{parsers_resp.text}" + ) + + parsers_data = parsers_resp.json() + parsers = parsers_data.get("parsers") + if not parsers: + logging.info( + "No parsers found for log type %s. Using fallback parser ID.", + log_type, + ) + parser_name = f"{instance_id}/logTypes/{log_type}/parsers/-" + else: + if len(parsers) > 1: + logging.warning( + "Multiple parsers found for log type %s. Using the first one.", + log_type, + ) + + # Use the first parser's name (which includes the UUID) + parser_name = parsers[0]["name"] + + url = f"{base_url}/{parser_name}:runAnalysis" + payload = { + "report_type": "GITHUB_PARSER_VALIDATION", + "pull_request": associated_pr, + } + + response = client.session.post(url, json=payload, timeout=timeout) + + if not response.ok: + raise APIError(f"API call failed: {response.text}") + + return response.json() + + +def get_analysis_report( + client: "ChronicleClient", + name: str, + timeout: int = 60, +) -> dict[str, Any]: + """Get a parser analysis report. + Args: + client: ChronicleClient instance + name: The full resource name 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(name, str) or len(name.strip()) < 5: + raise SecOpsError("name must be a valid string") + if not isinstance(timeout, int) or timeout < 0: + raise SecOpsError("timeout must be a non-negative integer") + + # The name includes 'projects/...', so we just append it to base_url + base_url = client.base_url(version="v1alpha") + url = f"{base_url}/{name}" + + response = client.session.get(url, timeout=timeout) + + if not response.ok: + raise APIError(f"API call failed: {response.text}") + + return response.json() diff --git a/src/secops/cli/cli_client.py b/src/secops/cli/cli_client.py index 4c483656..8f2f5326 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 00000000..fab7869f --- /dev/null +++ b/src/secops/cli/commands/log_type.py @@ -0,0 +1,104 @@ +# 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( + "--name", + required=True, + help="The full resource name 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(name=args.name) + 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/tests/chronicle/test_client_parser_validation.py b/tests/chronicle/test_client_parser_validation.py new file mode 100644 index 00000000..551ac275 --- /dev/null +++ b/tests/chronicle/test_client_parser_validation.py @@ -0,0 +1,60 @@ +"""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", + customer_id=None, + ) + + +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( + name="projects/test/locations/us/instances/test/logTypes/DEF/parsers/XYZ/analysisReports/123" + ) + + assert result == {"reportId": "123"} + mock_impl.assert_called_once_with( + mock_client, + "projects/test/locations/us/instances/test/logTypes/DEF/parsers/XYZ/analysisReports/123", + ) diff --git a/tests/cli/test_log_type.py b/tests/cli/test_log_type.py new file mode 100644 index 00000000..e1454203 --- /dev/null +++ b/tests/cli/test_log_type.py @@ -0,0 +1,92 @@ +"""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( + name="projects/test/locations/us/instances/abc/logTypes/DEF/parsers/XYZ/analysisReports/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( + name="projects/test/locations/us/instances/abc/logTypes/DEF/parsers/XYZ/analysisReports/123" + ) + + +def test_handle_get_analysis_report_command_secops_error(capsys): + """Test get_analysis_report command with SecOpsError.""" + args = Namespace( + name="projects/test/locations/us/instances/abc/logTypes/DEF/parsers/XYZ/analysisReports/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 00000000..11394d21 --- /dev/null +++ b/tests/cli/test_log_type_integration.py @@ -0,0 +1,91 @@ +"""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 resource name. The backend will likely 404, proving the routing works. + dummy_report_name = ( + "projects/140410331797/locations/us/instances/ebdc4bb9-878b-11e7-8455-10604b7cb5c1/logTypes/DUMMY_LOGTYPE/" + "parsers/xyz/analysisReports/123" + ) + + get_cmd = ( + ["secops"] + + common_args + + [ + "--project-id", + "140410331797", + "--customer-id", + "ebdc4bb9-878b-11e7-8455-10604b7cb5c1", + "log-type", + "get-analysis-report", + "--name", + dummy_report_name + ] + ) + + 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"]) From 44fdadf8e1498098a0e9879243e9013ca7004fe3 Mon Sep 17 00:00:00 2001 From: Isha Shree Date: Wed, 1 Apr 2026 10:36:11 +0000 Subject: [PATCH 02/11] Addressing comments --- src/secops/chronicle/client.py | 2 +- src/secops/chronicle/parser.py | 143 +++++++++++++++++- src/secops/chronicle/parser_validation.py | 159 -------------------- src/secops/chronicle/utils/request_utils.py | 10 +- 4 files changed, 148 insertions(+), 166 deletions(-) delete mode 100644 src/secops/chronicle/parser_validation.py diff --git a/src/secops/chronicle/client.py b/src/secops/chronicle/client.py index 45e5ed2a..ba03ca9f 100644 --- a/src/secops/chronicle/client.py +++ b/src/secops/chronicle/client.py @@ -334,7 +334,7 @@ create_watchlist as _create_watchlist, update_watchlist as _update_watchlist, ) -from secops.chronicle.parser_validation import ( +from secops.chronicle.parser import ( get_analysis_report as _get_analysis_report, trigger_github_checks as _trigger_github_checks, ) diff --git a/src/secops/chronicle/parser.py b/src/secops/chronicle/parser.py index e1c3488e..ae07c474 100644 --- a/src/secops/chronicle/parser.py +++ b/src/secops/chronicle/parser.py @@ -16,9 +16,12 @@ import base64 import json +import logging +import re from typing import Any -from secops.exceptions import APIError +from secops.chronicle.utils.request_utils import chronicle_request +from secops.exceptions import APIError, SecOpsError # Constants for size limits MAX_LOG_SIZE = 10 * 1024 * 1024 # 10MB per log @@ -254,6 +257,8 @@ def list_parsers( page_size: int | None = None, page_token: str | None = None, filter: str = None, # pylint: disable=redefined-builtin + instance_id: str | None = None, + api_version: str | None = None, ) -> list[Any] | dict[str, Any]: """List parsers. @@ -278,8 +283,13 @@ def list_parsers( parsers = [] while more: + eff_instance_id = instance_id or client.instance_id + if api_version: + base_url = client.base_url(version=api_version) + else: + base_url = client.base_url url = ( - f"{client.base_url}/{client.instance_id}" + f"{base_url}/{eff_instance_id}" f"/logTypes/{log_type}/parsers" ) @@ -489,3 +499,132 @@ 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, + customer_id: str | None = None, + 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. + customer_id: Optional. The customer UUID string. Defaults to client + configured ID. + 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 customer_id is not None: + if not isinstance(customer_id, str) or not re.match( + r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-" + r"[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", + customer_id, + ): + raise SecOpsError("customer_id must be a valid UUID string") + 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") + + eff_customer_id = customer_id or client.customer_id + instance_id = client.instance_id + if eff_customer_id and eff_customer_id != client.customer_id: + region = "us" if client.region in ["dev", "staging"] else client.region + instance_id = ( + f"projects/{client.project_id}/locations/" + f"{region}/instances/{eff_customer_id}" + ) + + try: + parsers = list_parsers( + client, + log_type=log_type, + instance_id=instance_id, + api_version="v1alpha", + ) + 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"{instance_id}/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", + name: str, + timeout: int = 60, +) -> dict[str, Any]: + """Get a parser analysis report. + + Args: + client: ChronicleClient instance + name: The full resource name 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(name, str) or len(name.strip()) < 5: + raise SecOpsError("name must be a valid string") + if not isinstance(timeout, int) or timeout < 0: + raise SecOpsError("timeout must be a non-negative integer") + + return chronicle_request( + client=client, + method="GET", + api_version="v1alpha", + endpoint_path=name, + timeout=timeout, + ) diff --git a/src/secops/chronicle/parser_validation.py b/src/secops/chronicle/parser_validation.py deleted file mode 100644 index 7c576de3..00000000 --- a/src/secops/chronicle/parser_validation.py +++ /dev/null @@ -1,159 +0,0 @@ -# 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. -# -"""Chronicle parser validation functionality.""" - -from typing import TYPE_CHECKING, Any -import logging -import re - -from secops.exceptions import APIError, SecOpsError - -if TYPE_CHECKING: - from secops.chronicle.client import ChronicleClient - - -def trigger_github_checks( - client: "ChronicleClient", - associated_pr: str, - log_type: str, - customer_id: str | None = None, - 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. - customer_id: Optional. The customer UUID string. Defaults to client - configured ID. - 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 customer_id is not None: - if not isinstance(customer_id, str) or not re.match( - r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-" - r"[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", - customer_id, - ): - raise SecOpsError( - "customer_id must be a valid UUID string" - ) - 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") - - eff_customer_id = customer_id or client.customer_id - instance_id = client.instance_id - if eff_customer_id and eff_customer_id != client.customer_id: - # Dev and staging use 'us' as the location - region = "us" if client.region in ["dev", "staging"] else client.region - instance_id = ( - f"projects/{client.project_id}/locations/" - f"{region}/instances/{eff_customer_id}" - ) - - # The backend expects the resource name to be in the format: - # projects/*/locations/*/instances/*/logTypes/*/parsers/ - base_url = client.base_url(version="v1alpha") - - # First get the list of parsers for this log_type to find a valid - # parser UUID - parsers_url = f"{base_url}/{instance_id}/logTypes/{log_type}/parsers" - parsers_resp = client.session.get(parsers_url, timeout=timeout) - if not parsers_resp.ok: - raise APIError( - f"Failed to fetch parsers for log type {log_type}: " - f"{parsers_resp.text}" - ) - - parsers_data = parsers_resp.json() - parsers = parsers_data.get("parsers") - if not parsers: - logging.info( - "No parsers found for log type %s. Using fallback parser ID.", - log_type, - ) - parser_name = f"{instance_id}/logTypes/{log_type}/parsers/-" - else: - if len(parsers) > 1: - logging.warning( - "Multiple parsers found for log type %s. Using the first one.", - log_type, - ) - - # Use the first parser's name (which includes the UUID) - parser_name = parsers[0]["name"] - - url = f"{base_url}/{parser_name}:runAnalysis" - payload = { - "report_type": "GITHUB_PARSER_VALIDATION", - "pull_request": associated_pr, - } - - response = client.session.post(url, json=payload, timeout=timeout) - - if not response.ok: - raise APIError(f"API call failed: {response.text}") - - return response.json() - - -def get_analysis_report( - client: "ChronicleClient", - name: str, - timeout: int = 60, -) -> dict[str, Any]: - """Get a parser analysis report. - Args: - client: ChronicleClient instance - name: The full resource name 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(name, str) or len(name.strip()) < 5: - raise SecOpsError("name must be a valid string") - if not isinstance(timeout, int) or timeout < 0: - raise SecOpsError("timeout must be a non-negative integer") - - # The name includes 'projects/...', so we just append it to base_url - base_url = client.base_url(version="v1alpha") - url = f"{base_url}/{name}" - - response = client.session.get(url, timeout=timeout) - - if not response.ok: - raise APIError(f"API call failed: {response.text}") - - return response.json() diff --git a/src/secops/chronicle/utils/request_utils.py b/src/secops/chronicle/utils/request_utils.py index 43f2d885..a643c561 100644 --- a/src/secops/chronicle/utils/request_utils.py +++ b/src/secops/chronicle/utils/request_utils.py @@ -230,12 +230,14 @@ def chronicle_request( # - RPC-style methods e.g: ":validateQuery" -> .../{instance_id}:validateQuery # - Legacy paths e.g: "legacy:..." -> .../{instance_id}/legacy:... # - normal paths e.g: "curatedRules/..." -> .../{instance_id}/curatedRules/... - base = f"{client.base_url(api_version)}/{client.instance_id}" + base = f"{client.base_url(api_version)}" - if endpoint_path.startswith(":"): - url = f"{base}{endpoint_path}" + if endpoint_path.startswith("projects/"): + url = f"{base}/{endpoint_path}" + elif endpoint_path.startswith(":"): + url = f"{base}/{client.instance_id}{endpoint_path}" else: - url = f'{base}/{endpoint_path.lstrip("/")}' + url = f'{base}/{client.instance_id}/{endpoint_path.lstrip("/")}' try: response = client.session.request( From 17161e2fadba49b96e64ec8b9d04cccf37225627 Mon Sep 17 00:00:00 2001 From: Mihir Vala <179564180+mihirvala-crestdata@users.noreply.github.com> Date: Fri, 3 Apr 2026 12:29:56 +0530 Subject: [PATCH 03/11] chore: fixed unnessary changes --- src/secops/chronicle/parser.py | 14 +------------- src/secops/chronicle/utils/request_utils.py | 6 ++---- 2 files changed, 3 insertions(+), 17 deletions(-) diff --git a/src/secops/chronicle/parser.py b/src/secops/chronicle/parser.py index 977a82a6..45535549 100644 --- a/src/secops/chronicle/parser.py +++ b/src/secops/chronicle/parser.py @@ -241,8 +241,6 @@ def list_parsers( page_size: int | None = None, page_token: str | None = None, filter: str = None, # pylint: disable=redefined-builtin - instance_id: str | None = None, - api_version: str | None = None, as_list: bool = True, ) -> dict[str, Any] | list[Any]: """List parsers. @@ -255,8 +253,6 @@ def list_parsers( If None (default), auto-paginates and returns all parsers. page_token: A page token, received from a previous ListParsers call. filter: Optional filter expression - instance_id: Optional instance ID to use instead of client's default - api_version: Optional API version to use (e.g., 'v1alpha') as_list: If True, return only the list of parsers. If False, return dict with metadata and pagination tokens. Defaults to True. When page_size is None, this is automatically @@ -278,18 +274,10 @@ def list_parsers( # For backward compatibility: if page_size is None, force as_list to True effective_as_list = True if page_size is None else as_list - # Handle custom instance_id if provided - if instance_id: - eff_instance_id = instance_id - path = f"{eff_instance_id}/logTypes/{log_type}/parsers" - else: - path = f"logTypes/{log_type}/parsers" - return chronicle_paginated_request( client, - path=path, + path=f"logTypes/{log_type}/parsers", items_key="parsers", - api_version=api_version, page_size=page_size, page_token=page_token, extra_params=extra_params if extra_params else None, diff --git a/src/secops/chronicle/utils/request_utils.py b/src/secops/chronicle/utils/request_utils.py index 924cc2da..273fe3dc 100644 --- a/src/secops/chronicle/utils/request_utils.py +++ b/src/secops/chronicle/utils/request_utils.py @@ -237,10 +237,8 @@ def chronicle_request( else: base = f"{client.base_url}/{client.instance_id}" - if endpoint_path.startswith("projects/"): - url = f"{base}/{endpoint_path}" - elif endpoint_path.startswith(":"): - url = f"{base.rsplit('/', 1)[0]}{endpoint_path}" + if endpoint_path.startswith(":"): + url = f"{base}{endpoint_path}" else: url = f'{base}/{endpoint_path.lstrip("/")}' From 6dff463e7235417f7743a728431250bce95be3b5 Mon Sep 17 00:00:00 2001 From: Mihir Vala <179564180+mihirvala-crestdata@users.noreply.github.com> Date: Fri, 3 Apr 2026 15:16:43 +0530 Subject: [PATCH 04/11] chore: refactoring and improvements --- api_module_mapping.md | 4 +- src/secops/chronicle/client.py | 25 +++++--- src/secops/chronicle/parser.py | 55 +++++++----------- src/secops/cli/commands/log_type.py | 58 +++++++++++++------ src/secops/cli/commands/rule.py | 1 - .../test_client_parser_validation.py | 8 ++- tests/cli/test_log_type.py | 12 +++- tests/cli/test_log_type_integration.py | 15 +++-- 8 files changed, 101 insertions(+), 77 deletions(-) diff --git a/api_module_mapping.md b/api_module_mapping.md index 9824371c..c52e5092 100644 --- a/api_module_mapping.md +++ b/api_module_mapping.md @@ -296,8 +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_validation.get_analysis_report |secops log-type get-analysis-report | -|logTypes.triggerGitHubChecks |v1alpha|chronicle.parser_validation.trigger_github_checks |secops log-type trigger-checks | +|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/client.py b/src/secops/chronicle/client.py index 01029ea1..94e2097d 100644 --- a/src/secops/chronicle/client.py +++ b/src/secops/chronicle/client.py @@ -782,29 +782,39 @@ def update_watchlist( update_mask, ) - def get_analysis_report(self, name: str) -> dict[str, Any]: + def get_analysis_report( + self, + log_type: str, + parser_id: str, + report_id: str, + ) -> dict[str, Any]: """Get a parser analysis report. Args: - name: The full resource name of the analysis report. + 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, name) + 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, - customer_id: str | None = None, ) -> 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. - customer_id: The customer UUID string. Returns: Dictionary containing the response details. @@ -814,10 +824,7 @@ def trigger_github_checks( APIError: If the gRPC API request fails. """ return _trigger_github_checks( - self, - associated_pr=associated_pr, - log_type=log_type, - customer_id=customer_id, + self, associated_pr=associated_pr, log_type=log_type ) def get_stats( diff --git a/src/secops/chronicle/parser.py b/src/secops/chronicle/parser.py index 45535549..e9dae24b 100644 --- a/src/secops/chronicle/parser.py +++ b/src/secops/chronicle/parser.py @@ -17,9 +17,9 @@ import base64 import json import logging -import re 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, @@ -442,7 +442,6 @@ def trigger_github_checks( client: "ChronicleClient", associated_pr: str, log_type: str, - customer_id: str | None = None, timeout: int = 60, ) -> dict[str, Any]: """Trigger GitHub checks for a parser. @@ -451,8 +450,6 @@ def trigger_github_checks( client: ChronicleClient instance associated_pr: The PR string (e.g., "owner/repo/pull/123"). log_type: The string name of the LogType enum. - customer_id: Optional. The customer UUID string. Defaults to client - configured ID. timeout: Optional RPC timeout in seconds (default: 60). Returns: @@ -465,13 +462,7 @@ def trigger_github_checks( 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 customer_id is not None: - if not isinstance(customer_id, str) or not re.match( - r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-" - r"[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", - customer_id, - ): - raise SecOpsError("customer_id must be a valid UUID string") + if not isinstance(associated_pr, str) or not associated_pr.strip(): raise SecOpsError("associated_pr must be a non-empty string") @@ -483,22 +474,8 @@ def trigger_github_checks( if not isinstance(timeout, int) or timeout < 0: raise SecOpsError("timeout must be a non-negative integer") - eff_customer_id = customer_id or client.customer_id - instance_id = client.instance_id - if eff_customer_id and eff_customer_id != client.customer_id: - region = "us" if client.region in ["dev", "staging"] else client.region - instance_id = ( - f"projects/{client.project_id}/locations/" - f"{region}/instances/{eff_customer_id}" - ) - try: - parsers = list_parsers( - client, - log_type=log_type, - instance_id=instance_id, - api_version="v1alpha", - ) + 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}" @@ -509,7 +486,7 @@ def trigger_github_checks( "No parsers found for log type %s. Using fallback parser ID.", log_type, ) - parser_name = f"{instance_id}/logTypes/{log_type}/parsers/-" + parser_name = f"logTypes/{log_type}/parsers/-" else: if len(parsers) > 1: logging.warning( @@ -536,14 +513,18 @@ def trigger_github_checks( def get_analysis_report( client: "ChronicleClient", - name: str, + log_type: str, + parser_id: str, + report_id: str, timeout: int = 60, ) -> dict[str, Any]: """Get a parser analysis report. Args: client: ChronicleClient instance - name: The full resource name of the analysis report. + 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: @@ -553,15 +534,23 @@ def get_analysis_report( SecOpsError: If input is invalid. APIError: If the API request fails. """ - if not isinstance(name, str) or len(name.strip()) < 5: - raise SecOpsError("name must be a valid string") + 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="v1alpha", - endpoint_path=name, + api_version=APIVersion.V1ALPHA, + endpoint_path=endpoint_path, timeout=timeout, ) diff --git a/src/secops/cli/commands/log_type.py b/src/secops/cli/commands/log_type.py index fab7869f..e14e3b40 100644 --- a/src/secops/cli/commands/log_type.py +++ b/src/secops/cli/commands/log_type.py @@ -23,15 +23,16 @@ 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", + 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" + 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 @@ -44,27 +45,42 @@ def setup_log_type_commands(subparsers): "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").' + "--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").' + "--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 ) - 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( - "--name", - required=True, - help="The full resource name of the analysis report." + "--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) @@ -91,7 +107,11 @@ def handle_trigger_checks_command(args, chronicle): def handle_get_analysis_report_command(args, chronicle): """Handle get analysis report command.""" try: - result = chronicle.get_analysis_report(name=args.name) + 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) diff --git a/src/secops/cli/commands/rule.py b/src/secops/cli/commands/rule.py index 8661663c..32ee4894 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_client_parser_validation.py b/tests/chronicle/test_client_parser_validation.py index 551ac275..df4d8c64 100644 --- a/tests/chronicle/test_client_parser_validation.py +++ b/tests/chronicle/test_client_parser_validation.py @@ -50,11 +50,15 @@ def test_get_analysis_report(mock_client, monkeypatch): ) result = mock_client.get_analysis_report( - name="projects/test/locations/us/instances/test/logTypes/DEF/parsers/XYZ/analysisReports/123" + log_type="DEF", + parser_id="XYZ", + report_id="123" ) assert result == {"reportId": "123"} mock_impl.assert_called_once_with( mock_client, - "projects/test/locations/us/instances/test/logTypes/DEF/parsers/XYZ/analysisReports/123", + 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 index e1454203..9d4ee9f0 100644 --- a/tests/cli/test_log_type.py +++ b/tests/cli/test_log_type.py @@ -56,7 +56,9 @@ def test_handle_trigger_checks_command_api_error(capsys): def test_handle_get_analysis_report_command_success(): """Test successful get_analysis_report command execution.""" args = Namespace( - name="projects/test/locations/us/instances/abc/logTypes/DEF/parsers/XYZ/analysisReports/123", + log_type="DEF", + parser_id="XYZ", + report_id="123", output="json", ) mock_chronicle = MagicMock() @@ -71,14 +73,18 @@ def test_handle_get_analysis_report_command_success(): pytest.fail("Command exited unexpectedly") mock_chronicle.get_analysis_report.assert_called_once_with( - name="projects/test/locations/us/instances/abc/logTypes/DEF/parsers/XYZ/analysisReports/123" + 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( - name="projects/test/locations/us/instances/abc/logTypes/DEF/parsers/XYZ/analysisReports/123", + log_type="DEF", + parser_id="XYZ", + report_id="123", output="json", ) mock_chronicle = MagicMock() diff --git a/tests/cli/test_log_type_integration.py b/tests/cli/test_log_type_integration.py index 11394d21..0ca9ba5a 100644 --- a/tests/cli/test_log_type_integration.py +++ b/tests/cli/test_log_type_integration.py @@ -52,12 +52,7 @@ def test_cli_log_type_lifecycle(cli_env, common_args): print("\nTesting log-type get-analysis-report command") - # We supply a dummy resource name. The backend will likely 404, proving the routing works. - dummy_report_name = ( - "projects/140410331797/locations/us/instances/ebdc4bb9-878b-11e7-8455-10604b7cb5c1/logTypes/DUMMY_LOGTYPE/" - "parsers/xyz/analysisReports/123" - ) - + # We supply a dummy log type, parser, and report ID. The backend will likely 404, proving the routing works. get_cmd = ( ["secops"] + common_args @@ -68,8 +63,12 @@ def test_cli_log_type_lifecycle(cli_env, common_args): "ebdc4bb9-878b-11e7-8455-10604b7cb5c1", "log-type", "get-analysis-report", - "--name", - dummy_report_name + "--log-type", + "DUMMY_LOGTYPE", + "--parser-id", + "xyz", + "--report-id", + "123" ] ) From 31bac701e0b1ef48eec40ea29048d96e7c798179 Mon Sep 17 00:00:00 2001 From: Mihir Vala <179564180+mihirvala-crestdata@users.noreply.github.com> Date: Fri, 3 Apr 2026 15:19:50 +0530 Subject: [PATCH 05/11] chore: fixed unit tests --- tests/chronicle/test_client_parser_validation.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/tests/chronicle/test_client_parser_validation.py b/tests/chronicle/test_client_parser_validation.py index df4d8c64..7c39accd 100644 --- a/tests/chronicle/test_client_parser_validation.py +++ b/tests/chronicle/test_client_parser_validation.py @@ -22,7 +22,9 @@ def mock_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"}) + mock_impl = MagicMock( + return_value={"message": "Success", "details": "Started"} + ) monkeypatch.setattr( "secops.chronicle.client._trigger_github_checks", mock_impl ) @@ -37,7 +39,6 @@ def test_trigger_github_checks(mock_client, monkeypatch): mock_client, associated_pr="owner/repo/pull/123", log_type="DUMMY_LOGTYPE", - customer_id=None, ) @@ -50,9 +51,7 @@ def test_get_analysis_report(mock_client, monkeypatch): ) result = mock_client.get_analysis_report( - log_type="DEF", - parser_id="XYZ", - report_id="123" + log_type="DEF", parser_id="XYZ", report_id="123" ) assert result == {"reportId": "123"} From 8dfec617a6c1fc044eaadc049342c6843d28c319 Mon Sep 17 00:00:00 2001 From: Mihir Vala <179564180+mihirvala-crestdata@users.noreply.github.com> Date: Fri, 3 Apr 2026 17:46:07 +0530 Subject: [PATCH 06/11] chore: case integration tests fix --- src/secops/chronicle/case.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/secops/chronicle/case.py b/src/secops/chronicle/case.py index 0e78d989..a6e84dff 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, From a05c67225eb85891d8b0dd539382c3ff9504d02b Mon Sep 17 00:00:00 2001 From: Mihir Vala <179564180+mihirvala-crestdata@users.noreply.github.com> Date: Fri, 3 Apr 2026 17:52:22 +0530 Subject: [PATCH 07/11] chore: fixed unit tests --- tests/chronicle/test_case.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/chronicle/test_case.py b/tests/chronicle/test_case.py index 6eb8438f..83a0c3c9 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 == {} From 774a778df42265897f269a22abb6a3f1afdfdbaf Mon Sep 17 00:00:00 2001 From: Mihir Vala <179564180+mihirvala-crestdata@users.noreply.github.com> Date: Mon, 6 Apr 2026 14:07:09 +0530 Subject: [PATCH 08/11] chore: added client integration tests --- tests/chronicle/test_log_type_integration.py | 87 ++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 tests/chronicle/test_log_type_integration.py diff --git a/tests/chronicle/test_log_type_integration.py b/tests/chronicle/test_log_type_integration.py new file mode 100644 index 00000000..e5c74110 --- /dev/null +++ b/tests/chronicle/test_log_type_integration.py @@ -0,0 +1,87 @@ +# Copyright 2025 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. +# +"""Integration tests for Chronicle log type and parser validation functions. + +These tests require valid credentials and API access. +They interact with real Chronicle API endpoints. +""" + +import pytest + +from secops import SecOpsClient +from secops.exceptions import APIError +from ..config import CHRONICLE_CONFIG, SERVICE_ACCOUNT_JSON + + +@pytest.mark.integration +def test_log_type_lifecycle_integration(): + """Test the complete log-type lifecycle.""" + client = SecOpsClient(service_account_info=SERVICE_ACCOUNT_JSON) + + # 5. Testing parser validation workflow + dummy_project_id = "140410331797" + dummy_customer_id = "ebdc4bb9-878b-11e7-8455-10604b7cb5c1" + + dummy_chronicle = client.chronicle( + project_id=dummy_project_id, + customer_id=dummy_customer_id, + region=CHRONICLE_CONFIG.get("region", "us"), + ) + + print("\nTesting trigger_github_checks with dummy data") + try: + # Trigger checks for a dummy PR and log type + result = dummy_chronicle.trigger_github_checks( + associated_pr="google/secops-wrapper/pull/617", + log_type="DUMMY_LOGTYPE", + ) + assert isinstance(result, dict) + print("Successfully triggered checks (or received valid JSON response)") + except (APIError, Exception) as e: + # We expect a failure due to dummy data, but we want to confirm + # it reached the server or handled the routing correctly. + error_msg = str(e) + assert ( + "api error" in error_msg.lower() + or "error" in error_msg.lower() + or "failed" in error_msg.lower() + ) + print( + f"Server gracefully handled the dummy trigger data: {error_msg.strip()}" + ) + + print("\nTesting get_analysis_report with dummy data") + try: + # Request a report for dummy resource names + report = dummy_chronicle.get_analysis_report( + log_type="DUMMY_LOGTYPE", parser_id="xyz", report_id="123" + ) + assert isinstance(report, dict) + print("Successfully retrieved report") + except (APIError, Exception) as e: + # We expect a 404 or similar since the report is dummy + error_msg = str(e) + assert ( + "api error" in error_msg.lower() + or "error" in error_msg.lower() + or "not found" in error_msg.lower() + ) + print( + f"Server gracefully handled dummy report request: {error_msg.strip()}" + ) + + +if __name__ == "__main__": + pytest.main(["-v", __file__, "-m", "integration"]) From f7be3f6468c960d4ec3083cdece80d973063b7ba Mon Sep 17 00:00:00 2001 From: Mihir Vala <179564180+mihirvala-crestdata@users.noreply.github.com> Date: Mon, 6 Apr 2026 14:15:00 +0530 Subject: [PATCH 09/11] chore: added docs in README and CLI. Added changelog. Updated project version. --- CHANGELOG.md | 9 +++++++++ CLI.md | 16 ++++++++++++++++ README.md | 21 +++++++++++++++++++++ pyproject.toml | 2 +- 4 files changed, 47 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 41ce46af..313e5d74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.40.0] - 2026-04-06 +### Added +- Parser validation methods + - `trigger_github_checks()` - Trigger GitHub checks for a parser against an associated pull request + - `get_analysis_report()` - Retrieve a completed parser analysis report +- CLI support for parser validation commands + - `secops log-type trigger-checks` - Trigger parser validation checks for a PR + - `secops log-type get-analysis-report` - Get details of a specific analysis report + ## [0.39.0] - 2026-04-02 ### Updated - Refactored Chronicle modules to use centralized `chronicle_request` and `chronicle_paginated_request` helper functions for improved code consistency and maintainability diff --git a/CLI.md b/CLI.md index db325310..4b851139 100644 --- a/CLI.md +++ b/CLI.md @@ -696,6 +696,22 @@ Error messages are detailed and help identify issues: - Size limit violations - API-specific errors +#### Parser Validation + +You can trigger and retrieve analysis reports for parsers associated with GitHub pull requests. + +Trigger GitHub checks for a parser: + +```bash +secops log-type trigger-checks --log-type "WINDOWS_AD" --associated-pr "owner/repo/pull/123" +``` + +Get a parser analysis report: + +```bash +secops log-type get-analysis-report --log-type "WINDOWS_AD" --parser-id "pa_12345" --report-id "report_12345" +``` + ### Parser Extension Management Parser extensions provide a flexible way to extend the capabilities of existing default (or custom) parsers without replacing them. diff --git a/README.md b/README.md index 34e9fe8a..106a9a14 100644 --- a/README.md +++ b/README.md @@ -1898,6 +1898,27 @@ This workflow is useful for: - Re-processing logs with updated parsers - Debugging parsing issues +### Parser Validation + +Trigger and retrieve analysis reports for parsers associated with GitHub pull requests: + +```python +# Trigger GitHub checks for a parser against a PR +response = chronicle.trigger_github_checks( + associated_pr="owner/repo/pull/123", + log_type="WINDOWS_AD" +) +print(f"Triggered checks: {response}") + +# Retrieve the analysis report +report = chronicle.get_analysis_report( + log_type="WINDOWS_AD", + parser_id="pa_1234567890", + report_id="report_0987654321" +) +print(f"Analysis report: {report}") +``` + ## Parser Extension Parser extensions provide a flexible way to extend the capabilities of existing default (or custom) parsers without replacing them. The extensions let you customize the parser pipeline by adding new parsing logic, extracting and transforming fields, and updating or removing UDM field mappings. diff --git a/pyproject.toml b/pyproject.toml index f0a3d424..e3dabf8e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "secops" -version = "0.39.0" +version = "0.40.0" description = "Python SDK for wrapping the Google SecOps API for common use cases" readme = "README.md" requires-python = ">=3.10" From 85a7311aca492a84699255b6b878ea6a1a08aace Mon Sep 17 00:00:00 2001 From: Mihir Vala <179564180+mihirvala-crestdata@users.noreply.github.com> Date: Mon, 6 Apr 2026 15:13:20 +0530 Subject: [PATCH 10/11] chore: minor refactoring and formatting --- src/secops/chronicle/client.py | 14 +++++++++++--- src/secops/chronicle/parser.py | 2 +- tests/chronicle/test_client_parser_validation.py | 2 +- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/secops/chronicle/client.py b/src/secops/chronicle/client.py index 94e2097d..595ed836 100644 --- a/src/secops/chronicle/client.py +++ b/src/secops/chronicle/client.py @@ -787,12 +787,14 @@ def get_analysis_report( log_type: str, parser_id: str, report_id: str, + timeout: int = 60, ) -> 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. + timeout: Optional timeout in seconds (default: 60). Returns: Dictionary containing the analysis report. Raises: @@ -803,28 +805,34 @@ def get_analysis_report( log_type=log_type, parser_id=parser_id, report_id=report_id, + timeout=timeout, ) def trigger_github_checks( self, associated_pr: str, log_type: str, + timeout: int = 60, ) -> 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. + timeout: Optional request timeout in seconds (default: 60). Returns: Dictionary containing the response details. Raises: - SecOpsError: If gRPC modules or client stub are not available. - APIError: If the gRPC API request fails. + SecOpsError: If modules or client stub are not available. + APIError: If the API request fails. """ return _trigger_github_checks( - self, associated_pr=associated_pr, log_type=log_type + self, + associated_pr=associated_pr, + log_type=log_type, + timeout=timeout, ) def get_stats( diff --git a/src/secops/chronicle/parser.py b/src/secops/chronicle/parser.py index e9dae24b..6cf3368c 100644 --- a/src/secops/chronicle/parser.py +++ b/src/secops/chronicle/parser.py @@ -450,7 +450,7 @@ def trigger_github_checks( 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). + timeout: Optional request timeout in seconds (default: 60). Returns: Dictionary containing the response details. diff --git a/tests/chronicle/test_client_parser_validation.py b/tests/chronicle/test_client_parser_validation.py index 7c39accd..9f3324e5 100644 --- a/tests/chronicle/test_client_parser_validation.py +++ b/tests/chronicle/test_client_parser_validation.py @@ -21,7 +21,7 @@ def mock_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 the underlying implementation to avoid REST dependency in tests mock_impl = MagicMock( return_value={"message": "Success", "details": "Started"} ) From c630cef31caaa5542e379da1e8d59df40dc5aef5 Mon Sep 17 00:00:00 2001 From: Mihir Vala <179564180+mihirvala-crestdata@users.noreply.github.com> Date: Mon, 6 Apr 2026 15:17:33 +0530 Subject: [PATCH 11/11] chore: fix unit tests --- tests/chronicle/test_client_parser_validation.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/chronicle/test_client_parser_validation.py b/tests/chronicle/test_client_parser_validation.py index 9f3324e5..e52db0ea 100644 --- a/tests/chronicle/test_client_parser_validation.py +++ b/tests/chronicle/test_client_parser_validation.py @@ -39,6 +39,7 @@ def test_trigger_github_checks(mock_client, monkeypatch): mock_client, associated_pr="owner/repo/pull/123", log_type="DUMMY_LOGTYPE", + timeout=60, ) @@ -60,4 +61,5 @@ def test_get_analysis_report(mock_client, monkeypatch): log_type="DEF", parser_id="XYZ", report_id="123", + timeout=60, )