diff --git a/CLI.md b/CLI.md index 2af82c2..8a670f0 100644 --- a/CLI.md +++ b/CLI.md @@ -962,6 +962,174 @@ secops integration integrations delete --integration-id "MyIntegration" ``` Download an integration package: +#### Integration Actions + +List integration actions: + +```bash +# List all actions for an integration +secops integration actions list --integration-name "MyIntegration" + +# List actions as a direct list (fetches all pages automatically) +secops integration actions list --integration-name "MyIntegration" --as-list + +# List with pagination +secops integration actions list --integration-name "MyIntegration" --page-size 50 + +# List with filtering +secops integration actions list --integration-name "MyIntegration" --filter-string "enabled = true" +``` + +Get action details: + +```bash +secops integration actions get --integration-name "MyIntegration" --action-id "123" +``` + +Create a new action: + +```bash +# Create a basic action with Python code +secops integration actions create \ + --integration-name "MyIntegration" \ + --display-name "Send Alert" \ + --code "def main(context): return {'status': 'success'}" + +# Create an async action +secops integration actions create \ + --integration-name "MyIntegration" \ + --display-name "Async Task" \ + --code "async def main(context): return await process()" \ + --is-async + +# Create with description +secops integration actions create \ + --integration-name "MyIntegration" \ + --display-name "My Action" \ + --code "def main(context): return {}" \ + --description "Action description" +``` + +> **Note:** When creating an action, the following default values are automatically applied: +> - `timeout_seconds`: 300 (5 minutes) +> - `enabled`: true +> - `script_result_name`: "result" +> +> The `--code` parameter contains the Python script that will be executed by the action. + +Update an existing action: + +```bash +# Update display name +secops integration actions update \ + --integration-name "MyIntegration" \ + --action-id "123" \ + --display-name "Updated Action Name" + +# Update code +secops integration actions update \ + --integration-name "MyIntegration" \ + --action-id "123" \ + --code "def main(context): return {'status': 'updated'}" + +# Update multiple fields with update mask +secops integration actions update \ + --integration-name "MyIntegration" \ + --action-id "123" \ + --display-name "New Name" \ + --description "New description" \ + --update-mask "displayName,description" +``` + +Delete an action: + +```bash +secops integration actions delete --integration-name "MyIntegration" --action-id "123" +``` + +Test an action: + +```bash +# Test an action to verify it executes correctly +secops integration actions test --integration-name "MyIntegration" --action-id "123" +``` + +Get action template: + +```bash +# Get synchronous action template +secops integration actions template --integration-name "MyIntegration" + +# Get asynchronous action template +secops integration actions template --integration-name "MyIntegration" --is-async +``` + +#### Action Revisions + +List action revisions: + +```bash +# List all revisions for an action +secops integration action-revisions list \ + --integration-name "MyIntegration" \ + --action-id "123" + +# List revisions as a direct list +secops integration action-revisions list \ + --integration-name "MyIntegration" \ + --action-id "123" \ + --as-list + +# List with pagination +secops integration action-revisions list \ + --integration-name "MyIntegration" \ + --action-id "123" \ + --page-size 10 + +# List with filtering and ordering +secops integration action-revisions list \ + --integration-name "MyIntegration" \ + --action-id "123" \ + --filter-string 'version = "1.0"' \ + --order-by "createTime desc" +``` + +Create a revision backup: + +```bash +# Create revision with comment +secops integration action-revisions create \ + --integration-name "MyIntegration" \ + --action-id "123" \ + --comment "Backup before major refactor" + +# Create revision without comment +secops integration action-revisions create \ + --integration-name "MyIntegration" \ + --action-id "123" +``` + +Rollback to a previous revision: + +```bash +secops integration action-revisions rollback \ + --integration-name "MyIntegration" \ + --action-id "123" \ + --revision-id "r456" +``` + +Delete an old revision: + +```bash +secops integration action-revisions delete \ + --integration-name "MyIntegration" \ + --action-id "123" \ + --revision-id "r789" +``` + +#### Integration Managers + +List integration managers: ```bash # Download integration as a ZIP file diff --git a/README.md b/README.md index caeee03..fec31d6 100644 --- a/README.md +++ b/README.md @@ -2063,6 +2063,430 @@ for watchlist in watchlists: print(f"Watchlist: {watchlist.get('displayName')}") ``` +## Integration Management + +### Integration Actions + +List all available actions for an integration: + +```python +# Get all actions for an integration +actions = chronicle.list_integration_actions("AWSSecurityHub") +for action in actions.get("actions", []): + print(f"Action: {action.get('displayName')}, ID: {action.get('name')}") + +# Get all actions as a list +actions = chronicle.list_integration_actions("AWSSecurityHub", as_list=True) + +# Get only enabled actions +actions = chronicle.list_integration_actions("AWSSecurityHub", filter_string="enabled = true") +``` + +Get details of a specific action: + +```python + +action = chronicle.get_integration_action( + integration_name="AWSSecurityHub", + action_id="123" +) +``` + +Create an integration action + +```python +from secops.chronicle.models import ActionParameter, ActionParamType + +new_action = chronicle.create_integration_action( + integration_name="MyIntegration", + display_name="New Action", + description="This is a new action", + enabled=True, + timeout_seconds=900, + is_async=False, + script_result_name="script_result", + parameters=[ + ActionParameter( + display_name="Parameter 1", + type=ActionParamType.STRING, + description="This is parameter 1", + mandatory=True, + ) + ], + script="print('Hello, World!')" + ) +``` + +Update an integration action + +```python +from secops.chronicle.models import ActionParameter, ActionParamType + +updated_action = chronicle.update_integration_action( + integration_name="MyIntegration", + action_id="123", + display_name="Updated Action Name", + description="Updated description", + enabled=False, + parameters=[ + ActionParameter( + display_name="New Parameter", + type=ActionParamType.PASSWORD, + description="This is a new parameter", + mandatory=True, + ) + ], + script="print('Updated script')" +) +``` + +Delete an integration action + +```python +chronicle.delete_integration_action( + integration_name="MyIntegration", + action_id="123" +) +``` + +Execute test run of an integration action + +```python +# Get the integration instance ID by using chronicle.list_integration_instances() +integration_instance_id = "abc-123-def-456" + +test_run = chronicle.execute_integration_action_test( + integration_name="MyIntegration", + test_case_id=123456, + action=chronicle.get_integration_action("MyIntegration", "123"), + scope="TEST", + integration_instance_id=integration_instance_id, +) +``` + +Get integration actions by environment + +```python +# Get all actions for an integration in the Default Environment +actions = chronicle.get_integration_actions_by_environment( + integration_name="MyIntegration", + environments=["Default Environment"], + include_widgets=True, +) +``` + +Get a template for creating an action in an integration + +```python +template = chronicle.get_integration_action_template("MyIntegration") +``` + +### Integration Action Revisions + +List all revisions for an action: + +```python +# Get all revisions for an action +revisions = chronicle.list_integration_action_revisions( + integration_name="MyIntegration", + action_id="123" +) +for revision in revisions.get("revisions", []): + print(f"Revision: {revision.get('name')}, Comment: {revision.get('comment')}") + +# Get all revisions as a list +revisions = chronicle.list_integration_action_revisions( + integration_name="MyIntegration", + action_id="123", + as_list=True +) + +# Filter revisions +revisions = chronicle.list_integration_action_revisions( + integration_name="MyIntegration", + action_id="123", + filter_string='version = "1.0"', + order_by="createTime desc" +) +``` + +Delete a specific action revision: + +```python +chronicle.delete_integration_action_revision( + integration_name="MyIntegration", + action_id="123", + revision_id="rev-456" +) +``` + +Create a new revision before making changes: + +```python +# Get the current action +action = chronicle.get_integration_action( + integration_name="MyIntegration", + action_id="123" +) + +# Create a backup revision +new_revision = chronicle.create_integration_action_revision( + integration_name="MyIntegration", + action_id="123", + action=action, + comment="Backup before major refactor" +) +print(f"Created revision: {new_revision.get('name')}") + +# Create revision with custom comment +new_revision = chronicle.create_integration_action_revision( + integration_name="MyIntegration", + action_id="123", + action=action, + comment="Version 2.0 - Added error handling" +) +``` + +Rollback to a previous revision: + +```python +# Rollback to a previous working version +rollback_result = chronicle.rollback_integration_action_revision( + integration_name="MyIntegration", + action_id="123", + revision_id="rev-456" +) +print(f"Rolled back to: {rollback_result.get('name')}") +``` + +Example workflow: Safe action updates with revision control: + +```python +# 1. Get the current action +action = chronicle.get_integration_action( + integration_name="MyIntegration", + action_id="123" +) + +# 2. Create a backup revision +backup = chronicle.create_integration_action_revision( + integration_name="MyIntegration", + action_id="123", + action=action, + comment="Backup before updating logic" +) + +# 3. Make changes to the action +updated_action = chronicle.update_integration_action( + integration_name="MyIntegration", + action_id="123", + display_name="Updated Action", + script=""" +def main(context): + # New logic here + return {"status": "success"} +""" +) + +# 4. Test the updated action +test_result = chronicle.execute_integration_action_test( + integration_name="MyIntegration", + action_id="123", + action=updated_action +) + +# 5. If test fails, rollback to backup +if not test_result.get("successful"): + print("Test failed - rolling back") + chronicle.rollback_integration_action_revision( + integration_name="MyIntegration", + action_id="123", + revision_id=backup.get("name").split("/")[-1] + ) +else: + print("Test passed - changes saved") +``` + +### Integration Managers + +List all available managers for an integration: + +```python +# Get all managers for an integration +managers = chronicle.list_integration_managers("MyIntegration") +for manager in managers.get("managers", []): + print(f"Manager: {manager.get('displayName')}, ID: {manager.get('name')}") + +# Get all managers as a list +managers = chronicle.list_integration_managers("MyIntegration", as_list=True) + +# Filter managers by display name +managers = chronicle.list_integration_managers( + "MyIntegration", + filter_string='displayName = "API Helper"' +) + +# Sort managers by display name +managers = chronicle.list_integration_managers( + "MyIntegration", + order_by="displayName" +) +``` + +Get details of a specific manager: + +```python +manager = chronicle.get_integration_manager( + integration_name="MyIntegration", + manager_id="123" +) +``` + +Create an integration manager: + +```python +new_manager = chronicle.create_integration_manager( + integration_name="MyIntegration", + display_name="API Helper", + description="Shared utility functions for API calls", + script=""" +def make_api_request(url, headers=None): + '''Helper function to make API requests''' + import requests + return requests.get(url, headers=headers) + +def parse_response(response): + '''Parse API response''' + return response.json() +""" +) +``` + +Update an integration manager: + +```python +updated_manager = chronicle.update_integration_manager( + integration_name="MyIntegration", + manager_id="123", + display_name="Updated API Helper", + description="Updated shared utility functions", + script=""" +def make_api_request(url, headers=None, method='GET'): + '''Updated helper function with method parameter''' + import requests + if method == 'GET': + return requests.get(url, headers=headers) + elif method == 'POST': + return requests.post(url, headers=headers) +""" +) + +# Update only specific fields +updated_manager = chronicle.update_integration_manager( + integration_name="MyIntegration", + manager_id="123", + description="New description only" +) +``` + +Delete an integration manager: + +```python +chronicle.delete_integration_manager( + integration_name="MyIntegration", + manager_id="123" +) +``` + +Get a template for creating a manager in an integration: + +```python +template = chronicle.get_integration_manager_template("MyIntegration") +print(f"Template script: {template.get('script')}") +``` + +### Integration Manager Revisions + +List all revisions for a specific manager: + +```python +# Get all revisions for a manager +revisions = chronicle.list_integration_manager_revisions( + integration_name="MyIntegration", + manager_id="123" +) +for revision in revisions.get("revisions", []): + print(f"Revision: {revision.get('name')}, Comment: {revision.get('comment')}") + +# Get all revisions as a list +revisions = chronicle.list_integration_manager_revisions( + integration_name="MyIntegration", + manager_id="123", + as_list=True +) + +# Filter revisions +revisions = chronicle.list_integration_manager_revisions( + integration_name="MyIntegration", + manager_id="123", + filter_string='comment contains "backup"', + order_by="createTime desc" +) +``` + +Get details of a specific revision: + +```python +revision = chronicle.get_integration_manager_revision( + integration_name="MyIntegration", + manager_id="123", + revision_id="r1" +) +print(f"Revision script: {revision.get('manager', {}).get('script')}") +``` + +Create a new revision snapshot: + +```python +# Get the current manager +manager = chronicle.get_integration_manager( + integration_name="MyIntegration", + manager_id="123" +) + +# Create a revision before making changes +revision = chronicle.create_integration_manager_revision( + integration_name="MyIntegration", + manager_id="123", + manager=manager, + comment="Backup before major refactor" +) +print(f"Created revision: {revision.get('name')}") +``` + +Rollback to a previous revision: + +```python +# Rollback to a previous working version +rollback_result = chronicle.rollback_integration_manager_revision( + integration_name="MyIntegration", + manager_id="123", + revision_id="acb123de-abcd-1234-ef00-1234567890ab" +) +print(f"Rolled back to: {rollback_result.get('name')}") +``` + +Delete a revision: + +```python +chronicle.delete_integration_manager_revision( + integration_name="MyIntegration", + manager_id="123", + revision_id="r1" +) +``` + +## Rule Management ### Rule Management The SDK provides comprehensive support for managing Chronicle detection rules: diff --git a/src/secops/chronicle/client.py b/src/secops/chronicle/client.py index 99981d3..8b45983 100644 --- a/src/secops/chronicle/client.py +++ b/src/secops/chronicle/client.py @@ -135,13 +135,6 @@ from secops.chronicle.log_ingest import ingest_udm as _ingest_udm from secops.chronicle.log_ingest import list_forwarders as _list_forwarders from secops.chronicle.log_ingest import update_forwarder as _update_forwarder -from secops.chronicle.log_types import classify_logs as _classify_logs -from secops.chronicle.log_types import get_all_log_types as _get_all_log_types -from secops.chronicle.log_types import ( - get_log_type_description as _get_log_type_description, -) -from secops.chronicle.log_types import is_valid_log_type as _is_valid_log_type -from secops.chronicle.log_types import search_log_types as _search_log_types from secops.chronicle.log_processing_pipelines import ( associate_streams as _associate_streams, ) @@ -167,10 +160,17 @@ list_log_processing_pipelines as _list_log_processing_pipelines, ) from secops.chronicle.log_processing_pipelines import ( - update_log_processing_pipeline as _update_log_processing_pipeline, + test_pipeline as _test_pipeline, ) from secops.chronicle.log_processing_pipelines import ( - test_pipeline as _test_pipeline, + update_log_processing_pipeline as _update_log_processing_pipeline, +) +from secops.chronicle.log_types import ( + classify_logs as _classify_logs, + get_all_log_types as _get_all_log_types, + get_log_type_description as _get_log_type_description, + is_valid_log_type as _is_valid_log_type, + search_log_types as _search_log_types, ) from secops.chronicle.models import ( APIVersion, diff --git a/src/secops/chronicle/soar/__init__.py b/src/secops/chronicle/soar/__init__.py index 9f3847d..75462c3 100644 --- a/src/secops/chronicle/soar/__init__.py +++ b/src/secops/chronicle/soar/__init__.py @@ -14,35 +14,65 @@ # """Chronicle SOAR API specific functionality.""" -from secops.chronicle.soar.service import SOARService - +from secops.chronicle.soar.integration.action_revisions import ( + create_integration_action_revision, + delete_integration_action_revision, + list_integration_action_revisions, + rollback_integration_action_revision, +) +from secops.chronicle.soar.integration.actions import ( + create_integration_action, + delete_integration_action, + execute_integration_action_test, + get_integration_action, + get_integration_action_template, + get_integration_actions_by_environment, + list_integration_actions, + update_integration_action, +) +from secops.chronicle.soar.integration.integration_instances import ( + create_integration_instance, + delete_integration_instance, + get_integration_instance, + list_integration_instances, + update_integration_instance, +) from secops.chronicle.soar.integration.integrations import ( - list_integrations, - get_integration, - delete_integration, create_integration, - transition_integration, - update_integration, - update_custom_integration, + delete_integration, + get_integration, get_integration_affected_items, get_integration_dependencies, get_integration_diff, get_integration_restricted_agents, + list_integrations, + transition_integration, + update_custom_integration, + update_integration, ) -from secops.chronicle.soar.integration.integration_instances import ( - list_integration_instances, - get_integration_instance, - delete_integration_instance, - create_integration_instance, - update_integration_instance, +from secops.chronicle.soar.integration.manager_revisions import ( + create_integration_manager_revision, + delete_integration_manager_revision, + get_integration_manager_revision, + list_integration_manager_revisions, + rollback_integration_manager_revision, +) +from secops.chronicle.soar.integration.managers import ( + create_integration_manager, + delete_integration_manager, + get_integration_manager, + get_integration_manager_template, + list_integration_managers, + update_integration_manager, ) from secops.chronicle.soar.integration.marketplace_integrations import ( - list_marketplace_integrations, get_marketplace_integration, get_marketplace_integration_diff, install_marketplace_integration, + list_marketplace_integrations, uninstall_marketplace_integration, ) +from secops.chronicle.soar.service import SOARService __all__ = [ # client @@ -71,4 +101,31 @@ "delete_integration_instance", "create_integration_instance", "update_integration_instance", + # Integration Actions + "create_integration_action", + "delete_integration_action", + "execute_integration_action_test", + "get_integration_action", + "get_integration_action_template", + "get_integration_actions_by_environment", + "list_integration_actions", + "update_integration_action", + # Integration Action Revisions + "create_integration_action_revision", + "delete_integration_action_revision", + "list_integration_action_revisions", + "rollback_integration_action_revision", + # Integration Managers + "create_integration_manager", + "delete_integration_manager", + "get_integration_manager", + "get_integration_manager_template", + "list_integration_managers", + "update_integration_manager", + # Integration Manager Revisions + "create_integration_manager_revision", + "delete_integration_manager_revision", + "get_integration_manager_revision", + "list_integration_manager_revisions", + "rollback_integration_manager_revision", ] diff --git a/src/secops/chronicle/soar/integration/action_revisions.py b/src/secops/chronicle/soar/integration/action_revisions.py new file mode 100644 index 0000000..96a44d6 --- /dev/null +++ b/src/secops/chronicle/soar/integration/action_revisions.py @@ -0,0 +1,200 @@ +# Copyright 2025 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. +# +"""Integration action revisions functionality for Chronicle.""" + +from typing import Any, TYPE_CHECKING + +from secops.chronicle.models import APIVersion +from secops.chronicle.utils.format_utils import ( + format_resource_id, + remove_none_values, +) +from secops.chronicle.utils.request_utils import ( + chronicle_paginated_request, + chronicle_request, +) + +if TYPE_CHECKING: + from secops.chronicle.client import ChronicleClient + + +def list_integration_action_revisions( + client: "ChronicleClient", + integration_name: str, + action_id: str, + page_size: int | None = None, + page_token: str | None = None, + filter_string: str | None = None, + order_by: str | None = None, + api_version: APIVersion | None = APIVersion.V1BETA, + as_list: bool = False, +) -> dict[str, Any] | list[dict[str, Any]]: + """List all revisions for a specific integration action. + + Use this method to browse the version history and identify previous + configurations of an automated task. + + Args: + client: ChronicleClient instance. + integration_name: Name of the integration the action belongs to. + action_id: ID of the action to list revisions for. + page_size: Maximum number of revisions to return. + page_token: Page token from a previous call to retrieve the next page. + filter_string: Filter expression to filter revisions. + order_by: Field to sort the revisions by. + api_version: API version to use for the request. Default is V1BETA. + as_list: If True, return a list of revisions instead of a dict with + revisions list and nextPageToken. + + Returns: + If as_list is True: List of revisions. + If as_list is False: Dict with revisions list and nextPageToken. + + Raises: + APIError: If the API request fails. + """ + extra_params = remove_none_values( + { + "filter": filter_string, + "orderBy": order_by, + } + ) + + return chronicle_paginated_request( + client, + api_version=api_version, + path=( + f"integrations/{format_resource_id(integration_name)}/" + f"actions/{action_id}/revisions" + ), + items_key="revisions", + page_size=page_size, + page_token=page_token, + extra_params=extra_params, + as_list=as_list, + ) + + +def delete_integration_action_revision( + client: "ChronicleClient", + integration_name: str, + action_id: str, + revision_id: str, + api_version: APIVersion | None = APIVersion.V1BETA, +) -> None: + """Delete a specific revision for a given integration action. + + Use this method to clean up obsolete action revisions. + + Args: + client: ChronicleClient instance. + integration_name: Name of the integration the action belongs to. + action_id: ID of the action the revision belongs to. + revision_id: ID of the revision to delete. + api_version: API version to use for the request. Default is V1BETA. + + Returns: + None + + Raises: + APIError: If the API request fails. + """ + chronicle_request( + client, + method="DELETE", + endpoint_path=( + f"integrations/{format_resource_id(integration_name)}/" + f"actions/{action_id}/revisions/{revision_id}" + ), + api_version=api_version, + ) + + +def create_integration_action_revision( + client: "ChronicleClient", + integration_name: str, + action_id: str, + action: dict[str, Any], + comment: str | None = None, + api_version: APIVersion | None = APIVersion.V1BETA, +) -> dict[str, Any]: + """Create a new revision snapshot of the current integration action. + + Use this method to establish a recovery point before making significant + changes to a security operation's script or parameters. + + Args: + client: ChronicleClient instance. + integration_name: Name of the integration the action belongs to. + action_id: ID of the action to create a revision for. + action: Dict containing the IntegrationAction to snapshot. + comment: Comment describing the revision. Maximum 400 characters. + Optional. + api_version: API version to use for the request. Default is V1BETA. + + Returns: + Dict containing the newly created IntegrationActionRevision resource. + + Raises: + APIError: If the API request fails. + """ + body = remove_none_values({"action": action, "comment": comment}) + + return chronicle_request( + client, + method="POST", + endpoint_path=( + f"integrations/{format_resource_id(integration_name)}/" + f"actions/{action_id}/revisions" + ), + api_version=api_version, + json=body, + ) + + +def rollback_integration_action_revision( + client: "ChronicleClient", + integration_name: str, + action_id: str, + revision_id: str, + api_version: APIVersion | None = APIVersion.V1BETA, +) -> dict[str, Any]: + """Revert the current action definition to a previously saved revision. + + Use this method to rapidly recover a functional automation state if an + update causes operational issues. + + Args: + client: ChronicleClient instance. + integration_name: Name of the integration the action belongs to. + action_id: ID of the action to rollback. + revision_id: ID of the revision to rollback to. + api_version: API version to use for the request. Default is V1BETA. + + Returns: + Dict containing the IntegrationActionRevision rolled back to. + + Raises: + APIError: If the API request fails. + """ + return chronicle_request( + client, + method="POST", + endpoint_path=( + f"integrations/{format_resource_id(integration_name)}/" + f"actions/{action_id}/revisions/{revision_id}:rollback" + ), + api_version=api_version, + ) diff --git a/src/secops/chronicle/soar/integration/actions.py b/src/secops/chronicle/soar/integration/actions.py new file mode 100644 index 0000000..5376147 --- /dev/null +++ b/src/secops/chronicle/soar/integration/actions.py @@ -0,0 +1,464 @@ +# Copyright 2025 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. +# +"""Integration actions functionality for Chronicle.""" + +from typing import Any, TYPE_CHECKING + +from secops.chronicle.models import ActionParameter, APIVersion +from secops.chronicle.utils.format_utils import ( + build_patch_body, + format_resource_id, + remove_none_values, +) +from secops.chronicle.utils.request_utils import ( + chronicle_paginated_request, + chronicle_request, +) + +if TYPE_CHECKING: + from secops.chronicle.client import ChronicleClient + + +def list_integration_actions( + client: "ChronicleClient", + integration_name: str, + page_size: int | None = None, + page_token: str | None = None, + filter_string: str | None = None, + order_by: str | None = None, + expand: str | None = None, + api_version: APIVersion | None = APIVersion.V1BETA, + as_list: bool = False, +) -> dict[str, Any] | list[dict[str, Any]]: + """Get a list of actions for a given integration. + + Args: + client: ChronicleClient instance + integration_name: Name of the integration to get actions for + page_size: Number of results to return per page + page_token: Token for the page to retrieve + filter_string: Filter expression to filter actions + order_by: Field to sort the actions by + expand: Comma-separated list of fields to expand in the response + api_version: API version to use for the request. Default is V1BETA. + as_list: If True, return a list of actions instead of a dict with + actions list and nextPageToken. + + Returns: + If as_list is True: List of actions. + If as_list is False: Dict with actions list and nextPageToken. + + Raises: + APIError: If the API request fails + """ + field_map = remove_none_values( + { + "filter": filter_string, + "orderBy": order_by, + "expand": expand, + } + ) + + return chronicle_paginated_request( + client, + api_version=api_version, + path=f"integrations/{format_resource_id(integration_name)}/actions", + items_key="actions", + page_size=page_size, + page_token=page_token, + extra_params=field_map, + as_list=as_list, + ) + + +def get_integration_action( + client: "ChronicleClient", + integration_name: str, + action_id: str, + api_version: APIVersion | None = APIVersion.V1BETA, +) -> dict[str, Any]: + """Get details of a specific action for a given integration. + + Args: + client: ChronicleClient instance + integration_name: Name of the integration the action belongs to + action_id: ID of the action to retrieve + api_version: API version to use for the request. Default is V1BETA. + + Returns: + Dict containing details of the specified action. + + Raises: + APIError: If the API request fails + """ + return chronicle_request( + client, + method="GET", + endpoint_path=( + f"integrations/{format_resource_id(integration_name)}" + f"/actions/{action_id}" + ), + api_version=api_version, + ) + + +def delete_integration_action( + client: "ChronicleClient", + integration_name: str, + action_id: str, + api_version: APIVersion | None = APIVersion.V1BETA, +) -> None: + """Delete a specific action from a given integration. + + Args: + client: ChronicleClient instance + integration_name: Name of the integration the action belongs to + action_id: ID of the action to delete + api_version: API version to use for the request. Default is V1BETA. + + Returns: + None + + Raises: + APIError: If the API request fails + """ + chronicle_request( + client, + method="DELETE", + endpoint_path=( + f"integrations/{format_resource_id(integration_name)}" + f"/actions/{action_id}" + ), + api_version=api_version, + ) + + +def create_integration_action( + client: "ChronicleClient", + integration_name: str, + display_name: str, + script: str, + timeout_seconds: int, + enabled: bool, + script_result_name: str, + is_async: bool, + description: str | None = None, + default_result_value: str | None = None, + async_polling_interval_seconds: int | None = None, + async_total_timeout_seconds: int | None = None, + dynamic_results: list[dict[str, Any]] | None = None, + parameters: list[dict[str, Any] | ActionParameter] | None = None, + ai_generated: bool | None = None, + api_version: APIVersion | None = APIVersion.V1BETA, +) -> dict[str, Any]: + """Create a new custom action for a given integration. + + Args: + client: ChronicleClient instance. + integration_name: Name of the integration to create the action for. + display_name: Action's display name. Maximum 150 characters. Required. + script: Action's Python script. Maximum size 5MB. Required. + timeout_seconds: Action timeout in seconds. Maximum 1200. Required. + enabled: Whether the action is enabled or disabled. Required. + script_result_name: Field name that holds the script result. + Maximum 100 characters. Required. + is_async: Whether the action is asynchronous. Required. + description: Action's description. Maximum 400 characters. Optional. + default_result_value: Action's default result value. + Maximum 1000 characters. Optional. + async_polling_interval_seconds: Polling interval in seconds for async + actions. Cannot exceed total timeout. Optional. + async_total_timeout_seconds: Total async timeout in seconds. + Maximum 1209600 (14 days). Optional. + dynamic_results: List of dynamic result metadata dicts. + Max 50. Optional. + parameters: List of ActionParameter instances or dicts. + Max 50. Optional. + ai_generated: Whether the action was generated by AI. Optional. + api_version: API version to use for the request. Default is V1BETA. + + Returns: + Dict containing the newly created IntegrationAction resource. + + Raises: + APIError: If the API request fails. + """ + resolved_parameters = ( + [ + p.to_dict() if isinstance(p, ActionParameter) else p + for p in parameters + ] + if parameters is not None + else None + ) + + body = remove_none_values( + { + "displayName": display_name, + "script": script, + "timeoutSeconds": timeout_seconds, + "enabled": enabled, + "scriptResultName": script_result_name, + "async": is_async, + "description": description, + "defaultResultValue": default_result_value, + "asyncPollingIntervalSeconds": async_polling_interval_seconds, + "asyncTotalTimeoutSeconds": async_total_timeout_seconds, + "dynamicResults": dynamic_results, + "parameters": resolved_parameters, + "aiGenerated": ai_generated, + } + ) + + return chronicle_request( + client, + method="POST", + endpoint_path=( + f"integrations/{format_resource_id(integration_name)}/actions" + ), + api_version=api_version, + json=body, + ) + + +def update_integration_action( + client: "ChronicleClient", + integration_name: str, + action_id: str, + display_name: str | None = None, + script: str | None = None, + timeout_seconds: int | None = None, + enabled: bool | None = None, + script_result_name: str | None = None, + is_async: bool | None = None, + description: str | None = None, + default_result_value: str | None = None, + async_polling_interval_seconds: int | None = None, + async_total_timeout_seconds: int | None = None, + dynamic_results: list[dict[str, Any]] | None = None, + parameters: list[dict[str, Any] | ActionParameter] | None = None, + ai_generated: bool | None = None, + update_mask: str | None = None, + api_version: APIVersion | None = APIVersion.V1BETA, +) -> dict[str, Any]: + """Update an existing custom action for a given integration. + + Only custom actions can be updated; predefined commercial actions are + immutable. + + Args: + client: ChronicleClient instance. + integration_name: Name of the integration the action belongs to. + action_id: ID of the action to update. + display_name: Action's display name. Maximum 150 characters. + script: Action's Python script. Maximum size 5MB. + timeout_seconds: Action timeout in seconds. Maximum 1200. + enabled: Whether the action is enabled or disabled. + script_result_name: Field name that holds the script result. + Maximum 100 characters. + is_async: Whether the action is asynchronous. + description: Action's description. Maximum 400 characters. + default_result_value: Action's default result value. + Maximum 1000 characters. + async_polling_interval_seconds: Polling interval in seconds for async + actions. Cannot exceed total timeout. + async_total_timeout_seconds: Total async timeout in seconds. Maximum + 1209600 (14 days). + dynamic_results: List of dynamic result metadata dicts. Max 50. + parameters: List of ActionParameter instances or dicts. + Max 50. Optional. + ai_generated: Whether the action was generated by AI. + update_mask: Comma-separated list of fields to update. If omitted, + the mask is auto-generated from whichever fields are provided. + Example: "displayName,script". + api_version: API version to use for the request. Default is V1BETA. + + Returns: + Dict containing the updated IntegrationAction resource. + + Raises: + APIError: If the API request fails. + """ + body, params = build_patch_body( + field_map=[ + ("displayName", "displayName", display_name), + ("script", "script", script), + ("timeoutSeconds", "timeoutSeconds", timeout_seconds), + ("enabled", "enabled", enabled), + ("scriptResultName", "scriptResultName", script_result_name), + ("async", "async", is_async), + ("description", "description", description), + ("defaultResultValue", "defaultResultValue", default_result_value), + ( + "asyncPollingIntervalSeconds", + "asyncPollingIntervalSeconds", + async_polling_interval_seconds, + ), + ( + "asyncTotalTimeoutSeconds", + "asyncTotalTimeoutSeconds", + async_total_timeout_seconds, + ), + ("dynamicResults", "dynamicResults", dynamic_results), + ("parameters", "parameters", parameters), + ("aiGenerated", "aiGenerated", ai_generated), + ], + update_mask=update_mask, + ) + + return chronicle_request( + client, + method="PATCH", + endpoint_path=( + f"integrations/{format_resource_id(integration_name)}" + f"/actions/{action_id}" + ), + api_version=api_version, + json=body, + params=params, + ) + + +def execute_integration_action_test( + client: "ChronicleClient", + integration_name: str, + test_case_id: int, + action: dict[str, Any], + scope: str, + integration_instance_id: str, + api_version: APIVersion | None = APIVersion.V1BETA, +) -> dict[str, Any]: + """Execute a test run of an integration action's script. + + Use this method to verify custom action logic, connectivity, and data + parsing against a specified integration instance and test case before + making the action available in playbooks. + + Args: + client: ChronicleClient instance. + integration_name: Name of the integration the action belongs to. + test_case_id: ID of the action test case. + action: Dict containing the IntegrationAction to test. + scope: The action test scope. + integration_instance_id: The integration instance ID to use. + api_version: API version to use for the request. Default is V1BETA. + + Returns: + Dict containing the test execution results with the following fields: + - output: The script output. + - debugOutput: The script debug output. + - resultJson: The result JSON if it exists (optional). + - resultName: The script result name (optional). + + Raises: + APIError: If the API request fails. + """ + body = { + "testCaseId": test_case_id, + "action": action, + "scope": scope, + "integrationInstanceId": integration_instance_id, + } + + return chronicle_request( + client, + method="POST", + endpoint_path=( + f"integrations/{format_resource_id(integration_name)}" + "/actions:executeTest" + ), + api_version=api_version, + json=body, + ) + + +def get_integration_actions_by_environment( + client: "ChronicleClient", + integration_name: str, + environments: list[str], + include_widgets: bool, + api_version: APIVersion | None = APIVersion.V1BETA, +) -> dict[str, Any]: + """List actions executable within specified environments. + + Use this method to discover which automated tasks have active integration + instances configured for a particular network or organizational context. + + Args: + client: ChronicleClient instance. + integration_name: Name of the integration to fetch actions for. + environments: List of environments to filter actions by. + include_widgets: Whether to include widget actions in the response. + api_version: API version to use for the request. Default is V1BETA. + + Returns: + Dict containing a list of IntegrationAction objects that have + integration instances in one of the given environments. + + Raises: + APIError: If the API request fails. + """ + params = { + "environments": environments, + "includeWidgets": include_widgets, + } + + return chronicle_request( + client, + method="GET", + endpoint_path=( + f"integrations/{format_resource_id(integration_name)}" + "/actions:fetchActionsByEnvironment" + ), + api_version=api_version, + params=params, + ) + + +def get_integration_action_template( + client: "ChronicleClient", + integration_name: str, + is_async: bool = False, + api_version: APIVersion | None = APIVersion.V1BETA, +) -> dict[str, Any]: + """Retrieve a default Python script template for a new integration action. + + Use this method to jumpstart the development of a custom automated task + by providing boilerplate code for either synchronous or asynchronous + operations. + + Args: + client: ChronicleClient instance. + integration_name: Name of the integration to fetch the template for. + is_async: Whether to fetch a template for an async action. Default + is False. + api_version: API version to use for the request. Default is V1BETA. + + Returns: + Dict containing the IntegrationAction template. + + Raises: + APIError: If the API request fails. + """ + return chronicle_request( + client, + method="GET", + endpoint_path=( + f"integrations/{format_resource_id(integration_name)}" + "/actions:fetchTemplate" + ), + api_version=api_version, + params={"async": is_async}, + ) diff --git a/src/secops/chronicle/soar/integration/integrations.py b/src/secops/chronicle/soar/integration/integrations.py index 54f659f..701c95d 100644 --- a/src/secops/chronicle/soar/integration/integrations.py +++ b/src/secops/chronicle/soar/integration/integrations.py @@ -14,7 +14,7 @@ # """Integrations functionality for Chronicle.""" -from typing import TYPE_CHECKING, Any +from typing import Any, TYPE_CHECKING from secops.chronicle.models import ( APIVersion, @@ -68,13 +68,12 @@ def list_integrations( Raises: APIError: If the API request fails """ - param_fields = { - "filter": filter_string, - "orderBy": order_by, - } - - # Remove keys with None values - param_fields = {k: v for k, v in param_fields.items() if v is not None} + param_fields = remove_none_values( + { + "filter": filter_string, + "orderBy": order_by, + } + ) return chronicle_paginated_request( client, @@ -290,16 +289,16 @@ def export_integration_items( integration_name: name of the integration to export items from actions: Optional. IDs of the actions to export as a list or comma-separated string. Format: [1,2,3] or "1,2,3" - jobs: Optional. IDs of the jobs to export as a list or - comma-separated string. - connectors: Optional. IDs of the connectors to export as a - list or comma-separated string. - managers: Optional. IDs of the managers to export as a list - or comma-separated string. - transformers: Optional. IDs of the transformers to export as - a list or comma-separated string. - logical_operators: Optional. IDs of the logical operators to - export as a list or comma-separated string. + jobs: Optional. IDs of the jobs to export as a list or + comma-separated string. + connectors: Optional. IDs of the connectors to export as a + list or comma-separated string. + managers: Optional. IDs of the managers to export as a list + or comma-separated string. + transformers: Optional. IDs of the transformers to export as + a list or comma-separated string. + logical_operators: Optional. IDs of the logical operators to + export as a list or comma-separated string. api_version: API version to use for the request. Default is V1BETA. Returns: @@ -487,7 +486,7 @@ def get_integration_restricted_agents( def get_integration_diff( client: "ChronicleClient", integration_name: str, - diff_type: DiffType = DiffType.COMMERCIAL, + diff_type: DiffType | None = DiffType.COMMERCIAL, api_version: APIVersion | None = APIVersion.V1BETA, ) -> dict[str, Any]: """Get the configuration diff of a specific integration. diff --git a/src/secops/chronicle/soar/integration/manager_revisions.py b/src/secops/chronicle/soar/integration/manager_revisions.py new file mode 100644 index 0000000..1da13a8 --- /dev/null +++ b/src/secops/chronicle/soar/integration/manager_revisions.py @@ -0,0 +1,243 @@ +# Copyright 2025 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. +# +"""Integration manager revisions functionality for Chronicle.""" + +from typing import Any, TYPE_CHECKING + +from secops.chronicle.models import APIVersion +from secops.chronicle.utils.format_utils import ( + format_resource_id, + remove_none_values, +) +from secops.chronicle.utils.request_utils import ( + chronicle_paginated_request, + chronicle_request, +) + +if TYPE_CHECKING: + from secops.chronicle.client import ChronicleClient + + +def list_integration_manager_revisions( + client: "ChronicleClient", + integration_name: str, + manager_id: str, + page_size: int | None = None, + page_token: str | None = None, + filter_string: str | None = None, + order_by: str | None = None, + api_version: APIVersion | None = APIVersion.V1BETA, + as_list: bool = False, +) -> dict[str, Any] | list[dict[str, Any]]: + """List all revisions for a specific integration manager. + + Use this method to browse the version history and identify previous + functional states of a manager. + + Args: + client: ChronicleClient instance. + integration_name: Name of the integration the manager belongs to. + manager_id: ID of the manager to list revisions for. + page_size: Maximum number of revisions to return. + page_token: Page token from a previous call to retrieve the next page. + filter_string: Filter expression to filter revisions. + order_by: Field to sort the revisions by. + api_version: API version to use for the request. Default is V1BETA. + as_list: If True, return a list of revisions instead of a dict with + revisions list and nextPageToken. + + Returns: + If as_list is True: List of revisions. + If as_list is False: Dict with revisions list and nextPageToken. + + Raises: + APIError: If the API request fails. + """ + extra_params = remove_none_values( + { + "filter": filter_string, + "orderBy": order_by, + } + ) + + return chronicle_paginated_request( + client, + api_version=api_version, + path=( + f"integrations/{format_resource_id(integration_name)}/" + f"managers/{manager_id}/revisions" + ), + items_key="revisions", + page_size=page_size, + page_token=page_token, + extra_params=extra_params, + as_list=as_list, + ) + + +def get_integration_manager_revision( + client: "ChronicleClient", + integration_name: str, + manager_id: str, + revision_id: str, + api_version: APIVersion | None = APIVersion.V1BETA, +) -> dict[str, Any]: + """Get a single revision for a specific integration manager. + + Use this method to retrieve a specific snapshot of an + IntegrationManagerRevision for comparison or review. + + Args: + client: ChronicleClient instance. + integration_name: Name of the integration the manager belongs to. + manager_id: ID of the manager the revision belongs to. + revision_id: ID of the revision to retrieve. + api_version: API version to use for the request. Default is V1BETA. + + Returns: + Dict containing details of the specified IntegrationManagerRevision. + + Raises: + APIError: If the API request fails. + """ + return chronicle_request( + client, + method="GET", + endpoint_path=( + f"integrations/{format_resource_id(integration_name)}/" + f"managers/{manager_id}/revisions/" + f"{format_resource_id(revision_id)}" + ), + api_version=api_version, + ) + + +def delete_integration_manager_revision( + client: "ChronicleClient", + integration_name: str, + manager_id: str, + revision_id: str, + api_version: APIVersion | None = APIVersion.V1BETA, +) -> None: + """Delete a specific revision for a given integration manager. + + Use this method to clean up obsolete snapshots and manage the historical + record of managers. + + Args: + client: ChronicleClient instance. + integration_name: Name of the integration the manager belongs to. + manager_id: ID of the manager the revision belongs to. + revision_id: ID of the revision to delete. + api_version: API version to use for the request. Default is V1BETA. + + Returns: + None + + Raises: + APIError: If the API request fails. + """ + chronicle_request( + client, + method="DELETE", + endpoint_path=( + f"integrations/{format_resource_id(integration_name)}/" + f"managers/{manager_id}/revisions/" + f"{format_resource_id(revision_id)}" + ), + api_version=api_version, + ) + + +def create_integration_manager_revision( + client: "ChronicleClient", + integration_name: str, + manager_id: str, + manager: dict[str, Any], + comment: str | None = None, + api_version: APIVersion | None = APIVersion.V1BETA, +) -> dict[str, Any]: + """Create a new revision snapshot of the current integration manager. + + Use this method to establish a recovery point before making significant + updates to a manager. + + Args: + client: ChronicleClient instance. + integration_name: Name of the integration the manager belongs to. + manager_id: ID of the manager to create a revision for. + manager: Dict containing the IntegrationManager to snapshot. + comment: Comment describing the revision. Maximum 400 characters. + Optional. + api_version: API version to use for the request. Default is V1BETA. + + Returns: + Dict containing the newly created IntegrationManagerRevision resource. + + Raises: + APIError: If the API request fails. + """ + body = {"manager": manager} + + if comment is not None: + body["comment"] = comment + + return chronicle_request( + client, + method="POST", + endpoint_path=( + f"integrations/{format_resource_id(integration_name)}/" + f"managers/{manager_id}/revisions" + ), + api_version=api_version, + json=body, + ) + + +def rollback_integration_manager_revision( + client: "ChronicleClient", + integration_name: str, + manager_id: str, + revision_id: str, + api_version: APIVersion | None = APIVersion.V1BETA, +) -> dict[str, Any]: + """Revert the current manager definition to a previously saved revision. + + Use this method to rapidly recover a functional state for common code if + an update causes operational issues in dependent actions or jobs. + + Args: + client: ChronicleClient instance. + integration_name: Name of the integration the manager belongs to. + manager_id: ID of the manager to rollback. + revision_id: ID of the revision to rollback to. + api_version: API version to use for the request. Default is V1BETA. + + Returns: + Dict containing the IntegrationManagerRevision rolled back to. + + Raises: + APIError: If the API request fails. + """ + return chronicle_request( + client, + method="POST", + endpoint_path=( + f"integrations/{format_resource_id(integration_name)}/" + f"managers/{manager_id}/revisions/" + f"{format_resource_id(revision_id)}:rollback" + ), + api_version=api_version, + ) diff --git a/src/secops/chronicle/soar/integration/managers.py b/src/secops/chronicle/soar/integration/managers.py new file mode 100644 index 0000000..5034546 --- /dev/null +++ b/src/secops/chronicle/soar/integration/managers.py @@ -0,0 +1,285 @@ +# Copyright 2025 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. +# +"""Integration manager functionality for Chronicle.""" + +from typing import Any, TYPE_CHECKING + +from secops.chronicle.models import APIVersion +from secops.chronicle.utils.format_utils import ( + build_patch_body, + format_resource_id, + remove_none_values, +) +from secops.chronicle.utils.request_utils import ( + chronicle_paginated_request, + chronicle_request, +) + +if TYPE_CHECKING: + from secops.chronicle.client import ChronicleClient + + +def list_integration_managers( + client: "ChronicleClient", + integration_name: str, + page_size: int | None = None, + page_token: str | None = None, + filter_string: str | None = None, + order_by: str | None = None, + api_version: APIVersion | None = APIVersion.V1BETA, + as_list: bool = False, +) -> dict[str, Any] | list[dict[str, Any]]: + """List all managers defined for a specific integration. + + Use this method to discover the library of managers available within a + particular integration's scope. + + Args: + client: ChronicleClient instance. + integration_name: Name of the integration to list managers for. + page_size: Maximum number of managers to return. Defaults to 100, + maximum is 100. + page_token: Page token from a previous call to retrieve the next page. + filter_string: Filter expression to filter managers. + order_by: Field to sort the managers by. + api_version: API version to use for the request. Default is V1BETA. + as_list: If True, return a list of managers instead of a dict with + managers list and nextPageToken. + + Returns: + If as_list is True: List of managers. + If as_list is False: Dict with managers list and nextPageToken. + + Raises: + APIError: If the API request fails. + """ + extra_params = remove_none_values( + { + "filter": filter_string, + "orderBy": order_by, + } + ) + + return chronicle_paginated_request( + client, + api_version=api_version, + path=f"integrations/{format_resource_id(integration_name)}/managers", + items_key="managers", + page_size=page_size, + page_token=page_token, + extra_params=extra_params, + as_list=as_list, + ) + + +def get_integration_manager( + client: "ChronicleClient", + integration_name: str, + manager_id: str, + api_version: APIVersion | None = APIVersion.V1BETA, +) -> dict[str, Any]: + """Get a single manager for a given integration. + + Use this method to retrieve the manager script and its metadata for + review or reference. + + Args: + client: ChronicleClient instance. + integration_name: Name of the integration the manager belongs to. + manager_id: ID of the manager to retrieve. + api_version: API version to use for the request. Default is V1BETA. + + Returns: + Dict containing details of the specified IntegrationManager. + + Raises: + APIError: If the API request fails. + """ + return chronicle_request( + client, + method="GET", + endpoint_path=( + f"integrations/{format_resource_id(integration_name)}/" + f"managers/{manager_id}" + ), + api_version=api_version, + ) + + +def delete_integration_manager( + client: "ChronicleClient", + integration_name: str, + manager_id: str, + api_version: APIVersion | None = APIVersion.V1BETA, +) -> None: + """Delete a specific custom manager from a given integration. + + Note that deleting a manager may break components (actions, jobs) that + depend on its code. + + Args: + client: ChronicleClient instance. + integration_name: Name of the integration the manager belongs to. + manager_id: ID of the manager to delete. + api_version: API version to use for the request. Default is V1BETA. + + Returns: + None + + Raises: + APIError: If the API request fails. + """ + chronicle_request( + client, + method="DELETE", + endpoint_path=( + f"integrations/{format_resource_id(integration_name)}/" + f"managers/{manager_id}" + ), + api_version=api_version, + ) + + +def create_integration_manager( + client: "ChronicleClient", + integration_name: str, + display_name: str, + script: str, + description: str | None = None, + api_version: APIVersion | None = APIVersion.V1BETA, +) -> dict[str, Any]: + """Create a new custom manager for a given integration. + + Use this method to add a new shared code utility. Each manager must have + a unique display name and a script containing valid Python logic for reuse + across actions, jobs, and connectors. + + Args: + client: ChronicleClient instance. + integration_name: Name of the integration to create the manager for. + display_name: Manager's display name. Maximum 150 characters. Required. + script: Manager's Python script. Maximum 5MB. Required. + description: Manager's description. Maximum 400 characters. Optional. + api_version: API version to use for the request. Default is V1BETA. + + Returns: + Dict containing the newly created IntegrationManager resource. + + Raises: + APIError: If the API request fails. + """ + body = remove_none_values( + { + "displayName": display_name, + "script": script, + "description": description, + } + ) + + return chronicle_request( + client, + method="POST", + endpoint_path=( + f"integrations/{format_resource_id(integration_name)}/managers" + ), + api_version=api_version, + json=body, + ) + + +def update_integration_manager( + client: "ChronicleClient", + integration_name: str, + manager_id: str, + display_name: str | None = None, + script: str | None = None, + description: str | None = None, + update_mask: str | None = None, + api_version: APIVersion | None = APIVersion.V1BETA, +) -> dict[str, Any]: + """Update an existing custom manager for a given integration. + + Use this method to modify the shared code, adjust its description, or + refine its logic across all components that import it. + + Args: + client: ChronicleClient instance. + integration_name: Name of the integration the manager belongs to. + manager_id: ID of the manager to update. + display_name: Manager's display name. Maximum 150 characters. + script: Manager's Python script. Maximum 5MB. + description: Manager's description. Maximum 400 characters. + update_mask: Comma-separated list of fields to update. If omitted, + the mask is auto-generated from whichever fields are provided. + Example: "displayName,script". + api_version: API version to use for the request. Default is V1BETA. + + Returns: + Dict containing the updated IntegrationManager resource. + + Raises: + APIError: If the API request fails. + """ + body, params = build_patch_body( + field_map=[ + ("displayName", "displayName", display_name), + ("script", "script", script), + ("description", "description", description), + ], + update_mask=update_mask, + ) + + return chronicle_request( + client, + method="PATCH", + endpoint_path=( + f"integrations/{format_resource_id(integration_name)}/" + f"managers/{manager_id}" + ), + api_version=api_version, + json=body, + params=params, + ) + + +def get_integration_manager_template( + client: "ChronicleClient", + integration_name: str, + api_version: APIVersion | None = APIVersion.V1BETA, +) -> dict[str, Any]: + """Retrieve a default Python script template for a new integration manager. + + Use this method to quickly start developing new managers. + + Args: + client: ChronicleClient instance. + integration_name: Name of the integration to fetch the template for. + api_version: API version to use for the request. Default is V1BETA. + + Returns: + Dict containing the IntegrationManager template. + + Raises: + APIError: If the API request fails. + """ + return chronicle_request( + client, + method="GET", + endpoint_path=( + f"integrations/{format_resource_id(integration_name)}/" + "managers:fetchTemplate" + ), + api_version=api_version, + ) diff --git a/src/secops/chronicle/soar/service.py b/src/secops/chronicle/soar/service.py index 0f3d27a..f4a8fd5 100644 --- a/src/secops/chronicle/soar/service.py +++ b/src/secops/chronicle/soar/service.py @@ -14,24 +14,44 @@ # """Chronicle SOAR API client.""" -from typing import TYPE_CHECKING, Any +from typing import Any, TYPE_CHECKING # pylint: disable=line-too-long from secops.chronicle.models import ( + ActionParameter, APIVersion, DiffType, IntegrationInstanceParameter, + IntegrationParam, IntegrationType, PythonVersion, TargetMode, - IntegrationParam, ) -from secops.chronicle.soar.integration.marketplace_integrations import ( - get_marketplace_integration as _get_marketplace_integration, - get_marketplace_integration_diff as _get_marketplace_integration_diff, - install_marketplace_integration as _install_marketplace_integration, - list_marketplace_integrations as _list_marketplace_integrations, - uninstall_marketplace_integration as _uninstall_marketplace_integration, +from secops.chronicle.soar.integration.action_revisions import ( + create_integration_action_revision as _create_integration_action_revision, + delete_integration_action_revision as _delete_integration_action_revision, + list_integration_action_revisions as _list_integration_action_revisions, + rollback_integration_action_revision as _rollback_integration_action_revision, +) +from secops.chronicle.soar.integration.actions import ( + create_integration_action as _create_integration_action, + delete_integration_action as _delete_integration_action, + execute_integration_action_test as _execute_integration_action_test, + get_integration_action as _get_integration_action, + get_integration_action_template as _get_integration_action_template, + get_integration_actions_by_environment as _get_integration_actions_by_environment, + list_integration_actions as _list_integration_actions, + update_integration_action as _update_integration_action, +) +from secops.chronicle.soar.integration.integration_instances import ( + create_integration_instance as _create_integration_instance, + delete_integration_instance as _delete_integration_instance, + execute_integration_instance_test as _execute_integration_instance_test, + get_default_integration_instance as _get_default_integration_instance, + get_integration_instance as _get_integration_instance, + get_integration_instance_affected_items as _get_integration_instance_affected_items, + list_integration_instances as _list_integration_instances, + update_integration_instance as _update_integration_instance, ) from secops.chronicle.soar.integration.integrations import ( create_integration as _create_integration, @@ -50,15 +70,27 @@ update_custom_integration as _update_custom_integration, update_integration as _update_integration, ) -from secops.chronicle.soar.integration.integration_instances import ( - create_integration_instance as _create_integration_instance, - delete_integration_instance as _delete_integration_instance, - execute_integration_instance_test as _execute_integration_instance_test, - get_default_integration_instance as _get_default_integration_instance, - get_integration_instance as _get_integration_instance, - get_integration_instance_affected_items as _get_integration_instance_affected_items, - list_integration_instances as _list_integration_instances, - update_integration_instance as _update_integration_instance, +from secops.chronicle.soar.integration.manager_revisions import ( + create_integration_manager_revision as _create_integration_manager_revision, + delete_integration_manager_revision as _delete_integration_manager_revision, + get_integration_manager_revision as _get_integration_manager_revision, + list_integration_manager_revisions as _list_integration_manager_revisions, + rollback_integration_manager_revision as _rollback_integration_manager_revision, +) +from secops.chronicle.soar.integration.managers import ( + create_integration_manager as _create_integration_manager, + delete_integration_manager as _delete_integration_manager, + get_integration_manager as _get_integration_manager, + get_integration_manager_template as _get_integration_manager_template, + list_integration_managers as _list_integration_managers, + update_integration_manager as _update_integration_manager, +) +from secops.chronicle.soar.integration.marketplace_integrations import ( + get_marketplace_integration as _get_marketplace_integration, + get_marketplace_integration_diff as _get_marketplace_integration_diff, + install_marketplace_integration as _install_marketplace_integration, + list_marketplace_integrations as _list_marketplace_integrations, + uninstall_marketplace_integration as _uninstall_marketplace_integration, ) # pylint: enable=line-too-long @@ -874,7 +906,7 @@ def delete_integration_instance( Raises: APIError: If the API request fails. """ - return _delete_integration_instance( + _delete_integration_instance( self._client, integration_name, integration_instance_id, @@ -1091,3 +1123,949 @@ def get_default_integration_instance( integration_name, api_version=api_version, ) + + # ------------------------------------------------------------------------- + # Integration Action methods + # ------------------------------------------------------------------------- + + def list_integration_actions( + self, + integration_name: str, + page_size: int | None = None, + page_token: str | None = None, + filter_string: str | None = None, + order_by: str | None = None, + expand: str | None = None, + api_version: APIVersion | None = APIVersion.V1BETA, + as_list: bool = False, + ) -> dict[str, Any] | list[dict[str, Any]]: + """Get a list of actions for a given integration. + + Args: + integration_name: Name of the integration to get actions for + page_size: Number of results to return per page + page_token: Token for the page to retrieve + filter_string: Filter expression to filter actions + order_by: Field to sort the actions by + expand: Comma-separated list of fields to expand in the response + api_version: API version to use for the request. Default is V1BETA. + as_list: If True, return a list of actions instead of a dict with + actions list and nextPageToken. + + Returns: + If as_list is True: List of actions. + If as_list is False: Dict with actions list and nextPageToken. + + Raises: + APIError: If the API request fails + """ + return _list_integration_actions( + self._client, + integration_name, + page_size=page_size, + page_token=page_token, + filter_string=filter_string, + order_by=order_by, + expand=expand, + api_version=api_version, + as_list=as_list, + ) + + def get_integration_action( + self, + integration_name: str, + action_id: str, + api_version: APIVersion | None = APIVersion.V1BETA, + ) -> dict[str, Any]: + """Get details of a specific action for a given integration. + + Args: + integration_name: Name of the integration the action belongs to + action_id: ID of the action to retrieve + api_version: API version to use for the request. Default is V1BETA. + + Returns: + Dict containing details of the specified action. + + Raises: + APIError: If the API request fails + """ + return _get_integration_action( + self._client, + integration_name, + action_id, + api_version=api_version, + ) + + def delete_integration_action( + self, + integration_name: str, + action_id: str, + api_version: APIVersion | None = APIVersion.V1BETA, + ) -> None: + """Delete a specific action from a given integration. + + Args: + integration_name: Name of the integration the action belongs to + action_id: ID of the action to delete + api_version: API version to use for the request. Default is V1BETA. + + Returns: + None + + Raises: + APIError: If the API request fails + """ + _delete_integration_action( + self._client, + integration_name, + action_id, + api_version=api_version, + ) + + def create_integration_action( + self, + integration_name: str, + display_name: str, + script: str, + timeout_seconds: int, + enabled: bool, + script_result_name: str, + is_async: bool, + description: str | None = None, + default_result_value: str | None = None, + async_polling_interval_seconds: int | None = None, + async_total_timeout_seconds: int | None = None, + dynamic_results: list[dict[str, Any]] | None = None, + parameters: list[dict[str, Any] | ActionParameter] | None = None, + ai_generated: bool | None = None, + api_version: APIVersion | None = APIVersion.V1BETA, + ) -> dict[str, Any]: + """Create a new custom action for a given integration. + + Args: + integration_name: Name of the integration to + create the action for. + display_name: Action's display name. + Maximum 150 characters. Required. + script: Action's Python script. Maximum size 5MB. Required. + timeout_seconds: Action timeout in seconds. Maximum 1200. Required. + enabled: Whether the action is enabled or disabled. Required. + script_result_name: Field name that holds the script result. + Maximum 100 characters. Required. + is_async: Whether the action is asynchronous. Required. + description: Action's description. Maximum 400 characters. Optional. + default_result_value: Action's default result value. + Maximum 1000 characters. Optional. + async_polling_interval_seconds: Polling interval + in seconds for async actions. + Cannot exceed total timeout. Optional. + async_total_timeout_seconds: Total async timeout in seconds. Maximum + 1209600 (14 days). Optional. + dynamic_results: List of dynamic result metadata dicts. + Max 50. Optional. + parameters: List of action parameter dicts. Max 50. Optional. + ai_generated: Whether the action was generated by AI. Optional. + api_version: API version to use for the request. Default is V1BETA. + + Returns: + Dict containing the newly created IntegrationAction resource. + + Raises: + APIError: If the API request fails. + """ + return _create_integration_action( + self._client, + integration_name, + display_name, + script, + timeout_seconds, + enabled, + script_result_name, + is_async, + description=description, + default_result_value=default_result_value, + async_polling_interval_seconds=async_polling_interval_seconds, + async_total_timeout_seconds=async_total_timeout_seconds, + dynamic_results=dynamic_results, + parameters=parameters, + ai_generated=ai_generated, + api_version=api_version, + ) + + def update_integration_action( + self, + integration_name: str, + action_id: str, + display_name: str | None = None, + script: str | None = None, + timeout_seconds: int | None = None, + enabled: bool | None = None, + script_result_name: str | None = None, + is_async: bool | None = None, + description: str | None = None, + default_result_value: str | None = None, + async_polling_interval_seconds: int | None = None, + async_total_timeout_seconds: int | None = None, + dynamic_results: list[dict[str, Any]] | None = None, + parameters: list[dict[str, Any] | ActionParameter] | None = None, + ai_generated: bool | None = None, + update_mask: str | None = None, + api_version: APIVersion | None = APIVersion.V1BETA, + ) -> dict[str, Any]: + """Update an existing custom action for a given integration. + + Only custom actions can be updated; predefined commercial actions are + immutable. + + Args: + integration_name: Name of the integration the action belongs to. + action_id: ID of the action to update. + display_name: Action's display name. Maximum 150 characters. + script: Action's Python script. Maximum size 5MB. + timeout_seconds: Action timeout in seconds. Maximum 1200. + enabled: Whether the action is enabled or disabled. + script_result_name: Field name that holds the script result. + Maximum 100 characters. + is_async: Whether the action is asynchronous. + description: Action's description. Maximum 400 characters. + default_result_value: Action's default result value. + Maximum 1000 characters. + async_polling_interval_seconds: Polling interval + in seconds for async actions. Cannot exceed total timeout. + async_total_timeout_seconds: Total async timeout in seconds. Maximum + 1209600 (14 days). + dynamic_results: List of dynamic result metadata dicts. Max 50. + parameters: List of action parameter dicts. Max 50. + ai_generated: Whether the action was generated by AI. + update_mask: Comma-separated list of fields to update. If omitted, + the mask is auto-generated from whichever fields are provided. + Example: "displayName,script". + api_version: API version to use for the request. Default is V1BETA. + + Returns: + Dict containing the updated IntegrationAction resource. + + Raises: + APIError: If the API request fails. + """ + return _update_integration_action( + self._client, + integration_name, + action_id, + display_name=display_name, + script=script, + timeout_seconds=timeout_seconds, + enabled=enabled, + script_result_name=script_result_name, + is_async=is_async, + description=description, + default_result_value=default_result_value, + async_polling_interval_seconds=async_polling_interval_seconds, + async_total_timeout_seconds=async_total_timeout_seconds, + dynamic_results=dynamic_results, + parameters=parameters, + ai_generated=ai_generated, + update_mask=update_mask, + api_version=api_version, + ) + + def execute_integration_action_test( + self, + integration_name: str, + test_case_id: int, + action: dict[str, Any], + scope: str, + integration_instance_id: str, + api_version: APIVersion | None = APIVersion.V1BETA, + ) -> dict[str, Any]: + """Execute a test run of an integration action's script. + + Use this method to verify custom action logic, connectivity, and data + parsing against a specified integration instance and test case before + making the action available in playbooks. + + Args: + integration_name: Name of the integration the action belongs to. + test_case_id: ID of the action test case. + action: Dict containing the IntegrationAction to test. + scope: The action test scope. + integration_instance_id: The integration instance ID to use. + api_version: API version to use for the request. Default is V1BETA. + + Returns: + Dict with the test execution results with the following fields: + - output: The script output. + - debugOutput: The script debug output. + - resultJson: The result JSON if it exists (optional). + - resultName: The script result name (optional). + + Raises: + APIError: If the API request fails. + """ + return _execute_integration_action_test( + self._client, + integration_name, + test_case_id, + action, + scope, + integration_instance_id, + api_version=api_version, + ) + + def get_integration_actions_by_environment( + self, + integration_name: str, + environments: list[str], + include_widgets: bool, + api_version: APIVersion | None = APIVersion.V1BETA, + ) -> dict[str, Any]: + """List actions executable within specified environments. + + Use this method to discover which automated tasks have active + integration instances configured for a particular + network or organizational context. + + Args: + integration_name: Name of the integration to fetch actions for. + environments: List of environments to filter actions by. + include_widgets: Whether to include widget actions in the response. + api_version: API version to use for the request. Default is V1BETA. + + Returns: + Dict containing a list of IntegrationAction objects that have + integration instances in one of the given environments. + + Raises: + APIError: If the API request fails. + """ + return _get_integration_actions_by_environment( + self._client, + integration_name, + environments, + include_widgets, + api_version=api_version, + ) + + def get_integration_action_template( + self, + integration_name: str, + is_async: bool = False, + api_version: APIVersion | None = APIVersion.V1BETA, + ) -> dict[str, Any]: + """Retrieve a default Python script template for a new + integration action. + + Use this method to jumpstart the development of a custom automated task + by providing boilerplate code for either synchronous or asynchronous + operations. + + Args: + integration_name: Name of the integration to fetch the template for. + is_async: Whether to fetch a template for an async action. Default + is False. + api_version: API version to use for the request. Default is V1BETA. + + Returns: + Dict containing the IntegrationAction template. + + Raises: + APIError: If the API request fails. + """ + return _get_integration_action_template( + self._client, + integration_name, + is_async=is_async, + api_version=api_version, + ) + + # ------------------------------------------------------------------------- + # Integration Action Revisions methods + # ------------------------------------------------------------------------- + + def list_integration_action_revisions( + self, + integration_name: str, + action_id: str, + page_size: int | None = None, + page_token: str | None = None, + filter_string: str | None = None, + order_by: str | None = None, + api_version: APIVersion | None = APIVersion.V1BETA, + as_list: bool = False, + ) -> dict[str, Any] | list[dict[str, Any]]: + """List all revisions for a specific integration action. + + Use this method to view the history of changes to an action, + enabling version control and the ability to rollback to + previous configurations. + + Args: + integration_name: Name of the integration the action + belongs to. + action_id: ID of the action to list revisions for. + page_size: Maximum number of revisions to return. + page_token: Page token from a previous call to retrieve the + next page. + filter_string: Filter expression to filter revisions. + order_by: Field to sort the revisions by. + api_version: API version to use for the request. Default is + V1BETA. + as_list: If True, return a list of revisions instead of a + dict with revisions list and nextPageToken. + + Returns: + If as_list is True: List of action revisions. + If as_list is False: Dict with action revisions list and + nextPageToken. + + Raises: + APIError: If the API request fails. + """ + return _list_integration_action_revisions( + self._client, + integration_name, + action_id, + page_size=page_size, + page_token=page_token, + filter_string=filter_string, + order_by=order_by, + api_version=api_version, + as_list=as_list, + ) + + def delete_integration_action_revision( + self, + integration_name: str, + action_id: str, + revision_id: str, + api_version: APIVersion | None = APIVersion.V1BETA, + ) -> None: + """Delete a specific action revision. + + Use this method to permanently remove a revision from the + action's history. + + Args: + integration_name: Name of the integration the action + belongs to. + action_id: ID of the action the revision belongs to. + revision_id: ID of the revision to delete. + api_version: API version to use for the request. Default is + V1BETA. + + Returns: + None + + Raises: + APIError: If the API request fails. + """ + _delete_integration_action_revision( + self._client, + integration_name, + action_id, + revision_id, + api_version=api_version, + ) + + def create_integration_action_revision( + self, + integration_name: str, + action_id: str, + action: dict[str, Any], + comment: str | None = None, + api_version: APIVersion | None = APIVersion.V1BETA, + ) -> dict[str, Any]: + """Create a new revision for an integration action. + + Use this method to save a snapshot of the current action + configuration before making changes, enabling easy rollback if + needed. + + Args: + integration_name: Name of the integration the action + belongs to. + action_id: ID of the action to create a revision for. + action: The action object to save as a revision. + comment: Optional comment describing the revision. + api_version: API version to use for the request. Default is + V1BETA. + + Returns: + Dict containing the newly created ActionRevision resource. + + Raises: + APIError: If the API request fails. + """ + return _create_integration_action_revision( + self._client, + integration_name, + action_id, + action, + comment=comment, + api_version=api_version, + ) + + def rollback_integration_action_revision( + self, + integration_name: str, + action_id: str, + revision_id: str, + api_version: APIVersion | None = APIVersion.V1BETA, + ) -> dict[str, Any]: + """Rollback an integration action to a previous revision. + + Use this method to restore an action to a previously saved + state, reverting any changes made since that revision. + + Args: + integration_name: Name of the integration the action + belongs to. + action_id: ID of the action to rollback. + revision_id: ID of the revision to rollback to. + api_version: API version to use for the request. Default is + V1BETA. + + Returns: + Dict containing the rolled back IntegrationAction resource. + + Raises: + APIError: If the API request fails. + """ + return _rollback_integration_action_revision( + self._client, + integration_name, + action_id, + revision_id, + api_version=api_version, + ) + + # ------------------------------------------------------------------------- + # Integration Manager methods + # ------------------------------------------------------------------------- + + def list_integration_managers( + self, + integration_name: str, + page_size: int | None = None, + page_token: str | None = None, + filter_string: str | None = None, + order_by: str | None = None, + api_version: APIVersion | None = APIVersion.V1BETA, + as_list: bool = False, + ) -> dict[str, Any] | list[dict[str, Any]]: + """List all managers defined for a specific integration. + + Use this method to discover the library of managers available + within a particular integration's scope. + + Args: + integration_name: Name of the integration to list managers + for. + page_size: Maximum number of managers to return. Defaults to + 100, maximum is 100. + page_token: Page token from a previous call to retrieve the + next page. + filter_string: Filter expression to filter managers. + order_by: Field to sort the managers by. + api_version: API version to use for the request. Default is + V1BETA. + as_list: If True, return a list of managers instead of a + dict with managers list and nextPageToken. + + Returns: + If as_list is True: List of managers. + If as_list is False: Dict with managers list and + nextPageToken. + + Raises: + APIError: If the API request fails. + """ + return _list_integration_managers( + self._client, + integration_name, + page_size=page_size, + page_token=page_token, + filter_string=filter_string, + order_by=order_by, + api_version=api_version, + as_list=as_list, + ) + + def get_integration_manager( + self, + integration_name: str, + manager_id: str, + api_version: APIVersion | None = APIVersion.V1BETA, + ) -> dict[str, Any]: + """Get a single manager for a given integration. + + Use this method to retrieve the manager script and its metadata + for review or reference. + + Args: + integration_name: Name of the integration the manager + belongs to. + manager_id: ID of the manager to retrieve. + api_version: API version to use for the request. Default is + V1BETA. + + Returns: + Dict containing details of the specified IntegrationManager. + + Raises: + APIError: If the API request fails. + """ + return _get_integration_manager( + self._client, + integration_name, + manager_id, + api_version=api_version, + ) + + def delete_integration_manager( + self, + integration_name: str, + manager_id: str, + api_version: APIVersion | None = APIVersion.V1BETA, + ) -> None: + """Delete a specific custom manager from a given integration. + + Note that deleting a manager may break components (actions, + jobs) that depend on its code. + + Args: + integration_name: Name of the integration the manager + belongs to. + manager_id: ID of the manager to delete. + api_version: API version to use for the request. Default is + V1BETA. + + Returns: + None + + Raises: + APIError: If the API request fails. + """ + _delete_integration_manager( + self._client, + integration_name, + manager_id, + api_version=api_version, + ) + + def create_integration_manager( + self, + integration_name: str, + display_name: str, + script: str, + description: str | None = None, + api_version: APIVersion | None = APIVersion.V1BETA, + ) -> dict[str, Any]: + """Create a new custom manager for a given integration. + + Use this method to add a new shared code utility. Each manager + must have a unique display name and a script containing valid + Python logic for reuse across actions, jobs, and connectors. + + Args: + integration_name: Name of the integration to create the + manager for. + display_name: Manager's display name. Maximum 150 + characters. Required. + script: Manager's Python script. Maximum 5MB. Required. + description: Manager's description. Maximum 400 characters. + Optional. + api_version: API version to use for the request. Default is + V1BETA. + + Returns: + Dict containing the newly created IntegrationManager + resource. + + Raises: + APIError: If the API request fails. + """ + return _create_integration_manager( + self._client, + integration_name, + display_name, + script, + description=description, + api_version=api_version, + ) + + def update_integration_manager( + self, + integration_name: str, + manager_id: str, + display_name: str | None = None, + script: str | None = None, + description: str | None = None, + update_mask: str | None = None, + api_version: APIVersion | None = APIVersion.V1BETA, + ) -> dict[str, Any]: + """Update an existing custom manager for a given integration. + + Use this method to modify the shared code, adjust its + description, or refine its logic across all components that + import it. + + Args: + integration_name: Name of the integration the manager + belongs to. + manager_id: ID of the manager to update. + display_name: Manager's display name. Maximum 150 + characters. + script: Manager's Python script. Maximum 5MB. + description: Manager's description. Maximum 400 characters. + update_mask: Comma-separated list of fields to update. If + omitted, the mask is auto-generated from whichever + fields are provided. Example: "displayName,script". + api_version: API version to use for the request. Default is + V1BETA. + + Returns: + Dict containing the updated IntegrationManager resource. + + Raises: + APIError: If the API request fails. + """ + return _update_integration_manager( + self._client, + integration_name, + manager_id, + display_name=display_name, + script=script, + description=description, + update_mask=update_mask, + api_version=api_version, + ) + + def get_integration_manager_template( + self, + integration_name: str, + api_version: APIVersion | None = APIVersion.V1BETA, + ) -> dict[str, Any]: + """Retrieve a default Python script template for a new + integration manager. + + Use this method to quickly start developing new managers. + + Args: + integration_name: Name of the integration to fetch the + template for. + api_version: API version to use for the request. Default is + V1BETA. + + Returns: + Dict containing the IntegrationManager template. + + Raises: + APIError: If the API request fails. + """ + return _get_integration_manager_template( + self._client, + integration_name, + api_version=api_version, + ) + + # ------------------------------------------------------------------------- + # Integration Manager Revisions methods + # ------------------------------------------------------------------------- + + def list_integration_manager_revisions( + self, + integration_name: str, + manager_id: str, + page_size: int | None = None, + page_token: str | None = None, + filter_string: str | None = None, + order_by: str | None = None, + api_version: APIVersion | None = APIVersion.V1BETA, + as_list: bool = False, + ) -> dict[str, Any] | list[dict[str, Any]]: + """List all revisions for a specific integration manager. + + Use this method to browse the version history and identify + previous functional states of a manager. + + Args: + integration_name: Name of the integration the manager + belongs to. + manager_id: ID of the manager to list revisions for. + page_size: Maximum number of revisions to return. + page_token: Page token from a previous call to retrieve the + next page. + filter_string: Filter expression to filter revisions. + order_by: Field to sort the revisions by. + api_version: API version to use for the request. Default is + V1BETA. + as_list: If True, return a list of revisions instead of a + dict with revisions list and nextPageToken. + + Returns: + If as_list is True: List of revisions. + If as_list is False: Dict with revisions list and + nextPageToken. + + Raises: + APIError: If the API request fails. + """ + return _list_integration_manager_revisions( + self._client, + integration_name, + manager_id, + page_size=page_size, + page_token=page_token, + filter_string=filter_string, + order_by=order_by, + api_version=api_version, + as_list=as_list, + ) + + def get_integration_manager_revision( + self, + integration_name: str, + manager_id: str, + revision_id: str, + api_version: APIVersion | None = APIVersion.V1BETA, + ) -> dict[str, Any]: + """Get a single revision for a specific integration manager. + + Use this method to retrieve a specific snapshot of an + IntegrationManagerRevision for comparison or review. + + Args: + integration_name: Name of the integration the manager + belongs to. + manager_id: ID of the manager the revision belongs to. + revision_id: ID of the revision to retrieve. + api_version: API version to use for the request. Default is + V1BETA. + + Returns: + Dict containing details of the specified + IntegrationManagerRevision. + + Raises: + APIError: If the API request fails. + """ + return _get_integration_manager_revision( + self._client, + integration_name, + manager_id, + revision_id, + api_version=api_version, + ) + + def delete_integration_manager_revision( + self, + integration_name: str, + manager_id: str, + revision_id: str, + api_version: APIVersion | None = APIVersion.V1BETA, + ) -> None: + """Delete a specific revision for a given integration manager. + + Use this method to clean up obsolete snapshots and manage the + historical record of managers. + + Args: + integration_name: Name of the integration the manager + belongs to. + manager_id: ID of the manager the revision belongs to. + revision_id: ID of the revision to delete. + api_version: API version to use for the request. Default is + V1BETA. + + Returns: + None + + Raises: + APIError: If the API request fails. + """ + _delete_integration_manager_revision( + self._client, + integration_name, + manager_id, + revision_id, + api_version=api_version, + ) + + def create_integration_manager_revision( + self, + integration_name: str, + manager_id: str, + manager: dict[str, Any], + comment: str | None = None, + api_version: APIVersion | None = APIVersion.V1BETA, + ) -> dict[str, Any]: + """Create a new revision snapshot of the current integration + manager. + + Use this method to establish a recovery point before making + significant updates to a manager. + + Args: + integration_name: Name of the integration the manager + belongs to. + manager_id: ID of the manager to create a revision for. + manager: Dict containing the IntegrationManager to snapshot. + comment: Comment describing the revision. Maximum 400 + characters. Optional. + api_version: API version to use for the request. Default is + V1BETA. + + Returns: + Dict containing the newly created + IntegrationManagerRevision resource. + + Raises: + APIError: If the API request fails. + """ + return _create_integration_manager_revision( + self._client, + integration_name, + manager_id, + manager, + comment=comment, + api_version=api_version, + ) + + def rollback_integration_manager_revision( + self, + integration_name: str, + manager_id: str, + revision_id: str, + api_version: APIVersion | None = APIVersion.V1BETA, + ) -> dict[str, Any]: + """Revert the current manager definition to a previously saved + revision. + + Use this method to rapidly recover a functional state for + common code if an update causes operational issues in dependent + actions or jobs. + + Args: + integration_name: Name of the integration the manager + belongs to. + manager_id: ID of the manager to rollback. + revision_id: ID of the revision to rollback to. + api_version: API version to use for the request. Default is + V1BETA. + + Returns: + Dict containing the IntegrationManagerRevision rolled back + to. + + Raises: + APIError: If the API request fails. + """ + return _rollback_integration_manager_revision( + self._client, + integration_name, + manager_id, + revision_id, + api_version=api_version, + ) diff --git a/src/secops/cli/commands/integration/action_revisions.py b/src/secops/cli/commands/integration/action_revisions.py new file mode 100644 index 0000000..69164a9 --- /dev/null +++ b/src/secops/cli/commands/integration/action_revisions.py @@ -0,0 +1,215 @@ +# Copyright 2025 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. +# +"""Google SecOps CLI integration action revisions commands""" + +import sys + +from secops.cli.utils.common_args import ( + add_as_list_arg, + add_pagination_args, +) +from secops.cli.utils.formatters import output_formatter + + +def setup_action_revisions_command(subparsers): + """Setup integration action revisions command""" + revisions_parser = subparsers.add_parser( + "action-revisions", + help="Manage integration action revisions", + ) + lvl1 = revisions_parser.add_subparsers( + dest="action_revisions_command", + help="Integration action revisions command", + ) + + # list command + list_parser = lvl1.add_parser( + "list", help="List integration action revisions" + ) + list_parser.add_argument( + "--integration-name", + type=str, + help="Name of the integration", + dest="integration_name", + required=True, + ) + list_parser.add_argument( + "--action-id", + type=str, + help="ID of the action", + dest="action_id", + required=True, + ) + add_pagination_args(list_parser) + add_as_list_arg(list_parser) + list_parser.add_argument( + "--filter-string", + type=str, + help="Filter string for listing revisions", + dest="filter_string", + ) + list_parser.add_argument( + "--order-by", + type=str, + help="Order by string for listing revisions", + dest="order_by", + ) + list_parser.set_defaults(func=handle_action_revisions_list_command) + + # delete command + delete_parser = lvl1.add_parser( + "delete", help="Delete an integration action revision" + ) + delete_parser.add_argument( + "--integration-name", + type=str, + help="Name of the integration", + dest="integration_name", + required=True, + ) + delete_parser.add_argument( + "--action-id", + type=str, + help="ID of the action", + dest="action_id", + required=True, + ) + delete_parser.add_argument( + "--revision-id", + type=str, + help="ID of the revision to delete", + dest="revision_id", + required=True, + ) + delete_parser.set_defaults(func=handle_action_revisions_delete_command) + + # create command + create_parser = lvl1.add_parser( + "create", help="Create a new integration action revision" + ) + create_parser.add_argument( + "--integration-name", + type=str, + help="Name of the integration", + dest="integration_name", + required=True, + ) + create_parser.add_argument( + "--action-id", + type=str, + help="ID of the action", + dest="action_id", + required=True, + ) + create_parser.add_argument( + "--comment", + type=str, + help="Comment describing the revision", + dest="comment", + ) + create_parser.set_defaults(func=handle_action_revisions_create_command) + + # rollback command + rollback_parser = lvl1.add_parser( + "rollback", help="Rollback action to a previous revision" + ) + rollback_parser.add_argument( + "--integration-name", + type=str, + help="Name of the integration", + dest="integration_name", + required=True, + ) + rollback_parser.add_argument( + "--action-id", + type=str, + help="ID of the action", + dest="action_id", + required=True, + ) + rollback_parser.add_argument( + "--revision-id", + type=str, + help="ID of the revision to rollback to", + dest="revision_id", + required=True, + ) + rollback_parser.set_defaults(func=handle_action_revisions_rollback_command) + + +def handle_action_revisions_list_command(args, chronicle): + """Handle integration action revisions list command""" + try: + out = chronicle.soar.list_integration_action_revisions( + integration_name=args.integration_name, + action_id=args.action_id, + page_size=args.page_size, + page_token=args.page_token, + filter_string=args.filter_string, + order_by=args.order_by, + as_list=args.as_list, + ) + output_formatter(out, getattr(args, "output", "json")) + except Exception as e: # pylint: disable=broad-exception-caught + print(f"Error listing action revisions: {e}", file=sys.stderr) + sys.exit(1) + + +def handle_action_revisions_delete_command(args, chronicle): + """Handle integration action revision delete command""" + try: + chronicle.soar.delete_integration_action_revision( + integration_name=args.integration_name, + action_id=args.action_id, + revision_id=args.revision_id, + ) + print(f"Action revision {args.revision_id} deleted successfully") + except Exception as e: # pylint: disable=broad-exception-caught + print(f"Error deleting action revision: {e}", file=sys.stderr) + sys.exit(1) + + +def handle_action_revisions_create_command(args, chronicle): + """Handle integration action revision create command""" + try: + # Get the current action to create a revision + action = chronicle.soar.get_integration_action( + integration_name=args.integration_name, + action_id=args.action_id, + ) + out = chronicle.soar.create_integration_action_revision( + integration_name=args.integration_name, + action_id=args.action_id, + action=action, + comment=args.comment, + ) + output_formatter(out, getattr(args, "output", "json")) + except Exception as e: # pylint: disable=broad-exception-caught + print(f"Error creating action revision: {e}", file=sys.stderr) + sys.exit(1) + + +def handle_action_revisions_rollback_command(args, chronicle): + """Handle integration action revision rollback command""" + try: + out = chronicle.soar.rollback_integration_action_revision( + integration_name=args.integration_name, + action_id=args.action_id, + revision_id=args.revision_id, + ) + output_formatter(out, getattr(args, "output", "json")) + except Exception as e: # pylint: disable=broad-exception-caught + print(f"Error rolling back action revision: {e}", file=sys.stderr) + sys.exit(1) diff --git a/src/secops/cli/commands/integration/actions.py b/src/secops/cli/commands/integration/actions.py new file mode 100644 index 0000000..6179012 --- /dev/null +++ b/src/secops/cli/commands/integration/actions.py @@ -0,0 +1,423 @@ +# Copyright 2025 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. +# +"""Google SecOps CLI integration actions commands""" + +import sys + +from secops.cli.utils.common_args import ( + add_as_list_arg, + add_pagination_args, +) +from secops.cli.utils.formatters import output_formatter + + +def setup_actions_command(subparsers): + """Setup integration actions command""" + actions_parser = subparsers.add_parser( + "actions", + help="Manage integration actions", + ) + lvl1 = actions_parser.add_subparsers( + dest="actions_command", help="Integration actions command" + ) + + # list command + list_parser = lvl1.add_parser("list", help="List integration actions") + list_parser.add_argument( + "--integration-name", + type=str, + help="Name of the integration", + dest="integration_name", + required=True, + ) + add_pagination_args(list_parser) + add_as_list_arg(list_parser) + list_parser.add_argument( + "--filter-string", + type=str, + help="Filter string for listing actions", + dest="filter_string", + ) + list_parser.add_argument( + "--order-by", + type=str, + help="Order by string for listing actions", + dest="order_by", + ) + list_parser.set_defaults(func=handle_actions_list_command) + + # get command + get_parser = lvl1.add_parser("get", help="Get integration action details") + get_parser.add_argument( + "--integration-name", + type=str, + help="Name of the integration", + dest="integration_name", + required=True, + ) + get_parser.add_argument( + "--action-id", + type=str, + help="ID of the action to get", + dest="action_id", + required=True, + ) + get_parser.set_defaults(func=handle_actions_get_command) + + # delete command + delete_parser = lvl1.add_parser( + "delete", + help="Delete an integration action", + ) + delete_parser.add_argument( + "--integration-name", + type=str, + help="Name of the integration", + dest="integration_name", + required=True, + ) + delete_parser.add_argument( + "--action-id", + type=str, + help="ID of the action to delete", + dest="action_id", + required=True, + ) + delete_parser.set_defaults(func=handle_actions_delete_command) + + # create command + create_parser = lvl1.add_parser( + "create", help="Create a new integration action" + ) + create_parser.add_argument( + "--integration-name", + type=str, + help="Name of the integration", + dest="integration_name", + required=True, + ) + create_parser.add_argument( + "--display-name", + type=str, + help="Display name for the action", + dest="display_name", + required=True, + ) + create_parser.add_argument( + "--code", + type=str, + help="Python code for the action", + dest="code", + required=True, + ) + create_parser.add_argument( + "--is-async", + action="store_true", + help="Whether the action is asynchronous", + dest="is_async", + ) + create_parser.add_argument( + "--description", + type=str, + help="Description of the action", + dest="description", + ) + create_parser.add_argument( + "--action-id", + type=str, + help="Custom ID for the action", + dest="action_id", + ) + create_parser.add_argument( + "--timeout-seconds", + type=int, + help="Action timeout in seconds (default: 300)", + dest="timeout_seconds", + default=300, + ) + create_parser.add_argument( + "--disabled", + action="store_false", + help="Create the action in a disabled state", + dest="enabled", + ) + create_parser.add_argument( + "--script-result-name", + type=str, + help="Field name that holds the script result (default: result)", + dest="script_result_name", + default="result", + ) + create_parser.set_defaults(func=handle_actions_create_command) + + # update command + update_parser = lvl1.add_parser( + "update", help="Update an integration action" + ) + update_parser.add_argument( + "--integration-name", + type=str, + help="Name of the integration", + dest="integration_name", + required=True, + ) + update_parser.add_argument( + "--action-id", + type=str, + help="ID of the action to update", + dest="action_id", + required=True, + ) + update_parser.add_argument( + "--display-name", + type=str, + help="New display name for the action", + dest="display_name", + ) + update_parser.add_argument( + "--script", + type=str, + help="New Python script for the action", + dest="script", + ) + update_parser.add_argument( + "--description", + type=str, + help="New description for the action", + dest="description", + ) + update_parser.add_argument( + "--update-mask", + type=str, + help="Comma-separated list of fields to update", + dest="update_mask", + ) + update_parser.set_defaults(func=handle_actions_update_command) + + # test command + test_parser = lvl1.add_parser( + "test", help="Execute an integration action test" + ) + test_parser.add_argument( + "--integration-name", + type=str, + help="Name of the integration", + dest="integration_name", + required=True, + ) + test_parser.add_argument( + "--action-id", + type=str, + help="ID of the action to test", + dest="action_id", + required=True, + ) + test_parser.add_argument( + "--test-case-id", + type=int, + help="ID of the action test case", + dest="test_case_id", + required=True, + ) + test_parser.add_argument( + "--scope", + type=str, + help="The action test scope", + dest="scope", + required=True, + ) + test_parser.add_argument( + "--integration-instance-id", + type=str, + help="The integration instance ID to use for testing", + dest="integration_instance_id", + required=True, + ) + test_parser.set_defaults(func=handle_actions_test_command) + + # by-environment command + by_env_parser = lvl1.add_parser( + "by-environment", + help="Get integration actions by environment", + ) + by_env_parser.add_argument( + "--integration-name", + type=str, + help="Name of the integration", + dest="integration_name", + required=True, + ) + by_env_parser.add_argument( + "--environments", + type=str, + nargs="+", + help="List of environments to filter by", + dest="environments", + required=True, + ) + by_env_parser.add_argument( + "--include-widgets", + action="store_true", + help="Whether to include widgets in the response", + dest="include_widgets", + ) + by_env_parser.set_defaults(func=handle_actions_by_environment_command) + + # template command + template_parser = lvl1.add_parser( + "template", + help="Get a template for creating an action", + ) + template_parser.add_argument( + "--integration-name", + type=str, + help="Name of the integration", + dest="integration_name", + required=True, + ) + template_parser.add_argument( + "--is-async", + action="store_true", + help="Whether to fetch template for async action", + dest="is_async", + ) + template_parser.set_defaults(func=handle_actions_template_command) + + +def handle_actions_list_command(args, chronicle): + """Handle integration actions list command""" + try: + out = chronicle.soar.list_integration_actions( + integration_name=args.integration_name, + page_size=args.page_size, + page_token=args.page_token, + filter_string=args.filter_string, + order_by=args.order_by, + as_list=args.as_list, + ) + output_formatter(out, getattr(args, "output", "json")) + except Exception as e: # pylint: disable=broad-exception-caught + print(f"Error listing integration actions: {e}", file=sys.stderr) + sys.exit(1) + + +def handle_actions_get_command(args, chronicle): + """Handle integration action get command""" + try: + out = chronicle.soar.get_integration_action( + integration_name=args.integration_name, + action_id=args.action_id, + ) + output_formatter(out, getattr(args, "output", "json")) + except Exception as e: # pylint: disable=broad-exception-caught + print(f"Error getting integration action: {e}", file=sys.stderr) + sys.exit(1) + + +def handle_actions_delete_command(args, chronicle): + """Handle integration action delete command""" + try: + chronicle.soar.delete_integration_action( + integration_name=args.integration_name, + action_id=args.action_id, + ) + print(f"Action {args.action_id} deleted successfully") + except Exception as e: # pylint: disable=broad-exception-caught + print(f"Error deleting integration action: {e}", file=sys.stderr) + sys.exit(1) + + +def handle_actions_create_command(args, chronicle): + """Handle integration action create command""" + try: + out = chronicle.soar.create_integration_action( + integration_name=args.integration_name, + display_name=args.display_name, + script=args.code, + timeout_seconds=args.timeout_seconds, + enabled=args.enabled, + script_result_name=args.script_result_name, + is_async=args.is_async, + description=args.description, + ) + output_formatter(out, getattr(args, "output", "json")) + except Exception as e: # pylint: disable=broad-exception-caught + print(f"Error creating integration action: {e}", file=sys.stderr) + sys.exit(1) + + +def handle_actions_update_command(args, chronicle): + """Handle integration action update command""" + try: + out = chronicle.soar.update_integration_action( + integration_name=args.integration_name, + action_id=args.action_id, + display_name=args.display_name, + script=args.script or None, + description=args.description, + update_mask=args.update_mask, + ) + output_formatter(out, getattr(args, "output", "json")) + except Exception as e: # pylint: disable=broad-exception-caught + print(f"Error updating integration action: {e}", file=sys.stderr) + sys.exit(1) + + +def handle_actions_test_command(args, chronicle): + """Handle integration action test command""" + try: + # First get the action to test + action = chronicle.soar.get_integration_action( + integration_name=args.integration_name, + action_id=args.action_id, + ) + out = chronicle.soar.execute_integration_action_test( + integration_name=args.integration_name, + test_case_id=args.test_case_id, + action=action, + scope=args.scope, + integration_instance_id=args.integration_instance_id, + ) + output_formatter(out, getattr(args, "output", "json")) + except Exception as e: # pylint: disable=broad-exception-caught + print(f"Error testing integration action: {e}", file=sys.stderr) + sys.exit(1) + + +def handle_actions_by_environment_command(args, chronicle): + """Handle get actions by environment command""" + try: + out = chronicle.soar.get_integration_actions_by_environment( + integration_name=args.integration_name, + environments=args.environments, + include_widgets=args.include_widgets, + ) + output_formatter(out, getattr(args, "output", "json")) + except Exception as e: # pylint: disable=broad-exception-caught + print(f"Error getting actions by environment: {e}", file=sys.stderr) + sys.exit(1) + + +def handle_actions_template_command(args, chronicle): + """Handle get action template command""" + try: + out = chronicle.soar.get_integration_action_template( + integration_name=args.integration_name, + is_async=args.is_async, + ) + output_formatter(out, getattr(args, "output", "json")) + except Exception as e: # pylint: disable=broad-exception-caught + print(f"Error getting action template: {e}", file=sys.stderr) + sys.exit(1) diff --git a/src/secops/cli/commands/integration/integration_client.py b/src/secops/cli/commands/integration/integration_client.py index 916c28f..95d3959 100644 --- a/src/secops/cli/commands/integration/integration_client.py +++ b/src/secops/cli/commands/integration/integration_client.py @@ -17,8 +17,8 @@ from secops.cli.commands.integration import ( marketplace_integration, integration, - # actions, - # action_revisions, + actions, + action_revisions, # connectors, # connector_revisions, # connector_context_properties, @@ -29,8 +29,8 @@ # job_context_properties, # job_instance_logs, # job_instances, - # managers, - # manager_revisions, + managers, + manager_revisions, integration_instances, # transformers, # transformer_revisions, @@ -55,8 +55,8 @@ def setup_integrations_command(subparsers): # transformer_revisions.setup_transformer_revisions_command(lvl1) # logical_operators.setup_logical_operators_command(lvl1) # logical_operator_revisions.setup_logical_operator_revisions_command(lvl1) - # actions.setup_actions_command(lvl1) - # action_revisions.setup_action_revisions_command(lvl1) + actions.setup_actions_command(lvl1) + action_revisions.setup_action_revisions_command(lvl1) # connectors.setup_connectors_command(lvl1) # connector_revisions.setup_connector_revisions_command(lvl1) # connector_context_properties.setup_connector_context_properties_command( @@ -69,6 +69,6 @@ def setup_integrations_command(subparsers): # job_context_properties.setup_job_context_properties_command(lvl1) # job_instance_logs.setup_job_instance_logs_command(lvl1) # job_instances.setup_job_instances_command(lvl1) - # managers.setup_managers_command(lvl1) - # manager_revisions.setup_manager_revisions_command(lvl1) + managers.setup_managers_command(lvl1) + manager_revisions.setup_manager_revisions_command(lvl1) marketplace_integration.setup_marketplace_integrations_command(lvl1) diff --git a/src/secops/cli/commands/integration/manager_revisions.py b/src/secops/cli/commands/integration/manager_revisions.py new file mode 100644 index 0000000..0dee3c5 --- /dev/null +++ b/src/secops/cli/commands/integration/manager_revisions.py @@ -0,0 +1,254 @@ +# Copyright 2025 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. +# +"""Google SecOps CLI integration manager revisions commands""" + +import sys + +from secops.cli.utils.common_args import ( + add_as_list_arg, + add_pagination_args, +) +from secops.cli.utils.formatters import output_formatter + + +def setup_manager_revisions_command(subparsers): + """Setup integration manager revisions command""" + revisions_parser = subparsers.add_parser( + "manager-revisions", + help="Manage integration manager revisions", + ) + lvl1 = revisions_parser.add_subparsers( + dest="manager_revisions_command", + help="Integration manager revisions command", + ) + + # list command + list_parser = lvl1.add_parser( + "list", help="List integration manager revisions" + ) + list_parser.add_argument( + "--integration-name", + type=str, + help="Name of the integration", + dest="integration_name", + required=True, + ) + list_parser.add_argument( + "--manager-id", + type=str, + help="ID of the manager", + dest="manager_id", + required=True, + ) + add_pagination_args(list_parser) + add_as_list_arg(list_parser) + list_parser.add_argument( + "--filter-string", + type=str, + help="Filter string for listing revisions", + dest="filter_string", + ) + list_parser.add_argument( + "--order-by", + type=str, + help="Order by string for listing revisions", + dest="order_by", + ) + list_parser.set_defaults(func=handle_manager_revisions_list_command) + + # get command + get_parser = lvl1.add_parser("get", help="Get a specific manager revision") + get_parser.add_argument( + "--integration-name", + type=str, + help="Name of the integration", + dest="integration_name", + required=True, + ) + get_parser.add_argument( + "--manager-id", + type=str, + help="ID of the manager", + dest="manager_id", + required=True, + ) + get_parser.add_argument( + "--revision-id", + type=str, + help="ID of the revision to get", + dest="revision_id", + required=True, + ) + get_parser.set_defaults(func=handle_manager_revisions_get_command) + + # delete command + delete_parser = lvl1.add_parser( + "delete", help="Delete an integration manager revision" + ) + delete_parser.add_argument( + "--integration-name", + type=str, + help="Name of the integration", + dest="integration_name", + required=True, + ) + delete_parser.add_argument( + "--manager-id", + type=str, + help="ID of the manager", + dest="manager_id", + required=True, + ) + delete_parser.add_argument( + "--revision-id", + type=str, + help="ID of the revision to delete", + dest="revision_id", + required=True, + ) + delete_parser.set_defaults(func=handle_manager_revisions_delete_command) + + # create command + create_parser = lvl1.add_parser( + "create", help="Create a new integration manager revision" + ) + create_parser.add_argument( + "--integration-name", + type=str, + help="Name of the integration", + dest="integration_name", + required=True, + ) + create_parser.add_argument( + "--manager-id", + type=str, + help="ID of the manager", + dest="manager_id", + required=True, + ) + create_parser.add_argument( + "--comment", + type=str, + help="Comment describing the revision", + dest="comment", + ) + create_parser.set_defaults(func=handle_manager_revisions_create_command) + + # rollback command + rollback_parser = lvl1.add_parser( + "rollback", help="Rollback manager to a previous revision" + ) + rollback_parser.add_argument( + "--integration-name", + type=str, + help="Name of the integration", + dest="integration_name", + required=True, + ) + rollback_parser.add_argument( + "--manager-id", + type=str, + help="ID of the manager", + dest="manager_id", + required=True, + ) + rollback_parser.add_argument( + "--revision-id", + type=str, + help="ID of the revision to rollback to", + dest="revision_id", + required=True, + ) + rollback_parser.set_defaults(func=handle_manager_revisions_rollback_command) + + +def handle_manager_revisions_list_command(args, chronicle): + """Handle integration manager revisions list command""" + try: + out = chronicle.soar.list_integration_manager_revisions( + integration_name=args.integration_name, + manager_id=args.manager_id, + page_size=args.page_size, + page_token=args.page_token, + filter_string=args.filter_string, + order_by=args.order_by, + as_list=args.as_list, + ) + output_formatter(out, getattr(args, "output", "json")) + except Exception as e: # pylint: disable=broad-exception-caught + print(f"Error listing manager revisions: {e}", file=sys.stderr) + sys.exit(1) + + +def handle_manager_revisions_get_command(args, chronicle): + """Handle integration manager revision get command""" + try: + out = chronicle.soar.get_integration_manager_revision( + integration_name=args.integration_name, + manager_id=args.manager_id, + revision_id=args.revision_id, + ) + output_formatter(out, getattr(args, "output", "json")) + except Exception as e: # pylint: disable=broad-exception-caught + print(f"Error getting manager revision: {e}", file=sys.stderr) + sys.exit(1) + + +def handle_manager_revisions_delete_command(args, chronicle): + """Handle integration manager revision delete command""" + try: + chronicle.soar.delete_integration_manager_revision( + integration_name=args.integration_name, + manager_id=args.manager_id, + revision_id=args.revision_id, + ) + print(f"Manager revision {args.revision_id} deleted successfully") + except Exception as e: # pylint: disable=broad-exception-caught + print(f"Error deleting manager revision: {e}", file=sys.stderr) + sys.exit(1) + + +def handle_manager_revisions_create_command(args, chronicle): + """Handle integration manager revision create command""" + try: + # Get the current manager to create a revision + manager = chronicle.soar.get_integration_manager( + integration_name=args.integration_name, + manager_id=args.manager_id, + ) + out = chronicle.soar.create_integration_manager_revision( + integration_name=args.integration_name, + manager_id=args.manager_id, + manager=manager, + comment=args.comment, + ) + output_formatter(out, getattr(args, "output", "json")) + except Exception as e: # pylint: disable=broad-exception-caught + print(f"Error creating manager revision: {e}", file=sys.stderr) + sys.exit(1) + + +def handle_manager_revisions_rollback_command(args, chronicle): + """Handle integration manager revision rollback command""" + try: + out = chronicle.soar.rollback_integration_manager_revision( + integration_name=args.integration_name, + manager_id=args.manager_id, + revision_id=args.revision_id, + ) + output_formatter(out, getattr(args, "output", "json")) + except Exception as e: # pylint: disable=broad-exception-caught + print(f"Error rolling back manager revision: {e}", file=sys.stderr) + sys.exit(1) diff --git a/src/secops/cli/commands/integration/managers.py b/src/secops/cli/commands/integration/managers.py new file mode 100644 index 0000000..428b77d --- /dev/null +++ b/src/secops/cli/commands/integration/managers.py @@ -0,0 +1,276 @@ +# Copyright 2025 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. +# +"""Google SecOps CLI integration managers commands""" + +import sys + +from secops.cli.utils.common_args import ( + add_as_list_arg, + add_pagination_args, +) +from secops.cli.utils.formatters import output_formatter + + +def setup_managers_command(subparsers): + """Setup integration managers command""" + managers_parser = subparsers.add_parser( + "managers", + help="Manage integration managers", + ) + lvl1 = managers_parser.add_subparsers( + dest="managers_command", help="Integration managers command" + ) + + # list command + list_parser = lvl1.add_parser("list", help="List integration managers") + list_parser.add_argument( + "--integration-name", + type=str, + help="Name of the integration", + dest="integration_name", + required=True, + ) + add_pagination_args(list_parser) + add_as_list_arg(list_parser) + list_parser.add_argument( + "--filter-string", + type=str, + help="Filter string for listing managers", + dest="filter_string", + ) + list_parser.add_argument( + "--order-by", + type=str, + help="Order by string for listing managers", + dest="order_by", + ) + list_parser.set_defaults(func=handle_managers_list_command) + + # get command + get_parser = lvl1.add_parser("get", help="Get integration manager details") + get_parser.add_argument( + "--integration-name", + type=str, + help="Name of the integration", + dest="integration_name", + required=True, + ) + get_parser.add_argument( + "--manager-id", + type=str, + help="ID of the manager to get", + dest="manager_id", + required=True, + ) + get_parser.set_defaults(func=handle_managers_get_command) + + # delete command + delete_parser = lvl1.add_parser( + "delete", + help="Delete an integration manager", + ) + delete_parser.add_argument( + "--integration-name", + type=str, + help="Name of the integration", + dest="integration_name", + required=True, + ) + delete_parser.add_argument( + "--manager-id", + type=str, + help="ID of the manager to delete", + dest="manager_id", + required=True, + ) + delete_parser.set_defaults(func=handle_managers_delete_command) + + # create command + create_parser = lvl1.add_parser( + "create", help="Create a new integration manager" + ) + create_parser.add_argument( + "--integration-name", + type=str, + help="Name of the integration", + dest="integration_name", + required=True, + ) + create_parser.add_argument( + "--display-name", + type=str, + help="Display name for the manager", + dest="display_name", + required=True, + ) + create_parser.add_argument( + "--code", + type=str, + help="Python code for the manager", + dest="code", + required=True, + ) + create_parser.add_argument( + "--description", + type=str, + help="Description of the manager", + dest="description", + ) + create_parser.set_defaults(func=handle_managers_create_command) + + # update command + update_parser = lvl1.add_parser( + "update", help="Update an integration manager" + ) + update_parser.add_argument( + "--integration-name", + type=str, + help="Name of the integration", + dest="integration_name", + required=True, + ) + update_parser.add_argument( + "--manager-id", + type=str, + help="ID of the manager to update", + dest="manager_id", + required=True, + ) + update_parser.add_argument( + "--display-name", + type=str, + help="New display name for the manager", + dest="display_name", + ) + update_parser.add_argument( + "--code", + type=str, + help="New Python code for the manager", + dest="code", + ) + update_parser.add_argument( + "--description", + type=str, + help="New description for the manager", + dest="description", + ) + update_parser.add_argument( + "--update-mask", + type=str, + help="Comma-separated list of fields to update", + dest="update_mask", + ) + update_parser.set_defaults(func=handle_managers_update_command) + + # template command + template_parser = lvl1.add_parser( + "template", + help="Get a template for creating a manager", + ) + template_parser.add_argument( + "--integration-name", + type=str, + help="Name of the integration", + dest="integration_name", + required=True, + ) + template_parser.set_defaults(func=handle_managers_template_command) + + +def handle_managers_list_command(args, chronicle): + """Handle integration managers list command""" + try: + out = chronicle.soar.list_integration_managers( + integration_name=args.integration_name, + page_size=args.page_size, + page_token=args.page_token, + filter_string=args.filter_string, + order_by=args.order_by, + as_list=args.as_list, + ) + output_formatter(out, getattr(args, "output", "json")) + except Exception as e: # pylint: disable=broad-exception-caught + print(f"Error listing integration managers: {e}", file=sys.stderr) + sys.exit(1) + + +def handle_managers_get_command(args, chronicle): + """Handle integration manager get command""" + try: + out = chronicle.soar.get_integration_manager( + integration_name=args.integration_name, + manager_id=args.manager_id, + ) + output_formatter(out, getattr(args, "output", "json")) + except Exception as e: # pylint: disable=broad-exception-caught + print(f"Error getting integration manager: {e}", file=sys.stderr) + sys.exit(1) + + +def handle_managers_delete_command(args, chronicle): + """Handle integration manager delete command""" + try: + chronicle.soar.delete_integration_manager( + integration_name=args.integration_name, + manager_id=args.manager_id, + ) + print(f"Manager {args.manager_id} deleted successfully") + except Exception as e: # pylint: disable=broad-exception-caught + print(f"Error deleting integration manager: {e}", file=sys.stderr) + sys.exit(1) + + +def handle_managers_create_command(args, chronicle): + """Handle integration manager create command""" + try: + out = chronicle.soar.create_integration_manager( + integration_name=args.integration_name, + display_name=args.display_name, + script=args.code, + description=args.description, + ) + output_formatter(out, getattr(args, "output", "json")) + except Exception as e: # pylint: disable=broad-exception-caught + print(f"Error creating integration manager: {e}", file=sys.stderr) + sys.exit(1) + + +def handle_managers_update_command(args, chronicle): + """Handle integration manager update command""" + try: + out = chronicle.soar.update_integration_manager( + integration_name=args.integration_name, + manager_id=args.manager_id, + display_name=args.display_name, + script=args.code, + description=args.description, + update_mask=args.update_mask, + ) + output_formatter(out, getattr(args, "output", "json")) + except Exception as e: # pylint: disable=broad-exception-caught + print(f"Error updating integration manager: {e}", file=sys.stderr) + sys.exit(1) + + +def handle_managers_template_command(args, chronicle): + """Handle get manager template command""" + try: + out = chronicle.soar.get_integration_manager_template( + integration_name=args.integration_name, + ) + output_formatter(out, getattr(args, "output", "json")) + except Exception as e: # pylint: disable=broad-exception-caught + print(f"Error getting manager template: {e}", file=sys.stderr) + sys.exit(1) diff --git a/tests/chronicle/soar/integration/__init__.py b/tests/chronicle/soar/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/chronicle/soar/integration/test_action_integration.py b/tests/chronicle/soar/integration/test_action_integration.py new file mode 100644 index 0000000..f584d36 --- /dev/null +++ b/tests/chronicle/soar/integration/test_action_integration.py @@ -0,0 +1,150 @@ +# Copyright 2025 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. +# +"""Integration tests for Chronicle integration actions. + +These tests require valid credentials and API access. +""" + +import time +import uuid + +import pytest + +from secops import SecOpsClient +from secops.exceptions import APIError +from tests.config import CHRONICLE_CONFIG, SERVICE_ACCOUNT_JSON + + +_ACTION_SCRIPT = ( + "from SiemplifyAction import SiemplifyAction\n" + "siemplify = SiemplifyAction()\n" + "siemplify.end('Test action', True)\n" +) + + +@pytest.mark.integration +def test_integration_actions_workflow(): + """Test full action lifecycle: template, create, get, list, update, delete. + + TODO: Remove 401 skip logic once SOAR IAM role issue is fixed. + """ + client = SecOpsClient(service_account_info=SERVICE_ACCOUNT_JSON) + chronicle = client.chronicle(**CHRONICLE_CONFIG) + + integration_name = None + action_id = None + + try: + print("\n1. Finding a target integration") + integrations_resp = chronicle.soar.list_integrations(page_size=5) + integrations = integrations_resp.get("integrations", []) + if not integrations: + pytest.skip("No integrations available to test action workflow.") + + integration_name = integrations[0]["name"].split("/")[-1] + print(f"Using integration: {integration_name}") + + print("\n2. Getting action template") + template_resp = chronicle.soar.get_integration_action_template( + integration_name=integration_name, + ) + assert template_resp is not None + print("Retrieved sync action template successfully") + + print("\n2a. Getting async action template") + async_template_resp = chronicle.soar.get_integration_action_template( + integration_name=integration_name, + is_async=True, + ) + assert async_template_resp is not None + print("Retrieved async action template successfully") + + print("\n3. Creating a custom action") + unique_id = str(uuid.uuid4())[:8] + display_name = f"SDK Test Action {unique_id}" + + create_resp = chronicle.soar.create_integration_action( + integration_name=integration_name, + display_name=display_name, + script=_ACTION_SCRIPT, + timeout_seconds=60, + enabled=True, + script_result_name="result", + is_async=False, + description="Temporary action created by integration test", + ) + + assert create_resp is not None + assert "name" in create_resp + action_id = create_resp["name"].split("/")[-1] + print(f"Created action with ID: {action_id}") + + time.sleep(2) + + print("\n4. Getting action details") + get_resp = chronicle.soar.get_integration_action( + integration_name=integration_name, + action_id=action_id, + ) + assert get_resp is not None + assert "name" in get_resp + assert get_resp.get("displayName") == display_name + print(f"Retrieved action: {get_resp.get('displayName')}") + + print("\n5. Listing actions and verifying created action appears") + list_resp = chronicle.soar.list_integration_actions( + integration_name=integration_name, + ) + actions = list_resp.get("actions", []) + assert isinstance(actions, list) + found = any(a.get("name", "").endswith(action_id) for a in actions) + assert found, f"Created action {action_id} not found in list." + print(f"Found action in list ({len(actions)} total)") + + print("\n5a. Listing actions with as_list=True") + actions_list = chronicle.soar.list_integration_actions( + integration_name=integration_name, + as_list=True, + ) + assert isinstance(actions_list, list) + print(f"as_list returned {len(actions_list)} actions") + + print("\n6. Updating the action") + updated_description = f"Updated by integration test {unique_id}" + update_resp = chronicle.soar.update_integration_action( + integration_name=integration_name, + action_id=action_id, + description=updated_description, + ) + assert update_resp is not None + assert "name" in update_resp + print("Updated action description successfully") + + except APIError as e: + error_msg = str(e) + if "401" in error_msg or "Unauthorized" in error_msg: + pytest.skip(f"Skipping due to SOAR IAM issue (401): {e}") + raise + finally: + print("\n7. Cleaning up") + if integration_name and action_id: + try: + chronicle.soar.delete_integration_action( + integration_name=integration_name, + action_id=action_id, + ) + print(f"Cleaned up action: {action_id}") + except Exception as cleanup_error: + print(f"Warning: Failed to delete test action: {cleanup_error}") diff --git a/tests/chronicle/soar/integration/test_action_revisions.py b/tests/chronicle/soar/integration/test_action_revisions.py new file mode 100644 index 0000000..8db23e3 --- /dev/null +++ b/tests/chronicle/soar/integration/test_action_revisions.py @@ -0,0 +1,409 @@ +# Copyright 2025 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. +# +"""Tests for Chronicle marketplace integration action revisions functions.""" + +from unittest.mock import Mock, patch + +import pytest + +from secops.chronicle.client import ChronicleClient +from secops.chronicle.models import APIVersion +from secops.chronicle.soar.integration.action_revisions import ( + list_integration_action_revisions, + delete_integration_action_revision, + create_integration_action_revision, + rollback_integration_action_revision, +) +from secops.exceptions import APIError + + +@pytest.fixture +def chronicle_client(): + """Create a Chronicle client for testing.""" + with patch("secops.auth.SecOpsAuth") as mock_auth: + mock_session = Mock() + mock_session.headers = {} + mock_auth.return_value.session = mock_session + return ChronicleClient( + customer_id="test-customer", + project_id="test-project", + default_api_version=APIVersion.V1BETA, + ) + + +# -- list_integration_action_revisions tests -- + + +def test_list_integration_action_revisions_success(chronicle_client): + """Test list_integration_action_revisions delegates to chronicle_paginated_request.""" + expected = { + "revisions": [{"name": "r1"}, {"name": "r2"}], + "nextPageToken": "t", + } + + with patch( + "secops.chronicle.soar.integration.action_revisions.chronicle_paginated_request", + return_value=expected, + ) as mock_paginated, patch( + "secops.chronicle.soar.integration.action_revisions.format_resource_id", + return_value="My Integration", + ): + result = list_integration_action_revisions( + chronicle_client, + integration_name="My Integration", + action_id="a1", + page_size=10, + page_token="next-token", + ) + + assert result == expected + + _, kwargs = mock_paginated.call_args + assert "actions/a1/revisions" in kwargs["path"] + assert kwargs["items_key"] == "revisions" + assert kwargs["page_size"] == 10 + assert kwargs["page_token"] == "next-token" + + +def test_list_integration_action_revisions_default_args(chronicle_client): + """Test list_integration_action_revisions with default args.""" + expected = {"revisions": []} + + with patch( + "secops.chronicle.soar.integration.action_revisions.chronicle_paginated_request", + return_value=expected, + ) as mock_paginated: + result = list_integration_action_revisions( + chronicle_client, + integration_name="test-integration", + action_id="a1", + ) + + assert result == expected + + +def test_list_integration_action_revisions_with_filters(chronicle_client): + """Test list_integration_action_revisions with filter and order_by.""" + expected = {"revisions": [{"name": "r1"}]} + + with patch( + "secops.chronicle.soar.integration.action_revisions.chronicle_paginated_request", + return_value=expected, + ) as mock_paginated: + result = list_integration_action_revisions( + chronicle_client, + integration_name="test-integration", + action_id="a1", + filter_string='version = "1.0"', + order_by="createTime", + ) + + assert result == expected + + _, kwargs = mock_paginated.call_args + assert kwargs["extra_params"] == { + "filter": 'version = "1.0"', + "orderBy": "createTime", + } + + +def test_list_integration_action_revisions_as_list(chronicle_client): + """Test list_integration_action_revisions returns list when as_list=True.""" + expected = [{"name": "r1"}, {"name": "r2"}] + + with patch( + "secops.chronicle.soar.integration.action_revisions.chronicle_paginated_request", + return_value=expected, + ) as mock_paginated: + result = list_integration_action_revisions( + chronicle_client, + integration_name="test-integration", + action_id="a1", + as_list=True, + ) + + assert result == expected + + _, kwargs = mock_paginated.call_args + assert kwargs["as_list"] is True + + +def test_list_integration_action_revisions_error(chronicle_client): + """Test list_integration_action_revisions raises APIError on failure.""" + with patch( + "secops.chronicle.soar.integration.action_revisions.chronicle_paginated_request", + side_effect=APIError("Failed to list action revisions"), + ): + with pytest.raises(APIError) as exc_info: + list_integration_action_revisions( + chronicle_client, + integration_name="test-integration", + action_id="a1", + ) + assert "Failed to list action revisions" in str(exc_info.value) + + +# -- delete_integration_action_revision tests -- + + +def test_delete_integration_action_revision_success(chronicle_client): + """Test delete_integration_action_revision issues DELETE request.""" + with patch( + "secops.chronicle.soar.integration.action_revisions.chronicle_request", + return_value=None, + ) as mock_request: + delete_integration_action_revision( + chronicle_client, + integration_name="test-integration", + action_id="a1", + revision_id="r1", + ) + + _, kwargs = mock_request.call_args + assert kwargs["method"] == "DELETE" + assert "actions/a1/revisions/r1" in kwargs["endpoint_path"] + assert kwargs["api_version"] == APIVersion.V1BETA + + +def test_delete_integration_action_revision_error(chronicle_client): + """Test delete_integration_action_revision raises APIError on failure.""" + with patch( + "secops.chronicle.soar.integration.action_revisions.chronicle_request", + side_effect=APIError("Failed to delete action revision"), + ): + with pytest.raises(APIError) as exc_info: + delete_integration_action_revision( + chronicle_client, + integration_name="test-integration", + action_id="a1", + revision_id="r1", + ) + assert "Failed to delete action revision" in str(exc_info.value) + + +# -- create_integration_action_revision tests -- + + +def test_create_integration_action_revision_success(chronicle_client): + """Test create_integration_action_revision issues POST request.""" + expected = { + "name": "revisions/r1", + "comment": "Test revision", + } + + action = { + "name": "actions/a1", + "displayName": "Test Action", + "code": "print('hello')", + } + + with patch( + "secops.chronicle.soar.integration.action_revisions.chronicle_request", + return_value=expected, + ) as mock_request: + result = create_integration_action_revision( + chronicle_client, + integration_name="test-integration", + action_id="a1", + action=action, + comment="Test revision", + ) + + assert result == expected + + _, kwargs = mock_request.call_args + assert kwargs["method"] == "POST" + assert "actions/a1/revisions" in kwargs["endpoint_path"] + assert kwargs["json"]["action"] == action + assert kwargs["json"]["comment"] == "Test revision" + assert kwargs["api_version"] == APIVersion.V1BETA + + +def test_create_integration_action_revision_without_comment(chronicle_client): + """Test create_integration_action_revision without comment.""" + expected = {"name": "revisions/r1"} + + action = { + "name": "actions/a1", + "displayName": "Test Action", + } + + with patch( + "secops.chronicle.soar.integration.action_revisions.chronicle_request", + return_value=expected, + ) as mock_request: + result = create_integration_action_revision( + chronicle_client, + integration_name="test-integration", + action_id="a1", + action=action, + ) + + assert result == expected + + _, kwargs = mock_request.call_args + assert kwargs["json"]["action"] == action + assert "comment" not in kwargs["json"] + + +def test_create_integration_action_revision_error(chronicle_client): + """Test create_integration_action_revision raises APIError on failure.""" + action = {"name": "actions/a1"} + + with patch( + "secops.chronicle.soar.integration.action_revisions.chronicle_request", + side_effect=APIError("Failed to create action revision"), + ): + with pytest.raises(APIError) as exc_info: + create_integration_action_revision( + chronicle_client, + integration_name="test-integration", + action_id="a1", + action=action, + ) + assert "Failed to create action revision" in str(exc_info.value) + + +# -- rollback_integration_action_revision tests -- + + +def test_rollback_integration_action_revision_success(chronicle_client): + """Test rollback_integration_action_revision issues POST request.""" + expected = { + "name": "revisions/r1", + "comment": "Rolled back", + } + + with patch( + "secops.chronicle.soar.integration.action_revisions.chronicle_request", + return_value=expected, + ) as mock_request: + result = rollback_integration_action_revision( + chronicle_client, + integration_name="test-integration", + action_id="a1", + revision_id="r1", + ) + + assert result == expected + + _, kwargs = mock_request.call_args + assert kwargs["method"] == "POST" + assert "actions/a1/revisions/r1:rollback" in kwargs["endpoint_path"] + assert kwargs["api_version"] == APIVersion.V1BETA + + +def test_rollback_integration_action_revision_error(chronicle_client): + """Test rollback_integration_action_revision raises APIError on failure.""" + with patch( + "secops.chronicle.soar.integration.action_revisions.chronicle_request", + side_effect=APIError("Failed to rollback action revision"), + ): + with pytest.raises(APIError) as exc_info: + rollback_integration_action_revision( + chronicle_client, + integration_name="test-integration", + action_id="a1", + revision_id="r1", + ) + assert "Failed to rollback action revision" in str(exc_info.value) + + +# -- API version tests -- + + +def test_list_integration_action_revisions_custom_api_version(chronicle_client): + """Test list_integration_action_revisions with custom API version.""" + expected = {"revisions": []} + + with patch( + "secops.chronicle.soar.integration.action_revisions.chronicle_paginated_request", + return_value=expected, + ) as mock_paginated: + result = list_integration_action_revisions( + chronicle_client, + integration_name="test-integration", + action_id="a1", + api_version=APIVersion.V1ALPHA, + ) + + assert result == expected + + _, kwargs = mock_paginated.call_args + assert kwargs["api_version"] == APIVersion.V1ALPHA + + +def test_delete_integration_action_revision_custom_api_version(chronicle_client): + """Test delete_integration_action_revision with custom API version.""" + with patch( + "secops.chronicle.soar.integration.action_revisions.chronicle_request", + return_value=None, + ) as mock_request: + delete_integration_action_revision( + chronicle_client, + integration_name="test-integration", + action_id="a1", + revision_id="r1", + api_version=APIVersion.V1ALPHA, + ) + + _, kwargs = mock_request.call_args + assert kwargs["api_version"] == APIVersion.V1ALPHA + + +def test_create_integration_action_revision_custom_api_version(chronicle_client): + """Test create_integration_action_revision with custom API version.""" + expected = {"name": "revisions/r1"} + action = {"name": "actions/a1"} + + with patch( + "secops.chronicle.soar.integration.action_revisions.chronicle_request", + return_value=expected, + ) as mock_request: + result = create_integration_action_revision( + chronicle_client, + integration_name="test-integration", + action_id="a1", + action=action, + api_version=APIVersion.V1ALPHA, + ) + + assert result == expected + + _, kwargs = mock_request.call_args + assert kwargs["api_version"] == APIVersion.V1ALPHA + + +def test_rollback_integration_action_revision_custom_api_version(chronicle_client): + """Test rollback_integration_action_revision with custom API version.""" + expected = {"name": "revisions/r1"} + + with patch( + "secops.chronicle.soar.integration.action_revisions.chronicle_request", + return_value=expected, + ) as mock_request: + result = rollback_integration_action_revision( + chronicle_client, + integration_name="test-integration", + action_id="a1", + revision_id="r1", + api_version=APIVersion.V1ALPHA, + ) + + assert result == expected + + _, kwargs = mock_request.call_args + assert kwargs["api_version"] == APIVersion.V1ALPHA + diff --git a/tests/chronicle/soar/integration/test_action_revisions_integration.py b/tests/chronicle/soar/integration/test_action_revisions_integration.py new file mode 100644 index 0000000..cb6a310 --- /dev/null +++ b/tests/chronicle/soar/integration/test_action_revisions_integration.py @@ -0,0 +1,173 @@ +# Copyright 2025 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. +# +"""Integration tests for Chronicle integration action revisions. + +These tests require valid credentials and API access. +""" + +import time +import uuid + +import pytest + +from secops import SecOpsClient +from secops.exceptions import APIError +from tests.config import CHRONICLE_CONFIG, SERVICE_ACCOUNT_JSON + + +@pytest.mark.integration +def test_action_revisions_workflow(): + """Test full action revision lifecycle: create, list, rollback, delete. + + TODO: Remove 401 skip logic once SOAR IAM role issue is fixed. + """ + client = SecOpsClient(service_account_info=SERVICE_ACCOUNT_JSON) + chronicle = client.chronicle(**CHRONICLE_CONFIG) + + integration_name = None + action_id = None + revision_id = None + + try: + print("\n1. Finding a target integration") + integrations_resp = chronicle.soar.list_integrations(page_size=5) + integrations = integrations_resp.get("integrations", []) + if not integrations: + pytest.skip("No integrations available to test action revisions.") + + integration_name = integrations[0]["name"].split("/")[-1] + print(f"Using integration: {integration_name}") + + print("\n2. Creating a temporary custom action") + unique_id = str(uuid.uuid4())[:8] + display_name = f"SDK Test Action {unique_id}" + script = ( + "from SiemplifyAction import SiemplifyAction\n" + "siemplify = SiemplifyAction()\n" + "siemplify.end('Test action', True)\n" + ) + + create_action_resp = chronicle.soar.create_integration_action( + integration_name=integration_name, + display_name=display_name, + script=script, + timeout_seconds=60, + enabled=True, + script_result_name="result", + is_async=False, + description="Temporary action created by integration test", + ) + + assert create_action_resp is not None + assert "name" in create_action_resp + action_id = create_action_resp["name"].split("/")[-1] + print(f"Created action with ID: {action_id}") + + time.sleep(2) + + print("\n3. Getting action details for snapshotting") + action = chronicle.soar.get_integration_action( + integration_name=integration_name, + action_id=action_id, + ) + assert action is not None + assert "name" in action + print(f"Retrieved action: {action.get('displayName')}") + + print("\n4. Creating a revision of the action") + revision_comment = f"SDK test revision {unique_id}" + create_rev_resp = chronicle.soar.create_integration_action_revision( + integration_name=integration_name, + action_id=action_id, + action=action, + comment=revision_comment, + ) + + assert create_rev_resp is not None + assert "name" in create_rev_resp + revision_id = create_rev_resp["name"].split("/")[-1] + print(f"Created revision with ID: {revision_id}") + + time.sleep(1) + + print("\n5. Listing revisions and verifying the new revision appears") + list_resp = chronicle.soar.list_integration_action_revisions( + integration_name=integration_name, + action_id=action_id, + ) + + revisions = list_resp.get("revisions", []) + assert isinstance(revisions, list) + found = any(r.get("name", "").endswith(revision_id) for r in revisions) + assert found, f"Created revision {revision_id} not found in list." + print(f"Found revision in list ({len(revisions)} total)") + + print("\n5a. Listing revisions as_list=True") + revisions_list = chronicle.soar.list_integration_action_revisions( + integration_name=integration_name, + action_id=action_id, + as_list=True, + ) + assert isinstance(revisions_list, list) + print(f"as_list returned {len(revisions_list)} revisions") + + print("\n6. Rolling back action to the created revision") + rollback_resp = chronicle.soar.rollback_integration_action_revision( + integration_name=integration_name, + action_id=action_id, + revision_id=revision_id, + ) + assert rollback_resp is not None + print(f"Rollback successful: {rollback_resp.get('name')}") + + time.sleep(1) + + print("\n7. Deleting the revision") + chronicle.soar.delete_integration_action_revision( + integration_name=integration_name, + action_id=action_id, + revision_id=revision_id, + ) + revision_id = None + print("Revision deleted successfully") + + except APIError as e: + error_msg = str(e) + if "401" in error_msg or "Unauthorized" in error_msg: + pytest.skip(f"Skipping due to SOAR IAM issue (401): {e}") + raise + finally: + print("\n8. Cleaning up") + if integration_name and revision_id: + try: + chronicle.soar.delete_integration_action_revision( + integration_name=integration_name, + action_id=action_id, + revision_id=revision_id, + ) + print(f"Cleaned up revision: {revision_id}") + except Exception as cleanup_error: + print( + f"Warning: Failed to delete test revision: {cleanup_error}" + ) + if integration_name and action_id: + try: + chronicle.soar.delete_integration_action( + integration_name=integration_name, + action_id=action_id, + ) + print(f"Cleaned up action: {action_id}") + except Exception as cleanup_error: + print(f"Warning: Failed to delete test action: {cleanup_error}") diff --git a/tests/chronicle/soar/integration/test_actions.py b/tests/chronicle/soar/integration/test_actions.py new file mode 100644 index 0000000..4432933 --- /dev/null +++ b/tests/chronicle/soar/integration/test_actions.py @@ -0,0 +1,675 @@ +# Copyright 2025 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. +# +"""Tests for Chronicle marketplace integration actions functions.""" + +from unittest.mock import Mock, patch + +import pytest + +from secops.chronicle.client import ChronicleClient +from secops.chronicle.models import APIVersion +from secops.chronicle.soar.integration.actions import ( + list_integration_actions, + get_integration_action, + delete_integration_action, + create_integration_action, + update_integration_action, + execute_integration_action_test, + get_integration_actions_by_environment, + get_integration_action_template, +) +from secops.exceptions import APIError + + +@pytest.fixture +def chronicle_client(): + """Create a Chronicle client for testing.""" + with patch("secops.auth.SecOpsAuth") as mock_auth: + mock_session = Mock() + mock_session.headers = {} + mock_auth.return_value.session = mock_session + return ChronicleClient( + customer_id="test-customer", + project_id="test-project", + default_api_version=APIVersion.V1BETA, + ) + + +# -- list_integration_actions tests -- + + +def test_list_integration_actions_success(chronicle_client): + """Test list_integration_actions delegates to chronicle_paginated_request.""" + expected = { + "actions": [{"name": "a1"}, {"name": "a2"}], + "nextPageToken": "t", + } + + with ( + patch( + "secops.chronicle.soar.integration.actions.chronicle_paginated_request", + return_value=expected, + ) as mock_paginated, + patch( + # Avoid assuming how format_resource_id encodes/cases values + "secops.chronicle.soar.integration.actions.format_resource_id", + return_value="My Integration", + ), + ): + result = list_integration_actions( + chronicle_client, + integration_name="My Integration", + page_size=10, + page_token="next-token", + ) + + assert result == expected + + mock_paginated.assert_called_once_with( + chronicle_client, + api_version=APIVersion.V1BETA, + path="integrations/My Integration/actions", + items_key="actions", + page_size=10, + page_token="next-token", + extra_params={}, + as_list=False, + ) + + +def test_list_integration_actions_default_args(chronicle_client): + """Test list_integration_actions with default args.""" + expected = {"actions": []} + + with patch( + "secops.chronicle.soar.integration.actions.chronicle_paginated_request", + return_value=expected, + ) as mock_paginated: + result = list_integration_actions( + chronicle_client, + integration_name="test-integration", + ) + + assert result == expected + + mock_paginated.assert_called_once_with( + chronicle_client, + api_version=APIVersion.V1BETA, + path="integrations/test-integration/actions", + items_key="actions", + page_size=None, + page_token=None, + extra_params={}, + as_list=False, + ) + + +def test_list_integration_actions_with_filter_order_expand(chronicle_client): + """Test list_integration_actions passes filter/orderBy/expand in extra_params.""" + expected = {"actions": [{"name": "a1"}]} + + with patch( + "secops.chronicle.soar.integration.actions.chronicle_paginated_request", + return_value=expected, + ) as mock_paginated: + result = list_integration_actions( + chronicle_client, + integration_name="test-integration", + filter_string='displayName = "My Action"', + order_by="displayName", + expand="parameters,dynamicResults", + ) + + assert result == expected + + mock_paginated.assert_called_once_with( + chronicle_client, + api_version=APIVersion.V1BETA, + path="integrations/test-integration/actions", + items_key="actions", + page_size=None, + page_token=None, + extra_params={ + "filter": 'displayName = "My Action"', + "orderBy": "displayName", + "expand": "parameters,dynamicResults", + }, + as_list=False, + ) + + +def test_list_integration_actions_as_list(chronicle_client): + """Test list_integration_actions with as_list=True.""" + expected = [{"name": "a1"}, {"name": "a2"}] + + with patch( + "secops.chronicle.soar.integration.actions.chronicle_paginated_request", + return_value=expected, + ) as mock_paginated: + result = list_integration_actions( + chronicle_client, + integration_name="test-integration", + as_list=True, + ) + + assert result == expected + + mock_paginated.assert_called_once_with( + chronicle_client, + api_version=APIVersion.V1BETA, + path="integrations/test-integration/actions", + items_key="actions", + page_size=None, + page_token=None, + extra_params={}, + as_list=True, + ) + + +def test_list_integration_actions_error(chronicle_client): + """Test list_integration_actions propagates APIError from helper.""" + with patch( + "secops.chronicle.soar.integration.actions.chronicle_paginated_request", + side_effect=APIError("Failed to list integration actions"), + ): + with pytest.raises(APIError) as exc_info: + list_integration_actions( + chronicle_client, + integration_name="test-integration", + ) + + assert "Failed to list integration actions" in str(exc_info.value) + + +# -- get_integration_action tests -- + + +def test_get_integration_action_success(chronicle_client): + """Test get_integration_action returns expected result.""" + expected = {"name": "actions/a1", "displayName": "Action 1"} + + with patch( + "secops.chronicle.soar.integration.actions.chronicle_request", + return_value=expected, + ) as mock_request: + result = get_integration_action( + chronicle_client, + integration_name="test-integration", + action_id="a1", + ) + + assert result == expected + + mock_request.assert_called_once_with( + chronicle_client, + method="GET", + endpoint_path="integrations/test-integration/actions/a1", + api_version=APIVersion.V1BETA, + ) + + +def test_get_integration_action_error(chronicle_client): + """Test get_integration_action raises APIError on failure.""" + with patch( + "secops.chronicle.soar.integration.actions.chronicle_request", + side_effect=APIError("Failed to get integration action"), + ): + with pytest.raises(APIError) as exc_info: + get_integration_action( + chronicle_client, + integration_name="test-integration", + action_id="a1", + ) + assert "Failed to get integration action" in str(exc_info.value) + + +# -- delete_integration_action tests -- + + +def test_delete_integration_action_success(chronicle_client): + """Test delete_integration_action issues DELETE request.""" + with patch( + "secops.chronicle.soar.integration.actions.chronicle_request", + return_value=None, + ) as mock_request: + delete_integration_action( + chronicle_client, + integration_name="test-integration", + action_id="a1", + ) + + mock_request.assert_called_once_with( + chronicle_client, + method="DELETE", + endpoint_path="integrations/test-integration/actions/a1", + api_version=APIVersion.V1BETA, + ) + + +def test_delete_integration_action_error(chronicle_client): + """Test delete_integration_action raises APIError on failure.""" + with patch( + "secops.chronicle.soar.integration.actions.chronicle_request", + side_effect=APIError("Failed to delete integration action"), + ): + with pytest.raises(APIError) as exc_info: + delete_integration_action( + chronicle_client, + integration_name="test-integration", + action_id="a1", + ) + assert "Failed to delete integration action" in str(exc_info.value) + + +# -- create_integration_action tests -- + + +def test_create_integration_action_required_fields_only(chronicle_client): + """Test create_integration_action sends only required fields when optionals omitted.""" + expected = {"name": "actions/new", "displayName": "My Action"} + + with patch( + "secops.chronicle.soar.integration.actions.chronicle_request", + return_value=expected, + ) as mock_request: + result = create_integration_action( + chronicle_client, + integration_name="test-integration", + display_name="My Action", + script="print('hi')", + timeout_seconds=120, + enabled=True, + script_result_name="result", + is_async=False, + ) + + assert result == expected + + mock_request.assert_called_once_with( + chronicle_client, + method="POST", + endpoint_path="integrations/test-integration/actions", + api_version=APIVersion.V1BETA, + json={ + "displayName": "My Action", + "script": "print('hi')", + "timeoutSeconds": 120, + "enabled": True, + "scriptResultName": "result", + "async": False, + }, + ) + + +def test_create_integration_action_all_fields(chronicle_client): + """Test create_integration_action with all optional fields.""" + expected = {"name": "actions/new"} + + with patch( + "secops.chronicle.soar.integration.actions.chronicle_request", + return_value=expected, + ) as mock_request: + result = create_integration_action( + chronicle_client, + integration_name="test-integration", + display_name="My Action", + script="print('hi')", + timeout_seconds=120, + enabled=True, + script_result_name="result", + is_async=True, + description="desc", + default_result_value="default", + async_polling_interval_seconds=5, + async_total_timeout_seconds=60, + dynamic_results=[{"name": "dr1"}], + parameters=[{"name": "p1"}], + ai_generated=False, + ) + + assert result == expected + + mock_request.assert_called_once_with( + chronicle_client, + method="POST", + endpoint_path="integrations/test-integration/actions", + api_version=APIVersion.V1BETA, + json={ + "displayName": "My Action", + "script": "print('hi')", + "timeoutSeconds": 120, + "enabled": True, + "scriptResultName": "result", + "async": True, + "description": "desc", + "defaultResultValue": "default", + "asyncPollingIntervalSeconds": 5, + "asyncTotalTimeoutSeconds": 60, + "dynamicResults": [{"name": "dr1"}], + "parameters": [{"name": "p1"}], + "aiGenerated": False, + }, + ) + + +def test_create_integration_action_none_fields_excluded(chronicle_client): + """Test that None optional fields are not included in request body.""" + with patch( + "secops.chronicle.soar.integration.actions.chronicle_request", + return_value={"name": "actions/new"}, + ) as mock_request: + create_integration_action( + chronicle_client, + integration_name="test-integration", + display_name="My Action", + script="print('hi')", + timeout_seconds=120, + enabled=True, + script_result_name="result", + is_async=False, + description=None, + default_result_value=None, + async_polling_interval_seconds=None, + async_total_timeout_seconds=None, + dynamic_results=None, + parameters=None, + ai_generated=None, + ) + + mock_request.assert_called_once_with( + chronicle_client, + method="POST", + endpoint_path="integrations/test-integration/actions", + api_version=APIVersion.V1BETA, + json={ + "displayName": "My Action", + "script": "print('hi')", + "timeoutSeconds": 120, + "enabled": True, + "scriptResultName": "result", + "async": False, + }, + ) + + +def test_create_integration_action_error(chronicle_client): + """Test create_integration_action raises APIError on failure.""" + with patch( + "secops.chronicle.soar.integration.actions.chronicle_request", + side_effect=APIError("Failed to create integration action"), + ): + with pytest.raises(APIError) as exc_info: + create_integration_action( + chronicle_client, + integration_name="test-integration", + display_name="My Action", + script="print('hi')", + timeout_seconds=120, + enabled=True, + script_result_name="result", + is_async=False, + ) + assert "Failed to create integration action" in str(exc_info.value) + + +# -- update_integration_action tests -- + + +def test_update_integration_action_with_explicit_update_mask(chronicle_client): + """Test update_integration_action passes through explicit update_mask.""" + expected = {"name": "actions/a1", "displayName": "New Name"} + + with patch( + "secops.chronicle.soar.integration.actions.chronicle_request", + return_value=expected, + ) as mock_request: + result = update_integration_action( + chronicle_client, + integration_name="test-integration", + action_id="a1", + display_name="New Name", + update_mask="displayName", + ) + + assert result == expected + + mock_request.assert_called_once_with( + chronicle_client, + method="PATCH", + endpoint_path="integrations/test-integration/actions/a1", + api_version=APIVersion.V1BETA, + json={"displayName": "New Name"}, + params={"updateMask": "displayName"}, + ) + + +def test_update_integration_action_auto_update_mask(chronicle_client): + """Test update_integration_action auto-generates updateMask based on fields. + + build_patch_body ordering isn't guaranteed; assert order-insensitively. + """ + expected = {"name": "actions/a1"} + + with patch( + "secops.chronicle.soar.integration.actions.chronicle_request", + return_value=expected, + ) as mock_request: + result = update_integration_action( + chronicle_client, + integration_name="test-integration", + action_id="a1", + enabled=False, + timeout_seconds=300, + ) + + assert result == expected + + # Assert the call happened once and inspect args to avoid ordering issues. + assert mock_request.call_count == 1 + _, kwargs = mock_request.call_args + + assert kwargs["method"] == "PATCH" + assert ( + kwargs["endpoint_path"] + == "integrations/test-integration/actions/a1" + ) + assert kwargs["api_version"] == APIVersion.V1BETA + + assert kwargs["json"] == {"enabled": False, "timeoutSeconds": 300} + + update_mask = kwargs["params"]["updateMask"] + assert set(update_mask.split(",")) == {"enabled", "timeoutSeconds"} + + +def test_update_integration_action_error(chronicle_client): + """Test update_integration_action raises APIError on failure.""" + with patch( + "secops.chronicle.soar.integration.actions.chronicle_request", + side_effect=APIError("Failed to update integration action"), + ): + with pytest.raises(APIError) as exc_info: + update_integration_action( + chronicle_client, + integration_name="test-integration", + action_id="a1", + display_name="New Name", + ) + assert "Failed to update integration action" in str(exc_info.value) + + +# -- test_integration_action tests -- + + +def test_execute_test_integration_action_success(chronicle_client): + """Test test_integration_action issues executeTest POST with correct body.""" + expected = {"output": "ok", "debugOutput": ""} + + with patch( + "secops.chronicle.soar.integration.actions.chronicle_request", + return_value=expected, + ) as mock_request: + action = {"displayName": "My Action", "script": "print('hi')"} + result = execute_integration_action_test( + chronicle_client, + integration_name="test-integration", + test_case_id=123, + action=action, + scope="INTEGRATION_INSTANCE", + integration_instance_id="inst-1", + ) + + assert result == expected + + mock_request.assert_called_once_with( + chronicle_client, + method="POST", + endpoint_path="integrations/test-integration/actions:executeTest", + api_version=APIVersion.V1BETA, + json={ + "testCaseId": 123, + "action": action, + "scope": "INTEGRATION_INSTANCE", + "integrationInstanceId": "inst-1", + }, + ) + + +def test_execute_test_integration_action_error(chronicle_client): + """Test test_integration_action raises APIError on failure.""" + with patch( + "secops.chronicle.soar.integration.actions.chronicle_request", + side_effect=APIError("Failed to test integration action"), + ): + with pytest.raises(APIError) as exc_info: + execute_integration_action_test( + chronicle_client, + integration_name="test-integration", + test_case_id=123, + action={"displayName": "My Action"}, + scope="INTEGRATION_INSTANCE", + integration_instance_id="inst-1", + ) + assert "Failed to test integration action" in str(exc_info.value) + + +# -- get_integration_actions_by_environment tests -- + + +def test_get_integration_actions_by_environment_success(chronicle_client): + """Test get_integration_actions_by_environment issues GET with correct params.""" + expected = {"actions": [{"name": "a1"}]} + + with patch( + "secops.chronicle.soar.integration.actions.chronicle_request", + return_value=expected, + ) as mock_request: + result = get_integration_actions_by_environment( + chronicle_client, + integration_name="test-integration", + environments=["prod", "dev"], + include_widgets=True, + ) + + assert result == expected + + mock_request.assert_called_once_with( + chronicle_client, + method="GET", + endpoint_path="integrations/test-integration/actions:fetchActionsByEnvironment", + api_version=APIVersion.V1BETA, + params={"environments": ["prod", "dev"], "includeWidgets": True}, + ) + + +def test_get_integration_actions_by_environment_error(chronicle_client): + """Test get_integration_actions_by_environment raises APIError on failure.""" + with patch( + "secops.chronicle.soar.integration.actions.chronicle_request", + side_effect=APIError("Failed to fetch actions by environment"), + ): + with pytest.raises(APIError) as exc_info: + get_integration_actions_by_environment( + chronicle_client, + integration_name="test-integration", + environments=["prod"], + include_widgets=False, + ) + assert "Failed to fetch actions by environment" in str(exc_info.value) + + +# -- get_integration_action_template tests -- + + +def test_get_integration_action_template_default_async_false(chronicle_client): + """Test get_integration_action_template uses async=False by default.""" + expected = {"script": "# template"} + + with patch( + "secops.chronicle.soar.integration.actions.chronicle_request", + return_value=expected, + ) as mock_request: + result = get_integration_action_template( + chronicle_client, + integration_name="test-integration", + ) + + assert result == expected + + mock_request.assert_called_once_with( + chronicle_client, + method="GET", + endpoint_path="integrations/test-integration/actions:fetchTemplate", + api_version=APIVersion.V1BETA, + params={"async": False}, + ) + + +def test_get_integration_action_template_async_true(chronicle_client): + """Test get_integration_action_template with is_async=True.""" + expected = {"script": "# async template"} + + with patch( + "secops.chronicle.soar.integration.actions.chronicle_request", + return_value=expected, + ) as mock_request: + result = get_integration_action_template( + chronicle_client, + integration_name="test-integration", + is_async=True, + ) + + assert result == expected + + mock_request.assert_called_once_with( + chronicle_client, + method="GET", + endpoint_path="integrations/test-integration/actions:fetchTemplate", + api_version=APIVersion.V1BETA, + params={"async": True}, + ) + + +def test_get_integration_action_template_error(chronicle_client): + """Test get_integration_action_template raises APIError on failure.""" + with patch( + "secops.chronicle.soar.integration.actions.chronicle_request", + side_effect=APIError("Failed to fetch action template"), + ): + with pytest.raises(APIError) as exc_info: + get_integration_action_template( + chronicle_client, + integration_name="test-integration", + ) + assert "Failed to fetch action template" in str(exc_info.value) diff --git a/tests/chronicle/soar/integration/test_manager_revisions.py b/tests/chronicle/soar/integration/test_manager_revisions.py new file mode 100644 index 0000000..260406d --- /dev/null +++ b/tests/chronicle/soar/integration/test_manager_revisions.py @@ -0,0 +1,424 @@ +# Copyright 2025 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. +# +"""Tests for Chronicle marketplace integration manager revisions functions.""" + +from unittest.mock import Mock, patch + +import pytest + +from secops.chronicle.client import ChronicleClient +from secops.chronicle.models import APIVersion +from secops.chronicle.soar.integration.manager_revisions import ( + list_integration_manager_revisions, + get_integration_manager_revision, + delete_integration_manager_revision, + create_integration_manager_revision, + rollback_integration_manager_revision, +) +from secops.exceptions import APIError + + +@pytest.fixture +def chronicle_client(): + """Create a Chronicle client for testing.""" + with patch("secops.auth.SecOpsAuth") as mock_auth: + mock_session = Mock() + mock_session.headers = {} + mock_auth.return_value.session = mock_session + return ChronicleClient( + customer_id="test-customer", + project_id="test-project", + default_api_version=APIVersion.V1BETA, + ) + + +# -- list_integration_manager_revisions tests -- + + +def test_list_integration_manager_revisions_success(chronicle_client): + """Test list_integration_manager_revisions delegates to chronicle_paginated_request.""" + expected = { + "revisions": [{"name": "r1"}, {"name": "r2"}], + "nextPageToken": "t", + } + + with ( + patch( + "secops.chronicle.soar.integration.manager_revisions.chronicle_paginated_request", + return_value=expected, + ) as mock_paginated, + patch( + "secops.chronicle.soar.integration.manager_revisions.format_resource_id", + return_value="My Integration", + ), + ): + result = list_integration_manager_revisions( + chronicle_client, + integration_name="My Integration", + manager_id="m1", + page_size=10, + page_token="next-token", + ) + + assert result == expected + + _, kwargs = mock_paginated.call_args + assert "managers/m1/revisions" in kwargs["path"] + assert kwargs["items_key"] == "revisions" + assert kwargs["page_size"] == 10 + assert kwargs["page_token"] == "next-token" + + +def test_list_integration_manager_revisions_default_args(chronicle_client): + """Test list_integration_manager_revisions with default args.""" + expected = {"revisions": []} + + with patch( + "secops.chronicle.soar.integration.manager_revisions.chronicle_paginated_request", + return_value=expected, + ) as mock_paginated: + result = list_integration_manager_revisions( + chronicle_client, + integration_name="test-integration", + manager_id="m1", + ) + + assert result == expected + + +def test_list_integration_manager_revisions_with_filters(chronicle_client): + """Test list_integration_manager_revisions with filter and order_by.""" + expected = {"revisions": [{"name": "r1"}]} + + with patch( + "secops.chronicle.soar.integration.manager_revisions.chronicle_paginated_request", + return_value=expected, + ) as mock_paginated: + result = list_integration_manager_revisions( + chronicle_client, + integration_name="test-integration", + manager_id="m1", + filter_string='version = "1.0"', + order_by="createTime", + ) + + assert result == expected + + _, kwargs = mock_paginated.call_args + assert kwargs["extra_params"] == { + "filter": 'version = "1.0"', + "orderBy": "createTime", + } + + +def test_list_integration_manager_revisions_as_list(chronicle_client): + """Test list_integration_manager_revisions returns list when as_list=True.""" + expected = [{"name": "r1"}, {"name": "r2"}] + + with patch( + "secops.chronicle.soar.integration.manager_revisions.chronicle_paginated_request", + return_value=expected, + ) as mock_paginated: + result = list_integration_manager_revisions( + chronicle_client, + integration_name="test-integration", + manager_id="m1", + as_list=True, + ) + + assert result == expected + + _, kwargs = mock_paginated.call_args + assert kwargs["as_list"] is True + + +def test_list_integration_manager_revisions_error(chronicle_client): + """Test list_integration_manager_revisions raises APIError on failure.""" + with patch( + "secops.chronicle.soar.integration.manager_revisions.chronicle_paginated_request", + side_effect=APIError("Failed to list manager revisions"), + ): + with pytest.raises(APIError) as exc_info: + list_integration_manager_revisions( + chronicle_client, + integration_name="test-integration", + manager_id="m1", + ) + assert "Failed to list manager revisions" in str(exc_info.value) + + +# -- get_integration_manager_revision tests -- + + +def test_get_integration_manager_revision_success(chronicle_client): + """Test get_integration_manager_revision issues GET request.""" + expected = { + "name": "revisions/r1", + "manager": { + "displayName": "My Manager", + "script": "def helper(): pass", + }, + "comment": "Initial version", + } + + with patch( + "secops.chronicle.soar.integration.manager_revisions.chronicle_request", + return_value=expected, + ) as mock_request: + result = get_integration_manager_revision( + chronicle_client, + integration_name="test-integration", + manager_id="m1", + revision_id="r1", + ) + + assert result == expected + + _, kwargs = mock_request.call_args + assert kwargs["method"] == "GET" + assert "managers/m1/revisions/r1" in kwargs["endpoint_path"] + assert kwargs["api_version"] == APIVersion.V1BETA + + +def test_get_integration_manager_revision_error(chronicle_client): + """Test get_integration_manager_revision raises APIError on failure.""" + with patch( + "secops.chronicle.soar.integration.manager_revisions.chronicle_request", + side_effect=APIError("Failed to get manager revision"), + ): + with pytest.raises(APIError) as exc_info: + get_integration_manager_revision( + chronicle_client, + integration_name="test-integration", + manager_id="m1", + revision_id="r1", + ) + assert "Failed to get manager revision" in str(exc_info.value) + + +# -- delete_integration_manager_revision tests -- + + +def test_delete_integration_manager_revision_success(chronicle_client): + """Test delete_integration_manager_revision issues DELETE request.""" + with patch( + "secops.chronicle.soar.integration.manager_revisions.chronicle_request", + return_value=None, + ) as mock_request: + delete_integration_manager_revision( + chronicle_client, + integration_name="test-integration", + manager_id="m1", + revision_id="r1", + ) + + _, kwargs = mock_request.call_args + assert kwargs["method"] == "DELETE" + assert "managers/m1/revisions/r1" in kwargs["endpoint_path"] + assert kwargs["api_version"] == APIVersion.V1BETA + + +def test_delete_integration_manager_revision_error(chronicle_client): + """Test delete_integration_manager_revision raises APIError on failure.""" + with patch( + "secops.chronicle.soar.integration.manager_revisions.chronicle_request", + side_effect=APIError("Failed to delete manager revision"), + ): + with pytest.raises(APIError) as exc_info: + delete_integration_manager_revision( + chronicle_client, + integration_name="test-integration", + manager_id="m1", + revision_id="r1", + ) + assert "Failed to delete manager revision" in str(exc_info.value) + + +# -- create_integration_manager_revision tests -- + + +def test_create_integration_manager_revision_required_fields_only( + chronicle_client, +): + """Test create_integration_manager_revision with required fields only.""" + expected = { + "name": "revisions/new", + "manager": {"displayName": "My Manager"}, + } + manager_dict = { + "displayName": "My Manager", + "script": "def helper(): pass", + } + + with patch( + "secops.chronicle.soar.integration.manager_revisions.chronicle_request", + return_value=expected, + ) as mock_request: + result = create_integration_manager_revision( + chronicle_client, + integration_name="test-integration", + manager_id="m1", + manager=manager_dict, + ) + + assert result == expected + + mock_request.assert_called_once_with( + chronicle_client, + method="POST", + endpoint_path=( + "integrations/test-integration/managers/m1/revisions" + ), + api_version=APIVersion.V1BETA, + json={"manager": manager_dict}, + ) + + +def test_create_integration_manager_revision_with_comment(chronicle_client): + """Test create_integration_manager_revision includes comment when provided.""" + expected = {"name": "revisions/new"} + manager_dict = {"displayName": "My Manager", "script": "def helper(): pass"} + + with patch( + "secops.chronicle.soar.integration.manager_revisions.chronicle_request", + return_value=expected, + ) as mock_request: + result = create_integration_manager_revision( + chronicle_client, + integration_name="test-integration", + manager_id="m1", + manager=manager_dict, + comment="Backup before major refactor", + ) + + assert result == expected + + _, kwargs = mock_request.call_args + assert kwargs["json"]["comment"] == "Backup before major refactor" + assert kwargs["json"]["manager"] == manager_dict + + +def test_create_integration_manager_revision_error(chronicle_client): + """Test create_integration_manager_revision raises APIError on failure.""" + manager_dict = {"displayName": "My Manager", "script": "def helper(): pass"} + + with patch( + "secops.chronicle.soar.integration.manager_revisions.chronicle_request", + side_effect=APIError("Failed to create manager revision"), + ): + with pytest.raises(APIError) as exc_info: + create_integration_manager_revision( + chronicle_client, + integration_name="test-integration", + manager_id="m1", + manager=manager_dict, + ) + assert "Failed to create manager revision" in str(exc_info.value) + + +# -- rollback_integration_manager_revision tests -- + + +def test_rollback_integration_manager_revision_success(chronicle_client): + """Test rollback_integration_manager_revision issues POST request.""" + expected = { + "name": "revisions/r1", + "manager": { + "displayName": "My Manager", + "script": "def helper(): pass", + }, + } + + with patch( + "secops.chronicle.soar.integration.manager_revisions.chronicle_request", + return_value=expected, + ) as mock_request: + result = rollback_integration_manager_revision( + chronicle_client, + integration_name="test-integration", + manager_id="m1", + revision_id="r1", + ) + + assert result == expected + + _, kwargs = mock_request.call_args + assert kwargs["method"] == "POST" + assert "managers/m1/revisions/r1:rollback" in kwargs["endpoint_path"] + assert kwargs["api_version"] == APIVersion.V1BETA + + +def test_rollback_integration_manager_revision_error(chronicle_client): + """Test rollback_integration_manager_revision raises APIError on failure.""" + with patch( + "secops.chronicle.soar.integration.manager_revisions.chronicle_request", + side_effect=APIError("Failed to rollback manager revision"), + ): + with pytest.raises(APIError) as exc_info: + rollback_integration_manager_revision( + chronicle_client, + integration_name="test-integration", + manager_id="m1", + revision_id="r1", + ) + assert "Failed to rollback manager revision" in str(exc_info.value) + + +# -- API version tests -- + + +def test_list_integration_manager_revisions_custom_api_version( + chronicle_client, +): + """Test list_integration_manager_revisions with custom API version.""" + expected = {"revisions": []} + + with patch( + "secops.chronicle.soar.integration.manager_revisions.chronicle_paginated_request", + return_value=expected, + ) as mock_paginated: + result = list_integration_manager_revisions( + chronicle_client, + integration_name="test-integration", + manager_id="m1", + api_version=APIVersion.V1ALPHA, + ) + + assert result == expected + + _, kwargs = mock_paginated.call_args + assert kwargs["api_version"] == APIVersion.V1ALPHA + + +def test_get_integration_manager_revision_custom_api_version(chronicle_client): + """Test get_integration_manager_revision with custom API version.""" + expected = {"name": "revisions/r1"} + + with patch( + "secops.chronicle.soar.integration.manager_revisions.chronicle_request", + return_value=expected, + ) as mock_request: + result = get_integration_manager_revision( + chronicle_client, + integration_name="test-integration", + manager_id="m1", + revision_id="r1", + api_version=APIVersion.V1ALPHA, + ) + + assert result == expected + + _, kwargs = mock_request.call_args + assert kwargs["api_version"] == APIVersion.V1ALPHA diff --git a/tests/chronicle/soar/integration/test_manager_revisions_integration.py b/tests/chronicle/soar/integration/test_manager_revisions_integration.py new file mode 100644 index 0000000..c2770e6 --- /dev/null +++ b/tests/chronicle/soar/integration/test_manager_revisions_integration.py @@ -0,0 +1,183 @@ +# Copyright 2025 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. +# +"""Integration tests for Chronicle integration manager revisions. + +These tests require valid credentials and API access. +""" + +import time +import uuid + +import pytest + +from secops import SecOpsClient +from secops.exceptions import APIError +from tests.config import CHRONICLE_CONFIG, SERVICE_ACCOUNT_JSON + + +_MANAGER_SCRIPT = ( + "def helper_function():\n" " return 'Integration test manager helper'\n" +) + + +@pytest.mark.integration +def test_manager_revisions_workflow(): + """Test full manager revision lifecycle: create, list, rollback, delete. + + TODO: Remove 401 skip logic once SOAR IAM role issue is fixed. + """ + client = SecOpsClient(service_account_info=SERVICE_ACCOUNT_JSON) + chronicle = client.chronicle(**CHRONICLE_CONFIG) + + integration_name = None + manager_id = None + revision_id = None + + try: + print("\n1. Finding a target integration") + integrations_resp = chronicle.soar.list_integrations(page_size=5) + integrations = integrations_resp.get("integrations", []) + if not integrations: + pytest.skip("No integrations available to test manager revisions.") + + integration_name = integrations[0]["name"].split("/")[-1] + print(f"Using integration: {integration_name}") + + print("\n2. Creating a temporary custom manager") + unique_id = str(uuid.uuid4())[:8] + display_name = f"SDK Test Manager {unique_id}" + + create_manager_resp = chronicle.soar.create_integration_manager( + integration_name=integration_name, + display_name=display_name, + script=_MANAGER_SCRIPT, + description="Temporary manager created by integration test", + ) + + assert create_manager_resp is not None + assert "name" in create_manager_resp + manager_id = create_manager_resp["name"].split("/")[-1] + print(f"Created manager with ID: {manager_id}") + + time.sleep(2) + + print("\n3. Getting manager details for snapshotting") + manager = chronicle.soar.get_integration_manager( + integration_name=integration_name, + manager_id=manager_id, + ) + assert manager is not None + assert "name" in manager + print(f"Retrieved manager: {manager.get('displayName')}") + + print("\n4. Creating a revision of the manager") + revision_comment = f"SDK test revision {unique_id}" + create_rev_resp = chronicle.soar.create_integration_manager_revision( + integration_name=integration_name, + manager_id=manager_id, + manager=manager, + comment=revision_comment, + ) + + assert create_rev_resp is not None + assert "name" in create_rev_resp + revision_id = create_rev_resp["name"].split("/")[-1] + print(f"Created revision with ID: {revision_id}") + + time.sleep(1) + + print("\n5. Listing revisions and verifying the new revision appears") + list_resp = chronicle.soar.list_integration_manager_revisions( + integration_name=integration_name, + manager_id=manager_id, + ) + + revisions = list_resp.get("revisions", []) + assert isinstance(revisions, list) + found = any(r.get("name", "").endswith(revision_id) for r in revisions) + assert found, f"Created revision {revision_id} not found in list." + print(f"Found revision in list ({len(revisions)} total)") + + print("\n5a. Listing revisions with as_list=True") + revisions_list = chronicle.soar.list_integration_manager_revisions( + integration_name=integration_name, + manager_id=manager_id, + as_list=True, + ) + assert isinstance(revisions_list, list) + print(f"as_list returned {len(revisions_list)} revisions") + + print("\n6. Getting the revision details") + get_rev_resp = chronicle.soar.get_integration_manager_revision( + integration_name=integration_name, + manager_id=manager_id, + revision_id=revision_id, + ) + assert get_rev_resp is not None + assert "displayName" in get_rev_resp.get("snapshot") + print( + f"Retrieved revision: {get_rev_resp.get('snapshot').get('displayName')}" + ) + + print("\n7. Rolling back manager to the created revision") + rollback_resp = chronicle.soar.rollback_integration_manager_revision( + integration_name=integration_name, + manager_id=manager_id, + revision_id=revision_id, + ) + assert rollback_resp is not None + print(f"Rollback successful: {rollback_resp.get('name')}") + + time.sleep(1) + + print("\n8. Deleting the revision") + chronicle.soar.delete_integration_manager_revision( + integration_name=integration_name, + manager_id=manager_id, + revision_id=revision_id, + ) + revision_id = None + print("Revision deleted successfully") + + except APIError as e: + error_msg = str(e) + if "401" in error_msg or "Unauthorized" in error_msg: + pytest.skip(f"Skipping due to SOAR IAM issue (401): {e}") + raise + finally: + print("\n9. Cleaning up") + if integration_name and revision_id: + try: + chronicle.soar.delete_integration_manager_revision( + integration_name=integration_name, + manager_id=manager_id, + revision_id=revision_id, + ) + print(f"Cleaned up revision: {revision_id}") + except Exception as cleanup_error: + print( + f"Warning: Failed to delete test revision: {cleanup_error}" + ) + if integration_name and manager_id: + try: + chronicle.soar.delete_integration_manager( + integration_name=integration_name, + manager_id=manager_id, + ) + print(f"Cleaned up manager: {manager_id}") + except Exception as cleanup_error: + print( + f"Warning: Failed to delete test manager: {cleanup_error}" + ) diff --git a/tests/chronicle/soar/integration/test_managers.py b/tests/chronicle/soar/integration/test_managers.py new file mode 100644 index 0000000..95c8f68 --- /dev/null +++ b/tests/chronicle/soar/integration/test_managers.py @@ -0,0 +1,485 @@ +# Copyright 2025 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. +# +"""Tests for Chronicle marketplace integration managers functions.""" + +from unittest.mock import Mock, patch + +import pytest + +from secops.chronicle.client import ChronicleClient +from secops.chronicle.models import APIVersion +from secops.chronicle.soar.integration.managers import ( + list_integration_managers, + get_integration_manager, + delete_integration_manager, + create_integration_manager, + update_integration_manager, + get_integration_manager_template, +) +from secops.exceptions import APIError + + +@pytest.fixture +def chronicle_client(): + """Create a Chronicle client for testing.""" + with patch("secops.auth.SecOpsAuth") as mock_auth: + mock_session = Mock() + mock_session.headers = {} + mock_auth.return_value.session = mock_session + return ChronicleClient( + customer_id="test-customer", + project_id="test-project", + default_api_version=APIVersion.V1BETA, + ) + + +# -- list_integration_managers tests -- + + +def test_list_integration_managers_success(chronicle_client): + """Test list_integration_managers delegates to chronicle_paginated_request.""" + expected = { + "managers": [{"name": "m1"}, {"name": "m2"}], + "nextPageToken": "t", + } + + with ( + patch( + "secops.chronicle.soar.integration.managers.chronicle_paginated_request", + return_value=expected, + ) as mock_paginated, + patch( + "secops.chronicle.soar.integration.managers.format_resource_id", + return_value="My Integration", + ), + ): + result = list_integration_managers( + chronicle_client, + integration_name="My Integration", + page_size=10, + page_token="next-token", + ) + + assert result == expected + + mock_paginated.assert_called_once_with( + chronicle_client, + api_version=APIVersion.V1BETA, + path="integrations/My Integration/managers", + items_key="managers", + page_size=10, + page_token="next-token", + extra_params={}, + as_list=False, + ) + + +def test_list_integration_managers_default_args(chronicle_client): + """Test list_integration_managers with default args.""" + expected = {"managers": []} + + with patch( + "secops.chronicle.soar.integration.managers.chronicle_paginated_request", + return_value=expected, + ) as mock_paginated: + result = list_integration_managers( + chronicle_client, + integration_name="test-integration", + ) + + assert result == expected + + +def test_list_integration_managers_with_filters(chronicle_client): + """Test list_integration_managers with filter and order_by.""" + expected = {"managers": [{"name": "m1"}]} + + with patch( + "secops.chronicle.soar.integration.managers.chronicle_paginated_request", + return_value=expected, + ) as mock_paginated: + result = list_integration_managers( + chronicle_client, + integration_name="test-integration", + filter_string='displayName = "My Manager"', + order_by="displayName", + ) + + assert result == expected + + _, kwargs = mock_paginated.call_args + assert kwargs["extra_params"] == { + "filter": 'displayName = "My Manager"', + "orderBy": "displayName", + } + + +def test_list_integration_managers_as_list(chronicle_client): + """Test list_integration_managers returns list when as_list=True.""" + expected = [{"name": "m1"}, {"name": "m2"}] + + with patch( + "secops.chronicle.soar.integration.managers.chronicle_paginated_request", + return_value=expected, + ) as mock_paginated: + result = list_integration_managers( + chronicle_client, + integration_name="test-integration", + as_list=True, + ) + + assert result == expected + + _, kwargs = mock_paginated.call_args + assert kwargs["as_list"] is True + + +def test_list_integration_managers_error(chronicle_client): + """Test list_integration_managers raises APIError on failure.""" + with patch( + "secops.chronicle.soar.integration.managers.chronicle_paginated_request", + side_effect=APIError("Failed to list integration managers"), + ): + with pytest.raises(APIError) as exc_info: + list_integration_managers( + chronicle_client, + integration_name="test-integration", + ) + assert "Failed to list integration managers" in str(exc_info.value) + + +# -- get_integration_manager tests -- + + +def test_get_integration_manager_success(chronicle_client): + """Test get_integration_manager issues GET request.""" + expected = { + "name": "managers/m1", + "displayName": "My Manager", + "script": "def helper(): pass", + } + + with patch( + "secops.chronicle.soar.integration.managers.chronicle_request", + return_value=expected, + ) as mock_request: + result = get_integration_manager( + chronicle_client, + integration_name="test-integration", + manager_id="m1", + ) + + assert result == expected + + mock_request.assert_called_once_with( + chronicle_client, + method="GET", + endpoint_path="integrations/test-integration/managers/m1", + api_version=APIVersion.V1BETA, + ) + + +def test_get_integration_manager_error(chronicle_client): + """Test get_integration_manager raises APIError on failure.""" + with patch( + "secops.chronicle.soar.integration.managers.chronicle_request", + side_effect=APIError("Failed to get integration manager"), + ): + with pytest.raises(APIError) as exc_info: + get_integration_manager( + chronicle_client, + integration_name="test-integration", + manager_id="m1", + ) + assert "Failed to get integration manager" in str(exc_info.value) + + +# -- delete_integration_manager tests -- + + +def test_delete_integration_manager_success(chronicle_client): + """Test delete_integration_manager issues DELETE request.""" + with patch( + "secops.chronicle.soar.integration.managers.chronicle_request", + return_value=None, + ) as mock_request: + delete_integration_manager( + chronicle_client, + integration_name="test-integration", + manager_id="m1", + ) + + mock_request.assert_called_once_with( + chronicle_client, + method="DELETE", + endpoint_path="integrations/test-integration/managers/m1", + api_version=APIVersion.V1BETA, + ) + + +def test_delete_integration_manager_error(chronicle_client): + """Test delete_integration_manager raises APIError on failure.""" + with patch( + "secops.chronicle.soar.integration.managers.chronicle_request", + side_effect=APIError("Failed to delete integration manager"), + ): + with pytest.raises(APIError) as exc_info: + delete_integration_manager( + chronicle_client, + integration_name="test-integration", + manager_id="m1", + ) + assert "Failed to delete integration manager" in str(exc_info.value) + + +# -- create_integration_manager tests -- + + +def test_create_integration_manager_required_fields_only(chronicle_client): + """Test create_integration_manager sends only required fields when optionals omitted.""" + expected = {"name": "managers/new", "displayName": "My Manager"} + + with patch( + "secops.chronicle.soar.integration.managers.chronicle_request", + return_value=expected, + ) as mock_request: + result = create_integration_manager( + chronicle_client, + integration_name="test-integration", + display_name="My Manager", + script="def helper(): pass", + ) + + assert result == expected + + mock_request.assert_called_once_with( + chronicle_client, + method="POST", + endpoint_path="integrations/test-integration/managers", + api_version=APIVersion.V1BETA, + json={ + "displayName": "My Manager", + "script": "def helper(): pass", + }, + ) + + +def test_create_integration_manager_with_description(chronicle_client): + """Test create_integration_manager includes description when provided.""" + expected = {"name": "managers/new", "displayName": "My Manager"} + + with patch( + "secops.chronicle.soar.integration.managers.chronicle_request", + return_value=expected, + ) as mock_request: + result = create_integration_manager( + chronicle_client, + integration_name="test-integration", + display_name="My Manager", + script="def helper(): pass", + description="A helpful manager", + ) + + assert result == expected + + _, kwargs = mock_request.call_args + assert kwargs["json"]["description"] == "A helpful manager" + + +def test_create_integration_manager_error(chronicle_client): + """Test create_integration_manager raises APIError on failure.""" + with patch( + "secops.chronicle.soar.integration.managers.chronicle_request", + side_effect=APIError("Failed to create integration manager"), + ): + with pytest.raises(APIError) as exc_info: + create_integration_manager( + chronicle_client, + integration_name="test-integration", + display_name="My Manager", + script="def helper(): pass", + ) + assert "Failed to create integration manager" in str(exc_info.value) + + +# -- update_integration_manager tests -- + + +def test_update_integration_manager_single_field(chronicle_client): + """Test update_integration_manager updates a single field.""" + expected = {"name": "managers/m1", "displayName": "Updated Manager"} + + with ( + patch( + "secops.chronicle.soar.integration.managers.chronicle_request", + return_value=expected, + ) as mock_request, + patch( + "secops.chronicle.soar.integration.managers.build_patch_body", + return_value=( + {"displayName": "Updated Manager"}, + {"updateMask": "displayName"}, + ), + ) as mock_build_patch, + ): + result = update_integration_manager( + chronicle_client, + integration_name="test-integration", + manager_id="m1", + display_name="Updated Manager", + ) + + assert result == expected + + mock_build_patch.assert_called_once() + mock_request.assert_called_once_with( + chronicle_client, + method="PATCH", + endpoint_path="integrations/test-integration/managers/m1", + api_version=APIVersion.V1BETA, + json={"displayName": "Updated Manager"}, + params={"updateMask": "displayName"}, + ) + + +def test_update_integration_manager_multiple_fields(chronicle_client): + """Test update_integration_manager updates multiple fields.""" + expected = {"name": "managers/m1", "displayName": "Updated Manager"} + + with ( + patch( + "secops.chronicle.soar.integration.managers.chronicle_request", + return_value=expected, + ) as mock_request, + patch( + "secops.chronicle.soar.integration.managers.build_patch_body", + return_value=( + { + "displayName": "Updated Manager", + "script": "def new_helper(): pass", + "description": "New description", + }, + {"updateMask": "displayName,script,description"}, + ), + ), + ): + result = update_integration_manager( + chronicle_client, + integration_name="test-integration", + manager_id="m1", + display_name="Updated Manager", + script="def new_helper(): pass", + description="New description", + ) + + assert result == expected + + +def test_update_integration_manager_with_update_mask(chronicle_client): + """Test update_integration_manager respects explicit update_mask.""" + expected = {"name": "managers/m1", "displayName": "Updated Manager"} + + with ( + patch( + "secops.chronicle.soar.integration.managers.chronicle_request", + return_value=expected, + ) as mock_request, + patch( + "secops.chronicle.soar.integration.managers.build_patch_body", + return_value=( + {"displayName": "Updated Manager"}, + {"updateMask": "displayName"}, + ), + ), + ): + result = update_integration_manager( + chronicle_client, + integration_name="test-integration", + manager_id="m1", + display_name="Updated Manager", + update_mask="displayName", + ) + + assert result == expected + + +def test_update_integration_manager_error(chronicle_client): + """Test update_integration_manager raises APIError on failure.""" + with ( + patch( + "secops.chronicle.soar.integration.managers.chronicle_request", + side_effect=APIError("Failed to update integration manager"), + ), + patch( + "secops.chronicle.soar.integration.managers.build_patch_body", + return_value=( + {"displayName": "Updated"}, + {"updateMask": "displayName"}, + ), + ), + ): + with pytest.raises(APIError) as exc_info: + update_integration_manager( + chronicle_client, + integration_name="test-integration", + manager_id="m1", + display_name="Updated", + ) + assert "Failed to update integration manager" in str(exc_info.value) + + +# -- get_integration_manager_template tests -- + + +def test_get_integration_manager_template_success(chronicle_client): + """Test get_integration_manager_template issues GET request.""" + expected = { + "displayName": "Template Manager", + "script": "# Template script\ndef template(): pass", + } + + with patch( + "secops.chronicle.soar.integration.managers.chronicle_request", + return_value=expected, + ) as mock_request: + result = get_integration_manager_template( + chronicle_client, + integration_name="test-integration", + ) + + assert result == expected + + mock_request.assert_called_once_with( + chronicle_client, + method="GET", + endpoint_path="integrations/test-integration/managers:fetchTemplate", + api_version=APIVersion.V1BETA, + ) + + +def test_get_integration_manager_template_error(chronicle_client): + """Test get_integration_manager_template raises APIError on failure.""" + with patch( + "secops.chronicle.soar.integration.managers.chronicle_request", + side_effect=APIError("Failed to get integration manager template"), + ): + with pytest.raises(APIError) as exc_info: + get_integration_manager_template( + chronicle_client, + integration_name="test-integration", + ) + assert "Failed to get integration manager template" in str( + exc_info.value + ) diff --git a/tests/chronicle/soar/integration/test_managers_integration.py b/tests/chronicle/soar/integration/test_managers_integration.py new file mode 100644 index 0000000..c4104ba --- /dev/null +++ b/tests/chronicle/soar/integration/test_managers_integration.py @@ -0,0 +1,138 @@ +# Copyright 2025 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. +# +"""Integration tests for Chronicle integration managers. + +These tests require valid credentials and API access. +""" + +import time +import uuid + +import pytest + +from secops import SecOpsClient +from secops.exceptions import APIError +from tests.config import CHRONICLE_CONFIG, SERVICE_ACCOUNT_JSON + + +_MANAGER_SCRIPT = ( + "def helper_function():\n" " return 'Integration test manager helper'\n" +) + + +@pytest.mark.integration +def test_integration_managers_workflow(): + """Test full manager lifecycle: template, create, get, list, update, delete. + + TODO: Remove 401 skip logic once SOAR IAM role issue is fixed. + """ + client = SecOpsClient(service_account_info=SERVICE_ACCOUNT_JSON) + chronicle = client.chronicle(**CHRONICLE_CONFIG) + + integration_name = None + manager_id = None + + try: + print("\n1. Finding a target integration") + integrations_resp = chronicle.soar.list_integrations(page_size=5) + integrations = integrations_resp.get("integrations", []) + if not integrations: + pytest.skip("No integrations available to test manager workflow.") + + integration_name = integrations[0]["name"].split("/")[-1] + print(f"Using integration: {integration_name}") + + print("\n2. Getting manager template") + template_resp = chronicle.soar.get_integration_manager_template( + integration_name=integration_name, + ) + assert template_resp is not None + print("Retrieved manager template successfully") + + print("\n3. Creating a custom manager") + unique_id = str(uuid.uuid4())[:8] + display_name = f"SDK Test Manager {unique_id}" + + create_resp = chronicle.soar.create_integration_manager( + integration_name=integration_name, + display_name=display_name, + script=_MANAGER_SCRIPT, + description="Temporary manager created by integration test", + ) + + assert create_resp is not None + assert "name" in create_resp + manager_id = create_resp["name"].split("/")[-1] + print(f"Created manager with ID: {manager_id}") + + time.sleep(2) + + print("\n4. Getting manager details") + get_resp = chronicle.soar.get_integration_manager( + integration_name=integration_name, + manager_id=manager_id, + ) + assert get_resp is not None + assert "name" in get_resp + assert get_resp.get("displayName") == display_name + print(f"Retrieved manager: {get_resp.get('displayName')}") + + print("\n5. Listing managers and verifying created manager appears") + list_resp = chronicle.soar.list_integration_managers( + integration_name=integration_name, + ) + managers = list_resp.get("managers", []) + assert isinstance(managers, list) + found = any(m.get("name", "").endswith(manager_id) for m in managers) + assert found, f"Created manager {manager_id} not found in list." + print(f"Found manager in list ({len(managers)} total)") + + print("\n5a. Listing managers with as_list=True") + managers_list = chronicle.soar.list_integration_managers( + integration_name=integration_name, + as_list=True, + ) + assert isinstance(managers_list, list) + print(f"as_list returned {len(managers_list)} managers") + + print("\n6. Updating the manager") + updated_description = f"Updated by integration test {unique_id}" + update_resp = chronicle.soar.update_integration_manager( + integration_name=integration_name, + manager_id=manager_id, + description=updated_description, + ) + assert update_resp is not None + assert "name" in update_resp + print("Updated manager description successfully") + + except APIError as e: + error_msg = str(e) + if "401" in error_msg or "Unauthorized" in error_msg: + pytest.skip(f"Skipping due to SOAR IAM issue (401): {e}") + raise + finally: + print("\n7. Cleaning up") + if integration_name and manager_id: + try: + chronicle.soar.delete_integration_manager( + integration_name=integration_name, + manager_id=manager_id, + ) + print(f"Cleaned up manager: {manager_id}") + except Exception as cleanup_error: + print( + f"Warning: Failed to delete test manager: {cleanup_error}" + ) diff --git a/tests/cli/soar/test_action_revisions_cli_integration.py b/tests/cli/soar/test_action_revisions_cli_integration.py new file mode 100644 index 0000000..e1d99d5 --- /dev/null +++ b/tests/cli/soar/test_action_revisions_cli_integration.py @@ -0,0 +1,308 @@ +# Copyright 2025 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. +# +"""Integration tests for the SecOps CLI integration action revisions commands.""" + +import json +import subprocess +import time +import uuid + +import pytest + + +AUTH_ERRORS = [ + "401", + "Unauthorized", + "AuthenticationError", + "Failed to get credentials", + "DefaultCredentialsError", +] + +_ACTION_SCRIPT = ( + "from SiemplifyAction import SiemplifyAction\n" + "siemplify = SiemplifyAction()\n" + "siemplify.end('Test action', True)\n" +) + + +@pytest.mark.integration +def test_cli_action_revisions_workflow(cli_env, common_args): + """Test action revision create, list, rollback, and delete via CLI. + + TODO: Remove 401 skip logic once SOAR IAM role issue is fixed. + """ + integration_name = None + action_id = None + revision_id = None + + # Step 1: Find a target integration + print("\n1. Finding a target integration") + list_integrations_cmd = ( + ["secops"] + + common_args + + ["integration", "integrations", "list", "--page-size", "5"] + ) + + list_integrations_result = subprocess.run( + list_integrations_cmd, env=cli_env, capture_output=True, text=True + ) + + if list_integrations_result.returncode != 0: + error_output = ( + list_integrations_result.stderr + list_integrations_result.stdout + ) + if any(err in error_output for err in AUTH_ERRORS): + pytest.skip( + f"Skipping due to SOAR IAM/auth issue: {error_output[:200]}" + ) + pytest.skip(f"Could not fetch integrations: {error_output[:200]}") + + try: + integrations_data = json.loads(list_integrations_result.stdout) + integrations = integrations_data.get("integrations", []) + if not integrations: + pytest.skip("No integrations available to test action revisions.") + integration_name = integrations[0]["name"].split("/")[-1] + print(f"Using integration: {integration_name}") + except (json.JSONDecodeError, KeyError): + pytest.skip("Could not parse integrations response") + + # Step 2: Create a temporary custom action + print("\n2. Creating a temporary custom action") + unique_id = str(uuid.uuid4())[:8] + display_name = f"CLI Test Action {unique_id}" + + create_action_cmd = ( + ["secops"] + + common_args + + [ + "integration", + "actions", + "create", + "--integration-name", + integration_name, + "--display-name", + display_name, + "--code", + _ACTION_SCRIPT, + "--description", + "Temporary action created by CLI integration test", + "--timeout-seconds", + "60", + "--script-result-name", + "result", + ] + ) + + create_action_result = subprocess.run( + create_action_cmd, env=cli_env, capture_output=True, text=True + ) + + if create_action_result.returncode != 0: + error_output = create_action_result.stderr + create_action_result.stdout + if any(err in error_output for err in AUTH_ERRORS): + pytest.skip( + f"Skipping due to SOAR IAM/auth issue: {error_output[:200]}" + ) + pytest.fail(f"Failed to create action: {error_output}") + + try: + create_action_data = json.loads(create_action_result.stdout) + assert "name" in create_action_data + action_id = create_action_data["name"].split("/")[-1] + print(f"Created action with ID: {action_id}") + except (json.JSONDecodeError, KeyError): + pytest.fail("Could not parse create action response") + + time.sleep(2) + + try: + # Step 3: Create a revision of the action + print("\n3. Creating a revision of the action") + revision_comment = f"CLI test revision {unique_id}" + + create_rev_cmd = ( + ["secops"] + + common_args + + [ + "integration", + "action-revisions", + "create", + "--integration-name", + integration_name, + "--action-id", + action_id, + "--comment", + revision_comment, + ] + ) + + create_rev_result = subprocess.run( + create_rev_cmd, env=cli_env, capture_output=True, text=True + ) + assert ( + create_rev_result.returncode == 0 + ), f"Failed to create revision: {create_rev_result.stderr}" + + create_rev_data = json.loads(create_rev_result.stdout) + assert "name" in create_rev_data + revision_id = create_rev_data["name"].split("/")[-1] + print(f"Created revision with ID: {revision_id}") + + time.sleep(1) + + # Step 4: List revisions and verify the new revision appears + print("\n4. Listing revisions") + list_rev_cmd = ( + ["secops"] + + common_args + + [ + "integration", + "action-revisions", + "list", + "--integration-name", + integration_name, + "--action-id", + action_id, + ] + ) + + list_rev_result = subprocess.run( + list_rev_cmd, env=cli_env, capture_output=True, text=True + ) + assert ( + list_rev_result.returncode == 0 + ), f"Failed to list revisions: {list_rev_result.stderr}" + + list_rev_data = json.loads(list_rev_result.stdout) + revisions = list_rev_data.get("revisions", []) + found = any(r.get("name", "").endswith(revision_id) for r in revisions) + assert found, f"Created revision {revision_id} not found in list." + print(f"Found revision in list ({len(revisions)} total)") + + # Step 5: Rollback to the created revision + print("\n5. Rolling back to the created revision") + rollback_cmd = ( + ["secops"] + + common_args + + [ + "integration", + "action-revisions", + "rollback", + "--integration-name", + integration_name, + "--action-id", + action_id, + "--revision-id", + revision_id, + ] + ) + + rollback_result = subprocess.run( + rollback_cmd, env=cli_env, capture_output=True, text=True + ) + assert ( + rollback_result.returncode == 0 + ), f"Failed to rollback revision: {rollback_result.stderr}" + rollback_data = json.loads(rollback_result.stdout) + assert "name" in rollback_data + print(f"Rollback successful: {rollback_data.get('name')}") + + time.sleep(1) + + # Step 6: Delete the revision + print("\n6. Deleting the revision") + delete_rev_cmd = ( + ["secops"] + + common_args + + [ + "integration", + "action-revisions", + "delete", + "--integration-name", + integration_name, + "--action-id", + action_id, + "--revision-id", + revision_id, + ] + ) + + delete_rev_result = subprocess.run( + delete_rev_cmd, env=cli_env, capture_output=True, text=True + ) + assert ( + delete_rev_result.returncode == 0 + ), f"Failed to delete revision: {delete_rev_result.stderr}" + revision_id = None + print("Revision deleted successfully") + + finally: + # Step 7: Cleanup — delete revision if not already deleted, then action + print("\n7. Cleaning up") + if revision_id: + delete_rev_cmd = ( + ["secops"] + + common_args + + [ + "integration", + "action-revisions", + "delete", + "--integration-name", + integration_name, + "--action-id", + action_id, + "--revision-id", + revision_id, + ] + ) + delete_rev_result = subprocess.run( + delete_rev_cmd, env=cli_env, capture_output=True, text=True + ) + if delete_rev_result.returncode == 0: + print(f"Cleaned up revision: {revision_id}") + else: + print( + f"Warning: Failed to delete test revision:" + f" {delete_rev_result.stderr}" + ) + + if action_id: + delete_action_cmd = ( + ["secops"] + + common_args + + [ + "integration", + "actions", + "delete", + "--integration-name", + integration_name, + "--action-id", + action_id, + ] + ) + delete_action_result = subprocess.run( + delete_action_cmd, + env=cli_env, + capture_output=True, + text=True, + ) + if delete_action_result.returncode == 0: + print(f"Cleaned up action: {action_id}") + else: + print( + f"Warning: Failed to delete test action:" + f" {delete_action_result.stderr}" + ) diff --git a/tests/cli/soar/test_actions_cli_integration.py b/tests/cli/soar/test_actions_cli_integration.py new file mode 100644 index 0000000..a6e9035 --- /dev/null +++ b/tests/cli/soar/test_actions_cli_integration.py @@ -0,0 +1,282 @@ +# Copyright 2025 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. +# +"""Integration tests for the SecOps CLI integration actions commands.""" + +import json +import subprocess +import time +import uuid + +import pytest + + +AUTH_ERRORS = [ + "401", + "Unauthorized", + "AuthenticationError", + "Failed to get credentials", + "DefaultCredentialsError", +] + +_ACTION_SCRIPT = ( + "from SiemplifyAction import SiemplifyAction\n" + "siemplify = SiemplifyAction()\n" + "siemplify.end('Test action', True)\n" +) + + +@pytest.mark.integration +def test_cli_actions_workflow(cli_env, common_args): + """Test action template, create, get, list, update, and delete via CLI. + + TODO: Remove 401 skip logic once SOAR IAM role issue is fixed. + """ + integration_name = None + action_id = None + + # Step 1: Find a target integration + print("\n1. Finding a target integration") + list_integrations_cmd = ( + ["secops"] + + common_args + + ["integration", "integrations", "list", "--page-size", "5"] + ) + + list_integrations_result = subprocess.run( + list_integrations_cmd, env=cli_env, capture_output=True, text=True + ) + + if list_integrations_result.returncode != 0: + error_output = ( + list_integrations_result.stderr + list_integrations_result.stdout + ) + if any(err in error_output for err in AUTH_ERRORS): + pytest.skip( + f"Skipping due to SOAR IAM/auth issue: {error_output[:200]}" + ) + pytest.skip(f"Could not fetch integrations: {error_output[:200]}") + + try: + integrations_data = json.loads(list_integrations_result.stdout) + integrations = integrations_data.get("integrations", []) + if not integrations: + pytest.skip("No integrations available to test action workflow.") + integration_name = integrations[0]["name"].split("/")[-1] + print(f"Using integration: {integration_name}") + except (json.JSONDecodeError, KeyError): + pytest.skip("Could not parse integrations response") + + # Step 2: Get action template + print("\n2. Getting action template") + template_cmd = ( + ["secops"] + + common_args + + [ + "integration", + "actions", + "template", + "--integration-name", + integration_name, + ] + ) + + template_result = subprocess.run( + template_cmd, env=cli_env, capture_output=True, text=True + ) + assert ( + template_result.returncode == 0 + ), f"Failed to get action template: {template_result.stderr}" + print("Retrieved action template successfully") + + unique_id = str(uuid.uuid4())[:8] + display_name = f"CLI Test Action {unique_id}" + + try: + # Step 3: Create an action + print("\n3. Creating an action") + create_cmd = ( + ["secops"] + + common_args + + [ + "integration", + "actions", + "create", + "--integration-name", + integration_name, + "--display-name", + display_name, + "--code", + _ACTION_SCRIPT, + "--description", + "Temporary action created by CLI integration test", + "--timeout-seconds", + "60", + "--script-result-name", + "result", + ] + ) + + create_result = subprocess.run( + create_cmd, env=cli_env, capture_output=True, text=True + ) + + if create_result.returncode != 0: + error_output = create_result.stderr + create_result.stdout + if any(err in error_output for err in AUTH_ERRORS): + pytest.skip( + f"Skipping due to SOAR IAM/auth issue: {error_output[:200]}" + ) + pytest.fail(f"Failed to create action: {error_output}") + + create_data = json.loads(create_result.stdout) + assert "name" in create_data + action_id = create_data["name"].split("/")[-1] + print(f"Created action with ID: {action_id}") + + time.sleep(2) + + # Step 4: Get action details + print("\n4. Getting action details") + get_cmd = ( + ["secops"] + + common_args + + [ + "integration", + "actions", + "get", + "--integration-name", + integration_name, + "--action-id", + action_id, + ] + ) + + get_result = subprocess.run( + get_cmd, env=cli_env, capture_output=True, text=True + ) + assert ( + get_result.returncode == 0 + ), f"Failed to get action: {get_result.stderr}" + get_data = json.loads(get_result.stdout) + assert get_data.get("displayName") == display_name + print(f"Retrieved action: {get_data.get('displayName')}") + + # Step 5: List actions and verify created action appears + print("\n5. Listing actions") + list_cmd = ( + ["secops"] + + common_args + + [ + "integration", + "actions", + "list", + "--integration-name", + integration_name, + ] + ) + + list_result = subprocess.run( + list_cmd, env=cli_env, capture_output=True, text=True + ) + assert ( + list_result.returncode == 0 + ), f"Failed to list actions: {list_result.stderr}" + list_data = json.loads(list_result.stdout) + actions = list_data.get("actions", []) + found = any(a.get("name", "").endswith(action_id) for a in actions) + assert found, f"Created action {action_id} not found in list." + print(f"Found action in list ({len(actions)} total)") + + # Step 6: Update the action + print("\n6. Updating the action") + updated_description = f"Updated by CLI integration test {unique_id}" + update_cmd = ( + ["secops"] + + common_args + + [ + "integration", + "actions", + "update", + "--integration-name", + integration_name, + "--action-id", + action_id, + "--description", + updated_description, + ] + ) + + update_result = subprocess.run( + update_cmd, env=cli_env, capture_output=True, text=True + ) + assert ( + update_result.returncode == 0 + ), f"Failed to update action: {update_result.stderr}" + update_data = json.loads(update_result.stdout) + assert "name" in update_data + print("Updated action description successfully") + + # Step 7: Delete the action + print("\n7. Deleting the action") + delete_cmd = ( + ["secops"] + + common_args + + [ + "integration", + "actions", + "delete", + "--integration-name", + integration_name, + "--action-id", + action_id, + ] + ) + + delete_result = subprocess.run( + delete_cmd, env=cli_env, capture_output=True, text=True + ) + assert ( + delete_result.returncode == 0 + ), f"Failed to delete action: {delete_result.stderr}" + action_id = None + print("Action deleted successfully") + + finally: + # Cleanup: delete action if not already deleted + print("\n8. Cleaning up") + if action_id: + delete_cmd = ( + ["secops"] + + common_args + + [ + "integration", + "actions", + "delete", + "--integration-name", + integration_name, + "--action-id", + action_id, + ] + ) + delete_result = subprocess.run( + delete_cmd, env=cli_env, capture_output=True, text=True + ) + if delete_result.returncode == 0: + print(f"Cleaned up action: {action_id}") + else: + print( + f"Warning: Failed to delete test action:" + f" {delete_result.stderr}" + ) diff --git a/tests/cli/soar/test_manager_revisions_cli_integration.py b/tests/cli/soar/test_manager_revisions_cli_integration.py new file mode 100644 index 0000000..048a440 --- /dev/null +++ b/tests/cli/soar/test_manager_revisions_cli_integration.py @@ -0,0 +1,334 @@ +# Copyright 2025 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. +# +"""Integration tests for the SecOps CLI integration manager revisions commands.""" + +import json +import subprocess +import time +import uuid + +import pytest + + +AUTH_ERRORS = [ + "401", + "Unauthorized", + "AuthenticationError", + "Failed to get credentials", + "DefaultCredentialsError", +] + +_MANAGER_SCRIPT = ( + "def helper_function():\n" " return 'Integration test manager helper'\n" +) + + +@pytest.mark.integration +def test_cli_manager_revisions_workflow(cli_env, common_args): + """Test manager revision create, list, rollback, and delete via CLI. + + TODO: Remove 401 skip logic once SOAR IAM role issue is fixed. + """ + integration_name = None + manager_id = None + revision_id = None + + # Step 1: Find a target integration + print("\n1. Finding a target integration") + list_integrations_cmd = ( + ["secops"] + + common_args + + ["integration", "integrations", "list", "--page-size", "5"] + ) + + list_integrations_result = subprocess.run( + list_integrations_cmd, env=cli_env, capture_output=True, text=True + ) + + if list_integrations_result.returncode != 0: + error_output = ( + list_integrations_result.stderr + list_integrations_result.stdout + ) + if any(err in error_output for err in AUTH_ERRORS): + pytest.skip( + f"Skipping due to SOAR IAM/auth issue: {error_output[:200]}" + ) + pytest.skip(f"Could not fetch integrations: {error_output[:200]}") + + try: + integrations_data = json.loads(list_integrations_result.stdout) + integrations = integrations_data.get("integrations", []) + if not integrations: + pytest.skip("No integrations available to test manager revisions.") + integration_name = integrations[0]["name"].split("/")[-1] + print(f"Using integration: {integration_name}") + except (json.JSONDecodeError, KeyError): + pytest.skip("Could not parse integrations response") + + # Step 2: Create a temporary custom manager + print("\n2. Creating a temporary custom manager") + unique_id = str(uuid.uuid4())[:8] + display_name = f"CLI Test Manager {unique_id}" + + create_manager_cmd = ( + ["secops"] + + common_args + + [ + "integration", + "managers", + "create", + "--integration-name", + integration_name, + "--display-name", + display_name, + "--code", + _MANAGER_SCRIPT, + "--description", + "Temporary manager created by CLI integration test", + ] + ) + + create_manager_result = subprocess.run( + create_manager_cmd, env=cli_env, capture_output=True, text=True + ) + + if create_manager_result.returncode != 0: + error_output = ( + create_manager_result.stderr + create_manager_result.stdout + ) + if any(err in error_output for err in AUTH_ERRORS): + pytest.skip( + f"Skipping due to SOAR IAM/auth issue: {error_output[:200]}" + ) + pytest.fail(f"Failed to create manager: {error_output}") + + try: + create_manager_data = json.loads(create_manager_result.stdout) + assert "name" in create_manager_data + manager_id = create_manager_data["name"].split("/")[-1] + print(f"Created manager with ID: {manager_id}") + except (json.JSONDecodeError, KeyError): + pytest.fail("Could not parse create manager response") + + time.sleep(2) + + try: + # Step 3: Create a revision of the manager + print("\n3. Creating a revision of the manager") + revision_comment = f"CLI test revision {unique_id}" + + create_rev_cmd = ( + ["secops"] + + common_args + + [ + "integration", + "manager-revisions", + "create", + "--integration-name", + integration_name, + "--manager-id", + manager_id, + "--comment", + revision_comment, + ] + ) + + create_rev_result = subprocess.run( + create_rev_cmd, env=cli_env, capture_output=True, text=True + ) + assert ( + create_rev_result.returncode == 0 + ), f"Failed to create revision: {create_rev_result.stderr}" + + create_rev_data = json.loads(create_rev_result.stdout) + assert "name" in create_rev_data + revision_id = create_rev_data["name"].split("/")[-1] + print(f"Created revision with ID: {revision_id}") + + time.sleep(1) + + # Step 4: List revisions and verify the new revision appears + print("\n4. Listing revisions") + list_rev_cmd = ( + ["secops"] + + common_args + + [ + "integration", + "manager-revisions", + "list", + "--integration-name", + integration_name, + "--manager-id", + manager_id, + ] + ) + + list_rev_result = subprocess.run( + list_rev_cmd, env=cli_env, capture_output=True, text=True + ) + assert ( + list_rev_result.returncode == 0 + ), f"Failed to list revisions: {list_rev_result.stderr}" + + list_rev_data = json.loads(list_rev_result.stdout) + revisions = list_rev_data.get("revisions", []) + found = any(r.get("name", "").endswith(revision_id) for r in revisions) + assert found, f"Created revision {revision_id} not found in list." + print(f"Found revision in list ({len(revisions)} total)") + + # Step 5: Get the revision details + print("\n5. Getting revision details") + get_rev_cmd = ( + ["secops"] + + common_args + + [ + "integration", + "manager-revisions", + "get", + "--integration-name", + integration_name, + "--manager-id", + manager_id, + "--revision-id", + revision_id, + ] + ) + + get_rev_result = subprocess.run( + get_rev_cmd, env=cli_env, capture_output=True, text=True + ) + assert ( + get_rev_result.returncode == 0 + ), f"Failed to get revision: {get_rev_result.stderr}" + get_rev_data = json.loads(get_rev_result.stdout) + assert "displayName" in get_rev_data.get("snapshot") + print( + f"Retrieved revision: {get_rev_data.get('snapshot').get('displayName')}" + ) + + # Step 6: Rollback to the created revision + print("\n6. Rolling back to the created revision") + rollback_cmd = ( + ["secops"] + + common_args + + [ + "integration", + "manager-revisions", + "rollback", + "--integration-name", + integration_name, + "--manager-id", + manager_id, + "--revision-id", + revision_id, + ] + ) + + rollback_result = subprocess.run( + rollback_cmd, env=cli_env, capture_output=True, text=True + ) + assert ( + rollback_result.returncode == 0 + ), f"Failed to rollback revision: {rollback_result.stderr}" + rollback_data = json.loads(rollback_result.stdout) + assert "name" in rollback_data + print(f"Rollback successful: {rollback_data.get('name')}") + + time.sleep(1) + + # Step 7: Delete the revision + print("\n7. Deleting the revision") + delete_rev_cmd = ( + ["secops"] + + common_args + + [ + "integration", + "manager-revisions", + "delete", + "--integration-name", + integration_name, + "--manager-id", + manager_id, + "--revision-id", + revision_id, + ] + ) + + delete_rev_result = subprocess.run( + delete_rev_cmd, env=cli_env, capture_output=True, text=True + ) + assert ( + delete_rev_result.returncode == 0 + ), f"Failed to delete revision: {delete_rev_result.stderr}" + revision_id = None + print("Revision deleted successfully") + + finally: + # Step 8: Cleanup — delete revision if not already deleted, then manager + print("\n8. Cleaning up") + if revision_id: + delete_rev_cmd = ( + ["secops"] + + common_args + + [ + "integration", + "manager-revisions", + "delete", + "--integration-name", + integration_name, + "--manager-id", + manager_id, + "--revision-id", + revision_id, + ] + ) + delete_rev_result = subprocess.run( + delete_rev_cmd, env=cli_env, capture_output=True, text=True + ) + if delete_rev_result.returncode == 0: + print(f"Cleaned up revision: {revision_id}") + else: + print( + f"Warning: Failed to delete test revision:" + f" {delete_rev_result.stderr}" + ) + + if manager_id: + delete_manager_cmd = ( + ["secops"] + + common_args + + [ + "integration", + "managers", + "delete", + "--integration-name", + integration_name, + "--manager-id", + manager_id, + ] + ) + delete_manager_result = subprocess.run( + delete_manager_cmd, + env=cli_env, + capture_output=True, + text=True, + ) + if delete_manager_result.returncode == 0: + print(f"Cleaned up manager: {manager_id}") + else: + print( + f"Warning: Failed to delete test manager:" + f" {delete_manager_result.stderr}" + ) diff --git a/tests/cli/soar/test_managers_cli_integration.py b/tests/cli/soar/test_managers_cli_integration.py new file mode 100644 index 0000000..0a9b019 --- /dev/null +++ b/tests/cli/soar/test_managers_cli_integration.py @@ -0,0 +1,277 @@ +# Copyright 2025 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. +# +"""Integration tests for the SecOps CLI integration managers commands.""" + +import json +import subprocess +import time +import uuid + +import pytest + + +AUTH_ERRORS = [ + "401", + "Unauthorized", + "AuthenticationError", + "Failed to get credentials", + "DefaultCredentialsError", +] + +_MANAGER_SCRIPT = ( + "def helper_function():\n" + " return 'Integration test manager helper'\n" +) + + +@pytest.mark.integration +def test_cli_managers_workflow(cli_env, common_args): + """Test manager template, create, get, list, update, and delete via CLI. + + TODO: Remove 401 skip logic once SOAR IAM role issue is fixed. + """ + integration_name = None + manager_id = None + + # Step 1: Find a target integration + print("\n1. Finding a target integration") + list_integrations_cmd = ( + ["secops"] + + common_args + + ["integration", "integrations", "list", "--page-size", "5"] + ) + + list_integrations_result = subprocess.run( + list_integrations_cmd, env=cli_env, capture_output=True, text=True + ) + + if list_integrations_result.returncode != 0: + error_output = ( + list_integrations_result.stderr + list_integrations_result.stdout + ) + if any(err in error_output for err in AUTH_ERRORS): + pytest.skip( + f"Skipping due to SOAR IAM/auth issue: {error_output[:200]}" + ) + pytest.skip(f"Could not fetch integrations: {error_output[:200]}") + + try: + integrations_data = json.loads(list_integrations_result.stdout) + integrations = integrations_data.get("integrations", []) + if not integrations: + pytest.skip("No integrations available to test manager workflow.") + integration_name = integrations[0]["name"].split("/")[-1] + print(f"Using integration: {integration_name}") + except (json.JSONDecodeError, KeyError): + pytest.skip("Could not parse integrations response") + + # Step 2: Get manager template + print("\n2. Getting manager template") + template_cmd = ( + ["secops"] + + common_args + + [ + "integration", + "managers", + "template", + "--integration-name", + integration_name, + ] + ) + + template_result = subprocess.run( + template_cmd, env=cli_env, capture_output=True, text=True + ) + assert ( + template_result.returncode == 0 + ), f"Failed to get manager template: {template_result.stderr}" + print("Retrieved manager template successfully") + + unique_id = str(uuid.uuid4())[:8] + display_name = f"CLI Test Manager {unique_id}" + + try: + # Step 3: Create a manager + print("\n3. Creating a manager") + create_cmd = ( + ["secops"] + + common_args + + [ + "integration", + "managers", + "create", + "--integration-name", + integration_name, + "--display-name", + display_name, + "--code", + _MANAGER_SCRIPT, + "--description", + "Temporary manager created by CLI integration test", + ] + ) + + create_result = subprocess.run( + create_cmd, env=cli_env, capture_output=True, text=True + ) + + if create_result.returncode != 0: + error_output = create_result.stderr + create_result.stdout + if any(err in error_output for err in AUTH_ERRORS): + pytest.skip( + f"Skipping due to SOAR IAM/auth issue: {error_output[:200]}" + ) + pytest.fail(f"Failed to create manager: {error_output}") + + create_data = json.loads(create_result.stdout) + assert "name" in create_data + manager_id = create_data["name"].split("/")[-1] + print(f"Created manager with ID: {manager_id}") + + time.sleep(2) + + # Step 4: Get manager details + print("\n4. Getting manager details") + get_cmd = ( + ["secops"] + + common_args + + [ + "integration", + "managers", + "get", + "--integration-name", + integration_name, + "--manager-id", + manager_id, + ] + ) + + get_result = subprocess.run( + get_cmd, env=cli_env, capture_output=True, text=True + ) + assert ( + get_result.returncode == 0 + ), f"Failed to get manager: {get_result.stderr}" + get_data = json.loads(get_result.stdout) + assert get_data.get("displayName") == display_name + print(f"Retrieved manager: {get_data.get('displayName')}") + + # Step 5: List managers and verify created manager appears + print("\n5. Listing managers") + list_cmd = ( + ["secops"] + + common_args + + [ + "integration", + "managers", + "list", + "--integration-name", + integration_name, + ] + ) + + list_result = subprocess.run( + list_cmd, env=cli_env, capture_output=True, text=True + ) + assert ( + list_result.returncode == 0 + ), f"Failed to list managers: {list_result.stderr}" + list_data = json.loads(list_result.stdout) + managers = list_data.get("managers", []) + found = any(m.get("name", "").endswith(manager_id) for m in managers) + assert found, f"Created manager {manager_id} not found in list." + print(f"Found manager in list ({len(managers)} total)") + + # Step 6: Update the manager + print("\n6. Updating the manager") + updated_description = f"Updated by CLI integration test {unique_id}" + update_cmd = ( + ["secops"] + + common_args + + [ + "integration", + "managers", + "update", + "--integration-name", + integration_name, + "--manager-id", + manager_id, + "--description", + updated_description, + ] + ) + + update_result = subprocess.run( + update_cmd, env=cli_env, capture_output=True, text=True + ) + assert ( + update_result.returncode == 0 + ), f"Failed to update manager: {update_result.stderr}" + update_data = json.loads(update_result.stdout) + assert "name" in update_data + print("Updated manager description successfully") + + # Step 7: Delete the manager + print("\n7. Deleting the manager") + delete_cmd = ( + ["secops"] + + common_args + + [ + "integration", + "managers", + "delete", + "--integration-name", + integration_name, + "--manager-id", + manager_id, + ] + ) + + delete_result = subprocess.run( + delete_cmd, env=cli_env, capture_output=True, text=True + ) + assert ( + delete_result.returncode == 0 + ), f"Failed to delete manager: {delete_result.stderr}" + manager_id = None + print("Manager deleted successfully") + + finally: + # Cleanup: delete manager if not already deleted + print("\n8. Cleaning up") + if manager_id: + delete_cmd = ( + ["secops"] + + common_args + + [ + "integration", + "managers", + "delete", + "--integration-name", + integration_name, + "--manager-id", + manager_id, + ] + ) + delete_result = subprocess.run( + delete_cmd, env=cli_env, capture_output=True, text=True + ) + if delete_result.returncode == 0: + print(f"Cleaned up manager: {manager_id}") + else: + print( + f"Warning: Failed to delete test manager:" + f" {delete_result.stderr}" + )