From 4625c4c13df95d733019d5650225e4d8ceec602f Mon Sep 17 00:00:00 2001 From: Isha Shree Date: Mon, 23 Mar 2026 06:56:02 +0000 Subject: [PATCH 1/7] 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 2/7] 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 3/7] 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 4/7] 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 5/7] 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 6/7] 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 7/7] 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 == {}