Skip to content

Commit a119b36

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

8 files changed

Lines changed: 538 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: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
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 configured ID.
40+
timeout: Optional RPC timeout in seconds (default: 60).
41+
42+
Returns:
43+
Dictionary containing the response details.
44+
45+
Raises:
46+
SecOpsError: If input is invalid.
47+
APIError: If the API request fails.
48+
"""
49+
if not isinstance(log_type, str) or len(log_type.strip()) < 2:
50+
raise SecOpsError("log_type must be a valid string of length >= 2")
51+
if customer_id is not None:
52+
if not isinstance(customer_id, str) or len(customer_id.strip()) < 2:
53+
raise SecOpsError("customer_id must be a valid string of length >= 2")
54+
if not isinstance(associated_pr, str) or not associated_pr.strip():
55+
raise SecOpsError("associated_pr must be a non-empty string")
56+
if not isinstance(timeout, int) or timeout < 0:
57+
raise SecOpsError("timeout must be a non-negative integer")
58+
59+
eff_customer_id = customer_id or client.customer_id
60+
instance_id = client.instance_id
61+
if eff_customer_id and eff_customer_id != client.customer_id:
62+
# Dev and staging use 'us' as the location
63+
region = "us" if client.region in ["dev", "staging"] else client.region
64+
instance_id = (
65+
f"projects/{client.project_id}/locations/"
66+
f"{region}/instances/{eff_customer_id}"
67+
)
68+
69+
# The backend expects the resource name to be in the format:
70+
# projects/*/locations/*/instances/*/logTypes/*/parsers/<UUID>
71+
base_url = client.base_url(version="v1alpha")
72+
73+
# First get the list of parsers for this log_type to find a valid
74+
# parser UUID
75+
parsers_url = f"{base_url}/{instance_id}/logTypes/{log_type}/parsers"
76+
parsers_resp = client.session.get(parsers_url, timeout=timeout)
77+
if not parsers_resp.ok:
78+
raise APIError(
79+
f"Failed to fetch parsers for log type {log_type}: "
80+
f"{parsers_resp.text}"
81+
)
82+
83+
parsers_data = parsers_resp.json()
84+
parsers = parsers_data.get("parsers")
85+
if not parsers:
86+
logging.info(
87+
"No parsers found for log type %s. Using fallback parser ID.",
88+
log_type,
89+
)
90+
parser_name = f"{instance_id}/logTypes/{log_type}/parsers/-"
91+
else:
92+
if len(parsers) > 1:
93+
logging.warning(
94+
"Multiple parsers found for log type %s. Using the first one.",
95+
log_type,
96+
)
97+
98+
# Use the first parser's name (which includes the UUID)
99+
parser_name = parsers[0]["name"]
100+
101+
url = f"{base_url}/{parser_name}:runAnalysis"
102+
payload = {
103+
"report_type": "GITHUB_PARSER_VALIDATION",
104+
"pull_request": associated_pr,
105+
}
106+
107+
response = client.session.post(url, json=payload, timeout=timeout)
108+
109+
if not response.ok:
110+
raise APIError(f"API call failed: {response.text}")
111+
112+
return response.json()
113+
114+
115+
def get_analysis_report(
116+
client: "ChronicleClient",
117+
name: str,
118+
timeout: int = 60,
119+
) -> dict[str, Any]:
120+
"""Get a parser analysis report.
121+
Args:
122+
client: ChronicleClient instance
123+
name: The full resource name of the analysis report.
124+
timeout: Optional timeout in seconds (default: 60).
125+
Returns:
126+
Dictionary containing the analysis report.
127+
Raises:
128+
SecOpsError: If input is invalid.
129+
APIError: If the API request fails.
130+
"""
131+
if not isinstance(name, str) or len(name.strip()) < 5:
132+
raise SecOpsError("name must be a valid string")
133+
if not isinstance(timeout, int) or timeout < 0:
134+
raise SecOpsError("timeout must be a non-negative integer")
135+
136+
# The name includes 'projects/...', so we just append it to base_url
137+
base_url = client.base_url(version="v1alpha")
138+
url = f"{base_url}/{name}"
139+
140+
response = client.session.get(url, timeout=timeout)
141+
142+
if not response.ok:
143+
raise APIError(f"API call failed: {response.text}")
144+
145+
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)