diff --git a/CHANGELOG.md b/CHANGELOG.md index f2fbe39..2bd3109 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ 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.42.0] - 2026-04-15 +### Added +- `fetch_parser_candidates()` method to retrieve parser candidates for a given log type +- CLI command `secops parser fetch-candidates` for fetching parser candidates for given type + ## [0.41.0] - 2026-04-09 ### Added - Comprehensive SOAR integration management capabilities diff --git a/CLI.md b/CLI.md index c6d8979..2af82c2 100644 --- a/CLI.md +++ b/CLI.md @@ -593,6 +593,12 @@ secops parser list --log-type "OKTA" --page-size 50 --filter "state=ACTIVE" secops parser get --log-type "WINDOWS" --id "pa_12345" ``` +#### Fetch parser candidates: + +```bash +secops parser fetch-candidates --log-type "WINDOWS_DHCP" --parser-action "PARSER_ACTION_OPT_IN_TO_PREVIEW" +``` + #### Create a new parser: ```bash diff --git a/README.md b/README.md index 616ee1b..caeee03 100644 --- a/README.md +++ b/README.md @@ -1739,6 +1739,12 @@ print(f"Parser content: {parser.get('text')}") chronicle.activate_parser(log_type=log_type, id=parser_id) chronicle.deactivate_parser(log_type=log_type, id=parser_id) +# Fetch parser candidates (unactivated prebuilt parsers) +candidates = chronicle.fetch_parser_candidates( + log_type=log_type, + parser_action="PARSER_ACTION_OPT_IN_TO_PREVIEW" +) + # Copy an existing parser as a starting point copied_parser = chronicle.copy_parser(log_type=log_type, id="pa_existing_parser") diff --git a/api_module_mapping.md b/api_module_mapping.md index 13fe821..000bf38 100644 --- a/api_module_mapping.md +++ b/api_module_mapping.md @@ -553,6 +553,7 @@ Following shows mapping between SecOps [REST Resource](https://cloud.google.com/ | logTypes.parsers.create | v1alpha | chronicle.parser.create_parser | secops parser create | | logTypes.parsers.deactivate | v1alpha | chronicle.parser.deactivate_parser | secops parser deactivate | | logTypes.parsers.delete | v1alpha | chronicle.parser.delete_parser | secops parser delete | +| logTypes.parsers.fetchParserCandidates | v1alpha | chronicle.parser.fetch_parser_candidates | secops parser fetch-candidates | | logTypes.parsers.get | v1alpha | chronicle.parser.get_parser | secops parser get | | logTypes.parsers.list | v1alpha | chronicle.parser.list_parsers | secops parser list | | logTypes.parsers.validationReports.get | v1alpha | | | diff --git a/examples/example.py b/examples/example.py index 8bcae51..aa33db6 100644 --- a/examples/example.py +++ b/examples/example.py @@ -78,8 +78,8 @@ def example_udm_search(chronicle): def example_udm_search_view(chronicle): - """Example 14: UDM Search View.""" - print("\n=== Example 14: UDM Search View ===") + """Example 15: UDM Search View.""" + print("\n=== Example 15: UDM Search View ===") start_time, end_time = get_time_range() try: @@ -1413,9 +1413,48 @@ def example_parser_workflow(chronicle): print(f"\nUnexpected error: {e}") +def example_fetch_parser_candidates(chronicle): + """Example 13: Fetch Parser Candidates for a log type.""" + print("\n=== Example 13: Fetch Parser Candidates ===") + + log_type = "OKTA" + parser_action = "PARSER_ACTION_OPT_IN_TO_PREVIEW" + + try: + print( + f"\nFetching parser candidates for log type '{log_type}' " + f"with action '{parser_action}'..." + ) + candidates = chronicle.fetch_parser_candidates( + log_type=log_type, + parser_action=parser_action, + ) + + if not candidates: + print(f"No parser candidates found for log type '{log_type}'.") + return + + print(f"Found {len(candidates)} parser candidate(s):") + for candidate in candidates: + name = candidate.get("name", "N/A") + state = candidate.get("state", "N/A") + parser_id = name.split("/")[-1] + print(f" - ID: {parser_id}, State: {state}") + + except APIError as e: + print(f"\nAPI Error: {e}") + print("\nTroubleshooting tips:") + print( + "- Ensure the log type supports prebuilt parser candidates" + ) + print("- Check if you have the required permissions") + except ValueError as e: + print(f"\nInvalid input: {e}") + + def example_rule_test(chronicle): - """Example 13: Test a detection rule against historical data.""" - print("\n=== Example 13: Test a Detection Rule Against Historical Data ===") + """Example 14: Test a detection rule against historical data.""" + print("\n=== Example 14: Test a Detection Rule Against Historical Data ===") # Define time range for testing - use a recent time period (last 7 days) end_time = datetime.now(timezone.utc) - timedelta(minutes=15) @@ -1491,8 +1530,9 @@ def example_rule_test(chronicle): "10": example_udm_ingestion, "11": example_gemini, "12": example_parser_workflow, - "13": example_rule_test, - "14": example_udm_search_view, + "13": example_fetch_parser_candidates, + "14": example_rule_test, + "15": example_udm_search_view, } @@ -1507,7 +1547,7 @@ def main(): parser.add_argument( "--example", "-e", - help="Example number to run (1-14). If not specified, runs all examples.", + help="Example number to run (1-15). If not specified, runs all examples.", ) args = parser.parse_args() diff --git a/pyproject.toml b/pyproject.toml index 16baa7f..f83e410 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "secops" -version = "0.41.0" +version = "0.42.0" description = "Python SDK for wrapping the Google SecOps API for common use cases" readme = "README.md" requires-python = ">=3.10" diff --git a/src/secops/chronicle/__init__.py b/src/secops/chronicle/__init__.py index 76d4150..ebf9121 100644 --- a/src/secops/chronicle/__init__.py +++ b/src/secops/chronicle/__init__.py @@ -137,6 +137,7 @@ ListBasis, MonthlyScheduleDetails, OneTimeScheduleDetails, + ParserAction, PrevalenceData, PythonVersion, ScheduleType, @@ -151,6 +152,7 @@ WidgetMetadata, ) from secops.chronicle.nl_search import translate_nl_to_udm +from secops.chronicle.parser import fetch_parser_candidates from secops.chronicle.reference_list import ( ReferenceListSyntaxType, ReferenceListView, @@ -243,6 +245,9 @@ "search_raw_logs", # Natural Language Search "translate_nl_to_udm", + # Parser + "fetch_parser_candidates", + "ParserAction", # Entity "import_entities", "summarize_entity", diff --git a/src/secops/chronicle/client.py b/src/secops/chronicle/client.py index 2540798..99981d3 100644 --- a/src/secops/chronicle/client.py +++ b/src/secops/chronicle/client.py @@ -174,6 +174,7 @@ ) from secops.chronicle.models import ( APIVersion, + AlertState, CaseCloseReason, CaseList, CasePriority, @@ -181,9 +182,9 @@ DashboardQuery, EntitySummary, InputInterval, - TileType, - AlertState, ListBasis, + ParserAction, + TileType, ) from secops.chronicle.nl_search import nl_search as _nl_search from secops.chronicle.nl_search import translate_nl_to_udm @@ -195,6 +196,9 @@ from secops.chronicle.parser import create_parser as _create_parser from secops.chronicle.parser import deactivate_parser as _deactivate_parser from secops.chronicle.parser import delete_parser as _delete_parser +from secops.chronicle.parser import ( + fetch_parser_candidates as _fetch_parser_candidates, +) from secops.chronicle.parser import get_parser as _get_parser from secops.chronicle.parser import list_parsers as _list_parsers from secops.chronicle.parser import run_parser as _run_parser @@ -2774,6 +2778,35 @@ def get_parser( """ return _get_parser(self, log_type=log_type, id=id) + def fetch_parser_candidates( + self, + log_type: str, + parser_action: ParserAction | str, + ) -> list[Any]: + """Retrieves prebuilt parser candidates. + + Args: + log_type: Log type of the parser + parser_action: Action to perform on the parser candidates. Can be + a ParserAction enum value or a string. Valid values: + - ParserAction.PARSER_ACTION_UNSPECIFIED + - ParserAction.PARSER_ACTION_OPT_IN_TO_PREVIEW + - ParserAction.PARSER_ACTION_OPT_OUT_OF_PREVIEW + - ParserAction.CLONE_PREBUILT + + Returns: + List of candidate parsers + + Raises: + ValueError: If parser_action is an invalid string value + APIError: If the API request fails + """ + return _fetch_parser_candidates( + self, + log_type=log_type, + parser_action=parser_action, + ) + def list_parsers( self, log_type: str = "-", diff --git a/src/secops/chronicle/models.py b/src/secops/chronicle/models.py index 025c213..5f598f3 100644 --- a/src/secops/chronicle/models.py +++ b/src/secops/chronicle/models.py @@ -1150,3 +1150,18 @@ class APIVersion(StrEnum): V1 = "v1" V1BETA = "v1beta" V1ALPHA = "v1alpha" + + +class ParserAction(StrEnum): + """Actions that can be performed on parser candidates. + + See: + https://cloud.google.com/chronicle/docs/reference/rest/v1beta/ + projects.locations.instances.logTypes.parsers/ + fetchParserCandidates#ParserAction + """ + + PARSER_ACTION_UNSPECIFIED = "PARSER_ACTION_UNSPECIFIED" + PARSER_ACTION_OPT_IN_TO_PREVIEW = "PARSER_ACTION_OPT_IN_TO_PREVIEW" + PARSER_ACTION_OPT_OUT_OF_PREVIEW = "PARSER_ACTION_OPT_OUT_OF_PREVIEW" + CLONE_PREBUILT = "CLONE_PREBUILT" diff --git a/src/secops/chronicle/parser.py b/src/secops/chronicle/parser.py index 6cf3368..3dd7f02 100644 --- a/src/secops/chronicle/parser.py +++ b/src/secops/chronicle/parser.py @@ -19,7 +19,7 @@ import logging from typing import Any -from secops.chronicle.models import APIVersion +from secops.chronicle.models import APIVersion, ParserAction from secops.chronicle.utils.format_utils import remove_none_values from secops.chronicle.utils.request_utils import ( chronicle_paginated_request, @@ -27,6 +27,7 @@ ) from secops.exceptions import APIError, SecOpsError + # Constants for size limits MAX_LOG_SIZE = 10 * 1024 * 1024 # 10MB per log MAX_LOGS = 1000 # Maximum number of logs to process @@ -235,6 +236,54 @@ def get_parser( ) +def fetch_parser_candidates( + client: "ChronicleClient", + log_type: str, + parser_action: ParserAction | str, +) -> list[Any]: + """Retrieves prebuilt parser candidates. + + Args: + client: ChronicleClient instance + log_type: Log type of the parser + parser_action: Action to perform on the parser candidates. Can be a + ParserAction enum value or a string. Valid values: + - ParserAction.PARSER_ACTION_UNSPECIFIED + - ParserAction.PARSER_ACTION_OPT_IN_TO_PREVIEW + - ParserAction.PARSER_ACTION_OPT_OUT_OF_PREVIEW + - ParserAction.CLONE_PREBUILT + + Returns: + List of candidate parsers + + Raises: + ValueError: If log_type is empty or parser_action is an invalid string + APIError: If the API request fails + """ + if not log_type: + raise ValueError("log_type cannot be empty") + if isinstance(parser_action, str) and not isinstance( + parser_action, ParserAction + ): + try: + parser_action = ParserAction(parser_action) + except ValueError as e: + valid = ", ".join(m.value for m in ParserAction) + raise ValueError( + f'Invalid parser_action: "{parser_action}". ' + f"Valid values: {valid}" + ) from e + + data = chronicle_request( + client, + method="GET", + endpoint_path=f"logTypes/{log_type}/parsers:fetchParserCandidates", + params={"parserAction": parser_action}, + error_message="Failed to fetch parser candidates", + ) + return data.get("candidates", []) + + def list_parsers( client: "ChronicleClient", log_type: str = "-", diff --git a/src/secops/cli/commands/parser.py b/src/secops/cli/commands/parser.py index a7bb81a..05950c2 100644 --- a/src/secops/cli/commands/parser.py +++ b/src/secops/cli/commands/parser.py @@ -144,6 +144,26 @@ def setup_parser_command(subparsers): ) list_parsers_sub.set_defaults(func=handle_parser_list_command) + # --- Fetch Parser Candidates Command --- + fetch_parser_candidates_sub = parser_subparsers.add_parser( + "fetch-candidates", help="Fetch unactivated prebuilt parsers." + ) + fetch_parser_candidates_sub.add_argument( + "--log-type", type=str, required=True, help="Log type of the parser." + ) + fetch_parser_candidates_sub.add_argument( + "--parser-action", + type=str, + required=True, + help=( + "Action for the parser candidates " + "(e.g., PARSER_ACTION_OPT_IN_TO_PREVIEW)." + ), + ) + fetch_parser_candidates_sub.set_defaults( + func=handle_parser_fetch_candidates_command + ) + # --- Run Parser Command --- run_parser_sub = parser_subparsers.add_parser( "run", @@ -314,6 +334,18 @@ def handle_parser_delete_command(args, chronicle): sys.exit(1) +def handle_parser_fetch_candidates_command(args, chronicle): + """Handle parser fetch-candidates command.""" + try: + result = chronicle.fetch_parser_candidates( + args.log_type, args.parser_action + ) + output_formatter(result, args.output) + except Exception as e: # pylint: disable=broad-exception-caught + print(f"Error fetching parser candidates: {e}", file=sys.stderr) + sys.exit(1) + + def handle_parser_get_command(args, chronicle): """Handle parser get command.""" try: diff --git a/tests/chronicle/test_integration.py b/tests/chronicle/test_integration.py index 5bf404d..582f3be 100644 --- a/tests/chronicle/test_integration.py +++ b/tests/chronicle/test_integration.py @@ -2212,3 +2212,33 @@ def test_chronicle_log_types_description(): except APIError as e: print(f"\nAPI Error details: {str(e)}") pytest.fail(f"API Error during log type description test: {e}") + + +@pytest.mark.integration +def test_chronicle_fetch_parser_candidates(): + """Test fetching parser candidates using the Chronicle client.""" + if ( + not CHRONICLE_CONFIG["customer_id"] + or not CHRONICLE_CONFIG["project_id"] + ): + pytest.skip("Chronicle configuration not available") + + try: + client = SecOpsClient() + chronicle = client.chronicle(**CHRONICLE_CONFIG) + + result = chronicle.fetch_parser_candidates( + log_type="OKTA", + parser_action="PARSER_ACTION_OPT_IN_TO_PREVIEW", + ) + + assert isinstance(result, list) + print(f"\nFetched {len(result)} parser candidate(s) for OKTA") + + for candidate in result: + assert isinstance(candidate, dict) + print(f" Candidate: {candidate.get('name', 'N/A')}") + + except APIError as e: + print(f"\nAPI Error details: {str(e)}") + pytest.fail(f"API Error during fetch_parser_candidates test: {e}") diff --git a/tests/chronicle/test_parser.py b/tests/chronicle/test_parser.py index 3c58fde..7051b00 100644 --- a/tests/chronicle/test_parser.py +++ b/tests/chronicle/test_parser.py @@ -20,6 +20,7 @@ import pytest from secops.chronicle.client import ChronicleClient +from secops.chronicle.models import ParserAction from secops.chronicle.parser import ( MAX_LOG_SIZE, MAX_LOGS, @@ -30,6 +31,7 @@ create_parser, deactivate_parser, delete_parser, + fetch_parser_candidates, get_parser, list_parsers, run_parser, @@ -153,6 +155,116 @@ def test_activate_release_candidate_parser_error( assert "Failed to activate parser" in str(exc_info.value) +# --- fetch_parser_candidates Tests --- +def test_fetch_parser_candidates_success(chronicle_client, mock_response): + """Test fetch_parser_candidates function for success.""" + log_type = "SOME_LOG_TYPE" + parser_action = "PARSER_ACTION_OPT_IN_TO_PREVIEW" + expected_parsers = [{"name": f"logTypes/{log_type}/parsers/pa_001"}] + mock_response.json.return_value = {"candidates": expected_parsers} + + with patch.object( + chronicle_client.session, "request", return_value=mock_response + ) as mock_request: + result = fetch_parser_candidates( + chronicle_client, log_type, parser_action + ) + + expected_url = ( + f"{chronicle_client.base_url}/{chronicle_client.instance_id}" + f"/logTypes/{log_type}/parsers:fetchParserCandidates" + ) + mock_request.assert_called_once_with( + method="GET", + url=expected_url, + params={"parserAction": parser_action}, + json=None, + headers=None, + timeout=None, + ) + assert result == expected_parsers + + +def test_fetch_parser_candidates_empty(chronicle_client, mock_response): + """Test fetch_parser_candidates function when no parsers are returned.""" + log_type = "EMPTY_LOG_TYPE" + parser_action = "PARSER_ACTION_OPT_IN_TO_PREVIEW" + mock_response.json.return_value = {} + + with patch.object( + chronicle_client.session, "request", return_value=mock_response + ) as mock_request: + result = fetch_parser_candidates( + chronicle_client, log_type, parser_action + ) + + expected_url = ( + f"{chronicle_client.base_url}/{chronicle_client.instance_id}" + f"/logTypes/{log_type}/parsers:fetchParserCandidates" + ) + mock_request.assert_called_once_with( + method="GET", + url=expected_url, + params={"parserAction": parser_action}, + json=None, + headers=None, + timeout=None, + ) + assert result == [] + + +def test_fetch_parser_candidates_error(chronicle_client, mock_error_response): + """Test fetch_parser_candidates function for API error.""" + log_type = "ERROR_LOG_TYPE" + parser_action = "PARSER_ACTION_OPT_IN_TO_PREVIEW" + + with patch.object( + chronicle_client.session, "request", return_value=mock_error_response + ): + with pytest.raises(APIError) as exc_info: + fetch_parser_candidates(chronicle_client, log_type, parser_action) + assert "Failed to fetch parser candidates" in str(exc_info.value) + + +def test_fetch_parser_candidates_with_enum(chronicle_client, mock_response): + """Test fetch_parser_candidates accepts ParserAction enum directly.""" + log_type = "SOME_LOG_TYPE" + parser_action = ParserAction.PARSER_ACTION_OPT_IN_TO_PREVIEW + expected_parsers = [{"name": f"logTypes/{log_type}/parsers/pa_001"}] + mock_response.json.return_value = {"candidates": expected_parsers} + + with patch.object( + chronicle_client.session, "request", return_value=mock_response + ) as mock_request: + result = fetch_parser_candidates( + chronicle_client, log_type, parser_action + ) + + expected_url = ( + f"{chronicle_client.base_url}/{chronicle_client.instance_id}" + f"/logTypes/{log_type}/parsers:fetchParserCandidates" + ) + mock_request.assert_called_once_with( + method="GET", + url=expected_url, + params={"parserAction": parser_action}, + json=None, + headers=None, + timeout=None, + ) + assert result == expected_parsers + + +def test_fetch_parser_candidates_invalid_string(chronicle_client): + """Test fetch_parser_candidates raises ValueError for invalid string.""" + with pytest.raises(ValueError) as exc_info: + fetch_parser_candidates( + chronicle_client, "SOME_LOG_TYPE", "INVALID_ACTION" + ) + assert 'Invalid parser_action: "INVALID_ACTION"' in str(exc_info.value) + assert "PARSER_ACTION_OPT_IN_TO_PREVIEW" in str(exc_info.value) + + # --- copy_parser Tests --- def test_copy_parser_success(chronicle_client, mock_response): """Test copy_parser function for success.""" diff --git a/tests/cli/test_integration.py b/tests/cli/test_integration.py index b925235..c2edff5 100644 --- a/tests/cli/test_integration.py +++ b/tests/cli/test_integration.py @@ -352,6 +352,33 @@ def test_cli_parser_list(cli_env, common_args): assert "Error:" not in result.stdout +@pytest.mark.integration +def test_cli_fetch_parser_candidates(cli_env, common_args): + """Test the parser fetch-candidates command.""" + cmd = ( + ["secops"] + + common_args + + [ + "parser", + "fetch-candidates", + "--log-type", + "OKTA", + "--parser-action", + "PARSER_ACTION_OPT_IN_TO_PREVIEW", + ] + ) + + result = subprocess.run(cmd, env=cli_env, capture_output=True, text=True) + + assert result.returncode == 0, f"Command failed: {result.stderr}" + + output = json.loads(result.stdout) + assert isinstance(output, list) + print(f"\nFetched {len(output)} parser candidate(s) for OKTA") + for candidate in output: + assert isinstance(candidate, dict) + + @pytest.mark.integration def test_cli_parser_get(cli_env, common_args): """Test the rule get command (first need to find an existing rule ID)."""