Python SDK для продукта Threat Analyzer
from .__version__ import (  # noqa: F401
    __author__,
    __author_email__,
    __description__,
    __license__,
    __title__,
    __version__,
)

cybsi/__init__.py
cyberstudio/cybsi-sdk
__version__ = "2.11.0"
__title__ = "cybsi-python-sdk"
__description__ = "Cybersecurity threat intelligence development kit"
__license__ = "Apache License 2.0"
__author__ = "Cybsi SDK Developers"
__author_email__ = ""

cybsi/__version__.py
cyberstudio/cybsi-sdk
"""A set of classes and functions implementing low-level Cybsi API client.

The client allows calling different sections of API.
For example, there's a separate section for observations,
and a separate section for reputation lists.
"""
from .api import Null, Nullable, NullType, Tag
from .view import RefView

# APIKeyAuth is exposed from package api
# only to improve initial SDK experience for newcomers
# (less typing of imports)
from .auth import APIKeyAuth
from .client import CybsiClient, CybsiAsyncClient, VersionView, Version, Config
from .client_config import Timeouts, Limits

from .enum import CybsiAPIEnum

cybsi/api/__init__.py
cyberstudio/cybsi-sdk
from typing import Optional, TypeVar, Union, cast


class Tag:
    """Identifier of a specific version of a resource.

    Prevents mid-air-collisions. Required for edit() functions on
    Threat Analyzer API resources.
    """

    pass


# This is a hack to prevent Sphinx autodoc-typehint type inlining.
# If we simply alias Tag = str, it inlines str everywhere,
# and functions lose descriptive parameter and return value types.
# Additionally, this hack prevents SDK users from creating Tag instances.
# Users have to call view()-like methods.
Tag.__supertype__ = str  # type: ignore


class NullType:
    """Depicts explicit `null` values in API.

    The only instance of this type is :data:`Null`.
    """

    pass


Null = NullType()
"""The only possible value of :class:`NullType`"""

T = TypeVar("T")
"""Non-null type."""

Nullable = Union[T, None, NullType]
"""Missing JSON field and ``null`` JSON field sometimes have a difference in Cybsi API.
Typically, ``register()`` or ``edit()``-like methods omit unset keyword parameters
when they send request to API.

Nullable is used to indicate optional keyword arguments
which are be passed as explicit ``null`` to API.
"""


# SDK internal function. Placed here for locality with Nullable.
def _unwrap_nullable(value: Nullable[T]) -> Optional[T]:
    return None if value is Null else cast(T, value)

cybsi/api/api.py
cyberstudio/cybsi-sdk
"""Use this section of API to operate artifacts.
"""
from .api import (
    ArtifactsAPI,
    ArtifactsAsyncAPI,
    ArtifactView,
    ArtifactCommonView,
    ArtifactContent,
    ArtifactAsyncContent,
    ArtifactContentView,
    ArtifactRegistrationView,
    ArtifactTypeRecognizedView,
)
from .enums import ArtifactTypes, ArtifactContentDownloadCompressionTypes

cybsi/api/artifact/__init__.py
cyberstudio/cybsi-sdk
import cgi
import uuid
from contextlib import asynccontextmanager, contextmanager
from datetime import datetime
from io import BytesIO
from typing import (
    Any,
    AsyncContextManager,
    AsyncIterator,
    ContextManager,
    Dict,
    Iterable,
    Iterator,
    List,
    Optional,
    cast,
)

from .. import RefView
from ..error import CybsiError
from ..internal import BaseAPI, BaseAsyncAPI, JsonObjectView, parse_rfc3339_timestamp
from ..internal.multipart import AsyncStreamWrapper
from ..observable import EntityView, ShareLevels
from ..pagination import AsyncPage, Cursor, Page
from ..view import _TaggedRefView
from .enums import ArtifactContentDownloadCompressionTypes, ArtifactTypes

_ARTIFACTS_PATH = "/enrichment/artifacts"
_ARTIFACTS_TYPE_PATH = "/enrichment/artifact-type"


class ArtifactsAPI(BaseAPI):
    """Artifact API."""

    def view(self, artifact_uuid: uuid.UUID) -> "ArtifactView":
        """Get an artifact view.

        Note:
            Calls `GET /enrichment/artifacts/{artifact_uuid}`.
        Args:
            artifact_uuid: Artifact uuid.
        Returns:
            View of the artifact.
        Raises:
            :class:`~cybsi.api.error.NotFoundError`: Artifact not found.
        """
        path = f"{_ARTIFACTS_PATH}/{artifact_uuid}"
        r = self._connector.do_get(path)
        return ArtifactView(r)

    def view_registrations(
        self, artifact_uuid: uuid.UUID
    ) -> List["ArtifactRegistrationView"]:
        """Get artifact registrations.

        Note:
            Calls `GET /enrichment/artifacts/{artifact_uuid}/registrations`.
        Args:
            artifact_uuid: Artifact uuid.
        Returns:
            List of artifact registrations.
        Raises:
            :class:`~cybsi.api.error.NotFoundError`: Artifact not found.
        """
        path = f"{_ARTIFACTS_PATH}/{artifact_uuid}/registrations"
        r = self._connector.do_get(path)
        return [ArtifactRegistrationView(v) for v in r.json()]

    def recognize_type(self, data: Any) -> "ArtifactTypeRecognizedView":
        """Recognize artifact type by its first bytes.

        The function allows to send the entire artifact, but it's recommended
        to send 10KB or less to save time and bandwidth.

        Note:
            Calls `PUT /enrichment/artifact-type`.
        Args:
            data: File-like object. If you have bytes, wrap them in BytesIO.
        Returns:
            Recognized artifact type.
        See Also:
            The function is similar to :meth:`upload`.
            The example for :meth:`upload` is applicable here too.
        """

        form = {"file": ("filename", data)}

        r = self._connector.do_put(path=_ARTIFACTS_TYPE_PATH, files=form)
        return ArtifactTypeRecognizedView(r.json())

    def upload(
        self,
        filename: str,
        data: Any,
        artifact_type: Optional[ArtifactTypes] = None,
        share_level: ShareLevels = ShareLevels.White,
        timeout: int = 300,
    ) -> RefView:
        """Upload an artifact.

        Note:
            Calls `POST /enrichment/artifacts`.
        Args:
            filename: Name of the artifact.
            data: File-like object. If you have bytes, wrap them in BytesIO.
            artifact_type: Artifact type.
            share_level: Artifact share level.
            timeout: Connection timeout in sec.
        Returns:
            Reference to artifact in API.
        Raises:
            :class:`~cybsi.api.error.SemanticError`: Semantic error. Possible code is
             :attr:`~cybsi.api.error.SemanticErrorCodes.InvalidShareLevel`.
        See Also:
            See :ref:`upload-download-artifact-example`
            for a complete example of this function usage.

        """

        form: Dict[str, Any] = {}

        if artifact_type is not None:
            form["type"] = artifact_type.value.encode()

        form["shareLevel"] = share_level.value.encode()
        form["file"] = (filename, data)

        r = self._connector.do_post(
            path=_ARTIFACTS_PATH,
            files=form,
            timeout=timeout,
        )
        return RefView(r.json())

    def get_content(
        self,
        artifact_uuid: uuid.UUID,
        *,
        archive: Optional[ArtifactContentDownloadCompressionTypes] = None,
        archive_password: Optional[str] = None,
    ) -> ContextManager["ArtifactContent"]:
        """Get artifact content.

        Note:
            Calls `GET /enrichment/artifacts/{artifact_uuid}/content`.
        Args:
            artifact_uuid: Artifact uuid.
            archive: Compress artifact content to archive of chosen type before sending.
            archive_password: Set archive password,
                if compression was chosen using ``archive`` argument.
        Returns:
            Contextmanager of binary file content.
        Raises:
            :class:`~cybsi.api.error.NotFoundError`: Artifact not found.
        Examples:
            >>> with client.artifacts.get_content(artifact_uuid) as content:
            >>>     with open("/tmp/artifact", "wb") as f:
            >>>         shutil.copyfileobj(content.stream, f, length=1024 * 1024)
        See Also:
            See :ref:`upload-download-artifact-example`
            for a complete example of this function usage.
        """
        path = f"{_ARTIFACTS_PATH}/{artifact_uuid}/content"

        params = {}
        if archive:
            params["archive"] = archive.value
        if archive_password:
            params["password"] = archive_password

        @contextmanager
        def load_content():
            r = self._connector.do_get(path=path, params=params, stream=True)
            try:
                filename = _parse_content_filename(r)
                yield ArtifactContent(filename, r)
            finally:
                r.close()

        return load_content()

    def filter(
        self,
        *,
        artifact_type: Optional[ArtifactTypes] = None,
        data_source_uuids: Optional[Iterable[uuid.UUID]] = None,
        file_uuid: Optional[uuid.UUID] = None,
        artifact_hash: Optional[str] = None,
        cursor: Optional[Cursor] = None,
        limit: Optional[int] = None,
    ) -> Page["ArtifactCommonView"]:
        """Filter artifacts using provided parameters.

        Note:
            Calls `GET /enrichment/artifacts`
        Args:
            artifact_type: Artifact type.
            data_source_uuids: Data sources of the artifact.
            file_uuid: Artifact hash must be the same
                as one of the hashes of provided File entity.
            artifact_hash: Artifact hash.
                Hash type (md5, sha1, sha256) is determined using its length.
            cursor: Page cursor.
            limit: Page limit.
        Raises:
            :class:`~cybsi.api.error.SemanticError`: Query contains logic errors.
        Note:
            Semantic error codes specific for this method:
                * :attr:`~cybsi.api.error.SemanticErrorCodes.DataSourceNotFound`
                * :attr:`~cybsi.api.error.SemanticErrorCodes.FileNotFound`
        Return:
            Page containing artifact descriptions.
        """
        params: Dict[str, Any] = {}
        if artifact_type:
            params["type"] = artifact_type.value
        if data_source_uuids:
            params["dataSourceUUID"] = [str(u) for u in data_source_uuids]
        if file_uuid:
            params["fileUUID"] = str(file_uuid)
        if artifact_hash:
            params["hash"] = artifact_hash
        if cursor:
            params["cursor"] = str(cursor)
        if limit:
            params["limit"] = str(limit)

        resp = self._connector.do_get(_ARTIFACTS_PATH, params=params)
        page = Page(self._connector.do_get, resp, ArtifactCommonView)
        return page


class ArtifactsAsyncAPI(BaseAsyncAPI):
    """Asynchronous artifact API."""

    async def view(self, artifact_uuid: uuid.UUID) -> "ArtifactView":
        """Get an artifact view.

        Note:
            Calls `GET /enrichment/artifacts/{artifact_uuid}`.
        Args:
            artifact_uuid: Artifact uuid.
        Returns:
            View of the artifact.
        Raises:
            :class:`~cybsi.api.error.NotFoundError`: Artifact not found.
        """
        path = f"{_ARTIFACTS_PATH}/{artifact_uuid}"
        r = await self._connector.do_get(path)
        return ArtifactView(r)

    async def view_registrations(
        self, artifact_uuid: uuid.UUID
    ) -> List["ArtifactRegistrationView"]:
        """Get artifact registrations.

        Note:
            Calls `GET /enrichment/artifacts/{artifact_uuid}/registrations`.
        Args:
            artifact_uuid: Artifact uuid.
        Returns:
            List of artifact registrations.
        Raises:
            :class:`~cybsi.api.error.NotFoundError`: Artifact not found.
        """
        path = f"{_ARTIFACTS_PATH}/{artifact_uuid}/registrations"
        r = await self._connector.do_get(path)
        return [ArtifactRegistrationView(v) for v in r.json()]

    async def recognize_type(self, data: Any) -> "ArtifactTypeRecognizedView":
        """Recognize artifact type by its first bytes.

        The function allows to send the entire artifact, but it's recommended
        to send 10KB or less to save time and bandwidth.

        Note:
            Calls `PUT /enrichment/artifact-type`.
        Args:
            data: File-like object. If you have bytes, wrap them in BytesIO.
        Returns:
            Recognized artifact type.
        See Also:
            The function is similar to :meth:`upload`.
            The example for :meth:`upload` is applicable here too.
        """

        form = {"file": ("filename", data)}

        r = await self._connector.do_put(path=_ARTIFACTS_TYPE_PATH, files=form)
        return ArtifactTypeRecognizedView(r.json())

    async def upload(
        self,
        *,
        filename: str,
        data: Any,
        data_size: int = 0,
        artifact_type: Optional[ArtifactTypes] = None,
        share_level: ShareLevels = ShareLevels.White,
        timeout: int = 300,
    ) -> RefView:
        """Upload an artifact.

        Note:
            Calls `POST /enrichment/artifacts`.
        Args:
            filename: Name of the artifact.
            data: File-like or async stream object.
                If you have bytes, wrap them in BytesIO.
            data_size: Total file size.
                Required for StreamReader or AsyncIterator data types.
            artifact_type: Artifact type.
            share_level: Artifact share level.
            timeout: Connection timeout in sec.
        Returns:
            Reference to artifact in API.
        Raises:
            :class:`~cybsi.api.error.SemanticError`: Semantic error. Possible code is
             :attr:`~cybsi.api.error.SemanticErrorCodes.InvalidShareLevel`.
        See Also:
            See :ref:`upload-download-artifact-example`
            for a complete example of this function usage.
            See :ref:`advanced` for asynchronous multipart upload example.
        Warning:
            Upload is synchronous if data_size is not provided.
        """

        form: Dict[str, Any] = {}

        if artifact_type is not None:
            form["type"] = artifact_type.value.encode()
        form["shareLevel"] = share_level.value.encode()
        if data_size > 0:
            form["file"] = (filename, AsyncStreamWrapper(data, data_size))
        else:
            form["file"] = (filename, data)

        r = await self._connector.do_post(
            path=_ARTIFACTS_PATH,
            files=form,
            timeout=timeout,
        )
        return RefView(r.json())

    def get_content(
        self,
        *,
        artifact_uuid: uuid.UUID,
        archive: Optional[ArtifactContentDownloadCompressionTypes] = None,
        archive_password: Optional[str] = None,
    ) -> AsyncContextManager["ArtifactAsyncContent"]:
        """Get artifact content

        Note:
            Calls `GET /enrichment/artifacts/{artifact_uuid}/content`.
        Args:
            artifact_uuid: Artifact uuid.
            archive: Compress artifact content to archive of chosen type before sending.
            archive_password: Set archive password,
                if compression was chosen using ``archive`` argument.
        Returns:
            Contextmanager of binary file content.
        Raises:
            :class:`~cybsi.api.error.NotFoundError`: Artifact not found.
        Examples:
            >>>    async with aiofiles.open("/tmp/artifact", "wb") as f, client.artifacts.get_content(
            >>>        artifact_uuid
            >>>    ) as content:
            >>>        async for chunk in content.iter_chunks(size=8192):
            >>>            await f.write(chunk)
        See Also:
            See :ref:`upload-download-artifact-example`
            for a complete example of this function usage.
        """  # noqa: E501
        path = f"{_ARTIFACTS_PATH}/{artifact_uuid}/content"

        params = {}
        if archive:
            params["archive"] = archive.value
        if archive_password:
            params["password"] = archive_password

        @asynccontextmanager
        async def load_content():
            r = await self._connector.do_get(path=path, params=params, stream=True)
            try:
                filename = _parse_content_filename(r)
                yield ArtifactAsyncContent(filename, r)
            finally:
                await r.aclose()

        return load_content()

    async def filter(
        self,
        *,
        artifact_type: Optional[ArtifactTypes] = None,
        data_source_uuids: Optional[Iterable[uuid.UUID]] = None,
        file_uuid: Optional[uuid.UUID] = None,
        artifact_hash: Optional[str] = None,
        cursor: Optional[Cursor] = None,
        limit: Optional[int] = None,
    ) -> AsyncPage["ArtifactCommonView"]:
        """Filter artifacts using provided parameters.

        Note:
            Calls `GET /enrichment/artifacts`
        Args:
            artifact_type: Artifact type.
            data_source_uuids: Data sources of the artifact.
            file_uuid: Artifact hash must be the same
                as one of the hashes of provided File entity.
            artifact_hash: Artifact hash.
                Hash type (md5, sha1, sha256) is determined using its length.
            cursor: Page cursor.
            limit: Page limit.
        Raises:
            :class:`~cybsi.api.error.SemanticError`: Query contains logic errors.
        Note:
            Semantic error codes specific for this method:
                * :attr:`~cybsi.api.error.SemanticErrorCodes.DataSourceNotFound`
                * :attr:`~cybsi.api.error.SemanticErrorCodes.FileNotFound`
        Return:
            Page containing artifact descriptions.
        """
        params: Dict[str, Any] = {}
        if artifact_type:
            params["type"] = artifact_type.value
        if data_source_uuids:
            params["dataSourceUUID"] = [str(u) for u in data_source_uuids]
        if file_uuid:
            params["fileUUID"] = str(file_uuid)
        if artifact_hash:
            params["hash"] = artifact_hash
        if cursor:
            params["cursor"] = str(cursor)
        if limit:
            params["limit"] = str(limit)

        resp = await self._connector.do_get(_ARTIFACTS_PATH, params=params)
        page = AsyncPage(self._connector.do_get, resp, ArtifactCommonView)
        return page


class ArtifactTypeRecognizedView(JsonObjectView):
    """Artifact type view, as recognized by Cybsi."""

    @property
    def type(self) -> ArtifactTypes:
        """Artifact type."""
        return ArtifactTypes(self._get("type"))

    @property
    def format_description(self) -> str:
        """Format description (magic).

        Example:
         ``PE32 executable (GUI) Intel 80386, for MS Windows, Nullsoft Installer self-extracting archive``
        """  # noqa: E501
        return self._get("formatDescription")


class ArtifactContent:
    """Binary artifact content.

    May be packed in archive, if it is requested in :meth:`ArtifactsAPI.get_content`.
    """

    def __init__(self, filename: str, raw: Any):
        self._filename = filename
        self._raw = raw

    @property
    def filename(self) -> str:
        """Artifact file name."""
        return self._filename

    @property
    def stream(self) -> Any:
        """Stream with binary artifact content.

        File-like object, supports read() but does not support seek().
        """
        # TODO: Implement actual streaming using iter_bytes()
        # https://www.python-httpx.org/compatibility/#streaming-responses
        return BytesIO(self._raw.read())

    def iter_chunks(self, size=4096) -> Iterator[bytes]:
        """Iterates over data chunks with maximum size limit.

        Args:
            size: chunk size.
        Return:
            Iterator over data chunks.
        """
        return self._raw.iter_bytes(size)

    def readall(self) -> bytes:
        """Read all content data."""
        return self._raw.read()


class ArtifactAsyncContent:
    """Binary artifact content with asynchronous API.

    May be packed in archive,
     if it is requested in :meth:`ArtifactsAsyncAPI.get_content`.
    """

    def __init__(self, filename: str, raw: Any):
        self._filename = filename
        self._raw = raw

    @property
    def filename(self) -> str:
        """Artifact file name."""
        return self._filename

    def iter_chunks(self, size=4096) -> AsyncIterator[bytes]:
        """Iterates over data chunks with maximum size limit.

        Args:
            size: chunk size.
        Return:
            Asynchronous iterator over data chunks.
        """
        return self._raw.aiter_bytes(size)

    async def readall(self) -> bytes:
        """Read all content data."""
        chunks = []
        async for chunk in self.iter_chunks():
            chunks.append(chunk)
        return b"".join(chunks)


def _parse_content_filename(response) -> str:
    """Parse filename parameter from content-disposition header."""
    try:
        value, params = cgi.parse_header(response.headers["content-disposition"])
    except KeyError:
        raise CybsiError("Content-disposition header not found") from None

    try:
        return params["filename"]
    except KeyError:
        raise CybsiError("filename not found in Content-disposition header") from None


class ArtifactCommonView(RefView):
    """Common artifact view."""

    @property
    def types(self) -> List[ArtifactTypes]:
        """Artifact types."""
        return [ArtifactTypes(t) for t in self._get("types")]

    @property
    def data_sources(self) -> List[RefView]:
        """Data sources which registered the artifact."""
        return [RefView(t) for t in self._get("dataSources")]

    @property
    def share_levels(self) -> Optional[List[ShareLevels]]:
        """Artifact share levels, ordered from lowest to highest."""
        return self._map_list_optional("shareLevels", ShareLevels)

    @property
    def content(self) -> "ArtifactContentView":
        """Artifact content meta information."""
        return ArtifactContentView(self._get("content"))


class ArtifactView(_TaggedRefView):
    """Artifact view."""

    @property
    def types(self) -> List[ArtifactTypes]:
        """Artifact types."""
        return [ArtifactTypes(t) for t in self._get("types")]

    @property
    def data_sources(self) -> List[RefView]:
        """Data sources which registered the artifact."""
        return [RefView(t) for t in self._get("dataSources")]

    @property
    def share_levels(self) -> Optional[List[ShareLevels]]:
        """Artifact share levels, ordered from lowest to highest."""
        return self._map_list_optional("shareLevels", ShareLevels)

    @property
    def content(self) -> "ArtifactContentView":
        """Artifact content meta information."""
        return ArtifactContentView(self._get("content"))

    @property
    def file_names(self) -> List[str]:
        """Artifact file names. Ordered by time of registration."""
        return cast(List[str], self._get("fileNames"))

    @property
    def entities(self) -> List[EntityView]:
        """List of file entities associated with this artifact."""
        return [EntityView(e) for e in self._get("entities")]


class ArtifactContentView(JsonObjectView):
    """Artifact content meta information."""

    @property
    def url(self) -> str:
        """Absolute URL of artifact content."""
        return cast(str, self._get("url"))

    @property
    def size(self) -> int:
        """File size."""
        return int(self._get("size"))

    @property
    def md5_hash(self) -> str:
        """MD5 hash of file content."""
        return cast(str, self._get("md5Hash"))

    @property
    def sha1_hash(self) -> str:
        """SHA1 hash of file content."""
        return cast(str, self._get("sha1Hash"))

    @property
    def sha256_hash(self) -> str:
        """SHA256 hash of file content."""
        return cast(str, self._get("sha256Hash"))

    @property
    def format_description(self) -> Optional[str]:
        """File format description magically extracted from signature."""
        return self._get_optional("formatDescription")


class ArtifactRegistrationView(JsonObjectView):
    """Artifact registration view."""

    @property
    def data_source(self) -> RefView:
        """Data source which registered the artifact."""
        return RefView(self._get("dataSource"))

    @property
    def type(self) -> ArtifactTypes:
        """Artifact type."""
        return ArtifactTypes(self._get("type"))

    @property
    def file_name(self) -> str:
        """Artifact file name"""
        return self._get("fileName")

    @property
    def share_level(self) -> ShareLevels:
        """Artifact share level"""
        return ShareLevels(self._get("shareLevel"))

    @property
    def registered_at(self) -> datetime:
        return parse_rfc3339_timestamp(self._get("registeredAt"))

cybsi/api/artifact/api.py
cyberstudio/cybsi-sdk
from enum_tools import document_enum

from cybsi.api.enum import CybsiAPIEnum


@document_enum
class ArtifactTypes(CybsiAPIEnum):
    Archive = "Archive"  # doc: Archive.
    FileSample = "FileSample"  # doc: File sample.
    NetworkTraffic = "NetworkTraffic"  # doc: Network traffic.


@document_enum
class ArtifactContentDownloadCompressionTypes(CybsiAPIEnum):
    """Artifact content download compression type.

    Don't confuse it with compression of original artifact content itself!
    Those values are used only for artifact content downloading.
    Uncompress the downloaded archive to get artifact content.
    """

    ZIP = "ZIP"  # doc: ZIP archive

cybsi/api/artifact/enums.py
cyberstudio/cybsi-sdk
"""Use this section of API operate auth information.
"""
from .api_key import (
    APIKeyAuth,
    APIKeysAPI,
    APIKeyForm,
    APIKeyRefView,
    APIKeyCommonView,
    APIKeyView,
)
from .token import TokenType, TokenView

cybsi/api/auth/__init__.py
cyberstudio/cybsi-sdk
import asyncio
import json
import logging
import threading
import uuid
from datetime import datetime
from typing import AsyncGenerator, Generator, Iterable, List, Optional, Tuple
from urllib.parse import urljoin

import httpx

from ..error import _raise_cybsi_error
from ..internal import (
    BaseAPI,
    JsonObject,
    JsonObjectForm,
    JsonObjectView,
    parse_rfc3339_timestamp,
    rfc3339_timestamp,
)
from ..pagination import Cursor, Page
from ..user import ActionSet, ResourceName
from ..view import Tag, _TaggedRefView
from .token import TokenView

logger = logging.getLogger(__name__)


class APIKeyAuth(httpx.Auth):
    """Authomatically handles authentication
    of :class:`~cybsi.api.CybsiClient` or :class:`~cybsi.api.CybsiAsyncClient`
    requests using API key.

    Args:
        api_url: Cybsi auth API URL. Usually equal to CybsiClient config API URL.
        api_key: Cybsi API key.
        ssl_verify: enable SSL verification. Deprecated, has no effect.
            Uses value set for CybsiClient config.
    Usage:
        >>> from cybsi.api import APIKeyAuth, Config, CybsiClient
        >>> api_url = "http://localhost:80/api"
        >>> api_key = "8Nqjk6V4Q_et_Rf5EPu4SeWy4nKbVPKPzKJESYdRd7E"
        >>> auth = APIKeyAuth("", api_key)
        >>> config = Config(api_url, auth)  # Consider using :attr:`Config.api_key`
        >>> client = CybsiClient(config)
        >>> client.observations
        <cybsi_sdk.client.observation.ObservationsAPI object at 0x7f57a293c190>
    """

    requires_response_body = True  # instructs httpx to pass token request response body

    _get_token_path = "auth/token"

    def __init__(self, *, api_url: str, api_key: str, ssl_verify: bool = True):
        # See https://www.python-httpx.org/advanced/#customizing-authentication
        self._api_key = api_key
        self._api_url = api_url
        self._sync_lock = threading.RLock()
        self._async_lock = asyncio.Lock()
        self._token = ""

    def sync_auth_flow(
        self, request: httpx.Request
    ) -> Generator[httpx.Request, httpx.Response, None]:
        token = self._token
        if token:
            request.headers["Authorization"] = self._token
            response = yield request
            if response.status_code != 401:
                return

        with self._sync_lock:
            if self._token == token:
                token_response = yield self._build_token_request(request)
                self._update_token(token_response, token_response.read())
            request.headers["Authorization"] = self._token
            yield request

    async def async_auth_flow(
        self, request: httpx.Request
    ) -> AsyncGenerator[httpx.Request, httpx.Response]:
        token = self._token
        if token:
            request.headers["Authorization"] = self._token
            response = yield request
            if response.status_code != 401:
                return

        async with self._async_lock:
            if self._token == token:
                token_response = yield self._build_token_request(request)
                self._update_token(token_response, await token_response.aread())
            request.headers["Authorization"] = self._token
            yield request

    def _build_token_request(self, req) -> httpx.Request:
        token_url = urljoin(self._api_url, self._get_token_path)
        headers = {
            "Accept": req.headers["Accept"],
            "User-Agent": req.headers["User-Agent"],
        }
        return httpx.Request(
            "GET",
            url=token_url,
            params={"apiKey": self._api_key},
            headers=headers,
        )

    def _update_token(
        self, token_response: httpx.Response, token_response_content: bytes
    ) -> None:
        if not token_response.is_success:
            _raise_cybsi_error(token_response)
        token = TokenView(json.loads(token_response_content))

        self._token = f"{token.type.value} {token.access_token}"


class APIKeysAPI(BaseAPI):
    """API-Keys API."""

    _path = "/api-keys"
    _users_path = "/users"
    _me_path = f"{_users_path}/me"

    def generate(self, user_uuid: uuid.UUID, form: "APIKeyForm") -> "APIKeyRefView":
        """Generate API-Key for user.

        Warning:
            Make sure to copy API key value because it is not stored on the server.
            Revoke API key if you suspect a leak.
        Note:
            Calls `POST /users/{userID}/api-keys`.
        Args:
            user_uuid: User identifier.
            form: APIKeyForm instance.
        Returns:
            Reference to the generated API-Key.
        Raises:
            :class:`~cybsi.api.error.SemanticError`: Form contains logic errors.
        Note:
            Semantic error codes specific for this method:
              * :attr:`~cybsi.api.error.SemanticErrorCodes.PermissionsExceeded`
              * :attr:`~cybsi.api.error.SemanticErrorCodes.UserDisabled`
        """
        path = f"{self._users_path}/{user_uuid}/api-keys"
        resp = self._connector.do_post(path=path, json=form.json())
        return APIKeyRefView(resp.json())

    def _filter(
        self, path: str, cursor: Optional[Cursor] = None, limit: Optional[int] = None
    ) -> Page["APIKeyCommonView"]:
        params: JsonObject = {}
        if cursor is not None:
            params["cursor"] = str(cursor)
        if limit is not None:
            params["limit"] = str(limit)
        resp = self._connector.do_get(path=path, params=params)
        page = Page(self._connector.do_get, resp, APIKeyCommonView)
        return page

    def filter(
        self,
        *,
        user_uuid: uuid.UUID,
        cursor: Optional[Cursor] = None,
        limit: Optional[int] = None,
    ) -> Page["APIKeyCommonView"]:
        """Get API keys created for user.
        Keys are ordered by creation timestamp, new first.

        Note:
            Calls `GET /users/{user_uuid}/api-keys`.
        Args:
            user_uuid: User identifier.
            cursor: Page cursor.
            limit: Page limit.
        Return:
            Page with user API-Key common views and next page cursor.
        """
        path = f"{self._users_path}/{user_uuid}/api-keys"
        return self._filter(path, cursor, limit)

    def filter_my(
        self,
        *,
        cursor: Optional[Cursor] = None,
        limit: Optional[int] = None,
    ) -> Page["APIKeyCommonView"]:
        """Get API keys which API-Client owns.

        Note:
            Calls `GET /users/me/api-keys`.
            Doesn't require any permissions.
        Args:
            cursor: Page cursor.
            limit: Page limit.
        Return:
            Page with user API-Key common views and next page cursor.
        .. versionadded:: 2.8
        """
        path = f"{self._me_path}/api-keys"
        return self._filter(path, cursor, limit)

    def view(self, api_key_id: uuid.UUID) -> "APIKeyView":
        """Get the API-Key view.

        Note:
            Calls `GET /api-keys/{api_key_id}`.
        Args:
            api_key_id: API-Key identifier.
        Returns:
            Full view of the API-Key include ETag value.
        Raises:
            :class:`~cybsi.api.error.NotFoundError`: User API-Key not found.
        """

        path = f"{self._path}/{api_key_id}"
        resp = self._connector.do_get(path)
        return APIKeyView(resp)

    def view_my(self, api_key_uuid) -> "APIKeyView":
        """Get the API-Key view which API-Client owns.

        Note:
            Calls `GET /users/me/api-keys/{api_key_id}`.
            Doesn't require any permissions.
        Args:
            api_key_uuid: API-Key identifier.
        Returns:
            Full view of the API-Key include ETag value.
        Raises:
            :class:`~cybsi.api.error.NotFoundError`: User API-Key not found.
            :class:`~cybsi.api.error.SemanticError`: Semantic error.
        Note:
            Semantic error codes specific for this method:
              * :attr:`~cybsi.api.error.SemanticErrorCodes.NotOwner`.
        .. versionadded:: 2.8
        """

        path = f"{self._me_path}/api-keys/{api_key_uuid}"
        resp = self._connector.do_get(path)
        return APIKeyView(resp)

    @staticmethod
    def _build_edit_api_key_form(
        description: Optional[str] = None, revoked: Optional[bool] = None
    ) -> JsonObject:
        form: JsonObject = {}
        if description is not None:
            form["description"] = description
        if revoked is not None:
            form["revoked"] = revoked
        return form

    def edit(
        self,
        api_key_id: uuid.UUID,
        tag: Tag,
        *,
        description: Optional[str] = None,
        revoked: Optional[bool] = None,
    ):
        """Edit description and/or revoke API-Key.

        Warning:
            Key revocation is an irreversible operation.
        Note:
            Calls `PATCH /api-keys/{api_key_id}`.
        Args:
            api_key_id: API-Key identifier.
            tag: :attr:`APIKeyView.tag` value. Use :meth:`view` to retrieve it.
            description: API-Key description.
            revoked: API-Key revoked flag. Key revocation is an irreversible operation.
        Raises:
            :class:`~cybsi.api.error.ResourceModifiedError`:
                API-Key changed since last request. Retry using updated tag.
            :class:`~cybsi.api.error.NotFoundError`: User API-Key not found.
        """

        path = f"{self._path}/{api_key_id}"
        form = self._build_edit_api_key_form(description, revoked)
        self._connector.do_patch(path=path, tag=tag, json=form)

    def edit_my(
        self,
        api_key_id: uuid.UUID,
        tag: Tag,
        *,
        description: Optional[str] = None,
        revoked: Optional[bool] = None,
    ):
        """Edit API-Key which API-Client owns.

        Warning:
            Key revocation is an irreversible operation.
        Note:
            Calls `PATCH /users/me/api-keys/{api_key_id}`.
            Doesn't require any permissions.
        Args:
            api_key_id: API-Key identifier.
            tag: :attr:`APIKeyView.tag` value. Use :meth:`view` to retrieve it.
            description: API-Key description.
            revoked: API-Key revoked flag. Key revocation is an irreversible operation.
        Raises:
            :class:`~cybsi.api.error.ResourceModifiedError`:
                API-Key changed since last request. Retry using updated tag.
            :class:`~cybsi.api.error.NotFoundError`: User API-Key not found.
            :class:`~cybsi.api.error.SemanticError`: Semantic error.
        Note:
            Semantic error codes specific for this method:
              * :attr:`~cybsi.api.error.SemanticErrorCodes.NotOwner`.
        .. versionadded:: 2.8
        """

        path = f"{self._me_path}/api-keys/{api_key_id}"
        form = self._build_edit_api_key_form(description, revoked)
        self._connector.do_patch(path=path, tag=tag, json=form)


class APIKeyForm(JsonObjectForm):
    """API-Key form.

    This is the form you need to fill to generate API-Key.

    Args:
        description: API-Key description.
        expires_at: Expiration date.
            The API-Key is automatically revoked after the expiration date.
        permissions: List of permissions. If not set then
            the API-Key inherits permissions from the owner user.
    """

    def __init__(
        self,
        expires_at: datetime,
        *,
        description: Optional[str] = None,
        permissions: Iterable[Tuple[ResourceName, ActionSet]] = [],
    ):
        super().__init__()
        self._data["expiresAt"] = rfc3339_timestamp(expires_at)
        if description is not None:
            self._data["description"] = description
        if permissions:
            self._data["permissions"] = [
                ":".join([res.value, act]) for res, act in permissions
            ]


class APIKeyRefView(JsonObjectView):
    """API-Key reference view."""

    @property
    def uuid(self) -> uuid.UUID:
        """API-Key identifier."""
        return uuid.UUID(self._get("id"))

    @property
    def url(self) -> Optional[str]:
        """URL to get full API-Key view.
        Property is presented if :class:`~cybsi.api.client.Config`
        embed_object_url is True.
        """
        return self._get_optional("url")

    @property
    def key(self) -> str:
        """API-Key."""
        return self._get("key")


class APIKeyCommonView(JsonObjectView):
    """API-Key common view."""

    @property
    def uuid(self) -> uuid.UUID:
        """API-Key identifier."""
        return uuid.UUID(self._get("id"))

    @property
    def url(self) -> Optional[str]:
        """URL to get API-Key full view.
        Property is presented if :class:`~cybsi.api.client.Config`
        embed_object_url is True.
        """
        return self._get_optional("url")

    @property
    def description(self) -> Optional[str]:
        """API-Key description."""
        return self._get_optional("description")

    @property
    def created_at(self) -> datetime:
        """Creation date."""
        return parse_rfc3339_timestamp(self._get("createdAt"))

    @property
    def expires_at(self) -> Optional[datetime]:
        """Expiration date. :data:`None` if it shouldn't expire.

        The API-Key is automatically revoked after the expiration date."""
        return self._map_optional("expiresAt", parse_rfc3339_timestamp)

    @property
    def last_used_at(self) -> Optional[datetime]:
        """Last used date. :data:`None` if it wasn't used yet."""
        return self._map_optional("lastUsedAt", parse_rfc3339_timestamp)

    @property
    def revoked(self) -> bool:
        """API-Key revoked flag."""
        return self._get("revoked")

    @property
    def permissions(self) -> List[Tuple[ResourceName, ActionSet]]:
        """List of permissions."""

        permissions: List[Tuple[ResourceName, ActionSet]] = []

        for raw_perm in self._get("permissions"):
            res, act = raw_perm.split(":", 1)
            permissions.append((ResourceName(res), act))
        return permissions


class APIKeyView(_TaggedRefView, APIKeyCommonView):
    """API-Key full view."""

    @property
    def uuid(self) -> uuid.UUID:
        """API-Key identifier."""
        return uuid.UUID(self._get("id"))

cybsi/api/auth/api_key.py
cyberstudio/cybsi-sdk
import datetime

from enum_tools import document_enum

from cybsi.api.enum import CybsiAPIEnum

from ..internal import JsonObjectView


@document_enum
class TokenType(CybsiAPIEnum):
    """Token type."""

    Bearer = "Bearer"  # doc: Authentication is made using Bearer token type


class TokenView(JsonObjectView):
    """Authorization token view."""

    @property
    def access_token(self) -> str:
        """Access token. A cryptic string."""
        return self._get("accessToken")

    @property
    def type(self) -> TokenType:
        """Token type."""
        return TokenType(self._get("tokenType"))

    @property
    def expires_in(self) -> datetime.timedelta:
        """Token lifetime until expiration."""
        seconds = int(self._get("expiresIn"))
        return datetime.timedelta(seconds=seconds)

cybsi/api/auth/token.py
cyberstudio/cybsi-sdk
from dataclasses import dataclass
from typing import Callable, Optional, Union

from .artifact import ArtifactsAPI, ArtifactsAsyncAPI
from .auth import APIKeyAuth, APIKeysAPI
from .client_config import DEFAULT_LIMITS, DEFAULT_TIMEOUTS, Limits, Timeouts
from .data_source import (
    DataSourcesAPI,
    DataSourcesAsyncAPI,
    DataSourceTypesAPI,
    DataSourceTypesAsyncAPI,
)
from .dictionary import DictionariesAPI
from .enrichment import EnrichmentAPI, EnrichmentAsyncAPI
from .error import CybsiError
from .internal import HTTPConnector, JsonObjectView
from .internal.connector import AsyncHTTPConnector
from .license import LicensesAPI
from .observable import ObservableAPI, ObservableAsyncAPI
from .observation import ObservationsAPI, ObservationsAsyncAPI
from .replist import ReplistsAPI, ReplistsAsyncAPI
from .report import ReportsAPI, ReportsAsyncAPI
from .search import SearchAPI
from .user import UsersAPI


@dataclass
class Config:
    """:class:`CybsiClient` config.

    Args:
        api_url: Base API URL.
        auth: Optional callable :class:`CybsiClient` can use to authenticate requests.
            In most cases it's enough to pass `api_key` instead of this.
        ssl_verify: Enable SSL certificate verification.
        timeouts: Timeout configuration. Default configuration is 60 sec
            on all operations.
        limits:  Configuration for limits to various client behaviors.
            Default configuration is max_connections=100, max_keepalive_connections=20.
        embed_object_url: Initialize URL property for all objects having uuid property
            (including :class:`~cybsi.api.view.RefView`).
            Views are compact if it's set to False.
    """

    api_url: str
    auth: Union[APIKeyAuth, Callable]
    ssl_verify: bool = True
    embed_object_url: bool = False
    timeouts: Timeouts = DEFAULT_TIMEOUTS
    limits: Limits = DEFAULT_LIMITS


class CybsiClient:
    """The main entry point for all actions with Cybsi REST API.

    As the client is low-level, it is structured around Cybsi REST API routes.
    Use properties of the client to retrieve handles of API sections.

    The client also follows Cybsi REST API input-output formats,
    providing little to no abstration from JSONs API uses.
    It's relatively easy to construct an invalid request,
    so use client's functions wisely.

    Hint:
        Use :class:`~cybsi.api.CybsiClient` properties
        to construct needed API handles. Don't construct sub-APIs manually.

        Do this:
            >>> from cybsi.api import CybsiClient
            >>> client = CybsiClient(config)
            >>> client.observations.generics.view(observation_uuid)
        Not this:
            >>> from cybsi.api.observation import GenericObservationsAPI
            >>> GenericObservationsAPI(connector).view(observation_uuid)

    Args:
        config: Client config.
    Usage:
        >>> from cybsi.api import APIKeyAuth, Config, CybsiClient
        >>> api_url = "http://localhost:80/api"
        >>> api_key = "8Nqjk6V4Q_et_Rf5EPu4SeWy4nKbVPKPzKJESYdRd7E"
        >>> auth = APIKeyAuth(api_url, api_key)
        >>> config = Config(api_url, auth)
        >>> client = CybsiClient(config)
        >>>
        >>> client.observations.generics.filter()
        >>> client.close()  # "with" syntax is also supported for CybsiClient
        <cybsi_sdk.client.observation.ObservationsAPI object at 0x7f57a293c190>
    """

    def __init__(self, config: Config):
        if config.auth is None:
            raise CybsiError("No authorization mechanism configured for client")

        self._connector = HTTPConnector(
            base_url=config.api_url,
            auth=config.auth,
            ssl_verify=config.ssl_verify,
            embed_object_url=config.embed_object_url,
            timeouts=config.timeouts,
            limits=config.limits,
        )

    def __enter__(self) -> "CybsiClient":
        self._connector.__enter__()
        return self

    def __exit__(
        self,
        exc_type=None,
        exc_value=None,
        traceback=None,
    ) -> None:
        self._connector.__exit__(exc_type, exc_value, traceback)

    def close(self) -> None:
        """Close client and release connections."""
        self._connector.close()

    @property
    def artifacts(self) -> ArtifactsAPI:
        """Artifacts API handle."""
        return ArtifactsAPI(self._connector)

    @property
    def data_sources(self) -> DataSourcesAPI:
        """Data sources API handle."""
        return DataSourcesAPI(self._connector)

    @property
    def data_source_types(self) -> DataSourceTypesAPI:
        """Data source types API handle."""
        return DataSourceTypesAPI(self._connector)

    @property
    def enrichment(self) -> EnrichmentAPI:
        """Enrichment API handle."""
        return EnrichmentAPI(self._connector)

    @property
    def observable(self) -> ObservableAPI:
        """Observable API handle."""
        return ObservableAPI(self._connector)

    @property
    def observations(self) -> ObservationsAPI:
        """Observations API handle."""
        return ObservationsAPI(self._connector)

    @property
    def replists(self) -> ReplistsAPI:
        """Reputation lists API handle."""
        return ReplistsAPI(self._connector)

    @property
    def reports(self) -> ReportsAPI:
        """Reports API handle."""
        return ReportsAPI(self._connector)

    @property
    def search(self) -> SearchAPI:
        """Search API handle."""
        return SearchAPI(self._connector)

    @property
    def users(self) -> UsersAPI:
        """Users API handle."""
        return UsersAPI(self._connector)

    @property
    def api_keys(self) -> APIKeysAPI:
        """API-Keys API handle."""
        return APIKeysAPI(self._connector)

    @property
    def dictionaries(self) -> DictionariesAPI:
        """Dictionaries API handle."""
        return DictionariesAPI(self._connector)

    @property
    def licenses(self) -> LicensesAPI:
        """Licenses API handle."""
        return LicensesAPI(self._connector)

    def version(self) -> "VersionView":
        """Get API and server version information.

        Note:
            Calls `GET /version`.
        Returns:
            Version view.
        """

        path = "/version"
        resp = self._connector.do_get(path)
        return VersionView(resp.json())


class CybsiAsyncClient:
    """The asynchronous analog of :class:`CybsiClient`.

    As you can see, the asynchronous client has fewer features than synchronous one.
    This is because we don't simply copy-paste features,
    but provide them only when they're actually useful in asynchronous applications.

    Args:
        config: Client config.
    """

    def __init__(self, config: Config):
        if config.auth is None:
            raise CybsiError("No authorization mechanism configured for client")

        self._connector = AsyncHTTPConnector(
            base_url=config.api_url,
            auth=config.auth,
            ssl_verify=config.ssl_verify,
            timeouts=config.timeouts,
            limits=config.limits,
        )

    async def __aenter__(self) -> "CybsiAsyncClient":
        await self._connector.__aenter__()
        return self

    async def __aexit__(
        self,
        exc_type=None,
        exc_value=None,
        traceback=None,
    ) -> None:
        await self._connector.__aexit__(exc_type, exc_value, traceback)

    async def aclose(self) -> None:
        """Close client and release connections."""
        await self._connector.aclose()

    @property
    def artifacts(self) -> ArtifactsAsyncAPI:
        """Artifacts API handle."""
        return ArtifactsAsyncAPI(self._connector)

    @property
    def enrichment(self) -> EnrichmentAsyncAPI:
        """Enrichment API handle."""
        return EnrichmentAsyncAPI(self._connector)

    @property
    def observations(self) -> ObservationsAsyncAPI:
        """Observations API handle."""
        return ObservationsAsyncAPI(self._connector)

    @property
    def reports(self) -> ReportsAsyncAPI:
        """Reports API handle."""
        return ReportsAsyncAPI(self._connector)

    @property
    def data_sources(self) -> DataSourcesAsyncAPI:
        """Data sources API handle."""
        return DataSourcesAsyncAPI(self._connector)

    @property
    def data_source_types(self) -> DataSourceTypesAsyncAPI:
        """Data source types API handle."""
        return DataSourceTypesAsyncAPI(self._connector)

    @property
    def replists(self) -> ReplistsAsyncAPI:
        """Replists API handle."""
        return ReplistsAsyncAPI(self._connector)

    @property
    def observable(self) -> ObservableAsyncAPI:
        """Observable API handle."""
        return ObservableAsyncAPI(self._connector)


class VersionView(JsonObjectView):
    """Version view."""

    @property
    def api_version(self) -> "Version":
        """API specification version."""
        return Version(self._get("apiVersion"))

    @property
    def server_version(self) -> "Version":
        """Server version."""
        return Version(self._get("serverVersion"))


class Version:
    """Version."""

    def __init__(self, version: str):
        self._version = version

        p1, self._build = version.split("+", 1) if "+" in version else (version, "")
        core, self._prerelease = p1.split("-", 1) if "-" in p1 else (p1, "")
        self._major, self._minor, self._patch = [int(p) for p in core.split(".", 2)]

    def __str__(self):
        return self._version

    @property
    def major(self) -> int:
        """Major part of version."""

        return self._major

    @property
    def minor(self) -> int:
        """Minor part of version."""

        return self._minor

    @property
    def patch(self) -> int:
        """Patch part of version."""

        return self._patch

    @property
    def prerelease(self) -> Optional[str]:
        """Prerelease part of version."""

        if self._prerelease != "":
            return self._prerelease
        return None

    @property
    def build(self) -> Optional[str]:
        """Build part of version."""

        if self._build != "":
            return self._build
        return None

cybsi/api/client.py
cyberstudio/cybsi-sdk
from typing import Optional, Union

import httpx

from .api import Null, Nullable, _unwrap_nullable

TimeoutTypes = Union[
    Optional[float],
    "Timeouts",
]


class Limits:
    """Configuration for limits to various client behaviors.

    Args:
        max_connections: The maximum number of concurrent connections that may be
            established.
        max_keepalive_connections: Allow the connection pool to maintain
            keep-alive connections below this point. Should be less than or equal
            to `max_connections`.
        keepalive_expiry: The maximum time (in second) to allow before closing
            a keep-alive connection.
    """

    def __init__(
        self,
        *,
        max_connections: Optional[int] = None,
        max_keepalive_connections: Optional[int] = None,
        keepalive_expiry: Optional[float] = 5.0,
    ):
        self.max_connections = max_connections
        self.max_keepalive_connections = max_keepalive_connections
        self.keepalive_expiry = keepalive_expiry

    def _as_httpx_limits(self) -> httpx.Limits:
        return httpx.Limits(
            max_connections=self.max_connections,
            max_keepalive_connections=self.max_keepalive_connections,
            keepalive_expiry=self.keepalive_expiry,
        )


class Timeouts:
    """Timeout configuration.

    Note:
        Default parameters value is :data:`~cybsi.api.Null`,
        it means "use default timeout".
        :data:`None` value means "infinite timeout".

        Please note those timeouts don't restrict total execution time
        of SDK methods (like register, view and so on).
        Wrap calls to SDK methods with asyncio.wait_for or
        similar functions if you want to control total execution time.
    Args:
        default: Timeout (in seconds) on all operations listed below.
        connect: The maximum amount of time (in seconds) to wait
            for a connection attempt to a server to succeed.
        read: The maximum amount of time (in seconds) to wait between
            consecutive read operations for a response from the server.
        write: The maximum amount of time (in seconds) to wait between
            consecutive write operations (send request) to the server.
    Raises:
        :class:`ValueError`: Timeout must either default settings
            for all operations or set all three parameters explicitly.
    Usage:
        >>> # No timeouts.
        >>> Timeouts(default=None)
        >>> # 5s timeout on all operations.
        >>> Timeouts(default=5.0)
        >>> # 5s timeout on connect, no other timeouts.
        >>> Timeouts(default=None, connect=5.0)
        >>> # 10s timeout on connect. 5s timeout elsewhere.
        >>> Timeouts(default=5.0, connect=10.0)
    """

    def __init__(
        self,
        *,
        default: Nullable[TimeoutTypes] = Null,
        connect: Nullable[float] = Null,
        read: Nullable[float] = Null,
        write: Nullable[float] = Null,
    ):
        if isinstance(default, Timeouts):
            # Passed as a single explicit Timeout.
            assert connect is Null
            assert read is Null
            assert write is Null
            self.connect = _unwrap_nullable(default.connect)  # type: Optional[float]
            self.read = _unwrap_nullable(default.read)  # type: Optional[float]
            self.write = _unwrap_nullable(default.write)  # type: Optional[float]

        elif not (connect is Null or read is Null or write is Null):
            self.connect = _unwrap_nullable(connect)
            self.read = _unwrap_nullable(read)
            self.write = _unwrap_nullable(write)
        else:
            if default is Null:
                raise ValueError(
                    "Timeout must either include default settings "
                    "for all operations or set all three parameters explicitly."
                )
            self.connect = (
                _unwrap_nullable(default)
                if connect is Null
                else _unwrap_nullable(connect)
            )
            self.read = (
                _unwrap_nullable(default) if read is Null else _unwrap_nullable(read)
            )
            self.write = (
                _unwrap_nullable(default) if write is Null else _unwrap_nullable(write)
            )

    def _as_httpx_timeouts(self) -> httpx.Timeout:
        return httpx.Timeout(
            connect=self.connect,
            read=self.read,
            write=self.write,
            # Timeout (in seconds) on acquiring connection from pool.
            # Internal default httpx.Timeout setting.
            pool=5.0,
        )


DEFAULT_TIMEOUTS = Timeouts(default=60.0)
DEFAULT_LIMITS = Limits(max_connections=100, max_keepalive_connections=20)

cybsi/api/client_config.py
cyberstudio/cybsi-sdk
"""Use this section of API to operate data sources.
"""
from .api import (
    DataSourcesAPI,
    DataSourcesAsyncAPI,
    DataSourceView,
    DataSourceForm,
    DataSourceCommonView,
    DataSourceLinksView,
)

from .api_types import (
    DataSourceTypesAPI,
    DataSourceTypesAsyncAPI,
    DataSourceTypeView,
    DataSourceTypeForm,
    DataSourceTypeCommonView,
)

from .enums import (
    DataSourceListOrder,
    DataSourceTypeListOrder,
)

cybsi/api/data_source/__init__.py
cyberstudio/cybsi-sdk
import uuid
from typing import Any, Dict, Iterable, List, Optional

from .. import RefView
from ..api import Nullable, Tag, _unwrap_nullable
from ..internal import BaseAPI, BaseAsyncAPI, JsonObjectForm, JsonObjectView
from ..pagination import AsyncPage, Cursor, Page
from ..view import _TaggedRefView
from .api_types import DataSourceTypeCommonView
from .enums import DataSourceListOrder

_PATH = "/data-sources"


class DataSourcesAPI(BaseAPI):
    """API to operate data source."""

    def view(self, datasource_uuid: uuid.UUID) -> "DataSourceView":
        """Get the data source view.

        Note:
            Calls `GET /data-sources/{datasource_uuid}`.
        Args:
            datasource_uuid: Data source UUID.
        Returns:
            View of the data source.
        Raises:
            :class:`~cybsi.api.error.NotFoundError`: Data source not found.
        """
        path = f"{_PATH}/{datasource_uuid}"
        r = self._connector.do_get(path)
        return DataSourceView(r)

    def view_links(self, datasource_uuid: uuid.UUID) -> "DataSourceLinksView":
        """Get links view of the data source.

        .. versionadded:: 2.7

        Note:
            Calls `GET /data-sources/{datasource_uuid}/links`.
        Args:
            datasource_uuid: Data source UUID.
        Returns:
            Links view of the data source.
        Raises:
            :class:`~cybsi.api.error.NotFoundError`: Data source not found.
        """

        path = f"{_PATH}/{datasource_uuid}/links"
        r = self._connector.do_get(path)
        return DataSourceLinksView(r.json())

    def me(self) -> "DataSourceView":
        """Get data source associated with current client.

        Note:
            Calls `GET /data-sources/me`.
        """
        path = f"{_PATH}/me"
        r = self._connector.do_get(path)
        return DataSourceView(r)

    def register(self, form: "DataSourceForm") -> RefView:
        """Register a data source.

        Note:
            Calls `POST /data-sources`.
        Args:
            form: Filled data source form.
        Raises:
            :class:`~cybsi.api.error.ConflictError`: Data source already exist.
            :class:`~cybsi.api.error.SemanticError`
        Note:
            Semantic error codes:
              * :attr:`~cybsi.api.error.SemanticErrorCodes.DataSourceTypeNotFound`
        """
        r = self._connector.do_post(path=_PATH, json=form.json())
        return RefView(r.json())

    def edit(
        self,
        type_uuid: uuid.UUID,
        tag: Tag,
        *,
        long_name: Optional[str] = None,
        manual_confidence: Nullable[float] = None,
    ) -> None:
        """Edit the data source.

        Note:
            Calls `PATCH /data-sources/{source_uuid}`.
        Args:
            type_uuid: Data source type uuid.
            tag: :attr:`DataSourceView.tag` value. Use :meth:`view` to retrieve it.
            long_name:  Human-readable data source name. Non-empty if not :data:`None`.
            manual_confidence:
                Confidence for data source.
                Overrides confidence of the data source inherited from data source type.
                Valid values are in [0, 1].
                :data:`~cybsi.api.Null` means
                that Cybsi can use confidence provided by data source type.
                :data:`None` means that confidence is left unchanged.
        Raises:
            :class:`~cybsi.api.error.InvalidRequestError`:
                Provided arguments have invalid values.
            :class:`~cybsi.api.error.NotFoundError`: Data source not found.
            :class:`~cybsi.api.error.ResourceModifiedError`:
                Data source changed since last request. Update tag and retry.
        """
        form: Dict[str, Any] = {}
        if long_name is not None:
            form["longName"] = long_name
        if manual_confidence is not None:
            form["manualConfidence"] = _unwrap_nullable(manual_confidence)
        path = f"{_PATH}/{type_uuid}"
        self._connector.do_patch(path=path, tag=tag, json=form)

    def filter(
        self,
        *,
        query: Optional[str] = None,
        type_uuids: Optional[Iterable[uuid.UUID]] = None,
        order_by: Optional[DataSourceListOrder] = None,
        cursor: Optional[Cursor] = None,
        limit: Optional[int] = None,
    ) -> Page["DataSourceCommonView"]:
        """Get a filtered list of data sources.

        .. versionchanged:: 2.8
            Added new parameters: `query`, `type_uuids`, `order_by`.
            Added semantic error `DataSourceNotFound`

        Note:
            Calls `GET /data-sources`.
        Args:
            query: Filter of data sources by specified substring (case-insensitive).
                Substring length must be in range [1, 50].
                Filtering is performed by specified substring in data source names or
                its type names.
            type_uuids: List of data source type UUIDs.
            order_by: Data source list sort key.
                If ``order_by`` is :data:`None` then the order is not specified.
                But results are stable sorted when paging.

                For "FullName" key, sorting is performed in case-insensitive manner
                in lexicographic order.

                If ``order_by`` is not :data:`None` then it is necessary to pass
                the parameter to each next request along with a non-empty cursor.
                Otherwise, the sort will be reset to the "default" sort,
                which may lead to inconsistency in the data selection.
            cursor: Page cursor.
            limit: Page limit.
        Returns:
            Page with data source common views and next page cursor.
        Raises:
            :class:`~cybsi.api.error.SemanticError`
        Note:
            Semantic error codes:
              * :attr:`~cybsi.api.error.SemanticErrorCodes.DataSourceTypeNotFound`
              * :attr:`~cybsi.api.error.SemanticErrorCodes.DataSourceNotFound`
        Usage:
            >>> import uuid
            >>> from cybsi.api import CybsiClient
            >>> from cybsi.api.pagination import chain_pages
            >>> from cybsi.api.data_source import DataSourceListOrder
            >>>
            >>> client: CybsiClient
            >>> # filter data sources by specified type uuid and sort
            >>> started_page = client.data_sources.filter(
            >>>     type_uuids=[uuid.UUID("89200bef-2f50-4d4f-8b38-843d5ab9dfa9")],
            >>>     order_by=DataSourceListOrder.FullName,
            >>> )
            >>> for item in chain_pages(started_page):
            >>>     # do something with data source
            >>>     print(item)
            >>> pass
        """

        params: Dict[str, Any] = {}
        if query is not None:
            params["query"] = query
        if type_uuids is not None:
            params["typeUUID"] = [str(u) for u in type_uuids]
        if order_by is not None:
            params["orderBy"] = order_by.value
        if cursor:
            params["cursor"] = str(cursor)
        if limit:
            params["limit"] = str(limit)

        resp = self._connector.do_get(path=_PATH, params=params)
        page = Page(self._connector.do_get, resp, DataSourceCommonView)
        return page


class DataSourcesAsyncAPI(BaseAsyncAPI):
    """Asynchronous API to operate data source."""

    async def view(self, datasource_uuid: uuid.UUID) -> "DataSourceView":
        """Get the data source view.

        Note:
            Calls `GET /data-sources/{datasource_uuid}`.
        Args:
            datasource_uuid: Data source UUID.
        Returns:
            View of the data source.
        Raises:
            :class:`~cybsi.api.error.NotFoundError`: Data source not found.
        """
        path = f"{_PATH}/{datasource_uuid}"
        r = await self._connector.do_get(path)
        return DataSourceView(r)

    async def view_links(self, datasource_uuid: uuid.UUID) -> "DataSourceLinksView":
        """Get links view of the data source.

        .. versionadded:: 2.7

        Note:
            Calls `GET /data-sources/{datasource_uuid}/links`.
        Args:
            datasource_uuid: Data source UUID.
        Returns:
            Links view of the data source.
        Raises:
            :class:`~cybsi.api.error.NotFoundError`: Data source not found.
        """

        path = f"{_PATH}/{datasource_uuid}/links"
        r = await self._connector.do_get(path)
        return DataSourceLinksView(r.json())

    async def me(self) -> "DataSourceView":
        """Get data source associated with current client.

        Note:
            Calls `GET /data-sources/me`.
        """
        path = f"{_PATH}/me"
        r = await self._connector.do_get(path)
        return DataSourceView(r)

    async def register(self, form: "DataSourceForm") -> RefView:
        """Register a data source.

        Note:
            Calls `POST /data-sources`.
        Args:
            form: Filled data source form.
        Raises:
            :class:`~cybsi.api.error.ConflictError`: Data source already exist.
            :class:`~cybsi.api.error.SemanticError`
        Note:
            Semantic error codes:
              * :attr:`~cybsi.api.error.SemanticErrorCodes.DataSourceTypeNotFound`
        """
        r = await self._connector.do_post(path=_PATH, json=form.json())
        return RefView(r.json())

    async def edit(
        self,
        type_uuid: uuid.UUID,
        tag: Tag,
        *,
        long_name: Optional[str] = None,
        manual_confidence: Nullable[float] = None,
    ) -> None:
        """Edit the data source.

        Note:
            Calls `PATCH /data-sources/{source_uuid}`.
        Args:
            type_uuid: Data source type uuid.
            tag: :attr:`DataSourceView.tag` value. Use :meth:`view` to retrieve it.
            long_name:  Human-readable data source name. Non-empty if not :data:`None`.
            manual_confidence:
                Confidence for data source.
                Overrides confidence of the data source inherited from data source type.
                Valid values are in [0, 1].
                :data:`~cybsi.api.Null` means
                that Cybsi can use confidence provided by data source type.
                :data:`None` means that confidence is left unchanged.
        Raises:
            :class:`~cybsi.api.error.InvalidRequestError`:
                Provided arguments have invalid values.
            :class:`~cybsi.api.error.NotFoundError`: Data source not found.
            :class:`~cybsi.api.error.ResourceModifiedError`:
                Data source changed since last request. Update tag and retry.
        """
        form: Dict[str, Any] = {}
        if long_name is not None:
            form["longName"] = long_name
        if manual_confidence is not None:
            form["manualConfidence"] = _unwrap_nullable(manual_confidence)
        path = f"{_PATH}/{type_uuid}"
        await self._connector.do_patch(path=path, tag=tag, json=form)

    async def filter(
        self,
        *,
        query: Optional[str] = None,
        type_uuids: Optional[Iterable[uuid.UUID]] = None,
        order_by: Optional[DataSourceListOrder] = None,
        cursor: Optional[Cursor] = None,
        limit: Optional[int] = None,
    ) -> AsyncPage["DataSourceCommonView"]:
        """Get a filtered list of data sources.

        Note:
            Calls `GET /data-sources`.
        Args:
            query: Filter of data sources by specified substring (case-insensitive).
                Substring length must be in range [1, 50].
                Filtering is performed by specified substring in data source names or
                its type names.
            type_uuids: List of data source type UUIDs.
            order_by: Data source list sort key.
                If ``order_by`` is :data:`None` then the order is not specified.
                But results are stable sorted when paging.

                For "FullName" key, sorting is performed in case-insensitive manner
                in lexicographic order.

                If ``order_by`` is not :data:`None` then it is necessary to pass
                the parameter to each next request along with a non-empty cursor.
                Otherwise, the sort will be reset to the "default" sort,
                which may lead to inconsistency in the data selection.
            cursor: Page cursor.
            limit: Page limit.
        Returns:
            Page with data source common views and next page cursor.
        Raises:
            :class:`~cybsi.api.error.SemanticError`
        Note:
            Semantic error codes:
              * :attr:`~cybsi.api.error.SemanticErrorCodes.DataSourceTypeNotFound`
        """

        params: Dict[str, Any] = {}
        if query is not None:
            params["query"] = query
        if type_uuids is not None:
            params["typeUUID"] = [str(u) for u in type_uuids]
        if order_by is not None:
            params["orderBy"] = order_by.value
        if cursor:
            params["cursor"] = str(cursor)
        if limit:
            params["limit"] = str(limit)

        resp = await self._connector.do_get(path=_PATH, params=params)
        page = AsyncPage(self._connector.do_get, resp, DataSourceCommonView)
        return page


class DataSourceForm(JsonObjectForm):
    """Data source form.

    This is the form you need to fill to register data source.

    Args:
        type_uuid: Data source type UUID.
        name: Data source identifier. Must be unique name for data source type.
            Name should consist of characters without spaces (`[a-zA-Z0-9_-]`) and
            have length in the range [1, 250].
        long_name: Human-readable data source name.
            Long name length must be in the range [1, 250].
        manual_confidence:
            Confidence of the data source.
            Overrides confidence of the data source inherited from data source type.
            Valid values are in [0, 1].
    Return:
        Data source register form.
    """

    def __init__(
        self,
        type_uuid: uuid.UUID,
        *,
        name: str,
        long_name: str,
        manual_confidence: Optional[float] = None,
    ):
        super().__init__()
        self._data["typeUUID"] = str(type_uuid)
        self._data["name"] = name
        self._data["longName"] = long_name
        if manual_confidence is not None:
            self._data["manualConfidence"] = manual_confidence


class DataSourceView(_TaggedRefView):
    """View of data source."""

    @property
    def type(self) -> DataSourceTypeCommonView:
        """Data source type."""
        return DataSourceTypeCommonView(self._get("type"))

    @property
    def name(self) -> str:
        """Data source identifier. Must be unique name for data source type."""
        return self._get("name")

    @property
    def long_name(self) -> str:
        """Human-readable data source name."""
        return self._get("longName")

    @property
    def confidence(self) -> float:
        """Confidence of data source."""
        return self._get("confidence")

    @property
    def manual_confidence(self) -> Optional[float]:
        """Manually set confidence of the data source. Overrides
        confidence of the data source type."""
        return self._get_optional("manualConfidence")


class DataSourceCommonView(RefView):
    """Data source short view."""

    @property
    def long_name(self) -> str:
        """Human-readable data source name."""
        return self._get("longName")

    @property
    def unique_name(self) -> str:
        """The unique identifier contains of the type `ShortName` and
        the source `Name` (ShortName/Name)."""
        return self._get("uniqueName")

    @property
    def confidence(self) -> float:
        """Confidence of data source."""
        return self._get("confidence")

    @property
    def type(self) -> "DataSourceTypeCommonView":
        """Data source type short view."""
        return DataSourceTypeCommonView(self._get("type"))


class DataSourceLinksView(JsonObjectView):
    """Data source links view"""

    @property
    def analyzer(self) -> Optional[RefView]:
        """Reference to analyzer.

        Use :meth:`~cybsi.api.enrichment.analyzers.AnalyzersAPI.view` to retrieve analyzer view.
        """  # noqa: E501
        return self._map_optional("analyzer", RefView)

    @property
    def external_db(self) -> Optional[RefView]:
        """Reference to external database.

        Use :meth:`~cybsi.api.enrichment.external_dbs.ExternalDBsAPI.view` to retrieve external db view.
        """  # noqa: E501
        return self._map_optional("externalDB", RefView)

    @property
    def user(self) -> Optional[RefView]:
        """Reference to user.

        .. deprecated:: 2.8

        Use :meth:`~cybsi.api.user.UsersAPI.view` to retrieve user view.
        """
        return self._map_optional("user", RefView)

    @property
    def users(self) -> Optional[List[RefView]]:
        """References to users.

        Use :meth:`~cybsi.api.user.UsersAPI.view` to retrieve user view.
        """
        return self._map_list_optional("users", RefView)

cybsi/api/data_source/api.py
cyberstudio/cybsi-sdk
import uuid
from typing import Any, Dict, Optional

from .. import RefView
from ..api import Nullable, Tag, _unwrap_nullable
from ..internal import BaseAPI, BaseAsyncAPI, JsonObjectForm
from ..pagination import AsyncPage, Cursor, Page
from ..view import _TaggedRefView
from .enums import DataSourceTypeListOrder

_PATH = "/data-source-types"


class DataSourceTypesAPI(BaseAPI):
    """API to operate data source types."""

    def register(self, form: "DataSourceTypeForm") -> RefView:
        """Register a data source type.

        Note:
            Calls `POST /data-source-types`.
        Args:
            form: Filled data source type form.
        Raises:
            :class:`~cybsi.api.error.ConflictError`: DataSourceType
                already exist.
        """
        r = self._connector.do_post(path=_PATH, json=form.json())
        return RefView(r.json())

    def view(self, type_uuid: uuid.UUID) -> "DataSourceTypeView":
        """Get the data source type view.

        Note:
            Calls `GET /data-source-types/{type_uuid}`.
        Args:
            type_uuid: Data source UUID.
        Returns:
            View of the data source type.
        Raises:
            :class:`~cybsi.api.error.NotFoundError`: Data source type not found.
        """
        path = f"{_PATH}/{type_uuid}"
        r = self._connector.do_get(path)
        return DataSourceTypeView(r)

    def edit(
        self,
        type_uuid: uuid.UUID,
        tag: Tag,
        *,
        long_name: Optional[str] = None,
        manual_confidence: Nullable[float] = None,
    ) -> None:
        """Edit the data source type.

        Note:
            Calls `PATCH /data-source-types/{type_uuid}`.
        Args:
            type_uuid: Data source type UUID.
            tag: :attr:`DataSourceTypeView.tag` value. Use :meth:`view` to retrieve it.
            long_name:  Human-readable data source type name.
                Non-empty if not :data:`None`.
            manual_confidence:
                Confidence for datasource type.
                Overrides default confidence of the data source type.
                Valid values are in [0, 1].
                :data:`~cybsi.api.Null` means
                that Cybsi can use default confidence.
                :data:`None` means that confidence is left unchanged.
        Raises:
            :class:`~cybsi.api.error.InvalidRequestError`:
                Provided arguments have invalid values.
            :class:`~cybsi.api.error.NotFoundError`: Data source type not found.
            :class:`~cybsi.api.error.ResourceModifiedError`:
                Data source type changed since last request. Update tag and retry.
        """
        form: Dict[str, Any] = {}
        if long_name is not None:
            form["longName"] = long_name
        if manual_confidence is not None:
            form["manualConfidence"] = _unwrap_nullable(manual_confidence)
        path = f"{_PATH}/{type_uuid}"
        self._connector.do_patch(path=path, tag=tag, json=form)

    def filter(
        self,
        *,
        order_by: Optional[DataSourceTypeListOrder] = None,
        cursor: Optional[Cursor] = None,
        limit: Optional[int] = None,
    ) -> Page["DataSourceTypeCommonView"]:
        """Get a filtered list of data source type.

        .. versionchanged:: 2.8
            Added new parameters: `order_by`.

        Note:
            Calls `GET /data-source-types`.
        Args:
            order_by: Data source type list sort key. Default value is "ShortName".
                The sort is performed in case-insensitive manner in lexicographic order.

                If ``order_by`` is not :data:`None` then it is necessary to pass
                the parameter to each next request along with a non-empty cursor.
                Otherwise, the sort will be reset to the "default" sort
                which may lead to inconsistency in the data selection.
            cursor: Page cursor.
            limit: Page limit.
        Returns:
            Page with data source type common views and next page cursor.
        Usage:
            >>> import uuid
            >>> from cybsi.api import CybsiClient
            >>> from cybsi.api.pagination import chain_pages
            >>> from cybsi.api.data_source import DataSourceTypeListOrder
            >>>
            >>> client: CybsiClient
            >>> # filter data sources by specified sort
            >>> started_page = client.data_source_types.filter(
            >>>     order_by=DataSourceTypeListOrder.LongName,
            >>> )
            >>> for item in chain_pages(started_page):
            >>>     # do something with data source type
            >>>     print(item)
            >>> pass
        """

        params: Dict[str, Any] = {}
        if order_by is not None:
            params["orderBy"] = order_by.value
        if cursor:
            params["cursor"] = str(cursor)
        if limit:
            params["limit"] = str(limit)

        resp = self._connector.do_get(path=_PATH, params=params)
        page = Page(self._connector.do_get, resp, DataSourceTypeCommonView)
        return page


class DataSourceTypesAsyncAPI(BaseAsyncAPI):
    """API to operate data source types."""

    async def register(self, form: "DataSourceTypeForm") -> RefView:
        """Register a data source type.

        Note:
            Calls `POST /data-source-types`.
        Args:
            form: Filled data source type form.
        Raises:
            :class:`~cybsi.api.error.ConflictError`: DataSourceType
                already exist.
        """
        r = await self._connector.do_post(path=_PATH, json=form.json())
        return RefView(r.json())

    async def view(self, type_uuid: uuid.UUID) -> "DataSourceTypeView":
        """Get the data source type view.

        Note:
            Calls `GET /data-source-types/{type_uuid}`.
        Args:
            type_uuid: Data source UUID.
        Returns:
            View of the data source type.
        Raises:
            :class:`~cybsi.api.error.NotFoundError`: Data source type not found.
        """
        path = f"{_PATH}/{type_uuid}"
        r = await self._connector.do_get(path)
        return DataSourceTypeView(r)

    async def edit(
        self,
        type_uuid: uuid.UUID,
        tag: Tag,
        *,
        long_name: Optional[str] = None,
        manual_confidence: Nullable[float] = None,
    ) -> None:
        """Edit the data source type.

        Note:
            Calls `PATCH /data-source-types/{type_uuid}`.
        Args:
            type_uuid: Data source type UUID.
            tag: :attr:`DataSourceTypeView.tag` value. Use :meth:`view` to retrieve it.
            long_name:  Human-readable data source type name.
                Non-empty if not :data:`None`.
            manual_confidence:
                Confidence for datasource type.
                Overrides default confidence of the data source type.
                Valid values are in [0, 1].
                :data:`~cybsi.api.Null` means
                that Cybsi can use default confidence.
                :data:`None` means that confidence is left unchanged.
        Raises:
            :class:`~cybsi.api.error.InvalidRequestError`:
                Provided arguments have invalid values.
            :class:`~cybsi.api.error.NotFoundError`: Data source type not found.
            :class:`~cybsi.api.error.ResourceModifiedError`:
                Data source type changed since last request. Update tag and retry.
        """
        form: Dict[str, Any] = {}
        if long_name is not None:
            form["longName"] = long_name
        if manual_confidence is not None:
            form["manualConfidence"] = _unwrap_nullable(manual_confidence)
        path = f"{_PATH}/{type_uuid}"
        await self._connector.do_patch(path=path, tag=tag, json=form)

    async def filter(
        self,
        *,
        order_by: Optional[DataSourceTypeListOrder] = None,
        cursor: Optional[Cursor] = None,
        limit: Optional[int] = None,
    ) -> AsyncPage["DataSourceTypeCommonView"]:
        """Get a filtered list of data source type.

        Note:
            Calls `GET /data-source-types`.
        Args:
            order_by: Data source type list sort key. Default value is "ShortName".
                The sort is performed in case-insensitive manner in lexicographic order.

                If ``order_by`` is not :data:`None` then it is necessary to pass
                the parameter to each next request along with a non-empty cursor.
                Otherwise, the sort will be reset to the "default" sort
                which may lead to inconsistency in the data selection.
            cursor: Page cursor.
            limit: Page limit.
        Returns:
            Page with data source type common views and next page cursor.
        """

        params: Dict[str, Any] = {}
        if order_by is not None:
            params["orderBy"] = order_by.value
        if cursor:
            params["cursor"] = str(cursor)
        if limit:
            params["limit"] = str(limit)

        resp = await self._connector.do_get(path=_PATH, params=params)
        page = AsyncPage(self._connector.do_get, resp, DataSourceTypeCommonView)
        return page


class DataSourceTypeForm(JsonObjectForm):
    """Data source type form.

    This is the form you need to fill to register data source type.

    Args:
        short_name: Data source type identifier. Must be unique across all data
            source types. Non-empty.
            Short name should consist of characters without spaces (`[a-zA-Z0-9_-]`) and
            have length in the range [1, 250].
        long_name: Human-readable data source type name. Non-empty.
            Long name length must be in the range [1, 250].
        manual_confidence: Confidence for datasource type.
            Overrides default confidence of the data source type.
            Valid values are in [0, 1].
    Return:
        Datasource Type register form.
    """

    def __init__(
        self,
        *,
        short_name: str,
        long_name: str,
        manual_confidence: Optional[float] = None,
    ):
        super().__init__()
        self._data["shortName"] = short_name
        self._data["longName"] = long_name
        if manual_confidence is not None:
            self._data["manualConfidence"] = manual_confidence


class DataSourceTypeView(_TaggedRefView):
    """View of data source type."""

    @property
    def short_name(self) -> str:
        """Data source type identifier. Unique across all data source types."""
        return self._get("shortName")

    @property
    def long_name(self) -> str:
        """Human-readable data source type name."""
        return self._get("longName")

    @property
    def confidence(self) -> float:
        """Confidence."""
        return self._get("confidence")

    @property
    def manual_confidence(self) -> Optional[float]:
        """Manually set confidence for datasource type.
        Overrides confidence of the data source type.
        """
        return self._get_optional("manualConfidence")


class DataSourceTypeCommonView(RefView):
    """Data source type short view"""

    @property
    def long_name(self) -> str:
        """Human-readable data source type name."""
        return self._get("longName")

    @property
    def confidence(self) -> float:
        """Confidence of data source type."""
        return self._get("confidence")

cybsi/api/data_source/api_types.py
cyberstudio/cybsi-sdk
from enum_tools import document_enum

from cybsi.api.enum import CybsiAPIEnum


@document_enum
class DataSourceListOrder(CybsiAPIEnum):
    """Sorting field of data sources."""

    FullName = "FullName"
    """Sort by FullName means sort by `LongName` of type + `LongName` of datasource."""


@document_enum
class DataSourceTypeListOrder(CybsiAPIEnum):
    """Sorting field of data source types."""

    ShortName = "ShortName"  # doc: Sort by ShortName field.
    LongName = "LongName"  # doc: Sort by LongName field.

cybsi/api/data_source/enums.py
cyberstudio/cybsi-sdk
"""Use this section of API to operate dictionaries and their items.
    Dictionary is an open set of string values. Dictionary items are used
    as attribute values. For example, dictionary "MalwareFamilies" contains names
    of malware families known by system.
    Use :meth:`cybsi.api.observation.generic.GenericObservationForm.add_attribute_fact`
    or :meth:`~DictionariesAPI.register_item()` to add items to a dictionary.
"""

from .api import (
    DictionariesAPI,
    DictionaryItemForm,
    DictionaryView,
    DictionaryCommonView,
    DictionaryItemView,
    DictionaryItemCommonView,
    DictItemAttributeValue,
)

cybsi/api/dictionary/__init__.py
cyberstudio/cybsi-sdk
import uuid
from typing import Any, Dict, Optional

from .. import RefView
from ..api import Tag
from ..internal import BaseAPI, JsonObjectForm
from ..pagination import Cursor, Page
from ..view import _TaggedRefView


class DictionariesAPI(BaseAPI):
    """API to operate dictionaries.

    .. versionadded:: 2.9
    """

    _path = "/dictionaries"
    _path_dictionary_items = "/dictionary-items"

    def view(self, dictionary_uuid: uuid.UUID) -> "DictionaryView":
        """Get the dictionary view.

        .. versionadded:: 2.9
        Note:
            Calls `GET /dictionaries/{dictionary_uuid}`.
        Args:
            dictionary_uuid: Dictionary UUID.
        Returns:
            View of the dictionary.
        Raises:
            :class:`~cybsi.api.error.NotFoundError`: Dictionary not found.
        """
        path = f"{self._path}/{dictionary_uuid}"
        r = self._connector.do_get(path)
        return DictionaryView(r.json())

    def filter(
        self,
        *,
        cursor: Optional[Cursor] = None,
        limit: Optional[int] = None,
    ) -> Page["DictionaryCommonView"]:
        """Get a filtered list of dictionaries.

        Results are sorted in alphabetical order by dictionary name.

        .. versionadded:: 2.9
        Note:
            Calls `GET /dictionaries`.
        Args:
            cursor: Page cursor.
            limit: Page limit.
        Returns:
            Page with dictionary views and next page cursor.
        Usage:
            >>> import uuid
            >>> from cybsi.api import CybsiClient
            >>> from cybsi.api.pagination import chain_pages
            >>>
            >>> client: CybsiClient
            >>>
            >>> started_page = client.dictionaries.filter()
            >>> for item in chain_pages(started_page):
            >>>     # do something with dictionaries
            >>>     print(item)
            >>> pass
        """

        params: Dict[str, Any] = {}
        if cursor:
            params["cursor"] = str(cursor)
        if limit:
            params["limit"] = str(limit)

        resp = self._connector.do_get(path=self._path, params=params)
        page = Page(self._connector.do_get, resp, DictionaryCommonView)
        return page

    def register_item(self, item: "DictionaryItemForm") -> RefView:
        """Register item for the dictionary.

        .. versionadded:: 2.9
        .. versionchanged:: 2.10
            Added semantic error `DictionaryClosed`
        .. versionchanged:: 2.11
            Move `dictionary_uuid` parameter into `DictionaryItemForm`
        Note:
            Calls `POST /dictionary-items`.
        Args:
            item: Filled dictionary item form.
        Returns:
            Reference to a newly added dictionary item.
        Raises:
            :class:`~cybsi.api.error.SemanticError`: Form contains logic errors.
            :class:`~cybsi.api.error.InvalidRequestError`:
                Provided values are invalid (see form value requirements).
        Note:
            Semantic error codes specific for this method:
              * :attr:`~cybsi.api.error.SemanticErrorCodes.DictionaryNotFound`
              * :attr:`~cybsi.api.error.SemanticErrorCodes.DictionaryClosed`
        """

        r = self._connector.do_post(path=self._path_dictionary_items, json=item.json())
        return RefView(r.json())

    def view_item(self, item_uuid: uuid.UUID) -> "DictionaryItemView":
        """Get the dictionary item view.

        .. versionadded:: 2.9
        Note:
            Calls `GET /dictionary-items/{item_uuid}`.
        Args:
            item_uuid: Dictionary item UUID.
        Returns:
            View of the dictionary item with tag.
        Raises:
            :class:`~cybsi.api.error.NotFoundError`: Dictionary item not found.
        """
        path = f"{self._path_dictionary_items}/{item_uuid}"
        r = self._connector.do_get(path)
        return DictionaryItemView(r)

    def edit_item(
        self,
        item_uuid: uuid.UUID,
        tag: Tag,
        *,
        description: Optional[str] = None,
    ) -> None:
        """Edit the dictionary item.

        .. versionadded:: 2.9
        .. versionchanged:: 2.10
            Added semantic error `DictionaryClosed`
        Note:
            Calls `PATCH /dictionary-items/{item_uuid}`.
        Args:
            item_uuid: Dictionary item UUID.
            tag: :attr:`DictionaryItemView.tag` value. Use :meth:`view_item`
                to retrieve it.
            description: Dictionary item description.
                Length must be in range [1;3000].
        Raises:
            :class:`~cybsi.api.error.InvalidRequestError`:
                Provided value are invalid (see args value requirements).
            :class:`~cybsi.api.error.NotFoundError`: Dictionary item not found.
            :class:`~cybsi.api.error.ResourceModifiedError`:
                Dictionary item changed since last request. Update tag and retry.
            :class:`~cybsi.api.error.SemanticError`: Form contains logic errors.
        Note:
            Semantic error codes specific for this method:
              * :attr:`~cybsi.api.error.SemanticErrorCodes.DictionaryClosed`
        """
        form = {}
        if description is not None:
            form["description"] = description

        path = f"{self._path_dictionary_items}/{item_uuid}"
        self._connector.do_patch(path=path, tag=tag, json=form)

    def filter_items(
        self,
        dictionary_uuid: uuid.UUID,
        *,
        prefix: Optional[str] = None,
        cursor: Optional[Cursor] = None,
        limit: Optional[int] = None,
    ) -> Page["DictionaryItemCommonView"]:
        """Get a filtered list of dictionary items.

        Results are sorted in alphabetical order by dictionary item name.

        .. versionadded:: 2.9
        Note:
            Calls `GET /dictionaries/{dictionaryUUID}/items`.
        Args:
            dictionary_uuid: Open dictionary UUID.
            prefix: Dictionary item name prefix (case-insensitive).
                Prefix length must be in range [1;30].
            cursor: Page cursor.
            limit: Page limit.
        Returns:
            Page with dictionary items common views and next page cursor.
        Usage:
            >>> import uuid
            >>> from cybsi.api import CybsiClient
            >>> from cybsi.api.pagination import chain_pages
            >>>
            >>> client: CybsiClient
            >>>
            >>> started_page = client.dictionaries.filter_items(
            >>>     dictionary_uuid=uuid.UUID("89200bef-2f50-4d4f-8b38-843d5ab9dfa9"),
            >>> )
            >>> for item in chain_pages(started_page):
            >>>     # do something with dictionary items
            >>>     print(item)
            >>> pass
        """

        params: Dict[str, Any] = {}
        if prefix is not None:
            params["prefix"] = prefix
        if cursor:
            params["cursor"] = str(cursor)
        if limit:
            params["limit"] = str(limit)

        path = f"{self._path}/{dictionary_uuid}/items"
        resp = self._connector.do_get(path=path, params=params)
        page = Page(self._connector.do_get, resp, DictionaryItemCommonView)
        return page

    def add_item_synonym(
        self, *, item_uuid: uuid.UUID, synonym_uuid: uuid.UUID
    ) -> None:
        """Add dictionary item to synonym group.

        .. versionadded:: 2.9
        .. versionchanged:: 2.10
            Added semantic error `DictionaryClosed`
        Note:
            Calls `PUT /dictionary-items/{item_uuid}/synonyms`.
        Args:
            item_uuid: Dictionary item UUID.
            synonym_uuid: Synonym item UUID.
        Raises:
            :class:`~cybsi.api.error.SemanticError`: Form contains logic errors.
            :class:`~cybsi.api.error.InvalidRequestError`:
                Provided values are invalid (see form value requirements).
            :class:`~cybsi.api.error.NotFoundError`: Dictionary item not found.
        Note:
            Semantic error codes specific for this method:
              * :attr:`~cybsi.api.error.SemanticErrorCodes.InvalidDictionary`
              * :attr:`~cybsi.api.error.SemanticErrorCodes.SynonymGroupConflict`
              * :attr:`~cybsi.api.error.SemanticErrorCodes.ItemAlreadyInSynonymGroup`
              * :attr:`~cybsi.api.error.SemanticErrorCodes.InvalidSynonym`
              * :attr:`~cybsi.api.error.SemanticErrorCodes.DictionaryItemNotFound`
              * :attr:`~cybsi.api.error.SemanticErrorCodes.DictionaryClosed`
        """

        form = {"synonymUUID": str(synonym_uuid)}
        path = f"{self._path_dictionary_items}/{item_uuid}/synonyms"
        self._connector.do_put(path=path, json=form)

    def remove_item_synonym(self, item_uuid: uuid.UUID) -> None:
        """Remove dictionary item from synonym group.

        .. versionadded:: 2.9
        .. versionchanged:: 2.10
            Added semantic error `DictionaryClosed`
        Note:
            Calls `DELETE /dictionary-items/{item_uuid}/synonyms`.
        Args:
            item_uuid: Dictionary item UUID.
        Raises:
            :class:`~cybsi.api.error.NotFoundError`: Dictionary item not found.
            :class:`~cybsi.api.error.SemanticError`: Form contains logic errors.
        Note:
            Semantic error codes specific for this method:
              * :attr:`~cybsi.api.error.SemanticErrorCodes.DictionaryClosed`
        """

        path = f"{self._path_dictionary_items}/{item_uuid}/synonyms"
        self._connector.do_delete(path=path)

    def add_related_item(
        self, *, item_uuid: uuid.UUID, related_uuid: uuid.UUID
    ) -> None:
        """Add related item for dictionary item.

        .. versionadded:: 2.11

        Note:
            Calls `PUT /dictionary-items/{item_uuid}/related-items`.
        Args:
            item_uuid: Dictionary item UUID.
            related_uuid: Related dictionary item UUID.
        Raises:
            :class:`~cybsi.api.error.NotFoundError`: Dictionary item not found.
            :class:`~cybsi.api.error.SemanticError`: Request contains logic errors.
        Note:
            Semantic error codes specific for this method:
              * :attr:`~cybsi.api.error.SemanticErrorCodes.DictionaryItemNotFound`
              * :attr:`~cybsi.api.error.SemanticErrorCodes.InvalidDictionary`
        """

        form = {"relatedItemUUID": str(related_uuid)}
        path = f"{self._path_dictionary_items}/{item_uuid}/related-items"
        self._connector.do_put(path=path, json=form)

    def remove_related_item(
        self, *, item_uuid: uuid.UUID, related_uuid: uuid.UUID
    ) -> None:
        """Remove related item for dictionary item.

        .. versionadded:: 2.11

        Note:
            Calls `DELETE /dictionary-items/{item_uuid}/related-items/{related_uuid}`.
        Args:
            item_uuid: Dictionary item UUID.
            related_uuid: Related dictionary item UUID.
        Raises:
            :class:`~cybsi.api.error.NotFoundError`: Dictionary item not found.
        """

        path = f"{self._path_dictionary_items}/{item_uuid}/related-items/{related_uuid}"
        self._connector.do_delete(path=path)


class DictionaryCommonView(RefView):
    """Dictionary common view."""

    @property
    def name(self) -> str:
        """Dictionary name."""
        return self._get("name")


class DictionaryView(DictionaryCommonView):
    """Dictionary detailed view."""

    @property
    def is_closed(self) -> bool:
        """Closed dictionary flag."""
        return self._get("isClosed")


class DictionaryItemCommonView(RefView):
    """Dictionary item common view."""

    @property
    def key(self) -> str:
        """Dictionary item key."""
        return self._get("key")


class DictionaryItemView(_TaggedRefView, DictionaryItemCommonView):
    """Dictionary item detailed view."""

    @property
    def dictionary(self) -> RefView:
        """Reference to dictionary."""
        return self._get("dictionary")

    @property
    def description(self) -> Optional[str]:
        """Dictionary item description."""
        return self._get_optional("description")

    @property
    def synonyms(self) -> Optional[RefView]:
        """List of refs to dictionary item that are synonyms."""
        return self._map_list_optional("synonyms", RefView)


class DictionaryItemForm(JsonObjectForm):
    """Dictionary item form. Use to add dictionary item.

    Args:
        dictionary_uuid: Dictionary identifier.
        key: Dictionary item key. The key should consist of characters
            according to the pattern `[a-zA-Z0-9_ -]` and
            have length in the range `[1;30]`.
            User-specified key case is preserved.
    """

    def __init__(self, *, dictionary_uuid: uuid.UUID, key: str):
        super().__init__()
        self._data["dictionaryUUID"] = str(dictionary_uuid)
        self._data["key"] = key


class DictItemAttributeValue(JsonObjectForm):
    """Dictionary item. Used as attribute value to create a general observation.
    See :meth:`~cybsi.api.observation.generic.GenericObservationForm.add_attribute_fact`

    Args:
       key: Dictionary item key. The key should consist of characters
            according to the pattern `[a-zA-Z0-9_ -]` and
            have length in the range `[1;30]`.
            User-specified key case is preserved.
    """

    def __init__(self, *, key: str):
        super().__init__()
        self._data["key"] = key

cybsi/api/dictionary/api.py
cyberstudio/cybsi-sdk
"""Use this section of API to control
enrichment configuration and enrichment results.
"""
from .api import EnrichmentAPI, EnrichmentAsyncAPI

from .config_rules import (
    ConfigRulesAPI,
    ConfigRuleView,
    ConfigRuleCommonView,
    ConfigRuleForm,
)

from .tasks import (
    TasksAPI,
    TaskView,
    TaskForm,
    ArtifactAnalysisParamsView,
    ArtifactDownloadParamsView,
    ArchiveUnpackParamsView,
    ExternalDBLookupParamsView,
    WhoisLookupParamsView,
    DNSLookupParamsView,
    ArtifactAnalysisParamsForm,
    ArtifactDownloadParamsForm,
    ArchiveUnpackParamsForm,
    ExternalDBLookupParamsForm,
    WhoisLookupParamsForm,
    DNSLookupParamsForm,
    ArtifactTaskResultView,
    ReportTaskResultView,
    ObservationTaskResultView,
    EnrichmentTaskErrorView,
)

from .task_queue import (
    TaskQueueAPI,
    AssignedTaskView,
    FailedTaskForm,
    CompletedTaskForm,
    TaskResultReportForm,
    TaskResultArtifactForm,
    TaskResultObservationForm,
)

from .external_dbs import (
    ExternalDBsAPI,
    ExternalDBView,
    ExternalDBForm,
)

from .analyzers import (
    AnalyzersAPI,
    AnalyzerView,
    AnalyzerForm,
)

from .enums import (
    EnrichmentErrorCodes,
    EnrichmentTaskPriorities,
    EnrichmentTypes,
    EnrichmentTriggerTypes,
    EnrichmentTaskStatuses,
)

cybsi/api/enrichment/__init__.py
cyberstudio/cybsi-sdk
"""Use this section of API to operate analyzers.

Analyzers are systems outside Cybsi. They can be queried by Cybsi
for information about artifacts. The result of such query is report.
The reports can contain observations and artifacts. The artifact is a regular file
with additional attributes that can be analyzed or unpacked by the system.
The observation typically provides group of facts obtained after analyzing an artifact.
"""
import uuid
from typing import Any, Dict, Iterable, List, Optional

from .. import RefView
from ..api import Nullable, Tag, _unwrap_nullable
from ..artifact import ArtifactTypes
from ..data_source import DataSourceCommonView
from ..internal import BaseAPI, JsonObjectForm
from ..pagination import Cursor, Page
from ..view import _TaggedRefView


class AnalyzersAPI(BaseAPI):
    """Analyzers API.

    .. versionadded:: 2.7
    """

    _path = "/enrichment/analyzers"

    def view(self, analyzer_uuid: uuid.UUID) -> "AnalyzerView":
        """Get the analyzer view.

        .. versionadded:: 2.7

        Note:
            Calls `GET /enrichment/analyzers/{analyzer_uuid}`.
        Args:
            analyzer_uuid: Analyzer uuid.
        Returns:
            View of the analyzer.
        Raises:
            :class:`~cybsi.api.error.NotFoundError`: Analyzer not found.
        """
        path = f"{self._path}/{analyzer_uuid}"
        r = self._connector.do_get(path=path)
        return AnalyzerView(r)

    def register(self, form: "AnalyzerForm") -> RefView:
        """Register analyzer.

        .. versionadded:: 2.7

        Note:
            Calls `POST /enrichment/analyzers`.
        Args:
            form: Filled analyzer form.
        Returns:
            Reference to analyzer in API.
        Raises:
            :class:`~cybsi.api.error.SemanticError`: Form contains logic errors.
            :class:`~cybsi.api.error.ConflictError`:
                An analyzer with such data source is already registered.
        Note:
            Semantic error codes:
              * :attr:`~cybsi.api.error.SemanticErrorCodes.DataSourceNotFound`
        """
        r = self._connector.do_post(path=self._path, json=form.json())
        return RefView(r.json())

    def edit(
        self,
        analyzer_uuid: uuid.UUID,
        tag: Tag,
        *,
        artifact_types: Optional[Iterable[ArtifactTypes]] = None,
        artifact_size_limit: Nullable[int] = None,
        dashboard_url: Nullable[str] = None,
        task_execution_timeout: Nullable[int] = None,
        task_execution_attempts_count: Nullable[int] = None,
    ) -> None:
        """Edit the analyzer.

        .. versionadded:: 2.7

        Note:
            Calls `PATCH /enrichment/analyzers/{analyzer_uuid}`.
        Args:
            analyzer_uuid: Analyzer uuid.
            tag: :attr:`AnalyzerView.tag` value. Use :meth:`view` to retrieve it.
            artifact_types: Non-empty artifact types list, if not :data:`None`.
            artifact_size_limit:
                Maximum allowable size of an artifact for analysis, bytes.
                :data:`~cybsi.api.Null` means there is no limit.
                :data:`None` means limit is left unchanged.
            dashboard_url: Analyzer panel link.
                :data:`~cybsi.api.Null` resets dashboard URL to empty value.
                :data:`None` means URL is left unchanged.
            task_execution_timeout: Enricher task execution timeout, sec.
                Timeout must be in range [1;864000].
                :data:`~cybsi.api.Null` means that Cybsi can use default timeout.
                :data:`None` means timeout is left unchanged.
            task_execution_attempts_count:
                The maximum number of attempts to complete the task by the enricher.
                Count must be in range [1;1000].
                :data:`~cybsi.api.Null` means that Cybsi can use default count.
                :data:`None` means that count is left unchanged.
        Raises:
            :class:`~cybsi.api.error.InvalidRequestError`:
                Provided arguments have invalid values.
            :class:`~cybsi.api.error.NotFoundError`: Analyzer not found.
            :class:`~cybsi.api.error.ResourceModifiedError`:
                Analyzer changed since last request. Update tag and retry.
        """
        form: Dict[str, Any] = {}
        if artifact_types is not None:
            form["artifactTypes"] = [t.value for t in artifact_types]
        if artifact_size_limit is not None:
            form["artifactSizeLimit"] = _unwrap_nullable(artifact_size_limit)
        if dashboard_url is not None:
            form["dashboardURL"] = _unwrap_nullable(dashboard_url)
        if task_execution_timeout is not None:
            form["taskExecutionTimeout"] = _unwrap_nullable(task_execution_timeout)
        if task_execution_attempts_count is not None:
            form["taskExecutionAttemptsCount"] = _unwrap_nullable(
                task_execution_attempts_count
            )
        path = f"{self._path}/{analyzer_uuid}"
        self._connector.do_patch(path=path, tag=tag, json=form)

    def filter(
        self,
        *,
        artifact_types: Optional[Iterable[ArtifactTypes]] = None,
        data_source_uuid: Optional[uuid.UUID] = None,
        cursor: Optional[Cursor] = None,
        limit: Optional[int] = None,
    ) -> Page["AnalyzerCommonView"]:
        """Get page of filtered analyzers list.

        Note:
            Calls `GET /enrichment/analyzers`
        Args:
            artifact_types: Artifact types list.
                Select analyzers that accept any specified artifact type.
            data_source_uuid: Data source identifier.
                Select analyzers by associated data source identifier.
            cursor: Page cursor.
            limit: Page limit.
        Returns:
            Page of filtered analyzers list and next page cursor.
        Raises:
            :class:`~cybsi.api.error.SemanticError`: query arguments contain errors.
        Note:
            Semantic error codes specific for this method:
              * :attr:`~cybsi.api.error.SemanticErrorCodes.DataSourceNotFound`
        """
        params: Dict[str, Any] = {}

        if artifact_types is not None:
            params["artifactType"] = [t.value for t in artifact_types]
        if data_source_uuid is not None:
            params["dataSourceUUID"] = str(data_source_uuid)
        if cursor:
            params["cursor"] = str(cursor)
        if limit:
            params["limit"] = str(limit)

        resp = self._connector.do_get(self._path, params=params)
        page = Page(self._connector.do_get, resp, AnalyzerCommonView)
        return page


class AnalyzerCommonView(RefView):
    """Analyzer common view"""

    @property
    def artifact_types(self) -> List[ArtifactTypes]:
        """Artifact types we can enrich in the analyzer."""
        return [ArtifactTypes(typ) for typ in self._get("artifactTypes")]

    @property
    def artifact_size_limit(self) -> Optional[int]:
        """Maximum allowable size of an artifact for analysis, bytes."""
        return self._get_optional("artifactSizeLimit")

    @property
    def dashboard_url(self) -> Optional[str]:
        """Analyzer panel link."""
        return self._get_optional("dashboardURL")

    @property
    def task_execution_timeout(self) -> Optional[int]:
        """Enricher task execution timeout, sec"""
        return self._get_optional("taskExecutionTimeout")

    @property
    def task_execution_attempts_count(self) -> Optional[int]:
        """The maximum number of attempts to complete the task by the enricher."""
        return self._get_optional("taskExecutionAttemptsCount")

    @property
    def data_source(self) -> "DataSourceCommonView":
        """Data source view representing analyzer."""
        return DataSourceCommonView(self._get("dataSource"))


class AnalyzerView(_TaggedRefView, AnalyzerCommonView):
    pass


class AnalyzerForm(JsonObjectForm):
    """Analyzer form.

    This is the form you need to fill to register analyzer.

    Args:
        data_source_uuid: Data source identifier representing analyzer.
            Unique for analyzer.
        artifact_types: Non-empty artifact types list.
        artifact_size_limit: Maximum allowable size of an artifact for analysis, bytes.
            Empty value means there is no limit.
        dashboard_url: Analyzer panel link.
        task_execution_timeout: Enricher task execution timeout, sec.
            Timeout must be in range [1;864000].
        task_execution_attempts_count: The maximum number of attempts
            to complete the task by the enricher. Count must be in range [1;1000].
    Usage:
        >>> import uuid
        >>> from cybsi.api.enrichment import AnalyzerForm
        >>> from cybsi.api.artifact import ArtifactTypes
        >>> analyzer = AnalyzerForm(
        >>>     data_source_uuid=uuid.UUID("4fd3126f-a0e8-4613-8dc5-cb449641adf2"),
        >>>     entity_types=[ArtifactTypes.FileSample, ArtifactTypes.Archive],
        >>> )
    """

    def __init__(
        self,
        data_source_uuid: uuid.UUID,
        artifact_types: Iterable[ArtifactTypes],
        *,
        artifact_size_limit: Optional[int] = None,
        dashboard_url: Optional[str] = None,
        task_execution_timeout: Optional[int] = None,
        task_execution_attempts_count: Optional[int] = None,
    ):
        super().__init__()
        self._data["dataSourceUUID"] = str(data_source_uuid)
        self._data["artifactTypes"] = [typ.value for typ in artifact_types]
        if artifact_size_limit is not None:
            self._data["artifactSizeLimit"] = artifact_size_limit
        if dashboard_url is not None:
            self._data["dashboardURL"] = dashboard_url
        if task_execution_timeout is not None:
            self._data["taskExecutionTimeout"] = task_execution_timeout
        if task_execution_attempts_count is not None:
            self._data["taskExecutionAttemptsCount"] = task_execution_attempts_count

cybsi/api/enrichment/analyzers.py
cyberstudio/cybsi-sdk
from ..internal import BaseAPI, BaseAsyncAPI
from .analyzers import AnalyzersAPI
from .config_rules import ConfigRulesAPI
from .external_dbs import ExternalDBsAPI
from .task_queue import TaskQueueAPI, TaskQueueAsyncAPI
from .tasks import TasksAPI, TasksAsyncAPI


class EnrichmentAPI(BaseAPI):
    """Enrichment API."""

    @property
    def config_rules(self) -> "ConfigRulesAPI":
        """Get config rules route."""
        return ConfigRulesAPI(self._connector)

    @property
    def tasks(self) -> "TasksAPI":
        """Get enrichment tasks route."""
        return TasksAPI(self._connector)

    @property
    def task_queue(self) -> "TaskQueueAPI":
        """Get task queue route."""
        return TaskQueueAPI(self._connector)

    @property
    def external_dbs(self) -> "ExternalDBsAPI":
        """Get external databases route."""
        return ExternalDBsAPI(self._connector)

    @property
    def analyzers(self) -> "AnalyzersAPI":
        """Get analyzers route."""
        return AnalyzersAPI(self._connector)


class EnrichmentAsyncAPI(BaseAsyncAPI):
    """Enrichment asynchronous API."""

    @property
    def tasks(self) -> "TasksAsyncAPI":
        """Get enrichment tasks route."""
        return TasksAsyncAPI(self._connector)

    @property
    def task_queue(self) -> "TaskQueueAsyncAPI":
        """Get task queue route."""
        return TaskQueueAsyncAPI(self._connector)

cybsi/api/enrichment/api.py
cyberstudio/cybsi-sdk
"""Use this section of API to operate config rules.
"""
import uuid
from typing import Any, Iterable, List, Optional

from .. import RefView, Tag
from ..api import Nullable, _unwrap_nullable
from ..artifact import ArtifactTypes
from ..internal import BaseAPI, JsonObject, JsonObjectForm
from ..observable import EntityTypes
from ..pagination import Cursor, Page
from ..view import _TaggedRefView
from .enums import EnrichmentTriggerTypes, EnrichmentTypes


class ConfigRulesAPI(BaseAPI):
    """Config rules API."""

    _path = "/enrichment/config/rules"

    def view(self, rule_uuid: uuid.UUID) -> "ConfigRuleView":
        """Get the config rule view.

        .. versionadded:: 2.7

        Note:
            Calls `GET /enrichment/config/rules/{rule_uuid}`.
        Args:
            rule_uuid: config rule uuid.
        Returns:
            View of the config rule.
        Raises:
            :class:`~cybsi.api.error.NotFoundError`: Config rule not found.
        """
        path = f"{self._path}/{rule_uuid}"
        r = self._connector.do_get(path=path)
        return ConfigRuleView(r)

    def register(self, rule: "ConfigRuleForm") -> RefView:
        """Register config rule.

        .. versionadded:: 2.7

        Note:
            Calls `POST /enrichment/config/rules`.
        Args:
            rule: Filled config rule form.
        Returns:
            Reference to config rule in API.
        Raises:
            :class:`~cybsi.api.error.SemanticError`: Form contains logic errors.
        Note:
            Semantic error codes specific for this method:
              * :attr:`~cybsi.api.error.SemanticErrorCodes.InvalidRule`
              * :attr:`~cybsi.api.error.SemanticErrorCodes.DataSourceNotFound`
              * :attr:`~cybsi.api.error.SemanticErrorCodes.MisconfiguredDataSource`
        """
        r = self._connector.do_post(path=self._path, json=rule.json())
        return RefView(r.json())

    def filter(
        self,
        *,
        data_source_uuids: Optional[Iterable[uuid.UUID]] = None,
        trigger_data_source_uuids: Optional[Iterable[uuid.UUID]] = None,
        enrichment_types: Optional[Iterable[EnrichmentTypes]] = None,
        artifact_types: Optional[Iterable[ArtifactTypes]] = None,
        entity_types: Optional[Iterable[EntityTypes]] = None,
        trigger_types: Optional[Iterable[EnrichmentTriggerTypes]] = None,
        is_disabled: Optional[bool] = None,
        name: Optional[str] = None,
        cursor: Optional[Cursor] = None,
        limit: Optional[int] = None,
    ) -> Page["ConfigRuleCommonView"]:
        """Filter config rules.

        .. versionchanged:: 2.8
            Added new parameters: `enrichment_types`, `artifact_types`, `entity_types`,
            `trigger_types`,`is_disabled`, `rule_name`.
            Parameters `data_source_uuids`, `trigger_data_source_uuids` changed to list.

        Note:
            Calls `GET /enrichment/config/rules`.
        Args:
            data_source_uuids: Filter config rules by list of associated data sources.
            trigger_data_source_uuids: Filter config rules by list of data sources
                which is the trigger for rules.
            enrichment_types: Filter config rules by list of enrichment type.
            artifact_types: Filter config rules by list of artifact type.
            entity_types: Filter config rules by list of entity type.
            trigger_types: Filter config rules by list of enrichment trigger type.
            is_disabled: Filter config rules by disabled status.
            name: Filter config rules by name.
            cursor: Page cursor.
            limit: Page limit.
        Returns:
            Page of config rules.
        Raises:
            :class:`~cybsi.api.error.SemanticError`: query arguments contain errors.
        Note:
            Semantic error codes specific for this method:
              * :attr:`~cybsi.api.error.SemanticErrorCodes.DataSourceNotFound`
        """

        params: JsonObject = {}
        if data_source_uuids is not None:
            params["dataSourceUUID"] = [str(u) for u in data_source_uuids]
        if trigger_data_source_uuids is not None:
            params["triggerDataSourceUUID"] = [
                str(u) for u in trigger_data_source_uuids
            ]
        if enrichment_types is not None:
            params["enrichmentType"] = [t.value for t in enrichment_types]
        if artifact_types is not None:
            params["artifactType"] = [t.value for t in artifact_types]
        if entity_types is not None:
            params["entityType"] = [t.value for t in entity_types]
        if trigger_types is not None:
            params["triggerType"] = [t.value for t in trigger_types]
        if is_disabled is not None:
            params["isDisabled"] = is_disabled
        if name is not None:
            params["name"] = name
        if limit is not None:
            params["limit"] = str(limit)
        if cursor is not None:
            params["cursor"] = str(cursor)

        resp = self._connector.do_get(self._path, params=params)
        page = Page(self._connector.do_get, resp, ConfigRuleCommonView)
        return page

    def edit(
        self,
        rule_uuid: uuid.UUID,
        tag: Tag,
        *,
        name: Optional[str] = None,
        is_disabled: Optional[bool] = None,
        triggers: Optional[Iterable[EnrichmentTriggerTypes]] = None,
        trigger_data_source_uuids: Nullable[Iterable[uuid.UUID]] = None,
        artifact_types: Nullable[Iterable[ArtifactTypes]] = None,
        entity_types: Nullable[Iterable[EntityTypes]] = None,
        data_source_uuids: Optional[Iterable[uuid.UUID]] = None,
        throttling_interval: Nullable[int] = None,
        enrichment: Optional[EnrichmentTypes] = None,
    ) -> None:
        """Edit config rule.

        .. versionadded:: 2.7

        Note:
            Calls `PATCH /enrichment/config/rules/{rule_uuid}`.
        Args:
            rule_uuid: config rule identifier.
            tag: :attr:`ConfigRuleView.tag` value. Use :meth:`view` to retrieve it.
            name: Config rule name.
            triggers: Non-empty list of enrichment trigger types.
            data_source_uuids:
                Non-empty data source uuid list associated with enrichers.
            enrichment: Enrichment type.
                Must be `ArtifactAnalysis` or `ExternalDBLookup`
                type for rule registration.
            is_disabled: Disabled config rule flag.
            trigger_data_source_uuids:
                Entity/Artifact registrar associated datasource list
            artifact_types: List of artifact types.
                Not-empty for `ArtifactAnalysis` enrichment type.
            entity_types: List of entity types.
                Not-empty for `ExternalDBLookup` enrichment type.
            throttling_interval: Throttling interval in seconds.
                Required for `OnRegistration` trigger type and
                must be minimum 3600 sec.
        Raises:
            :class:`~cybsi.api.error.SemanticError`: Query arguments contain errors.
            :class:`~cybsi.api.error.InvalidRequestError`:
                Provided arguments have invalid values.
            :class:`~cybsi.api.error.NotFoundError`: Config rule not found.
            :class:`~cybsi.api.error.ResourceModifiedError`:
                Config rule changed since last request. Retry using updated tag.
        Note:
            Semantic error codes specific for this method:
              * :attr:`~cybsi.api.error.SemanticErrorCodes.DataSourceNotFound`
              * :attr:`~cybsi.api.error.SemanticErrorCodes.MisconfiguredDataSource`
              * :attr:`~cybsi.api.error.SemanticErrorCodes.InvalidRule`
              * :attr:`~cybsi.api.error.SemanticErrorCodes.ImmutableValue`
        """
        data = _create_rule_form_data(
            name=name,
            is_disabled=is_disabled,
            triggers=triggers,
            trigger_data_source_uuids=trigger_data_source_uuids,
            data_source_uuids=data_source_uuids,
            artifact_types=artifact_types,
            entity_types=entity_types,
            throttling_interval=throttling_interval,
            enrichment=enrichment,
        )
        path = f"{self._path}/{rule_uuid}"
        self._connector.do_patch(path=path, tag=tag, json=data)


class ConfigRuleCommonView(RefView):
    """Config rule object view."""

    @property
    def name(self) -> str:
        """Config rule name."""
        return self._get("name")

    @property
    def is_disabled(self) -> bool:
        """Disabled config rule flag."""
        return self._get("isDisabled")

    @property
    def is_builtin(self) -> bool:
        """Builtin config rule flag.

        Builtin rules can be set to disabled state, other attributes are immutable.
        """
        return self._get("isBuiltin")

    @property
    def triggers(self) -> List[EnrichmentTriggerTypes]:
        """List of enrichment trigger types."""
        return [EnrichmentTriggerTypes(t) for t in self._get("triggers")]

    @property
    def enrichment(self) -> EnrichmentTypes:
        """Enrichment type."""
        return EnrichmentTypes(self._get("enrichment"))

    @property
    def artifact_types(self) -> Optional[List[ArtifactTypes]]:
        """List of artifact types.

        Not empty for `ArtifactAnalysis` and `ArchiveUnpack` enrichment types.
        """
        return self._map_list_optional("artifactTypes", ArtifactTypes)

    @property
    def entity_types(self) -> Optional[List[EntityTypes]]:
        """List of entity types.

        Not empty for `DNSLookup`, `WhoisLookup`, `ExternalDBLookup` enrichment types.
        """
        return self._map_list_optional("entityTypes", EntityTypes)

    @property
    def data_sources(self) -> Optional[List[RefView]]:
        """Data sources associated with enrichers.

        The rule creates enrichment tasks for enrichers listed here.
        """
        return self._map_list_optional("dataSources", RefView)

    @property
    def trigger_data_sources(self) -> Optional[List[RefView]]:
        """Entity/Artifact registrar associated datasource list.

        The rule is applied only if entity was mentioned or artifact was registered
        by one of data sources from the list.
        """
        return self._map_list_optional("triggerDataSources", RefView)

    @property
    def throttling_interval(self) -> Optional[int]:
        """Throttling interval in seconds."""
        return self._get_optional("throttlingInterval")


class ConfigRuleView(_TaggedRefView, ConfigRuleCommonView):
    """Config rule response view."""

    pass


class ConfigRuleForm(JsonObjectForm):
    """Config rule form.

    This is the form you need to fill to register config rule.

    Args:
        name: Config rule name.
        triggers: Non-empty list of enrichment trigger types.
        data_source_uuids: Non-empty data source uuid list associated with enrichers.
        enrichment: Enrichment type.
            Must be `ArtifactAnalysis` or `ExternalDBLookup` type for rule registration.
        is_disabled: Disabled config rule flag.
        trigger_data_source_uuids: Entity/Artifact registrar associated datasource list
        artifact_types: List of artifact types.
            Not-empty for `ArtifactAnalysis` enrichment type.
        entity_types: List of entity types.
            Not-empty for `ExternalDBLookup` enrichment type.
        throttling_interval: Throttling interval in seconds.
            Required for `OnRegistration` trigger type and
            must be minimum 3600 sec.

    Usage:
        >>> import uuid
        >>> from cybsi.api.enrichment import (
        >>>     EnrichmentTypes,
        >>>     EnrichmentTriggerTypes,
        >>>     ConfigRuleForm,
        >>> )
        >>> from cybsi.api.artifact import ArtifactTypes
        >>> rule = ConfigRuleForm(
        >>>     name="test artifact analysis rule",
        >>>     data_source_uuids=[uuid.UUID("3ab411dc-17ab-4169-8ea6-c08271fca49e")],
        >>>     triggers=[EnrichmentTriggerTypes.OnRegistration],
        >>>     enrichment=EnrichmentTypes.ArtifactAnalysis,
        >>>     artifact_types=[ArtifactTypes.FileSample, ArtifactTypes.Archive],
        >>>     is_disabled=True,
        >>> )
    """

    def __init__(
        self,
        name: str,
        triggers: Iterable[EnrichmentTriggerTypes],
        *,
        data_source_uuids: Iterable[uuid.UUID],
        enrichment: EnrichmentTypes,
        is_disabled: Optional[bool] = False,
        trigger_data_source_uuids: Optional[Iterable[uuid.UUID]] = None,
        artifact_types: Optional[Iterable[ArtifactTypes]] = None,
        entity_types: Optional[Iterable[EntityTypes]] = None,
        throttling_interval: Optional[int] = None,
    ):
        data = _create_rule_form_data(
            name=name,
            triggers=triggers,
            data_source_uuids=data_source_uuids,
            enrichment=enrichment,
            is_disabled=is_disabled,
            trigger_data_source_uuids=trigger_data_source_uuids,
            artifact_types=artifact_types,
            entity_types=entity_types,
            throttling_interval=throttling_interval,
        )
        super().__init__(data)


def _create_rule_form_data(
    name: Optional[str],
    is_disabled: Optional[bool] = None,
    triggers: Optional[Iterable[EnrichmentTriggerTypes]] = None,
    trigger_data_source_uuids: Nullable[Iterable[uuid.UUID]] = None,
    artifact_types: Nullable[Iterable[ArtifactTypes]] = None,
    entity_types: Nullable[Iterable[EntityTypes]] = None,
    data_source_uuids: Optional[Iterable[uuid.UUID]] = None,
    throttling_interval: Nullable[int] = None,
    enrichment: Optional[EnrichmentTypes] = None,
) -> JsonObject:
    """Create rule form data"""

    data: JsonObject = dict()

    if name is not None:
        data["name"] = name
    if is_disabled is not None:
        data["isDisabled"] = is_disabled
    if triggers is not None:
        data["triggers"] = [typ.value for typ in triggers]
    if data_source_uuids is not None:
        data["dataSourceUUIDs"] = [str(u) for u in data_source_uuids]
    if enrichment is not None:
        data["enrichment"] = enrichment.value
    if throttling_interval is not None:
        data["throttlingInterval"] = _unwrap_nullable(throttling_interval)

    if entity_types is not None:
        casted_ent_types: Any = _unwrap_nullable(entity_types)
        if casted_ent_types is not None:
            casted_ent_types = [typ.value for typ in casted_ent_types]
        data["entityTypes"] = casted_ent_types

    if trigger_data_source_uuids is None:
        casted_trigger_uuids = _unwrap_nullable(trigger_data_source_uuids)
        if casted_trigger_uuids is not None:
            casted_trigger_uuids = [str(u) for u in casted_trigger_uuids]
        data["triggerDataSourceUUIDs"] = casted_trigger_uuids

    if artifact_types is not None:
        casted_artifact_types: Any = _unwrap_nullable(artifact_types)
        if casted_artifact_types is not None:
            casted_artifact_types = [typ.value for typ in casted_artifact_types]
        data["artifactTypes"] = casted_artifact_types

    return data

cybsi/api/enrichment/config_rules.py
cyberstudio/cybsi-sdk
from enum_tools import document_enum

from cybsi.api.enum import CybsiAPIEnum


@document_enum
class EnrichmentTypes(CybsiAPIEnum):
    """Enrichment type."""

    ArchiveUnpack = "ArchiveUnpack"  # doc: Archive unpack.
    ArtifactAnalysis = "ArtifactAnalysis"  # doc: Artifact analysis.
    ArtifactDownload = "ArtifactDownload"  # doc: Artifact download.
    DNSLookup = "DNSLookup"  # doc: DNS lookup.
    ExternalDBLookup = "ExternalDBLookup"  # doc: External database lookup.
    WhoisLookup = "WhoisLookup"  # doc: Whois lookup.


@document_enum
class EnrichmentTriggerTypes(CybsiAPIEnum):
    """Enrichment trigger type."""

    OnDemand = "OnDemand"
    """Enrichment starts on manual task creation through API."""
    OnRegistration = "OnRegistration"
    """Enrichment starts automatically on artifact registration or entity mention."""


@document_enum
class EnrichmentErrorCodes(CybsiAPIEnum):
    """Enrichment task error code."""

    FatalError = "FatalError"
    """Enricher internal error."""
    TemporaryError = "TemporaryError"
    """Task timed out, network connectivity issues and so on."""
    NotFound = "NotFound"
    """Requested entity wasn't found in external database."""
    UnsupportedArtifact = "UnsupportedArtifact"
    """
        :attr:`EnrichmentTypes.ArtifactAnalysis` only.
        Enricher doesn't support such artifacts
        (invalid archive format, artifact size is too big).
    """
    Unavailable = "Unavailable"
    """
        :attr:`EnrichmentTypes.ArtifactDownload`,
        :attr:`EnrichmentTypes.WhoisLookup` only.
        Resource under provided URL is not available.
    """
    CorruptedArchive = "CorruptedArchive"
    """Only in task API. Archive is corrupted."""
    InvalidPassword = "InvalidPassword"
    """
        :attr:`EnrichmentTypes.ArchiveUnpack` only in task API.
        An incorrect archive password is specified or a password
        is required to unpack (not specified).
    """


@document_enum
class EnrichmentTaskPriorities(CybsiAPIEnum):
    """Enrichment task priority."""

    High = "High"  # doc: High.
    Normal = "Normal"  # doc: Normal.


@document_enum
class EnrichmentTaskStatuses(CybsiAPIEnum):
    """Enrichment task status."""

    Pending = "Pending"
    """Task in the queue, awaiting execution (new or has been restarted)."""
    Executing = "Executing"
    """The task is in progress."""
    Failed = "Failed"
    """An error has occurred, after a while another attempt will be made."""
    Completed = "Completed"
    """Final state. Task completed successfully."""
    Aborted = "Aborted"
    """Final state. The task could not be completed due to an error."""

cybsi/api/enrichment/enums.py
cyberstudio/cybsi-sdk
"""Use this section of API to operate external databases.

External databases are systems outside of Cybsi. They can be queried by Cybsi
for information about entities. The result of such query an observation.
The observation typically provides new attributes for the requested entity and
relationships of the requested entity with other entities.
"""

import uuid
from typing import Any, Dict, Iterable, List, Optional

from .. import RefView
from ..api import Nullable, Tag, _unwrap_nullable
from ..internal import BaseAPI, JsonObjectForm
from ..observable import EntityTypes
from ..pagination import Cursor, Page
from ..view import _TaggedRefView


class ExternalDBsAPI(BaseAPI):
    """External databases API.

    .. versionadded:: 2.7
    """

    _path = "/enrichment/external-dbs"

    def view(self, db_uuid: uuid.UUID) -> "ExternalDBView":
        """Get the external database view.

        .. versionadded:: 2.7

        Note:
            Calls `GET /enrichment/external-dbs/{db_uuid}`.
        Args:
            db_uuid: External database uuid.
        Returns:
            View of the external database.
        Raises:
            :class:`~cybsi.api.error.NotFoundError`: External database not found.
        """
        path = f"{self._path}/{db_uuid}"
        r = self._connector.do_get(path=path)
        return ExternalDBView(r)

    def register(self, form: "ExternalDBForm") -> RefView:
        """Register external database.

        .. versionadded:: 2.7

        Note:
            Calls `POST /enrichment/external-dbs`.
        Args:
            form: Filled external database form.
        Returns:
            Reference to external database in API.
        Raises:
            :class:`~cybsi.api.error.InvalidRequestError`:
                Provided values are invalid (see form value requirements).
            :class:`~cybsi.api.error.SemanticError`: Form contains logic errors.
            :class:`~cybsi.api.error.ConflictError`:
                An external database with such data source is already registered.
        Note:
            Semantic error codes::
              * :attr:`~cybsi.api.error.SemanticErrorCodes.DataSourceNotFound`
        """
        r = self._connector.do_post(path=self._path, json=form.json())
        return RefView(r.json())

    def edit(
        self,
        db_uuid: uuid.UUID,
        tag: Tag,
        *,
        entity_types: Optional[Iterable[EntityTypes]] = None,
        web_page_url: Nullable[str] = None,
        task_execution_timeout: Nullable[int] = None,
        task_execution_attempts_count: Nullable[int] = None,
    ) -> None:
        """Edit the external database.

        .. versionadded:: 2.7

        Note:
            Calls `PATCH /enrichment/external-dbs/{db_uuid}`.
        Args:
            db_uuid: External database uuid.
            tag: :attr:`ExternalDBView.tag` value. Use :meth:`view` to retrieve it.
            entity_types: Entity types list. Non-empty if not :data:`None`.
            web_page_url: Link to the public page of the external database.
                :data:`~cybsi.api.Null` resets URL to empty value.
                :data:`None` means URL is left unchanged.
            task_execution_timeout: Enricher task execution timeout, sec.
                Timeout must be in range [1;864000].
                :data:`~cybsi.api.Null` means that Cybsi can use default timeout.
                :data:`None` means timeout is left unchanged.
            task_execution_attempts_count:
                The maximum number of attempts to complete the task by the enricher.
                Count must be in range [1;1000].
                :data:`~cybsi.api.Null` means that Cybsi can use default count.
                :data:`None` means that count is left unchanged.
        Raises:
            :class:`~cybsi.api.error.InvalidRequestError`:
                Provided arguments have invalid values.
            :class:`~cybsi.api.error.NotFoundError`: External database not found.
            :class:`~cybsi.api.error.ResourceModifiedError`:
                External database changed since last request. Update tag and retry.
        """
        form: Dict[str, Any] = {}
        if entity_types is not None:
            form["entityTypes"] = [t.value for t in entity_types]
        if web_page_url is not None:
            form["webPageURL"] = _unwrap_nullable(web_page_url)
        if task_execution_timeout is not None:
            form["taskExecutionTimeout"] = _unwrap_nullable(task_execution_timeout)
        if task_execution_attempts_count is not None:
            form["taskExecutionAttemptsCount"] = _unwrap_nullable(
                task_execution_attempts_count
            )
        path = f"{self._path}/{db_uuid}"
        self._connector.do_patch(path=path, tag=tag, json=form)

    def filter(
        self,
        *,
        entity_types: Optional[Iterable[EntityTypes]] = None,
        data_source_uuid: Optional[uuid.UUID] = None,
        cursor: Optional[Cursor] = None,
        limit: Optional[int] = None,
    ) -> Page["ExternalDBView"]:
        """Get page of filtered external databases list.

        .. versionadded:: 2.7

        Note:
            Calls `GET /enrichment/external-dbs`
        Args:
            entity_types: Entity types list.
                Select external databases that accept any specified artifact type.
            data_source_uuid: Data source identifier.
                Select analyzers by associated data source identifier.
            cursor: Page cursor.
            limit: Page limit.
        Returns:
            Page of filtered external databases list and next page cursor.
        Raises:
            :class:`~cybsi.api.error.SemanticError`: query arguments contain errors.
        Note:
            Semantic error codes specific for this method:
              * :attr:`~cybsi.api.error.SemanticErrorCodes.DataSourceNotFound`
        """
        params: Dict[str, Any] = {}

        if entity_types is not None:
            params["entityType"] = [t.value for t in entity_types]
        if data_source_uuid is not None:
            params["dataSourceUUID"] = str(data_source_uuid)
        if cursor:
            params["cursor"] = str(cursor)
        if limit:
            params["limit"] = str(limit)

        resp = self._connector.do_get(self._path, params=params)
        page = Page(self._connector.do_get, resp, ExternalDBView)
        return page


class ExternalDBView(_TaggedRefView):
    @property
    def data_source(self) -> RefView:
        """Data source reference representing external database."""
        return RefView(self._get("dataSource"))

    @property
    def entity_types(self) -> List[EntityTypes]:
        """Entity types we can enrich in the external database."""
        return [EntityTypes(typ) for typ in self._get("entityTypes")]

    @property
    def web_page_url(self) -> Optional[str]:
        """Link to the public page of the external database."""
        return self._get_optional("webPageURL")

    @property
    def task_execution_timeout(self) -> Optional[int]:
        """Enricher task execution timeout, sec."""
        return self._get_optional("taskExecutionTimeout")

    @property
    def task_execution_attempts_count(self) -> Optional[int]:
        """The maximum number of attempts to complete the task by the enricher."""
        return self._get_optional("taskExecutionAttemptsCount")


class ExternalDBForm(JsonObjectForm):
    """External database form.

    This is the form you need to fill to register external database.

    Args:
        data_source_uuid: Data source identifier representing external database.
            Unique for external database.
        entity_types: Non-empty entity types list.
        web_page_url: Link to the public page of the external database.
        task_execution_timeout: Enricher task execution timeout, sec.
            Timeout must be in range [1;864000].
        task_execution_attempts_count: The maximum number of attempts
            to complete the task by the enricher. Count must be in range [1;1000].
    Usage:
        >>> import uuid
        >>> from cybsi.api.enrichment import ExternalDBForm
        >>> from cybsi.api.observable import EntityTypes
        >>> external_db = ExternalDBForm(
        >>>     data_source_uuid=uuid.UUID("4fd3126f-a0e8-4613-8dc5-cb449641adf2"),
        >>>     entity_types=[EntityTypes.DomainName, EntityTypes.IPAddress],
        >>> )
    """

    def __init__(
        self,
        data_source_uuid: uuid.UUID,
        entity_types: Iterable[EntityTypes],
        *,
        web_page_url: Optional[str] = None,
        task_execution_timeout: Optional[int] = None,
        task_execution_attempts_count: Optional[int] = None,
    ):
        super().__init__()
        self._data["dataSourceUUID"] = str(data_source_uuid)
        self._data["entityTypes"] = [typ.value for typ in entity_types]
        if web_page_url is not None:
            self._data["webPageURL"] = web_page_url
        if task_execution_timeout is not None:
            self._data["taskExecutionTimeout"] = task_execution_timeout
        if task_execution_attempts_count is not None:
            self._data["taskExecutionAttemptsCount"] = task_execution_attempts_count

cybsi/api/enrichment/external_dbs.py
cyberstudio/cybsi-sdk
"""Use this section of API to implement a custom enricher.

The API allows to fetch tasks assigned to enricher.
It also allows to publish task execution results and errors.

See Also:
    See :ref:`implement-custom-external-db-example`
    for a complete example of enricher capable of IP enrichment.

    See :ref:`implement-custom-analyzer-example`
    for a complete example of enricher capable of File sample enrichment.
"""
import datetime
import uuid
from typing import Iterable, List, Union, cast

from .. import RefView
from ..data_source import DataSourceCommonView
from ..internal import BaseAPI, BaseAsyncAPI, JsonObjectForm, parse_rfc3339_timestamp
from .enums import EnrichmentErrorCodes, EnrichmentTaskPriorities, EnrichmentTypes
from .tasks import ArtifactAnalysisParamsView, ExternalDBLookupParamsView

_PATH = "/enrichment/task-queue"


class TaskQueueAPI(BaseAPI):
    """Task queue API.

    .. versionadded:: 2.7
    """

    def get_assigned_tasks(self, limit: int = 1) -> List["AssignedTaskView"]:
        """Assign a batch of pending enrichment tasks for execution by client.

        .. versionadded:: 2.7

        All returned tasks have status `Executing`.

        Note:
            Calls `POST /enrichment/task-queue/executing-tasks`.
        Args:
            limit: Maximum task batch size.
        Returns:
            A batch of tasks for execution.
        Warning:
            Please wait some time if :meth:`get_assigned_tasks`
            returns empty list before calling it again.
        """
        path = f"{_PATH}/executing-tasks"
        r = self._connector.do_post(path=path, json={"limit": limit})
        return [AssignedTaskView(t) for t in r.json()]

    def complete_tasks(self, completed_tasks: Iterable["CompletedTaskForm"]) -> None:
        """Register successful task results.

        .. versionadded:: 2.7

        Note:
            Calls `POST /enrichment/task-queue/completed-tasks`.
        Args:
            completed_tasks: List of filled forms of completed tasks.
        Returns:
            None on successful registration of results.
        Raises:
            :class:`~cybsi.api.error.ForbiddenError`: Enricher cannot report
             result of one of tasks.
            :class:`~cybsi.api.error.SemanticError`: One of forms contains logic errors.
        Note:
            ForbiddenError error codes:
              * :attr:`~cybsi.api.error.ForbiddenErrorCodes.NotOwner`
                -- Task belongs to other enricher.
            SemanticError codes specific for this method:
              * :attr:`~cybsi.api.error.SemanticErrorCodes.InvalidTaskStatus`
                -- Current task status is not ``Executing``.
              * :attr:`~cybsi.api.error.SemanticErrorCodes.InvalidTaskResult`
                -- Result has a broken link to observation, report or artifact.
        """
        path = f"{_PATH}/completed-tasks"
        task_jsons = [r.json() for r in completed_tasks]
        self._connector.do_post(path=path, json={"tasks": task_jsons})

    def fail_tasks(self, failed_tasks: Iterable["FailedTaskForm"]) -> None:
        """Register failed task errors.

        .. versionadded:: 2.7

        Note:
            Calls `POST /enrichment/task-queue/failed-tasks`.
        Args:
            failed_tasks: List of filled forms of failed tasks.
        Returns:
            None on successful registration of errors.
        Raises:
            :class:`~cybsi.api.error.ForbiddenError`: Enricher cannot report
             result of one of tasks.
            :class:`~cybsi.api.error.SemanticError`: One of forms contains logic errors.
        Note:
            ForbiddenError codes:
              * :attr:`~cybsi.api.error.ForbiddenErrorCodes.NotOwner`
                -- Task belongs to other enricher.
            SemanticError codes specific for this method:
              * :attr:`~cybsi.api.error.SemanticErrorCodes.InvalidTaskStatus`
                -- Current task status is not ``Executing``.
              * :attr:`~cybsi.api.error.SemanticErrorCodes.InvalidErrorCode`
                -- Error code is invalid for tasks of such type.
        """
        path = f"{_PATH}/failed-tasks"
        task_jsons = [r.json() for r in failed_tasks]
        self._connector.do_post(path=path, json={"tasks": task_jsons})


class TaskQueueAsyncAPI(BaseAsyncAPI):
    """Task queue API.

    .. versionadded:: 2.7
    """

    async def get_assigned_tasks(self, limit: int = 1) -> List["AssignedTaskView"]:
        """Assign a batch of pending enrichment tasks for execution by client.

        .. versionadded:: 2.7

        All returned tasks have status `Executing`.

        Note:
            Calls `POST /enrichment/task-queue/executing-tasks`.
        Args:
            limit: Maximum task batch size.
        Returns:
            A batch of tasks for execution.
        Warning:
            Please wait some time if :meth:`get_assigned_tasks`
            returns empty list before calling it again.
        """
        path = f"{_PATH}/executing-tasks"
        r = await self._connector.do_post(path=path, json={"limit": limit})
        return [AssignedTaskView(t) for t in r.json()]

    async def complete_tasks(
        self, completed_tasks: Iterable["CompletedTaskForm"]
    ) -> None:
        """Register successful task results.

        .. versionadded:: 2.7

        Note:
            Calls `POST /enrichment/task-queue/completed-tasks`.
        Args:
            completed_tasks: List of filled forms of completed tasks.
        Returns:
            None on successful registration of results.
        Raises:
            :class:`~cybsi.api.error.ForbiddenError`: Enricher cannot report
             result of one of tasks.
            :class:`~cybsi.api.error.SemanticError`: One of forms contains logic errors.
        Note:
            ForbiddenError error codes:
              * :attr:`~cybsi.api.error.ForbiddenErrorCodes.NotOwner`
                -- Task belongs to other enricher.
            SemanticError codes specific for this method:
              * :attr:`~cybsi.api.error.SemanticErrorCodes.InvalidTaskStatus`
                -- Current task status is not ``Executing``.
              * :attr:`~cybsi.api.error.SemanticErrorCodes.InvalidTaskResult`
                -- Result has a broken link to observation, report or artifact.
        """
        path = f"{_PATH}/completed-tasks"
        task_jsons = [r.json() for r in completed_tasks]
        await self._connector.do_post(path=path, json={"tasks": task_jsons})

    async def fail_tasks(self, failed_tasks: Iterable["FailedTaskForm"]) -> None:
        """Register failed task errors.

        .. versionadded:: 2.7

        Note:
            Calls `POST /enrichment/task-queue/failed-tasks`.
        Args:
            failed_tasks: List of filled forms of failed tasks.
        Returns:
            None on successful registration of errors.
        Raises:
            :class:`~cybsi.api.error.ForbiddenError`: Enricher cannot report
             result of one of tasks.
            :class:`~cybsi.api.error.SemanticError`: One of forms contains logic errors.
        Note:
            ForbiddenError codes:
              * :attr:`~cybsi.api.error.ForbiddenErrorCodes.NotOwner`
                -- Task belongs to other enricher.
            SemanticError codes specific for this method:
              * :attr:`~cybsi.api.error.SemanticErrorCodes.InvalidTaskStatus`
                -- Current task status is not ``Executing``.
              * :attr:`~cybsi.api.error.SemanticErrorCodes.InvalidErrorCode`
                -- Error code is invalid for tasks of such type.
        """
        path = f"{_PATH}/failed-tasks"
        task_jsons = [r.json() for r in failed_tasks]
        await self._connector.do_post(path=path, json={"tasks": task_jsons})


EnrichmentTaskQueueParamsView = Union[
    ArtifactAnalysisParamsView, ExternalDBLookupParamsView
]


class AssignedTaskView(RefView):
    """Task assigned to enricher for execution."""

    _param_types = {
        EnrichmentTypes.ArtifactAnalysis: ArtifactAnalysisParamsView,
        EnrichmentTypes.ExternalDBLookup: ExternalDBLookupParamsView,
    }

    @property
    def priority(self) -> EnrichmentTaskPriorities:
        """Priority."""
        return EnrichmentTaskPriorities(self._get("priority"))

    @property
    def created_at(self) -> datetime.datetime:
        """Date and time of task creation."""
        return parse_rfc3339_timestamp(self._get("createdAt"))

    @property
    def updated_at(self) -> datetime.datetime:
        """Date and time of last task update."""
        return parse_rfc3339_timestamp(self._get("updatedAt"))

    @property
    def data_source(self) -> DataSourceCommonView:
        """Data source associated with enricher."""
        return DataSourceCommonView(self._get("dataSource"))

    @property
    def type(self) -> "EnrichmentTypes":
        """Enrichment type.

        Note:
            Possible values are subset of :class:`.enums.EnrichmentTypes` values.
            Only :attr:`.enums.EnrichmentTypes.ArtifactAnalysis` and
            :attr:`.enums.EnrichmentTypes.ExternalDBLookup` are possible here.
        """  # noqa: E501
        return EnrichmentTypes(self._get("type"))

    @property
    def params(self) -> EnrichmentTaskQueueParamsView:
        """Parameters of task. Determine exact type of parameters
        using property :attr:`type`.

        If enricher was a function, this would be function parameters.

        Usage:
            >>> from typing import cast
            >>> from cybsi.api.enrichment import (
            >>>     AssignedTaskView, EnrichmentTypes,
            >>>     ExternalDBLookupParamsView
            >>> )
            >>> task_view = AssignedTaskView()
            >>> if task_view.type == EnrichmentTypes.ExternalDBLookup:
            >>>     lookup = cast(ExternalDBLookupParamsView, task_view.params)
            >>>     print(lookup.entity)
        """
        params = self._param_types[self.type](self._get("params"))
        return cast(EnrichmentTaskQueueParamsView, params)


class CompletedTaskForm(JsonObjectForm):
    """Completed task form. Use to register successful task result.

    Args:
        task_uuid: Assigned task uuid.
        result: Task result, type depends on enrichment type.

          * For :attr:`.enums.EnrichmentTypes.ExternalDBLookup`
            provide :class:`TaskResultObservationForm`.
          * For :attr:`.enums.EnrichmentTypes.ArtifactAnalysis`
            provide :class:`TaskResultReportForm`.

    """  # noqa: E501

    def __init__(self, task_uuid: uuid.UUID, result: "TaskResultForm"):
        super().__init__()
        self._data["uuid"] = str(task_uuid)
        self._data["result"] = result.json()


class TaskResultObservationForm(JsonObjectForm):
    """Enrichment result containing observation.

    Use Observations API to register observation and get its uuid.

    Args:
        observation_uuid: Previously registered observation uuid.
    """

    def __init__(self, observation_uuid: uuid.UUID):
        super().__init__()
        self._data["observation"] = {"uuid": str(observation_uuid)}


class TaskResultReportForm(JsonObjectForm):
    """Enrichment result containing report.

    Use Report API to register report and get its uuid.

    Args:
        report_uuid: Previously registered report uuid.
    """

    def __init__(self, report_uuid: uuid.UUID):
        super().__init__()
        self._data["report"] = {"uuid": str(report_uuid)}


class TaskResultArtifactForm(JsonObjectForm):
    """Enrichment result containing artifact.

    Use Artifact API to register report and get its uuid.

    Args:
        artifact_uuid: Previously registered artifact uuid.
    """

    def __init__(self, artifact_uuid: uuid.UUID):
        super().__init__()
        self._data["artifact"] = {"uuid": str(artifact_uuid)}


TaskResultForm = Union[
    TaskResultArtifactForm, TaskResultObservationForm, TaskResultReportForm
]


class FailedTaskForm(JsonObjectForm):
    """Failed enrichment task form.

    Args:
        task_uuid: UUID of task assigned for execution.
        error_code: Enrichment error code.
        message: Error message.
    """

    def __init__(
        self,
        task_uuid: uuid.UUID,
        error_code: "EnrichmentErrorCodes",
        message: str,
    ) -> None:

        super().__init__()
        self._data["uuid"] = str(task_uuid)
        self._data["error"] = {
            "code": error_code.value,
            "message": message,
        }

cybsi/api/enrichment/task_queue.py
cyberstudio/cybsi-sdk
"""Use this section of API operate enrichment tasks.

The API allows to fetch and filter enrichment tasks.
It also allows registering enrichment task to start enrichment forcibly.
"""
import datetime
import uuid
from typing import Any, Dict, Iterable, List, Optional, Union, cast

from .. import RefView
from ..artifact import ArtifactTypes
from ..data_source import DataSourceCommonView
from ..internal import (
    BaseAPI,
    BaseAsyncAPI,
    JsonObjectForm,
    JsonObjectView,
    parse_rfc3339_timestamp,
)
from ..observable import EntityView, ShareLevels
from ..observation import ObservationCommonView
from ..pagination import AsyncPage, Cursor, Page
from .enums import (
    EnrichmentErrorCodes,
    EnrichmentTaskPriorities,
    EnrichmentTaskStatuses,
    EnrichmentTypes,
)

_PATH = "/enrichment/tasks"


class TasksAPI(BaseAPI):
    """Enrichment task API."""

    def register(self, form: "TaskForm") -> RefView:
        """Register enrichment task to start enrichment forcibly.

        Note:
            Calls `POST /enrichment/tasks`.
        Args:
            form: Filled enrichment task form.
        Returns:
            Reference to enrichment task in API.
        Raises:
            :class:`~cybsi.api.error.InvalidRequestError`: Form values are invalid.
            :class:`~cybsi.api.error.SemanticError`: Form contains logic errors.
        Note:
            Semantic error codes specific for this method:
              * :attr:`~cybsi.api.error.SemanticErrorCodes.EntityNotFound`
              * :attr:`~cybsi.api.error.SemanticErrorCodes.ArtifactNotFound`
              * :attr:`~cybsi.api.error.SemanticErrorCodes.DataSourceNotFound`
              * :attr:`~cybsi.api.error.SemanticErrorCodes.EnrichmentNotAllowed`
              * :attr:`~cybsi.api.error.SemanticErrorCodes.InvalidKeySet`
              * :attr:`~cybsi.api.error.SemanticErrorCodes.InvalidShareLevel`
        """
        r = self._connector.do_post(path=_PATH, json=form.json())
        return RefView(r.json())

    def view(self, task_uuid: uuid.UUID) -> "TaskView":
        """Get the enrichment task view.

        Note:
            Calls `GET /enrichment/tasks/{task_uuid}`.
        Args:
            task_uuid: enrichment task uuid.
        Returns:
            View of the enrichment task.
        Raises:
            :class:`~cybsi.api.error.NotFoundError`: Enrichment task not found.
        """
        path = f"{_PATH}/{task_uuid}"
        r = self._connector.do_get(path=path)
        return TaskView(r.json())

    def filter(
        self,
        *,
        artifact_uuid: Optional[uuid.UUID] = None,
        entity_uuid: Optional[uuid.UUID] = None,
        cursor: Optional[Cursor] = None,
        limit: Optional[int] = None,
    ) -> Page["TaskView"]:
        """Get enrichment task filtration list.

        Returns Pending, Completed and Failed enrichment tasks.

        Note:
            Calls `GET /enrichment/tasks`
        Args:
            cursor: Page cursor.
            limit: Page limit.
            artifact_uuid: Artifact identifier.
            entity_uuid: Entity identifier.
        Returns:
            Page with enrichment tasks and
            cursor allowing to get next batch of enrichment tasks.
        Raises:
            :class:`~cybsi.api.error.InvalidRequestError`:
                Artifact uuid and entity uuid parameters are missing.
                At least one of these parameters must be specified.
        """
        params: Dict[str, Any] = {}
        if cursor:
            params["cursor"] = str(cursor)
        if limit:
            params["limit"] = str(limit)
        if artifact_uuid:
            params["artifactUUID"] = str(artifact_uuid)
        if entity_uuid:
            params["entityUUID"] = str(entity_uuid)

        resp = self._connector.do_get(_PATH, params=params)
        page = Page(self._connector.do_get, resp, TaskView)
        return page


class TasksAsyncAPI(BaseAsyncAPI):
    """Enrichment task API."""

    async def register(self, form: "TaskForm") -> RefView:
        """Register enrichment task to start enrichment forcibly.

        Note:
            Calls `POST /enrichment/tasks`.
        Args:
            form: Filled enrichment task form.
        Returns:
            Reference to enrichment task in API.
        Raises:
            :class:`~cybsi.api.error.InvalidRequestError`: Form values are invalid.
            :class:`~cybsi.api.error.SemanticError`: Form contains logic errors.
        Note:
            Semantic error codes specific for this method:
              * :attr:`~cybsi.api.error.SemanticErrorCodes.EntityNotFound`
              * :attr:`~cybsi.api.error.SemanticErrorCodes.ArtifactNotFound`
              * :attr:`~cybsi.api.error.SemanticErrorCodes.DataSourceNotFound`
              * :attr:`~cybsi.api.error.SemanticErrorCodes.EnrichmentNotAllowed`
              * :attr:`~cybsi.api.error.SemanticErrorCodes.InvalidKeySet`
              * :attr:`~cybsi.api.error.SemanticErrorCodes.InvalidShareLevel`
        """
        r = await self._connector.do_post(path=_PATH, json=form.json())
        return RefView(r.json())

    async def view(self, task_uuid: uuid.UUID) -> "TaskView":
        """Get the enrichment task view.

        Note:
            Calls `GET /enrichment/tasks/{task_uuid}`.
        Args:
            task_uuid: enrichment task uuid.
        Returns:
            View of the enrichment task.
        Raises:
            :class:`~cybsi.api.error.NotFoundError`: Enrichment task not found.
        """
        path = f"{_PATH}/{task_uuid}"
        r = await self._connector.do_get(path=path)
        return TaskView(r.json())

    async def filter(
        self,
        *,
        artifact_uuid: Optional[uuid.UUID] = None,
        entity_uuid: Optional[uuid.UUID] = None,
        cursor: Optional[Cursor] = None,
        limit: Optional[int] = None,
    ) -> AsyncPage["TaskView"]:
        """Get enrichment task filtration list.

        Returns Pending, Completed and Failed enrichment tasks.

        Note:
            Calls `GET /enrichment/tasks`
        Args:
            cursor: Page cursor.
            limit: Page limit.
            artifact_uuid: Artifact identifier.
            entity_uuid: Entity identifier.
        Returns:
            Page with enrichment tasks and
            cursor allowing to get next batch of enrichment tasks.
        Raises:
            :class:`~cybsi.api.error.InvalidRequestError`:
                Artifact uuid and entity uuid parameters are missing.
                At least one of these parameters must be specified.
        """
        params: Dict[str, Any] = {}
        if cursor:
            params["cursor"] = str(cursor)
        if limit:
            params["limit"] = str(limit)
        if artifact_uuid:
            params["artifactUUID"] = str(artifact_uuid)
        if entity_uuid:
            params["entityUUID"] = str(entity_uuid)

        resp = await self._connector.do_get(_PATH, params=params)
        page = AsyncPage(self._connector.do_get, resp, TaskView)
        return page


class ArtifactAnalysisParamsForm(JsonObjectForm):
    """Artifact analysis task parameters form.

    Args:
        artifact_uuid: Artifact identifier.
        image_id: Analyzer-specific sandbox identifier (not all data sources).
        passwords: Password list for unpacking artifact and/or
            its attachments (not all data sources).
    """

    def __init__(
        self,
        artifact_uuid: uuid.UUID,
        image_id: Optional[str] = None,
        passwords: Optional[Iterable[str]] = None,
    ):
        super().__init__()
        self._data["artifact"] = {"uuid": str(artifact_uuid)}
        if image_id is not None:
            self._data["imageID"] = image_id
        if passwords is not None:
            self._data["passwords"] = list(passwords)


class ArchiveUnpackParamsForm(JsonObjectForm):
    """Archive unpack task parameters form.

    Args:
        artifact_uuid: Artifact identifier.
        register_nested_archive_as_file_sample:
            Flag that determines whether to register attached archives
            as FileSample artifacts during unpacking. Default value is false.
        password: Archive password.
    """

    def __init__(
        self,
        artifact_uuid: uuid.UUID,
        register_nested_archive_as_file_sample: Optional[bool] = False,
        password: Optional[str] = None,
    ):
        super().__init__()
        self._data["artifact"] = {"uuid": str(artifact_uuid)}
        if password is not None:
            self._data["password"] = password
        self._data[
            "registerNestedArchiveAsFileSample"
        ] = register_nested_archive_as_file_sample


class ArtifactDownloadParamsForm(JsonObjectForm):
    """Artifact download task parameters form.

    Args:
        url: URL of the resource to be loaded.
        share_level: Share level.
        artifact_type: Artifact type.
    """

    def __init__(
        self,
        url: str,
        share_level: ShareLevels,
        *,
        artifact_type: Optional[ArtifactTypes] = None,
    ):
        super().__init__()
        self._data["url"] = url
        if artifact_type is not None:
            self._data["type"] = artifact_type.value
        self._data["shareLevel"] = share_level


class ExternalDBLookupParamsForm(JsonObjectForm):
    """External database task parameters form.

    Args:
        entity_uuid: Entity identifier.
    Note:
        Entity type depends on the data source.
        The applicability of the data source to given entity types can be verified
        by querying the enrichment configuration.
    """

    def __init__(
        self,
        entity_uuid: uuid.UUID,
    ):
        super().__init__()
        self._data["entity"] = {"uuid": str(entity_uuid)}


DNSLookupParamsForm = ExternalDBLookupParamsForm
""" DNS lookup task parameters form.
    (Alias of :attr:`ExternalDBLookupParamsForm`).

    Args:
        entity_uuid: Entity identifier.
    Note:
        For DNSLookup enrichment type can be only
        IPAddress, DomainName entity types.
"""

WhoisLookupParamsForm = ExternalDBLookupParamsForm
""" Whois lookup task parameters form.
    (Alias of :attr:`ExternalDBLookupParamsForm`).

    Args:
        entity_uuid: Entity identifier.
    Note:
        For WhoisLookup enrichment type can be only
        IPAddress, DomainName entity types.
"""


EnrichmentTaskParamsForm = Union[
    ArchiveUnpackParamsForm,
    ArtifactAnalysisParamsForm,
    ArtifactDownloadParamsForm,
    ExternalDBLookupParamsForm,
    DNSLookupParamsForm,
    WhoisLookupParamsForm,
]


class ArtifactAnalysisParamsView(JsonObjectView):
    """Task parameters view of :attr:`~cybsi.api.enrichment.enums.EnrichmentTypes.ArtifactAnalysis`."""  # noqa: E501

    @property
    def artifact(self) -> RefView:
        """Artifact, enrichment target.

        Note:
            Use :class:`~cybsi.api.artifact.api.ArtifactsAPI`
            to retrieve complete artifact information and
            its binary content.
        """
        return RefView(self._get("artifact"))

    @property
    def image_id(self) -> Optional[str]:
        """Analyzer-specific image id."""
        return self._get_optional("imageID")

    @property
    def passwords(self) -> Optional[List[str]]:
        """Password list for unpacking an artifact and/or its attachments."""
        return self._get_optional("passwords")


class ArchiveUnpackParamsView(JsonObjectView):
    """Task parameters view of :attr:`~cybsi.api.enrichment.enums.EnrichmentTypes.ArchiveUnpack`."""  # noqa: E501

    @property
    def artifact(self) -> RefView:
        """Artifact, enrichment target.

        Note:
            Use :class:`~cybsi.api.artifact.api.ArtifactsAPI`
            to retrieve complete artifact information and
            its binary content.
        """
        return RefView(self._get("artifact"))

    @property
    def password(self) -> Optional[str]:
        """Archive password."""
        return self._get_optional("password")

    @property
    def register_nested_archive_as_file_sample(self) -> Optional[bool]:
        """Flag that determines whether to register attached archives
        as FileSample artifacts during unpacking."""
        return self._get_optional("registerNestedArchiveAsFileSample")


class ArtifactDownloadParamsView(JsonObjectView):
    """Task parameters view of :attr:`~cybsi.api.enrichment.enums.EnrichmentTypes.ArtifactDownload`."""  # noqa: E501

    @property
    def url(self) -> str:
        """URL of the resource to be loaded."""
        return self._get("url")

    @property
    def share_level(self) -> ShareLevels:
        """Share level."""
        return self._get("shareLevel")

    @property
    def type(self) -> Optional[ArtifactTypes]:
        """Artifact type."""
        return self._map_optional("type", ArtifactTypes)


class ExternalDBLookupParamsView(JsonObjectView):
    """Task parameters view of :attr:`~cybsi.api.enrichment.enums.EnrichmentTypes.ExternalDBLookup`."""  # noqa: E501

    @property
    def entity(self) -> EntityView:
        """Entity, enrichment target."""
        return EntityView(self._get("entity"))


DNSLookupParamsView = ExternalDBLookupParamsView
"""Task parameters view of :attr:`~cybsi.api.enrichment.enums.EnrichmentTypes.DNSLookup`.
    (Alias of :attr:`ExternalDBLookupParamsView`).
"""  # noqa: E501

WhoisLookupParamsView = ExternalDBLookupParamsView
"""Task parameters view of :attr:`~cybsi.api.enrichment.enums.EnrichmentTypes.WhoisLookup`.
    (Alias of :attr:`ExternalDBLookupParamsView`).
"""  # noqa: E501

EnrichmentTaskParamsView = Union[
    ArtifactAnalysisParamsView,
    ArtifactDownloadParamsView,
    ArchiveUnpackParamsView,
    ExternalDBLookupParamsView,
    DNSLookupParamsView,
    WhoisLookupParamsView,
]


class ArtifactTaskResultView(JsonObjectView):
    """Artifact result task view for
    :attr:`~cybsi.api.enrichment.enums.EnrichmentTypes.ArtifactDownload`
    """

    @property
    def artifact(self) -> RefView:
        return RefView(self._get("artifact"))


class ReportTaskResultView(JsonObjectView):
    """Report result task view for
    :attr:`~cybsi.api.enrichment.enums.EnrichmentTypes.ArtifactAnalysis`
    """

    @property
    def report(self) -> RefView:
        return RefView(self._get("report"))


class ObservationTaskResultView(JsonObjectView):
    """Observation result task view for
    :attr:`~cybsi.api.enrichment.enums.EnrichmentTypes.ExternalDBLookup`,
    :attr:`~cybsi.api.enrichment.enums.EnrichmentTypes.DNSLookup`,
    :attr:`~cybsi.api.enrichment.enums.EnrichmentTypes.WhoisLookup`.
    :attr:`~cybsi.api.enrichment.enums.EnrichmentTypes.ArchiveUnpack`.
    """

    @property
    def observation(self) -> ObservationCommonView:
        return ObservationCommonView(self._get("observation"))


EnrichmentTaskResultView = Union[
    ArtifactTaskResultView, ReportTaskResultView, ObservationTaskResultView
]


class EnrichmentTaskErrorView(JsonObjectView):
    """Enrichment task error view."""

    @property
    def code(self) -> EnrichmentErrorCodes:
        """Error code."""
        return EnrichmentErrorCodes(self._get("code"))

    @property
    def message(self) -> str:
        """Error massage."""
        return self._get("message")


class TaskForm(JsonObjectForm):
    """Enrichment task form.

    This is the form you need to fill to register enrichment task.

    Args:
        task_type: Enrichment task type.
        params: Enrichment task params.
        data_source: Data source from which the result is expected.
            Required for ArtifactAnalysis, ExternalDBLookup enrichment type.
    Usage:
        >>> import uuid
        >>> from cybsi.api.enrichment import (
        >>>     EnrichmentTypes, TaskForm,
        >>>     ArchiveUnpackParamsForm
        >>> )
        >>> params = ArchiveUnpackParamsForm(
        >>>     artifact_uuid=uuid.UUID("4f9bc3d5-1f12-427e-bcd2-c83fdd09a90f")
        >>> )
        >>> task = TaskForm(
        >>>     task_type=EnrichmentTypes.ArchiveUnpack,
        >>>     params=params
        >>> )
    """

    def __init__(
        self,
        task_type: EnrichmentTypes,
        params: "EnrichmentTaskParamsForm",
        *,
        data_source: Optional[uuid.UUID] = None,
    ):
        super().__init__()
        self._data["type"] = task_type.value
        self._data["params"] = params.json()
        if data_source is not None:
            self._data["dataSource"] = {"uuid": str(data_source)}


class TaskView(RefView):
    """Enrichment task view."""

    _param_types = {
        EnrichmentTypes.ArtifactDownload: ArtifactDownloadParamsView,
        EnrichmentTypes.ArchiveUnpack: ArchiveUnpackParamsView,
        EnrichmentTypes.ArtifactAnalysis: ArtifactAnalysisParamsView,
        EnrichmentTypes.ExternalDBLookup: ExternalDBLookupParamsView,
        EnrichmentTypes.WhoisLookup: ExternalDBLookupParamsView,
        EnrichmentTypes.DNSLookup: ExternalDBLookupParamsView,
    }

    _result_types = {
        EnrichmentTypes.ArtifactDownload: ArtifactTaskResultView,
        EnrichmentTypes.ArchiveUnpack: ObservationTaskResultView,
        EnrichmentTypes.ExternalDBLookup: ObservationTaskResultView,
        EnrichmentTypes.WhoisLookup: ObservationTaskResultView,
        EnrichmentTypes.DNSLookup: ObservationTaskResultView,
        EnrichmentTypes.ArtifactAnalysis: ReportTaskResultView,
    }

    @property
    def priority(self) -> EnrichmentTaskPriorities:
        """Priority."""
        return EnrichmentTaskPriorities(self._get("priority"))

    @property
    def created_at(self) -> datetime.datetime:
        """Date and time of task creation."""
        return parse_rfc3339_timestamp(self._get("createdAt"))

    @property
    def updated_at(self) -> datetime.datetime:
        """Date and time of last task update."""
        return parse_rfc3339_timestamp(self._get("updatedAt"))

    @property
    def data_source(self) -> Optional[DataSourceCommonView]:
        """Data source associated with enricher.

        Not empty for ArtifactAnalysis, ExternalDBLookup enrichment type.
        """
        return self._map_optional("dataSource", DataSourceCommonView)

    @property
    def type(self) -> EnrichmentTypes:
        """Enrichment type."""
        return EnrichmentTypes(self._get("type"))

    @property
    def params(self) -> EnrichmentTaskParamsView:
        """Parameters of task. Determine exact type of parameters
        using property :attr:`type`.

        If enricher was a function, this would be function parameters.

        Usage:
            >>> from typing import cast
            >>> from cybsi.api.enrichment import (
            >>>     TaskView, EnrichmentTypes,
            >>>     ExternalDBLookupParamsView
            >>> )
            >>> task_view = TaskView()
            >>> if task_view.type == EnrichmentTypes.ExternalDBLookup:
            >>>     lookup = cast(ExternalDBLookupParamsView, task_view.params)
            >>>     print(lookup.entity)
        """
        params = self._param_types[self.type](self._get("params"))
        return cast(EnrichmentTaskParamsView, params)

    @property
    def status(self) -> EnrichmentTaskStatuses:
        """Enrichment task status."""
        return EnrichmentTaskStatuses(self._get("status"))

    @property
    def error(self) -> Optional[EnrichmentTaskErrorView]:
        """Enrichment task error.

        Can be filled only if the enrichment task status is Failed or Aborted.
        """
        return self._map_optional("error", EnrichmentTaskErrorView)

    @property
    def result(self) -> EnrichmentTaskResultView:
        """Result of task. Determine exact type of result
        using property :attr:`type`.

        If enricher was a function, this would be function parameters.

        Usage:
            >>> from typing import cast
            >>> from cybsi.api.enrichment import (
            >>>     TaskView, EnrichmentTypes,
            >>>     ExternalDBLookupParamsView
            >>> )
            >>> task_view = TaskView()
            >>> if task_view.type == EnrichmentTypes.ExternalDBLookup:
            >>>     observation = cast(ObservationTaskResultView, task_view.result)
            >>>     print(observation)
        """
        result = self._result_types[self.type](self._get("result"))
        return cast(EnrichmentTaskResultView, result)

cybsi/api/enrichment/tasks.py
cyberstudio/cybsi-sdk
from enum import Enum
from functools import lru_cache
from typing import TypeVar

ET = TypeVar("ET")


class CybsiAPIEnum(Enum):
    """CybsiAPIEnum is a base class for all Cybsi API enumerations."""

    @classmethod
    @lru_cache()
    def from_string(cls, value: str, ignore_case=False):
        """Convert a string value to enumeration value.

        Args:
            value: value to convert.
            ignore_case: ignore enumeration values case.
        Return:
            Enumeration value.
        """
        try:
            return cls(value)
        except ValueError as exp:
            if not ignore_case:
                raise exp from None

            for member in cls:
                if str(member.value).lower() == value.lower():
                    return member

            raise exp from None

cybsi/api/enum.py
cyberstudio/cybsi-sdk
"""This section documents exceptions SDK can raise.

Those are exceptions SDK client can expect if SDK is used in correct way.
If your Python code didn't respect the expected model, there's no guarantees.
For example, if function argument types are ignored,
SDK can raise exceptions not listed here.

Each exception type is annotated if it makes sense to retry.

Some exceptions have ``code`` property. It allows to determine the concrete error.
"""
from typing import Any, Dict, Optional, cast

import httpx
from enum_tools import document_enum

from .enum import CybsiAPIEnum


class CybsiError(Exception):
    """Base exception used by SDK. Sometimes can be retried.

    If it's not of one of subclasses (see below), means unexpected error.

    :class:`CybsiError` covers those cases:

    * Unexpected API response status code. See :class:`APIError` for specific errors.
    * Unexpected API response content.
    * Connection error.

    """

    def __init__(self, message: str, ex: Optional[Exception] = None):
        if ex and str(ex) != "":
            message = f"{message}: {ex}"
        super().__init__(message)
        self._ex = ex


JsonObject = Dict[str, Any]


class APIError(CybsiError):
    """Base exception for HTTP 4xx API responses."""

    def __init__(
        self,
        status: int,
        content: JsonObject,
        header: Optional[str] = None,
        suffix: Optional[str] = None,
    ) -> None:
        self._status = status
        self._view = _ErrorView(content)
        self._header = (
            header
            if header is not None
            else f"API response error. HTTP status: {self._status}"
        )
        self._suffix = (
            suffix
            if suffix is not None
            else f"code: {self._view.code}, message: {self._view.message}"
        )

        msg = self._header
        if self._suffix:
            msg += f", {self._suffix}"
        super().__init__(msg)

    @property
    def content(self) -> JsonObject:
        return self._view


class InvalidRequestError(APIError):
    """Invalid request error. Retry will never work.

    Ideally, should not be raised by SDK. If it's raised, it means one of two things:

    * SDK was used incorrectly. For example, values of invalid type
      were passed to SDK functions.
    * There's a bug in request serialization in SDK. Please report the bug.

    """

    BadRequest = "BadRequest"
    InvalidData = "InvalidData"
    InvalidPathArgument = "InvalidPathArgument"
    InvalidQueryArgument = "InvalidQueryArgument"
    NoSuchAttribute = "NoSuchAttribute"

    def __init__(self, content: JsonObject) -> None:
        super().__init__(400, content, header="invalid request")


class UnauthorizedError(APIError):
    """Client lacks valid authentication credentials. Retry will never work."""

    Unauthorized = "Unauthorized"

    def __init__(self, content: JsonObject) -> None:
        super().__init__(401, content, header="operation not authorized")


class ForbiddenError(APIError):
    """Operation was forbidden. Retry will not work unless system is reconfigured."""

    def __init__(self, content: JsonObject) -> None:
        super().__init__(403, content, header="operation forbidden")

    @property
    def code(self) -> "ForbiddenErrorCodes":
        """Error code."""
        return ForbiddenErrorCodes(self._view.code)


class NotFoundError(APIError):
    """Requested resource not found. Retry will never work."""

    def __init__(self, content: JsonObject) -> None:
        super().__init__(404, {}, header="resource not found", suffix="")


class ConflictError(APIError):
    """Resource already exists. Retry will never work."""

    DuplicateDataSource = "DuplicateDataSource"
    DuplicateDataSourceType = "DuplicateDataSourceType"
    DuplicateKey = "DuplicateKey"
    DuplicateLogin = "DuplicateLogin"

    def __init__(self, content: JsonObject) -> None:
        super().__init__(409, content, header="resource already exists")


class ResourceModifiedError(APIError):
    """Resource was modified since last read. **Retry is a must**.

    Read the updated resource from API, and apply your modifications again.
    """

    ErrResourceModified = "ResourceModified"

    def __init__(self, content: JsonObject) -> None:
        super().__init__(
            412, content, header="resource was modified since last read", suffix=""
        )


class SemanticError(APIError):
    """Semantic error. Retry will not work (almost always).

    Request syntax was valid, but system business rules forbid the request.

    For example, we're trying to unpack an artifact, but the artifact is not an archive.
    """

    def __init__(self, content: JsonObject) -> None:
        super().__init__(422, content, header="semantic error")

    @property
    def code(self) -> "SemanticErrorCodes":
        """Error code."""
        return SemanticErrorCodes(self._view.code)


@document_enum
class ForbiddenErrorCodes(CybsiAPIEnum):
    """Possible error codes of :class:`ForbiddenError`."""

    InvalidCredentials = "InvalidCredentials"
    """User provided invalid credentials."""
    InsufficientAccessLevel = "InsufficientAccessLevel"
    """User authenticated but has insufficient access level to access the resource."""
    MissingPermissions = "MissingPermissions"
    """User authenticated but not authorized to perform operation."""
    NotOwner = "NotOwner"
    """Only owner can edit the resource."""
    Forbidden = "Forbidden"
    """Other cases."""


@document_enum
class SemanticErrorCodes(CybsiAPIEnum):
    """Common semantic error codes."""

    ArtifactNotFound = "ArtifactNotFound"
    """Artifact not found."""
    BrokenKeySet = "BrokenKeySet"
    """Key set identifies multiple entities."""
    CorruptedLicense = "CorruptedLicense"
    """Invalid signature or license data corrupted"""
    CursorOutOfRange = "CursorOutOfRange"
    """Cursor points outside of event list."""
    DataSourceNotFound = "DataSourceNotFound"
    """Data source not found."""
    DataSourceTypeNotFound = "DataSourceTypeNotFound"
    """Data source type not found."""
    DictionaryClosed = "DictionaryClosed"
    """Dictionary is closed for modification."""
    DictionaryNotFound = "DictionaryNotFound"
    """Dictionary not found."""
    DictionaryItemNotFound = "DictionaryItemNotFound"
    """Dictionary item not found."""
    DuplicatedEntityAttribute = "DuplicatedEntityAttribute"
    """
        Entity attribute was specified several times
        and attribute is not an array.
    """
    EnrichmentNotAllowed = "EnrichmentNotAllowed"
    """Enrichment using provided parameters is not possible."""
    EntityNotFound = "EntityNotFound"
    """Entity not found."""
    EntityViewNotFound = "EntityViewNotFound"
    """Entity view not found."""
    ExpiredLicense = "ExpiredLicense"
    """Expired license"""
    FileNotFound = "FileNotFound"
    """File entity not found."""
    LicenseFileMissing = "LicenseFileMissing"
    """License file is missing or corrupted"""
    ImmutableValue = "ImmutableValue"
    """Resource attribute edits are blocked."""
    InvalidAttribute = "InvalidAttribute"
    """Invalid attribute for such entity."""
    InvalidAttributeValue = "InvalidAttributeValue"
    """Invalid attribute value."""
    InvalidDictionary = "InvalidDictionary"
    """
        The specified dictionary item and the synonym
        refer to different directories.
    """
    InvalidErrorCode = "InvalidErrorCode"
    """Invalid task error code reported by enricher."""
    InvalidKey = "InvalidKey"
    """Invalid key value for such entity or entity key type."""
    InvalidKeySet = "InvalidKeySet"
    """
        Entity has invalid set of keys
        (invalid key type for such entity,
        invalid number of keys, and so on).
    """
    InvalidQueryText = "InvalidQueryText"
    """Query text is invalid CybsiLang expression."""
    InvalidRelationship = "InvalidRelationship"
    """Relationship is invalid."""
    InvalidRule = "InvalidRule"
    """Enrichment configuration rule is invalid."""
    InvalidShareLevel = "InvalidShareLevel"
    """Specified share level is above API client share level."""
    InvalidTaskResult = "InvalidTaskResult"
    """Invalid task result was reported by enricher."""
    InvalidTaskStatus = "InvalidTaskStatus"
    """
        Could not accept enrichment result,
        task status is not ``Executing``.
    """
    InvalidSynonym = "InvalidSynonym"
    """
        The specified element is an invalid synonym (for example,
        the element and the synonym have the same key).
    """
    InvalidTime = "InvalidTime"
    """Timestamp is invalid (for example, it's in the future)"""
    KeyConflict = "KeyConflict"
    """
        Provided entity key conflicts with key
        already registered in the system."""
    MisconfiguredDataSource = "MisconfiguredDataSource"
    """Enrichment rule is invalid for such data source."""
    NonLocalUser = "NonLocalUser"
    """
        Impossible to change user attribute because
        this attribute is managed by external provider.
    """
    NotOwner = "NotOwner"
    """Only owner can edit the resource."""
    ObservationNotFound = "ObservationNotFound"
    """Observation not found."""
    PasswordAuthDisabled = "PasswordAuthDisabled"
    """
        Impossible to change the password because
        password auth for such user is disabled.
    """
    PermissionsExceeded = "PermissionsExceeded"
    """Provided set of permissions exceeds user permissions."""
    ReportNotFound = "ReportNotFound"
    """Report not found."""
    StoredQueryNotFound = "StoredQueryNotFound"
    """Stored query not found."""
    SynonymGroupConflict = "SynonymGroupConflict"
    """The specified item and synonym belong to different synonym groups."""
    ItemAlreadyInSynonymGroup = "ItemAlreadyInSynonymGroup"
    """The specified synonym is already in a synonym group."""
    TaskNotFound = "TaskNotFound"
    """Enrichment task not found."""
    UnallowedObservationType = "UnallowedObservationType"
    """Observation of such type cannot be attached to report."""
    UnsupportedProduct = "UnsupportedProduct"
    """Unsupported product"""
    UserDisabled = "UserDisabled"
    """User disabled."""
    UserNotFound = "UserNotFound"
    """User not found."""
    WrongEntityAttribute = "WrongEntityAttribute"
    """The attribute is not registered for provided entity."""


class _ErrorView(dict):
    """Error returned by Cybsi API."""

    @property
    def code(self) -> str:
        """Error code."""

        return cast(str, self.get("code"))

    @property
    def message(self) -> str:
        """Error message."""

        return cast(str, self.get("message"))


_error_mapping = {
    400: InvalidRequestError,
    401: UnauthorizedError,
    403: ForbiddenError,
    404: NotFoundError,
    409: ConflictError,
    412: ResourceModifiedError,
    422: SemanticError,
}


def _raise_cybsi_error(resp: httpx.Response) -> None:
    err_cls = _error_mapping.get(resp.status_code, None)
    if err_cls is not None:
        raise err_cls(resp.json())

    raise CybsiError(
        f"unexpected response status code: {resp.status_code}. "
        f"Request body: {resp.text}"
    )

cybsi/api/error.py
cyberstudio/cybsi-sdk
from .base import (
    BaseAPI,
    BaseAsyncAPI,
    JsonObject,
    JsonObjectForm,
    JsonObjectView,
    list_mapper,
)
from .connector import HTTPConnector
from .time import (
    parse_rfc3339_timestamp,
    rfc3339_timestamp,
)

cybsi/api/internal/__init__.py
cyberstudio/cybsi-sdk
"""
Base internal classes, useful to simplify API implementation.
"""

import json
from typing import Any, Callable, Dict, List, Optional, TypeVar

from ..error import CybsiError
from .connector import AsyncHTTPConnector, HTTPConnector

JsonObject = Dict[str, Any]


class BaseAPI:
    # Base class for all API handle implementations.
    def __init__(self, connector: HTTPConnector):
        self._connector = connector


class BaseAsyncAPI:
    # Base class for all async API handle implementations.
    def __init__(self, connector: AsyncHTTPConnector):
        self._connector = connector


class JsonObjectForm:
    def __init__(self, data: Optional[JsonObject] = None):
        self._data = data or {}

    def __str__(self):
        return json.dumps(self._data, indent=2)

    def json(self):
        return self._data


class JsonObjectView:
    def __init__(self, data: Optional[JsonObject] = None):
        self._data = data or {}

    def __str__(self):
        return json.dumps(self._data, indent=2)

    def _get(self, key):
        try:
            return self._data[key]
        except KeyError as exp:
            msg = f"{self.__class__.__name__} does not have field: {exp}"
            raise CybsiError(msg) from None

    def _get_optional(self, key):
        return self._data.get(key, None)

    def _map_optional(self, key, mapper: Callable[[Any], Any]):
        value = self._get_optional(key)
        if value is not None:
            return mapper(value)
        return None

    def _map_list_optional(self, key, mapper: Callable[[Any], Any]):
        values = self._get_optional(key)
        if values is not None:
            return [mapper(val) for val in values]
        return None


T = TypeVar("T")


def list_mapper(item_creator: Callable[..., T]) -> Callable[..., List[T]]:
    def _create_typed_list(items: List) -> List[T]:
        return [item_creator(item) for item in items]

    return _create_typed_list

cybsi/api/internal/base.py
cyberstudio/cybsi-sdk
from typing import Any, Optional

import httpx

from cybsi.__version__ import __version__

from ..api import Tag
from ..client_config import DEFAULT_LIMITS, DEFAULT_TIMEOUTS, Limits, Timeouts
from ..error import CybsiError, _raise_cybsi_error
from .multipart import apply_async_multipart_stream

_BASIC_HEADERS = {
    "Accept": "application/vnd.ptsecurity.app-v2",
    "User-Agent": f"cybsi-sdk-client/v{__version__}",
}

_IF_MATCH_HEADER = "If-Match"

apply_async_multipart_stream()


class HTTPConnector:
    """Connector performing round trips to Cybsi."""

    def __init__(
        self,
        base_url: str,
        auth: Any,
        ssl_verify=True,
        embed_object_url=False,
        timeouts: Timeouts = DEFAULT_TIMEOUTS,
        limits: Limits = DEFAULT_LIMITS,
    ):
        self._embed_object_url = embed_object_url
        self._client = httpx.Client(
            auth=auth,
            verify=ssl_verify,
            base_url=base_url,
            headers=_BASIC_HEADERS,
            timeout=timeouts._as_httpx_timeouts(),
            limits=limits._as_httpx_limits(),
        )

    def __enter__(self) -> "HTTPConnector":
        self._client.__enter__()
        return self

    def __exit__(
        self,
        exc_type=None,
        exc_value=None,
        traceback=None,
    ) -> None:
        self._client.__exit__(exc_type, exc_value, traceback)

    def close(self) -> None:
        """Close client and release connections."""
        self._client.close()

    def do_get(
        self, path: str, params: Optional[dict] = None, stream=False, **kwargs
    ) -> httpx.Response:
        if params is None:
            params = {}
        if not self._embed_object_url:
            params["embedObjectURL"] = self._embed_object_url
        return self._do("GET", path, params=params, stream=stream, **kwargs)

    def do_post(self, path: str, json: Any = None, **kwargs) -> httpx.Response:
        return self._do("POST", path, json=json, **kwargs)

    def do_patch(
        self, path: str, tag: Tag, json: Any = None, **kwargs
    ) -> httpx.Response:
        headers = kwargs.setdefault("headers", {})
        headers[_IF_MATCH_HEADER] = tag
        return self._do("PATCH", path, json=json, **kwargs)

    def do_put(self, path: str, json: Any = None, **kwargs) -> httpx.Response:
        return self._do("PUT", path, json=json, **kwargs)

    def do_delete(
        self, path: str, params: Optional[dict] = None, **kwargs
    ) -> httpx.Response:
        return self._do("DELETE", path, params=params, **kwargs)

    def _do(self, method: str, path: str, stream=False, **kwargs):
        """Do HTTP request.

        Args:
            method: HTTP method i.e GET, POST, PUT.
            path: URL path.
            kwargs: Any kwargs supported by httpx.Request.
        Return:
            Response.
        Raise:
            :class:`~cybsi.api.error.CybsiError`: On connectivity issues.
            :class:`~cybsi.api.error.APIError`: If response status code is >= 400
        """
        req = self._client.build_request(method, url=path, **kwargs)
        try:
            resp = self._client.send(request=req, stream=stream)
        except CybsiError:
            raise
        except httpx.TimeoutException as exp:
            # Handle httpx.TimeoutException separately
            # because it doesn't have an exception message.
            raise CybsiError("request timeout", exp) from exp
        except Exception as exp:
            raise CybsiError("could not send request", exp) from exp

        if not resp.is_success:
            if resp.stream:  # type: ignore
                # read stream data to raise the error
                resp.read()
            _raise_cybsi_error(resp)

        return resp


class AsyncHTTPConnector:
    """Asynchronous connector performing round trips to Cybsi."""

    def __init__(
        self,
        base_url: str,
        auth: Any,
        ssl_verify=True,
        embed_object_url=False,
        timeouts: Timeouts = DEFAULT_TIMEOUTS,
        limits: Limits = DEFAULT_LIMITS,
    ):
        self._embed_object_url = embed_object_url
        self._client = httpx.AsyncClient(
            auth=auth,
            verify=ssl_verify,
            base_url=base_url,
            headers=_BASIC_HEADERS,
            timeout=timeouts._as_httpx_timeouts(),
            limits=limits._as_httpx_limits(),
        )

    async def __aenter__(self) -> "AsyncHTTPConnector":
        await self._client.__aenter__()
        return self

    async def __aexit__(
        self,
        exc_type=None,
        exc_value=None,
        traceback=None,
    ) -> None:
        await self._client.__aexit__(exc_type, exc_value, traceback)

    async def aclose(self) -> None:
        """Close client and release connections."""
        await self._client.aclose()

    async def do_get(
        self, path: str, params: Optional[dict] = None, stream=False, **kwargs
    ) -> httpx.Response:
        if params is None:
            params = {}
        if not self._embed_object_url:
            params["embedObjectURL"] = self._embed_object_url
        return await self._do("GET", path, params=params, stream=stream, **kwargs)

    async def do_post(self, path: str, json=None, **kwargs) -> httpx.Response:
        return await self._do("POST", path, json=json, **kwargs)

    async def do_patch(
        self, path: str, tag: Tag, json: Any = None, **kwargs
    ) -> httpx.Response:
        headers = kwargs.setdefault("headers", {})
        headers[_IF_MATCH_HEADER] = tag
        return await self._do("PATCH", path, json=json, **kwargs)

    async def do_put(self, path: str, json=None, **kwargs) -> httpx.Response:
        return await self._do("PUT", path, json=json, **kwargs)

    async def do_delete(
        self, path: str, params: Optional[dict] = None, **kwargs
    ) -> httpx.Response:
        return await self._do("DELETE", path, params=params, **kwargs)

    async def _do(self, method: str, path: str, stream=False, **kwargs):
        """Do HTTP request.

        Args:
            method: HTTP method i.e GET, POST, PUT.
            path: URL path.
            kwargs: Any kwargs supported by httpx.Request.
        Return:
            Response.
        Raise:
            :class:`~cybsi.api.error.CybsiError`: On connectivity issues.
            :class:`~cybsi.api.error.APIError`: If response status code is >= 400
        """
        req = self._client.build_request(method, url=path, **kwargs)
        try:
            resp = await self._client.send(request=req, stream=stream)
        except CybsiError:
            raise
        except httpx.TimeoutException as exp:
            # Handle httpx.TimeoutException separately
            # because it doesn't have an exception message.
            raise CybsiError("request timeout", exp) from exp
        except Exception as exp:
            raise CybsiError("could not send request", exp) from exp

        if not resp.is_success:
            if resp.stream:  # type: ignore
                # read stream data to raise the error
                await resp.aread()
            _raise_cybsi_error(resp)

        return resp

cybsi/api/internal/connector.py
cyberstudio/cybsi-sdk
"""
See https://github.com/encode/httpx/issues/1620

If you need to upload async stream as a multipart `files` argument,
you need to apply this patch and wrap stream with `AsyncStreamWrapper`::
"""
from asyncio import StreamReader
from typing import AsyncIterator, Optional, Union

from httpx import _content
from httpx._multipart import FileField, MultipartStream
from httpx._types import RequestFiles


class AsyncStreamWrapper:
    def __init__(self, stream: Union[AsyncIterator[bytes], StreamReader], size: int):
        self.stream = stream
        self.size = size


class AsyncAwareMultipartStream(MultipartStream):
    def __init__(
        self, data: dict, files: RequestFiles, boundary: Optional[bytes] = None
    ) -> None:
        super().__init__(data, files, boundary)
        for field in self.fields:
            if isinstance(field, FileField) and isinstance(
                field.file, AsyncStreamWrapper
            ):
                field.get_length = lambda f=field: len(f.render_headers()) + f.file.size  # type: ignore # noqa: E501

    async def __aiter__(self) -> AsyncIterator[bytes]:
        for field in self.fields:
            yield b"--%s\r\n" % self.boundary
            if isinstance(field, FileField) and isinstance(
                field.file, AsyncStreamWrapper
            ):
                yield field.render_headers()
                async for chunk in field.file.stream:
                    yield chunk
            else:
                for chunk in field.render():
                    yield chunk
            yield b"\r\n"
        yield b"--%s--\r\n" % self.boundary


def apply_async_multipart_stream():
    _content.MultipartStream = AsyncAwareMultipartStream

cybsi/api/internal/multipart.py
cyberstudio/cybsi-sdk
import datetime


def rfc3339_timestamp(dt: datetime.datetime) -> str:
    """
    Generate an :RFC:`3339`-formatted timestamp in UTC time zone from a
    :class:`datetime.datetime`.
    Microseconds are ignored.
    >>> import datetime as dtm
    >>> rfc3339_timestamp(dtm.datetime(2009,1,1,12,59,59,0,dtm.timezone.utc))
    '2009-01-01T12:59:59Z'
    If timestamp is naive, local time zone is used to calculate shift.
    >>> rfc3339_timestamp(dtm.datetime(2009,1,1,12,59,59,0))
    '2009-01-01T06:59:59Z'
    """
    dt = dt.astimezone(datetime.timezone.utc)
    timestamp = dt.strftime("%Y-%m-%dT%H:%M:%SZ")
    return timestamp


def parse_rfc3339_timestamp(ts: str) -> datetime.datetime:
    if ts.find(".") != -1:
        return datetime.datetime.strptime(ts, "%Y-%m-%dT%H:%M:%S.%fZ")
    else:
        return datetime.datetime.strptime(ts, "%Y-%m-%dT%H:%M:%SZ")

cybsi/api/internal/time.py
cyberstudio/cybsi-sdk
"""Use this section of API to operate licenses.
"""

from .enums import Status
from .api import LicensesAPI, LicenseInfoView

cybsi/api/license/__init__.py
cyberstudio/cybsi-sdk
from datetime import datetime
from typing import Any, List, Optional

from ..internal import BaseAPI, JsonObjectView, parse_rfc3339_timestamp
from .enums import Status


class LicensesAPI(BaseAPI):
    """API to operate licenses.

    .. versionadded:: 2.10
    """

    _path = "/license"
    _info_path = "/license/info"

    def upload(self, data: Any):
        """Upload license.

        .. versionadded:: 2.10
        Note:
            Calls `PUT /license`.
        Args:
            data: File-like object. Data must be present
                as a `license-access-token.key` file or
                as zip-archive with nested `license-access-token.key`.
                If you have key in bytes, wrap them in BytesIO.
        Raises:
            :class:`~cybsi.api.error.SemanticError`: Form contains logic errors.
        Note:
            Semantic error codes specific for this method:
              * :attr:`~cybsi.api.error.SemanticErrorCodes.LicenseFileMissing`
              * :attr:`~cybsi.api.error.SemanticErrorCodes.CorruptedLicense`
              * :attr:`~cybsi.api.error.SemanticErrorCodes.ExpiredLicense`
              * :attr:`~cybsi.api.error.SemanticErrorCodes.UnsupportedProduct`
        """
        self._connector.do_put(path=self._path, content=data)

    def info(self) -> "LicenseInfoView":
        """Get the license information.

        .. versionadded:: 2.10
        Note:
            Calls `GET /license/info`.
        Returns:
            View of the license info.
        Raises:
            :class:`~cybsi.api.error.NotFoundError`: License not found.
        """

        resp = self._connector.do_get(path=self._info_path)
        return LicenseInfoView(resp.json())


class LicenseInfoView(JsonObjectView):
    """License information view."""

    @property
    def no(self) -> int:
        """License serial number."""
        return self._get("no")

    @property
    def expires_at(self) -> datetime:
        """License expiration time."""
        return parse_rfc3339_timestamp(self._get("expiresAt"))

    @property
    def updated_at(self) -> Optional[datetime]:
        """Time of the last license update from the update server.
        :data:`None` if license wasn't updated yet.
        """
        return self._map_optional("updatedAt", parse_rfc3339_timestamp)

    @property
    def status(self) -> List[Status]:
        """License status (flag set)."""
        return [Status(s) for s in self._get("status")]

cybsi/api/license/api.py
cyberstudio/cybsi-sdk
from enum_tools.documentation import document_enum

from cybsi.api.enum import CybsiAPIEnum


@document_enum
class Status(CybsiAPIEnum):
    """License status."""

    Valid = "Valid"  # doc: Valid current license. Excludes all other statuses.
    Expired = "Expired"  # doc: The license has expired.
    Inactive = "Inactive"  # doc: Removed or deactivated.

cybsi/api/license/enums.py
cyberstudio/cybsi-sdk
"""Use this section of API to register entities
and retrieve aggregates about entities.
"""
from .api import ObservableAPI, ObservableAsyncAPI
from .entities_api import EntitiesAPI, EntitiesAsyncAPI
from .view import AbstractEntityView, EntityViewT, EntityViewsAPI, EntityViewView
from .entity import (
    EntityForm,
    EntityKeyView,
    EntityView,
    EntityAggregateView,
    EntityAttributeForecastView,
    AttributeForecastView,
)

from .aggregate_section import (
    SectionsView,
    SectionView,
    AttributesSectionData,
    AttributeAggregatedValue,
    ValuableFactView,
    AttributeValuableFactView,
    ThreatSectionData,
    GeoIPSectionData,
    LabelsSectionData,
    AttributeValueView,
)

from .links import (
    EntityLinksForecastView,
    LinkForecastView,
    EntityLinkStatisticView,
    LinkTypeView,
    LinkStatisticView,
    LinkConfidenceDistributionView,
)

from .relationships import (
    RelationshipsAPI,
    RelationshipsForecastView,
    RelationshipView,
)

from .annotations import AnnotationsAPI

from .enums import (
    AttributeNames,
    EntityKeyTypes,
    EntityTypes,
    RelationshipKinds,
    ShareLevels,
    EntityAggregateSections,
    ThreatStatus,
    ThreatCategory,
    RelatedThreatCategory,
    LinkDirection,
    NodeRole,
    IdentityClass,
)

cybsi/api/observable/__init__.py
cyberstudio/cybsi-sdk
import uuid
from datetime import datetime
from enum import Enum
from typing import Any, Callable, Dict, Generic, List, Optional, TypeVar, Union

from .. import RefView
from ..dictionary import DictionaryItemCommonView
from ..internal import JsonObject, JsonObjectView, list_mapper, parse_rfc3339_timestamp
from .enums import (
    AttributeNames,
    EntityAggregateSections,
    IdentityClass,
    NodeRole,
    PotentialDamage,
    RelatedThreatCategory,
    ShareLevels,
    ThreatCategory,
    ThreatStatus,
)

T = TypeVar("T")
"""Type of section data. Depends on section name."""


AttributeValueView = Union[str, bool, int, uuid.UUID, DictionaryItemCommonView, Enum]


def _convert_attribute_value_type(
    attribute_name: AttributeNames, val: Any
) -> AttributeValueView:
    _attribute_value_types = {
        AttributeNames.Names: str,
        AttributeNames.DisplayNames: str,
        AttributeNames.IsIoC: bool,
        AttributeNames.IsDGA: bool,
        AttributeNames.IsTrusted: bool,
        AttributeNames.Size: int,
        AttributeNames.MalwareNames: str,
        AttributeNames.MalwareClasses: DictionaryItemCommonView,
        AttributeNames.MalwareFamilies: DictionaryItemCommonView,
        AttributeNames.RelatedMalwareFamilies: DictionaryItemCommonView,
        AttributeNames.NodeRoles: NodeRole,
        AttributeNames.ThreatCategory: ThreatCategory,
        AttributeNames.RelatedThreatCategory: RelatedThreatCategory,
        AttributeNames.Sectors: DictionaryItemCommonView,
        AttributeNames.Class: IdentityClass,
        AttributeNames.ASN: int,
        AttributeNames.Statuses: DictionaryItemCommonView,
        AttributeNames.Campaigns: DictionaryItemCommonView,
        AttributeNames.ThreatActors: DictionaryItemCommonView,
        AttributeNames.AffectedCountries: DictionaryItemCommonView,
        AttributeNames.ExploitedVulnerabilities: DictionaryItemCommonView,
        AttributeNames.TargetedSectors: DictionaryItemCommonView,
        AttributeNames.RegistrationCountry: DictionaryItemCommonView,
        AttributeNames.PotentialDamage: PotentialDamage,
    }

    return _attribute_value_types[attribute_name](val)


class SectionView(JsonObjectView, Generic[T]):
    """Common section view."""

    def __init__(self, data: JsonObject, data_view: Callable[..., T]):
        super().__init__(data)
        self._data_view = data_view

    @property
    def name(self) -> str:
        """Section name."""
        return self._get("name")

    @property
    def data_raw(self) -> Any:
        """Section raw data."""
        return self._get("data")

    @property
    def data(self) -> T:
        """Section data."""
        return self._data_view(self._get("data"))


class ValuableFactView(JsonObjectView):
    """Facts influenced on value and its confidence."""

    @property
    def data_source(self) -> RefView:
        """Data source of the fact."""
        return RefView(self._get("dataSource"))

    @property
    def share_level(self) -> ShareLevels:
        """Share level of the fact."""
        return ShareLevels(self._get("shareLevel"))

    @property
    def seen_at(self) -> datetime:
        """Date and time when facts were seen."""
        return parse_rfc3339_timestamp(self._get("seenAt"))

    @property
    def confidence(self) -> float:
        """Data source confidence in the fact.
        Value is in range (0; 1]."""
        return self._get("confidence")

    @property
    def final_confidence(self) -> float:
        """Cybsi final confidence in the fact.
        Value is in range (0; 1]."""
        return self._get("finalConfidence")


class AttributeValuableFactView(ValuableFactView):
    """Valuable fact of attribute forecast view."""

    def __init__(self, data: JsonObject, attribute_name: AttributeNames):
        super().__init__(data)
        self._attribute_name = attribute_name

    @property
    def value(self) -> "AttributeValueView":
        """Facts attribute value. Returned value type depends on attribute.

        Usage:
            >>> from typing import cast
            >>> from cybsi.api.observable import AttributeValuableFactView
            >>> from cybsi.api.dictionary import DictionaryItemCommonView
            >>>
            >>> view = AttributesSectionData()
            >>> if view.attribute_name == AttributeNames.MalwareFamilies:
            >>>     for v in view.values:
            >>>         value = cast(DictionaryItemCommonView, v.value)
            >>>         print(value)
        """
        return _convert_attribute_value_type(self._attribute_name, self._get("value"))


class AttributeAggregatedValue(JsonObjectView):
    """View for attribute value aggregated data."""

    def __init__(self, data: JsonObject, attribute_name: AttributeNames):
        super().__init__(data)
        self._attribute_name = attribute_name

    @property
    def value(self) -> "AttributeValueView":
        """Attribute value. Returned value type depends on attribute.

        Usage:
            >>> from typing import cast
            >>> from cybsi.api.observable import AttributeValuableFactView
            >>> from cybsi.api.dictionary import DictionaryItemCommonView
            >>>
            >>> view = AttributesSectionData()
            >>> if view.attribute_name == AttributeNames.MalwareFamilies:
            >>>     for v in view.values:
            >>>         value = cast(DictionaryItemCommonView, v.value)
            >>>         print(value)
        """
        return _convert_attribute_value_type(self._attribute_name, self._get("value"))

    @property
    def confidence(self) -> float:
        """Confidence of the value. Value is in range (0; 1]."""
        return self._get("confidence")

    @property
    def valuable_facts(self) -> Optional[List[AttributeValuableFactView]]:
        """Facts influenced on value and its confidence.
        Can return None if valuable facts list is not set.

        Note:
            This list is not set in common aggregate methods results.
        """
        facts = self._get("valuableFacts")
        return (
            [AttributeValuableFactView(fact, self._attribute_name) for fact in facts]
            if facts is not None
            else None
        )


class AttributesSectionData(JsonObjectView):
    """View for attributes section data."""

    @property
    def attribute_name(self) -> AttributeNames:
        """Attribute name."""
        return AttributeNames(self._get("attributeName"))

    @property
    def has_conflicts(self) -> bool:
        """Attribute value has conflicts."""
        return self._get("hasConflicts")

    @property
    def values(self) -> List[AttributeAggregatedValue]:
        """Attribute values."""
        return [
            AttributeAggregatedValue(value, self.attribute_name)
            for value in self._get("values")
        ]


class ThreatSectionData(JsonObjectView):
    """View for threat section data."""

    @property
    def status(self) -> ThreatStatus:
        """Threat status."""
        return ThreatStatus(self._get("status"))


class GeoIPSectionData(JsonObjectView):
    """View for GeoIP section data."""

    @property
    def asn(self) -> Optional[int]:
        """Autonomous system number."""
        return self._get("asn")

    @property
    def country_code(self) -> Optional[str]:
        """Country cody in ISO."""
        return self._get("countryCode")

    @property
    def country(self) -> Optional[str]:
        """Country name."""
        return self._get("country")


class LabelsSectionData(JsonObjectView):
    """View for labels section data."""

    @property
    def labels(self) -> List[str]:
        """Labels."""
        return self._get("labels")


class SectionsView:
    """Sections list view."""

    _section_converters = {
        EntityAggregateSections.AssociatedAttributes: list_mapper(
            AttributesSectionData
        ),
        EntityAggregateSections.NaturalAttributes: list_mapper(AttributesSectionData),
        EntityAggregateSections.Threat: ThreatSectionData,
        EntityAggregateSections.GeoIP: GeoIPSectionData,
        EntityAggregateSections.Labels: LabelsSectionData,
    }

    def __init__(self, data: List[JsonObject]):
        self._sections = {}  # type: Dict[EntityAggregateSections,Any]
        for section in data:
            section_name = EntityAggregateSections(section["name"])
            view: Any = SectionView(
                section, self._section_converters[section_name]  # type: ignore
            )
            self._sections[section_name] = view

    @property
    def associated_attributes(self) -> SectionView[List[AttributesSectionData]]:
        """Associated with the entity attributes.

        Raises:
            :class:`KeyError`:
                Section is absent in the :class:`~cybsi.api.observable.SectionsView`.
        """
        val = self._sections[EntityAggregateSections.AssociatedAttributes]
        return val  # type: ignore

    @property
    def natural_attributes(self) -> SectionView[List[AttributesSectionData]]:
        """Natural attributes of the entity.

        Raises:
            :class:`KeyError`:
                Section is absent in the :class:`~cybsi.api.observable.SectionsView`.
        """
        val = self._sections[EntityAggregateSections.NaturalAttributes]
        return val  # type: ignore

    @property
    def threat(self) -> SectionView[ThreatSectionData]:
        """Entity threat status.

        Raises:
            :class:`KeyError`:
                Section is absent in the :class:`~cybsi.api.observable.SectionsView`.
        """
        return self._sections[EntityAggregateSections.Threat]  # type: ignore

    @property
    def geo_ip(self) -> SectionView[GeoIPSectionData]:
        """GeoIP information.

        Raises:
            :class:`KeyError`:
                Section is absent in the :class:`~cybsi.api.observable.SectionsView`.
        """
        return self._sections[EntityAggregateSections.GeoIP]  # type: ignore

    @property
    def labels(self) -> SectionView[LabelsSectionData]:
        """Labels of the entity.

        Raises:
            :class:`KeyError`:
                Section is absent in the :class:`~cybsi.api.observable.SectionsView`.
        """
        return self._sections[EntityAggregateSections.Labels]  # type: ignore

cybsi/api/observable/aggregate_section.py
cyberstudio/cybsi-sdk
from typing import Any, Dict, Optional

from ..internal import BaseAPI
from ..pagination import Cursor, Page


class AnnotationsAPI(BaseAPI):
    """Annotations API"""

    _path = "/observable/annotation"

    def filter(
        self,
        prefix: str,
        *,
        cursor: Optional[Cursor] = None,
        limit: Optional[int] = None,
    ) -> Page[str]:
        """Filter labels of observable entities.

        Note:
            Calls `GET /observable/annotation/labels`.
        Args:
            prefix: Label prefix.
                Prefix length must be in range [2, 50].
            cursor: Page cursor.
            limit: Page limit.
        Returns: List of labels.
            Labels are sorted in a stable best match order.
        """

        params: Dict[str, Any] = {}
        if prefix is not None:
            params["prefix"] = prefix
        if cursor is not None:
            params["cursor"] = str(cursor)
        if limit is not None:
            params["limit"] = str(limit)

        path = f"{self._path}/labels"
        resp = self._connector.do_get(path=path, params=params)
        page = Page(self._connector.do_get, resp, str)
        return page

cybsi/api/observable/annotations.py
cyberstudio/cybsi-sdk
from ..internal import BaseAPI, BaseAsyncAPI
from .annotations import AnnotationsAPI
from .entities_api import EntitiesAPI, EntitiesAsyncAPI
from .relationships import RelationshipsAPI
from .view import EntityViewsAPI


class ObservableAPI(BaseAPI):
    """Observable API."""

    @property
    def entities(self) -> EntitiesAPI:
        """Entities API."""
        return EntitiesAPI(self._connector)

    @property
    def entity_views(self) -> EntityViewsAPI:
        """Entity views API."""
        return EntityViewsAPI(self._connector)

    @property
    def relationships(self) -> RelationshipsAPI:
        """Relationships API."""
        return RelationshipsAPI(self._connector)

    @property
    def annotations(self) -> AnnotationsAPI:
        """Annotations API."""
        return AnnotationsAPI(self._connector)


class ObservableAsyncAPI(BaseAsyncAPI):
    """Observable async API."""

    @property
    def entities(self) -> EntitiesAsyncAPI:
        """Entities API handle."""
        return EntitiesAsyncAPI(self._connector)

cybsi/api/observable/api.py
cyberstudio/cybsi-sdk
import uuid
from datetime import datetime
from typing import Any, Dict, Iterable, List, Optional, Tuple

from .. import RefView
from ..internal import BaseAPI, BaseAsyncAPI, rfc3339_timestamp
from ..pagination import AsyncPage, Cursor, Page
from .entity import (
    EntityAggregateView,
    EntityAttributeForecastView,
    EntityForm,
    EntityKeyView,
)
from .enums import (
    AttributeNames,
    EntityAggregateSections,
    EntityKeyTypes,
    EntityTypes,
    LinkDirection,
    RelationshipKinds,
)
from .links import EntityLinksForecastView, EntityLinkStatisticView


def _convert_attribute_name_kebab(attribute_name: AttributeNames) -> str:
    """Convert attribute name value to kebab-case.

    Args:
        attribute_name: attribute name, such of 'DomainName'.
    Return:
        Attribute name on kebab-case, such of `domain-name`.
    """
    return _attr_value_kebab_converters[attribute_name]


_attr_value_kebab_converters: Dict[AttributeNames, str] = {
    AttributeNames.Size: "size",
    AttributeNames.Class: "class",
    AttributeNames.Sectors: "sectors",
    AttributeNames.DisplayNames: "display-names",
    AttributeNames.Names: "names",
    AttributeNames.NodeRoles: "node-roles",
    AttributeNames.RelatedMalwareFamilies: "related-malware-families",
    AttributeNames.MalwareClasses: "malware-classes",
    AttributeNames.IsIoC: "is-ioc",
    AttributeNames.IsTrusted: "is-trusted",
    AttributeNames.IsDGA: "is-dga",
    AttributeNames.IsDelegated: "is-delegated",
    AttributeNames.Statuses: "statuses",
    AttributeNames.ASN: "asn",
    AttributeNames.RegionalInternetRegistry: "regional-internet-registry",
    AttributeNames.RelatedThreatCategory: "related-threat-category",
    AttributeNames.ThreatCategory: "threat-category",
    AttributeNames.MalwareNames: "malware-names",
    AttributeNames.Campaigns: "campaigns",
    AttributeNames.ThreatActors: "threat-actors",
    AttributeNames.AffectedCountries: "affected-countries",
    AttributeNames.ExploitedVulnerabilities: "exploited-vulnerabilities",
    AttributeNames.TargetedSectors: "targeted-sectors",
    AttributeNames.RegistrationCountry: "registration-country",
    AttributeNames.PotentialDamage: "potential-damage",
}


class EntitiesAPI(BaseAPI):
    """Entities API."""

    _path = "/observable/entities"
    _path_canonical_key = "/observable/entity-canonical-key"

    def register(self, entity: EntityForm) -> RefView:
        """Register an entity.

        Note:
            Calls `PUT /observable/entities`.
        Args:
            entity: Entity registration form.
        Returns:
            Reference to a registered entity.
        Raises:
            :class:`~cybsi.api.error.SemanticError`: Form contains logic errors.
        Note:
            Semantic error codes specific for this method:
              * :attr:`~cybsi.api.error.SemanticErrorCodes.InvalidKeySet`
              * :attr:`~cybsi.api.error.SemanticErrorCodes.InvalidKey`
              * :attr:`~cybsi.api.error.SemanticErrorCodes.KeyConflict`
              * :attr:`~cybsi.api.error.SemanticErrorCodes.BrokenKeySet`
        Usage:
            >>> from cybsi.api import CybsiClient
            >>> from cybsi.api.observable import EntityForm, EntityTypes, EntityKeyTypes
            >>> client: CybsiClient
            >>> entity_form = EntityForm(
            >>>     EntityTypes.File,
            >>>     [(EntityKeyTypes.MD5, "3c4729715ef0d6bafd3d9c719e152099")],
            >>> )
            >>> # if you need to add extra key to entity form keys
            >>> entity_form.add_key(
            >>>     EntityKeyTypes.SHA1,
            >>>     "0462cb99ddd46c142aa46244cb8c2d35bfe226f5",
            >>> )
            >>> ref = client.observable.entities.register(entity_form)
            >>> # It's a good idea to use entity uuid in observation forms.
            >>> # But here we simply print returned ref.
            >>> print(ref)
        """
        r = self._connector.do_put(path=self._path, json=entity.json())
        return RefView(r.json())

    def view(
        self,
        entity_uuid: uuid.UUID,
        *,
        sections: Optional[Iterable[EntityAggregateSections]] = None,
        forecast_at: Optional[datetime] = None,
        with_valuable_facts: Optional[bool] = None,
    ) -> EntityAggregateView:
        """Get an entity view.

        Note:
            Calls `GET /observable/entities/{entity_uuid}`.
        Args:
            entity_uuid: Entity uuid.
            sections: Sections to be aggregated.
            forecast_at: Point of time to aggregate sections at.
            with_valuable_facts: Include valuable facts in response.
        Returns:
            Aggregated view of the entity.
        Raises:
            :class:`~cybsi.api.error.NotFoundError`: Entity not found.
        Usage:
            >>> from uuid import UUID
            >>> from cybsi.api import CybsiClient
            >>> from cybsi.api.observable import (
            >>>     EntityAggregateSections,
            >>>     EntityTypes,
            >>>     EntityKeyTypes
            >>> )
            >>> client: CybsiClient
            >>> filter_sections = [
            >>>     EntityAggregateSections.AssociatedAttributes,
            >>>     EntityAggregateSections.NaturalAttributes,
            >>> ]
            >>> aggregate = client.observable.entities.view(
            >>>    UUID("3a53cc35-f632-434c-bd4b-1ed8c014003a"),
            >>>    sections=filter_sections,
            >>>    with_valuable_facts = True
            >>> )
            >>> # Do something with an aggregate
            >>> if aggregate.sections.associated_attributes is not None:
            >>>     for attr in aggregate.sections.associated_attributes.data:
            >>>         print(attr.attribute_name)
        """

        params: Dict[str, Any] = {}
        if sections is not None:
            params["section"] = [section.value for section in sections]
        if forecast_at is not None:
            params["forecastAt"] = rfc3339_timestamp(forecast_at)
        if with_valuable_facts is not None:
            params["valuableFacts"] = with_valuable_facts

        path = f"{self._path}/{entity_uuid}"
        r = self._connector.do_get(path=path, params=params)
        return EntityAggregateView(r.json())

    def aggregate(
        self,
        *,
        entity_uuids: Optional[Iterable[uuid.UUID]] = None,
        dict_item_uuid: Optional[uuid.UUID] = None,
        entity_type: Optional[EntityTypes] = None,
        key_type: Optional[EntityKeyTypes] = None,
        key: Optional[str] = None,
        sections: Optional[Iterable[EntityAggregateSections]] = None,
        forecast_at: Optional[datetime] = None,
        cursor: Optional[Cursor] = None,
        limit: Optional[int] = None,
    ) -> Page[EntityAggregateView]:
        """Get list of aggregated entities.

        .. versionchanged:: 2.8
            Added new parameters: `ent_type`, `key_type`, `key`.
            Parameter `entity_uuids` changed to Optional.

        .. versionchanged:: 2.11
            Added new parameter `dict_item_uuid`.

        Note:
            Calls `GET /observable/entities`.

            To get entity aggregate, only one of [key, entity_uuids] parameters
            should be specified else Cybsi API will return error.
        Args:
            entity_uuids: Entity uuids.
                Excludes parameters: `dict_item_uuid`, `entity_type`, `key_type`, `key`.
            dict_item_uuid: Dictionary item which is attributed to the entity.
                Excludes parameters: `entity_uuids`, `entity_type`, `key_type`, `key`.
            entity_type: Entity type.
                Excludes parameter `entity_uuids`, `dict_item_uuid` and
                requires parameter `key`. The parameter is not required if
                the `entity_type` can be uniquely determined by `key_type`.
            key_type: Entity natural key type.
                Excludes parameter `entity_uuids`, `dict_item_uuid` and
                requires parameter `key`. The parameter is not required if
                only one `key_type` is used for the specified `entity_type`.
            key: Entity natural key value. Required if `entity_type` or `key_type`
                parameter is specified.
                It is possible to pass a key value in a non-canonical representation.
            sections: Sections to be aggregated.
            forecast_at: Point of time to aggregate sections at.
            cursor: Page cursor.
            limit: Page limit.
        Returns:
            Page with aggregated entities views and next page cursor.
        Note:
            Semantic error codes specific for this method:
              * :attr:`~cybsi.api.error.SemanticErrorCodes.EntityNotFound`
              * :attr:`~cybsi.api.error.SemanticErrorCodes.DictionaryItemNotFound`
        Usage:
            >>> from uuid import UUID
            >>> from cybsi.api import CybsiClient
            >>> from cybsi.api.pagination import chain_pages
            >>> from cybsi.api.observable import (
            >>>     EntityAggregateSections,
            >>>     EntityTypes,
            >>>     EntityKeyTypes
            >>> )
            >>> client: CybsiClient
            >>> entities = [
            >>>     UUID("3a53cc35-f632-434c-bd4b-1ed8c014003a"),
            >>>     UUID("85fca85e-0036-488d-9dcf-35970d182afc"),
            >>> ]
            >>> filter_sections = [EntityAggregateSections.AssociatedAttributes]
            >>> # You can aggregate entities by entity_uuids. Be aware that
            >>> # optional parameters such as entity_type, key_type or key are excluded.
            >>> # Cybsi API will return an error if you specify
            >>> # one of these with entity_uuids.
            >>> aggregates = client.observable.entities.aggregate(
            >>>     entity_uuids=entities,
            >>>     sections=filter_sections
            >>> )
            >>> for item in chain_pages(aggregates):
            >>>     # Do something with an aggregate
            >>>     pass
            >>>
            >>> # Also you can aggregate entities by natural key, but for this
            >>> # you need to specified one of parameters entity_type or key_type.
            >>> # Keep in mind that Cybsi API will return error if you provide
            >>> # entity_uuids with natural key.
            >>> aggregates = client.observable.entities.aggregate(
            >>>     entity_type=EntityTypes.URL,
            >>>     key_type=EntityKeyTypes.String,
            >>>     key="http://young.biz/wp-content/index/",
            >>> )
        """

        params: Dict[str, Any] = {}
        if entity_uuids is not None:
            params["uuid"] = [str(u) for u in entity_uuids]
        if dict_item_uuid is not None:
            params["dictItemUUID"] = str(dict_item_uuid)
        if entity_type is not None:
            params["type"] = entity_type.value
        if key_type is not None:
            params["keyType"] = key_type.value
        if key is not None:
            params["key"] = key
        if sections is not None:
            params["section"] = [section.value for section in sections]
        if forecast_at is not None:
            params["forecastAt"] = rfc3339_timestamp(forecast_at)
        if cursor:
            params["cursor"] = str(cursor)
        if limit:
            params["limit"] = limit

        r = self._connector.do_get(path=self._path, params=params)
        page = Page(self._connector.do_get, r, EntityAggregateView)
        return page

    def canonize_key(
        self, entity_type: EntityTypes, key_type: EntityKeyTypes, value: str
    ) -> EntityKeyView:
        """Get a canonized entity key.

        Note:
            Calls `GET /observable/entity-canonical-key`.
        Args:
            entity_type: Entity type.
            key_type: Key type.
            value: Key value.
        Returns:
            Canonized key view.
        Note:
            Semantic error codes specific for this method:
              * :attr:`~cybsi.api.error.SemanticErrorCodes.InvalidKeySet`
              * :attr:`~cybsi.api.error.SemanticErrorCodes.InvalidKey`
        Usage:
            >>> from cybsi.api import CybsiClient
            >>> from cybsi.api.observable import EntityTypes, EntityKeyTypes
            >>> client: CybsiClient
            >>> canonized_key = client.observable.entities.canonize_key(
            >>>     EntityTypes.DomainName, EntityKeyTypes.String, "xn--80ATjc.XN--P1AI"
            >>> )
            >>> # Do something with the canonized key
            >>> print(canonized_key)
            >>> # {
            >>> #   "type": "String",
            >>> #   "value": "\u043e\u043a\u043d\u0430.\u0440\u0444"
            >>> # }
        """
        params = {
            "entityType": entity_type.value,
            "keyType": key_type.value,
            "key": value,
        }
        r = self._connector.do_get(path=self._path_canonical_key, params=params)
        return EntityKeyView(r.json())

    def add_natural_keys(
        self, entity_uuid: uuid.UUID, keys: Iterable[Tuple[EntityKeyTypes, str]]
    ) -> None:
        """Add entity nalural keys.

        Note:
            Calls `PUT /observable/entities/{entity_uuid}/keys`.
        Args:
            entity_uuid: Entity UUID.
            keys: Entity natural keys.
        Raises:
            :class:`~cybsi.api.error.SemanticError`: Form contains logic errors.
        Note:
            Semantic error codes specific for this method:
              * :attr:`~cybsi.api.error.SemanticErrorCodes.InvalidKeySet`
              * :attr:`~cybsi.api.error.SemanticErrorCodes.InvalidKey`
              * :attr:`~cybsi.api.error.SemanticErrorCodes.KeyConflict`
              * :attr:`~cybsi.api.error.SemanticErrorCodes.BrokenKeySet`
        Usage:
            >>> import uuid
            >>> from cybsi.api import CybsiClient
            >>> from cybsi.api.observable import EntityKeyTypes
            >>> client: CybsiClient
            >>> entity_keys = [
            >>>     (EntityKeyTypes.MD5, "3c4729715ef0d6bafd3d9c719e152099"),
            >>>     (EntityKeyTypes.SHA1, "0462cb99ddd46c142aa46244cb8c2d35bfe226f5")
            >>> ],
            >>> client.observable.entities.add_natural_keys(
            >>>     entity_uuid=uuid.UUID("d90f5ea5-0f33-44a4-b063-6bf90e654a81"),
            >>>     form=entity_keys,
            >>> )
        """
        form = [{"type": t.value, "value": v} for t, v in keys]
        path = f"{self._path}/{entity_uuid}/keys"
        self._connector.do_put(path=path, json=form)

    def forecast_attribute_values(
        self,
        entity_uuid: uuid.UUID,
        attr_name: AttributeNames,
        forecast_at: Optional[datetime] = None,
    ) -> EntityAttributeForecastView:
        """Get a forecast of entity attribute value.

        See Also:
            See :ref:`attributes`
            for complete information about available attributes.

        Note:
            Calls `GET /observable/entities/{entity_uuid}/attributes/{attr_name}`.
        Args:
            entity_uuid: Entity UUID.
            attr_name: Attribute name. Converts to kebab-case on URL-path.
            forecast_at: Point of time to forecast at.
                If not specified, forecast is built on current time.
        Returns:
            Attribute forecast view.
        Raises:
            :class:`~cybsi.api.error.SemanticError`: Query contains logic errors.
            :class:`~cybsi.api.error.InvalidRequestError`:
                Attribute with specified name does not exist (NoSuchAttribute).
        Note:
            Semantic error codes specific for this method:
             * :attr:`~cybsi.api.error.SemanticErrorCodes.WrongEntityAttribute`
        Usage:
            >>> from uuid import UUID
            >>> from cybsi.api import CybsiClient
            >>> from cybsi.api.observable import EntityAttributeForecastView
            >>> from cybsi.api.observable import AttributeNames
            >>> client: CybsiClient
            >>> attr_forecast = client.observable.entities.forecast_attribute_values(
            >>>     entity_uuid=UUID("3a53cc35-f632-434c-bd4b-1ed8c014003a"),
            >>>     attr_name=AttributeNames.IsIoC,
            >>> )
            >>> # Do something with the forecast
            >>> print(attr_forecast)
        """

        params: Dict[str, Any] = {}
        if forecast_at is not None:
            params["forecastAt"] = rfc3339_timestamp(forecast_at)

        kebab_attr_name = _convert_attribute_name_kebab(attr_name)
        path = f"{self._path}/{entity_uuid}/attributes/{kebab_attr_name}"
        r = self._connector.do_get(path=path, params=params)
        return EntityAttributeForecastView(r.json(), attr_name)

    def forecast_links(
        self,
        entity_uuid: uuid.UUID,
        related_entity_types: Optional[Iterable[EntityTypes]] = None,
        direction: Optional[Iterable[LinkDirection]] = None,
        kind: Optional[Iterable[RelationshipKinds]] = None,
        confidence_threshold: Optional[float] = None,
        forecast_at: Optional[datetime] = None,
        cursor: Optional[Cursor] = None,
        limit: Optional[int] = None,
    ) -> Page[EntityLinksForecastView]:
        """Get a list of link forecasts of entity.

        See Also:
            See :ref:`relationships`
            for complete information about available relationships.

        Note:
            Calls `GET /observable/entities/{entity_uuid}/links`.
        Args:
            entity_uuid: Entity UUID.
            related_entity_types: Related entity types.
            direction: Link direction. Return any if not specified.
            kind: Kind of relationship. Return any if not specified.
            confidence_threshold: Discard links with confidence
                less than threshold. Valid values are in (0, 1].
            forecast_at: Date of forecast.
                If not specified, forecast is built on current time.
            cursor: Page cursor.
            limit: Page limit.
        Returns:
            Page with links forecast view and next page cursor.
        Usage:
            >>> from uuid import UUID
            >>> from cybsi.api import CybsiClient
            >>> from cybsi.api.pagination import chain_pages
            >>> from cybsi.api.observable import (
            >>>     EntityTypes,
            >>>     LinkDirection,
            >>>     RelationshipKinds,
            >>> )
            >>> from cybsi.api.observable import EntityLinksForecastView
            >>> client: CybsiClient
            >>> links_forecast = client.observable.entities.forecast_links(
            >>>     entity_uuid=UUID("3a53cc35-f632-434c-bd4b-1ed8c014003a"),
            >>>     related_entity_types=[EntityTypes.IPAddress, EntityTypes.File],
            >>>     direction=[LinkDirection.Forward],
            >>>     kind=[RelationshipKinds.ResolvesTo, RelationshipKinds.Uses],
            >>>     confidence_threshold=0.5
            >>> )
            >>> # Do something with the forecast
            >>> print(links_forecast)
        """

        params: Dict[str, Any] = {}
        if related_entity_types is not None:
            params["relatedEntityType"] = [typ.value for typ in related_entity_types]
        if direction is not None:
            params["direction"] = [d.value for d in direction]
        if kind is not None:
            params["kind"] = [k.value for k in kind]
        if confidence_threshold is not None:
            params["confidenceThreshold"] = confidence_threshold
        if forecast_at is not None:
            params["forecastAt"] = rfc3339_timestamp(forecast_at)
        if cursor:
            params["cursor"] = str(cursor)
        if limit:
            params["limit"] = str(limit)

        path = f"{self._path}/{entity_uuid}/links"
        r = self._connector.do_get(path=path, params=params)
        page = Page(self._connector.do_get, r, EntityLinksForecastView)
        return page

    def forecast_links_statistic(
        self,
        entity_uuid: uuid.UUID,
        forecast_at: Optional[datetime] = None,
    ) -> List[EntityLinkStatisticView]:
        """Get statictics of forecasted links for entity
        considering all facts about entity.

        Note:
            Calls `GET /observable/entities/{entity_uuid}/link-type-statistic`.
        Args:
            entity_uuid: Entity UUID.
            forecast_at: Date of forecast.
                If not specified, forecast is built on current time.
        Returns:
            List of link forecasts. Links are grouped by direction, relation kind,
             and entity type. Groups are ordered by direction,
             relation kind, and entity type.
        Usage:
            >>> from uuid import UUID
            >>> from cybsi.api import CybsiClient
            >>> from cybsi.api.observable import EntityLinkStatisticView
            >>> client: CybsiClient
            >>> link_forecast = client.observable.entities.forecast_links_statistic(
            >>>     entity_uuid=UUID("3a53cc35-f632-434c-bd4b-1ed8c014003a")
            >>> )
            >>> # Do something with the forecast
            >>> print(link_forecast)
        """

        params: Dict[str, Any] = {}
        if forecast_at is not None:
            params["forecastAt"] = rfc3339_timestamp(forecast_at)

        path = f"{self._path}/{entity_uuid}/link-type-statistic"
        r = self._connector.do_get(path=path, params=params)
        return [EntityLinkStatisticView(v) for v in r.json()]

    def add_labels(self, entity_uuid: uuid.UUID, labels: Iterable[str]) -> None:
        """Add entity labels.

        Note:
            Calls `PUT /observable/entities/{entityUUID}/labels`.
        Args:
            entity_uuid: Entity UUID.
            labels: List of labels. Label length must be in range [2, 50].
                Labels are converted to lowercase before saving.
        """

        path = f"{self._path}/{entity_uuid}/labels"
        self._connector.do_put(path=path, json=list(labels))

    def delete_labels(self, entity_uuid: uuid.UUID, labels: Iterable[str]) -> None:
        """Delete entity labels.

        Note:
            Calls `DELETE /observable/entities/{entityUUID}/labels`.
        Args:
            entity_uuid: Entity UUID.
            labels: List of labels.
                Labels are case-insensitive when compared.
        """

        params: Dict[str, Any] = {"label": list(labels)}
        path = f"{self._path}/{entity_uuid}/labels"
        self._connector.do_delete(path=path, params=params)


class EntitiesAsyncAPI(BaseAsyncAPI):
    """Asynchronous entities API."""

    _path = "/observable/entities"
    _path_canonical_key = "/observable/entity-canonical-key"

    async def register(self, entity: EntityForm) -> RefView:
        """Register an entity.

        Note:
            Calls `PUT /observable/entities`.
        Args:
            entity: Entity registration form.
        Returns:
            Reference to a registered entity.
        Raises:
            :class:`~cybsi.api.error.SemanticError`: Form contains logic errors.
        Note:
            Semantic error codes specific for this method:
              * :attr:`~cybsi.api.error.SemanticErrorCodes.InvalidKeySet`
              * :attr:`~cybsi.api.error.SemanticErrorCodes.InvalidKey`
              * :attr:`~cybsi.api.error.SemanticErrorCodes.KeyConflict`
              * :attr:`~cybsi.api.error.SemanticErrorCodes.BrokenKeySet`
        Usage:
            >>> import asyncio
            >>> from cybsi.api import CybsiAsyncClient
            >>> from cybsi.api.observable import EntityForm, EntityTypes, EntityKeyTypes
            >>> async def register_entities():
            >>>     entities = [
            >>>         EntityForm(
            >>>             EntityTypes.File,
            >>>             [(EntityKeyTypes.MD5, "3c4729715ef0d6bafd3d9c719e152099")]
            >>>         ),
            >>>         EntityForm(
            >>>             EntityTypes.IPAddress,
            >>>             [(EntityKeyTypes.String, "192.168.0.1")]
            >>>         ),
            >>>         EntityForm(
            >>>             EntityTypes.DomainName,
            >>>             [(EntityKeyTypes.String, "google.com")]
            >>>         ),
            >>>     ]
            >>>     # concurrently register a batch of entities
            >>>     async with CybsiAsyncClient as client:
            >>>         registrations = [
            >>>             client.observable.entities.register(e) for e in entities
            >>>         ]
            >>>         results = await asyncio.gather(*registrations)
            >>>         uuids = ", ".join(str(u.uuid) for u in results)
            >>>         print(f"Registered entities: {uuids}")
            >>> asyncio.run(register_entities())
        """
        r = await self._connector.do_put(path=self._path, json=entity.json())
        return RefView(r.json())

    async def view(
        self,
        entity_uuid: uuid.UUID,
        *,
        sections: Optional[Iterable[EntityAggregateSections]] = None,
        forecast_at: Optional[datetime] = None,
        with_valuable_facts: Optional[bool] = None,
    ) -> EntityAggregateView:
        """Get an entity view.

        Note:
            Calls `GET /observable/entities/{entity_uuid}`.
        Args:
            entity_uuid: Entity uuid.
            sections: Sections to be aggregated.
            forecast_at: Point of time to aggregate sections at.
            with_valuable_facts: Include valuable facts in response.
        Returns:
            Aggregated view of the entity.
        Raises:
            :class:`~cybsi.api.error.NotFoundError`: Entity not found.
        """

        params: Dict[str, Any] = {}
        if sections is not None:
            params["section"] = [section.value for section in sections]
        if forecast_at is not None:
            params["forecastAt"] = rfc3339_timestamp(forecast_at)
        if with_valuable_facts is not None:
            params["valuableFacts"] = with_valuable_facts

        path = f"{self._path}/{entity_uuid}"
        r = await self._connector.do_get(path=path, params=params)
        return EntityAggregateView(r.json())

    async def aggregate(
        self,
        *,
        entity_uuids: Optional[Iterable[uuid.UUID]] = None,
        dict_item_uuid: Optional[uuid.UUID] = None,
        entity_type: Optional[EntityTypes] = None,
        key_type: Optional[EntityKeyTypes] = None,
        key: Optional[str] = None,
        sections: Optional[Iterable[EntityAggregateSections]] = None,
        forecast_at: Optional[datetime] = None,
        cursor: Optional[Cursor] = None,
        limit: Optional[int] = None,
    ) -> AsyncPage[EntityAggregateView]:
        """Get list of aggregated entities.

        .. versionchanged:: 2.11
            Added new parameter `dict_item_uuid`.

        Note:
            Calls `GET /observable/entities`.

            To get entity aggregate, only one of [key, entity_uuids] parameters
            should be specified else Cybsi API will return error.
        Args:
            entity_uuids: Entity uuids.
                Excludes parameters: `dict_item_uuid`, `entity_type`, `key_type`, `key`.
            dict_item_uuid: Dictionary item which is attributed to the entity.
                Excludes parameters: `entity_uuids`, `entity_type`, `key_type`, `key`.
            entity_type: Entity type.
                Excludes parameter `entity_uuids`, `dict_item_uuid` and
                requires parameter `key`. The parameter is not required if
                the `entity_type` can be uniquely determined by `key_type`.
            key_type: Entity natural key type.
                Excludes parameter `entity_uuids`, `dict_item_uuid` and
                requires parameter `key`. The parameter is not required if
                only one `key_type` is used for the specified `entity_type`.
            key: Entity natural key value. Required if `entity_type` or `key_type`
                parameter is specified.
                It is possible to pass a key value in a non-canonical representation.
            sections: Sections to be aggregated.
            forecast_at: Point of time to aggregate sections at.
            cursor: Page cursor.
            limit: Page limit.
        Returns:
            Page with aggregated entities views and next page cursor.
        Note:
            Semantic error codes specific for this method:
              * :attr:`~cybsi.api.error.SemanticErrorCodes.EntityNotFound`
              * :attr:`~cybsi.api.error.SemanticErrorCodes.DictionaryItemNotFound`
        """

        params: Dict[str, Any] = {}
        if entity_uuids is not None:
            params["uuid"] = [str(u) for u in entity_uuids]
        if dict_item_uuid is not None:
            params["dictItemUUID"] = str(dict_item_uuid)
        if entity_type is not None:
            params["type"] = entity_type.value
        if key_type is not None:
            params["keyType"] = key_type.value
        if key is not None:
            params["key"] = key
        if sections is not None:
            params["section"] = [section.value for section in sections]
        if forecast_at is not None:
            params["forecastAt"] = rfc3339_timestamp(forecast_at)
        if cursor:
            params["cursor"] = str(cursor)
        if limit:
            params["limit"] = limit

        r = await self._connector.do_get(path=self._path, params=params)
        page = AsyncPage(self._connector.do_get, r, EntityAggregateView)
        return page

    async def canonize_key(
        self, entity_type: EntityTypes, key_type: EntityKeyTypes, value: str
    ) -> EntityKeyView:
        """Get a canonized entity key.

        Note:
            Calls `GET /observable/entity-canonical-key`.
        Args:
            entity_type: Entity type.
            key_type: Key type.
            value: Key value.
        Returns:
            Canonized key view.
        Note:
            Semantic error codes specific for this method:
              * :attr:`~cybsi.api.error.SemanticErrorCodes.InvalidKeySet`
              * :attr:`~cybsi.api.error.SemanticErrorCodes.InvalidKey`
        """
        params = {
            "entityType": entity_type.value,
            "keyType": key_type.value,
            "key": value,
        }
        r = await self._connector.do_get(path=self._path_canonical_key, params=params)
        return EntityKeyView(r.json())

    async def add_natural_keys(
        self, entity_uuid: uuid.UUID, keys: Iterable[Tuple[EntityKeyTypes, str]]
    ) -> None:
        """Add entity nalural keys.

        Note:
            Calls `PUT /observable/entities/{entity_uuid}/keys`.
        Args:
            entity_uuid: Entity UUID.
            keys: Entity natural keys.
        Raises:
            :class:`~cybsi.api.error.SemanticError`: Form contains logic errors.
        Note:
            Semantic error codes specific for this method:
              * :attr:`~cybsi.api.error.SemanticErrorCodes.InvalidKeySet`
              * :attr:`~cybsi.api.error.SemanticErrorCodes.InvalidKey`
              * :attr:`~cybsi.api.error.SemanticErrorCodes.KeyConflict`
              * :attr:`~cybsi.api.error.SemanticErrorCodes.BrokenKeySet`
        """
        form = [{"type": t.value, "value": v} for t, v in keys]
        path = f"{self._path}/{entity_uuid}/keys"
        await self._connector.do_put(path=path, json=form)

    async def forecast_attribute_values(
        self,
        entity_uuid: uuid.UUID,
        attr_name: AttributeNames,
        forecast_at: Optional[datetime] = None,
    ) -> EntityAttributeForecastView:
        """Get a forecast of entity attribute value.

        See Also:
            See :ref:`attributes`
            for complete information about available attributes.

        Note:
            Calls `GET /observable/entities/{entity_uuid}/attributes/{attr_name}`.
        Args:
            entity_uuid: Entity UUID.
            attr_name: Attribute name. Converts to kebab-case on URL-path.
            forecast_at: Point of time to forecast at.
                If not specified, forecast is built on current time.
        Returns:
            Attribute forecast view.
        Raises:
            :class:`~cybsi.api.error.SemanticError`: Query contains logic errors.
            :class:`~cybsi.api.error.InvalidRequestError`:
                Attribute with specified name does not exist (NoSuchAttribute).
        Note:
            Semantic error codes specific for this method:
             * :attr:`~cybsi.api.error.SemanticErrorCodes.WrongEntityAttribute`
        """

        params: Dict[str, Any] = {}
        if forecast_at is not None:
            params["forecastAt"] = rfc3339_timestamp(forecast_at)

        kebab_attr_name = _convert_attribute_name_kebab(attr_name)
        path = f"{self._path}/{entity_uuid}/attributes/{kebab_attr_name}"
        r = await self._connector.do_get(path=path, params=params)
        return EntityAttributeForecastView(r.json(), attr_name)

    async def forecast_links(
        self,
        entity_uuid: uuid.UUID,
        related_entity_types: Optional[Iterable[EntityTypes]] = None,
        direction: Optional[Iterable[LinkDirection]] = None,
        kind: Optional[Iterable[RelationshipKinds]] = None,
        confidence_threshold: Optional[float] = None,
        forecast_at: Optional[datetime] = None,
        cursor: Optional[Cursor] = None,
        limit: Optional[int] = None,
    ) -> AsyncPage[EntityLinksForecastView]:
        """Get a list of link forecasts of entity.

        See Also:
            See :ref:`relationships`
            for complete information about available relationships.

        Note:
            Calls `GET /observable/entities/{entity_uuid}/links`.
        Args:
            entity_uuid: Entity UUID.
            related_entity_types: Related entity types.
            direction: Link direction. Return any if not specified.
            kind: Kind of relationship. Return any if not specified.
            confidence_threshold: Discard links with confidence
                less than threshold. Valid values are in (0, 1].
            forecast_at: Date of forecast.
                If not specified, forecast is built on current time.
            cursor: Page cursor.
            limit: Page limit.
        Returns:
            Page with links forecast view and next page cursor.
        """

        params: Dict[str, Any] = {}
        if related_entity_types is not None:
            params["relatedEntityType"] = [typ.value for typ in related_entity_types]
        if direction is not None:
            params["direction"] = [d.value for d in direction]
        if kind is not None:
            params["kind"] = [k.value for k in kind]
        if confidence_threshold is not None:
            params["confidenceThreshold"] = confidence_threshold
        if forecast_at is not None:
            params["forecastAt"] = rfc3339_timestamp(forecast_at)
        if cursor:
            params["cursor"] = str(cursor)
        if limit:
            params["limit"] = str(limit)

        path = f"{self._path}/{entity_uuid}/links"
        r = await self._connector.do_get(path=path, params=params)
        page = AsyncPage(self._connector.do_get, r, EntityLinksForecastView)
        return page

    async def forecast_links_statistic(
        self,
        entity_uuid: uuid.UUID,
        forecast_at: Optional[datetime] = None,
    ) -> List[EntityLinkStatisticView]:
        """Get statictics of forecasted links for entity
        considering all facts about entity.

        Note:
            Calls `GET /observable/entities/{entity_uuid}/link-type-statistic`.
        Args:
            entity_uuid: Entity UUID.
            forecast_at: Date of forecast.
                If not specified, forecast is built on current time.
        Returns:
            List of link forecasts. Links are grouped by direction, relation kind,
             and entity type. Groups are ordered by direction,
             relation kind, and entity type.
        """

        params: Dict[str, Any] = {}
        if forecast_at is not None:
            params["forecastAt"] = rfc3339_timestamp(forecast_at)

        path = f"{self._path}/{entity_uuid}/link-type-statistic"
        r = await self._connector.do_get(path=path, params=params)
        return [EntityLinkStatisticView(v) for v in r.json()]

    async def add_labels(self, entity_uuid: uuid.UUID, labels: Iterable[str]) -> None:
        """Add entity labels.

        Note:
            Calls `PUT /observable/entities/{entityUUID}/labels`.
        Args:
            entity_uuid: Entity UUID.
            labels: List of labels. Label length must be in range [2, 50].
                Labels are converted to lowercase before saving.
        """

        path = f"{self._path}/{entity_uuid}/labels"
        await self._connector.do_put(path=path, json=list(labels))

    async def delete_labels(
        self, entity_uuid: uuid.UUID, labels: Iterable[str]
    ) -> None:
        """Delete entity labels.

        Note:
            Calls `DELETE /observable/entities/{entityUUID}/labels`.
        Args:
            entity_uuid: Entity UUID.
            labels: List of labels.
                Labels are case-insensitive when compared.
        """

        params: Dict[str, Any] = {"label": list(labels)}
        path = f"{self._path}/{entity_uuid}/labels"
        await self._connector.do_delete(path=path, params=params)

cybsi/api/observable/entities_api.py
cyberstudio/cybsi-sdk
from typing import Iterable, List, Optional, Tuple
from uuid import UUID

from ..internal import JsonObject, JsonObjectForm, JsonObjectView
from .aggregate_section import (
    AttributeValuableFactView,
    AttributeValueView,
    SectionsView,
    _convert_attribute_value_type,
)
from .enums import AttributeNames, EntityKeyTypes, EntityTypes
from .view import AbstractEntityView


class EntityForm(JsonObjectForm):
    """Entity form. Use to register or update an entity.

    Args:
        ent_type: Entity type.
        ent_keys: Entity natural keys.
    """

    def __init__(
        self, ent_type: EntityTypes, ent_keys: Iterable[Tuple[EntityKeyTypes, str]] = []
    ):
        super().__init__()
        self._data["type"] = ent_type.value
        keys = self._data.setdefault("keys", [])
        keys.extend({"type": t.value, "value": v} for t, v in ent_keys)

    def add_key(self, key_type: EntityKeyTypes, value: str) -> "EntityForm":
        """Add natural key to the list of entity keys.

        Args:
            key_type: Key type. Valid values depend on entity type.
            value: Key value.
        Return:
            Updated entity form.
        """
        keys = self._data.setdefault("keys", [])
        keys.append({"type": key_type.value, "value": value})
        return self


class EntityKeyView(JsonObjectView):
    """Entity key view."""

    @property
    def type(self) -> EntityKeyTypes:
        """Entity key type."""
        return EntityKeyTypes(self._get("type"))

    @property
    def value(self) -> str:
        """Entity key value.
        Type depends on key type.
        """
        return self._get("value")


class EntityView(AbstractEntityView):
    """Default and complete entity view.
    Includes entity types and natural keys.

    .. versionadded:: 2.9
    """

    @classmethod
    def _view_uuid(cls) -> UUID:
        # The default entity view has no view uuid
        return None  # type: ignore

    @property
    def uuid(self) -> UUID:
        """Entity UUID."""
        return UUID(self._get("uuid"))

    @property
    def url(self) -> Optional[str]:
        """URL of the entity in API.
        Property is presented if :class:`~cybsi.api.client.Config`
        embed_object_url is True.
        """
        return self._get_optional("url")

    @property
    def type(self) -> EntityTypes:
        """Entity type."""
        return EntityTypes(self._get("type"))

    @property
    def keys(self) -> List[EntityKeyView]:
        """Entity natural keys."""
        return [EntityKeyView(x) for x in self._get("keys")]


class EntityAggregateView(EntityView):
    """Entity aggregated view."""

    @property
    def sections(self) -> SectionsView:
        """Entity aggregated sections."""
        return SectionsView(self._get("sections"))


class EntityAttributeForecastView(JsonObjectView):
    """Entity attribute forecast view."""

    def __init__(self, data: JsonObject, attribute_name: AttributeNames):
        super().__init__(data)
        self._attribute_name = attribute_name

    @property
    def has_conflicts(self) -> bool:
        """Entity has conflicting facts about attribute."""
        return self._get("hasConflicts")

    @property
    def values(self) -> List["AttributeForecastView"]:
        """Attribute values forecast in descending order of confidence."""
        return [
            AttributeForecastView(x, self._attribute_name) for x in self._get("values")
        ]


class AttributeForecastView(JsonObjectView):
    """Single attribute value forecast."""

    def __init__(self, data: JsonObject, attribute_name: AttributeNames):
        super().__init__(data)
        self._attribute_name = attribute_name

    @property
    def value(self) -> AttributeValueView:
        """Return value type depends on attribute.

        Note:
            Return :class:`~cybsi.api.RefView` type
            is used to get the value of a dictionary item attribute.
            You can resolve the ref
            using :meth:`~cybsi.api.dictionary.DictionariesAPI.view_item`.
        """
        return _convert_attribute_value_type(self._attribute_name, self._get("value"))

    @property
    def confidence(self) -> float:
        """Confidence of forecast."""
        return self._get("confidence")

    @property
    def valuable_facts(self) -> Optional[List[AttributeValuableFactView]]:
        """List of forecast valuable facts in descending order of confidence."""
        values = self._get_optional("valuableFacts")
        if values is not None:
            return [
                AttributeValuableFactView(value, self._attribute_name)
                for value in values
            ]
        return None

cybsi/api/observable/entity.py
cyberstudio/cybsi-sdk
from enum_tools.documentation import document_enum

from cybsi.api.enum import CybsiAPIEnum


@document_enum
class ShareLevels(CybsiAPIEnum):
    """Information share level, according to Traffic Light Protocol."""

    White = "White"
    """Disclosure is not limited."""
    Green = "Green"
    """Limited disclosure, restricted to the community."""
    Amber = "Amber"
    """Limited disclosure, restricted to participants’ organizations."""
    Red = "Red"
    """Not for disclosure, restricted to participants only."""


@document_enum
class EntityTypes(CybsiAPIEnum):
    """Entity types."""

    IPAddress = "IPAddress"  # doc: IPv4 or IPv6 address.
    DomainName = "DomainName"  # doc: Domain name.
    File = "File"  # doc: File.
    EmailAddress = "EmailAddress"  # doc: Email address.
    PhoneNumber = "PhoneNumber"  # doc: Phone number.
    Identity = "Identity"  # doc: Identity.
    URL = "URL"  # doc: URL.


@document_enum
class EntityKeyTypes(CybsiAPIEnum):
    """Natural entity key types."""

    String = "String"  # doc: String identifying entity.
    MD5 = "MD5Hash"  # doc: File MD5 hash.
    SHA1 = "SHA1Hash"  # doc: File SHA1 hash.
    SHA256 = "SHA256Hash"  # doc: File SHA256 hash.
    IANAID = "IANAID"  # doc: Identity identifier in IANA registry.
    NICHandle = "NICHandle"  # doc: Identity identifier in NIC database.


@document_enum
class AttributeNames(CybsiAPIEnum):
    """Entity attribute names.

    See Also:
        See :ref:`attributes`
        for complete information about available attributes.
    """

    Class = "Class"
    """
        Identity class. Attribute value type is enum,
        see :class:`IdentityClass`.
        Attribute belongs to `Identity` entity type.
    """
    RegistrationCountry = "RegistrationCountry"
    """
       .. versionadded:: 2.11

       Registration country.
       Attribute value type is
       :class:`~cybsi.api.dictionary.api.DictItemAttributeValue`.
       Attribute belongs to `IPAddress` entity type.
    """
    DisplayNames = "DisplayNames"
    """
       Email address display names. Attribute value type is :class:`str`.
       Attribute belongs to `EmailAddress` entity type.
    """
    IsIoC = "IsIoC"
    """
      The entity is indicator of compromise. Attribute value type is :class:`bool`.
      Attribute belongs to `DomainName`, `IPAddress`, `URL`,
      `EmailAddress`, `PhoneNumber`, `File` entity type.
    """
    IsTrusted = "IsTrusted"
    """
      The entity is trusted. Attribute value type is :class:`bool`.
      Attribute belongs to `DomainName`, `IPAddress`, `URL`,
      `EmailAddress`, `File` entity type.
    """
    Names = "Names"
    """
      Names of the entity. Attribute value type is :class:`str`.
      Attribute belongs to `Identity`, `File` entity type.
    """
    NodeRoles = "NodeRoles"
    """
      The role of the node in a network.  Attribute value type is enum,
      see :class:`NodeRole`.
      Attribute belongs to `DomainName`, `IPAddress` entity type.
    """
    Sectors = "Sectors"
    """
      .. versionchanged:: 2.10
        Change attribute value type from enums to dictionary item.

      Identity industry sector. Attribute value type is
      :class:`~cybsi.api.dictionary.api.DictItemAttributeValue`.
      Attribute belongs to `Identity` entity type.
    """
    Size = "Size"
    """
      Entity size. Attribute value type is :class:`int`.
      Attribute belongs to `File` entity type.
    """
    IsDGA = "IsDGA"
    """
      The domain was generated by algorithm. Attribute value type is :class:`bool`.
      Attribute belongs to `DomainName` entity type.
    """
    MalwareClasses = "MalwareClasses"
    """
      .. versionadded:: 2.9

      The file belongs to malware class.
      Attribute value type is
      :class:`~cybsi.api.dictionary.api.DictItemAttributeValue`.
      Attribute belongs to `File` entity type.
    """
    MalwareFamilies = "MalwareFamilies"
    """
      .. versionadded:: 2.9

      The file belongs to a malware family.
      Attribute value type is
      :class:`~cybsi.api.dictionary.api.DictItemAttributeValue`.
      Attribute belongs to `File` entity type.
    """
    RelatedMalwareFamilies = "RelatedMalwareFamilies"
    """
       .. versionadded:: 2.9

       The entity belongs to related malware family.
       Attribute value type is
       :class:`~cybsi.api.dictionary.api.DictItemAttributeValue`.
       Attribute belongs to `DomainName`, `IPAddress`, `URL`,
       `EmailAddress` entity type.
    """
    IsDelegated = "IsDelegated"
    """
      .. versionadded:: 2.9

      Domain name is delegated if DNS servers are specified.
      Attribute value type is :class:`bool`.
      Attribute belongs to `DomainName` entity type.
    """
    Statuses = "Statuses"
    """
      .. versionadded:: 2.9

      Domain name or IP address status obtained from Whois.
      Attribute value type is
      :class:`~cybsi.api.dictionary.api.DictItemAttributeValue`.
      Attribute belongs to `DomainName`, `IPAddress` entity type.
    """
    ASN = "ASN"
    """
      .. versionadded:: 2.9

      Autonomous system number.
      Attribute value type is :class:`int`.
      Attribute belongs to `IPAddress` entity type.
    """
    RegionalInternetRegistry = "RegionalInternetRegistry"
    """
      .. versionadded:: 2.9

      IP address belongs to one of the regional internet registrars.
      Attribute value type is
      :class:`~cybsi.api.observable.enums.RegionalInternetRegistry`.
      Attribute belongs to `IPAddress` entity type.
    """
    ThreatCategory = "ThreatCategory"
    """
      .. versionadded:: 2.9

      The entity threat category.
      Attribute value type is  :class:`~cybsi.api.observable.enums.ThreatCategory`.
      Attribute belongs to `File` entity type.
    """
    RelatedThreatCategory = "RelatedThreatCategory"
    """
      .. versionadded:: 2.9

      The threat category with which the entity has a relationship.
      Attribute value type is
      :class:`~cybsi.api.observable.enums.RelatedThreatCategory`.
      Attribute belongs to  `DomainName`, `IPAddress`, `URL`,
      `EmailAddress` entity types.
    """
    MalwareNames = "MalwareNames"
    """
      .. versionadded:: 2.9

      The entity malware name. Attribute value type is :class:`str`.
      Attribute belongs to `File` entity type.
    """
    Campaigns = "Campaigns"
    """
      .. versionadded:: 2.10

      The entity is used in a malicious campaign.
      Attribute value type is
      :class:`~cybsi.api.dictionary.api.DictItemAttributeValue`.
      Attribute belongs to `File`, `DomainName`, `IPAddress`, `EmailAddress`,
      `URL` entity type.
    """
    ThreatActors = "ThreatActors"
    """
      .. versionadded:: 2.10

      The entity is used by threat actor.
      Attribute value type is
      :class:`~cybsi.api.dictionary.api.DictItemAttributeValue`.
      Attribute belongs to `File`, `DomainName`, `IPAddress`, `EmailAddress`,
      `URL` entity type.
    """
    AffectedCountries = "AffectedCountries"
    """
      .. versionadded:: 2.10

      The entity can be used most often in countries.
      Attribute value type is
      :class:`~cybsi.api.dictionary.api.DictItemAttributeValue`.
      Attribute belongs to `File`, `DomainName`, `IPAddress`, `EmailAddress`,
      `URL` entity type.
    """
    ExploitedVulnerabilities = "ExploitedVulnerabilities"
    """
      .. versionadded:: 2.10

      The entity exploits vulnerabilities.
      Attribute value type is
      :class:`~cybsi.api.dictionary.api.DictItemAttributeValue`.
      Attribute belongs to `File`, `DomainName`, `IPAddress`, `EmailAddress`,
      `URL` entity type.
    """
    TargetedSectors = "TargetedSectors"
    """
      .. versionadded:: 2.10

      The entity targets sectors of activity.
      Attribute value type is
      :class:`~cybsi.api.dictionary.api.DictItemAttributeValue`.
      Attribute belongs to `File`, `DomainName`, `IPAddress`, `EmailAddress`,
      `URL` entity type.
    """
    PotentialDamage = "PotentialDamage"
    """
      .. versionadded:: 2.11

      The amount of potential damage from the entity.
      Attribute value type is  :class:`~cybsi.api.observable.enums.PotentialDamage`.
      Attribute belongs to `File`, `DomainName`, `IPAddress`, `URL` entity type.
    """


@document_enum
class NodeRole(CybsiAPIEnum):
    """Node roles."""

    CnC = "CnC"  # doc: CnC node.
    TorNode = "TorNode"  # doc: Tor node of any type.
    TorExitNode = "TorExitNode"  # doc: Tor exit node.
    Proxy = "Proxy"  # doc: Proxy server.
    NameServer = "NameServer"  # doc: Name server.
    MailExchanger = "MailExchanger"  # doc: Mail server.
    Phishing = "Phishing"  # doc: Phishing server.
    DynDNS = "DynDNS"  # doc: Belongs to the DynDNS infrastructure
    Cloud = "Cloud"  # doc: Belongs to a cloud infrastructure
    VPN = "VPN"  # doc: VPN server
    STUN = "STUN"  # doc: STUN server
    Sinkhole = "Sinkhole"  # doc: Sinkhole nodes
    PayloadDelivery = "PayloadDelivery"  # doc: Serves malicious payloads
    ExfiltrationStore = "ExfiltrationStore"  # doc: Used for data exfiltration
    CDN = "CDN"  # doc: Belongs to a CDN infrastructure
    BitTorrentTracker = "BitTorrentTracker"  # doc: BitTorrent tracker


@document_enum
class IdentityClass(CybsiAPIEnum):
    """Identity classes."""

    Individual = "Individual"
    """A single person."""
    Group = "Group"
    """An informal collection of people, without formal governance."""
    Organization = "Organization"
    """A formal organization of people, with governance."""
    Class = "Class"
    """A class of entities, such as all hospitals, all Europeans etc."""


@document_enum
class RelationshipKinds(CybsiAPIEnum):
    """Kind of a relationship between entities.

    See Also:
        See :ref:`relationships`
        for complete information about available relationships.
    """

    Has = "Has"
    Contains = "Contains"
    BelongsTo = "BelongsTo"  # doc: Deprecated.
    ConnectsTo = "ConnectsTo"
    Drops = "Drops"
    Uses = "Uses"
    Owns = "Owns"
    Supports = "Supports"
    ResolvesTo = "ResolvesTo"
    VariantOf = "VariantOf"  # doc: Deprecated.
    Hosts = "Hosts"
    Serves = "Serves"
    Locates = "Locates"


@document_enum
class EntityAggregateSections(CybsiAPIEnum):
    """Entity aggregation section."""

    AssociatedAttributes = "AssociatedAttributes"
    NaturalAttributes = "NaturalAttributes"
    Threat = "Threat"
    GeoIP = "GeoIP"
    Labels = "Labels"


@document_enum
class ThreatStatus(CybsiAPIEnum):
    """Threat status."""

    Unknown = "Unknown"
    Malicious = "Malicious"
    NonMalicious = "NonMalicious"


@document_enum
class LinkDirection(CybsiAPIEnum):
    """Direction of links."""

    Forward = "Forward"
    Reverse = "Reverse"


@document_enum
class RegionalInternetRegistry(CybsiAPIEnum):
    """Regional internet registrars."""

    RIPE = "RIPE"
    APNIC = "APNIC"
    ARIN = "ARIN"
    AFRINIC = "AFRINIC"
    LACNIC = "LACNIC"


@document_enum
class ThreatCategory(CybsiAPIEnum):
    """Threat categories."""

    Clean = "Clean"
    Riskware = "Riskware"
    Adware = "Adware"
    Malware = "Malware"


@document_enum
class RelatedThreatCategory(CybsiAPIEnum):
    """Related threat categories."""

    Riskware = "Riskware"
    Adware = "Adware"
    Malware = "Malware"


@document_enum
class PotentialDamage(CybsiAPIEnum):
    """Potential damage."""

    Low = "Low"
    Medium = "Medium"
    High = "High"

cybsi/api/observable/enums.py
cyberstudio/cybsi-sdk
from typing import List

from ..internal import JsonObjectView
from .entity import EntityView
from .enums import EntityTypes, LinkDirection, RelationshipKinds


class EntityLinksForecastView(JsonObjectView):
    """Entity links forecast view."""

    @property
    def link(self) -> "LinkForecastView":
        """Link forecast view."""
        return LinkForecastView(self._get("link"))

    @property
    def confidence(self) -> float:
        """Confidence of forecast."""
        return self._get("confidence")


class LinkForecastView(JsonObjectView):
    """Link forecast view."""

    @property
    def url(self) -> str:
        """URL of relationship forecast detailed information."""
        return self._get("url")

    @property
    def direction(self) -> LinkDirection:
        """Link direction."""
        return LinkDirection(self._get("direction"))

    @property
    def relation_kind(self) -> RelationshipKinds:
        """Relationship kind."""
        return RelationshipKinds(self._get("relationKind"))

    @property
    def related_entity(self) -> EntityView:
        """Related entity view."""
        return EntityView(self._get("relatedEntity"))


class EntityLinkStatisticView(JsonObjectView):
    """Link statistics view."""

    @property
    def link_type(self) -> "LinkTypeView":
        """Link type view."""
        return LinkTypeView(self._get("linkType"))

    @property
    def link_statistic(self) -> "LinkStatisticView":
        """Link statistic."""
        return LinkStatisticView(self._get("links"))


class LinkTypeView(JsonObjectView):
    """Link view."""

    @property
    def url(self) -> str:
        """URL of link detailed information."""
        return self._get("url")

    @property
    def direction(self) -> LinkDirection:
        """Link direction."""
        return LinkDirection(self._get("linkDirection"))

    @property
    def relation_kind(self) -> RelationshipKinds:
        """Relationship kind."""
        return RelationshipKinds(self._get("relationKind"))

    @property
    def related_entities_type(self) -> EntityTypes:
        """Related entities types."""
        return EntityTypes(self._get("relatedEntitiesType"))


class LinkStatisticView(JsonObjectView):
    """Link statistic view."""

    @property
    def total(self) -> int:
        """Total number of forecasted links of this type."""
        return self._get("total")

    @property
    def confidence_distribution(self) -> List["LinkConfidenceDistributionView"]:
        """Distribution of number of links by confidence."""
        return [
            LinkConfidenceDistributionView(x)
            for x in self._get("distributionByConfidence")
        ]


class LinkConfidenceDistributionView(JsonObjectView):
    """Distribution of number of links by
    confidence with step of 0.1 in descending order."""

    @property
    def confidence_range(self) -> List[float]:
        """List of confidence in range format (from; to]."""
        return self._get("confidenceRange")

    @property
    def count(self) -> int:
        """Count of links whose forecast falls within specified range."""
        return self._get("count")

cybsi/api/observable/links.py
cyberstudio/cybsi-sdk
import uuid
from datetime import datetime
from typing import Dict, List, Optional, cast

from ..internal import BaseAPI, JsonObjectView, rfc3339_timestamp
from .aggregate_section import ValuableFactView
from .entity import EntityView
from .enums import RelationshipKinds


def _convert_relationship_kind_kebab(kind: RelationshipKinds) -> str:
    """Convert relationship kind value to kebab-case.

    Args:
        kind: relationship kind, such of 'ResolvesTo'.
    Return:
        Relationship kind on kebab-case, such of `resolves-to`.
    """
    return _rel_kind_kebab_converters[kind]


_rel_kind_kebab_converters: Dict[RelationshipKinds, str] = {
    RelationshipKinds.Has: "has",
    RelationshipKinds.Contains: "contains",
    RelationshipKinds.BelongsTo: "belongs-to",
    RelationshipKinds.ConnectsTo: "connects-to",
    RelationshipKinds.Drops: "drops",
    RelationshipKinds.Uses: "uses",
    RelationshipKinds.Owns: "owns",
    RelationshipKinds.Supports: "supports",
    RelationshipKinds.ResolvesTo: "resolves-to",
    RelationshipKinds.VariantOf: "variant-of",
    RelationshipKinds.Hosts: "hosts",
    RelationshipKinds.Serves: "serves",
    RelationshipKinds.Locates: "locates",
}


class RelationshipsAPI(BaseAPI):
    """Relationships API."""

    _path = "/observable/relationships"

    def forecast(
        self,
        *,
        source_entity_uuid: uuid.UUID,
        target_entity_uuid: uuid.UUID,
        kind: RelationshipKinds,
        forecast_at: Optional[datetime] = None,
        valuable_facts: Optional[bool] = None,
    ) -> "RelationshipsForecastView":
        """Get forecast of relationship between two entities.

        See Also:
           See :ref:`relationships`
           for complete information about available relationships.

        Note:
            Calls `GET /observable/relationships/{sourceEntityUUID}/{relationKind}/{targetEntityUUID}`. # noqa: E501
        Args:
            source_entity_uuid: Source entity UUID.
            kind: Kind of relationship. Converts to kebab-case on URL-path.
            target_entity_uuid: Target entity UUID.
            forecast_at: Date of forecast.
                If not specified, forecast is built on current time.
            valuable_facts: Attach list of valuable facts to forecast.
        Returns:
            Relationship forecast view.
        Usage:
            >>> from uuid import UUID
            >>> from cybsi.api import CybsiClient
            >>> from cybsi.api.observable import RelationshipKinds
            >>> client: CybsiClient
            >>> forecasts = client.observable.relationships.forecast(
            >>>     source_entity_uuid = UUID("3a53cc35-f632-434c-bd4b-1ed8c014003a"),
            >>>     target_entity_uuid = UUID("3a53cc35-f632-434c-bd4b-1ed8c014003a"),
            >>>     kind = RelationshipKinds.ResolvesTo,
            >>> )
            >>> # Do something with the forecast
            >>> print(forecasts)
        """
        kebab_kind = _convert_relationship_kind_kebab(kind)
        path = f"{self._path}/{source_entity_uuid}/{kebab_kind}/{target_entity_uuid}"
        params = {}
        if forecast_at is not None:
            params["forecastAt"] = rfc3339_timestamp(forecast_at)
        if valuable_facts is not None:
            params["valuableFacts"] = valuable_facts  # type: ignore
        r = self._connector.do_get(path=path, params=params)
        return RelationshipsForecastView(r.json())


class RelationshipsForecastView(JsonObjectView):
    """Relationship forecast view."""

    @property
    def relationship(self) -> "RelationshipView":
        """Relationship view."""
        return RelationshipView(self._get("relationship"))

    @property
    def confidence(self) -> float:
        """Relationship forecast confidence.
        0 if there's no valuable facts about the relationship."""
        return self._get("confidence")

    @property
    def valuable_facts(self) -> Optional[List[ValuableFactView]]:
        """List of forecast valuable facts in descending order of confidence."""
        return self._map_list_optional("valuableFacts", ValuableFactView)


class RelationshipView(JsonObjectView):
    """Relationship fact view."""

    @property
    def source(self) -> EntityView:
        """Relationship's source entity.

        Warning:
            Ref uuid may be zero uuid,
            if source entity keys were invalid during registration.
        """

        return EntityView(self._get("source"))

    @property
    def kind(self) -> RelationshipKinds:
        """Kind of the relationship."""

        return RelationshipKinds(self._get("kind"))

    @property
    def target(self) -> EntityView:
        """Target entity.

        Warning:
            Ref uuid may be zero uuid,
            if source entity keys were invalid during registration.
        """

        return EntityView(self._get("target"))

    @property
    def confidence(self) -> float:
        """Relationship fact confidence."""

        return float(cast(float, self._get("confidence")))

cybsi/api/observable/relationships.py
cyberstudio/cybsi-sdk
import abc
import uuid
from typing import Any, Dict, Optional, TypeVar

from ..internal import BaseAPI, JsonObjectView
from ..pagination import Cursor, Page


class EntityViewsAPI(BaseAPI):
    """Entity views API.

    Entity views allow to redefine API response format.
    In other words, some API methods returning basic entity view
    (:class:`~cybsi.api.observable.EntityView`) can return a completely
    different structure.

    The basic entity view contains entity keys and type,
    but custom views can contain much more information about entity.
    The example of a method utilizing custom views is
    :meth:`~cybsi.api.replist.ReplistsAPI.entities`.

    Extend :class:`AbstractEntityView` to implement a custom entity view.

    .. versionadded:: 2.9
    """

    _path = "/entity-views"

    def filter(
        self,
        *,
        cursor: Optional[Cursor] = None,
        limit: Optional[int] = None,
    ) -> Page["EntityViewView"]:
        """Filter views of observable entities.

        .. versionadded:: 2.9
        Note:
            Calls `GET /entity-views`.
        Args:
            cursor: Page cursor.
            limit: Page limit.
        Returns: List of views.
        """

        params: Dict[str, Any] = {}
        if cursor is not None:
            params["cursor"] = str(cursor)
        if limit is not None:
            params["limit"] = str(limit)

        resp = self._connector.do_get(path=self._path, params=params)
        page = Page(self._connector.do_get, resp, EntityViewView)
        return page


class EntityViewView(JsonObjectView):
    """
    View of the entity view.

    .. versionadded:: 2.9
    """

    @property
    def uuid(self) -> uuid.UUID:
        """Entity view UUID."""
        return uuid.UUID(self._get("uuid"))

    @property
    def name(self) -> str:
        """Entity view name."""
        return str(self._get("name"))


class AbstractEntityView(JsonObjectView, metaclass=abc.ABCMeta):
    """
    This is the class you need to extend to implement a custom entity view.

    .. automethod:: _view_uuid
    .. versionadded:: 2.9
    """

    @classmethod
    @abc.abstractmethod
    def _view_uuid(cls) -> uuid.UUID:
        """
        UUID of the view in API. Usually well-known.
        """
        pass


EntityViewT = TypeVar("EntityViewT", bound=AbstractEntityView)
"""
Any class implementing entity view.
"""

cybsi/api/observable/view.py
cyberstudio/cybsi-sdk
from .api import ObservationsAPI, ObservationsAsyncAPI
from .enums import ObservationTypes
from .view import ObservationHeaderView, ObservationCommonView
from .generic import (
    GenericObservationsAPI,
    GenericObservationsAsyncAPI,
    GenericObservationForm,
    GenericObservationView,
    GenericObservationContentView,
    AttributeValueFactView,
)
from .archive import (
    ArchiveObservationsAPI,
    ArchiveObservationView,
    ArchiveObservationContentView,
)
from .dns_lookup import (
    DNSLookupObservationsAPI,
    DNSLookupObservationView,
    DNSLookupObservationContentView,
)
from .network_session import (
    NetworkSessionObservationsAPI,
    NetworkSessionObservationView,
    NetworkSessionObservationContentView,
)
from .threat import (
    ThreatObservationsAPI,
    ThreatObservationView,
    ThreatObservationContentView,
)
from .whois_lookup import (
    WhoisLookupObservationsAPI,
    WhoisLookupObservationView,
    WhoisLookupObservationContentView,
)

cybsi/api/observation/__init__.py
cyberstudio/cybsi-sdk
"""Use this section of API to register/retrieve
observation of types known to Cybsi.

Each type of observation is handled by their own subsection of API.
"""
from datetime import datetime
from typing import Any, Dict, Iterable, Optional
from uuid import UUID

from ..internal import BaseAPI, BaseAsyncAPI, rfc3339_timestamp
from ..observable import ShareLevels
from ..pagination import Cursor, Page
from .archive import ArchiveObservationsAPI
from .dns_lookup import DNSLookupObservationsAPI
from .enums import ObservationTypes
from .generic import GenericObservationsAPI, GenericObservationsAsyncAPI
from .network_session import NetworkSessionObservationsAPI
from .threat import ThreatObservationsAPI
from .view import ObservationHeaderView
from .whois_lookup import WhoisLookupObservationsAPI

_PATH = "/enrichment/observations"


class ObservationsAPI(BaseAPI):
    """Observations API."""

    @property
    def archives(self) -> ArchiveObservationsAPI:
        """Get archive observations route."""
        return ArchiveObservationsAPI(self._connector)

    @property
    def dns_lookups(self) -> DNSLookupObservationsAPI:
        """Get DNS Lookup observations route."""
        return DNSLookupObservationsAPI(self._connector)

    @property
    def generics(self) -> GenericObservationsAPI:
        """Get generic observations route."""
        return GenericObservationsAPI(self._connector)

    @property
    def network_sessions(self) -> NetworkSessionObservationsAPI:
        """Get network session observations route."""
        return NetworkSessionObservationsAPI(self._connector)

    @property
    def threats(self) -> ThreatObservationsAPI:
        """Get threat observations route."""
        return ThreatObservationsAPI(self._connector)

    @property
    def whois_lookups(self) -> WhoisLookupObservationsAPI:
        """Get Whois lookup observations route."""
        return WhoisLookupObservationsAPI(self._connector)

    def search(
        self,
        *,
        types: Optional[Iterable[ObservationTypes]] = None,
        data_source_uuids: Optional[Iterable[UUID]] = None,
        reporter_uuids: Optional[Iterable[UUID]] = None,
        max_share_level: Optional[ShareLevels] = None,
        seen_before: Optional[datetime] = None,
        seen_after: Optional[datetime] = None,
        registered_before: Optional[datetime] = None,
        registered_after: Optional[datetime] = None,
        report_uuid: Optional[UUID] = None,
        entity_uuid: Optional[UUID] = None,
        cursor: Optional[Cursor] = None,
        limit: Optional[int] = None,
    ) -> Page["ObservationHeaderView"]:
        """Get observation search results page.

        The result elements are sorted in descending order of observation time.

        Note:
            Calls `GET /enrichment/observations`
        Args:
            types: List of observation types.
                Search for observations of only the specified types.
            entity_uuid: Entity identifier.
                Search for observations mentioning the entity.
            report_uuid: Report identifier.
                Search for observations of the report.
            data_source_uuids: List of data source identifiers.
                Search for observations by original data source identifiers.
            reporter_uuids: List of reporter identifiers.
                Search for observations by reporter data source identifiers.
            max_share_level: Max share level.
                Search for observations up to the max access level.
            seen_before: Search for observations seen before the time.
            seen_after: Search for observations seen after the time.
            registered_before: Search for observations registered before the time.
            registered_after: Search for observations registered after the time.
            cursor: Page cursor.
            limit: Page limit.
        Returns:
            Search results page and next page cursor.
        Raises:
            :class:`~cybsi.api.error.SemanticError`: query arguments contain errors.
        Note:
            Semantic error codes specific for this method:
              * :attr:`~cybsi.api.error.SemanticErrorCodes.InvalidShareLevel`
              * :attr:`~cybsi.api.error.SemanticErrorCodes.EntityNotFound`
              * :attr:`~cybsi.api.error.SemanticErrorCodes.DataSourceNotFound`
        """
        params: Dict[str, Any] = {}

        if types is not None:
            params["type"] = [str(t.value) for t in types]
        if reporter_uuids is not None:
            params["reporterUUID"] = [str(u) for u in reporter_uuids]
        if data_source_uuids is not None:
            params["dataSourceUUID"] = [str(u) for u in data_source_uuids]
        if entity_uuid is not None:
            params["entityUUID"] = str(entity_uuid)
        if report_uuid is not None:
            params["reportUUID"] = str(report_uuid)
        if max_share_level is not None:
            params["shareLevel"] = str(max_share_level.value)
        if seen_before is not None:
            params["seenBefore"] = rfc3339_timestamp(seen_before)
        if seen_after is not None:
            params["seenAfter"] = rfc3339_timestamp(seen_after)
        if registered_before is not None:
            params["registeredBefore"] = rfc3339_timestamp(registered_before)
        if registered_after is not None:
            params["registeredAfter"] = rfc3339_timestamp(registered_after)
        if cursor:
            params["cursor"] = str(cursor)
        if limit:
            params["limit"] = str(limit)

        resp = self._connector.do_get(path=_PATH, params=params)
        page = Page(self._connector.do_get, resp, ObservationHeaderView)
        return page


class ObservationsAsyncAPI(BaseAsyncAPI):
    """Observations asynchronous API."""

    @property
    def generics(self) -> GenericObservationsAsyncAPI:
        """Get generic observations route."""
        return GenericObservationsAsyncAPI(self._connector)

cybsi/api/observation/api.py
cyberstudio/cybsi-sdk
from typing import Any, Dict, Iterable, Optional
from uuid import UUID

from ..internal import BaseAPI, JsonObjectView
from ..pagination import Cursor, Page
from .view import ObservationHeaderView


class ArchiveObservationsAPI(BaseAPI):
    """Archive observation API."""

    _path = "/enrichment/observations/archives"

    def filter(
        self,
        *,
        entity_uuid: Optional[UUID] = None,
        artifact_uuid: Optional[UUID] = None,
        data_source_uuids: Optional[Iterable[UUID]] = None,
        reporter_uuids: Optional[Iterable[UUID]] = None,
        cursor: Optional[Cursor] = None,
        limit: Optional[int] = None,
    ) -> Page["ArchiveObservationView"]:
        """Get page of filtered observations.

        Page's items are sorted in descending order of observation time.

        Note:
            Calls `GET /enrichment/observations/archives`
        Args:
            artifact_uuid: Artifact identifier.
                Filter archive by specified artifact.
            entity_uuid: Entity identifier.
                Filter archive by specified file entity.
            data_source_uuids: List of data source identifiers.
                Filter observation by original data source identifiers.
            reporter_uuids: List of reporter identifiers.
                Filter observation by reporter data source identifiers.
            cursor: Page cursor.
            limit: Page limit.
        Returns:
            Page of observation list and next page cursor.
        Raises:
            :class:`~cybsi.api.error.SemanticError`: query arguments contain errors.
        Note:
            Semantic error codes specific for this method:
              * :attr:`~cybsi.api.error.SemanticErrorCodes.DataSourceNotFound`
              * :attr:`~cybsi.api.error.SemanticErrorCodes.EntityNotFound`
              * :attr:`~cybsi.api.error.SemanticErrorCodes.ArtifactNotFound`
        """
        params: Dict[str, Any] = {}

        if data_source_uuids is not None:
            params["dataSourceUUID"] = [str(u) for u in data_source_uuids]
        if reporter_uuids is not None:
            params["reporterUUID"] = [str(u) for u in reporter_uuids]
        if entity_uuid is not None:
            params["entityUUID"] = str(entity_uuid)
        if artifact_uuid is not None:
            params["artifactUUID"] = str(artifact_uuid)
        if cursor:
            params["cursor"] = str(cursor)
        if limit:
            params["limit"] = str(limit)

        resp = self._connector.do_get(self._path, params=params)
        page = Page(self._connector.do_get, resp, ArchiveObservationView)
        return page

    def view(self, observation_uuid: UUID) -> "ArchiveObservationView":
        """Get the Archive view.

        Note:
            Calls `GET /enrichment/observations/archives/{observation_uuid}`.
        Args:
            observation_uuid: Observation uuid.
        Returns:
            View of the observation.
        Raises:
            :class:`~cybsi.api.error.NotFoundError`: observation not found.
        """

        path = f"{self._path}/{observation_uuid}"
        r = self._connector.do_get(path)
        return ArchiveObservationView(r.json())


class ArchiveObservationView(ObservationHeaderView):
    """Archive observation view,
    as retrieved by :meth:`ArchiveObservationsAPI.view`."""

    @property
    def content(self) -> "ArchiveObservationContentView":
        """Content."""

        return ArchiveObservationContentView(self._get("content"))


class ArchiveObservationContentView(JsonObjectView):
    """Archive content.

    TODO:
        Implement properties.
    """

cybsi/api/observation/archive.py
cyberstudio/cybsi-sdk
from typing import Any, Dict, Iterable, Optional
from uuid import UUID

from ..internal import BaseAPI, JsonObjectView
from ..pagination import Cursor, Page
from .view import ObservationHeaderView


class DNSLookupObservationsAPI(BaseAPI):
    """DNS Lookup API."""

    _path = "/enrichment/observations/dns-lookups"

    def filter(
        self,
        *,
        entity_uuid: Optional[UUID] = None,
        data_source_uuids: Optional[Iterable[UUID]] = None,
        reporter_uuids: Optional[Iterable[UUID]] = None,
        cursor: Optional[Cursor] = None,
        limit: Optional[int] = None,
    ) -> Page["DNSLookupObservationView"]:
        """Get page of filtered DNS lookups.

        Page's items are sorted in descending order of lookup time.

        Note:
            Calls `GET /enrichment/observations/dns-lookups`
        Args:
            entity_uuid: Entity identifier.
                Filter lookups of specified DomainName/IPAddress entity.
            data_source_uuids: List of data source identifiers.
                Filter lookups by original data source identifiers.
            reporter_uuids: List of reporter identifiers.
                Filter lookups by reporter data source identifiers.
            cursor: Page cursor.
            limit: Page limit.
        Returns:
            Page of DNS lookups list and next page cursor.
        Raises:
            :class:`~cybsi.api.error.SemanticError`: query arguments contain errors.
        Note:
            Semantic error codes specific for this method:
              * :attr:`~cybsi.api.error.SemanticErrorCodes.DataSourceNotFound`
              * :attr:`~cybsi.api.error.SemanticErrorCodes.EntityNotFound`
        """
        params: Dict[str, Any] = {}

        if data_source_uuids is not None:
            params["dataSourceUUID"] = [str(u) for u in data_source_uuids]
        if reporter_uuids is not None:
            params["reporterUUID"] = [str(u) for u in reporter_uuids]
        if entity_uuid is not None:
            params["entityUUID"] = str(entity_uuid)
        if cursor:
            params["cursor"] = str(cursor)
        if limit:
            params["limit"] = str(limit)

        resp = self._connector.do_get(self._path, params=params)
        page = Page(self._connector.do_get, resp, DNSLookupObservationView)
        return page

    def view(self, observation_uuid: UUID) -> "DNSLookupObservationView":
        """Get the DNS lookup view.

        Note:
            Calls `GET /enrichment/observations/dns-lookups/{observation_uuid}`.
        Args:
            observation_uuid: Observation uuid.
        Returns:
            View of the observation.
        Raises:
            :class:`~cybsi.api.error.NotFoundError`: DNS Lookup not found.
        """

        path = f"{self._path}/{observation_uuid}"
        r = self._connector.do_get(path)
        return DNSLookupObservationView(r.json())


class DNSLookupObservationView(ObservationHeaderView):
    """DNS Lookup view,
    as retrieved by :meth:`DNSLookupObservationsAPI.view`."""

    @property
    def content(self) -> "DNSLookupObservationContentView":
        """Content."""

        return DNSLookupObservationContentView(self._get("content"))


class DNSLookupObservationContentView(JsonObjectView):
    """DNS lookup content.

    TODO:
        Implement properties.
    """

cybsi/api/observation/dns_lookup.py
cyberstudio/cybsi-sdk
from enum_tools import document_enum

from cybsi.api.enum import CybsiAPIEnum


@document_enum
class ObservationTypes(CybsiAPIEnum):
    """Observation types."""

    DNSLookup = "DNSLookup"  # doc: DNS lookup results.
    WhoisLookup = "WhoisLookup"  # doc: Whois lookup results.
    NetworkSession = "NetworkSession"  # doc: Network activity information.
    Threat = "Threat"  # doc: Threat information.
    Generic = "Generic"  # doc: Fact set of attributes and relationships.

cybsi/api/observation/enums.py
cyberstudio/cybsi-sdk
"""Generic observation is the main form of observation.
In essence, it's a container of arbitrary facts Cybsi domain model supports.

See Also:
    See :ref:`register-generic-observation-example`
    and :ref:`register-generic-observation-async-example`
    for a complete examples of generic observation API usage.
"""
import uuid
from datetime import datetime
from enum import Enum
from typing import Any, Dict, Iterable, List, Optional, Union, cast

from .. import RefView
from ..dictionary import DictItemAttributeValue
from ..internal import (
    BaseAPI,
    BaseAsyncAPI,
    JsonObjectForm,
    JsonObjectView,
    rfc3339_timestamp,
)
from ..observable import (
    AttributeNames,
    AttributeValueView,
    EntityForm,
    EntityView,
    RelationshipKinds,
    RelationshipView,
    ShareLevels,
)
from ..observable.aggregate_section import _convert_attribute_value_type
from ..pagination import AsyncPage, Cursor, Page
from .view import ObservationHeaderView

_PATH = "/enrichment/observations/generics"


class GenericObservationsAPI(BaseAPI):
    """Generic observation API."""

    def register(self, observation: "GenericObservationForm") -> RefView:
        """Register a generic observation.

        The observation is always registered unless it contains logic errors.
        Entity key value validation errors are ignored.

        Note:
            Calls `POST /enrichment/observations/generics`.
        Args:
            observation: Filled generic observation form.
        Returns:
            Reference to a newly registered observation.
        Raises:
            :class:`~cybsi.api.error.SemanticError`: Form contains logic errors.
        Note:
            Semantic error codes specific for this method:
              * :attr:`~cybsi.api.error.SemanticErrorCodes.DuplicatedEntityAttribute`
              * :attr:`~cybsi.api.error.SemanticErrorCodes.InvalidAttribute`
              * :attr:`~cybsi.api.error.SemanticErrorCodes.InvalidAttributeValue`
              * :attr:`~cybsi.api.error.SemanticErrorCodes.InvalidKeySet`
              * :attr:`~cybsi.api.error.SemanticErrorCodes.InvalidRelationship`
              * :attr:`~cybsi.api.error.SemanticErrorCodes.InvalidShareLevel`
              * :attr:`~cybsi.api.error.SemanticErrorCodes.InvalidTime`
        """
        r = self._connector.do_post(path=_PATH, json=observation.json())
        return RefView(r.json())

    def filter(
        self,
        *,
        data_source_uuids: Optional[Iterable[uuid.UUID]] = None,
        reporter_uuids: Optional[Iterable[uuid.UUID]] = None,
        cursor: Optional[Cursor] = None,
        limit: Optional[int] = None,
    ) -> Page["GenericObservationView"]:
        """Get page of filtered generic observation list.

        Page's items are sorted in descending order of observation time.

        Note:
            Calls `GET /enrichment/observations/generics`
        Args:
            cursor: Page cursor.
            limit: Page limit.
            data_source_uuids: List of data source identifiers.
                Filter observations by original data source identifiers.
            reporter_uuids: List of reporter identifiers.
                Filter observations by reporter data source identifiers.
        Returns:
            Page of generic observations list and next page cursor.
        Raises:
            :class:`~cybsi.api.error.SemanticError`: query arguments contain errors.
        Note:
            Semantic error codes specific for this method:
              * :attr:`~cybsi.api.error.SemanticErrorCodes.DataSourceNotFound`
        """
        params: Dict[str, Any] = {}

        if data_source_uuids is not None:
            params["dataSourceUUID"] = [str(u) for u in data_source_uuids]
        if reporter_uuids is not None:
            params["reporterUUID"] = [str(u) for u in reporter_uuids]
        if cursor:
            params["cursor"] = str(cursor)
        if limit:
            params["limit"] = str(limit)

        resp = self._connector.do_get(_PATH, params=params)
        page = Page(self._connector.do_get, resp, GenericObservationView)
        return page

    def view(self, observation_uuid: uuid.UUID) -> "GenericObservationView":
        """Get the generic observation view.

        Note:
            Calls `GET /enrichment/observations/generics/{observation_uuid}`.
        Args:
            observation_uuid: Observation uuid.
        Returns:
            View of the observation.
        Raises:
            :class:`~cybsi.api.error.NotFoundError`: Generic observation not found.
        """
        path = f"{_PATH}/{observation_uuid}"
        r = self._connector.do_get(path)
        return GenericObservationView(r.json())


class GenericObservationsAsyncAPI(BaseAsyncAPI):
    """Generic observation asynchronous API."""

    async def register(self, observation: "GenericObservationForm") -> RefView:
        """
        Register a generic observation.

        Async analog of :meth:`GenericObservationsAPI.register()`.
        """
        r = await self._connector.do_post(path=_PATH, json=observation.json())
        return RefView(r.json())

    async def filter(
        self,
        *,
        data_source_uuids: Optional[Iterable[uuid.UUID]] = None,
        reporter_uuids: Optional[Iterable[uuid.UUID]] = None,
        cursor: Optional[Cursor] = None,
        limit: Optional[int] = None,
    ) -> AsyncPage["GenericObservationView"]:
        """Get page of filtered generic observation list.

        Async analog of :meth:`GenericObservationsAPI.filter()`.
        """
        params: Dict[str, Any] = {}

        if data_source_uuids is not None:
            params["dataSourceUUID"] = [str(u) for u in data_source_uuids]
        if reporter_uuids is not None:
            params["reporterUUID"] = [str(u) for u in reporter_uuids]
        if cursor:
            params["cursor"] = str(cursor)
        if limit:
            params["limit"] = str(limit)

        resp = await self._connector.do_get(_PATH, params=params)
        page = AsyncPage(self._connector.do_get, resp, GenericObservationView)
        return page

    async def view(self, observation_uuid: uuid.UUID) -> "GenericObservationView":
        """Get the generic observation view.

        Async analog of :meth:`GenericObservationsAPI.view()`.
        """
        path = f"{_PATH}/{observation_uuid}"
        r = await self._connector.do_get(path)
        return GenericObservationView(r.json())


AttributeValueForm = Union[int, str, bool, uuid.UUID, DictItemAttributeValue, Enum]


class GenericObservationForm(JsonObjectForm):
    """Generic observation form.

    This is the form you need to fill to register observation.
    To fill the form, call :meth:`add_attribute_fact`
    and :meth:`add_entity_relationship`
    for each fact and relationship you observe.

    Args:
        share_level: Share level of the observation.
        seen_at: Date and time when facts were seen.
    Usage:
        >>> from datetime import datetime, timezone
        >>> from cybsi.api.dictionary import DictItemAttributeValue
        >>> from cybsi.api.observable import (
        >>>     AttributeNames, EntityForm,
        >>>     EntityKeyTypes, EntityTypes,
        >>>     ShareLevels, RelationshipKinds,
        >>> )
        >>> from cybsi.api.observation import GenericObservationForm
        >>> domain = EntityForm(EntityTypes.DomainName)
        >>> domain.add_key(EntityKeyTypes.String, "test.com")
        >>> ip_address = EntityForm(EntityTypes.IPAddress)
        >>> ip_address.add_key(EntityKeyTypes.String, "8.8.8.8")
        >>> observation = GenericObservationForm(
        >>>    share_level=ShareLevels.Green,
        >>>    seen_at=datetime.now(timezone.utc)
        >>> ).add_attribute_fact(
        >>>     entity=domain,
        >>>     attribute_name=AttributeNames.IsIoC,
        >>>     value=True,
        >>>     confidence=0.9
        >>> ).add_attribute_fact(
        >>>     entity=domain,
        >>>     attribute_name=AttributeNames.RelatedMalwareFamilies,
        >>>     value=DictItemAttributeValue(key="Aware"),
        >>>     confidence=0.9
        >>> ).add_entity_relationship(
        >>>     source=domain,
        >>>     kind=RelationshipKinds.ResolvesTo,
        >>>     target=ip_address,
        >>>     confidence=0.5,
        >>>)
    """

    def __init__(self, share_level: ShareLevels, seen_at: datetime):
        super().__init__()
        self._data["shareLevel"] = share_level.value
        self._data["seenAt"] = rfc3339_timestamp(seen_at)

    @property
    def _content(self):
        return self._data.setdefault("content", {})

    def set_data_source(self, source_uuid: uuid.UUID):
        """Set observation data source."""

        self._data["dataSourceUUID"] = str(source_uuid)
        return self

    def add_attribute_fact(
        self,
        entity: Union[uuid.UUID, EntityForm],
        attribute_name: AttributeNames,
        value: AttributeValueForm,
        *,
        confidence: Optional[float] = None,
    ) -> "GenericObservationForm":
        """Add attribute value fact to the observation.

        See Also:
            See :ref:`attributes` and
            :class:`~cybsi.api.observable.enums.AttributeNames`
            for complete information about available attributes.

        Args:
            entity: Filled form of entity or passed uuid of entity.
                Entity with passed UUID must be registered in system.
            attribute_name: Attribute name.
            value: Attribute value. Type depends on attribute name and entity type.
            confidence: Fact confidence. Confidence must be in range (0;1].
                Empty value means default value (1)
        Return:
            Updated observation form.
        """
        attribute_facts = self._content.setdefault("entityAttributeValues", [])

        if isinstance(entity, uuid.UUID):
            ent = {"uuid": str(entity)}
        else:
            ent = entity.json()

        if isinstance(value, uuid.UUID):
            value = str(value)

        if isinstance(value, Enum):
            value = str(value.value)

        if isinstance(value, DictItemAttributeValue):
            value = value.json()

        attribute_facts.append(
            {
                "entity": ent,
                "attributeName": attribute_name.value,
                "value": value,
                "confidence": confidence,
            }
        )
        return self

    def add_entity_relationship(
        self,
        *,
        source: Union[uuid.UUID, EntityForm],
        kind: RelationshipKinds,
        target: Union[uuid.UUID, EntityForm],
        confidence: Optional[float] = None,
    ) -> "GenericObservationForm":
        """Add entity relationship to observation.

        See Also:
            See :ref:`relationships`
            for complete information about available relationships.

        Args:
            source: Filled form of source entity in the relationship or
                passed uuid of source entity.
                Entity with passed UUID must be registered in system.
            kind: Kind of relationship.
            target: Filled form of target entity in the relationship or
                passed uuid of target entity.
                Entity with passed UUID must be registered in system.
            confidence: Relationship confidence. Confidence must be in range (0;1].
                Empty value means default value (1)
        Returns:
            Updated observation form.
        """
        entity_relationship = self._content.setdefault("entityRelationships", [])

        if isinstance(source, uuid.UUID):
            source_ent = {"uuid": str(source)}
        else:
            source_ent = source.json()

        if isinstance(target, uuid.UUID):
            target_ent = {"uuid": str(target)}
        else:
            target_ent = target.json()

        entity_relationship.append(
            {
                "source": source_ent,
                "kind": kind.value,
                "target": target_ent,
                "confidence": confidence,
            }
        )
        return self


class GenericObservationView(ObservationHeaderView):
    """Generic observation view,
    as retrieved by :meth:`GenericObservationsAPI.view`."""

    @property
    def content(self) -> "GenericObservationContentView":
        """Content."""

        return GenericObservationContentView(self._get("content"))


class GenericObservationContentView(JsonObjectView):
    """Generic observation content."""

    @property
    def entity_relationships(self) -> List["RelationshipView"]:
        """Entity relationships."""

        relationships = self._get("entityRelationships")
        return [RelationshipView(x) for x in relationships]

    @property
    def entity_attribute_values(self) -> List["AttributeValueFactView"]:
        """Entity attribute values."""

        attributes = self._get("entityAttributeValues")
        return [AttributeValueFactView(x) for x in attributes]


class AttributeValueFactView(JsonObjectView):
    """Attribute value fact view."""

    @property
    def entity(self) -> EntityView:
        """Entity of the fact.

        Warning:
            Ref uuid may be zero uuid,
            if entity keys were invalid during registration.
        """

        return EntityView(self._get("entity"))

    @property
    def attribute_name(self) -> AttributeNames:
        """Attribute name."""

        return AttributeNames(self._get("attributeName"))

    @property
    def value(self) -> AttributeValueView:
        """Value of the attribute.  Return type depends on attribute name and entity type.

        Usage:
            >>> from typing import cast
            >>> from cybsi.api.observation import GenericObservationContentView
            >>> from cybsi.api.dictionary import DictionaryItemCommonView
            >>>
            >>> view = GenericObservationContentView()
            >>> for v in view.entity_attribute_values:
            >>>     if v.attribute_name == AttributeNames.MalwareFamilies:
            >>>         value = cast(DictionaryItemCommonView, v.value)
            >>>         print(value)
        """
        return _convert_attribute_value_type(self.attribute_name, self._get("value"))

    @property
    def confidence(self) -> float:
        """Fact confidence."""

        return float(cast(float, self._get("confidence")))

cybsi/api/observation/generic.py
cyberstudio/cybsi-sdk
from typing import Any, Dict, Iterable, Optional
from uuid import UUID

from ..internal import BaseAPI, JsonObjectView
from ..pagination import Cursor, Page
from .view import ObservationHeaderView


class NetworkSessionObservationsAPI(BaseAPI):
    """Network Session API."""

    _path = "/enrichment/observations/network-sessions"

    def filter(
        self,
        *,
        entity_uuid: Optional[UUID] = None,
        data_source_uuids: Optional[Iterable[UUID]] = None,
        reporter_uuids: Optional[Iterable[UUID]] = None,
        cursor: Optional[Cursor] = None,
        limit: Optional[int] = None,
    ) -> Page["NetworkSessionObservationView"]:
        """Get page of filtered observations.

        Page's items are sorted in descending order of observation time.

        Note:
            Calls `GET /enrichment/observations/network-sessions`
        Args:
            entity_uuid: Entity identifier.
                Filter observations of specified file entity.
            data_source_uuids: List of data source identifiers.
                Filter observations by original data source identifiers.
            reporter_uuids: List of reporter identifiers.
                Filter observation by reporter data source identifiers.
            cursor: Page cursor.
            limit: Page limit.
        Returns:
            Page of observation list and next page cursor.
        Raises:
            :class:`~cybsi.api.error.SemanticError`: query arguments contain errors.
        Note:
            Semantic error codes specific for this method:
              * :attr:`~cybsi.api.error.SemanticErrorCodes.DataSourceNotFound`
              * :attr:`~cybsi.api.error.SemanticErrorCodes.EntityNotFound`
        """
        params: Dict[str, Any] = {}

        if data_source_uuids is not None:
            params["dataSourceUUID"] = [str(u) for u in data_source_uuids]
        if reporter_uuids is not None:
            params["reporterUUID"] = [str(u) for u in reporter_uuids]
        if entity_uuid is not None:
            params["entityUUID"] = str(entity_uuid)
        if cursor:
            params["cursor"] = str(cursor)
        if limit:
            params["limit"] = str(limit)

        resp = self._connector.do_get(self._path, params=params)
        page = Page(self._connector.do_get, resp, NetworkSessionObservationView)
        return page

    def view(self, observation_uuid: UUID) -> "NetworkSessionObservationView":
        """Get the NetworkSession view.

        Note:
            Calls `GET /enrichment/observations/network-sessions/{observation_uuid}`.
        Args:
            observation_uuid: Observation uuid.
        Returns:
            View of the observation.
        Raises:
            :class:`~cybsi.api.error.NotFoundError`: observation not found.
        """

        path = f"{self._path}/{observation_uuid}"
        r = self._connector.do_get(path)
        return NetworkSessionObservationView(r.json())


class NetworkSessionObservationView(ObservationHeaderView):
    """NetworkSession observation view,
    as retrieved by :meth:`NetworkSessionObservationsAPI.view`."""

    @property
    def content(self) -> "NetworkSessionObservationContentView":
        """Content."""

        return NetworkSessionObservationContentView(self._get("content"))


class NetworkSessionObservationContentView(JsonObjectView):
    """NetworkSession content.

    TODO:
        Implement properties.
    """

cybsi/api/observation/network_session.py
cyberstudio/cybsi-sdk
from typing import Any, Dict, Iterable, Optional
from uuid import UUID

from ..internal import BaseAPI, JsonObjectView
from ..pagination import Cursor, Page
from .view import ObservationHeaderView


class ThreatObservationsAPI(BaseAPI):
    """Threat observation API."""

    _path = "/enrichment/observations/threats"

    def filter(
        self,
        *,
        entity_uuid: Optional[UUID] = None,
        data_source_uuids: Optional[Iterable[UUID]] = None,
        reporter_uuids: Optional[Iterable[UUID]] = None,
        cursor: Optional[Cursor] = None,
        limit: Optional[int] = None,
    ) -> Page["ThreatObservationView"]:
        """Get page of filtered observations.

        Page's items are sorted in descending order of observation time.

        Note:
            Calls `GET /enrichment/observations/threats`
        Args:
            entity_uuid: Entity identifier.
                Filter threats of specified file entity.
            data_source_uuids: List of data source identifiers.
                Filter observation by original data source identifiers.
            reporter_uuids: List of reporter identifiers.
                Filter observation by reporter data source identifiers.
            cursor: Page cursor.
            limit: Page limit.
        Returns:
            Page of observation list and next page cursor.
        Raises:
            :class:`~cybsi.api.error.SemanticError`: query arguments contain errors.
        Note:
            Semantic error codes specific for this method:
              * :attr:`~cybsi.api.error.SemanticErrorCodes.DataSourceNotFound`
              * :attr:`~cybsi.api.error.SemanticErrorCodes.EntityNotFound`
        """
        params: Dict[str, Any] = {}

        if data_source_uuids is not None:
            params["dataSourceUUID"] = [str(u) for u in data_source_uuids]
        if reporter_uuids is not None:
            params["reporterUUID"] = [str(u) for u in reporter_uuids]
        if entity_uuid is not None:
            params["entityUUID"] = str(entity_uuid)
        if cursor:
            params["cursor"] = str(cursor)
        if limit:
            params["limit"] = str(limit)

        resp = self._connector.do_get(self._path, params=params)
        page = Page(self._connector.do_get, resp, ThreatObservationView)
        return page

    def view(self, observation_uuid: UUID) -> "ThreatObservationView":
        """Get the Threat observation view.

        Note:
            Calls `GET /enrichment/observations/threats/{observation_uuid}`.
        Args:
            observation_uuid: Observation uuid.
        Returns:
            View of the observation.
        Raises:
            :class:`~cybsi.api.error.NotFoundError`: observation not found.
        """

        path = f"{self._path}/{observation_uuid}"
        r = self._connector.do_get(path)
        return ThreatObservationView(r.json())


class ThreatObservationView(ObservationHeaderView):
    """Threat observation view,
    as retrieved by :meth:`ThreatObservationsAPI.view`."""

    @property
    def content(self) -> "ThreatObservationContentView":
        """Content."""

        return ThreatObservationContentView(self._get("content"))


class ThreatObservationContentView(JsonObjectView):
    """Threat observation content.

    TODO:
        Implement properties.
    """

cybsi/api/observation/threat.py
cyberstudio/cybsi-sdk
from datetime import datetime

from .. import RefView
from ..internal import parse_rfc3339_timestamp
from ..observable import ShareLevels
from .enums import ObservationTypes


class ObservationCommonView(RefView):
    """Observation short view."""

    @property
    def type(self) -> ObservationTypes:
        """Observation type."""
        return ObservationTypes(self._get("type"))


class ObservationHeaderView(ObservationCommonView):
    """Observation header view."""

    @property
    def reporter(self) -> RefView:
        """Source reporting the observation."""

        return RefView(self._get("reporter"))

    @property
    def data_source(self) -> RefView:
        """Observation data source."""

        return RefView(self._get("dataSource"))

    @property
    def share_level(self) -> ShareLevels:
        """Share level."""

        return ShareLevels(self._get("shareLevel"))

    @property
    def seen_at(self) -> datetime:
        """Date and time when observation was seen."""

        return parse_rfc3339_timestamp(self._get("seenAt"))

    @property
    def registered_at(self) -> datetime:
        """Date and time when observation was registered."""

        return parse_rfc3339_timestamp(self._get("registeredAt"))

cybsi/api/observation/view.py
cyberstudio/cybsi-sdk
from typing import Any, Dict, Iterable, Optional
from uuid import UUID

from ..internal import BaseAPI, JsonObjectView
from ..pagination import Cursor, Page
from .view import ObservationHeaderView


class WhoisLookupObservationsAPI(BaseAPI):
    """Whois Lookup API."""

    _path = "/enrichment/observations/whois-lookups"

    def filter(
        self,
        *,
        entity_uuid: Optional[UUID] = None,
        data_source_uuids: Optional[Iterable[UUID]] = None,
        reporter_uuids: Optional[Iterable[UUID]] = None,
        cursor: Optional[Cursor] = None,
        limit: Optional[int] = None,
    ) -> Page["WhoisLookupObservationView"]:
        """Get page of filtered whois lookups.

        Page's items are sorted in descending order of lookup time.

        Note:
            Calls `GET /enrichment/observations/whois-lookups`
        Args:
            entity_uuid: Entity identifier.
                Filter lookups of specified DomainName/IPAddress entity.
            data_source_uuids: List of data source identifiers.
                Filter lookups by original data source identifiers.
            reporter_uuids: List of reporter identifiers.
                Filter lookups by reporter data source identifiers.
            cursor: Page cursor.
            limit: Page limit.
        Returns:
            Page of Whois lookups list and next page cursor.
        Raises:
            :class:`~cybsi.api.error.SemanticError`: query arguments contain errors.
        Note:
            Semantic error codes specific for this method:
              * :attr:`~cybsi.api.error.SemanticErrorCodes.DataSourceNotFound`
              * :attr:`~cybsi.api.error.SemanticErrorCodes.EntityNotFound`
        """
        params: Dict[str, Any] = {}

        if data_source_uuids is not None:
            params["dataSourceUUID"] = [str(u) for u in data_source_uuids]
        if reporter_uuids is not None:
            params["reporterUUID"] = [str(u) for u in reporter_uuids]
        if entity_uuid is not None:
            params["entityUUID"] = str(entity_uuid)
        if cursor:
            params["cursor"] = str(cursor)
        if limit:
            params["limit"] = str(limit)

        resp = self._connector.do_get(self._path, params=params)
        page = Page(self._connector.do_get, resp, WhoisLookupObservationView)
        return page

    def view(self, observation_uuid: UUID) -> "WhoisLookupObservationView":
        """Get the Whois lookup view.

        Note:
            Calls `GET /enrichment/observations/whois-lookups/{observation_uuid}`.
        Args:
            observation_uuid: Observation uuid.
        Returns:
            View of the observation.
        Raises:
            :class:`~cybsi.api.error.NotFoundError`: Whois Lookup not found.
        """

        path = f"{self._path}/{observation_uuid}"
        r = self._connector.do_get(path)
        return WhoisLookupObservationView(r.json())


class WhoisLookupObservationView(ObservationHeaderView):
    """Whois Lookup view,
    as retrieved by :meth:`WhoisLookupObservationsAPI.view`."""

    @property
    def content(self) -> "WhoisLookupObservationContentView":
        """Content."""

        return WhoisLookupObservationContentView(self._get("content"))


class WhoisLookupObservationContentView(JsonObjectView):
    """Whois lookup content.

    TODO:
        Implement properties.
    """

cybsi/api/observation/whois_lookup.py
cyberstudio/cybsi-sdk
"""
Pagination API.

See Also:
    See :ref:`pagination-example`
    for complete examples of pagination usage.
"""
from typing import (
    AsyncIterator,
    Callable,
    Coroutine,
    Generic,
    Iterator,
    List,
    Optional,
    TypeVar,
    cast,
)

import httpx


class Cursor:

    """Page cursor value.

    If :data:`None` cursor value is **passed** to SDK
    (as a parameter to filter-like functions), SDK retrieves the first page.

    Typically, SDK does not **return** cursors as function return values.
    SDK returns :class:`Page` or :class:`AsyncPage`, and
    cursor is a property of the page.

    :data:`None` cursor value of a page means last page.
    """

    pass


# This is a hack to prevent Sphinx autodoc-typehint type inlining.
# If we simply alias Cursor = str, it inlines str everywhere,
# and functions lose descriptive parameter and return value types.
# Additionally, this hack prevents SDK users from creating Cursor instances.
# Users have to call filter()-like methods.
Cursor.__supertype__ = str  # type: ignore

DEFAULT_PAGE_LIMIT = 30
X_CURSOR_HEADER = "X-Cursor"

T = TypeVar("T")


class _BasePage(Generic[T]):
    def __init__(self, resp: httpx.Response, view: Callable[..., T]):
        self._resp = resp
        self._view = view

    @property
    def next_link(self) -> str:
        """Next page link."""
        return cast(str, self._resp.links.get("next", {}).get("url"))

    @property
    def cursor(self) -> Optional[Cursor]:
        """Page cursor. The current position in the collection.

        :data:`None` means the page is last one.
        """
        cursor = self._resp.headers.get(X_CURSOR_HEADER, None)
        cursor = None if cursor == "" else cursor
        return cast(Optional[Cursor], cursor)

    def data(self) -> List[T]:
        """Get page data as a list of items."""
        return list(iter(self))

    def __iter__(self) -> Iterator[T]:
        yield from (self._view(x) for x in self._resp.json())


class Page(_BasePage[T]):
    """Page returned by Cybsi API.
       Should not be constructed manually, use filter-like methods provided by SDK.

    Args:
        api_call: Callable object for getting next page
        resp: Response which represents a start page
        view: View class for page elements
    """

    def __init__(
        self,
        api_call: Callable[..., httpx.Response],
        resp: httpx.Response,
        view: Callable[..., T],
    ):
        super().__init__(resp, view)
        self._api_call = api_call

    def next_page(self) -> "Optional[Page[T]]":
        """Get next page.
        If there is no link to the next page it return None.
        """
        if self.next_link is None:
            return None

        return Page(self._api_call, self._api_call(self.next_link), self._view)


class AsyncPage(_BasePage[T]):
    """Page returned by Cybsi API.
       Should not be constructed manually, use filter-like methods provided by SDK.

    Args:
        api_call: Callable object for getting next page
        resp: Response which represents a start page
        view: View class for page elements
    """

    def __init__(
        self,
        api_call: Callable[..., Coroutine],
        resp: httpx.Response,
        view: Callable[..., T],
    ):
        super().__init__(resp, view)
        self._api_call = api_call

    async def next_page(self) -> "Optional[AsyncPage[T]]":
        """Get next page.
        If there is no link to the next page it return None.
        """
        if self.next_link is None:
            return None
        resp = await self._api_call(self.next_link)
        return AsyncPage(self._api_call, resp, self._view)


def chain_pages(start_page: Page[T]) -> Iterator[T]:
    """Get chain of collection objects."""

    page: Optional[Page[T]] = start_page
    while page:
        yield from page
        page = page.next_page()


async def chain_pages_async(start_page: AsyncPage[T]) -> AsyncIterator[T]:
    """Get chain of collection objects asynchronously."""
    page: Optional[AsyncPage[T]] = start_page
    while page:
        for elem in page:
            yield elem
        page = await page.next_page()

cybsi/api/pagination.py
cyberstudio/cybsi-sdk
"""Use this section of API to register and view reputation lists.

The API also allows retrieving reputation list content,
both as a snapshot and a stream of changes.
"""
from .api import (
    ReplistsAPI,
    ReplistsAsyncAPI,
    ReplistForm,
    ReplistCommonView,
    ReplistView,
    EntitySetChangeView,
    ReplistStatisticView,
    EntityTypeDistributionView,
)

from .enums import (
    EntitySetOperations,
    ReplistStatus,
)

cybsi/api/replist/__init__.py
cyberstudio/cybsi-sdk
import uuid
from datetime import datetime
from typing import Generic, List, Optional, Tuple, Type, cast

from .. import RefView
from ..api import Tag
from ..internal import (
    BaseAPI,
    BaseAsyncAPI,
    JsonObject,
    JsonObjectForm,
    JsonObjectView,
    parse_rfc3339_timestamp,
)
from ..observable import EntityTypes, EntityView, EntityViewT, ShareLevels
from ..pagination import AsyncPage, Cursor, Page
from ..search import StoredQueryCommonView
from ..view import _TaggedRefView
from .enums import EntitySetOperations, ReplistStatus

X_CHANGE_CURSOR = "X-Change-Cursor"

_REPLIST_BASE_PATH = "/replists"
_REPLIST_CHANGES_PATH_TPL = _REPLIST_BASE_PATH + "/{}/changes"
_REPLIST_ENTITIES_PATH_TPL = _REPLIST_BASE_PATH + "/{}/entities"


class ReplistsAPI(BaseAPI):
    """Reputation list API."""

    def register(self, replist: "ReplistForm") -> RefView:
        """Register reputation list.

        Note:
            Calls `POST /replists`.
        Args:
            replist: Filled replist form.
        Returns:
            Reference to the registered replist.
        Raises:
            :class:`~cybsi.api.error.SemanticError`: Form contains logic errors.
        Note:
            Semantic error codes specific for this method:
              * :attr:`~cybsi.api.error.SemanticErrorCodes.StoredQueryNotFound`
              * :attr:`~cybsi.api.error.SemanticErrorCodes.InvalidShareLevel`
        """
        resp = self._connector.do_post(path=_REPLIST_BASE_PATH, json=replist.json())
        return RefView(resp.json())

    def view(self, replist_uuid: uuid.UUID) -> "ReplistView":
        """Get reputation list full view.

        Note:
            Calls `GET /replists/{replist_uuid}`.
        Args:
            replist_uuid: Replist uuid.
        Returns:
            Full view of the replist with ETag string value.
        Raises:
            :class:`~cybsi.api.error.NotFoundError`: Replist not found.
        """
        path = f"{_REPLIST_BASE_PATH}/{replist_uuid}"
        resp = self._connector.do_get(path)
        return ReplistView(resp)

    def edit(
        self,
        replist_uuid: uuid.UUID,
        tag: Tag,
        *,
        is_enabled: Optional[bool] = None,
        query_uuid: Optional[uuid.UUID] = None,
        share_level: Optional[ShareLevels] = None,
    ) -> None:
        """Edit the reputation list.

        Note:
            Calls `PATCH /replists/{replist_uuid}`.
        Args:
            replist_uuid: Replist uuid.
            tag: :attr:`ReplistView.tag` value. Use :meth:`view` to retrieve it.
            query_uuid: Search query UUID attached to replist.
            share_level: Replist share level.
            is_enabled: Replist status toggle.
        Raises:
            :class:`~cybsi.api.error.NotFoundError`: Replist not found.
            :class:`~cybsi.api.error.ResourceModifiedError`:
                Replist changed since last request. Update tag and retry.
            :class:`~cybsi.api.error.SemanticError`: Form contains logic errors.
        Note:
            Semantic error codes specific for this method:
              * :attr:`~cybsi.api.error.SemanticErrorCodes.StoredQueryNotFound`
              * :attr:`~cybsi.api.error.SemanticErrorCodes.InvalidShareLevel`
        """
        form = {}
        if query_uuid is not None:
            form["queryUUID"] = str(query_uuid)
        if share_level is not None:
            form["shareLevel"] = share_level.value
        if is_enabled is not None:
            form["isEnabled"] = is_enabled  # type: ignore
        path = f"{_REPLIST_BASE_PATH}/{replist_uuid}"
        self._connector.do_patch(path=path, tag=tag, json=form)

    def filter(
        self,
        *,
        cursor: Optional[Cursor] = None,
        limit: Optional[int] = None,
    ) -> Page["ReplistCommonView"]:
        """Get replist filtration list.

        Note:
            Calls `GET /replists`
        Args:
            cursor: Page cursor.
            limit: Page limit.
        Return:
            Page with entities and cursor allowing to get next batch of changes.
        """
        params = {}
        if cursor:
            params["cursor"] = str(cursor)
        if limit:
            params["limit"] = str(limit)

        resp = self._connector.do_get(path=_REPLIST_BASE_PATH, params=params)
        page = Page(self._connector.do_get, resp, ReplistCommonView)
        return page

    def entities(
        self,
        replist_uuid: uuid.UUID,
        *,
        # see https://github.com/python/mypy/issues/3737
        entity_view: Type[EntityViewT] = EntityView,  # type: ignore
        cursor: Optional[Cursor] = None,
        limit: Optional[int] = None,
    ) -> Tuple[Page[EntityViewT], Cursor]:
        """Get replist entities.

        Note:
            Calls `GET /replist/{replist_uuid}/entities`
        Args:
            replist_uuid: Replist uuid.
            entity_view: Entity view to use.
                Default is :class:`~cybsi.api.observable.EntityView` -
                - only natural keys.
                You can specify one of builtin views in :mod:`~cybsi.utils.views`.
            cursor: Page cursor.
            limit: Page limit.
        Return:
            Page with entity views and cursor.
            The cursor can be used to call :meth:`changes`.
        Raises:
            :class:`~cybsi.api.error.NotFoundError`: Replist not found.
            :class:`~cybsi.api.error.SemanticError`: Semantic request error.
        Note:
            Semantic error codes specific for this method:
              * :attr:`~cybsi.api.error.SemanticErrorCodes.EntityViewNotFound`

        .. versionchanged:: 2.9
            Add entity view support. See :mod:`~cybsi.utils.views` for details.
        """

        params = {}
        if entity_view is not EntityView:
            params["viewUUID"] = str(entity_view._view_uuid())
        if cursor:
            params["cursor"] = str(cursor)
        if limit:
            params["limit"] = str(limit)

        path = _REPLIST_ENTITIES_PATH_TPL.format(replist_uuid)
        resp = self._connector.do_get(path, params=params)

        page = Page(self._connector.do_get, resp, entity_view)
        return page, cast(Cursor, resp.headers.get(X_CHANGE_CURSOR, ""))

    def changes(
        self,
        replist_uuid: uuid.UUID,
        *,
        cursor: Cursor,
        # see https://github.com/python/mypy/issues/3737
        entity_view: Type[EntityViewT] = EntityView,  # type: ignore
        limit: Optional[int] = None,
    ) -> Page["EntitySetChangeView[EntityViewT]"]:
        """Get replist changes

        Note:
            Calls `GET /replist/{replist_uuid}/changes`
        Args:
            replist_uuid: Replist uuid.
            entity_view: Entity view to use.
                Default is :class:`~cybsi.api.observable.EntityView` -
                - only natural keys.
                You can specify one of builtin views in :mod:`~cybsi.utils.views`.
            cursor: Page cursor.
                On the first request you should pass the cursor value
                obtained when requesting replist entities :meth:`entities`.
                Subsequent calls should use cursor property of the page
                returned by :meth:`changes`.
            limit: Page limit.
        Return:
            Page with changes.
        Warning:
            Cursor behaviour differs from other API methods.

            Do not save returned page cursor if it is :data:`None`.
            :data:`None` means that all changes **for this moment** are received.
            More changes can arrive later. Pass your previous non-none ``cursor``
            value in loop, until non-none cursor is returned.

            Please wait some time if method returns a page with :data:`None` cursor.
        Raises:
            :class:`~cybsi.api.error.NotFoundError`: Replist not found.
            :class:`~cybsi.api.error.SemanticError`: Semantic request error.
        Note:
            Semantic error codes specific for this method:
              * :attr:`~cybsi.api.error.SemanticErrorCodes.CursorOutOfRange`
              * :attr:`~cybsi.api.error.SemanticErrorCodes.EntityViewNotFound`

        .. versionchanged:: 2.9
            Add entity view support. See :mod:`~cybsi.utils.views` for details.
        """

        params = {"cursor": str(cursor)}
        if entity_view is not EntityView:
            params["viewUUID"] = str(entity_view._view_uuid())
        if limit:
            params["limit"] = str(limit)

        path = _REPLIST_CHANGES_PATH_TPL.format(replist_uuid)
        resp = self._connector.do_get(path, params=params)

        def entity_view_converter(entity_data):
            return EntitySetChangeView(entity_view, entity_data)

        page = Page(self._connector.do_get, resp, entity_view_converter)
        return page

    def statistic(self, replist_uuid: uuid.UUID) -> "ReplistStatisticView":
        """Get replist statistic.

        Note:
            Calls `GET /replists/{replist_uuid}/statistic`.
        Args:
            replist_uuid: Replist uuid.
        Returns:
            Replist statistic view.
        Raises:
            :class:`~cybsi.api.error.NotFoundError`: Replist not found.
        """

        path = f"{_REPLIST_BASE_PATH}/{replist_uuid}/statistic"
        resp = self._connector.do_get(path)
        return ReplistStatisticView(resp.json())


class ReplistsAsyncAPI(BaseAsyncAPI):
    """Replists asynchronous API."""

    async def entities(
        self,
        replist_uuid: uuid.UUID,
        *,
        # see https://github.com/python/mypy/issues/3737
        entity_view: Type[EntityViewT] = EntityView,  # type: ignore
        cursor: Optional[Cursor] = None,
        limit: Optional[int] = None,
    ) -> Tuple[AsyncPage[EntityViewT], Cursor]:
        """Get replist entities.

        Note:
            Calls `GET /replist/{replist_uuid}/entities`
        Args:
            replist_uuid: Replist uuid.
            entity_view: Entity view to use.
                Default is :class:`~cybsi.api.observable.EntityView` -
                - only natural keys.
                You can specify one of builtin views in :mod:`~cybsi.utils.views`.
            cursor: Page cursor.
            limit: Page limit.
        Return:
            Page with entity views and cursor.
            The cursor can be used to call :meth:`changes`.
        Raises:
            :class:`~cybsi.api.error.NotFoundError`: Replist not found.
        .. versionchanged:: 2.9
            Add entity view support. See :mod:`~cybsi.utils.views` for details.
        """

        params = {}
        if entity_view is not EntityView:
            params["viewUUID"] = str(entity_view._view_uuid())
        if cursor:
            params["cursor"] = str(cursor)
        if limit:
            params["limit"] = str(limit)

        path = _REPLIST_ENTITIES_PATH_TPL.format(replist_uuid)
        resp = await self._connector.do_get(path, params=params)

        page = AsyncPage(self._connector.do_get, resp, entity_view)
        return page, cast(Cursor, resp.headers.get(X_CHANGE_CURSOR, ""))

    async def changes(
        self,
        replist_uuid: uuid.UUID,
        *,
        cursor: Cursor,
        # see https://github.com/python/mypy/issues/3737
        entity_view: Type[EntityViewT] = EntityView,  # type: ignore
        limit: Optional[int] = None,
    ) -> AsyncPage["EntitySetChangeView[EntityViewT]"]:
        """Get replist changes

        Note:
            Calls `GET /replist/{replist_uuid}/changes`
        Args:
            replist_uuid: Replist uuid.
            entity_view: Entity view to use.
                Default is :class:`~cybsi.api.observable.EntityView` -
                - only natural keys.
                You can specify one of builtin views in :mod:`~cybsi.utils.views`.
            cursor: Page cursor.
                On the first request you should pass the cursor value
                obtained when requesting replist entities :meth:`entities`.
                Subsequent calls should use cursor property of the page
                returned by :meth:`changes`.
            limit: Page limit.
        Return:
            Page with changes.
        Warning:
            Cursor behaviour differs from other API methods.

            Do not save returned page cursor if it is :data:`None`.
            :data:`None` means that all changes **for this moment** are received.
            More changes can arrive later. Pass your previous non-none ``cursor``
            value in loop, until non-none cursor is returned.

            Please wait some time if method returns a page with :data:`None` cursor.
        Raises:
            :class:`~cybsi.api.error.NotFoundError`: Replist not found.
            :class:`~cybsi.api.error.SemanticError`: Semantic request error.
        Note:
            Semantic error codes specific for this method:
              * :attr:`~cybsi.api.error.SemanticErrorCodes.CursorOutOfRange`
        .. versionchanged:: 2.9
            Add entity view support. See :mod:`~cybsi.utils.views` for details.
        """

        params = {"cursor": str(cursor)}
        if entity_view is not EntityView:
            params["viewUUID"] = str(entity_view._view_uuid())
        if limit:
            params["limit"] = str(limit)

        path = _REPLIST_CHANGES_PATH_TPL.format(replist_uuid)
        resp = await self._connector.do_get(path, params=params)

        def entity_view_converter(entity_data):
            return EntitySetChangeView(entity_view, entity_data)

        page = AsyncPage(self._connector.do_get, resp, entity_view_converter)
        return page

    async def statistic(self, replist_uuid: uuid.UUID) -> "ReplistStatisticView":
        """Get replist statistic.

        Note:
            Calls `GET /replists/{replist_uuid}/statistic`.
        Args:
            replist_uuid: Replist uuid.
        Returns:
            Replist statistic view.
        Raises:
            :class:`~cybsi.api.error.NotFoundError`: Replist not found.
        """

        path = f"{_REPLIST_BASE_PATH}/{replist_uuid}/statistic"
        resp = await self._connector.do_get(path)
        return ReplistStatisticView(resp.json())


class ReplistForm(JsonObjectForm):
    """Reputation list form.

    Args:
        query_uuid: Search query UUID attached to replist.
        share_level: Replist share level.
        is_enabled: Replist status toggle.
    """

    def __init__(
        self,
        query_uuid: uuid.UUID,
        share_level: ShareLevels,
        *,
        is_enabled: Optional[bool] = None,
    ):
        super().__init__()
        self._data["queryUUID"] = str(query_uuid)
        self._data["shareLevel"] = share_level.value
        if is_enabled is not None:
            self._data["isEnabled"] = is_enabled


class ReplistCommonView(RefView):
    """Reputation list short view."""

    @property
    def query(self) -> "StoredQueryCommonView":
        """Search query attached to replist (without raw query text)."""
        return StoredQueryCommonView(self._get("query"))

    @property
    def author(self) -> "RefView":
        """Replist author."""
        return RefView(self._get("author"))

    @property
    def share_level(self) -> ShareLevels:
        """Replist share level."""
        return ShareLevels(self._get("shareLevel"))

    @property
    def is_enabled(self) -> bool:
        """Replist enabled status."""
        return self._get("isEnabled")


class ReplistView(_TaggedRefView, ReplistCommonView):
    """Reputation list full view."""

    @property
    def updated_at(self) -> Optional[datetime]:
        """Replist last updated time.

        :data:`None` if it was never updated.

        """
        return self._map_optional("createdAt", parse_rfc3339_timestamp)

    @property
    def status(self) -> ReplistStatus:
        """Replist current status."""
        return ReplistStatus(self._get("status"))


class EntitySetChangeView(JsonObjectView, Generic[EntityViewT]):
    """Replist change."""

    def __init__(
        self, entity_view: Type[EntityViewT], data: Optional[JsonObject] = None
    ):
        super(EntitySetChangeView, self).__init__(data)
        self._entity_view = entity_view

    @property
    def operation(self) -> EntitySetOperations:
        """Get change operation."""
        return EntitySetOperations(self._get("operation"))

    @property
    def entity(self) -> EntityViewT:
        """Get entity view."""
        en = self._get("entity")
        return self._entity_view(en)


class ReplistStatisticView(JsonObjectView):
    """Replist statistic view."""

    @property
    def entity_count(self) -> int:
        """Total number of entities in the replist."""
        return self._get("entityCount")

    @property
    def entity_type_distribution(self) -> List["EntityTypeDistributionView"]:
        """Distribution of entities number by their types."""
        return [
            EntityTypeDistributionView(x) for x in self._get("entityTypeDistribution")
        ]


class EntityTypeDistributionView(JsonObjectView):
    """Entity type distribution."""

    @property
    def entity_type(self) -> EntityTypes:
        """Entity type."""
        return EntityTypes(self._get("entityType"))

    @property
    def count(self) -> int:
        """Number of entities."""
        return self._get("count")

cybsi/api/replist/api.py
cyberstudio/cybsi-sdk
from enum_tools import document_enum

from cybsi.api.enum import CybsiAPIEnum


@document_enum
class EntitySetOperations(CybsiAPIEnum):
    """Type of operation applied to an entity in reputation list."""

    Add = "Add"  # doc: An entity was added.
    Remove = "Remove"  # doc: An entity was removed.


@document_enum
class ReplistStatus(CybsiAPIEnum):
    """Current status of reputation list."""

    Initializing = "Initializing"  # doc: Replist is in building process.
    Active = "Active"  # doc: Replist is enabled.
    Inactive = "Inactive"  # doc: Replist is disabled.

cybsi/api/replist/enums.py
cyberstudio/cybsi-sdk
"""Use this section of API to register and retrieve reports.
"""
from .api import (
    ReportsAPI,
    ReportsAsyncAPI,
    ReportForm,
    ReportView,
    ArtifactShortView,
    ReportHeaderView,
    SimilarReportView,
    SimilarReportCorrelationView,
    MatchedEntitiesView,
    ObservationView,
    ObservationContentView,
)

cybsi/api/report/__init__.py
cyberstudio/cybsi-sdk
import uuid
from datetime import datetime
from typing import Any, Dict, Iterable, List, Optional, cast

from .. import RefView
from ..artifact import ArtifactCommonView, ArtifactTypes
from ..internal import (
    BaseAPI,
    BaseAsyncAPI,
    JsonObject,
    JsonObjectForm,
    JsonObjectView,
    parse_rfc3339_timestamp,
    rfc3339_timestamp,
)
from ..observable import EntityView, ShareLevels
from ..observation import (
    DNSLookupObservationContentView,
    GenericObservationContentView,
    NetworkSessionObservationContentView,
    ObservationCommonView,
    ObservationHeaderView,
    ObservationTypes,
    ThreatObservationContentView,
    WhoisLookupObservationContentView,
)
from ..pagination import AsyncPage, Cursor, Page

_REPORTS_PATH = "/enrichment/reports"
_REPORTS_LABEL_PATH = "/enrichment/report-labels"


class ReportsAPI(BaseAPI):
    """Report API."""

    def register(self, report: "ReportForm") -> RefView:
        """Register report.

        Note:
            Calls `POST /enrichment/reports`.
        Args:
            report: Filled report form.
        Returns:
            Reference to the registered report.
        Raises:
            :class:`~cybsi.api.error.SemanticError`: Form contains logic errors.
            :class:`~cybsi.api.error.ConflictError`:
                A report with such external_id and data source is already registered.
        Note:
            Semantic error codes specific for this method:
              * :attr:`~cybsi.api.error.SemanticErrorCodes.InvalidTime`
              * :attr:`~cybsi.api.error.SemanticErrorCodes.DataSourceNotFound`
              * :attr:`~cybsi.api.error.SemanticErrorCodes.ObservationNotFound`
              * :attr:`~cybsi.api.error.SemanticErrorCodes.ArtifactNotFound`
              * :attr:`~cybsi.api.error.SemanticErrorCodes.UnallowedObservationType`
              * :attr:`~cybsi.api.error.SemanticErrorCodes.InvalidShareLevel`
        """
        r = self._connector.do_post(path=_REPORTS_PATH, json=report.json())
        return RefView(r.json())

    def view(self, report_uuid: uuid.UUID) -> "ReportView":
        """Get report view.

        Note:
            Calls `GET /enrichment/reports/{report_uuid}`.
        Args:
            report_uuid: Report uuid.
        Returns:
            View of the report.
        Raises:
            :class:`~cybsi.api.error.NotFoundError`: Report not found.
        """
        path = f"{_REPORTS_PATH}/{report_uuid}"
        r = self._connector.do_get(path)
        return ReportView(r.json())

    def filter(
        self,
        *,
        file_uuid: Optional[uuid.UUID] = None,
        reporter_uuids: Optional[Iterable[uuid.UUID]] = None,
        data_source_uuids: Optional[Iterable[uuid.UUID]] = None,
        entity_uuids: Optional[Iterable[uuid.UUID]] = None,
        labels: Optional[Iterable[str]] = None,
        analyzed_artifact_uuid: Optional[uuid.UUID] = None,
        title: Optional[str] = None,
        created_before: Optional[datetime] = None,
        created_after: Optional[datetime] = None,
        updated_before: Optional[datetime] = None,
        updated_after: Optional[datetime] = None,
        external_id: Optional[str] = None,
        cursor: Optional[Cursor] = None,
        limit: Optional[int] = None,
    ) -> Page["ReportHeaderView"]:
        """Get report header filtration list that matches the specified criteria.

        Reports are returned in reverse order of registration time.

        Note:
            Calls `GET /enrichment/reports`.
        Args:
            file_uuid: File identifier.
                Filter reports by the artifact of the observed file
                with the specified identifier.
            reporter_uuids: Reporter identifiers.
                Filter reports by reporter data source identifiers.
            data_source_uuids: Data source identifiers.
                Filter reports by original data source identifiers.
            entity_uuids: entity identifiers.
                Filter reports by at least one of the specified entity.
            labels: Filter reports by any of the specified labels.
                Label case is ignored.
            analyzed_artifact_uuid: Analyzed artifact identifier.
                Filter reports based on analysis of the specified artifact.
            title: Filter reports by specified title. Title case is ignored.
                Allows special symbols '%' and '_'.
                The '%' symbol replaces any number of title symbols.
                The '_' symbol replaces only one title symbol.
                To search for '%' and '_' symbols in the usual sense,
                you need to prepend them with '\'.
            created_before: Filter reports created before the timestamp (inclusive).
            created_after: Filter reports created after the timestamp (inclusive).
            updated_before: Filter reports updated before the timestamp (inclusive).
            updated_after: Filter reports updated after the timestamp (inclusive).
            external_id: Filter reports by their external id. Min length is 1.
            cursor: Page cursor.
            limit: Page limit.
        Returns:
            Page of reports list and next page cursor.
        Raises:
            :class:`~cybsi.api.error.SemanticError`: Query arguments contain
                semantic errors.
        Note:
            Semantic error codes specific for this method:
              * :attr:`~cybsi.api.error.SemanticErrorCodes.DataSourceNotFound`
              * :attr:`~cybsi.api.error.SemanticErrorCodes.FileNotFound`
              * :attr:`~cybsi.api.error.SemanticErrorCodes.EntityNotFound`
              * :attr:`~cybsi.api.error.SemanticErrorCodes.ArtifactNotFound`
        """
        params: Dict[str, Any] = {}

        if file_uuid is not None:
            params["fileUUID"] = str(file_uuid)
        if reporter_uuids is not None:
            params["reporterUUID"] = [str(u) for u in reporter_uuids]
        if data_source_uuids is not None:
            params["dataSourceUUID"] = [str(u) for u in data_source_uuids]
        if entity_uuids is not None:
            params["entityUUID"] = [str(u) for u in entity_uuids]
        if labels is not None:
            params["label"] = list(labels)
        if analyzed_artifact_uuid is not None:
            params["analyzedArtifactUUID"] = str(analyzed_artifact_uuid)
        if title is not None:
            params["title"] = title
        if created_before is not None:
            params["createdBefore"] = rfc3339_timestamp(created_before)
        if created_after is not None:
            params["createdAfter"] = rfc3339_timestamp(created_after)
        if updated_before is not None:
            params["updatedBefore"] = rfc3339_timestamp(updated_before)
        if updated_after is not None:
            params["updatedAfter"] = rfc3339_timestamp(updated_after)
        if external_id is not None:
            params["externalID"] = external_id
        if cursor:
            params["cursor"] = str(cursor)
        if limit:
            params["limit"] = str(limit)

        resp = self._connector.do_get(path=_REPORTS_PATH, params=params)
        page = Page(self._connector.do_get, resp, ReportHeaderView)
        return page

    def filter_similar_reports(
        self,
        report_uuid: uuid.UUID,
        *,
        reporter_uuid: Optional[uuid.UUID] = None,
        data_source_uuid: Optional[uuid.UUID] = None,
        cursor: Optional[Cursor] = None,
        limit: Optional[int] = None,
    ) -> Page["SimilarReportView"]:
        """Get similar reports filtration list in descending order of similarity.

        Note:
            Calls `GET /enrichment/reports/{report_uuid}/similar-reports`.
        Args:
            report_uuid: Report identifier.
            reporter_uuid: Reporter identifier.
                Filter similar reports the reporter data source identifiers.
            data_source_uuid: Data source identifier.
                Filter similar reports the original data source identifiers.
            cursor: Page cursor.
            limit: Page limit.
        Returns:
            Page of similar reports list and next page cursor.
        """

        params: Dict[str, Any] = {}
        if reporter_uuid is not None:
            params["reporterUUID"] = str(reporter_uuid)
        if data_source_uuid is not None:
            params["dataSourceUUID"] = str(data_source_uuid)
        if cursor:
            params["cursor"] = str(cursor)
        if limit:
            params["limit"] = str(limit)

        path = f"{_REPORTS_PATH}/{report_uuid}/similar-reports"
        resp = self._connector.do_get(path, params)
        page = Page(self._connector.do_get, resp, SimilarReportView)
        return page

    def explain_report_similarity(
        self,
        report_uuid: uuid.UUID,
        similar_report_uuid: uuid.UUID,
    ) -> "SimilarReportView":
        """Get information about the similarity of reports.

        Note:
            Report A is similar to report B,
            but not the same as report B is similar report A.
        Note:
            Calls `GET /enrichment/reports/{report_uuid}/similar-reports/{similar_report_uuid}`. # noqa: E501
        Args:
            report_uuid: Report identifier.
            similar_report_uuid: Report identifier for comparison
        Returns:
            View of the report.
        """
        path = f"{_REPORTS_PATH}/{report_uuid}/similar-reports/{similar_report_uuid}"
        r = self._connector.do_get(path)
        return SimilarReportView(r.json())

    def search_labels(
        self,
        prefix: str,
        *,
        cursor: Optional[Cursor] = None,
        limit: Optional[int] = None,
    ) -> Page[str]:
        """Get report label filtration list.

        Note:
            Calls `GET /enrichment/report-labels`.
        Args:
            prefix: Label prefix.
                Prefix length must be in range [2;50].
            cursor: Page cursor.
            limit: Page limit.
        Returns:
            Page of reports label list and next page cursor.
        """
        params: Dict[str, Any] = {"prefix": prefix}
        if cursor:
            params["cursor"] = str(cursor)
        if limit:
            params["limit"] = str(limit)

        resp = self._connector.do_get(path=_REPORTS_LABEL_PATH, params=params)
        page = Page(self._connector.do_get, resp, str)
        return page

    def attach_observations(
        self, report_uuid: uuid.UUID, observation_uuids: Iterable[uuid.UUID]
    ) -> RefView:
        """Attach observations to existing report.

        Note:
            Calls `POST /enrichment/reports/{report_uuid}/observations`.
        Args:
            report_uuid: Report UUID.
            observation_uuids: Observation UUIDs.
        Returns:
            Reference to the report.
        Raises:
            :class:`~cybsi.api.error.ForbiddenError`: Operation was forbidden.
                Most probably because user is not owner of the report.
            :class:`~cybsi.api.error.NotFoundError`: Report not found.
            :class:`~cybsi.api.error.SemanticError`: Form contains logic errors.
        Note:
            Semantic error codes specific for this method:
              * :attr:`~cybsi.api.error.SemanticErrorCodes.ObservationNotFound`
              * :attr:`~cybsi.api.error.SemanticErrorCodes.UnallowedObservationType`
        """
        form: Dict[str, Any] = {"observations": [str(u) for u in observation_uuids]}

        path = f"{_REPORTS_PATH}/{report_uuid}/observations"
        resp = self._connector.do_post(path=path, json=form)
        return RefView(resp.json())

    def filter_observations(
        self,
        report_uuid: uuid.UUID,
        *,
        cursor: Optional[Cursor] = None,
        limit: Optional[int] = None,
    ) -> Page["ObservationView"]:
        """Filter observations in the report.
        Observations having share level above user's access level are filtered out.

        Note:
            Calls `GET /enrichment/reports/{report_uuid}/observations`.
        Args:
            report_uuid: Report UUID.
            cursor: Page cursor.
            limit: Page limit.
        Returns:
            Page of report observations list and next page cursor.
                The list of observations is ordered as in the report.
        Raises:
            :class:`~cybsi.api.error.NotFoundError`: Report not found.
        """
        params: Dict[str, Any] = {}
        if cursor is not None:
            params["cursor"] = str(cursor)
        if limit is not None:
            params["limit"] = str(limit)

        path = f"{_REPORTS_PATH}/{report_uuid}/observations"
        resp = self._connector.do_get(path=path, params=params)
        page = Page(self._connector.do_get, resp, ObservationView)
        return page

    def attach_artifacts(
        self, report_uuid: uuid.UUID, artifact_uuids: Iterable[uuid.UUID]
    ) -> RefView:
        """Attach artifacts to existing report.

        Note:
            Calls `POST /enrichment/reports/{report_uuid}/artifacts`.
        Args:
            report_uuid: Report UUID.
            artifact_uuids: Artifact UUIDs.
        Returns:
            Reference to the report.
        Raises:
            :class:`~cybsi.api.error.ForbiddenError`: Operation was forbidden.
                Most probably because user is not owner of the report.
            :class:`~cybsi.api.error.SemanticError`: Form contains logic errors.
            :class:`~cybsi.api.error.NotFoundError`: Report not found.
        Note:
            Semantic error codes specific for this method:
              * :attr:`~cybsi.api.error.SemanticErrorCodes.ArtifactNotFound`
        """
        form: Dict[str, Any] = {"artifacts": [str(u) for u in artifact_uuids]}

        path = f"{_REPORTS_PATH}/{report_uuid}/artifacts"
        resp = self._connector.do_post(path=path, json=form)
        return RefView(resp.json())

    def filter_artifacts(
        self,
        report_uuid: uuid.UUID,
        *,
        cursor: Optional[Cursor] = None,
        limit: Optional[int] = None,
    ) -> Page["ArtifactCommonView"]:
        """Filter artifacts in the report.
        Artifacts having share level above user's access level are filtered out.

        Note:
            Calls `GET /enrichment/reports/{report_uuid}/artifacts`.
        Args:
            report_uuid: Report UUID.
            cursor: Page cursor.
            limit: Page limit.
        Returns:
            Page of report artifacts list and next page cursor.
                The list of artifacts is ordered as in the report.
        Raises:
            :class:`~cybsi.api.error.NotFoundError`: Report not found.
        """
        params: Dict[str, Any] = {}
        if cursor is not None:
            params["cursor"] = str(cursor)
        if limit is not None:
            params["limit"] = str(limit)

        path = f"{_REPORTS_PATH}/{report_uuid}/artifacts"
        resp = self._connector.do_get(path=path, params=params)
        page = Page(self._connector.do_get, resp, ArtifactCommonView)
        return page


class ReportsAsyncAPI(BaseAsyncAPI):
    """Report asynchronous API."""

    async def register(self, report: "ReportForm") -> RefView:
        """Register report.

        Note:
            Calls `POST /enrichment/reports`.
        Args:
            report: Filled report form.
        Returns:
            Reference to the registered report.
        Raises:
            :class:`~cybsi.api.error.SemanticError`: Form contains logic errors.
            :class:`~cybsi.api.error.ConflictError`:
                A report with such external_id and data source is already registered.
        Note:
            Semantic error codes specific for this method:
              * :attr:`~cybsi.api.error.SemanticErrorCodes.InvalidTime`
              * :attr:`~cybsi.api.error.SemanticErrorCodes.DataSourceNotFound`
              * :attr:`~cybsi.api.error.SemanticErrorCodes.ObservationNotFound`
              * :attr:`~cybsi.api.error.SemanticErrorCodes.ArtifactNotFound`
              * :attr:`~cybsi.api.error.SemanticErrorCodes.UnallowedObservationType`
              * :attr:`~cybsi.api.error.SemanticErrorCodes.InvalidShareLevel`
        """
        r = await self._connector.do_post(path=_REPORTS_PATH, json=report.json())
        return RefView(r.json())

    async def view(self, report_uuid: uuid.UUID) -> "ReportView":
        """Get report view.

        Note:
            Calls `GET /enrichment/reports/{report_uuid}`.
        Args:
            report_uuid: Report uuid.
        Returns:
            View of the report.
        Raises:
            :class:`~cybsi.api.error.NotFoundError`: Report not found.
        """
        path = f"{_REPORTS_PATH}/{report_uuid}"
        r = await self._connector.do_get(path)
        return ReportView(r.json())

    async def filter(
        self,
        *,
        file_uuid: Optional[uuid.UUID] = None,
        reporter_uuids: Optional[Iterable[uuid.UUID]] = None,
        data_source_uuids: Optional[Iterable[uuid.UUID]] = None,
        entity_uuids: Optional[Iterable[uuid.UUID]] = None,
        labels: Optional[Iterable[str]] = None,
        analyzed_artifact_uuid: Optional[uuid.UUID] = None,
        title: Optional[str] = None,
        created_before: Optional[datetime] = None,
        created_after: Optional[datetime] = None,
        updated_before: Optional[datetime] = None,
        updated_after: Optional[datetime] = None,
        external_id: Optional[str] = None,
        cursor: Optional[Cursor] = None,
        limit: Optional[int] = None,
    ) -> AsyncPage["ReportHeaderView"]:
        """Get report header filtration list that matches the specified criteria.

        Reports are returned in reverse order of registration time.

        Note:
            Calls `GET /enrichment/reports`.
        Args:
            file_uuid: File identifier.
                Filter reports by the artifact of the observed file
                with the specified identifier.
            reporter_uuids: Reporter identifiers.
                Filter reports by reporter data source identifiers.
            data_source_uuids: Data source identifiers.
                Filter reports by original data source identifiers.
            entity_uuids: entity identifiers.
                Filter reports by at least one of the specified entity.
            labels: Filter reports by any of the specified labels.
                Label case is ignored.
            analyzed_artifact_uuid: Analyzed artifact identifier.
                Filter reports based on analysis of the specified artifact.
            title: Filter reports by specified title. Title case is ignored.
                Allows special symbols '%' and '_'.
                The '%' symbol replaces any number of title symbols.
                The '_' symbol replaces only one title symbol.
                To search for '%' and '_' symbols in the usual sense,
                you need to prepend them with '\'.
            created_before: Filter reports created before the timestamp (inclusive).
            created_after: Filter reports created after the timestamp (inclusive).
            updated_before: Filter reports updated before the timestamp (inclusive).
            updated_after: Filter reports updated after the timestamp (inclusive).
            external_id: Filter reports by their external id. Min length is 1.
            cursor: Page cursor.
            limit: Page limit.
        Returns:
            Page of reports list and next page cursor.
        Raises:
            :class:`~cybsi.api.error.SemanticError`: Query arguments contain
                semantic errors.
        Note:
            Semantic error codes specific for this method:
              * :attr:`~cybsi.api.error.SemanticErrorCodes.DataSourceNotFound`
              * :attr:`~cybsi.api.error.SemanticErrorCodes.FileNotFound`
              * :attr:`~cybsi.api.error.SemanticErrorCodes.EntityNotFound`
              * :attr:`~cybsi.api.error.SemanticErrorCodes.ArtifactNotFound`
        """
        params: Dict[str, Any] = {}

        if file_uuid is not None:
            params["fileUUID"] = str(file_uuid)
        if reporter_uuids is not None:
            params["reporterUUID"] = [str(u) for u in reporter_uuids]
        if data_source_uuids is not None:
            params["dataSourceUUID"] = [str(u) for u in data_source_uuids]
        if entity_uuids is not None:
            params["entityUUID"] = [str(u) for u in entity_uuids]
        if labels is not None:
            params["label"] = list(labels)
        if analyzed_artifact_uuid is not None:
            params["analyzedArtifactUUID"] = str(analyzed_artifact_uuid)
        if title is not None:
            params["title"] = title
        if created_before is not None:
            params["createdBefore"] = rfc3339_timestamp(created_before)
        if created_after is not None:
            params["createdAfter"] = rfc3339_timestamp(created_after)
        if updated_before is not None:
            params["updatedBefore"] = rfc3339_timestamp(updated_before)
        if updated_after is not None:
            params["updatedAfter"] = rfc3339_timestamp(updated_after)
        if external_id is not None:
            params["externalID"] = external_id
        if cursor:
            params["cursor"] = str(cursor)
        if limit:
            params["limit"] = str(limit)

        resp = await self._connector.do_get(path=_REPORTS_PATH, params=params)
        page = AsyncPage(self._connector.do_get, resp, ReportHeaderView)
        return page

    async def filter_similar_reports(
        self,
        report_uuid: uuid.UUID,
        *,
        reporter_uuid: Optional[uuid.UUID] = None,
        data_source_uuid: Optional[uuid.UUID] = None,
        cursor: Optional[Cursor] = None,
        limit: Optional[int] = None,
    ) -> AsyncPage["SimilarReportView"]:
        """Get similar reports filtration list in descending order of similarity.

        Note:
            Calls `GET /enrichment/reports/{report_uuid}/similar-reports`.
        Args:
            report_uuid: Report identifier.
            reporter_uuid: Reporter identifier.
                Filter similar reports the reporter data source identifiers.
            data_source_uuid: Data source identifier.
                Filter similar reports the original data source identifiers.
            cursor: Page cursor.
            limit: Page limit.
        Returns:
            Page of similar reports list and next page cursor.
        """

        params: Dict[str, Any] = {}
        if reporter_uuid is not None:
            params["reporterUUID"] = str(reporter_uuid)
        if data_source_uuid is not None:
            params["dataSourceUUID"] = str(data_source_uuid)
        if cursor:
            params["cursor"] = str(cursor)
        if limit:
            params["limit"] = str(limit)

        path = f"{_REPORTS_PATH}/{report_uuid}/similar-reports"
        resp = await self._connector.do_get(path, params)
        page = AsyncPage(self._connector.do_get, resp, SimilarReportView)
        return page

    async def explain_report_similarity(
        self,
        report_uuid: uuid.UUID,
        similar_report_uuid: uuid.UUID,
    ) -> "SimilarReportView":
        """Get information about the similarity of reports.

        Note:
            Report A is similar to report B,
            but not the same as report B is similar report A.
        Note:
            Calls `GET /enrichment/reports/{report_uuid}/similar-reports/{similar_report_uuid}`. # noqa: E501
        Args:
            report_uuid: Report identifier.
            similar_report_uuid: Report identifier for comparison
        Returns:
            View of the report.
        """
        path = f"{_REPORTS_PATH}/{report_uuid}/similar-reports/{similar_report_uuid}"
        r = await self._connector.do_get(path)
        return SimilarReportView(r.json())

    async def search_labels(
        self,
        prefix: str,
        *,
        cursor: Optional[Cursor] = None,
        limit: Optional[int] = None,
    ) -> AsyncPage[str]:
        """Get report label filtration list.

        Note:
            Calls `GET /enrichment/report-labels`.
        Args:
            prefix: Label prefix.
                Prefix length must be in range [2;50].
            cursor: Page cursor.
            limit: Page limit.
        Returns:
            Page of reports label list and next page cursor.
        """
        params: Dict[str, Any] = {"prefix": prefix}
        if cursor:
            params["cursor"] = str(cursor)
        if limit:
            params["limit"] = str(limit)

        resp = await self._connector.do_get(_REPORTS_LABEL_PATH, params=params)
        page = AsyncPage(self._connector.do_get, resp, str)
        return page

    async def attach_observations(
        self, report_uuid: uuid.UUID, observation_uuids: Iterable[uuid.UUID]
    ) -> RefView:
        """Attach observations to existing report.

        Note:
            Calls `POST /enrichment/reports/{report_uuid}/observations`.
        Args:
            report_uuid: Report UUID.
            observation_uuids: Observation UUIDs.
        Returns:
            Reference to the report.
        Raises:
            :class:`~cybsi.api.error.SemanticError`: Form contains logic errors.
            :class:`~cybsi.api.error.NotFoundError`: Report not found.
        Note:
            Semantic error codes specific for this method:
              * :attr:`~cybsi.api.error.SemanticErrorCodes.ObservationNotFound`
              * :attr:`~cybsi.api.error.SemanticErrorCodes.UnallowedObservationType`
        """
        form: Dict[str, Any] = {"observations": [str(u) for u in observation_uuids]}

        path = f"{_REPORTS_PATH}/{report_uuid}/observations"
        resp = await self._connector.do_post(path=path, json=form)
        return RefView(resp.json())

    async def filter_observations(
        self,
        report_uuid: uuid.UUID,
        *,
        cursor: Optional[Cursor] = None,
        limit: Optional[int] = None,
    ) -> AsyncPage["ObservationView"]:
        """Filter observations in the report.
        Observations having share level above user's access level are filtered out.

        Note:
            Calls `GET /enrichment/reports/{report_uuid}/observations`.
        Args:
            report_uuid: Report UUID.
            cursor: Page cursor.
            limit: Page limit.
        Returns:
            Page of report observations list and next page cursor.
                The list of observations is ordered as in the report.
        Raises:
            :class:`~cybsi.api.error.NotFoundError`: Report not found.
        """
        params: Dict[str, Any] = {}
        if cursor is not None:
            params["cursor"] = str(cursor)
        if limit is not None:
            params["limit"] = str(limit)

        path = f"{_REPORTS_PATH}/{report_uuid}/observations"
        resp = await self._connector.do_get(path=path, params=params)
        page = AsyncPage(self._connector.do_get, resp, ObservationView)
        return page

    async def attach_artifacts(
        self, report_uuid: uuid.UUID, artifact_uuids: Iterable[uuid.UUID]
    ) -> RefView:
        """Attach artifacts to existing report.

        Note:
            Calls `POST /enrichment/reports/{report_uuid}/artifacts`.
        Args:
            report_uuid: Report UUID.
            artifact_uuids: Artifact UUIDs.
        Returns:
            Reference to the report.
        Raises:
            :class:`~cybsi.api.error.SemanticError`: Form contains logic errors.
            :class:`~cybsi.api.error.NotFoundError`: Report not found.
        Note:
            Semantic error codes specific for this method:
              * :attr:`~cybsi.api.error.SemanticErrorCodes.ArtifactNotFound`
        """
        form: Dict[str, Any] = {"artifacts": [str(u) for u in artifact_uuids]}

        path = f"{_REPORTS_PATH}/{report_uuid}/artifacts"
        resp = await self._connector.do_post(path=path, json=form)
        return RefView(resp.json())

    async def filter_artifacts(
        self,
        report_uuid: uuid.UUID,
        *,
        cursor: Optional[Cursor] = None,
        limit: Optional[int] = None,
    ) -> AsyncPage["ArtifactCommonView"]:
        """Filter artifacts in the report.
        Artifacts having share level above user's access level are filtered out.

        Note:
            Calls `GET /enrichment/reports/{report_uuid}/artifacts`.
        Args:
            report_uuid: Report UUID.
            cursor: Page cursor.
            limit: Page limit.
        Returns:
            Page of report artifacts list and next page cursor.
                The list of artifacts is ordered as in the report.
        Raises:
            :class:`~cybsi.api.error.NotFoundError`: Report not found.
        """
        params: Dict[str, Any] = {}
        if cursor is not None:
            params["cursor"] = str(cursor)
        if limit is not None:
            params["limit"] = str(limit)

        path = f"{_REPORTS_PATH}/{report_uuid}/artifacts"
        resp = await self._connector.do_get(path=path, params=params)
        page = AsyncPage(self._connector.do_get, resp, ArtifactCommonView)
        return page


class ReportForm(JsonObjectForm):
    """Report form.

    Args:
        share_level: Report share level.
        title: Report title.
        description: Report description.
        external_id: ID of the report in external system. Must be unique for datasource.
        created_at: Time of report creation.
        published_at: Time of report publication.
        external_refs: List of external references associated with report.
        labels: List of report labels.
        data_source: UUID of report's associated datasource.
        observation_uuids: List of report's observations.
        artifact_uuids: List of report's artifacts.
        analyzed_artifact_uuid: Analyzed artifact identifier.
    """

    def __init__(
        self,
        share_level: ShareLevels,
        *,
        title: Optional[str] = None,
        description: Optional[str] = None,
        external_id: Optional[str] = None,
        created_at: Optional[datetime] = None,
        published_at: Optional[datetime] = None,
        external_refs: Optional[Iterable[str]] = None,
        labels: Optional[Iterable[str]] = None,
        data_source: Optional[uuid.UUID] = None,
        observation_uuids: Optional[Iterable[uuid.UUID]] = None,
        artifact_uuids: Optional[Iterable[uuid.UUID]] = None,
        analyzed_artifact_uuid: Optional[uuid.UUID] = None,
    ):
        super().__init__()
        self._data["shareLevel"] = share_level.value
        if title is not None:
            self._data["title"] = title
        if description is not None:
            self._data["description"] = description
        if external_id is not None:
            self._data["externalID"] = external_id
        if created_at is not None:
            self._data["createdAt"] = rfc3339_timestamp(created_at)
        if published_at is not None:
            self._data["publishedAt"] = rfc3339_timestamp(published_at)
        if external_refs is not None:
            self._data["externalRefs"] = list(external_refs)
        if labels is not None:
            self._data["labels"] = list(labels)
        if data_source is not None:
            self._data["dataSource"] = str(data_source)
        if observation_uuids is not None:
            self._data["observations"] = [str(u) for u in observation_uuids]
        if artifact_uuids is not None:
            self._data["artifacts"] = [str(u) for u in artifact_uuids]
        if analyzed_artifact_uuid is not None:
            self._data["analyzedArtifactUUID"] = str(analyzed_artifact_uuid)

    def add_observation(self, observation_uuid: uuid.UUID) -> "ReportForm":
        """Add observation to report.
        Args:
            observation_uuid: UUID of associated observation.
        Return:
            Updated report form.
        """
        observations = self._data.setdefault("observations", [])
        observations.append(str(observation_uuid))
        return self

    def add_artifact(self, artifact_uuid: uuid.UUID) -> "ReportForm":
        """Add artifact to report.
        Args:
            artifact_uuid: UUID of associated artifact.
        Return:
            Updated report form.
        """
        artifacts = self._data.setdefault("artifacts", [])
        artifacts.append(str(artifact_uuid))
        return self


class ReportHeaderView(RefView):
    """Report header view."""

    @property
    def share_level(self) -> ShareLevels:
        """Report share level."""
        return ShareLevels(self._get("shareLevel"))

    @property
    def title(self) -> Optional[str]:
        """Report title."""
        return self._get_optional("title")

    @property
    def description(self) -> Optional[str]:
        """Report description."""
        return self._get_optional("description")

    @property
    def external_id(self) -> Optional[str]:
        """Unique external ID for current data source."""
        return self._get_optional("externalID")

    @property
    def created_at(self) -> datetime:
        """Report created time."""
        return parse_rfc3339_timestamp(self._get("createdAt"))

    @property
    def published_at(self) -> datetime:
        """Report publication time."""
        return parse_rfc3339_timestamp(self._get("publishedAt"))

    @property
    def registered_at(self) -> datetime:
        """Report registered time."""
        return parse_rfc3339_timestamp(self._get("registeredAt"))

    @property
    def external_refs(self) -> Optional[List[str]]:
        """List of external references associated with report."""
        return self._get_optional("externalRefs")

    @property
    def labels(self) -> Optional[List[str]]:
        """List of report labels."""
        return self._get_optional("labels")

    @property
    def data_source(self) -> RefView:
        """Original data source of report."""
        return RefView(self._get("dataSource"))

    @property
    def reporter(self) -> RefView:
        """Data source that reported to system."""
        return RefView(self._get("reporter"))

    @property
    def analyzed_artifact_uuid(self) -> Optional[uuid.UUID]:
        """Analyzed artifact UUID."""
        return self._map_optional("analyzedArtifactUUID", uuid.UUID)


class ReportView(ReportHeaderView):
    """Report view."""

    @property
    def artifacts(self) -> Optional[List["ArtifactShortView"]]:
        """Artifacts attached to report."""
        return self._map_list_optional("artifacts", ArtifactShortView)

    @property
    def observations(self) -> Optional[List[ObservationCommonView]]:
        """Observations attached to report."""
        return self._map_list_optional("observations", ObservationCommonView)


class ArtifactShortView(RefView):
    """Artifact short view."""

    @property
    def type(self) -> ArtifactTypes:
        """Artifact type."""
        return self._get("type")


class SimilarReportView(JsonObjectView):
    """Similar report view."""

    @property
    def report(self) -> ReportHeaderView:
        """Report header view."""
        return ReportHeaderView(self._get("report"))

    @property
    def correlation(self) -> "SimilarReportCorrelationView":
        """Report correlation view."""
        return SimilarReportCorrelationView(self._get("correlation"))


class SimilarReportCorrelationView(JsonObjectView):
    """Similar report correlation view."""

    @property
    def similarity(self) -> float:
        """Similarity degree of reports in the range [0;1]
        The larger the value, the greater the degree of coincidence."""
        return self._get("similarity")

    @property
    def matched_entities(self) -> Optional[List["MatchedEntitiesView"]]:
        """List of matched entities."""
        return self._map_list_optional("matchedEntities", MatchedEntitiesView)


class MatchedEntitiesView(JsonObjectView):
    """Matched entity view."""

    @property
    def entity(self) -> EntityView:
        """Entity."""
        return self._get("entity")

    @property
    def weight(self) -> float:
        """Weight value of entity in range [0;1]."""
        return self._get("weight")


class ObservationView(ObservationHeaderView):
    """Observation view."""

    @property
    def content(self) -> "ObservationContentView":
        """Observation content.

        Depends on observation type.

        Using:
            >>> from typing import cast
            >>> from cybsi.api.observation import ObservationTypes
            >>> from cybsi.api.report import ObservationView
            >>> view = ObservationView()
            >>> if view.type == ObservationTypes.Generic:
            >>>     # do something with generic content
            >>>     print(view.content.generic)
        """

        return ObservationContentView(self.type, self._get("content"))


class ObservationContentView:
    """Observation content view."""

    _content_converters = {
        ObservationTypes.DNSLookup: DNSLookupObservationContentView,
        ObservationTypes.Generic: GenericObservationContentView,
        ObservationTypes.NetworkSession: NetworkSessionObservationContentView,
        ObservationTypes.Threat: ThreatObservationContentView,
        ObservationTypes.WhoisLookup: WhoisLookupObservationContentView,
    }

    def __init__(self, obs_type: ObservationTypes, content: JsonObject):
        view = self._content_converters[obs_type]
        self._contents = {obs_type: view(content)}

    @property
    def dns_lookup(self) -> DNSLookupObservationContentView:
        """Content of dns lookup observation.

        Raises:
            :class:`KeyError`:
                Content is absent in the :class:`ObservationContentView`.
        """
        val = cast(
            DNSLookupObservationContentView, self._contents[ObservationTypes.DNSLookup]
        )
        return val

    @property
    def generic(self) -> GenericObservationContentView:
        """Content of generic observation.

        Raises:
            :class:`KeyError`:
                Content is absent in the :class:`ObservationContentView`.
        """
        val = cast(
            GenericObservationContentView, self._contents[ObservationTypes.Generic]
        )
        return val

    @property
    def network_session(self) -> NetworkSessionObservationContentView:
        """Content of network session observation.

        Raises:
            :class:`KeyError`:
                Content is absent in the :class:`ObservationContentView`.
        """
        val = cast(
            NetworkSessionObservationContentView,
            self._contents[ObservationTypes.NetworkSession],
        )
        return val

    @property
    def threat(self) -> ThreatObservationContentView:
        """Content of threat observation.

        Raises:
            :class:`KeyError`:
                Content is absent in the :class:`ObservationContentView`.
        """
        val = cast(
            ThreatObservationContentView, self._contents[ObservationTypes.Threat]
        )
        return val

    @property
    def whois_lookup(self) -> WhoisLookupObservationContentView:
        """Content of whois lookup observation.

        Raises:
            :class:`KeyError`:
                Content is absent in the :class:`ObservationContentView`.
        """
        val = cast(
            WhoisLookupObservationContentView,
            self._contents[ObservationTypes.WhoisLookup],
        )
        return val

cybsi/api/report/api.py
cyberstudio/cybsi-sdk
"""Use this section of API to register, retrieve
or validate search queries.
"""
from .api import SearchAPI
from .enums import QueryCompatibility
from .error import CybsiLangErrorCodes
from .stored_queries import (
    StoredQueriesAPI,
    StoredQueryCommonView,
    StoredQueryForm,
    StoredQueryValidationView,
    StoredQueryView,
    CybsiLangErrorView,
    ErrorPosition,
)

cybsi/api/search/__init__.py
cyberstudio/cybsi-sdk
from ..internal import BaseAPI
from .stored_queries import StoredQueriesAPI


class SearchAPI(BaseAPI):
    """Search API."""

    @property
    def stored_queries(self) -> StoredQueriesAPI:
        """Get stored queries route."""
        return StoredQueriesAPI(self._connector)

cybsi/api/search/api.py
cyberstudio/cybsi-sdk
from enum_tools.documentation import document_enum

from cybsi.api.enum import CybsiAPIEnum


@document_enum
class QueryCompatibility(CybsiAPIEnum):
    """Stored query compatibility."""

    Replist = "Replist"  # doc: Replists.

cybsi/api/search/enums.py
cyberstudio/cybsi-sdk
from enum_tools.documentation import document_enum

from cybsi.api.enum import CybsiAPIEnum


@document_enum
class CybsiLangErrorCodes(CybsiAPIEnum):
    """Stored query error code."""

    CommentNotTerminated = "CommentNotTerminated"
    """Syntax error: comment not terminated"""
    IdentifierNotTerminated = "IdentifierNotTerminated"
    """Syntax error: identifier not terminated"""
    InvalidCharacter = "InvalidCharacter"
    """Symbol error: invalid character"""
    InvalidEscapeSequence = "InvalidEscapeSequence"
    """Symbol error: invalid escape sequence"""
    InvalidIdentifier = "InvalidIdentifier"
    """Syntax error: invalid identifier"""
    InvalidNumber = "InvalidNumber"
    """Syntax error: invalid number"""
    StringNotTerminated = "StringNotTerminated"
    """Syntax error: string not terminated"""

    UnexpectedToken = "UnexpectedToken"
    """Syntax error: unexpected token"""

    AmbiguousIdentifierType = "AmbiguousIdentifierType"
    """Semantic error: the identifier has multiple possible types"""
    AttributeNotFound = "AttributeNotFound"
    """Semantic error: attribute not found"""
    DatasourceNotFound = "DatasourceNotFound"
    """Semantic error: datasource not found"""
    DatasourceTypeNotFound = "DatasourceTypeNotFound"
    """Semantic error: datasource type not found"""
    DuplicatedSpecifier = "DuplicatedSpecifier"
    """Semantic error: duplicated specifier"""
    EmptyEntityBody = "EmptyEntityBody"
    """Semantic error: entity has empty body"""
    EmptyExpressionResult = "EmptyExpressionResult"
    """Semantic error: expression produces empty result"""
    EntityTypeNotFound = "EntityTypeNotFound"
    """Semantic error: entity type not found"""
    NoValidRelations = "NoValidRelations"
    """Semantic error: allowed relations for such source and target not found"""
    InvalidOperation = "InvalidOperation"
    """Semantic error: operation is invalid for provided types"""
    InvalidValue = "InvalidValue"
    """Semantic error: value is invalid"""
    InvalidValueType = "InvalidValueType"
    """Semantic error: value type is invalid"""
    RelationKindNotFound = "RelationKindNotFound"
    """Semantic error: relation kind not found"""
    UnsupportedAttribute = "UnsupportedAttribute"
    """Semantic error: attribute is not supported"""

    UnsupportedExpression = "UnsupportedExpression"
    """The expression is allowed by grammar but not supported yet."""

cybsi/api/search/error.py
cyberstudio/cybsi-sdk
import uuid
from typing import Any, Dict, List, Optional

from .. import RefView
from ..api import Tag
from ..error import CybsiError, SemanticError, SemanticErrorCodes
from ..internal import BaseAPI, JsonObjectForm, JsonObjectView
from ..pagination import Cursor, Page
from ..view import _TaggedRefView
from .enums import QueryCompatibility
from .error import CybsiLangErrorCodes


class StoredQueriesAPI(BaseAPI):
    """Stored queries API."""

    _path = "/search/stored-queries"
    _validate_path = "/search/query"

    def register(self, stored_query: "StoredQueryForm") -> RefView:
        """Register a stored query.

        Note:
            Calls `POST /search/stored-queries`.
        Args:
            stored_query: Stored query registration form.
        Returns:
            Reference to a registered stored query.
        Raises:
            :class:`~cybsi.api.error.SemanticError`: Form contains logic errors.
        Note:
            Semantic error codes specific for this method:
              * :attr:`~cybsi.api.error.SemanticErrorCodes.InvalidQueryText`
        """
        r = self._connector.do_post(path=self._path, json=stored_query.json())
        return RefView(r.json())

    def view(self, query_uuid: uuid.UUID) -> "StoredQueryView":
        """Get a stored query view.

        Note:
            Calls `GET /search/stored-queries/{query_uuid}`.
        Args:
            query_uuid: Stored query uuid.
        Returns:
            View of the stored query.
        Raises:
            :class:`~cybsi.api.error.NotFoundError`: Stored query not found.
        """
        path = f"{self._path}/{query_uuid}"
        r = self._connector.do_get(path)
        return StoredQueryView(r)

    def validate(
        self, text: str, compatibility: QueryCompatibility
    ) -> "StoredQueryValidationView":
        """Validates search query.

        Note:
            Calls `PUT /search/query`.
        Args:
            text: Text of the query.
            compatibility: Compatibility scope for query text.
        Returns:
            View of the validation results.
        """
        data = {"text": text, "compatibility": compatibility.value}
        r = self._connector.do_put(self._validate_path, data)
        return StoredQueryValidationView(r.json())

    def edit(
        self,
        query_uuid: uuid.UUID,
        tag: Tag,
        *,
        name: Optional[str] = None,
        text: Optional[str] = None,
    ) -> None:
        """Edit the stored query.

        Note:
            Calls `PATCH /search/stored-queries/{query_uuid}`.
        Args:
            query_uuid: Stored query uuid.
            tag: :attr:`StoredQueryView.tag` value. Use :meth:`view` to retrieve it.
            name: New stored query name, non-empty if not :data:`None`.
            text: New stored query text, non-empty if not :data:`None`.
        Raises:
            :class:`~cybsi.api.error.InvalidRequestError`:
                Provided arguments have invalid values.
            :class:`~cybsi.api.error.NotFoundError`: Stored query not found.
            :class:`~cybsi.api.error.ResourceModifiedError`:
                Stored query changed since last request. Update tag and retry.
            :class:`~cybsi.api.error.SemanticError`: Form contains logic errors.
        Note:
            Semantic error codes specific for this method:
              * :attr:`~cybsi.api.error.SemanticErrorCodes.InvalidQueryText`
        """
        form: Dict[str, Any] = {}
        if name is not None:
            form["name"] = name
        if text is not None:
            form["text"] = text
        path = f"{self._path}/{query_uuid}"
        self._connector.do_patch(path=path, tag=tag, json=form)

    def filter(
        self,
        *,
        user_uuid: Optional[uuid.UUID] = None,
        cursor: Optional[Cursor] = None,
        limit: Optional[int] = None,
    ) -> Page["StoredQueryView"]:
        """Get page of filtered stored queries list.

        Note:
            Calls `GET /search/stored-queries`
        Args:
            user_uuid: User's identifier.
                Filter stored queries by author's id.
            cursor: Page cursor.
            limit: Page limit.
        Returns:
            Page of filtered stored queries list and next page cursor.
        Raises:
            :class:`~cybsi.api.error.SemanticError`: query arguments contain errors.
        Note:
            Semantic error codes specific for this method:
              * :attr:`~cybsi.api.error.SemanticErrorCodes.UserNotFound`
        """
        params: Dict[str, Any] = {}

        if user_uuid is not None:
            params["userUUID"] = str(user_uuid)
        if cursor:
            params["cursor"] = str(cursor)
        if limit:
            params["limit"] = str(limit)

        resp = self._connector.do_get(self._path, params=params)
        page = Page(self._connector.do_get, resp, StoredQueryView)
        return page


class StoredQueryForm(JsonObjectForm):
    """Stored query form.

    This is the form you need to fill to register stored query.

    Args:
        name: Name of the stored query, non-empty.
        text: Text of the stored query, non-empty.
    """

    def __init__(self, name: str, text: str):
        super().__init__()
        self._data["name"] = name
        self._data["text"] = text


class StoredQueryValidationView(JsonObjectView):
    """View of a search query validation,
    as retrieved by :meth:`StoredQueriesAPI.validate`."""

    @property
    def errors(self) -> List["CybsiLangErrorView"]:
        """Errors."""

        return [CybsiLangErrorView(err) for err in self._get("errors")]

    @property
    def warnings(self) -> List["CybsiLangErrorView"]:
        """Warnings."""

        return [CybsiLangErrorView(warn) for warn in self._get("warnings")]


class CybsiLangErrorView(JsonObjectView):
    """View of a search query validation errors."""

    @classmethod
    def from_semantic_error(cls, exc: SemanticError) -> "CybsiLangErrorView":
        """Extract CybsiLang error from semantic error.

        Args:
            exc: SemanticError exception.

        Note:
            Only :attr:`~cybsi.api.error.SemanticErrorCodes.InvalidQueryText`
            can be unwrapped
        """
        if exc.code == SemanticErrorCodes.InvalidQueryText:
            return cls(exc.content["details"])
        raise CybsiError("unexpected error code")

    @property
    def code(self) -> CybsiLangErrorCodes:
        """Code.
        See :class:`~cybsi.api.search.error.CybsiLangErrorCodes`
        for all available error codes
        """

        return CybsiLangErrorCodes(self._get("code"))

    @property
    def message(self) -> str:
        """Message."""

        return self._get("message")

    @property
    def details_raw(self) -> Dict[str, Any]:
        """Details."""

        return self._get("details")

    @property
    def position(self) -> "ErrorPosition":
        """Position of the error start."""

        return ErrorPosition(self._get("position"))

    @property
    def until_position(self) -> "ErrorPosition":
        """Position of the error end.

        Points to a symbol next to the last symbol of the error.
        """

        return ErrorPosition(self._get("untilPosition"))


class ErrorPosition(JsonObjectView):
    """Error position."""

    @property
    def line(self) -> int:
        """Line. Starts from 1."""

        return self._get("line")

    @property
    def column(self) -> int:
        """Column. Relative position from start of line.
        Starts from 1."""

        return self._get("column")

    @property
    def offset(self) -> int:
        """Offset. Absolute position from start of query text.
        Starts from 0."""

        return self._get("offset")


class StoredQueryCommonView(RefView):
    """Stored query short view,
    as retrieved by :meth:`~cybsi.api.replist.ReplistsAPI.view`."""

    @property
    def name(self) -> str:
        """Query name."""
        return self._get("name")


class StoredQueryView(_TaggedRefView, StoredQueryCommonView):
    """View of a stored query,
    as retrieved by :meth:`StoredQueriesAPI.view`."""

    @property
    def text(self) -> str:
        """Query text."""

        return self._get("text")

    @property
    def author(self) -> RefView:
        """User, author of the query."""

        return RefView(self._get("author"))

cybsi/api/search/stored_queries.py
cyberstudio/cybsi-sdk
"""Use this section of API to operate users.
"""

from .api import (
    UsersAPI,
    UserForm,
    UserView,
    UserCommonView,
    RoleCommonView,
    CurrentUserView,
    ActionSet,
)

from .enums import RoleName, ResourceName

cybsi/api/user/__init__.py
cyberstudio/cybsi-sdk
import sys
import uuid
from typing import Iterable, List, Optional, Tuple

if sys.version_info >= (3, 8):
    from typing import Literal
else:
    from typing_extensions import Literal

from ..api import Nullable, _unwrap_nullable
from ..internal import BaseAPI, JsonObject, JsonObjectForm, JsonObjectView
from ..observable import ShareLevels
from ..pagination import Cursor, Page
from ..view import RefView, Tag, _TaggedRefView
from .enums import ResourceName, RoleName

ActionSet = Literal["r", "w", "rw"]
""" Action set.

    Actions can be:

    * r - Reading action, includes: review, getting information, filtering, downloading.
    * w - Writing action, includes: upload, create, modify, delete.
    * rw - Reading and writing action, combines reading and writing actions.
"""


class UsersAPI(BaseAPI):
    """Users API."""

    _path = "/users"
    _me_path = "/users/me"

    def register(self, form: "UserForm") -> RefView:
        """Register user.

        Note:
            Calls `POST /users`.
        Args:
            form: Filled user form.
        Returns:
            Reference to the registered user.
        Raises:
            :class:`~cybsi.api.error.SemanticError`: Form contains logic errors.
            :class:`~cybsi.api.error.ConflictError`:
                User with the specified login already exists.
        Note:
            Semantic error codes specific for this method:
              * :attr:`~cybsi.api.error.SemanticErrorCodes.DataSourceNotFound`
        """

        resp = self._connector.do_post(path=self._path, json=form.json())
        return RefView(resp.json())

    def view(self, user_uuid: uuid.UUID) -> "UserView":
        """Get the user view.

        Note:
            Calls `GET /users/{user_uuid}`.
        Args:
            user_uuid: User UUID.
        Returns:
            Full view of the user with ETag string value.
        Raises:
            :class:`~cybsi.api.error.NotFoundError`: User not found.
        """

        path = f"{self._path}/{user_uuid}"
        resp = self._connector.do_get(path)
        return UserView(resp)

    def me(self) -> "CurrentUserView":
        """Get user associated with current client.

        Note:
            Calls `GET /users/me`.
        Returns:
            View of current user.
        """

        r = self._connector.do_get(self._me_path)
        return CurrentUserView(r.json())

    def filter(
        self,
        *,
        user_uuids: Optional[Iterable[uuid.UUID]] = None,
        data_source_uuid: Optional[uuid.UUID] = None,
        query: Optional[str] = None,
        providers: Optional[Iterable[str]] = None,
        is_disabled: Optional[bool] = None,
        cursor: Optional[Cursor] = None,
        limit: Optional[int] = None,
    ) -> Page["UserCommonView"]:
        """Filter users.

        .. versionchanged:: 2.8
            Added new parameters: `data_source_uuid`, `query`, `providers`,
            `is_disabled`.
            Added semantic error: `UserNotFound`.

        Note:
            Calls `GET /users`.
        Args:
            user_uuids: User identifiers.
                Filter users by specified user UUIDs.
            data_source_uuid: Data source identifier.
                Filter users by associated data source UUID.
            query: Filter users by substring.
                Filter users whoes login, name or email starts with query.
            providers: Provider identifiers.
                Filter user by authentication provider ids.
            is_disabled: Disabled user flag.
                If true filter disabled users.
            cursor: Page cursor.
            limit: Page limit.
        Return:
            Page with user common views and next page cursor.
        Raises:
            :class:`~cybsi.api.error.SemanticError`: query arguments contain errors.
        Note:
            Semantic error codes specific for this method:
              * :attr:`~cybsi.api.error.SemanticErrorCodes.DataSourceNotFound`
              * :attr:`~cybsi.api.error.SemanticErrorCodes.UserNotFound`
        Usage:
            >>> import uuid
            >>> from cybsi.api import CybsiClient
            >>> from cybsi.api.pagination import chain_pages
            >>>
            >>> client: CybsiClient
            >>> # filter users by specified data source uuid
            >>> started_page = client.users.filter(
            >>>     data_source_uuid=uuid.UUID("007c3927-1ef6-474a-b89b-d6feb3c73871"),
            >>> )
            >>> for item in chain_pages(started_page):
            >>>     # do something with users
            >>>     print(item)
            >>> pass
        """

        params: JsonObject = {}
        if data_source_uuid is not None:
            params["dataSourceUUID"] = str(data_source_uuid)
        if user_uuids is not None:
            params["uuid"] = [str(u) for u in user_uuids]
        if cursor is not None:
            params["cursor"] = str(cursor)
        if limit is not None:
            params["limit"] = str(limit)
        if is_disabled is not None:
            params["isDisabled"] = is_disabled
        if providers is not None:
            params["providerID"] = providers
        if query is not None:
            params["query"] = query

        resp = self._connector.do_get(path=self._path, params=params)
        page = Page(self._connector.do_get, resp, UserCommonView)
        return page

    def edit(
        self,
        user_uuid: uuid.UUID,
        tag: Tag,
        *,
        full_name: Nullable[str] = None,
        email: Nullable[str] = None,
        data_source_uuid: Nullable[uuid.UUID] = None,
        access_level: Optional[ShareLevels] = None,
        roles: Optional[Iterable[RoleName]] = None,
        password: Optional[str] = None,
        is_disabled: Optional[bool] = None,
    ):
        """Edit user.

        Note:
            Calls `PATCH /users/{user_uuid}`.
        Args:
            user_uuid: User identifier.
            tag: :attr:`UserView.tag` value. Use :meth:`view` to retrieve it.
            full_name: User full name.
                Name must be less than or equal to 250 characters.
            email: User email. Email length must be in range [3, 254].
            data_source_uuid: Associated data source UUID.
                Cannot be changed for users of external providers.
            access_level: User access level.
            roles: List of user role names.
            password: User password. Password length must be in range [4, 50].
                Cannot be changed for users of external providers.
            is_disabled: Disabled user flag.
        Raises:
            :class:`~cybsi.api.error.SemanticError`: Form contains logic errors.
            :class:`~cybsi.api.error.ConflictError`:
                User with specified associated data source already exists.
            :class:`~cybsi.api.error.NotFoundError`: User not found.
            :class:`~cybsi.api.error.ResourceModifiedError`:
                User changed since last request. Retry using updated tag.
        Note:
            Semantic error codes specific for this method:
              * :attr:`~cybsi.api.error.SemanticErrorCodes.DataSourceNotFound`
              * :attr:`~cybsi.api.error.SemanticErrorCodes.NonLocalUser`
        """
        form: JsonObject = {}
        if full_name is not None:
            form["fullName"] = _unwrap_nullable(full_name)
        if email is not None:
            form["email"] = _unwrap_nullable(email)
        if data_source_uuid is not None:
            form["dataSourceUUID"] = str(_unwrap_nullable(data_source_uuid))
        if access_level is not None:
            form["accessLevel"] = access_level.value
        if roles is not None:
            form["roles"] = [role.value for role in roles]
        if password is not None:
            form["password"] = password
        if is_disabled is not None:
            form["isDisabled"] = is_disabled

        path = f"{self._path}/{user_uuid}"
        self._connector.do_patch(path=path, tag=tag, json=form)

    def edit_me(
        self,
        tag: Tag,
        *,
        full_name: Nullable[str] = None,
        email: Nullable[str] = None,
    ):
        """Edit API-Client user.

        Note:
            Calls `PATCH /users/me`.
            Doesn't require any permissions.
        Args:
            tag: :attr:`UserView.tag` value. Use :meth:`view` to retrieve it.
            full_name: User full name.
                Name must be less than or equal to 250 characters.
            email: User email. Email length must be in range [3, 254].
        Raises:
            :class:`~cybsi.api.error.SemanticError`: Form contains logic errors.
            :class:`~cybsi.api.error.ResourceModifiedError`:
                User changed since last request. Retry using updated tag.
        Note:
            Semantic error codes specific for this method:
              * :attr:`~cybsi.api.error.SemanticErrorCodes.NonLocalUser`
        .. versionadded:: 2.8
        """
        form: JsonObject = {}
        if full_name is not None:
            form["fullName"] = _unwrap_nullable(full_name)
        if email is not None:
            form["email"] = _unwrap_nullable(email)

        self._connector.do_patch(path=self._me_path, tag=tag, json=form)

    def change_my_password(self, *, old_password: str, new_password: str):
        """Change password of current client.

        Note:
            The password can be changed only if the password was set initially.
            Password login is not available for users without a password.

            To confirm authorization and exclude situations when
            the password is changed using the API key,
            you must specify the valid current user password in the request.
        Note:
            Calls `PUT /users/me/password`.
        Args:
            old_password: Old user password. Password length must be in range [4, 50].
            new_password: New user password. Password length must be in range [4, 50].
        Raises:
            :class:`~cybsi.api.error.SemanticError`: Form contains logic errors.
            :class:`~cybsi.api.error.ForbiddenError`:
                Specified user password does not match the current one.
        Note:
            Semantic error codes specific for this method:
              * :attr:`~cybsi.api.error.SemanticErrorCodes.NonLocalUser`
              * :attr:`~cybsi.api.error.SemanticErrorCodes.PasswordAuthDisabled`
        """

        form: JsonObject = {"oldPassword": old_password, "newPassword": new_password}
        path = f"{self._me_path}/password"
        self._connector.do_put(path=path, json=form)


class UserForm(JsonObjectForm):
    """User form.

    This is the form you need to fill to register user.

    Args:
        login: User login. Login should consist of characters
            without spaces (`[a-zA-Z0-9_-]`) and have length in the range [1, 250].
        access_level: User access level.
        roles: List of user role names.
        password: User password. Password length must be in range [4, 50].
            if ``password`` is :data:`None` then
            the user will not be able to enter the system by password.
        full_name: User full name. Name must be less than or equal to 250 characters.
        email: User email. Email length must be in range [3, 254].
        data_source_uuid: Associated data source UUID.
            If ``data_source_uuid`` is :data:`None` then data source will be created and
            assigned when the user first tries to register TI data.

    Usage:
        >>> from cybsi.api.observable import ShareLevels
        >>> from cybsi.api.user import UserForm
        >>>
        >>> userForm = UserForm(
        >>>     login="user_test",
        >>>     access_level=ShareLevels.Green,
        >>>     roles=[RoleName.EntityReader],
        >>>     password="string",
        >>>     full_name="Test Tester",
        >>>     email="test@pt.com",
        >>> )
        >>> pass
    """

    def __init__(
        self,
        login: str,
        access_level: ShareLevels,
        roles: Iterable[RoleName],
        *,
        password: Optional[str] = None,
        full_name: Optional[str] = None,
        email: Optional[str] = None,
        data_source_uuid: Optional[uuid.UUID] = None,
    ):
        super().__init__()
        self._data["login"] = login
        self._data["accessLevel"] = access_level.value
        self._data["roles"] = [role.value for role in roles]
        if password is not None:
            self._data["password"] = password
        if full_name is not None:
            self._data["fullName"] = full_name
        if email is not None:
            self._data["email"] = email
        if data_source_uuid is not None:
            self._data["dataSourceUUID"] = str(data_source_uuid)


class UserCommonView(RefView):
    """User common view."""

    @property
    def login(self) -> str:
        """User login."""
        return self._get("login")

    @property
    def full_name(self) -> Optional[str]:
        """User full name."""
        return self._get_optional("fullName")

    @property
    def email(self) -> Optional[str]:
        """User email."""
        return self._get_optional("email")

    @property
    def is_disabled(self) -> bool:
        """Disabled user flag."""
        return self._get("isDisabled")

    @property
    def auth_provider_id(self) -> str:
        """Authentication provider ID."""
        return self._get("authProviderID")


class UserView(_TaggedRefView, UserCommonView):
    """User full view."""

    @property
    def access_level(self) -> ShareLevels:
        """User access level."""
        return ShareLevels(self._get("accessLevel"))

    @property
    def roles(self) -> List["RoleCommonView"]:
        """List of user role."""
        return [RoleCommonView(r) for r in self._get("roles")]

    @property
    def permissions(self) -> List[Tuple[ResourceName, ActionSet]]:
        """List of permissions derived from user roles."""

        permissions: List[Tuple[ResourceName, ActionSet]] = []

        for raw_perm in self._get("permissions"):
            res, act = raw_perm.split(":", 1)
            permissions.append((ResourceName(res), act))
        return permissions

    @property
    def data_source(self) -> Optional[RefView]:
        """Associated data source."""
        return self._map_optional("dataSource", RefView)


class RoleCommonView(JsonObjectView):
    """Role common view."""

    @property
    def uuid(self) -> uuid.UUID:
        """Role UUID."""
        return uuid.UUID(self._get("uuid"))

    @property
    def name(self) -> RoleName:
        """Role name."""
        return RoleName(self._get("name"))


class CurrentUserView(UserCommonView):
    """Current user view."""

    @property
    def access_level(self) -> ShareLevels:
        """User access level."""
        return ShareLevels(self._get("accessLevel"))

    @property
    def permissions(self) -> List[Tuple[ResourceName, ActionSet]]:
        """List of permissions derived from user roles."""

        permissions: List[Tuple[ResourceName, ActionSet]] = []

        for raw_perm in self._get("permissions"):
            res, act = raw_perm.split(":", 1)
            permissions.append((ResourceName(res), act))
        return permissions

    @property
    def data_source(self) -> Optional[RefView]:
        """Associated data source."""
        return self._map_optional("dataSource", RefView)

cybsi/api/user/api.py
cyberstudio/cybsi-sdk
from enum_tools import document_enum

from cybsi.api.enum import CybsiAPIEnum


@document_enum
class RoleName(CybsiAPIEnum):
    """Role name.

    Role means a list of permissions.
    Each permission is Resource:Action pair.
    See :class:`ResourceName`.
    """

    Administrator = "Administrator"
    """
        Administrator's role permissions:
        [DataSources:rw,EnrichmentConfig:r,Users:r]
    """
    ConfigReader = "ConfigReader"
    """
        ConfigReader's role permissions:
        [DataSources:r, EnrichmentConfig:r]
    """
    FeedAdministrator = "FeedAdministrator"
    """
        FeedAdministrator's role permissions:
        [DataSources:r, Feeds:rw, FeedsData:r, Observable:r,
        ReputationLists:rw, ReputationListsContent:r, Search:r,
        SearchFilters:rw, StoredQuery:rw, Users:r]
    """
    FeedDataReader = "FeedDataReader"
    """
        FeedDataReader's role permissions:
        [DataSources:r, Feeds:r, FeedsData:r, ReputationLists:r,
        ReputationListsContent:r, SearchFilters:r, StoredQuery:r, Users:r]
    """
    EnrichmentRunner = "EnrichmentRunner"
    """
        EnrichmentRunner's role permissions:
        [DataSources:r, EnrichmentTasks:rw]
    """
    EnrichmentTaskReader = "EnrichmentTaskReader"
    """
        EnrichmentTaskReader's role permissions:
        [DataSources:r, EnrichmentTasks:r]
    """
    ReportRegistrant = "ReportRegistrant"
    """
        ReportRegistrant's role permissions:
        [Observations:w, Reports:w]
    """
    ReportReader = "ReportReader"
    """
        ReportReader's role permissions:
        [DataSources:r, Observations:r, RawReports:r, Reports:r]
    """
    EntityRegistrant = "EntityRegistrant"
    """
        EntityRegistrant's role permissions:
        [Observable:w]
    """
    EntityReader = "EntityReader"
    """
        EntityReader's role permissions:
        [DataSources:r, Observable:r]
    """
    ArtifactReader = "ArtifactReader"
    """
        ArtifactReader's role permissions:
        [Artifacts:r, DataSources:r]
    """
    ArtifactRegistrant = "ArtifactRegistrant"
    """
        ArtifactRegistrant's role permissions:
        [Artifacts:w]
    """
    ArtifactContentReader = "ArtifactContentReader"
    """
        ArtifactContentReader's role permissions:
        [Artifacts:r, ArtifactsContent:r]
    """
    Searcher = "Searcher"
    """
        Searcher's role permissions:
        [DataSources:r, Observable:r, Search:r, SearchFilters:rw]
    """
    UserAdministrator = "UserAdministrator"
    """
        UserAdministrator's role permissions:
        [APIKeys:rw, Users:rw]
    """


@document_enum
class ResourceName(CybsiAPIEnum):
    """Resource name.

    Permission can be with read/write action for almost all resources.
    Exclusion resources:
    ArtifactsContent, RawReports, Search, ReputationListsContent.
    """

    Artifacts = "Artifacts"
    """Samples."""
    ArtifactsContent = "ArtifactsContent"
    """Sample contents. Permission can be only with reading action."""
    DataSources = "DataSources"
    """Data sources."""
    EnrichmentConfig = "EnrichmentConfig"
    """Enrichment configs."""
    EnrichmentTasks = "EnrichmentTasks"
    """Enrichment tasks."""
    Feeds = "Feeds"
    """Feeds."""
    FeedsData = "FeedsData"
    """Feed contents. Permission can be only with reading action."""
    Observable = "Observable"
    """Observable entities."""
    Observations = "Observations"
    """Observations."""
    RawReports = "RawReports"
    """
    Initial data of reports and observations.
    Permission can be only with reading action.
    """
    Reports = "Reports"
    """Reports."""
    Search = "Search"
    """Search. Permission can be only with reading action."""
    SearchFilters = "SearchFilters"
    """Search filters."""
    Users = "Users"
    """Users."""
    APIKeys = "APIKeys"
    """Access keys."""
    ReputationLists = "ReputationLists"
    """Reputation lists."""
    ReputationListsContent = "ReputationListsContent"
    """Reputation list contents. Permission can be only with reading action."""
    StoredQuery = "StoredQuery"
    """Stored queries."""

cybsi/api/user/enums.py
cyberstudio/cybsi-sdk
import uuid
from typing import Optional, cast

import httpx

from .api import Tag
from .internal import JsonObjectView


class RefView(JsonObjectView):
    """Reference to a resource.

    Many API methods operate such references.
    Most commonly, methods return a reference on a resource registration.
    """

    @property
    def uuid(self) -> uuid.UUID:
        """Resource UUID."""
        return uuid.UUID(self._get("uuid"))

    @property
    def url(self) -> Optional[str]:
        """URL of the resource in API.
        Can be used to retrieve complete view of the resource.
        Property is presented if :class:`~cybsi.api.client.Config`
        embed_object_url is True.
        """
        return self._get_optional("url")


class _TaggedRefView(RefView):
    _etag_header = "ETag"

    def __init__(self, resp: httpx.Response):
        super().__init__(resp.json())
        self._tag = cast(Tag, resp.headers.get(self._etag_header, ""))

    @property
    def tag(self) -> Tag:
        """Resource tag.

        Protects against concurrent object changes.
        Alternatively, can be used for caching.
        """
        return self._tag

cybsi/api/view.py
cyberstudio/cybsi-sdk
from .converters import convert_attribute_value, convert_entity_key

cybsi/utils/__init__.py
cyberstudio/cybsi-sdk
"""
A set of utility functions allowing to convert
a string to a valid value of a chosen type.
"""
from typing import Any, Callable, Dict, Type

from cybsi.api.dictionary import DictItemAttributeValue
from cybsi.api.enum import CybsiAPIEnum
from cybsi.api.observable.enums import (
    AttributeNames,
    EntityKeyTypes,
    IdentityClass,
    NodeRole,
    PotentialDamage,
    RegionalInternetRegistry,
    RelatedThreatCategory,
    ThreatCategory,
)


def _str_converter(val: str):
    if isinstance(val, str):
        return val.strip()
    raise ValueError(f"value {val} is not a string")


def _int_converter(val: str):
    return int(val)


def _bool_converter(val: str):
    if isinstance(val, bool):
        return val

    if not isinstance(val, str):
        raise ValueError(f"value {val} is not string")

    if val.lower() == "true":
        return True
    if val.lower() == "false":
        return False
    raise ValueError("bool value must be 'true' or 'false'")


def _new_enum_value_converter(api_enum: Type[CybsiAPIEnum], ignore_case=False):
    def _enum_converter(val: Any) -> str:
        return api_enum.from_string(str(val), ignore_case=ignore_case).value

    return _enum_converter


def _dict_item_value_converter(val: str):
    if isinstance(val, str):
        return DictItemAttributeValue(key=val)

    raise ValueError(f"value {val} is not a string")


_entity_key_converters: Dict[EntityKeyTypes, Callable[[Any], Any]] = {
    EntityKeyTypes.String: _str_converter,
    EntityKeyTypes.MD5: _str_converter,
    EntityKeyTypes.SHA1: _str_converter,
    EntityKeyTypes.SHA256: _str_converter,
    EntityKeyTypes.IANAID: _str_converter,
    EntityKeyTypes.NICHandle: _str_converter,
}


def convert_entity_key(k_type: EntityKeyTypes, val: Any) -> Any:
    """Convert value to entity key type.

    Args:
        k_type: Type of entity key.
        val: Value to convert. Usually string,
         but value of desired type is accepted too.
    Return:
        Valid value of entity key, return type depends on entity key type.
    """

    converter = _entity_key_converters.get(k_type)
    if not converter:
        raise ValueError("no converter for key type: %s", k_type)
    try:
        result = converter(val)
    except ValueError:
        raise ValueError(f'"{val}" is not convertible to {k_type} value')
    return result


_attr_value_converters: Dict[AttributeNames, Callable[[str], Any]] = {
    AttributeNames.Size: _int_converter,
    AttributeNames.DisplayNames: _str_converter,
    AttributeNames.Names: _str_converter,
    AttributeNames.IsIoC: _bool_converter,
    AttributeNames.IsTrusted: _bool_converter,
    AttributeNames.IsDGA: _bool_converter,
    AttributeNames.Class: _new_enum_value_converter(IdentityClass, ignore_case=True),
    AttributeNames.NodeRoles: _new_enum_value_converter(NodeRole, ignore_case=True),
    AttributeNames.Sectors: _dict_item_value_converter,
    AttributeNames.MalwareClasses: _dict_item_value_converter,
    AttributeNames.MalwareFamilies: _dict_item_value_converter,
    AttributeNames.RelatedMalwareFamilies: _dict_item_value_converter,
    AttributeNames.IsDelegated: _bool_converter,
    AttributeNames.Statuses: _dict_item_value_converter,
    AttributeNames.ASN: _int_converter,
    AttributeNames.RelatedThreatCategory: _new_enum_value_converter(
        RelatedThreatCategory, ignore_case=True
    ),
    AttributeNames.ThreatCategory: _new_enum_value_converter(
        ThreatCategory, ignore_case=True
    ),
    AttributeNames.MalwareNames: _str_converter,
    AttributeNames.RegionalInternetRegistry: _new_enum_value_converter(
        RegionalInternetRegistry, ignore_case=True
    ),
    AttributeNames.Campaigns: _dict_item_value_converter,
    AttributeNames.ThreatActors: _dict_item_value_converter,
    AttributeNames.AffectedCountries: _dict_item_value_converter,
    AttributeNames.ExploitedVulnerabilities: _dict_item_value_converter,
    AttributeNames.TargetedSectors: _dict_item_value_converter,
    AttributeNames.RegistrationCountry: _dict_item_value_converter,
    AttributeNames.PotentialDamage: _new_enum_value_converter(
        PotentialDamage, ignore_case=True
    ),
}


def convert_attribute_value(attribute_name: AttributeNames, val: Any) -> Any:
    """Convert value to attribute value type.

    Args:
        attribute_name: attribute name.
        val: value to convert.
         Usually string, but value of desired type is accepted too.
    Return:
        Valid value of attribute, return type depends on attribute.
    """

    converter = _attr_value_converters.get(attribute_name)
    if not converter:
        raise ValueError("no converter for attribute: %s", attribute_name)
    try:
        result = converter(val)
    except ValueError:
        raise ValueError(
            f'"{val}" is not convertible to {attribute_name} value'
        ) from None
    return result

cybsi/utils/converters.py
cyberstudio/cybsi-sdk
import uuid
from typing import List, Optional, Union, cast

from cybsi.api.internal import JsonObjectView
from cybsi.api.observable import (
    AbstractEntityView,
    AttributeNames,
    EntityKeyView,
    EntityTypes,
)


class BasicEntityView(JsonObjectView):
    """Builtin basic entity view.
    Includes only entity type and natual keys.

    .. versionadded:: 2.9
    """

    @property
    def uuid(self) -> uuid.UUID:
        """Entity UUID."""
        return uuid.UUID(self._get("uuid"))

    @property
    def type(self) -> EntityTypes:
        """Entity type."""
        return EntityTypes(self._get("type"))

    @property
    def keys(self) -> List[EntityKeyView]:
        """Entity natural keys."""
        return [EntityKeyView(x) for x in self._get("naturalKeys")]


class PTMSEntityView(AbstractEntityView):
    """Entity view tailored for consumption by PT Multiscanner.

    .. versionadded:: 2.9
    """

    @classmethod
    def _view_uuid(cls) -> uuid.UUID:
        return uuid.UUID("190b4e72-7887-4555-a9a9-6bec33c6529d")

    @property
    def entity(self) -> BasicEntityView:
        """Basic entity view."""
        return BasicEntityView(self._get("entity"))

    @property
    def malware_classes(self) -> Optional[List[str]]:
        """Malware classes.
        Expected, but not required for File entity type.
        :data:`None` for other entity types."""
        return self._map_list_optional("malwareClasses", str)

    @property
    def malware_family(self) -> Optional[str]:
        """Malware family value having the highest confidence.
        Expected, but not required for File entity type.
        :data:`None` for other entity types.
        """
        return self._get_optional("malwareFamily")

    @property
    def related_malware_family(self) -> Optional[str]:
        """Related malware family value having the highest confidence.
        Expected, but not required for DomainName, URL,
        IPAddress, EmailAddress entity types.
        :data:`None` for other entity types."""
        return self._get_optional("relatedMalwareFamily")


class CybsiEntityView(AbstractEntityView):
    """Entity view tailored for consumption by Cybsi-Cybsi relay.

    .. versionadded:: 2.10
    """

    @classmethod
    def _view_uuid(cls) -> uuid.UUID:
        return uuid.UUID("4bd21f23-e4b9-45ab-bde8-078f7115b0b8")

    @property
    def entity(self) -> BasicEntityView:
        """Basic entity view."""
        return BasicEntityView(self._get("entity"))

    @property
    def attribute_values(self) -> List["AttributeValuesView"]:
        """Natural and associated attributes forecast of the entity.

        Natural attributes only from the list:
            For `File` entity type: `Size`, `Names`, `MalwareNames`.
        """

        attributes = self._get("attributeValues")
        return [AttributeValuesView(x) for x in attributes]


class AttributeValuesView(JsonObjectView):
    """Attribute value view."""

    @property
    def name(self) -> AttributeNames:
        """Attribute name."""

        return AttributeNames(self._get("name"))

    @property
    def values(self) -> List["ValuesView"]:
        """Attribute values.

        Method returns list of forecasted attribute values and their confidence.
        """

        return [ValuesView(x) for x in self._get("values")]


class ValuesView(JsonObjectView):
    """Values view"""

    @property
    def value(self) -> Union[str, bool, int]:
        """Forecast attribute value.

        Return type depends on attribute name.

        Use :meth:`~cybsi.utils.converters.convert_attribute_value`
            to get attribute value form to create a general observation.
        """

        return self._get("value")

    @property
    def confidence(self) -> float:
        """Attribute confidence."""

        return float(cast(float, self._get("confidence")))

cybsi/utils/views.py
cyberstudio/cybsi-sdk
# -*- coding: utf-8 -*-
# Cybsi SDK documentation build configuration file.

import sys
import os

# Insert Cybsi SDK' path into the system.
sys.path.insert(0, os.path.abspath(".."))
sys.path.insert(0, os.path.abspath("_themes"))

import cybsi  # noqa: E402


# -- General configuration ------------------------------------------------
extensions = [
    "sphinx.ext.autodoc",
    "sphinx.ext.napoleon",
    "sphinx_autodoc_typehints",
    "sphinx.ext.intersphinx",
    "sphinx.ext.todo",
    "sphinx.ext.viewcode",
    "enum_tools.autoenum",
]

templates_path = ["_templates"]

# The suffix(es) of source filenames.
source_suffix = ".rst"

# The master toctree document.
master_doc = "index"

# General information about the project.
project = u"Cybsi SDK"
copyright = u"Cybsi SDK developers"
author = u"Cybsi SDK developers"
version = cybsi.__version__
release = cybsi.__version__
language = "en"

# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
exclude_patterns = ["_build"]

# If true, '()' will be appended to :func: etc. cross-reference text.
add_function_parentheses = True

# If true, the current module name will be prepended to all description
# unit titles (such as .. function::).
add_module_names = True

# Warn about all references where the target cannot be found.
nitpicky = True
nitpick_ignore = [
    ("py:class", "T"),  # Ignore classic typevar
    ("py:obj", "cybsi.api.api.T"),  # https://github.com/sphinx-doc/sphinx/issues/9705
    ("py:obj", "cybsi.api.pagination.T"),  # https://github.com/sphinx-doc/sphinx/issues/9705
    ("py:class", "EntityViewT"),  # https://github.com/sphinx-doc/sphinx/issues/9705
    ("py:class", "httpx.Response"),
    ("py:class", "enum.EnumMeta"),
    ("py:class", "typing.AbstractContextManager"),
    ("py:class", "typing.AbstractAsyncContextManager"),
]

# The name of the Pygments (syntax highlighting) style to use.
pygments_style = "colorful"

# If true, `todo` and `todoList` produce output, else they produce nothing.
todo_include_todos = True

# -- Options for HTML output ----------------------------------------------

# Docs of sphinx_book_theme https://pydata-sphinx-theme.readthedocs.io/en/latest/user_guide/install.html
html_theme = 'sphinx_book_theme'
html_theme_options = {
    "repository_url": "https://github.com/cyberstudio/cybsi-sdk",
    "use_repository_button": True,
    "use_issues_button": True,
    'secondary_sidebar_items': [],
}
html_static_path = ["_static"]

# If true, SmartyPants will be used to convert quotes and dashes to
# typographically correct entities.
html_use_smartypants = False

# Custom sidebar templates, maps document names to template names.
html_sidebars = {
    "index": ["sidebar.html", "sourcelink.html", "searchbox.html"],
    "**": [
        "sidebar.html",
        "localtoc.html",
        "relations.html",
        "sourcelink.html",
        "searchbox.html",
    ],
}

# If true, links to the reST sources are added to the pages.
html_show_sourcelink = False

# If true, "Created using Sphinx" is shown in the HTML footer. Default is True.
html_show_sphinx = False

# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True.
html_show_copyright = True

html_search_language = 'en'

# Output file base name for HTML help builder.
htmlhelp_basename = "cybsisdkdoc"

# -- Options for LaTeX output ---------------------------------------------

# Grouping the document tree into LaTeX files. List of tuples
# (source start file, target name, title,
#  author, documentclass [howto, manual, or own class]).
latex_documents = [
    (master_doc, "cybsi.tex", u"Cybsi SDK Documentation",
     author, "manual")
]

# LaTeX document customization
latex_elements = {
    # \\usepackage[X2,T1]{fontenc} if you need Cyrillic letters (Cybsi работает)
    'fontenc': '\\usepackage[T1]{fontenc}'
}

# -- Options for manual page output ---------------------------------------

# One entry per manual page. List of tuples
# (source start file, name, description, authors, manual section).
man_pages = [
    (master_doc, "cybsi_sdk", u"Cybsi SDK Documentation",
     [author], 1)]

# -- Options controlling document structure --------------------------------
autodoc_member_order = 'bysource'


# -- Options for type hints in developer documentation ---------------------
intersphinx_mapping = {
    "python": ("https://docs.python.org/3/", None),
}

autodoc_typehints = 'none'
napoleon_use_param = True
napoleon_use_rtype = True
napoleon_preprocess_types = True
napoleon_attr_annotations = True
napoleon_use_ivar = True
typehints_document_rtype = True
typehints_fully_qualified = False
always_document_param_types = False

docs/conf.py
cyberstudio/cybsi-sdk
#!/usr/bin/env python3
import shutil
import time
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
from io import BytesIO
from os import environ
from typing import cast

from cybsi.api import APIKeyAuth, Config, CybsiClient
from cybsi.api.enrichment import (
    ArtifactAnalysisParamsView,
    AssignedTaskView,
    CompletedTaskForm,
    EnrichmentErrorCodes,
    EnrichmentTypes,
    FailedTaskForm,
    TaskResultReportForm,
)
from cybsi.api.observable import (
    AttributeNames,
    EntityForm,
    EntityKeyTypes,
    EntityTypes,
    ShareLevels,
    ThreatCategory,
)
from cybsi.api.observation import GenericObservationForm
from cybsi.api.report import ReportForm


def main():
    api_key = environ["CYBSI_API_KEY"]
    api_url = environ["CYBSI_API_URL"]

    auth = APIKeyAuth(api_url=api_url, api_key=api_key)
    config = Config(api_url, auth, ssl_verify=False)
    client = CybsiClient(config)

    wait_on_empty_tasks_sec = 10

    # Main loop, take a task and handle it.
    while True:
        tasks = client.enrichment.task_queue.get_assigned_tasks(limit=1)
        if tasks:
            handle_task(client, tasks[0])
        else:
            time.sleep(wait_on_empty_tasks_sec)


def handle_task(client: CybsiClient, task: AssignedTaskView) -> None:
    try:
        artifact_uuid = get_task_artifact_uuid(task)
        result = analyze_artifact(client, task.uuid, artifact_uuid)
        register_result(client, result)
    except Exception as ex:
        failure = FileSampleAnalysisTaskFailure(task_id=task.uuid, ex=ex)
        register_failure(client, failure)


def get_task_artifact_uuid(task_view: AssignedTaskView) -> uuid.UUID:
    # Our enricher expects ArtifactAnalysis tasks.
    if task_view.type != EnrichmentTypes.ArtifactAnalysis:
        # Shouldn't happen unless Cybsi enrichment rules are mis-configured.
        raise Exception("unexpected enrichment type")

    # As enrichment type is ArtifactAnalysis,
    # we can safely cast parameters to a proper type and extract artifact.
    task_params = cast(ArtifactAnalysisParamsView, task_view.params)
    artifact = task_params.artifact

    return artifact.uuid


def analyze_artifact(
    client: CybsiClient,
    task_id: uuid.UUID,
    artifact_uuid: uuid.UUID,
) -> "FileSampleAnalysisTaskResult":
    view = client.artifacts.view(artifact_uuid)
    with client.artifacts.get_content(artifact_uuid) as content:
        # Here we load the entire artifact content to memory,
        # but it's also possible to pass stream to it to external system.
        buffer = copy_artifact_content_to_mem(content.stream)  # noqa: F841

    # Copy content.stream to external system.
    # Call external system to analyze artifact.
    # This is a canned result.
    return FileSampleAnalysisTaskResult(
        task_id=task_id,
        file_md5_hash=view.content.md5_hash,
        is_malicious=True,
        external_id=uuid.uuid4().hex[:8],
    )


def copy_artifact_content_to_mem(artifact_content) -> BytesIO:
    buffer = BytesIO()
    shutil.copyfileobj(artifact_content, buffer, length=1024 * 1024)
    return buffer


def register_failure(client, failure: "FileSampleAnalysisTaskFailure") -> None:
    form = FailedTaskForm(
        task_uuid=failure.task_id,
        error_code=EnrichmentErrorCodes.FatalError,
        message=str(failure.ex),
    )

    client.enrichment.task_queue.fail_tasks([form])


def register_result(
    client: CybsiClient, completed: "FileSampleAnalysisTaskResult"
) -> None:
    # Register report, it's required for ArtifactAnalysis tasks.
    report_uuid = register_report(client, completed)

    # Mention report in task completion form and send the form.
    result = TaskResultReportForm(report_uuid)
    form = CompletedTaskForm(task_uuid=completed.task_id, result=result)
    client.enrichment.task_queue.complete_tasks([form])


def register_report(
    client: CybsiClient, result: "FileSampleAnalysisTaskResult"
) -> uuid.UUID:
    observation = GenericObservationForm(
        share_level=ShareLevels.Green, seen_at=datetime.now(timezone.utc)
    )
    # Add facts from result to observation
    file_form = EntityForm(EntityTypes.File)
    file_form.add_key(EntityKeyTypes.MD5, result.file_md5_hash)
    report_description = "File sample is not malicious"
    if result.is_malicious:
        report_description = "File sample is malicious"
        observation.add_attribute_fact(
            entity=file_form,
            attribute_name=AttributeNames.ThreatCategory,
            value=ThreatCategory.Malware,
            confidence=0.9,
        )
    observation_ref = client.observations.generics.register(observation)

    report = ReportForm(
        ShareLevels.Green,
        description=report_description,
        title="File analysis, md5:" + result.file_md5_hash,
        external_id=result.external_id,
    ).add_observation(observation_ref.uuid)

    report_ref = client.reports.register(report)
    return report_ref.uuid


@dataclass
class FileSampleAnalysisTaskResult:
    task_id: uuid.UUID
    file_md5_hash: str
    is_malicious: bool
    external_id: str


@dataclass
class FileSampleAnalysisTaskFailure:
    task_id: uuid.UUID
    ex: Exception


if __name__ == "__main__":
    main()

examples/artifact_analysis_enricher.py
cyberstudio/cybsi-sdk
from os import environ

from cybsi.api import APIKeyAuth, Config, CybsiClient, Limits, Timeouts

if __name__ == "__main__":
    api_key = environ["CYBSI_API_KEY"]
    api_url = environ["CYBSI_API_URL"]

    auth = APIKeyAuth(api_url=api_url, api_key=api_key)

    # Set custom timeouts and limits of HTTP client
    limits = Limits(max_connections=100, max_keepalive_connections=20)
    timeouts = Timeouts(default=3.0)

    config = Config(api_url, auth, ssl_verify=False, timeouts=timeouts, limits=limits)
    client = CybsiClient(config)
    client.artifacts.filter()

examples/client_configurations.py
cyberstudio/cybsi-sdk
#!/usr/bin/env python3
import random
import string
from os import environ

from cybsi.api import APIKeyAuth, Config, CybsiClient
from cybsi.api.data_source import DataSourceTypeForm, DataSourceTypeView


def register_datasource_type(client: CybsiClient) -> "DataSourceTypeView":
    circl_type = DataSourceTypeForm(
        short_name="CIRCL_"
        + "".join(random.choices(string.ascii_uppercase + string.digits, k=5)),
        long_name="Computer Incident Response Center Luxembourg",
    )
    ref = client.data_source_types.register(circl_type)
    view = client.data_source_types.view(ref.uuid)
    return view


if __name__ == "__main__":
    api_key = environ["CYBSI_API_KEY"]
    api_url = environ["CYBSI_API_URL"]

    auth = APIKeyAuth(api_url=api_url, api_key=api_key)
    config = Config(api_url, auth, embed_object_url=True, ssl_verify=False)

    # create client with embed object URL
    with CybsiClient(config) as client_embed_url:
        type_view = register_datasource_type(client_embed_url)
        print(type_view)

    # {
    #   "uuid": "89cb3de7-66ec-4d17-aa85-f42487921b59",
    #   "url": "http://cybsi.ptsecurity.ru/data-source-types/89cb3de7-66ec-4d17-aa85-f42487921b59",
    #   "shortName": "CIRCL",
    #   "longName": "Computer Incident Response Center Luxembourg",
    #   ...
    # }

    # create client without embed object URL
    config.embed_object_url = False
    with CybsiClient(config) as client_without_url:
        type_view = register_datasource_type(client_without_url)
        print(type_view)

    # {
    #   "uuid": "abc83124-c3ed-4ae7-bea8-ef5addc3094d",
    #   "shortName": "CIRCL",
    #   "longName": "Computer Incident Response Center Luxembourg",
    #   ...
    # }

examples/data_source_embed_url.py
cyberstudio/cybsi-sdk
#!/usr/bin/env python3
import time
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
from os import environ
from typing import List, cast

from cybsi.api import APIKeyAuth, Config, CybsiClient
from cybsi.api.enrichment import (
    AssignedTaskView,
    CompletedTaskForm,
    EnrichmentErrorCodes,
    EnrichmentTypes,
    ExternalDBLookupParamsView,
    FailedTaskForm,
    TaskResultObservationForm,
)
from cybsi.api.observable import (
    AttributeNames,
    EntityForm,
    EntityKeyTypes,
    EntityTypes,
    ShareLevels,
)
from cybsi.api.observation import GenericObservationForm


def main():
    api_key = environ["CYBSI_API_KEY"]
    api_url = environ["CYBSI_API_URL"]

    auth = APIKeyAuth(api_url=api_url, api_key=api_key)
    config = Config(api_url, auth, ssl_verify=False)
    client = CybsiClient(config)

    wait_on_empty_tasks_sec = 10
    max_task_batch_size = 8

    task_queue = client.enrichment.task_queue

    # Main loop, take a batch of tasks, and handle them.
    while True:
        tasks = task_queue.get_assigned_tasks(max_task_batch_size)
        if tasks:
            handle_task_batch(client, tasks)
        else:
            time.sleep(wait_on_empty_tasks_sec)


def handle_task_batch(client: CybsiClient, batch: List[AssignedTaskView]) -> None:
    results = []
    failures = []
    for task in batch:
        try:
            result = enrich_ip(task)
            results.append(result)
        except Exception as ex:
            failures.append(IPEnrichmentTaskFailure(task_id=task.uuid, ex=ex))

    register_results(client, results, failures)


def enrich_ip(task_view: AssignedTaskView) -> "IPEnrichmentTaskResult":
    # Our enricher expects ExternalDBLookup tasks.
    if task_view.type != EnrichmentTypes.ExternalDBLookup:
        # Shouldn't happen unless Cybsi enrichment rules are mis-configured.
        raise Exception("unexpected enrichment type")

    # As enrichment type is ExternalDBLookup,
    # we can safely cast parameters to a proper type and extract entity.
    lookup = cast(ExternalDBLookupParamsView, task_view.params)
    entity = lookup.entity

    # Our enricher works with IPs only.
    if entity.type != EntityTypes.IPAddress:
        # Shouldn't happen unless Cybsi enrichment rules are mis-configured.
        raise Exception("unexpected entity type")

    # Extract first entity key, it's the only key of IP entity.
    ip_address = entity.keys[0].value

    # Call external system to bring new information about IP.
    # Our imaginary system is a database of IoCs.
    is_ioc = query_external_database(ip_address)

    return IPEnrichmentTaskResult(ip=ip_address, task_id=task_view.uuid, is_ioc=is_ioc)


def query_external_database(ip_address: str) -> bool:
    # This could be a network call in actual enricher.
    return True


def register_results(
    client: CybsiClient,
    completed: List["IPEnrichmentTaskResult"],
    failed: List["IPEnrichmentTaskFailure"],
) -> None:
    # Register definite task failures.
    failed_forms = [
        FailedTaskForm(
            task_uuid=f.task_id,
            error_code=EnrichmentErrorCodes.FatalError,
            message=str(f.ex),
        )
        for f in failed
    ]
    client.enrichment.task_queue.fail_tasks(failed_forms)

    # Register a common observation for all tasks in batch.
    # We could create a separate observation for each entity,
    # but that would be wasteful.
    observation = GenericObservationForm(
        share_level=ShareLevels.Green, seen_at=datetime.now(timezone.utc)
    )
    for c in completed:
        # Facts with IsIoC=false are not very useful, don't register them.
        if c.is_ioc:
            entity = EntityForm(EntityTypes.IPAddress)
            entity.add_key(EntityKeyTypes.String, c.ip)
            observation.add_attribute_fact(
                entity=entity,
                attribute_name=AttributeNames.IsIoC,
                value=True,
                confidence=0.9,
            )

    observation_ref = client.observations.generics.register(observation)
    result = TaskResultObservationForm(observation_ref.uuid)

    # Register successfully completed tasks.
    completed_forms = [
        CompletedTaskForm(task_uuid=c.task_id, result=result) for c in completed
    ]
    client.enrichment.task_queue.complete_tasks(completed_forms)


@dataclass
class IPEnrichmentTaskResult:
    ip: str
    task_id: uuid.UUID
    is_ioc: bool


@dataclass
class IPEnrichmentTaskFailure:
    task_id: uuid.UUID
    ex: Exception


if __name__ == "__main__":
    main()

examples/external_db_lookup_enricher.py
cyberstudio/cybsi-sdk
#!/usr/bin/env python3
import uuid
from os import environ

from cybsi.api import APIKeyAuth, Config, CybsiClient
from cybsi.api.pagination import chain_pages

if __name__ == "__main__":
    api_key = environ["CYBSI_API_KEY"]
    api_url = environ["CYBSI_API_URL"]

    auth = APIKeyAuth(api_url=api_url, api_key=api_key)
    config = Config(api_url, auth, ssl_verify=False)
    client = CybsiClient(config)

    reporters = [uuid.uuid4(), uuid.uuid4()]
    dataSources = [uuid.uuid4(), uuid.uuid4()]

    generic_observations = client.observations.generics.filter(
        data_source_uuids=reporters,
        reporter_uuids=dataSources,
        limit=20,
    )

    for obs in chain_pages(generic_observations):
        print(obs)

    client.close()

examples/filter_generic_observation.py
cyberstudio/cybsi-sdk
#!/usr/bin/env python3
import datetime as dtm
from os import environ

from cybsi.api import APIKeyAuth, Config, CybsiClient
from cybsi.api.auth import APIKeyForm
from cybsi.api.error import ConflictError
from cybsi.api.observable import ShareLevels
from cybsi.api.user import UserForm
from cybsi.api.user.enums import ResourceName, RoleName

if __name__ == "__main__":
    api_key = environ["CYBSI_API_KEY"]
    api_url = environ["CYBSI_API_URL"]

    auth = APIKeyAuth(api_url=api_url, api_key=api_key)
    config = Config(api_url, auth, ssl_verify=False)
    client = CybsiClient(config)

    ref_key = None
    try:
        # creating new user
        userForm = UserForm(
            login="user_test",
            access_level=ShareLevels.Green,
            roles=[RoleName.EntityReader],
            password="string",
            full_name="Test Tester",
            email="test@pt.com",
        )
        user_ref = client.users.register(userForm)

        # generate API-Key for new user
        apiKeyForm = APIKeyForm(
            description="test key",
            expires_at=dtm.datetime(2022, 6, 1, 0, 0, 0, 0, dtm.timezone.utc),
            permissions=[
                (ResourceName.DataSources, "r"),
                (ResourceName.Observable, "r"),
            ],
        )
        ref_key = client.api_keys.generate(user_uuid=user_ref.uuid, form=apiKeyForm)
    except ConflictError:
        # handle Duplicate Error here
        exit(1)

    print(ref_key)
    # do something with api-key, for example, create new client or save it to file
    client.close()

    auth = APIKeyAuth(api_url=api_url, api_key=api_key)
    config = Config(api_url, auth, ssl_verify=False)
    client = CybsiClient(config)

examples/generate_user_api_key.py
cyberstudio/cybsi-sdk
#!/usr/bin/env python3
from datetime import datetime, timezone
from os import environ

from cybsi.api import APIKeyAuth, Config, CybsiClient
from cybsi.api.dictionary import DictItemAttributeValue
from cybsi.api.error import SemanticError, SemanticErrorCodes
from cybsi.api.observable import (
    AttributeNames,
    EntityForm,
    EntityKeyTypes,
    EntityTypes,
    ShareLevels,
)
from cybsi.api.observation import GenericObservationForm
from cybsi.api.pagination import chain_pages
from cybsi.api.replist import ReplistForm
from cybsi.api.search.stored_queries import CybsiLangErrorView, StoredQueryForm
from cybsi.utils.views import PTMSEntityView

if __name__ == "__main__":
    api_key = environ["CYBSI_API_KEY"]
    api_url = environ["CYBSI_API_URL"]

    auth = APIKeyAuth(api_url=api_url, api_key=api_key)
    config = Config(api_url, auth, ssl_verify=False)
    client = CybsiClient(config)

    # register generic observation with MalwareClasses/Families attribute facts
    observation = GenericObservationForm(
        share_level=ShareLevels.Green, seen_at=datetime.now(timezone.utc)
    )
    fileEntity = EntityForm(
        EntityTypes.File,
        [(EntityKeyTypes.SHA1, "9ee972c591dc03b24f7bc23c27361dcb719371f2")],
    )
    observation.add_attribute_fact(
        entity=fileEntity,
        attribute_name=AttributeNames.MalwareFamilies,
        value=DictItemAttributeValue(key="Aware"),
        confidence=0.8,
    )
    observation.add_attribute_fact(
        entity=fileEntity,
        attribute_name=AttributeNames.MalwareClasses,
        value=DictItemAttributeValue(key="Trojan"),
        confidence=0.95,
    )
    client.observations.generics.register(observation)

    query_text = "ENT { MalwareClasses = Trojan }"
    try:  # store query
        query_form = StoredQueryForm("malicious entities", query_text)
        query_ref = client.search.stored_queries.register(query_form)
    except SemanticError as exp:
        if exp.code == SemanticErrorCodes.InvalidQueryText:
            e = CybsiLangErrorView.from_semantic_error(exp)
            # handle CybsiLangError here
        exit(1)

    # register new reputation list with given query
    replist = ReplistForm(
        is_enabled=True,
        query_uuid=query_ref.uuid,
        share_level=ShareLevels.Green,
    )
    replist_ref = client.replists.register(replist)
    # view related entities on the PTMSEntityView
    related_entities = client.replists.entities(
        replist_ref.uuid, entity_view=PTMSEntityView
    )
    for ent in chain_pages(related_entities[0]):
        print(ent)

    client.close()

# For file entity RelatedMalwareFamilies attribute value is always null.
# Basic view example:
# {
#     "type": "File",
#     "uuid": "31d8248f-a004-455a-95c2-5c96cba7fae6",
#     "keys": [
#         {
#             "type": "SHA1Hash",
#             "value": "9ee972c591dc03b24f7bc23c27361dcb719371f2"
#         }
#     ]
# }
# PTMS view example:
# {
#     "entity": {
#         "uuid": "31d8248f-a004-455a-95c2-5c96cba7fae6",
#         "type": "File",
#         "naturalKeys": [
#             {
#                 "type": "SHA1Hash",
#                 "value": "9ee972c591dc03b24f7bc23c27361dcb719371f2"
#             }
#         ]
#     },
#     "malwareClasses": [
#       "Ransomware",
#       "Encryptor"
#     ],
#    "malwareFamily": "PlugX",
#    "relatedMalwareFamily": null
# }
# Cybsi-Cybsi view example:
# {
#     "entity": {
#         "uuid": "31d8248f-a004-455a-95c2-5c96cba7fae6",
#         "type": "File",
#         "naturalKeys": [
#             {
#                 "type": "SHA1Hash",
#                 "value": "9ee972c591dc03b24f7bc23c27361dcb719371f2"
#             }
#         ]
#     },
#     "attributeValues": [
#         {
#             "name": "MalwareFamilies",
#             "values": [
#               {
#                   "value": "PlugX",
#                   "confidence": 1
#               }
#             ]
#         }
#     ]
# }

examples/get_entity_view.py
cyberstudio/cybsi-sdk
#!/usr/bin/env python3
from os import environ

from cybsi.api import APIKeyAuth, Config, CybsiClient
from cybsi.api.observable import (
    AttributeNames,
    EntityForm,
    EntityKeyTypes,
    EntityTypes,
    RelationshipKinds,
)

# Run register_generic.py before script to initialize entity relationships.
if __name__ == "__main__":
    api_key = environ["CYBSI_API_KEY"]
    api_url = environ["CYBSI_API_URL"]

    auth = APIKeyAuth(api_url=api_url, api_key=api_key)
    config = Config(api_url, auth, ssl_verify=False)
    client = CybsiClient(config)

    domain = EntityForm(EntityTypes.DomainName)
    domain.add_key(EntityKeyTypes.String, "test.com")
    domain_ref = client.observable.entities.register(domain)

    ip_address = EntityForm(EntityTypes.IPAddress)
    ip_address.add_key(EntityKeyTypes.String, "8.8.8.8")
    ip_address_ref = client.observable.entities.register(ip_address)

    # Get IsIoC attribute value forecast of ip-address entity.
    attribute_forecast = client.observable.entities.forecast_attribute_values(
        ip_address_ref.uuid, AttributeNames.IsIoC
    )
    print(attribute_forecast)
    # {
    #   "values": [
    #     {
    #       "value": true,
    #       "confidence": 0.8998864,
    #       "valuableFacts": [
    #         {
    #           "dataSource": {
    #             "url": "http://.../3ab411dc-17ab-4169-8ea6-c08271fca49e",
    #             "uuid": "3ab411dc-17ab-4169-8ea6-c08271fca49e"
    #           },
    #           "shareLevel": "Green",
    #           "seenAt": "2021-12-24T17:50:08+03:00",
    #           "confidence": 0.9,
    #           "value": true,
    #           "finalConfidence": 0.8998864
    #         }
    #       ]
    #     }
    #   ],
    #   "hasConflicts": false
    # }

    # Get link forecasts list of ip-address entity.
    link_forecast = client.observable.entities.forecast_links(ip_address_ref.uuid)
    print(link_forecast.data()[0])
    # {
    #   "link": {
    #     "direction": "Reverse",
    #     "relationKind": "ResolvesTo",
    #     "relatedEntity": {
    #       "type": "DomainName",
    #       "url": "http://.../66fd82a1-c35c-424e-986c-133054bd7797",
    #       "uuid": "66fd82a1-c35c-424e-986c-133054bd7797",
    #       "keys": [
    #         {
    #           "type": "String",
    #           "value": "test.com"
    #         }
    #       ]
    #     }
    #   },
    #   "confidence": 0.4999951
    # }

    # Get forecast of relationship (domain name entity resolves ip-address entity).
    relationship_forecast = client.observable.relationships.forecast(
        source_entity_uuid=domain_ref.uuid,
        target_entity_uuid=ip_address_ref.uuid,
        kind=RelationshipKinds.ResolvesTo,
    )
    print(relationship_forecast)
    # {
    #   "relationship": {
    #     "sourceEntity": {
    #       "type": "DomainName",
    #       "url": "http://.../66fd82a1-c35c-424e-986c-133054bd7797",
    #       "uuid": "66fd82a1-c35c-424e-986c-133054bd7797",
    #       "keys": [
    #         {
    #           "type": "String",
    #           "value": "test.com"
    #         }
    #       ]
    #     },
    #     "relationKind": "ResolvesTo",
    #     "targetEntity": {
    #       "type": "IPAddress",
    #       "url": "http://.../40a13d3f-96d2-4c85-acc5-5657f2ecb69d",
    #       "uuid": "40a13d3f-96d2-4c85-acc5-5657f2ecb69d",
    #       "keys": [
    #         {
    #           "type": "String",
    #           "value": "8.8.8.8"
    #         }
    #       ]
    #     }
    #   },
    #   "confidence": 0.4999951,
    #   "valuableFacts": null
    # }

    print(client.observable.entities.forecast_links_statistic(domain_ref.uuid)[0])
    # {
    #     "linkType": {
    #         "url": "http://.../observable/entities/66fd82a1-c35c-424e-986c-133054bd7797/links?kind=ResolvesTo&relatedEntityType=IPAddress&direction=Forward", # noqa: E501
    #         "linkDirection": "Forward",
    #         "relationKind": "ResolvesTo",
    #         "relatedEntitiesType": "IPAddress"
    #     },
    #     "links": {
    #         "total": 1,
    #         "distributionByConfidence": [
    #             {
    #                 "confidenceRange": [0, 0.1],
    #                 "count": 0
    #             },
    #             {
    #                 "confidenceRange": [0.1, 0.2],
    #                 "count": 0
    #             },
    #             {
    #                 "confidenceRange": [0.2, 0.3],
    #                 "count": 0
    #             },
    #             {
    #                 "confidenceRange": [0.3, 0.4],
    #                 "count": 0
    #             },
    #             {
    #                 "confidenceRange": [0.4, 0.5],
    #                 "count": 1
    #             },
    #             {
    #                 "confidenceRange": [0.5, 0.6],
    #                 "count": 0
    #             },
    #             {
    #                 "confidenceRange": [0.6, 0.7],
    #                 "count": 0
    #             },
    #             {
    #                 "confidenceRange": [0.7, 0.8],
    #                 "count": 0
    #             },
    #             {
    #                 "confidenceRange": [0.8, 0.9],
    #                 "count": 0
    #             },
    #             {
    #                 "confidenceRange": [0.9, 1],
    #                 "count": 0
    #             }
    #         ]
    #     }
    # }
    client.close()

examples/get_forecasts.py
cyberstudio/cybsi-sdk
#!/usr/bin/env python3
import uuid

from cybsi.api import APIKeyAuth, Config, CybsiClient
from cybsi.api.observable import EntityView
from cybsi.api.pagination import Page, chain_pages

if __name__ == "__main__":
    api_url = "http://127.0.0.1/api"
    auth = APIKeyAuth(api_url=api_url, api_key="api_key")
    config = Config(api_url, auth, ssl_verify=False)

    replist_uuid = uuid.uuid4()
    start_page: Page[EntityView]

    with CybsiClient(config) as client:
        start_page, _ = client.replists.entities(replist_uuid)
        for item in chain_pages(start_page):
            # Do something with an item
            pass

examples/pagination_chained.py
cyberstudio/cybsi-sdk
#!/usr/bin/env python3
import uuid

from cybsi.api import APIKeyAuth, Config, CybsiClient
from cybsi.api.observable import EntityView
from cybsi.api.pagination import Page

if __name__ == "__main__":
    api_url = "http://127.0.0.1/api"
    auth = APIKeyAuth(api_url=api_url, api_key="api_key")
    config = Config(api_url, auth, ssl_verify=False)

    replist_uuid = uuid.uuid4()
    page: Page[EntityView]

    with CybsiClient(config) as client:
        page, _ = client.replists.entities(replist_uuid)
        while page:
            # Page is iterable
            for ent in page:
                # Do something with element
                pass
            # Do something with a page
            page = page.next_page()  # type: ignore

examples/pagination_manual.py
cyberstudio/cybsi-sdk
#!/usr/bin/env python3
from os import environ

from cybsi.api import APIKeyAuth, Config, CybsiClient
from cybsi.api.data_source import DataSourceForm, DataSourceTypeForm
from cybsi.api.error import ConflictError

if __name__ == "__main__":
    api_key = environ["CYBSI_API_KEY"]
    api_url = environ["CYBSI_API_URL"]

    auth = APIKeyAuth(api_url=api_url, api_key=api_key)
    config = Config(api_url, auth, ssl_verify=False)
    client = CybsiClient(config)

    ds_type_uuid = None
    ds_uuid = None
    try:  # store datasource_type
        circl_type = DataSourceTypeForm(
            short_name="CIRCL",
            long_name="Computer Incident Response Center Luxembourg",
        )
        ds_type_ref = client.data_source_types.register(circl_type)
        ds_type_uuid = ds_type_ref.uuid
        datasource_form = DataSourceForm(
            type_uuid=ds_type_uuid,
            name="MISP",
            long_name="MISP",
        )
        ds_ref = client.data_sources.register(datasource_form)
        ds_uuid = ds_ref.uuid
    except ConflictError:
        # handle Duplicate Error here
        exit(1)
    view = client.data_sources.view(ds_uuid)
    type_view = client.data_source_types.view(ds_type_uuid)

    client.close()

examples/register_data_sources.py
cyberstudio/cybsi-sdk
#!/usr/bin/env python3
from datetime import datetime, timezone
from os import environ

from cybsi.api import APIKeyAuth, Config, CybsiClient
from cybsi.api.dictionary import DictItemAttributeValue
from cybsi.api.observable import (
    AttributeNames,
    EntityForm,
    EntityKeyTypes,
    EntityTypes,
    RelatedThreatCategory,
    RelationshipKinds,
    ShareLevels,
)
from cybsi.api.observation import GenericObservationForm


def create_generic_observation():
    domain = EntityForm(
        EntityTypes.DomainName,
        [(EntityKeyTypes.String, "test.com1")],
    )
    ip_address = EntityForm(
        EntityTypes.IPAddress,
        [(EntityKeyTypes.String, "8.8.8.1")],
    )

    observation = (
        GenericObservationForm(
            share_level=ShareLevels.Green, seen_at=datetime.now(timezone.utc)
        )
        .add_attribute_fact(
            entity=domain,
            attribute_name=AttributeNames.IsIoC,
            value=True,
            confidence=0.9,
        )
        .add_attribute_fact(
            entity=domain,
            attribute_name=AttributeNames.RelatedThreatCategory,
            value=RelatedThreatCategory.Malware,
            confidence=0.9,
        )
        .add_attribute_fact(
            entity=domain,
            attribute_name=AttributeNames.RelatedMalwareFamilies,
            value=DictItemAttributeValue(key="Aware"),
            confidence=0.8,
        )
        .add_entity_relationship(
            source=domain,
            kind=RelationshipKinds.ResolvesTo,
            target=ip_address,
            confidence=0.5,
        )
    )
    return observation


if __name__ == "__main__":
    api_key = environ["CYBSI_API_KEY"]
    api_url = environ["CYBSI_API_URL"]

    auth = APIKeyAuth(api_url=api_url, api_key=api_key)
    config = Config(api_url, auth, ssl_verify=False)

    with CybsiClient(config) as client:
        generic_observation = create_generic_observation()
        ref = client.observations.generics.register(generic_observation)
        view = client.observations.generics.view(ref.uuid)

examples/register_generic.py
cyberstudio/cybsi-sdk
#!/usr/bin/env python3
import asyncio
from datetime import datetime, timezone
from os import environ

from cybsi.api import APIKeyAuth, Config, CybsiAsyncClient
from cybsi.api.dictionary import DictItemAttributeValue
from cybsi.api.observable import (
    AttributeNames,
    EntityForm,
    EntityKeyTypes,
    EntityTypes,
    RelationshipKinds,
    ShareLevels,
)
from cybsi.api.observation import GenericObservationForm


def create_generic_observation(domain, ip):
    domain = EntityForm(
        EntityTypes.DomainName,
        [(EntityKeyTypes.String, domain)],
    )
    ip_address = EntityForm(
        EntityTypes.IPAddress,
        [(EntityKeyTypes.String, ip)],
    )

    observation = (
        GenericObservationForm(
            share_level=ShareLevels.Green, seen_at=datetime.now(timezone.utc)
        )
        .add_attribute_fact(
            entity=domain,
            attribute_name=AttributeNames.IsIoC,
            value=True,
            confidence=0.9,
        )
        .add_attribute_fact(
            entity=domain,
            attribute_name=AttributeNames.RelatedMalwareFamilies,
            value=DictItemAttributeValue(key="Aware"),
            confidence=0.8,
        )
        .add_entity_relationship(
            source=domain,
            kind=RelationshipKinds.ResolvesTo,
            target=ip_address,
            confidence=0.5,
        )
    )
    return observation


async def main():
    api_key = environ["CYBSI_API_KEY"]
    api_url = environ["CYBSI_API_URL"]

    auth = APIKeyAuth(api_url=api_url, api_key=api_key)
    config = Config(api_url, auth, ssl_verify=False)

    domain_ip_pairs = [
        ("aa.com", "7.7.7.7"),
        ("bb.com", "8.8.8.8"),
        ("cc.com", "9.9.9.9"),
    ]
    generics = [
        create_generic_observation(domain, ip) for domain, ip in domain_ip_pairs
    ]

    async with CybsiAsyncClient(config) as client:
        registrations = [client.observations.generics.register(g) for g in generics]

        results = await asyncio.gather(*registrations)
        uuids = ", ".join(str(u.uuid) for u in results)

        print(f"Registered observations: {uuids}")


if __name__ == "__main__":
    asyncio.run(main())

examples/register_generic_async.py
cyberstudio/cybsi-sdk
#!/usr/bin/env python3
from os import environ

from cybsi.api import APIKeyAuth, Config, CybsiClient
from cybsi.api.error import SemanticError, SemanticErrorCodes
from cybsi.api.observable.enums import ShareLevels
from cybsi.api.replist import ReplistForm
from cybsi.api.search.enums import QueryCompatibility
from cybsi.api.search.stored_queries import CybsiLangErrorView, StoredQueryForm

if __name__ == "__main__":
    api_key = environ["CYBSI_API_KEY"]
    api_url = environ["CYBSI_API_URL"]

    auth = APIKeyAuth(api_url=api_url, api_key=api_key)
    config = Config(api_url, auth, ssl_verify=False)
    client = CybsiClient(config)

    query_text = "ENT { IsIoC }"
    validate_view = client.search.stored_queries.validate(
        query_text, QueryCompatibility.Replist
    )
    if len(validate_view.errors) > 0:
        # erroneous query
        exit(1)  # handle errors here
    if len(validate_view.warnings) > 0:
        # semantic warnings
        # for example: always empty result
        pass  # handle warnings here

    try:  # store query
        query_form = StoredQueryForm("malicious entities", query_text)
        query_ref = client.search.stored_queries.register(query_form)
    except SemanticError as exp:
        if exp.code == SemanticErrorCodes.InvalidQueryText:
            e = CybsiLangErrorView.from_semantic_error(exp)
            # handle CybsiLangError here
        exit(1)

    # get stored query view
    query_view = client.search.stored_queries.view(query_ref.uuid)
    # register new disabled reputation list with given query
    replist = ReplistForm(
        is_enabled=False,
        query_uuid=query_view.uuid,
        share_level=ShareLevels.White,
    )
    replist_ref = client.replists.register(replist)
    # get replist view and set it enable
    replist_view = client.replists.view(replist_ref.uuid)
    client.replists.edit(
        replist_view.uuid,
        replist_view.tag,
        is_enabled=True,
        share_level=ShareLevels.Green,
    )

    client.close()

examples/register_replist_with_custom_query.py
cyberstudio/cybsi-sdk
#!/usr/bin/env python3
import asyncio
from os import environ
from typing import AsyncIterator

import aiofiles
import aiohttp

from cybsi.api import APIKeyAuth, Config, CybsiAsyncClient
from cybsi.api.artifact.enums import ArtifactTypes


async def upload_local_file(client: CybsiAsyncClient):
    # Create test file with 1MB size
    chunk_size = 1024
    file_size = 1024 * chunk_size
    file_name = "test.txt"
    create_file(file_name, file_size)

    # Upload file as async bytes iterator
    async_iter_ref = await client.artifacts.upload(
        filename=file_name,
        data=async_file_iter(file_name, chunk_size),
        data_size=file_size,
        artifact_type=ArtifactTypes.FileSample,
    )
    print(async_iter_ref)


def create_file(file_name: str, file_size: int) -> None:
    with open(file_name, "wb") as f:
        f.seek(file_size - 1)
        f.write(b"\0")


async def async_file_iter(file_name: str, chunk_size: int) -> AsyncIterator[bytes]:
    async with aiofiles.open(file_name, mode="r") as f:
        while True:
            chunk = await f.read(chunk_size)
            if not chunk:
                return
            yield chunk.encode()


async def upload_remote_file(client: CybsiAsyncClient):
    async with aiohttp.request(
        "GET", "https://nakamotoinstitute.org/static/docs/taoup.pdf"
    ) as resp:
        assert resp.status == 200

        stream_reader_ref = await client.artifacts.upload(
            filename="taoup.pdf",
            data=resp.content,
            data_size=int(resp.headers["Content-Length"]),
            artifact_type=ArtifactTypes.FileSample,
        )
        print(stream_reader_ref)


async def main():
    api_key = environ["CYBSI_API_KEY"]
    api_url = environ["CYBSI_API_URL"]

    auth = APIKeyAuth(api_url=api_url, api_key=api_key)
    config = Config(api_url, auth, ssl_verify=False)

    async with CybsiAsyncClient(config) as client:
        upload_local_file_task = asyncio.create_task(upload_local_file(client))
        upload_remote_file_task = asyncio.create_task(upload_remote_file(client))

        await upload_local_file_task
        await upload_remote_file_task


if __name__ == "__main__":
    asyncio.run(main())

examples/upload_async_multipart_artifact.py
cyberstudio/cybsi-sdk
#!/usr/bin/env python3
import shutil
from io import BytesIO
from os import environ
from zipfile import ZipFile

from cybsi.api import APIKeyAuth, Config, CybsiClient
from cybsi.api.artifact.enums import ArtifactContentDownloadCompressionTypes


def main():
    api_key = environ["CYBSI_API_KEY"]
    api_url = environ["CYBSI_API_URL"]

    auth = APIKeyAuth(api_url=api_url, api_key=api_key)
    config = Config(api_url, auth, ssl_verify=False)
    client = CybsiClient(config)

    # Upload artifact. We pass BytesIO, but any file-like object will do.
    content = BytesIO(b"artifact content")
    recognized_type_view = client.artifacts.recognize_type(content)

    print(recognized_type_view.type)
    # FileSample
    print(recognized_type_view.format_description)
    # ASCII text, with no line terminators

    content.seek(0)
    artifact_ref = client.artifacts.upload(
        "example.txt", content, artifact_type=recognized_type_view.type
    )
    print_zipped_content(client, artifact_uuid=artifact_ref.uuid)
    print_plain_content(client, artifact_uuid=artifact_ref.uuid)

    client.close()


def print_zipped_content(client, artifact_uuid):
    """Download artifact content as ZIP archive (protected by password),
    unpack it in memory, and print ZIP content.
    """
    archive_password = "p@ss"
    with client.artifacts.get_content(
        artifact_uuid,
        archive=ArtifactContentDownloadCompressionTypes.ZIP,
        archive_password=archive_password,
    ) as content:
        buffer = copy_artifact_content_to_mem(content.stream)

    zip_archive = ZipFile(buffer)
    zip_archive.setpassword(archive_password.encode())
    unzipped_content = {name: zip_archive.read(name) for name in zip_archive.namelist()}
    print("zipfile entry names and their content: ", unzipped_content)


def print_plain_content(client, artifact_uuid):
    """Download entire artifact content and print it."""
    with client.artifacts.get_content(artifact_uuid) as content:
        buffer = copy_artifact_content_to_mem(content.stream)
    print("plain artifact content: ", buffer.getvalue())


def copy_artifact_content_to_mem(artifact_content) -> BytesIO:
    buffer = BytesIO()
    shutil.copyfileobj(artifact_content, buffer, length=1024 * 1024)
    return buffer


def copy_artifact_content_to_file(local_filename, artifact_content) -> None:
    with open(local_filename, "wb") as f:
        shutil.copyfileobj(artifact_content, f, length=1024 * 1024)


if __name__ == "__main__":
    main()

examples/upload_download_artifact.py
cyberstudio/cybsi-sdk
#!/usr/bin/env python3
from os import environ

from cybsi.api import APIKeyAuth, Config, CybsiClient


def main():
    api_key = environ["CYBSI_API_KEY"]
    api_url = environ["CYBSI_API_URL"]

    auth = APIKeyAuth(api_url=api_url, api_key=api_key)
    config = Config(api_url, auth, ssl_verify=False)
    client = CybsiClient(config)

    # license.zip is zip archive with nested `license-access-token.key`
    f = open("license.zip", "rb")
    client.licenses.upload(f)
    client.close()


if __name__ == "__main__":
    main()

examples/upload_license.py
cyberstudio/cybsi-sdk
import unittest
import datetime
from cybsi.api.internal import parse_rfc3339_timestamp
from typing import Union

import httpx


class BaseTest(unittest.TestCase):
    @staticmethod
    def _make_response(status_code: int, content: Union[list, dict]):
        """Make mock response"""
        return httpx.Response(status_code=status_code, json=content)

    @staticmethod
    def assert_timestamp(expected_timestamp: str, actual_timestamp: datetime.datetime):
        """Assert given timestamp with expected

        expected_timestamp string format is %Y-%m-%dT%H:%M:%S.%fZ
        """
        assert parse_rfc3339_timestamp(expected_timestamp) == actual_timestamp

tests/__init__.py
cyberstudio/cybsi-sdk
import datetime as dtm
import uuid
from typing import cast
from unittest.mock import patch

from cybsi.api.api import Tag
from cybsi.api.auth.api_key import APIKeyForm, APIKeysAPI
from cybsi.api.internal.connector import HTTPConnector
from cybsi.api.user.enums import ResourceName
from tests import BaseTest


class ApiKeyTest(BaseTest):
    def setUp(self) -> None:
        self.base_url = "http://localhost"
        self.connector = HTTPConnector(base_url=self.base_url, auth=None)
        self.api_keys_api = APIKeysAPI(self.connector)

    @patch.object(HTTPConnector, "do_post")
    def test_api_key_generate(self, mock):
        api_key_generate_form = APIKeyForm(
            dtm.datetime(2022, 6, 1, 0, 0, 0, 0, dtm.timezone.utc),
            description="test",
            permissions=[
                (ResourceName.DataSources, "r"),
                (ResourceName.Observable, "r"),
            ],
        )

        api_key_generate_response = {
            "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
            "url": "https://test.ru/api-keys/d2209619-2a59-44f7-8d79-caa20fdedbcc",
            "key": "string",
        }

        mock.return_value = self._make_response(201, api_key_generate_response)

        user_uuid = uuid.UUID(int=123)
        res = self.api_keys_api.generate(user_uuid, api_key_generate_form)

        _, kwargs = mock.call_args

        assert f"/users/{user_uuid}/api-keys" == kwargs["path"]
        assert api_key_generate_form.json() == kwargs["json"]

        assert api_key_generate_response["key"] == res.key
        assert api_key_generate_response["id"] == str(res.uuid)
        assert api_key_generate_response["url"] == res.url

    @patch.object(HTTPConnector, "do_get")
    def test_api_key_filter(self, mock):
        api_key_filter_response = [
            {
                "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
                "url": "https://test.ru/api-keys/d2209619-2a59-44f7-8d79-caa20fdedbcc",
                "description": "test",
                "createdAt": "2019-08-24T14:15:22.000Z",
                "expiresAt": "2020-09-25T15:16:23.123Z",
                "lastUsedAt": "2021-10-26T16:17:24.456Z",
                "revoked": True,
                "permissions": ["DataSources:r"],
            }
        ]

        mock.return_value = self._make_response(200, api_key_filter_response)
        cursor, link = "FFAAAAAAAB4", "test_link"
        mock.return_value.headers.update(
            {"X-Cursor": cursor, "Link": f"{link}; rel=next"}
        )

        user_uuid = uuid.UUID(int=123)
        res = self.api_keys_api.filter(user_uuid=user_uuid)

        _, kwargs = mock.call_args

        assert f"/users/{user_uuid}/api-keys" == kwargs["path"]

        assert cursor == res.cursor
        assert link == res.next_link

        assert api_key_filter_response[0]["id"] == str(res.data()[0].uuid)
        assert api_key_filter_response[0]["url"] == res.data()[0].url
        assert api_key_filter_response[0]["description"] == res.data()[0].description

        self.assert_timestamp(
            api_key_filter_response[0]["createdAt"],
            res.data()[0].created_at,
        )
        self.assert_timestamp(
            api_key_filter_response[0]["expiresAt"],
            res.data()[0].expires_at,
        )
        self.assert_timestamp(
            api_key_filter_response[0]["lastUsedAt"],
            res.data()[0].last_used_at,
        )

        assert api_key_filter_response[0]["revoked"] == res.data()[0].revoked

        permissions = []
        for raw_perm in api_key_filter_response[0]["permissions"]:
            name, act = raw_perm.split(":", 1)
            permissions.append((ResourceName(name), act))
        assert permissions == res.data()[0].permissions

    @patch.object(HTTPConnector, "do_get")
    def test_api_key_get(self, mock):
        api_key_view_response = {
            "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
            "url": "https://test.ru/api-keys/d2209619-2a59-44f7-8d79-caa20fdedbcc",
            "description": "test",
            "createdAt": "2019-08-24T14:15:22.000Z",
            "expiresAt": "2020-09-25T15:16:23.123Z",
            "lastUsedAt": "2021-10-26T16:17:24.456Z",
            "revoked": True,
            "permissions": ["DataSources:r"],
        }

        mock.return_value = self._make_response(200, api_key_view_response)
        etag = "33a64df551425fcc55e4d42a148795d9f25f89d4"
        mock.return_value.headers.update({"ETag": etag})

        api_key_uuid = uuid.UUID(api_key_view_response["id"])
        res = self.api_keys_api.view(api_key_uuid)

        args, _ = mock.call_args

        assert f"/api-keys/{api_key_uuid}" == args[0]

        assert etag == res.tag

        assert api_key_view_response["id"] == str(res.uuid)
        assert api_key_view_response["url"] == res.url
        assert api_key_view_response["description"] == res.description

        self.assert_timestamp(api_key_view_response["createdAt"], res.created_at)
        self.assert_timestamp(api_key_view_response["expiresAt"], res.expires_at)
        self.assert_timestamp(api_key_view_response["lastUsedAt"], res.last_used_at)

        assert api_key_view_response["revoked"] == res.revoked

        permissions = []
        for raw_perm in api_key_view_response["permissions"]:
            name, act = raw_perm.split(":", 1)
            permissions.append((ResourceName(name), act))
        assert permissions == res.permissions

    @patch.object(HTTPConnector, "do_patch")
    def test_api_key_edit(self, mock):
        api_key_edit_form = {"description": "desc", "revoked": False}

        api_key_uuid = uuid.UUID(int=123)
        tag = cast(Tag, "tag")

        res = self.api_keys_api.edit(
            api_key_uuid,
            tag,
            description=api_key_edit_form["description"],
            revoked=api_key_edit_form["revoked"],
        )

        _, kwargs = mock.call_args

        assert f"/api-keys/{api_key_uuid}", kwargs["path"]
        assert tag == kwargs["tag"]
        assert api_key_edit_form == kwargs["json"]

        assert res is None

tests/auth/test_api_key.py
cyberstudio/cybsi-sdk
import json
import unittest
from unittest.mock import patch

import httpx

from cybsi.__version__ import __version__
from cybsi.api.error import CybsiError, NotFoundError
from cybsi.api.internal.connector import HTTPConnector


class HTTPConnectorTest(unittest.TestCase):
    def setUp(self) -> None:
        self.base_url = "http://localhost"
        self.connector = HTTPConnector(
            base_url=self.base_url, auth=None, embed_object_url=True
        )

    @patch.object(httpx.Client, "send")
    def test_connector_default_headers(self, mock) -> None:
        mock.return_value.status_code = 200

        self.connector.do_get("/test")
        _, kwargs = mock.call_args
        req: httpx.Request = kwargs["request"]

        self.assertEqual("application/vnd.ptsecurity.app-v2", req.headers.get("Accept"))
        self.assertEqual(
            f"cybsi-sdk-client/v{__version__}", req.headers.get("User-Agent")
        )

    @patch.object(httpx.Client, "send")
    def test_connector_do_get(self, mock) -> None:
        status_code = 200
        mock.return_value.status_code = status_code

        self.connector.do_get("/test", params={"p1": "v1"})
        _, kwargs = mock.call_args
        req: httpx.Request = kwargs["request"]

        self.assertEqual("GET", req.method)
        self.assertEqual(f"{self.base_url}/test?p1=v1", req.url)

    @patch.object(httpx.Client, "send")
    def test_connector_do_post(self, mock) -> None:
        status_code = 200
        mock.return_value.status_code = status_code
        body = {"field1": "value1", "field2": "value2"}
        self.connector.do_post("/test", json=body)

        _, kwargs = mock.call_args
        req: httpx.Request = kwargs["request"]

        self.assertEqual("POST", req.method)
        self.assertEqual(f"{self.base_url}/test", req.url)
        self.assertEqual(body, json.loads(req.content))

    @patch.object(httpx.Client, "send")
    def test_connector_do_post_503(self, mock) -> None:
        mock.return_value = self._make_response(
            503, b"Server is busy laying on the floor"
        )

        with self.assertRaises(CybsiError):
            self.connector.do_post("/test", json={})

    @patch.object(httpx.Client, "send")
    def test_connector_do_get_404(self, mock) -> None:
        mock.return_value = self._make_response(404, b'{"code": "NotFound"}')

        with self.assertRaises(NotFoundError):
            self.connector.do_get("/test")

    @staticmethod
    def _make_response(status_code, content) -> httpx.Response:
        return httpx.Response(status_code=status_code, content=content)

tests/internal/test_http_connector.py
cyberstudio/cybsi-sdk
import uuid
from typing import Any, List, cast
from unittest.mock import patch

from cybsi.api.internal.connector import HTTPConnector
from cybsi.api.observable import (
    AbstractEntityView,
    EntityKeyTypes,
    EntityTypes,
    EntityView,
    NodeRole,
)
from cybsi.api.pagination import Cursor, Page
from cybsi.api.replist import EntitySetChangeView, EntitySetOperations, ReplistsAPI
from tests import BaseTest


class CustomEntityView(AbstractEntityView):
    @classmethod
    def _view_uuid(cls) -> uuid.UUID:
        return uuid.UUID("b7560175-d831-4b86-98db-bbf93d97c905")

    @property
    def entity_type(self) -> EntityTypes:
        return EntityTypes(self._get("type"))

    @property
    def value(self) -> str:
        return self._get("value")

    @property
    def node_role(self) -> NodeRole:
        return NodeRole(self._get("nodeRole"))


class ReplistTest(BaseTest):
    def setUp(self) -> None:
        self.base_url = "http://localhost"
        self.connector = HTTPConnector(base_url=self.base_url, auth=None)
        self.replists_api = ReplistsAPI(self.connector)

    @patch.object(HTTPConnector, "do_get")
    def test_replist_entities_default_view(self, mock) -> None:
        entities_response = [
            {
                "type": "IPAddress",
                "uuid": "8f960b00-220a-4785-b9b9-b993efab9165",
                "keys": [{"type": "String", "value": "171.25.193.77"}],
            }
        ]  # type: List[Any]

        mock.return_value = self._make_response(200, entities_response)

        replist_uuid = uuid.uuid4()
        # GIVEN: Replist containing some entities
        # WHEN: Request the entities
        call_result_page: Page[EntityView]
        call_result_page, cursor = self.replists_api.entities(replist_uuid)

        args, _ = mock.call_args

        assert f"/replists/{replist_uuid}/entities" == args[0]

        ent = entities_response[0]
        parsed = call_result_page.data()[0]

        # THEN: Entities are properly parsed, types are converted to SDK types
        assert uuid.UUID(ent["uuid"]) == parsed.uuid
        assert EntityTypes(ent["type"]) == parsed.type
        assert EntityKeyTypes(ent["keys"][0]["type"]) == parsed.keys[0].type

    @patch.object(HTTPConnector, "do_get")
    def test_replist_entities_custom_view(self, mock) -> None:
        entities_response = [
            {"type": "IPAddress", "value": "171.25.193.77", "nodeRole": "CnC"}
        ]

        mock.return_value = self._make_response(200, entities_response)

        replist_uuid = uuid.uuid4()

        # GIVEN: Replist containing some entities
        # WHEN: Request the entities, require non-basic view
        call_result_page, cursor = self.replists_api.entities(
            replist_uuid, entity_view=CustomEntityView
        )

        args, _ = mock.call_args

        assert f"/replists/{replist_uuid}/entities" == args[0]

        ent = entities_response[0]
        parsed: CustomEntityView = call_result_page.data()[0]

        # THEN: Entity fields are parsed correctly.
        assert EntityTypes(ent["type"]) == parsed.entity_type
        assert ent["value"] == parsed.value
        assert NodeRole(ent["nodeRole"]) == parsed.node_role

    @patch.object(HTTPConnector, "do_get")
    def test_replist_changes_custom_view(self, mock) -> None:
        changes_response = [
            {
                "operation": "Add",
                "entity": {
                    "type": "IPAddress",
                    "value": "171.25.193.77",
                    "nodeRole": "CnC",
                },
            }
        ]  # type: List[Any]

        mock.return_value = self._make_response(200, changes_response)

        replist_uuid = uuid.uuid4()
        cursor = cast(Cursor, "replist-start-cursor")

        # GIVEN: Replist containing some changes
        # WHEN: Request the changes, require non-basic view
        call_result_page: Page[EntitySetChangeView[CustomEntityView]]
        call_result_page = self.replists_api.changes(
            replist_uuid, cursor=cursor, entity_view=CustomEntityView
        )

        args, _ = mock.call_args

        assert f"/replists/{replist_uuid}/changes" == args[0]

        change = changes_response[0]
        ent = change["entity"]
        parsed = call_result_page.data()[0]

        # THEN: Change fields are parsed correctly.
        assert EntitySetOperations(change["operation"]) == parsed.operation
        assert EntityTypes(ent["type"]) == parsed.entity.entity_type
        assert ent["value"] == parsed.entity.value
        assert NodeRole(ent["nodeRole"]) == parsed.entity.node_role

tests/replist/test_replist.py
cyberstudio/cybsi-sdk
import json
import unittest
from itertools import chain

import httpx

from cybsi.api.pagination import Page, chain_pages


class PaginationTest(unittest.TestCase):
    @staticmethod
    def _make_response(status_code=200, headers=None, data=None) -> httpx.Response:
        """Make test response"""
        data = json.dumps(data).encode() if data else "[]".encode()
        response = httpx.Response(
            status_code=status_code, headers=headers or {}, content=data
        )
        return response

    def test_pagination_no_next_link(self):
        response = self._make_response(200)

        page = Page(lambda: httpx.Response(), response, lambda x: x)
        self.assertIs(page.next_page(), None)

    def test_pagination_iterate_pages(self):
        expected = ["http://link1.com", "http://link2.com"]

        def pages():
            for link in expected + [""]:
                if link:
                    link_hdr = f'<l1>; rel="first",<{link}>; rel="next"'
                else:
                    link_hdr = '<l1>; rel="first"'
                yield self._make_response(200, headers={"link": link_hdr})

        actual = []
        page_gen = pages()

        page = Page(lambda _: next(page_gen), next(page_gen), lambda x: x)
        for _ in range(len(expected) + 1):
            actual.append(page.next_link)
            page = page.next_page()

        self.assertEqual(expected, actual[:2])
        self.assertIs(actual[-1], None)

    def test_pagination_get_page_data(self):
        expected = [1, 2, 3, 4]
        response = self._make_response(200, data=[str(i) for i in expected])

        page = Page(lambda: httpx.Response(), response, lambda x: int(x))
        self.assertEqual(expected, page.data())

    def test_pagination_iterate_page_data(self):
        expected = [1, 2, 3, 4]
        response = self._make_response(200, data=[str(i) for i in expected])

        page = Page(lambda: httpx.Response(), response, lambda x: int(x))
        actual = []
        for item in page:
            actual.append(item)

        self.assertEqual(expected, page.data())

    def test_pagination_chain_pages(self):
        data = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]

        def pages():
            for page_data in data + [[]]:
                if page_data:
                    hdr = '<l1>; rel="first",<link>; rel="next"'
                else:
                    hdr = '<l1>; rel="first"'
                yield self._make_response(200, headers={"link": hdr}, data=page_data)

        actual = []
        page_gen = pages()

        page = Page(lambda _: next(page_gen), next(page_gen), lambda x: x)

        for elem, _ in zip(chain_pages(page), range(10)):
            actual.append(elem)

        expected = list(chain(*data))
        self.assertEqual(expected, actual)

tests/test_pagination.py
cyberstudio/cybsi-sdk