From e5667d271f2ba2b159b2d5f7d44299a5c153f518 Mon Sep 17 00:00:00 2001 From: bruzzechesse Date: Thu, 19 Mar 2026 10:58:03 +0100 Subject: [PATCH 1/3] New fetch_parser_candidates for parsers --- CLI.md | 6 ++++ README.md | 3 ++ src/secops/chronicle/client.py | 24 +++++++++++++++ src/secops/chronicle/parser.py | 32 ++++++++++++++++++++ src/secops/cli/commands/parser.py | 24 +++++++++++++++ tests/chronicle/test_parser.py | 50 +++++++++++++++++++++++++++++++ 6 files changed, 139 insertions(+) diff --git a/CLI.md b/CLI.md index 2031ce67..d9fa6a20 100644 --- a/CLI.md +++ b/CLI.md @@ -552,6 +552,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 34e9fe8a..3eb876cc 100644 --- a/README.md +++ b/README.md @@ -1739,6 +1739,9 @@ 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/src/secops/chronicle/client.py b/src/secops/chronicle/client.py index 949a5565..ba86a9f7 100644 --- a/src/secops/chronicle/client.py +++ b/src/secops/chronicle/client.py @@ -193,6 +193,7 @@ 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 get_parser as _get_parser +from secops.chronicle.parser import fetch_parser_candidates as _fetch_parser_candidates from secops.chronicle.parser import list_parsers as _list_parsers from secops.chronicle.parser import run_parser as _run_parser from secops.chronicle.parser_extension import ParserExtensionConfig @@ -2679,6 +2680,29 @@ def get_parser( """ return _get_parser(self, log_type=log_type, id=id) + def fetch_parser_candidates( + self, + log_type: str, + parser_action: str, + ) -> list[Any]: + """Retrieves unactivated prebuilt parsers that you can copy to a local parser. + + Args: + log_type: Log type of the parser + parser_action: Action to perform (e.g., 'CLONE_PREBUILT') + + Returns: + List of candidate parsers + + Raises: + 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/parser.py b/src/secops/chronicle/parser.py index e1c3488e..91beb6d4 100644 --- a/src/secops/chronicle/parser.py +++ b/src/secops/chronicle/parser.py @@ -248,6 +248,38 @@ def get_parser( return response.json() +def fetch_parser_candidates( + client: "ChronicleClient", + log_type: str, + parser_action: str, +) -> list[Any]: + """Retrieves unactivated prebuilt parsers that you can copy to a local parser. + + Args: + client: ChronicleClient instance + log_type: Log type of the parser + parser_action: Action to perform (e.g., 'CLONE_PREBUILT') + + Returns: + List of candidate parsers + + Raises: + APIError: If the API request fails + """ + url = ( + f"{client.base_url}/{client.instance_id}" + f"/logTypes/{log_type}/parsers:fetchParserCandidates" + ) + + response = client.session.get(url, params={"parserAction": parser_action}) + + if response.status_code != 200: + raise APIError(f"Failed to fetch parser candidates: {response.text}") + + data = response.json() + 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 b6896170..cc145eaf 100644 --- a/src/secops/cli/commands/parser.py +++ b/src/secops/cli/commands/parser.py @@ -143,6 +143,20 @@ 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., CLONE_PREBUILT)." + ) + fetch_parser_candidates_sub.set_defaults( + func=handle_parser_fetch_candidates_command + ) + # --- Run Parser Command --- run_parser_sub = parser_subparsers.add_parser( "run", @@ -313,6 +327,16 @@ 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_parser.py b/tests/chronicle/test_parser.py index 2da74cc2..0af6cd39 100644 --- a/tests/chronicle/test_parser.py +++ b/tests/chronicle/test_parser.py @@ -27,6 +27,7 @@ activate_parser, activate_release_candidate_parser, copy_parser, + fetch_parser_candidates, create_parser, deactivate_parser, delete_parser, @@ -139,6 +140,55 @@ def test_activate_release_candidate_parser_error( assert "Failed to activate parser: Error message" 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 = "CLONE_PREBUILT" + expected_parsers = [{"name": "pa_candidate_1"}, {"name": "pa_candidate_2"}] + mock_response.json.return_value = {"parsers": expected_parsers} + + with patch.object( + chronicle_client.session, "get", return_value=mock_response + ) as mock_get: + result = fetch_parser_candidates(chronicle_client, log_type, parser_action) + + expected_url = f"{chronicle_client.base_url}/{chronicle_client.instance_id}/logTypes/{log_type}/parsers:fetchParserCandidates" + mock_get.assert_called_once_with(expected_url, params={"parserAction": parser_action}) + 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 = "CLONE_PREBUILT" + mock_response.json.return_value = {} + + with patch.object( + chronicle_client.session, "get", return_value=mock_response + ) as mock_get: + result = fetch_parser_candidates(chronicle_client, log_type, parser_action) + + expected_url = f"{chronicle_client.base_url}/{chronicle_client.instance_id}/logTypes/{log_type}/parsers:fetchParserCandidates" + mock_get.assert_called_once_with(expected_url, params={"parserAction": parser_action}) + 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 = "CLONE_PREBUILT" + + with patch.object( + chronicle_client.session, "get", 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: Error message" in str( + exc_info.value + ) + + # --- copy_parser Tests --- def test_copy_parser_success(chronicle_client, mock_response): """Test copy_parser function for success.""" From 377ea1dd5f0860f8b21d64a5e251f126b7c4c155 Mon Sep 17 00:00:00 2001 From: bruzzechesse Date: Thu, 19 Mar 2026 12:14:47 +0100 Subject: [PATCH 2/3] formatting --- src/secops/chronicle/client.py | 9 ++++++--- src/secops/chronicle/parser.py | 4 ++-- src/secops/cli/commands/parser.py | 9 +++++++-- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/src/secops/chronicle/client.py b/src/secops/chronicle/client.py index ba86a9f7..5874f708 100644 --- a/src/secops/chronicle/client.py +++ b/src/secops/chronicle/client.py @@ -13,6 +13,7 @@ # limitations under the License. # """Chronicle API client.""" + import ipaddress import re from collections.abc import Iterator @@ -193,7 +194,9 @@ 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 get_parser as _get_parser -from secops.chronicle.parser import fetch_parser_candidates as _fetch_parser_candidates +from secops.chronicle.parser import ( + fetch_parser_candidates as _fetch_parser_candidates, +) from secops.chronicle.parser import list_parsers as _list_parsers from secops.chronicle.parser import run_parser as _run_parser from secops.chronicle.parser_extension import ParserExtensionConfig @@ -2685,11 +2688,11 @@ def fetch_parser_candidates( log_type: str, parser_action: str, ) -> list[Any]: - """Retrieves unactivated prebuilt parsers that you can copy to a local parser. + """Retrieves prebuilt parsers candidates. Args: log_type: Log type of the parser - parser_action: Action to perform (e.g., 'CLONE_PREBUILT') + parser_action: Action to perform Returns: List of candidate parsers diff --git a/src/secops/chronicle/parser.py b/src/secops/chronicle/parser.py index 91beb6d4..33955188 100644 --- a/src/secops/chronicle/parser.py +++ b/src/secops/chronicle/parser.py @@ -253,12 +253,12 @@ def fetch_parser_candidates( log_type: str, parser_action: str, ) -> list[Any]: - """Retrieves unactivated prebuilt parsers that you can copy to a local parser. + """Retrieves prebuilt parsers candidates. Args: client: ChronicleClient instance log_type: Log type of the parser - parser_action: Action to perform (e.g., 'CLONE_PREBUILT') + parser_action: Action to perform Returns: List of candidate parsers diff --git a/src/secops/cli/commands/parser.py b/src/secops/cli/commands/parser.py index cc145eaf..6f92a5ca 100644 --- a/src/secops/cli/commands/parser.py +++ b/src/secops/cli/commands/parser.py @@ -151,7 +151,10 @@ def setup_parser_command(subparsers): "--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., CLONE_PREBUILT)." + "--parser-action", + type=str, + required=True, + help="Action for the parser candidates (e.g., CLONE_PREBUILT).", ) fetch_parser_candidates_sub.set_defaults( func=handle_parser_fetch_candidates_command @@ -330,7 +333,9 @@ def handle_parser_delete_command(args, chronicle): 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) + 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) From ece56b186cf8ae6aa1bd59e0f4288a1087e63a12 Mon Sep 17 00:00:00 2001 From: bruzzechesse Date: Thu, 19 Mar 2026 12:17:10 +0100 Subject: [PATCH 3/3] fix test --- tests/chronicle/test_parser.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/chronicle/test_parser.py b/tests/chronicle/test_parser.py index 0af6cd39..433bfef4 100644 --- a/tests/chronicle/test_parser.py +++ b/tests/chronicle/test_parser.py @@ -144,8 +144,8 @@ def test_activate_release_candidate_parser_error( def test_fetch_parser_candidates_success(chronicle_client, mock_response): """Test fetch_parser_candidates function for success.""" log_type = "SOME_LOG_TYPE" - parser_action = "CLONE_PREBUILT" - expected_parsers = [{"name": "pa_candidate_1"}, {"name": "pa_candidate_2"}] + parser_action = "PARSER_ACTION_OPT_IN_TO_PREVIEW" + expected_parsers = [] mock_response.json.return_value = {"parsers": expected_parsers} with patch.object( @@ -161,7 +161,7 @@ def test_fetch_parser_candidates_success(chronicle_client, mock_response): 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 = "CLONE_PREBUILT" + parser_action = "PARSER_ACTION_OPT_IN_TO_PREVIEW" mock_response.json.return_value = {} with patch.object(