Skip to content

Commit d64e6e4

Browse files
committed
Adding new APIs in cli
1 parent edc6a63 commit d64e6e4

8 files changed

Lines changed: 541 additions & 0 deletions

File tree

api_module_mapping.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,8 @@ Following shows mapping between SecOps [REST Resource](https://cloud.google.com/
285285
|logTypes.getLogTypeSetting |v1alpha| | |
286286
|logTypes.legacySubmitParserExtension |v1alpha| | |
287287
|logTypes.list |v1alpha| | |
288+
|logTypes.getParserAnalysisReport |v1alpha|chronicle.parser_validation.get_analysis_report |secops log-type get-analysis-report |
289+
|logTypes.triggerGitHubChecks |v1alpha|chronicle.parser_validation.trigger_github_checks |secops log-type trigger-checks |
288290
|logTypes.logs.export |v1alpha| | |
289291
|logTypes.logs.get |v1alpha| | |
290292
|logTypes.logs.import |v1alpha|chronicle.log_ingest.ingest_log |secops log ingest |

src/secops/chronicle/client.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,10 @@
334334
create_watchlist as _create_watchlist,
335335
update_watchlist as _update_watchlist,
336336
)
337+
from secops.chronicle.parser_validation import (
338+
get_analysis_report as _get_analysis_report,
339+
trigger_github_checks as _trigger_github_checks,
340+
)
337341
from secops.exceptions import SecOpsError
338342

339343

@@ -761,6 +765,44 @@ def update_watchlist(
761765
update_mask,
762766
)
763767

