├── tests ├── __init__.py ├── unit │ ├── __init__.py │ ├── test_caller.py │ ├── test_utils.py │ └── test_mapper.py ├── integration │ ├── __init__.py │ ├── test_account.py │ ├── test_network.py │ ├── test_endpoint.py │ ├── test_email.py │ ├── test_api_key.py │ ├── test_script.py │ ├── test_search.py │ ├── test_common.py │ ├── test_object.py │ ├── test_sandbox.py │ ├── test_workbench.py │ └── test_oat.py ├── conftest.py └── data.py ├── src └── pytmv1 │ ├── py.typed │ ├── model │ ├── __init__.py │ ├── request.py │ ├── response.py │ └── enum.py │ ├── __about__.py │ ├── api │ ├── system.py │ ├── __init__.py │ ├── task.py │ ├── account.py │ ├── object.py │ ├── api_key.py │ ├── alert.py │ ├── script.py │ ├── sandbox.py │ ├── note.py │ ├── email.py │ ├── endpoint.py │ └── oat.py │ ├── adapter.py │ ├── exception.py │ ├── client.py │ ├── result.py │ ├── mapper.py │ ├── utils.py │ ├── __init__.py │ └── core.py ├── .coveragerc ├── .gitignore ├── tox.ini ├── .github └── workflows │ ├── build.yml │ ├── test.yml │ ├── lint.yml │ ├── release.yml │ └── coverage.yml ├── pyproject.toml └── LICENSE /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/pytmv1/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/pytmv1/model/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/integration/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = __about__.py -------------------------------------------------------------------------------- /src/pytmv1/__about__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.9.3" 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | __pycache__/ 3 | .mypy_cache/ 4 | .pytest_cache/ 5 | .coverage 6 | dist/ 7 | venv/ 8 | .venv/ 9 | .vscode/ -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = .git,.github,.mypy_cache,.pytest_cache,__pycache__,dist,venv 3 | extend-ignore = E701 4 | max-complexity = 10 5 | max-line-length = 79 6 | statistics = true -------------------------------------------------------------------------------- /tests/unit/test_caller.py: -------------------------------------------------------------------------------- 1 | import pytmv1 2 | from pytmv1.core import API_VERSION 3 | 4 | 5 | def test_client(): 6 | client = pytmv1.init("dummy_name", "dummy_token", "https://dummy.com") 7 | assert client._core._appname == "dummy_name" 8 | assert client._core._token == "dummy_token" 9 | assert client._core._url == "https://dummy.com/" + API_VERSION 10 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: [main, develop] 6 | pull_request: 7 | branches: [develop] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | - name: Set up Python 3.8 15 | uses: actions/setup-python@v4 16 | with: 17 | python-version: "3.8" 18 | - name: Install hatch 19 | run: pip install --upgrade hatch 20 | - name: Run hatch build 21 | run: hatch --verbose build 22 | -------------------------------------------------------------------------------- /src/pytmv1/api/system.py: -------------------------------------------------------------------------------- 1 | from ..core import Core 2 | from ..model.enum import Api 3 | from ..model.response import ConnectivityResp 4 | from ..result import Result 5 | 6 | 7 | class System: 8 | _core: Core 9 | 10 | def __init__(self, core: Core): 11 | self._core = core 12 | 13 | def check_connectivity(self) -> Result[ConnectivityResp]: 14 | """Checks the connection to the API service 15 | and verifies if your authentication token is valid. 16 | 17 | :rtype: Result[ConnectivityResp] 18 | """ 19 | return self._core.send(ConnectivityResp, Api.CONNECTIVITY) 20 | -------------------------------------------------------------------------------- /src/pytmv1/api/__init__.py: -------------------------------------------------------------------------------- 1 | from .account import Account 2 | from .alert import Alert 3 | from .api_key import ApiKey 4 | from .email import Email 5 | from .endpoint import Endpoint 6 | from .note import Note 7 | from .oat import Oat 8 | from .object import Object 9 | from .sandbox import Sandbox 10 | from .script import CustomScript 11 | from .system import System 12 | from .task import Task 13 | 14 | __all__ = [ 15 | "Account", 16 | "Alert", 17 | "ApiKey", 18 | "CustomScript", 19 | "Email", 20 | "Endpoint", 21 | "Note", 22 | "Oat", 23 | "Object", 24 | "Sandbox", 25 | "System", 26 | "Task", 27 | ] 28 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | import pytmv1 4 | from pytmv1.core import Core 5 | 6 | 7 | def pytest_addoption(parser): 8 | parser.addoption( 9 | "--mock-url", 10 | action="store", 11 | default="", 12 | dest="mock-url", 13 | help="Mock URL for Vision One API", 14 | ) 15 | parser.addoption( 16 | "--token", 17 | action="store", 18 | default="", 19 | dest="token", 20 | help="Token Vision One API", 21 | ) 22 | 23 | 24 | @pytest.fixture(scope="package") 25 | def url(pytestconfig): 26 | url = pytestconfig.getoption("mock-url") 27 | return url if url else "https://dummy-server.com" 28 | 29 | 30 | @pytest.fixture(scope="package") 31 | def token(pytestconfig): 32 | token = pytestconfig.getoption("token") 33 | return token if token else "dummyToken" 34 | 35 | 36 | @pytest.fixture(scope="package") 37 | def client(pytestconfig, token, url): 38 | return pytmv1.init( 39 | "appname", 40 | token, 41 | url, 42 | ) 43 | 44 | 45 | @pytest.fixture(scope="package") 46 | def core(pytestconfig, token, url): 47 | return Core( 48 | "appname", 49 | token, 50 | url, 51 | 0, 52 | 0, 53 | 30, 54 | 30, 55 | ) 56 | -------------------------------------------------------------------------------- /tests/integration/test_account.py: -------------------------------------------------------------------------------- 1 | from pytmv1 import AccountRequest, ResultCode 2 | 3 | 4 | def test_disable_accounts(client): 5 | result = client.account.disable(AccountRequest(accountName="test")) 6 | assert result.result_code == ResultCode.SUCCESS 7 | assert len(result.response.items) > 0 8 | assert result.response.items[0].status == 202 9 | assert result.response.items[0].task_id == "00000009" 10 | 11 | 12 | def test_enable_accounts(client): 13 | result = client.account.enable(AccountRequest(accountName="test")) 14 | assert result.result_code == ResultCode.SUCCESS 15 | assert len(result.response.items) > 0 16 | assert result.response.items[0].status == 202 17 | assert result.response.items[0].task_id == "00000010" 18 | 19 | 20 | def test_reset_accounts(client): 21 | result = client.account.reset(AccountRequest(accountName="test")) 22 | assert result.result_code == ResultCode.SUCCESS 23 | assert len(result.response.items) > 0 24 | assert result.response.items[0].status == 202 25 | assert result.response.items[0].task_id == "00000011" 26 | 27 | 28 | def test_sign_out_accounts(client): 29 | result = client.account.sign_out(AccountRequest(accountName="test")) 30 | assert result.result_code == ResultCode.SUCCESS 31 | assert len(result.response.items) > 0 32 | assert result.response.items[0].status == 202 33 | assert result.response.items[0].task_id == "00000012" 34 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | push: 5 | branches: [main, develop] 6 | pull_request: 7 | branches: [develop] 8 | 9 | jobs: 10 | unit: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | python-version: [ "3.8", "3.9", "3.10", "3.11" ] 15 | steps: 16 | - uses: actions/checkout@v3 17 | - name: Set up Python ${{ matrix.python-version }} 18 | uses: actions/setup-python@v4 19 | with: 20 | python-version: ${{ matrix.python-version }} 21 | - name: Install dependencies 22 | run: pip install -e ".[dev]" 23 | - name: Run pytest 24 | run: pytest --log-cli-level="DEBUG" --verbose ./tests/unit 25 | integration: 26 | runs-on: ubuntu-latest 27 | strategy: 28 | matrix: 29 | python-version: [ "3.8", "3.9", "3.10", "3.11" ] 30 | steps: 31 | - uses: actions/checkout@v3 32 | - name: Set up Python ${{ matrix.python-version }} 33 | uses: actions/setup-python@v4 34 | with: 35 | python-version: ${{ matrix.python-version }} 36 | - name: Install dependencies 37 | run: pip install -e ".[dev]" 38 | - name: Run pytest 39 | run: pytest --collect-only --quiet ./tests/integration || echo "Integration tests skipped" 40 | # run: pytest --mock-url="${{ secrets.MOCK_URL }}" --log-cli-level="DEBUG" --verbose ./tests/integration -------------------------------------------------------------------------------- /src/pytmv1/adapter.py: -------------------------------------------------------------------------------- 1 | import typing 2 | from typing import Any 3 | 4 | from requests.adapters import DEFAULT_POOLBLOCK 5 | from requests.adapters import HTTPAdapter as AdapterUrllib 6 | from urllib3.connectionpool import HTTPConnectionPool as HTTPUrllib 7 | from urllib3.connectionpool import HTTPSConnectionPool as HTTPSUrllib 8 | from urllib3.poolmanager import PoolManager as ManagerUrllib 9 | 10 | 11 | class HTTPConnectionPool(HTTPUrllib): 12 | @typing.no_type_check 13 | def urlopen(self, method, url, **kwargs): 14 | return super().urlopen( 15 | method, 16 | url, 17 | pool_timeout=5, 18 | **kwargs, 19 | ) 20 | 21 | 22 | class HTTPSConnectionPool(HTTPSUrllib, HTTPConnectionPool): ... 23 | 24 | 25 | class PoolManager(ManagerUrllib): 26 | def __init__( 27 | self, 28 | **connection_pool_kw: Any, 29 | ): 30 | super().__init__(**connection_pool_kw) 31 | self.pool_classes_by_scheme = { 32 | "http": HTTPConnectionPool, 33 | "https": HTTPSConnectionPool, 34 | } 35 | 36 | 37 | class HTTPAdapter(AdapterUrllib): 38 | @typing.no_type_check 39 | def init_poolmanager( 40 | self, connections, maxsize, block=DEFAULT_POOLBLOCK, **pool_kwargs 41 | ) -> None: 42 | super().init_poolmanager(connections, maxsize, block, **pool_kwargs) 43 | self.poolmanager = PoolManager( 44 | num_pools=connections, 45 | maxsize=maxsize, 46 | block=block, 47 | **pool_kwargs, 48 | ) 49 | -------------------------------------------------------------------------------- /src/pytmv1/exception.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import List 4 | 5 | from requests import Response 6 | 7 | from .model.common import Error, MsError 8 | 9 | 10 | class ServerCustError(Exception): 11 | def __init__(self, status: int, message: str): 12 | super().__init__(message) 13 | self.status = status 14 | 15 | 16 | class ServerJsonError(Exception): 17 | def __init__(self, error: Error): 18 | super().__init__( 19 | f"Error response received from Vision One. [Error={error}]" 20 | ) 21 | self.error = error 22 | 23 | 24 | class ServerMultiJsonError(Exception): 25 | def __init__(self, errors: List[MsError]): 26 | super().__init__( 27 | ( 28 | "Multi error response received from Vision One." 29 | f" [Errors={errors}]" 30 | ), 31 | ) 32 | self.errors = errors 33 | 34 | 35 | class ServerHtmlError(ServerCustError): 36 | def __init__(self, status: int, html: str): 37 | super().__init__( 38 | status, 39 | html, 40 | ) 41 | 42 | 43 | class ServerTextError(ServerCustError): 44 | def __init__(self, status: int, text: str): 45 | super().__init__( 46 | status, 47 | text, 48 | ) 49 | 50 | 51 | class ParseModelError(ServerCustError): 52 | def __init__(self, model: str, raw_response: Response): 53 | super().__init__( 54 | 500, 55 | ( 56 | "Could not parse response from Vision One.\n" 57 | f"Conditions unmet [Model={model}, {raw_response}]" 58 | ), 59 | ) 60 | -------------------------------------------------------------------------------- /tests/integration/test_network.py: -------------------------------------------------------------------------------- 1 | import os 2 | from threading import Thread 3 | 4 | import psutil 5 | import pytest 6 | 7 | import pytmv1 8 | 9 | 10 | def test_conn_opened_with_single_call_single_client_is_one(client): 11 | client.object.list_exception() 12 | assert len(list_tcp_conn()) == 1 13 | 14 | 15 | @pytest.mark.parametrize("execution_number", range(10)) 16 | def test_conn_opened_with_multi_call_single_client_is_one( 17 | execution_number, client 18 | ): 19 | client.object.list_exception() 20 | assert len(list_tcp_conn()) == 1 21 | 22 | 23 | def test_conn_opened_with_multi_processing_single_client_is_one(client): 24 | threads = thread_list(lambda: client.note.create("1", "dummy note")) 25 | for t in threads: 26 | t.start() 27 | for t in threads: 28 | t.join() 29 | assert len(list_tcp_conn()) == 1 30 | 31 | 32 | def test_conn_opened_with_multi_processing_multi_client_is_two(client, url): 33 | client.object.list_exception() 34 | threads = thread_list( 35 | lambda: pytmv1.init( 36 | "appname2", "dummyToken", url 37 | ).object.list_exception() 38 | ) 39 | for t in threads: 40 | t.start() 41 | for t in threads: 42 | t.join() 43 | assert len(list_tcp_conn()) == 2 44 | 45 | 46 | def list_tcp_conn(): 47 | return list( 48 | filter( 49 | lambda sc: len(sc[4]) 50 | and 1024 > sc[4][1] < 49151 51 | and sc[-1] == os.getpid() 52 | and sc[5] == "ESTABLISHED", 53 | psutil.net_connections("tcp"), 54 | ) 55 | ) 56 | 57 | 58 | def thread_list(func): 59 | return [Thread(target=func) for _ in range(10)] 60 | -------------------------------------------------------------------------------- /tests/integration/test_endpoint.py: -------------------------------------------------------------------------------- 1 | from pytmv1 import ( 2 | CollectFileRequest, 3 | EndpointRequest, 4 | MultiResp, 5 | ResultCode, 6 | TerminateProcessRequest, 7 | ) 8 | 9 | 10 | def test_collect_endpoints_file(client): 11 | result = client.endpoint.collect_file( 12 | CollectFileRequest(endpointName="client1", filePath="/tmp/dummy.txt") 13 | ) 14 | assert isinstance(result.response, MultiResp) 15 | assert result.result_code == ResultCode.SUCCESS 16 | assert len(result.response.items) > 0 17 | assert result.response.items[0].status == 202 18 | 19 | 20 | def test_isolate_endpoints(client): 21 | result = client.endpoint.isolate(EndpointRequest(endpointName="client1")) 22 | assert isinstance(result.response, MultiResp) 23 | assert result.result_code == ResultCode.SUCCESS 24 | assert len(result.response.items) > 0 25 | assert result.response.items[0].status == 202 26 | 27 | 28 | def test_restore_endpoints(client): 29 | result = client.endpoint.restore(EndpointRequest(endpointName="client1")) 30 | assert isinstance(result.response, MultiResp) 31 | assert result.result_code == ResultCode.SUCCESS 32 | assert len(result.response.items) > 0 33 | assert result.response.items[0].status == 202 34 | 35 | 36 | def test_terminate_endpoints_process(client): 37 | result = client.endpoint.terminate_process( 38 | TerminateProcessRequest( 39 | endpointName="client1", fileSha1="sha12345", fileName="dummy.exe" 40 | ) 41 | ) 42 | assert isinstance(result.response, MultiResp) 43 | assert result.result_code == ResultCode.SUCCESS 44 | assert len(result.response.items) > 0 45 | assert result.response.items[0].status == 202 46 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: lint 2 | 3 | on: 4 | push: 5 | branches: [main, develop] 6 | pull_request: 7 | branches: [develop] 8 | 9 | jobs: 10 | black: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | - name: Set up Python 3.8 15 | uses: actions/setup-python@v4 16 | with: 17 | python-version: "3.8" 18 | - name: Install dependencies 19 | run: pip install --upgrade pip black 20 | - name: Run black 21 | run: black --check --diff --verbose . 22 | isort: 23 | runs-on: ubuntu-latest 24 | steps: 25 | - uses: actions/checkout@v3 26 | - name: Set up Python 3.8 27 | uses: actions/setup-python@v4 28 | with: 29 | python-version: "3.8" 30 | - name: Install dependencies 31 | run: pip install --upgrade pip isort[colors] 32 | - name: Run isort 33 | run: isort --check-only --diff --verbose . 34 | flake8: 35 | runs-on: ubuntu-latest 36 | steps: 37 | - uses: actions/checkout@v3 38 | - name: Set up Python 3.8 39 | uses: actions/setup-python@v4 40 | with: 41 | python-version: "3.8" 42 | - name: Install dependencies 43 | run: pip install --upgrade pip flake8 44 | - name: Run flake8 45 | run: flake8 --verbose . 46 | mypy: 47 | runs-on: ubuntu-latest 48 | steps: 49 | - uses: actions/checkout@v3 50 | - name: Set up Python 3.8 51 | uses: actions/setup-python@v4 52 | with: 53 | python-version: "3.8" 54 | - name: Install dependencies 55 | run: pip install --upgrade pip mypy pydantic 56 | - name: Run mypy 57 | run: mypy --install-types --non-interactive ./src 58 | -------------------------------------------------------------------------------- /src/pytmv1/api/task.py: -------------------------------------------------------------------------------- 1 | from typing import Type 2 | 3 | from ..core import Core 4 | from ..model.response import BaseTaskResp, T 5 | from ..result import Result 6 | 7 | 8 | class Task: 9 | _core: Core 10 | 11 | def __init__(self, core: Core): 12 | self._core = core 13 | 14 | def get_result( 15 | self, 16 | task_id: str, 17 | poll: bool = True, 18 | poll_time_sec: float = 1800, 19 | ) -> Result[BaseTaskResp]: 20 | """Retrieves the result of a response task. 21 | 22 | :param task_id: Task id. 23 | :type task_id: str 24 | :param poll: If we should wait until the task is finished before 25 | to return the result. 26 | :type poll: bool 27 | :param poll_time_sec: Maximum time to wait for the result 28 | to be available. 29 | :type poll_time_sec: float 30 | :rtype: Result[T]: 31 | """ 32 | return self.get_result_class( 33 | task_id, BaseTaskResp, poll, poll_time_sec 34 | ) 35 | 36 | def get_result_class( 37 | self, 38 | task_id: str, 39 | class_: Type[T], 40 | poll: bool = True, 41 | poll_time_sec: float = 1800, 42 | ) -> Result[T]: 43 | """Retrieves the result of a response task. 44 | 45 | :param task_id: Task id. 46 | :type task_id: str 47 | :param class_: Expected task result class. 48 | :type class_: Type[T] 49 | :param poll: If we should wait until the task is finished before 50 | to return the result. 51 | :type poll: bool 52 | :param poll_time_sec: Maximum time to wait for the result 53 | to be available. 54 | :type poll_time_sec: float 55 | :rtype: Result[T]: 56 | """ 57 | return self._core.send_task_result( 58 | class_, task_id, poll, poll_time_sec 59 | ) 60 | -------------------------------------------------------------------------------- /tests/integration/test_email.py: -------------------------------------------------------------------------------- 1 | from pytmv1 import EmailMessageIdRequest, ResultCode 2 | 3 | 4 | def test_delete_email_messages(client): 5 | result = client.email.delete(EmailMessageIdRequest(messageId="1")) 6 | assert result.result_code == ResultCode.SUCCESS 7 | assert len(result.response.items) > 0 8 | assert result.response.items[0].status == 202 9 | 10 | 11 | def test_delete_email_messages_is_failed(client): 12 | result = client.email.delete( 13 | EmailMessageIdRequest(messageId="server_error") 14 | ) 15 | assert result.result_code == ResultCode.ERROR 16 | assert result.errors[0].status == 500 17 | assert result.errors[0].code == "InternalServerError" 18 | 19 | 20 | def test_delete_email_messages_is_bad_request(client): 21 | result = client.email.delete( 22 | EmailMessageIdRequest(messageId="invalid_format") 23 | ) 24 | assert result.result_code == ResultCode.ERROR 25 | assert result.errors[0].code == "BadRequest" 26 | assert result.errors[0].status == 400 27 | 28 | 29 | def test_delete_email_messages_is_denied(client): 30 | result = client.email.delete( 31 | EmailMessageIdRequest(messageId="access_denied") 32 | ) 33 | assert result.result_code == ResultCode.ERROR 34 | assert result.errors[0].code == "AccessDenied" 35 | assert result.errors[0].status == 403 36 | 37 | 38 | def test_quarantine_email_messages(client): 39 | result = client.email.quarantine(EmailMessageIdRequest(messageId="1")) 40 | assert result.result_code == ResultCode.SUCCESS 41 | assert len(result.response.items) > 0 42 | assert result.response.items[0].status == 202 43 | 44 | 45 | def test_restore_email_messages(client): 46 | result = client.email.restore(EmailMessageIdRequest(messageId="1")) 47 | assert result.result_code == ResultCode.SUCCESS 48 | assert len(result.response.items) > 0 49 | assert result.response.items[0].status == 202 50 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | version: 7 | description: "Release version" 8 | required: true 9 | type: string 10 | 11 | jobs: 12 | release: 13 | runs-on: ubuntu-latest 14 | env: 15 | VERSION: ${{ github.event.inputs.version }} 16 | BRANCH: releases/${{ github.event.inputs.version }} 17 | steps: 18 | - uses: actions/checkout@v3 19 | with: 20 | ref: releases/${{ github.event.inputs.version }} 21 | ssh-key: ${{ secrets.USER_SSH_KEY }} 22 | fetch-depth: 0 23 | - name: Set up Python 3.8 24 | uses: actions/setup-python@v4 25 | with: 26 | python-version: "3.8" 27 | - name: Set up Git 28 | run: | 29 | echo "${{ secrets.USER_GPG_KEY }}" | gpg --import 30 | git config --add user.name "${{ secrets.USER_NAME }}" 31 | git config --add user.email "${{ secrets.USER_MAIL }}" 32 | git config --add user.signingkey "${{ secrets.USER_GPG_ID }}" 33 | git config commit.gpgsign true 34 | - name: Run build 35 | run: | 36 | pip install --upgrade hatch 37 | hatch version ${{ env.VERSION }} 38 | hatch --verbose build 39 | - name: Merge release branch 40 | run: | 41 | git commit -am "Release ${{ env.VERSION }}: increment version" 42 | git checkout main 43 | git merge --no-ff ${{ env.BRANCH }} 44 | git tag -a v${{ env.VERSION }} -m "Release ${{ env.VERSION }}" 45 | # Try to merge back release branch 46 | git checkout ${{ env.BRANCH }} 47 | git merge --ff-only main 48 | git checkout develop 49 | git merge --no-ff ${{ env.BRANCH }} 50 | git push --atomic origin main develop refs/tags/v${{ env.VERSION }} :${{ env.BRANCH }} 51 | - name: Publish package to PyPI 52 | uses: pypa/gh-action-pypi-publish@release/v1 53 | with: 54 | password: ${{ secrets.PYPI_TOKEN }} 55 | verbose: true -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling>=1.12.2"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "pytmv1" 7 | description = "Python library for Trend Micro Vision One" 8 | license = "Apache-2.0" 9 | readme = "README.md" 10 | dynamic = ["version"] 11 | requires-python = ">=3.8" 12 | authors = [ 13 | { name = "Thomas Legros", email = "thomas_legros@trendmicro.com" } 14 | ] 15 | maintainers = [ 16 | { name = "TrendATI", email = "ati-integration@trendmicro.com"}, 17 | ] 18 | classifiers = [ 19 | "Development Status :: 2 - Pre-Alpha", 20 | "Intended Audience :: Developers", 21 | "License :: OSI Approved :: Apache Software License", 22 | "Natural Language :: English", 23 | "Operating System :: OS Independent", 24 | "Programming Language :: Python :: 3.8", 25 | "Programming Language :: Python :: 3.9", 26 | "Programming Language :: Python :: 3.10", 27 | "Programming Language :: Python :: 3.11", 28 | "Topic :: Internet :: WWW/HTTP", 29 | "Topic :: Software Development :: Libraries", 30 | ] 31 | dependencies = [ 32 | "beautifulsoup4 ~= 4.11.1", 33 | "requests ~= 2.32.3", 34 | "pydantic ~= 2.5.3", 35 | ] 36 | 37 | [project.optional-dependencies] 38 | dev = [ 39 | "hatch ~= 1.6.3", 40 | "psutil ~= 5.9.4", 41 | "pytest ~= 7.2.0", 42 | "pytest-mock ~= 3.10.0", 43 | "pytest-cov ~= 4.0.0", 44 | ] 45 | 46 | [project.urls] 47 | "Source" = "https://github.com/TrendATI/pytmv1" 48 | "Issues" = "https://github.com/TrendATI/pytmv1/issues" 49 | 50 | [tool.hatch.build.targets.sdist] 51 | exclude = [".github", "template", "tests"] 52 | 53 | [tool.hatch.version] 54 | path = "src/pytmv1/__about__.py" 55 | 56 | [tool.black] 57 | target-version = ["py37"] 58 | line-length = 79 59 | preview = true 60 | color = true 61 | 62 | [tool.isort] 63 | profile = "black" 64 | line_length = 79 65 | color_output = true 66 | 67 | [tool.mypy] 68 | python_version = "3.8" 69 | exclude = ["dist", "template", "tests", "venv"] 70 | show_column_numbers = true 71 | warn_unused_configs = true 72 | pretty = true 73 | strict = true 74 | 75 | [tool.pytest.ini_options] 76 | addopts = "--show-capture=log -s" -------------------------------------------------------------------------------- /tests/integration/test_api_key.py: -------------------------------------------------------------------------------- 1 | from pytmv1 import ApiKeyRequest, ApiStatus, NoContentResp, ResultCode 2 | 3 | 4 | def test_create_api_key(client): 5 | result = client.api_key.create( 6 | ApiKeyRequest(name="test", role="Master Administrator") 7 | ) 8 | assert result.result_code == ResultCode.SUCCESS 9 | assert len(result.response.items) > 0 10 | assert result.response.items[0].status == 201 11 | assert ( 12 | result.response.items[0].id == "d367abdd-7739-4129-a36a-862c4ec018b4" 13 | ) 14 | assert result.response.items[0].value == "string" 15 | 16 | 17 | def test_update_api_key(client): 18 | result = client.api_key.update( 19 | key_id="d367abdd-7739-4129-a36a-862c4ec018b4", 20 | etag="d41d8cd98f00b204e9800998ecf8427e", 21 | name="thomas_legros3", 22 | ) 23 | assert result.result_code == ResultCode.SUCCESS 24 | assert isinstance(result.response, NoContentResp) 25 | 26 | 27 | def test_delete_api_key(client): 28 | result = client.api_key.delete("d367abdd-7739-4129-a36a-862c4ec018b4") 29 | assert result.result_code == ResultCode.SUCCESS 30 | assert len(result.response.items) > 0 31 | assert result.response.items[0].status == 204 32 | 33 | 34 | def test_get_api_key(client): 35 | result = client.api_key.get("d367abdd-7739-4129-a36a-862c4ec018b4") 36 | assert result.result_code == ResultCode.SUCCESS 37 | assert result.response.etag == "d41d8cd98f00b204e9800998ecf8427e" 38 | assert result.response.data.id == "d367abdd-7739-4129-a36a-862c4ec018b4" 39 | assert result.response.data.status == ApiStatus.ENABLED 40 | assert result.response.data.name == "test" 41 | assert result.response.data.role == "Master Administrator" 42 | 43 | 44 | def test_list_api_keys(client): 45 | result = client.api_key.list(name="test", role="Master Administrator") 46 | assert result.result_code == ResultCode.SUCCESS 47 | assert len(result.response.items) > 0 48 | assert ( 49 | result.response.items[0].id == "d367abdd-7739-4129-a36a-862c4ec018b4" 50 | ) 51 | 52 | 53 | def test_consume_api_keys(client): 54 | result = client.api_key.consume( 55 | lambda s: None, name="test", role="Master Administrator" 56 | ) 57 | assert result.result_code == ResultCode.SUCCESS 58 | assert result.response.total_consumed == 1 59 | -------------------------------------------------------------------------------- /tests/integration/test_script.py: -------------------------------------------------------------------------------- 1 | from pytmv1 import ( 2 | AddCustomScriptResp, 3 | ConsumeLinkableResp, 4 | CustomScriptRequest, 5 | ListCustomScriptsResp, 6 | MultiResp, 7 | NoContentResp, 8 | ResultCode, 9 | ScriptType, 10 | TextResp, 11 | ) 12 | 13 | 14 | def test_add_custom_script(client): 15 | result = client.script.create( 16 | script_type=ScriptType.BASH, 17 | script_name="add_script.sh", 18 | script_content="#!/bin/sh\necho 'Add script'", 19 | ) 20 | assert isinstance(result.response, AddCustomScriptResp) 21 | assert result.result_code == ResultCode.SUCCESS 22 | assert result.response.script_id 23 | 24 | 25 | def test_delete_custom_script(client): 26 | result = client.script.delete("delete_script") 27 | assert isinstance(result.response, NoContentResp) 28 | assert result.result_code == ResultCode.SUCCESS 29 | 30 | 31 | def test_download_custom_script(client): 32 | result = client.script.download("download_script") 33 | assert isinstance(result.response, TextResp) 34 | assert result.result_code == ResultCode.SUCCESS 35 | assert result.response.text == "#!/bin/sh Download Script" 36 | 37 | 38 | def test_run_custom_scripts(client): 39 | result = client.script.run( 40 | CustomScriptRequest(fileName="test", endpointName="client1") 41 | ) 42 | assert isinstance(result.response, MultiResp) 43 | assert result.result_code == ResultCode.SUCCESS 44 | assert len(result.response.items) > 0 45 | assert result.response.items[0].status == 202 46 | 47 | 48 | def test_update_custom_script(client): 49 | result = client.script.update( 50 | script_id="123", 51 | script_type=ScriptType.BASH, 52 | script_name="update_script.sh", 53 | script_content="#!/bin/sh Update script", 54 | ) 55 | assert isinstance(result.response, NoContentResp) 56 | assert result.result_code == ResultCode.SUCCESS 57 | 58 | 59 | def test_consume_custom_scripts(client): 60 | result = client.script.consume( 61 | lambda s: None, fileName="random_script.ps1", fileType="powershell" 62 | ) 63 | assert isinstance(result.response, ConsumeLinkableResp) 64 | assert result.result_code == ResultCode.SUCCESS 65 | assert result.response.total_consumed == 1 66 | 67 | 68 | def test_list_custom_scripts(client): 69 | result = client.script.list( 70 | fileName="random_script.ps1", fileType="powershell" 71 | ) 72 | assert isinstance(result.response, ListCustomScriptsResp) 73 | assert result.result_code == ResultCode.SUCCESS 74 | assert len(result.response.items) > 0 75 | -------------------------------------------------------------------------------- /src/pytmv1/model/request.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from .common import BaseModel 4 | from .enum import ApiExpInMonths, ApiStatus, ObjectType, RiskLevel, ScanAction 5 | 6 | 7 | class AccountRequest(BaseModel): 8 | account_name: str 9 | """User account name.""" 10 | description: Optional[str] = None 11 | """Description of a response task.""" 12 | 13 | 14 | class EndpointRequest(BaseModel): 15 | endpoint_name: Optional[str] = None 16 | """Endpoint name.""" 17 | agent_guid: Optional[str] = None 18 | """Agent guid""" 19 | description: Optional[str] = None 20 | """Description of a response task.""" 21 | 22 | 23 | class CustomScriptRequest(EndpointRequest): 24 | file_name: str 25 | parameter: Optional[str] = None 26 | """Options passed to the script during execution""" 27 | 28 | 29 | class EmailMessageIdRequest(BaseModel): 30 | message_id: str 31 | """Email message id.""" 32 | mail_box: Optional[str] = None 33 | """Email address.""" 34 | description: Optional[str] = None 35 | """Description of a response task.""" 36 | 37 | 38 | class EmailMessageUIdRequest(BaseModel): 39 | unique_id: str 40 | """Email unique message id.""" 41 | description: Optional[str] = None 42 | """Description of a response task.""" 43 | 44 | 45 | class ObjectRequest(BaseModel): 46 | object_type: ObjectType 47 | """Type of object.""" 48 | object_value: str 49 | """Value of an object.""" 50 | description: Optional[str] = None 51 | """Description of an object.""" 52 | 53 | 54 | class SuspiciousObjectRequest(ObjectRequest): 55 | scan_action: Optional[ScanAction] = None 56 | """Action applied after detecting a suspicious object.""" 57 | risk_level: Optional[RiskLevel] = None 58 | """Risk level of a suspicious object.""" 59 | days_to_expiration: Optional[int] = None 60 | """Number of days before the object expires.""" 61 | 62 | 63 | class CollectFileRequest(EndpointRequest): 64 | file_path: str 65 | """File path of the file to be collected from the target.""" 66 | 67 | 68 | class TerminateProcessRequest(EndpointRequest): 69 | file_sha1: str 70 | """SHA1 hash of the terminated process's executable file.""" 71 | file_name: Optional[str] = None 72 | """File name of the target.""" 73 | 74 | 75 | class ApiKeyRequest(BaseModel): 76 | name: str 77 | role: str 78 | months_to_expiration: Optional[ApiExpInMonths] = ApiExpInMonths.ZERO 79 | description: Optional[str] = None 80 | status: Optional[ApiStatus] = ApiStatus.ENABLED 81 | -------------------------------------------------------------------------------- /src/pytmv1/client.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | import threading 5 | from functools import lru_cache 6 | from logging import Logger 7 | from typing import Any 8 | 9 | from . import api 10 | from .core import Core 11 | 12 | log: Logger = logging.getLogger(__name__) 13 | lock = threading.Lock() 14 | 15 | 16 | def init( 17 | name: str, 18 | token: str, 19 | url: str, 20 | pool_connections: int = 1, 21 | pool_maxsize: int = 1, 22 | connect_timeout: int = 10, 23 | read_timeout: int = 30, 24 | ) -> Client: 25 | """Synchronized Helper function to initialize a :class:`Client`. 26 | 27 | :param name: Identify the application using this library. 28 | :type name: str 29 | :param token: Authentication token created for your account. 30 | :type token: str 31 | :param url: Vision One API url this client connects to. 32 | :type url: str 33 | :param pool_connections: (optional) Number of connection to cache. 34 | :type pool_connections: int 35 | :param pool_maxsize: (optional) Maximum size of the pool. 36 | :type pool_maxsize: int 37 | :param connect_timeout: (optional) Seconds before connection timeout. 38 | :type connect_timeout: int 39 | :param read_timeout: (optional) Seconds before read timeout. 40 | :type connect_timeout: int 41 | :rtype: Client 42 | """ 43 | lock.acquire() 44 | _cl = _client( 45 | appname=name, 46 | token=token, 47 | url=url, 48 | pool_connections=pool_connections, 49 | pool_maxsize=pool_maxsize, 50 | connect_timeout=connect_timeout, 51 | read_timeout=read_timeout, 52 | ) 53 | lock.release() 54 | return _cl 55 | 56 | 57 | @lru_cache(maxsize=1) 58 | def _client(**kwargs: Any) -> Client: 59 | log.debug( 60 | "Initializing new client with [Appname=%s, Token=*****, URL=%s]", 61 | kwargs["appname"], 62 | kwargs["url"], 63 | ) 64 | return Client(Core(**kwargs)) 65 | 66 | 67 | class Client: 68 | def __init__(self, core: Core): 69 | self._core = core 70 | self.account = api.Account(self._core) 71 | self.alert = api.Alert(self._core) 72 | self.api_key = api.ApiKey(self._core) 73 | self.email = api.Email(self._core) 74 | self.endpoint = api.Endpoint(self._core) 75 | self.note = api.Note(self._core) 76 | self.oat = api.Oat(self._core) 77 | self.object = api.Object(self._core) 78 | self.sandbox = api.Sandbox(self._core) 79 | self.script = api.CustomScript(self._core) 80 | self.system = api.System(self._core) 81 | self.task = api.Task(self._core) 82 | -------------------------------------------------------------------------------- /src/pytmv1/api/account.py: -------------------------------------------------------------------------------- 1 | from pytmv1.core import Core 2 | from pytmv1.model.enum import Api 3 | 4 | from ..model.request import AccountRequest 5 | from ..model.response import MultiResp 6 | from ..result import MultiResult 7 | 8 | 9 | class Account: 10 | _core: Core 11 | 12 | def __init__(self, core: Core): 13 | self._core = core 14 | 15 | def disable(self, *accounts: AccountRequest) -> MultiResult[MultiResp]: 16 | """Signs the user out of all active application and browser sessions, 17 | and prevents the user from signing in any new session. 18 | 19 | :param accounts: Account(s) to disable. 20 | :type accounts: Tuple[AccountTask, ...] 21 | :rtype: MultiResult[MultiResp] 22 | """ 23 | return self._core.send_multi( 24 | MultiResp, 25 | Api.DISABLE_ACCOUNT, 26 | json=[ 27 | task.model_dump(by_alias=True, exclude_none=True) 28 | for task in accounts 29 | ], 30 | ) 31 | 32 | def enable(self, *accounts: AccountRequest) -> MultiResult[MultiResp]: 33 | """Allows the user to sign in to new application and browser sessions. 34 | 35 | :param accounts: Account(s) to enable. 36 | :type accounts: Tuple[AccountTask, ...] 37 | :rtype: MultiResult[MultiResp] 38 | """ 39 | return self._core.send_multi( 40 | MultiResp, 41 | Api.ENABLE_ACCOUNT, 42 | json=[ 43 | task.model_dump(by_alias=True, exclude_none=True) 44 | for task in accounts 45 | ], 46 | ) 47 | 48 | def reset(self, *accounts: AccountRequest) -> MultiResult[MultiResp]: 49 | """Signs the user out of all active application and browser sessions, 50 | and forces the user to create a new password during the next sign-in 51 | attempt. 52 | 53 | :param accounts: Account(s) to reset. 54 | :type accounts: Tuple[AccountTask, ...] 55 | :rtype: MultiResult[MultiResp] 56 | """ 57 | return self._core.send_multi( 58 | MultiResp, 59 | Api.RESET_PASSWORD, 60 | json=[ 61 | task.model_dump(by_alias=True, exclude_none=True) 62 | for task in accounts 63 | ], 64 | ) 65 | 66 | def sign_out(self, *accounts: AccountRequest) -> MultiResult[MultiResp]: 67 | """Signs the user out of all active application and browser sessions. 68 | 69 | :param accounts: Account(s) to sign out. 70 | :type accounts: Tuple[AccountTask, ...] 71 | :rtype: MultiResult[MultiResp] 72 | """ 73 | return self._core.send_multi( 74 | MultiResp, 75 | Api.SIGN_OUT_ACCOUNT, 76 | json=[ 77 | task.model_dump(by_alias=True, exclude_none=True) 78 | for task in accounts 79 | ], 80 | ) 81 | -------------------------------------------------------------------------------- /tests/unit/test_utils.py: -------------------------------------------------------------------------------- 1 | from pytmv1 import ObjectRequest, QueryOp, SuspiciousObjectRequest, utils 2 | from pytmv1.model.enum import ObjectType, ScanAction, SearchMode 3 | 4 | 5 | def test_build_activity_request(): 6 | start_time = "2021-04-05T08:22:37Z" 7 | end_time = "2021-04-06T08:22:37Z" 8 | select = ["dpt", "dst", "endpointHostName"] 9 | top = 50 10 | mode = SearchMode.DEFAULT 11 | result = utils.build_activity_request( 12 | start_time, 13 | end_time, 14 | select, 15 | top, 16 | mode, 17 | ) 18 | assert result == { 19 | "startDateTime": start_time, 20 | "endDateTime": end_time, 21 | "select": ",".join(select) if select else select, 22 | "top": top, 23 | "mode": mode, 24 | } 25 | 26 | 27 | def test_build_object_request(): 28 | result = utils.build_object_request( 29 | ObjectRequest(objectType=ObjectType.IP, objectValue="1.1.1.1") 30 | ) 31 | assert result == [{"ip": "1.1.1.1"}] 32 | 33 | 34 | def test_build_object_request_by_field_name(): 35 | result = utils.build_object_request( 36 | ObjectRequest(object_type=ObjectType.IP, object_value="1.1.1.1") 37 | ) 38 | assert result == [{"ip": "1.1.1.1"}] 39 | 40 | 41 | def test_build_sandbox_file_request(): 42 | result = utils.build_sandbox_file_request("pwd", "pwd2", None) 43 | assert result == { 44 | "documentPassword": "cHdk", 45 | "archivePassword": "cHdkMg==", 46 | } 47 | 48 | 49 | def test_build_suspicious_request(): 50 | result = utils.build_suspicious_request( 51 | SuspiciousObjectRequest( 52 | objectType=ObjectType.DOMAIN, 53 | objectValue="bad.com", 54 | scanAction=ScanAction.BLOCK, 55 | ) 56 | ) 57 | assert result == [{"domain": "bad.com", "scanAction": "block"}] 58 | 59 | 60 | def test_b64_encode(): 61 | assert utils._b64_encode("testString") == "dGVzdFN0cmluZw==" 62 | 63 | 64 | def test_b64_encode_with_none(): 65 | assert utils._b64_encode(None) is None 66 | 67 | 68 | def test_build_query(): 69 | result = utils._build_query( 70 | QueryOp.AND, "TMV1-Query", {"dpt": "443", "src": "1.1.1.1"} 71 | ) 72 | assert result == {"TMV1-Query": "dpt eq '443' and src eq '1.1.1.1'"} 73 | 74 | 75 | def test__build_activity_query(): 76 | result = utils._build_activity_query( 77 | QueryOp.AND, {"dpt": "443", "src": "1.1.1.1"} 78 | ) 79 | assert result == {"TMV1-Query": 'dpt:"443" and src:"1.1.1.1"'} 80 | 81 | 82 | def test_filter_query(): 83 | assert utils.filter_query( 84 | QueryOp.AND, {"fileName": "test.sh", "fileType": "bash"} 85 | ) == {"filter": "fileName eq 'test.sh' and fileType eq 'bash'"} 86 | 87 | 88 | def test_filter_none(): 89 | dictionary = utils.filter_none({"123": None}) 90 | assert len(dictionary) == 0 91 | dictionary = utils.filter_none({"123": "Value"}) 92 | assert len(dictionary) == 1 93 | -------------------------------------------------------------------------------- /.github/workflows/coverage.yml: -------------------------------------------------------------------------------- 1 | name: coverage 2 | 3 | on: 4 | push: 5 | branches: [main, develop] 6 | pull_request: 7 | branches: [develop] 8 | 9 | jobs: 10 | coverage: 11 | runs-on: ubuntu-latest 12 | outputs: 13 | total: ${{ steps.cov.outputs.total }} 14 | steps: 15 | - uses: actions/checkout@v3 16 | - name: Set up Python 3.8 17 | uses: actions/setup-python@v4 18 | with: 19 | python-version: "3.8" 20 | - name: Install dependencies 21 | run: pip install -e ".[dev]" 22 | - name: Run pytest coverage 23 | id: cov 24 | shell: bash {0} 25 | run: | 26 | pytest --cov-fail-under=90 --cov-report=term-missing --cov-report=xml --cov=pytmv1 --log-cli-level="DEBUG" --verbose ./tests/unit 27 | rc=$? 28 | echo "total=$(head -2 coverage.xml|grep -Po 'line-rate="\K\d{1}\.?\d{0,4}' |awk '{print $1 * 100}'|cut -d. -f1)" >> $GITHUB_OUTPUT 29 | exit $rc 30 | # run: | 31 | # pytest --mock-url="${{ secrets.MOCK_URL }}" --cov-fail-under=95 --cov-report=term-missing --cov-report=xml --cov=pytmv1 --log-cli-level="DEBUG" --verbose 32 | # rc=$? 33 | # echo "total=$(head -2 coverage.xml|grep -Po 'line-rate="\K\d{1}\.?\d{0,4}' |awk '{print $1 * 100}'|cut -d. -f1)" >> $GITHUB_OUTPUT 34 | # exit $rc 35 | badge: 36 | runs-on: ubuntu-latest 37 | needs: coverage 38 | if: ${{ always() && github.event_name == 'push' && needs.coverage.outputs.total != '' }} 39 | steps: 40 | - name: Install curl 41 | run: sudo apt install -y curl 42 | - if: ${{ needs.coverage.outputs.total < 25 }} 43 | run: echo "color=red" >> $GITHUB_ENV 44 | - if: ${{ needs.coverage.outputs.total >= 25 && needs.coverage.outputs.total < 50 }} 45 | run: echo "color=orange" >> $GITHUB_ENV 46 | - if: ${{ needs.coverage.outputs.total >= 50 && needs.coverage.outputs.total < 75 }} 47 | run: echo "color=yellow" >> $GITHUB_ENV 48 | - if: ${{ needs.coverage.outputs.total >= 75 && needs.coverage.outputs.total < 90 }} 49 | run: echo "color=yellowgreen" >> $GITHUB_ENV 50 | - if: ${{ needs.coverage.outputs.total >= 90 && needs.coverage.outputs.total < 95 }} 51 | run: echo "color=green" >> $GITHUB_ENV 52 | - if: ${{ needs.coverage.outputs.total >= 95 }} 53 | run: echo "color=brightgreen" >> $GITHUB_ENV 54 | - name: Update coverage badge 55 | run: | 56 | curl \ 57 | -X PATCH \ 58 | -H "Accept: application/vnd.github+json" \ 59 | -H "Authorization: Bearer ${{ secrets.GIST_TOKEN }}" \ 60 | -H "X-GitHub-Api-Version: 2022-11-28" \ 61 | https://api.github.com/gists/6c39ef59cc8beb9595e91fc96793de5b \ 62 | -d '{ 63 | "files": { 64 | "coverage.json": { 65 | "content": "{\n\"schemaVersion\": 1,\n\"label\": \"coverage\",\n\"message\": \"${{ needs.coverage.outputs.total }}%\",\n\"color\": \"${{ env.color }}\"\n}" 66 | } 67 | } 68 | }' 69 | -------------------------------------------------------------------------------- /tests/integration/test_search.py: -------------------------------------------------------------------------------- 1 | from pytmv1 import ( 2 | GetEmailActivitiesCountResp, 3 | GetEndpointActivitiesCountResp, 4 | IntegrityLevel, 5 | ListEmailActivityResp, 6 | ListEndpointActivityResp, 7 | ListEndpointDataResp, 8 | ProductCode, 9 | QueryOp, 10 | ResultCode, 11 | ) 12 | 13 | 14 | def test_consume_email_activities(client): 15 | result = client.email.consume_activity( 16 | lambda s: None, mailMsgSubject="spam" 17 | ) 18 | assert result.result_code == ResultCode.SUCCESS 19 | assert result.response.total_consumed == 1 20 | 21 | 22 | def test_list_email_activities(client): 23 | result = client.email.list_activity( 24 | mailMsgSubject="spam", mailSenderIp="192.169.1.1" 25 | ) 26 | assert result.result_code == ResultCode.SUCCESS 27 | assert isinstance(result.response, ListEmailActivityResp) 28 | assert len(result.response.items) > 0 29 | 30 | 31 | def test_get_email_activities_count(client): 32 | result = client.email.get_activity_count(mailMsgSubject="spam") 33 | assert result.result_code == ResultCode.SUCCESS 34 | assert isinstance(result.response, GetEmailActivitiesCountResp) 35 | assert result.response.total_count > 0 36 | 37 | 38 | def test_consume_endpoint_activities(client): 39 | result = client.endpoint.consume_activity(lambda s: None, dpt="443") 40 | assert result.result_code == ResultCode.SUCCESS 41 | assert result.response.total_consumed == 1 42 | 43 | 44 | def test_list_endpoint_activities(client): 45 | result = client.endpoint.list_activity(dpt="443") 46 | assert result.result_code == ResultCode.SUCCESS 47 | assert isinstance(result.response, ListEndpointActivityResp) 48 | assert len(result.response.items) > 0 49 | assert ( 50 | result.response.items[0].object_integrity_level 51 | == IntegrityLevel.MEDIUM 52 | ) 53 | 54 | 55 | def test_get_endpoint_activities_count(client): 56 | result = client.endpoint.get_activity_count(dpt="443") 57 | assert result.result_code == ResultCode.SUCCESS 58 | assert isinstance(result.response, GetEndpointActivitiesCountResp) 59 | assert result.response.total_count > 0 60 | 61 | 62 | def test_consume_endpoint_data(client): 63 | result = client.endpoint.consume_data( 64 | lambda s: None, QueryOp.AND, endpointName="client1" 65 | ) 66 | assert result.result_code == ResultCode.SUCCESS 67 | assert result.response.total_consumed == 1 68 | 69 | 70 | def test_list_endpoint_data(client): 71 | result = client.endpoint.list_data( 72 | endpointName="client1", productCode=ProductCode.XES 73 | ) 74 | assert result.result_code == ResultCode.SUCCESS 75 | assert isinstance(result.response, ListEndpointDataResp) 76 | assert len(result.response.items) > 0 77 | assert result.response.items[0].component_update_policy == "N-2" 78 | assert result.response.items[0].component_update_status == "pause" 79 | assert result.response.items[0].component_version == "outdatedVersion" 80 | 81 | 82 | def test_list_endpoint_data_optional_fields(client): 83 | result = client.endpoint.list_data(endpointName="optional_fields") 84 | assert result.result_code == ResultCode.SUCCESS 85 | assert isinstance(result.response, ListEndpointDataResp) 86 | assert len(result.response.items) > 0 87 | assert not result.response.items[0].product_code 88 | assert not result.response.items[0].os_name 89 | assert not result.response.items[0].os_version 90 | assert not result.response.items[0].os_description 91 | -------------------------------------------------------------------------------- /tests/integration/test_common.py: -------------------------------------------------------------------------------- 1 | from pytmv1 import ( 2 | CollectFileTaskResp, 3 | EmailMessageIdRequest, 4 | ResultCode, 5 | Status, 6 | ) 7 | 8 | 9 | def test_check_connectivity(client): 10 | assert client.system.check_connectivity() 11 | 12 | 13 | def test_get_task_result(client): 14 | result = client.task.get_result("collect_file") 15 | assert isinstance(result.response, CollectFileTaskResp) 16 | assert result.result_code == ResultCode.SUCCESS 17 | assert result.response.status == Status.SUCCEEDED 18 | assert result.response.action == "collectFile" 19 | assert result.response.file_sha256 20 | assert result.response.id == "00000003" 21 | 22 | 23 | def test_collect_file_task_result(client): 24 | result = client.task.get_result_class( 25 | "collect_file", CollectFileTaskResp, False 26 | ) 27 | assert isinstance(result.response, CollectFileTaskResp) 28 | assert result.result_code == ResultCode.SUCCESS 29 | assert result.response.status == Status.SUCCEEDED 30 | assert result.response.action == "collectFile" 31 | assert result.response.file_sha256 32 | assert result.response.id == "00000003" 33 | 34 | 35 | def test_collect_file_task_result_is_failed(client): 36 | result = client.task.get_result_class( 37 | "internal_error", CollectFileTaskResp, False 38 | ) 39 | assert not result.response 40 | assert result.result_code == ResultCode.ERROR 41 | assert result.error.code == "InternalServerError" 42 | assert result.error.status == 500 43 | 44 | 45 | def test_collect_file_task_result_is_bad_request(client): 46 | result = client.task.get_result_class( 47 | "bad_request", CollectFileTaskResp, False 48 | ) 49 | assert not result.response 50 | assert result.result_code == ResultCode.ERROR 51 | assert result.error.code == "BadRequest" 52 | assert result.error.status == 400 53 | 54 | 55 | def test_multi_status_is_failed(client): 56 | result = client.email.delete( 57 | EmailMessageIdRequest(messageId="internal_server_error") 58 | ) 59 | assert not result.response 60 | assert result.result_code == ResultCode.ERROR 61 | assert result.errors[0].code == "InternalServerError" 62 | assert result.errors[0].status == 500 63 | 64 | 65 | def test_multi_status_is_bad_request(client): 66 | result = client.email.delete( 67 | EmailMessageIdRequest(messageId="fields_not_found") 68 | ) 69 | assert not result.response 70 | assert result.result_code == ResultCode.ERROR 71 | assert result.errors[0].code == "BadRequest" 72 | assert result.errors[0].status == 400 73 | 74 | 75 | def test_multi_status_is_denied(client): 76 | result = client.email.delete( 77 | EmailMessageIdRequest(messageId="insufficient_permissions") 78 | ) 79 | assert not result.response 80 | assert result.result_code == ResultCode.ERROR 81 | assert result.errors[0].code == "AccessDenied" 82 | assert result.errors[0].status == 403 83 | 84 | 85 | def test_multi_status_is_not_supported(client): 86 | result = client.email.delete( 87 | EmailMessageIdRequest(messageId="action_not_supported") 88 | ) 89 | assert not result.response 90 | assert result.result_code == ResultCode.ERROR 91 | assert result.errors[0].code == "NotSupported" 92 | assert result.errors[0].status == 400 93 | 94 | 95 | def test_multi_status_is_task_error(client): 96 | result = client.email.delete( 97 | EmailMessageIdRequest(messageId="task_duplication") 98 | ) 99 | assert not result.response 100 | assert result.result_code == ResultCode.ERROR 101 | assert result.errors[0].code == "TaskError" 102 | assert result.errors[0].status == 400 103 | -------------------------------------------------------------------------------- /tests/integration/test_object.py: -------------------------------------------------------------------------------- 1 | from pytmv1 import ( 2 | ListExceptionsResp, 3 | ListSuspiciousResp, 4 | MultiResp, 5 | ObjectRequest, 6 | ObjectType, 7 | ResultCode, 8 | SuspiciousObjectRequest, 9 | ) 10 | from pytmv1.model.enum import ScanAction 11 | 12 | 13 | def test_add_exceptions(client): 14 | result = client.object.add_exception( 15 | ObjectRequest(objectType=ObjectType.IP, objectValue="1.1.1.1") 16 | ) 17 | assert isinstance(result.response, MultiResp) 18 | assert result.result_code == ResultCode.SUCCESS 19 | assert len(result.response.items) > 0 20 | assert result.response.items[0].task_id is None 21 | assert result.response.items[0].status == 201 22 | 23 | 24 | def test_add_block(client): 25 | result = client.object.add_block( 26 | ObjectRequest(objectType=ObjectType.IP, objectValue="1.1.1.1") 27 | ) 28 | assert isinstance(result.response, MultiResp) 29 | assert result.result_code == ResultCode.SUCCESS 30 | assert len(result.response.items) > 0 31 | assert result.response.items[0].task_id 32 | assert result.response.items[0].status == 202 33 | 34 | 35 | def test_add_suspicious(client): 36 | result = client.object.add_suspicious( 37 | SuspiciousObjectRequest( 38 | objectType=ObjectType.IP, 39 | objectValue="1.1.1.1", 40 | scanAction=ScanAction.BLOCK, 41 | ) 42 | ) 43 | assert isinstance(result.response, MultiResp) 44 | assert result.result_code == ResultCode.SUCCESS 45 | assert len(result.response.items) > 0 46 | assert result.response.items[0].task_id is None 47 | assert result.response.items[0].status == 201 48 | 49 | 50 | def test_delete_block(client): 51 | result = client.object.delete_block( 52 | ObjectRequest(objectType=ObjectType.IP, objectValue="1.1.1.1") 53 | ) 54 | assert isinstance(result.response, MultiResp) 55 | assert result.result_code == ResultCode.SUCCESS 56 | assert len(result.response.items) > 0 57 | assert result.response.items[0].task_id 58 | assert result.response.items[0].status == 202 59 | 60 | 61 | def test_delete_exceptions(client): 62 | result = client.object.delete_exception( 63 | ObjectRequest(objectType=ObjectType.IP, objectValue="1.1.1.1") 64 | ) 65 | assert isinstance(result.response, MultiResp) 66 | assert result.result_code == ResultCode.SUCCESS 67 | assert len(result.response.items) > 0 68 | assert result.response.items[0].task_id is None 69 | assert result.response.items[0].status == 204 70 | 71 | 72 | def test_delete_suspicious(client): 73 | result = client.object.delete_suspicious( 74 | ObjectRequest(objectType=ObjectType.IP, objectValue="1.1.1.1") 75 | ) 76 | assert isinstance(result.response, MultiResp) 77 | assert result.result_code == ResultCode.SUCCESS 78 | assert len(result.response.items) > 0 79 | assert result.response.items[0].task_id is None 80 | assert result.response.items[0].status == 204 81 | 82 | 83 | def test_list_exceptions(client): 84 | result = client.object.list_exception() 85 | assert isinstance(result.response, ListExceptionsResp) 86 | assert result.result_code == ResultCode.SUCCESS 87 | assert len(result.response.items) > 0 88 | assert result.response.items[0].type == ObjectType.URL 89 | assert result.response.items[0].value == "https://*.example.com/path1/*" 90 | 91 | 92 | def test_list_suspicious(client): 93 | result = client.object.list_suspicious() 94 | assert isinstance(result.response, ListSuspiciousResp) 95 | assert result.result_code == ResultCode.SUCCESS 96 | assert len(result.response.items) > 0 97 | assert result.response.items[0].type == ObjectType.FILE_SHA256 98 | assert ( 99 | result.response.items[0].value 100 | == "asidj123123jsdsidjsid123sidsidj123sss123s224212312312312312sdaas" 101 | ) 102 | 103 | 104 | def test_consume_exceptions(client): 105 | result = client.object.consume_exception(lambda s: None) 106 | assert result.result_code == ResultCode.SUCCESS 107 | assert result.response.total_consumed == 1 108 | 109 | 110 | def test_consume_suspicious(client): 111 | result = client.object.consume_suspicious(lambda s: None) 112 | assert result.result_code == ResultCode.SUCCESS 113 | assert result.response.total_consumed == 1 114 | -------------------------------------------------------------------------------- /src/pytmv1/result.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | import time 5 | from dataclasses import dataclass, field 6 | from enum import Enum 7 | from functools import wraps 8 | from logging import Logger 9 | from typing import Any, Callable, Generic, List, Optional, TypeVar 10 | 11 | from pydantic import ValidationError 12 | from requests import RequestException 13 | 14 | from .exception import ServerCustError, ServerJsonError, ServerMultiJsonError 15 | from .model.common import Error, MsError 16 | from .model.response import MR, R 17 | 18 | E = TypeVar("E", bound=Error) 19 | F = TypeVar("F", bound=Callable[..., Any]) 20 | 21 | log: Logger = logging.getLogger(__name__) 22 | 23 | 24 | def multi_result(func: F) -> Callable[..., MultiResult[MR]]: 25 | @wraps(func) 26 | def _multi_result(*args: Any, **kwargs: Any) -> MultiResult[MR]: 27 | obj: MR | Exception = _wrapper(func, *args, **kwargs) 28 | return ( 29 | MultiResult.success(obj) 30 | if not isinstance(obj, Exception) 31 | else MultiResult.failed(obj) 32 | ) 33 | 34 | return _multi_result 35 | 36 | 37 | def result(func: F) -> Callable[..., Result[R]]: 38 | @wraps(func) 39 | def _result(*args: Any, **kwargs: Any) -> Result[R]: 40 | obj: R | Exception = _wrapper(func, *args, **kwargs) 41 | return ( 42 | Result.success(obj) 43 | if not isinstance(obj, Exception) 44 | else Result.failed(obj) 45 | ) 46 | 47 | return _result 48 | 49 | 50 | def _wrapper(func: F, *args: Any, **kwargs: Any) -> R | Exception: 51 | try: 52 | start_time: float = time.time() 53 | log.debug( 54 | "Execution started [%s, %s]", 55 | args, 56 | kwargs, 57 | ) 58 | response: R = func(*args, **kwargs) 59 | log.debug( 60 | "Execution finished [Elapsed=%s, %s]", 61 | time.time() - start_time, 62 | response, 63 | ) 64 | return response 65 | except ( 66 | ServerCustError, 67 | ServerJsonError, 68 | ServerMultiJsonError, 69 | ValidationError, 70 | RequestException, 71 | RuntimeError, 72 | ) as exc: 73 | log.exception("Unexpected issue occurred [%s]", exc) 74 | return exc 75 | 76 | 77 | def _error(exc: Exception) -> Error: 78 | if isinstance(exc, ServerJsonError): 79 | return exc.error 80 | return Error( 81 | status=_status(exc), code=type(exc).__name__, message=str(exc) 82 | ) 83 | 84 | 85 | def _errors(exc: Exception) -> List[MsError]: 86 | if isinstance(exc, ServerMultiJsonError): 87 | return exc.errors 88 | if isinstance(exc, ServerJsonError): 89 | return [ 90 | MsError( 91 | status=exc.error.status, 92 | code=exc.error.code, 93 | message=exc.error.message, 94 | number=exc.error.number, 95 | ) 96 | ] 97 | return [ 98 | MsError(status=_status(exc), code=type(exc).__name__, message=str(exc)) 99 | ] 100 | 101 | 102 | def _status(exc: Exception) -> int: 103 | return exc.status if isinstance(exc, ServerCustError) else 500 104 | 105 | 106 | @dataclass 107 | class BaseResult(Generic[R]): 108 | result_code: ResultCode 109 | response: Optional[R] = None 110 | 111 | 112 | @dataclass 113 | class Result(BaseResult[R]): 114 | error: Optional[Error] = None 115 | 116 | @classmethod 117 | def success(cls, response: R) -> Result[R]: 118 | return cls(ResultCode.SUCCESS, response) 119 | 120 | @classmethod 121 | def failed(cls, exc: Exception) -> Result[R]: 122 | return cls( 123 | ResultCode.ERROR, 124 | None, 125 | _error(exc), 126 | ) 127 | 128 | 129 | @dataclass 130 | class MultiResult(BaseResult[MR]): 131 | errors: List[MsError] = field(default_factory=list) 132 | 133 | @classmethod 134 | def success(cls, response: MR) -> MultiResult[MR]: 135 | return cls(ResultCode.SUCCESS, response) 136 | 137 | @classmethod 138 | def failed(cls, exc: Exception) -> MultiResult[MR]: 139 | return cls( 140 | ResultCode.ERROR, 141 | None, 142 | _errors(exc), 143 | ) 144 | 145 | 146 | class ResultCode(str, Enum): 147 | SUCCESS = "SUCCESS" 148 | ERROR = "ERROR" 149 | -------------------------------------------------------------------------------- /tests/integration/test_sandbox.py: -------------------------------------------------------------------------------- 1 | from pytmv1 import ( 2 | BytesResp, 3 | ListSandboxSuspiciousResp, 4 | ResultCode, 5 | SandboxAnalysisResultResp, 6 | SandboxSubmissionStatusResp, 7 | SubmitFileToSandboxResp, 8 | ) 9 | 10 | 11 | def test_submit_sandbox_file(client): 12 | result = client.sandbox.submit_file( 13 | bytes("content", "utf-8"), "fileName.txt" 14 | ) 15 | assert isinstance(result.response, SubmitFileToSandboxResp) 16 | assert result.result_code == ResultCode.SUCCESS 17 | assert result.response.id 18 | 19 | 20 | def test_submit_sandbox_file_is_too_large_file(client): 21 | result = client.sandbox.submit_file( 22 | bytes("content", "utf-8"), "tooBig.txt" 23 | ) 24 | assert result.result_code == ResultCode.ERROR 25 | assert result.error.code == "RequestEntityTooLarge" 26 | assert result.error.status == 413 27 | 28 | 29 | def test_submit_sandbox_file_is_too_many_request(client): 30 | result = client.sandbox.submit_file( 31 | bytes("content", "utf-8"), "tooMany.txt" 32 | ) 33 | assert result.result_code == ResultCode.ERROR 34 | assert result.error.code == "TooManyRequests" 35 | assert result.error.status == 429 36 | 37 | 38 | def test_submit_sandbox_urls_with_multi_url(client): 39 | result = client.sandbox.submit_url( 40 | "https://trendmicro.com", "https://trendmicro2.com" 41 | ) 42 | assert result.result_code == ResultCode.SUCCESS 43 | assert result.response.items[0].url == "https://www.trendmicro.com" 44 | assert result.response.items[0].status == 202 45 | assert result.response.items[0].task_id == "00000005" 46 | assert ( 47 | result.response.items[0].id == "012e4eac-9bd9-4e89-95db-77e02f75a6f5" 48 | ) 49 | assert ( 50 | result.response.items[0].digest.md5 51 | == "f3a2e1227de8d5ae7296665c1f34b28d" 52 | ) 53 | assert result.response.items[1].url == "https://www.trendmicro2.com" 54 | assert result.response.items[1].status == 202 55 | assert result.response.items[1].task_id == "00000006" 56 | assert ( 57 | result.response.items[1].id 58 | == "01232cs823-9bd9-4e89-95db-77e02f75a6f34" 59 | ) 60 | assert ( 61 | result.response.items[1].digest.md5 62 | == "x23s2sd11227de8d5ae7296665c1f34b3212" 63 | ) 64 | 65 | 66 | def test_submit_sandbox_urls_is_bad_request(client): 67 | result = client.sandbox.submit_url("bad_request") 68 | assert result.result_code == ResultCode.ERROR 69 | assert result.errors[0].extra["url"] == "https://www.trendmicro.com" 70 | assert result.errors[0].status == 202 71 | assert result.errors[0].task_id == "00000005" 72 | assert result.errors[1].extra["url"] == "test" 73 | assert result.errors[1].status == 400 74 | assert result.errors[1].code == "BadRequest" 75 | assert result.errors[1].message == "URL format is not right" 76 | 77 | 78 | def test_get_sandbox_submission_status(client): 79 | result = client.sandbox.get_submission_status("123") 80 | assert isinstance(result.response, SandboxSubmissionStatusResp) 81 | assert result.result_code == ResultCode.SUCCESS 82 | assert result.response.id == "123" 83 | 84 | 85 | def test_get_sandbox_analysis_result(client): 86 | result = client.sandbox.get_analysis_result("123", False) 87 | assert isinstance(result.response, SandboxAnalysisResultResp) 88 | assert result.result_code == ResultCode.SUCCESS 89 | assert result.response.id == "123" 90 | 91 | 92 | def test_list_sandbox_suspicious(client): 93 | result = client.sandbox.list_suspicious("1", False) 94 | assert isinstance(result.response, ListSandboxSuspiciousResp) 95 | assert result.result_code == ResultCode.SUCCESS 96 | assert len(result.response.items) > 0 97 | assert result.response.items[0].type == "ip" 98 | assert result.response.items[0].value == "6.6.6.6" 99 | 100 | 101 | def test_download_sandbox_analysis_result(client): 102 | result = client.sandbox.download_analysis_result("1", False) 103 | assert isinstance(result.response, BytesResp) 104 | assert result.result_code == ResultCode.SUCCESS 105 | assert result.response.content 106 | 107 | 108 | def test_download_sandbox_investigation_package(client): 109 | result = client.sandbox.download_investigation_package("1", False) 110 | assert isinstance(result.response, BytesResp) 111 | assert result.result_code == ResultCode.SUCCESS 112 | assert result.response.content 113 | -------------------------------------------------------------------------------- /src/pytmv1/mapper.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, List, Union 2 | 3 | from pydantic.alias_generators import to_camel 4 | 5 | from .model.common import ( 6 | Alert, 7 | Entity, 8 | HostInfo, 9 | SaeAlert, 10 | SaeIndicator, 11 | TiAlert, 12 | TiIndicator, 13 | ) 14 | 15 | INDICATOR_CEF_MAP: Dict[str, str] = { 16 | "command_line": "dproc", 17 | "url": "request", 18 | "domain": "sntdom", 19 | "ip": "src", 20 | "email_sender": "suser", 21 | "fullpath": "filePath", 22 | "filename": "fname", 23 | "file_sha1": "fileHash", 24 | "user_account": "suser", 25 | "host": "shost", 26 | "port": "spt", 27 | "process_id": "dpid", 28 | "registry_key": "TrendMicroVoRegistryKeyHandle", 29 | "registry_value": "TrendMicroVoRegistryValue", 30 | "registry_value_data": "TrendMicroVoRegistryData", 31 | "file_sha256": "TrendMicroVoFileHashSha256", 32 | "email_message_id": "TrendMicroVoEmailMessageId", 33 | "email_message_unique_id": "TrendMicroVoEmailMessageUniqueId", 34 | } 35 | 36 | 37 | def map_cef(alert: Alert) -> Dict[str, str]: 38 | data: Dict[str, str] = _map_common(alert) 39 | _map_entities(data, alert.impact_scope.entities) 40 | if isinstance(alert, SaeAlert): 41 | _map_sae(data, alert) 42 | if isinstance(alert, TiAlert): 43 | _map_ti(data, alert) 44 | return data 45 | 46 | 47 | def _map_common(alert: Alert) -> Dict[str, str]: 48 | return dict( 49 | externalId=alert.id, 50 | act=alert.investigation_status, 51 | cat=alert.model, 52 | Severity=alert.severity, 53 | rt=alert.created_date_time, 54 | sourceServiceName=alert.alert_provider, 55 | msg="Workbench Link: " + alert.workbench_link, 56 | cnt=str(alert.score), 57 | cn1=str(alert.impact_scope.desktop_count), 58 | cn1Label="Desktop Count", 59 | cn2=str(alert.impact_scope.server_count), 60 | cn2Label="Server Count", 61 | cn3=str(alert.impact_scope.account_count), 62 | cn3Label="Account Count", 63 | cn4=str(alert.impact_scope.email_address_count), 64 | cn4Label="Email Address Count", 65 | ) 66 | 67 | 68 | def _map_entities(data: Dict[str, str], entities: List[Entity]) -> None: 69 | for entity in entities: 70 | if isinstance(entity.entity_value, HostInfo): 71 | data["dhost"] = entity.entity_value.name 72 | data["dst"] = ", ".join(entity.entity_value.ips) 73 | else: 74 | data["duser"] = entity.entity_value 75 | 76 | 77 | def _map_indicators( 78 | data: Dict[str, str], 79 | indicators: Union[List[TiIndicator], List[SaeIndicator]], 80 | ) -> None: 81 | for indicator in indicators: 82 | if isinstance(indicator.value, HostInfo): 83 | data["shost"] = indicator.value.name 84 | data["src"] = ", ".join(indicator.value.ips) 85 | else: 86 | data[ 87 | INDICATOR_CEF_MAP.get(indicator.type, to_camel(indicator.type)) 88 | ] = indicator.value 89 | 90 | 91 | def _map_sae(data: Dict[str, str], alert: SaeAlert) -> None: 92 | data["cs1"] = ", ".join(alert.indicators[0].provenance) 93 | data["cs1Label"] = "Provenance" 94 | data["cs2"] = alert.matched_rules[0].matched_filters[0].name 95 | data["cs2Label"] = "Matched Filter" 96 | data["cs3"] = ", ".join( 97 | alert.matched_rules[0].matched_filters[0].mitre_technique_ids 98 | ) 99 | data["cs3Label"] = "Matched Techniques" 100 | data["reason"] = alert.matched_rules[0].name 101 | data["msg"] = data.get("msg", "") + f"\nDescription: {alert.description}" 102 | _map_indicators(data, alert.indicators) 103 | 104 | 105 | def _map_ti(data: Dict[str, str], alert: TiAlert) -> None: 106 | data["cs1"] = ", ".join(alert.indicators[0].provenance) 107 | data["cs1Label"] = "Provenance" 108 | data["cs2"] = ", ".join(alert.matched_indicator_patterns[0].tags) 109 | data["cs2Label"] = "Matched Pattern Tags" 110 | data["cs3"] = alert.matched_indicator_patterns[0].pattern 111 | data["cs3Label"] = "Matched Pattern" 112 | data["msg"] = data.get("msg", "") + f"\nReport Link: {alert.report_link}" 113 | data["createdBy"] = alert.created_by 114 | if alert.campaign: 115 | data["campaign"] = alert.campaign 116 | if alert.industry: 117 | data["industry"] = alert.industry 118 | if alert.region_and_country: 119 | data["regionAndCountry"] = alert.region_and_country 120 | _map_indicators(data, alert.indicators) 121 | -------------------------------------------------------------------------------- /tests/data.py: -------------------------------------------------------------------------------- 1 | from requests import Response 2 | 3 | from pytmv1 import ( 4 | Entity, 5 | HostInfo, 6 | ImpactScope, 7 | Indicator, 8 | InvestigationStatus, 9 | MatchedFilter, 10 | MatchedIndicatorPattern, 11 | MatchedRule, 12 | SaeAlert, 13 | Severity, 14 | TiAlert, 15 | ) 16 | 17 | 18 | class TextResponse(Response): 19 | def __init__(self, value: str): 20 | super().__init__() 21 | self.value = value 22 | 23 | @property 24 | def content(self) -> bytes: 25 | return self.value.encode("utf-8") 26 | 27 | @property 28 | def text(self) -> str: 29 | return self.value 30 | 31 | 32 | def sae_alert(): 33 | return SaeAlert.model_construct( 34 | id="1", 35 | investigationStatus=InvestigationStatus.NEW, 36 | model="Possible Credential Dumping via Registry", 37 | severity=Severity.HIGH, 38 | createdDateTime="2022-09-06T02:49:33Z", 39 | alertProvider="SAE", 40 | description="description", 41 | workbenchLink="https://THE_WORKBENCH_URL", 42 | score=64, 43 | impactScope=ImpactScope.model_construct( 44 | desktopCount=1, 45 | serverCount=0, 46 | accountCount=1, 47 | emailAddressCount=0, 48 | entities=[ 49 | Entity.model_construct( 50 | entity_value=HostInfo.model_construct( 51 | name="host", ips=["1.1.1.1", "2.2.2.2"] 52 | ) 53 | ) 54 | ], 55 | ), 56 | indicators=[ 57 | Indicator.model_construct( 58 | provenance=["Alert"], 59 | value=HostInfo.model_construct( 60 | name="host", ips=["1.1.1.1", "2.2.2.2"] 61 | ), 62 | ) 63 | ], 64 | matchedRules=[ 65 | MatchedRule.model_construct( 66 | name="Potential Credential Dumping via Registry", 67 | matchedFilters=[ 68 | MatchedFilter.model_construct( 69 | name="Possible Credential Dumping via Registry Hive", 70 | mitreTechniqueIds=[ 71 | "V9.T1003.004", 72 | "V9.T1003.002", 73 | "T1003", 74 | ], 75 | ) 76 | ], 77 | ) 78 | ], 79 | ) 80 | 81 | 82 | def ti_alert(): 83 | return TiAlert.model_construct( 84 | id="1", 85 | investigationStatus=InvestigationStatus.NEW, 86 | model="Threat Intelligence Sweeping", 87 | campaign="campaign", 88 | industry="industry", 89 | regionAndCountry="regionAndCountry", 90 | severity=Severity.MEDIUM, 91 | createdDateTime="2022-09-06T02:49:33Z", 92 | alertProvider="TI", 93 | workbenchLink="https://THE_WORKBENCH_URL", 94 | reportLink="https://THE_TI_REPORT_URL", 95 | createdBy="n/a", 96 | score=42, 97 | impactScope=ImpactScope.model_construct( 98 | desktopCount=1, 99 | serverCount=0, 100 | accountCount=1, 101 | emailAddressCount=0, 102 | entities=[ 103 | Entity.model_construct( 104 | entity_value=HostInfo.model_construct( 105 | name="host", ips=["1.1.1.1", "2.2.2.2"] 106 | ) 107 | ) 108 | ], 109 | ), 110 | indicators=[ 111 | Indicator.model_construct( 112 | provenance=["Alert"], 113 | value=HostInfo.model_construct( 114 | name="host", ips=["1.1.1.1", "2.2.2.2"] 115 | ), 116 | ) 117 | ], 118 | matchedIndicatorPatterns=[ 119 | MatchedIndicatorPattern.model_construct( 120 | tags=["STIX2.malicious-activity"], 121 | pattern="[file:name = 'goog-phish-proto-1.vlpset']", 122 | ) 123 | ], 124 | matchedRules=[ 125 | MatchedRule.model_construct( 126 | name="Potential Credential Dumping via Registry", 127 | matchedFilters=[ 128 | MatchedFilter.model_construct( 129 | name="Possible Credential Dumping via Registry Hive", 130 | mitreTechniqueIds=[ 131 | "V9.T1003.004", 132 | "V9.T1003.002", 133 | "T1003", 134 | ], 135 | ) 136 | ], 137 | ) 138 | ], 139 | ) 140 | -------------------------------------------------------------------------------- /tests/integration/test_workbench.py: -------------------------------------------------------------------------------- 1 | from pytmv1 import ( 2 | AddAlertNoteResp, 3 | AlertStatus, 4 | GetAlertNoteResp, 5 | ListAlertNoteResp, 6 | ListAlertsResp, 7 | NoContentResp, 8 | Provider, 9 | ResultCode, 10 | Severity, 11 | ) 12 | 13 | 14 | def test_create_note(client): 15 | result = client.note.create("1", "dummy note") 16 | assert isinstance(result.response, AddAlertNoteResp) 17 | assert result.result_code == ResultCode.SUCCESS 18 | assert result.response.note_id.isdigit() 19 | 20 | 21 | def test_update_note(client): 22 | result = client.note.update("1", "2", "3", "update content") 23 | assert isinstance(result.response, NoContentResp) 24 | assert result.result_code == ResultCode.SUCCESS 25 | 26 | 27 | def test_delete_note(client): 28 | result = client.note.delete("1", "2", "3") 29 | assert isinstance(result.response, NoContentResp) 30 | assert result.result_code == ResultCode.SUCCESS 31 | 32 | 33 | def test_get_note(client): 34 | result = client.note.get("1", "2") 35 | assert isinstance(result.response, GetAlertNoteResp) 36 | assert result.result_code == ResultCode.SUCCESS 37 | assert result.response.etag == "33a64df551425fcc55e4d42a148795d9f25f89d4" 38 | assert result.response.data.content 39 | 40 | 41 | def test_list_notes(client): 42 | result = client.note.list("1", creatorName="John Doe") 43 | assert isinstance(result.response, ListAlertNoteResp) 44 | assert result.result_code == ResultCode.SUCCESS 45 | assert len(result.response.items) > 0 46 | 47 | 48 | def test_consume_notes(client): 49 | result = client.note.consume(lambda s: None, "1", creatorName="John Doe") 50 | assert result.result_code == ResultCode.SUCCESS 51 | assert result.response.total_consumed == 2 52 | 53 | 54 | def test_consume_alerts(client): 55 | result = client.alert.consume( 56 | lambda s: None, "2020-06-15T10:00:00Z", "2020-06-15T10:00:00Z" 57 | ) 58 | assert result.result_code == ResultCode.SUCCESS 59 | assert result.response.total_consumed == 2 60 | 61 | 62 | def test_consume_alerts_with_next_link(client): 63 | result = client.alert.consume( 64 | lambda s: None, "next_link", "2020-06-15T10:00:00Z" 65 | ) 66 | assert result.result_code == ResultCode.SUCCESS 67 | assert result.response.total_consumed == 11 68 | 69 | 70 | def test_update_alert_status(client): 71 | result = client.alert.update_status( 72 | "1", 73 | "d41d8cd98f00b204e9800998ecf8427e", 74 | AlertStatus.IN_PROGRESS, 75 | ) 76 | assert isinstance(result.response, NoContentResp) 77 | assert result.result_code == ResultCode.SUCCESS 78 | 79 | 80 | def test_update_alert_status_is_precondition_failed(client): 81 | result = client.alert.update_status( 82 | "1", 83 | "precondition_failed", 84 | AlertStatus.IN_PROGRESS, 85 | ) 86 | assert not result.response 87 | assert result.result_code == ResultCode.ERROR 88 | assert result.error.code == "ConditionNotMet" 89 | assert result.error.status == 412 90 | 91 | 92 | def test_update_alert_status_is_not_found(client): 93 | result = client.alert.update_status( 94 | "1", "not_found", AlertStatus.IN_PROGRESS 95 | ) 96 | assert not result.response 97 | assert result.result_code == ResultCode.ERROR 98 | assert result.error.code == "NotFound" 99 | assert result.error.status == 404 100 | 101 | 102 | def test_get_alert(client): 103 | result = client.alert.get("12345") 104 | assert result.result_code == ResultCode.SUCCESS 105 | assert result.response.etag == "33a64df551425fcc55e4d42a148795d9f25f89d4" 106 | assert result.response.data.alert_provider == Provider.SAE 107 | assert result.response.data.incident_id == "IC-1-20230706-00001" 108 | assert result.response.data.impact_scope.container_count == 1 109 | assert result.response.data.impact_scope.cloud_identity_count == 1 110 | assert result.response.data.indicators[0].field == "objectCmd" 111 | 112 | 113 | def test_get_alert_ti(client): 114 | result = client.alert.get("TI_ALERT") 115 | assert result.result_code == ResultCode.SUCCESS 116 | assert result.response.data.alert_provider == Provider.TI 117 | assert result.response.etag == "33a64df551425fcc55e4d42a148795d9f25f89d4" 118 | assert result.response.data.campaign == "campaign" 119 | assert ( 120 | result.response.data.indicators[0].fields[0][0] 121 | == "processFileHashSha1" 122 | ) 123 | 124 | 125 | def test_list_alerts(client): 126 | result = client.alert.list( 127 | "2020-06-15T10:00:00Z", "2020-06-15T10:00:00Z", severity=Severity.HIGH 128 | ) 129 | assert isinstance(result.response, ListAlertsResp) 130 | assert result.result_code == ResultCode.SUCCESS 131 | assert len(result.response.items) > 0 132 | -------------------------------------------------------------------------------- /tests/integration/test_oat.py: -------------------------------------------------------------------------------- 1 | from pytmv1 import ( 2 | EndpointActivity, 3 | GetOatPackageResp, 4 | NoContentResp, 5 | OatRiskLevel, 6 | ResultCode, 7 | ) 8 | 9 | 10 | def test_list_oat(client): 11 | result = client.oat.list( 12 | "2020-06-15T10:00:00Z", 13 | "2020-06-15T10:00:00Z", 14 | "2020-06-15T10:00:00Z", 15 | "2020-06-15T10:00:00Z", 16 | ) 17 | assert result.result_code == ResultCode.SUCCESS 18 | assert result.response.count == 0 19 | assert result.response.total_count == 0 20 | assert result.response.items[0].endpoint.endpoint_name == "LAB-Luwak-1048" 21 | assert ( 22 | result.response.items[0].uuid == "fdd69d98-58de-4249-9871-2e1b233b72ff" 23 | ) 24 | assert result.response.items[0].source == "endpointActivityData" 25 | assert isinstance(result.response.items[0].detail, EndpointActivity) 26 | 27 | 28 | def test_consume_oat(client): 29 | result = client.oat.consume( 30 | lambda s: None, 31 | "next_link", 32 | "2020-06-15T10:00:00Z", 33 | "2020-06-15T10:00:00Z", 34 | "2020-06-15T10:00:00Z", 35 | ) 36 | assert result.result_code == ResultCode.SUCCESS 37 | assert result.response.total_consumed == 3 38 | 39 | 40 | def test_create_pipeline(client): 41 | result = client.oat.create_pipeline( 42 | True, 43 | [OatRiskLevel.INFO, OatRiskLevel.MEDIUM, OatRiskLevel.CRITICAL], 44 | "my pipeline", 45 | ) 46 | assert result.result_code == ResultCode.SUCCESS 47 | assert ( 48 | result.response.pipeline_id == "c80d8eaa-e55f-4c64-991f-9d0bdf59ee5b" 49 | ) 50 | 51 | 52 | def test_get_pipeline(client): 53 | result = client.oat.get_pipeline("83df1ed3-84e7-4e6d-98b5-d79468cccba1") 54 | assert result.result_code == ResultCode.SUCCESS 55 | assert result.response.etag == "0x8D9AB4B0CA9D336" 56 | assert result.response.data.id is None 57 | assert result.response.data.has_detail is True 58 | assert result.response.data.risk_levels == [OatRiskLevel.CRITICAL] 59 | 60 | 61 | def test_update_pipeline(client): 62 | result = client.oat.update_pipeline( 63 | "83df1ed3-84e7-4e6d-98b5-d79468cccba1", 64 | "b971d16b5f6330c81a7455fcb8ba8e09c11ba970", 65 | True, 66 | risk_levels=[ 67 | OatRiskLevel.INFO, 68 | OatRiskLevel.MEDIUM, 69 | OatRiskLevel.CRITICAL, 70 | OatRiskLevel.HIGH, 71 | ], 72 | description="my pipeline updated", 73 | ) 74 | assert result.result_code == ResultCode.SUCCESS 75 | assert isinstance(result.response, NoContentResp) 76 | 77 | 78 | def test_delete_pipelines(client): 79 | result = client.oat.delete_pipelines( 80 | "853c03ea-33dd-435e-8fc6-bf551bfec024" 81 | ) 82 | assert result.result_code == ResultCode.SUCCESS 83 | assert len(result.response.items) > 0 84 | assert result.response.items[0].status == 204 85 | 86 | 87 | def test_list_pipelines(client): 88 | result = client.oat.list_pipelines() 89 | assert result.result_code == ResultCode.SUCCESS 90 | assert result.response.count == 1 91 | assert ( 92 | result.response.items[0].id == "8746fc45-6b9d-4923-b476-931aec6e06eb" 93 | ) 94 | assert result.response.items[0].has_detail is True 95 | assert result.response.items[0].description == "siemhost1" 96 | 97 | 98 | def test_get_package(client): 99 | result = client.oat.get_package( 100 | "83df1ed3-84e7-4e6d-98b5-d79468cccba1", 101 | "2024073012-774c3fb6-f777-4ce1-8564-39885e7d41a4", 102 | ) 103 | assert isinstance(result.response, GetOatPackageResp) 104 | assert result.result_code == ResultCode.SUCCESS 105 | assert result.response.package.detection_time == "2024-07-30T12:01:01Z" 106 | assert ( 107 | result.response.package.endpoint.guid 108 | == "ab673395-9bf9-49fc-b8ac-7fa15467d20a" 109 | ) 110 | assert result.response.package.filters[0].tactics == ["TA0002"] 111 | assert result.response.package.filters[0].techniques == ["T1059.004"] 112 | assert ( 113 | result.response.package.detail.event_source_type 114 | == "EVENT_SOURCE_TELEMETRY" 115 | ) 116 | assert ( 117 | result.response.package.detail.uuid 118 | == "eb5b2977-3bde-45ea-b493-05984bab5d0f" 119 | ) 120 | 121 | 122 | def test_list_packages(client): 123 | result = client.oat.list_packages("83df1ed3-84e7-4e6d-98b5-d79468cccba1") 124 | assert result.result_code == ResultCode.SUCCESS 125 | assert result.response.total_count == 20 126 | assert result.response.count == 2 127 | assert ( 128 | result.response.items[0].id 129 | == "2021103019-7898c20d-fc91-443b-9a4e-f8ec3ab745ab" 130 | ) 131 | assert result.response.items[0].created_date_time == "2021-10-30T19:50:00Z" 132 | 133 | 134 | def test_consume_packages(client): 135 | result = client.oat.consume_packages( 136 | "8746fc45-6b9d-4923-b476-931aec6e06eb", 137 | lambda s: None, 138 | "next_link", 139 | "2020-06-15T10:00:00Z", 140 | ) 141 | assert result.result_code == ResultCode.SUCCESS 142 | assert result.response.total_consumed == 4 143 | -------------------------------------------------------------------------------- /tests/unit/test_mapper.py: -------------------------------------------------------------------------------- 1 | from pytmv1 import ( 2 | Entity, 3 | HostInfo, 4 | InvestigationStatus, 5 | SaeIndicator, 6 | Severity, 7 | TiIndicator, 8 | mapper, 9 | ) 10 | from tests import data 11 | 12 | 13 | def test_map_cef_with_sae_alert(mocker): 14 | mock_mapper = mocker.patch.object(mapper, "_map_sae") 15 | mapper.map_cef(data.sae_alert()) 16 | mock_mapper.assert_called() 17 | 18 | 19 | def test_map_cef_with_ti_alert(mocker): 20 | mock_mapper = mocker.patch.object(mapper, "_map_ti") 21 | mapper.map_cef(data.ti_alert()) 22 | mock_mapper.assert_called() 23 | 24 | 25 | def test_map_common(): 26 | dictionary = mapper._map_common(data.sae_alert()) 27 | assert dictionary["externalId"] == "1" 28 | assert dictionary["act"] == InvestigationStatus.NEW.value 29 | assert dictionary["cat"] == "Possible Credential Dumping via Registry" 30 | assert dictionary["Severity"] == Severity.HIGH.value 31 | assert dictionary["rt"] == "2022-09-06T02:49:33Z" 32 | assert dictionary["sourceServiceName"] == "SAE" 33 | assert dictionary["msg"] == "Workbench Link: https://THE_WORKBENCH_URL" 34 | assert dictionary["cnt"] == "64" 35 | assert dictionary["cn1"] == "1" 36 | assert dictionary["cn1Label"] == "Desktop Count" 37 | assert dictionary["cn2"] == "0" 38 | assert dictionary["cn2Label"] == "Server Count" 39 | assert dictionary["cn3"] == "1" 40 | assert dictionary["cn3Label"] == "Account Count" 41 | assert dictionary["cn4"] == "0" 42 | assert dictionary["cn4Label"] == "Email Address Count" 43 | 44 | 45 | def test_map_entities_with_type_email(): 46 | entities = [Entity.model_construct(entity_value="email@email.com")] 47 | dictionary = {} 48 | mapper._map_entities(dictionary, entities) 49 | assert dictionary["duser"] == "email@email.com" 50 | 51 | 52 | def test_map_entities_with_type_host_info(): 53 | entities = [ 54 | Entity.model_construct( 55 | entity_value=HostInfo.model_construct( 56 | name="host", ips=["1.1.1.1", "2.2.2.2"] 57 | ) 58 | ) 59 | ] 60 | dictionary = {} 61 | mapper._map_entities(dictionary, entities) 62 | assert dictionary["dhost"] == "host" 63 | assert dictionary["dst"] == "1.1.1.1, 2.2.2.2" 64 | 65 | 66 | def test_map_entities_with_type_user(): 67 | entities = [Entity.model_construct(entity_value="username")] 68 | dictionary = {} 69 | mapper._map_entities(dictionary, entities) 70 | assert dictionary["duser"] == "username" 71 | 72 | 73 | def test_map_indicators_with_type_command_line(): 74 | indicators = [ 75 | TiIndicator.model_construct(type="command_line", value="cmd.exe") 76 | ] 77 | dictionary = {} 78 | mapper._map_indicators(dictionary, indicators) 79 | assert dictionary["dproc"] == "cmd.exe" 80 | 81 | 82 | def test_map_indicators_with_type_host_info(): 83 | indicators = [ 84 | SaeIndicator.model_construct( 85 | value=HostInfo.model_construct( 86 | name="host", ips=["1.1.1.1", "2.2.2.2"] 87 | ) 88 | ) 89 | ] 90 | dictionary = {} 91 | mapper._map_indicators(dictionary, indicators) 92 | assert dictionary["shost"] == "host" 93 | assert dictionary["src"] == "1.1.1.1, 2.2.2.2" 94 | 95 | 96 | def test_map_indicators_with_unknown_type(): 97 | indicators = [ 98 | SaeIndicator.model_construct(type="unknown_type", value="unknown") 99 | ] 100 | dictionary = {} 101 | mapper._map_indicators(dictionary, indicators) 102 | assert dictionary["unknownType"] == "unknown" 103 | 104 | 105 | def test_map_sae(): 106 | alert = data.sae_alert() 107 | dictionary = mapper._map_common(alert) 108 | mapper._map_sae(dictionary, alert) 109 | assert dictionary["cs2"] == "Possible Credential Dumping via Registry Hive" 110 | assert dictionary["cs2Label"] == "Matched Filter" 111 | assert dictionary["cs3"] == "V9.T1003.004, V9.T1003.002, T1003" 112 | assert dictionary["cs3Label"] == "Matched Techniques" 113 | assert dictionary["reason"] == "Potential Credential Dumping via Registry" 114 | assert ( 115 | dictionary["msg"] 116 | == "Workbench Link: https://THE_WORKBENCH_URL\nDescription:" 117 | " description" 118 | ) 119 | 120 | 121 | def test_map_ti(): 122 | alert = data.ti_alert() 123 | dictionary = mapper._map_common(alert) 124 | mapper._map_ti(dictionary, alert) 125 | assert dictionary["cs2"] == "STIX2.malicious-activity" 126 | assert dictionary["cs2Label"] == "Matched Pattern Tags" 127 | assert dictionary["cs3"] == "[file:name = 'goog-phish-proto-1.vlpset']" 128 | assert dictionary["cs3Label"] == "Matched Pattern" 129 | assert ( 130 | dictionary["msg"] 131 | == "Workbench Link: https://THE_WORKBENCH_URL\nReport Link:" 132 | " https://THE_TI_REPORT_URL" 133 | ) 134 | assert dictionary["createdBy"] == "n/a" 135 | assert dictionary["campaign"] == "campaign" 136 | assert dictionary["industry"] == "industry" 137 | assert dictionary["regionAndCountry"] == "regionAndCountry" 138 | -------------------------------------------------------------------------------- /src/pytmv1/utils.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import re 3 | from typing import Any, Dict, List, Optional, Pattern, Type 4 | 5 | from .model.enum import QueryOp, SearchMode, TaskAction 6 | from .model.request import ObjectRequest, SuspiciousObjectRequest 7 | from .model.response import ( 8 | AccountTaskResp, 9 | BaseTaskResp, 10 | BlockListTaskResp, 11 | CollectFileTaskResp, 12 | CustomScriptTaskResp, 13 | EmailMessageTaskResp, 14 | EndpointTaskResp, 15 | SandboxSubmitUrlTaskResp, 16 | TerminateProcessTaskResp, 17 | ) 18 | 19 | MAC_ADDRESS_PATTERN: Pattern[str] = re.compile( 20 | "^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$" 21 | ) 22 | GUID_PATTERN: Pattern[str] = re.compile("^(\\w+-+){1,5}\\w+$") 23 | 24 | TASK_ACTION_MAP: Dict[TaskAction, Type[BaseTaskResp]] = { 25 | TaskAction.COLLECT_FILE: CollectFileTaskResp, 26 | TaskAction.ISOLATE_ENDPOINT: EndpointTaskResp, 27 | TaskAction.RESTORE_ENDPOINT: EndpointTaskResp, 28 | TaskAction.TERMINATE_PROCESS: TerminateProcessTaskResp, 29 | TaskAction.QUARANTINE_MESSAGE: EmailMessageTaskResp, 30 | TaskAction.DELETE_MESSAGE: EmailMessageTaskResp, 31 | TaskAction.RESTORE_MESSAGE: EmailMessageTaskResp, 32 | TaskAction.BLOCK_SUSPICIOUS: BlockListTaskResp, 33 | TaskAction.REMOVE_SUSPICIOUS: BlockListTaskResp, 34 | TaskAction.RESET_PASSWORD: AccountTaskResp, 35 | TaskAction.SUBMIT_SANDBOX: SandboxSubmitUrlTaskResp, 36 | TaskAction.ENABLE_ACCOUNT: AccountTaskResp, 37 | TaskAction.DISABLE_ACCOUNT: AccountTaskResp, 38 | TaskAction.FORCE_SIGN_OUT: AccountTaskResp, 39 | TaskAction.RUN_CUSTOM_SCRIPT: CustomScriptTaskResp, 40 | } 41 | 42 | 43 | def _build_query( 44 | op: QueryOp, header: str, fields: Dict[str, str] 45 | ) -> Dict[str, str]: 46 | return filter_none( 47 | { 48 | header: (" " + op + " ").join( 49 | [f"{k} eq '{v}'" for k, v in fields.items()] 50 | ) 51 | } 52 | ) 53 | 54 | 55 | def _build_activity_query( 56 | op: QueryOp, fields: Dict[str, str] 57 | ) -> Dict[str, str]: 58 | return filter_none( 59 | { 60 | "TMV1-Query": (" " + op + " ").join( 61 | [f'{k}:"{v}"' for k, v in fields.items()] 62 | ) 63 | } 64 | ) 65 | 66 | 67 | def _b64_encode(value: Optional[str]) -> Optional[str]: 68 | return base64.b64encode(value.encode()).decode() if value else None 69 | 70 | 71 | def build_activity_request( 72 | start_time: Optional[str], 73 | end_time: Optional[str], 74 | select: Optional[List[str]], 75 | top: int, 76 | search_mode: SearchMode, 77 | ) -> Dict[str, str]: 78 | return filter_none( 79 | { 80 | "startDateTime": start_time, 81 | "endDateTime": end_time, 82 | "select": ",".join(select) if select else select, 83 | "top": top, 84 | "mode": search_mode, 85 | } 86 | ) 87 | 88 | 89 | def build_object_request(*tasks: ObjectRequest) -> List[Dict[str, str]]: 90 | return [ 91 | filter_none( 92 | { 93 | task.object_type.value: task.object_value, 94 | "description": task.description, 95 | } 96 | ) 97 | for task in tasks 98 | ] 99 | 100 | 101 | def build_sandbox_file_request( 102 | document_password: Optional[str], 103 | archive_password: Optional[str], 104 | arguments: Optional[str], 105 | ) -> Dict[str, str]: 106 | return filter_none( 107 | { 108 | "documentPassword": _b64_encode(document_password), 109 | "archivePassword": _b64_encode(archive_password), 110 | "arguments": _b64_encode(arguments), 111 | } 112 | ) 113 | 114 | 115 | def build_suspicious_request( 116 | *tasks: SuspiciousObjectRequest, 117 | ) -> List[Dict[str, Any]]: 118 | return [ 119 | filter_none( 120 | { 121 | task.object_type.value: task.object_value, 122 | "description": task.description, 123 | "riskLevel": ( 124 | task.risk_level.value if task.risk_level else None 125 | ), 126 | "scanAction": ( 127 | task.scan_action.value if task.scan_action else None 128 | ), 129 | "daysToExpiration": task.days_to_expiration, 130 | } 131 | ) 132 | for task in tasks 133 | ] 134 | 135 | 136 | def filter_none(dictionary: Dict[str, Optional[Any]]) -> Dict[str, Any]: 137 | return {k: v for k, v in dictionary.items() if v} 138 | 139 | 140 | def tmv1_filter(op: QueryOp, fields: Dict[str, str]) -> Dict[str, str]: 141 | return _build_query(op, "TMV1-Filter", fields) 142 | 143 | 144 | def tmv1_query(op: QueryOp, fields: Dict[str, str]) -> Dict[str, str]: 145 | return _build_query(op, "TMV1-Query", fields) 146 | 147 | 148 | def tmv1_activity_query(op: QueryOp, fields: Dict[str, str]) -> Dict[str, str]: 149 | return _build_activity_query(op, fields) 150 | 151 | 152 | def filter_query(op: QueryOp, fields: Dict[str, str]) -> Dict[str, str]: 153 | return _build_query(op, "filter", fields) 154 | 155 | 156 | def task_action_resp_class( 157 | task_action: TaskAction, 158 | ) -> Type[BaseTaskResp]: 159 | return TASK_ACTION_MAP.get(task_action, BaseTaskResp) 160 | -------------------------------------------------------------------------------- /src/pytmv1/api/object.py: -------------------------------------------------------------------------------- 1 | from typing import Callable 2 | 3 | from .. import utils 4 | from ..core import Core 5 | from ..model.common import ExceptionObject, SuspiciousObject 6 | from ..model.enum import Api 7 | from ..model.request import ObjectRequest, SuspiciousObjectRequest 8 | from ..model.response import ( 9 | ConsumeLinkableResp, 10 | ListExceptionsResp, 11 | ListSuspiciousResp, 12 | MultiResp, 13 | ) 14 | from ..result import MultiResult, Result 15 | 16 | 17 | class Object: 18 | _core: Core 19 | 20 | def __init__(self, core: Core): 21 | self._core = core 22 | 23 | def add_block(self, *objects: ObjectRequest) -> MultiResult[MultiResp]: 24 | """Adds object(s) to the Suspicious Object List, 25 | which blocks the objects on subsequent detections. 26 | 27 | :param objects: Object(s) to add. 28 | :type objects: Tuple[ObjectTask, ...] 29 | :rtype: MultiResult[MultiResp] 30 | """ 31 | return self._core.send_multi( 32 | MultiResp, 33 | Api.ADD_TO_BLOCK_LIST, 34 | json=utils.build_object_request(*objects), 35 | ) 36 | 37 | def delete_block(self, *objects: ObjectRequest) -> MultiResult[MultiResp]: 38 | """Removes object(s) that was added to the Suspicious Object List 39 | using the "Add to block list" action 40 | 41 | :param objects: Object(s) to remove. 42 | :type objects: Tuple[ObjectTask, ...] 43 | :rtype: MultiResult[MultiResp] 44 | """ 45 | return self._core.send_multi( 46 | MultiResp, 47 | Api.REMOVE_FROM_BLOCK_LIST, 48 | json=utils.build_object_request(*objects), 49 | ) 50 | 51 | def add_exception(self, *objects: ObjectRequest) -> MultiResult[MultiResp]: 52 | """Adds object(s) to the Exception List. 53 | 54 | :param objects: Object(s) to add. 55 | :type objects: Tuple[ObjectTask, ...] 56 | :rtype: MultiResult[MultiResp] 57 | """ 58 | return self._core.send_multi( 59 | MultiResp, 60 | Api.ADD_TO_EXCEPTION_LIST, 61 | json=utils.build_object_request(*objects), 62 | ) 63 | 64 | def delete_exception( 65 | self, *objects: ObjectRequest 66 | ) -> MultiResult[MultiResp]: 67 | """Removes object(s) from the Exception List. 68 | 69 | :param objects: Object(s) to remove. 70 | :type objects: Tuple[ObjectTask, ...] 71 | :rtype: MultiResult[MultiResp] 72 | """ 73 | return self._core.send_multi( 74 | MultiResp, 75 | Api.REMOVE_FROM_EXCEPTION_LIST, 76 | json=utils.build_object_request(*objects), 77 | ) 78 | 79 | def add_suspicious( 80 | self, *objects: SuspiciousObjectRequest 81 | ) -> MultiResult[MultiResp]: 82 | """Adds object(s) to the Suspicious Object List. 83 | 84 | :param objects: Object(s) to add. 85 | :type objects: Tuple[SuspiciousObjectTask, ...] 86 | :rtype: MultiResult[MultiResp] 87 | """ 88 | return self._core.send_multi( 89 | MultiResp, 90 | Api.ADD_TO_SUSPICIOUS_LIST, 91 | json=utils.build_suspicious_request(*objects), 92 | ) 93 | 94 | def delete_suspicious( 95 | self, *objects: ObjectRequest 96 | ) -> MultiResult[MultiResp]: 97 | """Removes object(s) from the Suspicious List. 98 | 99 | :param objects: Object(s) to remove. 100 | :type objects: Tuple[ObjectTask, ...] 101 | :rtype: MultiResult[MultiResp] 102 | """ 103 | return self._core.send_multi( 104 | MultiResp, 105 | Api.REMOVE_FROM_SUSPICIOUS_LIST, 106 | json=utils.build_object_request(*objects), 107 | ) 108 | 109 | def list_exception(self) -> Result[ListExceptionsResp]: 110 | """Retrieves exception objects in a paginated list. 111 | 112 | :rtype: Result[GetExceptionListResp]: 113 | """ 114 | return self._core.send(ListExceptionsResp, Api.GET_EXCEPTION_OBJECTS) 115 | 116 | def list_suspicious( 117 | self, 118 | ) -> Result[ListSuspiciousResp]: 119 | """Retrieves suspicious objects in a paginated list. 120 | 121 | :rtype: Result[GetSuspiciousListResp]: 122 | """ 123 | return self._core.send(ListSuspiciousResp, Api.GET_SUSPICIOUS_OBJECTS) 124 | 125 | def consume_exception( 126 | self, consumer: Callable[[ExceptionObject], None] 127 | ) -> Result[ConsumeLinkableResp]: 128 | """Retrieves and consume exception objects. 129 | 130 | :param consumer: Function which will consume every record in result. 131 | :type consumer: Callable[[ExceptionObject], None] 132 | :rtype: Result[ConsumeLinkableResp]: 133 | """ 134 | return self._core.send_linkable( 135 | ListExceptionsResp, Api.GET_EXCEPTION_OBJECTS, consumer 136 | ) 137 | 138 | def consume_suspicious( 139 | self, consumer: Callable[[SuspiciousObject], None] 140 | ) -> Result[ConsumeLinkableResp]: 141 | """Retrieves and consume suspicious objects. 142 | 143 | :param consumer: Function which will consume every record in result. 144 | :type consumer: Callable[[SuspiciousObject], None] 145 | :rtype: Result[ConsumeLinkableResp]: 146 | """ 147 | return self._core.send_linkable( 148 | ListSuspiciousResp, Api.GET_SUSPICIOUS_OBJECTS, consumer 149 | ) 150 | -------------------------------------------------------------------------------- /src/pytmv1/api/api_key.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Callable, Optional 4 | 5 | from .. import utils 6 | from ..core import Core 7 | from ..model.common import ApiKey as Apk 8 | from ..model.enum import Api, ApiStatus, HttpMethod, QueryOp 9 | from ..model.request import ApiKeyRequest 10 | from ..model.response import ( 11 | ConsumeLinkableResp, 12 | GetApiKeyResp, 13 | ListApiKeyResp, 14 | MultiApiKeyResp, 15 | MultiResp, 16 | NoContentResp, 17 | ) 18 | from ..result import MultiResult, Result 19 | 20 | 21 | class ApiKey: 22 | _core: Core 23 | 24 | def __init__(self, core: Core): 25 | self._core = core 26 | 27 | def create( 28 | self, 29 | *keys: ApiKeyRequest, 30 | ) -> MultiResult[MultiApiKeyResp]: 31 | """Generates API keys designed to access Trend Vision One APIs. 32 | 33 | :param keys: API Key(s) to create. 34 | :type keys: Tuple[ApiKeyTask, ...] 35 | :return: MultiResult[MultiApiKeyResp] 36 | """ 37 | return self._core.send_multi( 38 | MultiApiKeyResp, 39 | Api.CREATE_API_KEYS, 40 | json=[ 41 | task.model_dump(by_alias=True, exclude_none=True) 42 | for task in keys 43 | ], 44 | ) 45 | 46 | def get(self, key_id: str) -> Result[GetApiKeyResp]: 47 | """Retrieves the specified API key. 48 | 49 | :param key_id: Identifier of the API key. 50 | :type key_id: str 51 | :return: Result[GetApiKeyDetailsResp] 52 | """ 53 | return self._core.send( 54 | GetApiKeyResp, Api.GET_API_KEY.value.format(key_id) 55 | ) 56 | 57 | def update( 58 | self, 59 | key_id: str, 60 | etag: str, 61 | role: Optional[str] = None, 62 | name: Optional[str] = None, 63 | status: Optional[ApiStatus] = None, 64 | description: Optional[str] = None, 65 | ) -> Result[NoContentResp]: 66 | """Updates the specified API key. 67 | 68 | :param key_id: Identifier of the API key. 69 | :type key_id: str 70 | :param etag: ETag of the resource you want to update. 71 | :type etag: str 72 | :param role: User role assigned to the API key. 73 | :type role: Optional[str] 74 | :param name: Unique name of the API key. 75 | :type name: Optional[str] 76 | :param status: Status of an API key. 77 | :type status: Optional[ApiStatus] 78 | :param description: A brief note about the API key 79 | :type description: str 80 | :return: Result[NoContentResp] 81 | """ 82 | return self._core.send( 83 | NoContentResp, 84 | Api.UPDATE_API_KEY.value.format(key_id), 85 | HttpMethod.PATCH, 86 | headers={ 87 | "If-Match": etag if not etag.startswith('"') else etag[1:-1] 88 | }, 89 | json=utils.filter_none( 90 | { 91 | "role": role, 92 | "name": name, 93 | "status": status, 94 | "description": description, 95 | } 96 | ), 97 | ) 98 | 99 | def delete(self, *key_ids: str) -> MultiResult[MultiResp]: 100 | """Deletes the specified API keys. 101 | 102 | :param key_ids: Identifier of the API keys. 103 | :type key_ids: List[str] 104 | :return: MultiResult[MultiResp] 105 | """ 106 | return self._core.send_multi( 107 | MultiResp, 108 | Api.DELETE_API_KEYS, 109 | json=[{"id": key_id} for key_id in key_ids], 110 | ) 111 | 112 | def list( 113 | self, top: int = 50, op: QueryOp = QueryOp.AND, **fields: str 114 | ) -> Result[ListApiKeyResp]: 115 | """Retrieves API keys in a paginated list. 116 | 117 | :param top: Number of records displayed on a page. 118 | :type top: int 119 | :param op: Query operator to apply. 120 | :type op: QueryOp 121 | :param fields: Field/value used to filter result (ie: id="...") 122 | :type fields: Dict[str, str] 123 | check Vision One API documentation for full list of supported fields. 124 | :return: Result[GetApiKeyListResp] 125 | """ 126 | return self._core.send( 127 | ListApiKeyResp, 128 | Api.GET_API_KEY_LIST, 129 | params={"orderBy": "createdDateTime desc", "top": top}, 130 | headers=utils.tmv1_filter(op, fields), 131 | ) 132 | 133 | def consume( 134 | self, 135 | consumer: Callable[[Apk], None], 136 | top: int = 50, 137 | op: QueryOp = QueryOp.AND, 138 | **fields: str, 139 | ) -> Result[ConsumeLinkableResp]: 140 | """Retrieves and consume API keys. 141 | 142 | :param consumer: Function which will consume every record in result. 143 | :type consumer: Callable[[ApiKey], None] 144 | :param top: Number of records displayed on a page. 145 | :type top: int 146 | :param op: Query operator to apply. 147 | :type op: QueryOp 148 | :param fields: Field/value used to filter result (ie: id="...") 149 | :type fields: Dict[str, str] 150 | check Vision One API documentation for full list of supported fields. 151 | :return: Result[GetApiKeyListResp] 152 | """ 153 | return self._core.send_linkable( 154 | ListApiKeyResp, 155 | Api.GET_API_KEY_LIST, 156 | consumer, 157 | params={"orderBy": "createdDateTime desc", "top": top}, 158 | headers=utils.tmv1_filter(op, fields), 159 | ) 160 | -------------------------------------------------------------------------------- /src/pytmv1/api/alert.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, Optional, Union 2 | 3 | from .. import utils 4 | from ..core import Core 5 | from ..model.common import SaeAlert, TiAlert 6 | from ..model.enum import ( 7 | AlertStatus, 8 | Api, 9 | HttpMethod, 10 | InvestigationResult, 11 | InvestigationStatus, 12 | QueryOp, 13 | ) 14 | from ..model.response import ( 15 | ConsumeLinkableResp, 16 | GetAlertResp, 17 | ListAlertsResp, 18 | NoContentResp, 19 | ) 20 | from ..result import Result 21 | 22 | 23 | class Alert: 24 | _core: Core 25 | 26 | def __init__(self, core: Core): 27 | self._core = core 28 | 29 | def update_status( 30 | self, 31 | alert_id: str, 32 | etag: str, 33 | status: Optional[AlertStatus] = None, 34 | inv_result: Optional[InvestigationResult] = None, 35 | inv_status: Optional[InvestigationStatus] = None, 36 | ) -> Result[NoContentResp]: 37 | """Edit the status of an alert or investigation triggered in Workbench. 38 | 39 | :param alert_id: Workbench alert id. 40 | :type alert_id: str 41 | :param status: Status of a case or investigation. 42 | :type status: Optional[AlertStatus] 43 | :param inv_result: Findings of a case or investigation. 44 | :type inv_result: Optional[InvestigationResult] 45 | :param inv_status: (deprecated) Status of an investigation. 46 | :type inv_status: Optional[InvestigationStatus] 47 | :param etag: Target resource will be updated only if 48 | it matches ETag of the target one. 49 | :type etag: str 50 | :rtype: Result[NoContentResp]: 51 | """ 52 | return self._core.send( 53 | NoContentResp, 54 | Api.UPDATE_ALERT_STATUS.value.format(alert_id), 55 | HttpMethod.PATCH, 56 | json=utils.filter_none( 57 | { 58 | "status": status, 59 | "investigationResult": inv_result, 60 | "investigationStatus": inv_status, 61 | } 62 | ), 63 | headers={ 64 | "If-Match": ( 65 | etag if etag.startswith('"') else '"' + etag + '"' 66 | ) 67 | }, 68 | ) 69 | 70 | def get(self, alert_id: str) -> Result[GetAlertResp]: 71 | """Displays information about the specified alert. 72 | 73 | :param alert_id: Workbench alert id. 74 | :type alert_id: str 75 | :rtype: Result[GetAlertDetailsResp]: 76 | """ 77 | return self._core.send( 78 | GetAlertResp, 79 | Api.GET_ALERT.value.format(alert_id), 80 | ) 81 | 82 | def list( 83 | self, 84 | start_time: Optional[str] = None, 85 | end_time: Optional[str] = None, 86 | op: QueryOp = QueryOp.AND, 87 | **fields: str, 88 | ) -> Result[ListAlertsResp]: 89 | """Retrieves workbench alerts in a paginated list. 90 | 91 | :param start_time: Date that indicates the start of the data retrieval 92 | time range (yyyy-MM-ddThh:mm:ssZ). 93 | Defaults to 24 hours before the request is made. 94 | :type start_time: Optional[str] 95 | :param end_time: Date that indicates the end of the data retrieval 96 | time range (yyyy-MM-ddThh:mm:ssZ). 97 | Defaults to the time the request is made. 98 | :type end_time: Optional[str] 99 | :param op: Operator to apply between fields (ie: ... OR ...). 100 | :type op: QueryOp 101 | :param fields: Field/value used to filter result (i.e:fileName="1.sh"), 102 | check Vision One API documentation for full list of supported fields. 103 | :type fields: Dict[str, str] 104 | :rtype: Result[GetAlertListResp]: 105 | """ 106 | return self._core.send( 107 | ListAlertsResp, 108 | Api.GET_ALERT_LIST, 109 | params=utils.filter_none( 110 | { 111 | "startDateTime": start_time, 112 | "endDateTime": end_time, 113 | "orderBy": "createdDateTime desc", 114 | } 115 | ), 116 | headers=utils.tmv1_filter(op, fields), 117 | ) 118 | 119 | def consume( 120 | self, 121 | consumer: Callable[[Union[SaeAlert, TiAlert]], None], 122 | start_time: Optional[str] = None, 123 | end_time: Optional[str] = None, 124 | date_time_target: Optional[str] = "createdDateTime", 125 | op: QueryOp = QueryOp.AND, 126 | **fields: str, 127 | ) -> Result[ConsumeLinkableResp]: 128 | """Retrieves and consume workbench alerts. 129 | 130 | :param consumer: Function which will consume every record in result. 131 | :type consumer: Callable[[Union[SaeAlert, TiAlert]], None] 132 | :param start_time: Date that indicates the start of the data retrieval 133 | time range (yyyy-MM-ddThh:mm:ssZ). 134 | Defaults to 24 hours before the request is made. 135 | :type start_time: Optional[str] 136 | :param end_time: Date that indicates the end of the data retrieval 137 | time range (yyyy-MM-ddThh:mm:ssZ). 138 | Defaults to the time the request is made. 139 | :type end_time: Optional[str] 140 | :param op: Operator to apply between fields (ie: ... OR ...). 141 | :type op: QueryOp 142 | :param fields: Field/value used to filter result (i.e:fileName="1.sh"), 143 | check Vision One API documentation for full list of supported fields. 144 | :type fields: Dict[str, str] 145 | :rtype: Result[ConsumeLinkableResp]: 146 | """ 147 | return self._core.send_linkable( 148 | ListAlertsResp, 149 | Api.GET_ALERT_LIST, 150 | consumer, 151 | params=utils.filter_none( 152 | { 153 | "startDateTime": start_time, 154 | "endDateTime": end_time, 155 | "dateTimeTarget": date_time_target, 156 | "orderBy": "createdDateTime desc", 157 | } 158 | ), 159 | headers=utils.tmv1_filter(op, fields), 160 | ) 161 | -------------------------------------------------------------------------------- /src/pytmv1/api/script.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, Optional 2 | 3 | from .. import utils 4 | from ..core import Core 5 | from ..model.common import Script 6 | from ..model.enum import Api, HttpMethod, QueryOp, ScriptType 7 | from ..model.request import CustomScriptRequest 8 | from ..model.response import ( 9 | AddCustomScriptResp, 10 | ConsumeLinkableResp, 11 | ListCustomScriptsResp, 12 | MultiResp, 13 | NoContentResp, 14 | TextResp, 15 | ) 16 | from ..result import MultiResult, Result 17 | 18 | 19 | class CustomScript: 20 | _core: Core 21 | 22 | def __init__(self, core: Core): 23 | self._core = core 24 | 25 | def create( 26 | self, 27 | script_type: ScriptType, 28 | script_name: str, 29 | script_content: str, 30 | description: Optional[str] = None, 31 | ) -> Result[AddCustomScriptResp]: 32 | """ 33 | Uploads a custom script. Supported file extensions: .ps1, .sh. 34 | Note: Custom scripts must use UTF-8 encoding. 35 | :param script_type: File type. 36 | :type script_type: ScriptType 37 | :param script_name: File name. 38 | :type script_name: str 39 | :param script_content: Plain text content of the script. 40 | :type script_content: str 41 | :param description: Description. 42 | :type description: Optional[str] 43 | :return: Result[AddACustomScriptResp] 44 | """ 45 | return self._core.send( 46 | AddCustomScriptResp, 47 | Api.ADD_CUSTOM_SCRIPT, 48 | HttpMethod.POST, 49 | data=utils.filter_none( 50 | {"fileType": script_type.value, "description": description} 51 | ), 52 | files={ 53 | "file": ( 54 | script_name, 55 | bytes(script_content, "utf-8"), 56 | "text/plain", 57 | ) 58 | }, 59 | ) 60 | 61 | def update( 62 | self, 63 | script_id: str, 64 | script_type: ScriptType, 65 | script_name: str, 66 | script_content: str, 67 | description: Optional[str] = None, 68 | ) -> Result[NoContentResp]: 69 | """ 70 | Updates a custom script. Supported file extensions: .ps1, .sh. 71 | Note: Custom scripts must use UTF-8 encoding. 72 | :param script_id: Unique string that identifies a script file. 73 | :type script_id: str 74 | :param script_type: File type. 75 | :type script_type: ScriptType 76 | :param script_name: File name. 77 | :type script_name: str 78 | :param script_content: Plain text content of the file. 79 | :type script_content: str 80 | :param description: Description. 81 | :type description: Optional[str] 82 | :return: Result[NoContentResp] 83 | """ 84 | return self._core.send( 85 | NoContentResp, 86 | Api.UPDATE_CUSTOM_SCRIPT.value.format(script_id), 87 | HttpMethod.POST, 88 | data=utils.filter_none( 89 | {"fileType": script_type.value, "description": description} 90 | ), 91 | files={ 92 | "file": ( 93 | script_name, 94 | bytes(script_content, "utf-8"), 95 | "text/plain", 96 | ) 97 | }, 98 | ) 99 | 100 | def download(self, script_id: str) -> Result[TextResp]: 101 | """Downloads custom script. 102 | 103 | :param script_id: Unique string that identifies a script file. 104 | :type script_id: str 105 | :return: Result[BytesResp] 106 | """ 107 | return self._core.send( 108 | TextResp, Api.DOWNLOAD_CUSTOM_SCRIPT.value.format(script_id) 109 | ) 110 | 111 | def delete(self, script_id: str) -> Result[NoContentResp]: 112 | """Deletes custom script. 113 | 114 | :param script_id: Unique string that identifies a script file. 115 | :type script_id: str 116 | :return: Result[NoContentResp] 117 | """ 118 | return self._core.send( 119 | NoContentResp, 120 | Api.DELETE_CUSTOM_SCRIPT.value.format(script_id), 121 | HttpMethod.DELETE, 122 | ) 123 | 124 | def list( 125 | self, op: QueryOp = QueryOp.AND, **fields: str 126 | ) -> Result[ListCustomScriptsResp]: 127 | """Retrieves scripts in a paginated list filtered by provided values. 128 | 129 | :param op: Operator to apply between fields (ie: ... OR ...). 130 | :type op: QueryOp 131 | :param fields: Field/value used to filter result (i.e:fileName="1.sh"), 132 | check Vision One API documentation for full list of supported fields. 133 | :type fields: Dict[str, str] 134 | :return: Result[GetCustomScriptsResp] 135 | """ 136 | return self._core.send( 137 | ListCustomScriptsResp, 138 | Api.GET_CUSTOM_SCRIPTS, 139 | params=utils.filter_query(op, fields), 140 | ) 141 | 142 | def run(self, *scripts: CustomScriptRequest) -> MultiResult[MultiResp]: 143 | """Runs multiple custom script. 144 | 145 | :param scripts: Custom scripts to run. 146 | :type scripts: Tuple[CustomScriptTask, ...] 147 | :rtype: MultiResult[MultiResp] 148 | """ 149 | return self._core.send_multi( 150 | MultiResp, 151 | Api.RUN_CUSTOM_SCRIPT, 152 | json=[ 153 | task.model_dump(by_alias=True, exclude_none=True) 154 | for task in scripts 155 | ], 156 | ) 157 | 158 | def consume( 159 | self, 160 | consumer: Callable[[Script], None], 161 | op: QueryOp = QueryOp.AND, 162 | **fields: str, 163 | ) -> Result[ConsumeLinkableResp]: 164 | """Retrieves and consume cust. scripts filtered by provided values. 165 | 166 | :param consumer: Function which will consume every record in result. 167 | :type consumer: Callable[[Script], None] 168 | :param op: Operator to apply between fields (ie: ... OR ...). 169 | :type op: QueryOp 170 | :param fields: Field/value used to filter result (i.e:fileName="1.sh"), 171 | check Vision One API documentation for full list of supported fields. 172 | :type fields: Dict[str, str] 173 | :return: Result[ConsumeLinkableResp] 174 | """ 175 | return self._core.send_linkable( 176 | ListCustomScriptsResp, 177 | Api.GET_CUSTOM_SCRIPTS, 178 | consumer, 179 | params=utils.filter_query(op, fields), 180 | ) 181 | -------------------------------------------------------------------------------- /src/pytmv1/__init__.py: -------------------------------------------------------------------------------- 1 | from .__about__ import __version__ 2 | from .client import Client, init 3 | from .mapper import map_cef 4 | from .model.common import ( 5 | Account, 6 | Alert, 7 | AlertNote, 8 | ApiKey, 9 | Digest, 10 | EmailActivity, 11 | EmailMessage, 12 | Endpoint, 13 | EndpointActivity, 14 | Entity, 15 | Error, 16 | ExceptionObject, 17 | HostInfo, 18 | ImpactScope, 19 | Indicator, 20 | MatchedEvent, 21 | MatchedFilter, 22 | MatchedIndicatorPattern, 23 | MatchedRule, 24 | MsData, 25 | MsDataApiKey, 26 | MsDataUrl, 27 | MsError, 28 | OatEndpoint, 29 | OatEvent, 30 | OatFilter, 31 | OatObject, 32 | OatPackage, 33 | OatPipeline, 34 | SaeAlert, 35 | SaeIndicator, 36 | SandboxSuspiciousObject, 37 | SuspiciousObject, 38 | TaskError, 39 | TiAlert, 40 | TiIndicator, 41 | Value, 42 | ValueList, 43 | ) 44 | from .model.enum import ( 45 | AlertStatus, 46 | ApiExpInMonths, 47 | ApiStatus, 48 | DetectionType, 49 | EntityType, 50 | Iam, 51 | IntegrityLevel, 52 | InvestigationResult, 53 | InvestigationStatus, 54 | OatDataSource, 55 | OatEntityType, 56 | OatRiskLevel, 57 | ObjectType, 58 | OperatingSystem, 59 | ProductCode, 60 | Provenance, 61 | Provider, 62 | QueryOp, 63 | RiskLevel, 64 | SandboxAction, 65 | SandboxObjectType, 66 | ScanAction, 67 | ScriptType, 68 | Severity, 69 | Status, 70 | TaskAction, 71 | ) 72 | from .model.request import ( 73 | AccountRequest, 74 | ApiKeyRequest, 75 | CollectFileRequest, 76 | CustomScriptRequest, 77 | EmailMessageIdRequest, 78 | EmailMessageUIdRequest, 79 | EndpointRequest, 80 | ObjectRequest, 81 | SuspiciousObjectRequest, 82 | TerminateProcessRequest, 83 | ) 84 | from .model.response import ( 85 | AccountTaskResp, 86 | AddAlertNoteResp, 87 | AddCustomScriptResp, 88 | BaseTaskResp, 89 | BlockListTaskResp, 90 | BytesResp, 91 | CollectFileTaskResp, 92 | ConnectivityResp, 93 | ConsumeLinkableResp, 94 | CustomScriptTaskResp, 95 | EmailMessageTaskResp, 96 | EndpointTaskResp, 97 | GetAlertNoteResp, 98 | GetAlertResp, 99 | GetApiKeyResp, 100 | GetEmailActivitiesCountResp, 101 | GetEndpointActivitiesCountResp, 102 | GetOatPackageResp, 103 | GetPipelineResp, 104 | ListAlertNoteResp, 105 | ListAlertsResp, 106 | ListApiKeyResp, 107 | ListCustomScriptsResp, 108 | ListEmailActivityResp, 109 | ListEndpointActivityResp, 110 | ListEndpointDataResp, 111 | ListExceptionsResp, 112 | ListOatPackagesResp, 113 | ListOatPipelinesResp, 114 | ListOatsResp, 115 | ListSandboxSuspiciousResp, 116 | ListSuspiciousResp, 117 | MultiApiKeyResp, 118 | MultiResp, 119 | MultiUrlResp, 120 | NoContentResp, 121 | OatPipelineResp, 122 | SandboxAnalysisResultResp, 123 | SandboxSubmissionStatusResp, 124 | SandboxSubmitUrlTaskResp, 125 | SubmitFileToSandboxResp, 126 | TerminateProcessTaskResp, 127 | TextResp, 128 | ) 129 | from .result import MultiResult, Result, ResultCode 130 | 131 | __all__ = [ 132 | "__version__", 133 | "init", 134 | "map_cef", 135 | "Account", 136 | "AccountRequest", 137 | "AccountTaskResp", 138 | "AddAlertNoteResp", 139 | "AddCustomScriptResp", 140 | "Alert", 141 | "AlertNote", 142 | "AlertStatus", 143 | "ApiExpInMonths", 144 | "ApiKey", 145 | "ApiKeyRequest", 146 | "ApiStatus", 147 | "BaseTaskResp", 148 | "BlockListTaskResp", 149 | "BytesResp", 150 | "Client", 151 | "CollectFileRequest", 152 | "CollectFileTaskResp", 153 | "ConnectivityResp", 154 | "ConsumeLinkableResp", 155 | "CustomScriptRequest", 156 | "CustomScriptTaskResp", 157 | "Digest", 158 | "EmailActivity", 159 | "EmailMessage", 160 | "EmailMessageIdRequest", 161 | "EmailMessageTaskResp", 162 | "EmailMessageUIdRequest", 163 | "Endpoint", 164 | "EndpointActivity", 165 | "EndpointRequest", 166 | "EndpointTaskResp", 167 | "Entity", 168 | "EntityType", 169 | "Error", 170 | "ExceptionObject", 171 | "ScriptType", 172 | "GetAlertResp", 173 | "GetAlertNoteResp", 174 | "GetApiKeyResp", 175 | "GetEmailActivitiesCountResp", 176 | "GetEndpointActivitiesCountResp", 177 | "GetOatPackageResp", 178 | "GetPipelineResp", 179 | "HostInfo", 180 | "Iam", 181 | "ImpactScope", 182 | "Indicator", 183 | "IntegrityLevel", 184 | "InvestigationResult", 185 | "InvestigationStatus", 186 | "ListAlertsResp", 187 | "ListAlertNoteResp", 188 | "ListApiKeyResp", 189 | "ListCustomScriptsResp", 190 | "ListEmailActivityResp", 191 | "ListEndpointActivityResp", 192 | "ListEndpointDataResp", 193 | "ListExceptionsResp", 194 | "ListOatsResp", 195 | "ListOatPackagesResp", 196 | "ListOatPipelinesResp", 197 | "ListSandboxSuspiciousResp", 198 | "ListSuspiciousResp", 199 | "MatchedEvent", 200 | "MatchedFilter", 201 | "MatchedIndicatorPattern", 202 | "MatchedRule", 203 | "MsData", 204 | "MsDataApiKey", 205 | "MsDataUrl", 206 | "MsError", 207 | "MultiApiKeyResp", 208 | "MultiResult", 209 | "MultiResp", 210 | "MultiUrlResp", 211 | "NoContentResp", 212 | "OatDataSource", 213 | "OatEndpoint", 214 | "OatEntityType", 215 | "OatEvent", 216 | "OatFilter", 217 | "OatObject", 218 | "OatPackage", 219 | "OatPipeline", 220 | "OatPipelineResp", 221 | "OatRiskLevel", 222 | "DetectionType", 223 | "ObjectRequest", 224 | "ObjectType", 225 | "OperatingSystem", 226 | "ProductCode", 227 | "Provenance", 228 | "Provider", 229 | "QueryOp", 230 | "Result", 231 | "ResultCode", 232 | "RiskLevel", 233 | "SaeAlert", 234 | "SaeIndicator", 235 | "SandboxAction", 236 | "SandboxAnalysisResultResp", 237 | "SandboxObjectType", 238 | "SandboxSubmissionStatusResp", 239 | "SandboxSubmitUrlTaskResp", 240 | "SandboxSuspiciousObject", 241 | "ScanAction", 242 | "Severity", 243 | "Status", 244 | "SubmitFileToSandboxResp", 245 | "SuspiciousObject", 246 | "SuspiciousObjectRequest", 247 | "TaskAction", 248 | "TaskError", 249 | "TerminateProcessRequest", 250 | "TerminateProcessTaskResp", 251 | "TextResp", 252 | "TiAlert", 253 | "TiIndicator", 254 | "Value", 255 | "ValueList", 256 | ] 257 | -------------------------------------------------------------------------------- /src/pytmv1/api/sandbox.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from .. import utils 4 | from ..core import Core 5 | from ..model.enum import Api, HttpMethod 6 | from ..model.response import ( 7 | BytesResp, 8 | ListSandboxSuspiciousResp, 9 | MultiUrlResp, 10 | SandboxAnalysisResultResp, 11 | SandboxSubmissionStatusResp, 12 | SubmitFileToSandboxResp, 13 | ) 14 | from ..result import MultiResult, Result 15 | 16 | 17 | class Sandbox: 18 | _core: Core 19 | 20 | def __init__(self, core: Core): 21 | self._core = core 22 | 23 | def submit_file( 24 | self, 25 | file: bytes, 26 | file_name: str, 27 | document_password: Optional[str] = None, 28 | archive_password: Optional[str] = None, 29 | arguments: Optional[str] = None, 30 | ) -> Result[SubmitFileToSandboxResp]: 31 | """Submits a file to the sandbox for analysis. 32 | 33 | :param file: Raw content in bytes. 34 | :type file: bytes 35 | :param file_name: Name of the file. 36 | :type file_name: str 37 | :param document_password: Password used to 38 | decrypt the submitted file sample. 39 | :type document_password: Optional[str] 40 | :param archive_password: Password encoded in Base64 used to decrypt 41 | the submitted archive. 42 | :type archive_password: Optional[str] 43 | :param arguments: Command line arguments to run the submitted file. 44 | Only available for Portable Executable (PE) files and script files. 45 | :type arguments: Optional[str] 46 | :rtype: Result[SubmitFileToSandboxResp]: 47 | """ 48 | return self._core.send( 49 | SubmitFileToSandboxResp, 50 | Api.SUBMIT_FILE_TO_SANDBOX, 51 | HttpMethod.POST, 52 | data=utils.build_sandbox_file_request( 53 | document_password, archive_password, arguments 54 | ), 55 | files={"file": (file_name, file, "application/octet-stream")}, 56 | ) 57 | 58 | def submit_url(self, *urls: str) -> MultiResult[MultiUrlResp]: 59 | """Submits URLs to the sandbox for analysis. 60 | 61 | :param urls: URL(s) to be submitted. 62 | :type urls: Tuple[str, ...] 63 | :rtype: MultiResult[MultiUrlResp] 64 | """ 65 | return self._core.send_multi( 66 | MultiUrlResp, 67 | Api.SUBMIT_URLS_TO_SANDBOX, 68 | json=[{"url": url} for url in urls], 69 | ) 70 | 71 | def download_analysis_result( 72 | self, 73 | submit_id: str, 74 | poll: bool = True, 75 | poll_time_sec: float = 1800, 76 | ) -> Result[BytesResp]: 77 | """Downloads the analysis results of the specified object as PDF. 78 | 79 | :param submit_id: Sandbox submission id. 80 | :type submit_id: str 81 | :param poll: If we should wait until the task is finished before 82 | to return the result. 83 | :type poll: bool 84 | :param poll_time_sec: Maximum time to wait for the result to 85 | be available. 86 | :type poll_time_sec: float 87 | :rtype: Result[BytesResp]: 88 | """ 89 | return self._core.send_sandbox_result( 90 | BytesResp, 91 | Api.DOWNLOAD_SANDBOX_ANALYSIS_RESULT, 92 | submit_id, 93 | poll, 94 | poll_time_sec, 95 | ) 96 | 97 | def download_investigation_package( 98 | self, 99 | submit_id: str, 100 | poll: bool = True, 101 | poll_time_sec: float = 1800, 102 | ) -> Result[BytesResp]: 103 | """Downloads the Investigation Package of the specified object. 104 | 105 | :param submit_id: Sandbox submission id. 106 | :type submit_id: str 107 | :param poll: If we should wait until the task is finished before 108 | to return the result. 109 | :type poll: bool 110 | :param poll_time_sec: Maximum time to wait for the result to 111 | be available. 112 | :type poll_time_sec: float 113 | :rtype: Result[BytesResp]: 114 | """ 115 | return self._core.send_sandbox_result( 116 | BytesResp, 117 | Api.DOWNLOAD_SANDBOX_INVESTIGATION_PACKAGE, 118 | submit_id, 119 | poll, 120 | poll_time_sec, 121 | ) 122 | 123 | def get_analysis_result( 124 | self, 125 | submit_id: str, 126 | poll: bool = True, 127 | poll_time_sec: float = 1800, 128 | ) -> Result[SandboxAnalysisResultResp]: 129 | """Retrieves the analysis results of the specified object. 130 | 131 | :param submit_id: Sandbox submission id. 132 | :type submit_id: str 133 | :param poll: If we should wait until the task is finished before 134 | to return the result. 135 | :type poll: bool 136 | :param poll_time_sec: Maximum time to wait for the result 137 | to be available. 138 | :type poll_time_sec: float 139 | :rtype: Result[SandboxAnalysisResultResp]: 140 | """ 141 | return self._core.send_sandbox_result( 142 | SandboxAnalysisResultResp, 143 | Api.GET_SANDBOX_ANALYSIS_RESULT, 144 | submit_id, 145 | poll, 146 | poll_time_sec, 147 | ) 148 | 149 | def get_submission_status( 150 | self, submit_id: str 151 | ) -> Result[SandboxSubmissionStatusResp]: 152 | """Retrieves the submission status of the specified object. 153 | 154 | :param submit_id: Sandbox submission id. 155 | :type submit_id: str 156 | :rtype: Result[SandboxSubmissionStatusResp]: 157 | """ 158 | return self._core.send( 159 | SandboxSubmissionStatusResp, 160 | Api.GET_SANDBOX_SUBMISSION_STATUS.value.format(submit_id), 161 | ) 162 | 163 | def list_suspicious( 164 | self, 165 | submit_id: str, 166 | poll: bool = True, 167 | poll_time_sec: float = 1800, 168 | ) -> Result[ListSandboxSuspiciousResp]: 169 | """Retrieves the suspicious object list associated to the 170 | specified object. 171 | 172 | :param submit_id: Sandbox submission id. 173 | :type submit_id: str 174 | :param poll: If we should wait until the task is finished before 175 | to return the result. 176 | :type poll: bool 177 | :param poll_time_sec: Maximum time to wait for the result 178 | to be available. 179 | :type poll_time_sec: float 180 | :rtype: Result[SandboxSuspiciousListResp]: 181 | """ 182 | return self._core.send_sandbox_result( 183 | ListSandboxSuspiciousResp, 184 | Api.GET_SANDBOX_SUSPICIOUS_LIST, 185 | submit_id, 186 | poll, 187 | poll_time_sec, 188 | ) 189 | -------------------------------------------------------------------------------- /src/pytmv1/api/note.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, Optional 2 | 3 | from .. import utils 4 | from ..core import Core 5 | from ..model.common import AlertNote 6 | from ..model.enum import Api, HttpMethod, QueryOp 7 | from ..model.response import ( 8 | AddAlertNoteResp, 9 | ConsumeLinkableResp, 10 | GetAlertNoteResp, 11 | ListAlertNoteResp, 12 | NoContentResp, 13 | ) 14 | from ..result import Result 15 | 16 | 17 | class Note: 18 | _core: Core 19 | 20 | def __init__(self, core: Core): 21 | self._core = core 22 | 23 | def create( 24 | self, alert_id: str, note_content: str 25 | ) -> Result[AddAlertNoteResp]: 26 | """Adds a note to the specified Workbench alert. 27 | 28 | :param alert_id: Workbench alert id. 29 | :type alert_id: str 30 | :param note_content: Value of the note. 31 | :type note_content: str 32 | :rtype: Result[AddAlertNoteResp]: 33 | """ 34 | return self._core.send( 35 | AddAlertNoteResp, 36 | Api.ADD_ALERT_NOTE.value.format(alert_id), 37 | HttpMethod.POST, 38 | json={"content": note_content}, 39 | ) 40 | 41 | def update( 42 | self, alert_id: str, note_id: str, etag: str, note_content: str 43 | ) -> Result[NoContentResp]: 44 | """Updates the content of the specified Workbench alert note. 45 | 46 | :param alert_id: Workbench alert id. 47 | :type alert_id: str 48 | :param note_id: Workbench alert note id. 49 | :type note_id: str 50 | :param etag: Workbench alert note ETag. 51 | :param note_content: Content of the alert note. 52 | :return: Result[NoContentResp] 53 | """ 54 | return self._core.send( 55 | NoContentResp, 56 | Api.UPDATE_ALERT_NOTE.value.format(alert_id, note_id), 57 | HttpMethod.PATCH, 58 | json={"content": note_content}, 59 | headers={ 60 | "If-Match": etag if etag.startswith('"') else '"' + etag + '"' 61 | }, 62 | ) 63 | 64 | def delete( 65 | self, 66 | alert_id: str, 67 | *note_ids: str, 68 | ) -> Result[NoContentResp]: 69 | """Deletes the specified notes from a Workbench alert. 70 | 71 | :param alert_id: Workbench alert id. 72 | :type alert_id: str 73 | :param note_ids: Workbench alert note ids. 74 | :type note_ids: Tuple[str, ...] 75 | :return: Result[NoContentResp] 76 | """ 77 | return self._core.send( 78 | NoContentResp, 79 | Api.DELETE_ALERT_NOTE.value.format(alert_id), 80 | HttpMethod.POST, 81 | json=[{"id": note_id} for note_id in note_ids], 82 | ) 83 | 84 | def get(self, alert_id: str, note_id: str) -> Result[GetAlertNoteResp]: 85 | """Retrieves the specified Workbench alert note. 86 | 87 | :param alert_id: Workbench alert id. 88 | :type alert_id: str 89 | :param note_id: Workbench alert note id. 90 | :type note_id: str 91 | :return: 92 | """ 93 | return self._core.send( 94 | GetAlertNoteResp, 95 | Api.GET_ALERT_NOTE.value.format(alert_id, note_id), 96 | ) 97 | 98 | def list( 99 | self, 100 | alert_id: str, 101 | top: int = 50, 102 | start_time: Optional[str] = None, 103 | end_time: Optional[str] = None, 104 | op: QueryOp = QueryOp.AND, 105 | **fields: str, 106 | ) -> Result[ListAlertNoteResp]: 107 | """Retrieves workbench alert notes in a paginated list. 108 | 109 | :param alert_id: Workbench alert id. 110 | :type alert_id: str 111 | :param top: Number of records fetched per page. 112 | :type top: int 113 | :param start_time: Date that indicates the start of the data retrieval 114 | time range (yyyy-MM-ddThh:mm:ssZ). 115 | :type start_time: Optional[str] 116 | :param end_time: Date that indicates the end of the data retrieval 117 | time range (yyyy-MM-ddThh:mm:ssZ). 118 | :type end_time: Optional[str] 119 | :param op: Operator to apply between fields (ie: ... OR ...). 120 | :type op: QueryOp 121 | :param fields: Field/value used to filter result (i.e:id="1"), 122 | check Vision One API documentation for full list of supported fields. 123 | :type fields: Dict[str, str] 124 | :rtype: Result[GetAlertNoteListResp]: 125 | """ 126 | return self._core.send( 127 | ListAlertNoteResp, 128 | Api.GET_ALERT_NOTE_LIST.value.format(alert_id), 129 | params=utils.filter_none( 130 | { 131 | "startDateTime": start_time, 132 | "endDateTime": end_time, 133 | "orderBy": "createdDateTime desc", 134 | "top": top, 135 | } 136 | ), 137 | headers=utils.tmv1_filter(op, fields), 138 | ) 139 | 140 | def consume( 141 | self, 142 | consumer: Callable[[AlertNote], None], 143 | alert_id: str, 144 | top: int = 50, 145 | start_time: Optional[str] = None, 146 | end_time: Optional[str] = None, 147 | op: QueryOp = QueryOp.AND, 148 | **fields: str, 149 | ) -> Result[ConsumeLinkableResp]: 150 | """Retrieves workbench alert notes in a paginated list. 151 | 152 | :param consumer: Function which will consume every record in result. 153 | :type consumer: Callable[[AlertNote], None] 154 | :param alert_id: Workbench alert id. 155 | :type alert_id: str 156 | :param top: Number of records fetched per page. 157 | :type top: int 158 | :param start_time: Date that indicates the start of the data retrieval 159 | time range (yyyy-MM-ddThh:mm:ssZ). 160 | :type start_time: Optional[str] 161 | :param end_time: Date that indicates the end of the data retrieval 162 | time range (yyyy-MM-ddThh:mm:ssZ). 163 | :type end_time: Optional[str] 164 | :param op: Operator to apply between fields (ie: ... OR ...). 165 | :type op: QueryOp 166 | :param fields: Field/value used to filter result (i.e:id="1"), 167 | check Vision One API documentation for full list of supported fields. 168 | :type fields: Dict[str, str] 169 | :rtype: Result[ConsumeLinkableResp]: 170 | """ 171 | return self._core.send_linkable( 172 | ListAlertNoteResp, 173 | Api.GET_ALERT_NOTE_LIST.value.format(alert_id), 174 | consumer, 175 | params=utils.filter_none( 176 | { 177 | "startDateTime": start_time, 178 | "endDateTime": end_time, 179 | "orderBy": "createdDateTime desc", 180 | "top": top, 181 | } 182 | ), 183 | headers=utils.tmv1_filter(op, fields), 184 | ) 185 | -------------------------------------------------------------------------------- /src/pytmv1/model/response.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any, Dict, Generic, List, Optional, TypeVar, Union 4 | 5 | from pydantic import Field, field_validator, model_validator 6 | 7 | from .common import ( 8 | Account, 9 | AlertNote, 10 | ApiKey, 11 | BaseConsumable, 12 | BaseModel, 13 | Digest, 14 | EmailActivity, 15 | EmailMessage, 16 | Endpoint, 17 | EndpointActivity, 18 | ExceptionObject, 19 | MsData, 20 | MsDataApiKey, 21 | MsDataUrl, 22 | OatEvent, 23 | OatPackage, 24 | OatPipeline, 25 | SaeAlert, 26 | SandboxSuspiciousObject, 27 | Script, 28 | SuspiciousObject, 29 | TaskError, 30 | TiAlert, 31 | get_object, 32 | ) 33 | from .enum import ( 34 | ObjectType, 35 | RiskLevel, 36 | SandboxAction, 37 | SandboxObjectType, 38 | Status, 39 | ) 40 | 41 | C = TypeVar("C", bound=BaseConsumable) 42 | M = TypeVar("M", bound=MsData) 43 | 44 | 45 | class BaseResponse(BaseModel): 46 | def __init__(self, **data: Any): 47 | super().__init__(**data) 48 | 49 | 50 | class BaseLinkableResp(BaseResponse, Generic[C]): 51 | next_link: Optional[str] = None 52 | items: List[C] 53 | 54 | @model_validator(mode="before") 55 | @classmethod 56 | def map_data(cls, data: Dict[str, Any]) -> Dict[str, Any]: 57 | if not data["items"]: 58 | data["items"] = [] 59 | return data 60 | 61 | 62 | class BaseMultiResponse(BaseResponse, Generic[M]): 63 | items: List[M] 64 | 65 | 66 | class BaseStatusResponse(BaseResponse): 67 | id: str 68 | status: Status 69 | created_date_time: str 70 | last_action_date_time: str 71 | 72 | 73 | class BaseTaskResp(BaseStatusResponse): 74 | action: str 75 | description: Optional[str] = None 76 | account: Optional[str] = None 77 | error: Optional[TaskError] = None 78 | 79 | 80 | MR = TypeVar("MR", bound=BaseMultiResponse[Any]) 81 | R = TypeVar("R", bound=BaseResponse) 82 | S = TypeVar("S", bound=BaseStatusResponse) 83 | T = TypeVar("T", bound=BaseTaskResp) 84 | 85 | 86 | class AccountTaskResp(BaseTaskResp): 87 | tasks: List[Account] 88 | 89 | 90 | class AddAlertNoteResp(BaseResponse): 91 | note_id: str = Field(validation_alias="Location") 92 | 93 | @field_validator("note_id", mode="before") 94 | @classmethod 95 | def get_id(cls, value: str) -> str: 96 | return _get_id(value) 97 | 98 | 99 | class AddCustomScriptResp(BaseResponse): 100 | script_id: str = Field(validation_alias="Location") 101 | 102 | @field_validator("script_id", mode="before") 103 | @classmethod 104 | def get_id(cls, value: str) -> str: 105 | return _get_id(value) 106 | 107 | 108 | class BlockListTaskResp(BaseTaskResp): 109 | type: ObjectType 110 | value: str 111 | 112 | @model_validator(mode="before") 113 | @classmethod 114 | def map_data(cls, data: Dict[str, str]) -> Dict[str, str]: 115 | obj = get_object(data) 116 | if obj: 117 | data["type"] = obj[0] 118 | data["value"] = obj[1] 119 | return data 120 | 121 | 122 | class BytesResp(BaseResponse): 123 | content: bytes 124 | 125 | 126 | class CollectFileTaskResp(BaseTaskResp): 127 | agent_guid: str 128 | endpoint_name: str 129 | file_path: Optional[str] = None 130 | file_sha1: Optional[str] = None 131 | file_sha256: Optional[str] = None 132 | file_size: Optional[int] = None 133 | resource_location: Optional[str] = None 134 | expired_date_time: Optional[str] = None 135 | password: Optional[str] = None 136 | 137 | 138 | class ConnectivityResp(BaseResponse): 139 | status: str 140 | 141 | 142 | class ConsumeLinkableResp(BaseResponse, alias_generator=None): 143 | total_consumed: int 144 | 145 | 146 | class EndpointTaskResp(BaseTaskResp): 147 | agent_guid: str 148 | endpoint_name: str 149 | 150 | 151 | class GetAlertResp(BaseResponse): 152 | data: Union[SaeAlert, TiAlert] 153 | etag: str 154 | 155 | 156 | class GetAlertNoteResp(BaseResponse): 157 | data: AlertNote 158 | etag: str 159 | 160 | 161 | class GetApiKeyResp(BaseResponse): 162 | data: ApiKey 163 | etag: str 164 | 165 | 166 | class GetOatPackageResp(BaseResponse): 167 | package: OatEvent 168 | 169 | 170 | class GetPipelineResp(BaseResponse): 171 | data: OatPipeline 172 | etag: str 173 | 174 | 175 | class ListAlertsResp(BaseLinkableResp[Union[SaeAlert, TiAlert]]): 176 | total_count: int 177 | count: int 178 | 179 | 180 | class ListAlertNoteResp(BaseLinkableResp[AlertNote]): ... 181 | 182 | 183 | class ListApiKeyResp(BaseLinkableResp[ApiKey]): 184 | total_count: int 185 | count: int 186 | 187 | 188 | class ListCustomScriptsResp(BaseLinkableResp[Script]): ... 189 | 190 | 191 | class ListEndpointActivityResp(BaseLinkableResp[EndpointActivity]): 192 | progress_rate: int 193 | 194 | 195 | class GetEndpointActivitiesCountResp(BaseResponse): 196 | total_count: int 197 | 198 | 199 | class ListEmailActivityResp(BaseLinkableResp[EmailActivity]): 200 | progress_rate: int 201 | 202 | 203 | class GetEmailActivitiesCountResp(BaseResponse): 204 | total_count: int 205 | 206 | 207 | class ListEndpointDataResp(BaseLinkableResp[Endpoint]): ... 208 | 209 | 210 | class ListExceptionsResp(BaseLinkableResp[ExceptionObject]): ... 211 | 212 | 213 | class ListOatsResp(BaseLinkableResp[OatEvent]): 214 | total_count: int 215 | count: int 216 | 217 | 218 | class ListOatPipelinesResp(BaseResponse): 219 | items: List[OatPipeline] 220 | count: int 221 | 222 | 223 | class ListOatPackagesResp(BaseLinkableResp[OatPackage]): 224 | total_count: int 225 | count: int 226 | requested_date_time: str 227 | latest_package_created_date_time: Optional[str] = None 228 | 229 | 230 | class ListSuspiciousResp(BaseLinkableResp[SuspiciousObject]): ... 231 | 232 | 233 | class MultiResp(BaseMultiResponse[MsData]): ... 234 | 235 | 236 | class MultiUrlResp(BaseMultiResponse[MsDataUrl]): ... 237 | 238 | 239 | class MultiApiKeyResp(BaseMultiResponse[MsDataApiKey]): ... 240 | 241 | 242 | class NoContentResp(BaseResponse): ... 243 | 244 | 245 | class EmailMessageTaskResp(BaseTaskResp): 246 | tasks: List[EmailMessage] 247 | 248 | 249 | class CustomScriptTaskResp(BaseTaskResp): 250 | file_name: str 251 | agent_guid: str 252 | endpoint_name: str 253 | parameter: Optional[str] = None 254 | resource_location: Optional[str] = None 255 | expired_date_time: Optional[str] = None 256 | password: Optional[str] = None 257 | 258 | 259 | class OatPipelineResp(BaseResponse): 260 | pipeline_id: str = Field(validation_alias="Location") 261 | 262 | @field_validator("pipeline_id", mode="before") 263 | @classmethod 264 | def get_id(cls, value: str) -> str: 265 | return _get_id(value) 266 | 267 | 268 | class SubmitFileToSandboxResp(BaseResponse): 269 | id: str 270 | digest: Digest 271 | arguments: Optional[str] = None 272 | 273 | 274 | class SandboxAnalysisResultResp(BaseResponse): 275 | id: str 276 | type: SandboxObjectType 277 | analysis_completion_date_time: str 278 | risk_level: RiskLevel 279 | true_file_type: Optional[str] = None 280 | digest: Optional[Digest] = None 281 | arguments: Optional[str] = None 282 | detection_names: List[str] = Field(default=[]) 283 | threat_types: List[str] = Field(default=[]) 284 | 285 | 286 | class SandboxSubmissionStatusResp(BaseStatusResponse): 287 | action: SandboxAction 288 | resource_location: Optional[str] = None 289 | is_cached: Optional[bool] = None 290 | digest: Optional[Digest] = None 291 | arguments: Optional[str] = None 292 | 293 | 294 | class ListSandboxSuspiciousResp(BaseResponse): 295 | items: List[SandboxSuspiciousObject] 296 | 297 | 298 | class SandboxSubmitUrlTaskResp(BaseTaskResp): 299 | url: str 300 | sandbox_task_id: str 301 | 302 | 303 | class TerminateProcessTaskResp(BaseTaskResp): 304 | agent_guid: str 305 | endpoint_name: str 306 | file_sha1: str 307 | file_name: Optional[str] = None 308 | 309 | 310 | class TextResp(BaseResponse): 311 | text: str 312 | 313 | 314 | def _get_id(location: str) -> str: 315 | return location.split("/")[-1] 316 | -------------------------------------------------------------------------------- /src/pytmv1/api/email.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, List, Optional, Union 2 | 3 | from .. import utils 4 | from ..core import Core 5 | from ..model.common import EmailActivity 6 | from ..model.enum import Api, QueryOp, SearchMode 7 | from ..model.request import EmailMessageIdRequest, EmailMessageUIdRequest 8 | from ..model.response import ( 9 | ConsumeLinkableResp, 10 | GetEmailActivitiesCountResp, 11 | ListEmailActivityResp, 12 | MultiResp, 13 | ) 14 | from ..result import MultiResult, Result 15 | 16 | 17 | class Email: 18 | _core: Core 19 | 20 | def __init__(self, core: Core): 21 | self._core = core 22 | 23 | def quarantine( 24 | self, *messages: Union[EmailMessageUIdRequest, EmailMessageIdRequest] 25 | ) -> MultiResult[MultiResp]: 26 | """Quarantine a message from one or more mailboxes. 27 | 28 | :param messages: Message(s) to quarantine. 29 | :type messages: Tuple[EmailUIdTask, EmailMsgIdTask, ...] 30 | :rtype: MultiResult[MultiResp] 31 | """ 32 | return self._core.send_multi( 33 | MultiResp, 34 | Api.QUARANTINE_EMAIL_MESSAGE, 35 | json=[ 36 | task.model_dump(by_alias=True, exclude_none=True) 37 | for task in messages 38 | ], 39 | ) 40 | 41 | def restore( 42 | self, *messages: Union[EmailMessageUIdRequest, EmailMessageIdRequest] 43 | ) -> MultiResult[MultiResp]: 44 | """Restore quarantined email message(s). 45 | 46 | :param messages: Message(s) to restore. 47 | :type messages: Tuple[EmailUIdTask, EmailMsgIdTask, ...] 48 | :rtype: MultiResult[MultiResp] 49 | """ 50 | return self._core.send_multi( 51 | MultiResp, 52 | Api.RESTORE_EMAIL_MESSAGE, 53 | json=[ 54 | task.model_dump(by_alias=True, exclude_none=True) 55 | for task in messages 56 | ], 57 | ) 58 | 59 | def delete( 60 | self, *messages: Union[EmailMessageUIdRequest, EmailMessageIdRequest] 61 | ) -> MultiResult[MultiResp]: 62 | """Deletes a message from one or more mailboxes. 63 | 64 | :param messages: Message(s) to delete. 65 | :type messages: Tuple[EmailUIdTask, EmailMsgIdTask, ...] 66 | :rtype: MultiResult[MultiResp] 67 | """ 68 | return self._core.send_multi( 69 | MultiResp, 70 | Api.DELETE_EMAIL_MESSAGE, 71 | json=[ 72 | task.model_dump(by_alias=True, exclude_none=True) 73 | for task in messages 74 | ], 75 | ) 76 | 77 | def get_activity_count( 78 | self, 79 | start_time: Optional[str] = None, 80 | end_time: Optional[str] = None, 81 | select: Optional[List[str]] = None, 82 | top: int = 500, 83 | op: QueryOp = QueryOp.AND, 84 | **fields: str, 85 | ) -> Result[GetEmailActivitiesCountResp]: 86 | """Retrieves the count of email activity data in a paginated list 87 | filtered by provided values. 88 | 89 | :param start_time: Date that indicates the start of the data retrieval 90 | time range (yyyy-MM-ddThh:mm:ssZ). 91 | Defaults to 24 hours before the request is made. 92 | :type start_time: Optional[str] 93 | :param end_time: Date that indicates the end of the data retrieval 94 | time range (yyyy-MM-ddThh:mm:ssZ). 95 | Defaults to the time the request is made. 96 | :type end_time: Optional[str] 97 | :param select: List of fields to include in the search results, 98 | if no fields are specified, the query returns all supported fields. 99 | :type select: Optional[List[str]] 100 | :param top: Number of records fetched per page. 101 | :type top: int 102 | :param op: Operator to apply between fields (ie: uuid=... OR tags=...) 103 | :type op: QueryOp 104 | :param fields: Field/value used to filter result (ie: uuid="123456") 105 | check Vision One API documentation for full list of supported fields. 106 | :type fields: Dict[str, str] 107 | :rtype: Result[GetEmailActivityDataCountResp]: 108 | """ 109 | return self._core.send( 110 | GetEmailActivitiesCountResp, 111 | Api.GET_EMAIL_ACTIVITY_DATA, 112 | params=utils.build_activity_request( 113 | start_time, 114 | end_time, 115 | select, 116 | top, 117 | SearchMode.COUNT_ONLY, 118 | ), 119 | headers=utils.tmv1_activity_query(op, fields), 120 | ) 121 | 122 | def list_activity( 123 | self, 124 | start_time: Optional[str] = None, 125 | end_time: Optional[str] = None, 126 | select: Optional[List[str]] = None, 127 | top: int = 500, 128 | op: QueryOp = QueryOp.AND, 129 | **fields: str, 130 | ) -> Result[ListEmailActivityResp]: 131 | """Retrieves email activity data in a paginated list 132 | filtered by provided values. 133 | 134 | :param start_time: Date that indicates the start of the data retrieval 135 | time range (yyyy-MM-ddThh:mm:ssZ). 136 | Defaults to 24 hours before the request is made. 137 | :type start_time: Optional[str] 138 | :param end_time: Date that indicates the end of the data retrieval 139 | time range (yyyy-MM-ddThh:mm:ssZ). 140 | Defaults to the time the request is made. 141 | :type end_time: Optional[str] 142 | :param select: List of fields to include in the search results, 143 | if no fields are specified, the query returns all supported fields. 144 | :type select: Optional[List[str]] 145 | :param top: Number of records fetched per page. 146 | :type top: int 147 | :param op: Operator to apply between fields (ie: uuid=... OR tags=...) 148 | :type op: QueryOp 149 | :param fields: Field/value used to filter result (ie: uuid="123456") 150 | check Vision One API documentation for full list of supported fields. 151 | :type fields: Dict[str, str] 152 | :rtype: Result[GetEmailActivityDataResp]: 153 | """ 154 | return self._core.send( 155 | ListEmailActivityResp, 156 | Api.GET_EMAIL_ACTIVITY_DATA, 157 | params=utils.build_activity_request( 158 | start_time, 159 | end_time, 160 | select, 161 | top, 162 | SearchMode.DEFAULT, 163 | ), 164 | headers=utils.tmv1_activity_query(op, fields), 165 | ) 166 | 167 | def consume_activity( 168 | self, 169 | consumer: Callable[[EmailActivity], None], 170 | start_time: Optional[str] = None, 171 | end_time: Optional[str] = None, 172 | select: Optional[List[str]] = None, 173 | top: int = 500, 174 | op: QueryOp = QueryOp.AND, 175 | **fields: str, 176 | ) -> Result[ConsumeLinkableResp]: 177 | """Retrieves and consume email activity data in a paginated list 178 | filtered by provided values. 179 | 180 | :param consumer: Function which will consume every record in result. 181 | :type consumer: Callable[[EmailActivity], None] 182 | :param start_time: Date that indicates the start of the data retrieval 183 | time range (yyyy-MM-ddThh:mm:ssZ). 184 | Defaults to 24 hours before the request is made. 185 | :type start_time: Optional[str] 186 | :param end_time: Date that indicates the end of the data retrieval 187 | time range (yyyy-MM-ddThh:mm:ssZ). 188 | Defaults to the time the request is made. 189 | :type end_time: Optional[str] 190 | :param select: List of fields to include in the search results, 191 | if no fields are specified, the query returns all supported fields. 192 | :type select: Optional[List[str]] 193 | :param top: Number of records fetched per page. 194 | :type top: int 195 | :param op: Operator to apply between fields (ie: uuid=... OR tags=...) 196 | :type op: QueryOp 197 | :param fields: Field/value used to filter result (ie: uuid="123456") 198 | check Vision One API documentation for full list of supported fields. 199 | :type fields: Dict[str, str] 200 | :rtype: Result[ConsumeLinkableResp]: 201 | """ 202 | return self._core.send_linkable( 203 | ListEmailActivityResp, 204 | Api.GET_EMAIL_ACTIVITY_DATA, 205 | consumer, 206 | params=utils.build_activity_request( 207 | start_time, 208 | end_time, 209 | select, 210 | top, 211 | SearchMode.DEFAULT, 212 | ), 213 | headers=utils.tmv1_activity_query(op, fields), 214 | ) 215 | -------------------------------------------------------------------------------- /src/pytmv1/model/enum.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class AlertStatus(str, Enum): 5 | OPEN = "Open" 6 | IN_PROGRESS = "In Progress" 7 | CLOSED = "Closed" 8 | 9 | 10 | class Api(str, Enum): 11 | CONNECTIVITY = "/healthcheck/connectivity" 12 | CREATE_API_KEYS = "/iam/apiKeys" 13 | GET_API_KEY_LIST = "/iam/apiKeys" 14 | GET_API_KEY = "/iam/apiKeys/{0}" 15 | UPDATE_API_KEY = "/iam/apiKeys/{0}" 16 | DELETE_API_KEYS = "/iam/apiKeys/delete" 17 | GET_ENDPOINT_DATA = "/eiqs/endpoints" 18 | GET_CUSTOM_SCRIPTS = "/response/customScripts" 19 | ADD_CUSTOM_SCRIPT = "/response/customScripts" 20 | DELETE_CUSTOM_SCRIPT = "/response/customScripts/{0}" 21 | DOWNLOAD_CUSTOM_SCRIPT = "/response/customScripts/{0}" 22 | UPDATE_CUSTOM_SCRIPT = "/response/customScripts/{0}/update" 23 | DELETE_EMAIL_MESSAGE = "/response/emails/delete" 24 | QUARANTINE_EMAIL_MESSAGE = "/response/emails/quarantine" 25 | RESTORE_EMAIL_MESSAGE = "/response/emails/restore" 26 | DISABLE_ACCOUNT = "/response/domainAccounts/disable" 27 | ENABLE_ACCOUNT = "/response/domainAccounts/enable" 28 | RESET_PASSWORD = "/response/domainAccounts/resetPassword" 29 | SIGN_OUT_ACCOUNT = "/response/domainAccounts/signOut" 30 | COLLECT_ENDPOINT_FILE = "/response/endpoints/collectFile" 31 | ISOLATE_ENDPOINT = "/response/endpoints/isolate" 32 | RESTORE_ENDPOINT = "/response/endpoints/restore" 33 | RUN_CUSTOM_SCRIPT = "/response/endpoints/runScript" 34 | TERMINATE_ENDPOINT_PROCESS = "/response/endpoints/terminateProcess" 35 | ADD_TO_BLOCK_LIST = "/response/suspiciousObjects" 36 | REMOVE_FROM_BLOCK_LIST = "/response/suspiciousObjects/delete" 37 | GET_TASK_RESULT = "/response/tasks/{0}" 38 | GET_SANDBOX_ANALYSIS_RESULT = "/sandbox/analysisResults/{0}" 39 | DOWNLOAD_SANDBOX_INVESTIGATION_PACKAGE = ( 40 | "/sandbox/analysisResults/{0}/investigationPackage" 41 | ) 42 | DOWNLOAD_SANDBOX_ANALYSIS_RESULT = "/sandbox/analysisResults/{0}/report" 43 | GET_SANDBOX_SUSPICIOUS_LIST = ( 44 | "/sandbox/analysisResults/{0}/suspiciousObjects" 45 | ) 46 | SUBMIT_FILE_TO_SANDBOX = "/sandbox/files/analyze" 47 | GET_SANDBOX_SUBMISSION_STATUS = "/sandbox/tasks/{0}" 48 | SUBMIT_URLS_TO_SANDBOX = "/sandbox/urls/analyze" 49 | GET_EMAIL_ACTIVITY_DATA = "/search/emailActivities" 50 | GET_ENDPOINT_ACTIVITY_DATA = "/search/endpointActivities" 51 | GET_SUSPICIOUS_OBJECTS = "/threatintel/suspiciousObjects" 52 | ADD_TO_SUSPICIOUS_LIST = "/threatintel/suspiciousObjects" 53 | REMOVE_FROM_SUSPICIOUS_LIST = "/threatintel/suspiciousObjects/delete" 54 | ADD_TO_EXCEPTION_LIST = "/threatintel/suspiciousObjectExceptions" 55 | GET_EXCEPTION_OBJECTS = "/threatintel/suspiciousObjectExceptions" 56 | REMOVE_FROM_EXCEPTION_LIST = ( 57 | "/threatintel/suspiciousObjectExceptions/delete" 58 | ) 59 | GET_ALERT_LIST = "/workbench/alerts" 60 | UPDATE_ALERT_STATUS = "/workbench/alerts/{0}" 61 | GET_ALERT = "/workbench/alerts/{0}" 62 | ADD_ALERT_NOTE = "/workbench/alerts/{0}/notes" 63 | GET_ALERT_NOTE_LIST = "/workbench/alerts/{0}/notes" 64 | GET_ALERT_NOTE = "/workbench/alerts/{0}/notes/{1}" 65 | UPDATE_ALERT_NOTE = "/workbench/alerts/{0}/notes/{1}" 66 | DELETE_ALERT_NOTE = "/workbench/alerts/{0}/notes/delete" 67 | GET_OAT_LIST = "/oat/detections" 68 | CREATE_OAT_PIPELINE = "/oat/dataPipelines" 69 | GET_OAT_PIPELINE = "/oat/dataPipelines/{0}" 70 | UPDATE_OAT_PIPELINE = "/oat/dataPipelines/{0}" 71 | DELETE_OAT_PIPELINE = "/oat/dataPipelines/delete" 72 | LIST_OAT_PIPELINE = "/oat/dataPipelines" 73 | DOWNLOAD_OAT_PACKAGE = "/oat/dataPipelines/{0}/packages/{1}" 74 | LIST_OAT_PACKAGE = "/oat/dataPipelines/{0}/packages" 75 | 76 | 77 | class ApiExpInMonths(int, Enum): 78 | ONE = 1 79 | THREE = 3 80 | SIX = 6 81 | TWELVE = 12 82 | ZERO = 0 83 | 84 | 85 | class ApiStatus(str, Enum): 86 | ENABLED = "enabled" 87 | DISABLED = "disabled" 88 | 89 | 90 | class Iam(str, Enum): 91 | # Azure AD 92 | AAD = "AAD" 93 | # On-premise AD 94 | OPAD = "OPAD" 95 | 96 | 97 | class IntegrityLevel(int, Enum): 98 | UNTRUSTED = 0 99 | LOW = 4096 100 | MEDIUM = 8192 101 | HIGH = 12288 102 | SYSTEM = 16384 103 | 104 | 105 | class InvestigationResult(str, Enum): 106 | NO_FINDINGS = "No Findings" 107 | NOTEWORTHY = "Noteworthy" 108 | TRUE_POSITIVE = "True Positive" 109 | FALSE_POSITIVE = "False Positive" 110 | BENIGN_TRUE_POSITIVE = "Benign True Positive" 111 | 112 | 113 | class InvestigationStatus(str, Enum): 114 | BENIGN_TRUE_POSITIVE = "Benign True Positive" 115 | CLOSED = "Closed" 116 | FALSE_POSITIVE = "False Positive" 117 | IN_PROGRESS = "In Progress" 118 | NEW = "New" 119 | TRUE_POSITIVE = "True Positive" 120 | 121 | 122 | class EntityType(str, Enum): 123 | HOST = "host" 124 | ACCOUNT = "account" 125 | EMAIL_ADDRESS = "emailAddress" 126 | CONTAINER = "container" 127 | CLOUD_IDENTITY = "cloudIdentity" 128 | AWS_LAMBDA = "awsLambda" 129 | 130 | 131 | class ScriptType(str, Enum): 132 | POWERSHELL = "powershell" 133 | BASH = "bash" 134 | 135 | 136 | class HttpMethod(str, Enum): 137 | GET = "GET" 138 | PATCH = "PATCH" 139 | POST = "POST" 140 | PUT = "PUT" 141 | DELETE = "DELETE" 142 | 143 | 144 | class OatDataSource(str, Enum): 145 | DETECTIONS = "detections" 146 | IDENTITY_ACTIVITY_DATA = "identityActivityData" 147 | ENDPOINT_ACTIVITY_DATA = "endpointActivityData" 148 | CLOUD_ACTIVITY_DATA = "cloudActivityData" 149 | EMAIL_ACTIVITY_DATA = "emailActivityData" 150 | MOBILE_ACTIVITY_DATA = "mobileActivityData" 151 | NETWORK_ACTIVITY_DATA = "networkActivityData" 152 | CONTAINER_ACTIVITY_DATA = "containerActivityData" 153 | 154 | 155 | class OatEntityType(str, Enum): 156 | ENDPOINT = "endpoint" 157 | MAILBOX = "mailbox" 158 | CLOUDTRAIL = "cloudtrail" 159 | MESSAGING = "messaging" 160 | NETWORK = "network" 161 | ICS = "ics" 162 | CONTAINER = "container" 163 | 164 | 165 | class OatRiskLevel(str, Enum): 166 | UNDEFINED = "undefined" 167 | INFO = "info" 168 | LOW = "low" 169 | MEDIUM = "medium" 170 | HIGH = "high" 171 | CRITICAL = "critical" 172 | 173 | 174 | class DetectionType(str, Enum): 175 | CUSTOM = "custom" 176 | PRESET = "preset" 177 | 178 | 179 | class ObjectType(str, Enum): 180 | IP = "ip" 181 | URL = "url" 182 | DOMAIN = "domain" 183 | FILE_SHA1 = "fileSha1" 184 | FILE_SHA256 = "fileSha256" 185 | SENDER_MAIL_ADDRESS = "senderMailAddress" 186 | 187 | 188 | class OperatingSystem(str, Enum): 189 | LINUX = "Linux" 190 | WINDOWS = "Windows" 191 | MACOS = "macOS" 192 | MACOSX = "macOSX" 193 | 194 | 195 | class ProductCode(str, Enum): 196 | SAO = "sao" 197 | SDS = "sds" 198 | XES = "xes" 199 | 200 | 201 | class Provenance(str, Enum): 202 | ALERT = "Alert" 203 | SWEEPING = "Sweeping" 204 | NETWORK_ANALYTICS = "Network Analytics" 205 | 206 | 207 | class Provider(str, Enum): 208 | SAE = "SAE" 209 | TI = "TI" 210 | 211 | 212 | class QueryOp(str, Enum): 213 | AND = "and" 214 | OR = "or" 215 | 216 | 217 | class RiskLevel(str, Enum): 218 | NO_RISK = "noRisk" 219 | LOW = "low" 220 | MEDIUM = "medium" 221 | HIGH = "high" 222 | 223 | 224 | class SandboxAction(str, Enum): 225 | ANALYZE_FILE = "analyzeFile" 226 | ANALYZE_URL = "analyzeUrl" 227 | 228 | 229 | class SandboxObjectType(str, Enum): 230 | URL = "url" 231 | FILE = "file" 232 | 233 | 234 | class ScanAction(str, Enum): 235 | BLOCK = "block" 236 | LOG = "log" 237 | 238 | 239 | class SearchMode(str, Enum): 240 | DEFAULT = "default" 241 | COUNT_ONLY = "countOnly" 242 | 243 | 244 | class Severity(str, Enum): 245 | CRITICAL = "critical" 246 | HIGH = "high" 247 | MEDIUM = "medium" 248 | LOW = "low" 249 | 250 | 251 | class Status(str, Enum): 252 | FAILED = "failed" 253 | QUEUED = "queued" 254 | REJECTED = "rejected" 255 | CANCELED = "canceled" 256 | RUNNING = "running" 257 | SUCCEEDED = "succeeded" 258 | PENDING_APPROVAL = "pendingApproval" 259 | 260 | 261 | class TaskAction(str, Enum): 262 | COLLECT_FILE = "collectFile" 263 | COLLECT_EVIDENCE = "collectEvidence" 264 | COLLECT_NETWORK_ANALYSIS_PACKAGE = "collectNetworkAnalysisPackage" 265 | ISOLATE_ENDPOINT = "isolate" 266 | ISOLATE_ENDPOINT_MULTIPLE = "isolateForMultiple" 267 | RESTORE_ENDPOINT = "restoreIsolate" 268 | RESTORE_ENDPOINT_MULTIPLE = "restoreIsolateForMultiple" 269 | TERMINATE_PROCESS = "terminateProcess" 270 | DUMP_PROCESS_MEMORY = "dumpProcessMemory" 271 | QUARANTINE_MESSAGE = "quarantineMessage" 272 | DELETE_MESSAGE = "deleteMessage" 273 | RESTORE_MESSAGE = "restoreMessage" 274 | BLOCK_SUSPICIOUS = "block" 275 | REMOVE_SUSPICIOUS = "restoreBlock" 276 | RESET_PASSWORD = "resetPassword" 277 | SUBMIT_SANDBOX = "submitSandbox" 278 | ENABLE_ACCOUNT = "enableAccount" 279 | DISABLE_ACCOUNT = "disableAccount" 280 | FORCE_SIGN_OUT = "forceSignOut" 281 | REMOTE_SHELL = "remoteShell" 282 | RUN_INVESTIGATION_KIT = "runInvestigationKit" 283 | RUN_CUSTOM_SCRIPT = "runCustomScript" 284 | RUN_CUSTOM_SCRIPT_MULTIPLE = "runCustomScriptForMultiple" 285 | RUN_OS_QUERY = "runOsquery" 286 | RUN_YARA_RULES = "runYaraRules" 287 | -------------------------------------------------------------------------------- /src/pytmv1/api/endpoint.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, List, Optional 2 | 3 | from .. import utils 4 | from ..core import Core 5 | from ..model.common import Endpoint as Ept 6 | from ..model.common import EndpointActivity 7 | from ..model.enum import Api, QueryOp, SearchMode 8 | from ..model.request import ( 9 | CollectFileRequest, 10 | EndpointRequest, 11 | TerminateProcessRequest, 12 | ) 13 | from ..model.response import ( 14 | ConsumeLinkableResp, 15 | GetEndpointActivitiesCountResp, 16 | ListEndpointActivityResp, 17 | ListEndpointDataResp, 18 | MultiResp, 19 | ) 20 | from ..result import MultiResult, Result 21 | 22 | 23 | class Endpoint: 24 | _core: Core 25 | 26 | def __init__(self, core: Core): 27 | self._core = core 28 | 29 | def isolate(self, *endpoints: EndpointRequest) -> MultiResult[MultiResp]: 30 | """Disconnects one or more endpoints from the network 31 | but allows communication with the managing Trend Micro server product. 32 | 33 | :param endpoints: Endpoint(s) to isolate. 34 | :type endpoints: Tuple[EndpointTask, ...] 35 | :rtype: MultiResult[MultiResp] 36 | """ 37 | return self._core.send_endpoint(Api.ISOLATE_ENDPOINT, *endpoints) 38 | 39 | def restore(self, *endpoints: EndpointRequest) -> MultiResult[MultiResp]: 40 | """Restores network connectivity to one or more endpoints that applied 41 | the "Isolate endpoint" action. 42 | 43 | :param endpoints: Endpoint(s) to restore. 44 | :type endpoints: Tuple[EndpointTask, ...] 45 | :rtype: MultiResult[MultiResp] 46 | """ 47 | return self._core.send_endpoint(Api.RESTORE_ENDPOINT, *endpoints) 48 | 49 | def collect_file( 50 | self, *files: CollectFileRequest 51 | ) -> MultiResult[MultiResp]: 52 | """Collects a file from one or more endpoints and then sends the files 53 | to Vision One in a password-protected archive. 54 | 55 | :param files: File(s) to collect. 56 | :type files: Tuple[FileTask, ...] 57 | :rtype: MultiResult[MultiResp] 58 | """ 59 | return self._core.send_endpoint(Api.COLLECT_ENDPOINT_FILE, *files) 60 | 61 | def terminate_process( 62 | self, *processes: TerminateProcessRequest 63 | ) -> MultiResult[MultiResp]: 64 | """Terminates a process that is running on one or more endpoints. 65 | 66 | :param processes: Process(es) to terminate. 67 | :type processes: Tuple[ProcessTask, ...] 68 | :rtype: MultiResult[MultiResp] 69 | """ 70 | return self._core.send_endpoint( 71 | Api.TERMINATE_ENDPOINT_PROCESS, *processes 72 | ) 73 | 74 | def get_activity_count( 75 | self, 76 | start_time: Optional[str] = None, 77 | end_time: Optional[str] = None, 78 | select: Optional[List[str]] = None, 79 | top: int = 500, 80 | op: QueryOp = QueryOp.AND, 81 | **fields: str, 82 | ) -> Result[GetEndpointActivitiesCountResp]: 83 | """Retrieves the count of endpoint activity data in a paginated list 84 | filtered by provided values. 85 | 86 | :param start_time: Date that indicates the start of the data retrieval 87 | time range (yyyy-MM-ddThh:mm:ssZ). 88 | Defaults to 24 hours before the request is made. 89 | :type start_time: Optional[str] 90 | :param end_time: Date that indicates the end of the data retrieval 91 | time range (yyyy-MM-ddThh:mm:ssZ). 92 | Defaults to the time the request is made. 93 | :type end_time: Optional[str] 94 | :param select: List of fields to include in the search results, 95 | if no fields are specified, the query returns all supported fields. 96 | :type select: Optional[List[str]] 97 | :param top: Number of records fetched per page. 98 | :type top: int 99 | :param op: Operator to apply between fields (ie: dpt=... OR src=...) 100 | :type op: QueryOp 101 | :param fields: Field/value used to filter result (ie: dpt="443") 102 | check Vision One API documentation for full list of supported fields. 103 | :type fields: Dict[str, str] 104 | :rtype: Result[GetEndpointActivityDataCountResp]: 105 | """ 106 | return self._core.send( 107 | GetEndpointActivitiesCountResp, 108 | Api.GET_ENDPOINT_ACTIVITY_DATA, 109 | params=utils.build_activity_request( 110 | start_time, 111 | end_time, 112 | select, 113 | top, 114 | SearchMode.COUNT_ONLY, 115 | ), 116 | headers=utils.tmv1_activity_query(op, fields), 117 | ) 118 | 119 | def list_data( 120 | self, op: QueryOp = QueryOp.AND, **fields: str 121 | ) -> Result[ListEndpointDataResp]: 122 | """Retrieves endpoints in a paginated list filtered by provided values. 123 | 124 | :param op: Query operator to apply. 125 | :type op: QueryOp 126 | :param fields: Field/value used to filter result (i.e:ip="1.1.1.1") 127 | check Vision One API documentation for full list of supported fields. 128 | :type fields: Dict[str, str] 129 | :rtype: Result[GetEndpointDataResp]: 130 | """ 131 | return self._core.send( 132 | ListEndpointDataResp, 133 | Api.GET_ENDPOINT_DATA, 134 | headers=utils.tmv1_query(op, fields), 135 | ) 136 | 137 | def list_activity( 138 | self, 139 | start_time: Optional[str] = None, 140 | end_time: Optional[str] = None, 141 | select: Optional[List[str]] = None, 142 | top: int = 500, 143 | op: QueryOp = QueryOp.AND, 144 | **fields: str, 145 | ) -> Result[ListEndpointActivityResp]: 146 | """Retrieves endpoint activity data in a paginated list 147 | filtered by provided values. 148 | 149 | :param start_time: Date that indicates the start of the data retrieval 150 | time range (yyyy-MM-ddThh:mm:ssZ). 151 | Defaults to 24 hours before the request is made. 152 | :type start_time: Optional[str] 153 | :param end_time: Date that indicates the end of the data retrieval 154 | time range (yyyy-MM-ddThh:mm:ssZ). 155 | Defaults to the time the request is made. 156 | :type end_time: Optional[str] 157 | :param select: List of fields to include in the search results, 158 | if no fields are specified, the query returns all supported fields. 159 | :type select: Optional[List[str]] 160 | :param top: Number of records fetched per page. 161 | :type top: int 162 | :param op: Operator to apply between fields (ie: dpt=... OR src=...) 163 | :type op: QueryOp 164 | :param fields: Field/value used to filter result (ie: dpt="443") 165 | check Vision One API documentation for full list of supported fields. 166 | :type fields: Dict[str, str] 167 | :rtype: Result[GetEndpointActivityDataResp]: 168 | """ 169 | return self._core.send( 170 | ListEndpointActivityResp, 171 | Api.GET_ENDPOINT_ACTIVITY_DATA, 172 | params=utils.build_activity_request( 173 | start_time, 174 | end_time, 175 | select, 176 | top, 177 | SearchMode.DEFAULT, 178 | ), 179 | headers=utils.tmv1_activity_query(op, fields), 180 | ) 181 | 182 | def consume_data( 183 | self, 184 | consumer: Callable[[Ept], None], 185 | op: QueryOp, 186 | **fields: str, 187 | ) -> Result[ConsumeLinkableResp]: 188 | """Retrieves and consume endpoints. 189 | 190 | :param consumer: Function which will consume every record in result. 191 | :type consumer: Callable[[Endpoint], None] 192 | :param op: Query operator to apply. 193 | :type op: QueryOp 194 | :param fields: Field/value used to filter result (i.e:ip="1.1.1.1") 195 | check Vision One API documentation for full list of supported fields. 196 | :type fields: Dict[str, str] 197 | :rtype: Result[ConsumeLinkableResp]: 198 | """ 199 | return self._core.send_linkable( 200 | ListEndpointDataResp, 201 | Api.GET_ENDPOINT_DATA, 202 | consumer, 203 | headers=utils.tmv1_query(op, fields), 204 | ) 205 | 206 | def consume_activity( 207 | self, 208 | consumer: Callable[[EndpointActivity], None], 209 | start_time: Optional[str] = None, 210 | end_time: Optional[str] = None, 211 | select: Optional[List[str]] = None, 212 | top: int = 500, 213 | op: QueryOp = QueryOp.AND, 214 | **fields: str, 215 | ) -> Result[ConsumeLinkableResp]: 216 | """Retrieves and consume endpoint activity data in a paginated list 217 | filtered by provided values. 218 | 219 | :param consumer: Function which will consume every record in result. 220 | :type consumer: Callable[[EndpointActivity], None] 221 | :param start_time: Date that indicates the start of the data retrieval 222 | time range (yyyy-MM-ddThh:mm:ssZ). 223 | Defaults to 24 hours before the request is made. 224 | :type start_time: Optional[str] 225 | :param end_time: Date that indicates the end of the data retrieval 226 | time range (yyyy-MM-ddThh:mm:ssZ). 227 | Defaults to the time the request is made. 228 | :type end_time: Optional[str] 229 | :param select: List of fields to include in the search results, 230 | if no fields are specified, the query returns all supported fields. 231 | :type select: Optional[List[str]] 232 | :param top: Number of records fetched per page. 233 | :type top: int 234 | :param op: Operator to apply between fields (ie: dpt=... OR src=...) 235 | :type op: QueryOp 236 | :param fields: Field/value used to filter result (ie: dpt="443") 237 | check Vision One API documentation for full list of supported fields. 238 | :type fields: Dict[str, str] 239 | :rtype: Result[ConsumeLinkableResp]: 240 | """ 241 | return self._core.send_linkable( 242 | ListEndpointActivityResp, 243 | Api.GET_ENDPOINT_ACTIVITY_DATA, 244 | consumer, 245 | params=utils.build_activity_request( 246 | start_time, 247 | end_time, 248 | select, 249 | top, 250 | SearchMode.DEFAULT, 251 | ), 252 | headers=utils.tmv1_activity_query(op, fields), 253 | ) 254 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2022 Trend Micro 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. -------------------------------------------------------------------------------- /src/pytmv1/api/oat.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, List, Optional 2 | 3 | from .. import utils 4 | from ..core import Core 5 | from ..model.common import OatEvent, OatPackage 6 | from ..model.enum import Api, HttpMethod, OatRiskLevel, QueryOp 7 | from ..model.response import ( 8 | ConsumeLinkableResp, 9 | GetOatPackageResp, 10 | GetPipelineResp, 11 | ListOatPackagesResp, 12 | ListOatPipelinesResp, 13 | ListOatsResp, 14 | MultiResp, 15 | NoContentResp, 16 | OatPipelineResp, 17 | ) 18 | from ..result import MultiResult, Result 19 | 20 | 21 | class Oat: 22 | _core: Core 23 | 24 | def __init__(self, core: Core): 25 | self._core = core 26 | 27 | def list( 28 | self, 29 | detected_start_date_time: Optional[str] = None, 30 | detected_end_date_time: Optional[str] = None, 31 | ingested_start_date_time: Optional[str] = None, 32 | ingested_end_date_time: Optional[str] = None, 33 | top: int = 50, 34 | op: QueryOp = QueryOp.AND, 35 | **fields: str, 36 | ) -> Result[ListOatsResp]: 37 | """Retrieves Observed Attack Techniques events in a paginated list. 38 | 39 | :param detected_start_date_time: Date that indicates the start of 40 | the event detection data retrieval time range (yyyy-MM-ddThh:mm:ssZ). 41 | Defaults to 24 hours before the request is made. 42 | :type detected_start_date_time: Optional[str] 43 | :param detected_end_date_time: Date that indicates the end of 44 | the event detection data retrieval time range (yyyy-MM-ddThh:mm:ssZ). 45 | Defaults to the time the request is made. 46 | :type detected_end_date_time: Optional[str] 47 | :param ingested_start_date_time: Date that indicates the start of 48 | the data ingestion time range (yyyy-MM-ddThh:mm:ssZ). 49 | :type ingested_start_date_time: Optional[str] 50 | :param ingested_end_date_time: Date that indicates the end of 51 | the data ingestion time range (yyyy-MM-ddThh:mm:ssZ). 52 | :type ingested_end_date_time: Optional[str] 53 | :param top: Number of records displayed on a page. 54 | :type top: int 55 | :param op: Operator to apply between fields (ie: ... OR ...). 56 | :type op: QueryOp 57 | :param fields: Field/value used to filter result (i.e:uuid="123"), 58 | check Vision One API documentation for full list of supported fields. 59 | :type fields: Dict[str, str] 60 | :rtype: Result[ListOatsResp] 61 | """ 62 | return self._core.send( 63 | ListOatsResp, 64 | Api.GET_OAT_LIST, 65 | params=utils.filter_none( 66 | { 67 | "detectedStartDateTime": detected_start_date_time, 68 | "detectedEndDateTime": detected_end_date_time, 69 | "ingestedStartDateTime": ingested_start_date_time, 70 | "ingestedEndDateTime": ingested_end_date_time, 71 | "top": top, 72 | } 73 | ), 74 | headers=utils.tmv1_filter(op, fields), 75 | ) 76 | 77 | def consume( 78 | self, 79 | consumer: Callable[[OatEvent], None], 80 | detected_start_date_time: Optional[str] = None, 81 | detected_end_date_time: Optional[str] = None, 82 | ingested_start_date_time: Optional[str] = None, 83 | ingested_end_date_time: Optional[str] = None, 84 | top: int = 50, 85 | op: QueryOp = QueryOp.AND, 86 | **fields: str, 87 | ) -> Result[ConsumeLinkableResp]: 88 | """Retrieves and consume OAT events. 89 | 90 | :param consumer: Function which will consume every record in result. 91 | :type consumer: Callable[[Oat], None] 92 | :param detected_start_date_time: Date that indicates the start of 93 | the event detection data retrieval time range (yyyy-MM-ddThh:mm:ssZ). 94 | Defaults to 24 hours before the request is made. 95 | :type detected_start_date_time: Optional[str] 96 | :param detected_end_date_time: Date that indicates the end of 97 | the event detection data retrieval time range (yyyy-MM-ddThh:mm:ssZ). 98 | Defaults to the time the request is made. 99 | :type detected_end_date_time: Optional[str] 100 | :param ingested_start_date_time: Date that indicates the start of 101 | the data ingestion time range (yyyy-MM-ddThh:mm:ssZ). 102 | :type ingested_start_date_time: Optional[str] 103 | :param ingested_end_date_time: Date that indicates the end of 104 | the data ingestion time range (yyyy-MM-ddThh:mm:ssZ). 105 | :type ingested_end_date_time: Optional[str] 106 | :param top: Number of records displayed on a page. 107 | :type top: int 108 | :param op: Operator to apply between fields (ie: ... OR ...). 109 | :type op: QueryOp 110 | :param fields: Field/value used to filter result (i.e:uuid="123"), 111 | check Vision One API documentation for full list of supported fields. 112 | :type fields: Dict[str, str] 113 | :rtype: Result[ConsumeLinkableResp] 114 | """ 115 | return self._core.send_linkable( 116 | ListOatsResp, 117 | Api.GET_OAT_LIST, 118 | consumer, 119 | params=utils.filter_none( 120 | { 121 | "detectedStartDateTime": detected_start_date_time, 122 | "detectedEndDateTime": detected_end_date_time, 123 | "ingestedStartDateTime": ingested_start_date_time, 124 | "ingestedEndDateTime": ingested_end_date_time, 125 | "top": top, 126 | } 127 | ), 128 | headers=utils.tmv1_filter(op, fields), 129 | ) 130 | 131 | def create_pipeline( 132 | self, 133 | has_detail: bool, 134 | risk_levels: List[OatRiskLevel], 135 | description: Optional[str] = None, 136 | ) -> Result[OatPipelineResp]: 137 | """Registers a customer 138 | to the Observed Attack Techniques data pipeline. 139 | 140 | :param has_detail: Retrieve detailed logs from the OAT data pipeline. 141 | :type has_detail: bool 142 | :param risk_levels: Risk levels to include in the results, 143 | requests must include at least one risk level. 144 | :type risk_levels: List[OatRiskLevel] 145 | :param description: Notes or comments about the pipeline. 146 | :type description: Optional[str] 147 | :return: Result[PipelineResp] 148 | """ 149 | return self._core.send( 150 | OatPipelineResp, 151 | Api.CREATE_OAT_PIPELINE, 152 | HttpMethod.POST, 153 | json=utils.filter_none( 154 | { 155 | "hasDetail": has_detail, 156 | "riskLevels": risk_levels, 157 | "description": description, 158 | } 159 | ), 160 | ) 161 | 162 | def get_pipeline(self, pipeline_id: str) -> Result[GetPipelineResp]: 163 | """Displays the settings of the specified data pipeline. 164 | 165 | :param pipeline_id: Pipeline ID. 166 | :type pipeline_id: str 167 | :return: Result[GetPipelineResp] 168 | """ 169 | return self._core.send( 170 | GetPipelineResp, Api.GET_OAT_PIPELINE.value.format(pipeline_id) 171 | ) 172 | 173 | def update_pipeline( 174 | self, 175 | pipeline_id: str, 176 | etag: str, 177 | has_detail: Optional[bool] = None, 178 | risk_levels: Optional[List[OatRiskLevel]] = None, 179 | description: Optional[str] = None, 180 | ) -> Result[NoContentResp]: 181 | """Modifies the settings of the specified data pipeline. 182 | 183 | :param pipeline_id: Pipeline ID. 184 | :type pipeline_id: str 185 | :param etag: ETag of the resource you want to update. 186 | :type etag: str 187 | :param has_detail: Retrieve detailed logs from the OAT data pipeline. 188 | :type has_detail: Optional[bool] 189 | :param risk_levels: Risk levels to include in the results, 190 | requests must include at least one risk level. 191 | :type risk_levels: Optional[List[OatRiskLevel]] 192 | :param description: Notes or comments about the pipeline. 193 | :type description: Optional[str] 194 | :return: Result[NoContentResp] 195 | """ 196 | return self._core.send( 197 | NoContentResp, 198 | Api.UPDATE_OAT_PIPELINE.value.format(pipeline_id), 199 | HttpMethod.PATCH, 200 | json=utils.filter_none( 201 | { 202 | "hasDetail": has_detail, 203 | "riskLevels": risk_levels, 204 | "description": description, 205 | } 206 | ), 207 | headers={ 208 | "If-Match": (etag[1:-1] if etag.startswith('"') else etag) 209 | }, 210 | ) 211 | 212 | def delete_pipelines(self, *pipeline_ids: str) -> MultiResult[MultiResp]: 213 | """Unregisters a customer 214 | from the Observed Attack Techniques data pipeline(s). 215 | 216 | :param pipeline_ids: Pipeline IDs. 217 | :type pipeline_ids: List[str] 218 | :return: MultiResult[MultiResp] 219 | """ 220 | return self._core.send_multi( 221 | MultiResp, 222 | Api.DELETE_OAT_PIPELINE, 223 | json=[{"id": pipeline_id} for pipeline_id in pipeline_ids], 224 | ) 225 | 226 | def list_pipelines(self) -> Result[ListOatPipelinesResp]: 227 | """Displays all data pipelines that have registered users. 228 | 229 | :return: Result[ListOatPipelineResp] 230 | """ 231 | return self._core.send(ListOatPipelinesResp, Api.LIST_OAT_PIPELINE) 232 | 233 | def get_package( 234 | self, pipeline_id: str, package_id: str 235 | ) -> Result[GetOatPackageResp]: 236 | """Retrieves the specified Observed Attack Techniques package. 237 | 238 | :param pipeline_id: Pipeline ID. 239 | :type pipeline_id: str 240 | :param package_id: Package ID. 241 | :type package_id: str 242 | :return: Result[GetOatPackageResp] 243 | """ 244 | return self._core.send( 245 | GetOatPackageResp, 246 | Api.DOWNLOAD_OAT_PACKAGE.value.format(pipeline_id, package_id), 247 | ) 248 | 249 | def list_packages( 250 | self, 251 | pipeline_id: str, 252 | start_date_time: Optional[str] = None, 253 | end_date_time: Optional[str] = None, 254 | top: int = 500, 255 | ) -> Result[ListOatPackagesResp]: 256 | """Displays all the available packages from a data pipeline 257 | in a paginated list. 258 | 259 | :param pipeline_id: Pipeline ID. 260 | :type pipeline_id: str 261 | :param start_date_time: Date that indicates the start of 262 | the data retrieval time range. (yyyy-MM-ddThh:mm:ssZ). 263 | :type start_date_time: Optional[str] 264 | :param end_date_time: Date that indicates the end of 265 | the data retrieval time range. (yyyy-MM-ddThh:mm:ssZ). 266 | :type end_date_time: Optional[str] 267 | :param top: Number of records displayed on a page. 268 | :type top: int 269 | :return: Result[ListOatPackagesResp] 270 | """ 271 | return self._core.send( 272 | ListOatPackagesResp, 273 | Api.LIST_OAT_PACKAGE.value.format(pipeline_id), 274 | params=utils.filter_none( 275 | { 276 | "startDateTime": start_date_time, 277 | "endDateTime": end_date_time, 278 | "top": top, 279 | } 280 | ), 281 | ) 282 | 283 | def consume_packages( 284 | self, 285 | pipeline_id: str, 286 | consumer: Callable[[OatPackage], None], 287 | start_date_time: Optional[str] = None, 288 | end_date_time: Optional[str] = None, 289 | top: int = 500, 290 | ) -> Result[ConsumeLinkableResp]: 291 | """Displays and consume all the available packages from a data pipeline 292 | in a paginated list. 293 | 294 | :param pipeline_id: Pipeline ID. 295 | :type pipeline_id: str 296 | :param consumer: Function which will consume every record in result. 297 | :type consumer: Callable[[OatPackage], None] 298 | :param start_date_time: Date that indicates the start of 299 | the data retrieval time range. (yyyy-MM-ddThh:mm:ssZ). 300 | :type start_date_time: Optional[str] 301 | :param end_date_time: Date that indicates the end of 302 | the data retrieval time range. (yyyy-MM-ddThh:mm:ssZ). 303 | :type end_date_time: Optional[str] 304 | :param top: Number of records displayed on a page. 305 | :type top: int 306 | :return: Result[ConsumeLinkableResp] 307 | """ 308 | return self._core.send_linkable( 309 | ListOatPackagesResp, 310 | Api.LIST_OAT_PACKAGE.value.format(pipeline_id), 311 | consumer, 312 | params=utils.filter_none( 313 | { 314 | "startDateTime": start_date_time, 315 | "endDateTime": end_date_time, 316 | "top": top, 317 | } 318 | ), 319 | ) 320 | -------------------------------------------------------------------------------- /src/pytmv1/core.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import re 4 | import time 5 | from logging import Logger 6 | from typing import Any, Callable, Dict, List, Optional, Type, Union 7 | from urllib.parse import SplitResult, urlsplit 8 | 9 | from bs4 import BeautifulSoup 10 | from pydantic import AnyHttpUrl, TypeAdapter 11 | from requests import PreparedRequest, Request, Response 12 | 13 | from . import utils 14 | from .__about__ import __version__ 15 | from .adapter import HTTPAdapter 16 | from .exception import ( 17 | ParseModelError, 18 | ServerHtmlError, 19 | ServerJsonError, 20 | ServerMultiJsonError, 21 | ServerTextError, 22 | ) 23 | from .model.common import Error, MsError, MsStatus 24 | from .model.enum import Api, HttpMethod, Status, TaskAction 25 | from .model.request import EndpointRequest 26 | from .model.response import ( 27 | MR, 28 | BaseLinkableResp, 29 | BaseTaskResp, 30 | BytesResp, 31 | C, 32 | ConsumeLinkableResp, 33 | GetAlertNoteResp, 34 | GetAlertResp, 35 | GetApiKeyResp, 36 | GetOatPackageResp, 37 | GetPipelineResp, 38 | MultiApiKeyResp, 39 | MultiResp, 40 | MultiUrlResp, 41 | NoContentResp, 42 | R, 43 | S, 44 | SandboxSubmissionStatusResp, 45 | T, 46 | TextResp, 47 | ) 48 | from .result import multi_result, result 49 | 50 | USERAGENT_SUFFIX: str = "PyTMV1" 51 | API_VERSION: str = "v3.0" 52 | 53 | log: Logger = logging.getLogger(__name__) 54 | 55 | 56 | class Core: 57 | def __init__( 58 | self, 59 | appname: str, 60 | token: str, 61 | url: str, 62 | pool_connections: int, 63 | pool_maxsize: int, 64 | connect_timeout: int, 65 | read_timeout: int, 66 | ): 67 | self._adapter = HTTPAdapter(pool_connections, pool_maxsize, 0, True) 68 | self._c_timeout = connect_timeout 69 | self._r_timeout = read_timeout 70 | self._appname = appname 71 | self._token = token 72 | self._url = str(TypeAdapter(AnyHttpUrl).validate_python(_format(url))) 73 | self._headers: Dict[str, str] = { 74 | "Authorization": f"Bearer {self._token}", 75 | "User-Agent": f"{self._appname}-{USERAGENT_SUFFIX}/{__version__}", 76 | } 77 | self._proxies: Optional[Dict[str, str]] = _proxy( 78 | os.getenv("HTTP_PROXY"), os.getenv("HTTPS_PROXY") 79 | ) 80 | 81 | @result 82 | def send( 83 | self, 84 | class_: Type[R], 85 | api: str, 86 | method: HttpMethod = HttpMethod.GET, 87 | **kwargs: Any, 88 | ) -> R: 89 | return self._process( 90 | class_, 91 | api, 92 | method, 93 | **kwargs, 94 | ) 95 | 96 | @multi_result 97 | def send_endpoint( 98 | self, 99 | api: Api, 100 | *tasks: EndpointRequest, 101 | ) -> MultiResp: 102 | return self._process( 103 | MultiResp, 104 | api, 105 | HttpMethod.POST, 106 | json=[ 107 | task.model_dump(by_alias=True, exclude_none=True) 108 | for task in tasks 109 | ], 110 | ) 111 | 112 | @result 113 | def send_linkable( 114 | self, 115 | class_: Type[BaseLinkableResp[C]], 116 | api: str, 117 | consumer: Callable[[C], None], 118 | **kwargs: Any, 119 | ) -> ConsumeLinkableResp: 120 | return ConsumeLinkableResp( 121 | total_consumed=self._consume_linkable( 122 | lambda: self._process( 123 | class_, 124 | api, 125 | **kwargs, 126 | ), 127 | consumer, 128 | kwargs.get("headers", {}), 129 | ) 130 | ) 131 | 132 | @multi_result 133 | def send_multi( 134 | self, 135 | class_: Type[MR], 136 | api: str, 137 | **kwargs: Any, 138 | ) -> MR: 139 | return self._process( 140 | class_, 141 | api, 142 | HttpMethod.POST, 143 | **kwargs, 144 | ) 145 | 146 | @result 147 | def send_sandbox_result( 148 | self, 149 | class_: Type[R], 150 | api: Api, 151 | submit_id: str, 152 | poll: bool, 153 | poll_time_sec: float, 154 | ) -> R: 155 | if poll: 156 | _poll_status( 157 | lambda: self._process( 158 | SandboxSubmissionStatusResp, 159 | Api.GET_SANDBOX_SUBMISSION_STATUS.value.format(submit_id), 160 | ), 161 | poll_time_sec, 162 | ) 163 | return self._process(class_, api.value.format(submit_id)) 164 | 165 | @result 166 | def send_task_result( 167 | self, class_: Type[T], task_id: str, poll: bool, poll_time_sec: float 168 | ) -> T: 169 | status_call: Callable[[], T] = lambda: self._process( 170 | class_, 171 | Api.GET_TASK_RESULT.value.format(task_id), 172 | ) 173 | if poll: 174 | return _poll_status( 175 | status_call, 176 | poll_time_sec, 177 | ) 178 | return status_call() 179 | 180 | def _consume_linkable( 181 | self, 182 | api_call: Callable[[], BaseLinkableResp[C]], 183 | consumer: Callable[[C], None], 184 | headers: Dict[str, str], 185 | count: int = 0, 186 | ) -> int: 187 | total_count: int = count 188 | response: BaseLinkableResp[C] = api_call() 189 | for item in response.items: 190 | consumer(item) 191 | total_count += 1 192 | if response.next_link: 193 | sr: SplitResult = urlsplit(response.next_link) 194 | log.debug("Found nextLink") 195 | return self._consume_linkable( 196 | lambda: self._process( 197 | type(response), 198 | f"{sr.path[5:]}?{sr.query}", 199 | headers=headers, 200 | ), 201 | consumer, 202 | headers, 203 | total_count, 204 | ) 205 | log.debug( 206 | "Records consumed: [Total=%s, Type=%s]", 207 | total_count, 208 | type( 209 | response.items[0] if len(response.items) > 0 else response 210 | ).__name__, 211 | ) 212 | return total_count 213 | 214 | def _process( 215 | self, 216 | class_: Type[R], 217 | uri: str, 218 | method: HttpMethod = HttpMethod.GET, 219 | **kwargs: Any, 220 | ) -> R: 221 | log.debug( 222 | "Processing request [Method=%s, Class=%s, URI=%s, Options=%s]", 223 | method.value, 224 | class_.__name__, 225 | uri, 226 | kwargs, 227 | ) 228 | raw_response: Response = self._send_internal( 229 | self._prepare(uri, method, **kwargs) 230 | ) 231 | _validate(raw_response) 232 | return _parse_data(raw_response, class_) 233 | 234 | def _prepare( 235 | self, uri: str, method: HttpMethod, **kwargs: Any 236 | ) -> PreparedRequest: 237 | return Request( 238 | method.value, 239 | self._url + uri, 240 | headers={**self._headers, **kwargs.pop("headers", {})}, 241 | **kwargs, 242 | ).prepare() 243 | 244 | def _send_internal(self, request: PreparedRequest) -> Response: 245 | log.info( 246 | "Sending request [Method=%s, URL=%s, Headers=%s, Body=%s]", 247 | request.method, 248 | request.url, 249 | re.sub("Bearer \\S+", "*****", str(request.headers)), 250 | _hide_binary(request), 251 | ) 252 | response: Response = self._adapter.send( 253 | request, 254 | timeout=(self._c_timeout, self._r_timeout), 255 | proxies=self._proxies, 256 | ) 257 | log.info( 258 | "Received response [Status=%s, Headers=%s, Body=%s]", 259 | response.status_code, 260 | response.headers, 261 | _hide_binary(response), 262 | ) 263 | return response 264 | 265 | 266 | def _proxy( 267 | http: Optional[str], https: Optional[str] 268 | ) -> Optional[Dict[str, str]]: 269 | proxies = {} 270 | if http: 271 | proxies["http"] = http 272 | if https: 273 | proxies["https"] = https 274 | log.debug("Proxy settings: %s", proxies) 275 | return proxies if len(proxies.items()) > 0 else None 276 | 277 | 278 | def _format(url: str) -> str: 279 | return (url if url.endswith("/") else url + "/") + API_VERSION 280 | 281 | 282 | def _hide_binary(http_object: Union[PreparedRequest, Response]) -> str: 283 | content_type = http_object.headers.get("Content-Type", "") 284 | if "json" not in content_type and "application" in content_type: 285 | return "***binary content***" 286 | if "multipart/form-data" in content_type: 287 | return "***file content***" 288 | if isinstance(http_object, Response): 289 | return http_object.text 290 | return str(http_object.body) 291 | 292 | 293 | def _is_http_success(status_codes: List[int]) -> bool: 294 | return len(list(filter(lambda s: not 200 <= s < 399, status_codes))) == 0 295 | 296 | 297 | def _parse_data(raw_response: Response, class_: Type[R]) -> R: 298 | content_type = raw_response.headers.get("Content-Type", "") 299 | if raw_response.status_code == 201: 300 | return class_(**raw_response.headers) 301 | if raw_response.status_code == 204 and class_ == NoContentResp: 302 | return class_() 303 | if "text" in content_type and class_ == TextResp: 304 | return class_.model_construct(text=raw_response.text) 305 | if "json" in content_type or "text" in content_type: 306 | log.debug("Parsing json response [Class=%s]", class_.__name__) 307 | if class_ in [MultiResp, MultiUrlResp, MultiApiKeyResp]: 308 | return class_(items=raw_response.json()) 309 | if class_ == GetOatPackageResp: 310 | return class_(package=raw_response.json()) 311 | if class_ in [ 312 | GetAlertResp, 313 | GetApiKeyResp, 314 | GetAlertNoteResp, 315 | GetPipelineResp, 316 | ]: 317 | return class_( 318 | data=raw_response.json(), 319 | etag=raw_response.headers.get("ETag", ""), 320 | ) 321 | if class_ == BaseTaskResp: 322 | resp_class: Type[BaseTaskResp] = utils.task_action_resp_class( 323 | TaskAction(raw_response.json()["action"]) 324 | ) 325 | class_ = resp_class if issubclass(resp_class, class_) else class_ 326 | return class_(**raw_response.json()) 327 | if ( 328 | "application" in content_type 329 | or "gzip" == raw_response.headers.get("Content-Encoding") 330 | ) and class_ == BytesResp: 331 | log.debug("Parsing binary response") 332 | return class_.model_construct(content=raw_response.content) 333 | raise ParseModelError(class_.__name__, raw_response) 334 | 335 | 336 | def _parse_html(html: str) -> str: 337 | log.info("Parsing html response [Html=%s]", html) 338 | soup = BeautifulSoup(html, "html.parser") 339 | return "\n".join( 340 | line.strip() for line in soup.text.split("\n") if line.strip() 341 | ) 342 | 343 | 344 | def _poll_status( 345 | status_call: Callable[[], S], 346 | poll_time_sec: float, 347 | ) -> S: 348 | start_time: float = time.time() 349 | elapsed_time: float = 0 350 | response: S = status_call() 351 | while elapsed_time < poll_time_sec: 352 | if response.status in [Status.QUEUED, Status.RUNNING]: 353 | time.sleep(2) 354 | response = status_call() 355 | elapsed_time = time.time() - start_time 356 | else: 357 | break 358 | return response 359 | 360 | 361 | def _validate(raw_response: Response) -> None: 362 | log.debug("Validating response [%s]", raw_response) 363 | content_type: str = raw_response.headers.get("Content-Type", "") 364 | if not _is_http_success([raw_response.status_code]): 365 | if "application/json" in content_type: 366 | error: Dict[str, Any] = raw_response.json().get("error") 367 | error["status"] = raw_response.status_code 368 | raise ServerJsonError( 369 | Error(**error), 370 | ) 371 | if "text/html" in content_type: 372 | raise ServerHtmlError( 373 | raw_response.status_code, _parse_html(raw_response.text) 374 | ) 375 | raise ServerTextError(raw_response.status_code, raw_response.text) 376 | if raw_response.status_code == 207: 377 | if not _is_http_success( 378 | MsStatus( 379 | root=[int(d.get("status", 500)) for d in raw_response.json()] 380 | ).values() 381 | ): 382 | raise ServerMultiJsonError( 383 | [ 384 | MsError.model_validate(error) 385 | for error in raw_response.json() 386 | ] 387 | ) 388 | --------------------------------------------------------------------------------