From ca0295f0b32711d5a3efe591ea8b17e9a789c8c8 Mon Sep 17 00:00:00 2001 From: Joey Mizrahi Date: Mon, 24 Aug 2026 15:37:55 +0300 Subject: [PATCH 1/3] claude api module --- .../AnthropicClaude/AnthropicClaude.py | 1232 +--------------- .../AnthropicClaude/AnthropicClaude_test.py | 651 +-------- Packs/AnthropicClaude/ReleaseNotes/1_3_2.md | 6 + Packs/AnthropicClaude/pack_metadata.json | 2 +- .../.pack-ignore | 0 .../.secrets-ignore | 3 + .../AnthropicClaudeStandardConnector.py | 14 + .../AnthropicClaudeStandardConnector.yml | 75 + ...opicClaudeStandardConnector_description.md | 6 + ...AnthropicClaudeStandardConnector_image.png | Bin 0 -> 3974 bytes .../AnthropicClaudeStandardConnector_test.py | 44 + .../README.md | 12 + .../ReleaseNotes/1_0_0.md | 6 + .../pack_metadata.json | 29 + Packs/ApiModules/.secrets-ignore | 2 + .../AnthropicClaudeApiModule.py | 1288 +++++++++++++++++ .../AnthropicClaudeApiModule.yml | 19 + .../AnthropicClaudeApiModule_test.py | 843 +++++++++++ .../AnthropicClaudeApiModule/README.md | 28 + .../test_data/activities_page1.json | 16 + 20 files changed, 2455 insertions(+), 1821 deletions(-) create mode 100644 Packs/AnthropicClaude/ReleaseNotes/1_3_2.md create mode 100644 Packs/AnthropicClaudeStandardConnector/.pack-ignore create mode 100644 Packs/AnthropicClaudeStandardConnector/.secrets-ignore create mode 100644 Packs/AnthropicClaudeStandardConnector/Integrations/AnthropicClaudeStandardConnector/AnthropicClaudeStandardConnector.py create mode 100644 Packs/AnthropicClaudeStandardConnector/Integrations/AnthropicClaudeStandardConnector/AnthropicClaudeStandardConnector.yml create mode 100644 Packs/AnthropicClaudeStandardConnector/Integrations/AnthropicClaudeStandardConnector/AnthropicClaudeStandardConnector_description.md create mode 100644 Packs/AnthropicClaudeStandardConnector/Integrations/AnthropicClaudeStandardConnector/AnthropicClaudeStandardConnector_image.png create mode 100644 Packs/AnthropicClaudeStandardConnector/Integrations/AnthropicClaudeStandardConnector/AnthropicClaudeStandardConnector_test.py create mode 100644 Packs/AnthropicClaudeStandardConnector/README.md create mode 100644 Packs/AnthropicClaudeStandardConnector/ReleaseNotes/1_0_0.md create mode 100644 Packs/AnthropicClaudeStandardConnector/pack_metadata.json create mode 100644 Packs/ApiModules/Scripts/AnthropicClaudeApiModule/AnthropicClaudeApiModule.py create mode 100644 Packs/ApiModules/Scripts/AnthropicClaudeApiModule/AnthropicClaudeApiModule.yml create mode 100644 Packs/ApiModules/Scripts/AnthropicClaudeApiModule/AnthropicClaudeApiModule_test.py create mode 100644 Packs/ApiModules/Scripts/AnthropicClaudeApiModule/README.md create mode 100644 Packs/ApiModules/Scripts/AnthropicClaudeApiModule/test_data/activities_page1.json diff --git a/Packs/AnthropicClaude/Integrations/AnthropicClaude/AnthropicClaude.py b/Packs/AnthropicClaude/Integrations/AnthropicClaude/AnthropicClaude.py index 5d43a132cdb0..de4dd85324e4 100644 --- a/Packs/AnthropicClaude/Integrations/AnthropicClaude/AnthropicClaude.py +++ b/Packs/AnthropicClaude/Integrations/AnthropicClaude/AnthropicClaude.py @@ -1,1238 +1,14 @@ import demistomock as demisto # noqa: F401 from CommonServerPython import * # noqa: F401 +from AnthropicClaudeApiModule import * # noqa: E402 -import requests -import urllib3 -import parse_emails -# Disable insecure warnings -urllib3.disable_warnings() +def main(): + run_anthropic_claude_integration() -""" CONSTANTS """ -DATE_FORMAT = "%Y-%m-%dT%H:%M:%SZ" # ISO8601 format with UTC, default in XSOAR -ANTHROPIC_VERSION = "2023-06-01" -EML_FILE_SUFFIX = ".eml" - -class Config: - """Global static configuration for the Anthropic Compliance API event collector.""" - - # send_events_to_xsiam identifiers (dataset: anthropic_claude_raw). - VENDOR = "anthropic" - PRODUCT = "claude" - - # Activity Feed pagination / fetch budget. - ACTIVITIES_PAGE_SIZE = 5000 # API max page size for the Activity Feed. - MAX_FETCH_CALLS = 10 # API call budget per fetch cycle (5000 x 10 = 50,000 events). - DEFAULT_MAX_EVENTS_PER_FETCH = 50000 - DEFAULT_FETCH_LOOKBACK = "1 minute" # On the first fetch (no last_run), look back this far. - - # Rate-limit / transient-error handling for the Compliance API. - # urllib3 retries with exponential back-off and honors the Retry-After header on 429. - MAX_RETRIES = 3 - BACKOFF_FACTOR = 2 # Sleep ~ BACKOFF_FACTOR * (2 ** (retry - 1)) seconds between attempts. - RETRY_STATUS_CODES = (429, 500, 502, 503, 504) - - # Read-only compliance commands. - DEFAULT_LIST_LIMIT = 50 - - # Documentation links surfaced in user-facing error messages. - COMPLIANCE_KEY_DOCS = "https://platform.claude.com/docs/en/manage-claude/compliance-api-access" - API_KEY_DOCS = "https://console.anthropic.com/keys" - - -class ApiPaths: - """Centralized Anthropic Compliance API endpoint paths (relative to the base URL).""" - - ACTIVITIES = "v1/compliance/activities" - ORGANIZATIONS = "v1/compliance/organizations" - GROUPS = "v1/compliance/groups" - CHATS = "v1/compliance/apps/chats" - PROJECTS = "v1/compliance/apps/projects" - # Flat delete paths (Rev K): a file id / document id is globally unique, so no project scoping. - CHAT_FILES = "v1/compliance/apps/chats/files" - PROJECT_DOCUMENTS = "v1/compliance/apps/projects/documents" - - @classmethod - def organization_users(cls, org_uuid: str) -> str: - return f"{cls.ORGANIZATIONS}/{org_uuid}/users" - - @classmethod - def roles(cls, org_uuid: str) -> str: - return f"{cls.ORGANIZATIONS}/{org_uuid}/roles" - - @classmethod - def role(cls, org_uuid: str, role_id: str) -> str: - return f"{cls.ORGANIZATIONS}/{org_uuid}/roles/{role_id}" - - @classmethod - def role_permissions(cls, org_uuid: str, role_id: str) -> str: - return f"{cls.ORGANIZATIONS}/{org_uuid}/roles/{role_id}/permissions" - - @classmethod - def group(cls, group_id: str) -> str: - return f"{cls.GROUPS}/{group_id}" - - @classmethod - def group_members(cls, group_id: str) -> str: - return f"{cls.GROUPS}/{group_id}/members" - - @classmethod - def chat_messages(cls, chat_id: str) -> str: - return f"{cls.CHATS}/{chat_id}/messages" - - @classmethod - def project(cls, project_id: str) -> str: - return f"{cls.PROJECTS}/{project_id}" - - @classmethod - def project_attachments(cls, project_id: str) -> str: - return f"{cls.PROJECTS}/{project_id}/attachments" - - @classmethod - def project_document(cls, project_id: str, document_id: str) -> str: - return f"{cls.PROJECTS}/{project_id}/documents/{document_id}" - - -CHECK_EMAIL_HEADERS_PROMPT = """ -I have a set of email headers. -Analyze these headers for any potential security issues such as spoofing, phishing attempts, or other malicious activity. -Please identify any suspicious fields, explain why they might be concerning, and suggest any further actions that could be taken \ -to investigate or mitigate these issues. -Additional instructions: {} - -''' -{} -''' - -Please, review each header, highlighting any red flags and explaining the potential risks associated with them. -Make you answer very concise and easily readable, with references to the email headers if there are, otherwise do not refer to \ -hypothetical problems. -""" - -CHECK_EMAIL_BODY_PROMPT = """ -I have this email body that I suspect may contain security risks such as phishing links, suspicious attachments, -or signs of social engineering. Please analyze the content of this email body, identify any elements that may pose security -threats, and explain why these elements are concerning. Also, suggest any steps that could be taken to further verify these risks -or protect against these threats. -{} -''' -{} -''' - -Highlight potential security risks, and explain the implications of such risks. -Make you answer very concise and easily readable, with references to the email body if there are, otherwise do not refer to \ -hypothetical problems. -""" - -CREATE_SOC_EMAIL_TEMPLATE_PROMPT = """ -Based on the details provided in our conversation and any specific instructions you have been given, -create a professional email template suitable for a Security Operations Center (SOC). -The template should be adaptable, clearly structured, and include placeholders for specific incident details, -recommendations for action, and any necessary escalation points. -Please ensure the tone is appropriate for communication within a cybersecurity context. -{} -""" - - -class ArgAndParamNames: - MODEL = "model" - MESSAGE = "message" - RESET_CONVERSATION_HISTORY = "reset_conversation_history" - ENTRY_ID = "entry_id" - ADDITIONAL_INSTRUCTIONS = "additional_instructions" - MAX_TOKENS = "max_tokens" - TEMPERATURE = "temperature" - TOP_P = "top_p" - - -class Roles: - ASSISTANT = "assistant" - USER = "user" - - -class EmailParts: - HEADERS = "headers" - BODY = "body" - - -""" CLIENT CLASS """ - - -class AnthropicClient(BaseClient): - MESSAGES_ENDPOINT = "v1/messages" - - def __init__(self, url: str, api_key: str, model: str, proxy: bool, verify: bool): - super().__init__(base_url=url, proxy=proxy, verify=verify) - - self.api_key = api_key - self.model = model - self.headers = {"x-api-key": self.api_key, "anthropic-version": ANTHROPIC_VERSION, "Content-Type": "application/json"} - - def get_messages(self, chat_context: List[dict[str, str]], completion_params: dict[str, str | None]) -> dict[str, Any]: - """Gets the response to a messages request using the Anthropic API.""" - - # Convert chat context to Anthropic format - messages = [] - for msg in chat_context: - if msg["role"] in [Roles.USER, Roles.ASSISTANT]: - messages.append({"role": msg["role"], "content": msg["content"]}) - - options: Dict[str, Any] = { - ArgAndParamNames.MODEL: self.model, - "messages": messages, - # Anthropic API requires max_tokens to be specified, default to 1024 if not provided - ArgAndParamNames.MAX_TOKENS: 1024, - } - - max_tokens = completion_params.get(ArgAndParamNames.MAX_TOKENS, None) - if max_tokens: - try: - # Ensure max_tokens is a valid integer - options[ArgAndParamNames.MAX_TOKENS] = int(max_tokens) - except (ValueError, TypeError): - # Use default if conversion fails - demisto.debug(f"Could not convert max_tokens value '{max_tokens}' to integer, using default value 1024") - options[ArgAndParamNames.MAX_TOKENS] = 1024 - - temperature = completion_params.get(ArgAndParamNames.TEMPERATURE, None) - if temperature: - options[ArgAndParamNames.TEMPERATURE] = float(temperature) - - top_p = completion_params.get(ArgAndParamNames.TOP_P, None) - if top_p: - options[ArgAndParamNames.TOP_P] = float(top_p) - - demisto.debug(f"anthropic-claude Using options for message: {options=}") - return self._http_request( - method="POST", url_suffix=AnthropicClient.MESSAGES_ENDPOINT, json_data=options, headers=self.headers - ) - - -class ComplianceClient(BaseClient): - """Client for the Anthropic Compliance API (Activity Feed + read-only directory/content endpoints). - - Authenticates with the Compliance Access Key (``sk-ant-api01-...``) via the ``x-api-key`` header. - """ - - def __init__(self, url: str, api_key: str, proxy: bool, verify: bool): - super().__init__(base_url=url, proxy=proxy, verify=verify) - self.api_key = api_key - self.headers = {"accept": "application/json", "x-api-key": self.api_key} - - def http_get(self, url_suffix: str, params: dict[str, Any] | None = None) -> dict[str, Any]: - """Performs an authenticated GET request against a Compliance API endpoint. - - Retries on rate-limit (429) and transient 5xx responses using exponential back-off; the - underlying urllib3 Retry honors the server's ``Retry-After`` header when present. - """ - return self._http_request( - method="GET", - url_suffix=url_suffix, - params=params, - headers=self.headers, - retries=Config.MAX_RETRIES, - backoff_factor=Config.BACKOFF_FACTOR, - status_list_to_retry=list(Config.RETRY_STATUS_CODES), - ) - - def http_delete(self, url_suffix: str) -> requests.Response: - """Performs an authenticated DELETE request against a Compliance API endpoint. - - Returns the raw :class:`requests.Response` so callers can inspect the HTTP status code - directly (a 404 means the resource is already gone, which the delete commands treat as an - idempotent success). A 404 is included in ``ok_codes`` so it does not raise. - - Retries on rate-limit (429) and transient 5xx responses using exponential back-off; the - underlying urllib3 Retry honors the server's ``Retry-After`` header when present. - """ - return self._http_request( - method="DELETE", - url_suffix=url_suffix, - headers=self.headers, - resp_type="response", - ok_codes=(200, 204, 404), - retries=Config.MAX_RETRIES, - backoff_factor=Config.BACKOFF_FACTOR, - status_list_to_retry=list(Config.RETRY_STATUS_CODES), - ) - - def get_activities( - self, - limit: int, - created_at_gte: str | None = None, - created_at_gt: str | None = None, - created_at_lt: str | None = None, - after_id: str | None = None, - activity_types: list[str] | None = None, - ) -> dict[str, Any]: - """Fetches a single page of the Activity Feed (``GET /v1/compliance/activities``).""" - params: dict[str, Any] = {"limit": limit} - if after_id: - params["after_id"] = after_id - else: - # Time-window bounds only apply to the first call of a cycle (cursor takes over afterwards). - if created_at_gte: - params["created_at.gte"] = created_at_gte - if created_at_gt: - params["created_at.gt"] = created_at_gt - if created_at_lt: - params["created_at.lt"] = created_at_lt - if activity_types: - params["activity_types[]"] = activity_types - return self.http_get(ApiPaths.ACTIVITIES, params=params) - - -""" HELPER FUNCTIONS """ - - -def conversation_to_chat_context(conversation: List[dict[str, str]]) -> List[dict[str, str]]: - """A 'Conversation' list that was retrieved from 'demisto.context()' is formatted to be more intuitive for XSOAR users - and is formatted as: [ - {'user': '', 'assistant': ''}, - {'user': '', 'assistant': ''}, - ... - ]. - - The conversational format that is supported by the Anthropic Messages API is a sequence of messages, - labeled with roles: - [ - {'role': 'user', 'content': ''}, - {'role': 'assistant', 'content': ''}, - {'role': 'user', 'content': ''}, - {'role': 'assistant', 'content': ''}, - ... - ] - - Therefore, it has to be transformed. - """ - - chat_context = [] - for element in conversation: - demisto.debug(f"anthropic-claude conversation_to_chat_context reading {element=} from conversation") - chat_context.append({"role": Roles.USER, "content": element.get(Roles.USER, "")}) - chat_context.append({"role": Roles.ASSISTANT, "content": element.get(Roles.ASSISTANT, "")}) - - return chat_context - - -def get_chat_context(reset_conversation_history: bool, message: str) -> List[dict[str, str]]: - """ - Retrieves the existing chat conversation history from the incident context, if exists. - If `reset_conversation_history` is True, or if no conversation history exists, it initializes a new conversation list - with the given message and returns it. - - Args: - reset_conversation_history (bool): Flag to determine whether to reset the existing conversation history. - message (str): The new message to be added to the conversation. - - Returns: - List[Dict[str, str]]: The updated conversation history with the new message appended. - """ - # Retrieve or initialize conversation history based on the context and reset flag - conversation = demisto.context().get("AnthropicClaude", {}).get("Conversation") - - if reset_conversation_history or not conversation: - conversation = [] - demisto.debug("anthropic-claude get_chat_context conversation history reset or initialized as empty.") - else: - demisto.debug( - f"anthropic-claude get_chat_context using conversation history from context:" - f" [type(conversation)={type(conversation)}]{conversation=}" - ) - - # Create the chat context which is suitable with the required format for a 'messages' request. - chat_context = conversation_to_chat_context(conversation) - chat_context.append({"role": Roles.USER, "content": message}) - demisto.debug(f"anthropic-claude get_chat_context updated chat_context with new message: {chat_context=}") - return chat_context - - -def extract_assistant_message(response: dict[str, Any]) -> str: - """ - Extracts the assistant message from a response. - Returns: - The assistant message as a string. - """ - if not response: - return_error("Could not retrieve message from response.") - - content = response.get("content", []) - if not content: - return_error("Could not retrieve content from response.") - - message_content = "" - for item in content: - if item.get("type") == "text": - message_content += item.get("text", "") - - if not message_content: - return_error("Could not retrieve text from response content.") - - return message_content - - -def get_email_parts(entry_id: str) -> tuple[List[dict[str, str]] | None, str | None, str | None, str | None]: - """ - Extracts and parses the headers, text body, and HTML body from an .eml file identified by a given entry ID. - - Args: - - entry_id (str): The unique identifier for the uploaded .eml file in the war room. - - Returns: - - tuple[List[Dict[str, str]] | None, str | None, str | None]: A tuple containing three elements: - - headers (List[Dict[str, str]] | None): A list of dictionaries where each dictionary represents an email header. - - text_body (str | None): The plain text body of the email, if available. - - html_body (str | None): The HTML body of the email, if available. - - file_name (str | None): The name of the .eml file in the war room. - """ - if not entry_id: - DemistoException("Provide an entryId of an uploaded '.eml' file.") - - get_file_path_res = demisto.getFilePath(entry_id) - file_path = get_file_path_res["path"] - file_name = get_file_path_res["name"] - - if not file_name.endswith(EML_FILE_SUFFIX): - DemistoException("Provided 'entry_id' does not point to a valid '.eml' file.") - - email_parser = parse_emails.EmailParser(file_path=file_path) - email_parser.parse() - - headers, text_body, html_body = ( - email_parser.parsed_email.get("Headers", None), - email_parser.parsed_email.get("Text", None), - email_parser.parsed_email.get("HTML", None), - ) - return headers, text_body, html_body, file_name - - -def check_email_part(email_part: str, client: AnthropicClient, args: dict[str, Any]) -> CommandResults: - """ - Checks email parts (headers/body) for potential security issues using predefined prompts - ('CHECK_EMAIL_HEADERS_PROMPT', 'CHECK_EMAIL_BODY_PROMPT') that are sent to the Claude model. - """ - entry_id: str = args.get(ArgAndParamNames.ENTRY_ID, "") - email_headers, email_text_body, email_html_body, file_name = get_email_parts(entry_id) - additional_instructions = ( - (f"anthropic-claude check_email_part " f"Additional instructions: {ArgAndParamNames.ADDITIONAL_INSTRUCTIONS}\n") - if args.get(ArgAndParamNames.ADDITIONAL_INSTRUCTIONS, "") - else "" - ) - - if email_part == EmailParts.HEADERS: - demisto.debug(f"anthropic-claude checking email headers: {email_headers=}") - if email_headers: - email_headers_formatted = { - header["name"]: header["value"] for header in email_headers if "name" in header and "value" in header - } - readable_input = tableToMarkdown(name=f"{file_name} headers:", t=email_headers_formatted, sort_headers=False) - check_email_part_message = CHECK_EMAIL_HEADERS_PROMPT.format(additional_instructions, readable_input) - - else: - raise DemistoException("'parse_emails' did not extract any email headers from the provided file..") - elif email_part == EmailParts.BODY: - demisto.debug(f"anthropic-claude checking email body: {email_text_body=} {email_html_body=}") - - if not email_text_body and not email_html_body: - raise DemistoException("'email_parser' did not extract any email body from the provided file.") - - email_text_body = email_text_body if email_text_body else "" - email_html_body = email_html_body if email_html_body else "" - - email_body = {"Body/Text": email_text_body, "HTML/Text": email_html_body} - - readable_input = tableToMarkdown(name=f"{file_name} body:", t=email_body, sort_headers=False) - check_email_part_message = CHECK_EMAIL_BODY_PROMPT.format(additional_instructions, readable_input) - else: - raise DemistoException("Invalid email part to check provided.") - - demisto.debug(f"anthropic-claude check_email_part {check_email_part_message=}") - - # Starting a new conversation as of a new topic discussed. - args.update({ArgAndParamNames.RESET_CONVERSATION_HISTORY: "yes", ArgAndParamNames.MESSAGE: check_email_part_message}) - send_message_command_results, response = send_message_command(client, args) - - # Displaying the analyzed email part to the war room and setting the context for the email checking response - # prior to returning the 'send-message-command' results and the entire conversation to the context. - return_results( - CommandResults( - readable_output=readable_input, - outputs_prefix="AnthropicClaude.Email" + email_part.capitalize(), - outputs={"Email" + email_part.capitalize(): readable_input, "Response": response}, - replace_existing=True, - ) - ) - return send_message_command_results - - -""" COMMAND FUNCTIONS """ - - -def test_module(client: AnthropicClient, params: dict) -> str: - """Tests API connectivity and authentication along with model compatability with 'Messages' endpoint. - - Returning 'ok' indicates that the integration works like it is supposed to. - Connection to the service is successful. - Raises exceptions if something goes wrong. - - :type client: ``AnthropicClient`` - :param client: client to use - - :return: 'ok' if test passed, anything else will fail the test. - :rtype: ``str`` - """ - message = "" - try: - chat_message = {"role": "user", "content": "test"} - completion_params = { - ArgAndParamNames.MAX_TOKENS: int(params.get(ArgAndParamNames.MAX_TOKENS, "").replace(",", "") or 1024), - ArgAndParamNames.TEMPERATURE: params.get(ArgAndParamNames.TEMPERATURE, None), - ArgAndParamNames.TOP_P: params.get(ArgAndParamNames.TOP_P, None), - } - client.get_messages(chat_context=[chat_message], completion_params=completion_params) - message = "ok" - except DemistoException as e: - if "Forbidden" in str(e) or "Authorization" in str(e): - message = "Authorization Error: make sure API Key is correctly set" - else: - raise e - return message - - -def send_message_command(client: AnthropicClient, args: dict[str, Any]) -> tuple[CommandResults, dict[str, Any]]: - """ - Sending a message with conversation context to an Anthropic Claude model and retrieving the generated response. - """ - message = args.get(ArgAndParamNames.MESSAGE, "") - if not message: - raise ValueError("Message not provided") - - completion_params = { - ArgAndParamNames.MAX_TOKENS: int(args.get(ArgAndParamNames.MAX_TOKENS, "").replace(",", "") or 1024), - ArgAndParamNames.TEMPERATURE: args.get(ArgAndParamNames.TEMPERATURE, None), - ArgAndParamNames.TOP_P: args.get(ArgAndParamNames.TOP_P, None), - } - - reset_conversation_history = args.get(ArgAndParamNames.RESET_CONVERSATION_HISTORY, "") == "yes" - chat_context = get_chat_context(reset_conversation_history, message) - demisto.debug(f"anthropic-claude send_message_command {chat_context=}, {completion_params=}") - - response = client.get_messages(chat_context=chat_context, completion_params=completion_params) - demisto.debug(f"anthropic-claude send_message_command {response=}") - - assistant_message = extract_assistant_message(response) - conversation_step = [{Roles.USER: message, Roles.ASSISTANT: assistant_message}] - - usage: dict[str, str] = response.get("usage", {}) - - readable_output = ( - assistant_message - + "\n" - + tableToMarkdown( - name=f'{response.get(ArgAndParamNames.MODEL, "")} response:', - sort_headers=False, - t={ - "Input tokens": usage.get("input_tokens", ""), - "Output tokens": usage.get("output_tokens", ""), - "Context messages": str(len(chat_context)), - }, - ) - ) - return CommandResults( - outputs_prefix="AnthropicClaude.Conversation", - outputs=conversation_step, - replace_existing=reset_conversation_history, - readable_output=readable_output, - ), response - - -def check_email_headers_command(client: AnthropicClient, args: dict[str, Any]) -> CommandResults: - return check_email_part(EmailParts.HEADERS, client, args) - - -def check_email_body_command(client: AnthropicClient, args: dict[str, Any]) -> CommandResults: - return check_email_part(EmailParts.BODY, client, args) - - -def create_soc_email_template_command(client: AnthropicClient, args: dict[str, Any]) -> CommandResults: - additional_instructions = ( - f"Additional instructions: {args.get(ArgAndParamNames.ADDITIONAL_INSTRUCTIONS)}\n" - if args.get(ArgAndParamNames.ADDITIONAL_INSTRUCTIONS, "") - else "" - ) - create_soc_email_template_message = CREATE_SOC_EMAIL_TEMPLATE_PROMPT.format(additional_instructions) - args.update({ArgAndParamNames.MESSAGE: create_soc_email_template_message}) - send_message_command_results, response = send_message_command(client, args) - # Setting the SOCEmailTemplate context prior to returning the 'send-message-command' results - # and setting the entire conversation in the context. - return_results( - CommandResults(outputs_prefix="AnthropicClaude.SocEmailTemplate", outputs={"Response": response}, replace_existing=True) - ) - return send_message_command_results - - -""" EVENT COLLECTOR FUNCTIONS """ - - -def add_time_to_events(events: list[dict[str, Any]]) -> None: - """Sets the ``_time`` field on each event from the documented ``created_at`` timestamp.""" - for event in events: - created_at = event.get("created_at") - if created_at: - event["_time"] = created_at - - -def deduplicate_events(events: list[dict[str, Any]], last_fetched_ids: list[str]) -> list[dict[str, Any]]: - """Remove already-processed events based on previously fetched IDs. - - The Activity Feed is queried with a half-open time window (``created_at.gt``), but events that - share the exact boundary timestamp may reappear across consecutive runs. We dedup them using the - IDs persisted in the previous ``last_run``. - """ - if not events or not last_fetched_ids: - return events - - fetched_ids = set(last_fetched_ids) - new_events = [event for event in events if event.get("id") not in fetched_ids] - skipped = len(events) - len(new_events) - if skipped: - demisto.debug(f"[Dedup] Skipped {skipped} duplicate events; {len(new_events)} new events remain.") - return new_events - - -def fetch_events_with_pagination( - client: ComplianceClient, - last_run: dict[str, Any], - max_events: int, - activity_types: list[str] | None, -) -> tuple[list[dict[str, Any]], dict[str, Any]]: - """Fetch Activity Feed events incrementally using cursor pagination. - - The first call of a cycle uses ``created_at.gt`` against the newest timestamp seen in the - previous run. On the very first run (no ``last_run``) it looks back a fixed one-minute window. - Subsequent pages within the same cycle advance using the opaque ``after_id`` cursor, until - ``has_more`` is ``False``, the per-fetch event cap is reached, or the API-call budget is exhausted. - - To guarantee no events are lost across runs, the persisted cursor (``newest_created_at`` and the - boundary ``last_fetched_ids``) is derived only from the events actually returned to the caller — - never from events that were dropped by the per-fetch cap. This keeps the cursor from advancing - past undelivered events. - - Returns the collected events and the next ``last_run`` state. - """ - previous_newest = last_run.get("newest_created_at") - previous_ids = last_run.get("last_fetched_ids", []) - if previous_newest: - created_at_gt: str | None = previous_newest - created_at_gte: str | None = None - else: - # No stored state: default to a one-minute lookback and let next_run advance the cursor. - lookback_dt = arg_to_datetime(Config.DEFAULT_FETCH_LOOKBACK) - created_at_gte = lookback_dt.strftime(DATE_FORMAT) if lookback_dt else None - created_at_gt = None - - collected: list[dict[str, Any]] = [] - after_id: str | None = None - - for call_num in range(Config.MAX_FETCH_CALLS): - if len(collected) >= max_events: - break - page_limit = min(Config.ACTIVITIES_PAGE_SIZE, max_events - len(collected)) - response = client.get_activities( - limit=page_limit, - created_at_gte=created_at_gte, - # Apply the time bound only on the first call; the cursor (after_id) drives the rest. - created_at_gt=created_at_gt if call_num == 0 else None, - after_id=after_id, - activity_types=activity_types, - ) - activities = response.get("data", []) or [] - demisto.debug(f"[Fetch] Call {call_num}: fetched {len(activities)} activities.") - - collected.extend(activities) - - after_id = response.get("last_id") - if not response.get("has_more") or not after_id: - break - - # Drop events already pushed in a prior run (boundary-timestamp duplicates), then cap to the budget. - deduped = deduplicate_events(collected, previous_ids)[:max_events] - - # Derive the cursor from the DELIVERED events only, so capping never advances past undelivered ones. - newest_created_at = previous_newest - for event in deduped: - created_at = event.get("created_at") - if created_at and (not newest_created_at or created_at > newest_created_at): - newest_created_at = created_at - - # Persist the IDs sharing the newest delivered timestamp so the next run can dedup boundary events. - # When nothing new was delivered, carry the previous boundary IDs forward to keep dedup intact. - boundary_ids = [e["id"] for e in deduped if e.get("id") and e.get("created_at") == newest_created_at] - next_run = { - "newest_created_at": newest_created_at, - "last_fetched_ids": boundary_ids or previous_ids, - } - return deduped, next_run - - -def fetch_events_command(client: ComplianceClient, params: dict[str, Any]) -> None: - """Fetch-events entry point: pull Activity Feed events and push them to XSIAM.""" - last_run = demisto.getLastRun() or {} - max_events = arg_to_number(params.get("max_events_per_fetch")) or Config.DEFAULT_MAX_EVENTS_PER_FETCH - activity_types = argToList(params.get("activity_types")) or None - - events, next_run = fetch_events_with_pagination(client, last_run, max_events, activity_types) - - if events: - add_time_to_events(events) - send_events_to_xsiam(events, vendor=Config.VENDOR, product=Config.PRODUCT) - else: - demisto.debug("[Fetch] No new events to send to XSIAM this cycle.") - - # Persist the cursor regardless of whether events were found, so the next run advances correctly. - demisto.setLastRun(next_run) - demisto.info(f"[Fetch] Completed fetch cycle: sent {len(events)} events to XSIAM. {next_run=}") - - -def get_events_command(client: ComplianceClient, args: dict[str, Any]) -> tuple[list[dict[str, Any]], CommandResults]: - """Manually retrieve Activity Feed events for testing/troubleshooting. - - Supports optional ``start_time``/``end_time`` arguments to bound the Activity Feed query by - creation time (RFC 3339, e.g. ``2025-06-07T08:09:10Z``). - """ - limit = arg_to_number(args.get("limit")) or Config.DEFAULT_LIST_LIMIT - activity_types = argToList(args.get("activity_types")) or None - - start_dt = arg_to_datetime(args.get("start_time")) - end_dt = arg_to_datetime(args.get("end_time")) - created_at_gte = start_dt.strftime(DATE_FORMAT) if start_dt else None - created_at_lt = end_dt.strftime(DATE_FORMAT) if end_dt else None - - response = client.get_activities( - limit=min(limit, Config.ACTIVITIES_PAGE_SIZE), - created_at_gte=created_at_gte, - created_at_lt=created_at_lt, - activity_types=activity_types, - ) - events = (response.get("data", []) or [])[:limit] - add_time_to_events(events) - - readable = tableToMarkdown( - name="Anthropic Claude Activity Feed events", - t=events, - headers=["id", "created_at", "activity_type"], - removeNull=True, - ) - results = CommandResults( - outputs_prefix="AnthropicClaude.Event", - outputs_key_field="id", - outputs=events, - readable_output=readable, - raw_response=response, - ) - return events, results - - -""" COMPLIANCE COMMAND FUNCTIONS """ - - -def _paginate_args(args: dict[str, Any]) -> dict[str, Any]: - """Builds common list query params (limit + XSOAR page-token convention).""" - params: dict[str, Any] = {} - if limit := arg_to_number(args.get("limit")): - params["limit"] = limit - if next_token := args.get("next_token"): - params["page"] = next_token - return params - - -def resolve_org_uuid(args: dict[str, Any], params: dict[str, Any]) -> str: - """Resolve the organization UUID, preferring the command argument over the instance parameter.""" - org_uuid = args.get("org_uuid") or params.get("organization_uuid") - if not org_uuid: - raise DemistoException( - "An Organization UUID is required for this command. Provide the 'org_uuid' argument or set the " - "'Organization UUID' integration parameter. Run 'claude-list-organizations' to find available UUIDs." - ) - return org_uuid - - -def _list_command( - client: ComplianceClient, - url_suffix: str, - outputs_prefix: str, - args: dict[str, Any], - headers: list[str], - table_name: str, - use_pagination: bool = True, -) -> CommandResults: - """Generic GET-and-tabulate helper for the read-only compliance list endpoints.""" - params = _paginate_args(args) if use_pagination else {} - response = client.http_get(url_suffix, params=params or None) - data = response.get("data", response) - readable = tableToMarkdown(name=table_name, t=data, headers=headers, removeNull=True) - if next_page := response.get("next_page"): - readable += f"\n**Next page token:** `{next_page}`" - return CommandResults( - outputs_prefix=outputs_prefix, - outputs_key_field="id", - outputs=data, - readable_output=readable, - raw_response=response, - ) - - -def list_organizations_command(client: ComplianceClient, args: dict[str, Any]) -> CommandResults: - limit = arg_to_number(args.get("limit")) or Config.DEFAULT_LIST_LIMIT - response = client.http_get(ApiPaths.ORGANIZATIONS, params={"limit": limit}) - data = (response.get("data", []) or [])[:limit] - readable = tableToMarkdown("Organizations", data, headers=["uuid", "name", "created_at"], removeNull=True) - return CommandResults( - outputs_prefix="AnthropicClaude.Organization", - outputs_key_field="uuid", - outputs=data, - readable_output=readable, - raw_response=response, - ) - - -def list_organization_users_command(client: ComplianceClient, args: dict[str, Any], params: dict[str, Any]) -> CommandResults: - org_uuid = resolve_org_uuid(args, params) - return _list_command( - client, - ApiPaths.organization_users(org_uuid), - "AnthropicClaude.Organization.User", - args, - headers=["id", "full_name", "email", "organization_role", "created_at"], - table_name="Organization Users", - ) - - -def list_roles_command(client: ComplianceClient, args: dict[str, Any], params: dict[str, Any]) -> CommandResults: - org_uuid = resolve_org_uuid(args, params) - role_id = args.get("role_id") - headers = ["id", "name", "description", "created_at", "updated_at"] - if role_id: - response = client.http_get(ApiPaths.role(org_uuid, role_id)) - readable = tableToMarkdown("Role", response, headers=headers, removeNull=True) - return CommandResults( - outputs_prefix="AnthropicClaude.Organization.Role", - outputs_key_field="id", - outputs=response, - readable_output=readable, - raw_response=response, - ) - return _list_command( - client, - ApiPaths.roles(org_uuid), - "AnthropicClaude.Organization.Role", - args, - headers=headers, - table_name="Roles", - ) - - -def list_role_permissions_command(client: ComplianceClient, args: dict[str, Any], params: dict[str, Any]) -> CommandResults: - org_uuid = resolve_org_uuid(args, params) - role_id = args["role_id"] - return _list_command( - client, - ApiPaths.role_permissions(org_uuid, role_id), - "AnthropicClaude.Organization.Role.Permission", - args, - headers=["resource_type", "resource_id", "action"], - table_name="Role Permissions", - ) - - -def list_groups_command(client: ComplianceClient, args: dict[str, Any]) -> CommandResults: - group_id = args.get("group_id") - headers = ["id", "name", "description", "source_type", "roles", "created_at", "updated_at"] - if group_id: - response = client.http_get(ApiPaths.group(group_id)) - readable = tableToMarkdown("Group", response, headers=headers, removeNull=True) - return CommandResults( - outputs_prefix="AnthropicClaude.Group", - outputs_key_field="id", - outputs=response, - readable_output=readable, - raw_response=response, - ) - return _list_command( - client, - ApiPaths.GROUPS, - "AnthropicClaude.Group", - args, - headers=headers, - table_name="Groups", - ) - - -def list_group_members_command(client: ComplianceClient, args: dict[str, Any]) -> CommandResults: - group_id = args["group_id"] - params = _paginate_args(args) - response = client.http_get(ApiPaths.group_members(group_id), params=params or None) - members = response.get("data", []) - readable = tableToMarkdown( - f"Group {group_id} Members", members, headers=["user_id", "email", "created_at", "updated_at"], removeNull=True - ) - if next_page := response.get("next_page"): - readable += f"\n**Next page token:** `{next_page}`" - # Merge the members into the matching Group context entry via DT, keyed on the group ID. - return CommandResults( - outputs_prefix=f"AnthropicClaude.Group(val.id == '{group_id}').Member", - outputs_key_field="user_id", - outputs=members, - readable_output=readable, - raw_response=response, - ) - - -def list_chats_command(client: ComplianceClient, args: dict[str, Any]) -> CommandResults: - params: dict[str, Any] = {} - if user_ids := argToList(args.get("user_ids")): - params["user_ids[]"] = user_ids - if organization_ids := argToList(args.get("organization_ids")): - params["organization_ids[]"] = organization_ids - if project_ids := argToList(args.get("project_ids")): - params["project_ids[]"] = project_ids - for arg_name in ("created_at_gte", "created_at_lte", "updated_at_gte", "updated_at_lte", "after_id", "before_id"): - if value := args.get(arg_name): - params[arg_name.replace("_gte", ".gte").replace("_lte", ".lte") if "_at_" in arg_name else arg_name] = value - if limit := arg_to_number(args.get("limit")): - params["limit"] = limit - response = client.http_get(ApiPaths.CHATS, params=params) - data = response.get("data", []) - headers = ["id", "name", "created_at", "updated_at", "deleted_at", "href", "model", "organization_uuid", "project_id"] - readable = tableToMarkdown("Chats", data, headers=headers, removeNull=True) - return CommandResults( - outputs_prefix="AnthropicClaude.Chat", - outputs_key_field="id", - outputs=data, - readable_output=readable, - raw_response=response, - ) - - -def list_chat_messages_command(client: ComplianceClient, args: dict[str, Any]) -> CommandResults: - chat_id = args["chat_id"] - params: dict[str, Any] = {} - if limit := arg_to_number(args.get("limit")): - params["limit"] = limit - for arg_name in ("after_id", "before_id", "order"): - if value := args.get(arg_name): - params[arg_name] = value - for arg_name in ("created_at_gte", "created_at_lte", "updated_at_gte", "updated_at_lte"): - if value := args.get(arg_name): - params[arg_name.replace("_gte", ".gte").replace("_lte", ".lte")] = value - response = client.http_get(ApiPaths.chat_messages(chat_id), params=params or None) - data = response.get("chat_messages", []) - readable = tableToMarkdown(f"Chat {chat_id} Messages", data, headers=["id", "role", "created_at"], removeNull=True) - # Merge the messages into the matching Chat context entry via DT, keyed on the chat ID. - return CommandResults( - outputs_prefix=f"AnthropicClaude.Chat(val.id == '{chat_id}').Message", - outputs_key_field="id", - outputs=data, - readable_output=readable, - raw_response=response, - ) - - -def list_projects_command(client: ComplianceClient, args: dict[str, Any]) -> CommandResults: - project_id = args.get("project_id") - headers = [ - "id", - "name", - "is_private", - "organization_uuid", - "created_at", - "updated_at", - "deleted_at", - ] - if project_id: - response = client.http_get(ApiPaths.project(project_id)) - readable = tableToMarkdown("Project", response, headers=headers, removeNull=True) - return CommandResults( - outputs_prefix="AnthropicClaude.Project", - outputs_key_field="id", - outputs=response, - readable_output=readable, - raw_response=response, - ) - return _list_command( - client, - ApiPaths.PROJECTS, - "AnthropicClaude.Project", - args, - headers=headers, - table_name="Projects", - ) - - -def list_project_attachments_command(client: ComplianceClient, args: dict[str, Any]) -> CommandResults: - project_id = args["project_id"] - params = _paginate_args(args) - response = client.http_get(ApiPaths.project_attachments(project_id), params=params or None) - attachments = response.get("data", []) - readable = tableToMarkdown( - f"Project {project_id} Attachments", - attachments, - headers=["id", "filename", "mime_type", "type", "created_at"], - removeNull=True, - ) - if next_page := response.get("next_page"): - readable += f"\n**Next page token:** `{next_page}`" - # Merge the attachments into the matching Project context entry via DT, keyed on the project ID. - return CommandResults( - outputs_prefix=f"AnthropicClaude.Project(val.id == '{project_id}').Attachment", - outputs_key_field="id", - outputs=attachments, - readable_output=readable, - raw_response=response, - ) - - -def get_project_document_command(client: ComplianceClient, args: dict[str, Any]) -> CommandResults: - project_id = args["project_id"] - document_id = args["document_id"] - response = client.http_get(ApiPaths.project_document(project_id, document_id)) - readable = tableToMarkdown( - "Project Document", - response, - headers=["id", "filename", "mime_type", "created_at"], - removeNull=True, - ) - return CommandResults( - outputs_prefix="AnthropicClaude.ProjectDocument", - outputs_key_field="id", - outputs=response, - readable_output=readable, - raw_response=response, - ) - - -def _delete_command( - client: ComplianceClient, - resource_id: str, - url_suffix: str, - deleted_type: str, - outputs_prefix: str, - resource_label: str, -) -> CommandResults: - """Generic DELETE-and-report helper for the irreversible compliance delete endpoints. - - The HTTP status code is inspected directly: a 404 means the resource is already gone, which is - treated as an idempotent success so re-running a delete is always safe. Any other non-2xx code - is raised by ``http_delete`` before reaching here. - """ - response = client.http_delete(url_suffix) - already_deleted = response.status_code == 404 - - try: - raw_response = response.json() - except ValueError: - raw_response = {"id": resource_id, "type": deleted_type} - - outputs = {"id": resource_id, "type": deleted_type, "Deleted": True} - note = " (was already deleted)" if already_deleted else "" - readable = tableToMarkdown( - f"{resource_label} deleted{note}", - outputs, - headers=["id", "type", "Deleted"], - removeNull=True, - ) - return CommandResults( - outputs_prefix=outputs_prefix, - outputs_key_field="id", - outputs=outputs, - readable_output=readable, - raw_response=raw_response, - ) - - -def chat_file_delete_command(client: ComplianceClient, args: dict[str, Any]) -> CommandResults: - """Permanently delete a Claude file (chat file or project binary file) via the Compliance API. - - This is an irreversible hard delete (DELETE /v1/compliance/apps/chats/files/{claude_file_id}). - """ - file_id = args["file_id"] - return _delete_command( - client, - resource_id=file_id, - url_suffix=f"{ApiPaths.CHAT_FILES}/{file_id}", - deleted_type="claude_file_deleted", - outputs_prefix="AnthropicClaude.DeletedFile", - resource_label="File", - ) - - -def project_document_delete_command(client: ComplianceClient, args: dict[str, Any]) -> CommandResults: - """Permanently delete a Claude project document via the Compliance API. - - This is an irreversible hard delete - (DELETE /v1/compliance/apps/projects/documents/{document_id}). - """ - document_id = args["document_id"] - return _delete_command( - client, - resource_id=document_id, - url_suffix=f"{ApiPaths.PROJECT_DOCUMENTS}/{document_id}", - deleted_type="claude_project_document_deleted", - outputs_prefix="AnthropicClaude.DeletedProjectDocument", - resource_label="Project document", - ) - - -def module_test_compliance(client: ComplianceClient) -> str: - """Validates the Compliance Access Key by hitting the Activity Feed with a minimal request.""" - try: - client.get_activities(limit=1) - except DemistoException as e: - if "401" in str(e) or "403" in str(e) or "Forbidden" in str(e) or "Authorization" in str(e): - return "Authorization Error: make sure the Compliance Access Key is correct and has the required scopes." - raise - return "ok" - - -def ensure_compliance_key(compliance_api_key: str | None) -> None: - """Fail fast with a helpful error if the Compliance Access Key is not configured.""" - if not compliance_api_key: - raise DemistoException( - "This command requires the Anthropic Compliance Access Key (sk-ant-api01-...), which is not configured. " - "Set the 'Compliance Access Key' integration parameter. " - f"See how to obtain one here: {Config.COMPLIANCE_KEY_DOCS}" - ) - - -def ensure_api_key(api_key: str | None) -> None: - """Fail fast with a helpful error if the Anthropic API Key is not configured.""" - if not api_key: - raise DemistoException( - "This command requires the Anthropic API Key, which is not configured. " - "Set the 'API Key' integration parameter. " - f"Generate one here: {Config.API_KEY_DOCS}" - ) - - -""" MAIN FUNCTION """ - - -def main() -> None: # pragma: no cover - """main function, parses params and runs command functions - - :return: - :rtype: - """ - - params = demisto.params() - args = demisto.args() - command = demisto.command() - - api_key = params.get("apikey", {}).get("password") - # If a model name was provided within the free text box, it will override the selected one from the model selection box. - # The provided model will be tested for compatability within the test module. - model = params.get("model-freetext") if params.get("model-freetext") else params.get("model-select") - compliance_api_key = params.get("compliance_apikey", {}).get("password") - - url = params.get("url") - verify = not params.get("insecure", False) - proxy = params.get("proxy", False) - - # Compliance commands whose org_uuid argument falls back to the instance Organization UUID parameter. - org_scoped_commands = { - "claude-list-organization-users": list_organization_users_command, - "claude-list-roles": list_roles_command, - "claude-list-role-permissions": list_role_permissions_command, - } - # Remaining read-only Compliance API commands. - compliance_commands = { - "claude-list-organizations": list_organizations_command, - "claude-list-groups": list_groups_command, - "claude-list-group-members": list_group_members_command, - "claude-list-chats": list_chats_command, - "claude-list-chat-messages": list_chat_messages_command, - "claude-list-projects": list_projects_command, - "claude-list-project-attachments": list_project_attachments_command, - "claude-get-project-document": get_project_document_command, - "claude-chat-file-delete": chat_file_delete_command, - "claude-project-document-delete": project_document_delete_command, - } - # LLM (Messages API) commands that require the Anthropic API Key. - llm_commands: dict[str, Any] = { - "claude-send-message": lambda c, a: send_message_command(c, a)[0], - "claude-check-email-header": check_email_headers_command, - "claude-check-email-body": check_email_body_command, - "claude-create-soc-email-template": create_soc_email_template_command, - } - - demisto.debug(f"anthropic-claude Command being called is {command}") - try: - if command == "test-module": - # Validate whichever credentials are configured (a customer may configure either or both). - # Each test is labeled so a failure clearly indicates which key is invalid. - results: list[str] = [] - if api_key: - try: - llm_client = AnthropicClient(url=url, api_key=api_key, model=model, verify=verify, proxy=proxy) - results.append(test_module(client=llm_client, params=params)) - except Exception as e: - raise DemistoException(f"API Key (LLM) validation failed: {e}") from e - if compliance_api_key: - try: - compliance_client = ComplianceClient(url=url, api_key=compliance_api_key, verify=verify, proxy=proxy) - results.append(module_test_compliance(client=compliance_client)) - except Exception as e: - raise DemistoException(f"Compliance Access Key validation failed: {e}") from e - if not results: - raise DemistoException( - "No credentials configured. Set the 'API Key' for LLM commands and/or the " - "'Compliance Access Key' for event collection and compliance commands." - ) - # Surface the first failing credential's message; only report "ok" when every check passed. - failure = next((result for result in results if result != "ok"), None) - return_results(failure or "ok") - - elif command == "fetch-events": - ensure_compliance_key(compliance_api_key) - compliance_client = ComplianceClient(url=url, api_key=compliance_api_key, verify=verify, proxy=proxy) - fetch_events_command(client=compliance_client, params=params) - - elif command == "claude-get-events": - ensure_compliance_key(compliance_api_key) - compliance_client = ComplianceClient(url=url, api_key=compliance_api_key, verify=verify, proxy=proxy) - events, results_obj = get_events_command(client=compliance_client, args=args) - # get_events_command already set _time on each event, so just push when requested. - if events and argToBoolean(args.get("should_push_events", "false")): - send_events_to_xsiam(events, vendor=Config.VENDOR, product=Config.PRODUCT) - return_results(results_obj) - - elif command in org_scoped_commands: - ensure_compliance_key(compliance_api_key) - compliance_client = ComplianceClient(url=url, api_key=compliance_api_key, verify=verify, proxy=proxy) - return_results(org_scoped_commands[command](compliance_client, args, params)) - - elif command in compliance_commands: - ensure_compliance_key(compliance_api_key) - compliance_client = ComplianceClient(url=url, api_key=compliance_api_key, verify=verify, proxy=proxy) - return_results(compliance_commands[command](compliance_client, args)) - - elif command in llm_commands: - ensure_api_key(api_key) - llm_args = dict(args) - llm_args.update({key: value for key, value in params.items() if key not in llm_args and value is not None}) - llm_client = AnthropicClient(url=url, api_key=api_key, model=model, verify=verify, proxy=proxy) - return_results(llm_commands[command](llm_client, llm_args)) - - else: - raise NotImplementedError(f"Command {command} is not implemented.") - - except Exception as e: - return_error(f"Failed to execute {demisto.command()} command. Error: {str(e)}") - - -""" ENTRY POINT """ +from CommonServerUserPython import * # noqa: E402 # pylint: disable=wrong-import-position if __name__ in ("__main__", "__builtin__", "builtins"): main() diff --git a/Packs/AnthropicClaude/Integrations/AnthropicClaude/AnthropicClaude_test.py b/Packs/AnthropicClaude/Integrations/AnthropicClaude/AnthropicClaude_test.py index 60240a99db3a..22eb76de8fd3 100644 --- a/Packs/AnthropicClaude/Integrations/AnthropicClaude/AnthropicClaude_test.py +++ b/Packs/AnthropicClaude/Integrations/AnthropicClaude/AnthropicClaude_test.py @@ -1,597 +1,64 @@ -"""Unit tests for the Anthropic Claude Compliance API event collection and read-only commands.""" +"""Sanity tests for the AnthropicClaude shim integration. -import json -import os +Full behavioural coverage lives in the AnthropicClaudeApiModule tests; this file +exists only to confirm the shim wires through to the ApiModule correctly. +""" import pytest -import requests -from CommonServerPython import CommandResults, DemistoException -from AnthropicClaude import ( - Config, - ComplianceClient, - add_time_to_events, - deduplicate_events, - fetch_events_with_pagination, - fetch_events_command, - get_events_command, - list_organizations_command, - list_organization_users_command, - list_roles_command, - list_role_permissions_command, - list_groups_command, - list_group_members_command, - list_chats_command, - list_chat_messages_command, - list_projects_command, - list_project_attachments_command, - get_project_document_command, - chat_file_delete_command, - project_document_delete_command, - module_test_compliance, - resolve_org_uuid, - ensure_compliance_key, - ensure_api_key, -) -BASE_URL = "https://api.anthropic.com/" - - -def load_test_data(filename: str) -> dict: - """Loads a JSON fixture from the test_data directory.""" - path = os.path.join(os.path.dirname(__file__), "test_data", filename) - with open(path) as fh: - return json.load(fh) - - -def build_client() -> ComplianceClient: - return ComplianceClient(url=BASE_URL, api_key="sk-ant-api01-test", proxy=False, verify=False) - - -def make_activities(start: int, count: int, base_minute: int = 0) -> list[dict]: - """Builds a list of activity events with increasing ids/timestamps.""" - return [ - { - "id": f"activity_{i:04d}", - "activity_type": "chat.created", - "created_at": f"2026-06-11T07:{base_minute:02d}:{i % 60:02d}Z", - } - for i in range(start, start + count) - ] - - -""" EVENT COLLECTOR TESTS """ - - -def test_add_time_to_events(): - events = [{"created_at": "2026-06-11T07:08:59Z"}, {"id": "no_time"}] - add_time_to_events(events) - assert events[0]["_time"] == "2026-06-11T07:08:59Z" - assert "_time" not in events[1] - - -def test_deduplicate_events(): - events = [{"id": "a"}, {"id": "b"}, {"id": "c"}] - assert deduplicate_events(events, ["b"]) == [{"id": "a"}, {"id": "c"}] - assert deduplicate_events(events, []) == events - assert deduplicate_events([], ["b"]) == [] - - -def test_fetch_events_first_run(mocker): - """First run: uses the one-minute lookback lower bound, single page, no has_more.""" - client = build_client() - response = {"data": make_activities(0, 3), "has_more": False, "last_id": "activity_0002"} - get_mock = mocker.patch.object(client, "get_activities", return_value=response) - - events, next_run = fetch_events_with_pagination(client, last_run={}, max_events=50000, activity_types=None) - - assert len(events) == 3 - # First call should use created_at.gte (first-fetch lower bound), not after_id. - _, kwargs = get_mock.call_args - assert kwargs["created_at_gte"] is not None - assert kwargs["after_id"] is None - assert next_run["newest_created_at"] == "2026-06-11T07:00:02Z" - - -def test_fetch_events_subsequent_run(mocker): - """Subsequent run: uses created_at.gt against the previously stored newest timestamp.""" - client = build_client() - response = {"data": make_activities(5, 2), "has_more": False, "last_id": "activity_0006"} - get_mock = mocker.patch.object(client, "get_activities", return_value=response) - - last_run = {"newest_created_at": "2026-06-11T07:00:04Z", "last_fetched_ids": ["activity_0004"]} - events, next_run = fetch_events_with_pagination(client, last_run, max_events=50000, activity_types=None) - - assert len(events) == 2 - _, kwargs = get_mock.call_args - assert kwargs["created_at_gt"] == "2026-06-11T07:00:04Z" - assert kwargs["created_at_gte"] is None - - -def test_fetch_events_pagination(mocker): - """Cursor pagination: walks multiple pages until has_more is False.""" - client = build_client() - page1 = {"data": make_activities(0, 2), "has_more": True, "last_id": "activity_0001"} - page2 = {"data": make_activities(2, 2), "has_more": False, "last_id": "activity_0003"} - get_mock = mocker.patch.object(client, "get_activities", side_effect=[page1, page2]) - - events, _ = fetch_events_with_pagination(client, last_run={}, max_events=50000, activity_types=None) - - assert len(events) == 4 - assert get_mock.call_count == 2 - # The second call must carry the cursor from page1's last_id. - second_kwargs = get_mock.call_args_list[1].kwargs - assert second_kwargs["after_id"] == "activity_0001" - - -def test_fetch_events_dedup(mocker): - """Boundary events already seen in the previous run are not returned again.""" - client = build_client() - response = { - "data": [ - {"id": "activity_dup", "created_at": "2026-06-11T07:00:04Z", "activity_type": "x"}, - {"id": "activity_new", "created_at": "2026-06-11T07:00:05Z", "activity_type": "y"}, - ], - "has_more": False, - "last_id": "activity_new", - } - mocker.patch.object(client, "get_activities", return_value=response) - - last_run = {"newest_created_at": "2026-06-11T07:00:04Z", "last_fetched_ids": ["activity_dup"]} - events, _ = fetch_events_with_pagination(client, last_run, max_events=50000, activity_types=None) - - ids = [e["id"] for e in events] - assert "activity_dup" not in ids - assert "activity_new" in ids - - -def test_fetch_events_respects_max_events(mocker): - """The collector stops once max_events is reached even if more pages exist.""" - client = build_client() - page = {"data": make_activities(0, 3), "has_more": True, "last_id": "activity_0002"} - mocker.patch.object(client, "get_activities", return_value=page) - - events, _ = fetch_events_with_pagination(client, last_run={}, max_events=3, activity_types=None) - - assert len(events) == 3 - - -def test_fetch_events_pushes_to_xsiam(mocker): - """fetch_events sets _time, pushes events with the correct vendor/product, and persists last_run.""" - client = build_client() - response = {"data": make_activities(0, 2), "has_more": False, "last_id": "activity_0001"} - mocker.patch.object(client, "get_activities", return_value=response) - mocker.patch("AnthropicClaude.demisto.getLastRun", return_value={}) - set_last_run = mocker.patch("AnthropicClaude.demisto.setLastRun") - send_mock = mocker.patch("AnthropicClaude.send_events_to_xsiam") - - fetch_events_command(client, params={"max_events_per_fetch": "1000"}) - - send_mock.assert_called_once() - sent_events = send_mock.call_args.args[0] - assert send_mock.call_args.kwargs["vendor"] == Config.VENDOR - assert send_mock.call_args.kwargs["product"] == Config.PRODUCT - assert all("_time" in e for e in sent_events) - set_last_run.assert_called_once() - - -def test_get_events_command_no_push(mocker): - client = build_client() - response = {"data": make_activities(0, 2), "has_more": False, "last_id": "activity_0001"} - mocker.patch.object(client, "get_activities", return_value=response) - - events, results = get_events_command(client, args={"limit": "50"}) - - assert len(events) == 2 - assert isinstance(results, CommandResults) - assert all("_time" in e for e in events) - - -""" COMPLIANCE COMMAND TESTS """ - - -def test_list_organizations_command(mocker): - client = build_client() - response = {"data": [{"uuid": "org-1", "name": "Acme", "created_at": "2026-01-01T00:00:00Z"}]} - mocker.patch.object(client, "http_get", return_value=response) - - results = list_organizations_command(client, args={"limit": "50"}) - - assert results.outputs_prefix == "AnthropicClaude.Organization" - assert results.outputs[0]["uuid"] == "org-1" - - -def test_list_organization_users_command(mocker): - client = build_client() - response = {"data": [{"id": "u1", "email": "user@example.com", "organization_role": "admin"}]} - get_mock = mocker.patch.object(client, "http_get", return_value=response) - - results = list_organization_users_command(client, args={"org_uuid": "org-1", "limit": "10"}, params={}) - - assert results.outputs_prefix == "AnthropicClaude.Organization.User" - get_mock.assert_called_once() - assert "organizations/org-1/users" in get_mock.call_args.args[0] - - -def test_list_roles_single_role(mocker): - """When role_id is provided, the single-role endpoint is used (no data[] wrapper).""" - client = build_client() - response = {"id": "role-1", "name": "Owner", "description": "desc"} - get_mock = mocker.patch.object(client, "http_get", return_value=response) - - results = list_roles_command(client, args={"org_uuid": "org-1", "role_id": "role-1"}, params={}) - - assert results.outputs["id"] == "role-1" - assert "roles/role-1" in get_mock.call_args.args[0] - - -def test_list_roles_list_mode(mocker): - client = build_client() - response = {"data": [{"id": "role-1", "name": "Owner"}], "next_page": "tok123"} - mocker.patch.object(client, "http_get", return_value=response) - - results = list_roles_command(client, args={"org_uuid": "org-1"}, params={}) - - assert results.outputs[0]["id"] == "role-1" - assert "tok123" in results.readable_output - - -def test_list_groups_single_group(mocker): - client = build_client() - response = {"id": "grp-1", "name": "Engineers", "source_type": "scim"} - get_mock = mocker.patch.object(client, "http_get", return_value=response) - - results = list_groups_command(client, args={"group_id": "grp-1"}) - - assert results.outputs["id"] == "grp-1" - assert "groups/grp-1" in get_mock.call_args.args[0] - - -def test_list_chats_command(mocker): - client = build_client() - response = {"data": [{"id": "chat-1", "name": "Chat", "model": "claude-3"}]} - get_mock = mocker.patch.object(client, "http_get", return_value=response) - - results = list_chats_command(client, args={"user_ids": "u1,u2", "limit": "100"}) - - assert results.outputs_prefix == "AnthropicClaude.Chat" - params = get_mock.call_args.kwargs["params"] - assert params["user_ids[]"] == ["u1", "u2"] - - -def test_list_chat_messages_command(mocker): - client = build_client() - response = {"chat_messages": [{"id": "m1", "role": "user", "created_at": "2026-01-01T00:00:00Z"}]} - mocker.patch.object(client, "http_get", return_value=response) - - results = list_chat_messages_command(client, args={"chat_id": "chat-1"}) - - # Messages merge into the parent Chat entry via DT. - assert results.outputs_prefix == "AnthropicClaude.Chat(val.id == 'chat-1').Message" - assert results.outputs[0]["id"] == "m1" - - -def test_list_projects_single_project(mocker): - client = build_client() - response = {"id": "proj-1", "name": "Project", "is_private": True} - get_mock = mocker.patch.object(client, "http_get", return_value=response) - - results = list_projects_command(client, args={"project_id": "proj-1"}) - - assert results.outputs["id"] == "proj-1" - assert "projects/proj-1" in get_mock.call_args.args[0] - - -def test_get_project_document_command(mocker): - client = build_client() - response = {"id": "claude_proj_doc_1", "filename": "spec.md", "content": "hello"} - get_mock = mocker.patch.object(client, "http_get", return_value=response) - - results = get_project_document_command(client, args={"project_id": "proj-1", "document_id": "claude_proj_doc_1"}) - - assert results.outputs_prefix == "AnthropicClaude.ProjectDocument" - assert results.outputs["content"] == "hello" - assert "projects/proj-1/documents/claude_proj_doc_1" in get_mock.call_args.args[0] - - -def test_list_group_members_dt_prefix(mocker): - """Group members merge into the parent Group entry via DT.""" - client = build_client() - response = {"data": [{"user_id": "u1", "email": "user@example.com"}]} - mocker.patch.object(client, "http_get", return_value=response) - - results = list_group_members_command(client, args={"group_id": "grp-1"}) - - assert results.outputs_prefix == "AnthropicClaude.Group(val.id == 'grp-1').Member" - assert results.outputs[0]["user_id"] == "u1" - - -def test_resolve_org_uuid_falls_back_to_param(): - assert resolve_org_uuid({"org_uuid": "arg-org"}, {"organization_uuid": "param-org"}) == "arg-org" - assert resolve_org_uuid({}, {"organization_uuid": "param-org"}) == "param-org" - - -def test_resolve_org_uuid_missing_raises(): - with pytest.raises(DemistoException, match="Organization UUID is required"): - resolve_org_uuid({}, {}) - - -def test_require_compliance_key_missing_raises(): - with pytest.raises(DemistoException, match="Compliance Access Key"): - ensure_compliance_key(None) - # Present key does not raise. - ensure_compliance_key("sk-ant-api01-test") - - -def test_require_api_key_missing_raises(): - with pytest.raises(DemistoException, match="API Key"): - ensure_api_key(None) - ensure_api_key("some-key") - - -""" TEST-MODULE TESTS """ - - -def test_test_module_compliance_success(mocker): - client = build_client() - mocker.patch.object(client, "get_activities", return_value={"data": []}) - assert module_test_compliance(client) == "ok" - - -def test_test_module_compliance_auth_failure(mocker): - from CommonServerPython import DemistoException - - client = build_client() - mocker.patch.object(client, "get_activities", side_effect=DemistoException("Error 401 Unauthorized")) - result = module_test_compliance(client) - assert "Authorization Error" in result - - -def test_test_module_compliance_other_error_raises(mocker): - from CommonServerPython import DemistoException - - client = build_client() - mocker.patch.object(client, "get_activities", side_effect=DemistoException("500 Server Error")) - with pytest.raises(DemistoException): - module_test_compliance(client) - - -""" ADDITIONAL EVENT COLLECTOR TESTS """ - - -def test_fetch_events_no_drop_across_cap_boundary_two_runs(mocker): - """When total events exceed max_events_per_fetch, the cap must not drop events across runs. - - Run 1 collects exactly `max_events`; the persisted cursor must reflect only the delivered - events so run 2 resumes from the correct boundary and the remaining events are returned with - no gaps and no overlap. - """ - client = build_client() - # Six unique events across two ascending pages; cap each run at 3. - all_events = make_activities(0, 6) - page_first_half = {"data": all_events[:3], "has_more": True, "last_id": "activity_0002"} - mocker.patch.object(client, "get_activities", return_value=page_first_half) - - run1_events, run1_next = fetch_events_with_pagination(client, last_run={}, max_events=3, activity_types=None) - run1_ids = [e["id"] for e in run1_events] - - assert run1_ids == ["activity_0000", "activity_0001", "activity_0002"] - # Cursor reflects the newest DELIVERED event only. - assert run1_next["newest_created_at"] == all_events[2]["created_at"] - - # Run 2 resumes after the boundary; the API returns the remaining events. - page_second_half = {"data": all_events[3:], "has_more": False, "last_id": "activity_0005"} - mocker.patch.object(client, "get_activities", return_value=page_second_half) - - run2_events, _ = fetch_events_with_pagination(client, last_run=run1_next, max_events=3, activity_types=None) - run2_ids = [e["id"] for e in run2_events] - - # No event is dropped and none is duplicated across the cap boundary. - assert run2_ids == ["activity_0003", "activity_0004", "activity_0005"] - assert set(run1_ids).isdisjoint(run2_ids) - assert sorted(run1_ids + run2_ids) == [e["id"] for e in all_events] - - -def test_fetch_events_descending_feed_shape(mocker): - """The real Activity Feed returns events newest-first; the cursor must capture the newest one.""" - client = build_client() - page = load_test_data("activities_page1.json") - # Close out pagination so the single fixture page is the whole cycle. - page = {**page, "has_more": False} - mocker.patch.object(client, "get_activities", return_value=page) - - events, next_run = fetch_events_with_pagination(client, last_run={}, max_events=50000, activity_types=None) - - assert len(events) == 2 - # activity_002 (07:08:59) is newer than activity_001 (07:08:58) despite appearing first. - assert next_run["newest_created_at"] == "2026-06-11T07:08:59Z" - assert next_run["last_fetched_ids"] == ["activity_002"] - - -def test_get_events_command_with_time_range(mocker): - """start_time/end_time map to created_at.gte / created_at.lt bounds on the Activity Feed query.""" - client = build_client() - response = {"data": make_activities(0, 1), "has_more": False, "last_id": "activity_0000"} - get_mock = mocker.patch.object(client, "get_activities", return_value=response) - - get_events_command( - client, - args={"limit": "10", "start_time": "2025-06-07T08:09:10Z", "end_time": "2025-06-07T09:09:10Z"}, +import AnthropicClaude as integration_module + + +def test_shim_imports_run_entry_point(): + assert hasattr( + integration_module, "run_anthropic_claude_integration" + ), "AnthropicClaudeApiModule.run_anthropic_claude_integration must be importable via the shim" + + +def test_shim_imports_client_classes(): + assert hasattr(integration_module, "AnthropicClient") + assert hasattr(integration_module, "ComplianceClient") + + +def test_shim_imports_command_functions(): + for name in ( + # LLM commands + "send_message_command", + "check_email_headers_command", + "check_email_body_command", + "create_soc_email_template_command", + # Compliance read-only commands + "list_organizations_command", + "list_organization_users_command", + "list_roles_command", + "list_role_permissions_command", + "list_groups_command", + "list_group_members_command", + "list_chats_command", + "list_chat_messages_command", + "list_projects_command", + "list_project_attachments_command", + "get_project_document_command", + # Delete commands (also exposed on the satellite integration) + "chat_file_delete_command", + "project_document_delete_command", + # Event collector + "fetch_events_command", + "get_events_command", + ): + assert hasattr(integration_module, name), f"Command {name!r} missing from shim" + + +def test_main_delegates_to_api_module(mocker): + mock_run = mocker.patch("AnthropicClaude.run_anthropic_claude_integration") + integration_module.main() + mock_run.assert_called_once_with() + + +def test_main_propagates_exceptions(mocker): + mocker.patch( + "AnthropicClaude.run_anthropic_claude_integration", + side_effect=RuntimeError("boom"), ) - - kwargs = get_mock.call_args.kwargs - assert kwargs["created_at_gte"] == "2025-06-07T08:09:10Z" - assert kwargs["created_at_lt"] == "2025-06-07T09:09:10Z" - - -""" ADDITIONAL COMPLIANCE COMMAND TESTS """ - - -def test_list_role_permissions_command(mocker): - client = build_client() - response = {"data": [{"resource_type": "chats", "action": "read"}], "next_page": "tok-perm"} - get_mock = mocker.patch.object(client, "http_get", return_value=response) - - results = list_role_permissions_command(client, args={"org_uuid": "org-1", "role_id": "role-1"}, params={}) - - assert results.outputs_prefix == "AnthropicClaude.Organization.Role.Permission" - assert results.outputs[0]["resource_type"] == "chats" - assert "roles/role-1/permissions" in get_mock.call_args.args[0] - - -def test_list_role_permissions_missing_role_id_raises(mocker): - """role_id is required for the permissions endpoint; omitting it must raise.""" - client = build_client() - mocker.patch.object(client, "http_get") - with pytest.raises(KeyError): - list_role_permissions_command(client, args={"org_uuid": "org-1"}, params={}) - - -def test_list_project_attachments_command(mocker): - client = build_client() - response = { - "data": [{"id": "att-1", "filename": "diagram.png", "mime_type": "image/png"}], - "next_page": "tok-att", - } - get_mock = mocker.patch.object(client, "http_get", return_value=response) - - results = list_project_attachments_command(client, args={"project_id": "proj-1"}) - - # Attachments merge into the parent Project entry via DT. - assert results.outputs_prefix == "AnthropicClaude.Project(val.id == 'proj-1').Attachment" - assert results.outputs[0]["id"] == "att-1" - assert "projects/proj-1/attachments" in get_mock.call_args.args[0] - assert "tok-att" in results.readable_output - - -def test_list_chats_date_range_param_mapping(mocker): - """created_at_gte argument maps to the created_at.gte query parameter.""" - client = build_client() - response = {"data": [{"id": "chat-1", "name": "Chat"}]} - get_mock = mocker.patch.object(client, "http_get", return_value=response) - - list_chats_command(client, args={"user_ids": "u1", "created_at_gte": "2025-06-07T08:09:10Z"}) - - params = get_mock.call_args.kwargs["params"] - assert params["created_at.gte"] == "2025-06-07T08:09:10Z" - - -def test_http_get_retries_on_rate_limit(mocker): - """ComplianceClient.http_get enables back-off retries on 429 and transient 5xx codes.""" - client = build_client() - request_mock = mocker.patch.object(client, "_http_request", return_value={"data": []}) - - client.http_get("v1/compliance/activities", params={"limit": 1}) - - kwargs = request_mock.call_args.kwargs - assert kwargs["retries"] == Config.MAX_RETRIES - assert kwargs["backoff_factor"] == Config.BACKOFF_FACTOR - assert 429 in kwargs["status_list_to_retry"] - - -""" DELETE COMMAND TESTS """ - - -def make_response(status_code: int, body: dict | None = None): - """Builds a lightweight requests.Response stand-in for delete command tests.""" - response = requests.Response() - response.status_code = status_code - response._content = json.dumps(body).encode() if body is not None else b"" - return response - - -def test_http_delete_retries_on_rate_limit(mocker): - """ComplianceClient.http_delete enables back-off retries and treats 404 as an ok code.""" - client = build_client() - request_mock = mocker.patch.object(client, "_http_request", return_value=make_response(200)) - - client.http_delete("v1/compliance/apps/chats/files/claude_file_1") - - kwargs = request_mock.call_args.kwargs - assert kwargs["method"] == "DELETE" - assert kwargs["resp_type"] == "response" - assert 404 in kwargs["ok_codes"] - assert kwargs["retries"] == Config.MAX_RETRIES - assert kwargs["backoff_factor"] == Config.BACKOFF_FACTOR - assert 429 in kwargs["status_list_to_retry"] - - -def test_chat_file_delete_command_happy_path(mocker): - """Happy path: hits the flat chat-files delete path and reports the deleted id.""" - client = build_client() - response = make_response(200, {"id": "claude_file_1", "type": "claude_file_deleted"}) - delete_mock = mocker.patch.object(client, "http_delete", return_value=response) - - results = chat_file_delete_command(client, args={"file_id": "claude_file_1"}) - - assert "v1/compliance/apps/chats/files/claude_file_1" in delete_mock.call_args.args[0] - assert results.outputs_prefix == "AnthropicClaude.DeletedFile" - assert results.outputs["id"] == "claude_file_1" - assert results.outputs["type"] == "claude_file_deleted" - assert results.outputs["Deleted"] is True - assert "already deleted" not in results.readable_output.lower() - - -def test_chat_file_delete_command_idempotent_on_404(mocker): - """A 404 (already deleted / unknown id) is treated as an idempotent success.""" - client = build_client() - mocker.patch.object(client, "http_delete", return_value=make_response(404)) - - results = chat_file_delete_command(client, args={"file_id": "claude_file_gone"}) - - assert results.outputs["id"] == "claude_file_gone" - assert results.outputs["Deleted"] is True - assert "already deleted" in results.readable_output.lower() - - -def test_chat_file_delete_command_propagates_non_404(mocker): - """Non-404 errors (e.g. 401 insufficient scope) propagate to the caller.""" - client = build_client() - mocker.patch.object( - client, - "http_delete", - side_effect=DemistoException("Error 401: does not have the delete:compliance_user_data scope"), - ) - - with pytest.raises(DemistoException): - chat_file_delete_command(client, args={"file_id": "claude_file_1"}) - - -def test_chat_file_delete_command_missing_arg_raises(mocker): - client = build_client() - mocker.patch.object(client, "http_delete") - with pytest.raises(KeyError): - chat_file_delete_command(client, args={}) - - -def test_project_document_delete_command_happy_path(mocker): - """Happy path: hits the flat project-documents delete path and reports the deleted id.""" - client = build_client() - response = make_response(200, {"id": "claude_proj_doc_1", "type": "claude_project_document_deleted"}) - delete_mock = mocker.patch.object(client, "http_delete", return_value=response) - - results = project_document_delete_command(client, args={"document_id": "claude_proj_doc_1"}) - - assert "v1/compliance/apps/projects/documents/claude_proj_doc_1" in delete_mock.call_args.args[0] - assert results.outputs_prefix == "AnthropicClaude.DeletedProjectDocument" - assert results.outputs["id"] == "claude_proj_doc_1" - assert results.outputs["type"] == "claude_project_document_deleted" - assert results.outputs["Deleted"] is True - - -def test_project_document_delete_command_idempotent_on_404(mocker): - """A 404 (already deleted / unknown id) is treated as an idempotent success.""" - client = build_client() - mocker.patch.object(client, "http_delete", return_value=make_response(404)) - - results = project_document_delete_command(client, args={"document_id": "claude_proj_doc_gone"}) - - assert results.outputs["id"] == "claude_proj_doc_gone" - assert results.outputs["Deleted"] is True - assert "already deleted" in results.readable_output.lower() - - -def test_project_document_delete_command_missing_arg_raises(mocker): - client = build_client() - mocker.patch.object(client, "http_delete") - with pytest.raises(KeyError): - project_document_delete_command(client, args={}) + with pytest.raises(RuntimeError, match="boom"): + integration_module.main() diff --git a/Packs/AnthropicClaude/ReleaseNotes/1_3_2.md b/Packs/AnthropicClaude/ReleaseNotes/1_3_2.md new file mode 100644 index 000000000000..85016360ae68 --- /dev/null +++ b/Packs/AnthropicClaude/ReleaseNotes/1_3_2.md @@ -0,0 +1,6 @@ + +#### Integrations + +##### Anthropic Claude + +- Extracted the Compliance client's constructor, ``x-api-key`` UCP override, ``http_delete``, and the two irreversible delete commands (`claude-chat-file-delete` and `claude-project-document-delete`) into the shared `AnthropicClaudeApiModule`. Behaviour is unchanged. This enables the new Anthropic Claude Standard Connector satellite pack to share the same client and command code. diff --git a/Packs/AnthropicClaude/pack_metadata.json b/Packs/AnthropicClaude/pack_metadata.json index 3b86f1718ab8..5394154dff16 100644 --- a/Packs/AnthropicClaude/pack_metadata.json +++ b/Packs/AnthropicClaude/pack_metadata.json @@ -2,7 +2,7 @@ "name": "Anthropic Claude", "description": "Designed to assist security professionals with security investigations, threat hunting, and anomaly detection, leveraging Anthropic Claude's natural language conversational capabilities.", "support": "xsoar", - "currentVersion": "1.3.1", + "currentVersion": "1.3.2", "author": "Cortex XSOAR", "url": "https://www.paloaltonetworks.com/cortex", "email": "", diff --git a/Packs/AnthropicClaudeStandardConnector/.pack-ignore b/Packs/AnthropicClaudeStandardConnector/.pack-ignore new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/Packs/AnthropicClaudeStandardConnector/.secrets-ignore b/Packs/AnthropicClaudeStandardConnector/.secrets-ignore new file mode 100644 index 000000000000..43c39346a2db --- /dev/null +++ b/Packs/AnthropicClaudeStandardConnector/.secrets-ignore @@ -0,0 +1,3 @@ +https://api.anthropic.com/ +https://platform.claude.com +https://platform.claude.com/docs/en/manage-claude/compliance-api-access diff --git a/Packs/AnthropicClaudeStandardConnector/Integrations/AnthropicClaudeStandardConnector/AnthropicClaudeStandardConnector.py b/Packs/AnthropicClaudeStandardConnector/Integrations/AnthropicClaudeStandardConnector/AnthropicClaudeStandardConnector.py new file mode 100644 index 000000000000..de4dd85324e4 --- /dev/null +++ b/Packs/AnthropicClaudeStandardConnector/Integrations/AnthropicClaudeStandardConnector/AnthropicClaudeStandardConnector.py @@ -0,0 +1,14 @@ +import demistomock as demisto # noqa: F401 +from CommonServerPython import * # noqa: F401 + +from AnthropicClaudeApiModule import * # noqa: E402 + + +def main(): + run_anthropic_claude_integration() + + +from CommonServerUserPython import * # noqa: E402 # pylint: disable=wrong-import-position + +if __name__ in ("__main__", "__builtin__", "builtins"): + main() diff --git a/Packs/AnthropicClaudeStandardConnector/Integrations/AnthropicClaudeStandardConnector/AnthropicClaudeStandardConnector.yml b/Packs/AnthropicClaudeStandardConnector/Integrations/AnthropicClaudeStandardConnector/AnthropicClaudeStandardConnector.yml new file mode 100644 index 000000000000..d7ac6ab38431 --- /dev/null +++ b/Packs/AnthropicClaudeStandardConnector/Integrations/AnthropicClaudeStandardConnector/AnthropicClaudeStandardConnector.yml @@ -0,0 +1,75 @@ +category: Analytics & SIEM +provider: Anthropic +sectionorder: +- Connect +commonfields: + id: AnthropicClaudeStandardConnector + version: -1 +configuration: +- display: '' + displaypassword: Compliance Access Key + hiddenusername: true + name: compliance_apikey + required: false + section: Connect + type: 9 + additionalinfo: The Anthropic Compliance Access Key (sk-ant-api01-...) used for the delete commands. Requires the delete:compliance_user_data scope. +- display: Trust any certificate (not secure) + name: insecure + required: false + type: 8 + section: Connect +- display: Use system proxy settings + name: proxy + required: false + type: 8 + section: Connect +description: 'This integration is configured automatically as part of the Anthropic Claude Standard Connector. Do not configure this integration directly — set it up from the connector page instead.' +display: Anthropic Claude (Standard Connector) +name: AnthropicClaudeStandardConnector +script: + commands: + - arguments: + - description: 'The Claude file ID to permanently delete (e.g., claude_file_...). Deletes a file uploaded in a conversation or a project binary file (project_file). This is an irreversible hard delete.' + name: file_id + required: true + description: 'Permanently delete a Claude file (a conversation file or a project binary file) via the Compliance API. This is an irreversible hard delete, and it requires a Compliance Access Key with the delete:compliance_user_data scope. Deleting an already-deleted or unknown file ID succeeds (idempotent).' + execution: true + name: claude-chat-file-delete + outputs: + - contextPath: AnthropicClaude.DeletedFile.id + description: The ID of the file that was deleted. + type: String + - contextPath: AnthropicClaude.DeletedFile.type + description: The deletion confirmation type (claude_file_deleted). + type: String + - contextPath: AnthropicClaude.DeletedFile.Deleted + description: The deletion result for the file (true when deleted). + type: Boolean + - arguments: + - description: 'The Claude project document ID to permanently delete (e.g., claude_proj_doc_...). Applies to project plain-text documents (project_doc). This is an irreversible hard delete.' + name: document_id + required: true + description: 'Permanently delete a Claude project document (a plain-text project_doc) via the Compliance API. This is an irreversible hard delete, and it requires a Compliance Access Key with the delete:compliance_user_data scope. Deleting an already-deleted or unknown document ID succeeds (idempotent).' + execution: true + name: claude-project-document-delete + outputs: + - contextPath: AnthropicClaude.DeletedProjectDocument.id + description: The ID of the project document that was deleted. + type: String + - contextPath: AnthropicClaude.DeletedProjectDocument.type + description: The deletion confirmation type (claude_project_document_deleted). + type: String + - contextPath: AnthropicClaude.DeletedProjectDocument.Deleted + description: The deletion result for the project document (true when deleted). + type: Boolean + dockerimage: demisto/parse-emails:0.1.48.10569905 + runonce: false + script: '' + subtype: python3 + type: python +fromversion: 8.15.0 +tests: +- No tests +marketplaces: +- platform diff --git a/Packs/AnthropicClaudeStandardConnector/Integrations/AnthropicClaudeStandardConnector/AnthropicClaudeStandardConnector_description.md b/Packs/AnthropicClaudeStandardConnector/Integrations/AnthropicClaudeStandardConnector/AnthropicClaudeStandardConnector_description.md new file mode 100644 index 000000000000..eb5ee8daf220 --- /dev/null +++ b/Packs/AnthropicClaudeStandardConnector/Integrations/AnthropicClaudeStandardConnector/AnthropicClaudeStandardConnector_description.md @@ -0,0 +1,6 @@ +This integration is configured automatically as part of the **Anthropic Claude Standard Connector**. Do not configure this integration directly — set it up from the connector page instead. + +It exposes only the two irreversible Compliance API delete commands +(`claude-chat-file-delete`, `claude-project-document-delete`) and requires +the Anthropic Compliance Access Key (`sk-ant-api01-...`) with the +`delete:compliance_user_data` scope. diff --git a/Packs/AnthropicClaudeStandardConnector/Integrations/AnthropicClaudeStandardConnector/AnthropicClaudeStandardConnector_image.png b/Packs/AnthropicClaudeStandardConnector/Integrations/AnthropicClaudeStandardConnector/AnthropicClaudeStandardConnector_image.png new file mode 100644 index 0000000000000000000000000000000000000000..7b127a62c8254f145aaad75abf418dd765506003 GIT binary patch literal 3974 zcmb_f_d6So*NxTKQH@daV^tI_ilU)4V^cM1REeO(YV1u(6fqjRRBc6yP@34Qb}4Er zO081VF2UFPKfFKOd!Ogt-_AYf+~>v`8^W0Ax#$4^0F#ckhRIb8xC$BCYgaj=lz-}~ z(0OTFdIJCqtp67jqd&sPS4|3U6POyHa)@UG004>WXsDV7*#5Bwnwm~dTwwAUC|}V+ z{^LxL>}C(fHfKyFoJtG}bLqjWTBZM%4gZ>6uKCii^~@o}_27#w>t-#SMuP=xoT}6| z*xHidpVLlpE$#3Yei}*0A$bpx*0wz}Ja@d!+Ha$+sLIvOeI{a}*cR11sLdlj0yt1i z;Ju#Opz5nmWu)3gfH|n1(xz9=xR-zUce{B61L+$5zhz5hVl@?hY{RD{S9*Kb52{Q6 zX(DB#{bMMqm~>i#nKp*uKh0|&3s{UiKl^K10+?_tTHgMAo zZlKqGZrRxu9;G!L_0BoAwVs{+@3sLY<2z;$`z61L&~{&8<0O)1hxz=_RXc(4OBDJ> zUXA~w-69d=2W%sk=~+3nA566-=gq^p60W({SzQdiNlgXwD59u)5_#2I!_JKfa82(L z%fZZ(lK>tCFTmRH`<_oOV~a~md)>kJn4}_p1yBC<`r^al#tno~C!Ep!7OX@Sc4c#1 zuR8)xH3SL-ED0{IWPORRaK7FwhO}9H9lp`C=Io!9m8Iab&f>Q<6**RFv^??AdQndI zjiBc4#o10_(BbbF3oXi4^=@=PAn<%Ir`GR}6^}{?Cv}!u9=BW#S+-!@mGELjFmO9X z)acd1pRFwxUfu-wm$(%eTFc=O;C!n!LSB-|kI(j$B1vAdEB-P!eJ5A4p|e)U`CpFL zHZ9ywLjBF&_~V*O1yXv*+HJu}GT@mLCghl3md#L=ZLf|HdUj+#M|??qxr`ZPprw6; zre|B+*oY+(6>M6=ctQLs->WQZ5ITczK=5jf?`>xsI~q0QfQ2>+-?Wqs#C`+g7=_&*$8xu5vQ0ODsyx zV4t_Lbfn@Q$<*-Wq;1u$NCU_O@kW!?QN-#?uwc5rs*^5mtF!$+3x#^Ax+BtgqGdgQ zxhJWoDR93FwK-Xply%R!KsD-GHGwcv)xNO)ZXsl!Nli`dS5Vq*Gp~(Nk#pjwPqNfB zG(SfQVS8ue6&8<4D973(d%7r(`6jVzS-H8)o}Ql3XmopD-$ugcqtPf|+U$E=Z$%&1 zBs)B^JUQf|u=Y3VI(HUbVs=m4q|Zw_#&^`-Yi+L@hA6aJ+yt>3MXtUq!ajb`*cQI- zBV>|3yRE$hOXP{AVPNmAZV5fRS`-^=E{b*aLw5rAa(|jQYt6>AZrEy2v0g@Z6cBWQ z1B^Kgq=^If68&kV@&fm0ljQF_nklT#ctq+bJ!4UzvCx>xdA#&hp4kwh?AH>#R+Ov< zQARPtL2epL3Hz2$Ke#PU`ZL+z!@c8wL!z;P#&4YywB`}@0UnMtv* z(d^7jzw$=_cg6f`gmifKc|F_To@07aL4d3qDERL4t}UBei1Md}iP|mRnyz-3ePWb# zw4As@{O1jKc_L%!c)1x>Co5Vp6+daSF;W;K*Ez1WHR~Sc#xn!T8v~ln>B|jlZ9N!j}N$5YN1xRaMbiRo605GTf(wNV^67%`3W%X1w>-yt=vG+trD77YTX`?M!!ED zrSHRBef~_>I&(_HsjqB8)Fn2J(pxeCA|u1f$H%9wt)0Ap>Tl1Mckh^d9xjQ=dUm3( zmvLttre@~k8kdN@=_+ShZh6)R)Us-Op_N}fhIxE)5kWS{mLE?N zP;?t9K<_dJZIzoCe{w+^6;O`Q8P?~191I>U=t@+n}OM7kToye*C zLJM!9R)QJ|Rl|Afc(&Y3{KNKFPP_wABvO#m&2>saX~5sw!hfoPgTsj@9KlhfmzMqF zp1n0bIvNSPtnp$D040h<>0{g{f)I_v?;mEc>3lkg*il1KWh%bU?M}Wp`Rh<7$7U~U zZ<_zDc4It_1%UBlRq8K$Qfgdf+dNsX=jr^qgnZ@99Y@^M8~3$AKqAfu-aIhm)+%rqkMgCv+M8tzi|&GALi z(YRAlD;ckPIwpF03o}w;dRrl*j4?x(KVO?vZrs?Q>gbT-%ruzX6_lnCzgG!WxXW%J z;yFjWesOU@`JW|G@Bq#QM1Q5h=oXXVz2wCx(1AfFnUcKrKAc`b7R+@$QeK1_z0q&_ z`c_%djjMa51x?NMgkVJ2tN4|Jz>2GOj)EW_)*-%hly*fQWv4c6$qakLC(}pn;?Y(> zHn**#PyPLhtAkNg$ zf@NRG`#-d;7RchX&68nweWXy@f7{0L!Dm;2Ql-qfNUgr!`wrcV0)etlC8^56M-k5+ zaq(8f-ZFTb=RRA%eg*0_Kon!6vDI>j3wMtXir}pp?4ou!EO%=geXajMM1s06|8p`I zAEmo%oC-&yX{Ct4b@hCl<+l`?Cv!i3tlXPkoYl7F%L#So;-tH=FWs3tN+HxHEiDbF zP5n%hxpk8C@FKYmwVAX=EtRcgR$4F|b^bg+yaQMxo$?8?UYmp%ARE6GtfAN^vM&hSs$ zQX1{98U{(X*?RZW0lW+Ml^E`ZY`l>W19wUR?b2lt#7{vOX^cIq1dbIa{3U9EtxY)9 zu|IVr!vjN7JnLMgmJ-Ku$+@n_WJp?@KU(2WfVmtXo(~g8+DF$HouJBf&vk_!+~rSQ zCyOL!#rBTZE-rG=X7tw;`}Tzgva}1S!xl!h=~`)y8zdh$g+Ac3n1$x4Q-yJK8At3y7x<8KfP%FPH?hd%ITsWjW{ES+WfU|Y}ZO%+caZ$RUbt|pQqCgYPWbfxhd zOKL*XO@Xyxx`u##teFe6S`oF>MOO&5F$Q=$G`;WWpT?N?Za>_~$T2N%jz{j}(IGp-EVdVHQ~@zTD>u znDvkGXapfXjVq9!a5m9Sa$`F=;-lj}vv96cr0A|Snuuep1ygq0WKIg)=g&mE(iMud z56ss#1SH+OckPP#Z|vQi$mIQDt=WDiJ4{%v&6^dIr~5LLob)}_`X;U}oyO3>J%pk_ zVYSB<%kkBFTWWXLxJnFDh-+!$7X9E?Uv2n!c*rwcO?Sum#|$eatQ6%?XCLTveg3h| z_%Lk`b4v6?LU$ZFlWfkZa30Sw&Hg&n%$K4jg4=>YV`~3wQHzM$4mox9&e~Cpyt>-d zpX!;cm|u}pK;4R@jueQWAH-CK!YQ{{N(mB|qwumI+L^v*_r8d5%DYix^D0}(*4jqR zywsZ5$@OzACAh`w(M4X9SDXB1G~ErX0?o903^OkRigdsUuV_-sz=Nb`-y!zr?kCA_ z_#!&Ntk851;XJTBIx{A1o+1#~2>;H}g93f0vu>N?7&A`<#QS?#<7ifsU8)J*$zeVx zP10UvCLCeSuwH|)WqzTMpVVdffl{hk2B2_1*N4z-|9g%1zb31H1KYh=`J@&^Hz0+F%{WLC9~b?z5FUo6?uwvV79(;{t}Zt}1Qijl9m}PHP@}9U2;1 z?Dnf^SmjKA=5xQbL=FsaGjIxnIM-+Y2z@UG%zKF&IM`WOkZpsCEnOGT6S{0)(1XR} zye??r^>>5+@P1t5NE8a1g}b1rjjT?)>m;}t7**$T3J^~~ZOFkfQ%G8sPju#RRz@vD z0(z5BX3u#$?WLQm{yQZztIz{LWm%J3zanBz4mkmacT-XOVHwf;H+eHDr^3v7!ARGj zKUanY*(cpX+P%Uz)#c+mO=7w~@W?|ZZ>kb8qZG@&C}2%!P>7#B*H3E!UGDKBAacop zqv=IEcB@j^eI~Jb6_;y{1_vGzI^OU$H_tU1|nrqRCHz#k71}t@& zh{(yAJ(cVfYt9mw9M~MM(CkM+v+O-e1evlq$%aCCKx10@eGczCY`u{DXxED&a<7z* z*UdPW|1cf@elb(WDmxB|W*#&+4y5=omAPq^UXH8L04W{=3UHy>8r1a`fYCAFl$herSM>rlCfq In%%4a0dcsF2LJ#7 literal 0 HcmV?d00001 diff --git a/Packs/AnthropicClaudeStandardConnector/Integrations/AnthropicClaudeStandardConnector/AnthropicClaudeStandardConnector_test.py b/Packs/AnthropicClaudeStandardConnector/Integrations/AnthropicClaudeStandardConnector/AnthropicClaudeStandardConnector_test.py new file mode 100644 index 000000000000..c249c8f1ae35 --- /dev/null +++ b/Packs/AnthropicClaudeStandardConnector/Integrations/AnthropicClaudeStandardConnector/AnthropicClaudeStandardConnector_test.py @@ -0,0 +1,44 @@ +"""Sanity tests for the AnthropicClaudeStandardConnector shim integration. + +Full behavioural coverage lives in the AnthropicClaudeApiModule tests; this file +exists only to confirm the shim wires through to the ApiModule correctly. The +satellite integration exposes only the two delete commands in its YAML, but the +shared ``run_anthropic_claude_integration()`` function still holds the full +dispatcher — the YAML is the enforcement boundary. +""" + +import pytest + +import AnthropicClaudeStandardConnector as integration_module + + +def test_shim_imports_run_entry_point(): + assert hasattr( + integration_module, "run_anthropic_claude_integration" + ), "AnthropicClaudeApiModule.run_anthropic_claude_integration must be importable via the shim" + + +def test_shim_imports_delete_commands(): + """The satellite integration YAML surfaces exactly these two delete commands.""" + assert hasattr(integration_module, "chat_file_delete_command") + assert hasattr(integration_module, "project_document_delete_command") + + +def test_shim_imports_compliance_client(): + """The delete commands require the ComplianceClient (Compliance Access Key auth).""" + assert hasattr(integration_module, "ComplianceClient") + + +def test_main_delegates_to_api_module(mocker): + mock_run = mocker.patch("AnthropicClaudeStandardConnector.run_anthropic_claude_integration") + integration_module.main() + mock_run.assert_called_once_with() + + +def test_main_propagates_exceptions(mocker): + mocker.patch( + "AnthropicClaudeStandardConnector.run_anthropic_claude_integration", + side_effect=RuntimeError("boom"), + ) + with pytest.raises(RuntimeError, match="boom"): + integration_module.main() diff --git a/Packs/AnthropicClaudeStandardConnector/README.md b/Packs/AnthropicClaudeStandardConnector/README.md new file mode 100644 index 000000000000..4d6f6bfe0ebc --- /dev/null +++ b/Packs/AnthropicClaudeStandardConnector/README.md @@ -0,0 +1,12 @@ +# Anthropic Claude (Standard Connector) + +Satellite pack of the Anthropic Claude integration used for the Standard +Connector deployment. It ships a narrow integration exposing only the two +irreversible Compliance API delete commands +(`claude-chat-file-delete`, `claude-project-document-delete`), sharing all +its code with the parent Anthropic Claude pack via the +`AnthropicClaudeApiModule`. + +This pack is configured automatically as part of the Anthropic Claude +Standard Connector. Do **not** configure the integration directly — set it up +from the connector page instead. diff --git a/Packs/AnthropicClaudeStandardConnector/ReleaseNotes/1_0_0.md b/Packs/AnthropicClaudeStandardConnector/ReleaseNotes/1_0_0.md new file mode 100644 index 000000000000..df35cbe7d288 --- /dev/null +++ b/Packs/AnthropicClaudeStandardConnector/ReleaseNotes/1_0_0.md @@ -0,0 +1,6 @@ + +#### Integrations + +##### New: Anthropic Claude (Standard Connector) + +- Initial release of the Anthropic Claude Standard Connector satellite pack. Exposes the two irreversible Compliance API delete commands (`claude-chat-file-delete` and `claude-project-document-delete`) for use through the Anthropic Claude connector on the Cortex platform. Shares its client code with the parent Anthropic Claude pack via the `AnthropicClaudeApiModule`. diff --git a/Packs/AnthropicClaudeStandardConnector/pack_metadata.json b/Packs/AnthropicClaudeStandardConnector/pack_metadata.json new file mode 100644 index 000000000000..2c1422d24018 --- /dev/null +++ b/Packs/AnthropicClaudeStandardConnector/pack_metadata.json @@ -0,0 +1,29 @@ +{ + "name": "Anthropic Claude (Standard Connector)", + "description": "Satellite pack of Anthropic Claude used for the Standard Connector deployment. Shares core logic with the Anthropic Claude pack via the AnthropicClaudeApiModule.", + "support": "xsoar", + "currentVersion": "1.0.0", + "author": "Cortex XSOAR", + "url": "https://www.paloaltonetworks.com/cortex", + "email": "", + "created": "2026-08-24T00:00:00Z", + "categories": [ + "Analytics & SIEM" + ], + "tags": [], + "useCases": [], + "keywords": [ + "Anthropic", + "Claude" + ], + "marketplaces": [ + "platform" + ], + "supportedModules": [ + "agentix", + "cloud_runtime_security", + "xsiam", + "edr", + "cloud" + ] +} diff --git a/Packs/ApiModules/.secrets-ignore b/Packs/ApiModules/.secrets-ignore index 1525bb8feac0..6b3e2b91153d 100644 --- a/Packs/ApiModules/.secrets-ignore +++ b/Packs/ApiModules/.secrets-ignore @@ -1790,3 +1790,5 @@ YOUNG https://some_url https://c.sharepoint.com https://e.sharepoint.com +https://api.anthropic.com +https://console.anthropic.com diff --git a/Packs/ApiModules/Scripts/AnthropicClaudeApiModule/AnthropicClaudeApiModule.py b/Packs/ApiModules/Scripts/AnthropicClaudeApiModule/AnthropicClaudeApiModule.py new file mode 100644 index 000000000000..a960376d98bb --- /dev/null +++ b/Packs/ApiModules/Scripts/AnthropicClaudeApiModule/AnthropicClaudeApiModule.py @@ -0,0 +1,1288 @@ +"""Shared implementation of the Anthropic Claude integration. + +This module owns **everything** — the LLM (Messages API) client, the Compliance API +client, all prompts, every command handler, the event collector, param parsing, and +the full dispatch (``run_anthropic_claude_integration``). Both integrations in the +content repository are reduced to a MicrosoftGraphFilesStandardConnector-style shim: + +- ``AnthropicClaude`` (parent pack) — full integration, exposes every command and + both API-key params. +- ``AnthropicClaudeStandardConnector`` (satellite pack) — narrow integration wired + to the Anthropic Claude connector, YAML surfaces only the two delete commands and + the Compliance Access Key param. + +The two integration ``.py`` files are byte-for-byte identical shims that just call +``run_anthropic_claude_integration()``. The YAML files (params + commands surface) +are the only difference between the two integrations — the YAML is the enforcement +boundary. +""" + +import demistomock as demisto # noqa: F401 +from CommonServerPython import * # noqa: F401 + +import parse_emails +import requests +import urllib3 + +# Disable insecure warnings +urllib3.disable_warnings() + + +""" CONSTANTS """ + +DATE_FORMAT = "%Y-%m-%dT%H:%M:%SZ" # ISO8601 format with UTC, default in XSOAR +ANTHROPIC_VERSION = "2023-06-01" +EML_FILE_SUFFIX = ".eml" + + +class Config: + """Global static configuration for the Anthropic Claude integration.""" + + # send_events_to_xsiam identifiers (dataset: anthropic_claude_raw). + VENDOR = "anthropic" + PRODUCT = "claude" + + # Activity Feed pagination / fetch budget. + ACTIVITIES_PAGE_SIZE = 5000 # API max page size for the Activity Feed. + MAX_FETCH_CALLS = 10 # API call budget per fetch cycle (5000 x 10 = 50,000 events). + DEFAULT_MAX_EVENTS_PER_FETCH = 50000 + DEFAULT_FETCH_LOOKBACK = "1 minute" # On the first fetch (no last_run), look back this far. + + # Rate-limit / transient-error handling for the Compliance API. + # urllib3 retries with exponential back-off and honors the Retry-After header on 429. + MAX_RETRIES = 3 + BACKOFF_FACTOR = 2 # Sleep ~ BACKOFF_FACTOR * (2 ** (retry - 1)) seconds between attempts. + RETRY_STATUS_CODES = (429, 500, 502, 503, 504) + + # Read-only compliance commands. + DEFAULT_LIST_LIMIT = 50 + + # Documentation links surfaced in user-facing error messages. + COMPLIANCE_KEY_DOCS = "https://platform.claude.com/docs/en/manage-claude/compliance-api-access" + API_KEY_DOCS = "https://console.anthropic.com/keys" + + +class ApiPaths: + """Centralized Anthropic Compliance API endpoint paths (relative to the base URL).""" + + ACTIVITIES = "v1/compliance/activities" + ORGANIZATIONS = "v1/compliance/organizations" + GROUPS = "v1/compliance/groups" + CHATS = "v1/compliance/apps/chats" + PROJECTS = "v1/compliance/apps/projects" + # Flat delete paths (Rev K): a file id / document id is globally unique, so no project scoping. + CHAT_FILES = "v1/compliance/apps/chats/files" + PROJECT_DOCUMENTS = "v1/compliance/apps/projects/documents" + + @classmethod + def organization_users(cls, org_uuid: str) -> str: + return f"{cls.ORGANIZATIONS}/{org_uuid}/users" + + @classmethod + def roles(cls, org_uuid: str) -> str: + return f"{cls.ORGANIZATIONS}/{org_uuid}/roles" + + @classmethod + def role(cls, org_uuid: str, role_id: str) -> str: + return f"{cls.ORGANIZATIONS}/{org_uuid}/roles/{role_id}" + + @classmethod + def role_permissions(cls, org_uuid: str, role_id: str) -> str: + return f"{cls.ORGANIZATIONS}/{org_uuid}/roles/{role_id}/permissions" + + @classmethod + def group(cls, group_id: str) -> str: + return f"{cls.GROUPS}/{group_id}" + + @classmethod + def group_members(cls, group_id: str) -> str: + return f"{cls.GROUPS}/{group_id}/members" + + @classmethod + def chat_messages(cls, chat_id: str) -> str: + return f"{cls.CHATS}/{chat_id}/messages" + + @classmethod + def project(cls, project_id: str) -> str: + return f"{cls.PROJECTS}/{project_id}" + + @classmethod + def project_attachments(cls, project_id: str) -> str: + return f"{cls.PROJECTS}/{project_id}/attachments" + + @classmethod + def project_document(cls, project_id: str, document_id: str) -> str: + return f"{cls.PROJECTS}/{project_id}/documents/{document_id}" + + +class ArgAndParamNames: + MODEL = "model" + MESSAGE = "message" + RESET_CONVERSATION_HISTORY = "reset_conversation_history" + ENTRY_ID = "entry_id" + ADDITIONAL_INSTRUCTIONS = "additional_instructions" + MAX_TOKENS = "max_tokens" + TEMPERATURE = "temperature" + TOP_P = "top_p" + + +class Roles: + ASSISTANT = "assistant" + USER = "user" + + +class EmailParts: + HEADERS = "headers" + BODY = "body" + + +CHECK_EMAIL_HEADERS_PROMPT = """ +I have a set of email headers. +Analyze these headers for any potential security issues such as spoofing, phishing attempts, or other malicious activity. +Please identify any suspicious fields, explain why they might be concerning, and suggest any further actions that could be taken \ +to investigate or mitigate these issues. +Additional instructions: {} + +''' +{} +''' + +Please, review each header, highlighting any red flags and explaining the potential risks associated with them. +Make you answer very concise and easily readable, with references to the email headers if there are, otherwise do not refer to \ +hypothetical problems. +""" + +CHECK_EMAIL_BODY_PROMPT = """ +I have this email body that I suspect may contain security risks such as phishing links, suspicious attachments, +or signs of social engineering. Please analyze the content of this email body, identify any elements that may pose security +threats, and explain why these elements are concerning. Also, suggest any steps that could be taken to further verify these risks +or protect against these threats. +{} +''' +{} +''' + +Highlight potential security risks, and explain the implications of such risks. +Make you answer very concise and easily readable, with references to the email body if there are, otherwise do not refer to \ +hypothetical problems. +""" + +CREATE_SOC_EMAIL_TEMPLATE_PROMPT = """ +Based on the details provided in our conversation and any specific instructions you have been given, +create a professional email template suitable for a Security Operations Center (SOC). +The template should be adaptable, clearly structured, and include placeholders for specific incident details, +recommendations for action, and any necessary escalation points. +Please ensure the tone is appropriate for communication within a cybersecurity context. +{} +""" + + +""" CLIENT CLASSES """ + + +class AnthropicClient(BaseClient): + """Client for the Anthropic LLM Messages API — authenticates with the LLM API Key.""" + + MESSAGES_ENDPOINT = "v1/messages" + + def __init__(self, url: str, api_key: str, model: str, proxy: bool, verify: bool): + super().__init__(base_url=url, proxy=proxy, verify=verify) + + self.api_key = api_key + self.model = model + self.headers = {"x-api-key": self.api_key, "anthropic-version": ANTHROPIC_VERSION, "Content-Type": "application/json"} + + def get_messages(self, chat_context: List[dict[str, str]], completion_params: dict[str, str | None]) -> dict[str, Any]: + """Gets the response to a messages request using the Anthropic API.""" + + # Convert chat context to Anthropic format + messages = [] + for msg in chat_context: + if msg["role"] in [Roles.USER, Roles.ASSISTANT]: + messages.append({"role": msg["role"], "content": msg["content"]}) + + options: Dict[str, Any] = { + ArgAndParamNames.MODEL: self.model, + "messages": messages, + # Anthropic API requires max_tokens to be specified, default to 1024 if not provided + ArgAndParamNames.MAX_TOKENS: 1024, + } + + max_tokens = completion_params.get(ArgAndParamNames.MAX_TOKENS, None) + if max_tokens: + try: + # Ensure max_tokens is a valid integer + options[ArgAndParamNames.MAX_TOKENS] = int(max_tokens) + except (ValueError, TypeError): + # Use default if conversion fails + demisto.debug(f"Could not convert max_tokens value '{max_tokens}' to integer, using default value 1024") + options[ArgAndParamNames.MAX_TOKENS] = 1024 + + temperature = completion_params.get(ArgAndParamNames.TEMPERATURE, None) + if temperature: + options[ArgAndParamNames.TEMPERATURE] = float(temperature) + + top_p = completion_params.get(ArgAndParamNames.TOP_P, None) + if top_p: + options[ArgAndParamNames.TOP_P] = float(top_p) + + demisto.debug(f"anthropic-claude Using options for message: {options=}") + return self._http_request( + method="POST", url_suffix=AnthropicClient.MESSAGES_ENDPOINT, json_data=options, headers=self.headers + ) + + +class ComplianceClient(BaseClient): + """Client for the Anthropic Compliance API (Activity Feed + read-only directory/content endpoints + deletes). + + Authenticates with the Compliance Access Key (``sk-ant-api01-...``) via the ``x-api-key`` header. + + On the ConnectUs (UCP) path, the header is written by :meth:`_apply_ucp_api_key`. On the legacy + XSOAR path, the constructor pre-populates ``self.headers`` and every request passes it via the + ``headers=`` argument on ``_http_request``. + """ + + def __init__(self, url: str, api_key: str, proxy: bool, verify: bool): + super().__init__(base_url=url, proxy=proxy, verify=verify) + self.api_key = api_key + # Legacy XSOAR path: header is set here and forwarded on every request. + # On the UCP path api_key will be None (the profile supplies the credential via + # _apply_ucp_api_key below); the header written here is superseded by the UCP context. + self.headers = {"accept": "application/json", "x-api-key": self.api_key} + + def _apply_ucp_api_key(self, credentials, ctx): + """Override the default ``Authorization: Bearer …`` placement. + + The Anthropic Compliance API rejects Bearer auth; it requires ``x-api-key: {key}``. The + ConnectUs profile has ``type: api_key`` and ``metadata.auth.parameter: api_key``, which the + CSP aliases to the envelope key ``key``. This override reads that key and writes the correct + header, matching the flat-form pattern documented on :class:`BaseClient` in CommonServerPython. + + Raises :class:`UcpException` if the key is missing so the dispatcher surfaces the generic UCP + authentication error rather than sending a request with an empty header. + """ + api_key_data = credentials.get("api_key", credentials) + key = api_key_data.get("key", "") + if not key: + demisto.error("[UCP][AnthropicClaudeApiModule.py] API key is empty in UCP credentials") + raise UcpException + ctx.headers["x-api-key"] = key + # accept header is always safe to add; harmless if the caller already set it. + ctx.headers.setdefault("accept", "application/json") + + def http_get(self, url_suffix: str, params: dict[str, Any] | None = None) -> dict[str, Any]: + """Performs an authenticated GET request against a Compliance API endpoint. + + Retries on rate-limit (429) and transient 5xx responses using exponential back-off; the + underlying urllib3 Retry honors the server's ``Retry-After`` header when present. + """ + return self._http_request( + method="GET", + url_suffix=url_suffix, + params=params, + headers=self.headers, + retries=Config.MAX_RETRIES, + backoff_factor=Config.BACKOFF_FACTOR, + status_list_to_retry=list(Config.RETRY_STATUS_CODES), + ) + + def http_delete(self, url_suffix: str) -> requests.Response: + """Performs an authenticated DELETE request against a Compliance API endpoint. + + Returns the raw :class:`requests.Response` so callers can inspect the HTTP status code + directly (a 404 means the resource is already gone, which the delete commands treat as an + idempotent success). A 404 is included in ``ok_codes`` so it does not raise. + + Retries on rate-limit (429) and transient 5xx responses using exponential back-off; the + underlying urllib3 Retry honors the server's ``Retry-After`` header when present. + """ + return self._http_request( + method="DELETE", + url_suffix=url_suffix, + headers=self.headers, + resp_type="response", + ok_codes=(200, 204, 404), + retries=Config.MAX_RETRIES, + backoff_factor=Config.BACKOFF_FACTOR, + status_list_to_retry=list(Config.RETRY_STATUS_CODES), + ) + + def get_activities( + self, + limit: int, + created_at_gte: str | None = None, + created_at_gt: str | None = None, + created_at_lt: str | None = None, + after_id: str | None = None, + activity_types: list[str] | None = None, + ) -> dict[str, Any]: + """Fetches a single page of the Activity Feed (``GET /v1/compliance/activities``).""" + params: dict[str, Any] = {"limit": limit} + if after_id: + params["after_id"] = after_id + else: + # Time-window bounds only apply to the first call of a cycle (cursor takes over afterwards). + if created_at_gte: + params["created_at.gte"] = created_at_gte + if created_at_gt: + params["created_at.gt"] = created_at_gt + if created_at_lt: + params["created_at.lt"] = created_at_lt + if activity_types: + params["activity_types[]"] = activity_types + return self.http_get(ApiPaths.ACTIVITIES, params=params) + + +""" HELPER FUNCTIONS """ + + +def ensure_api_key(api_key: str | None) -> None: + """Fail fast with a helpful error if the Anthropic LLM API Key is not configured.""" + if not api_key: + raise DemistoException( + "This command requires the Anthropic API Key, which is not configured. " + "Set the 'API Key' integration parameter. " + f"Generate one here: {Config.API_KEY_DOCS}" + ) + + +def ensure_compliance_key(compliance_api_key: str | None) -> None: + """Fail fast with a helpful error if the Compliance Access Key is not configured.""" + if not compliance_api_key: + raise DemistoException( + "This command requires the Anthropic Compliance Access Key (sk-ant-api01-...), which is not configured. " + "Set the 'Compliance Access Key' integration parameter. " + f"See how to obtain one here: {Config.COMPLIANCE_KEY_DOCS}" + ) + + +def resolve_org_uuid(args: dict[str, Any], params: dict[str, Any]) -> str: + """Resolve the organization UUID, preferring the command argument over the instance parameter.""" + org_uuid = args.get("org_uuid") or params.get("organization_uuid") + if not org_uuid: + raise DemistoException( + "An Organization UUID is required for this command. Provide the 'org_uuid' argument or set the " + "'Organization UUID' integration parameter. Run 'claude-list-organizations' to find available UUIDs." + ) + return org_uuid + + +def conversation_to_chat_context(conversation: List[dict[str, str]]) -> List[dict[str, str]]: + """A 'Conversation' list that was retrieved from 'demisto.context()' is formatted to be more intuitive for XSOAR users + and is formatted as: [ + {'user': '', 'assistant': ''}, + {'user': '', 'assistant': ''}, + ... + ]. + + The conversational format that is supported by the Anthropic Messages API is a sequence of messages, + labeled with roles: + [ + {'role': 'user', 'content': ''}, + {'role': 'assistant', 'content': ''}, + {'role': 'user', 'content': ''}, + {'role': 'assistant', 'content': ''}, + ... + ] + + Therefore, it has to be transformed. + """ + + chat_context = [] + for element in conversation: + demisto.debug(f"anthropic-claude conversation_to_chat_context reading {element=} from conversation") + chat_context.append({"role": Roles.USER, "content": element.get(Roles.USER, "")}) + chat_context.append({"role": Roles.ASSISTANT, "content": element.get(Roles.ASSISTANT, "")}) + + return chat_context + + +def get_chat_context(reset_conversation_history: bool, message: str) -> List[dict[str, str]]: + """ + Retrieves the existing chat conversation history from the incident context, if exists. + If `reset_conversation_history` is True, or if no conversation history exists, it initializes a new conversation list + with the given message and returns it. + + Args: + reset_conversation_history (bool): Flag to determine whether to reset the existing conversation history. + message (str): The new message to be added to the conversation. + + Returns: + List[Dict[str, str]]: The updated conversation history with the new message appended. + """ + # Retrieve or initialize conversation history based on the context and reset flag + conversation = demisto.context().get("AnthropicClaude", {}).get("Conversation") + + if reset_conversation_history or not conversation: + conversation = [] + demisto.debug("anthropic-claude get_chat_context conversation history reset or initialized as empty.") + else: + demisto.debug( + f"anthropic-claude get_chat_context using conversation history from context:" + f" [type(conversation)={type(conversation)}]{conversation=}" + ) + + # Create the chat context which is suitable with the required format for a 'messages' request. + chat_context = conversation_to_chat_context(conversation) + chat_context.append({"role": Roles.USER, "content": message}) + demisto.debug(f"anthropic-claude get_chat_context updated chat_context with new message: {chat_context=}") + return chat_context + + +def extract_assistant_message(response: dict[str, Any]) -> str: + """ + Extracts the assistant message from a response. + Returns: + The assistant message as a string. + """ + if not response: + return_error("Could not retrieve message from response.") + + content = response.get("content", []) + if not content: + return_error("Could not retrieve content from response.") + + message_content = "" + for item in content: + if item.get("type") == "text": + message_content += item.get("text", "") + + if not message_content: + return_error("Could not retrieve text from response content.") + + return message_content + + +def get_email_parts(entry_id: str) -> tuple[List[dict[str, str]] | None, str | None, str | None, str | None]: + """ + Extracts and parses the headers, text body, and HTML body from an .eml file identified by a given entry ID. + + Args: + - entry_id (str): The unique identifier for the uploaded .eml file in the war room. + + Returns: + - tuple[List[Dict[str, str]] | None, str | None, str | None]: A tuple containing three elements: + - headers (List[Dict[str, str]] | None): A list of dictionaries where each dictionary represents an email header. + - text_body (str | None): The plain text body of the email, if available. + - html_body (str | None): The HTML body of the email, if available. + - file_name (str | None): The name of the .eml file in the war room. + """ + if not entry_id: + DemistoException("Provide an entryId of an uploaded '.eml' file.") + + get_file_path_res = demisto.getFilePath(entry_id) + file_path = get_file_path_res["path"] + file_name = get_file_path_res["name"] + + if not file_name.endswith(EML_FILE_SUFFIX): + DemistoException("Provided 'entry_id' does not point to a valid '.eml' file.") + + email_parser = parse_emails.EmailParser(file_path=file_path) + email_parser.parse() + + headers, text_body, html_body = ( + email_parser.parsed_email.get("Headers", None), + email_parser.parsed_email.get("Text", None), + email_parser.parsed_email.get("HTML", None), + ) + return headers, text_body, html_body, file_name + + +def check_email_part(email_part: str, client: "AnthropicClient", args: dict[str, Any]) -> CommandResults: + """ + Checks email parts (headers/body) for potential security issues using predefined prompts + ('CHECK_EMAIL_HEADERS_PROMPT', 'CHECK_EMAIL_BODY_PROMPT') that are sent to the Claude model. + """ + entry_id: str = args.get(ArgAndParamNames.ENTRY_ID, "") + email_headers, email_text_body, email_html_body, file_name = get_email_parts(entry_id) + additional_instructions = ( + (f"anthropic-claude check_email_part " f"Additional instructions: {ArgAndParamNames.ADDITIONAL_INSTRUCTIONS}\n") + if args.get(ArgAndParamNames.ADDITIONAL_INSTRUCTIONS, "") + else "" + ) + + if email_part == EmailParts.HEADERS: + demisto.debug(f"anthropic-claude checking email headers: {email_headers=}") + if email_headers: + email_headers_formatted = { + header["name"]: header["value"] for header in email_headers if "name" in header and "value" in header + } + readable_input = tableToMarkdown(name=f"{file_name} headers:", t=email_headers_formatted, sort_headers=False) + check_email_part_message = CHECK_EMAIL_HEADERS_PROMPT.format(additional_instructions, readable_input) + + else: + raise DemistoException("'parse_emails' did not extract any email headers from the provided file..") + elif email_part == EmailParts.BODY: + demisto.debug(f"anthropic-claude checking email body: {email_text_body=} {email_html_body=}") + + if not email_text_body and not email_html_body: + raise DemistoException("'email_parser' did not extract any email body from the provided file.") + + email_text_body = email_text_body if email_text_body else "" + email_html_body = email_html_body if email_html_body else "" + + email_body = {"Body/Text": email_text_body, "HTML/Text": email_html_body} + + readable_input = tableToMarkdown(name=f"{file_name} body:", t=email_body, sort_headers=False) + check_email_part_message = CHECK_EMAIL_BODY_PROMPT.format(additional_instructions, readable_input) + else: + raise DemistoException("Invalid email part to check provided.") + + demisto.debug(f"anthropic-claude check_email_part {check_email_part_message=}") + + # Starting a new conversation as of a new topic discussed. + args.update({ArgAndParamNames.RESET_CONVERSATION_HISTORY: "yes", ArgAndParamNames.MESSAGE: check_email_part_message}) + send_message_command_results, response = send_message_command(client, args) + + # Displaying the analyzed email part to the war room and setting the context for the email checking response + # prior to returning the 'send-message-command' results and the entire conversation to the context. + return_results( + CommandResults( + readable_output=readable_input, + outputs_prefix="AnthropicClaude.Email" + email_part.capitalize(), + outputs={"Email" + email_part.capitalize(): readable_input, "Response": response}, + replace_existing=True, + ) + ) + return send_message_command_results + + +""" LLM COMMAND FUNCTIONS """ + + +def module_test_llm(client: AnthropicClient, params: dict) -> str: + """Tests LLM API connectivity and authentication along with model compatability with 'Messages' endpoint. + + Returning 'ok' indicates that the integration works like it is supposed to. + Connection to the service is successful. + Raises exceptions if something goes wrong. + + Named ``module_test_llm`` (not ``test_module``) so pytest does not collect this production + function as a test case — pytest treats every top-level ``test_*`` function as a test and + would report ``fixture 'client' not found`` at collection time. Same pattern as + ``MicrosoftGraphFilesApiModule.test_function()`` and matches the sibling + ``module_test_compliance()`` helper below. + """ + message = "" + try: + chat_message = {"role": "user", "content": "test"} + completion_params = { + ArgAndParamNames.MAX_TOKENS: int(params.get(ArgAndParamNames.MAX_TOKENS, "").replace(",", "") or 1024), + ArgAndParamNames.TEMPERATURE: params.get(ArgAndParamNames.TEMPERATURE, None), + ArgAndParamNames.TOP_P: params.get(ArgAndParamNames.TOP_P, None), + } + client.get_messages(chat_context=[chat_message], completion_params=completion_params) + message = "ok" + except DemistoException as e: + if "Forbidden" in str(e) or "Authorization" in str(e): + message = "Authorization Error: make sure API Key is correctly set" + else: + raise e + return message + + +def send_message_command(client: AnthropicClient, args: dict[str, Any]) -> tuple[CommandResults, dict[str, Any]]: + """ + Sending a message with conversation context to an Anthropic Claude model and retrieving the generated response. + """ + message = args.get(ArgAndParamNames.MESSAGE, "") + if not message: + raise ValueError("Message not provided") + + completion_params = { + ArgAndParamNames.MAX_TOKENS: int(args.get(ArgAndParamNames.MAX_TOKENS, "").replace(",", "") or 1024), + ArgAndParamNames.TEMPERATURE: args.get(ArgAndParamNames.TEMPERATURE, None), + ArgAndParamNames.TOP_P: args.get(ArgAndParamNames.TOP_P, None), + } + + reset_conversation_history = args.get(ArgAndParamNames.RESET_CONVERSATION_HISTORY, "") == "yes" + chat_context = get_chat_context(reset_conversation_history, message) + demisto.debug(f"anthropic-claude send_message_command {chat_context=}, {completion_params=}") + + response = client.get_messages(chat_context=chat_context, completion_params=completion_params) + demisto.debug(f"anthropic-claude send_message_command {response=}") + + assistant_message = extract_assistant_message(response) + conversation_step = [{Roles.USER: message, Roles.ASSISTANT: assistant_message}] + + usage: dict[str, str] = response.get("usage", {}) + + readable_output = ( + assistant_message + + "\n" + + tableToMarkdown( + name=f'{response.get(ArgAndParamNames.MODEL, "")} response:', + sort_headers=False, + t={ + "Input tokens": usage.get("input_tokens", ""), + "Output tokens": usage.get("output_tokens", ""), + "Context messages": str(len(chat_context)), + }, + ) + ) + return CommandResults( + outputs_prefix="AnthropicClaude.Conversation", + outputs=conversation_step, + replace_existing=reset_conversation_history, + readable_output=readable_output, + ), response + + +def check_email_headers_command(client: AnthropicClient, args: dict[str, Any]) -> CommandResults: + return check_email_part(EmailParts.HEADERS, client, args) + + +def check_email_body_command(client: AnthropicClient, args: dict[str, Any]) -> CommandResults: + return check_email_part(EmailParts.BODY, client, args) + + +def create_soc_email_template_command(client: AnthropicClient, args: dict[str, Any]) -> CommandResults: + additional_instructions = ( + f"Additional instructions: {args.get(ArgAndParamNames.ADDITIONAL_INSTRUCTIONS)}\n" + if args.get(ArgAndParamNames.ADDITIONAL_INSTRUCTIONS, "") + else "" + ) + create_soc_email_template_message = CREATE_SOC_EMAIL_TEMPLATE_PROMPT.format(additional_instructions) + args.update({ArgAndParamNames.MESSAGE: create_soc_email_template_message}) + send_message_command_results, response = send_message_command(client, args) + # Setting the SOCEmailTemplate context prior to returning the 'send-message-command' results + # and setting the entire conversation in the context. + return_results( + CommandResults(outputs_prefix="AnthropicClaude.SocEmailTemplate", outputs={"Response": response}, replace_existing=True) + ) + return send_message_command_results + + +""" EVENT COLLECTOR FUNCTIONS """ + + +def add_time_to_events(events: list[dict[str, Any]]) -> None: + """Sets the ``_time`` field on each event from the documented ``created_at`` timestamp.""" + for event in events: + created_at = event.get("created_at") + if created_at: + event["_time"] = created_at + + +def deduplicate_events(events: list[dict[str, Any]], last_fetched_ids: list[str]) -> list[dict[str, Any]]: + """Remove already-processed events based on previously fetched IDs. + + The Activity Feed is queried with a half-open time window (``created_at.gt``), but events that + share the exact boundary timestamp may reappear across consecutive runs. We dedup them using the + IDs persisted in the previous ``last_run``. + """ + if not events or not last_fetched_ids: + return events + + fetched_ids = set(last_fetched_ids) + new_events = [event for event in events if event.get("id") not in fetched_ids] + skipped = len(events) - len(new_events) + if skipped: + demisto.debug(f"[Dedup] Skipped {skipped} duplicate events; {len(new_events)} new events remain.") + return new_events + + +def fetch_events_with_pagination( + client: ComplianceClient, + last_run: dict[str, Any], + max_events: int, + activity_types: list[str] | None, +) -> tuple[list[dict[str, Any]], dict[str, Any]]: + """Fetch Activity Feed events incrementally using cursor pagination. + + The first call of a cycle uses ``created_at.gt`` against the newest timestamp seen in the + previous run. On the very first run (no ``last_run``) it looks back a fixed one-minute window. + Subsequent pages within the same cycle advance using the opaque ``after_id`` cursor, until + ``has_more`` is ``False``, the per-fetch event cap is reached, or the API-call budget is exhausted. + + To guarantee no events are lost across runs, the persisted cursor (``newest_created_at`` and the + boundary ``last_fetched_ids``) is derived only from the events actually returned to the caller — + never from events that were dropped by the per-fetch cap. This keeps the cursor from advancing + past undelivered events. + + Returns the collected events and the next ``last_run`` state. + """ + previous_newest = last_run.get("newest_created_at") + previous_ids = last_run.get("last_fetched_ids", []) + if previous_newest: + created_at_gt: str | None = previous_newest + created_at_gte: str | None = None + else: + # No stored state: default to a one-minute lookback and let next_run advance the cursor. + lookback_dt = arg_to_datetime(Config.DEFAULT_FETCH_LOOKBACK) + created_at_gte = lookback_dt.strftime(DATE_FORMAT) if lookback_dt else None + created_at_gt = None + + collected: list[dict[str, Any]] = [] + after_id: str | None = None + + for call_num in range(Config.MAX_FETCH_CALLS): + if len(collected) >= max_events: + break + page_limit = min(Config.ACTIVITIES_PAGE_SIZE, max_events - len(collected)) + response = client.get_activities( + limit=page_limit, + created_at_gte=created_at_gte, + # Apply the time bound only on the first call; the cursor (after_id) drives the rest. + created_at_gt=created_at_gt if call_num == 0 else None, + after_id=after_id, + activity_types=activity_types, + ) + activities = response.get("data", []) or [] + demisto.debug(f"[Fetch] Call {call_num}: fetched {len(activities)} activities.") + + collected.extend(activities) + + after_id = response.get("last_id") + if not response.get("has_more") or not after_id: + break + + # Drop events already pushed in a prior run (boundary-timestamp duplicates), then cap to the budget. + deduped = deduplicate_events(collected, previous_ids)[:max_events] + + # Derive the cursor from the DELIVERED events only, so capping never advances past undelivered ones. + newest_created_at = previous_newest + for event in deduped: + created_at = event.get("created_at") + if created_at and (not newest_created_at or created_at > newest_created_at): + newest_created_at = created_at + + # Persist the IDs sharing the newest delivered timestamp so the next run can dedup boundary events. + # When nothing new was delivered, carry the previous boundary IDs forward to keep dedup intact. + boundary_ids = [e["id"] for e in deduped if e.get("id") and e.get("created_at") == newest_created_at] + next_run = { + "newest_created_at": newest_created_at, + "last_fetched_ids": boundary_ids or previous_ids, + } + return deduped, next_run + + +def fetch_events_command(client: ComplianceClient, params: dict[str, Any]) -> None: + """Fetch-events entry point: pull Activity Feed events and push them to XSIAM.""" + last_run = demisto.getLastRun() or {} + max_events = arg_to_number(params.get("max_events_per_fetch")) or Config.DEFAULT_MAX_EVENTS_PER_FETCH + activity_types = argToList(params.get("activity_types")) or None + + events, next_run = fetch_events_with_pagination(client, last_run, max_events, activity_types) + + if events: + add_time_to_events(events) + send_events_to_xsiam(events, vendor=Config.VENDOR, product=Config.PRODUCT) + else: + demisto.debug("[Fetch] No new events to send to XSIAM this cycle.") + + # Persist the cursor regardless of whether events were found, so the next run advances correctly. + demisto.setLastRun(next_run) + demisto.info(f"[Fetch] Completed fetch cycle: sent {len(events)} events to XSIAM. {next_run=}") + + +def get_events_command(client: ComplianceClient, args: dict[str, Any]) -> tuple[list[dict[str, Any]], CommandResults]: + """Manually retrieve Activity Feed events for testing/troubleshooting. + + Supports optional ``start_time``/``end_time`` arguments to bound the Activity Feed query by + creation time (RFC 3339, e.g. ``2025-06-07T08:09:10Z``). + """ + limit = arg_to_number(args.get("limit")) or Config.DEFAULT_LIST_LIMIT + activity_types = argToList(args.get("activity_types")) or None + + start_dt = arg_to_datetime(args.get("start_time")) + end_dt = arg_to_datetime(args.get("end_time")) + created_at_gte = start_dt.strftime(DATE_FORMAT) if start_dt else None + created_at_lt = end_dt.strftime(DATE_FORMAT) if end_dt else None + + response = client.get_activities( + limit=min(limit, Config.ACTIVITIES_PAGE_SIZE), + created_at_gte=created_at_gte, + created_at_lt=created_at_lt, + activity_types=activity_types, + ) + events = (response.get("data", []) or [])[:limit] + add_time_to_events(events) + + readable = tableToMarkdown( + name="Anthropic Claude Activity Feed events", + t=events, + headers=["id", "created_at", "activity_type"], + removeNull=True, + ) + results = CommandResults( + outputs_prefix="AnthropicClaude.Event", + outputs_key_field="id", + outputs=events, + readable_output=readable, + raw_response=response, + ) + return events, results + + +""" COMPLIANCE COMMAND FUNCTIONS """ + + +def _paginate_args(args: dict[str, Any]) -> dict[str, Any]: + """Builds common list query params (limit + XSOAR page-token convention).""" + params: dict[str, Any] = {} + if limit := arg_to_number(args.get("limit")): + params["limit"] = limit + if next_token := args.get("next_token"): + params["page"] = next_token + return params + + +def _list_command( + client: ComplianceClient, + url_suffix: str, + outputs_prefix: str, + args: dict[str, Any], + headers: list[str], + table_name: str, + use_pagination: bool = True, +) -> CommandResults: + """Generic GET-and-tabulate helper for the read-only compliance list endpoints.""" + params = _paginate_args(args) if use_pagination else {} + response = client.http_get(url_suffix, params=params or None) + data = response.get("data", response) + readable = tableToMarkdown(name=table_name, t=data, headers=headers, removeNull=True) + if next_page := response.get("next_page"): + readable += f"\n**Next page token:** `{next_page}`" + return CommandResults( + outputs_prefix=outputs_prefix, + outputs_key_field="id", + outputs=data, + readable_output=readable, + raw_response=response, + ) + + +def list_organizations_command(client: ComplianceClient, args: dict[str, Any]) -> CommandResults: + limit = arg_to_number(args.get("limit")) or Config.DEFAULT_LIST_LIMIT + response = client.http_get(ApiPaths.ORGANIZATIONS, params={"limit": limit}) + data = (response.get("data", []) or [])[:limit] + readable = tableToMarkdown("Organizations", data, headers=["uuid", "name", "created_at"], removeNull=True) + return CommandResults( + outputs_prefix="AnthropicClaude.Organization", + outputs_key_field="uuid", + outputs=data, + readable_output=readable, + raw_response=response, + ) + + +def list_organization_users_command(client: ComplianceClient, args: dict[str, Any], params: dict[str, Any]) -> CommandResults: + org_uuid = resolve_org_uuid(args, params) + return _list_command( + client, + ApiPaths.organization_users(org_uuid), + "AnthropicClaude.Organization.User", + args, + headers=["id", "full_name", "email", "organization_role", "created_at"], + table_name="Organization Users", + ) + + +def list_roles_command(client: ComplianceClient, args: dict[str, Any], params: dict[str, Any]) -> CommandResults: + org_uuid = resolve_org_uuid(args, params) + role_id = args.get("role_id") + headers = ["id", "name", "description", "created_at", "updated_at"] + if role_id: + response = client.http_get(ApiPaths.role(org_uuid, role_id)) + readable = tableToMarkdown("Role", response, headers=headers, removeNull=True) + return CommandResults( + outputs_prefix="AnthropicClaude.Organization.Role", + outputs_key_field="id", + outputs=response, + readable_output=readable, + raw_response=response, + ) + return _list_command( + client, + ApiPaths.roles(org_uuid), + "AnthropicClaude.Organization.Role", + args, + headers=headers, + table_name="Roles", + ) + + +def list_role_permissions_command(client: ComplianceClient, args: dict[str, Any], params: dict[str, Any]) -> CommandResults: + org_uuid = resolve_org_uuid(args, params) + role_id = args["role_id"] + return _list_command( + client, + ApiPaths.role_permissions(org_uuid, role_id), + "AnthropicClaude.Organization.Role.Permission", + args, + headers=["resource_type", "resource_id", "action"], + table_name="Role Permissions", + ) + + +def list_groups_command(client: ComplianceClient, args: dict[str, Any]) -> CommandResults: + group_id = args.get("group_id") + headers = ["id", "name", "description", "source_type", "roles", "created_at", "updated_at"] + if group_id: + response = client.http_get(ApiPaths.group(group_id)) + readable = tableToMarkdown("Group", response, headers=headers, removeNull=True) + return CommandResults( + outputs_prefix="AnthropicClaude.Group", + outputs_key_field="id", + outputs=response, + readable_output=readable, + raw_response=response, + ) + return _list_command( + client, + ApiPaths.GROUPS, + "AnthropicClaude.Group", + args, + headers=headers, + table_name="Groups", + ) + + +def list_group_members_command(client: ComplianceClient, args: dict[str, Any]) -> CommandResults: + group_id = args["group_id"] + params = _paginate_args(args) + response = client.http_get(ApiPaths.group_members(group_id), params=params or None) + members = response.get("data", []) + readable = tableToMarkdown( + f"Group {group_id} Members", members, headers=["user_id", "email", "created_at", "updated_at"], removeNull=True + ) + if next_page := response.get("next_page"): + readable += f"\n**Next page token:** `{next_page}`" + # Merge the members into the matching Group context entry via DT, keyed on the group ID. + return CommandResults( + outputs_prefix=f"AnthropicClaude.Group(val.id == '{group_id}').Member", + outputs_key_field="user_id", + outputs=members, + readable_output=readable, + raw_response=response, + ) + + +def list_chats_command(client: ComplianceClient, args: dict[str, Any]) -> CommandResults: + params: dict[str, Any] = {} + if user_ids := argToList(args.get("user_ids")): + params["user_ids[]"] = user_ids + if organization_ids := argToList(args.get("organization_ids")): + params["organization_ids[]"] = organization_ids + if project_ids := argToList(args.get("project_ids")): + params["project_ids[]"] = project_ids + for arg_name in ("created_at_gte", "created_at_lte", "updated_at_gte", "updated_at_lte", "after_id", "before_id"): + if value := args.get(arg_name): + params[arg_name.replace("_gte", ".gte").replace("_lte", ".lte") if "_at_" in arg_name else arg_name] = value + if limit := arg_to_number(args.get("limit")): + params["limit"] = limit + response = client.http_get(ApiPaths.CHATS, params=params) + data = response.get("data", []) + headers = ["id", "name", "created_at", "updated_at", "deleted_at", "href", "model", "organization_uuid", "project_id"] + readable = tableToMarkdown("Chats", data, headers=headers, removeNull=True) + return CommandResults( + outputs_prefix="AnthropicClaude.Chat", + outputs_key_field="id", + outputs=data, + readable_output=readable, + raw_response=response, + ) + + +def list_chat_messages_command(client: ComplianceClient, args: dict[str, Any]) -> CommandResults: + chat_id = args["chat_id"] + params: dict[str, Any] = {} + if limit := arg_to_number(args.get("limit")): + params["limit"] = limit + for arg_name in ("after_id", "before_id", "order"): + if value := args.get(arg_name): + params[arg_name] = value + for arg_name in ("created_at_gte", "created_at_lte", "updated_at_gte", "updated_at_lte"): + if value := args.get(arg_name): + params[arg_name.replace("_gte", ".gte").replace("_lte", ".lte")] = value + response = client.http_get(ApiPaths.chat_messages(chat_id), params=params or None) + data = response.get("chat_messages", []) + readable = tableToMarkdown(f"Chat {chat_id} Messages", data, headers=["id", "role", "created_at"], removeNull=True) + # Merge the messages into the matching Chat context entry via DT, keyed on the chat ID. + return CommandResults( + outputs_prefix=f"AnthropicClaude.Chat(val.id == '{chat_id}').Message", + outputs_key_field="id", + outputs=data, + readable_output=readable, + raw_response=response, + ) + + +def list_projects_command(client: ComplianceClient, args: dict[str, Any]) -> CommandResults: + project_id = args.get("project_id") + headers = [ + "id", + "name", + "is_private", + "organization_uuid", + "created_at", + "updated_at", + "deleted_at", + ] + if project_id: + response = client.http_get(ApiPaths.project(project_id)) + readable = tableToMarkdown("Project", response, headers=headers, removeNull=True) + return CommandResults( + outputs_prefix="AnthropicClaude.Project", + outputs_key_field="id", + outputs=response, + readable_output=readable, + raw_response=response, + ) + return _list_command( + client, + ApiPaths.PROJECTS, + "AnthropicClaude.Project", + args, + headers=headers, + table_name="Projects", + ) + + +def list_project_attachments_command(client: ComplianceClient, args: dict[str, Any]) -> CommandResults: + project_id = args["project_id"] + params = _paginate_args(args) + response = client.http_get(ApiPaths.project_attachments(project_id), params=params or None) + attachments = response.get("data", []) + readable = tableToMarkdown( + f"Project {project_id} Attachments", + attachments, + headers=["id", "filename", "mime_type", "type", "created_at"], + removeNull=True, + ) + if next_page := response.get("next_page"): + readable += f"\n**Next page token:** `{next_page}`" + # Merge the attachments into the matching Project context entry via DT, keyed on the project ID. + return CommandResults( + outputs_prefix=f"AnthropicClaude.Project(val.id == '{project_id}').Attachment", + outputs_key_field="id", + outputs=attachments, + readable_output=readable, + raw_response=response, + ) + + +def get_project_document_command(client: ComplianceClient, args: dict[str, Any]) -> CommandResults: + project_id = args["project_id"] + document_id = args["document_id"] + response = client.http_get(ApiPaths.project_document(project_id, document_id)) + readable = tableToMarkdown( + "Project Document", + response, + headers=["id", "filename", "mime_type", "created_at"], + removeNull=True, + ) + return CommandResults( + outputs_prefix="AnthropicClaude.ProjectDocument", + outputs_key_field="id", + outputs=response, + readable_output=readable, + raw_response=response, + ) + + +def _delete_command( + client: ComplianceClient, + resource_id: str, + url_suffix: str, + deleted_type: str, + outputs_prefix: str, + resource_label: str, +) -> CommandResults: + """Generic DELETE-and-report helper for the irreversible compliance delete endpoints. + + The HTTP status code is inspected directly: a 404 means the resource is already gone, which is + treated as an idempotent success so re-running a delete is always safe. Any other non-2xx code + is raised by ``http_delete`` before reaching here. + """ + response = client.http_delete(url_suffix) + already_deleted = response.status_code == 404 + + try: + raw_response = response.json() + except ValueError: + raw_response = {"id": resource_id, "type": deleted_type} + + outputs = {"id": resource_id, "type": deleted_type, "Deleted": True} + note = " (was already deleted)" if already_deleted else "" + readable = tableToMarkdown( + f"{resource_label} deleted{note}", + outputs, + headers=["id", "type", "Deleted"], + removeNull=True, + ) + return CommandResults( + outputs_prefix=outputs_prefix, + outputs_key_field="id", + outputs=outputs, + readable_output=readable, + raw_response=raw_response, + ) + + +def chat_file_delete_command(client: ComplianceClient, args: dict[str, Any]) -> CommandResults: + """Permanently delete a Claude file (chat file or project binary file) via the Compliance API. + + This is an irreversible hard delete (DELETE /v1/compliance/apps/chats/files/{claude_file_id}). + """ + file_id = args["file_id"] + return _delete_command( + client, + resource_id=file_id, + url_suffix=f"{ApiPaths.CHAT_FILES}/{file_id}", + deleted_type="claude_file_deleted", + outputs_prefix="AnthropicClaude.DeletedFile", + resource_label="File", + ) + + +def project_document_delete_command(client: ComplianceClient, args: dict[str, Any]) -> CommandResults: + """Permanently delete a Claude project document via the Compliance API. + + This is an irreversible hard delete + (DELETE /v1/compliance/apps/projects/documents/{document_id}). + """ + document_id = args["document_id"] + return _delete_command( + client, + resource_id=document_id, + url_suffix=f"{ApiPaths.PROJECT_DOCUMENTS}/{document_id}", + deleted_type="claude_project_document_deleted", + outputs_prefix="AnthropicClaude.DeletedProjectDocument", + resource_label="Project document", + ) + + +def module_test_compliance(client: ComplianceClient) -> str: + """Validates the Compliance Access Key by hitting the Activity Feed with a minimal request.""" + try: + client.get_activities(limit=1) + except DemistoException as e: + if "401" in str(e) or "403" in str(e) or "Forbidden" in str(e) or "Authorization" in str(e): + return "Authorization Error: make sure the Compliance Access Key is correct and has the required scopes." + raise + return "ok" + + +""" MAIN FUNCTION """ + + +def run_anthropic_claude_integration() -> None: + """Full integration entry point — parses params and dispatches every command. + + This function is called verbatim by both integration shims (``AnthropicClaude`` and + ``AnthropicClaudeStandardConnector``). The YAML files are the enforcement boundary: the + satellite YAML only surfaces the two delete commands and the ``compliance_apikey`` param, + so the LLM branches of this dispatcher are unreachable there even though the code exists. + """ + params = demisto.params() + args = demisto.args() + command = demisto.command() + + api_key = params.get("apikey", {}).get("password") + # If a model name was provided within the free text box, it will override the selected one from the model selection box. + # The provided model will be tested for compatability within the test module. + model = params.get("model-freetext") if params.get("model-freetext") else params.get("model-select") + compliance_api_key = params.get("compliance_apikey", {}).get("password") + + url = params.get("url") + verify = not params.get("insecure", False) + proxy = params.get("proxy", False) + + # Compliance commands whose org_uuid argument falls back to the instance Organization UUID parameter. + org_scoped_commands = { + "claude-list-organization-users": list_organization_users_command, + "claude-list-roles": list_roles_command, + "claude-list-role-permissions": list_role_permissions_command, + } + # Remaining read-only Compliance API commands. + compliance_commands = { + "claude-list-organizations": list_organizations_command, + "claude-list-groups": list_groups_command, + "claude-list-group-members": list_group_members_command, + "claude-list-chats": list_chats_command, + "claude-list-chat-messages": list_chat_messages_command, + "claude-list-projects": list_projects_command, + "claude-list-project-attachments": list_project_attachments_command, + "claude-get-project-document": get_project_document_command, + "claude-chat-file-delete": chat_file_delete_command, + "claude-project-document-delete": project_document_delete_command, + } + # LLM (Messages API) commands that require the Anthropic API Key. + llm_commands: dict[str, Any] = { + "claude-send-message": lambda c, a: send_message_command(c, a)[0], + "claude-check-email-header": check_email_headers_command, + "claude-check-email-body": check_email_body_command, + "claude-create-soc-email-template": create_soc_email_template_command, + } + + demisto.debug(f"anthropic-claude Command being called is {command}") + try: + if command == "test-module": + # Validate whichever credentials are configured (a customer may configure either or both). + # Each test is labeled so a failure clearly indicates which key is invalid. + # On the satellite integration only compliance_apikey is exposed in the YAML, so only the + # compliance branch runs; the LLM branch is unreachable there. + results: list[str] = [] + if api_key: + try: + llm_client = AnthropicClient(url=url, api_key=api_key, model=model, verify=verify, proxy=proxy) + results.append(module_test_llm(client=llm_client, params=params)) + except Exception as e: + raise DemistoException(f"API Key (LLM) validation failed: {e}") from e + if compliance_api_key or should_use_ucp_auth(): + try: + compliance_client = ComplianceClient(url=url, api_key=compliance_api_key, verify=verify, proxy=proxy) + results.append(module_test_compliance(client=compliance_client)) + except Exception as e: + raise DemistoException(f"Compliance Access Key validation failed: {e}") from e + if not results: + raise DemistoException( + "No credentials configured. Set the 'API Key' for LLM commands and/or the " + "'Compliance Access Key' for event collection and compliance commands." + ) + # Surface the first failing credential's message; only report "ok" when every check passed. + failure = next((result for result in results if result != "ok"), None) + return_results(failure or "ok") + + elif command == "fetch-events": + ensure_compliance_key(compliance_api_key) + compliance_client = ComplianceClient(url=url, api_key=compliance_api_key, verify=verify, proxy=proxy) + fetch_events_command(client=compliance_client, params=params) + + elif command == "claude-get-events": + ensure_compliance_key(compliance_api_key) + compliance_client = ComplianceClient(url=url, api_key=compliance_api_key, verify=verify, proxy=proxy) + events, results_obj = get_events_command(client=compliance_client, args=args) + # get_events_command already set _time on each event, so just push when requested. + if events and argToBoolean(args.get("should_push_events", "false")): + send_events_to_xsiam(events, vendor=Config.VENDOR, product=Config.PRODUCT) + return_results(results_obj) + + elif command in org_scoped_commands: + ensure_compliance_key(compliance_api_key) + compliance_client = ComplianceClient(url=url, api_key=compliance_api_key, verify=verify, proxy=proxy) + return_results(org_scoped_commands[command](compliance_client, args, params)) + + elif command in compliance_commands: + # UCP path: credential is supplied by the connector profile via _apply_ucp_api_key(), + # not by demisto.params(). Only the two delete commands are exposed on the satellite + # YAML — the rest of compliance_commands are only reachable on the legacy XSOAR path. + if not should_use_ucp_auth(): + ensure_compliance_key(compliance_api_key) + compliance_client = ComplianceClient(url=url, api_key=compliance_api_key, verify=verify, proxy=proxy) + return_results(compliance_commands[command](compliance_client, args)) + + elif command in llm_commands: + ensure_api_key(api_key) + llm_args = dict(args) + llm_args.update({key: value for key, value in params.items() if key not in llm_args and value is not None}) + llm_client = AnthropicClient(url=url, api_key=api_key, model=model, verify=verify, proxy=proxy) + return_results(llm_commands[command](llm_client, llm_args)) + + else: + raise NotImplementedError(f"Command {command} is not implemented.") + + except Exception as e: + return_error(f"Failed to execute {demisto.command()} command. Error: {str(e)}") diff --git a/Packs/ApiModules/Scripts/AnthropicClaudeApiModule/AnthropicClaudeApiModule.yml b/Packs/ApiModules/Scripts/AnthropicClaudeApiModule/AnthropicClaudeApiModule.yml new file mode 100644 index 000000000000..5149e77c5777 --- /dev/null +++ b/Packs/ApiModules/Scripts/AnthropicClaudeApiModule/AnthropicClaudeApiModule.yml @@ -0,0 +1,19 @@ +commonfields: + id: AnthropicClaudeApiModule + version: -1 +name: AnthropicClaudeApiModule +script: '' +type: python +subtype: python3 +tags: +- infra +- server +comment: Common Anthropic Claude Compliance API code shared by the AnthropicClaude and AnthropicClaudeStandardConnector integrations. +system: true +scripttarget: 0 +dependson: {} +timeout: 0s +dockerimage: demisto/parse-emails:0.1.48.10569905 +fromversion: 6.10.0 +tests: +- No tests diff --git a/Packs/ApiModules/Scripts/AnthropicClaudeApiModule/AnthropicClaudeApiModule_test.py b/Packs/ApiModules/Scripts/AnthropicClaudeApiModule/AnthropicClaudeApiModule_test.py new file mode 100644 index 000000000000..010c596b160f --- /dev/null +++ b/Packs/ApiModules/Scripts/AnthropicClaudeApiModule/AnthropicClaudeApiModule_test.py @@ -0,0 +1,843 @@ +"""Unit tests for the shared AnthropicClaudeApiModule. + +The shared module owns every integration concern: the ``AnthropicClient`` (LLM Messages +API), the ``ComplianceClient`` (with the ``_apply_ucp_api_key`` override), the event +collector, all fourteen read-only and delete compliance commands, all four LLM commands, +and the ``run_anthropic_claude_integration()`` dispatcher. Both consuming integrations +(``AnthropicClaude`` parent + ``AnthropicClaudeStandardConnector`` satellite) are +byte-identical shims that just call the dispatcher — the YAML is the enforcement boundary. +""" + +import json +import os +from types import SimpleNamespace + +import pytest +import requests +from CommonServerPython import CommandResults, DemistoException, UcpException + +from AnthropicClaudeApiModule import ( + AnthropicClient, + ApiPaths, + ComplianceClient, + Config, + _delete_command, + add_time_to_events, + chat_file_delete_command, + conversation_to_chat_context, + deduplicate_events, + ensure_api_key, + ensure_compliance_key, + extract_assistant_message, + fetch_events_command, + fetch_events_with_pagination, + get_events_command, + get_project_document_command, + list_chat_messages_command, + list_chats_command, + list_group_members_command, + list_groups_command, + list_organization_users_command, + list_organizations_command, + list_project_attachments_command, + list_projects_command, + list_role_permissions_command, + list_roles_command, + module_test_compliance, + project_document_delete_command, + resolve_org_uuid, + module_test_llm, + send_message_command, +) + + +BASE_URL = "https://api.anthropic.com/" + + +def build_compliance_client() -> ComplianceClient: + return ComplianceClient(url=BASE_URL, api_key="sk-ant-api01-test", proxy=False, verify=False) + + +def build_llm_client() -> AnthropicClient: + return AnthropicClient(url=BASE_URL, api_key="llm-key", model="claude-3-haiku-20240307", proxy=False, verify=False) + + +def load_test_data(filename: str) -> dict: + """Loads a JSON fixture from the test_data directory colocated with this test file.""" + path = os.path.join(os.path.dirname(__file__), "test_data", filename) + with open(path) as fh: + return json.load(fh) + + +def make_response(status_code: int, body: dict | None = None) -> requests.Response: + """Build a real requests.Response so ``.status_code`` and ``.json()`` behave normally.""" + response = requests.Response() + response.status_code = status_code + if body is not None: + import json + + response._content = json.dumps(body).encode("utf-8") + response.headers["Content-Type"] = "application/json" + return response + + +def make_activities(start: int, count: int, base_minute: int = 0) -> list[dict]: + """Builds a list of activity events with increasing ids/timestamps.""" + return [ + { + "id": f"activity_{i:04d}", + "activity_type": "chat.created", + "created_at": f"2026-06-11T07:{base_minute:02d}:{i % 60:02d}Z", + } + for i in range(start, start + count) + ] + + +# ── Config / ApiPaths ───────────────────────────────────────────────────────── + + +def test_config_carries_full_surface(): + """Sanity: the shared Config carries every constant the collector + all commands need.""" + assert Config.VENDOR == "anthropic" + assert Config.PRODUCT == "claude" + assert Config.ACTIVITIES_PAGE_SIZE == 5000 + assert Config.MAX_FETCH_CALLS == 10 + assert Config.DEFAULT_MAX_EVENTS_PER_FETCH == 50000 + assert Config.DEFAULT_FETCH_LOOKBACK == "1 minute" + assert Config.MAX_RETRIES == 3 + assert Config.BACKOFF_FACTOR == 2 + assert Config.RETRY_STATUS_CODES == (429, 500, 502, 503, 504) + assert Config.DEFAULT_LIST_LIMIT == 50 + assert Config.COMPLIANCE_KEY_DOCS.startswith("https://") + assert Config.API_KEY_DOCS.startswith("https://") + + +def test_apipaths_carries_full_surface(): + assert ApiPaths.ACTIVITIES == "v1/compliance/activities" + assert ApiPaths.ORGANIZATIONS == "v1/compliance/organizations" + assert ApiPaths.GROUPS == "v1/compliance/groups" + assert ApiPaths.CHATS == "v1/compliance/apps/chats" + assert ApiPaths.PROJECTS == "v1/compliance/apps/projects" + assert ApiPaths.CHAT_FILES == "v1/compliance/apps/chats/files" + assert ApiPaths.PROJECT_DOCUMENTS == "v1/compliance/apps/projects/documents" + # Classmethod interpolation. + assert ApiPaths.organization_users("org-1") == "v1/compliance/organizations/org-1/users" + assert ApiPaths.role_permissions("org-1", "role-1") == "v1/compliance/organizations/org-1/roles/role-1/permissions" + assert ApiPaths.group_members("grp-1") == "v1/compliance/groups/grp-1/members" + assert ApiPaths.chat_messages("chat-1") == "v1/compliance/apps/chats/chat-1/messages" + assert ApiPaths.project_attachments("proj-1") == "v1/compliance/apps/projects/proj-1/attachments" + assert ApiPaths.project_document("proj-1", "doc-1") == "v1/compliance/apps/projects/proj-1/documents/doc-1" + + +# ── ensure_api_key / ensure_compliance_key / resolve_org_uuid ───────────────── + + +def test_ensure_api_key_raises_when_missing(): + with pytest.raises(DemistoException, match="API Key"): + ensure_api_key(None) + with pytest.raises(DemistoException, match="API Key"): + ensure_api_key("") + + +def test_ensure_api_key_passes_when_present(): + ensure_api_key("some-key") + + +def test_ensure_compliance_key_raises_when_missing(): + with pytest.raises(DemistoException, match="Compliance Access Key"): + ensure_compliance_key(None) + with pytest.raises(DemistoException, match="Compliance Access Key"): + ensure_compliance_key("") + + +def test_ensure_compliance_key_passes_when_present(): + ensure_compliance_key("sk-ant-api01-test") + + +def test_resolve_org_uuid_falls_back_to_param(): + assert resolve_org_uuid({"org_uuid": "arg-org"}, {"organization_uuid": "param-org"}) == "arg-org" + assert resolve_org_uuid({}, {"organization_uuid": "param-org"}) == "param-org" + + +def test_resolve_org_uuid_missing_raises(): + with pytest.raises(DemistoException, match="Organization UUID is required"): + resolve_org_uuid({}, {}) + + +# ── ComplianceClient constructor (legacy XSOAR path) ────────────────────────── + + +def test_client_constructor_sets_x_api_key_header_for_legacy_path(): + """On the legacy XSOAR path the header is pre-populated in the constructor.""" + client = build_compliance_client() + assert client.headers["x-api-key"] == "sk-ant-api01-test" + assert client.headers["accept"] == "application/json" + + +# ── ComplianceClient._apply_ucp_api_key (UCP path) ──────────────────────────── + + +def _make_ctx() -> SimpleNamespace: + """Minimal UcpRequestContext-compatible stub — the override only touches ``headers``.""" + return SimpleNamespace(headers={}, params={}, auth=None, data=None, json_data=None) + + +def test_apply_ucp_api_key_writes_x_api_key_from_flat_form(): + """Flat form: {'type': 'api_key', 'key': '...'} — the CSP alias for manifest auth.parameter='api_key'.""" + client = build_compliance_client() + ctx = _make_ctx() + creds = {"type": "api_key", "key": "sk-ant-flat-key"} + + client._apply_ucp_api_key(creds, ctx) + + assert ctx.headers["x-api-key"] == "sk-ant-flat-key" + assert ctx.headers["accept"] == "application/json" + # Guard-rail: MUST NOT set the default Authorization header the base implementation writes. + assert "Authorization" not in ctx.headers + + +def test_apply_ucp_api_key_writes_x_api_key_from_nested_form(): + """Nested form: {'type': 'api_key', 'api_key': {'key': '...'}} — also valid per BaseClient contract.""" + client = build_compliance_client() + ctx = _make_ctx() + creds = {"type": "api_key", "api_key": {"key": "sk-ant-nested-key"}} + + client._apply_ucp_api_key(creds, ctx) + + assert ctx.headers["x-api-key"] == "sk-ant-nested-key" + assert "Authorization" not in ctx.headers + + +def test_apply_ucp_api_key_raises_ucp_exception_on_empty_key(mocker): + """Empty key must raise UcpException so the dispatcher surfaces the generic UCP error.""" + mocker.patch("AnthropicClaudeApiModule.demisto.error") + client = build_compliance_client() + ctx = _make_ctx() + creds = {"type": "api_key", "api_key": {"key": ""}} + + with pytest.raises(UcpException): + client._apply_ucp_api_key(creds, ctx) + + +def test_apply_ucp_api_key_raises_when_no_key_provided(mocker): + mocker.patch("AnthropicClaudeApiModule.demisto.error") + client = build_compliance_client() + ctx = _make_ctx() + + with pytest.raises(UcpException): + client._apply_ucp_api_key({"type": "api_key"}, ctx) + + +def test_apply_ucp_api_key_preserves_existing_accept_header(): + """setdefault must not overwrite an accept header the caller already set.""" + client = build_compliance_client() + ctx = _make_ctx() + ctx.headers["accept"] = "application/vnd.api+json" + + client._apply_ucp_api_key({"type": "api_key", "key": "k"}, ctx) + + assert ctx.headers["accept"] == "application/vnd.api+json" + + +# ── http_get / http_delete ──────────────────────────────────────────────────── + + +def test_http_get_retries_on_rate_limit(mocker): + """ComplianceClient.http_get enables back-off retries on 429 and transient 5xx codes.""" + client = build_compliance_client() + request_mock = mocker.patch.object(client, "_http_request", return_value={"data": []}) + + client.http_get("v1/compliance/activities", params={"limit": 1}) + + kwargs = request_mock.call_args.kwargs + assert kwargs["retries"] == Config.MAX_RETRIES + assert kwargs["backoff_factor"] == Config.BACKOFF_FACTOR + assert 429 in kwargs["status_list_to_retry"] + + +def test_http_delete_treats_404_as_ok(mocker): + """404 is included in ok_codes so 'already deleted' does not raise.""" + client = build_compliance_client() + request_mock = mocker.patch.object(client, "_http_request", return_value=make_response(404)) + + response = client.http_delete("v1/compliance/apps/chats/files/gone") + + assert response.status_code == 404 + call_kwargs = request_mock.call_args.kwargs + assert call_kwargs["method"] == "DELETE" + assert 404 in call_kwargs["ok_codes"] + assert call_kwargs["status_list_to_retry"] == list(Config.RETRY_STATUS_CODES) + assert call_kwargs["retries"] == Config.MAX_RETRIES + assert call_kwargs["backoff_factor"] == Config.BACKOFF_FACTOR + assert call_kwargs["resp_type"] == "response" + + +# ── Event collector: helpers ────────────────────────────────────────────────── + + +def test_add_time_to_events(): + events = [{"created_at": "2026-06-11T07:08:59Z"}, {"id": "no_time"}] + add_time_to_events(events) + assert events[0]["_time"] == "2026-06-11T07:08:59Z" + assert "_time" not in events[1] + + +def test_deduplicate_events(): + events = [{"id": "a"}, {"id": "b"}, {"id": "c"}] + assert deduplicate_events(events, ["b"]) == [{"id": "a"}, {"id": "c"}] + assert deduplicate_events(events, []) == events + assert deduplicate_events([], ["b"]) == [] + + +# ── Event collector: fetch_events_with_pagination ───────────────────────────── + + +def test_fetch_events_first_run(mocker): + """First run: uses the one-minute lookback lower bound, single page, no has_more.""" + client = build_compliance_client() + response = {"data": make_activities(0, 3), "has_more": False, "last_id": "activity_0002"} + get_mock = mocker.patch.object(client, "get_activities", return_value=response) + + events, next_run = fetch_events_with_pagination(client, last_run={}, max_events=50000, activity_types=None) + + assert len(events) == 3 + # First call should use created_at.gte (first-fetch lower bound), not after_id. + _, kwargs = get_mock.call_args + assert kwargs["created_at_gte"] is not None + assert kwargs["after_id"] is None + assert next_run["newest_created_at"] == "2026-06-11T07:00:02Z" + + +def test_fetch_events_subsequent_run(mocker): + """Subsequent run: uses created_at.gt against the previously stored newest timestamp.""" + client = build_compliance_client() + response = {"data": make_activities(5, 2), "has_more": False, "last_id": "activity_0006"} + get_mock = mocker.patch.object(client, "get_activities", return_value=response) + + last_run = {"newest_created_at": "2026-06-11T07:00:04Z", "last_fetched_ids": ["activity_0004"]} + events, next_run = fetch_events_with_pagination(client, last_run, max_events=50000, activity_types=None) + + assert len(events) == 2 + _, kwargs = get_mock.call_args + assert kwargs["created_at_gt"] == "2026-06-11T07:00:04Z" + assert kwargs["created_at_gte"] is None + + +def test_fetch_events_pagination(mocker): + """Cursor pagination: walks multiple pages until has_more is False.""" + client = build_compliance_client() + page1 = {"data": make_activities(0, 2), "has_more": True, "last_id": "activity_0001"} + page2 = {"data": make_activities(2, 2), "has_more": False, "last_id": "activity_0003"} + get_mock = mocker.patch.object(client, "get_activities", side_effect=[page1, page2]) + + events, _ = fetch_events_with_pagination(client, last_run={}, max_events=50000, activity_types=None) + + assert len(events) == 4 + assert get_mock.call_count == 2 + # The second call must carry the cursor from page1's last_id. + second_kwargs = get_mock.call_args_list[1].kwargs + assert second_kwargs["after_id"] == "activity_0001" + + +def test_fetch_events_dedup(mocker): + """Boundary events already seen in the previous run are not returned again.""" + client = build_compliance_client() + response = { + "data": [ + {"id": "activity_dup", "created_at": "2026-06-11T07:00:04Z", "activity_type": "x"}, + {"id": "activity_new", "created_at": "2026-06-11T07:00:05Z", "activity_type": "y"}, + ], + "has_more": False, + "last_id": "activity_new", + } + mocker.patch.object(client, "get_activities", return_value=response) + + last_run = {"newest_created_at": "2026-06-11T07:00:04Z", "last_fetched_ids": ["activity_dup"]} + events, _ = fetch_events_with_pagination(client, last_run, max_events=50000, activity_types=None) + + ids = [e["id"] for e in events] + assert "activity_dup" not in ids + assert "activity_new" in ids + + +def test_fetch_events_respects_max_events(mocker): + """The collector stops once max_events is reached even if more pages exist.""" + client = build_compliance_client() + page = {"data": make_activities(0, 3), "has_more": True, "last_id": "activity_0002"} + mocker.patch.object(client, "get_activities", return_value=page) + + events, _ = fetch_events_with_pagination(client, last_run={}, max_events=3, activity_types=None) + + assert len(events) == 3 + + +def test_fetch_events_no_drop_across_cap_boundary_two_runs(mocker): + """When total events exceed max_events_per_fetch, the cap must not drop events across runs. + + Run 1 collects exactly `max_events`; the persisted cursor must reflect only the delivered + events so run 2 resumes from the correct boundary and the remaining events are returned with + no gaps and no overlap. + """ + client = build_compliance_client() + # Six unique events across two ascending pages; cap each run at 3. + all_events = make_activities(0, 6) + page_first_half = {"data": all_events[:3], "has_more": True, "last_id": "activity_0002"} + mocker.patch.object(client, "get_activities", return_value=page_first_half) + + run1_events, run1_next = fetch_events_with_pagination(client, last_run={}, max_events=3, activity_types=None) + run1_ids = [e["id"] for e in run1_events] + + assert run1_ids == ["activity_0000", "activity_0001", "activity_0002"] + # Cursor reflects the newest DELIVERED event only. + assert run1_next["newest_created_at"] == all_events[2]["created_at"] + + # Run 2 resumes after the boundary; the API returns the remaining events. + page_second_half = {"data": all_events[3:], "has_more": False, "last_id": "activity_0005"} + mocker.patch.object(client, "get_activities", return_value=page_second_half) + + run2_events, _ = fetch_events_with_pagination(client, last_run=run1_next, max_events=3, activity_types=None) + run2_ids = [e["id"] for e in run2_events] + + # No event is dropped and none is duplicated across the cap boundary. + assert run2_ids == ["activity_0003", "activity_0004", "activity_0005"] + assert set(run1_ids).isdisjoint(run2_ids) + assert sorted(run1_ids + run2_ids) == [e["id"] for e in all_events] + + +def test_fetch_events_descending_feed_shape(mocker): + """The real Activity Feed returns events newest-first; the cursor must capture the newest one.""" + client = build_compliance_client() + page = load_test_data("activities_page1.json") + # Close out pagination so the single fixture page is the whole cycle. + page = {**page, "has_more": False} + mocker.patch.object(client, "get_activities", return_value=page) + + events, next_run = fetch_events_with_pagination(client, last_run={}, max_events=50000, activity_types=None) + + assert len(events) == 2 + # activity_002 (07:08:59) is newer than activity_001 (07:08:58) despite appearing first. + assert next_run["newest_created_at"] == "2026-06-11T07:08:59Z" + assert next_run["last_fetched_ids"] == ["activity_002"] + + +# ── Event collector: fetch_events_command / get_events_command ──────────────── + + +def test_fetch_events_pushes_to_xsiam(mocker): + """fetch_events sets _time, pushes events with the correct vendor/product, and persists last_run.""" + client = build_compliance_client() + response = {"data": make_activities(0, 2), "has_more": False, "last_id": "activity_0001"} + mocker.patch.object(client, "get_activities", return_value=response) + mocker.patch("AnthropicClaudeApiModule.demisto.getLastRun", return_value={}) + set_last_run = mocker.patch("AnthropicClaudeApiModule.demisto.setLastRun") + send_mock = mocker.patch("AnthropicClaudeApiModule.send_events_to_xsiam") + + fetch_events_command(client, params={"max_events_per_fetch": "1000"}) + + send_mock.assert_called_once() + sent_events = send_mock.call_args.args[0] + assert send_mock.call_args.kwargs["vendor"] == Config.VENDOR + assert send_mock.call_args.kwargs["product"] == Config.PRODUCT + assert all("_time" in e for e in sent_events) + set_last_run.assert_called_once() + + +def test_get_events_command_no_push(mocker): + client = build_compliance_client() + response = {"data": make_activities(0, 2), "has_more": False, "last_id": "activity_0001"} + mocker.patch.object(client, "get_activities", return_value=response) + + events, results = get_events_command(client, args={"limit": "50"}) + + assert len(events) == 2 + assert isinstance(results, CommandResults) + assert all("_time" in e for e in events) + + +def test_get_events_command_with_time_range(mocker): + """start_time/end_time map to created_at.gte / created_at.lt bounds on the Activity Feed query.""" + client = build_compliance_client() + response = {"data": make_activities(0, 1), "has_more": False, "last_id": "activity_0000"} + get_mock = mocker.patch.object(client, "get_activities", return_value=response) + + get_events_command( + client, + args={"limit": "10", "start_time": "2025-06-07T08:09:10Z", "end_time": "2025-06-07T09:09:10Z"}, + ) + + kwargs = get_mock.call_args.kwargs + assert kwargs["created_at_gte"] == "2025-06-07T08:09:10Z" + assert kwargs["created_at_lt"] == "2025-06-07T09:09:10Z" + + +# ── Read-only compliance commands ───────────────────────────────────────────── + + +def test_list_organizations_command(mocker): + client = build_compliance_client() + response = {"data": [{"uuid": "org-1", "name": "Acme", "created_at": "2026-01-01T00:00:00Z"}]} + mocker.patch.object(client, "http_get", return_value=response) + + results = list_organizations_command(client, args={"limit": "50"}) + + assert results.outputs_prefix == "AnthropicClaude.Organization" + assert results.outputs[0]["uuid"] == "org-1" + + +def test_list_organization_users_command(mocker): + client = build_compliance_client() + response = {"data": [{"id": "u1", "email": "user@example.com", "organization_role": "admin"}]} + get_mock = mocker.patch.object(client, "http_get", return_value=response) + + results = list_organization_users_command(client, args={"org_uuid": "org-1", "limit": "10"}, params={}) + + assert results.outputs_prefix == "AnthropicClaude.Organization.User" + get_mock.assert_called_once() + assert "organizations/org-1/users" in get_mock.call_args.args[0] + + +def test_list_roles_single_role(mocker): + """When role_id is provided, the single-role endpoint is used (no data[] wrapper).""" + client = build_compliance_client() + response = {"id": "role-1", "name": "Owner", "description": "desc"} + get_mock = mocker.patch.object(client, "http_get", return_value=response) + + results = list_roles_command(client, args={"org_uuid": "org-1", "role_id": "role-1"}, params={}) + + assert results.outputs["id"] == "role-1" + assert "roles/role-1" in get_mock.call_args.args[0] + + +def test_list_roles_list_mode(mocker): + client = build_compliance_client() + response = {"data": [{"id": "role-1", "name": "Owner"}], "next_page": "tok123"} + mocker.patch.object(client, "http_get", return_value=response) + + results = list_roles_command(client, args={"org_uuid": "org-1"}, params={}) + + assert results.outputs[0]["id"] == "role-1" + assert "tok123" in results.readable_output + + +def test_list_role_permissions_command(mocker): + client = build_compliance_client() + response = {"data": [{"resource_type": "chats", "action": "read"}], "next_page": "tok-perm"} + get_mock = mocker.patch.object(client, "http_get", return_value=response) + + results = list_role_permissions_command(client, args={"org_uuid": "org-1", "role_id": "role-1"}, params={}) + + assert results.outputs_prefix == "AnthropicClaude.Organization.Role.Permission" + assert results.outputs[0]["resource_type"] == "chats" + assert "roles/role-1/permissions" in get_mock.call_args.args[0] + + +def test_list_role_permissions_missing_role_id_raises(mocker): + """role_id is required for the permissions endpoint; omitting it must raise.""" + client = build_compliance_client() + mocker.patch.object(client, "http_get") + with pytest.raises(KeyError): + list_role_permissions_command(client, args={"org_uuid": "org-1"}, params={}) + + +def test_list_groups_single_group(mocker): + client = build_compliance_client() + response = {"id": "grp-1", "name": "Engineers", "source_type": "scim"} + get_mock = mocker.patch.object(client, "http_get", return_value=response) + + results = list_groups_command(client, args={"group_id": "grp-1"}) + + assert results.outputs["id"] == "grp-1" + assert "groups/grp-1" in get_mock.call_args.args[0] + + +def test_list_group_members_dt_prefix(mocker): + """Group members merge into the parent Group entry via DT.""" + client = build_compliance_client() + response = {"data": [{"user_id": "u1", "email": "user@example.com"}]} + mocker.patch.object(client, "http_get", return_value=response) + + results = list_group_members_command(client, args={"group_id": "grp-1"}) + + assert results.outputs_prefix == "AnthropicClaude.Group(val.id == 'grp-1').Member" + assert results.outputs[0]["user_id"] == "u1" + + +def test_list_chats_command(mocker): + client = build_compliance_client() + response = {"data": [{"id": "chat-1", "name": "Chat", "model": "claude-3"}]} + get_mock = mocker.patch.object(client, "http_get", return_value=response) + + results = list_chats_command(client, args={"user_ids": "u1,u2", "limit": "100"}) + + assert results.outputs_prefix == "AnthropicClaude.Chat" + params = get_mock.call_args.kwargs["params"] + assert params["user_ids[]"] == ["u1", "u2"] + + +def test_list_chats_date_range_param_mapping(mocker): + """created_at_gte argument maps to the created_at.gte query parameter.""" + client = build_compliance_client() + response = {"data": [{"id": "chat-1", "name": "Chat"}]} + get_mock = mocker.patch.object(client, "http_get", return_value=response) + + list_chats_command(client, args={"user_ids": "u1", "created_at_gte": "2025-06-07T08:09:10Z"}) + + params = get_mock.call_args.kwargs["params"] + assert params["created_at.gte"] == "2025-06-07T08:09:10Z" + + +def test_list_chat_messages_command(mocker): + client = build_compliance_client() + response = {"chat_messages": [{"id": "m1", "role": "user", "created_at": "2026-01-01T00:00:00Z"}]} + mocker.patch.object(client, "http_get", return_value=response) + + results = list_chat_messages_command(client, args={"chat_id": "chat-1"}) + + # Messages merge into the parent Chat entry via DT. + assert results.outputs_prefix == "AnthropicClaude.Chat(val.id == 'chat-1').Message" + assert results.outputs[0]["id"] == "m1" + + +def test_list_projects_single_project(mocker): + client = build_compliance_client() + response = {"id": "proj-1", "name": "Project", "is_private": True} + get_mock = mocker.patch.object(client, "http_get", return_value=response) + + results = list_projects_command(client, args={"project_id": "proj-1"}) + + assert results.outputs["id"] == "proj-1" + assert "projects/proj-1" in get_mock.call_args.args[0] + + +def test_list_project_attachments_command(mocker): + client = build_compliance_client() + response = { + "data": [{"id": "att-1", "filename": "diagram.png", "mime_type": "image/png"}], + "next_page": "tok-att", + } + get_mock = mocker.patch.object(client, "http_get", return_value=response) + + results = list_project_attachments_command(client, args={"project_id": "proj-1"}) + + # Attachments merge into the parent Project entry via DT. + assert results.outputs_prefix == "AnthropicClaude.Project(val.id == 'proj-1').Attachment" + assert results.outputs[0]["id"] == "att-1" + assert "projects/proj-1/attachments" in get_mock.call_args.args[0] + assert "tok-att" in results.readable_output + + +def test_get_project_document_command(mocker): + client = build_compliance_client() + response = {"id": "claude_proj_doc_1", "filename": "spec.md", "content": "hello"} + get_mock = mocker.patch.object(client, "http_get", return_value=response) + + results = get_project_document_command(client, args={"project_id": "proj-1", "document_id": "claude_proj_doc_1"}) + + assert results.outputs_prefix == "AnthropicClaude.ProjectDocument" + assert results.outputs["content"] == "hello" + assert "projects/proj-1/documents/claude_proj_doc_1" in get_mock.call_args.args[0] + + +# ── Delete commands ────────────────────────────────────────────────────────── + + +def test_chat_file_delete_command_happy_path(mocker): + """Happy path: hits the flat chat-files delete path and reports the deleted id.""" + client = build_compliance_client() + delete_mock = mocker.patch.object( + client, "http_delete", return_value=make_response(200, {"id": "claude_file_1", "type": "claude_file_deleted"}) + ) + + results = chat_file_delete_command(client, args={"file_id": "claude_file_1"}) + + delete_mock.assert_called_once_with(f"{ApiPaths.CHAT_FILES}/claude_file_1") + assert isinstance(results, CommandResults) + assert results.outputs["id"] == "claude_file_1" + assert results.outputs["type"] == "claude_file_deleted" + assert results.outputs["Deleted"] is True + assert results.outputs_prefix == "AnthropicClaude.DeletedFile" + + +def test_chat_file_delete_command_idempotent_on_404(mocker): + """A 404 (already deleted / unknown id) is treated as an idempotent success and annotated.""" + client = build_compliance_client() + mocker.patch.object(client, "http_delete", return_value=make_response(404)) + + results = chat_file_delete_command(client, args={"file_id": "claude_file_gone"}) + + assert results.outputs["Deleted"] is True + assert "was already deleted" in results.readable_output + + +def test_chat_file_delete_command_missing_arg_raises(): + client = build_compliance_client() + with pytest.raises(KeyError): + chat_file_delete_command(client, args={}) + + +def test_project_document_delete_command_happy_path(mocker): + """Happy path: hits the flat project-documents delete path and reports the deleted id.""" + client = build_compliance_client() + delete_mock = mocker.patch.object( + client, + "http_delete", + return_value=make_response(200, {"id": "claude_proj_doc_1", "type": "claude_project_document_deleted"}), + ) + + results = project_document_delete_command(client, args={"document_id": "claude_proj_doc_1"}) + + delete_mock.assert_called_once_with(f"{ApiPaths.PROJECT_DOCUMENTS}/claude_proj_doc_1") + assert results.outputs["id"] == "claude_proj_doc_1" + assert results.outputs["type"] == "claude_project_document_deleted" + assert results.outputs["Deleted"] is True + assert results.outputs_prefix == "AnthropicClaude.DeletedProjectDocument" + + +def test_project_document_delete_command_idempotent_on_404(mocker): + client = build_compliance_client() + mocker.patch.object(client, "http_delete", return_value=make_response(404)) + + results = project_document_delete_command(client, args={"document_id": "claude_proj_doc_gone"}) + + assert results.outputs["Deleted"] is True + assert "was already deleted" in results.readable_output + + +def test_project_document_delete_command_missing_arg_raises(): + client = build_compliance_client() + with pytest.raises(KeyError): + project_document_delete_command(client, args={}) + + +def test_delete_command_falls_back_when_response_body_not_json(mocker): + """When the DELETE response body cannot be parsed as JSON the helper still returns a valid result.""" + client = build_compliance_client() + response = requests.Response() + response.status_code = 200 + response._content = b"not-json" + mocker.patch.object(client, "http_delete", return_value=response) + + results = _delete_command( + client, + resource_id="rid", + url_suffix="v1/some/path/rid", + deleted_type="some_type", + outputs_prefix="Any.Prefix", + resource_label="Thing", + ) + + assert results.outputs["id"] == "rid" + assert results.outputs["type"] == "some_type" + assert results.raw_response == {"id": "rid", "type": "some_type"} + + +# ── module_test_compliance ──────────────────────────────────────────────────── + + +def test_module_test_compliance_success(mocker): + client = build_compliance_client() + mocker.patch.object(client, "get_activities", return_value={"data": []}) + assert module_test_compliance(client) == "ok" + + +def test_module_test_compliance_auth_failure(mocker): + client = build_compliance_client() + mocker.patch.object(client, "get_activities", side_effect=DemistoException("Error 401 Unauthorized")) + result = module_test_compliance(client) + assert "Authorization Error" in result + + +def test_module_test_compliance_other_error_raises(mocker): + client = build_compliance_client() + mocker.patch.object(client, "get_activities", side_effect=DemistoException("500 Server Error")) + with pytest.raises(DemistoException): + module_test_compliance(client) + + +# ── LLM: AnthropicClient / helpers / commands ──────────────────────────────── + + +def test_anthropic_client_constructor_sets_llm_headers(): + client = build_llm_client() + assert client.api_key == "llm-key" + assert client.model == "claude-3-haiku-20240307" + assert client.headers["x-api-key"] == "llm-key" + assert client.headers["Content-Type"] == "application/json" + assert client.headers["anthropic-version"] # populated to the module constant + + +def test_conversation_to_chat_context_alternates_user_and_assistant(): + conversation = [ + {"user": "hi", "assistant": "hello"}, + {"user": "bye", "assistant": "goodbye"}, + ] + context = conversation_to_chat_context(conversation) + assert context == [ + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": "hello"}, + {"role": "user", "content": "bye"}, + {"role": "assistant", "content": "goodbye"}, + ] + + +def test_extract_assistant_message_concatenates_text_parts(): + response = {"content": [{"type": "text", "text": "Hello, "}, {"type": "text", "text": "world!"}]} + assert extract_assistant_message(response) == "Hello, world!" + + +def test_extract_assistant_message_skips_non_text_parts(): + response = { + "content": [ + {"type": "tool_use", "name": "some_tool"}, + {"type": "text", "text": "only this"}, + ] + } + assert extract_assistant_message(response) == "only this" + + +def test_send_message_command_happy_path(mocker): + client = build_llm_client() + mocker.patch("AnthropicClaudeApiModule.demisto.context", return_value={}) + mocker.patch.object( + client, + "get_messages", + return_value={ + "model": "claude-3-haiku-20240307", + "content": [{"type": "text", "text": "hi there"}], + "usage": {"input_tokens": 5, "output_tokens": 2}, + }, + ) + + results, response = send_message_command(client, args={"message": "hello", "reset_conversation_history": "yes"}) + + assert isinstance(results, CommandResults) + assert response["content"][0]["text"] == "hi there" + # The conversation step is written back to the AnthropicClaude.Conversation context path. + assert results.outputs_prefix == "AnthropicClaude.Conversation" + assert results.outputs == [{"user": "hello", "assistant": "hi there"}] + assert results.replace_existing is True + + +def test_send_message_command_requires_message(): + client = build_llm_client() + with pytest.raises(ValueError, match="Message not provided"): + send_message_command(client, args={}) + + +def test_module_test_llm_success(mocker): + client = build_llm_client() + mocker.patch.object(client, "get_messages", return_value={"content": [{"type": "text", "text": "pong"}]}) + assert module_test_llm(client, params={"max_tokens": "1024"}) == "ok" + + +def test_module_test_llm_auth_error_returns_labeled_message(mocker): + client = build_llm_client() + mocker.patch.object(client, "get_messages", side_effect=DemistoException("403 Forbidden")) + assert "Authorization Error" in module_test_llm(client, params={"max_tokens": "1024"}) + + +def test_module_test_llm_other_error_raises(mocker): + client = build_llm_client() + mocker.patch.object(client, "get_messages", side_effect=DemistoException("500 Server Error")) + with pytest.raises(DemistoException): + module_test_llm(client, params={"max_tokens": "1024"}) diff --git a/Packs/ApiModules/Scripts/AnthropicClaudeApiModule/README.md b/Packs/ApiModules/Scripts/AnthropicClaudeApiModule/README.md new file mode 100644 index 000000000000..9fa951e38e79 --- /dev/null +++ b/Packs/ApiModules/Scripts/AnthropicClaudeApiModule/README.md @@ -0,0 +1,28 @@ +# AnthropicClaudeApiModule + +Common Anthropic Claude Compliance API code shared by: + +- The full `AnthropicClaude` integration (parent pack, all commands + event collector). +- The narrower `AnthropicClaudeStandardConnector` integration (ConnectUs satellite pack, delete commands only). + +The module carries only what the two irreversible compliance delete commands +(`claude-chat-file-delete` and `claude-project-document-delete`) need — the LLM +client, the compliance list/get commands, and the event collector stay inline +in the parent pack because they are not shared. + +## What lives here + +- `Config` — retry/back-off constants and the compliance-key docs URL. +- `ApiPaths` — the two flat delete paths (`CHAT_FILES`, `PROJECT_DOCUMENTS`). +- `ComplianceClient` — subclass of `BaseClient` that: + - Pre-populates the `x-api-key` header on the legacy XSOAR path. + - Overrides `_apply_ucp_api_key` to write `x-api-key` (not `Authorization: Bearer …`) on the ConnectUs / UCP path. +- `ensure_compliance_key`, `_delete_command`, `chat_file_delete_command`, `project_document_delete_command`. + +## UCP auth override + +The ConnectUs profile for this connector uses `type: api_key` with +`metadata.auth.parameter: api_key`. The Content Serialisation Protocol aliases +that to the envelope key `key`. Because the Anthropic Compliance API rejects +Bearer auth and requires `x-api-key`, the client overrides the default +`BaseClient._apply_ucp_api_key` and writes the header directly. diff --git a/Packs/ApiModules/Scripts/AnthropicClaudeApiModule/test_data/activities_page1.json b/Packs/ApiModules/Scripts/AnthropicClaudeApiModule/test_data/activities_page1.json new file mode 100644 index 000000000000..185dbecd5bc5 --- /dev/null +++ b/Packs/ApiModules/Scripts/AnthropicClaudeApiModule/test_data/activities_page1.json @@ -0,0 +1,16 @@ +{ + "data": [ + { + "id": "activity_002", + "activity_type": "user.login", + "created_at": "2026-06-11T07:08:59Z" + }, + { + "id": "activity_001", + "activity_type": "chat.created", + "created_at": "2026-06-11T07:08:58Z" + } + ], + "has_more": true, + "last_id": "activity_001" +} From d74940811c9937b179334ca6a47ef8ce6d39b0c9 Mon Sep 17 00:00:00 2001 From: Joey Mizrahi Date: Mon, 24 Aug 2026 16:25:18 +0300 Subject: [PATCH 2/3] add README --- .../README.md | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 Packs/AnthropicClaudeStandardConnector/Integrations/AnthropicClaudeStandardConnector/README.md diff --git a/Packs/AnthropicClaudeStandardConnector/Integrations/AnthropicClaudeStandardConnector/README.md b/Packs/AnthropicClaudeStandardConnector/Integrations/AnthropicClaudeStandardConnector/README.md new file mode 100644 index 000000000000..e76c2e5dc2ac --- /dev/null +++ b/Packs/AnthropicClaudeStandardConnector/Integrations/AnthropicClaudeStandardConnector/README.md @@ -0,0 +1,70 @@ +Wired to the Anthropic Claude connector to expose two irreversible hard-delete commands from the Anthropic Compliance API. Configured automatically as part of the connector setup — do not add an instance of this integration directly. + +## Authentication + +Authenticates with the Anthropic Compliance Access Key (`sk-ant-api01-...`) via the `x-api-key` header. The key must have the `delete:compliance_user_data` scope. + +- On the ConnectUs (UCP) path (standard connector, this integration's normal deployment), the credential is supplied by the connector profile and injected at request time. +- On the legacy XSOAR path (direct configuration, not recommended), set the "Compliance Access Key" integration parameter. + +For details on obtaining a Compliance Access Key, see the [Anthropic Compliance API documentation](https://platform.claude.com/docs/en/manage-claude/compliance-api-access). + +## Configuration + +This integration is configured automatically as part of the **Anthropic Claude Standard Connector**. Set it up from the connector page, not from **Settings → Integrations**. + +| Parameter | Description | Required | +| --- | --- | --- | +| Compliance Access Key | The Anthropic Compliance Access Key. Requires the `delete:compliance_user_data` scope. | True | +| Trust any certificate (not secure) | Bypass TLS certificate validation. | False | +| Use system proxy settings | Route requests through the system HTTPS proxy. | False | + +## Commands + +You can execute these commands from the Cortex XSOAR CLI, as part of an automation, or in a playbook. + +### claude-chat-file-delete + +*** +Permanently delete a Claude file (a conversation file or a project binary file) via the Compliance API. This is an irreversible hard delete, and it requires a Compliance Access Key with the `delete:compliance_user_data` scope. Deleting an already-deleted or unknown file ID succeeds (idempotent). + +#### Base Command + +`claude-chat-file-delete` + +#### Input + +| **Argument Name** | **Description** | **Required** | +| --- | --- | --- | +| file_id | The Claude file ID to permanently delete (e.g., `claude_file_...`). Deletes a file uploaded in a conversation or a project binary file (`project_file`). This is an irreversible hard delete. | Required | + +#### Context Output + +| **Path** | **Type** | **Description** | +| --- | --- | --- | +| AnthropicClaude.DeletedFile.id | String | The ID of the file that was deleted. | +| AnthropicClaude.DeletedFile.type | String | The deletion confirmation type (`claude_file_deleted`). | +| AnthropicClaude.DeletedFile.Deleted | Boolean | The deletion result for the file (`true` when deleted). | + +### claude-project-document-delete + +*** +Permanently delete a Claude project document (a plain-text `project_doc`) via the Compliance API. This is an irreversible hard delete, and it requires a Compliance Access Key with the `delete:compliance_user_data` scope. Deleting an already-deleted or unknown document ID succeeds (idempotent). + +#### Base Command + +`claude-project-document-delete` + +#### Input + +| **Argument Name** | **Description** | **Required** | +| --- | --- | --- | +| document_id | The Claude project document ID to permanently delete (e.g., `claude_proj_doc_...`). Applies to project plain-text documents (`project_doc`). This is an irreversible hard delete. | Required | + +#### Context Output + +| **Path** | **Type** | **Description** | +| --- | --- | --- | +| AnthropicClaude.DeletedProjectDocument.id | String | The ID of the project document that was deleted. | +| AnthropicClaude.DeletedProjectDocument.type | String | The deletion confirmation type (`claude_project_document_deleted`). | +| AnthropicClaude.DeletedProjectDocument.Deleted | Boolean | The deletion result for the project document (`true` when deleted). | From 22d01f6896624e13753ab7fe85dbeeaee7a65e3e Mon Sep 17 00:00:00 2001 From: Joey Mizrahi Date: Tue, 25 Aug 2026 11:54:22 +0300 Subject: [PATCH 3/3] default for URL param --- .../AnthropicClaudeApiModule/AnthropicClaudeApiModule.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Packs/ApiModules/Scripts/AnthropicClaudeApiModule/AnthropicClaudeApiModule.py b/Packs/ApiModules/Scripts/AnthropicClaudeApiModule/AnthropicClaudeApiModule.py index a960376d98bb..82e57134f984 100644 --- a/Packs/ApiModules/Scripts/AnthropicClaudeApiModule/AnthropicClaudeApiModule.py +++ b/Packs/ApiModules/Scripts/AnthropicClaudeApiModule/AnthropicClaudeApiModule.py @@ -1186,7 +1186,7 @@ def run_anthropic_claude_integration() -> None: model = params.get("model-freetext") if params.get("model-freetext") else params.get("model-select") compliance_api_key = params.get("compliance_apikey", {}).get("password") - url = params.get("url") + url = params.get("url", "https://api.anthropic.com/") verify = not params.get("insecure", False) proxy = params.get("proxy", False)