768+
def get_analysis_report(self, name: str) -> dict[str, Any]:
769+
"""Get a parser analysis report.
770+
Args:
771+
name: The full resource name of the analysis report.
772+
Returns:
773+
Dictionary containing the analysis report.
774+
Raises:
775+
APIError: If the API request fails.
776+
"""
777+
return _get_analysis_report(self, name)
778+
779+
def trigger_github_checks(
780+
self,
781+
associated_pr: str,
782+
log_type: str,
783+
customer_id: str | None = None,
784+
) -> dict[str, Any]:
785+
"""Trigger GitHub checks for a parser.
786+
787+
Args:
788+
associated_pr: The PR string (e.g., "owner/repo/pull/123").
789+
log_type: The string name of the LogType enum.
790+
customer_id: The customer UUID string.
791+
792+
Returns:
793+
Dictionary containing the response details.
794+
795+
Raises:
796+
SecOpsError: If gRPC modules or client stub are not available.
797+
APIError: If the gRPC API request fails.
798+
"""
799+
return _trigger_github_checks(
800+
self,
801+
associated_pr=associated_pr,
802+
log_type=log_type,
803+
customer_id=customer_id,
804+
)
805+
764806
def get_stats(
765807
self,
766808
query: str,
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
#
15+
"""Chronicle parser validation functionality."""
16+
17+
from typing import TYPE_CHECKING, Any
18+
import logging
19+
20+
from secops.exceptions import APIError, SecOpsError
21+
22+
if TYPE_CHECKING:
23+
from secops.chronicle.client import ChronicleClient
24+
25+
26+
def trigger_github_checks(
27+
client: "ChronicleClient",
28+
associated_pr: str,
29+
log_type: str,
30+
customer_id: str | None = None,
31+
timeout: int = 60,
32+
) -> dict[str, Any]:
33+
"""Trigger GitHub checks for a parser.
34+
35+
Args:
36+
client: ChronicleClient instance
37+
associated_pr: The PR string (e.g., "owner/repo/pull/123").
38+
log_type: The string name of the LogType enum.
39+
customer_id: Optional. The customer UUID string. Defaults to client
40+
configured ID.
41+
timeout: Optional RPC timeout in seconds (default: 60).
42+
43+
Returns:
44+
Dictionary containing the response details.
45+
46+
Raises:
47+
SecOpsError: If input is invalid.
48+
APIError: If the API request fails.
49+
"""
50+
if not isinstance(log_type, str) or len(log_type.strip()) < 2:
51+
raise SecOpsError("log_type must be a valid string of length >= 2")
52+
if customer_id is not None:
53+
if not isinstance(customer_id, str) or len(customer_id.strip()) < 2:
54+
raise SecOpsError(
55+
"customer_id must be a valid string of length >= 2"
56+
)
57+
if not isinstance(associated_pr, str) or not associated_pr.strip():
58+
raise SecOpsError("associated_pr must be a non-empty string")
59+
if not isinstance(timeout, int) or timeout < 0:
60+
raise SecOpsError("timeout must be a non-negative integer")
61+
62+
eff_customer_id = customer_id or client.customer_id
63+
instance_id = client.instance_id
64+
if eff_customer_id and eff_customer_id != client.customer_id:
65+
# Dev and staging use 'us' as the location
66+
region = "us" if client.region in ["dev", "staging"] else client.region
67+
instance_id = (
68+
f"projects/{client.project_id}/locations/"
69+
f"{region}/instances/{eff_customer_id}"
70+
)
71+
72+
# The backend expects the resource name to be in the format:
73+
# projects/*/locations/*/instances/*/logTypes/*/parsers/<UUID>
74+
base_url = client.base_url(version="v1alpha")
75+
76+
# First get the list of parsers for this log_type to find a valid
77+
# parser UUID
78+
parsers_url = f"{base_url}/{instance_id}/logTypes/{log_type}/parsers"
79+
parsers_resp = client.session.get(parsers_url, timeout=timeout)
80+
if not parsers_resp.ok:
81+
raise APIError(
82+
f"Failed to fetch parsers for log type {log_type}: "
83+
f"{parsers_resp.text}"
84+
)
85+
86+
parsers_data = parsers_resp.json()
87+
parsers = parsers_data.get("parsers")
88+
if not parsers:
89+
logging.info(
90+
"No parsers found for log type %s. Using fallback parser ID.",
91+
log_type,
92+
)
93+
parser_name = f"{instance_id}/logTypes/{log_type}/parsers/-"
94+
else:
95+
if len(parsers) > 1:
96+
logging.warning(
97+
"Multiple parsers found for log type %s. Using the first one.",
98+
log_type,
99+
)
100+
101+
# Use the first parser's name (which includes the UUID)
102+
parser_name = parsers[0]["name"]
103+
104+
url = f"{base_url}/{parser_name}:runAnalysis"
105+
payload = {
106+
"report_type": "GITHUB_PARSER_VALIDATION",
107+
"pull_request": associated_pr,
108+
}
109+
110+
response = client.session.post(url, json=payload, timeout=timeout)
111+
112+
if not response.ok:
113+
raise APIError(f"API call failed: {response.text}")
114+
115+
return response.json()
116+
117+
118+
def get_analysis_report(
119+
client: "ChronicleClient",
120+
name: str,
121+
timeout: int = 60,
122+
) -> dict[str, Any]:
123+
"""Get a parser analysis report.
124+
Args:
125+
client: ChronicleClient instance
126+
name: The full resource name of the analysis report.
127+
timeout: Optional timeout in seconds (default: 60).
128+
Returns:
129+
Dictionary containing the analysis report.
130+
Raises:
131+
SecOpsError: If input is invalid.
132+
APIError: If the API request fails.
133+
"""
134+
if not isinstance(name, str) or len(name.strip()) < 5:
135+
raise SecOpsError("name must be a valid string")
136+
if not isinstance(timeout, int) or timeout < 0:
137+
raise SecOpsError("timeout must be a non-negative integer")
138+
139+
# The name includes 'projects/...', so we just append it to base_url
140+
base_url = client.base_url(version="v1alpha")
141+
url = f"{base_url}/{name}"
142+
143+
response = client.session.get(url, timeout=timeout)
144+
145+
if not response.ok:
146+
raise APIError(f"API call failed: {response.text}")
147+
148+
return response.json()

src/secops/cli/cli_client.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from secops.cli.commands.investigation import setup_investigation_command
2727
from secops.cli.commands.iocs import setup_iocs_command
2828
from secops.cli.commands.log import setup_log_command
29+
from secops.cli.commands.log_type import setup_log_type_commands
2930
from secops.cli.commands.log_processing import (
3031
setup_log_processing_command,
3132
)
@@ -168,6 +169,7 @@ def build_parser() -> argparse.ArgumentParser:
168169
setup_investigation_command(subparsers)
169170
setup_iocs_command(subparsers)
170171
setup_log_command(subparsers)
172+
setup_log_type_commands(subparsers)
171173
setup_log_processing_command(subparsers)
172174
setup_parser_command(subparsers)
173175
setup_parser_extension_command(subparsers)
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
#
15+
"""CLI for ParserValidationToolingService under Log Type command group"""
16+
17+
import sys
18+
19+
from secops.cli.utils.formatters import output_formatter
20+
from secops.exceptions import APIError, SecOpsError
21+
22+
23+
def setup_log_type_commands(subparsers):
24+
"""Set up the log_type service commands for Parser Validation."""
25+
log_type_parser = subparsers.add_parser(
26+
"log-type", help="Log Type related operations (including Parser Validation)"
27+
)
28+
29+
log_type_subparsers = log_type_parser.add_subparsers(
30+
title="Log Type Commands",
31+
dest="log_type_command",
32+
help="Log Type sub-command to execute"
33+
)
34+
35+
if sys.version_info >= (3, 7):
36+
log_type_subparsers.required = True
37+
38+
log_type_parser.set_defaults(
39+
func=lambda args, chronicle: log_type_parser.print_help()
40+
)
41+
42+
# --- trigger-checks command ---
43+
trigger_github_checks_parser = log_type_subparsers.add_parser(
44+
"trigger-checks", help="Trigger GitHub checks for a parser"
45+
)
46+
trigger_github_checks_parser.add_argument(
47+
"--associated-pr",
48+
"--associated_pr",
49+
required=True,
50+
help='The PR string (e.g., "owner/repo/pull/123").'
51+
)
52+
trigger_github_checks_parser.add_argument(
53+
"--log-type",
54+
"--log_type",
55+
required=True,
56+
help='The string name of the LogType enum (e.g., "DUMMY_LOGTYPE").'
57+
)
58+
trigger_github_checks_parser.set_defaults(func=handle_trigger_checks_command)
59+
60+
# --- get-analysis-report command ---
61+
get_report_parser = log_type_subparsers.add_parser(
62+
"get-analysis-report", help="Get a parser analysis report"
63+
)
64+
get_report_parser.add_argument(
65+
"--name",
66+
required=True,
67+
help="The full resource name of the analysis report."
68+
)
69+
get_report_parser.set_defaults(func=handle_get_analysis_report_command)
70+
71+
72+
def handle_trigger_checks_command(args, chronicle):
73+
"""Handle trigger checks command."""
74+
try:
75+
result = chronicle.trigger_github_checks(
76+
associated_pr=args.associated_pr,
77+
log_type=args.log_type,
78+
)
79+
output_formatter(result, args.output)
80+
except APIError as e:
81+
print(f"Error: {e}", file=sys.stderr)
82+
sys.exit(1)
83+
except SecOpsError as e:
84+
print(f"Error: {e}", file=sys.stderr)
85+
sys.exit(1)
86+
except Exception as e: # pylint: disable=broad-exception-caught
87+
print(f"Error triggering GitHub checks: {e}", file=sys.stderr)
88+
sys.exit(1)
89+
90+
91+
def handle_get_analysis_report_command(args, chronicle):
92+
"""Handle get analysis report command."""
93+
try:
94+
result = chronicle.get_analysis_report(name=args.name)
95+
output_formatter(result, args.output)
96+
except APIError as e:
97+
print(f"Error: {e}", file=sys.stderr)
98+
sys.exit(1)
99+
except SecOpsError as e:
100+
print(f"Error: {e}", file=sys.stderr)
101+
sys.exit(1)
102+
except Exception as e: # pylint: disable=broad-exception-caught
103+
print(f"Error fetching analysis report: {e}", file=sys.stderr)
104+
sys.exit(1)
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
"""Test parser validation methods on ChronicleClient."""
2+
3+
from unittest.mock import MagicMock
4+
import pytest
5+
6+
from secops.chronicle.client import ChronicleClient
7+
8+
9+
@pytest.fixture
10+
def mock_client():
11+
"""Create a mock ChronicleClient."""
12+
client = ChronicleClient(
13+
project_id="test-project",
14+
customer_id="test-customer",
15+
auth=MagicMock(),
16+
)
17+
# Mock the parser validation service stub
18+
client.parser_validation_service_stub = MagicMock()
19+
return client
20+
21+
22+
def test_trigger_github_checks(mock_client, monkeypatch):
23+
"""Test ChronicleClient.trigger_github_checks."""
24+
# Mock the underlying implementation to avoid gRPC dependency in tests
25+
mock_impl = MagicMock(return_value={"message": "Success", "details": "Started"})
26+
monkeypatch.setattr(
27+
"secops.chronicle.client._trigger_github_checks", mock_impl
28+
)
29+
30+
result = mock_client.trigger_github_checks(
31+
associated_pr="owner/repo/pull/123",
32+
log_type="DUMMY_LOGTYPE",
33+
)
34+
35+
assert result == {"message": "Success", "details": "Started"}
36+
mock_impl.assert_called_once_with(
37+
mock_client,
38+
associated_pr="owner/repo/pull/123",
39+
log_type="DUMMY_LOGTYPE",
40+
customer_id=None,
41+
)
42+
43+
44+
def test_get_analysis_report(mock_client, monkeypatch):
45+
"""Test ChronicleClient.get_analysis_report."""
46+
# Mock the underlying implementation
47+
mock_impl = MagicMock(return_value={"reportId": "123"})
48+
monkeypatch.setattr(
49+
"secops.chronicle.client._get_analysis_report", mock_impl
50+
)
51+
52+
result = mock_client.get_analysis_report(
53+
name="projects/test/locations/us/instances/test/logTypes/DEF/parsers/XYZ/analysisReports/123"
54+
)
55+
56+
assert result == {"reportId": "123"}
57+
mock_impl.assert_called_once_with(
58+
mock_client,
59+
"projects/test/locations/us/instances/test/logTypes/DEF/parsers/XYZ/analysisReports/123",
60+
)

0 commit comments

Comments
 (0)